diff --git a/.github/workflows/cycode-release-gate.yml b/.github/workflows/cycode-release-gate.yml new file mode 100644 index 0000000..bd7349b --- /dev/null +++ b/.github/workflows/cycode-release-gate.yml @@ -0,0 +1,52 @@ +name: "Cycode Release Security Gate" + +on: + push: + branches: + - "release/**" + workflow_dispatch: + +jobs: + security-gate: + name: "Security Gate — SAST, SCA, Secrets" + 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: SAST scan + env: + CYCODE_CLIENT_ID: ${{ secrets.CYCODE_CLIENT_ID }} + CYCODE_CLIENT_SECRET: ${{ secrets.CYCODE_CLIENT_SECRET }} + run: cycode scan --severity-threshold HIGH -t sast path ./ + + - name: SCA scan + env: + CYCODE_CLIENT_ID: ${{ secrets.CYCODE_CLIENT_ID }} + CYCODE_CLIENT_SECRET: ${{ secrets.CYCODE_CLIENT_SECRET }} + run: cycode scan --severity-threshold HIGH -t sca path ./ + + - name: Secrets scan + env: + CYCODE_CLIENT_ID: ${{ secrets.CYCODE_CLIENT_ID }} + CYCODE_CLIENT_SECRET: ${{ secrets.CYCODE_CLIENT_SECRET }} + run: cycode scan --severity-threshold HIGH -t secret path ./ + + build: + name: "Build & Package" + needs: security-gate + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build + run: echo "Build, compile, package, test — your build steps here" 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..0d4dd47 --- /dev/null +++ b/azure-pipelines-api-gate.yml @@ -0,0 +1,40 @@ +# 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): +# - Secret pipeline variables CYCODE_CLIENT_ID and CYCODE_CLIENT_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: + # Must match the repo name shown in Cycode's Violations UI. + REPO_NAME: "AppSecHQ/vectorvictor" + +steps: + - checkout: self + + - script: | + if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq jq || true + 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..56904da --- /dev/null +++ b/azure-pipelines-publish-results.yml @@ -0,0 +1,81 @@ +# 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: + SCAN_PATH: "./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 ------------------------------------ + - script: | + python3 scripts/cycode-summary.py cycode-results.json > cycode-summary.md + echo "##vso[task.uploadsummary]$(System.DefaultWorkingDirectory)/cycode-summary.md" + displayName: 'Publish "Cycode Scan Summary" tab' + condition: succeededOrFailed() + + # ---- Surface 3: Downloadable artifact -------------------------------- + - task: PublishBuildArtifacts@1 + displayName: "Publish raw Cycode JSON" + condition: succeededOrFailed() + inputs: + pathToPublish: "cycode-results.json" + 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..b06ef41 --- /dev/null +++ b/azure-pipelines-template-consumer.yml @@ -0,0 +1,33 @@ +# 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 + +extends: + template: templates/cycode-scan.yml + parameters: + scanPath: "./vulnerable_apps/" + scanTypeFlags: "-t sast" + severityThreshold: "high" + repoName: "AppSecHQ/vectorvictor" + gateMode: "both" # defense in depth: CLI + API + publishResults: true + poolName: "Default" diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..929fd69 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,49 @@ +trigger: + branches: + include: + - main + +pr: + branches: + include: + - main + +pool: + name: Default + +steps: + - checkout: self + fetchDepth: 0 + + - task: UsePythonVersion@0 + inputs: + versionSpec: "3.12" + displayName: Set up Python + + - script: pip install cycode + displayName: Install Cycode CLI + + # Full scan — runs on push to main + - script: cycode scan -t sast path ./vulnerable_apps/ + displayName: Run SAST full scan + condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) + + # Delta scan — runs on pull requests only + - bash: | + TARGET=$(echo "$(System.PullRequest.TargetBranch)" | sed 's|refs/heads/||') + git fetch origin "${TARGET}" + BASE=$(git merge-base "$(System.PullRequest.SourceCommitId)" FETCH_HEAD) + HEAD="$(System.PullRequest.SourceCommitId)" + echo "Target branch : ${TARGET}" + echo "Base SHA : ${BASE}" + echo "Head SHA : ${HEAD}" + echo "Range : ${BASE}..${HEAD}" + cycode scan -t sast commit-history -r "${BASE}..${HEAD}" . + displayName: Run SAST delta scan + condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest')) + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) 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..7c0e7d0 --- /dev/null +++ b/scripts/cycode-gate.sh @@ -0,0 +1,114 @@ +#!/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 — repo name as shown in Cycode's Violations UI, e.g. "AppSecHQ/vectorvictor" +# +# 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") +echo "Open violations matching filters for ${REPO_NAME}: ${COUNT}" + +# --- Decision ------------------------------------------------------------- +if (( COUNT > 0 )); then + echo + echo "Top findings:" + jq -r '.result[] | " [\(.severity // "-")] \(.source_policy_name // "-") — \(.detection_details.file_path // .detection_details.package_name // "-") (risk \(.risk_score // "-"))"' \ + <<<"$RESPONSE" | head -20 + echo + echo "##vso[task.logissue type=error]Cycode gate failed: ${COUNT} 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..9ad7ee7 --- /dev/null +++ b/scripts/cycode-json-to-junit.py @@ -0,0 +1,84 @@ +#!/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 + + +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("]]>", "]]]]>") + 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..8f8c73e --- /dev/null +++ b/scripts/cycode-summary.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Generate a short Markdown summary of a Cycode JSON scan result. + +The output is used with `##vso[task.uploadsummary]` so the summary appears +as a tab on the Azure Pipelines build summary page. + +Usage: + cycode -o json scan -t sast path ./src > cycode.json + cycode-summary.py cycode.json > cycode-summary.md +""" +from __future__ import annotations + +import json +import sys + + +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 main(src: str) -> int: + with open(src) as f: + data = json.load(f) + + detections = extract_detections(data) + counts: dict[str, int] = {} + for d in detections: + sev = d.get("severity") or "Unknown" + counts[sev] = counts.get(sev, 0) + 1 + + lines = [] + lines.append("## Cycode Scan Summary") + lines.append("") + lines.append(f"**Total findings:** {len(detections)}") + lines.append("") + lines.append("| Severity | Count |") + lines.append("|---|---|") + for sev in ["Critical", "High", "Medium", "Low", "Info", "Unknown"]: + if counts.get(sev): + lines.append(f"| {sev} | {counts[sev]} |") + lines.append("") + + if detections: + lines.append("### Top findings") + for d in detections[:10]: + dd = d.get("detection_details") or {} + path = dd.get("file_path") or d.get("file_path") or "?" + line = dd.get("line") or d.get("line") or "" + msg = (d.get("message") or d.get("detection_rule_id") or "")[:120] + loc = f"`{path}:{line}`" if line else f"`{path}`" + lines.append(f"- **[{d.get('severity', '?')}]** {msg} — {loc}") + + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: cycode-summary.py ", file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1])) 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..9afbf64 --- /dev/null +++ b/templates/cycode-scan.yml @@ -0,0 +1,135 @@ +# 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 + sudo apt-get update -qq && sudo apt-get install -y -qq jq || true + 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" + } +}