From c44ab2d7953b770cc7fe9c0103bc977290b1d1ac Mon Sep 17 00:00:00 2001 From: Frank Tucker Date: Mon, 12 Oct 2026 23:19:18 +0000 Subject: [PATCH] ci: one security scan (SAST, Trivy, SBOM, ZAP DAST); local SAST with MicroHealth's rules Replace sast.yml with security-scan.yml, the caller of MicroHealthLLC/sast-rules' shared security-scan.yml @ 341cd54 (v2026.10.5): Opengrep SAST, Trivy, the CycloneDX SBOM and ZAP DAST in parallel, one security-report. Every scanner is on. Runs on pushes to main (the default branch, so release scans feed MicroHealth's reports) and integration, every PR (diff SAST against the base), nightly and on dispatch. ZAP scans vars.ZAP_TARGET_URL and is reported not_run until it is set. It does not gate releases; the Tests workflow still does. Local: scripts/sast.sh copied unchanged from sast-rules/local/sast.sh (it reads the workflow pin); scripts/check.sh runs the guard tests, the host unit tests, then SAST. A SecurityScan guard (test_supply_chain) fails if the scan leaves security-scan.yml, a scanner is switched off, or local SAST is unwired. SECURITY.md, README and AI_RULES updated. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/sast.yml | 20 -------------- .github/workflows/security-scan.yml | 43 +++++++++++++++++++++++++++++ AI_RULES.md | 7 +++++ README.md | 6 +++- SECURITY.md | 25 ++++++++++++++--- scripts/check.sh | 23 +++++++++++++++ scripts/sast.sh | 24 ++++++++++++++++ test/guards/test_supply_chain.py | 28 +++++++++++++++++++ 8 files changed, 151 insertions(+), 25 deletions(-) delete mode 100644 .github/workflows/sast.yml create mode 100644 .github/workflows/security-scan.yml create mode 100755 scripts/check.sh create mode 100755 scripts/sast.sh diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml deleted file mode 100644 index e852d4b..0000000 --- a/.github/workflows/sast.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: SAST -# MicroHealth's shared static analysis: Opengrep with MicroHealth's rules -# (MicroHealthLLC/sast-rules). The pin below is moved by a pull request that -# sast-rules opens on each release; merging it is how this repository takes -# new rules. -on: - schedule: - # Daily — 10:30 PM US Eastern (EDT) = 02:30 UTC the next day - - cron: "30 2 * * *" - workflow_dispatch: -permissions: - contents: read -jobs: - sast: - permissions: {contents: read, actions: read, security-events: write, id-token: write} - uses: MicroHealthLLC/sast-rules/.github/workflows/opengrep.yml@d7c3dad1ed1fe24d092f469360ca68d00a3a654d # v2026.10.0 - with: - aws_region: ${{ vars.AWS_REGION_EAST_1 }} - s3_bucket: ${{ vars.SECURITY_REPORTS_S3_BUCKET }} - secrets: inherit diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..7b7cb3a --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,43 @@ +name: Security scan +# MicroHealth's one security workflow (MicroHealthLLC/sast-rules, +# security-scan.yml): SAST (Opengrep with MicroHealth's rules), Trivy +# (dependencies, secrets, misconfiguration), the CycloneDX SBOM and ZAP DAST, +# in parallel, ending in ONE report (the `security-report` artifact). Every +# scanner is on; switch one off with its input (`sast: false`, `trivy: false`, +# `sbom: false`, `zap: false`). It sits beside the Tests workflow (the release +# gate, with its firmware CVE audit); it does not gate releases. +# +# The pin below is moved by a pull request that sast-rules opens on each +# release; merging it is how this repository takes new rules and engines. +# scripts/sast.sh reads the same pin, so a local scan runs the same rules. +# +# Needs: vars.AWS_REGION_EAST_1, secrets.AWS_ROLE (OIDC; pulls MicroHealth's +# FIPS images from ECR, writes the bucket), vars.SECURITY_REPORTS_S3_BUCKET +# (empty: reports stay artifacts only) and vars.ZAP_TARGET_URL (empty: ZAP is +# recorded as not run, with the reason; the run does not fail). Only a full +# scan of the default branch is uploaded; a pull request's stays an artifact. +on: + push: + # main is the default branch (every push to it is a release): its full + # scans feed Tueri Core; integration's go under _branch-scans/. + branches: [main, integration] + pull_request: + schedule: + # Daily — 10:30 PM US Eastern (EDT) = 02:30 UTC the next day + - cron: "30 2 * * *" + workflow_dispatch: +permissions: + contents: read +concurrency: + group: security-scan-${{ github.ref }} + cancel-in-progress: true +jobs: + security: + permissions: {contents: read, actions: read, security-events: write, id-token: write} + uses: MicroHealthLLC/sast-rules/.github/workflows/security-scan.yml@341cd54bc0aae9359fa56965281d45ad58ce812c # v2026.10.5 + with: + aws_region: ${{ vars.AWS_REGION_EAST_1 }} + s3_bucket: ${{ vars.SECURITY_REPORTS_S3_BUCKET }} + zap_target: ${{ vars.ZAP_TARGET_URL }} + baseline_ref: ${{ github.base_ref }} + secrets: inherit diff --git a/AI_RULES.md b/AI_RULES.md index 883f575..262863d 100644 --- a/AI_RULES.md +++ b/AI_RULES.md @@ -212,6 +212,13 @@ only while no SSH session is open; in a session they go to the server. unit-tests, persistence, build (both boards: RAM budget, zero compiler warnings in `main/`, no key material), audit (CVEs), network-surface (linked symbols, generated sdkconfig), secret-scan. The code-quality workflow stays advisory. +- The **Security scan** workflow (`.github/workflows/security-scan.yml`) calls MicroHealth's + shared `security-scan.yml` (`MicroHealthLLC/sast-rules`, pinned by commit): SAST, Trivy, + SBOM and ZAP DAST, one report. Every scanner stays on (test/guards/test_supply_chain.py, + `SecurityScan`). Before pushing run `scripts/check.sh` (guards, host tests, then + `scripts/sast.sh` with the same rules); if SAST can't run locally, say so in the PR. + `scripts/sast.sh` is copied from sast-rules unchanged; never edit it. Suppress a finding + only inline, with the full rule id and why it does not apply. - Tests live in `test/`: `test/host` compiles hardware-independent firmware sources unchanged for Linux against in-memory stand-ins for NVS, partitions and logging (`idf_stubs/`, `fake_idf.cpp`); `test/guards` holds Python checks of the rules in this file. Keep logic that diff --git a/README.md b/README.md index 02d184a..3312ca7 100644 --- a/README.md +++ b/README.md @@ -222,5 +222,9 @@ charges its battery over USB-C while SimplrSSH runs. - Every push and pull request runs the **Tests** checks (`.github/workflows/tests.yml`). To run the unit tests on a PC (Linux, with `cmake`, `g++` and `libmbedtls-dev`): `cmake -S test/host -B build-host && cmake --build build-host && ctest --test-dir build-host`, - and `python3 -m unittest discover -s test/guards`. + and `python3 -m unittest discover -s test/guards`. `scripts/check.sh` runs both, then + `scripts/sast.sh` (MicroHealth's SAST rules; needs docker or nerdctl logged in to + MicroHealth's ECR; `SKIP_SAST=1` leaves it out). +- The **Security scan** workflow (`.github/workflows/security-scan.yml`) runs SAST, Trivy, an + SBOM and ZAP DAST and writes one report; see [SECURITY.md](SECURITY.md). - Contributor rules are in [AI_RULES.md](AI_RULES.md). diff --git a/SECURITY.md b/SECURITY.md index 4e3bb00..1eb0f59 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -110,10 +110,27 @@ a release waits for it to pass on the same commit. Security-relevant gates: audit (esp-idf-sbom, NVD) of ESP-IDF, its libraries and the components of both images that blocks on new high/critical findings. -Outside the Tests workflow, `.github/workflows/sast.yml` runs MicroHealth's shared static -analysis nightly (and on demand): Opengrep with MicroHealth's rules -(`MicroHealthLLC/sast-rules`, pinned by commit). It is report-only; findings go to GitHub code -scanning and to MicroHealth's security reports. +Outside the Tests workflow, `.github/workflows/security-scan.yml` runs MicroHealth's shared +security scan (`MicroHealthLLC/sast-rules`, pinned by commit) on every push to `main` and +`integration`, every pull request, nightly and on demand: SAST (Opengrep with MicroHealth's +rules), Trivy (dependencies, secrets and misconfiguration in the repository), a CycloneDX SBOM +and ZAP DAST, in parallel, ending in one `security-report` (each scanner's status and counts by +severity). Every scanner is on. A scanner that was on and left no report fails the run; a count +is shown only for a scanner that ran, so "did not run" never reads as "found nothing". It does +not gate releases (the Tests workflow does), and findings are not a release blocker unless the +Tests audit also reports them. SimplrSSH is firmware with no web endpoint, so ZAP has nothing +to scan unless `ZAP_TARGET_URL` is set; until then the report shows ZAP as not run, with the +reason. Trivy does not understand ESP-IDF component locks; the CVE audit above remains the +check for the firmware's own components. SAST findings also go to GitHub code scanning. +Only full scans of `main` feed MicroHealth's security reports: `integration`'s are kept apart +and never ingested, and a pull request's stay run artifacts (SAST there reports only what is +new against the base). + +The same SAST rules run locally: `scripts/sast.sh` (copied unchanged from +`MicroHealthLLC/sast-rules`; it reads the workflow's pin) and `scripts/check.sh`, which runs the +guard and host tests first. It needs docker or nerdctl logged in to MicroHealth's ECR. A +suppression is inline, with the full rule id and the reason it does not apply: +`// noopengrep: — `. Found while introducing these checks: diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..7f03231 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# The local check before a push: the guard tests and the host unit tests the +# Tests workflow runs, then SAST with MicroHealth's rules at the commit +# .github/workflows/security-scan.yml pins (scripts/sast.sh) — the same rules +# CI's security scan runs. It does not build firmware (that needs ESP-IDF). +# +# scripts/check.sh # guards, host tests (cmake, g++, libmbedtls-dev), SAST +# SKIP_SAST=1 scripts/check.sh # without SAST (no docker/nerdctl or ECR login); +# # say so in the PR — CI's security scan still runs it +# +# SAST needs git, curl, python3, sha256sum and docker or nerdctl logged in to +# MicroHealth's ECR (295669632222); it fails on an ERROR finding. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +(cd test/guards && python3 -m unittest test_supply_chain test_firmware_rules test_rules_file test_ci_scripts) +cmake -S test/host -B build-host -DSTRICT=ON -DSANITIZE=ON +cmake --build build-host -j "$(nproc)" +ctest --test-dir build-host -L unit --output-on-failure +if [[ "${SKIP_SAST:-}" == 1 ]]; then + echo "SAST SKIPPED (SKIP_SAST=1): not run locally; CI's security scan still runs it." >&2 + exit 0 +fi +scripts/sast.sh "$@" diff --git a/scripts/sast.sh b/scripts/sast.sh new file mode 100755 index 0000000..d2827bd --- /dev/null +++ b/scripts/sast.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Local SAST: MicroHealth's rules (MicroHealthLLC/sast-rules) at exactly the +# commit this repository's CI pins, run the way CI runs them. Copied from +# sast-rules/local/sast.sh; it reads the pin, so it never needs editing. +# +# scripts/sast.sh [--fail-on error|warning|none] [--json out.json] +# +# Needs git, curl, python3, sha256sum and docker or nerdctl logged in to +# MicroHealth's ECR. +set -euo pipefail +root="$(git rev-parse --show-toplevel)" +pin="$(grep -rhoE 'MicroHealthLLC/sast-rules/\.github/workflows/[a-z-]+\.yml@[0-9a-f]{40}' \ + "$root/.github/workflows" | head -1 | sed 's/.*@//')" +[ -n "$pin" ] || { echo "No sast-rules pin in .github/workflows." >&2; exit 2; } +cache="${XDG_CACHE_HOME:-$HOME/.cache}/sast-rules/${pin}" +if [ ! -f "$cache/.complete" ]; then + rm -rf "$cache"; mkdir -p "$cache" + git -C "$cache" init -q + git -C "$cache" fetch -q --depth 1 https://github.com/MicroHealthLLC/sast-rules.git "$pin" + git -C "$cache" checkout -q FETCH_HEAD + [ "$(git -C "$cache" rev-parse HEAD)" = "$pin" ] || { echo "sast-rules fetch is not ${pin}" >&2; exit 1; } + touch "$cache/.complete" +fi +exec "$cache/scripts/scan-local.sh" "$@" "$root" diff --git a/test/guards/test_supply_chain.py b/test/guards/test_supply_chain.py index 756af16..2e0ebcd 100644 --- a/test/guards/test_supply_chain.py +++ b/test/guards/test_supply_chain.py @@ -4,6 +4,7 @@ (dependencies.lock for the T-Deck, dependencies.lock.esp32p4 for the Tab5). """ +import os import re import unittest @@ -159,5 +160,32 @@ def test_ci_idf_version_matches_manifest(self): self.assertTrue(idf.lstrip("~^=") == ci, f"manifest idf {idf} vs CI v{ci}") +class SecurityScan(unittest.TestCase): + """One security workflow: MicroHealth's shared security-scan (SAST, Trivy, SBOM, ZAP DAST), + every scanner on, and the same rules locally through scripts/sast.sh.""" + + SHARED = re.compile(r"uses:\s*MicroHealthLLC/sast-rules/\.github/workflows/security-scan\.yml@([0-9a-f]{40})") + + def test_one_security_scan_calls_the_shared_workflow(self): + text = repo.read(".github", "workflows", "security-scan.yml") + self.assertRegex(text, self.SHARED) + callers = [n for n, t in repo.workflows().items() if "MicroHealthLLC/sast-rules/" in t] + self.assertEqual(callers, [".github/workflows/security-scan.yml"], + "SAST, Trivy, SBOM and ZAP run from the one security-scan workflow") + + def test_every_scanner_on(self): + text = repo.read(".github", "workflows", "security-scan.yml") + for scanner in ("sast", "trivy", "sbom", "zap"): + with self.subTest(scanner=scanner): + self.assertNotRegex(text, rf"(?m)^\s*{scanner}:\s*false", "every scanner stays on") + self.assertIn("zap_target: ${{ vars.ZAP_TARGET_URL }}", text) + + def test_local_sast_uses_the_pinned_rules(self): + sast = repo.path("scripts", "sast.sh") + self.assertTrue(os.access(sast, os.X_OK), "scripts/sast.sh must be executable") + self.assertIn("MicroHealthLLC/sast-rules", repo.read("scripts", "sast.sh")) + self.assertIn("scripts/sast.sh", repo.read("scripts", "check.sh")) + + if __name__ == "__main__": unittest.main()