sarif.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. """SARIF 2.1.0 for one scan: the log encoder."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import re
  6. from dataclasses import dataclass
  7. from typing import TYPE_CHECKING, Final, NamedTuple
  8. from urllib.parse import quote
  9. from . import cwe, secret, source
  10. from .finding import LINK_SITE, repository_file
  11. if TYPE_CHECKING:
  12. import uuid
  13. from collections.abc import Collection, Mapping, Sequence
  14. from .finding import Finding, Panel, Record
  15. SCHEMA_ID = (
  16. "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json"
  17. )
  18. # The driver name GitHub keys alert identity on; renaming it orphans every open alert.
  19. TOOL_NAME = "Claude Security Plugin for Claude Code"
  20. TOOL_URI = "https://claude.com/product/claude-security"
  21. PROPERTY_BAG = "claudeSecurityPlugin"
  22. ID_PREFIX = "claude-security-plugin"
  23. FINDING_ID: Final = "claudeSecurityPluginFindingId"
  24. ID_VERSION = "v3"
  25. CONTEXT_LINES = 3
  26. SRCROOT = "%SRCROOT%"
  27. # error is SARIF's highest level, so CRITICAL and HIGH both map to it.
  28. LEVEL = {"CRITICAL": "error", "HIGH": "error", "MEDIUM": "warning", "LOW": "note"}
  29. @dataclass(frozen=True)
  30. class Scan:
  31. """The one scan a log describes: its identity, where it ran, and the repository it names."""
  32. id: uuid.UUID
  33. mode: str
  34. # The scan root below the repository top level, slash-terminated; "" when there is none.
  35. prefix: str
  36. # The credential-free https form of the repository's remote; None when there is not one.
  37. remote: str | None
  38. # The directories the scan was limited to, relative to the scan root; empty for all of it.
  39. scope: tuple[str, ...]
  40. # The commit the scanned tree was exactly at; None when it was dirty, unversioned or unknown.
  41. revision: str | None
  42. def log(
  43. findings: Sequence[Record],
  44. scan: Scan,
  45. tool_version: str | None,
  46. run_properties: Mapping[str, object],
  47. panels: Mapping[str, Panel],
  48. notifications: Sequence[Mapping[str, object]],
  49. ) -> dict[str, object]:
  50. """The SARIF 2.1.0 log for one scan: one run, one rule per category, one result per finding.
  51. `findings` are the records render_report built with placed(): each on the
  52. line its file places it on, carrying its id.
  53. """
  54. filed = [(item, category_of(item)) for item in findings]
  55. categories = list(dict.fromkeys(category for _, category in filed))
  56. index = {category: position for position, category in enumerate(categories)}
  57. driver: dict[str, object] = {
  58. "name": TOOL_NAME,
  59. "organization": "Anthropic",
  60. "informationUri": TOOL_URI,
  61. **({"version": tool_version} if tool_version else {}),
  62. "rules": [rule(category) for category in categories],
  63. }
  64. invocation: dict[str, object] = {"executionSuccessful": True}
  65. if notifications:
  66. invocation["toolExecutionNotifications"] = list(notifications)
  67. base_description = (
  68. "The top level of the scanned repository, or the scanned directory when the scan did "
  69. "not run inside a git checkout."
  70. )
  71. run: dict[str, object] = {
  72. "tool": {"driver": driver},
  73. "automationDetails": {"id": automation_id(scan), "guid": str(scan.id)},
  74. "invocations": [invocation],
  75. "originalUriBaseIds": {SRCROOT: {"description": {"text": base_description}}},
  76. "results": [
  77. result(item, category, index[category], scan, panels.get(item["id"]))
  78. for item, category in filed
  79. ],
  80. "properties": {
  81. PROPERTY_BAG: {
  82. **run_properties,
  83. "target_kind": "git-remote" if scan.remote else "local-path",
  84. }
  85. },
  86. }
  87. if scan.remote:
  88. provenance: dict[str, object] = {"repositoryUri": scan.remote}
  89. if scan.revision:
  90. provenance["revisionId"] = scan.revision
  91. run["versionControlProvenance"] = [provenance]
  92. return {"$schema": SCHEMA_ID, "version": "2.1.0", "runs": [run]}
  93. def automation_id(scan: Scan) -> str:
  94. """The run's automation id: the plugin prefix, the mode, the scan's extent if any, its id."""
  95. category = f"{ID_PREFIX}/{scan.mode}"
  96. if scan.scope:
  97. extent = ",".join(scan.prefix + entry.strip("/") for entry in sorted(scan.scope))
  98. else:
  99. extent = scan.prefix.rstrip("/")
  100. if extent:
  101. category += "/" + quote(uri_bytes(extent))
  102. return f"{category}/{scan.id}"
  103. def category_of(finding: Finding) -> cwe.Category | None:
  104. """The Simplified Mapping entry the finding's CWE rolls up to; None for Uncategorized."""
  105. return cwe.catalog.category(cwe.id_number(finding["cwe_id"]))
  106. def rule_id(category: cwe.Category | None) -> str:
  107. """A rule's id: its entry's CWE id (`CWE-89`), or `uncategorized`."""
  108. return category.id if category is not None else cwe.UNCATEGORIZED.lower()
  109. def rule(category: cwe.Category | None) -> dict[str, object]:
  110. """The reporting descriptor for one entry: the catalog's names, its page, its fixed tags."""
  111. help_text = (
  112. "Each alert's message names the finding's own CWE and states the impact, exploit "
  113. "scenario, preconditions and recommended fix; the finding appears under its F<n> id "
  114. "in CLAUDE-SECURITY-RESULTS.md."
  115. )
  116. if category is None:
  117. return {
  118. "id": rule_id(None),
  119. "name": cwe.UNCATEGORIZED,
  120. "shortDescription": {"text": cwe.UNCATEGORIZED},
  121. "fullDescription": {
  122. "text": "Findings whose CWE is not an entry of the CWE Simplified Mapping view "
  123. f"and rolls up to none, reported by {TOOL_NAME} from static review of the "
  124. "source."
  125. },
  126. "help": {"text": help_text},
  127. "properties": {"tags": ["security"]},
  128. }
  129. return {
  130. "id": category.id,
  131. "name": rule_name(category.name),
  132. "shortDescription": {"text": category.name},
  133. "fullDescription": {
  134. "text": f"{category.title} ({category.id}, CWE {cwe.catalog.version}): findings whose "
  135. f"CWE is this entry of the Simplified Mapping view or rolls up to it, reported by "
  136. f"{TOOL_NAME} from static review of the source."
  137. },
  138. "help": {"text": help_text},
  139. "helpUri": f"https://cwe.mitre.org/data/definitions/{category.number}.html",
  140. "properties": {"tags": ["security", f"external/cwe/cwe-{category.number}"]},
  141. }
  142. def placed(
  143. findings: Sequence[Finding],
  144. scan: Scan,
  145. sources: Mapping[str, str],
  146. *,
  147. refused_secrets: Collection[str],
  148. ) -> list[Record]:
  149. """Each finding as the products carry it: `line` moved to where its file places it, id added.
  150. `sources` is the text of each scanned file by the finding's `file`; a
  151. finding whose file is absent from it keeps its line and hashes the number.
  152. """
  153. texts = {repository_file(scan.prefix, file=file): text for file, text in sources.items()}
  154. secrets = {
  155. (path, line)
  156. for f in findings
  157. if secret.is_credential(f)
  158. for path, line in secret_lines(repository_path(scan, f), f, texts)
  159. } | {
  160. (path, line)
  161. for quote in refused_secrets
  162. for path, text in texts.items()
  163. for line in source.quoted_lines(text, quote, whole=False)
  164. }
  165. return [placed_one(f, scan, sources.get(f["file"]), secrets) for f in findings]
  166. class Span(NamedTuple):
  167. """Lines `first` to `last` of the file at repository path `path`."""
  168. path: str
  169. first: int
  170. last: int
  171. def placed_one(
  172. finding: Finding, scan: Scan, text: str | None, secrets: Collection[tuple[str, int]]
  173. ) -> Record:
  174. """One finding placed in `text`, its file's content (None when unread), and given its id.
  175. The link to the change is dropped for a finding inside a credential's window,
  176. or whose link opens at a line or range that reaches one: it may describe the credential.
  177. """
  178. moved: Finding = finding
  179. code = None
  180. path = repository_path(scan, finding)
  181. if text is not None:
  182. lines = source.normalized_lines(text)
  183. row = source.placing_row(lines, finding["line"], finding["snippet"])
  184. if row is not None:
  185. moved = {**finding, "line": row + 1}
  186. code = None if near(Span(path, row + 1, row + 1), secrets) else code_at(lines, row)
  187. anchor = Span(path, moved["line"], moved["line"])
  188. link = link_site(scan, moved["via_change"])
  189. if near(anchor, secrets) or (link is not None and near(link, secrets)):
  190. moved = {**moved, "via_change": None}
  191. return {**moved, FINDING_ID: fingerprint(moved, scan, code)}
  192. def near(span: Span, secrets: Collection[tuple[str, int]]) -> bool:
  193. """True when `span` comes within CONTEXT_LINES of a credential line in its file."""
  194. return any(
  195. p == span.path and span.first - CONTEXT_LINES <= n <= span.last + CONTEXT_LINES
  196. for p, n in secrets
  197. )
  198. def link_site(scan: Scan, via_change: str | None) -> Span | None:
  199. """The span a change link opens with; None when it opens with no file:line."""
  200. matched = LINK_SITE.match(via_change or "")
  201. if not matched:
  202. return None
  203. first, last = sorted((int(matched[2]), int(matched[3] or matched[2])))
  204. return Span(repository_file(scan.prefix, file=matched[1]), first, last)
  205. def secret_lines(home: str, credential: Finding, texts: Mapping[str, str]) -> set[tuple[str, int]]:
  206. """Every (repository path, line) of `texts` on which the `credential` finding's quote occurs.
  207. `home` is the credential's own repository path and `texts` the scanned
  208. files by repository path. The quote is marked wherever it occurs in any
  209. file, with one restraint: a quote its own file shows only as part of a
  210. longer line is marked in the other files just where it is a whole line of
  211. theirs. The credential's own line is always marked, for one whose quote
  212. matched nothing or whose file was not read.
  213. """
  214. snippet = credential["snippet"]
  215. own = texts.get(home, "")
  216. found = bool(source.quoted_lines(own, snippet, whole=False))
  217. fragment = found and not source.quoted_lines(own, snippet, whole=True)
  218. return {
  219. (path, line)
  220. for path, text in texts.items()
  221. for line in source.quoted_lines(text, snippet, whole=fragment and path != home)
  222. } | {(home, credential["line"])}
  223. def fingerprint(finding: Finding, scan: Scan, code: str | None) -> str:
  224. """ID_VERSION, a colon, and a sha256 hex over the rule id, the repository path and `code`.
  225. `code` is the file's normalized lines around the placed one (code_at);
  226. None hashes the finding's line number in its place.
  227. """
  228. where: str | int = finding["line"] if code is None else code
  229. basis = [rule_id(category_of(finding)), repository_path(scan, finding), where]
  230. canonical = json.dumps(basis, separators=(",", ":"), ensure_ascii=True)
  231. return f"{ID_VERSION}:{hashlib.sha256(canonical.encode()).hexdigest()}"
  232. def code_at(lines: Sequence[str], row: int) -> str:
  233. """The normalized `lines` CONTEXT_LINES either side of `row`, joined; fewer at a file's ends."""
  234. return "\n".join(lines[max(row - CONTEXT_LINES, 0) : row + CONTEXT_LINES + 1])
  235. class Site(NamedTuple):
  236. """What one result stands for: a rule at a line of a file; the log holds one result per site."""
  237. rule: str
  238. path: str
  239. line: int
  240. def site(finding: Record, scan: Scan) -> Site | None:
  241. """The finding's site: its rule id, repository path and placed line; None for a line below 1."""
  242. if finding["line"] < 1:
  243. return None
  244. return Site(rule_id(category_of(finding)), repository_path(scan, finding), finding["line"])
  245. def result(
  246. finding: Record,
  247. category: cwe.Category | None,
  248. rule_index: int,
  249. scan: Scan,
  250. panel: Panel | None,
  251. ) -> dict[str, object]:
  252. """One result: the finding under its rule, its location, and its JSONL record, id included."""
  253. shown = secret.withheld(finding)
  254. record: dict[str, object] = {**shown}
  255. if panel is not None:
  256. record["verification"] = {"panel": panel}
  257. return {
  258. "ruleId": rule_id(category),
  259. "ruleIndex": rule_index,
  260. "level": LEVEL[finding["severity"]],
  261. "message": {"text": message(finding)},
  262. "locations": [location(shown, scan)],
  263. "properties": {PROPERTY_BAG: record},
  264. }
  265. def location(finding: Finding, scan: Scan) -> dict[str, object]:
  266. """A result's one location: the file relative to SRCROOT, the line, the snippet, the symbol.
  267. The line is the finding's placed line (placed()): where its quoted code
  268. sits in the file, or the line it declared when nothing places it.
  269. """
  270. line = finding["line"]
  271. region: dict[str, object] = {"startLine": max(line, 1)}
  272. if line >= 1 and finding["snippet"].strip():
  273. region["snippet"] = {"text": finding["snippet"]}
  274. place: dict[str, object] = {
  275. "physicalLocation": {
  276. "artifactLocation": {
  277. "uri": quote(uri_bytes(repository_path(scan, finding))),
  278. "uriBaseId": SRCROOT,
  279. },
  280. "region": region,
  281. }
  282. }
  283. if symbol := finding["symbol"].strip():
  284. place["logicalLocations"] = [{"name": symbol, "fullyQualifiedName": symbol}]
  285. return place
  286. def repository_path(scan: Scan, finding: Finding) -> str:
  287. """The finding's file relative to the repository top level, with any leading climb folded."""
  288. return repository_file(scan.prefix, file=finding["file"])
  289. def uri_bytes(text: str) -> bytes:
  290. """`text` as the bytes its uri must name; byte-faithful for a filesystem name."""
  291. try:
  292. return text.encode("utf-8", "surrogateescape")
  293. except UnicodeEncodeError:
  294. return text.encode("utf-8", "surrogatepass")
  295. def notification(descriptor_id: str, level: str, text: str) -> dict[str, object]:
  296. """One invocation notification: its namespaced descriptor id, its level, and its message."""
  297. return {"descriptor": {"id": descriptor_id}, "level": level, "message": {"text": text}}
  298. def message(finding: Finding) -> str:
  299. """A result's message: the finding's prose, its stated parts labeled, then its ratings."""
  300. parts = [sentence(finding["title"]), sentence(finding["description"])]
  301. labeled = (
  302. ("Impact", finding["impact"]),
  303. ("Exploit scenario", finding["exploit_scenario"]),
  304. ("Preconditions", "; ".join(finding["preconditions"])),
  305. ("Recommendation", finding["recommendation"]),
  306. )
  307. parts += [f"{label}: {text}" for label, value in labeled if (text := sentence(value))]
  308. if finding["line"] < 1:
  309. parts.append("The exact line was not determined; see the description.")
  310. if secret.is_credential(finding):
  311. parts.append("The source line is not quoted because it holds the credential.")
  312. parts.append(
  313. f"{finding['cwe_id']}. Severity {finding['severity']}, confidence {finding['confidence']}."
  314. )
  315. return "\n\n".join(parts)
  316. def rule_name(category: str) -> str:
  317. """A category's common name in PascalCase, for a rule name: `SQLInjection`."""
  318. return "".join(word[:1].upper() + word[1:] for word in re.split(r"[^A-Za-z0-9]+", category))
  319. def sentence(text: str) -> str:
  320. """`text` stripped and closed with a period unless it already ends in punctuation."""
  321. text = text.strip()
  322. return text if not text or text[-1] in ".!?" else text + "."