ci: add ThreatCrush security scan #1
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Managed by sh1pt Actions Fleet | |
| # pack: threatcrush-scan@1.1.0 | |
| # install: sh1pt-actions-store | |
| # hash: sha256:8b7430185637af9aea9bef6223b7ed1754a7f1c5e0d78ad058d47832a1ac46d3 | |
| name: threatcrush security scan | |
| on: | |
| pull_request: | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| security-events: write | |
| jobs: | |
| scan: | |
| name: Scan for credentials and vulnerable patterns | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| # An unretried `npm i -g` is a network call to a registry that decides | |
| # whether a security gate runs at all. Retry before giving up; a | |
| # transient registry blip is not a security signal and should not read | |
| # like one. | |
| - name: Install ThreatCrush | |
| run: | | |
| for attempt in 1 2 3; do | |
| if npm install -g "@profullstack/threatcrush@latest"; then | |
| exit 0 | |
| fi | |
| delay=$((attempt * 10)) | |
| echo "::warning::ThreatCrush install attempt ${attempt}/3 failed; retrying in ${delay}s" | |
| sleep "${delay}" | |
| done | |
| echo "::error::ThreatCrush install failed after 3 attempts" | |
| exit 1 | |
| # Recorded into every run log so a release that changes the interface | |
| # shows up immediately, rather than silently scoring zero. | |
| - name: Record the CLI interface | |
| run: | | |
| threatcrush --version || true | |
| threatcrush scan --help || true | |
| # Which interface does the installed CLI actually have? | |
| # | |
| # Determined up front rather than inferred from an exit code, because | |
| # exit codes cannot tell the two failures apart. `0.2.2` has no | |
| # `--format`: the scan died with `error: unknown option '--format'` and | |
| # commander exited 1 — the same code the CLI uses for "findings at or | |
| # above --fail-on". Read as a result, that produced a green check and a | |
| # "0 findings" comment on a repository nothing had scanned. | |
| - name: Detect the CLI output interface | |
| id: iface | |
| run: | | |
| if threatcrush scan --help 2>&1 | grep -q -- '--format'; then | |
| echo "native=true" >> "$GITHUB_OUTPUT" | |
| echo "Native SARIF output available." | |
| else | |
| echo "native=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::CLI $(threatcrush --version 2>/dev/null || echo unknown) predates --format; converting terminal output instead." | |
| fi | |
| - name: Scan | |
| id: scan | |
| run: | | |
| set -o pipefail | |
| FAIL_ON="" | |
| SCAN_PATH="." | |
| code=0 | |
| if [ "${{ steps.iface.outputs.native }}" = "true" ]; then | |
| ARGS=(scan "$SCAN_PATH" --format sarif --output threatcrush.sarif) | |
| if [ -n "$FAIL_ON" ]; then | |
| ARGS+=(--fail-on "$FAIL_ON") | |
| fi | |
| threatcrush "${ARGS[@]}" || code=$? | |
| else | |
| # Compatibility path for CLIs older than native SARIF. The | |
| # converter fails closed: if it cannot recognise the output it | |
| # exits non-zero and writes nothing, so an unparseable scan can | |
| # never arrive downstream looking like a clean one. | |
| threatcrush scan "$SCAN_PATH" 2>&1 | tee threatcrush-output.txt || true | |
| PREFIX="" | |
| if [ "$SCAN_PATH" != "." ]; then | |
| # Paths in terminal output are relative to the scan root. Left | |
| # unprefixed they resolve to nothing in the repository view, and | |
| # every finding reads as out-of-scope. | |
| PREFIX="$SCAN_PATH" | |
| fi | |
| python3 .github/threatcrush-to-sarif.py \ | |
| --input threatcrush-output.txt \ | |
| --output threatcrush.sarif \ | |
| --path-prefix "$PREFIX" \ | |
| --tool-version "$(threatcrush --version 2>/dev/null || echo unknown)" \ | |
| --fail-on "$FAIL_ON" || code=$? | |
| fi | |
| # The SARIF file is the evidence that a scan happened, and it is the | |
| # only evidence worth trusting. An exit code says what the process | |
| # thought; the file says what it produced. Absent the file there is | |
| # nothing to report, and reporting nothing as "no findings" is the | |
| # failure this whole workflow is arranged to avoid. | |
| if [ ! -s threatcrush.sarif ]; then | |
| echo "status=error" >> "$GITHUB_OUTPUT" | |
| echo "::error::ThreatCrush produced no SARIF (exit ${code}) — this diff was NOT scanned" | |
| exit 1 | |
| fi | |
| case "$code" in | |
| 0) echo "status=clean" >> "$GITHUB_OUTPUT" ;; | |
| # Exit 1 *with* a SARIF file is the documented "findings at or | |
| # above --fail-on" result. Without one it was caught above. The CLI | |
| # only returns 1 when --fail-on was passed, so propagate it: a gate | |
| # that records the finding and then lets the job pass is not a gate. | |
| 1) | |
| echo "status=findings" >> "$GITHUB_OUTPUT" | |
| exit 1 | |
| ;; | |
| *) | |
| echo "status=error" >> "$GITHUB_OUTPUT" | |
| echo "::error::ThreatCrush scan failed with exit code ${code} — results may be incomplete" | |
| exit "$code" | |
| ;; | |
| esac | |
| # Reached only when the scan step already failed the job. The empty run | |
| # exists so the upload does not error on a missing file and bury the real | |
| # cause; it is not a result. The scan step has already set status=error, | |
| # so the report says NOT RUN rather than rendering this as a clean scan. | |
| - name: Ensure SARIF exists | |
| if: always() | |
| run: | | |
| if [ ! -f threatcrush.sarif ]; then | |
| cat > threatcrush.sarif <<'JSON' | |
| { | |
| "version": "2.1.0", | |
| "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", | |
| "runs": [{ "tool": { "driver": { "name": "ThreatCrush", "rules": [] } }, "results": [] }] | |
| } | |
| JSON | |
| fi | |
| - name: Upload to the Security tab | |
| if: always() && 'true' == 'true' | |
| continue-on-error: true | |
| uses: github/codeql-action/upload-sarif@v3 | |
| with: | |
| sarif_file: threatcrush.sarif | |
| category: threatcrush | |
| - name: Build the report | |
| if: always() | |
| run: | | |
| python3 << 'PYEOF' | |
| import json, os | |
| status = os.environ.get("SCAN_STATUS", "") | |
| try: | |
| with open("threatcrush.sarif") as handle: | |
| results = json.load(handle)["runs"][0]["results"] | |
| except Exception as err: | |
| results = None | |
| print(f"::warning::could not read SARIF: {err}") | |
| lines = ["## ThreatCrush Security Scan", ""] | |
| # Fail closed: render findings only on positive evidence that a scan | |
| # completed. Testing for `status == "error"` was fail-open and got | |
| # caught immediately — when the capability check failed, the scan | |
| # step was *skipped*, so `status` was the empty string rather than | |
| # "error", and the comment cheerfully reported "0 findings" for a | |
| # scan that never started. Any state that is not a known-good | |
| # outcome is NOT RUN. | |
| if status not in ("clean", "findings") or results is None: | |
| # Never render "no issues found" for a scan that did not finish. | |
| # An unexamined diff is not a clean one, and the two are | |
| # indistinguishable to whoever reads the comment. | |
| lines += [ | |
| "**NOT RUN** — the scan did not complete, so this diff was not examined.", | |
| "This is not a clean result. See the job log.", | |
| ] | |
| else: | |
| counts = {"error": 0, "warning": 0, "note": 0} | |
| for result in results: | |
| level = result.get("level", "warning") | |
| if level in counts: | |
| counts[level] += 1 | |
| lines.append(f"**{len(results)}** finding(s)") | |
| lines.append("") | |
| if results: | |
| badges = [] | |
| if counts["error"]: | |
| badges.append(f"**HIGH/CRITICAL**: {counts['error']}") | |
| if counts["warning"]: | |
| badges.append(f"**MEDIUM**: {counts['warning']}") | |
| if counts["note"]: | |
| badges.append(f"**LOW**: {counts['note']}") | |
| if badges: | |
| lines += [" | ".join(badges), ""] | |
| lines += ["| Severity | Rule | Location |", "|---|---|---|"] | |
| for result in results[:50]: | |
| location = result["locations"][0]["physicalLocation"] | |
| uri = location["artifactLocation"]["uri"] | |
| line_no = location.get("region", {}).get("startLine", 1) | |
| label = {"error": "HIGH", "warning": "MEDIUM", "note": "LOW"}.get( | |
| result.get("level", "warning"), "INFO" | |
| ) | |
| lines.append(f"| {label} | `{result.get('ruleId','?')}` | `{uri}`:{line_no} |") | |
| if len(results) > 50: | |
| # Say so. A silent truncation reads as "that was everything". | |
| lines += ["", f"_…and {len(results) - 50} more. Full results in the Security tab._"] | |
| lines += ["", "Snippets are redacted; ThreatCrush never prints matched credential material."] | |
| else: | |
| lines.append("No findings.") | |
| with open(os.environ["RUNNER_TEMP"] + "/threatcrush-comment.md", "w") as handle: | |
| handle.write("\n".join(lines) + "\n") | |
| PYEOF | |
| env: | |
| SCAN_STATUS: ${{ steps.scan.outputs.status }} | |
| - name: Write report to job summary | |
| if: always() | |
| run: cat "$RUNNER_TEMP/threatcrush-comment.md" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true | |
| - name: Upload SARIF artifact | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: threatcrush-sarif | |
| path: threatcrush.sarif | |
| retention-days: 30 | |
| # Best-effort. `pull_request` gives fork PRs a read-only token, so this | |
| # 403s on fork submissions — the report is in the job summary either way, | |
| # and the scan's pass/fail is decided by the scan step, not by whether a | |
| # comment posted. Deliberately NOT switching to pull_request_target to | |
| # get a writable token: that event runs with repository secrets in scope | |
| # against a checkout of untrusted contributor code. | |
| - name: Comment on PR | |
| if: always() && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' | |
| continue-on-error: true | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| let body; | |
| try { | |
| body = fs.readFileSync(`${process.env.RUNNER_TEMP}/threatcrush-comment.md`, 'utf8'); | |
| } catch { | |
| body = '## ThreatCrush Security Scan\n\nScan completed but the report could not be read.'; | |
| } | |
| try { | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| }); | |
| const existing = comments.find( | |
| (c) => c.user.type === 'Bot' && c.body.includes('ThreatCrush Security Scan'), | |
| ); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| comment_id: existing.id, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body, | |
| }); | |
| } | |
| } catch (err) { | |
| core.warning( | |
| `Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. ` + | |
| 'Findings are in the job summary.', | |
| ); | |
| } |