Skip to content
Merged
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
20 changes: 0 additions & 20 deletions .github/workflows/sast.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions AI_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
25 changes: 21 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <rule-id> — <reason>`.

Found while introducing these checks:

Expand Down
23 changes: 23 additions & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
24 changes: 24 additions & 0 deletions scripts/sast.sh
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 28 additions & 0 deletions test/guards/test_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
(dependencies.lock for the T-Deck, dependencies.lock.esp32p4 for the Tab5).
"""

import os
import re
import unittest

Expand Down Expand Up @@ -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()
Loading