Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,34 +144,40 @@ 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:

```json
{
"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
Expand Down
3 changes: 2 additions & 1 deletion src/coretrace_python/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
18 changes: 13 additions & 5 deletions src/coretrace_python/reporters/json_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"] = {
Expand All @@ -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
],
}
Expand Down
16 changes: 16 additions & 0 deletions src/coretrace_python/reporters/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path, PurePosixPath

from coretrace_python.findings import Coverage, Finding

Expand All @@ -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
36 changes: 22 additions & 14 deletions src/coretrace_python/reporters/sarif.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand All @@ -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,
}
}
Expand All @@ -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,
Expand All @@ -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"
2 changes: 1 addition & 1 deletion src/coretrace_python/reporters/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:")
2 changes: 1 addition & 1 deletion tests/test_detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
2 changes: 1 addition & 1 deletion tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
125 changes: 125 additions & 0 deletions tests/test_relative_paths.py
Original file line number Diff line number Diff line change
@@ -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",
}
Loading
Loading