Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
137 changes: 137 additions & 0 deletions .github/workflows/security-monthly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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/<YYYY-MM>/,
# and opens a PR for the team to review. Nothing is merged automatically.
#
# Auth: Claude runs on your Claude subscription via CLAUDE_CODE_OAUTH_TOKEN
# (from `claude setup-token`) — no pay-per-token API key. 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 (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: |
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: |
CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml"
osv-scanner scan $CFG --recursive --format=json --output=/tmp/osv.json . || true
node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# ---- 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# ---- deterministic: render the report from the (possibly updated) data ----
- 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" \
--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
Comment thread
JoaoGSP marked this conversation as resolved.
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 the current lockfile.
- 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
68 changes: 68 additions & 0 deletions .github/workflows/security-pr-archive.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Security — archive PR SBOM on merge

# When a pull request is MERGED, generate the SBOM + report for the resulting
# state and commit it under security/pr-<number>-<date>/ on the default branch,
# so the repo keeps a permanent, per-PR supply-chain history. Runs once per merge
# (not on synchronize), so it never loops and never touches the PR while it is
# 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]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

permissions:
contents: write

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

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
Comment thread
JoaoGSP marked this conversation as resolved.
Outdated
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: |
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

- name: Generate SBOM + report and archive under security/pr-<n>-<date>/
run: |
bash scripts/generate-sbom.sh
NAME=$(basename "$(ls sbom/*.cdx.json | head -1)" .cdx.json)
CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml"
osv-scanner scan $CFG --recursive --format=json --output=/tmp/osv.json . || true
node scripts/scan-vulns.mjs /tmp/osv.json sbom/vulnerabilities.csv
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
DATE=$(date -u +%Y-%m-%d)
PR="${{ github.event.pull_request.number }}"
DIR="security/pr-${PR}-${DATE}"
Comment thread
JoaoGSP marked this conversation as resolved.
Outdated
node scripts/build-report.mjs --config security/report-config.json --cdx "sbom/$NAME.cdx.json" --out "$DIR" --date "$DATE"
Comment thread
JoaoGSP marked this conversation as resolved.
Outdated
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 }}"
Comment thread
JoaoGSP marked this conversation as resolved.
Outdated
fi
108 changes: 108 additions & 0 deletions .github/workflows/security-pr-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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
Comment thread
JoaoGSP marked this conversation as resolved.

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

- 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# 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: node scripts/pr-gate-diff.mjs /tmp/base.json /tmp/head.json "${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 @@ -58,3 +58,6 @@ resources/strucpp/
playwright-report
/blob-report/
/playwright/.cache/

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