mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
ci: add ThreatCrush security scan (#105)
Installs threatcrush-scan@1.1.0 from the sh1pt Actions Store. Scans pull requests for hardcoded credentials, injection, SSRF, unsafe deserialisation and dependency tampering; uploads SARIF to the Security tab. Report-only — it will not fail a pull request. Set the pack's failOn input to critical,high once the existing findings are triaged. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
262f2a74cc
commit
595eec118b
2 changed files with 505 additions and 0 deletions
210
.github/threatcrush-to-sarif.py
vendored
Normal file
210
.github/threatcrush-to-sarif.py
vendored
Normal file
|
|
@ -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())
|
||||||
295
.github/workflows/threatcrush-scan.yml
vendored
Normal file
295
.github/workflows/threatcrush-scan.yml
vendored
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
# 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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue