sarif.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. """SARIF 2.1.0 for one scan: the log encoder."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import posixpath
  6. import re
  7. from bisect import bisect_right
  8. from dataclasses import dataclass
  9. from itertools import accumulate
  10. from typing import TYPE_CHECKING, NamedTuple
  11. from urllib.parse import quote
  12. from . import cwe, secret
  13. if TYPE_CHECKING:
  14. import uuid
  15. from collections.abc import Mapping, Sequence
  16. from .finding import Finding, Panel
  17. SCHEMA_ID = (
  18. "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json"
  19. )
  20. # The driver name GitHub keys alert identity on; renaming it orphans every open alert.
  21. TOOL_NAME = "Claude Security Plugin for Claude Code"
  22. TOOL_URI = "https://claude.com/product/claude-security"
  23. PROPERTY_BAG = "claudeSecurityPlugin"
  24. ID_PREFIX = "claude-security-plugin"
  25. FINGERPRINT_KEY = ID_PREFIX + "/v2"
  26. CONTEXT_LINES = 3
  27. SRCROOT = "%SRCROOT%"
  28. LEVEL = {"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[Finding],
  44. scan: Scan,
  45. tool_version: str | None,
  46. run_properties: Mapping[str, object],
  47. panels: Mapping[str, Panel],
  48. sources: Mapping[str, str],
  49. notifications: Sequence[Mapping[str, object]],
  50. ) -> dict[str, object]:
  51. """The SARIF 2.1.0 log for one scan: one run, one rule per category, one result per finding.
  52. `sources` is the text of each scanned file a finding names, keyed by the
  53. finding's `file`; a finding whose file is absent from it is fingerprinted
  54. on its own quote of the code instead.
  55. """
  56. filed = [(item, category_of(item)) for item in findings]
  57. categories = list(dict.fromkeys(category for _, category in filed))
  58. index = {category: position for position, category in enumerate(categories)}
  59. driver: dict[str, object] = {
  60. "name": TOOL_NAME,
  61. "organization": "Anthropic",
  62. "informationUri": TOOL_URI,
  63. **({"version": tool_version} if tool_version else {}),
  64. "rules": [rule(category) for category in categories],
  65. }
  66. invocation: dict[str, object] = {"executionSuccessful": True}
  67. if notifications:
  68. invocation["toolExecutionNotifications"] = list(notifications)
  69. base_description = (
  70. "The top level of the scanned repository, or the scanned directory when the scan did "
  71. "not run inside a git checkout."
  72. )
  73. run: dict[str, object] = {
  74. "tool": {"driver": driver},
  75. "automationDetails": {"id": automation_id(scan), "guid": str(scan.id)},
  76. "invocations": [invocation],
  77. "originalUriBaseIds": {SRCROOT: {"description": {"text": base_description}}},
  78. "results": [
  79. result(
  80. item,
  81. category,
  82. index[category],
  83. scan,
  84. panels.get(item["id"]),
  85. sources.get(item["file"]),
  86. )
  87. for item, category in filed
  88. ],
  89. "properties": {
  90. PROPERTY_BAG: {
  91. **run_properties,
  92. "target_kind": "git-remote" if scan.remote else "local-path",
  93. }
  94. },
  95. }
  96. if scan.remote:
  97. provenance: dict[str, object] = {"repositoryUri": scan.remote}
  98. if scan.revision:
  99. provenance["revisionId"] = scan.revision
  100. run["versionControlProvenance"] = [provenance]
  101. return {"$schema": SCHEMA_ID, "version": "2.1.0", "runs": [run]}
  102. def automation_id(scan: Scan) -> str:
  103. """The run's automation id: the plugin prefix, the mode, the scan's extent if any, its id."""
  104. category = f"{ID_PREFIX}/{scan.mode}"
  105. if scan.scope:
  106. extent = ",".join(scan.prefix + entry.strip("/") for entry in sorted(scan.scope))
  107. else:
  108. extent = scan.prefix.rstrip("/")
  109. if extent:
  110. category += "/" + quote(uri_bytes(extent))
  111. return f"{category}/{scan.id}"
  112. def category_of(finding: Finding) -> cwe.Category | None:
  113. """The Simplified Mapping entry the finding's CWE rolls up to; None for Uncategorized."""
  114. return cwe.catalog.category(cwe.id_number(finding["cwe_id"]))
  115. def rule_id(category: cwe.Category | None) -> str:
  116. """A rule's id: its entry's CWE id (`CWE-89`), or `uncategorized`."""
  117. return category.id if category is not None else cwe.UNCATEGORIZED.lower()
  118. def rule(category: cwe.Category | None) -> dict[str, object]:
  119. """The reporting descriptor for one entry: the catalog's names, its page, its fixed tags."""
  120. help_text = (
  121. "Each alert's message names the finding's own CWE and states the impact, exploit "
  122. "scenario, preconditions and recommended fix; the finding appears under its F<n> id "
  123. "in CLAUDE-SECURITY-RESULTS.md."
  124. )
  125. if category is None:
  126. return {
  127. "id": rule_id(None),
  128. "name": cwe.UNCATEGORIZED,
  129. "shortDescription": {"text": cwe.UNCATEGORIZED},
  130. "fullDescription": {
  131. "text": "Findings whose CWE is not an entry of the CWE Simplified Mapping view "
  132. f"and rolls up to none, reported by {TOOL_NAME} from static review of the "
  133. "source."
  134. },
  135. "help": {"text": help_text},
  136. "properties": {"tags": ["security"]},
  137. }
  138. return {
  139. "id": category.id,
  140. "name": rule_name(category.name),
  141. "shortDescription": {"text": category.name},
  142. "fullDescription": {
  143. "text": f"{category.title} ({category.id}, CWE {cwe.catalog.version}): findings whose "
  144. f"CWE is this entry of the Simplified Mapping view or rolls up to it, reported by "
  145. f"{TOOL_NAME} from static review of the source."
  146. },
  147. "help": {"text": help_text},
  148. "helpUri": f"https://cwe.mitre.org/data/definitions/{category.number}.html",
  149. "properties": {"tags": ["security", f"external/cwe/cwe-{category.number}"]},
  150. }
  151. def fingerprint(
  152. finding: Finding, category: cwe.Category | None, scan: Scan, source: str | None
  153. ) -> str:
  154. """The finding's partial fingerprint: a sha256 over its rule, its path and the code it names.
  155. `source` is the text of the finding's file. The code is the file's own
  156. lines around the one that places the finding (see code_at); when the file
  157. was not read, or no line of it places the finding, the finding's symbol
  158. and snippet stand in for them, and the line when it has neither. A
  159. hard-coded credential finding is the exception: its symbol and the number
  160. of the line that places it stand in for the code always, so no text of a
  161. file that holds a credential enters the hash.
  162. """
  163. parts = [scan.remote or "", rule_id(category), repository_path(scan, finding)]
  164. symbol = finding["symbol"].strip()
  165. snippet = quoted_line(finding)
  166. if secret.is_credential(finding):
  167. lines = None if source is None else normalized_lines(source)
  168. placed = None if lines is None else placing_row(lines, finding["line"], snippet)
  169. parts += [symbol, str(finding["line"] if placed is None else placed + 1)]
  170. return hashlib.sha256(json.dumps(parts).encode()).hexdigest()
  171. code = None if source is None else code_at(source, finding["line"], snippet)
  172. if code is not None:
  173. parts.append(code)
  174. else:
  175. parts += [symbol, snippet]
  176. if not symbol and not snippet:
  177. parts.append(str(finding["line"]))
  178. return hashlib.sha256(json.dumps(parts).encode()).hexdigest()
  179. class Site(NamedTuple):
  180. """What one result stands for: a rule at a line of a file; the log holds one result per site."""
  181. rule: str
  182. path: str
  183. line: int
  184. def site(finding: Finding, scan: Scan, source: str | None) -> Site | None:
  185. """The finding's site: its rule id, its repository path, and the line that places it.
  186. The line is the one of `source`, the finding's file, that places the
  187. finding (placing_row), so two findings that quote one line of code are one
  188. site whatever lines they declare; it is the declared line when the file
  189. was not read or no line of it places the finding. A finding left with no
  190. line (it declared none, line < 1, and none places it) has no site: None.
  191. """
  192. line = finding["line"]
  193. if source is not None:
  194. row = placing_row(normalized_lines(source), line, quoted_line(finding))
  195. if row is not None:
  196. line = row + 1
  197. if line < 1:
  198. return None
  199. return Site(rule_id(category_of(finding)), repository_path(scan, finding), line)
  200. def quoted_line(finding: Finding) -> str:
  201. """The finding's snippet with its whitespace normalized, the form placing_row looks for."""
  202. return " ".join(finding["snippet"].split())
  203. def normalized_lines(source: str) -> list[str]:
  204. """A file's lines, split on the newline alone, each with its whitespace normalized."""
  205. return [" ".join(each.split()) for each in source.split("\n")]
  206. def placing_row(lines: Sequence[str], line: int, quoted: str) -> int | None:
  207. """The index into the normalized `lines` of the one placing a finding; None when none does.
  208. The finding is placed on the line nearest its declared `line` where
  209. `quoted`, its normalized snippet, appears, whitespace aside, and on the
  210. declared line itself when it appears nowhere.
  211. """
  212. declared = line - 1
  213. at = min(
  214. (min(max(declared, first), last) for first, last in occurrences(lines, quoted)),
  215. key=lambda row: abs(row - declared),
  216. default=declared,
  217. )
  218. return at if 0 <= at < len(lines) else None
  219. def code_at(source: str, line: int, quoted: str) -> str | None:
  220. """The normalized lines of `source` around the one placing a finding; None when none does."""
  221. lines = normalized_lines(source)
  222. at = placing_row(lines, line, quoted)
  223. if at is None:
  224. return None
  225. return "\n".join(lines[max(at - CONTEXT_LINES, 0) : at + CONTEXT_LINES + 1])
  226. def occurrences(lines: Sequence[str], quoted: str) -> list[tuple[int, int]]:
  227. """The (first, last) index into the normalized `lines` of each occurrence of `quoted`."""
  228. if not quoted:
  229. return []
  230. filled = [row for row, line in enumerate(lines) if line]
  231. starts = list(accumulate((len(lines[row]) + 1 for row in filled), initial=0))
  232. flat = " ".join(lines[row] for row in filled)
  233. def row_at(offset: int) -> int:
  234. return filled[bisect_right(starts, offset) - 1]
  235. return [
  236. (row_at(found.start()), row_at(found.end() - 1))
  237. for found in re.finditer(re.escape(quoted), flat)
  238. ]
  239. def result(
  240. finding: Finding,
  241. category: cwe.Category | None,
  242. rule_index: int,
  243. scan: Scan,
  244. panel: Panel | None,
  245. source: str | None,
  246. ) -> dict[str, object]:
  247. """One result: the finding under its rule, its partial fingerprint, and its JSONL record."""
  248. shown = secret.withheld(finding)
  249. record: dict[str, object] = {**shown}
  250. if panel is not None:
  251. record["verification"] = {"panel": panel}
  252. return {
  253. "ruleId": rule_id(category),
  254. "ruleIndex": rule_index,
  255. "level": LEVEL[finding["severity"]],
  256. "message": {"text": message(finding)},
  257. "locations": [location(shown, scan)],
  258. "partialFingerprints": {FINGERPRINT_KEY: fingerprint(finding, category, scan, source)},
  259. "properties": {PROPERTY_BAG: record},
  260. }
  261. def location(finding: Finding, scan: Scan) -> dict[str, object]:
  262. """A result's one location: the file relative to SRCROOT, the line, the snippet, the symbol."""
  263. line = finding["line"]
  264. region: dict[str, object] = {"startLine": max(line, 1)}
  265. if line >= 1 and finding["snippet"].strip():
  266. region["snippet"] = {"text": finding["snippet"]}
  267. place: dict[str, object] = {
  268. "physicalLocation": {
  269. "artifactLocation": {
  270. "uri": quote(uri_bytes(repository_path(scan, finding))),
  271. "uriBaseId": SRCROOT,
  272. },
  273. "region": region,
  274. }
  275. }
  276. if symbol := finding["symbol"].strip():
  277. place["logicalLocations"] = [{"name": symbol, "fullyQualifiedName": symbol}]
  278. return place
  279. def repository_path(scan: Scan, finding: Finding) -> str:
  280. """The finding's file relative to the repository top level, with any leading climb folded."""
  281. return posixpath.normpath(scan.prefix + finding["file"])
  282. def uri_bytes(text: str) -> bytes:
  283. """`text` as the bytes its uri must name; byte-faithful for a filesystem name."""
  284. try:
  285. return text.encode("utf-8", "surrogateescape")
  286. except UnicodeEncodeError:
  287. return text.encode("utf-8", "surrogatepass")
  288. def notification(descriptor_id: str, level: str, text: str) -> dict[str, object]:
  289. """One invocation notification: its namespaced descriptor id, its level, and its message."""
  290. return {"descriptor": {"id": descriptor_id}, "level": level, "message": {"text": text}}
  291. def message(finding: Finding) -> str:
  292. """A result's message: the finding's prose, its stated parts labeled, then its ratings."""
  293. parts = [sentence(finding["title"]), sentence(finding["description"])]
  294. labeled = (
  295. ("Impact", finding["impact"]),
  296. ("Exploit scenario", finding["exploit_scenario"]),
  297. ("Preconditions", "; ".join(finding["preconditions"])),
  298. ("Recommendation", finding["recommendation"]),
  299. )
  300. parts += [f"{label}: {text}" for label, value in labeled if (text := sentence(value))]
  301. if finding["line"] < 1:
  302. parts.append("The exact line was not determined; see the description.")
  303. if secret.is_credential(finding):
  304. parts.append("The source line is not quoted because it holds the credential.")
  305. parts.append(
  306. f"{finding['cwe_id']}. Severity {finding['severity']}, confidence {finding['confidence']}."
  307. )
  308. return "\n\n".join(parts)
  309. def rule_name(category: str) -> str:
  310. """A category's common name in PascalCase, for a rule name: `SQLInjection`."""
  311. return "".join(word[:1].upper() + word[1:] for word in re.split(r"[^A-Za-z0-9]+", category))
  312. def sentence(text: str) -> str:
  313. """`text` stripped and closed with a period unless it already ends in punctuation."""
  314. text = text.strip()
  315. return text if not text or text[-1] in ".!?" else text + "."