diff --git a/.github/scripts/threatcrush-to-sarif.py b/.github/scripts/threatcrush-to-sarif.py new file mode 100644 index 00000000..e6c63207 --- /dev/null +++ b/.github/scripts/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 6bedfd3e..1ce8b3bc 100644 --- a/.github/workflows/threatcrush-scan.yml +++ b/.github/workflows/threatcrush-scan.yml @@ -1,229 +1,254 @@ +# Managed by sh1pt Actions Fleet +# pack: threatcrush-scan@1.1.0 +# install: sh1pt-actions-store +# hash: sha256:581c5abba03af662b4bee6926f6e30954ad7338b28309b552344b00898d57089 name: threatcrush security scan -# Scans pull requests with the ThreatCrush CLI. -# -# The CLI runs *inside the runner container* — installed from npm and executed -# locally. Nothing is uploaded and no scanning API is called; ThreatCrush needs -# no account for local code scans (see packages/scanners/threatcrush). -# -# Companion to vu1nz-scan.yml, which is AI-backed and reviews the PR diff. -# This one is a local static/secrets scan, so the two are complementary rather -# than redundant. - on: pull_request: permissions: contents: read pull-requests: write - -concurrency: - group: threatcrush-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + security-events: write jobs: scan: - name: Scan PR for vulnerabilities and secrets + name: Scan for credentials and vulnerable patterns runs-on: ubuntu-latest timeout-minutes: 15 steps: - # Full history so the PR's changed files can be resolved against the base. - uses: actions/checkout@v4 - with: - fetch-depth: 0 - # Node 22 matches mise.toml. It also matters here: the CLI depends on - # better-sqlite3 ^11.7.0, which publishes prebuilt binaries for ABI 127 - # (Node 22) but not ABI 137 (Node 24). On Node 24 the install falls back - # to a node-gyp source build and fails. - uses: actions/setup-node@v4 with: - node-version: 22 + 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 - if npm install -g @profullstack/threatcrush@latest; then - echo "available=true" >> "$GITHUB_OUTPUT" - threatcrush --version + 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 "available=false" >> "$GITHUB_OUTPUT" - echo "::error title=ThreatCrush unavailable::Could not install \ - @profullstack/threatcrush. Nothing was scanned." - exit 1 + echo "native=false" >> "$GITHUB_OUTPUT" + echo "::notice::CLI $(threatcrush --version 2>/dev/null || echo unknown) predates --format; converting terminal output instead." fi - - name: Collect changed files - id: changed - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - git diff --name-only --diff-filter=d "$BASE_SHA" "$HEAD_SHA" \ - > "$RUNNER_TEMP/changed.txt" || : > "$RUNNER_TEMP/changed.txt" - echo "count=$(wc -l < "$RUNNER_TEMP/changed.txt")" >> "$GITHUB_OUTPUT" - echo "Changed files:"; cat "$RUNNER_TEMP/changed.txt" - - - name: Run ThreatCrush + - name: Scan id: scan - env: - NO_COLOR: "1" - TERM: dumb run: | - set -uo pipefail - - # Scanned from the repository root so reported paths are already - # repository-relative and line up with the changed-file list. - # Dependencies are deliberately NOT installed: node_modules would add - # scan time and third-party noise, and the PR only changes source. - threatcrush scan . > "$RUNNER_TEMP/threatcrush.txt" 2>&1 - STATUS=$? - echo "exit_code=$STATUS" >> "$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 + # 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/scripts/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 CLI exits non-zero when it finds issues; only >1 is operational - # failure (matching packages/scanners/threatcrush). - if [ "$STATUS" -gt 1 ]; then - echo "::error title=Scan failed::threatcrush exited $STATUS" - tail -40 "$RUNNER_TEMP/threatcrush.txt" + # 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 - echo "::group::raw scanner output" - cat "$RUNNER_TEMP/threatcrush.txt" - echo "::endgroup::" - - - name: Build PR comment - id: comment - env: - PR_NUMBER: ${{ github.event.pull_request.number }} + 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: | - python3 << 'PYEOF' - import json, os, re, sys - - tmp = os.environ["RUNNER_TEMP"] - raw = open(f"{tmp}/threatcrush.txt", encoding="utf-8", errors="replace").read() - raw = re.sub(r"\x1b\[[0-9;]*m", "", raw) - - changed = { - line.strip() - for line in open(f"{tmp}/changed.txt", encoding="utf-8") - if line.strip() + 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 - # ThreatCrush prints a multi-line block per finding. `scan` has no - # machine-readable mode -- only `harden` accepts --json -- so the text - # output is parsed: - # - # CRITICAL AWS Access Key - # File: path/to/file.env:23 - # Info: Possible AWS Access Key detected - # Code: **************** - # - # Severity is bare for CRITICAL and bracketed for the rest. "Code:" is - # a redacted excerpt, not a location, so it is skipped. - header_re = re.compile(r"^\s*\[?(CRITICAL|HIGH|MEDIUM|LOW|INFO)\]?\s{1,}(\S.*?)\s*$") - file_re = re.compile(r"^\s*File:\s*(\S+?)(?::(\d+))?\s*$") - info_re = re.compile(r"^\s*Info:\s*(\S.*?)\s*$") - - findings = [] - severity, title = "MEDIUM", "Finding" - for line in raw.splitlines(): - stripped = line.strip() - header = header_re.match(line) - if header and not stripped.startswith(("File:", "Info:", "Code:")): - severity, title = header.group(1).upper(), header.group(2) - continue - located = file_re.match(line) - if located: - path, line_no = located.groups() - findings.append({ - "severity": severity, - "title": title, - "file": path.lstrip("./"), - # :0 means a whole-file finding. - "line": int(line_no) if line_no else 0, - "info": "", - }) - continue - info = info_re.match(line) - if info and findings: - findings[-1]["info"] = info.group(1) - - in_pr = [f for f in findings if f["file"] in changed] - pre_existing = len(findings) - len(in_pr) - - order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4} - in_pr.sort(key=lambda f: (order.get(f["severity"], 9), f["file"], f["line"])) - - counts = {} - for finding in in_pr: - counts[finding["severity"]] = counts.get(finding["severity"], 0) + 1 - blocking = counts.get("CRITICAL", 0) + counts.get("HIGH", 0) - - out = ["## ThreatCrush security scan", ""] - out.append( - f"**{len(in_pr)}** finding(s) in files changed by this PR " - f"(scanned {len(changed)} changed file(s); {pre_existing} further " - f"finding(s) elsewhere in the repository are not attributed to this PR)." - ) - out.append("") - - badges = [f"**{sev}**: {counts[sev]}" for sev in - ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO") if counts.get(sev)] - if badges: - out += [" | ".join(badges), ""] - - if blocking: - out += ["> **High or critical findings in changed files — review before merging.**", ""] + - 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 - if in_pr: - out += ["### Findings", "", - "| Severity | File | Issue |", "|---|---|---|"] - for finding in in_pr: - where = finding["file"] + (f":{finding['line']}" if finding["line"] else "") - detail = finding["info"] or finding["title"] - out.append(f"| {finding['severity']} | `{where}` | {finding['title']} — {detail} |") - out.append("") + - 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: - out += ["No new findings in the files this PR changes.", ""] - - out.append( - "ThreatCrush CLI ran locally in the runner " - "(`npm i -g @profullstack/threatcrush`); no code left the container." - ) - - body = "\n".join(out) - with open(f"{tmp}/threatcrush-comment.md", "w", encoding="utf-8") as handle: - handle.write(body) - - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle: - handle.write(f"total={len(in_pr)}\n") - handle.write(f"blocking={'true' if blocking else 'false'}\n") - - if blocking: - print(f"::error::ThreatCrush found {blocking} high/critical finding(s) in changed files") - sys.exit(1) - print(f"::notice::ThreatCrush: {len(in_pr)} finding(s) in changed files, none high/critical") + 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: | - if [ -f "$RUNNER_TEMP/threatcrush-comment.md" ]; then - cat "$RUNNER_TEMP/threatcrush-comment.md" >> "$GITHUB_STEP_SUMMARY" - else - echo "## ThreatCrush security scan" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Scan did not complete; see the job log." >> "$GITHUB_STEP_SUMMARY" - fi + 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 - # Best-effort, matching vu1nz-scan.yml. Fork PRs and Dependabot get a - # read-only token, so posting fails with 403; the findings are in the job - # summary either way and the pass/fail decision belongs to the previous - # step. 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 @@ -234,7 +259,7 @@ jobs: try { body = fs.readFileSync(`${process.env.RUNNER_TEMP}/threatcrush-comment.md`, 'utf8'); } catch { - body = '## ThreatCrush security scan\n\nScan did not complete; see the job log.'; + body = '## ThreatCrush Security Scan\n\nScan completed but the report could not be read.'; } try { @@ -243,9 +268,8 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, }); - - const existing = comments.find(c => - c.user.type === 'Bot' && c.body.includes('ThreatCrush security scan') + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes('ThreatCrush Security Scan'), ); if (existing) { @@ -264,5 +288,8 @@ jobs: }); } } catch (err) { - core.warning(`Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. Findings are in the job summary.`); + core.warning( + `Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. ` + + 'Findings are in the job summary.', + ); }