Skip to content

Commit cdda182

Browse files
authored
ci: add tirith security scan and fail-fast preflight gate (#1383)
* ci: add tirith security scan and fail-fast preflight gate Add a security gate to the CI workflow so the expensive build/test work runs only after the cheap, high-signal security checks pass. Two new jobs run unconditionally and in parallel at the front of the pipeline (not gated on `changes`, so they cover docs-only PRs and fork PRs where local git hooks never run): - `tirith-scan`: scans the whole checkout with tirith (https://tirith.sh/docs/ci/), uploads SARIF to the Security tab, and gates downstream CI. Wired on `pull_request` (not `pull_request_target`), so fork PRs only get a read-only token and cannot leak secrets while their code is scanned; the SARIF upload is therefore skipped for forks (the scan and gate still run). tirith is installed from the flake's pinned nixpkgs to match the dev shell. - `preflight`: runs the existing `gitleaks` and `treefmt` flake checks (treefmt carries actionlint + zizmor with the repo's exact ignore config) as an early fail-fast gate before any build/test work. `test` and `build-native-packages` now `needs` both gates, so they are skipped unless the gates pass. tirith details: - `--fail-on high` blocks on high/critical findings. tirith's `--ci` exit code is 0 = clean, 1 = at/above threshold, 2 = findings below threshold; the gate treats 0/2 as pass and 1 (or any other code) as a block, and the scan exit code is passed via env to avoid template injection. - `*.snap` is excluded because terminal snapshot test fixtures intentionally contain ANSI escapes that trip the `ansi_escapes` rule; the rule stays active for real source files. Heavy gate jobs run on blacksmith runners, consistent with the rest of the nix-based CI jobs. * ci: isolate tirith SARIF upload in a least-privilege job Address CodeRabbit's security review: keep the elevated `security-events: write` scope out of any job that executes PR-controlled code. - `tirith-scan` now holds only `contents: read`. It still runs the local action, the PR flake and the installed tirith (it must, to scan the PR), but with no write scope a neutered scan cannot abuse any privilege — the same trust model as the existing `test`/`build` jobs that run the PR's `nix flake check`. - The SARIF is handed off as an artifact and published by a new `tirith-sarif-upload` job that never executes PR code: it only checks out files for path mapping and runs trusted, pinned actions (download-artifact + codeql upload-sarif). This is the only job with `security-events: write`. - The upload runs `always()` so findings reach the Security tab even when the gate blocks the PR, and is skipped for fork PRs (read-only token cannot upload).
1 parent 17911d6 commit cdda182

1 file changed

Lines changed: 136 additions & 2 deletions

File tree

‎.github/workflows/ci.yaml‎

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,139 @@ jobs:
2121
- id: detect
2222
uses: ./.github/actions/detect-code-changes
2323

24+
# Security gate: scan the repository with tirith (https://tirith.sh/docs/ci/)
25+
# before any of the real CI work runs. This is wired on `pull_request` (not
26+
# `pull_request_target`), so fork PRs only ever get a read-only token and
27+
# cannot leak secrets while we scan their code. The downstream jobs `needs`
28+
# this job, so they are skipped unless tirith passes. tirith is installed from
29+
# the flake's pinned nixpkgs so CI matches the dev shell.
30+
tirith-scan:
31+
name: tirith security scan
32+
runs-on: blacksmith-32vcpu-ubuntu-2404
33+
timeout-minutes: 15
34+
# Read-only: this job executes PR-controlled code (the local action, the PR
35+
# flake, the installed tirith), so it must hold no write scopes. The SARIF
36+
# is published from the separate `tirith-sarif-upload` job, which never runs
37+
# PR code, keeping the elevated `security-events: write` away from the scan.
38+
permissions:
39+
contents: read
40+
steps:
41+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
42+
with:
43+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
44+
persist-credentials: false
45+
- uses: ./.github/actions/setup-nix-cache
46+
- name: Install tirith
47+
run: nix profile install --inputs-from . nixpkgs#tirith
48+
# One pass produces the SARIF report and sets the gate exit code. tirith's
49+
# `--ci` exit code is: 0 = no findings, 1 = findings at/above --fail-on,
50+
# 2 = findings present but all below it. We capture it (rather than letting
51+
# it abort the step) so the SARIF still uploads before the gate decides.
52+
#
53+
# `*.snap` are excluded: they are terminal snapshot test fixtures that
54+
# intentionally contain ANSI escape sequences, so the `ansi_escapes` rule
55+
# (High) fires on them as a false positive. The rule stays active for real
56+
# source files; we only skip the test data.
57+
- name: Scan repository with tirith
58+
id: scan
59+
run: |
60+
set +e
61+
tirith scan --ci --fail-on high --exclude '*.snap' --format sarif ./ > results.sarif
62+
code=$?
63+
set -e
64+
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
65+
echo "tirith exited ${code}"
66+
- name: Summarize findings
67+
if: always()
68+
run: |
69+
count="$(jq '[.runs[].results[]?] | length' results.sarif 2>/dev/null || echo 0)"
70+
echo "tirith reported ${count} finding(s)" | tee -a "$GITHUB_STEP_SUMMARY"
71+
jq -r '.runs[].results[]? | "- [\(.level // "warning")] \(.ruleId): \(.locations[0].physicalLocation.artifactLocation.uri)"' \
72+
results.sarif 2>/dev/null | tee -a "$GITHUB_STEP_SUMMARY" || true
73+
# Hand the SARIF to the privileged upload job as an artifact. Uploaded
74+
# before the gate step so findings reach the Security tab even when the
75+
# gate then blocks the PR.
76+
- name: Upload SARIF artifact
77+
if: always()
78+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
79+
with:
80+
name: tirith-sarif
81+
path: results.sarif
82+
if-no-files-found: error
83+
- name: Gate CI on tirith findings
84+
if: always()
85+
env:
86+
SCAN_EXIT: ${{ steps.scan.outputs.exit_code }}
87+
run: |
88+
case "$SCAN_EXIT" in
89+
0 | 2)
90+
echo "tirith gate passed (exit ${SCAN_EXIT}: no findings, or all below 'high')"
91+
;;
92+
1)
93+
echo "::error::tirith found findings at or above the 'high' threshold; blocking downstream CI"
94+
exit 1
95+
;;
96+
*)
97+
echo "::error::tirith scan errored (exit ${SCAN_EXIT}); blocking downstream CI"
98+
exit 1
99+
;;
100+
esac
101+
102+
# Publish the tirith SARIF to the Security tab. Split from the scan so the
103+
# `security-events: write` scope lives in a job that never executes
104+
# PR-controlled code: it only downloads the artifact and runs trusted, pinned
105+
# actions. `always()` so findings are published even when the gate blocked the
106+
# PR; skipped for fork PRs, whose token is read-only and cannot upload.
107+
tirith-sarif-upload:
108+
name: tirith SARIF upload
109+
needs: tirith-scan
110+
if: ${{ always() && !github.event.pull_request.head.repo.fork }}
111+
runs-on: ubuntu-slim
112+
timeout-minutes: 10
113+
permissions:
114+
contents: read
115+
security-events: write
116+
steps:
117+
# Checkout only places files for path mapping; no PR code is executed in
118+
# this privileged job.
119+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
120+
with:
121+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
122+
persist-credentials: false
123+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
124+
with:
125+
name: tirith-sarif
126+
- uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
127+
with:
128+
sarif_file: results.sarif
129+
category: tirith
130+
131+
# Fast, unconditional fail-fast gate for the cheap-but-high-signal checks:
132+
# secret detection (gitleaks) and workflow/format lint (the treefmt check,
133+
# which runs actionlint + zizmor with the repo's exact ignore config). Runs in
134+
# parallel with tirith-scan and is NOT gated on `changes`, so secrets and
135+
# workflow regressions are caught even on docs-only PRs and on fork PRs (where
136+
# local git hooks never run). Both are existing flake checks, so this reuses
137+
# their exact behavior instead of re-specifying flags here. The heavy test job
138+
# re-runs them inside `nix flake check`; that is intentional duplication so the
139+
# gate can fail fast before any build/test work starts.
140+
preflight:
141+
name: security & lint preflight
142+
runs-on: blacksmith-32vcpu-ubuntu-2404
143+
timeout-minutes: 15
144+
permissions:
145+
contents: read
146+
steps:
147+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
148+
with:
149+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
150+
persist-credentials: false
151+
- uses: ./.github/actions/setup-nix-cache
152+
- name: Secret scan and workflow lint
153+
run: nix build --print-build-logs .#checks.x86_64-linux.gitleaks .#checks.x86_64-linux.treefmt
154+
24155
test:
25-
needs: changes
156+
needs: [changes, tirith-scan, preflight]
26157
if: needs.changes.outputs.code-changed == 'true'
27158
runs-on: blacksmith-32vcpu-ubuntu-2404
28159
timeout-minutes: 30
@@ -50,7 +181,7 @@ jobs:
50181
just test-node
51182
52183
build-native-packages:
53-
needs: changes
184+
needs: [changes, tirith-scan, preflight]
54185
if: needs.changes.outputs.code-changed == 'true'
55186
name: ${{ matrix.name }}
56187
permissions:
@@ -394,6 +525,9 @@ jobs:
394525
action-timeline:
395526
needs:
396527
- changes
528+
- tirith-scan
529+
- tirith-sarif-upload
530+
- preflight
397531
- test
398532
- build-native-packages
399533
- npm-publish-dry-run-and-upload-pkg-pr-now

0 commit comments

Comments
 (0)