auditwalk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. auditwalk-0.1.0/PKG-INFO +111 -0
  2. auditwalk-0.1.0/README.md +104 -0
  3. auditwalk-0.1.0/pyproject.toml +24 -0
  4. auditwalk-0.1.0/setup.cfg +4 -0
  5. auditwalk-0.1.0/src/auditwalk/__init__.py +23 -0
  6. auditwalk-0.1.0/src/auditwalk/__main__.py +8 -0
  7. auditwalk-0.1.0/src/auditwalk/cli/__init__.py +1 -0
  8. auditwalk-0.1.0/src/auditwalk/cli/adapters/__init__.py +1 -0
  9. auditwalk-0.1.0/src/auditwalk/cli/adapters/argparse_app.py +69 -0
  10. auditwalk-0.1.0/src/auditwalk/cli/commands_compare.py +151 -0
  11. auditwalk-0.1.0/src/auditwalk/cli/commands_scan.py +154 -0
  12. auditwalk-0.1.0/src/auditwalk/cli/dispatch.py +47 -0
  13. auditwalk-0.1.0/src/auditwalk/cli/handlers/__init__.py +1 -0
  14. auditwalk-0.1.0/src/auditwalk/cli/handlers/preflight_handlers.py +98 -0
  15. auditwalk-0.1.0/src/auditwalk/cli/handlers/scan_handlers.py +54 -0
  16. auditwalk-0.1.0/src/auditwalk/cli/main.py +34 -0
  17. auditwalk-0.1.0/src/auditwalk/cli/registry.py +168 -0
  18. auditwalk-0.1.0/src/auditwalk/compare/__init__.py +1 -0
  19. auditwalk-0.1.0/src/auditwalk/core/__init__.py +0 -0
  20. auditwalk-0.1.0/src/auditwalk/core/errors.py +13 -0
  21. auditwalk-0.1.0/src/auditwalk/core/paths.py +29 -0
  22. auditwalk-0.1.0/src/auditwalk/core/results_schema.py +38 -0
  23. auditwalk-0.1.0/src/auditwalk/core/store.py +37 -0
  24. auditwalk-0.1.0/src/auditwalk/detectors/__init__.py +10 -0
  25. auditwalk-0.1.0/src/auditwalk/detectors/environment.py +40 -0
  26. auditwalk-0.1.0/src/auditwalk/detectors/filesystem.py +152 -0
  27. auditwalk-0.1.0/src/auditwalk/gui/__init__.py +1 -0
  28. auditwalk-0.1.0/src/auditwalk/gui/main_window.py +440 -0
  29. auditwalk-0.1.0/src/auditwalk/gui/observer_server.py +306 -0
  30. auditwalk-0.1.0/src/auditwalk/inbox.py +148 -0
  31. auditwalk-0.1.0/src/auditwalk/modules/__init__.py +1 -0
  32. auditwalk-0.1.0/src/auditwalk/modules/preflight.py +59 -0
  33. auditwalk-0.1.0/src/auditwalk/reason_codes.py +3 -0
  34. auditwalk-0.1.0/src/auditwalk/risk/__init__.py +7 -0
  35. auditwalk-0.1.0/src/auditwalk/risk/reason_codes.py +44 -0
  36. auditwalk-0.1.0/src/auditwalk/risk/risk_decision.py +39 -0
  37. auditwalk-0.1.0/src/auditwalk/risk/risk_policy.py +167 -0
  38. auditwalk-0.1.0/src/auditwalk/risk_decision.py +3 -0
  39. auditwalk-0.1.0/src/auditwalk/risk_policy.py +3 -0
  40. auditwalk-0.1.0/src/auditwalk/runs/__init__.py +1 -0
  41. auditwalk-0.1.0/src/auditwalk/runs/run_store.py +399 -0
  42. auditwalk-0.1.0/src/auditwalk/runs/schema.py +241 -0
  43. auditwalk-0.1.0/src/auditwalk/scan/__init__.py +1 -0
  44. auditwalk-0.1.0/src/auditwalk/scan/scanner.py +266 -0
  45. auditwalk-0.1.0/src/auditwalk/storage/__init__.py +1 -0
  46. auditwalk-0.1.0/src/auditwalk/threat_intel.py +46 -0
  47. auditwalk-0.1.0/src/auditwalk/transfer_assessment.py +329 -0
  48. auditwalk-0.1.0/src/auditwalk/worker/__init__.py +1 -0
  49. auditwalk-0.1.0/src/auditwalk.egg-info/PKG-INFO +111 -0
  50. auditwalk-0.1.0/src/auditwalk.egg-info/SOURCES.txt +56 -0
  51. auditwalk-0.1.0/src/auditwalk.egg-info/dependency_links.txt +1 -0
  52. auditwalk-0.1.0/src/auditwalk.egg-info/entry_points.txt +2 -0
  53. auditwalk-0.1.0/src/auditwalk.egg-info/top_level.txt +1 -0
  54. auditwalk-0.1.0/tests/test_gui_observer_controls.py +39 -0
  55. auditwalk-0.1.0/tests/test_observer_server.py +115 -0
  56. auditwalk-0.1.0/tests/test_retention.py +26 -0
  57. auditwalk-0.1.0/tests/test_run_store.py +169 -0
  58. auditwalk-0.1.0/tests/test_schema.py +83 -0
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: auditwalk
3
+ Version: 0.1.0
4
+ Summary: AuditWalk
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # AuditWalk
9
+
10
+ Local-first security + audit toolkit to capture evidence from the browser, queue it for review, and run lightweight integrity scans without leaving your machine.
11
+
12
+ ## Build Planning
13
+ - Master MVP checklist: `docs/MVP_BUILD_DOCUMENTATION.md`
14
+ - Execution roadmap: `docs/ROADMAP.md`
15
+ - Near-term task list: `TODO.md`
16
+ - Module ownership map: `docs/architecture/module_ownership.md`
17
+
18
+ ## Quick Start (Developer Setup)
19
+
20
+ Clone the repository and run the initial setup:
21
+
22
+ ```bash
23
+ make install-dev
24
+ make hooks
25
+ make repo-steward-check
26
+ ```
27
+
28
+ This will:
29
+ - Install development dependencies into the local virtual environment
30
+ - Install the repository pre-commit hook
31
+ - Verify repository stewardship checks pass
32
+
33
+ ## Developer setup
34
+
35
+ Install the local git pre-commit hook:
36
+
37
+ ```bash
38
+ make hooks
39
+ ```
40
+
41
+ Check whether it is installed:
42
+
43
+ ```bash
44
+ make hooks-status
45
+ ```
46
+
47
+ ## MVP Scope
48
+ - Bookmarklet that POSTs the active tab (URL + title + timestamp) to a localhost ingest endpoint.
49
+ - Loopback-only ingest server (`scripts/run_ingest.py`) that validates payloads and appends them to `~/.auditwalk/inbox.jsonl`.
50
+ - Inbox utilities + CLI commands (`scripts/auditwalk_cli.py`) for listing, processing, and manually adding queue entries.
51
+ - Hardened file-system scanner (`scanner.py`) with optional hashing, suspicious-extension detection, and JSON export to feed future analysis steps.
52
+ - Documentation covering install, security notes, and usage so Antoine can run the MVP end-to-end.
53
+
54
+ ## Install
55
+ ```bash
56
+ # Clone + enter repo
57
+ cd ~/DevEnv
58
+ # (repo already exists locally, update if needed)
59
+ python3 -m venv venv
60
+ source venv/bin/activate
61
+ pip install -r requirements.txt # if/when we add one; for now: pip install rich tqdm
62
+ ```
63
+
64
+ ## Usage
65
+ ### 1. Run the ingest server
66
+ ```bash
67
+ source venv/bin/activate
68
+ python3 scripts/run_ingest.py --port 8841 --token YOUR_SHARED_TOKEN
69
+ ```
70
+ Options:
71
+ - `--host` (default `127.0.0.1`)
72
+ - `--port` (default `8841`)
73
+ - `--inbox` (default `~/.auditwalk/inbox.jsonl`)
74
+ - `--token` (optional shared secret; bookmarklet must send `X-AuditWalk-Token` header)
75
+
76
+ ### 2. Install the bookmarklet
77
+ Create a new browser bookmark with the URL field set to:
78
+ ```
79
+ javascript:(()=>{const data={url:location.href,title:document.title,timestamp:Date.now()/1000,source:'bookmarklet'};fetch('http://127.0.0.1:8841/share',{method:'POST',headers:{'Content-Type':'application/json','X-AuditWalk-Token':'TOKEN_HERE'},body:JSON.stringify(data)}).then(()=>console.log('Sent to AuditWalk')).catch(err=>alert('AuditWalk share failed: '+err));})();
80
+ ```
81
+ Update `TOKEN_HERE` if you launched the server with `--token`.
82
+
83
+ ### 3. Manage the inbox
84
+ ```bash
85
+ python3 scripts/auditwalk_cli.py inbox-list --limit 10
86
+ python3 scripts/auditwalk_cli.py inbox-process --clear
87
+ python3 scripts/auditwalk_cli.py inbox-add https://example.com --title "Manual"
88
+ ```
89
+ - `inbox-list` – prints recent captures.
90
+ - `inbox-process` – dumps entries (optionally `--clear`).
91
+ - `inbox-add` – helper for manual testing without the bookmarklet.
92
+
93
+ ### 4. Run the scanner
94
+ ```bash
95
+ python3 scanner.py --path /home/adenmediagroup --recent-hours 24 --json-report outputs/scan.json
96
+ ```
97
+ Flags:
98
+ - `--no-hash` to skip SHA-256 (faster, no dedupe)
99
+ - `--suspicious-exts ".exe,.dll"` to customize detection list
100
+ - `--json-report` to capture structured results for later diffing
101
+
102
+ ## Outputs
103
+ - **Inbox file:** `~/.auditwalk/inbox.jsonl` (one JSON object per line). Use the CLI to view/process entries.
104
+ - **Scanner report:** Rich tables in the console + optional JSON file containing every record, suspicious hits, and recent-change counts.
105
+ - **Docs:** `docs/inbox_workflow.md` for the share workflow, plus this README for quick start.
106
+
107
+ ## Security Notes
108
+ - Ingest server binds to `127.0.0.1` only. Keep it behind a shared token to avoid drive-by localhost POSTs.
109
+ - Bookmarklet may require allowing mixed content on strict HTTPS pages.
110
+ - Inbox file inherits your home permissions; ensure `~/.auditwalk` is not world-readable.
111
+ - Scanner skip lists prevent re-hashing this repo and common churn directories; adjust as needed per environment.
@@ -0,0 +1,104 @@
1
+ # AuditWalk
2
+
3
+ Local-first security + audit toolkit to capture evidence from the browser, queue it for review, and run lightweight integrity scans without leaving your machine.
4
+
5
+ ## Build Planning
6
+ - Master MVP checklist: `docs/MVP_BUILD_DOCUMENTATION.md`
7
+ - Execution roadmap: `docs/ROADMAP.md`
8
+ - Near-term task list: `TODO.md`
9
+ - Module ownership map: `docs/architecture/module_ownership.md`
10
+
11
+ ## Quick Start (Developer Setup)
12
+
13
+ Clone the repository and run the initial setup:
14
+
15
+ ```bash
16
+ make install-dev
17
+ make hooks
18
+ make repo-steward-check
19
+ ```
20
+
21
+ This will:
22
+ - Install development dependencies into the local virtual environment
23
+ - Install the repository pre-commit hook
24
+ - Verify repository stewardship checks pass
25
+
26
+ ## Developer setup
27
+
28
+ Install the local git pre-commit hook:
29
+
30
+ ```bash
31
+ make hooks
32
+ ```
33
+
34
+ Check whether it is installed:
35
+
36
+ ```bash
37
+ make hooks-status
38
+ ```
39
+
40
+ ## MVP Scope
41
+ - Bookmarklet that POSTs the active tab (URL + title + timestamp) to a localhost ingest endpoint.
42
+ - Loopback-only ingest server (`scripts/run_ingest.py`) that validates payloads and appends them to `~/.auditwalk/inbox.jsonl`.
43
+ - Inbox utilities + CLI commands (`scripts/auditwalk_cli.py`) for listing, processing, and manually adding queue entries.
44
+ - Hardened file-system scanner (`scanner.py`) with optional hashing, suspicious-extension detection, and JSON export to feed future analysis steps.
45
+ - Documentation covering install, security notes, and usage so Antoine can run the MVP end-to-end.
46
+
47
+ ## Install
48
+ ```bash
49
+ # Clone + enter repo
50
+ cd ~/DevEnv
51
+ # (repo already exists locally, update if needed)
52
+ python3 -m venv venv
53
+ source venv/bin/activate
54
+ pip install -r requirements.txt # if/when we add one; for now: pip install rich tqdm
55
+ ```
56
+
57
+ ## Usage
58
+ ### 1. Run the ingest server
59
+ ```bash
60
+ source venv/bin/activate
61
+ python3 scripts/run_ingest.py --port 8841 --token YOUR_SHARED_TOKEN
62
+ ```
63
+ Options:
64
+ - `--host` (default `127.0.0.1`)
65
+ - `--port` (default `8841`)
66
+ - `--inbox` (default `~/.auditwalk/inbox.jsonl`)
67
+ - `--token` (optional shared secret; bookmarklet must send `X-AuditWalk-Token` header)
68
+
69
+ ### 2. Install the bookmarklet
70
+ Create a new browser bookmark with the URL field set to:
71
+ ```
72
+ javascript:(()=>{const data={url:location.href,title:document.title,timestamp:Date.now()/1000,source:'bookmarklet'};fetch('http://127.0.0.1:8841/share',{method:'POST',headers:{'Content-Type':'application/json','X-AuditWalk-Token':'TOKEN_HERE'},body:JSON.stringify(data)}).then(()=>console.log('Sent to AuditWalk')).catch(err=>alert('AuditWalk share failed: '+err));})();
73
+ ```
74
+ Update `TOKEN_HERE` if you launched the server with `--token`.
75
+
76
+ ### 3. Manage the inbox
77
+ ```bash
78
+ python3 scripts/auditwalk_cli.py inbox-list --limit 10
79
+ python3 scripts/auditwalk_cli.py inbox-process --clear
80
+ python3 scripts/auditwalk_cli.py inbox-add https://example.com --title "Manual"
81
+ ```
82
+ - `inbox-list` – prints recent captures.
83
+ - `inbox-process` – dumps entries (optionally `--clear`).
84
+ - `inbox-add` – helper for manual testing without the bookmarklet.
85
+
86
+ ### 4. Run the scanner
87
+ ```bash
88
+ python3 scanner.py --path /home/adenmediagroup --recent-hours 24 --json-report outputs/scan.json
89
+ ```
90
+ Flags:
91
+ - `--no-hash` to skip SHA-256 (faster, no dedupe)
92
+ - `--suspicious-exts ".exe,.dll"` to customize detection list
93
+ - `--json-report` to capture structured results for later diffing
94
+
95
+ ## Outputs
96
+ - **Inbox file:** `~/.auditwalk/inbox.jsonl` (one JSON object per line). Use the CLI to view/process entries.
97
+ - **Scanner report:** Rich tables in the console + optional JSON file containing every record, suspicious hits, and recent-change counts.
98
+ - **Docs:** `docs/inbox_workflow.md` for the share workflow, plus this README for quick start.
99
+
100
+ ## Security Notes
101
+ - Ingest server binds to `127.0.0.1` only. Keep it behind a shared token to avoid drive-by localhost POSTs.
102
+ - Bookmarklet may require allowing mixed content on strict HTTPS pages.
103
+ - Inbox file inherits your home permissions; ensure `~/.auditwalk` is not world-readable.
104
+ - Scanner skip lists prevent re-hashing this repo and common churn directories; adjust as needed per environment.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "auditwalk"
7
+ version = "0.1.0"
8
+ description = "AuditWalk"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = []
12
+
13
+ [project.scripts]
14
+ auditwalk = "auditwalk.cli.main:main"
15
+
16
+ [tool.setuptools]
17
+ package-dir = {"" = "src"}
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ """AuditWalk local-first utilities."""
2
+
3
+ from . import reason_codes # compatibility shim
4
+ from . import risk # canonical risk package
5
+ from .inbox import (
6
+ INBOX_PATH,
7
+ InboxItem,
8
+ append_item,
9
+ ensure_inbox,
10
+ iter_inbox,
11
+ load_inbox,
12
+ )
13
+
14
+ __all__ = [
15
+ "INBOX_PATH",
16
+ "InboxItem",
17
+ "append_item",
18
+ "ensure_inbox",
19
+ "iter_inbox",
20
+ "load_inbox",
21
+ "reason_codes",
22
+ "risk",
23
+ ]
@@ -0,0 +1,8 @@
1
+ """Module entrypoint: python -m auditwalk"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from auditwalk.cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """AuditWalk CLI package."""
@@ -0,0 +1 @@
1
+ """CLI adapter implementations."""
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from typing import Any, Dict, Tuple
5
+
6
+ from auditwalk.cli.registry import ArgSpec, CommandRegistry, OptSpec
7
+
8
+
9
+ def _dest_from_flags(flags: Tuple[str, ...]) -> str:
10
+ long_flags = [f for f in flags if f.startswith("--")]
11
+ base = (long_flags[0] if long_flags else flags[0]).lstrip("-")
12
+ return base.replace("-", "_")
13
+
14
+
15
+ def _add_option(parser: argparse.ArgumentParser, opt: OptSpec) -> None:
16
+ kwargs: Dict[str, Any] = {"help": opt.help, "dest": _dest_from_flags(opt.flags)}
17
+ if opt.takes_value:
18
+ kwargs["default"] = opt.default
19
+ if opt.choices:
20
+ kwargs["choices"] = list(opt.choices)
21
+ parser.add_argument(*opt.flags, **kwargs)
22
+ else:
23
+ parser.add_argument(*opt.flags, action="store_true", **kwargs)
24
+
25
+
26
+ def _add_positional(parser: argparse.ArgumentParser, arg: ArgSpec) -> None:
27
+ kwargs: Dict[str, Any] = {"help": arg.help}
28
+ if arg.nargs:
29
+ kwargs["nargs"] = arg.nargs
30
+ elif not arg.required:
31
+ kwargs["nargs"] = "?"
32
+ parser.add_argument(arg.name, **kwargs)
33
+
34
+
35
+ def _noun_help(registry: CommandRegistry, noun: str) -> str:
36
+ verbs = registry.verbs(noun)
37
+ preview = ", ".join(verbs[:5])
38
+ suffix = "..." if len(verbs) > 5 else ""
39
+ return f"{noun}: {preview}{suffix}" if preview else noun
40
+
41
+
42
+ def build_argparse_app(registry: CommandRegistry) -> argparse.ArgumentParser:
43
+ root = argparse.ArgumentParser(
44
+ prog="auditwalk",
45
+ description="AuditWalk - system integrity scanning and change detection.",
46
+ )
47
+ root.set_defaults(_aw_is_root=True)
48
+
49
+ noun_subparsers = root.add_subparsers(dest="_aw_noun", metavar="<noun>")
50
+ noun_subparsers.required = False
51
+
52
+ verb_subparsers: Dict[str, argparse._SubParsersAction] = {}
53
+ for noun in registry.nouns():
54
+ noun_parser = noun_subparsers.add_parser(noun, help=_noun_help(registry, noun))
55
+ noun_parser.set_defaults(_aw_noun=noun)
56
+ vs = noun_parser.add_subparsers(dest="_aw_verb", metavar="<verb>")
57
+ vs.required = True
58
+ verb_subparsers[noun] = vs
59
+
60
+ for spec in registry.all_specs():
61
+ vs = verb_subparsers[spec.noun]
62
+ verb_parser = vs.add_parser(spec.verb, help=spec.summary, description=spec.summary)
63
+ verb_parser.set_defaults(_aw_noun=spec.noun, _aw_verb=spec.verb, _aw_handler=spec.handler)
64
+ for arg in spec.args:
65
+ _add_positional(verb_parser, arg)
66
+ for opt in spec.opts:
67
+ _add_option(verb_parser, opt)
68
+
69
+ return root
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from auditwalk.compare.differ import default_diff_path, diff
8
+ from auditwalk.core.errors import EXIT_COMPARE_FAILED, EXIT_OK, EXIT_REPORT_FAILED
9
+ from auditwalk.core.jsonio import write_json
10
+ from auditwalk.report.render_html import render_html
11
+ from auditwalk.runs.run_store import RunStore
12
+ from auditwalk.score.scoring import score
13
+
14
+
15
+ def cmd_compare(args: Any) -> int:
16
+ store = RunStore(args.data_dir)
17
+ data_dir = Path(store.data_dir)
18
+ try:
19
+ store.apply_default_compare_retention()
20
+ store.prune_orphan_compares()
21
+ except Exception:
22
+ pass
23
+
24
+ try:
25
+ run_a_raw, run_b_raw = _resolve_compare_args(args, getattr(args, "baseline_run_id", None))
26
+ run_a = _resolve_baseline_alias(run_a_raw, getattr(args, "baseline_run_id", None))
27
+ left = store.load_run_bundle(run_a)
28
+ right = store.load_run_bundle(run_b_raw)
29
+ result = diff(
30
+ left,
31
+ right,
32
+ process_normalization_profile=str(getattr(args, "process_normalization_profile", "default")),
33
+ compare_profile=str(getattr(args, "profile", "off")),
34
+ )
35
+
36
+ out_path = _resolve_out_path(
37
+ args.out,
38
+ data_dir,
39
+ str(result.get("left_run_id")),
40
+ str(result.get("right_run_id")),
41
+ )
42
+ out_path.parent.mkdir(parents=True, exist_ok=True)
43
+ write_json(out_path, result)
44
+ store.sync_compare_index()
45
+ except Exception as exc:
46
+ print(f"compare failed: {exc}")
47
+ return EXIT_COMPARE_FAILED
48
+
49
+ if args.score:
50
+ try:
51
+ rules = Path(Path(__file__).resolve().parents[1] / "data" / "scoring_rules.yml")
52
+ score_obj = score(right, result, rules_path=rules)
53
+ store.save_score(run_b_raw, score_obj)
54
+ right["score"] = score_obj
55
+ except Exception as exc:
56
+ print(f"scoring failed: {exc}")
57
+ return EXIT_COMPARE_FAILED
58
+
59
+ if args.html:
60
+ try:
61
+ html_out = Path(args.html_out) if args.html_out else data_dir / "reports" / f"{right.get('meta', {}).get('run_id', 'run')}.html"
62
+ html_out.parent.mkdir(parents=True, exist_ok=True)
63
+ html_out.write_text(render_html(right, diff_bundle=result), encoding="utf-8")
64
+ except Exception as exc:
65
+ print(f"report failed: {exc}")
66
+ return EXIT_REPORT_FAILED
67
+
68
+ if args.format == "json" or args.json:
69
+ print(
70
+ json.dumps(
71
+ {
72
+ "diff_path": str(out_path),
73
+ "run_a_input": run_a_raw,
74
+ "run_b_input": run_b_raw,
75
+ "run_a_resolved": run_a,
76
+ "diff": result,
77
+ },
78
+ ensure_ascii=True,
79
+ )
80
+ )
81
+ else:
82
+ print(f"Diff: {result.get('compare_id')}")
83
+ print(f"Saved: {out_path}")
84
+ profile = result.get("profile", {}) if isinstance(result.get("profile"), dict) else {}
85
+ if profile.get("name", "off") != "off":
86
+ print(
87
+ "note: compare profile applied "
88
+ f"({profile.get('name')}; ignored files left={profile.get('files_left_ignored', 0)} "
89
+ f"right={profile.get('files_right_ignored', 0)})"
90
+ )
91
+ norm = result.get("normalization", {}) if isinstance(result.get("normalization"), dict) else {}
92
+ left_norm = norm.get("processes_left", {}) if isinstance(norm.get("processes_left"), dict) else {}
93
+ right_norm = norm.get("processes_right", {}) if isinstance(norm.get("processes_right"), dict) else {}
94
+ if left_norm.get("applied") or right_norm.get("applied"):
95
+ print(
96
+ "note: process normalization applied "
97
+ f"(profile={left_norm.get('profile', right_norm.get('profile', 'default'))})"
98
+ )
99
+ for domain, details in (result.get("domains", {}) or {}).items():
100
+ counts = details.get("counts", {}) if isinstance(details, dict) else {}
101
+ print(
102
+ f"{domain}: added={counts.get('added', 0)} removed={counts.get('removed', 0)} modified={counts.get('modified', 0)}"
103
+ )
104
+
105
+ return EXIT_OK
106
+
107
+
108
+ def _resolve_out_path(raw_out: Path | None, data_dir: Path, run_a: str, run_b: str) -> Path:
109
+ if raw_out is None:
110
+ return default_diff_path(data_dir, run_a, run_b)
111
+
112
+ out = Path(raw_out)
113
+ if out.exists() and out.is_dir():
114
+ return out / f"diff_{run_a}__{run_b}.json"
115
+
116
+ out_str = str(out)
117
+ if out_str.endswith("_"):
118
+ return Path(f"{out_str}{run_a}__{run_b}.json")
119
+
120
+ if out.suffix.lower() != ".json":
121
+ return Path(f"{out_str}_{run_a}__{run_b}.json")
122
+
123
+ return out
124
+
125
+
126
+ def _resolve_baseline_alias(run_a: str, baseline_run_id: str | None) -> str:
127
+ if run_a != "baseline":
128
+ return run_a
129
+ if baseline_run_id:
130
+ return baseline_run_id
131
+ raise ValueError("baseline alias used but no baseline_run_id configured")
132
+
133
+
134
+ def _resolve_compare_args(args: Any, baseline_run_id: str | None) -> tuple[str, str]:
135
+ run_a = getattr(args, "run_a", None)
136
+ run_b = getattr(args, "run_b", None)
137
+ use_baseline = bool(getattr(args, "use_baseline", False))
138
+
139
+ if use_baseline:
140
+ if not baseline_run_id:
141
+ raise ValueError("--use-baseline requested but baseline_run_id is not configured")
142
+ if run_b is None and run_a is not None:
143
+ return ("baseline", str(run_a))
144
+ if run_b is None:
145
+ raise ValueError("--use-baseline requires target run id/path")
146
+ return ("baseline", str(run_b))
147
+
148
+ if run_a is None or run_b is None:
149
+ raise ValueError("compare requires RUN_A RUN_B (or --use-baseline RUN_B)")
150
+
151
+ return (str(run_a), str(run_b))
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import webbrowser
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from auditwalk.compare.differ import default_diff_path, diff
9
+ from auditwalk.core.errors import EXIT_COMPARE_FAILED, EXIT_OK, EXIT_REPORT_FAILED, EXIT_SCAN_FAILED
10
+ from auditwalk.core.jsonio import write_json
11
+ from auditwalk.report.render_html import render_html
12
+ from auditwalk.runs.run_store import RunStore
13
+ from auditwalk.scan.scanner import scan
14
+ from auditwalk.score.scoring import score
15
+
16
+
17
+ def cmd_scan(args: Any) -> int:
18
+ try:
19
+ result = scan(
20
+ mode=args.mode,
21
+ roots=args.roots,
22
+ exclude=args.exclude,
23
+ label=args.label,
24
+ baseline=args.baseline,
25
+ do_hash=args.hash,
26
+ max_files=args.max_files,
27
+ max_procs=args.max_procs,
28
+ timeout_seconds=args.timeout_seconds,
29
+ strict_files=args.strict_files,
30
+ data_dir=args.data_dir,
31
+ )
32
+ except Exception as exc:
33
+ print(f"scan failed: {exc}")
34
+ return EXIT_SCAN_FAILED
35
+
36
+ store = RunStore(args.data_dir)
37
+ data_dir = Path(store.data_dir)
38
+ try:
39
+ store.apply_default_compare_retention()
40
+ store.prune_orphan_compares()
41
+ except Exception:
42
+ pass
43
+
44
+ run_bundle = store.load_run_bundle(result["run_id"])
45
+ rules = Path(Path(__file__).resolve().parents[1] / "data" / "scoring_rules.yml")
46
+
47
+ # Always score the run (run-only context), then optionally rescore with diff context.
48
+ run_score = score(run_bundle, None, rules_path=rules)
49
+ store.save_score(result["run_id"], run_score)
50
+ run_bundle["score"] = run_score
51
+ result["score"] = run_score
52
+ result["top_findings"] = run_score.get("findings", [])[:3]
53
+
54
+ diff_bundle = None
55
+ compare_target = args.compare
56
+ if compare_target == "baseline":
57
+ compare_target = getattr(args, "baseline_run_id", None)
58
+ if not compare_target:
59
+ print("warning: compare target 'baseline' requested, but baseline_run_id is not configured; compare skipped.")
60
+
61
+ if args.compare_last:
62
+ refs = store.list_runs(last=2)
63
+ if len(refs) >= 2:
64
+ compare_target = refs[1].run_id
65
+ else:
66
+ baseline_target = getattr(args, "baseline_run_id", None)
67
+ if baseline_target:
68
+ compare_target = baseline_target
69
+ print(f"note: --compare-last fallback to configured baseline run: {baseline_target}")
70
+ else:
71
+ print("warning: --compare-last requested, but no prior run exists; compare skipped.")
72
+
73
+ if compare_target:
74
+ try:
75
+ left = store.load_run_bundle(compare_target)
76
+ diff_bundle = diff(left, run_bundle)
77
+ out_path = default_diff_path(data_dir, compare_target, result["run_id"])
78
+ write_json(out_path, diff_bundle)
79
+ store.sync_compare_index()
80
+
81
+ # Rescore run with diff context.
82
+ run_score = score(run_bundle, diff_bundle, rules_path=rules)
83
+ store.save_score(result["run_id"], run_score)
84
+ run_bundle["score"] = run_score
85
+ result["score"] = run_score
86
+ result["top_findings"] = run_score.get("findings", [])[:3]
87
+ except Exception as exc:
88
+ print(f"compare failed: {exc}")
89
+ return EXIT_COMPARE_FAILED
90
+
91
+ if args.html:
92
+ try:
93
+ html_out = Path(args.html_out) if args.html_out else data_dir / "reports" / f"{result['run_id']}.html"
94
+ html_out.parent.mkdir(parents=True, exist_ok=True)
95
+ html_out.write_text(render_html(run_bundle, diff_bundle=diff_bundle), encoding="utf-8")
96
+ if args.open:
97
+ webbrowser.open(html_out.resolve().as_uri())
98
+ result["html_path"] = str(html_out)
99
+ except Exception as exc:
100
+ print(f"report failed: {exc}")
101
+ return EXIT_REPORT_FAILED
102
+
103
+ _print_scan_summary(result)
104
+ _print_scan_warnings(run_bundle)
105
+ if args.json:
106
+ print(json.dumps(result, ensure_ascii=True))
107
+ return EXIT_OK
108
+
109
+
110
+ def _print_scan_summary(result: dict) -> None:
111
+ score = result.get("score", {}) if isinstance(result.get("score"), dict) else {}
112
+ total = score.get("total", 0)
113
+ tier = score.get("tier", "Clean")
114
+ findings = result.get("top_findings", []) if isinstance(result.get("top_findings", []), list) else []
115
+
116
+ print("AuditWalk v0.1")
117
+ print(f"Mode: {str(result.get('mode', 'unknown')).upper()}")
118
+ print(f"Run: {result.get('run_id')}")
119
+ if result.get("label"):
120
+ print(f"Label: {result.get('label')}")
121
+ print()
122
+ print(f"Score: {total} ({str(tier).upper()})")
123
+ print()
124
+ print("Findings:")
125
+ if findings:
126
+ for f in findings[:3]:
127
+ pts = f.get("points", 0)
128
+ rid = f.get("rule_id", "RULE")
129
+ msg = f.get("message", "finding")
130
+ print(f" +{pts:<3} {rid:<12} {msg}")
131
+ else:
132
+ print(" (none)")
133
+ print()
134
+ print("Artifacts:")
135
+ print(f" {result.get('run_path')}")
136
+ if result.get("html_path"):
137
+ print(f" {result.get('html_path')}")
138
+
139
+
140
+ def _print_scan_warnings(run_bundle: dict) -> None:
141
+ meta = run_bundle.get("meta", {}) if isinstance(run_bundle.get("meta"), dict) else {}
142
+ collector_status = meta.get("collector_status", {}) if isinstance(meta.get("collector_status"), dict) else {}
143
+ partial_collectors = [name for name, state in collector_status.items() if state in {"partial", "error"}]
144
+ files_data = run_bundle.get("files", {}) if isinstance(run_bundle.get("files"), dict) else {}
145
+ summary = files_data.get("collector_summary", {}) if isinstance(files_data.get("collector_summary"), dict) else {}
146
+ vanished = int(summary.get("vanished", 0)) if isinstance(summary.get("vanished"), int) else 0
147
+
148
+ if vanished > 0 and "files" not in partial_collectors:
149
+ print(f"Note: {vanished} files changed during scan (vanished).")
150
+
151
+ if not partial_collectors:
152
+ return
153
+ joined = ", ".join(sorted(partial_collectors))
154
+ print(f"Warning: partial collector results detected: {joined}")