# 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.', ); }