diff --git a/.github/threatcrush-to-sarif.py b/.github/threatcrush-to-sarif.py new file mode 100644 index 0000000..e6c6320 --- /dev/null +++ b/.github/threatcrush-to-sarif.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Convert ThreatCrush terminal output to SARIF 2.1.0. + +Compatibility shim for CLI versions older than native ``--format sarif``. +When the CLI can emit SARIF itself the workflow uses that and never runs this +file; parsing a human-readable stream is strictly worse and exists only so a +repository is not left unscanned while waiting for a release. + +It **fails closed**. If it cannot recognise the output it exits non-zero and +dumps what it saw. Emitting empty SARIF instead would report "0 findings", +which is indistinguishable from a clean scan and is the single most expensive +thing a security tool can get wrong. + +Three details of the format, each of which is load-bearing: + +* Severity is bare for ``CRITICAL`` and bracketed for ``[HIGH]``/``[MEDIUM]``/ + ``[LOW]``. One regex shape misses half the findings. +* ``File:`` paths are relative to the scan root, not the repository root. Left + unprefixed, every finding resolves to nothing in the consumer's view of the + repo. Hence ``--path-prefix``. +* Whole-file findings report line ``:0``. SARIF requires ``startLine >= 1``. + +``Code:`` lines are redacted excerpts of the match. They are skipped rather +than parsed, both because matching them would double-count every finding and +because a redacted excerpt tells a reader nothing the ``Info:`` line does not. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys + +ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") + +# ` CRITICAL AWS Access Key` / ` [HIGH] Sensitive File` +SEVERITY_LINE = re.compile(r"^\s*(?:\[(CRITICAL|HIGH|MEDIUM|LOW|INFO)\]|(CRITICAL))\s+(.+?)\s*$") +FILE_LINE = re.compile(r"^\s*File:\s*(.+?):(\d+)\s*$") +INFO_LINE = re.compile(r"^\s*Info:\s*(.+?)\s*$") + +# Proof that a scan ran to completion. Without one of these we are looking at a +# crash, a help screen, or an unrecognised release — never at a clean result. +FOOTER = re.compile(r"^\s*(?:\d+\s+issue\(s\)\s+found|.*No security issues found)") + +LEVELS = {"CRITICAL": "error", "HIGH": "error", "MEDIUM": "warning", "LOW": "note", "INFO": "none"} +SECURITY_SEVERITY = {"CRITICAL": "9.0", "HIGH": "7.0", "MEDIUM": "5.0", "LOW": "3.0", "INFO": "1.0"} +RANK = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4} + + +class Unrecognised(Exception): + """The output did not look like a completed ThreatCrush scan.""" + + +def rule_id(title: str) -> str: + """Derive a stable rule id from a finding title. + + Old CLIs print `AWS Access Key`, not `secret-aws-access-key`. Slugifying + keeps SARIF results groupable and keeps fingerprints stable across runs, + which is what stops the Security tab treating every run as brand-new alerts. + """ + slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") + return f"threatcrush-{slug}" if slug else "threatcrush-finding" + + +def parse(text: str) -> list[dict]: + lines = ANSI.sub("", text).splitlines() + if not any(FOOTER.match(line) for line in lines): + raise Unrecognised("no scan-completion footer found") + + findings: list[dict] = [] + pending: dict | None = None + + for line in lines: + severity_match = SEVERITY_LINE.match(line) + if severity_match: + severity = severity_match.group(1) or severity_match.group(2) + pending = {"severity": severity.upper(), "title": severity_match.group(3).strip()} + continue + + if pending is None: + continue + + file_match = FILE_LINE.match(line) + if file_match: + pending["file"] = file_match.group(1).strip() + pending["line"] = int(file_match.group(2)) + continue + + info_match = INFO_LINE.match(line) + if info_match and "file" in pending: + pending["message"] = info_match.group(1).strip() + findings.append(pending) + pending = None + + return findings + + +def to_sarif(findings: list[dict], prefix: str, version: str) -> dict: + rules: dict[str, dict] = {} + results = [] + + for finding in findings: + rid = rule_id(finding["title"]) + rules.setdefault( + rid, + { + "id": rid, + "name": rid, + "shortDescription": {"text": finding["title"]}, + "fullDescription": {"text": finding["title"]}, + "defaultConfiguration": {"level": LEVELS[finding["severity"]]}, + "properties": { + "tags": ["security", "threatcrush"], + "security-severity": SECURITY_SEVERITY[finding["severity"]], + }, + }, + ) + + uri = finding["file"].lstrip("./") + if prefix: + uri = f"{prefix.strip('/')}/{uri}" + + results.append( + { + "ruleId": rid, + "level": LEVELS[finding["severity"]], + "message": {"text": finding.get("message", finding["title"])}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": uri, "uriBaseId": "%SRCROOT%"}, + # Clamped: SARIF rejects 0, and a whole-file finding + # has no line to report. + "region": {"startLine": max(1, finding["line"])}, + } + } + ], + "partialFingerprints": { + "primaryLocationLineHash": f"{rid}:{uri}:{max(1, finding['line'])}" + }, + "properties": {"severity": finding["severity"].lower()}, + } + ) + + return { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "ThreatCrush", + "version": version, + "informationUri": "https://threatcrush.com", + "rules": list(rules.values()), + } + }, + "results": results, + "columnKind": "utf16CodeUnits", + } + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", required=True, help="captured `threatcrush scan` output") + parser.add_argument("--output", required=True, help="SARIF file to write") + parser.add_argument("--path-prefix", default="", help="prepended to every file URI") + parser.add_argument("--tool-version", default="unknown") + parser.add_argument("--fail-on", default="", help="comma-separated severities that exit 1") + args = parser.parse_args() + + with open(args.input, encoding="utf-8", errors="replace") as handle: + text = handle.read() + + try: + findings = parse(text) + except Unrecognised as err: + print(f"error: unrecognised ThreatCrush output ({err})", file=sys.stderr) + print("--- first 40 lines ---", file=sys.stderr) + for line in ANSI.sub("", text).splitlines()[:40]: + print(line, file=sys.stderr) + return 2 + + with open(args.output, "w", encoding="utf-8") as handle: + json.dump(to_sarif(findings, args.path_prefix, args.tool_version), handle, indent=2) + handle.write("\n") + + print(f"converted {len(findings)} finding(s) to {args.output}") + + thresholds = [s.strip().lower() for s in args.fail_on.split(",") if s.strip()] + if thresholds: + unknown = [s for s in thresholds if s not in RANK] + if unknown: + # Silently ignoring a typo produces a gate that never fires, which + # looks exactly like a passing build. + print(f"error: unknown severity in --fail-on: {', '.join(unknown)}", file=sys.stderr) + return 2 + floor = min(RANK[s] for s in thresholds) + if any(RANK[f["severity"].lower()] >= floor for f in findings): + print(f"::error::findings at or above {args.fail_on}") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/threatcrush-scan.yml b/.github/workflows/threatcrush-scan.yml index 255a99a..d9190b6 100644 --- a/.github/workflows/threatcrush-scan.yml +++ b/.github/workflows/threatcrush-scan.yml @@ -1,246 +1,295 @@ -name: ThreatCrush PR Scanner - -# Runs the ThreatCrush CLI against every pull request and scores what it found -# against the known vulnerability catalog (PRD §7.1). -# -# Two deliberate differences from the PRD's draft workflow: -# -# 1. `branches:` includes `master`, this repository's actual default branch. -# The draft listed only main/develop, which would have meant the workflow -# never ran on a single PR. -# -# 2. The scanner install is fault-tolerant. If the CLI cannot be fetched the -# job writes a valid empty SARIF and carries on, so the reporting and -# coverage steps still execute and say plainly that the scanner did not -# run. The alternative — a hard failure on step two — leaves every PR with -# a red X and no diagnostic. See docs/SCANNER_INTEGRATION.md. +# 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: - types: [opened, synchronize, reopened] - branches: [master, main, develop] - workflow_dispatch: -# Least privilege: read the code, write the PR comment, upload SARIF. permissions: contents: read pull-requests: write security-events: write -concurrency: - group: threatcrush-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -env: - THREATCRUSH_PACKAGE: '@profullstack/threatcrush' - THREATCRUSH_VERSION: latest - SARIF_FILE: threatcrush-results.sarif - jobs: - threatcrush-scan: - name: Scan PR for Security Issues + scan: + name: Scan for credentials and vulnerable patterns runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - # NOTE: this is a `pull_request` workflow, so for fork PRs the checked-out - # code is untrusted and the token is read-only. That is the correct trade. - # Do NOT switch this to `pull_request_target` to get a writable token — - # that combination executes fork code with repository secrets in scope and - # is one of the most reliably exploited CI misconfigurations there is. - - name: Checkout PR - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false + - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-node@v4 with: - python-version: '3.11' - - # Node 20, not latest. The CLI depends on better-sqlite3, a native module; - # 20 is the newest runtime with reliable prebuilt binaries, so the install - # does not fall back to a node-gyp source build. - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Validate test-case submissions - id: lint - run: bash scripts/validate-test-case.sh + node-version: "20" - - name: Install ThreatCrush CLI - id: install + # 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: | - set -uo pipefail - echo "scanner_available=false" >> "$GITHUB_OUTPUT" - - # ThreatCrush ships as an npm package. https://threatcrush.com/install.sh - # bootstraps mise + node before running the same `npm i -g`, which is - # redundant here because setup-node already provided a runtime. - if npm install -g "${THREATCRUSH_PACKAGE}@${THREATCRUSH_VERSION}"; then - echo "scanner_available=true" >> "$GITHUB_OUTPUT" - echo "installed ${THREATCRUSH_PACKAGE}@${THREATCRUSH_VERSION} from npm" - - # Fallback: the official installer, in case the package layout changes. - elif curl -fsSL --max-time 120 https://threatcrush.com/install.sh | sh; then - echo "scanner_available=true" >> "$GITHUB_OUTPUT" - echo "installed ThreatCrush via threatcrush.com/install.sh" - - else - echo "::warning title=ThreatCrush unavailable::Could not install \ - ${THREATCRUSH_PACKAGE} from npm or threatcrush.com/install.sh. The scan step \ - will be skipped and this run will report zero findings. This is an \ - infrastructure problem, not a detection result." - fi - - command -v threatcrush >/dev/null 2>&1 && threatcrush --version || true + 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 - # The PRD assumed this CLI's flags; they have never been verified against a - # real binary. Print the interface so the log is the source of truth, and - # the scan step below can be corrected from evidence rather than guesswork. + # 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 - if: steps.install.outputs.scanner_available == 'true' - continue-on-error: true run: | - echo "::group::threatcrush --help"; threatcrush --help || true; echo "::endgroup::" - echo "::group::threatcrush scan --help"; threatcrush scan --help || true; echo "::endgroup::" + threatcrush --version || true + threatcrush scan --help || true - # The real interface, confirmed from the CLI bundle: - # - # .command("scan").argument("[path]", "Path to scan", ".") - # - # No options at all. The PRD's --format/--output/--config/--fail-on do not - # exist, and it scans a PATH, not a pull request URL — which answers PRD - # Open Question 1: no, there is no native PR-level scanning. + # Which interface does the installed CLI actually have? # - # Diff-only scoping is therefore done here rather than by the scanner: on a - # pull request, scan just the changed files. - - name: Run ThreatCrush Scan + # 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 - if: steps.install.outputs.scanner_available == 'true' run: | - set -uo pipefail - - # Always scan the whole corpus, never a per-file list. The CLI reports - # paths relative to the directory it was given, so a fixed root keeps - # --path-prefix correct; a changed-files list would make the prefix - # vary per invocation. The corpus is 31 files and scans in seconds, so - # diff-only scoping buys nothing here. - SCAN_ROOT=vulns - echo "Scanning: $SCAN_ROOT" - - threatcrush scan "$SCAN_ROOT" > threatcrush-output.txt 2>&1 - echo "scan_exit=$?" >> "$GITHUB_OUTPUT" - echo "::group::raw scanner output"; cat threatcrush-output.txt; echo "::endgroup::" - - # The CLI cannot emit SARIF, so its text output is converted here. - if python3 scripts/threatcrush-to-sarif.py \ - --input threatcrush-output.txt \ - --output "$SARIF_FILE" \ - --path-prefix "$SCAN_ROOT"; then - echo "sarif_produced=true" >> "$GITHUB_OUTPUT" + 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 - echo "sarif_produced=false" >> "$GITHUB_OUTPUT" + # 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 - continue-on-error: true - env: - THREATCRUSH_API_KEY: ${{ secrets.THREATCRUSH_API_KEY }} - - name: Ensure a SARIF file exists + 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: | - # Downstream steps and the Security tab both need well-formed SARIF, - # including on the path where the scanner never ran. - if [ ! -s "$SARIF_FILE" ]; then - echo "no SARIF produced — writing an empty run" - cat > "$SARIF_FILE" <<'JSON' + if [ ! -f threatcrush.sarif ]; then + cat > threatcrush.sarif <<'JSON' { "version": "2.1.0", - "$schema": "https://json.schemastore.org/sarif-2.1.0.json", - "runs": [ - { - "tool": { - "driver": { - "name": "threatcrush", - "informationUri": "https://threatcrush.com", - "rules": [] - } - }, - "results": [] - } - ] + "$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 - python3 -c "import json,sys; json.load(open('$SARIF_FILE')); print('SARIF parses')" - - name: Upload SARIF to GitHub Security tab - if: always() + - name: Upload to the Security tab + if: always() && 'true' == 'true' + continue-on-error: true uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: ${{ env.SARIF_FILE }} + sarif_file: threatcrush.sarif category: threatcrush - continue-on-error: true - - name: Check expected detections - id: coverage + - name: Build the report if: always() run: | - python3 scripts/validate-coverage.py \ - --sarif "$SARIF_FILE" \ - --catalog vulns/VULNERABILITY_CATALOG.json \ - --output coverage-report.json \ - --markdown coverage-report.md - continue-on-error: true + python3 << 'PYEOF' + import json, os - - name: Parse results and post PR comment - if: always() - run: | - python3 scripts/generate-report.py \ - --sarif "$SARIF_FILE" \ - --coverage coverage-report.json \ - --pr-number "${{ github.event.pull_request.number }}" \ - --repo "${{ github.repository }}" + 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: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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 scan artifacts + - name: Upload SARIF artifact if: always() uses: actions/upload-artifact@v4 with: - name: threatcrush-scan-${{ github.event.pull_request.number || github.run_id }} - path: | - ${{ env.SARIF_FILE }} - threatcrush-output.txt - coverage-report.json - coverage-report.md - scan-summary.md - if-no-files-found: warn + name: threatcrush-sarif + path: threatcrush.sarif retention-days: 30 - # A green check must mean "the corpus was actually scanned". Run 30686062988 - # went green while scanning nothing: the install succeeded, the scan then - # died on an unknown flag, and this gate only looked at the install. It now - # checks that the scan ran AND produced real SARIF. - - name: Fail the job when nothing was actually scanned - if: always() - run: | - FAILED=0 - - if [ "${{ steps.install.outputs.scanner_available }}" != "true" ]; then - echo "::error title=Scanner not installed::The ThreatCrush CLI could not be \ - installed, so nothing was scanned." - FAILED=1 - elif [ "${{ steps.scan.outputs.sarif_produced }}" != "true" ]; then - echo "::error title=Scan produced no results::The CLI installed but the scan \ - did not yield usable SARIF. The findings below are empty because the scan \ - failed, not because the corpus is clean. See the 'raw scanner output' group." - FAILED=1 - fi + # 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.'; + } - [ "$FAILED" = "1" ] && exit 1 - echo "corpus was scanned and SARIF was produced" + 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.', + ); + }