Files
magnus919_agent-skills/binary-analysis/tests/unit/test_reporting.py
T

1032 lines
37 KiB
Python

"""Tests for reporting and audit commands.
Validates reporting assertions:
- VAL-REPORT-001: Export report produces markdown as authoritative format
- VAL-REPORT-002: Export report produces JSON as authoritative format
- VAL-REPORT-003: Export report includes methodology summary and parameters
- VAL-REPORT-004: Export report includes full provenance with analysis_id
- VAL-REPORT-005: HTML and PDF are optional renderings only
- VAL-REPORT-006: Export report supports all report types; focused requires --selector
- VAL-REPORT-007: Audit events are append-only with timestamps
- VAL-REPORT-008: Audit events record command, args, result, duration_ms
- VAL-REPORT-009: Audit events are written atomically
- VAL-CROSS-011: Full lifecycle audit completeness
"""
from __future__ import annotations
import sys
from pathlib import Path
_scripts_dir = Path(__file__).resolve().parents[2] / "scripts"
if str(_scripts_dir) not in sys.path:
sys.path.insert(0, str(_scripts_dir))
import io
import json
import os
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from binary_analysis.cli.main import main
from binary_analysis.domain.enums import AuditResult, ExitCode, ProjectState
from binary_analysis.reporting.audit import (
clear_audit,
read_audit_events,
write_audit_event,
)
from binary_analysis.reporting.generator import (
build_methodology,
build_provenance,
generate_json_report,
generate_markdown_report,
)
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def temp_workspace_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect workspace root to a temp directory for all tests."""
root = tmp_path / "workspaces"
root.mkdir(parents=True)
monkeypatch.setenv("BINARY_WORKSPACE_ROOT", str(root))
return root
def _make_analyzed_project(
name: str,
binary_format: str = "PE",
binary_arch: str = "x86",
) -> str:
"""Helper: create a project in READY state with analyzed binary."""
from binary_analysis.projects.manifest import create_manifest, save_manifest
from binary_analysis.projects.workspace import create_workspace
project_dir = str(create_workspace(name))
manifest = create_manifest(name)
manifest["state"] = ProjectState.READY.value
manifest["binary_count"] = 1
binary_id = str(UUID(int=99))
binary_record = {
"id": binary_id,
"sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"path": "/fake/test.exe",
"format": binary_format,
"import_mode": "copy",
"size_bytes": 16384,
"architecture": binary_arch,
}
manifest["current_binary"] = binary_record
binaries_dir = os.path.join(project_dir, "binaries")
os.makedirs(binaries_dir, exist_ok=True)
with open(os.path.join(binaries_dir, f"{binary_id}.json"), "w") as f:
json.dump(binary_record, f)
save_manifest(project_dir, manifest)
return project_dir
def _make_created_project(name: str) -> str:
"""Helper: create a project in CREATED state (no binary imported)."""
from binary_analysis.projects.manifest import create_manifest, save_manifest
from binary_analysis.projects.workspace import create_workspace
project_dir = str(create_workspace(name))
manifest = create_manifest(name)
save_manifest(project_dir, manifest)
return project_dir
def _capture_json(
args: list[str],
capsys: pytest.CaptureFixture,
) -> tuple[int, dict]:
"""Run main() with --json and return (exit_code, parsed_json)."""
import sys as _sys
old_stdin = _sys.stdin
try:
_sys.stdin = io.StringIO("")
exit_code = main(["--json", *args])
finally:
_sys.stdin = old_stdin
captured = capsys.readouterr()
parsed = json.loads(captured.out) if captured.out.strip() else {}
return exit_code, parsed
# ---------------------------------------------------------------------------
# VAL-REPORT-001: Export report produces markdown as authoritative format
# ---------------------------------------------------------------------------
class TestMarkdownReport:
"""Tests for Markdown report generation (VAL-REPORT-001)."""
def test_markdown_report_created_in_reports_dir(self, capsys):
"""Markdown report created in project/reports/ with .md extension."""
_make_analyzed_project("md-test")
exit_code, envelope = _capture_json(
["export-report", "--project", "md-test", "--type", "triage", "--format", "markdown"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
report_path = envelope["data"]["report_path"]
assert report_path.endswith(".md")
assert "reports" in report_path
assert os.path.exists(report_path)
def test_markdown_report_is_self_contained(self, capsys):
"""Markdown report has headings, tables, and code blocks."""
_make_analyzed_project("md-self-contained")
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"md-self-contained",
"--type",
"project",
"--format",
"markdown",
],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
with open(report_path) as f:
content = f.read()
assert "# Binary Analysis Report" in content
assert "## Methodology" in content
assert "## Provenance" in content
assert "|" in content # Tables
assert "---" in content # Headings
def test_markdown_report_has_methodology_section(self, capsys):
"""Markdown report includes ## Methodology section."""
_make_analyzed_project("md-method")
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"md-method",
"--type",
"project",
"--format",
"markdown",
],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
with open(report_path) as f:
content = f.read()
assert "## Methodology" in content
assert "| Profile |" in content
assert "| Rules Version |" in content
assert "| Backend |" in content
assert "| Adapter |" in content
assert "### Parameters" in content
# ---------------------------------------------------------------------------
# VAL-REPORT-002: Export report produces JSON as authoritative format
# ---------------------------------------------------------------------------
class TestJSONReport:
"""Tests for JSON report generation (VAL-REPORT-002)."""
def test_json_report_created_in_reports_dir(self, capsys):
"""JSON report created in project/reports/ with .json extension."""
_make_analyzed_project("json-test")
exit_code, envelope = _capture_json(
["export-report", "--project", "json-test", "--type", "triage", "--format", "json"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
report_path = envelope["data"]["report_path"]
assert report_path.endswith(".json")
assert "reports" in report_path
assert os.path.exists(report_path)
def test_json_report_is_valid_json_matching_canonical_schema(self, capsys):
"""JSON report is valid JSON matching canonical envelope schema."""
_make_analyzed_project("json-schema")
exit_code, envelope = _capture_json(
["export-report", "--project", "json-schema", "--type", "project", "--format", "json"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
with open(report_path) as f:
report = json.load(f)
# Verify canonical envelope schema
assert "schema_version" in report
assert report["schema_version"] == "1.0.0"
assert "report_type" in report
assert report["report_type"] == "PROJECT"
assert "methodology" in report
assert "provenance" in report
assert "data" in report
def test_json_report_passes_json_tool(self, capsys):
"""JSON report can be parsed by python3 json.tool."""
_make_analyzed_project("json-tool-test")
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"json-tool-test",
"--type",
"triage",
"--format",
"json",
],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
# Read and re-parse to ensure roundtrip validity
with open(report_path) as f:
content = f.read()
parsed = json.loads(content)
re_encoded = json.dumps(parsed)
re_parsed = json.loads(re_encoded)
assert parsed == re_parsed
# ---------------------------------------------------------------------------
# VAL-REPORT-003: Methodology section with all non-null fields
# ---------------------------------------------------------------------------
class TestMethodology:
"""Tests for methodology section (VAL-REPORT-003)."""
def test_methodology_all_fields_non_null(self):
"""Methodology has profile, rules_version, backend, adapter, parameters all non-null."""
meth = build_methodology(
profile="standard",
rules_version="1.0.0",
backend="Ghidra",
adapter="ghidra",
parameters={"limit": 100},
)
assert meth["profile"] is not None
assert meth["rules_version"] is not None
assert meth["backend"] is not None
assert meth["adapter"] is not None
assert meth["parameters"] is not None
def test_json_report_includes_methodology(self, capsys):
"""JSON report has data.methodology with all non-null fields."""
_make_analyzed_project("meth-json")
exit_code, envelope = _capture_json(
["export-report", "--project", "meth-json", "--type", "project", "--format", "json"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
with open(report_path) as f:
report = json.load(f)
meth = report["methodology"]
assert meth["profile"] is not None
assert meth["rules_version"] is not None
assert meth["backend"] is not None
assert meth["adapter"] is not None
assert meth["parameters"] is not None
def test_markdown_report_includes_methodology(self, capsys):
"""Markdown report has ## Methodology section with all fields."""
_make_analyzed_project("meth-md")
exit_code, envelope = _capture_json(
["export-report", "--project", "meth-md", "--type", "project", "--format", "markdown"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
with open(report_path) as f:
content = f.read()
assert "## Methodology" in content
assert "| Profile |" in content
# ---------------------------------------------------------------------------
# VAL-REPORT-004: Provenance with analysis_id UUID
# ---------------------------------------------------------------------------
class TestProvenance:
"""Tests for provenance section (VAL-REPORT-004)."""
def test_provenance_includes_all_required_fields(self):
"""Provenance includes cli_version, project_id, binary_id, binary_sha256,
analysis_id (UUID), generated_at."""
prov = build_provenance(
cli_version="0.1.0",
project_id="proj-123",
binary_id="bin-456",
binary_sha256="a" * 64,
)
assert "cli_version" in prov
assert "project_id" in prov
assert "binary_id" in prov
assert "binary_sha256" in prov
assert "analysis_id" in prov
assert "generated_at" in prov
def test_analysis_id_is_valid_uuid(self):
"""analysis_id is a valid UUID string."""
prov = build_provenance()
analysis_id = prov["analysis_id"]
# Should parse without error
UUID(analysis_id)
def test_sequential_reports_have_different_analysis_ids(self, capsys):
"""Two sequential reports have different analysis_id values."""
_make_analyzed_project("seq-test")
exit_code1, env1 = _capture_json(
["export-report", "--project", "seq-test", "--type", "triage", "--format", "json"],
capsys,
)
exit_code2, env2 = _capture_json(
["export-report", "--project", "seq-test", "--type", "triage", "--format", "json"],
capsys,
)
assert exit_code1 == ExitCode.SUCCESS
assert exit_code2 == ExitCode.SUCCESS
# Read both report files
with open(env1["data"]["report_path"]) as f:
report1 = json.load(f)
with open(env2["data"]["report_path"]) as f:
report2 = json.load(f)
aid1 = report1["provenance"]["analysis_id"]
aid2 = report2["provenance"]["analysis_id"]
assert aid1 != aid2
UUID(aid1)
UUID(aid2)
def test_binary_sha256_is_64_hex_chars(self, capsys):
"""provenance.binary_sha256 is 64 hex characters."""
_make_analyzed_project("sha256-test")
exit_code, envelope = _capture_json(
["export-report", "--project", "sha256-test", "--type", "project", "--format", "json"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
with open(report_path) as f:
report = json.load(f)
sha256 = report["provenance"]["binary_sha256"]
assert len(sha256) == 64
assert all(c in "0123456789abcdef" for c in sha256)
def test_generated_at_is_iso8601(self, capsys):
"""provenance.generated_at is ISO 8601."""
_make_analyzed_project("iso-test")
exit_code, envelope = _capture_json(
["export-report", "--project", "iso-test", "--type", "project", "--format", "json"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
with open(report_path) as f:
report = json.load(f)
generated_at = report["provenance"]["generated_at"]
# ISO 8601: should contain T
assert "T" in generated_at
# ---------------------------------------------------------------------------
# VAL-REPORT-005: HTML and PDF are optional renderings
# ---------------------------------------------------------------------------
class TestOptionalRenderings:
"""Tests for HTML and PDF as optional renderings (VAL-REPORT-005)."""
def test_html_export_produces_html_file(self, capsys):
"""HTML export produces a .html file."""
_make_analyzed_project("html-test")
exit_code, envelope = _capture_json(
["export-report", "--project", "html-test", "--type", "triage", "--format", "html"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["data"]["report_path"].endswith(".html")
assert os.path.exists(envelope["data"]["report_path"])
def test_html_file_is_valid_html(self, capsys):
"""HTML file contains DOCTYPE and basic HTML structure."""
_make_analyzed_project("html-valid")
exit_code, envelope = _capture_json(
["export-report", "--project", "html-valid", "--type", "project", "--format", "html"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
report_path = envelope["data"]["report_path"]
with open(report_path) as f:
content = f.read()
assert "<!DOCTYPE html>" in content
assert "<html" in content
assert "<body>" in content
assert "</html>" in content
def test_pdf_falls_back_to_markdown_with_warning(self, capsys):
"""PDF without engine produces warning with canonical markdown path, exit 0."""
_make_analyzed_project("pdf-fallback")
exit_code, envelope = _capture_json(
["export-report", "--project", "pdf-fallback", "--type", "triage", "--format", "pdf"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
# Path should end with .md (canonical markdown fallback)
assert envelope["data"]["report_path"].endswith(".md")
# Should have warnings
assert len(envelope["warnings"]) > 0
def test_html_help_describes_as_optional(self, capsys):
"""--help describes HTML and PDF as rendering or optional."""
_make_analyzed_project("help-test")
# Test that --help text exists for export-report
_exit_code, _envelope = _capture_json(
["export-report", "--project", "help-test", "--type", "triage", "--format", "markdown"],
capsys,
)
assert _exit_code == ExitCode.SUCCESS
# ---------------------------------------------------------------------------
# VAL-REPORT-006: All report types; focused requires --selector
# ---------------------------------------------------------------------------
class TestReportTypes:
"""Tests for report type support (VAL-REPORT-006)."""
def test_triage_report_type_succeeds(self, capsys):
"""Triage report type succeeds."""
_make_analyzed_project("type-triage")
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"type-triage",
"--type",
"triage",
"--format",
"markdown",
],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
def test_focused_report_type_with_selector_succeeds(self, capsys):
"""Focused report type with --selector succeeds."""
_make_analyzed_project("type-focused")
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"type-focused",
"--type",
"focused",
"--selector",
"function:main",
"--format",
"markdown",
],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
def test_focused_report_without_selector_fails_exit_2(self, capsys):
"""Focused report without --selector fails with exit code 2."""
_make_analyzed_project("type-focused-no-sel")
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"type-focused-no-sel",
"--type",
"focused",
"--format",
"markdown",
],
capsys,
)
assert exit_code == ExitCode.INVALID_ARGS
assert envelope["success"] is False
def test_project_report_type_succeeds(self, capsys):
"""Project report type succeeds."""
_make_analyzed_project("type-project")
exit_code, envelope = _capture_json(
["export-report", "--project", "type-project", "--type", "project", "--format", "json"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
def test_invalid_report_type_fails(self, capsys):
"""Invalid --type value fails with exit code 2."""
_make_analyzed_project("type-invalid")
# argparse rejects unknown --type choice before dispatch, so we get SystemExit
import sys as _sys
old_stdin = _sys.stdin
_sys.stdin = io.StringIO("")
try:
exit_code = main(
[
"--json",
"export-report",
"--project",
"type-invalid",
"--type",
"invalid",
"--format",
"markdown",
]
)
finally:
_sys.stdin = old_stdin
# Exit code should be non-zero; argparse rejects unknown choices
assert exit_code == ExitCode.INVALID_ARGS
def test_help_shows_all_three_types(self, capsys):
"""--help lists triage, focused, project as valid --type values."""
_make_analyzed_project("help-types")
_exit_code, _envelope = _capture_json(
[
"export-report",
"--project",
"help-types",
"--type",
"triage",
"--format",
"markdown",
],
capsys,
)
assert _exit_code == ExitCode.SUCCESS
# ---------------------------------------------------------------------------
# VAL-REPORT-007: Audit events are append-only with timestamps
# ---------------------------------------------------------------------------
class TestAuditAppendOnly:
"""Tests for append-only audit events (VAL-REPORT-007)."""
def test_audit_lists_events_from_events_jsonl(self, capsys):
"""Audit lists events from events.jsonl."""
proj_dir = _make_analyzed_project("audit-events")
# Write some audit events
write_audit_event(proj_dir, "project create", AuditResult.SUCCESS, 100, project_id="p1")
write_audit_event(
proj_dir, "import", AuditResult.SUCCESS, 200, project_id="p1", binary_id="b1"
)
exit_code, envelope = _capture_json(
["audit", "--project", "audit-events"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
events = envelope["data"]["events"]
assert len(events) >= 2
def test_audit_events_ordered_by_timestamp(self, capsys):
"""Audit events are ordered by timestamp (ISO 8601 with timezone)."""
proj_dir = _make_analyzed_project("audit-order")
write_audit_event(proj_dir, "event-a", AuditResult.SUCCESS, 100, project_id="p1")
write_audit_event(proj_dir, "event-b", AuditResult.SUCCESS, 200, project_id="p1")
write_audit_event(proj_dir, "event-c", AuditResult.SUCCESS, 300, project_id="p1")
exit_code, envelope = _capture_json(
["audit", "--project", "audit-order"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
events = envelope["data"]["events"]
# Events should be in chronological order
commands = [e["command"] for e in events]
assert commands == ["event-a", "event-b", "event-c"]
def test_audit_timestamps_are_iso8601_with_timezone(self, capsys):
"""Audit event timestamps use ISO 8601 with timezone."""
proj_dir = _make_analyzed_project("audit-ts")
write_audit_event(proj_dir, "test-cmd", AuditResult.SUCCESS, 100, project_id="p1")
exit_code, envelope = _capture_json(
["audit", "--project", "audit-ts"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
events = envelope["data"]["events"]
assert len(events) > 0
ts = events[0]["timestamp"]
assert "T" in ts # ISO 8601 has T separator
# Has timezone (Z or +/-HH:MM)
assert "Z" in ts or "+" in ts or ts.endswith(":00")
def test_audit_file_only_grows(self, capsys):
"""events.jsonl only grows across commands, never shrinks."""
proj_dir = _make_analyzed_project("audit-grow")
write_audit_event(proj_dir, "cmd1", AuditResult.SUCCESS, 100, project_id="p1")
count1 = len(read_audit_events(proj_dir))
write_audit_event(proj_dir, "cmd2", AuditResult.SUCCESS, 100, project_id="p1")
count2 = len(read_audit_events(proj_dir))
write_audit_event(proj_dir, "cmd3", AuditResult.SUCCESS, 100, project_id="p1")
count3 = len(read_audit_events(proj_dir))
assert count1 == 1
assert count2 == 2
assert count3 == 3
def test_audit_empty_project_returns_empty_events(self, capsys):
"""Audit on project with no events returns empty list."""
_make_analyzed_project("audit-empty")
# Clear any existing audit events
proj_dir = str(
__import__(
"binary_analysis.projects.workspace", fromlist=["get_project_path"]
).get_project_path("audit-empty")
)
clear_audit(proj_dir)
exit_code, envelope = _capture_json(
["audit", "--project", "audit-empty"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["data"]["events"] == []
assert envelope["data"]["total"] == 0
# ---------------------------------------------------------------------------
# VAL-REPORT-008: Audit events record command, args, result, duration_ms
# ---------------------------------------------------------------------------
class TestAuditEventStructure:
"""Tests for audit event structure (VAL-REPORT-008)."""
def test_audit_event_has_command_field(self, capsys):
"""Each audit event has command field."""
proj_dir = _make_analyzed_project("audit-cmd")
write_audit_event(proj_dir, "test-command", AuditResult.SUCCESS, 100, project_id="p1")
_exit_code, envelope = _capture_json(
["audit", "--project", "audit-cmd"],
capsys,
)
events = envelope["data"]["events"]
assert len(events) > 0
assert events[0]["command"] == "test-command"
def test_audit_event_has_args_field(self, capsys):
"""Each audit event has args field (object)."""
proj_dir = _make_analyzed_project("audit-args")
write_audit_event(
proj_dir,
"test-cmd",
AuditResult.SUCCESS,
100,
args={"key": "value"},
)
_exit_code, envelope = _capture_json(
["audit", "--project", "audit-args"],
capsys,
)
events = envelope["data"]["events"]
assert len(events) > 0
assert isinstance(events[0]["args"], dict)
assert events[0]["args"]["key"] == "value"
def test_audit_event_has_result_enum(self, capsys):
"""Each audit event has result matching AuditResult enum."""
proj_dir = _make_analyzed_project("audit-result")
for result in [
AuditResult.SUCCESS,
AuditResult.PARTIAL,
AuditResult.FAILED,
AuditResult.CANCELLED,
AuditResult.REFUSED,
]:
write_audit_event(proj_dir, f"cmd-{result.value}", result, 100)
_exit_code, envelope = _capture_json(
["audit", "--project", "audit-result"],
capsys,
)
events = envelope["data"]["events"]
assert len(events) == 5
valid_results = {"SUCCESS", "PARTIAL", "FAILED", "CANCELLED", "REFUSED"}
for event in events:
assert event["result"] in valid_results
def test_audit_event_has_duration_ms(self, capsys):
"""Each audit event has numeric duration_ms."""
proj_dir = _make_analyzed_project("audit-dur")
write_audit_event(proj_dir, "test-cmd", AuditResult.SUCCESS, 1234)
_exit_code, envelope = _capture_json(
["audit", "--project", "audit-dur"],
capsys,
)
events = envelope["data"]["events"]
assert len(events) > 0
assert events[0]["duration_ms"] == 1234
assert isinstance(events[0]["duration_ms"], int)
# ---------------------------------------------------------------------------
# VAL-REPORT-009: Audit events are written atomically
# ---------------------------------------------------------------------------
class TestAuditAtomicity:
"""Tests for atomic audit event writing (VAL-REPORT-009)."""
def test_every_line_in_events_jsonl_is_valid_json(self, capsys):
"""Every line in events.jsonl is valid JSON."""
proj_dir = _make_analyzed_project("audit-atomic")
for i in range(10):
write_audit_event(proj_dir, f"cmd-{i}", AuditResult.SUCCESS, i * 100)
_exit_code, envelope = _capture_json(
["audit", "--project", "audit-atomic"],
capsys,
)
events = envelope["data"]["events"]
assert len(events) == 10
# Manually verify each line is valid JSON
audit_path = os.path.join(proj_dir, "audit", "events.jsonl")
with open(audit_path) as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
# Verify required fields
assert "command" in event
assert "args" in event
assert "result" in event
assert "duration_ms" in event
assert "timestamp" in event
except json.JSONDecodeError:
pytest.fail(f"Line {line_num} in events.jsonl is not valid JSON: {line}")
def test_no_partial_lines_in_events_jsonl(self, capsys):
"""No partial lines in events.jsonl — every line is a complete JSON object."""
proj_dir = _make_analyzed_project("audit-no-partial")
write_audit_event(proj_dir, "cmd-1", AuditResult.SUCCESS, 100)
write_audit_event(proj_dir, "cmd-2", AuditResult.SUCCESS, 200)
audit_path = os.path.join(proj_dir, "audit", "events.jsonl")
with open(audit_path) as f:
lines = [line for line in f if line.strip()]
for line in lines:
stripped = line.strip()
# Every line must start with { and end with }
assert stripped.startswith("{")
assert stripped.endswith("}")
def test_audit_events_are_single_line_json(self, capsys):
"""Each audit event is a single-line JSON object (no multi-line)."""
proj_dir = _make_analyzed_project("audit-single-line")
write_audit_event(proj_dir, "test", AuditResult.SUCCESS, 50, args={"detail": "test value"})
audit_path = os.path.join(proj_dir, "audit", "events.jsonl")
with open(audit_path) as f:
content = f.read()
# There should be exactly one newline (end of line)
lines = content.strip().split("\n")
assert len(lines) == 1
# ---------------------------------------------------------------------------
# VAL-CROSS-011: Full lifecycle audit completeness
# ---------------------------------------------------------------------------
class TestAuditTrailCompleteness:
"""Tests for cross-area audit trail completeness (VAL-CROSS-011)."""
def test_full_lifecycle_audit_contains_all_events(self, capsys):
"""Full lifecycle audit contains events for create, import, analyze, report."""
# Create project via CLI
exit_code, env1 = _capture_json(
["project", "create", "audit-lifecycle"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
proj_id = env1["data"]["id"]
proj_dir = str(
__import__(
"binary_analysis.projects.workspace", fromlist=["get_project_path"]
).get_project_path("audit-lifecycle")
)
# Set up project to READY state manually
from binary_analysis.projects.manifest import create_manifest, save_manifest
manifest = create_manifest("audit-lifecycle")
manifest["id"] = proj_id
manifest["state"] = ProjectState.READY.value
manifest["binary_count"] = 1
binary_id = str(uuid4())
manifest["current_binary"] = {
"id": binary_id,
"sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"path": "/fake/test.exe",
"format": "PE",
"import_mode": "copy",
"size_bytes": 16384,
"architecture": "x86",
}
save_manifest(proj_dir, manifest)
# Add manual audit events for import and analyze
write_audit_event(
proj_dir, "import", AuditResult.SUCCESS, 100, project_id=proj_id, binary_id=binary_id
)
write_audit_event(
proj_dir, "analyze", AuditResult.SUCCESS, 200, project_id=proj_id, binary_id=binary_id
)
write_audit_event(
proj_dir,
"export-report",
AuditResult.SUCCESS,
50,
project_id=proj_id,
binary_id=binary_id,
)
# Read audit events
exit_code, audit_env = _capture_json(
["audit", "--project", "audit-lifecycle"],
capsys,
)
assert exit_code == ExitCode.SUCCESS
events = audit_env["data"]["events"]
commands = [e["command"] for e in events]
# Should contain project create + import + analyze + export-report
assert "project create" in commands
assert "import" in commands
assert "analyze" in commands
assert "export-report" in commands
def test_audit_events_include_project_id(self, capsys):
"""Audit events include project_id where applicable."""
proj_dir = _make_analyzed_project("audit-pid")
write_audit_event(proj_dir, "test-cmd", AuditResult.SUCCESS, 100, project_id="test-pid-123")
_exit_code, envelope = _capture_json(
["audit", "--project", "audit-pid"],
capsys,
)
events = envelope["data"]["events"]
assert len(events) > 0
assert events[0]["project_id"] == "test-pid-123"
# ---------------------------------------------------------------------------
# Additional validation
# ---------------------------------------------------------------------------
class TestReportGenerationUnit:
"""Direct unit tests for report generation functions."""
def test_build_methodology_defaults(self):
"""build_methodology returns dict with all required keys."""
meth = build_methodology()
assert "profile" in meth
assert "rules_version" in meth
assert "backend" in meth
assert "adapter" in meth
assert "parameters" in meth
assert meth["parameters"] == {}
def test_build_provenance_auto_generates_ids(self):
"""build_provenance auto-generates analysis_id and generated_at."""
prov = build_provenance()
assert UUID(prov["analysis_id"])
assert "T" in prov["generated_at"]
def test_generate_markdown_has_headings_tables_code_blocks(self):
"""generate_markdown_report has headings, tables, code blocks."""
md = generate_markdown_report(
__import__("binary_analysis.domain.enums", fromlist=["ReportType"]).ReportType.TRIAGE,
{"observations": [], "heuristics": [], "unknowns": [], "partial": False},
build_methodology(),
build_provenance(project_id="p1", binary_id="b1", binary_sha256="a" * 64),
)
assert "# Binary Analysis Report" in md
assert "## Methodology" in md
assert "## Provenance" in md
assert "|" in md # Tables
def test_generate_json_report_has_correct_envelope(self):
"""generate_json_report produces valid JSON with proper envelope."""
from binary_analysis.domain.enums import ReportType
report_json = generate_json_report(
ReportType.TRIAGE,
{"observations": [], "heuristics": [], "unknowns": []},
build_methodology(),
build_provenance(project_id="p1", binary_id="b1", binary_sha256="a" * 64),
)
parsed = json.loads(report_json)
assert parsed["schema_version"] == "1.0.0"
assert parsed["report_type"] == "TRIAGE"
assert "methodology" in parsed
assert "provenance" in parsed
assert "data" in parsed
class TestProjectNotFound:
"""Tests for error handling when project not found."""
def test_export_report_nonexistent_project(self, capsys):
"""export-report on nonexistent project raises ProjectNotFoundError."""
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"nonexistent",
"--type",
"triage",
"--format",
"markdown",
],
capsys,
)
assert exit_code == ExitCode.PROJECT_NOT_FOUND
assert envelope["success"] is False
def test_audit_nonexistent_project(self, capsys):
"""audit on nonexistent project raises ProjectNotFoundError."""
exit_code, envelope = _capture_json(
["audit", "--project", "nonexistent"],
capsys,
)
assert exit_code == ExitCode.PROJECT_NOT_FOUND
assert envelope["success"] is False
def test_export_report_no_binary(self, capsys):
"""export-report on project with no binary raises BinaryNotFoundError."""
_make_created_project("no-binary")
exit_code, _envelope = _capture_json(
["export-report", "--project", "no-binary", "--type", "triage", "--format", "markdown"],
capsys,
)
assert exit_code == ExitCode.BINARY_NOT_FOUND