From 29af3912cc89abd8667a52b2160fc7eb2f5e1363 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 5 Sep 2026 03:27:23 +0200 Subject: [PATCH 1/2] test: define report paths relative to the checked root --- tests/test_relative_paths.py | 125 +++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/test_relative_paths.py diff --git a/tests/test_relative_paths.py b/tests/test_relative_paths.py new file mode 100644 index 0000000..9ce74f5 --- /dev/null +++ b/tests/test_relative_paths.py @@ -0,0 +1,125 @@ +"""Acceptance tests for report paths relative to the checked root (issue #68). + +A report knows the root it was produced for: the directory checked, or the directory of +the file checked. Every reporter renders paths under that root relative to it, POSIX +style, and leaves other paths as they are. The SARIF log names the root once as the +``SRCROOT`` original URI base, so code scanning services attach results to files. The JSON +document carries the root so consumers can rebuild absolute paths. + +Expected to remain red until ``Report`` has a ``root`` and the CLI passes it. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from coretrace_python.cli import main +from coretrace_python.findings import Confidence, Coverage, FileCoverage, Finding, Severity +from coretrace_python.reporters import Report, render_json, render_sarif, render_text +from coretrace_python.source import SourceId, SourceSpan + +MISSING = None if "root" in Report.__dataclass_fields__ else "Report has no root" + + +@pytest.fixture(autouse=True) +def require_root() -> None: + if MISSING is not None: + pytest.fail(f"relative report paths are not implemented yet: {MISSING}") + + +REPO = Path(__file__).resolve().parent.parent +PLUGINS = REPO / "src" / "coretrace_python" / "bundled" +ROOT = Path("/work/project") + + +def finding(path: str, line: int = 3) -> Finding: + return Finding( + "dangerous-eval", "m", Severity.HIGH, Confidence.HIGH, SourceSpan(SourceId(path), line, 5, line, 9), "run" + ) + + +def report(*findings: Finding, root: Path | None = ROOT) -> Report: + coverage = Coverage(tuple(FileCoverage(str(f.span.source_id), "analysed", 1, 1) for f in findings)) + return Report(findings, "coretrace-python-analyzer", "0.1.0", coverage, root) + + +# --------------------------------------------------------------------------- reporters + + +def test_text_paths_are_relative_to_the_root() -> None: + text = render_text(report(finding("/work/project/app/views.py"), finding("/elsewhere/x.py"))) + + assert text.startswith("/elsewhere/x.py:3:5: high dangerous-eval: m [run]\napp/views.py:3:5:") + + +def test_json_paths_are_relative_and_the_root_is_recorded() -> None: + document = json.loads(render_json(report(finding("/work/project/app/views.py")))) + + assert document["root"] == "/work/project" + assert document["findings"][0]["location"]["path"] == "app/views.py" + assert document["coverage"]["details"][0]["path"] == "app/views.py" + + +def test_json_without_a_root_keeps_paths_and_omits_the_root() -> None: + document = json.loads(render_json(report(finding("/work/project/app/views.py"), root=None))) + + assert "root" not in document + assert document["findings"][0]["location"]["path"] == "/work/project/app/views.py" + + +def test_sarif_locations_use_the_srcroot_uri_base() -> None: + document = json.loads(render_sarif(report(finding("/work/project/app/views.py")))) + run = document["runs"][0] + + assert run["originalUriBaseIds"] == {"SRCROOT": {"uri": "file:///work/project/"}} + location = run["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"] + assert location == {"uri": "app/views.py", "uriBaseId": "SRCROOT"} + + +def test_sarif_paths_outside_the_root_stay_absolute_without_a_base() -> None: + document = json.loads(render_sarif(report(finding("/elsewhere/x.py")))) + location = document["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"] + + assert location == {"uri": "/elsewhere/x.py"} + + +def test_sarif_without_a_root_has_no_uri_bases() -> None: + document = json.loads(render_sarif(report(finding("app/views.py"), root=None))) + + assert "originalUriBaseIds" not in document["runs"][0] + + +# --------------------------------------------------------------------------- CLI + + +def test_directory_checks_report_paths_relative_to_the_directory(tmp_path: Path, capsys) -> None: # type: ignore[no-untyped-def] + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "app.py").write_text("def run(code):\n eval(code)\n", encoding="utf-8") + + assert main(["--check", str(tmp_path), "--format", "json"]) == 1 + document = json.loads(capsys.readouterr().out) + + assert document["root"] == str(tmp_path.resolve()) + assert document["findings"][0]["location"]["path"] == "pkg/app.py" + assert document["coverage"]["details"][0]["path"] == "pkg/app.py" + + assert main(["--check", str(tmp_path)]) == 1 + assert capsys.readouterr().out.startswith("pkg/app.py:2:5: high dangerous-eval") + + +def test_file_checks_report_paths_relative_to_the_file_directory(tmp_path: Path, capsys) -> None: # type: ignore[no-untyped-def] + source = tmp_path / "app.py" + source.write_text("def run(code):\n eval(code)\n", encoding="utf-8") + + assert main(["--check", str(source), "--format", "sarif"]) == 1 + document = json.loads(capsys.readouterr().out) + run = document["runs"][0] + + assert run["originalUriBaseIds"]["SRCROOT"]["uri"] == tmp_path.resolve().as_uri() + "/" + assert run["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"] == { + "uri": "app.py", + "uriBaseId": "SRCROOT", + } From 675bf567ab538257e2f03599922e185817b6270c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 5 Sep 2026 03:27:23 +0200 Subject: [PATCH 2/2] feat: render report paths relative to the checked root --- docs/usage.md | 14 +++++--- src/coretrace_python/cli.py | 3 +- src/coretrace_python/engine.py | 8 +++-- src/coretrace_python/reporters/json_format.py | 18 +++++++--- src/coretrace_python/reporters/report.py | 16 +++++++++ src/coretrace_python/reporters/sarif.py | 36 +++++++++++-------- src/coretrace_python/reporters/text.py | 2 +- tests/test_cli.py | 5 +-- tests/test_dependencies.py | 2 +- tests/test_detectors.py | 2 +- tests/test_project.py | 2 +- tests/test_syntax_coverage.py | 4 +-- 12 files changed, 78 insertions(+), 34 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index f429447..cca91c5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -144,7 +144,8 @@ sources and in configuration files. `--format` accepts `text`, `json` and `sarif`. `--format text` prints one line per finding, `path:line:column: severity rule: message`, -then the count and the coverage line. +then the count and the coverage line. Paths are relative to the checked directory, or to +the directory of the checked file; a path outside it is printed as it is. `--format json` prints one document: @@ -152,26 +153,31 @@ then the count and the coverage line. { "schema_version": 1, "tool": {"name": "coretrace-python-analyzer", "version": "0.1.0"}, + "root": "/home/me/project", "findings": [ { "rule_id": "sql-injection", "message": "SQL injection: http input reaches python.sqlite3.connect.cursor.execute", "severity": "high", "confidence": "high", - "location": {"path": "src/app.py", "line": 50, "column": 21, "end_line": 50, "end_column": 60}, + "location": {"path": "app.py", "line": 50, "column": 21, "end_line": 50, "end_column": 60}, "function": "user", "metadata": {"source": "python.flask.request.args", "verdict": "vulnerability"} } ], "coverage": { "files": 2, "files_analysed": 2, "functions": 12, "functions_analysed": 12, - "details": [{"path": "src/app.py", "status": "analysed", "functions": 9, "analysed": 9}] + "details": [{"path": "app.py", "status": "analysed", "functions": 9, "analysed": 9}] } } ``` +`root` is the directory the paths are relative to. + `--format sarif` prints a SARIF 2.1.0 log, one run with the tool, its rules and one -result per finding, ready for code scanning services: +result per finding. The root is declared once as the `SRCROOT` original URI base and +every location under it is relative to that base, which is what code scanning services +need to attach results to files: ```bash coretrace-python-analyzer --check src/ --format sarif > report.sarif diff --git a/src/coretrace_python/cli.py b/src/coretrace_python/cli.py index 135a60a..88f581f 100644 --- a/src/coretrace_python/cli.py +++ b/src/coretrace_python/cli.py @@ -204,7 +204,8 @@ 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 - print(render(args.format or "text", engine.report(findings, coverage)), end="") + 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="") 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 73a5643..5e5e049 100644 --- a/src/coretrace_python/engine.py +++ b/src/coretrace_python/engine.py @@ -684,5 +684,9 @@ def _register_all(module: nodes.Module) -> AnalysisManager: return manager -def report(findings: Sequence[Finding], coverage: Coverage | None = None) -> Report: - return Report(tuple(findings), TOOL_NAME, __version__, coverage) +def report( + findings: Sequence[Finding], coverage: Coverage | None = None, root: Path | None = None +) -> Report: + """The report of a check; paths under ``root`` are rendered relative to it.""" + + return Report(tuple(findings), TOOL_NAME, __version__, coverage, root) diff --git a/src/coretrace_python/reporters/json_format.py b/src/coretrace_python/reporters/json_format.py index be04e3c..25bfd41 100644 --- a/src/coretrace_python/reporters/json_format.py +++ b/src/coretrace_python/reporters/json_format.py @@ -8,15 +8,16 @@ from coretrace_python.reporters.report import Report -def finding_record(finding: Finding) -> dict[str, object]: +def finding_record(finding: Finding, report: Report | None = None) -> dict[str, object]: span = finding.span + path = str(span.source_id) return { "rule_id": finding.rule_id, "message": finding.message, "severity": finding.severity.value, "confidence": finding.confidence.value, "location": { - "path": str(span.source_id), + "path": report.locate(path) if report is not None else path, "line": span.start_line, "column": span.start_column, "end_line": span.end_line, @@ -28,11 +29,13 @@ def finding_record(finding: Finding) -> dict[str, object]: def render_json(report: Report) -> str: - document = { + document: dict[str, object] = { "schema_version": FINDING_SCHEMA_VERSION, "tool": {"name": report.tool_name, "version": report.tool_version}, - "findings": [finding_record(finding) for finding in report.findings], } + if report.root is not None: + document["root"] = str(report.root) + document["findings"] = [finding_record(finding, report) for finding in report.findings] if report.coverage is not None: coverage = report.coverage document["coverage"] = { @@ -41,7 +44,12 @@ def render_json(report: Report) -> str: "functions": coverage.functions, "functions_analysed": coverage.functions_analysed, "details": [ - {"path": d.path, "status": d.status, "functions": d.functions, "analysed": d.analysed} + { + "path": report.locate(d.path), + "status": d.status, + "functions": d.functions, + "analysed": d.analysed, + } for d in coverage.details ], } diff --git a/src/coretrace_python/reporters/report.py b/src/coretrace_python/reporters/report.py index 90fbf42..75a6725 100644 --- a/src/coretrace_python/reporters/report.py +++ b/src/coretrace_python/reporters/report.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path, PurePosixPath from coretrace_python.findings import Coverage, Finding @@ -18,6 +19,21 @@ class Report: tool_name: str tool_version: str coverage: Coverage | None = None + # The directory the report is about: paths under it are rendered relative to it. + root: Path | None = None def __post_init__(self) -> None: object.__setattr__(self, "findings", tuple(sorted(self.findings, key=_order))) + + def locate(self, path: str) -> str: + """``path`` relative to the root, POSIX style, when it lies under the root.""" + + if self.root is None: + return path + try: + return PurePosixPath(Path(path).relative_to(self.root)).as_posix() + except ValueError: + return path + + def under_root(self, path: str) -> bool: + return self.root is not None and self.locate(path) != path diff --git a/src/coretrace_python/reporters/sarif.py b/src/coretrace_python/reporters/sarif.py index a0d6e2e..e1d1806 100644 --- a/src/coretrace_python/reporters/sarif.py +++ b/src/coretrace_python/reporters/sarif.py @@ -9,6 +9,7 @@ SARIF_VERSION = "2.1.0" SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" +SRCROOT = "SRCROOT" _LEVELS = { Severity.CRITICAL: "error", @@ -19,7 +20,13 @@ } -def _result(finding: Finding, rule_index: int) -> dict[str, object]: +def _artifact(report: Report, path: str) -> dict[str, str]: + if report.under_root(path): + return {"uri": report.locate(path), "uriBaseId": SRCROOT} + return {"uri": path} + + +def _result(report: Report, finding: Finding, rule_index: int) -> 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: @@ -33,7 +40,7 @@ def _result(finding: Finding, rule_index: int) -> dict[str, object]: "locations": [ { "physicalLocation": { - "artifactLocation": {"uri": str(span.source_id)}, + "artifactLocation": _artifact(report, str(span.source_id)), "region": region, } } @@ -46,12 +53,12 @@ def render_sarif(report: Report) -> str: for finding in report.findings: if finding.rule_id not in rule_ids: rule_ids.append(finding.rule_id) - document = { - "$schema": SARIF_SCHEMA, - "version": SARIF_VERSION, - "runs": [ - { - "tool": { + run: dict[str, object] = {} + if report.root is not None: + run["originalUriBaseIds"] = {SRCROOT: {"uri": report.root.as_uri() + "/"}} + run.update( + { + "tool": { "driver": { "name": report.tool_name, "version": report.tool_version, @@ -61,10 +68,11 @@ def render_sarif(report: Report) -> str: ], } }, - "results": [ - _result(finding, rule_ids.index(finding.rule_id)) for finding in report.findings - ], - } - ], - } + "results": [ + _result(report, finding, rule_ids.index(finding.rule_id)) + for finding in report.findings + ], + } + ) + document = {"$schema": SARIF_SCHEMA, "version": SARIF_VERSION, "runs": [run]} return json.dumps(document, indent=2) + "\n" diff --git a/src/coretrace_python/reporters/text.py b/src/coretrace_python/reporters/text.py index 06ba6c6..86722d2 100644 --- a/src/coretrace_python/reporters/text.py +++ b/src/coretrace_python/reporters/text.py @@ -10,7 +10,7 @@ def render_text(report: Report) -> str: for finding in report.findings: span = finding.span line = ( - f"{span.source_id}:{span.start_line}:{span.start_column}: " + f"{report.locate(str(span.source_id))}:{span.start_line}:{span.start_column}: " f"{finding.severity.value} {finding.rule_id}: {finding.message}" ) if finding.function is not None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0152d3f..0a06305 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,7 +43,7 @@ def test_check_reports_findings_from_loaded_plugins(tmp_path, capsys) -> None: assert exit_code == 1 assert captured.out == ( - f"{source}:2:5: high dangerous-eval: call to python.builtins.eval executes" + "target.py:2:5: high dangerous-eval: call to python.builtins.eval executes" " dynamically built code [run]\n" "1 finding\n" "coverage: 1/1 files, 1/1 functions\n" @@ -73,7 +73,8 @@ def test_check_json_format(tmp_path, capsys) -> None: assert exit_code == 1 assert document["tool"]["name"] == "coretrace-python-analyzer" assert [f["rule_id"] for f in document["findings"]] == ["dangerous-eval"] - assert document["findings"][0]["location"]["path"] == str(source) + assert document["findings"][0]["location"]["path"] == source.name + assert document["root"] == str(source.resolve().parent) def test_check_sarif_format(tmp_path, capsys) -> None: diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 5fccb43..0a56808 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -342,4 +342,4 @@ def test_cli_reports_dependency_findings(tmp_path: Path, capsys) -> None: # typ output = capsys.readouterr().out assert exit_code == 1 - assert output.startswith(f"{(root / 'requirements.txt').resolve()}:1:1: critical vulnerable-dependency:") + assert output.startswith("requirements.txt:1:1: critical vulnerable-dependency:") diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 9dea70a..3ffd836 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -322,4 +322,4 @@ def test_cli_reports_taint_findings(tmp_path: Path, capsys) -> None: # type: ig output = capsys.readouterr().out assert exit_code == 1 - assert output.startswith(f"{source}:4:5: high command-injection: Command injection: stdin input reaches python.os.system [run]\n") + assert output.startswith("run.py:4:5: high command-injection: Command injection: stdin input reaches python.os.system [run]\n") diff --git a/tests/test_project.py b/tests/test_project.py index 73cfa3d..9b59a59 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -257,7 +257,7 @@ def test_check_accepts_a_directory(tmp_path: Path, capsys) -> None: # type: ign assert exit_code == 1 assert output == ( - f"{(root / 'app' / 'main.py').resolve()}:4:5: high command-injection: Command injection:" + "app/main.py:4:5: high command-injection: Command injection:" " stdin input reaches python.os.system through app.helpers.execute [run]\n" "1 finding\n" "coverage: 4/4 files, 3/3 functions\n" diff --git a/tests/test_syntax_coverage.py b/tests/test_syntax_coverage.py index 22d423a..c6c1da7 100644 --- a/tests/test_syntax_coverage.py +++ b/tests/test_syntax_coverage.py @@ -277,9 +277,9 @@ def test_check_reports_unsupported_functions_and_keeps_going(tmp_path, capsys) - output = capsys.readouterr().out assert exit_code == 1 - assert f"{source}:3:1: info unsupported-syntax: " in output + assert "mixed.py:3:1: info unsupported-syntax: " in output assert "break" in output - assert f"{source}:8:9: high dangerous-eval:" in output + assert "mixed.py:8:9: high dangerous-eval:" in output assert output.endswith("2 findings\ncoverage: 1/1 files, 1/2 functions\n")