finding.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. """The Finding every product is built from, and build_finding, which validates a raw one."""
  2. from __future__ import annotations
  3. import ntpath
  4. import os
  5. import re
  6. from typing import TypedDict
  7. from . import absolute, cwe
  8. from .strictjson import JsonMap, has_lone_surrogate, is_int, is_list, is_map, is_str
  9. class Panel(TypedDict):
  10. """A validated panel round: the vote counts and the fixed voter count."""
  11. true: int
  12. false: int
  13. voters: int
  14. class Finding(TypedDict):
  15. """One validated finding, in the JSONL record's field order; the record adds the id (Record)."""
  16. id: str
  17. title: str
  18. impact: str
  19. file: str
  20. line: int
  21. description: str
  22. exploit_scenario: str
  23. preconditions: list[str]
  24. category: str
  25. severity: str
  26. confidence: str
  27. recommendation: str
  28. cwe_id: str
  29. snippet: str
  30. symbol: str
  31. declared_line: int
  32. class Record(Finding):
  33. """A finding as every product carries it: placed on its line (sarif.placed), its id last."""
  34. claudeSecurityPluginFindingId: str
  35. SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW")
  36. CONFIDENCES = ("low", "medium", "high")
  37. CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}
  38. PANEL_VOTER_COUNT = 3
  39. PANEL_KEEP_QUORUM = 2
  40. # \Z, not $: `$` also matches before a trailing newline, and this names a file.
  41. FINDING_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\Z")
  42. class FindingError(Exception):
  43. """A refusal; the message names what a findings.json record got wrong."""
  44. class FindingPathError(FindingError):
  45. """A refusal of one finding's model-written path; the rest of a report can still carry.
  46. The message names the finding and quotes the declared path; `finding_id`
  47. and `wrong` (what is wrong with the path, without the path) are what a
  48. caller surviving the refusal may record in a product.
  49. """
  50. finding_id: str
  51. wrong: str
  52. cwe: int = 0
  53. snippet: str = ""
  54. def __init__(self, finding_id: str, *, declared: str, wrong: str) -> None:
  55. super().__init__(f"finding {finding_id} file {declared!r} {wrong}")
  56. self.finding_id = finding_id
  57. self.wrong = wrong
  58. def cwe_number(item: JsonMap, finding_id: str) -> int:
  59. """A finding's CWE number; a cwe_id missing, unreadable or malformed is refused.
  60. A well-formed id is accepted as declared, whether or not the pinned CWE
  61. release defines it; one the release does not define files the finding
  62. under Uncategorized, and the renderer discloses the substitution.
  63. """
  64. declared = text_field(item, "cwe_id", finding_id, required=True)
  65. matched = re.fullmatch(
  66. r"(?:CWE-)?0*([1-9][0-9]{0,4})", declared.strip().upper().replace("_", "-")
  67. )
  68. if not matched:
  69. msg = f"finding {finding_id} cwe_id {declared!r} is not a CWE id such as CWE-89"
  70. raise FindingError(msg)
  71. return int(matched[1])
  72. def confidence_value(raw: object) -> str:
  73. """A finding's stated confidence, normalized to low|medium|high; refuses others."""
  74. if is_str(raw):
  75. word = raw.strip().lower()
  76. if word in CONFIDENCE_RANK:
  77. return word
  78. msg = f"confidence {raw!r} is not one of {'/'.join(CONFIDENCES)}"
  79. raise FindingError(msg)
  80. def panel_complete(record: object) -> Panel | None:
  81. """One round record's panel when the full voter count returned an integer tally, else None."""
  82. if not is_map(record):
  83. return None
  84. panel = record.get("panel")
  85. if not is_map(panel):
  86. return None
  87. panel_true = panel.get("true")
  88. if not is_int(panel_true):
  89. return None
  90. if panel.get("voters") != PANEL_VOTER_COUNT:
  91. return None
  92. false_votes = panel.get("false")
  93. return {
  94. "true": panel_true,
  95. "false": false_votes if is_int(false_votes) else 0,
  96. "voters": PANEL_VOTER_COUNT,
  97. }
  98. def vote_confidence_ceiling(record: object) -> str | None:
  99. """A finding's vote-backed confidence: `high` if unanimous, `medium` if complete, else None."""
  100. panel = panel_complete(record)
  101. if panel is None:
  102. return None
  103. return "high" if panel["true"] >= PANEL_VOTER_COUNT else "medium"
  104. def line_number(raw: object) -> int | None:
  105. """A findings.json line value as an integer: an int, or digits in a string; None otherwise."""
  106. if is_str(raw) and re.fullmatch(r"\s*-?[0-9]{1,15}\s*", raw):
  107. return int(raw)
  108. return raw if is_int(raw) else None
  109. def line_field(item: JsonMap, key: str, finding_id: str, default: int) -> int:
  110. """One of a finding's line fields as an integer (line_number); absent reads as `default`."""
  111. line = line_number(item.get(key, default))
  112. if line is None:
  113. msg = f"finding {finding_id} {key} {item.get(key)!r} is not an integer"
  114. raise FindingError(msg)
  115. return line
  116. def scan_prefix_shaped(prefix: str) -> bool:
  117. """Whether `prefix` is what `git rev-parse --show-prefix` prints: empty, or `a/b/`."""
  118. if not prefix:
  119. return True
  120. return (
  121. prefix.endswith("/")
  122. and "\\" not in prefix
  123. and not ntpath.splitdrive(prefix)[0]
  124. and all(segment not in {"", ".", ".."} for segment in prefix.split("/")[:-1])
  125. )
  126. def text_field(item: JsonMap, key: str, finding_id: str, required: bool = False) -> str:
  127. """One of a finding's text fields; absent or null reads as empty unless it is required."""
  128. value = item.get(key)
  129. if value is None:
  130. text = ""
  131. elif is_str(value):
  132. text = value
  133. else:
  134. msg = f"finding {finding_id} {key} is {type(value).__name__}, not a string"
  135. raise FindingError(msg)
  136. if has_lone_surrogate(text):
  137. msg = f"finding {finding_id} {key} contains an unpaired surrogate"
  138. raise FindingError(msg)
  139. if required and not text.strip():
  140. msg = f"finding {finding_id} is missing required field {key!r}"
  141. raise FindingError(msg)
  142. return text
  143. def leaked_spelling(path: str, *, first: str, scan_root: str) -> bool:
  144. """Whether a path's cross-platform absolute spelling names the machine, not the repository."""
  145. if not absolute.spelled(path):
  146. return False
  147. if os.name == "nt":
  148. return True
  149. return not os.path.lexists(os.path.join(scan_root, first))
  150. def file_field(
  151. item: JsonMap, finding_id: str, scan_root: str, scan_prefix: str, must_exist: bool
  152. ) -> str:
  153. """A finding's file relative to the scan root; a path that leaves the repository is refused.
  154. `scan_prefix` is the scan root's path below the repository top level (`a/b/`,
  155. or empty): a file may climb one directory per prefix component and no further.
  156. A file spelled relative to the top level, absent under the scan root but
  157. present under the top level, is respelled relative to the scan root.
  158. With `must_exist` (a codebase scan, whose whole tree is still present when
  159. the report renders), a path that exists neither under the scan root nor at
  160. the repository top level is refused.
  161. A name that merely spells like another platform's absolute path is treated
  162. as repository content when the scan root holds its first segment.
  163. """
  164. declared = text_field(item, "file", finding_id, required=True).strip()
  165. depth = scan_prefix.count("/")
  166. escapes = f"escapes the {'repository' if depth else 'scan root'}"
  167. path = declared.replace("\\", "/")
  168. prefix = scan_root.replace("\\", "/").rstrip("/") + "/"
  169. if scan_root and path.startswith(prefix):
  170. path = path[len(prefix) :].lstrip("/")
  171. if os.path.isabs(path):
  172. try:
  173. path = os.path.relpath(os.path.realpath(path), scan_root).replace("\\", "/")
  174. except (ValueError, OSError) as error:
  175. raise FindingPathError(finding_id, declared=declared, wrong=escapes) from error
  176. parts = [part for part in path.split("/") if part and part != "."]
  177. climb = next((i for i, part in enumerate(parts) if part != ".."), len(parts))
  178. inside = parts[climb:]
  179. if (
  180. not inside
  181. or ".." in inside
  182. or climb > depth
  183. or leaked_spelling(path, first=parts[0], scan_root=scan_root)
  184. ):
  185. raise FindingPathError(finding_id, declared=declared, wrong=escapes)
  186. if not os.path.lexists(os.path.join(scan_root, *parts)):
  187. # A file only the repository top level holds was spelled relative to it, not the scan root.
  188. if depth and not climb:
  189. at_top = os.path.join(os.path.normpath(os.path.join(scan_root, "../" * depth)), *parts)
  190. if os.path.lexists(at_top):
  191. return os.path.relpath(at_top, scan_root).replace("\\", "/")
  192. if must_exist:
  193. raise FindingPathError(
  194. finding_id, declared=declared, wrong="does not exist in the scanned tree"
  195. )
  196. return "/".join(parts)
  197. def build_finding(
  198. raw: object,
  199. index: int,
  200. rounds_by_id: JsonMap,
  201. scan_root: str,
  202. scan_prefix: str,
  203. must_exist: bool,
  204. ) -> Finding:
  205. """Validate one raw findings.json record into a Finding."""
  206. if not is_map(raw):
  207. msg = f"findings.json item {index} is not an object"
  208. raise FindingError(msg)
  209. numbered = f"F{index + 1}"
  210. finding_id = text_field(raw, "id", numbered) or numbered
  211. if not FINDING_ID_RE.match(finding_id):
  212. msg = f"finding id {finding_id!r} is not a valid id"
  213. raise FindingError(msg)
  214. severity = str(raw.get("severity", "")).strip().upper()
  215. if severity not in SEVERITIES:
  216. msg = (
  217. f"finding {finding_id} severity {raw.get('severity')!r} is not one of "
  218. f"{'/'.join(SEVERITIES)}"
  219. )
  220. raise FindingError(msg)
  221. confidence = confidence_value(raw.get("confidence"))
  222. ceiling = vote_confidence_ceiling(rounds_by_id.get(finding_id))
  223. if ceiling is not None and CONFIDENCE_RANK[confidence] > CONFIDENCE_RANK[ceiling]:
  224. confidence = ceiling
  225. line = line_field(raw, "line", finding_id, 0)
  226. declared_line = line_field(raw, "declared_line", finding_id, line)
  227. preconditions: list[str] = []
  228. declared = raw.get("preconditions")
  229. if declared is not None:
  230. if not is_list(declared):
  231. msg = f"finding {finding_id} preconditions must be a list"
  232. raise FindingError(msg)
  233. preconditions = [item for item in declared if is_str(item)]
  234. if len(preconditions) != len(declared) or any(map(has_lone_surrogate, preconditions)):
  235. msg = f"finding {finding_id} preconditions must be a list of strings"
  236. raise FindingError(msg)
  237. number = cwe_number(raw, finding_id)
  238. category = cwe.catalog.category(number)
  239. title = text_field(raw, "title", finding_id, required=True)
  240. impact = text_field(raw, "impact", finding_id)
  241. description = text_field(raw, "description", finding_id, required=True)
  242. exploit_scenario = text_field(raw, "exploit_scenario", finding_id, required=True)
  243. recommendation = text_field(raw, "recommendation", finding_id)
  244. snippet = text_field(raw, "snippet", finding_id)
  245. symbol = text_field(raw, "symbol", finding_id)
  246. try:
  247. file = file_field(raw, finding_id, scan_root, scan_prefix, must_exist)
  248. except FindingPathError as error:
  249. error.cwe, error.snippet = number, snippet
  250. raise
  251. return {
  252. "id": finding_id,
  253. "title": title,
  254. "impact": impact,
  255. "file": file,
  256. "line": line,
  257. "description": description,
  258. "exploit_scenario": exploit_scenario,
  259. "preconditions": preconditions,
  260. "category": category.name if category is not None else cwe.UNCATEGORIZED,
  261. "severity": severity,
  262. "confidence": confidence,
  263. "recommendation": recommendation,
  264. "cwe_id": f"CWE-{number}",
  265. "snippet": snippet,
  266. "symbol": symbol,
  267. "declared_line": declared_line,
  268. }