From d5160410a0325fb4a3eb45b34e63a0aaea173bc9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 5 Sep 2026 03:40:59 +0200 Subject: [PATCH 1/2] test: define inline suppressions --- tests/test_suppressions.py | 167 +++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/test_suppressions.py diff --git a/tests/test_suppressions.py b/tests/test_suppressions.py new file mode 100644 index 0000000..9f3f289 --- /dev/null +++ b/tests/test_suppressions.py @@ -0,0 +1,167 @@ +"""Acceptance tests for inline suppressions (issue #68). + +A finding whose line carries a ``# coretrace: ignore`` comment is suppressed; with a +bracketed list, ``# coretrace: ignore[dangerous-eval, weak-crypto]``, only those rules +are. The comment works in Python sources and in the text files the project checks read +(requirements files). Suppressed findings are kept apart: the text report counts them, +the JSON report lists them, the SARIF log marks them as suppressed in source, and they +do not affect the exit status. + +Expected to remain red until ``coretrace_python.findings.suppressions`` exists. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.cli import main +from coretrace_python.source import SourceManager + +try: + from coretrace_python.findings.suppressions import partition, suppressions_in +except ImportError as error: # pragma: no cover - red until suppressions land + MISSING = error +else: + MISSING = None + + +@pytest.fixture(autouse=True) +def require_suppressions() -> None: + if MISSING is not None: + pytest.fail(f"inline suppressions are not implemented yet: {MISSING}") + + +REPO = Path(__file__).resolve().parent.parent +PLUGINS = REPO / "src" / "coretrace_python" / "bundled" + +EVAL = "def run(code):\n eval(code){comment}\n" + + +def check(text: str) -> engine.FileAnalysis: + return engine.analyze_file(SourceManager().add_source("app.py", text), [PLUGINS]) + + +def project(root: Path, files: dict[str, str]) -> Path: + for relative, text in files.items(): + (root / relative).write_text(text, encoding="utf-8") + return root + + +# --------------------------------------------------------------------------- parsing + + +def test_suppressions_are_read_from_comments_only() -> None: + text = ( + "x = 1 # coretrace: ignore\n" + "y = 2 # coretrace: ignore[dangerous-eval, weak-crypto]\n" + "z = '# coretrace: ignore'\n" + "w = 3 # coretrace:ignore[ssrf]\n" + "v = 4 # unrelated\n" + ) + + found = suppressions_in(text) + + assert found == {1: None, 2: frozenset({"dangerous-eval", "weak-crypto"}), 4: frozenset({"ssrf"})} + + +def test_suppressions_in_text_that_is_not_python_use_the_comment_syntax() -> None: + assert suppressions_in("pyyaml==5.3.1 # coretrace: ignore[vulnerable-dependency]\nflask\n") == { + 1: frozenset({"vulnerable-dependency"}) + } + + +def test_partition_separates_suppressed_findings() -> None: + analysis = check(EVAL.format(comment="")) + (finding,) = analysis.findings + + kept, suppressed = partition((finding,), lambda source_id: EVAL.format(comment=" # coretrace: ignore")) + + assert kept == () and suppressed == (finding,) + assert partition((finding,), lambda source_id: None) == ((finding,), ()) + + +# --------------------------------------------------------------------------- engine + + +def test_a_bare_ignore_suppresses_every_finding_on_its_line() -> None: + analysis = check(EVAL.format(comment=" # coretrace: ignore")) + + assert analysis.findings == () + assert [f.rule_id for f in analysis.suppressed] == ["dangerous-eval"] + assert analysis.coverage.functions_analysed == 1 + + +def test_a_scoped_ignore_suppresses_only_the_listed_rules() -> None: + assert check(EVAL.format(comment=" # coretrace: ignore[dangerous-eval]")).findings == () + other = check(EVAL.format(comment=" # coretrace: ignore[weak-crypto]")) + assert [f.rule_id for f in other.findings] == ["dangerous-eval"] + assert other.suppressed == () + + +def test_project_checks_honour_suppressions_in_requirements_files(tmp_path: Path) -> None: + root = project( + tmp_path, + { + "requirements.txt": "pyyaml==5.3.1 # coretrace: ignore[vulnerable-dependency]\n", + "app.py": "import yaml\n\ndef load(text):\n return yaml.load(text)\n", + }, + ) + + analysis = engine.analyze_project(root, [PLUGINS]) + + assert [f.rule_id for f in analysis.findings] == ["reachable-vulnerability"] + assert [f.rule_id for f in analysis.suppressed] == ["vulnerable-dependency"] + + +def test_cached_modules_stay_suppressed(tmp_path: Path) -> None: + from coretrace_python.cache import ProjectCache + + root = project(tmp_path, {"app.py": EVAL.format(comment=" # coretrace: ignore")}) + cache = ProjectCache(tmp_path / "cache") + engine.analyze_project(root, [PLUGINS], cache=cache) + + second = engine.analyze_project(root, [PLUGINS], cache=cache) + + assert second.reused == ("app",) + assert second.findings == () and len(second.suppressed) == 1 + + +# --------------------------------------------------------------------------- reports and CLI + + +def test_text_report_counts_suppressions_and_exit_status_ignores_them(tmp_path: Path, capsys) -> None: # type: ignore[no-untyped-def] + source = tmp_path / "app.py" + source.write_text(EVAL.format(comment=" # coretrace: ignore"), encoding="utf-8") + + assert main(["--check", str(source)]) == 0 + assert capsys.readouterr().out == "no findings, 1 suppressed\ncoverage: 1/1 files, 1/1 functions\n" + + +def test_json_report_lists_suppressed_findings(tmp_path: Path, capsys) -> None: # type: ignore[no-untyped-def] + source = tmp_path / "app.py" + source.write_text( + "import hashlib\n\ndef run(code):\n eval(code) # coretrace: ignore\n hashlib.md5(code)\n", + encoding="utf-8", + ) + + assert main(["--check", str(source), "--format", "json"]) == 1 + document = json.loads(capsys.readouterr().out) + + assert [f["rule_id"] for f in document["findings"]] == ["weak-crypto"] + assert [f["rule_id"] for f in document["suppressed"]] == ["dangerous-eval"] + assert document["suppressed"][0]["location"]["path"] == "app.py" + + +def test_sarif_marks_suppressed_results_as_suppressed_in_source(tmp_path: Path, capsys) -> None: # type: ignore[no-untyped-def] + source = tmp_path / "app.py" + source.write_text(EVAL.format(comment=" # coretrace: ignore"), encoding="utf-8") + + assert main(["--check", str(source), "--format", "sarif"]) == 0 + (result,) = json.loads(capsys.readouterr().out)["runs"][0]["results"] + + assert result["ruleId"] == "dangerous-eval" + assert result["suppressions"] == [{"kind": "inSource"}] From afab707dbdc90a303d9dc162f55e9f8ac22b1e06 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 5 Sep 2026 03:41:00 +0200 Subject: [PATCH 2/2] feat: silence a finding with an inline coretrace ignore comment --- docs/usage.md | 15 ++++ src/coretrace_python/cli.py | 5 +- src/coretrace_python/engine.py | 39 ++++++++-- src/coretrace_python/findings/suppressions.py | 71 +++++++++++++++++++ src/coretrace_python/reporters/json_format.py | 2 + src/coretrace_python/reporters/report.py | 3 + src/coretrace_python/reporters/sarif.py | 15 ++-- src/coretrace_python/reporters/text.py | 2 + 8 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 src/coretrace_python/findings/suppressions.py diff --git a/docs/usage.md b/docs/usage.md index cca91c5..f7e6ff2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -139,6 +139,21 @@ sources and in configuration files. | `denied-dependency` | A package the policy denies. | | `unpinned-dependency` | A requirement without an exact pin when the policy requires pins. | +## Suppressing a finding + +A finding whose line carries a `# coretrace: ignore` comment is suppressed. With a list +of rules, only those are: + +```python +data = yaml.load(text) # coretrace: ignore[insecure-deserialization] +``` + +The comment also works in requirements files, where a line +`pyyaml==5.3.1 # coretrace: ignore[vulnerable-dependency]` silences that requirement's +finding. Suppressed findings are kept apart: the text report counts them, the JSON report +lists them under `suppressed`, the SARIF log marks them as suppressed in source, and +they never affect the exit status. + ## Reports `--format` accepts `text`, `json` and `sarif`. diff --git a/src/coretrace_python/cli.py b/src/coretrace_python/cli.py index 88f581f..476e3d3 100644 --- a/src/coretrace_python/cli.py +++ b/src/coretrace_python/cli.py @@ -196,6 +196,7 @@ def main(argv: list[str] | None = None) -> int: ) findings = analysis.findings coverage = analysis.coverage + suppressed = analysis.suppressed if args.sbom is not None: args.sbom.write_text( render_sbom(analysis.dependencies, analysis.advisories, engine.TOOL_NAME, __version__), @@ -204,8 +205,10 @@ def main(argv: list[str] | None = None) -> int: else: file_analysis = engine.analyze_file(SourceManager().load_file(args.path), plugin_roots) findings, coverage = file_analysis.findings, file_analysis.coverage + suppressed = file_analysis.suppressed root = args.path.resolve() if args.path.is_dir() else args.path.resolve().parent - print(render(args.format or "text", engine.report(findings, coverage, root)), end="") + rendered = render(args.format or "text", engine.report(findings, coverage, root, suppressed)) + print(rendered, end="") return EXIT_FINDINGS if findings else EXIT_CLEAN except _ANALYSIS_ERRORS as error: print(f"error: {error}", file=sys.stderr) diff --git a/src/coretrace_python/engine.py b/src/coretrace_python/engine.py index 5e5e049..d49dfbb 100644 --- a/src/coretrace_python/engine.py +++ b/src/coretrace_python/engine.py @@ -8,7 +8,7 @@ import concurrent.futures import multiprocessing -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path from types import MappingProxyType @@ -58,6 +58,7 @@ Severity, ) from coretrace_python.findings.refutation import RefutationAnalysis +from coretrace_python.findings.suppressions import partition from coretrace_python.frontend import HIRBuildError, ParseError, build_hir from coretrace_python.hir import HIR_SCHEMA_VERSION, nodes from coretrace_python.interprocedural import ( @@ -96,7 +97,7 @@ from coretrace_python.semantic.imports import ImportAnalysis, ImportResolutionError, ImportTable from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeError from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId -from coretrace_python.source import SourceFile, SourceManager, SourceSpan, decode_text +from coretrace_python.source import SourceFile, SourceId, SourceManager, SourceSpan, decode_text from coretrace_python.taint import ( EntryPoint, ModelTable, @@ -189,12 +190,32 @@ class ProjectAnalysis: reused: tuple[str, ...] = () advisories: tuple[Advisory, ...] = () coverage: Coverage = field(default_factory=Coverage) + # Findings silenced by an inline ``# coretrace: ignore`` comment. + suppressed: tuple[Finding, ...] = () @dataclass(frozen=True) class FileAnalysis: findings: tuple[Finding, ...] coverage: Coverage + suppressed: tuple[Finding, ...] = () + + +def _text_of(sources: SourceManager) -> Callable[[SourceId], str | None]: + """The text of a source by identifier: loaded sources first, then the file on disk.""" + + def text_of(source_id: SourceId) -> str | None: + try: + return sources.get(source_id).text + except KeyError: + pass + path = Path(str(source_id)) + try: + return decode_text(path.read_bytes()) if path.is_file() else None + except (OSError, UnicodeDecodeError): + return None + + return text_of def build_manager( @@ -257,7 +278,8 @@ def analyze_file(source: SourceFile, plugin_roots: Sequence[Path]) -> FileAnalys findings, supported = _check_module(manager, tuple(loaded.plugin for loaded in registry)) functions = len(analyzable_functions(manager.module)) coverage = Coverage((FileCoverage(str(source.source_id), "analysed", functions, len(supported)),)) - return FileAnalysis(findings, coverage) + kept, suppressed = partition(findings, lambda sid: source.text if sid == source.source_id else None) + return FileAnalysis(kept, coverage, suppressed) def resolve_dependencies(root: Path, sources: SourceManager) -> DependencyGraph: @@ -438,15 +460,17 @@ def analyze_project( for plugin in all_plugins: if isinstance(plugin, ProjectPlugin): findings.extend(plugin.analyze_project(context)) + kept, suppressed = partition(apply_policy(policy, findings), _text_of(sources)) return ProjectAnalysis( graph, index, - apply_policy(policy, findings), + kept, dependencies, MappingProxyType(keys), reused, advisories, Coverage(tuple(sorted(coverage, key=lambda c: c.path))), + suppressed, ) @@ -685,8 +709,11 @@ def _register_all(module: nodes.Module) -> AnalysisManager: def report( - findings: Sequence[Finding], coverage: Coverage | None = None, root: Path | None = None + findings: Sequence[Finding], + coverage: Coverage | None = None, + root: Path | None = None, + suppressed: Sequence[Finding] = (), ) -> Report: """The report of a check; paths under ``root`` are rendered relative to it.""" - return Report(tuple(findings), TOOL_NAME, __version__, coverage, root) + return Report(tuple(findings), TOOL_NAME, __version__, coverage, root, tuple(suppressed)) diff --git a/src/coretrace_python/findings/suppressions.py b/src/coretrace_python/findings/suppressions.py new file mode 100644 index 0000000..c9bd213 --- /dev/null +++ b/src/coretrace_python/findings/suppressions.py @@ -0,0 +1,71 @@ +"""Inline suppressions: ``# coretrace: ignore`` on the line of a finding. + +A bare ``ignore`` silences every finding reported on that line; ``ignore[rule, rule]`` +silences the listed rules only. Python sources are tokenized so a string that merely +contains the marker is not a suppression; other text files (requirements files, +configuration) are read line by line with the same comment syntax. +""" + +from __future__ import annotations + +import io +import re +import tokenize +from collections.abc import Callable, Iterable, Mapping + +from coretrace_python.findings.model import Finding +from coretrace_python.source import SourceId + +MARKER = re.compile(r"#\s*coretrace:\s*ignore(?:\[([^\]]*)\])?") + +Suppressions = Mapping[int, frozenset[str] | None] +"""Line number to the suppressed rules, ``None`` meaning every rule.""" + + +def suppressions_in(text: str) -> Suppressions: + found: dict[int, frozenset[str] | None] = {} + for line, comment in _comments(text): + match = MARKER.search(comment) + if match is None: + continue + rules = match.group(1) + if rules is None: + found[line] = None + elif found.get(line, ()) is not None: + listed = frozenset(r.strip() for r in rules.split(",") if r.strip()) + found[line] = frozenset(found.get(line) or ()) | listed + return found + + +def _comments(text: str) -> Iterable[tuple[int, str]]: + try: + tokens = list(tokenize.generate_tokens(io.StringIO(text).readline)) + except (tokenize.TokenError, SyntaxError): + for number, line in enumerate(text.splitlines(), 1): + if "#" in line: + yield number, line[line.index("#") :] + return + for token in tokens: + if token.type == tokenize.COMMENT: + yield token.start[0], token.string + + +def partition( + findings: Iterable[Finding], text_of: Callable[[SourceId], str | None] +) -> tuple[tuple[Finding, ...], tuple[Finding, ...]]: + """Split findings into the kept and the suppressed, reading each file's text once.""" + + cache: dict[SourceId, Suppressions] = {} + kept: list[Finding] = [] + suppressed: list[Finding] = [] + for finding in findings: + source_id = finding.span.source_id + if source_id not in cache: + text = text_of(source_id) + cache[source_id] = suppressions_in(text) if text is not None else {} + rules = cache[source_id].get(finding.span.start_line, frozenset()) + if rules is None or finding.rule_id in rules: + suppressed.append(finding) + else: + kept.append(finding) + return tuple(kept), tuple(suppressed) diff --git a/src/coretrace_python/reporters/json_format.py b/src/coretrace_python/reporters/json_format.py index 25bfd41..362681d 100644 --- a/src/coretrace_python/reporters/json_format.py +++ b/src/coretrace_python/reporters/json_format.py @@ -36,6 +36,8 @@ def render_json(report: Report) -> str: if report.root is not None: document["root"] = str(report.root) document["findings"] = [finding_record(finding, report) for finding in report.findings] + if report.suppressed: + document["suppressed"] = [finding_record(finding, report) for finding in report.suppressed] if report.coverage is not None: coverage = report.coverage document["coverage"] = { diff --git a/src/coretrace_python/reporters/report.py b/src/coretrace_python/reporters/report.py index 75a6725..0c447c8 100644 --- a/src/coretrace_python/reporters/report.py +++ b/src/coretrace_python/reporters/report.py @@ -21,9 +21,12 @@ class Report: coverage: Coverage | None = None # The directory the report is about: paths under it are rendered relative to it. root: Path | None = None + # Findings silenced by an inline suppression; reported apart, never counted. + suppressed: tuple[Finding, ...] = () def __post_init__(self) -> None: object.__setattr__(self, "findings", tuple(sorted(self.findings, key=_order))) + object.__setattr__(self, "suppressed", tuple(sorted(self.suppressed, key=_order))) def locate(self, path: str) -> str: """``path`` relative to the root, POSIX style, when it lies under the root.""" diff --git a/src/coretrace_python/reporters/sarif.py b/src/coretrace_python/reporters/sarif.py index e1d1806..2ed4223 100644 --- a/src/coretrace_python/reporters/sarif.py +++ b/src/coretrace_python/reporters/sarif.py @@ -26,13 +26,15 @@ def _artifact(report: Report, path: str) -> dict[str, str]: return {"uri": path} -def _result(report: Report, finding: Finding, rule_index: int) -> dict[str, object]: +def _result( + report: Report, finding: Finding, rule_index: int, suppressed: bool = False +) -> dict[str, object]: span = finding.span region: dict[str, int] = {"startLine": span.start_line, "startColumn": span.start_column} if span.end_line is not None and span.end_column is not None: region["endLine"] = span.end_line region["endColumn"] = span.end_column - return { + result: dict[str, object] = { "ruleId": finding.rule_id, "ruleIndex": rule_index, "level": _LEVELS[finding.severity], @@ -46,11 +48,14 @@ def _result(report: Report, finding: Finding, rule_index: int) -> dict[str, obje } ], } + if suppressed: + result["suppressions"] = [{"kind": "inSource"}] + return result def render_sarif(report: Report) -> str: rule_ids: list[str] = [] - for finding in report.findings: + for finding in (*report.findings, *report.suppressed): if finding.rule_id not in rule_ids: rule_ids.append(finding.rule_id) run: dict[str, object] = {} @@ -69,8 +74,8 @@ def render_sarif(report: Report) -> str: } }, "results": [ - _result(report, finding, rule_ids.index(finding.rule_id)) - for finding in report.findings + *(_result(report, f, rule_ids.index(f.rule_id)) for f in report.findings), + *(_result(report, f, rule_ids.index(f.rule_id), True) for f in report.suppressed), ], } ) diff --git a/src/coretrace_python/reporters/text.py b/src/coretrace_python/reporters/text.py index 86722d2..8e57668 100644 --- a/src/coretrace_python/reporters/text.py +++ b/src/coretrace_python/reporters/text.py @@ -18,6 +18,8 @@ def render_text(report: Report) -> str: lines.append(line) count = len(report.findings) summary = "no findings" if count == 0 else f"{count} finding{'s' if count > 1 else ''}" + if report.suppressed: + summary += f", {len(report.suppressed)} suppressed" lines.append(summary) if report.coverage is not None: lines.append(report.coverage.summary())