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
5+name: CoinPayPortal invoice command
6+
7+on:
8+ issue_comment:
9+ types: [created]
10+
11+permissions:
12+ issues: write
13+ pull-requests: write
14+
15+concurrency:
16+ group: coinpay-${{ github.workflow }}-${{ github.event.issue.number }}
17+ cancel-in-progress: false
18+
19+jobs:
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.
11+name: threatcrush security scan
12+
13+on:
14+ pull_request:
15+
16+permissions:
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.
22+concurrency:
23+ group: threatcrush-${{ github.ref }}
24+ cancel-in-progress: true
25+
26+jobs:
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
5+name: vu1nz security scan
6+
7+on:
8+ pull_request:
9+
10+permissions:
11+ contents: read
12+ pull-requests: write
13+
14+jobs:
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+ }