diff --git a/README.md b/README.md index 2993f9f..f04489b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ tooling directories (`.venv`, `node_modules`, `__pycache__`, `build`, `dist`) ar The dependency files at the root (`requirements*.txt`, `pyproject.toml`, `poetry.lock`, `uv.lock`) are resolved into a dependency graph. Plugins may contribute advisories; the shipped `dependency/` plugins report a requirement that allows a vulnerable version at -its line, and every call in the project to an API the advisory affects. The shipped +its line, and every call in the project to an API the advisory affects. When +attacker-controlled data reaches such a call, the engine correlates the four facts into +one critical `exploitable-vulnerability` finding, judged like any other flow. The shipped advisory database is a small offline sample; a live OSV feed is future work. `--plugins` is a directory searched recursively for `plugin.toml` manifests and may be diff --git a/src/coretrace_python/dependency/correlation.py b/src/coretrace_python/dependency/correlation.py new file mode 100644 index 0000000..f700418 --- /dev/null +++ b/src/coretrace_python/dependency/correlation.py @@ -0,0 +1,87 @@ +"""Correlation engine (architecture §27). + +A package required in a vulnerable version, an API the advisory affects, a call to that +API reachable in the project, and attacker-controlled data reaching that call: the +affected APIs become sinks of the ``ADVISORY`` taint kind, so the shared taint engine, +the function summaries and the refutation engine do the work, and the flows they leave +are correlated here into one high-confidence ``exploitable-vulnerability`` finding. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping + +from coretrace_python.dependency.graph import Advisory, DependencyGraph +from coretrace_python.findings import Confidence, Finding, Severity +from coretrace_python.findings.refutation import Status, Verdicts +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.taint import Sink, TaintFlow, TaintKind + + +def affected_symbols( + dependencies: DependencyGraph, advisories: Iterable[Advisory] +) -> Mapping[SymbolId, Advisory]: + """The APIs affected by advisories whose package is required in a vulnerable version.""" + + affected: dict[SymbolId, Advisory] = {} + for requirement in dependencies.requirements: + for advisory in advisories: + if advisory.affects(requirement): + for symbol in advisory.affected_symbols: + affected.setdefault(symbol, advisory) + return affected + + +def advisory_sinks(affected: Mapping[SymbolId, Advisory]) -> tuple[Sink, ...]: + return tuple(Sink(symbol, TaintKind.ADVISORY) for symbol in affected) + + +def correlate( + function: str, + flows: Iterable[TaintFlow], + verdicts: Verdicts | None, + affected: Mapping[SymbolId, Advisory], +) -> tuple[Finding, ...]: + """Exploitable-vulnerability findings for the non-refuted ADVISORY flows of a function.""" + + findings: list[Finding] = [] + for flow in flows: + if not flow.kinds & TaintKind.ADVISORY: + continue + advisory = affected.get(flow.sink.symbol) + if advisory is None: + continue + verdict = verdicts.verdict(flow) if verdicts is not None else None + if verdict is not None and verdict.status is Status.REFUTED: + continue + hotspot = verdict is not None and verdict.status is Status.HOTSPOT + message = ( + f"{advisory.id}: {flow.source.label} input reaches {flow.sink.symbol}, affected in " + f"the required {advisory.package} {advisory.vulnerable}: {advisory.summary}" + ) + metadata = { + "advisory": advisory.id, + "package": advisory.package, + "symbol": str(flow.sink.symbol), + "source": str(flow.source.symbol), + "source_label": flow.source.label, + "verdict": "hotspot" if hotspot else "vulnerability", + } + if verdict is not None: + metadata["evidence"] = verdict.evidence + if flow.through is not None and flow.sink_location is not None: + message += f" through {flow.through}" + metadata["through"] = flow.through + metadata["sink_line"] = str(flow.sink_location.start_line) + findings.append( + Finding( + rule_id="exploitable-vulnerability", + message=message, + severity=Severity.CRITICAL, + confidence=Confidence.MEDIUM if hotspot else Confidence.HIGH, + span=flow.location, + function=function, + metadata=metadata, + ) + ) + return tuple(findings) diff --git a/src/coretrace_python/engine.py b/src/coretrace_python/engine.py index 7bae9b9..146f83c 100644 --- a/src/coretrace_python/engine.py +++ b/src/coretrace_python/engine.py @@ -26,6 +26,7 @@ DependencyGraph, parse_dependencies, ) +from coretrace_python.dependency.correlation import advisory_sinks, affected_symbols, correlate from coretrace_python.findings import Confidence, Finding, Severity from coretrace_python.findings.refutation import RefutationAnalysis from coretrace_python.frontend import HIRBuildError, ParseError, build_hir @@ -155,7 +156,7 @@ def check(source: SourceFile, plugin_roots: Sequence[Path]) -> tuple[Finding, .. registry = load_plugins(plugin_roots, manager) manager.provide(SecurityModelAnalysis, plugin_models(loaded.plugin for loaded in registry)) manager.provide(ProjectSummaries, SummaryIndex()) - return _check_module(manager, tuple(loaded.plugin for loaded in registry)) + return _check_module(manager, tuple(loaded.plugin for loaded in registry))[0] def resolve_dependencies(root: Path, sources: SourceManager) -> DependencyGraph: @@ -195,8 +196,9 @@ def analyze_project( probe = next(iter(managers.values()), None) or _register_all(build_hir(sources.add_source("", ""))) registry = load_plugins(plugin_roots, probe) all_plugins: tuple[Plugin, ...] = (*(loaded.plugin for loaded in registry), *plugins) - models = plugin_models(all_plugins) advisories = tuple(a for plugin in all_plugins for a in plugin.advisories) + affected = affected_symbols(dependencies, advisories) + models = plugin_models(all_plugins).extended(*advisory_sinks(affected)) index = SummaryIndex() for manager in managers.values(): manager.provide(SecurityModelAnalysis, models) @@ -233,7 +235,20 @@ def analyze_project( module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin)) for name in sorted(analysable): - findings.extend(_check_module(analysable[name], module_plugins)) + manager = analysable[name] + module_findings, supported = _check_module(manager, module_plugins) + findings.extend(module_findings) + if affected: + graph_of_module = manager.get(CallGraphAnalysis) + for function in supported: + findings.extend( + correlate( + graph_of_module.name_of(function), + manager.get(TaintAnalysis, function).flows, + manager.get(RefutationAnalysis, function), + affected, + ) + ) context = ProjectContext(graph, dependencies, advisories, analysable) for plugin in all_plugins: if isinstance(plugin, ProjectPlugin): @@ -246,7 +261,12 @@ def _summaries_of(manager: AnalysisManager) -> dict[str, FunctionSummary]: return {name: table.summary(name) for name in table.names} -def _check_module(manager: AnalysisManager, plugins: tuple[Plugin, ...]) -> tuple[Finding, ...]: +def _check_module( + manager: AnalysisManager, plugins: tuple[Plugin, ...] +) -> tuple[tuple[Finding, ...], tuple[nodes.Function, ...]]: + """Findings of the module plugins plus notes for unsupported functions, and the + functions that could be analysed.""" + supported: list[nodes.Function] = [] notes: list[Finding] = [] for function in analyzable_functions(manager.module): @@ -266,7 +286,7 @@ def _check_module(manager: AnalysisManager, plugins: tuple[Plugin, ...]) -> tupl else: supported.append(function) findings = run_plugins(manager, plugins, tuple(supported)) - return (*findings, *notes) + return (*findings, *notes), tuple(supported) def _note(rule_id: str, message: str, source: SourceFile, line: int) -> Finding: diff --git a/src/coretrace_python/taint/models.py b/src/coretrace_python/taint/models.py index 05042a0..41a9d79 100644 --- a/src/coretrace_python/taint/models.py +++ b/src/coretrace_python/taint/models.py @@ -25,7 +25,8 @@ class TaintKind(Flag): PATH = auto() SSRF = auto() CODE = auto() - ALL = SQL | COMMAND | HTML | PATH | SSRF | CODE + ADVISORY = auto() + ALL = SQL | COMMAND | HTML | PATH | SSRF | CODE | ADVISORY class ModelError(Exception): @@ -111,6 +112,17 @@ def sink(self, symbol: SymbolId) -> Sink | None: found = self._by_symbol[Sink].get(symbol) return found if isinstance(found, Sink) else None + def extended(self, *sinks: Sink) -> ModelTable: + """A table with extra sinks; a sink already present gains the new kinds.""" + + merged = {sink.symbol: sink for sink in self.sinks} + for sink in sinks: + current = merged.get(sink.symbol) + merged[sink.symbol] = ( + Sink(sink.symbol, current.kinds | sink.kinds) if current is not None else sink + ) + return ModelTable(self.sources, tuple(merged.values()), self.sanitizers, self.entry_points) + def sanitizer(self, symbol: SymbolId) -> Sanitizer | None: found = self._by_symbol[Sanitizer].get(symbol) return found if isinstance(found, Sanitizer) else None diff --git a/tests/test_correlation.py b/tests/test_correlation.py new file mode 100644 index 0000000..5616279 --- /dev/null +++ b/tests/test_correlation.py @@ -0,0 +1,218 @@ +"""Acceptance tests for the correlation engine (``docs/architecture.md`` §27). + +SCA, SAST, the call graph and taint combine into one high-confidence finding: a package +required in a vulnerable version, an API the advisory affects, a call to that API +reachable in the project, and attacker-controlled data reaching that call. The engine +registers the affected APIs of vulnerable requirements as sinks of the ``ADVISORY`` +taint kind, so interprocedural taint and refutation apply unchanged, and correlates the +resulting flows into ``exploitable-vulnerability`` findings. + +Expected to remain red until ``TaintKind.ADVISORY`` and ``dependency.correlation`` exist. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.findings import Confidence, Finding, Severity +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.source import SourceManager +from coretrace_python.taint import SecurityModelRegistry, Sink, TaintKind + +try: + from coretrace_python.dependency.correlation import advisory_sinks, affected_symbols, correlate +except ImportError as error: # pragma: no cover - red until correlation lands + MISSING = error +else: + MISSING = None + + +@pytest.fixture(autouse=True) +def require_correlation() -> None: + if MISSING is not None or not hasattr(TaintKind, "ADVISORY"): + pytest.fail(f"correlation is not implemented yet: {MISSING}") + + +REPO = Path(__file__).resolve().parent.parent +PLUGINS = REPO / "plugins" + +VULNERABLE = "pyyaml==5.3.1\n" + + +def project(root: Path, files: dict[str, str]) -> Path: + for relative, text in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root + + +def rules(findings: tuple[Finding, ...]) -> list[tuple[str, str, int]]: + return sorted((Path(str(f.span.source_id)).name, f.rule_id, f.span.start_line) for f in findings) + + +def exploitable(findings: tuple[Finding, ...]) -> list[Finding]: + return [f for f in findings if f.rule_id == "exploitable-vulnerability"] + + +# --------------------------------------------------------------------------- models + + +def test_advisory_taint_kind_exists_and_is_part_of_all() -> None: + assert TaintKind.ADVISORY in TaintKind.ALL + assert TaintKind.ADVISORY & TaintKind.COMMAND == TaintKind.NONE + + +def test_model_tables_extend_with_advisory_sinks_merging_kinds() -> None: + registry = SecurityModelRegistry() + registry.register(Sink(SymbolId("python.requests.get"), TaintKind.SSRF)) + table = registry.freeze() + + extended = table.extended( + Sink(SymbolId("python.requests.get"), TaintKind.ADVISORY), + Sink(SymbolId("python.yaml.load"), TaintKind.ADVISORY), + ) + + assert extended.sink(SymbolId("python.requests.get")).kinds == TaintKind.SSRF | TaintKind.ADVISORY # type: ignore[union-attr] + assert extended.sink(SymbolId("python.yaml.load")).kinds == TaintKind.ADVISORY # type: ignore[union-attr] + assert table.sink(SymbolId("python.yaml.load")) is None + + +def test_only_affected_apis_of_vulnerable_requirements_become_sinks(tmp_path: Path) -> None: + from coretrace_python.dependency import parse_dependencies + + graph = parse_dependencies(SourceManager().add_source("requirements.txt", "pyyaml==5.3.1\nrequests==2.31.0\n")) + loaded = engine.load_plugins([PLUGINS], engine.build_manager(engine.build_hir(SourceManager().add_source("e.py", "")))) + advisories = tuple(a for p in loaded for a in p.plugin.advisories) + + affected = affected_symbols(graph, advisories) + sinks = advisory_sinks(affected) + + assert SymbolId("python.yaml.load") in affected + assert SymbolId("python.requests.get") not in affected + assert all(sink.kinds == TaintKind.ADVISORY for sink in sinks) + assert {sink.symbol for sink in sinks} == set(affected) + + +# --------------------------------------------------------------------------- end to end + + +def test_tainted_call_to_an_affected_api_is_exploitable(tmp_path: Path) -> None: + root = project( + tmp_path, + {"requirements.txt": VULNERABLE, "app.py": "import yaml\n\ndef load():\n return yaml.load(input())\n"}, + ) + + findings = engine.analyze_project(root, [PLUGINS]).findings + + assert rules(findings) == [ + ("app.py", "exploitable-vulnerability", 4), + ("app.py", "reachable-vulnerability", 4), + ("requirements.txt", "vulnerable-dependency", 1), + ] + (finding,) = exploitable(findings) + assert finding.severity is Severity.CRITICAL + assert finding.confidence is Confidence.HIGH + assert finding.function == "load" + assert finding.metadata["advisory"] == "CVE-2020-1747" + assert finding.metadata["package"] == "pyyaml" + assert finding.metadata["symbol"] == "python.yaml.load" + assert finding.metadata["source_label"] == "stdin" + assert "CVE-2020-1747" in finding.message and "stdin" in finding.message + + +def test_untainted_calls_are_reachable_but_not_exploitable(tmp_path: Path) -> None: + root = project( + tmp_path, + {"requirements.txt": VULNERABLE, "app.py": "import yaml\n\ndef load():\n return yaml.load('a: 1')\n"}, + ) + + findings = engine.analyze_project(root, [PLUGINS]).findings + + assert exploitable(findings) == [] + assert ("app.py", "reachable-vulnerability", 4) in rules(findings) + + +def test_safe_versions_produce_nothing(tmp_path: Path) -> None: + root = project( + tmp_path, + {"requirements.txt": "pyyaml==6.0\n", "app.py": "import yaml\n\ndef load():\n return yaml.load(input())\n"}, + ) + assert engine.analyze_project(root, [PLUGINS]).findings == () + + +def test_correlation_crosses_functions_and_files(tmp_path: Path) -> None: + root = project( + tmp_path, + { + "requirements.txt": VULNERABLE, + "app/__init__.py": "", + "app/config.py": "import yaml\n\ndef parse(text):\n return yaml.load(text)\n", + "app/main.py": "from app.config import parse\n\ndef run():\n parse(input())\n", + }, + ) + + findings = engine.analyze_project(root, [PLUGINS]).findings + + (finding,) = exploitable(findings) + assert Path(str(finding.span.source_id)).name == "main.py" + assert finding.span.start_line == 4 + assert finding.function == "run" + assert finding.metadata["through"] == "app.config.parse" + assert finding.metadata["sink_line"] == "4" + + +def test_refuted_flows_are_not_exploitable(tmp_path: Path) -> None: + root = project( + tmp_path, + { + "requirements.txt": VULNERABLE, + "app.py": "import yaml\n\ndef load():\n data = input()\n if not data.isdigit():\n return None\n return yaml.load(data)\n", + }, + ) + + findings = engine.analyze_project(root, [PLUGINS]).findings + + assert exploitable(findings) == [] + assert ("app.py", "reachable-vulnerability", 7) in rules(findings) + + +def test_hotspot_flows_are_reported_with_medium_confidence(tmp_path: Path) -> None: + root = project( + tmp_path, + { + "requirements.txt": VULNERABLE, + "app.py": "import yaml\n\ndef load():\n data = input()\n if data:\n return yaml.load(data)\n", + }, + ) + + (finding,) = exploitable(engine.analyze_project(root, [PLUGINS]).findings) + + assert finding.confidence is Confidence.MEDIUM + assert finding.metadata["verdict"] == "hotspot" + + +def test_advisory_sinks_do_not_trigger_the_generic_detectors(tmp_path: Path) -> None: + root = project( + tmp_path, + {"requirements.txt": VULNERABLE, "app.py": "import yaml\n\ndef load():\n return yaml.load(input())\n"}, + ) + + findings = engine.analyze_project(root, [PLUGINS]).findings + + assert not any(f.rule_id in {"command-injection", "sql-injection", "xss", "ssrf", "path-traversal"} for f in findings) + + +def test_single_files_have_no_dependency_context() -> None: + findings = engine.check( + SourceManager().add_source("one.py", "import yaml\n\ndef load():\n return yaml.load(input())\n"), + [PLUGINS], + ) + assert findings == () + + +def test_correlate_is_a_pure_function() -> None: + assert correlate("f", (), None, {}) == ()