"""The Finding record every product carries, and build_finding, which validates a raw one.""" from __future__ import annotations import os import re from typing import TypedDict from . import absolute, cwe from .strictjson import JsonMap, has_lone_surrogate, is_int, is_list, is_map, is_str class Panel(TypedDict): """A validated panel round: the vote counts and the fixed voter count.""" true: int false: int voters: int class Finding(TypedDict): """One validated finding: the JSONL record, whose field order is this class's order.""" id: str title: str impact: str file: str line: int description: str exploit_scenario: str preconditions: list[str] category: str severity: str confidence: str recommendation: str cwe_id: str snippet: str symbol: str SEVERITIES = ("HIGH", "MEDIUM", "LOW") CONFIDENCES = ("low", "medium", "high") CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3} PANEL_VOTER_COUNT = 3 PANEL_KEEP_QUORUM = 2 # \Z, not $: `$` also matches before a trailing newline, and this names a file. FINDING_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\Z") class FindingError(Exception): """A refusal; the message names what a findings.json record got wrong.""" def cwe_number(item: JsonMap, finding_id: str) -> int: """A finding's CWE number; a cwe_id missing, unreadable or malformed is refused. A well-formed id is accepted as declared, whether or not the pinned CWE release defines it; one the release does not define files the finding under Uncategorized, and the renderer discloses the substitution. """ declared = text_field(item, "cwe_id", finding_id, required=True) matched = re.fullmatch( r"(?:CWE-)?0*([1-9][0-9]{0,4})", declared.strip().upper().replace("_", "-") ) if not matched: msg = f"finding {finding_id} cwe_id {declared!r} is not a CWE id such as CWE-89" raise FindingError(msg) return int(matched[1]) def confidence_value(raw: object) -> str: """A finding's stated confidence, normalized to low|medium|high; refuses others.""" if is_str(raw): word = raw.strip().lower() if word in CONFIDENCE_RANK: return word msg = f"confidence {raw!r} is not one of {'/'.join(CONFIDENCES)}" raise FindingError(msg) def panel_complete(record: object) -> Panel | None: """One round record's panel when the full voter count returned an integer tally, else None.""" if not is_map(record): return None panel = record.get("panel") if not is_map(panel): return None panel_true = panel.get("true") if not is_int(panel_true): return None if panel.get("voters") != PANEL_VOTER_COUNT: return None false_votes = panel.get("false") return { "true": panel_true, "false": false_votes if is_int(false_votes) else 0, "voters": PANEL_VOTER_COUNT, } def vote_confidence_ceiling(record: object) -> str | None: """A finding's vote-backed confidence: `high` if unanimous, `medium` if complete, else None.""" panel = panel_complete(record) if panel is None: return None return "high" if panel["true"] >= PANEL_VOTER_COUNT else "medium" def text_field(item: JsonMap, key: str, finding_id: str, required: bool = False) -> str: """One of a finding's text fields; absent or null reads as empty unless it is required.""" value = item.get(key) if value is None: text = "" elif is_str(value): text = value else: msg = f"finding {finding_id} {key} is {type(value).__name__}, not a string" raise FindingError(msg) if has_lone_surrogate(text): msg = f"finding {finding_id} {key} contains an unpaired surrogate" raise FindingError(msg) if required and not text.strip(): msg = f"finding {finding_id} is missing required field {key!r}" raise FindingError(msg) return text def file_field( item: JsonMap, finding_id: str, scan_root: str, scan_prefix: str, must_exist: bool ) -> str: """A finding's file relative to the scan root; a path that leaves the repository is refused. `scan_prefix` is the scan root's path below the repository top level (`a/b/`, or empty): a file may climb one directory per prefix component and no further. A file spelled relative to the top level, absent under the scan root but present under the top level, is respelled relative to the scan root. With `must_exist` (a codebase scan, whose whole tree is still present when the report renders), a path that exists neither under the scan root nor at the repository top level is refused. """ declared = text_field(item, "file", finding_id, required=True).strip() depth = scan_prefix.count("/") beyond = "repository" if depth else "scan root" refusal = f"finding {finding_id} file {declared!r} escapes the {beyond}" path = declared.replace("\\", "/") prefix = scan_root.replace("\\", "/").rstrip("/") + "/" if scan_root and path.startswith(prefix): path = path[len(prefix) :].lstrip("/") if os.path.isabs(path): try: path = os.path.relpath(os.path.realpath(path), scan_root).replace("\\", "/") except (ValueError, OSError) as error: raise FindingError(refusal) from error parts = [part for part in path.split("/") if part and part != "."] climb = next((i for i, part in enumerate(parts) if part != ".."), len(parts)) inside = parts[climb:] if not inside or absolute.spelled(path) or ".." in inside or climb > depth: raise FindingError(refusal) if not os.path.lexists(os.path.join(scan_root, *parts)): # A file only the repository top level holds was spelled relative to it, not the scan root. if depth and not climb: at_top = os.path.join(os.path.normpath(os.path.join(scan_root, "../" * depth)), *parts) if os.path.lexists(at_top): return os.path.relpath(at_top, scan_root).replace("\\", "/") if must_exist: msg = f"finding {finding_id} file {declared!r} does not exist in the scanned tree" raise FindingError(msg) return "/".join(parts) def build_finding( raw: object, index: int, rounds_by_id: JsonMap, scan_root: str, scan_prefix: str, must_exist: bool, ) -> Finding: """Validate one raw findings.json record into a Finding.""" if not is_map(raw): msg = f"findings.json item {index} is not an object" raise FindingError(msg) numbered = f"F{index + 1}" finding_id = text_field(raw, "id", numbered) or numbered if not FINDING_ID_RE.match(finding_id): msg = f"finding id {finding_id!r} is not a valid id" raise FindingError(msg) severity = str(raw.get("severity", "")).strip().upper() if severity not in SEVERITIES: msg = ( f"finding {finding_id} severity {raw.get('severity')!r} is not one of " f"{'/'.join(SEVERITIES)}" ) raise FindingError(msg) confidence = confidence_value(raw.get("confidence")) ceiling = vote_confidence_ceiling(rounds_by_id.get(finding_id)) if ceiling is not None and CONFIDENCE_RANK[confidence] > CONFIDENCE_RANK[ceiling]: confidence = ceiling line = raw.get("line", 0) if is_str(line) and re.fullmatch(r"\s*-?[0-9]{1,15}\s*", line): line = int(line) if not is_int(line): msg = f"finding {finding_id} line {raw.get('line')!r} is not an integer" raise FindingError(msg) preconditions: list[str] = [] declared = raw.get("preconditions") if declared is not None: if not is_list(declared): msg = f"finding {finding_id} preconditions must be a list" raise FindingError(msg) preconditions = [item for item in declared if is_str(item)] if len(preconditions) != len(declared) or any(map(has_lone_surrogate, preconditions)): msg = f"finding {finding_id} preconditions must be a list of strings" raise FindingError(msg) number = cwe_number(raw, finding_id) category = cwe.catalog.category(number) return { "id": finding_id, "title": text_field(raw, "title", finding_id, required=True), "impact": text_field(raw, "impact", finding_id), "file": file_field(raw, finding_id, scan_root, scan_prefix, must_exist), "line": line, "description": text_field(raw, "description", finding_id, required=True), "exploit_scenario": text_field(raw, "exploit_scenario", finding_id, required=True), "preconditions": preconditions, "category": category.name if category is not None else cwe.UNCATEGORIZED, "severity": severity, "confidence": confidence, "recommendation": text_field(raw, "recommendation", finding_id), "cwe_id": f"CWE-{number}", "snippet": text_field(raw, "snippet", finding_id), "symbol": text_field(raw, "symbol", finding_id), }