+ m = re.search(r"/runner/work/[^/]+/[^/]+/(.+)$", path)
+ if m:
+ return m.group(1)
+ return path.lstrip("/")
+
+
+def row_from_detection(d: dict) -> dict:
+ dd = d.get("detection_details") or {}
+ cwe = dd.get("cwe") or []
+ owasp = dd.get("owasp") or []
+ langs = dd.get("languages") or []
+ metadata_parts = []
+ if cwe:
+ metadata_parts.append("CWE: " + "; ".join(cwe))
+ if owasp:
+ metadata_parts.append("OWASP: " + "; ".join(owasp))
+ if dd.get("category"):
+ metadata_parts.append(f"Category: {dd['category']}")
+ if langs:
+ metadata_parts.append("Languages: " + ", ".join(langs))
+ return {
+ "severity": d.get("severity") or "Unknown",
+ "type": d.get("type") or "?",
+ "issue_name": dd.get("policy_display_name") or d.get("detection_rule_id") or "Unnamed finding",
+ "description": (dd.get("description") or d.get("message") or "").strip(),
+ "file": normalize_file_path(dd.get("file_path") or d.get("file_path") or ""),
+ "line": dd.get("line") or d.get("line") or "",
+ "metadata": " | ".join(metadata_parts),
+ "cwe": "; ".join(cwe),
+ "owasp": "; ".join(owasp),
+ "category": dd.get("category") or "",
+ "languages": ", ".join(langs),
+ "remediation": (dd.get("remediation_guidelines") or dd.get("custom_remediation_guidelines") or "").strip(),
+ "detection_rule_id": dd.get("detection_rule_id") or "",
+ "policy_id": dd.get("policy_id") or "",
+ "id": d.get("id") or "",
+ }
+
+
+def console_url(row: dict, base_url: str) -> str:
+ base = base_url.rstrip("/")
+ rule_id = row.get("detection_rule_id") or row.get("policy_id")
+ if rule_id:
+ return f"{base}/policies/{rule_id}"
+ return base
+
+
+def count_by_severity(rows: list[dict]) -> dict[str, int]:
+ counts: dict[str, int] = {}
+ for r in rows:
+ counts[r["severity"]] = counts.get(r["severity"], 0) + 1
+ return counts
+
+
+def severity_sort_key(row: dict) -> tuple:
+ try:
+ idx = SEVERITY_ORDER.index(row["severity"])
+ except ValueError:
+ idx = len(SEVERITY_ORDER)
+ return (idx, row["type"], row["file"], row["line"] or 0)
+
+
+# ----------------------------- Markdown -----------------------------------
+
+def render_markdown(rows: list[dict], base_url: str, artifact_hint: str = "") -> str:
+ counts = count_by_severity(rows)
+ lines: list[str] = []
+ lines.append("## Cycode Scan Summary")
+ lines.append("")
+ lines.append(f"**Total findings:** {len(rows)}")
+ lines.append("")
+ lines.append("| Severity | Count |")
+ lines.append("|---|---|")
+ for sev in SEVERITY_ORDER:
+ if counts.get(sev):
+ lines.append(f"| {sev} | {counts[sev]} |")
+ lines.append("")
+
+ if artifact_hint:
+ lines.append(f"> Full HTML + CSV reports are published as the **{artifact_hint}** artifact on this build.")
+ lines.append("")
+
+ if rows:
+ lines.append("### Top findings (by severity)")
+ lines.append("")
+ for r in rows[:15]:
+ loc = f"`{r['file']}:{r['line']}`" if r["line"] else f"`{r['file']}`"
+ url = console_url(r, base_url)
+ desc = r["description"][:200]
+ lines.append(
+ f"- **[{r['severity']}]** {r['issue_name']} — {loc} \n"
+ f" {desc}{'...' if len(r['description']) > 200 else ''} \n"
+ f" [↗ Console]({url})"
+ )
+ lines.append("")
+ return "\n".join(lines)
+
+
+# ------------------------------- HTML -------------------------------------
+
+HTML_TEMPLATE = """
+
+
+
+Cycode Scan Report
+
+
+
+Cycode Scan Report
+Generated {generated_at} · {total} finding{plural} across {scan_types}
+
+
+ {summary_cards}
+
+
+
+
+
+
+
+
+
+
+
+
+ | Severity |
+ Issue Name |
+ Description & Mitigation |
+ File & Line |
+ Metadata |
+ Console |
+
+
+
+ {rows}
+
+
+
+
+
+
+
+
+"""
+
+
+def render_html(rows: list[dict], base_url: str, scan_types: list[str]) -> str:
+ import datetime
+
+ counts = count_by_severity(rows)
+ present_sevs = [s for s in SEVERITY_ORDER if counts.get(s)]
+ present_types = sorted({r["type"] for r in rows if r.get("type")})
+
+ summary_cards = ['']
+ for sev in present_sevs:
+ color = SEVERITY_COLOR.get(sev, "#000")
+ summary_cards.append(
+ f'{html.escape(sev)}
'
+ f'
{counts[sev]}
'
+ )
+
+ severity_options = "\n ".join(
+ f'' for s in present_sevs
+ )
+ type_options = "\n ".join(
+ f'' for t in present_types
+ )
+
+ row_html_parts: list[str] = []
+ for r in rows:
+ sev_color = SEVERITY_COLOR.get(r["severity"], "#000")
+ url = console_url(r, base_url)
+ location = html.escape(r["file"] or "unknown")
+ line_html = f'Line {html.escape(str(r["line"]))}
' if r["line"] else ""
+ meta_parts = []
+ if r["cwe"]: meta_parts.append(f"CWE: {html.escape(r['cwe'])}
")
+ if r["owasp"]: meta_parts.append(f"OWASP: {html.escape(r['owasp'])}
")
+ if r["category"]: meta_parts.append(f"Category: {html.escape(r['category'])}
")
+ if r["languages"]: meta_parts.append(f"Language: {html.escape(r['languages'])}
")
+ metadata_html = "".join(meta_parts) or '—'
+
+ # Description + Mitigation combined in one cell with expandable
+ description_full = r["description"]
+ remediation = r["remediation"]
+ desc_short = description_full[:180] + ("..." if len(description_full) > 180 else "")
+ desc_cell_parts = [f'{html.escape(desc_short)}
']
+ if description_full and len(description_full) > 180:
+ desc_cell_parts.append(
+ f'Full description
'
+ f'{html.escape(description_full)}
'
+ )
+ if remediation:
+ desc_cell_parts.append(
+ f'Mitigation guidance
'
+ f'{html.escape(remediation)}
'
+ )
+
+ # Build a searchable haystack for client-side filtering
+ haystack = " ".join([
+ r["severity"], r["type"], r["issue_name"], r["description"],
+ r["file"], str(r["line"]), r["cwe"], r["owasp"],
+ r["category"], r["languages"],
+ ]).lower()
+
+ row_html_parts.append(
+ f''
+ f'| {html.escape(r["severity"])}'
+ f' {html.escape(r["type"])} | '
+ f'{html.escape(r["issue_name"])} | '
+ f'{"".join(desc_cell_parts)} | '
+ f'{location} {line_html} | '
+ f'{metadata_html} | '
+ f'View ↗ | '
+ f'
'
+ )
+
+ if not row_html_parts:
+ row_html_parts.append('| No findings. |
')
+
+ return HTML_TEMPLATE.format(
+ generated_at=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ total=len(rows),
+ plural="" if len(rows) == 1 else "s",
+ scan_types=", ".join(scan_types) if scan_types else "scan",
+ summary_cards="\n ".join(summary_cards),
+ severity_options=severity_options,
+ type_options=type_options,
+ rows="\n ".join(row_html_parts),
+ console_base=html.escape(base_url),
+ )
+
+
+# -------------------------------- CSV -------------------------------------
+
+CSV_COLUMNS = [
+ "severity",
+ "type",
+ "issue_name",
+ "description",
+ "file",
+ "line",
+ "cwe",
+ "owasp",
+ "category",
+ "languages",
+ "mitigation",
+ "console_url",
+ "detection_rule_id",
+ "policy_id",
+ "detection_id",
+]
+
+
+def write_csv(rows: list[dict], path: str, base_url: str) -> None:
+ with open(path, "w", newline="", encoding="utf-8") as f:
+ writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
+ writer.writerow([
+ "Severity", "Type", "Issue Name", "Issue Description",
+ "File", "Line", "CWE", "OWASP", "Category", "Languages",
+ "Mitigation", "Console URL",
+ "Detection Rule ID", "Policy ID", "Detection ID",
+ ])
+ for r in rows:
+ writer.writerow([
+ r["severity"], r["type"], r["issue_name"], r["description"],
+ r["file"], r["line"], r["cwe"], r["owasp"], r["category"], r["languages"],
+ r["remediation"], console_url(r, base_url),
+ r["detection_rule_id"], r["policy_id"], r["id"],
+ ])
+
+
+# ------------------------------- main -------------------------------------
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("input_json")
+ parser.add_argument("--md", help="Write Markdown here (default: stdout)")
+ parser.add_argument("--html", help="Write HTML report here")
+ parser.add_argument("--csv", help="Write CSV export here")
+ parser.add_argument(
+ "--console-url",
+ default=os.environ.get("CYCODE_CONSOLE_URL", "https://app.cycode.com"),
+ help="Base URL of the Cycode console (default: $CYCODE_CONSOLE_URL or https://app.cycode.com)",
+ )
+ parser.add_argument(
+ "--artifact-hint",
+ default=os.environ.get("CYCODE_ARTIFACT_NAME", "cycode-report"),
+ help="Artifact name to reference in the Markdown summary (default: $CYCODE_ARTIFACT_NAME or cycode-report)",
+ )
+ args = parser.parse_args()
+
+ with open(args.input_json) as f:
+ data = json.load(f)
+
+ detections = extract_detections(data)
+ rows = [row_from_detection(d) for d in detections]
+ rows.sort(key=severity_sort_key)
+
+ scan_types = sorted({r["type"] for r in rows if r.get("type")})
+
+ md = render_markdown(rows, args.console_url, args.artifact_hint if (args.html or args.csv) else "")
+ if args.md:
+ with open(args.md, "w", encoding="utf-8") as f:
+ f.write(md)
+ else:
+ print(md)
+
+ if args.html:
+ with open(args.html, "w", encoding="utf-8") as f:
+ f.write(render_html(rows, args.console_url, scan_types))
+
+ if args.csv:
+ write_csv(rows, args.csv, args.console_url)
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/semgrep-rules/gha-dangerous-pr-target-checkout.yml b/semgrep-rules/gha-dangerous-pr-target-checkout.yml
new file mode 100644
index 0000000..77cdd3f
--- /dev/null
+++ b/semgrep-rules/gha-dangerous-pr-target-checkout.yml
@@ -0,0 +1,97 @@
+rules:
+
+ # ---------------------------------------------------------------
+ # Dangerous pull_request_target + PR code checkout
+ # ---------------------------------------------------------------
+ # Based on the official Semgrep rule:
+ # yaml.github-actions.security.pull-request-target-code-checkout
+ # Source: https://github.com/semgrep/semgrep-rules/blob/develop/yaml/github-actions/security/pull-request-target-code-checkout.yaml
+ #
+ # The pull_request_target event runs in the context of the BASE
+ # (target) repository, which means it has access to all repo
+ # secrets. Checking out the PR head then executes untrusted
+ # code from the fork with those secrets available.
+ #
+ # Covers all three trigger syntax variants:
+ # on:
+ # pull_request_target: ... (mapping)
+ # on: [push, pull_request_target] (list)
+ # on: pull_request_target (bare string)
+ #
+ # Covers all ref variants via metavariable-pattern:
+ # github.event.pull_request.head.sha
+ # github.event.pull_request.head.ref
+ # github.head_ref
+ # ---------------------------------------------------------------
+ - id: gha-pr-target-code-checkout
+ languages: [yaml]
+ message: >-
+ This GitHub Actions workflow uses `pull_request_target` and checks
+ out code from the incoming pull request. When using
+ `pull_request_target`, the Action runs in the context of the target
+ repository, which includes access to all repository secrets.
+ By checking out the incoming PR code, any build scripts
+ (npm install, make, pip install) execute attacker-controlled code
+ with access to those secrets, allowing an attacker to steal them.
+ Audit your workflow to ensure no incoming PR code is executed.
+ See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
+ severity: ERROR
+ metadata:
+ category: security
+ owasp:
+ - "A08:2021 - Software and Data Integrity Failures"
+ cwe:
+ - "CWE-829: Inclusion of Functionality from Untrusted Control Sphere"
+ references:
+ - https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
+ - https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
+ likelihood: MEDIUM
+ impact: HIGH
+ confidence: HIGH
+ patterns:
+ # Match any of the three trigger syntax forms
+ - pattern-either:
+ - pattern-inside: |
+ on:
+ ...
+ pull_request_target: ...
+ ...
+ ...
+ - pattern-inside: |
+ on: [..., pull_request_target, ...]
+ ...
+ - pattern-inside: |
+ on: pull_request_target
+ ...
+ # Scope to within a job's steps block
+ - pattern-inside: |
+ jobs:
+ ...
+ $JOBNAME:
+ ...
+ steps:
+ ...
+ # Match the checkout action with a ref parameter
+ - pattern: |
+ ...
+ uses: "$ACTION"
+ with:
+ ...
+ ref: $EXPR
+ # Ensure it's an actions/checkout action
+ - metavariable-regex:
+ metavariable: $ACTION
+ regex: actions/checkout@.*
+ # Ensure the ref expression references the incoming PR
+ - metavariable-pattern:
+ language: generic
+ metavariable: $EXPR
+ patterns:
+ - pattern-inside: "${{ ... }}"
+ - pattern-either:
+ - pattern: github.event.pull_request ...
+ - pattern: github.head_ref ...
+ paths:
+ include:
+ - "*.yml"
+ - "*.yaml"
diff --git a/semgrep-rules/gha-excessive-permissions.yml b/semgrep-rules/gha-excessive-permissions.yml
new file mode 100644
index 0000000..0aa4f47
--- /dev/null
+++ b/semgrep-rules/gha-excessive-permissions.yml
@@ -0,0 +1,125 @@
+rules:
+
+ # ---------------------------------------------------------------
+ # Rule 1: Workflow-level permissions: write-all
+ # ---------------------------------------------------------------
+ # Catches the most obvious anti-pattern: granting the GITHUB_TOKEN
+ # full write access to every scope at the workflow level.
+ #
+ # Example match:
+ # permissions: write-all
+ # jobs:
+ # build:
+ # runs-on: ubuntu-latest
+ # ---------------------------------------------------------------
+ - id: gha-workflow-permissions-write-all
+ message: >
+ GitHub Actions workflow grants 'permissions: write-all' at the
+ workflow level, giving the GITHUB_TOKEN unrestricted write access
+ to all scopes (contents, packages, pull-requests, etc.).
+ Apply least-privilege by declaring only the permissions each job
+ actually needs.
+ severity: ERROR
+ languages: [yaml]
+ metadata:
+ cwe:
+ - "CWE-250: Execution with Unnecessary Privileges"
+ references:
+ - https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
+ category: security
+ patterns:
+ - pattern: |
+ permissions: write-all
+ - pattern-not-inside: |
+ jobs:
+ ...
+ $JOB:
+ ...
+ permissions: write-all
+ ...
+ paths:
+ include:
+ - "*.yml"
+ - "*.yaml"
+
+ # ---------------------------------------------------------------
+ # Rule 2: Job-level permissions: write-all
+ # ---------------------------------------------------------------
+ # Same problem scoped to a single job. Still overly broad since
+ # it grants write to every scope for that job.
+ #
+ # Example match:
+ # jobs:
+ # deploy:
+ # permissions: write-all
+ # runs-on: ubuntu-latest
+ # ---------------------------------------------------------------
+ - id: gha-job-permissions-write-all
+ message: >
+ GitHub Actions job grants 'permissions: write-all', giving the
+ GITHUB_TOKEN unrestricted write access for this job. Narrow the
+ permissions to only those required (e.g., contents: read).
+ severity: WARNING
+ languages: [yaml]
+ metadata:
+ cwe:
+ - "CWE-250: Execution with Unnecessary Privileges"
+ references:
+ - https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
+ category: security
+ pattern: |
+ jobs:
+ ...
+ $JOB:
+ ...
+ permissions: write-all
+ ...
+ paths:
+ include:
+ - "*.yml"
+ - "*.yaml"
+
+ # ---------------------------------------------------------------
+ # Rule 3: Workflow-level broad write scopes
+ # ---------------------------------------------------------------
+ # Catches explicit per-scope write grants at the workflow level
+ # that are commonly over-provisioned. The metavariable $SCOPE
+ # matches any scope key (contents, packages, etc.) set to write.
+ #
+ # Example match:
+ # permissions:
+ # contents: write
+ # packages: write
+ # pull-requests: write
+ # ---------------------------------------------------------------
+ - id: gha-workflow-broad-write-permissions
+ message: >
+ GitHub Actions workflow grants write access to scope '$SCOPE'
+ at the workflow level. Verify this is required. Prefer setting
+ permissions at the job level so each job gets only what it needs.
+ severity: WARNING
+ languages: [yaml]
+ metadata:
+ cwe:
+ - "CWE-250: Execution with Unnecessary Privileges"
+ references:
+ - https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication
+ category: security
+ patterns:
+ - pattern: |
+ permissions:
+ ...
+ $SCOPE: write
+ ...
+ - pattern-not-inside: |
+ jobs:
+ ...
+ $JOB:
+ ...
+ permissions:
+ ...
+ ...
+ paths:
+ include:
+ - "*.yml"
+ - "*.yaml"
diff --git a/templates/cycode-scan.yml b/templates/cycode-scan.yml
new file mode 100644
index 0000000..2b117d2
--- /dev/null
+++ b/templates/cycode-scan.yml
@@ -0,0 +1,142 @@
+# Centralized Cycode scan template.
+#
+# Single source of truth for the Cycode CI/CD gate across many pipelines.
+# Consumers `extends:` this template and pass app-specific parameters.
+#
+# Runs a scan in JSON mode, publishes results to the Tests tab + build summary
+# tab + artifact, then enforces a pass/fail gate via the CLI, the API, or both.
+#
+# Cross-repo usage pattern (production):
+# resources:
+# repositories:
+# - repository: security-templates
+# type: git
+# name: SecurityTeam/cycode-pipeline-templates
+# ref: refs/tags/v1
+# extends:
+# template: templates/cycode-scan.yml@security-templates
+# parameters: { ... }
+#
+# Single-repo usage (this demo): see ../azure-pipelines-template-consumer.yml
+
+parameters:
+ - name: scanPath
+ type: string
+ default: "./"
+ - name: scanTypeFlags
+ type: string
+ default: "-t sast" # e.g. "-t sast -t sca -t secret"
+ - name: severityThreshold
+ type: string
+ default: "info" # info | low | medium | high | critical
+ - name: repoName
+ type: string # required — repo name as shown in Cycode
+ - name: gateMode
+ type: string
+ default: "cli" # cli | api | both | none
+ - name: publishResults
+ type: boolean
+ default: true
+ - name: poolName
+ type: string
+ default: "Default" # self-hosted pool; use 'ubuntu-latest' for MS-hosted
+ - name: pythonVersion
+ type: string
+ default: "3.12"
+
+stages:
+ - stage: Security
+ displayName: "Security — Cycode"
+ jobs:
+ - job: CycodeScan
+ displayName: "Cycode scan + gate"
+ pool:
+ name: ${{ parameters.poolName }}
+ steps:
+ - checkout: self
+ fetchDepth: 0
+
+ - task: UsePythonVersion@0
+ displayName: "Use Python ${{ parameters.pythonVersion }}"
+ inputs:
+ versionSpec: ${{ parameters.pythonVersion }}
+
+ - script: |
+ set -e
+ python3 -m pip install --upgrade pip
+ pip install cycode
+ if ! command -v jq >/dev/null 2>&1; then
+ if command -v apt-get >/dev/null 2>&1; then
+ sudo apt-get update -qq && sudo apt-get install -y -qq jq
+ elif command -v brew >/dev/null 2>&1; then
+ brew install jq
+ else
+ echo "##vso[task.logissue type=error]jq is not installed and no supported package manager (apt-get, brew) was found. Preinstall jq on the agent."
+ exit 1
+ fi
+ fi
+ displayName: "Install Cycode CLI + jq"
+
+ # ---- Scan (soft-fail so we can publish results, then gate) ------
+ - script: |
+ set +e
+ cycode -o json scan --soft-fail \
+ ${{ parameters.scanTypeFlags }} \
+ --severity-threshold ${{ parameters.severityThreshold }} \
+ path ${{ parameters.scanPath }} > cycode-results.json
+ echo "scan exit: $?"
+ ls -la cycode-results.json
+ displayName: "Cycode scan (JSON)"
+ env:
+ CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID)
+ CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET)
+
+ # ---- Publish results --------------------------------------------
+ - ${{ if eq(parameters.publishResults, true) }}:
+ - script: python3 scripts/cycode-json-to-junit.py cycode-results.json cycode-junit.xml
+ displayName: "Convert JSON → JUnit"
+ condition: succeededOrFailed()
+
+ - task: PublishTestResults@2
+ displayName: "Publish to Tests tab"
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFormat: "JUnit"
+ testResultsFiles: "cycode-junit.xml"
+ testRunTitle: "Cycode"
+ mergeTestResults: true
+ failTaskOnFailedTests: false
+
+ - script: |
+ python3 scripts/cycode-summary.py cycode-results.json > cycode-summary.md
+ echo "##vso[task.uploadsummary]$(System.DefaultWorkingDirectory)/cycode-summary.md"
+ displayName: "Publish custom summary tab"
+ condition: succeededOrFailed()
+
+ - task: PublishBuildArtifacts@1
+ displayName: "Publish raw Cycode JSON"
+ condition: succeededOrFailed()
+ inputs:
+ pathToPublish: "cycode-results.json"
+ artifactName: "cycode-report"
+
+ # ---- Gates ------------------------------------------------------
+ - ${{ if or(eq(parameters.gateMode, 'cli'), eq(parameters.gateMode, 'both')) }}:
+ - script: |
+ cycode scan \
+ ${{ parameters.scanTypeFlags }} \
+ --severity-threshold ${{ parameters.severityThreshold }} \
+ path ${{ parameters.scanPath }}
+ displayName: "Gate: CLI scan (fails on findings)"
+ env:
+ CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID)
+ CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET)
+
+ - ${{ if or(eq(parameters.gateMode, 'api'), eq(parameters.gateMode, 'both')) }}:
+ - script: bash scripts/cycode-gate.sh
+ displayName: "Gate: API (fails on Open violations)"
+ env:
+ CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID)
+ CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET)
+ REPO_NAME: ${{ parameters.repoName }}
+ SEVERITY_MIN: ${{ parameters.severityThreshold }}
diff --git a/vulnerable_dockerfiles/Dockerfile.n8n-vulnerable b/vulnerable_dockerfiles/Dockerfile.n8n-vulnerable
new file mode 100644
index 0000000..f006ea0
--- /dev/null
+++ b/vulnerable_dockerfiles/Dockerfile.n8n-vulnerable
@@ -0,0 +1,47 @@
+# Vulnerable n8n Container
+# This Dockerfile uses a vulnerable version of n8n affected by CVE-2026-21858
+
+FROM n8nio/n8n:1.100.0
+
+LABEL maintainer="demo@example.com"
+LABEL description="Workflow automation service"
+LABEL vulnerability="CVE-2026-21858"
+
+# Environment configuration
+ENV N8N_BASIC_AUTH_ACTIVE=false
+ENV N8N_HOST=0.0.0.0
+ENV N8N_PORT=5678
+ENV N8N_PROTOCOL=http
+ENV GENERIC_TIMEZONE=UTC
+
+# Expose the n8n port
+EXPOSE 5678
+
+# Start n8n
+CMD ["n8n", "start"]
+
+# =============================================================================
+# VULNERABILITY INFORMATION
+# =============================================================================
+#
+# CVE-2026-21858: Critical Content-Type Confusion RCE in n8n
+#
+# Affected: n8n versions >= 1.65.0 and < 1.121.0
+# CVSS Score: 10.0 (Critical)
+# CWE: CWE-20 (Improper Input Validation)
+#
+# The n8n:1.100.0 base image contains a critical vulnerability in Form Webhook
+# handling that allows unauthenticated attackers to:
+# - Read arbitrary files from the container
+# - Extract database and session secrets
+# - Forge administrator sessions
+# - Execute arbitrary commands
+#
+# REMEDIATION:
+# Update to a fixed version:
+# FROM n8nio/n8n:1.121.0
+#
+# References:
+# - https://nvd.nist.gov/vuln/detail/CVE-2026-21858
+# - https://github.com/n8n-io/n8n/security/advisories/GHSA-v4pr-fm98-w9pg
+# =============================================================================
diff --git a/vulnerable_packages/README.md b/vulnerable_packages/README.md
new file mode 100644
index 0000000..4b63231
--- /dev/null
+++ b/vulnerable_packages/README.md
@@ -0,0 +1,29 @@
+# Vulnerable Packages
+
+Examples of applications using vulnerable open-source packages for SCA (Software Composition Analysis) testing.
+
+## Contents
+
+### n8n-workflow/
+
+Workflow automation service using a vulnerable version of n8n affected by **CVE-2026-21858** (CVSS 10.0 Critical).
+
+- **Vulnerability**: Content-Type confusion enabling unauthenticated RCE
+- **Affected versions**: n8n >= 1.65.0 and < 1.121.0
+- **Fixed version**: 1.121.0
+
+## Use Cases
+
+- Testing SCA scanner detection of critical CVEs
+- Validating vulnerability enrichment (CVSS, EPSS, KEV status)
+- Demonstrating supply chain risk from vulnerable dependencies
+- Training on dependency vulnerability remediation
+
+## Adding New Examples
+
+When adding vulnerable package examples:
+
+1. Create a subdirectory with a descriptive name
+2. Include the package manifest (package.json, requirements.txt, go.mod, etc.)
+3. Document the specific CVE(s) and affected versions in a README
+4. Include remediation guidance
diff --git a/vulnerable_packages/n8n-workflow/README.md b/vulnerable_packages/n8n-workflow/README.md
new file mode 100644
index 0000000..5a8a4c6
--- /dev/null
+++ b/vulnerable_packages/n8n-workflow/README.md
@@ -0,0 +1,40 @@
+# N8N Workflow Service
+
+Example workflow automation service using n8n.
+
+## Vulnerability
+
+This package includes **n8n v1.100.0**, which is affected by **CVE-2026-21858** (CVSS 10.0 Critical).
+
+### CVE-2026-21858: Content-Type Confusion RCE
+
+- **Affected versions**: n8n >= 1.65.0 and < 1.121.0
+- **Fixed version**: 1.121.0
+- **CWE**: CWE-20 (Improper Input Validation)
+- **Attack vector**: Unauthenticated remote code execution via Form Webhook
+
+### Description
+
+A content-type confusion vulnerability in n8n's Form Webhook handling allows an unauthenticated attacker to:
+
+1. Read arbitrary files from the n8n instance
+2. Extract database credentials and session secrets
+3. Forge administrator sessions
+4. Execute arbitrary commands on the host
+
+### Remediation
+
+Upgrade to n8n version 1.121.0 or later:
+
+```json
+{
+ "dependencies": {
+ "n8n": "1.121.0"
+ }
+}
+```
+
+### References
+
+- [NVD - CVE-2026-21858](https://nvd.nist.gov/vuln/detail/CVE-2026-21858)
+- [GitHub Security Advisory GHSA-v4pr-fm98-w9pg](https://github.com/n8n-io/n8n/security/advisories/GHSA-v4pr-fm98-w9pg)
diff --git a/vulnerable_packages/n8n-workflow/package.json b/vulnerable_packages/n8n-workflow/package.json
new file mode 100644
index 0000000..17616fa
--- /dev/null
+++ b/vulnerable_packages/n8n-workflow/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "n8n-workflow-service",
+ "version": "1.0.0",
+ "description": "Workflow automation service using n8n",
+ "main": "index.js",
+ "scripts": {
+ "start": "n8n start",
+ "dev": "n8n start --tunnel"
+ },
+ "keywords": [
+ "workflow",
+ "automation",
+ "n8n"
+ ],
+ "author": "Demo Project",
+ "license": "MIT",
+ "dependencies": {
+ "n8n": "1.100.0",
+ "express": "^4.18.2",
+ "dotenv": "^16.3.1"
+ }
+}