finding.py 8.9 KB

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