|
13 | 13 | FORMAT_CHOICES = ["terminal", "markdown", "sarif", "json"] |
14 | 14 |
|
15 | 15 |
|
| 16 | +def _make_run_dir(base_dir: str) -> str: |
| 17 | + """Create a timestamped subdirectory for this scan run. |
| 18 | +
|
| 19 | + Each run gets its own directory so previous results are never |
| 20 | + overwritten. A 'latest' symlink points to the newest run. |
| 21 | +
|
| 22 | + Structure: |
| 23 | + argus-results/ |
| 24 | + ├── 2026-04-12T07-24-50Z/ |
| 25 | + │ ├── argus.log |
| 26 | + │ ├── argus-audit.json |
| 27 | + │ └── ... |
| 28 | + └── latest -> 2026-04-12T07-24-50Z/ |
| 29 | + """ |
| 30 | + from datetime import datetime, timezone |
| 31 | + |
| 32 | + base = Path(base_dir) |
| 33 | + base.mkdir(parents=True, exist_ok=True) |
| 34 | + |
| 35 | + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ") |
| 36 | + run_dir = base / ts |
| 37 | + run_dir.mkdir(parents=True, exist_ok=True) |
| 38 | + |
| 39 | + # Update 'latest' symlink |
| 40 | + latest = base / "latest" |
| 41 | + try: |
| 42 | + if latest.is_symlink() or latest.exists(): |
| 43 | + latest.unlink() |
| 44 | + latest.symlink_to(ts) |
| 45 | + except OSError: |
| 46 | + pass # Symlinks may not work on all platforms |
| 47 | + |
| 48 | + return str(run_dir) |
| 49 | + |
| 50 | + |
16 | 51 | def build_parser() -> argparse.ArgumentParser: |
17 | 52 | """Build the top-level argument parser with scan and report subcommands.""" |
18 | 53 | parser = argparse.ArgumentParser( |
@@ -402,8 +437,9 @@ def _cmd_source_scan(args: argparse.Namespace) -> int: |
402 | 437 | if args.formats: |
403 | 438 | config.reporting.formats = args.formats |
404 | 439 |
|
405 | | - # Initialize audit trail |
406 | | - output_dir = config.reporting.output_dir |
| 440 | + # Initialize audit trail — each run gets a timestamped subdirectory |
| 441 | + output_dir = _make_run_dir(config.reporting.output_dir) |
| 442 | + config.reporting.output_dir = output_dir |
407 | 443 | log = get_logger("argus", output_dir=output_dir, verbose=args.verbose) |
408 | 444 | manifest = create_manifest( |
409 | 445 | config_path=args.config, |
@@ -492,7 +528,8 @@ def _cmd_container_scan(args: argparse.Namespace) -> int: |
492 | 528 | if args.scanners: |
493 | 529 | config["scanners"] = [s.strip() for s in args.scanners.split(",")] |
494 | 530 |
|
495 | | - output_dir = args.output_dir or config.get("output_dir", "./argus-results") |
| 531 | + base_dir = args.output_dir or config.get("output_dir", "./argus-results") |
| 532 | + output_dir = _make_run_dir(base_dir) |
496 | 533 | formats = args.formats or ["terminal", "markdown"] |
497 | 534 |
|
498 | 535 | # Run |
@@ -539,7 +576,8 @@ def _cmd_dast_scan(args: argparse.Namespace) -> int: |
539 | 576 | """Run DAST scanning lifecycle (start target, scan with ZAP, cleanup).""" |
540 | 577 | from argus.dast import DastEngine |
541 | 578 |
|
542 | | - output_dir = args.output_dir or "./argus-results" |
| 579 | + base_dir = args.output_dir or "./argus-results" |
| 580 | + output_dir = _make_run_dir(base_dir) |
543 | 581 | formats = args.formats or ["terminal", "markdown"] |
544 | 582 |
|
545 | 583 | # Parse env vars from --env KEY=VALUE flags |
|
0 commit comments