History

chore: add ThreatCrush security scan on pull requests (#136)

* Add .github/workflows/coinpay.yml via sh1pt coinpay-invoice@1.0.0 (#3) Co-authored-by: sh1pt-actions-fleet[bot] <287014002+sh1pt-actions-fleet[bot]@users.noreply.github.com> * Add .github/workflows/vu1nz-scan.yml via sh1pt vu1nz-scan@1.0.1 (#2) Co-authored-by: sh1pt-actions-fleet[bot] <287014002+sh1pt-actions-fleet[bot]@users.noreply.github.com> * chore: add ThreatCrush security scan on pull requests vu1nz-scan.yml reviews a PR's diff with an LLM and needs an API key to do it. This is the other half: offline static analysis over the whole tree, no secrets, no network beyond the install. It looks for what a diff review is worst at spotting — a credential committed three releases ago, an injection pattern in a file nobody touched this PR — and writes SARIF, so findings land in the Security tab with history rather than only in a comment. Fails on critical/high to match vu1nz-scan.yml, which already exits 1 on high/critical. That floor is quieter than it sounds: ThreatCrush caps pattern-confidence findings at medium, so a bare "this construct exists on this line" match cannot break a build. Only contextual and evidence findings reach it. main is at 0 critical / 0 high today, so this is green on arrival and needs no suppressions. Node 22, not the pack's 20: it matches engines.node and the CI matrix floor. Not 24 — better-sqlite3 has no prebuild there yet and the install falls through to a node-gyp source build and fails. The workflow is arranged so a scan that did not run can never report as a scan that found nothing. The SARIF file, not the exit code, is the evidence a scan happened; --format support is checked up front rather than inferred from an exit code, because a CLI without it exits 1 on the unknown option, which is the same code the CLI uses for "findings at or above --fail-on". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> ---------

Anthony Ettinger committed 3 months ago

Commit ce409461 parent c42592e3 files · +541 −0Browse files

Showing the changes to .github (3 of 3 files). Show all

Unified · Split

3 files
  1. .github/workflows/coinpay.yml +31 −0
  2. .github/workflows/threatcrush-scan.yml +291 −0
  3. .github/workflows/vu1nz-scan.yml +219 −0
.github/workflows/coinpay.yml+31−0
@@ -0,0 +1,31 @@
1# Managed by sh1pt Actions Fleet
2# pack: coinpay-invoice@1.0.0
3# install: sh1pt-actions-store
4# hash: sha256:34ad3313699d6a34845801f6d1c0963e36bee5d375678ffdd2a4bf0f46a17fa2
5name: CoinPayPortal invoice command
6
7on:
8 issue_comment:
9 types: [created]
10
11permissions:
12 issues: write
13 pull-requests: write
14
15concurrency:
16 group: coinpay-${{ github.workflow }}-${{ github.event.issue.number }}
17 cancel-in-progress: false
18
19jobs:
20 coinpay:
21 # Only spin up when a comment actually invokes the bot.
22 if: startsWith(github.event.comment.body, '/coinpay')
23 runs-on: ubuntu-latest
24 timeout-minutes: 15
25 steps:
26 - uses: profullstack/coinpaybot@v0
27 with:
28 github-token: ${{ secrets.GITHUB_TOKEN }}
29 coinpay-api-key: ${{ secrets.COINPAY_API_KEY }}
30 coinpay-business-id: ${{ secrets.COINPAY_BUSINESS_ID }}
31 coinpay-base-url: https://coinpayportal.com
.github/workflows/threatcrush-scan.yml+291−0
@@ -0,0 +1,291 @@
1# Adapted from the upstream `threatcrush-scan@1.1.0` pack
2# (profullstack/threatcrush → .github/workflows/threatcrush-scan.yml).
3# Deviations from that pack, all to fit this repo:
4# - Node 22, not 20, matching `engines.node` and the CI matrix floor
5# - fails on critical/high, matching vu1nz-scan.yml, which already does
6# - the legacy text-output compatibility path is dropped; see "Detect the
7# CLI output interface" below
8# The sh1pt fleet header is deliberately not carried over: this file is no
9# longer byte-identical to the pack, and claiming a hash that does not match
10# would be worse than not claiming one.
11name: threatcrush security scan
12
13on:
14 pull_request:
15
16permissions:
17 contents: read
18 pull-requests: write
19 security-events: write
20
21# A new push to the same branch supersedes the run in flight, as in ci.yml.
22concurrency:
23 group: threatcrush-${{ github.ref }}
24 cancel-in-progress: true
25
26jobs:
27 scan:
28 name: Scan for credentials and vulnerable patterns
29 runs-on: ubuntu-latest
30 timeout-minutes: 15
31
32 steps:
33 - uses: actions/checkout@v7
34
35 # 22 is this repo's floor (`engines.node`) and the low end of the CI
36 # matrix. Not 24: ThreatCrush pulls in better-sqlite3, which ships
37 # prebuilt binaries for 22 but not yet for 24, where the install falls
38 # through to a node-gyp source build and fails. A security gate that
39 # cannot install is a security gate that does not run.
40 - uses: actions/setup-node@v7
41 with:
42 node-version: "22"
43
44 # An unretried `npm i -g` is a network call to a registry that decides
45 # whether a security gate runs at all. Retry before giving up; a
46 # transient registry blip is not a security signal and should not read
47 # like one.
48 - name: Install ThreatCrush
49 run: |
50 for attempt in 1 2 3; do
51 if npm install -g "@profullstack/threatcrush@latest"; then
52 exit 0
53 fi
54 delay=$((attempt * 10))
55 echo "::warning::ThreatCrush install attempt ${attempt}/3 failed; retrying in ${delay}s"
56 sleep "${delay}"
57 done
58 echo "::error::ThreatCrush install failed after 3 attempts"
59 exit 1
60
61 # Recorded into every run log so a release that changes the interface
62 # shows up immediately, rather than silently scoring zero.
63 - name: Record the CLI interface
64 run: |
65 threatcrush --version || true
66 threatcrush scan --help || true
67
68 # Checked up front rather than inferred from an exit code, because exit
69 # codes cannot tell the two failures apart. CLIs before 0.3.0 have no
70 # `--format`: the scan died with `error: unknown option '--format'` and
71 # commander exited 1 — the same code the CLI uses for "findings at or
72 # above --fail-on". Read as a result, that produces a green check on a
73 # repository nothing has scanned.
74 #
75 # Upstream converts legacy text output with a vendored Python script.
76 # That path is dropped here rather than carrying a converter for a CLI
77 # older than the current npm release. Failing closed is the same safety
78 # property: an unrecognised interface stops the job instead of reporting
79 # an unscanned diff as clean.
80 - name: Detect the CLI output interface
81 run: |
82 if ! threatcrush scan --help 2>&1 | grep -q -- '--format'; then
83 echo "::error::CLI $(threatcrush --version 2>/dev/null || echo unknown) has no --format; cannot emit SARIF. This diff was NOT scanned."
84 exit 1
85 fi
86 echo "Native SARIF output available."
87
88 # --fail-on critical,high matches vu1nz-scan.yml, which already exits 1
89 # on high/critical findings. It is not as noisy as it sounds:
90 # `pattern`-confidence findings are capped at medium upstream, so a bare
91 # "this construct exists" match cannot break a build. Only `contextual`
92 # and `evidence` findings reach this floor. Drop the flag to make the
93 # scan advisory.
94 - name: Scan
95 id: scan
96 run: |
97 set -o pipefail
98 code=0
99 threatcrush scan . --format sarif --output threatcrush.sarif --fail-on critical,high || code=$?
100
101 # The SARIF file is the evidence that a scan happened, and it is the
102 # only evidence worth trusting. An exit code says what the process
103 # thought; the file says what it produced. Absent the file there is
104 # nothing to report, and reporting nothing as "no findings" is the
105 # failure this whole workflow is arranged to avoid.
106 if [ ! -s threatcrush.sarif ]; then
107 echo "status=error" >> "$GITHUB_OUTPUT"
108 echo "::error::ThreatCrush produced no SARIF (exit ${code}) — this diff was NOT scanned"
109 exit 1
110 fi
111
112 case "$code" in
113 0) echo "status=clean" >> "$GITHUB_OUTPUT" ;;
114 # Exit 1 *with* a SARIF file is the documented "findings at or
115 # above --fail-on" result; without one it was caught above.
116 # Propagate it: a gate that records the finding and then lets the
117 # job pass is not a gate.
118 1)
119 echo "status=findings" >> "$GITHUB_OUTPUT"
120 exit 1
121 ;;
122 *)
123 echo "status=error" >> "$GITHUB_OUTPUT"
124 echo "::error::ThreatCrush scan failed with exit code ${code} — results may be incomplete"
125 exit "$code"
126 ;;
127 esac
128
129 # Reached only when an earlier step already failed the job. The empty
130 # run exists so the upload does not error on a missing file and bury the
131 # real cause; it is not a result. The scan step has already set
132 # status=error (or never ran), so the report says NOT RUN rather than
133 # rendering this as a clean scan.
134 - name: Ensure SARIF exists
135 if: always()
136 run: |
137 if [ ! -f threatcrush.sarif ]; then
138 cat > threatcrush.sarif <<'JSON'
139 {
140 "version": "2.1.0",
141 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
142 "runs": [{ "tool": { "driver": { "name": "ThreatCrush", "rules": [] } }, "results": [] }]
143 }
144 JSON
145 fi
146
147 - name: Upload to the Security tab
148 if: always()
149 continue-on-error: true
150 uses: github/codeql-action/upload-sarif@v3
151 with:
152 sarif_file: threatcrush.sarif
153 category: threatcrush
154
155 - name: Build the report
156 if: always()
157 run: |
158 python3 << 'PYEOF'
159 import json, os
160
161 status = os.environ.get("SCAN_STATUS", "")
162 try:
163 with open("threatcrush.sarif") as handle:
164 results = json.load(handle)["runs"][0]["results"]
165 except Exception as err:
166 results = None
167 print(f"::warning::could not read SARIF: {err}")
168
169 lines = ["## ThreatCrush Security Scan", ""]
170
171 # Fail closed: render findings only on positive evidence that a scan
172 # completed. Testing for `status == "error"` would be fail-open —
173 # when an earlier step fails, the scan step is *skipped*, so
174 # `status` is the empty string rather than "error", and the comment
175 # cheerfully reports "0 findings" for a scan that never started. Any
176 # state that is not a known-good outcome is NOT RUN.
177 if status not in ("clean", "findings") or results is None:
178 # Never render "no issues found" for a scan that did not finish.
179 # An unexamined diff is not a clean one, and the two are
180 # indistinguishable to whoever reads the comment.
181 lines += [
182 "**NOT RUN** — the scan did not complete, so this diff was not examined.",
183 "This is not a clean result. See the job log.",
184 ]
185 else:
186 counts = {"error": 0, "warning": 0, "note": 0}
187 for result in results:
188 level = result.get("level", "warning")
189 if level in counts:
190 counts[level] += 1
191
192 lines.append(f"**{len(results)}** finding(s)")
193 lines.append("")
194
195 if results:
196 badges = []
197 if counts["error"]:
198 badges.append(f"**HIGH/CRITICAL**: {counts['error']}")
199 if counts["warning"]:
200 badges.append(f"**MEDIUM**: {counts['warning']}")
201 if counts["note"]:
202 badges.append(f"**LOW**: {counts['note']}")
203 if badges:
204 lines += [" | ".join(badges), ""]
205
206 lines += ["| Severity | Rule | Location |", "|---|---|---|"]
207 for result in results[:50]:
208 location = result["locations"][0]["physicalLocation"]
209 uri = location["artifactLocation"]["uri"]
210 line_no = location.get("region", {}).get("startLine", 1)
211 label = {"error": "HIGH", "warning": "MEDIUM", "note": "LOW"}.get(
212 result.get("level", "warning"), "INFO"
213 )
214 lines.append(f"| {label} | `{result.get('ruleId','?')}` | `{uri}`:{line_no} |")
215 if len(results) > 50:
216 # Say so. A silent truncation reads as "that was everything".
217 lines += ["", f"_…and {len(results) - 50} more. Full results in the Security tab._"]
218 lines += ["", "Snippets are redacted; ThreatCrush never prints matched credential material."]
219 else:
220 lines.append("No findings.")
221
222 with open(os.environ["RUNNER_TEMP"] + "/threatcrush-comment.md", "w") as handle:
223 handle.write("\n".join(lines) + "\n")
224 PYEOF
225 env:
226 SCAN_STATUS: ${{ steps.scan.outputs.status }}
227
228 - name: Write report to job summary
229 if: always()
230 run: cat "$RUNNER_TEMP/threatcrush-comment.md" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
231
232 - name: Upload SARIF artifact
233 if: always()
234 uses: actions/upload-artifact@v4
235 with:
236 name: threatcrush-sarif
237 path: threatcrush.sarif
238 retention-days: 30
239
240 # Best-effort, exactly as vu1nz-scan.yml treats its own comment step.
241 # `pull_request` gives fork PRs a read-only token, so this 403s on fork
242 # submissions — the report is in the job summary either way, and the
243 # scan's pass/fail is decided by the scan step, not by whether a comment
244 # posted. Deliberately NOT switching to pull_request_target to get a
245 # writable token: that event runs with repository secrets in scope
246 # against a checkout of untrusted contributor code.
247 - name: Comment on PR
248 if: always() && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]'
249 continue-on-error: true
250 uses: actions/github-script@v7
251 with:
252 script: |
253 const fs = require('fs');
254 let body;
255 try {
256 body = fs.readFileSync(`${process.env.RUNNER_TEMP}/threatcrush-comment.md`, 'utf8');
257 } catch {
258 body = '## ThreatCrush Security Scan\n\nScan completed but the report could not be read.';
259 }
260
261 try {
262 const { data: comments } = await github.rest.issues.listComments({
263 issue_number: context.issue.number,
264 owner: context.repo.owner,
265 repo: context.repo.repo,
266 });
267 const existing = comments.find(
268 (c) => c.user.type === 'Bot' && c.body.includes('ThreatCrush Security Scan'),
269 );
270
271 if (existing) {
272 await github.rest.issues.updateComment({
273 comment_id: existing.id,
274 owner: context.repo.owner,
275 repo: context.repo.repo,
276 body,
277 });
278 } else {
279 await github.rest.issues.createComment({
280 issue_number: context.issue.number,
281 owner: context.repo.owner,
282 repo: context.repo.repo,
283 body,
284 });
285 }
286 } catch (err) {
287 core.warning(
288 `Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. ` +
289 'Findings are in the job summary.',
290 );
291 }
.github/workflows/vu1nz-scan.yml+219−0
@@ -0,0 +1,219 @@
1# Managed by sh1pt Actions Fleet
2# pack: vu1nz-scan@1.0.1
3# install: sh1pt-actions-store
4# hash: sha256:69dca6b225e64533cd02750003f56fd1ccb4f178c37f48d4398a8254a3fe887b
5name: vu1nz security scan
6
7on:
8 pull_request:
9
10permissions:
11 contents: read
12 pull-requests: write
13
14jobs:
15 review:
16 name: Review PR for security vulnerabilities
17 runs-on: ubuntu-latest
18 timeout-minutes: 15
19
20 steps:
21 - uses: actions/checkout@v4
22
23 - uses: actions/setup-python@v5
24 with:
25 python-version: "3.12"
26
27 - name: Install vu1nz
28 run: pip install --quiet git+https://github.com/profullstack/vu1nz-gh-actions.git
29
30 - name: Load env file
31 env:
32 ENV_FILE: ${{ secrets.ENV_FILE }}
33 run: |
34 echo "$ENV_FILE" > "$RUNNER_TEMP/.env"
35 echo "Keys in ENV_FILE:"
36 grep -oP '^[A-Z_]+(?==)' "$RUNNER_TEMP/.env" || echo "(no keys found or different format)"
37 ANTHROPIC_API_KEY=$(grep -E '^ANTHROPIC_API_KEY=' "$RUNNER_TEMP/.env" | head -1 | sed 's/^ANTHROPIC_API_KEY=//')
38 if [ -n "$ANTHROPIC_API_KEY" ]; then
39 echo "::add-mask::$ANTHROPIC_API_KEY"
40 echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" >> "$GITHUB_ENV"
41 echo "ANTHROPIC_API_KEY found and exported"
42 else
43 echo "::warning::ANTHROPIC_API_KEY not found in ENV_FILE"
44 fi
45
46 - name: Review PR
47 id: review
48 env:
49 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50 NO_COLOR: "1"
51 TERM: dumb
52 run: |
53 vu1nz review-pr main \
54 ${{ github.repository }} \
55 ${{ github.event.pull_request.number }} \
56 --token "$GITHUB_TOKEN" \
57 --json \
58 | tee "$RUNNER_TEMP/vu1nz-review-raw.txt" || true
59
60 python3 -c "
61 import json, re, sys
62 raw = open('$RUNNER_TEMP/vu1nz-review-raw.txt').read()
63 raw = re.sub(r'\x1b\[[0-9;]*m', '', raw)
64 start = raw.find('{')
65 if start >= 0:
66 obj, _ = json.JSONDecoder(strict=False).raw_decode(raw, start)
67 json.dump(obj, sys.stdout)
68 else:
69 print('{}')
70 " > "$RUNNER_TEMP/vu1nz-review.json"
71
72 - name: Build PR comment
73 id: comment
74 run: |
75 python3 << 'PYEOF'
76 import json, os, sys
77
78 review_file = os.environ.get("RUNNER_TEMP", "") + "/vu1nz-review.json"
79 comment_file = os.environ.get("RUNNER_TEMP", "") + "/vu1nz-comment.md"
80
81 try:
82 with open(review_file) as f:
83 data = json.loads(f.read(), strict=False)
84 except Exception as e:
85 print(f"::warning::Could not parse review results: {e}")
86 with open(comment_file, "w") as f:
87 f.write("## vu1nz Security Review\n\nCould not parse review results.\n")
88 sys.exit(0)
89
90 findings = data.get("findings", [])
91 analysis = data.get("analysis", "")
92 pr = data.get("pr_number", "?")
93 total = len(findings)
94
95 counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
96 for finding in findings:
97 sev = finding.get("severity", "").lower()
98 if sev in counts:
99 counts[sev] += 1
100
101 has_hc = counts["critical"] > 0 or counts["high"] > 0
102
103 lines = ["## vu1nz Security Review", ""]
104 lines.append(f"**{total}** finding(s) in PR #{pr}")
105 lines.append("")
106
107 badge_parts = []
108 for sev in ("critical", "high", "medium", "low"):
109 if counts[sev] > 0:
110 badge_parts.append(f"**{sev.upper()}**: {counts[sev]}")
111 if badge_parts:
112 lines.append(" | ".join(badge_parts))
113 lines.append("")
114
115 if has_hc:
116 lines.append("> **High or critical findings - review before merging.**")
117 lines.append("")
118
119 if findings:
120 lines.append("### Findings")
121 lines.append("")
122 lines.append("| Severity | File | Issue | Suggestion |")
123 lines.append("|----------|------|-------|------------|")
124 for f in findings:
125 sev = f.get("severity", "?").upper()
126 file = f.get("file", "N/A")
127 issue = f.get("issue", "").replace("\n", " ")[:150]
128 suggestion = f.get("suggestion", "").replace("\n", " ")[:150]
129 lines.append(f"| {sev} | `{file}` | {issue} | {suggestion} |")
130 lines.append("")
131 else:
132 lines.append("No security issues found.")
133 lines.append("")
134
135 if analysis:
136 lines.append("<details><summary>Full AI Analysis</summary>")
137 lines.append("")
138 lines.append(analysis)
139 lines.append("")
140 lines.append("</details>")
141
142 body = "\n".join(lines)
143 with open(comment_file, "w") as f:
144 f.write(body)
145
146 with open(os.environ.get("GITHUB_OUTPUT", ""), "a") as out:
147 out.write(f"total={total}\n")
148 out.write(f"has_high_critical={'true' if has_hc else 'false'}\n")
149
150 if has_hc:
151 print(f"::error::vu1nz found high/critical vulnerabilities in PR code")
152 sys.exit(1)
153
154 print(f"::notice::vu1nz review: {total} finding(s), no high/critical issues")
155 PYEOF
156
157 - name: Write report to job summary
158 if: always()
159 run: |
160 if [ -f "$RUNNER_TEMP/vu1nz-comment.md" ]; then
161 cat "$RUNNER_TEMP/vu1nz-comment.md" >> "$GITHUB_STEP_SUMMARY"
162 else
163 echo "## vu1nz Security Review" >> "$GITHUB_STEP_SUMMARY"
164 echo "" >> "$GITHUB_STEP_SUMMARY"
165 echo "Scan completed but could not read results." >> "$GITHUB_STEP_SUMMARY"
166 fi
167
168 - name: Comment on PR
169 # Best-effort only. Skip for Dependabot (read-only token can't comment)
170 # and never fail the job if posting the comment errors — the scan's
171 # pass/fail is decided by the "Build PR comment" step, and findings are
172 # always written to the job summary.
173 if: always() && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]'
174 continue-on-error: true
175 uses: actions/github-script@v7
176 with:
177 script: |
178 const fs = require('fs');
179 const commentFile = `${process.env.RUNNER_TEMP}/vu1nz-comment.md`;
180 let body;
181 try {
182 body = fs.readFileSync(commentFile, 'utf8');
183 } catch {
184 body = '## vu1nz Security Review\n\nScan completed but could not read results.';
185 }
186
187 try {
188 const { data: comments } = await github.rest.issues.listComments({
189 issue_number: context.issue.number,
190 owner: context.repo.owner,
191 repo: context.repo.repo,
192 });
193
194 const existing = comments.find(c =>
195 c.user.type === 'Bot' && c.body.includes('vu1nz Security Review')
196 );
197
198 if (existing) {
199 await github.rest.issues.updateComment({
200 comment_id: existing.id,
201 owner: context.repo.owner,
202 repo: context.repo.repo,
203 body: body,
204 });
205 } else {
206 await github.rest.issues.createComment({
207 issue_number: context.issue.number,
208 owner: context.repo.owner,
209 repo: context.repo.repo,
210 body: body,
211 });
212 }
213 } catch (err) {
214 // Posting the comment is best-effort. Read-only tokens return 403
215 // and transient GitHub outages return 503 (the "Unicorn" HTML
216 // page); neither should fail the scan. Findings are in the job
217 // summary regardless.
218 core.warning(`Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. Findings are in the job summary.`);
219 }