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.
- auditwalk-0.1.0/PKG-INFO +111 -0
- auditwalk-0.1.0/README.md +104 -0
- auditwalk-0.1.0/pyproject.toml +24 -0
- auditwalk-0.1.0/setup.cfg +4 -0
- auditwalk-0.1.0/src/auditwalk/__init__.py +23 -0
- auditwalk-0.1.0/src/auditwalk/__main__.py +8 -0
- auditwalk-0.1.0/src/auditwalk/cli/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/cli/adapters/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/cli/adapters/argparse_app.py +69 -0
- auditwalk-0.1.0/src/auditwalk/cli/commands_compare.py +151 -0
- auditwalk-0.1.0/src/auditwalk/cli/commands_scan.py +154 -0
- auditwalk-0.1.0/src/auditwalk/cli/dispatch.py +47 -0
- auditwalk-0.1.0/src/auditwalk/cli/handlers/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/cli/handlers/preflight_handlers.py +98 -0
- auditwalk-0.1.0/src/auditwalk/cli/handlers/scan_handlers.py +54 -0
- auditwalk-0.1.0/src/auditwalk/cli/main.py +34 -0
- auditwalk-0.1.0/src/auditwalk/cli/registry.py +168 -0
- auditwalk-0.1.0/src/auditwalk/compare/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/core/__init__.py +0 -0
- auditwalk-0.1.0/src/auditwalk/core/errors.py +13 -0
- auditwalk-0.1.0/src/auditwalk/core/paths.py +29 -0
- auditwalk-0.1.0/src/auditwalk/core/results_schema.py +38 -0
- auditwalk-0.1.0/src/auditwalk/core/store.py +37 -0
- auditwalk-0.1.0/src/auditwalk/detectors/__init__.py +10 -0
- auditwalk-0.1.0/src/auditwalk/detectors/environment.py +40 -0
- auditwalk-0.1.0/src/auditwalk/detectors/filesystem.py +152 -0
- auditwalk-0.1.0/src/auditwalk/gui/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/gui/main_window.py +440 -0
- auditwalk-0.1.0/src/auditwalk/gui/observer_server.py +306 -0
- auditwalk-0.1.0/src/auditwalk/inbox.py +148 -0
- auditwalk-0.1.0/src/auditwalk/modules/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/modules/preflight.py +59 -0
- auditwalk-0.1.0/src/auditwalk/reason_codes.py +3 -0
- auditwalk-0.1.0/src/auditwalk/risk/__init__.py +7 -0
- auditwalk-0.1.0/src/auditwalk/risk/reason_codes.py +44 -0
- auditwalk-0.1.0/src/auditwalk/risk/risk_decision.py +39 -0
- auditwalk-0.1.0/src/auditwalk/risk/risk_policy.py +167 -0
- auditwalk-0.1.0/src/auditwalk/risk_decision.py +3 -0
- auditwalk-0.1.0/src/auditwalk/risk_policy.py +3 -0
- auditwalk-0.1.0/src/auditwalk/runs/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/runs/run_store.py +399 -0
- auditwalk-0.1.0/src/auditwalk/runs/schema.py +241 -0
- auditwalk-0.1.0/src/auditwalk/scan/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/scan/scanner.py +266 -0
- auditwalk-0.1.0/src/auditwalk/storage/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk/threat_intel.py +46 -0
- auditwalk-0.1.0/src/auditwalk/transfer_assessment.py +329 -0
- auditwalk-0.1.0/src/auditwalk/worker/__init__.py +1 -0
- auditwalk-0.1.0/src/auditwalk.egg-info/PKG-INFO +111 -0
- auditwalk-0.1.0/src/auditwalk.egg-info/SOURCES.txt +56 -0
- auditwalk-0.1.0/src/auditwalk.egg-info/dependency_links.txt +1 -0
- auditwalk-0.1.0/src/auditwalk.egg-info/entry_points.txt +2 -0
- auditwalk-0.1.0/src/auditwalk.egg-info/top_level.txt +1 -0
- auditwalk-0.1.0/tests/test_gui_observer_controls.py +39 -0
- auditwalk-0.1.0/tests/test_observer_server.py +115 -0
- auditwalk-0.1.0/tests/test_retention.py +26 -0
- auditwalk-0.1.0/tests/test_run_store.py +169 -0
- auditwalk-0.1.0/tests/test_schema.py +83 -0
auditwalk-0.1.0/PKG-INFO
ADDED
|
@@ -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,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 @@
|
|
|
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}")
|