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
132 changes: 132 additions & 0 deletions .github/workflows/security-monthly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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.
#
# Python variant: the SBOM is built from a clean venv (cyclonedx-py) and OSV
# scans the resulting CycloneDX SBOM. There is no AI triage on this public repo —
# advisories that surface above the VEX baseline are shown in the PR for a human.

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
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- name: Month stamp
id: m
run: echo "month=$(date -u +%Y-%m)" >> "$GITHUB_OUTPUT"

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: { node-version: "22" }
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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: |
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: |
NAME=$(node -p "require('./security/report-config.json').sbomBasename")
CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml"
set +e
osv-scanner scan $CFG --format=json --output=/tmp/osv.json "sbom/$NAME.cdx.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
# unfiltered scan too, so the report headline numbers are derived live
set +e
osv-scanner scan --format=json --output=/tmp/osv-raw.json "sbom/$NAME.cdx.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('./security/report-config.json').sbomBasename")
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 \
--baseline osv-scanner.toml \
--out "security/${{ steps.m.outputs.month }}" \
--date "${{ steps.m.outputs.month }}"

- name: Render PDF
uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # 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/"
# Deterministic path (build-report.mjs derives the same basename from the
# report title) instead of ls|grep, which without pipefail yields "$DIR/"
# on no match and fails Chrome with an opaque error.
REPORT="$DIR/$(node -p "require('./security/report-config.json').title.replace(/[^A-Za-z0-9]+/g,'-')")-Security-Report.html"
[ -f "$REPORT" ] || { echo "::error::report HTML not found at $REPORT"; exit 1; }
# --no-sandbox / --disable-dev-shm-usage: Chrome's zygote sandbox aborts
# (SIGABRT) on GitHub runners; required for headless Chrome in CI.
# --blink-settings=scriptEnabled=false: the report HTML is a static document
# written from report-config.json — no JS should run while rendering it with
# local file:// access (the old --disable-javascript switch is a silent no-op
# in modern Chromium). Defense-in-depth on top of the HTML escaping.
"${{ steps.chrome.outputs.chrome-path }}" --headless=new --no-sandbox --disable-dev-shm-usage \
--blink-settings=scriptEnabled=false --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@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6
with:
# A PR opened with GITHUB_TOKEN does not trigger other workflows; set a
# SECURITY_BOT_TOKEN (GitHub App / fine-grained PAT) to make checks run.
token: ${{ secrets.SECURITY_BOT_TOKEN || github.token }}
base: main
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) 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.
87 changes: 87 additions & 0 deletions .github/workflows/security-pr-archive.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Security — archive SBOM on merge

# Python variant. Runs when a commit lands on the default branch (a PR merged, or
# a direct push). Regenerates the SBOM + raw scan for that state and stores them
# on a dedicated ORPHAN branch `security-archive` under <id>-<date>/ — a permanent
# per-merge trail WITHOUT bloating the code branch or slowing clones.
#
# `push` (not pull_request): the token is always writable (works for merged fork
# PRs) and it only fires for the default branch. SBOM + raw scan only (no rendered
# report) — nothing that could become a stale/false attestation.

on:
push:
branches: [main]

permissions:
contents: write

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

env:
# Repos with native deps set this (e.g. orchestrator-agent: "--only-binary av").
SBOM_PIP_ARGS: ""

jobs:
archive:
runs-on: ubuntu-latest
steps:
- name: Checkout (post-merge state)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 2

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: { node-version: "22" }
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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: |
NAME=$(node -p "require('./security/report-config.json').sbomBasename")
echo "NAME=$NAME" >> "$GITHUB_ENV"
bash scripts/generate-sbom.sh
CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml"
set +e
osv-scanner scan $CFG --format=json --output=/tmp/osv.json "sbom/$NAME.cdx.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
133 changes: 133 additions & 0 deletions .github/workflows/security-pr-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: { node-version: "22" }
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: { python-version: "3.12" }

- 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

- 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
# Includes the Python plugin manifests: their deps ship in the deployed
# product, so a PR adding a vulnerable plugin dependency must trigger a
# base resolve rather than passing without a diff.
CHANGED=$(git diff --name-only "$BASE" HEAD -- requirements.txt requirements-dev.txt pyproject.toml poetry.lock Pipfile Pipfile.lock 'core/src/drivers/plugins/python/*/requirements.txt' 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"
set +e
osv-scanner scan $CFG --format=json --output=/tmp/head.json "sbom/$NAME.cdx.json"
rc=$?
set -e
# osv-scanner: 0 = no vulns, 1 = vulns found. Any other code = failure →
# fail the gate CLOSED, never pass a broken scan.
if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then echo "::error::osv-scanner failed on HEAD (exit $rc)"; exit 1; fi
[ -s /tmp/head.json ] || echo '{"results":[]}' > /tmp/head.json

- 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")
BASE="${{ github.event.pull_request.base.sha }}"
# Restore the BASE version of EVERY changed manifest (root + Python plugins)
# so the base SBOM reflects base deps; generate-sbom.sh resolves all of them.
MANIFESTS=$(git diff --name-only "$BASE" HEAD -- requirements.txt requirements-dev.txt 'core/src/drivers/plugins/python/*/requirements.txt' 2>/dev/null || true)
BAK=/tmp/manifest-bak; rm -rf "$BAK"; mkdir -p "$BAK"
for f in $MANIFESTS; do
mkdir -p "$BAK/$(dirname "$f")"
cp "$f" "$BAK/$f" 2>/dev/null || true
git show "$BASE:$f" > "$f" 2>/dev/null || : > "$f"
done
bash scripts/generate-sbom.sh
cp "sbom/$NAME.cdx.json" /tmp/base.cdx.json
for f in $MANIFESTS; do cp "$BAK/$f" "$f" 2>/dev/null || git checkout -- "$f" 2>/dev/null || true; done
CFG=""; [ -f osv-scanner.toml ] && CFG="--config=osv-scanner.toml"
set +e
osv-scanner scan $CFG --format=json --output=/tmp/base.json /tmp/base.cdx.json
rc=$?
set -e
if [ "$rc" != "0" ] && [ "$rc" != "1" ]; then echo "::error::osv-scanner failed on BASE (exit $rc)"; exit 1; fi
[ -s /tmp/base.json ] || echo '{"results":[]}' > /tmp/base.json
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/
17 changes: 17 additions & 0 deletions osv-scanner.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# osv-scanner suppression baseline for openplc-runtime (VEX "not affected"/mitigated
# decisions from the SBOM & Vulnerability Report).
#
# Intentionally EMPTY. requirements.txt is unpinned and CI resolves it into a clean
# venv, so the scanned dependency set is the currently-resolved one — which has no
# known advisory. The previous entries were suppressing IDs that no longer resolve
# (all were reported "unused ignores"); a stale suppression can only fail OPEN (a
# revived vulnerable version would stay hidden until its ignoreUntil), so they are
# removed rather than kept. The live stack is left UNSUPPRESSED, so any advisory
# that surfaces in a future scan is shown for human review (no AI triage on this
# public repo). SOEM (C, EtherCAT) is a submodule tracked apart.
#
# When a real advisory surfaces and is triaged "not affected"/"mitigated", add an
# [[IgnoredVulns]] entry carrying a CISA VEX status + justification, e.g.:
# node scripts/gen-osv-ignores.mjs <triaged.csv> <YYYY-MM-DD> >> osv-scanner.toml
# VEX not_affected/<cisa_justification> [pkg]: <basis>
# VEX affected/mitigated [pkg]: <compensating control>
Loading
Loading