mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +00:00
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>
210 lines
7.9 KiB
Python
210 lines
7.9 KiB
Python
#!/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())
|