Skip to content

[BUG] Executive report written non-atomically: a failed write destroys the previous penetration_test_report.md #828

Description

@thejesh23

Describe the bug

write_executive_report (strix/report/writer.py:58) is the only artifact writer in the module that does not go through the module's own _atomic_write_text helper. It opens the destination with path.open("w"), which truncates penetration_test_report.md in place before any new bytes are written:

def write_executive_report(run_dir: Path, final_scan_result: str) -> None:
    path = run_dir / "penetration_test_report.md"
    with path.open("w", encoding="utf-8") as f:
        f.write("# Security Penetration Test Report\n\n")
        f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n")
        f.write(f"{final_scan_result}\n")

Every sibling writer in the same file is already atomic — write_run_record (:52), the per-vulnerability markdown (:78), vulnerabilities.csv (:103) and vulnerabilities.json (:105) all call _atomic_write_text, which writes to a temp file in the same directory and Path.replace()s it into place.

So if anything raises or the process dies between the open("w") and the final f.write(...) — ENOSPC, a disconnected network mount, Ctrl-C, an OOM kill — the run ends with a truncated or empty penetration_test_report.md, and the previous complete report is already gone. On a re-run of a long scan that is the primary human-readable deliverable, lost with no copy.

To Reproduce

  1. Complete a scan so penetration_test_report.md exists with real content.
  2. Start a second write and have it fail partway through, e.g. fill the volume (ENOSPC) or interrupt between the two f.write calls.
  3. Read penetration_test_report.md.

A deterministic version of the same failure, no full scan needed:

from unittest.mock import patch
from strix.report import writer

writer.write_executive_report(tmp_path, "First complete report.")

class Boom:
    @staticmethod
    def now(_tz):
        raise OSError("clock unavailable")

with patch.object(writer, "datetime", Boom):
    try:
        writer.write_executive_report(tmp_path, "Second report.")
    except OSError:
        pass

print((tmp_path / "penetration_test_report.md").read_text())

Expected behavior
A failed write leaves the previously written report intact, matching the atomicity guarantee every other writer in writer.py already provides.

Actual behavior
The file is left containing only # Security Penetration Test Report\n\n (the header written before the failure). The prior complete report is unrecoverable.

System Information:

  • OS: macOS 15 (the bug is filesystem/OS independent)
  • Strix Version or Commit: 48b4821 (v1.2.0)
  • Python Version: 3.12
  • LLM Used: n/a — reproduces without an LLM call

Additional context
Fix is a two-line change: build the payload first, then hand it to the existing _atomic_write_text. I have a PR ready with a regression test that fails on main and passes with the fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions