From 69b20fb44453305d92247f980ec99774d9bde21d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 12 Aug 2026 04:04:54 +0000 Subject: [PATCH 1/2] Scan the repository for secrets and shell injection on a schedule docmd builds sites by running a CLI over somebody's content, and several of its commands hand values straight to a shell. This watches that surface, and the more expensive accident of a real credential reaching a public branch, on every pull request, every push to main, and once a week. It is deliberately not a gate. The scan fails only on a critical finding, so a false positive can never hold a PR: results go to the Security tab on push and to the job summary on a pull request, where uploading SARIF would need a write token a fork does not get. The scanner is pinned, and installed with --ignore-scripts so CI never builds the native dependency that only its daemon uses. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/security-scan.yml | 85 +++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/security-scan.yml diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 00000000..42f4ac48 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,85 @@ +name: Security scan + +# Non-blocking secret and code scan of the repository. +# Deliberately not a merge gate: it fails only on a `critical` finding, so a +# false positive can never hold a PR. Results land in the Security tab on push +# and on the weekly run; pull requests get the counts in the job summary, +# because uploading SARIF needs a write token that a fork PR does not get. + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + # Mondays, 06:17 UTC. Off the hour so it does not queue behind everything + # else that runs at :00. + - cron: '17 6 * * 1' + workflow_dispatch: + +concurrency: + group: security-scan-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + scan: + name: Secrets and code (non-blocking) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: '20' + + - name: Install scanner + # Pinned so a rule change upstream never lands as a surprise CI failure. + # --ignore-scripts skips a native better-sqlite3 build that only the + # scanner's daemon needs; `scan` does not touch it. + run: npm install -g --ignore-scripts @profullstack/threatcrush@0.11.0 + + - name: Scan the repository + # Exits non-zero only on a critical finding. Everything below critical + # is reported and never blocks. + run: threatcrush scan . --format sarif --output threatcrush.sarif --fail-on critical + + - name: Summarise + if: always() + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import json, pathlib, collections + report = pathlib.Path("threatcrush.sarif") + if not report.exists(): + print("The scan wrote no report.") + raise SystemExit(0) + results = json.loads(report.read_text())["runs"][0]["results"] + levels = collections.Counter(one.get("level", "none") for one in results) + print("## Security scan\n") + print(f"{len(results)} finding(s): " + ", ".join(f"{n} {lvl}" for lvl, n in levels.most_common()) + "\n") + print("| Level | Rule | Where |") + print("| --- | --- | --- |") + for one in results[:30]: + where = one["locations"][0]["physicalLocation"] + path = where["artifactLocation"]["uri"] + line = where.get("region", {}).get("startLine", 1) + print(f"| {one.get('level', 'none')} | {one.get('ruleId', '')} | `{path}:{line}` |") + if len(results) > 30: + print(f"\n...and {len(results) - 30} more. The full report is in the Security tab.") + PY + + - name: Upload to code scanning + # Skipped on pull requests: a fork PR has no token that may write + # security events, and the step would fail on somebody's contribution + # rather than on anything about their change. + if: always() && github.event_name != 'pull_request' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: threatcrush.sarif + category: threatcrush From 1dfc7d22d914e3c9fdcb6c906b59f81156ae04b6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 12 Aug 2026 04:09:50 +0000 Subject: [PATCH 2/2] Make the scan report-only rather than a gate on critical Run against a repo whose criticals are false positives, --fail-on critical exits 1, so the promise that a false positive cannot hold a PR was not one the workflow could keep. No --fail-on is passed now, which makes it true structurally rather than by severity accounting. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/security-scan.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 42f4ac48..25906cbf 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -1,8 +1,10 @@ name: Security scan -# Non-blocking secret and code scan of the repository. -# Deliberately not a merge gate: it fails only on a `critical` finding, so a -# false positive can never hold a PR. Results land in the Security tab on push +# Report-only secret and code scan of the repository. +# Deliberately not a merge gate, and structurally incapable of becoming one by +# accident: no --fail-on is passed, so no finding at any severity fails the +# job. Add `--fail-on critical` to the scan step to make it a gate, once the +# current findings have been triaged. Results land in the Security tab on push # and on the weekly run; pull requests get the counts in the job summary, # because uploading SARIF needs a write token that a fork PR does not get. @@ -46,9 +48,7 @@ jobs: run: npm install -g --ignore-scripts @profullstack/threatcrush@0.11.0 - name: Scan the repository - # Exits non-zero only on a critical finding. Everything below critical - # is reported and never blocks. - run: threatcrush scan . --format sarif --output threatcrush.sarif --fail-on critical + run: threatcrush scan . --format sarif --output threatcrush.sarif - name: Summarise if: always()