Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/workflows/security-monthly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Security — monthly SBOM & VEX report

# Runs on GitHub's servers (not on anyone's laptop). Every month it regenerates
# the SBOM, scans dependencies, renders the
# report from the versioned template, drops everything into security/<YYYY-MM>/,
# and opens a PR for the team to review. Nothing is merged automatically.
#

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

concurrency:
group: security-monthly
cancel-in-progress: false

jobs:
report:
runs-on: ubuntu-latest
steps:
- name: Checkout (with submodules for vendored C libs)
uses: actions/checkout@v4
with:
submodules: recursive
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" }
- name: Enable pnpm
run: corepack enable
- uses: actions/setup-python@v5
with: { python-version: "3.12" }

# ---- deterministic: SBOM (CycloneDX + SPDX + components.csv) ----
- name: Generate SBOM
run: bash scripts/generate-sbom.sh

# ---- deterministic: vulnerability scan (OSV, honoring the VEX baseline) ----
- name: Install osv-scanner
run: |
OSV_VER=v2.4.0 # pinned; verified against the release SHA256SUMS
curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_linux_amd64" -o /tmp/osv-scanner_linux_amd64
curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_SHA256SUMS" -o /tmp/osv_SHA256SUMS
( cd /tmp && grep " osv-scanner_linux_amd64$" osv_SHA256SUMS | sha256sum -c - )
install -m 0755 /tmp/osv-scanner_linux_amd64 /usr/local/bin/osv-scanner
- name: Scan
run: |
CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml"
set +e
osv-scanner scan $CFG --recursive --format=json --output=/tmp/osv.json .
rc=$?
set -e
# osv-scanner: 0 = no vulns, 1 = vulns found. Any other code is a scanner
# failure — do NOT let scan-vulns turn it into an empty (clean) register.
if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then echo "::error::osv-scanner failed (exit $rc)"; exit 1; fi
[ -s /tmp/osv.json ] || echo '{"results":[]}' > /tmp/osv.json
node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv
# also an UNfiltered scan, so the report's headline numbers are derived
# live (raw total vs. suppressed vs. surfacing) instead of hard-coded.
set +e
osv-scanner scan --recursive --format=json --output=/tmp/osv-raw.json .
rc2=$?
set -e
if [ "$rc2" != "0" ] && [ "$rc2" != "1" ]; then echo "::error::osv-scanner (raw) failed (exit $rc2)"; exit 1; fi
[ -s /tmp/osv-raw.json ] || echo '{"results":[]}' > /tmp/osv-raw.json

- name: Build report (md + html)
run: |
NAME=$(node -p "require('./package.json').name")
node scripts/build-report.mjs \
--config security/report-config.json \
--cdx "sbom/$NAME.cdx.json" \
--osv-raw /tmp/osv-raw.json \
--osv-delta /tmp/osv.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('./package.json').name")
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.
# --disable-javascript: the report HTML is a static document written from
# report-config.json (AI-authored) — no JS should ever run while rendering
# it with local file:// access. Defense-in-depth on top of the HTML escaping.
"${{ steps.chrome.outputs.chrome-path }}" --headless=new --no-sandbox --disable-dev-shm-usage \
--disable-javascript --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:
# A PR opened with the default GITHUB_TOKEN does NOT trigger other
# workflows (so the gate/lint/tests would never run on the monthly PR).
# Set a SECURITY_BOT_TOKEN secret (a GitHub App installation token or a
# fine-grained PAT with contents+PR write) to make checks run; it falls
# back to GITHUB_TOKEN if unset (PR still opens, just without checks).
token: ${{ secrets.SECURITY_BOT_TOKEN || github.token }}
base: development
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 the current lockfile.
- Dependencies scanned against OSV (same source as Dependabot), honoring `osv-scanner.toml` (the VEX baseline).
- New advisories (if any) surface in `vulnerabilities.csv` for human review (no AI triage on this public repo).

Nothing is merged automatically. Approve to archive this month's snapshot.
88 changes: 88 additions & 0 deletions .github/workflows/security-pr-archive.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Security — archive SBOM on merge

# Runs when a commit lands on the default branch (a PR merged, or a direct push).
# It regenerates the SBOM + raw scan for that exact state and stores them on a
# dedicated ORPHAN branch `security-archive` under <id>-<date>/, giving a
# permanent per-merge supply-chain trail WITHOUT bloating the code branch's
# history or slowing clones.
#
# Why `push` (not pull_request): the push event's GITHUB_TOKEN is always writable
# — so this also works for merged fork PRs — and it only fires for the default
# branch. We archive the SBOM + raw scan output only (no rendered "report"), so
# there is never a stale/false attestation committed anywhere.

on:
push:
branches: [development]

permissions:
contents: write

concurrency:
group: security-archive
cancel-in-progress: false

jobs:
archive:
runs-on: ubuntu-latest
steps:
- name: Checkout (post-merge state)
uses: actions/checkout@v4
with:
fetch-depth: 2 # enough to read the merge/commit subject
submodules: recursive

- uses: actions/setup-node@v4
with: { node-version: "22" }
- name: Enable pnpm
run: corepack enable
- uses: actions/setup-python@v5
with: { python-version: "3.12" }

- name: Install osv-scanner
run: |
OSV_VER=v2.4.0 # pinned; verified against the release SHA256SUMS
curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_linux_amd64" -o /tmp/osv-scanner_linux_amd64
curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_SHA256SUMS" -o /tmp/osv_SHA256SUMS
( cd /tmp && grep " osv-scanner_linux_amd64$" osv_SHA256SUMS | sha256sum -c - )
install -m 0755 /tmp/osv-scanner_linux_amd64 /usr/local/bin/osv-scanner

- name: Generate SBOM + scan (SBOM & raw scan only — no rendered report)
run: |
bash scripts/generate-sbom.sh
NAME=$(basename "$(ls sbom/*.cdx.json | head -1)" .cdx.json)
echo "NAME=$NAME" >> "$GITHUB_ENV"
CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml"
set +e
osv-scanner scan $CFG --recursive --format=json --output=/tmp/osv.json .
rc=$?
set -e
if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then echo "::error::osv-scanner failed (exit $rc)"; exit 1; fi
[ -s /tmp/osv.json ] || echo '{"results":[]}' > /tmp/osv.json
node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv

- name: Publish snapshot to the security-archive orphan branch
env:
GH_TOKEN: ${{ github.token }}
run: |
MSG=$(git log -1 --format=%s)
PR=$(printf '%s' "$MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
if [ -n "$PR" ]; then ID="pr-$PR"; else ID="commit-$(git rev-parse --short HEAD)"; fi
DIR="${ID}-$(date -u +%Y-%m-%d)"
URL="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
if git clone --depth 1 --branch security-archive "$URL" /tmp/arch 2>/dev/null; then
:
else
git clone --depth 1 "$URL" /tmp/arch
git -C /tmp/arch checkout --orphan security-archive
git -C /tmp/arch rm -rf . >/dev/null 2>&1 || true
fi
mkdir -p "/tmp/arch/$DIR"
cp "sbom/$NAME.cdx.json" "sbom/$NAME.spdx.json" "sbom/$NAME.components.csv" sbom/vulnerabilities.csv "/tmp/arch/$DIR/"
git -C /tmp/arch -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" add "$DIR"
if git -C /tmp/arch diff --cached --quiet; then
echo "No SBOM changes to archive for $DIR."
else
git -C /tmp/arch -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" commit -m "chore(security): SBOM snapshot ${DIR}"
git -C /tmp/arch push "$URL" HEAD:security-archive
fi
129 changes: 129 additions & 0 deletions .github/workflows/security-pr-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Security — PR gate

# Runs on every pull request. Scans the BASE and the HEAD of the PR and blocks
# ONLY on security advisories the PR *introduces* (present in head, absent in
# base) at or above the severity threshold — pre-existing issues never block.
# Both scans honor osv-scanner.toml (the VEX baseline), so a justified new
# suppression in the PR clears the gate.
#
# The job never writes CODE to the repo (contents: read), so the check is present
# on every commit and is safe to require in branch protection. It DOES post a
# single sticky PR comment (pull-requests: write) with the actionable result, so
# the author sees what to fix without digging into the check log. The per-PR SBOM
# snapshot is archived on MERGE by security-pr-archive.yml.
#
# To ENFORCE the block, mark the "gate" job a required status check in branch
# protection for the default branch.

on:
pull_request:

permissions:
contents: read
pull-requests: write # post/update the result comment (never pushes code)

concurrency:
group: security-pr-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true

env:
# Block when the PR introduces a NEW advisory at or above this severity.
GATE_THRESHOLD: HIGH

jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Checkout PR head
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive

- uses: actions/setup-node@v4
with: { node-version: "22" }

- name: Unit tests (security scripts)
run: node --test scripts/__tests__/*.test.mjs

- name: Install osv-scanner
run: |
OSV_VER=v2.4.0 # pinned; verified against the release SHA256SUMS
curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_linux_amd64" -o /tmp/osv-scanner_linux_amd64
curl -sSfL "https://github.com/google/osv-scanner/releases/download/${OSV_VER}/osv-scanner_SHA256SUMS" -o /tmp/osv_SHA256SUMS
( cd /tmp && grep " osv-scanner_linux_amd64$" osv_SHA256SUMS | sha256sum -c - )
install -m 0755 /tmp/osv-scanner_linux_amd64 /usr/local/bin/osv-scanner

# Both scans use the HEAD osv-scanner.toml so a justified suppression added
# in the PR is honored on both sides.
- name: Scan HEAD
run: |
cp osv-scanner.toml /tmp/head-config.toml 2>/dev/null || true
CFG=""; [ -f /tmp/head-config.toml ] && CFG="--config=/tmp/head-config.toml"
set +e
osv-scanner scan $CFG --recursive --format=json --output=/tmp/head.json .
rc=$?
set -e
# osv-scanner: 0 = no vulns, 1 = vulns found. ANY other code is an
# operational failure — fail the gate CLOSED, never pass a broken scan.
if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then
echo "::error::osv-scanner failed to scan HEAD (exit $rc)"; exit 1
fi
[ -s /tmp/head.json ] || echo '{"results":[]}' > /tmp/head.json

- name: Scan BASE
run: |
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}"
git worktree add -f /tmp/base "${{ github.event.pull_request.base.sha }}"
CFG=""; [ -f /tmp/head-config.toml ] && CFG="--config=/tmp/head-config.toml"
set +e
osv-scanner scan $CFG --recursive --format=json --output=/tmp/base.json /tmp/base
rc=$?
set -e
if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then
echo "::error::osv-scanner failed to scan BASE (exit $rc)"; exit 1
fi
[ -s /tmp/base.json ] || echo '{"results":[]}' > /tmp/base.json

# THE GATE — non-zero exit here fails the check and (with branch protection)
# blocks the merge. It also writes /tmp/gate-comment.md and /tmp/gate-status.
- name: Evaluate — block on newly-introduced advisories
run: |
BASE="${{ github.event.pull_request.base.sha }}"
# Run the decision script AND read the threshold from BASE, not HEAD: a PR
# must not be able to weaken its own gate by editing pr-gate-diff.mjs or
# GATE_THRESHOLD in the same PR (pull_request runs the merge-ref code).
# Falls back to the HEAD copy only when base has no gate yet (first landing).
if git cat-file -e "$BASE:scripts/pr-gate-diff.mjs" 2>/dev/null; then
git show "$BASE:scripts/pr-gate-diff.mjs" > /tmp/pr-gate-diff.base.mjs
BT=$(git show "$BASE:.github/workflows/security-pr-gate.yml" 2>/dev/null \
| sed -nE 's/^[[:space:]]*GATE_THRESHOLD:[[:space:]]*"?([A-Za-z]+)"?.*/\1/p' | head -1)
echo "Evaluating with the base copy of the gate (threshold=${BT:-$GATE_THRESHOLD})."
else
cp scripts/pr-gate-diff.mjs /tmp/pr-gate-diff.base.mjs
echo "::warning::base has no pr-gate-diff.mjs yet (first introduction) — using the HEAD copy."
fi
node /tmp/pr-gate-diff.base.mjs /tmp/base.json /tmp/head.json "${BT:-$GATE_THRESHOLD}"

# Post/update ONE sticky comment on the PR with the actionable result.
# Runs even when the gate failed (always()); never flips the verdict
# (continue-on-error) — the pass/fail is decided by the step above.
- 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("<!-- security-pr-gate -->")) | .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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,6 @@ temp/

# Generated test output headers
tests/**/*.hpp

# transient SBOM build output (canonical copy lives in security/<YYYY-MM>/sbom/)
/sbom/
Loading