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()