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
134 changes: 134 additions & 0 deletions .github/workflows/security-monthly.yml
Original file line number Diff line number Diff line change
@@ -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/<YYYY-MM>/,
# 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.
69 changes: 69 additions & 0 deletions .github/workflows/security-pr-archive.yml
Original file line number Diff line number Diff line change
@@ -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-<number>-<date>/ 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-<n>-<date>/
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
104 changes: 104 additions & 0 deletions .github/workflows/security-pr-gate.yml
Original file line number Diff line number Diff line change
@@ -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:-<none>}"

- 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("<!-- 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 @@ -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/<YYYY-MM>/sbom/)
/sbom/
Loading
Loading