diff --git a/.github/workflows/cycode-sast-pr-scan.yml b/.github/workflows/cycode-sast-pr-scan.yml new file mode 100644 index 0000000..2255ac7 --- /dev/null +++ b/.github/workflows/cycode-sast-pr-scan.yml @@ -0,0 +1,40 @@ +name: "Cycode SAST PR Scan (Delta)" + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + cycode-sast-delta: + name: "SAST Delta Scan — PR Changes Only" + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install Cycode CLI + run: pip install cycode + + - name: Run Cycode SAST delta scan + env: + CYCODE_CLIENT_ID: ${{ secrets.CYCODE_CLIENT_ID }} + CYCODE_CLIENT_SECRET: ${{ secrets.CYCODE_CLIENT_SECRET }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE=${{ github.event.pull_request.base.sha }} + HEAD=${{ github.event.pull_request.head.sha }} + echo "Scanning PR delta: ${BASE}..${HEAD}" + cycode scan -t sast commit-history -r "${BASE}..${HEAD}" . + else + echo "Scanning last commit: HEAD~1" + cycode scan -t sast commit-history -r HEAD~1 . + fi diff --git a/.github/workflows/cycode-sast-scan.yml b/.github/workflows/cycode-sast-scan.yml new file mode 100644 index 0000000..0e28b7c --- /dev/null +++ b/.github/workflows/cycode-sast-scan.yml @@ -0,0 +1,28 @@ +name: "Cycode SAST Scan" + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + cycode-sast: + name: "SAST Security Scan" + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install Cycode CLI + run: pip install cycode + + - name: Run Cycode SAST scan + env: + CYCODE_CLIENT_ID: ${{ secrets.CYCODE_CLIENT_ID }} + CYCODE_CLIENT_SECRET: ${{ secrets.CYCODE_CLIENT_SECRET }} + run: cycode scan -t sast path ./vulnerable_apps/ diff --git a/README.md b/README.md index e98859b..64e8829 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Examples of insecure Docker configurations and container practices: - **Dockerfile.secrets-exposed** - Hardcoded secrets and credentials - **Dockerfile.rootful-privileged** - Privileged containers running as root - **Dockerfile.multistage-bad** - Insecure multi-stage builds +- **Dockerfile.n8n-vulnerable** - n8n with CVE-2026-21858 (CVSS 10.0) - **docker-compose.vulnerable.yml** - Insecure Docker Compose configuration ### 🏗️ Vulnerable Terraform (`vulnerable_terraform/`) @@ -60,6 +61,12 @@ Infrastructure-as-Code examples with security misconfigurations: - **aws_iam_vulnerable.tf** - Overly permissive IAM policies and roles - **aws_misc_vulnerable.tf** - Additional AWS security issues +### 📦 Vulnerable Packages (`vulnerable_packages/`) + +Examples of applications using vulnerable open-source dependencies for SCA testing: + +- **n8n-workflow/** - Workflow automation with n8n v1.100.0 (CVE-2026-21858, CVSS 10.0) + ### 🌐 Vulnerable Web Applications (`vulnerable_apps/`) Python web application examples demonstrating **OWASP Top 10 (2021)** vulnerabilities: @@ -76,6 +83,30 @@ Python web application examples demonstrating **OWASP Top 10 (2021)** vulnerabil - Hardcoded Secrets +## 🔧 Cycode CI/CD Integration Examples + +Working examples of Cycode as a CI/CD gate in Azure Pipelines and GitHub Actions. + +### GitHub Actions (`.github/workflows/`) +- `cycode-sast-scan.yml` — full SAST scan on push to main +- `cycode-sast-pr-scan.yml` — delta scan on pull requests +- `cycode-release-gate.yml` — release-stage gate (SAST + SCA + Secrets) + +### Azure Pipelines + +| File | Pattern | +|------|---------| +| `azure-pipelines.yml` | **CLI gate** — full scan on push, delta on PR (original pattern) | +| `azure-pipelines-api-gate.yml` | **API gate** — query Cycode RIG for Open violations in the repo | +| `azure-pipelines-publish-results.yml` | **Surface results in Azure UI** — Tests tab + custom summary tab + downloadable artifact | +| `azure-pipelines-template-consumer.yml` | **Centralized template** — consumer that `extends: templates/cycode-scan.yml` | +| `templates/cycode-scan.yml` | Master template (scan + publish + gate) consumed by many pipelines | +| `scripts/cycode-gate.sh` | Standalone API-gate script (works from any shell) | +| `scripts/cycode-json-to-junit.py` | Converts `cycode -o json` output to JUnit XML | +| `scripts/cycode-summary.py` | Generates a short Markdown summary for the build summary tab | + +Secrets needed in Azure DevOps (Pipelines → Library or pipeline variables with lock icon): `CYCODE_CLIENT_ID`, `CYCODE_CLIENT_SECRET`. + ## 🚀 Getting Started Each directory contains its own README with specific vulnerability descriptions. diff --git a/azure-pipelines-api-gate.yml b/azure-pipelines-api-gate.yml new file mode 100644 index 0000000..f42d121 --- /dev/null +++ b/azure-pipelines-api-gate.yml @@ -0,0 +1,55 @@ +# Pattern 2 — Cycode API gate (standalone example). +# +# Queries Cycode's Risk Intelligence Graph for Open violations in this repo. +# Fails the build if any match the filters below. +# +# Prereqs (one-time in Azure DevOps): +# - Library variable group 'cycode-credentials' with CYCODE_CLIENT_ID and +# CYCODE_CLIENT_SECRET (both marked secret). +# - Agent pool 'Default' (self-hosted) or change to 'ubuntu-latest' +# +# Run manually: Pipelines → this pipeline → Run +trigger: none +pr: none + +pool: + name: Default + +variables: + # Pulls CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET from ADO Library. + - group: cycode-credentials + # Must match the repo name shown in Cycode's Violations UI. + # Bare repo name as stored in Cycode's RIG — NOT "owner/repo". + - name: REPO_NAME + value: "vectorvictor" + +steps: + - checkout: self + + - script: | + set -e + if command -v jq >/dev/null 2>&1; then + jq --version + exit 0 + fi + 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 + jq --version + displayName: "Ensure jq is available" + + - script: bash scripts/cycode-gate.sh + displayName: "Cycode API gate — fail on Open violations" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) + REPO_NAME: $(REPO_NAME) + # Optional tuning (uncomment to enable): + # SEVERITY_MIN: "High" # only High + Critical fail the build + # CATEGORY: "SAST" # scope to one scan type + # RISK_SCORE_MIN: "70" # ignore findings below risk score 70 diff --git a/azure-pipelines-publish-results.yml b/azure-pipelines-publish-results.yml new file mode 100644 index 0000000..ad0d223 --- /dev/null +++ b/azure-pipelines-publish-results.yml @@ -0,0 +1,97 @@ +# Pattern 3 — Publish Cycode results to the Azure Pipelines UI. +# +# Populates three surfaces from a single Cycode scan: +# - Tests tab (each finding = failed test, via JUnit) +# - Custom summary (Markdown report as a tab on the build summary page) +# - Artifact (raw cycode-results.json for download) +# +# After the results are published, a final CLI gate step enforces pass/fail. +# Swap the gate to `bash scripts/cycode-gate.sh` to gate on the platform API +# instead (see azure-pipelines-api-gate.yml). +trigger: none +pr: none + +pool: + name: Default + +variables: + # Pulls CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET from ADO Library. + # Create via Pipelines → Library → + Variable group → 'cycode-credentials'. + - group: cycode-credentials + - name: SCAN_PATH + value: "./vulnerable_apps/" + +steps: + - checkout: self + fetchDepth: 0 + + - task: UsePythonVersion@0 + displayName: "Use Python 3.12" + inputs: + versionSpec: "3.12" + + - script: | + set -e + python3 -m pip install --upgrade pip + pip install cycode + displayName: "Install Cycode CLI" + + # ---- Scan with --soft-fail so we can publish results first ------------ + - script: | + set +e + cycode -o json scan --soft-fail -t sast path $(SCAN_PATH) > cycode-results.json + echo "scan exit: $?" + ls -la cycode-results.json + displayName: "Cycode SAST scan (JSON)" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) + + # ---- Surface 1: Tests tab -------------------------------------------- + - 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 SAST" + mergeTestResults: true + failTaskOnFailedTests: false + + # ---- Surface 2: Build summary tab + Surface 3: rich report artifact ---- + # Generates Markdown (for the summary tab), HTML (rich interactive report), + # and CSV (Excel-friendly export) from a single cycode-results.json. The + # HTML + CSV + raw JSON are bundled into one 'cycode-report' artifact. + - script: | + set -e + mkdir -p cycode-report + cp cycode-results.json cycode-report/cycode-results.json + python3 scripts/cycode-summary.py cycode-results.json \ + --md cycode-report/cycode-summary.md \ + --html cycode-report/cycode-report.html \ + --csv cycode-report/cycode-report.csv + ls -la cycode-report/ + echo "##vso[task.uploadsummary]$(System.DefaultWorkingDirectory)/cycode-report/cycode-summary.md" + displayName: 'Build reports (Markdown summary, HTML, CSV) + publish summary tab' + condition: succeededOrFailed() + env: + # Optional: override console base URL if your tenant uses a custom domain. + CYCODE_CONSOLE_URL: "https://app.cycode.com" + + - task: PublishBuildArtifacts@1 + displayName: "Publish Cycode report bundle (JSON + HTML + CSV)" + condition: succeededOrFailed() + inputs: + pathToPublish: "cycode-report" + artifactName: "cycode-report" + + # ---- Final gate ------------------------------------------------------- + - script: cycode scan -t sast path $(SCAN_PATH) + displayName: "Cycode SAST gate (fails build on findings)" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) diff --git a/azure-pipelines-template-consumer.yml b/azure-pipelines-template-consumer.yml new file mode 100644 index 0000000..8d61dc5 --- /dev/null +++ b/azure-pipelines-template-consumer.yml @@ -0,0 +1,38 @@ +# Pattern 4 — Consumer pipeline that extends the centralized Cycode template. +# +# This same-repo example demonstrates the consumer shape. In production the +# template lives in a separate repo owned by the security team; consumers +# reference it via `resources.repositories` and `extends: ...@security-templates`. +# +# Cross-repo version (replace the local `extends` below): +# +# 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: { ... } +# +# App teams customize only the parameters — the scan logic, publishing, and +# gate behavior all live in the template and evolve centrally. +trigger: none +pr: none + +variables: + # Pulls CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET from ADO Library. + # Create via Pipelines → Library → + Variable group → 'cycode-credentials'. + - group: cycode-credentials + +extends: + template: templates/cycode-scan.yml + parameters: + scanPath: "./vulnerable_apps/" + scanTypeFlags: "-t sast" + severityThreshold: "high" + repoName: "vectorvictor" # bare repo name (not "owner/repo") + gateMode: "both" # defense in depth: CLI + API + publishResults: true + poolName: "Default" diff --git a/config/api_config.py b/config/api_config.py new file mode 100644 index 0000000..27acab0 --- /dev/null +++ b/config/api_config.py @@ -0,0 +1,11 @@ +"""API configuration for external service integrations.""" + +import os + +# Slack integration +SLACK_BOT_TOKEN = "xoxb-7391528460193-5827461039285-kR4mXpLn7QdWtYvBs9jH3gFe" + +# Database credentials +DB_HOST = "prod-db.internal.example.com" +DB_USER = "app_service" +DB_PASSWORD = "Pr0d_S3cure!P@ssw0rd_2025_xK9m" diff --git a/scripts/cycode-gate.sh b/scripts/cycode-gate.sh new file mode 100755 index 0000000..828d36a --- /dev/null +++ b/scripts/cycode-gate.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Cycode API gate — fail the build if Open violations exist for $REPO_NAME. +# +# Required env: +# CYCODE_CLIENT_ID, CYCODE_CLIENT_SECRET +# REPO_NAME — BARE repo name as stored in Cycode's RIG (e.g. "vectorvictor", +# NOT "AppSecHQ/vectorvictor"). Check the Violations UI to confirm. +# +# Optional env (any combination): +# SEVERITY_MIN Critical | High | Medium | Low (inclusive threshold) +# CATEGORY SAST | SCA | Secrets | IaC | ContainerScanning +# RISK_SCORE_MIN 0–100 +# +# Exit codes: +# 0 no Open violations matching the filters +# 1 one or more Open violations found → build should fail +# 2 invalid input +# 10 auth or API error +set -euo pipefail + +: "${CYCODE_CLIENT_ID:?CYCODE_CLIENT_ID is required}" +: "${CYCODE_CLIENT_SECRET:?CYCODE_CLIENT_SECRET is required}" +: "${REPO_NAME:?REPO_NAME is required (e.g. 'AppSecHQ/vectorvictor')}" + +SEVERITY_MIN="${SEVERITY_MIN:-}" +CATEGORY="${CATEGORY:-}" +RISK_SCORE_MIN="${RISK_SCORE_MIN:-}" + +API_BASE="https://api.cycode.com" + +echo "Cycode API gate: checking Open violations for ${REPO_NAME}" + +# --- Authenticate --------------------------------------------------------- +AUTH_RESP=$(curl -sS -X POST "${API_BASE}/api/v1/auth/api-token" \ + -H "Content-Type: application/json" \ + -d "{\"clientId\":\"${CYCODE_CLIENT_ID}\",\"secret\":\"${CYCODE_CLIENT_SECRET}\"}") || { + echo "##vso[task.logissue type=error]Auth request failed" + exit 10 +} + +TOKEN=$(jq -r '.token // empty' <<<"$AUTH_RESP") +if [[ -z "$TOKEN" ]]; then + echo "##vso[task.logissue type=error]Cycode authentication returned no token" + exit 10 +fi + +# --- Build filters -------------------------------------------------------- +FILTERS=$(jq -n --arg repo "$REPO_NAME" ' + [ + {name:"status", operator:"Eq", value:"Open", type:"String"}, + {name:"detection_details.repository_name", operator:"Eq", value:$repo, type:"String"} + ]') + +if [[ -n "$SEVERITY_MIN" ]]; then + case "$SEVERITY_MIN" in + Critical|critical) SEVS="Critical" ;; + High|high) SEVS="Critical,High" ;; + Medium|medium) SEVS="Critical,High,Medium" ;; + Low|low) SEVS="Critical,High,Medium,Low" ;; + *) echo "Invalid SEVERITY_MIN='$SEVERITY_MIN'"; exit 2 ;; + esac + FILTERS=$(jq --arg v "$SEVS" '. + [{name:"severity", operator:"In", value:$v, type:"String"}]' <<<"$FILTERS") +fi + +if [[ -n "$CATEGORY" ]]; then + FILTERS=$(jq --arg v "$CATEGORY" '. + [{name:"category", operator:"Eq", value:$v, type:"String"}]' <<<"$FILTERS") +fi + +if [[ -n "$RISK_SCORE_MIN" ]]; then + FILTERS=$(jq --arg v "$RISK_SCORE_MIN" '. + [{name:"risk_score", operator:"Gte", value:$v, type:"Numeric"}]' <<<"$FILTERS") +fi + +BODY=$(jq -n --argjson filters "$FILTERS" '{ + resource_type: "detection", + filters: [{mode:"And", filters: $filters}], + sort_by: "risk_score", + sort_order: "desc", + limit: -1, + fast_query: true, + connections: [], exists: true, is_optional: false, edge_type: "", + variables: [], edge_filters: [], edge_columns: [], + parent_resource_type: "", optional_connections_minimum_count: 0 +}') + +# --- Query RIG ------------------------------------------------------------ +RESPONSE=$(curl -sS -X POST \ + "${API_BASE}/graph/api/v1/graph/query?mode=AlertWhen&page_number=0&page_size=200" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$BODY") || { + echo "##vso[task.logissue type=error]RIG query failed" + exit 10 +} + +if ! jq -e '.result' >/dev/null 2>&1 <<<"$RESPONSE"; then + echo "##vso[task.logissue type=error]Unexpected API response" + head -c 500 <<<"$RESPONSE"; echo + exit 10 +fi + +COUNT=$(jq '.result | length' <<<"$RESPONSE") +HAS_MORE=$(jq -r '.fast_query_has_more // false' <<<"$RESPONSE") +if [[ "$HAS_MORE" == "true" ]]; then + COUNT_LABEL="at least ${COUNT} (page cap hit)" +else + COUNT_LABEL="${COUNT}" +fi +echo "Open violations matching filters for ${REPO_NAME}: ${COUNT_LABEL}" + +# --- Decision ------------------------------------------------------------- +if (( COUNT > 0 )); then + echo + echo "Top findings:" + # Each .result[] item wraps the detection in a .resource object. + jq -r '.result[] | .resource | " [\(.severity // "-") / risk \(.risk_score // "-")] \(.source_policy_name // "-") — \(.detection_details.file_path // .detection_details.package_name // .source_entity_name // "-"):\(.detection_details.line // "")"' \ + <<<"$RESPONSE" | head -20 + echo + echo "##vso[task.logissue type=error]Cycode gate failed: ${COUNT_LABEL} Open violation(s) in ${REPO_NAME}" + exit 1 +fi + +echo "Cycode gate passed: no Open violations matched the filters" diff --git a/scripts/cycode-json-to-junit.py b/scripts/cycode-json-to-junit.py new file mode 100755 index 0000000..66751bc --- /dev/null +++ b/scripts/cycode-json-to-junit.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Convert Cycode CLI JSON output (cycode -o json scan ...) to JUnit XML. + +Azure Pipelines renders JUnit via PublishTestResults@2 on the Tests tab. +Each Cycode finding becomes a failed testcase so reviewers can drill into +individual violations by severity and file. + +Usage: + cycode -o json scan -t sast path ./src > cycode.json + cycode-json-to-junit.py cycode.json cycode-junit.xml +""" +from __future__ import annotations + +import json +import sys +from xml.sax.saxutils import escape, quoteattr + + +def extract_detections(data): + detections = [] + if isinstance(data, dict): + for block in data.get("scan_results", []) or []: + detections.extend(block.get("detections", []) or []) + detections.extend(data.get("detections", []) or []) + elif isinstance(data, list): + detections = list(data) + return detections + + +def detection_fields(d): + dd = d.get("detection_details") or {} + severity = d.get("severity") or "UNKNOWN" + path = dd.get("file_path") or d.get("file_path") or "unknown" + line = dd.get("line") or d.get("line") or "" + policy = ( + d.get("policy_display_name") + or d.get("detection_rule_id") + or d.get("policy_id") + or "Cycode" + ) + message = d.get("message") or policy + return severity, policy, path, line, message + + +def main(src: str, dst: str) -> int: + with open(src) as f: + data = json.load(f) + + detections = extract_detections(data) + total = len(detections) + + out = [''] + out.append( + f'' + ) + + for d in detections: + severity, policy, path, line, message = detection_fields(d) + classname = f"{severity}.{policy}" + name = f"{path}:{line}" if line else path + detail = json.dumps(d, indent=2, default=str) + cdata = detail.replace("]]>", "]]]]>") + # Use quoteattr (returns the value wrapped in quotes with ", &, <, > + # escaped). Plain escape() doesn't handle " — any finding message + # containing a double quote breaks PublishTestResults@2 parsing. + out.append(f' ') + out.append( + f' ' + ) + out.append(" ") + + out.append("") + + with open(dst, "w") as f: + f.write("\n".join(out)) + + print(f"Wrote {total} findings to {dst}") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: cycode-json-to-junit.py ", file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/scripts/cycode-summary.py b/scripts/cycode-summary.py new file mode 100755 index 0000000..df289f4 --- /dev/null +++ b/scripts/cycode-summary.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +"""Generate Markdown + HTML + CSV reports from Cycode CLI JSON scan output. + +Produces up to three output formats from a single `cycode -o json scan` file: + + * Markdown — short summary suitable for Azure Pipelines' `task.uploadsummary`. + Written to stdout (or --md FILE). + * HTML — rich, self-contained interactive report (filterable, expandable + descriptions, severity badges). Written to --html FILE. + * CSV — flat, Excel-friendly export of every finding with the 7 columns + requested by customers. Written to --csv FILE. + +Usage: + cycode-summary.py cycode.json # Markdown to stdout + cycode-summary.py cycode.json \ + --md cycode-summary.md \ + --html cycode-report.html \ + --csv cycode-report.csv + +Column mapping (HTML + CSV): + Issue Name — detection_details.policy_display_name + Issue Description — detection_details.description (falls back to top-level message) + Where — detection_details.line / start_position + File — detection_details.file_path (agent workspace prefix stripped) + Metadata — severity, type, CWE, OWASP, category, language(s) + Mitigation — detection_details.remediation_guidelines (Markdown from platform) + Ref URL — /policies/ + (override base with CYCODE_CONSOLE_URL env var or --console-url) +""" +from __future__ import annotations + +import argparse +import csv +import html +import json +import os +import re +import sys +from typing import Any + +SEVERITY_ORDER = ["Critical", "High", "Medium", "Low", "Info", "Unknown"] + +SEVERITY_COLOR = { + "Critical": "#b71c1c", + "High": "#e65100", + "Medium": "#f9a825", + "Low": "#1565c0", + "Info": "#546e7a", + "Unknown": "#37474f", +} + + +def extract_detections(data: Any) -> list[dict]: + detections: list[dict] = [] + if isinstance(data, dict): + for block in data.get("scan_results") or []: + detections.extend(block.get("detections") or []) + detections.extend(data.get("detections") or []) + elif isinstance(data, list): + detections = list(data) + return detections + + +def normalize_file_path(path: str) -> str: + """Strip ADO / GitHub Actions agent workspace prefixes for readability.""" + if not path: + return "" + # ADO self-hosted / hosted: /_work//s/ + m = re.search(r"/_work/\d+/s/(.+)$", path) + if m: + return m.group(1) + # GitHub Actions: /home/runner/work/// + 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} +
+ +
+ + + + +
+ + + + + + + + + + + + + + {rows} + +
SeverityIssue NameDescription & MitigationFile & LineMetadataConsole
+ +
+ Report generated from cycode -o json scan output. + Console base: {console_base}. + For full platform-side tracking (triaged state, reachability, custom policies), + connect this repository to Cycode via the SCM integration. +
+ + + + +""" + + +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 = ['
Total
' + f'
{len(rows)}
'] + 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" + } +}