diff --git a/.github/workflows/security-monthly.yml b/.github/workflows/security-monthly.yml new file mode 100644 index 0000000..984d104 --- /dev/null +++ b/.github/workflows/security-monthly.yml @@ -0,0 +1,134 @@ +name: Security — monthly SBOM & VEX report + +# Runs on GitHub's servers (not on anyone's laptop). Every month it regenerates +# the SBOM, scans dependencies, has Claude triage any NEW advisory and write the +# report from the versioned template, drops everything into security//, +# and opens a PR for the team to review. Nothing is merged automatically. +# +# Python variant: the SBOM is built from a clean venv (cyclonedx-py) and OSV +# scans the resulting CycloneDX SBOM. Auth for the triage step: your Claude +# subscription via CLAUDE_CODE_OAUTH_TOKEN. See security/README.md. + +on: + schedule: + - cron: "0 6 1 * *" # 06:00 UTC on the 1st of every month + workflow_dispatch: {} # manual "Run workflow" button + +permissions: + contents: write + pull-requests: write + id-token: write # required by claude-code-action (OIDC) + +concurrency: + group: security-monthly + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-latest + # Mapped here because the `secrets` context is NOT allowed in a step-level + # `if:` — the Claude step gates on `env.CLAUDE_CODE_OAUTH_TOKEN` instead. + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Month stamp + id: m + run: echo "month=$(date -u +%Y-%m)" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@v4 + with: { node-version: "22" } + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + + # ---- deterministic: SBOM from a clean venv (CycloneDX + SPDX + CSV) ---- + # SBOM_PIP_ARGS forces wheels for native deps where needed (set per repo). + - name: Generate SBOM + run: bash scripts/generate-sbom.sh + + # ---- deterministic: vulnerability scan (OSV over the SBOM, VEX baseline) ---- + - name: Install osv-scanner + run: | + curl -sSfL "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64" -o /usr/local/bin/osv-scanner + chmod +x /usr/local/bin/osv-scanner + - name: Scan + run: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + osv-scanner scan $CFG --format=json --output=/tmp/osv.json "sbom/$NAME.cdx.json" || true + node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv + + # ---- judgment: Claude triages the delta + updates the report data ---- + # Runs on YOUR subscription (no API key). Skipped gracefully if the token + # isn't configured yet — the deterministic report still builds below. + - name: Claude — triage new advisories & update report data + if: ${{ env.CLAUDE_CODE_OAUTH_TOKEN != '' }} + continue-on-error: true + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: > + You are updating this repository's monthly security report DATA only. + Inputs: the fresh scan at /tmp/osv.json and the SBOM under sbom/. + For every advisory present in /tmp/osv.json that is NOT already listed + in osv-scanner.toml, read the source code and decide reachability + (is the vulnerable function called? is its input attacker-controlled?). + Then: + (a) if NOT exploitable, add an [[IgnoredVulns]] entry to osv-scanner.toml + with a CISA VEX reason and an ignoreUntil date ~3 months out; + (b) if exploitable, add/refresh the corresponding entry in + security/report-config.json (affected[]) with the fix version; + (c) keep the counts and the notAffected[]/mitigated[] arrays in + security/report-config.json consistent with the scan. + EDIT ONLY these two files: osv-scanner.toml and + security/report-config.json. Do not touch anything else. If there are + no new advisories, make no changes. + + # ---- deterministic: render the report from the (possibly updated) data ---- + - name: Build report (md + html) + run: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + node scripts/build-report.mjs \ + --config security/report-config.json \ + --cdx "sbom/$NAME.cdx.json" \ + --out "security/${{ steps.m.outputs.month }}" \ + --date "${{ steps.m.outputs.month }}" + + - name: Render PDF + uses: browser-actions/setup-chrome@v1 + id: chrome + - name: Assemble dated folder + run: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + MONTH="${{ steps.m.outputs.month }}" + DIR="security/$MONTH"; mkdir -p "$DIR/sbom" + cp "sbom/$NAME".cdx.json "sbom/$NAME".spdx.json "sbom/$NAME".components.csv sbom/vulnerabilities.csv "$DIR/sbom/" + REPORT="$DIR/$(ls "$DIR" | grep -E 'Security-Report\.html$')" + # --no-sandbox / --disable-dev-shm-usage: Chrome's zygote sandbox aborts + # (SIGABRT) on GitHub runners; required for headless Chrome in CI. + "${{ steps.chrome.outputs.chrome-path }}" --headless=new --no-sandbox --disable-dev-shm-usage \ + --disable-gpu --no-pdf-header-footer \ + --run-all-compositor-stages-before-draw --virtual-time-budget=5000 \ + --print-to-pdf="${REPORT%.html}.pdf" "file://$PWD/$REPORT" + ln -sfn "$MONTH" security/latest + + # ---- delivery: open the PR for review ---- + - name: Open Pull Request + uses: peter-evans/create-pull-request@v6 + with: + branch: chore/security-${{ steps.m.outputs.month }} + title: "chore(security): monthly SBOM & VEX report — ${{ steps.m.outputs.month }}" + labels: supply-chain, security + commit-message: "chore(security): SBOM & VEX report ${{ steps.m.outputs.month }}" + body: | + Automated monthly supply-chain snapshot for **${{ github.event.repository.name }}** — `security/${{ steps.m.outputs.month }}/`. + + - SBOM regenerated (CycloneDX + SPDX) from a clean virtual environment. + - Dependencies scanned against OSV (same source as Dependabot), honoring `osv-scanner.toml` (the VEX baseline). + - New advisories (if any) were triaged by Claude and reflected in `report-config.json` / `osv-scanner.toml` — **review those diffs**. + + Nothing is merged automatically. Approve to archive this month's snapshot. diff --git a/.github/workflows/security-pr-archive.yml b/.github/workflows/security-pr-archive.yml new file mode 100644 index 0000000..1df28b2 --- /dev/null +++ b/.github/workflows/security-pr-archive.yml @@ -0,0 +1,69 @@ +name: Security — archive PR SBOM on merge + +# Python variant. When a pull request is MERGED, resolve the SBOM + report for +# the resulting state (cyclonedx-py in a clean venv) and commit it under +# security/pr--/ on the default branch, keeping a permanent per-PR +# supply-chain history. Runs once per merge — never loops, never touches a PR +# under review. +# +# NOTE: this pushes directly to the default branch. If you later require PRs on +# the default branch in branch protection, switch this to open a PR instead. + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +concurrency: + group: security-pr-archive + cancel-in-progress: false + +env: + # Repos with native deps set this (e.g. orchestrator-agent: "--only-binary av"). + SBOM_PIP_ARGS: "" + +jobs: + archive: + if: ${{ github.event.pull_request.merged == true }} + runs-on: ubuntu-latest + steps: + - name: Checkout the default branch (post-merge state) + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: { node-version: "22" } + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + + - name: Install osv-scanner + run: | + curl -sSfL "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64" -o /usr/local/bin/osv-scanner + chmod +x /usr/local/bin/osv-scanner + + - name: Generate SBOM + report and archive under security/pr--/ + run: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + bash scripts/generate-sbom.sh + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + osv-scanner scan $CFG --format=json --output=/tmp/osv.json "sbom/$NAME.cdx.json" || true + node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv + DATE=$(date -u +%Y-%m-%d) + PR="${{ github.event.pull_request.number }}" + DIR="security/pr-${PR}-${DATE}" + node scripts/build-report.mjs --config security/report-config.json --cdx "sbom/$NAME.cdx.json" --out "$DIR" --date "$DATE" + mkdir -p "$DIR/sbom" + cp "sbom/$NAME".cdx.json "sbom/$NAME".spdx.json "sbom/$NAME".components.csv sbom/vulnerabilities.csv "$DIR/sbom/" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "$DIR" + if git diff --cached --quiet; then + echo "No SBOM to archive." + else + git commit -m "chore(security): SBOM snapshot for merged PR #${PR} (${DATE})" + git push origin "HEAD:${{ github.event.pull_request.base.ref }}" + fi diff --git a/.github/workflows/security-pr-gate.yml b/.github/workflows/security-pr-gate.yml new file mode 100644 index 0000000..fcf3444 --- /dev/null +++ b/.github/workflows/security-pr-gate.yml @@ -0,0 +1,104 @@ +name: Security — PR gate + +# Python variant. Runs on every pull request and blocks ONLY on advisories the +# PR *introduces* (present in head, absent in base) at/above the threshold — +# pre-existing issues never block. Because requirements.txt is unpinned, the diff +# is computed from RESOLVED SBOMs (cyclonedx-py in a clean venv). If the PR does +# not touch a dependency manifest, no new dependency is possible, so we skip the +# (expensive) base resolve and pass. +# +# The job never writes CODE (contents: read) — safe to require in branch +# protection. It posts one sticky PR comment (pull-requests: write) with the +# actionable result. The per-PR SBOM snapshot is archived on MERGE by +# security-pr-archive.yml. + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: security-pr-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + GATE_THRESHOLD: HIGH + # Repos with native deps set this (e.g. orchestrator-agent: "--only-binary av"). + SBOM_PIP_ARGS: "" + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: { node-version: "22" } + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + + - name: Install osv-scanner + run: | + curl -sSfL "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64" -o /usr/local/bin/osv-scanner + chmod +x /usr/local/bin/osv-scanner + + - name: Did this PR change any dependency manifest? + id: deps + run: | + BASE="${{ github.event.pull_request.base.sha }}" + git fetch --no-tags --depth=1 origin "$BASE" 2>/dev/null || true + CHANGED=$(git diff --name-only "$BASE" HEAD -- requirements.txt requirements-dev.txt pyproject.toml poetry.lock Pipfile Pipfile.lock 2>/dev/null || true) + if [ -n "$CHANGED" ]; then echo "changed=true" >> "$GITHUB_OUTPUT"; else echo "changed=false" >> "$GITHUB_OUTPUT"; fi + echo "manifests changed: ${CHANGED:-}" + + - name: Resolve & scan HEAD SBOM + run: | + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + bash scripts/generate-sbom.sh + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + osv-scanner scan $CFG --format=json --output=/tmp/head.json "sbom/$NAME.cdx.json" || true + + - name: Resolve & scan BASE SBOM (only if manifests changed) + run: | + if [ "${{ steps.deps.outputs.changed }}" != "true" ]; then + echo "No dependency manifest changed — base == head, nothing new can be introduced." + cp /tmp/head.json /tmp/base.json + exit 0 + fi + NAME=$(node -p "require('./security/report-config.json').sbomBasename") + cp requirements.txt /tmp/head-req.bak 2>/dev/null || true + git show "${{ github.event.pull_request.base.sha }}:requirements.txt" > requirements.txt 2>/dev/null || : > requirements.txt + bash scripts/generate-sbom.sh + cp "sbom/$NAME.cdx.json" /tmp/base.cdx.json + cp /tmp/head-req.bak requirements.txt 2>/dev/null || true + CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml" + osv-scanner scan $CFG --format=json --output=/tmp/base.json /tmp/base.cdx.json || true + bash scripts/generate-sbom.sh # restore HEAD sbom/ (overwritten by the base resolve) + + - name: Evaluate — block on newly-introduced advisories + run: node scripts/pr-gate-diff.mjs /tmp/base.json /tmp/head.json "${GATE_THRESHOLD}" + + - name: Comment result on the PR + if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + STATUS=$(cat /tmp/gate-status 2>/dev/null || echo clean) + CID=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \ + --jq '.[] | select(.body | contains("")) | .id' | head -1) + if [ "$STATUS" = "clean" ] && [ -z "$CID" ]; then + echo "Clean and no existing comment — nothing to post."; exit 0 + fi + if [ -n "$CID" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$CID" -F body=@/tmp/gate-comment.md >/dev/null && echo "Updated comment $CID" + else + gh pr comment "$PR" --repo "$REPO" --body-file /tmp/gate-comment.md && echo "Created comment" + fi diff --git a/.gitignore b/.gitignore index aafd6a9..a4c8bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ core/src/drivers/plugins/native/ethercat/libs/soem/cmake/CYGWIN.cmake *.o *.so .DS_Store + +# transient SBOM build output (canonical copy lives in security//sbom/) +/sbom/ diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000..4b92bf6 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,125 @@ +# osv-scanner suppression baseline for openplc-runtime = our VEX "not affected" +# decisions from the SBOM & Vulnerability Report. Only advisories triaged as not +# reachable are suppressed: click/python-dotenv (vulnerable function never called) +# and the pytest/pre-commit/venv developer tooling. The live Flask runtime stack +# is left UNSUPPRESSED so a new advisory there surfaces for human review (no AI +# triage on this public repo). SOEM (C, EtherCAT) is a submodule tracked apart. +# +# Regenerated from a live osv-scanner scan of the CycloneDX SBOM. +# Each entry expires (ignoreUntil) so suppressions are re-reviewed quarterly. +# Full rationale: security//OpenPLC-Runtime-Security-Report.md. + +[[IgnoredVulns]] +id = "GHSA-58qw-9mgm-455v" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [pip] — see security report" + +[[IgnoredVulns]] +id = "GHSA-5rjg-fvgr-3xxf" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [setuptools] — see security report" + +[[IgnoredVulns]] +id = "GHSA-6w46-j5rx-g56g" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: test/developer tooling (pytest/pre-commit/virtualenv subtree), not part of the running server [pytest] — see security report" + +[[IgnoredVulns]] +id = "GHSA-cx63-2mw6-8hw5" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [setuptools] — see security report" + +[[IgnoredVulns]] +id = "GHSA-h35f-9h28-mq5c" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [setuptools] — see security report" + +[[IgnoredVulns]] +id = "GHSA-jp4c-xjxw-mgf9" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [pip] — see security report" + +[[IgnoredVulns]] +id = "GHSA-mf9w-mj56-hr94" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [vulnerable_code_not_in_execute_path]: the running Flask server never invokes the vulnerable function (click.edit is never called; only load_dotenv read is used, not set_key/unset_key) [python-dotenv] — see security report" + +[[IgnoredVulns]] +id = "GHSA-qmgc-5h2g-mvrw" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: test/developer tooling (pytest/pre-commit/virtualenv subtree), not part of the running server [filelock] — see security report" + +[[IgnoredVulns]] +id = "GHSA-r9hx-vwmv-q579" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [setuptools] — see security report" + +[[IgnoredVulns]] +id = "GHSA-w853-jp5j-5j7f" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: test/developer tooling (pytest/pre-commit/virtualenv subtree), not part of the running server [filelock] — see security report" + +[[IgnoredVulns]] +id = "GHSA-wf93-45jw-7689" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [pip] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2022-43012" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [setuptools] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2025-49" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [setuptools] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-1374" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: test/developer tooling (pytest/pre-commit/virtualenv subtree), not part of the running server [filelock] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-1375" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: test/developer tooling (pytest/pre-commit/virtualenv subtree), not part of the running server [filelock] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-1845" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: test/developer tooling (pytest/pre-commit/virtualenv subtree), not part of the running server [pytest] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-1918" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [setuptools] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-196" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [pip] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-2132" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [vulnerable_code_not_in_execute_path]: the running Flask server never invokes the vulnerable function (click.edit is never called; only load_dotenv read is used, not set_key/unset_key) [click] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-2270" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [vulnerable_code_not_in_execute_path]: the running Flask server never invokes the vulnerable function (click.edit is never called; only load_dotenv read is used, not set_key/unset_key) [python-dotenv] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-2875" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [pip] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-2876" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [pip] — see security report" + +[[IgnoredVulns]] +id = "PYSEC-2026-3447" +ignoreUntil = "2026-10-01T00:00:00Z" +reason = "VEX not_affected [component_not_present]: Python venv bootstrap tooling, not a runtime dependency of the deployed server [setuptools] — see security report" diff --git a/scripts/build-report.mjs b/scripts/build-report.mjs new file mode 100644 index 0000000..3a16031 --- /dev/null +++ b/scripts/build-report.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// build-report.mjs — render the Security Report (Markdown + HTML) deterministically +// from structured data, so the monthly output is IDENTICAL in shape every run and +// never drifts from the SBOM. +// +// node scripts/build-report.mjs \ +// --config security/report-config.json \ +// --cdx sbom/.cdx.json \ +// --out security/ \ +// --date 2026-08 +// +// Component count and license distribution are computed live from the CycloneDX +// SBOM; everything else (VEX triage, narrative) comes from the config, which is +// what Claude updates when a new advisory appears. + +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; + +const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, arr) => { + if (v.startsWith('--')) a.push([v.slice(2), arr[i + 1]]); + return a; +}, [])); +const cfg = JSON.parse(readFileSync(args.config, 'utf8')); +const cdx = JSON.parse(readFileSync(args.cdx, 'utf8')); +const date = args.date || 'unknown'; +const outDir = args.out || '.'; +mkdirSync(outDir, { recursive: true }); + +// --- live metrics from the SBOM --- +const comps = cdx.components || []; +const componentCount = comps.length; +const licAgg = {}; +for (const c of comps) { + for (const l of (c.licenses || [])) { + const id = l.license?.id || l.expression || l.license?.name || 'Unlicensed'; + licAgg[id] = (licAgg[id] || 0) + 1; + } +} +const topLicenses = Object.entries(licAgg).sort((a, b) => b[1] - a[1]).slice(0, 8); + +const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); +const rich = (s) => String(s ?? ''); // config strings may contain / — keep as-is in HTML +const stripTags = (s) => String(s ?? '').replace(/<[^>]+>/g, ''); + +// ========================= MARKDOWN ========================= +const md = []; +md.push(`# ${cfg.title} — Software Supply Chain Security Report (SBOM & VEX)\n`); +md.push('| | |'); +md.push('|---|---|'); +md.push(`| **Product** | ${cfg.title} (\`${cfg.product}\`) — ${cfg.subtitle} · v${cfg.version} |`); +md.push(`| **Report type** | Software Bill of Materials (SBOM) & Vulnerability Exploitability eXchange (VEX) |`); +md.push(`| **Assessment date** | ${date} · Report version 1.0 |`); +md.push(`| **Prepared by** | Autonomy Logic — Engineering / Product Security |`); +md.push(`| **Classification** | Confidential |`); +md.push(`| **Security contact** | ${cfg.securityContact} |`); +md.push('\n---\n'); +md.push('## Executive Summary\n'); +md.push(`This report documents the third-party software composition and known-vulnerability posture of **${cfg.title}**. It is aligned with U.S. Executive Order 14028, the NTIA *Minimum Elements for an SBOM*, and the CISA Vulnerability Exploitability eXchange (VEX) guidance.\n`); +md.push(`> **Headline posture.** ${stripTags(cfg.headline)}\n`); +md.push('### Key metrics\n'); +md.push('| Metric | Value |'); +md.push('|---|---|'); +md.push(`| Components inventoried (full transitive graph) | **${componentCount}** |`); +md.push(`| Raw advisories detected | ${cfg.advisories.total} (${cfg.advisories.critical} critical · ${cfg.advisories.high} high · ${cfg.advisories.moderate} moderate · ${cfg.advisories.low} low) |`); +md.push(`| **AFFECTED — action required** | **${cfg.counts.affected.n}** (${cfg.counts.affected.sev}) |`); +md.push(`| AFFECTED — mitigating controls in place | ${cfg.counts.mitigated.n} (${cfg.counts.mitigated.sev}) |`); +md.push(`| **NOT AFFECTED** | **${cfg.counts.notAffected.n}** (${cfg.counts.notAffected.pct}) |`); +md.push('\n## 1. Scope & System Description\n'); +md.push(stripTags(cfg.scope) + '\n'); +md.push(`> **Scope note.** ${stripTags(cfg.scopeNote)}\n`); +md.push('## 2. Methodology\n'); +md.push(stripTags(cfg.methodologyNote) + ' For each relevant package, source code was analyzed to determine whether the vulnerable code path is invoked and whether its input is attacker-controlled.\n'); +md.push('## 3. Software Bill of Materials Summary\n'); +md.push('| License | Components |'); +md.push('|---|---|'); +for (const [l, n] of topLicenses) md.push(`| ${l} | ${n} |`); +md.push(`\n**License finding:** ${stripTags(cfg.licenseNote)}\n`); +md.push('## 4. Findings Requiring Remediation (Affected)\n'); +if (cfg.affected.length) { + md.push('| Priority | Component | Installed | Fixed in | Severity | Reachability rationale |'); + md.push('|---|---|---|---|---|---|'); + for (const f of cfg.affected) md.push(`| ${f.priority} | \`${f.component}\` | ${f.installed} | ${f.fixedIn} | ${f.severity} | ${stripTags(f.rationale)} |`); +} else md.push('**None.**'); +md.push('\n## 5. Not Affected — VEX Justifications\n'); +md.push('| VEX justification (CISA) | Count | Representative components | Basis |'); +md.push('|---|---|---|---|'); +for (const n of cfg.notAffected) md.push(`| \`${n.justification}\` | ${n.count} | ${stripTags(n.components)} | ${stripTags(n.basis)} |`); +if (cfg.criticalNote) md.push(`\n**On critical severity.** ${stripTags(cfg.criticalNote)}\n`); +md.push('\n## 6. Mitigated Findings\n'); +md.push('| Component | Advisories | Existing control |'); +md.push('|---|---|---|'); +for (const m of cfg.mitigated) md.push(`| \`${m.component}\` | ${m.advisories} | ${stripTags(m.control)} |`); +md.push('\n## 7. Remediation Plan\n'); +for (const r of cfg.remediation) md.push(`- ${stripTags(r)}`); +md.push('\n## 8. Secure Development & Supply-Chain Practices\n'); +md.push('| Practice | Status |'); +md.push('|---|---|'); +for (const p of cfg.practices) md.push(`| ${p.practice} | ${p.status} |`); +md.push('\n## 9. Attached Artifacts\n'); +md.push('| Artifact | Format | Purpose |'); +md.push('|---|---|---|'); +md.push(`| \`sbom/${cfg.sbomBasename}.cdx.json\` | CycloneDX 1.6 | Canonical machine-readable SBOM |`); +md.push(`| \`sbom/${cfg.sbomBasename}.spdx.json\` | SPDX 2.3 (ISO/IEC 5962) | Procurement / compliance SBOM |`); +md.push(`| \`sbom/${cfg.sbomBasename}.components.csv\` | CSV | Human-readable component inventory (${componentCount} rows) |`); +md.push(`| \`sbom/vulnerabilities.csv\` | CSV | Full annotated advisory register (${cfg.advisories.total} rows) |`); +md.push(`\n---\n\n*Prepared by Autonomy Logic Engineering. Assessment date ${date}. Regenerate per release.*\n`); +const mdOut = md.join('\n'); + +// ========================= HTML ========================= +const sevBadge = (s) => s; // severity strings already human +const row = (cells) => `${cells.map((c) => `${c}`).join('')}`; +const th = (cells) => `${cells.map((c) => `${c}`).join('')}`; +const badge = (txt, cls) => `${txt}`; + +const html = ` + +${esc(cfg.title)} — Security Report (SBOM & VEX) + +

${esc(cfg.title)}

+

Software Supply Chain Security Report — SBOM & VEX

+ +${row(['Product', `${esc(cfg.title)} (${esc(cfg.product)}) — ${esc(cfg.subtitle)} · v${esc(cfg.version)}`])} +${row(['Report type', 'Software Bill of Materials (SBOM) & Vulnerability Exploitability eXchange (VEX)'])} +${row(['Assessment date', `${esc(date)} · Report version 1.0`])} +${row(['Prepared by', 'Autonomy Logic — Engineering / Product Security'])} +${row(['Classification', 'Confidential'])} +${row(['Security contact', esc(cfg.securityContact)])} +
+

Executive Summary

+

This report documents the third-party software composition and known-vulnerability posture of ${esc(cfg.title)}. It is aligned with U.S. Executive Order 14028, the NTIA Minimum Elements for an SBOM, and the CISA VEX guidance.

+
Headline posture. ${rich(cfg.headline)}
+

Key metrics

+${th(['Metric', 'Value'])} +${row(['Components inventoried (full transitive graph)', `${componentCount}`])} +${row(['Raw advisories detected', `${cfg.advisories.total} (${cfg.advisories.critical} critical · ${cfg.advisories.high} high · ${cfg.advisories.moderate} moderate · ${cfg.advisories.low} low)`])} +${row([badge('AFFECTED — action required', 'b-red'), `${cfg.counts.affected.n} (${cfg.counts.affected.sev})`])} +${row([badge('AFFECTED — mitigated', 'b-amber'), `${cfg.counts.mitigated.n} (${cfg.counts.mitigated.sev})`])} +${row([badge('NOT AFFECTED', 'b-green'), `${cfg.counts.notAffected.n} (${cfg.counts.notAffected.pct})`])} +
+

1. Scope & System Description

${rich(cfg.scope)}

+
Scope note. ${rich(cfg.scopeNote)}
+

2. Methodology

${rich(cfg.methodologyNote)} For each relevant package, source code was analyzed to determine whether the vulnerable code path is invoked and whether its input is attacker-controlled.

+

3. Software Bill of Materials Summary

+${th(['License', 'Components'])} +${topLicenses.map(([l, n]) => row([esc(l), String(n)])).join('\n')} +
+
License finding: ${rich(cfg.licenseNote)}
+

4. Findings Requiring Remediation (Affected)

+${cfg.affected.length ? `${th(['Priority', 'Component', 'Installed', 'Fixed in', 'Severity', 'Reachability rationale'])} +${cfg.affected.map((f) => row([f.priority, `${esc(f.component)}`, esc(f.installed), esc(f.fixedIn), f.severity, rich(f.rationale)])).join('\n')} +
` : '
None.
'} +

5. Not Affected — VEX Justifications

+${th(['VEX justification (CISA)', 'Count', 'Representative components', 'Basis'])} +${cfg.notAffected.map((n) => row([`${esc(n.justification)}`, n.count, rich(n.components), rich(n.basis)])).join('\n')} +
+${cfg.criticalNote ? `
On critical severity. ${rich(cfg.criticalNote)}
` : ''} +

6. Mitigated Findings

+${th(['Component', 'Advisories', 'Existing control'])} +${cfg.mitigated.map((m) => row([`${esc(m.component)}`, m.advisories, rich(m.control)])).join('\n')} +
+

7. Remediation Plan

    ${cfg.remediation.map((r) => `
  • ${rich(r)}
  • `).join('')}
+

8. Secure Development & Supply-Chain Practices

+${th(['Practice', 'Status'])} +${cfg.practices.map((p) => row([esc(p.practice), p.status === 'In place' ? badge('In place', 'b-green') : badge('Recommended', 'b-amber')])).join('\n')} +
+

9. Attached Artifacts

+${th(['Artifact', 'Format', 'Purpose'])} +${row([`sbom/${cfg.sbomBasename}.cdx.json`, 'CycloneDX 1.6', 'Canonical machine-readable SBOM'])} +${row([`sbom/${cfg.sbomBasename}.spdx.json`, 'SPDX 2.3 (ISO/IEC 5962)', 'Procurement / compliance SBOM'])} +${row([`sbom/${cfg.sbomBasename}.components.csv`, 'CSV', `Human-readable component inventory (${componentCount} rows)`])} +${row(['sbom/vulnerabilities.csv', 'CSV', `Full annotated advisory register (${cfg.advisories.total} rows)`])} +
+

Prepared by Autonomy Logic Engineering. Assessment date ${esc(date)}. Regenerate per release.

+`; + +const base = `${cfg.title.replace(/[^A-Za-z0-9]+/g, '-')}-Security-Report`; +writeFileSync(`${outDir}/${base}.md`, mdOut); +writeFileSync(`${outDir}/${base}.html`, html); +console.log(`Wrote ${outDir}/${base}.md and .html (${componentCount} components, ${topLicenses.length} license classes)`); diff --git a/scripts/cdx-to-spdx.mjs b/scripts/cdx-to-spdx.mjs new file mode 100644 index 0000000..926b252 --- /dev/null +++ b/scripts/cdx-to-spdx.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Convert a CycloneDX 1.6 BOM (produced by cdxgen) into a valid SPDX 2.3 JSON document. +// Keeps NTIA minimum elements: supplier, name, version, unique id (purl), relationships, author, timestamp. +import { readFileSync, writeFileSync } from 'node:fs'; + +const SRC = process.argv[2] || 'sbom/bom.cdx.json'; +const OUT = process.argv[3] || 'sbom/bom.spdx.json'; + +const cdx = JSON.parse(readFileSync(SRC, 'utf8')); +const created = cdx.metadata?.timestamp || new Date().toISOString(); + +// The actual SBOM generator(s), read from the CycloneDX metadata (cdxgen, +// cyclonedx-py, …) — never hardcoded. +const toolComps = cdx.metadata?.tools?.components + || (Array.isArray(cdx.metadata?.tools) ? cdx.metadata.tools : []); +const toolCreators = toolComps + .filter((t) => t && t.name) + .map((t) => `Tool: ${t.name}${t.version ? '-' + t.version : ''}`); + +// Flatten root + nested workspace components into the package list. +const comps = []; +const walk = (c) => { + if (!c) return; + comps.push(c); + (c.components || []).forEach(walk); +}; +walk(cdx.metadata?.component); +(cdx.components || []).forEach((c) => comps.push(c)); + +// Stable SPDXID per bom-ref/purl. +const idFor = new Map(); +let n = 0; +const spdxId = (ref) => { + if (idFor.has(ref)) return idFor.get(ref); + const safe = String(ref).replace(/[^a-zA-Z0-9.-]/g, '-').replace(/-+/g, '-'); + const id = `SPDXRef-Pkg-${++n}-${safe}`.slice(0, 200); + idFor.set(ref, id); + return id; +}; + +const licenseExpr = (c) => { + const ls = c.licenses || []; + if (!ls.length) return 'NOASSERTION'; + const parts = ls + .map((l) => l.license?.id || l.expression || null) + .filter(Boolean); + if (!parts.length) return 'NOASSERTION'; + const expr = parts.length === 1 ? parts[0] : parts.join(' AND '); + // Non-SPDX placeholders -> NOASSERTION + if (/SEE LICENSE|UNLICENSED|UNKNOWN/i.test(expr)) return 'NOASSERTION'; + return expr; +}; + +const packages = comps.map((c) => { + const ref = c['bom-ref'] || c.purl || `${c.group || ''}/${c.name}@${c.version || ''}`; + const pkg = { + name: (c.group ? `${c.group}/` : '') + c.name, + SPDXID: spdxId(ref), + versionInfo: c.version || 'NOASSERTION', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'NOASSERTION', + licenseDeclared: licenseExpr(c), + copyrightText: 'NOASSERTION', + supplier: c.publisher ? `Organization: ${c.publisher}` : 'NOASSERTION', + }; + if (c.purl) { + pkg.externalRefs = [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: c.purl, + }, + ]; + } + return pkg; +}); + +// Relationships: document DESCRIBES root; root/deps DEPENDS_ON edges from cdx.dependencies. +const rootRef = cdx.metadata?.component?.['bom-ref']; +const relationships = []; +if (rootRef && idFor.has(rootRef)) { + relationships.push({ + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: idFor.get(rootRef), + }); +} +for (const dep of cdx.dependencies || []) { + const from = idFor.get(dep.ref); + if (!from) continue; + for (const to of dep.dependsOn || []) { + const t = idFor.get(to); + if (!t) continue; + relationships.push({ + spdxElementId: from, + relationshipType: 'DEPENDS_ON', + relatedSpdxElement: t, + }); + } +} + +// Derive the document name/namespace from THIS SBOM's root component — never +// hardcode a product name (that mislabels every other product's SPDX). +const rootName = (cdx.metadata?.component?.name || 'unknown-project').replace(/[^a-zA-Z0-9._-]/g, '-'); +const serial = (cdx.serialNumber || `urn:uuid:${rootName}`).replace('urn:uuid:', ''); +const doc = { + spdxVersion: 'SPDX-2.3', + dataLicense: 'CC0-1.0', + SPDXID: 'SPDXRef-DOCUMENT', + name: `${rootName}-SBOM`, + documentNamespace: `https://autonomylogic.com/spdx/${rootName}/${serial}`, + creationInfo: { + created, + // Derive the generator tool(s) from the source CycloneDX metadata instead of + // hardcoding (which would mis-attribute e.g. cyclonedx-py SBOMs to cdxgen). + creators: [ + 'Organization: Autonomy Logic', + ...toolCreators, + 'Tool: cdx-to-spdx', + ], + }, + packages, + relationships, +}; + +writeFileSync(OUT, JSON.stringify(doc, null, 2)); +console.log(`Wrote ${OUT}: ${packages.length} packages, ${relationships.length} relationships`); diff --git a/scripts/gen-osv-ignores.mjs b/scripts/gen-osv-ignores.mjs new file mode 100644 index 0000000..27ab502 --- /dev/null +++ b/scripts/gen-osv-ignores.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Generate osv-scanner.toml [[IgnoredVulns]] entries from a triaged +// vulnerabilities.csv. Rows classified "not affected" / "mitigated" (our VEX +// baseline) become suppressions so the monthly scan only surfaces NEW findings. +// Rows that require action (APLICA / affected) are intentionally NOT suppressed +// — they keep alerting until the dependency is bumped. +// +// Usage: +// node scripts/gen-osv-ignores.mjs sbom/vulnerabilities.csv 2026-10-01 >> osv-scanner.toml +// arg1 = CSV path +// arg2 = review-by date (YYYY-MM-DD) written as ignoreUntil +// +// The CSV must contain an advisory-id column (named cve / advisory / id) whose +// cells hold OSV ids (GHSA-*, CVE-*, PYSEC-*, possibly pipe-separated). If a +// verdict column is present (verdict / verdict_vex / reach), only not-affected +// rows are suppressed; otherwise every listed id is suppressed (review!). + +import { readFileSync } from 'node:fs'; + +const [csvPath, reviewDate = '1970-01-01'] = process.argv.slice(2); +if (!csvPath) { console.error('usage: gen-osv-ignores.mjs '); process.exit(1); } + +const rows = readFileSync(csvPath, 'utf8').trim().split('\n').map(parseCsvLine); +const header = rows.shift().map((h) => h.toLowerCase()); +const idCol = header.findIndex((h) => ['cve', 'advisory', 'id', 'advisory_id'].includes(h)); +const verdictCol = header.findIndex((h) => ['verdict', 'verdict_vex'].includes(h)); +const reasonCol = header.findIndex((h) => ['description', 'title', 'basis', 'verdict_vex', 'verdict'].includes(h)); +if (idCol < 0) { console.error('No advisory-id column (cve/advisory/id) found'); process.exit(1); } + +const isNotAffected = (v) => /not[_ ]?affected|mitig|\bn\/?a\b/i.test(v || ''); +const seen = new Set(); +let emitted = 0; + +for (const r of rows) { + const verdict = verdictCol >= 0 ? r[verdictCol] : ''; + if (verdictCol >= 0 && !isNotAffected(verdict)) continue; // keep actionable ones visible + const ids = String(r[idCol] || '').split('|').map((s) => s.trim()).filter((s) => /^(GHSA|CVE|PYSEC)-/i.test(s)); + const reason = (reasonCol >= 0 ? r[reasonCol] : verdict) || 'triaged not-affected'; + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + console.log(`\n[[IgnoredVulns]]`); + console.log(`id = "${id}"`); + console.log(`ignoreUntil = "${reviewDate}T00:00:00Z"`); + console.log(`reason = ${JSON.stringify('VEX not_affected: ' + reason.replace(/\s+/g, ' ').slice(0, 160))}`); + emitted++; + } +} +console.error(`Emitted ${emitted} ignore entries from ${rows.length} rows.`); + +function parseCsvLine(line) { + const out = []; let cur = '', q = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (q) { if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; } else if (c === '"') q = false; else cur += c; } + else { if (c === '"') q = true; else if (c === ',') { out.push(cur); cur = ''; } else cur += c; } + } + out.push(cur); return out; +} diff --git a/scripts/generate-sbom.sh b/scripts/generate-sbom.sh new file mode 100755 index 0000000..0f8e4e0 --- /dev/null +++ b/scripts/generate-sbom.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# generate-sbom.sh (Python variant) — Reproducible SBOM for a Python service. +# +# requirements.txt here is UNPINNED, so we resolve it into a CLEAN runtime venv +# and snapshot the fully-resolved environment. cyclonedx-py runs from a SEPARATE +# tool venv pointed at the clean one, so the SBOM captures ONLY the app's real +# dependency tree (tooling never contaminates the resolved set). +# +# Produces under ./sbom/: .cdx.json (CycloneDX 1.6) · .spdx.json +# (SPDX 2.3) · .components.csv. Name comes from security/report-config.json +# so this is package-manager-agnostic. +# +# Env: SBOM_PIP_ARGS lets a repo force wheels for native deps, e.g. +# SBOM_PIP_ARGS="--only-binary av" (aiortc/PyAV). + +set -euo pipefail +cd "$(dirname "$0")/.." + +NAME="$(node -p "require('./security/report-config.json').sbomBasename")" +OUT="sbom" +mkdir -p "$OUT" +PIP_ARGS="${SBOM_PIP_ARGS:-}" + +VENV=".sbom-venv" # clean runtime env (app deps only) +TOOLS=".sbom-tools" # cyclonedx-py, isolated from the runtime env +trap 'rm -rf "$VENV" "$TOOLS"' EXIT + +echo "==> Resolving requirements into a clean venv for ${NAME}" +python3 -m venv "$VENV" +"$VENV/bin/pip" install --quiet --disable-pip-version-check --upgrade pip +# shellcheck disable=SC2086 +"$VENV/bin/pip" install --quiet --disable-pip-version-check $PIP_ARGS -r requirements.txt + +echo "==> Generating CycloneDX 1.6 SBOM (cyclonedx-py, from the clean env)" +python3 -m venv "$TOOLS" +"$TOOLS/bin/pip" install --quiet --disable-pip-version-check --upgrade pip cyclonedx-bom +"$TOOLS/bin/cyclonedx-py" environment "$VENV" \ + --output-format JSON \ + --spec-version 1.6 \ + --output-file "$OUT/$NAME.cdx.json" + +echo "==> Converting to SPDX 2.3" +node scripts/cdx-to-spdx.mjs "$OUT/$NAME.cdx.json" "$OUT/$NAME.spdx.json" + +echo "==> Emitting flattened CSV" +NAME="$NAME" OUT="$OUT" node -e ' +const fs=require("fs"); +const name=process.env.NAME, out=process.env.OUT; +const b=require(`./${out}/${name}.cdx.json`); +const esc=s=>{s=String(s==null?"":s);return /[",\n]/.test(s)?"\""+s.replace(/"/g,"\"\"")+"\"":s;}; +const rows=[["name","version","type","purl","license"]]; +for(const c of (b.components||[])){ + const lic=(c.licenses||[]).map(l=>l.license?.id||l.expression||l.license?.name||"").join(" / "); + rows.push([(c.group?c.group+"/":"")+c.name,c.version||"",c.type||"",c.purl||"",lic]); +} +fs.writeFileSync(`${out}/${name}.components.csv`,rows.map(r=>r.map(esc).join(",")).join("\n")); +console.log(`Wrote ${out}/${name}.components.csv:`,rows.length-1,"components"); +' + +echo "==> Done. Artifacts in ./$OUT/" +ls -la "$OUT" diff --git a/scripts/pr-gate-diff.mjs b/scripts/pr-gate-diff.mjs new file mode 100644 index 0000000..8bec73c --- /dev/null +++ b/scripts/pr-gate-diff.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// pr-gate-diff.mjs — the PR security gate's decision. +// node scripts/pr-gate-diff.mjs [threshold] +// Both inputs are osv-scanner JSON outputs produced WITH --config=osv-scanner.toml +// (so the VEX baseline is already applied). We block ONLY on advisories that the +// PR INTRODUCES — present in head, absent in base — at or above +// (default HIGH). Pre-existing advisories never block. Exit 1 = block, 0 = pass. +// +// Side effect: writes a Markdown body to $GATE_COMMENT_FILE (default +// /tmp/gate-comment.md) and a one-word status (block|warn|clean) to +// $GATE_STATUS_FILE (default /tmp/gate-status), so the workflow can post a PR +// comment. The full detail is also printed to stdout (the check log). + +import { readFileSync, writeFileSync } from 'node:fs'; + +const [baseP, headP, thresholdArg] = process.argv.slice(2); +const THRESHOLD = (thresholdArg || 'HIGH').toUpperCase(); +const ORDER = ['LOW', 'MODERATE', 'HIGH', 'CRITICAL']; +const COMMENT_FILE = process.env.GATE_COMMENT_FILE || '/tmp/gate-comment.md'; +const STATUS_FILE = process.env.GATE_STATUS_FILE || '/tmp/gate-status'; +const MARKER = ''; + +const load = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return {}; } }; + +// --- CVSS v3.x base-score computation (for entries that carry only a vector) --- +function cvss3Base(vector) { + const m = {}; + for (const kv of vector.split('/')) { const [k, v] = kv.split(':'); if (k && v) m[k] = v; } + const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[m.AV]; + const AC = { L: 0.77, H: 0.44 }[m.AC]; + const UI = { N: 0.85, R: 0.62 }[m.UI]; + const scopeChanged = m.S === 'C'; + const PR = (scopeChanged ? { N: 0.85, L: 0.68, H: 0.5 } : { N: 0.85, L: 0.62, H: 0.27 })[m.PR]; + const imp = { N: 0, L: 0.22, H: 0.56 }; + const C = imp[m.C], I = imp[m.I], A = imp[m.A]; + if ([AV, AC, UI, PR, C, I, A].some((x) => x === undefined)) return null; + const iss = 1 - (1 - C) * (1 - I) * (1 - A); + const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss; + if (impact <= 0) return 0; + const expl = 8.22 * AV * AC * PR * UI; + const roundup = (x) => Math.ceil(x * 10) / 10; + return roundup(Math.min((scopeChanged ? 1.08 : 1) * (impact + expl), 10)); +} +const bucketFromScore = (s) => (s >= 9 ? 'CRITICAL' : s >= 7 ? 'HIGH' : s >= 4 ? 'MODERATE' : s > 0 ? 'LOW' : 'LOW'); + +function severityOf(v) { + const word = (v.database_specific?.severity || '').toUpperCase(); + if (ORDER.includes(word)) return word === 'MEDIUM' ? 'MODERATE' : word; + for (const s of (v.severity || [])) { + if (typeof s.score === 'string' && s.score.startsWith('CVSS:3')) { + const sc = cvss3Base(s.score); + if (sc != null) return bucketFromScore(sc); + } + } + return 'HIGH'; // conservative: an unscored NEW advisory should surface, not slip through +} + +function fixedOf(v) { + const fixes = [...new Set((v.affected || []).flatMap((a) => (a.ranges || []) + .flatMap((r) => (r.events || []).filter((e) => e.fixed).map((e) => e.fixed))))]; + return fixes.join(', '); +} + +function index(data) { + const m = new Map(); + for (const r of (data.results || [])) for (const p of (r.packages || [])) for (const v of (p.vulnerabilities || [])) { + if (!v.id || m.has(v.id)) continue; + m.set(v.id, { sev: severityOf(v), pkg: p.package?.name || '?', ver: p.package?.version || '', fixed: fixedOf(v), summary: (v.summary || '').slice(0, 120) }); + } + return m; +} + +const base = index(load(baseP)); +const head = index(load(headP)); +const min = ORDER.indexOf(THRESHOLD); + +const introduced = [...head.entries()].filter(([id]) => !base.has(id)) + .map(([id, d]) => ({ id, ...d })) + .sort((a, b) => ORDER.indexOf(b.sev) - ORDER.indexOf(a.sev)); +const blocking = introduced.filter((a) => ORDER.indexOf(a.sev) >= min); + +// ---- stdout (the check log — full detail) ---- +if (introduced.length) { + console.log(`\nAdvisories introduced by this PR (${introduced.length}):`); + for (const a of introduced) console.log(` [${a.sev}] ${a.pkg}@${a.ver} ${a.id} ${a.summary}`); +} else { + console.log('No new advisories introduced by this PR.'); +} + +// ---- PR comment body ---- +const advLink = (id) => (id.startsWith('GHSA') ? `https://github.com/advisories/${id}` : `https://osv.dev/${id}`); +const rows = introduced.map((a) => `| ${a.sev} | \`${a.pkg}\` | ${a.ver} | [${a.id}](${advLink(a.id)}) | ${a.fixed || '—'} |`).join('\n'); +const table = introduced.length + ? `| Severity | Package | Version | Advisory | Fixed in |\n|---|---|---|---|---|\n${rows}` + : ''; +let status, body; +if (blocking.length) { + status = 'block'; + body = `${MARKER}\n## 🔴 Security gate — blocked\nThis PR introduces **${blocking.length}** new advisory(ies) at or above **${THRESHOLD}**, so the merge is blocked.\n\n${table}\n\n**How to unblock:**\n1. Upgrade the dependency to a fixed version (see *Fixed in*), or\n2. Remove the dependency, or\n3. If it is not exploitable in our usage, add a justified \`[[IgnoredVulns]]\` entry (CISA VEX reason) to \`osv-scanner.toml\`.\n\n_Only High/Critical block; Moderate/Low are shown for awareness. Full detail in the check log._`; +} else if (introduced.length) { + status = 'warn'; + body = `${MARKER}\n## 🟡 Security gate — passed (with notes)\nThis PR introduces new advisories, but none at or above **${THRESHOLD}**, so it does **not** block. Consider addressing them as hygiene.\n\n${table}\n\n_Full detail in the check log._`; +} else { + status = 'clean'; + body = `${MARKER}\n## ✅ Security gate — no new vulnerabilities\nThis PR does not introduce any new advisory versus the base branch.`; +} +writeFileSync(COMMENT_FILE, body); +writeFileSync(STATUS_FILE, status); + +// ---- verdict ---- +if (blocking.length) { + console.error(`\n❌ BLOCKED: this PR introduces ${blocking.length} new ${THRESHOLD}+ advisory(ies). Fix or remove the dependency, or add a justified suppression to osv-scanner.toml.`); + process.exit(1); +} +console.log(`\n✅ PASS: no new ${THRESHOLD}+ advisories introduced.`); diff --git a/scripts/scan-vulns.mjs b/scripts/scan-vulns.mjs new file mode 100644 index 0000000..ff5269e --- /dev/null +++ b/scripts/scan-vulns.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// scan-vulns.mjs — flatten osv-scanner JSON into the annotated vulnerabilities.csv. +// node scripts/scan-vulns.mjs +// osv-scanner already honored osv-scanner.toml, so anything here that is NOT in the +// suppression baseline is, by definition, part of the monthly "delta". + +import { readFileSync, writeFileSync } from 'node:fs'; +const [src, out = 'sbom/vulnerabilities.csv'] = process.argv.slice(2); +let data = {}; +try { data = JSON.parse(readFileSync(src, 'utf8')); } catch { data = {}; } + +const esc = (s) => { s = String(s ?? ''); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; +const sevOf = (v) => (v.database_specific?.severity) + || (Array.isArray(v.severity) && v.severity[0]?.score) || ''; +const rows = [['severity', 'package', 'version', 'advisory', 'fixed_in', 'summary', 'url']]; +for (const res of (data.results || [])) { + for (const p of (res.packages || [])) { + for (const v of (p.vulnerabilities || [])) { + const fixed = [...new Set((v.affected || []).flatMap((a) => (a.ranges || []) + .flatMap((r) => (r.events || []).filter((e) => e.fixed).map((e) => e.fixed))))].join(' | '); + rows.push([sevOf(v), p.package?.name || '', p.package?.version || '', v.id || '', + fixed, (v.summary || '').slice(0, 140), `https://osv.dev/${v.id}`]); + } + } +} +writeFileSync(out, rows.map((r) => r.map(esc).join(',')).join('\n')); +console.log(`Wrote ${out}: ${rows.length - 1} advisories (post-suppression delta).`); diff --git a/security/README.md b/security/README.md new file mode 100644 index 0000000..6aac261 --- /dev/null +++ b/security/README.md @@ -0,0 +1,52 @@ +# Security — SBOM & Vulnerability reports (automated, monthly) + +This folder holds this repository's supply-chain security deliverable: a **dated +snapshot per month** with the SBOM (CycloneDX + SPDX), the component inventory, +the annotated vulnerability register (VEX), and the human-readable report. + +``` +security/ +├── 2026-07/ ← a monthly snapshot (immutable once merged) +│ ├── 00-READ-ME-FIRST.md +│ ├── OpenPLC-Runtime-Security-Report.md / .html / .pdf +│ └── sbom/ → *.cdx.json · *.spdx.json · *.components.csv · vulnerabilities.csv +├── latest → 2026-07 ← pointer to the current month +└── report-config.json ← report DATA (VEX triage + narrative) +``` + +## How it runs + +`.github/workflows/security-monthly.yml` runs on the **1st of each month** (and on +the manual **Run workflow** button). Per run it: regenerates the SBOM, scans +dependencies with **osv-scanner** (OSV = the same advisory source as Dependabot, +honoring the suppression baseline in `../osv-scanner.toml`), has **Claude triage +any new advisory** and update the report data, renders the report from +`report-config.json`, writes `security//`, and **opens a PR** for review. + +The report is **generated from data** (`report-config.json` + the live SBOM), so +it never drifts from the actual dependency graph. + +## One-time setup — run Claude on your subscription (no API key) + +Claude runs inside the workflow on **your Claude subscription** (Pro/Max), not a +pay-per-token API key: + +1. On your machine: `claude setup-token` → complete the browser login → copy the + token it prints (`CLAUDE_CODE_OAUTH_TOKEN`). +2. In the repo: **Settings → Secrets and variables → Actions → New repository + secret** → name `CLAUDE_CODE_OAUTH_TOKEN`, value = the token. +3. Done. The monthly workflow will authenticate as you. (If the secret is absent, + the workflow still runs and builds the report deterministically — it just + skips the AI triage step.) + +> To move billing off a personal account later (e.g. an org API key), replace the +> secret with `ANTHROPIC_API_KEY` and swap the one input in the workflow — nothing +> else changes. + +## Reviewing the monthly PR + +- **No new advisory:** confirm the summary, approve, merge (the snapshot is archived). +- **New advisory, not exploitable:** check Claude's justification in the + `osv-scanner.toml` diff, then merge. +- **New advisory, exploitable:** bump the dependency (separate PR) and merge the + report that documents it. diff --git a/security/report-config.json b/security/report-config.json new file mode 100644 index 0000000..9c4eeef --- /dev/null +++ b/security/report-config.json @@ -0,0 +1,41 @@ +{ + "product": "openplc-runtime", + "title": "OpenPLC Runtime", + "subtitle": "IEC 61131-3 PLC execution engine (C/C++ core + Python web server)", + "version": "4.1.8", + "sbomBasename": "openplc-runtime", + "ecosystem": "pip", + "securityContact": "Thiago Alves — thiago.alves@autonomylogic.com", + "advisorySource": "PyPI Advisory Database / OSV via pip-audit (equivalent to Dependabot for Python)", + "advisories": { "total": 5, "critical": 0, "high": 0, "moderate": 5, "low": 0 }, + "counts": { + "affected": { "n": 0, "sev": "none require action" }, + "mitigated": { "n": 0, "sev": "—" }, + "notAffected": { "n": 5, "pct": "100%" } + }, + "headline": "No vulnerability requires remediation and none is exploitable in the product. The runtime is a hybrid C/C++ execution core plus a Python (Flask) management web server. The Python dependency surface is small; its 5 advisories are all either in test tooling or in vulnerable functions the runtime never calls. The single third-party C library (SOEM, EtherCAT) has no known vulnerability. There are no critical or high exploitable findings.", + "scope": "The OpenPLC Runtime consists of a C/C++ execution core (the real-time PLC engine and native fieldbus plugins: EtherCAT, S7comm, built with CMake) and a Python (Flask) web server for management, configuration, and monitoring (REST + WebSocket). This assessment covers both third-party dependency ecosystems: the Python packages (from requirements.txt) and the third-party C libraries linked into the core.", + "scopeNote": "The first-party C/C++ source (the PLC engine, the S7comm/OPC-UA plugins, and the STruCpp runtime library) is Autonomy Logic's own code, not third-party supply chain. The only third-party C library is SOEM (EtherCAT), tracked as a pinned git submodule and queried against OSV (no known advisory) — it is outside this npm/pip SBOM and inventoried separately. The runtime embeds the system CPython interpreter; its version tracks the deployment base image and should be kept current with security releases.", + "methodologyNote": "Python dependencies were resolved into a clean virtual environment from requirements.txt and audited against the PyPI Advisory Database / OSV (the same coverage as Dependabot for Python); test/build-only packages were separated from runtime packages. Third-party C libraries are pinned as git submodules and queried against OSV. For each advisory, source was analyzed to determine whether the specific vulnerable function is invoked and whether its input is attacker-controlled.", + "licenseNote": "The OpenPLC Runtime is MIT-licensed (first-party code); the Python dependencies are permissive (MIT/BSD/Apache/PSF, with one MPL-2.0 file-level weak-copyleft component). Material item — SOEM (EtherCAT master library) is GPLv3 or commercial (dual-licensed by RT-Labs). SOEM is vendored as a git submodule and linked into the optional EtherCAT native plugin; under the open-source option it is GPLv3, making that combined plugin subject to GPLv3 copyleft, and SOEM's license notes that commercial products likely need a purchased license. This affects only the EtherCAT plugin — the MIT core and the other plugins (Modbus, OPC-UA, S7comm) are unaffected. Recommend legal review (obtain a commercial SOEM license, comply with GPLv3 for that plugin, or ship it as a separable optional component).", + "affected": [], + "mitigated": [], + "notAffected": [ + { "justification": "vulnerable_code_not_in_execute_path", "count": "2", "components": "click 8.1.8 (PYSEC-2026-2132), python-dotenv 1.2.1 (PYSEC-2026-2270)", "basis": "click is a transitive Flask dependency; the vulnerable click.edit() (spawns $EDITOR) is never called. For python-dotenv the runtime only calls load_dotenv() (read) at startup on a local, trusted .env; the vulnerable write functions set_key/unset_key are never used." }, + { "justification": "component_not_present", "count": "3", "components": "pytest 8.4.2 (PYSEC-2026-1845), filelock 3.19.1 (PYSEC-2026-1374 / 1375)", "basis": "Test framework and developer tooling (filelock reaches the tree only through pre-commit/virtualenv). Not part of the running server; local-only vectors." } + ], + "criticalNote": "There are no critical or high exploitable findings. All 5 Python advisories are confined to test/dev tooling or to functions the running Flask server never invokes; the single third-party C library (SOEM) has no known advisory for its pinned commit.", + "remediation": [ + "No action required for exploitable vulnerabilities — all 5 advisories are not reachable in the running product and SOEM has no known advisory.", + "Hygiene: pin Python dependencies to exact versions in requirements.txt; keep the embedded CPython and base image current with security releases.", + "Legal: resolve the SOEM / EtherCAT-plugin licensing question (GPLv3-or-commercial) — material for the acquirer's IP review." + ], + "practices": [ + { "practice": "Small, auditable third-party surface (both ecosystems inventoried)", "status": "In place" }, + { "practice": "Third-party C libraries pinned as git submodules (reproducible)", "status": "In place" }, + { "practice": "Automated dependency updates (Dependabot / advisory databases)", "status": "In place" }, + { "practice": "SBOM generated per release in CycloneDX and SPDX", "status": "In place" }, + { "practice": "Pin Python dependencies to exact versions in requirements.txt", "status": "Recommended" }, + { "practice": "Resolve the SOEM / EtherCAT-plugin licensing question", "status": "Recommended" } + ] +}