agentspm 0.1.1__py3-none-any.whl
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.
- agentspm/__init__.py +6 -0
- agentspm/canonical.py +63 -0
- agentspm/cli.py +624 -0
- agentspm/collectors/__init__.py +173 -0
- agentspm/collectors/base.py +402 -0
- agentspm/collectors/entra.py +603 -0
- agentspm/collectors/gateway.py +357 -0
- agentspm/collectors/gitrepo.py +465 -0
- agentspm/collectors/mock_gateway.py +91 -0
- agentspm/doctor.py +390 -0
- agentspm/evaluate.py +288 -0
- agentspm/graph/__init__.py +31 -0
- agentspm/graph/derive.py +259 -0
- agentspm/graph/merge.py +495 -0
- agentspm/graph/store.py +353 -0
- agentspm/gwverify.py +384 -0
- agentspm/mockgw/__init__.py +28 -0
- agentspm/mockgw/fixtures.py +229 -0
- agentspm/mockgw/keys.py +101 -0
- agentspm/mockgw/server.py +188 -0
- agentspm/models.py +274 -0
- agentspm/purge.py +196 -0
- agentspm/py.typed +0 -0
- agentspm/report/__init__.py +224 -0
- agentspm/report/json.py +21 -0
- agentspm/report/md.py +199 -0
- agentspm/report/redact.py +278 -0
- agentspm/report/sarif.py +151 -0
- agentspm/schema/v0.1/agentspm.schema.json +765 -0
- agentspm/schema/v0.1/findings.schema.json +57 -0
- agentspm/validate.py +184 -0
- agentspm-0.1.1.dist-info/METADATA +177 -0
- agentspm-0.1.1.dist-info/RECORD +36 -0
- agentspm-0.1.1.dist-info/WHEEL +4 -0
- agentspm-0.1.1.dist-info/entry_points.txt +2 -0
- agentspm-0.1.1.dist-info/licenses/LICENSE +202 -0
agentspm/__init__.py
ADDED
agentspm/canonical.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Canonical serialization and deterministic hashing (FR-10).
|
|
2
|
+
|
|
3
|
+
Every hash in this repository is computed over the *canonical JSON form*:
|
|
4
|
+
sorted keys, no whitespace, UTF-8, no NaN. Two runs over the same graph must
|
|
5
|
+
produce byte-identical hashes, so nothing in this module may consult the
|
|
6
|
+
clock, the environment, or any random source.
|
|
7
|
+
|
|
8
|
+
Timestamp fields are volatile run metadata, not graph content: they are
|
|
9
|
+
stripped before hashing (``strip_timestamps``) so that re-collecting an
|
|
10
|
+
unchanged graph yields the same ``graph_hash`` (AK-08 / TC-16).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# Volatile timestamp keys excluded from content/graph hashes (FR-10).
|
|
20
|
+
TIMESTAMP_KEYS = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"collected_at",
|
|
23
|
+
"last_seen",
|
|
24
|
+
"entitlement_review_at",
|
|
25
|
+
"evaluated_at",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def canonical_json(value: Any) -> str:
|
|
31
|
+
"""Serialize ``value`` to canonical JSON (sorted keys, compact, UTF-8)."""
|
|
32
|
+
return json.dumps(
|
|
33
|
+
value,
|
|
34
|
+
sort_keys=True,
|
|
35
|
+
separators=(",", ":"),
|
|
36
|
+
ensure_ascii=False,
|
|
37
|
+
allow_nan=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def strip_timestamps(value: Any) -> Any:
|
|
42
|
+
"""Recursively remove volatile timestamp keys; used for hashing only."""
|
|
43
|
+
if isinstance(value, dict):
|
|
44
|
+
return {
|
|
45
|
+
key: strip_timestamps(item)
|
|
46
|
+
for key, item in value.items()
|
|
47
|
+
if key not in TIMESTAMP_KEYS
|
|
48
|
+
}
|
|
49
|
+
if isinstance(value, list):
|
|
50
|
+
return [strip_timestamps(item) for item in value]
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sha256_hex(data: bytes | str) -> str:
|
|
55
|
+
"""Hex SHA-256 of raw bytes (strings are encoded as UTF-8)."""
|
|
56
|
+
if isinstance(data, str):
|
|
57
|
+
data = data.encode("utf-8")
|
|
58
|
+
return hashlib.sha256(data).hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def content_hash(document: Any) -> str:
|
|
62
|
+
"""Deterministic content hash of one document (timestamps excluded)."""
|
|
63
|
+
return sha256_hex(canonical_json(strip_timestamps(document)))
|
agentspm/cli.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""agentspm CLI (AK-06): scan -> evaluate -> report, plus export and doctor.
|
|
2
|
+
|
|
3
|
+
Pipeline (ADR-0001): **collect -> validate -> merge -> derive** (``scan``) ->
|
|
4
|
+
**evaluate** (pinned OPA, hard fail on any engine problem) -> **report**
|
|
5
|
+
(redacted by default; the full profile never leaves the evidence store).
|
|
6
|
+
|
|
7
|
+
Exit codes:
|
|
8
|
+
|
|
9
|
+
* ``scan``: non-zero ONLY for a configuration error (2), an empty graph
|
|
10
|
+
after the scan (1), or ``--strict`` with a degraded run (1). A failing
|
|
11
|
+
collector alone degrades to a partial scan — the previous snapshot stays
|
|
12
|
+
and the report shows the staleness.
|
|
13
|
+
* ``evaluate``: any OPA problem is a hard fail (1) — never default results.
|
|
14
|
+
* ``report``: refusing to write a full profile outside the evidence store,
|
|
15
|
+
or failing the redaction gate, exits 1.
|
|
16
|
+
* ``export``: fewer than two age recipients is refused (1).
|
|
17
|
+
* ``doctor``: any failing check exits 1 with a readable list.
|
|
18
|
+
|
|
19
|
+
Everything tenant-specific comes from the deployment YAML + ``AGENTSPM_*``
|
|
20
|
+
environment variables (FR-D5); this module never hard-codes tenant state.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import tarfile
|
|
29
|
+
import tempfile
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from datetime import UTC, datetime, timedelta
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Annotated, Any
|
|
35
|
+
|
|
36
|
+
import structlog
|
|
37
|
+
import typer
|
|
38
|
+
|
|
39
|
+
from agentspm.collectors import (
|
|
40
|
+
Collector,
|
|
41
|
+
CollectorConfigError,
|
|
42
|
+
build_enabled_collectors,
|
|
43
|
+
collector_registry,
|
|
44
|
+
load_config,
|
|
45
|
+
run_collectors,
|
|
46
|
+
)
|
|
47
|
+
from agentspm.doctor import (
|
|
48
|
+
MIN_AGE_RECIPIENTS,
|
|
49
|
+
age_recipients_from_config,
|
|
50
|
+
has_failures,
|
|
51
|
+
resolve_age_path,
|
|
52
|
+
run_doctor,
|
|
53
|
+
)
|
|
54
|
+
from agentspm.evaluate import EvaluateError, find_policies_dir, run_evaluate
|
|
55
|
+
from agentspm.graph.derive import derive
|
|
56
|
+
from agentspm.graph.store import GraphStore
|
|
57
|
+
from agentspm.report import (
|
|
58
|
+
META_DROPPED_DOCUMENTS,
|
|
59
|
+
META_SCAN_COLLECTORS,
|
|
60
|
+
META_SCAN_ID,
|
|
61
|
+
META_SCAN_TIME,
|
|
62
|
+
META_SYNTHETIC_DATA,
|
|
63
|
+
REPORT_FORMATS,
|
|
64
|
+
REPORT_PROFILES,
|
|
65
|
+
ReportError,
|
|
66
|
+
active_retention,
|
|
67
|
+
build_report,
|
|
68
|
+
load_controls,
|
|
69
|
+
resolve_evidence_store,
|
|
70
|
+
)
|
|
71
|
+
from agentspm.report.json import render_json
|
|
72
|
+
from agentspm.report.md import render_md
|
|
73
|
+
from agentspm.report.redact import (
|
|
74
|
+
Pseudonymizer,
|
|
75
|
+
RedactionError,
|
|
76
|
+
assert_no_personal_identifiers,
|
|
77
|
+
configure_logging,
|
|
78
|
+
load_or_create_key,
|
|
79
|
+
redact_structure,
|
|
80
|
+
resolve_pseudo_key_path,
|
|
81
|
+
)
|
|
82
|
+
from agentspm.report.sarif import render_sarif
|
|
83
|
+
|
|
84
|
+
log = structlog.get_logger(__name__)
|
|
85
|
+
|
|
86
|
+
ENV_CONFIG = "AGENTSPM_CONFIG"
|
|
87
|
+
ENV_DB = "AGENTSPM_DB"
|
|
88
|
+
|
|
89
|
+
DEFAULT_DB = "agentspm.sqlite"
|
|
90
|
+
_CONFIG_FALLBACKS = ("agentspm.yaml", "deploy/agentspm.example.yaml")
|
|
91
|
+
#: Ten z fallbacku, ktery je soucasti repa a nikdy neni provozni konfiguraci.
|
|
92
|
+
_EXAMPLE_CONFIG = "deploy/agentspm.example.yaml"
|
|
93
|
+
|
|
94
|
+
EXIT_ERROR = 1
|
|
95
|
+
EXIT_CONFIG = 2
|
|
96
|
+
|
|
97
|
+
MOCK_COLLECTOR = "mock-gateway"
|
|
98
|
+
|
|
99
|
+
app = typer.Typer(
|
|
100
|
+
name="agentspm",
|
|
101
|
+
help="Agent security posture: collect, evaluate against the policy pack, report.",
|
|
102
|
+
no_args_is_help=True,
|
|
103
|
+
add_completion=False,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
ConfigOption = Annotated[
|
|
107
|
+
Path | None,
|
|
108
|
+
typer.Option(
|
|
109
|
+
"--config",
|
|
110
|
+
envvar=ENV_CONFIG,
|
|
111
|
+
help="Deployment YAML (default: ./agentspm.yaml, then the example config).",
|
|
112
|
+
),
|
|
113
|
+
]
|
|
114
|
+
DbOption = Annotated[
|
|
115
|
+
Path | None,
|
|
116
|
+
typer.Option("--db", envvar=ENV_DB, help="SQLite graph database path."),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _fail(message: str, exit_code: int = EXIT_ERROR) -> typer.Exit:
|
|
121
|
+
typer.echo(f"error: {message}", err=True)
|
|
122
|
+
return typer.Exit(exit_code)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _load_cli_config(config_opt: Path | None) -> tuple[dict[str, Any], Path | None]:
|
|
126
|
+
"""Resolve and load the deployment config; a missing default is fine."""
|
|
127
|
+
path: Path | None
|
|
128
|
+
if config_opt is not None:
|
|
129
|
+
if not config_opt.is_file():
|
|
130
|
+
raise _fail(f"config file not found: {config_opt}", EXIT_CONFIG)
|
|
131
|
+
path = config_opt
|
|
132
|
+
else:
|
|
133
|
+
path = next(
|
|
134
|
+
(Path(c) for c in _CONFIG_FALLBACKS if Path(c).is_file()), None
|
|
135
|
+
)
|
|
136
|
+
if path is None:
|
|
137
|
+
return {}, None
|
|
138
|
+
try:
|
|
139
|
+
return load_config(path), path
|
|
140
|
+
except (CollectorConfigError, OSError, ValueError) as exc:
|
|
141
|
+
raise _fail(f"cannot load config {path}: {exc}", EXIT_CONFIG) from exc
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _db_path(db_opt: Path | None, config: Mapping[str, Any]) -> Path:
|
|
145
|
+
if db_opt is not None:
|
|
146
|
+
return db_opt
|
|
147
|
+
configured = config.get("database")
|
|
148
|
+
if isinstance(configured, str) and configured:
|
|
149
|
+
return Path(configured)
|
|
150
|
+
return Path(DEFAULT_DB)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _freshness_path(db_path: Path) -> Path:
|
|
154
|
+
return db_path.with_suffix(".freshness.json")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.callback()
|
|
158
|
+
def _main() -> None:
|
|
159
|
+
configure_logging()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# -- scan ---------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _build_selected_collectors(
|
|
166
|
+
names: list[str], config: Mapping[str, Any]
|
|
167
|
+
) -> list[Collector]:
|
|
168
|
+
registry = collector_registry()
|
|
169
|
+
sections_raw = config.get("collectors")
|
|
170
|
+
sections = sections_raw if isinstance(sections_raw, Mapping) else {}
|
|
171
|
+
collectors: list[Collector] = []
|
|
172
|
+
for name in names:
|
|
173
|
+
factory = registry.get(name)
|
|
174
|
+
if factory is None:
|
|
175
|
+
raise _fail(
|
|
176
|
+
f"unknown collector {name!r}; registered: "
|
|
177
|
+
f"{', '.join(sorted(registry))}",
|
|
178
|
+
EXIT_CONFIG,
|
|
179
|
+
)
|
|
180
|
+
section_raw = sections.get(name)
|
|
181
|
+
section = dict(section_raw) if isinstance(section_raw, Mapping) else {}
|
|
182
|
+
try:
|
|
183
|
+
collectors.append(factory(section, dict(os.environ)))
|
|
184
|
+
except CollectorConfigError as exc:
|
|
185
|
+
raise _fail(str(exc), EXIT_CONFIG) from exc
|
|
186
|
+
return collectors
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def scan(
|
|
191
|
+
collector: Annotated[
|
|
192
|
+
list[str] | None,
|
|
193
|
+
typer.Option(
|
|
194
|
+
"--collector",
|
|
195
|
+
help="Run only this collector (repeatable); default: all enabled in the config.",
|
|
196
|
+
),
|
|
197
|
+
] = None,
|
|
198
|
+
config: ConfigOption = None,
|
|
199
|
+
db: DbOption = None,
|
|
200
|
+
strict: Annotated[
|
|
201
|
+
bool,
|
|
202
|
+
typer.Option(
|
|
203
|
+
"--strict",
|
|
204
|
+
help="Exit non-zero when any collector failed, degraded to partial, "
|
|
205
|
+
"or dropped documents.",
|
|
206
|
+
),
|
|
207
|
+
] = False,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Collect -> validate -> merge -> derive into the SQLite graph."""
|
|
210
|
+
cfg, _ = _load_cli_config(config)
|
|
211
|
+
database = _db_path(db, cfg)
|
|
212
|
+
store = GraphStore(database)
|
|
213
|
+
|
|
214
|
+
if collector:
|
|
215
|
+
collectors = _build_selected_collectors(list(collector), cfg)
|
|
216
|
+
else:
|
|
217
|
+
try:
|
|
218
|
+
collectors = build_enabled_collectors(cfg)
|
|
219
|
+
except CollectorConfigError as exc:
|
|
220
|
+
raise _fail(str(exc), EXIT_CONFIG) from exc
|
|
221
|
+
if not collectors:
|
|
222
|
+
typer.echo(
|
|
223
|
+
"no collectors enabled in the config and none selected via "
|
|
224
|
+
"--collector; nothing was collected",
|
|
225
|
+
err=True,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
outcomes = run_collectors(store, collectors)
|
|
229
|
+
events = [event for outcome in outcomes for event in outcome.events]
|
|
230
|
+
derive_result = derive(store, events)
|
|
231
|
+
|
|
232
|
+
scan_id = str(uuid.uuid4())
|
|
233
|
+
scan_time = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
234
|
+
collectors_meta = {
|
|
235
|
+
outcome.collector: {
|
|
236
|
+
"status": outcome.status,
|
|
237
|
+
"documents": outcome.documents,
|
|
238
|
+
"dropped": outcome.dropped,
|
|
239
|
+
"warnings": list(outcome.warnings),
|
|
240
|
+
}
|
|
241
|
+
for outcome in outcomes
|
|
242
|
+
}
|
|
243
|
+
dropped_total = sum(outcome.dropped for outcome in outcomes)
|
|
244
|
+
dropped_by_collector = {
|
|
245
|
+
outcome.collector: outcome.dropped for outcome in outcomes if outcome.dropped
|
|
246
|
+
}
|
|
247
|
+
synthetic = MOCK_COLLECTOR in {c.name for c in collectors} or (
|
|
248
|
+
MOCK_COLLECTOR in store.collectors_with_contributions()
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
store.set_meta(META_SCAN_ID, scan_id)
|
|
252
|
+
store.set_meta(META_SCAN_TIME, scan_time)
|
|
253
|
+
store.set_meta_json(META_SCAN_COLLECTORS, collectors_meta)
|
|
254
|
+
store.set_meta_json(
|
|
255
|
+
META_DROPPED_DOCUMENTS,
|
|
256
|
+
{"total": dropped_total, "by_collector": dropped_by_collector},
|
|
257
|
+
)
|
|
258
|
+
store.set_meta(META_SYNTHETIC_DATA, "true" if synthetic else "false")
|
|
259
|
+
|
|
260
|
+
freshness = {
|
|
261
|
+
"scan_id": scan_id,
|
|
262
|
+
"scan_time": scan_time,
|
|
263
|
+
"graph_hash": derive_result.graph_hash,
|
|
264
|
+
"collectors": collectors_meta,
|
|
265
|
+
}
|
|
266
|
+
freshness_path = _freshness_path(database)
|
|
267
|
+
freshness_path.write_text(
|
|
268
|
+
json.dumps(freshness, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
assets_total = len(store.list_asset_rows())
|
|
272
|
+
for outcome in outcomes:
|
|
273
|
+
typer.echo(
|
|
274
|
+
f"collector {outcome.collector}: {outcome.status} "
|
|
275
|
+
f"({outcome.documents} documents, {outcome.dropped} dropped)"
|
|
276
|
+
)
|
|
277
|
+
typer.echo(
|
|
278
|
+
f"scan {scan_id}: {assets_total} assets in graph, "
|
|
279
|
+
f"graph_hash {derive_result.graph_hash[:16]}…, freshness {freshness_path}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if assets_total == 0:
|
|
283
|
+
raise _fail("scan produced an empty graph (0 assets)")
|
|
284
|
+
if strict:
|
|
285
|
+
degraded = [
|
|
286
|
+
outcome.collector
|
|
287
|
+
for outcome in outcomes
|
|
288
|
+
if outcome.status in ("failed", "partial") or outcome.dropped
|
|
289
|
+
]
|
|
290
|
+
if degraded:
|
|
291
|
+
raise _fail(
|
|
292
|
+
"--strict: degraded collectors: " + ", ".join(sorted(degraded))
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# -- evaluate -------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.command()
|
|
300
|
+
def evaluate(
|
|
301
|
+
config: ConfigOption = None,
|
|
302
|
+
db: DbOption = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Evaluate the graph with the pinned OPA binary (hard fail on any error)."""
|
|
305
|
+
cfg, _ = _load_cli_config(config)
|
|
306
|
+
store = GraphStore(_db_path(db, cfg))
|
|
307
|
+
try:
|
|
308
|
+
result = run_evaluate(store, cfg)
|
|
309
|
+
except EvaluateError as exc:
|
|
310
|
+
raise _fail(str(exc)) from exc
|
|
311
|
+
typer.echo(
|
|
312
|
+
f"evaluate: {result.findings_stored} findings stored "
|
|
313
|
+
f"({result.findings_dropped} dropped by validation), "
|
|
314
|
+
f"policy pack {result.policy_pack_version}, opa {result.opa_path}"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# -- report ---------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _render(
|
|
322
|
+
report_model: dict[str, Any],
|
|
323
|
+
fmt: str,
|
|
324
|
+
controls: list[dict[str, Any]] | None,
|
|
325
|
+
) -> str:
|
|
326
|
+
if fmt == "md":
|
|
327
|
+
return render_md(report_model, controls)
|
|
328
|
+
if fmt == "json":
|
|
329
|
+
return render_json(report_model)
|
|
330
|
+
if controls is None:
|
|
331
|
+
raise _fail("SARIF output needs controls.yaml (policy pack not found)")
|
|
332
|
+
return render_sarif(report_model, controls)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _is_within(path: Path, root: Path) -> bool:
|
|
336
|
+
try:
|
|
337
|
+
path.resolve().relative_to(root.resolve())
|
|
338
|
+
except ValueError:
|
|
339
|
+
return False
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@app.command()
|
|
344
|
+
def report(
|
|
345
|
+
fmt: Annotated[
|
|
346
|
+
str, typer.Option("--format", help="Output format: md, json or sarif.")
|
|
347
|
+
] = "md",
|
|
348
|
+
profile: Annotated[
|
|
349
|
+
str,
|
|
350
|
+
typer.Option(
|
|
351
|
+
"--profile",
|
|
352
|
+
help="redacted (default, pseudonymized) or full (evidence store only).",
|
|
353
|
+
),
|
|
354
|
+
] = "redacted",
|
|
355
|
+
out: Annotated[Path | None, typer.Option("--out", help="Output path.")] = None,
|
|
356
|
+
config: ConfigOption = None,
|
|
357
|
+
db: DbOption = None,
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Render the posture report (redacted by default)."""
|
|
360
|
+
if fmt not in REPORT_FORMATS:
|
|
361
|
+
raise _fail(f"unknown format {fmt!r} (use md, json or sarif)", EXIT_CONFIG)
|
|
362
|
+
if profile not in REPORT_PROFILES:
|
|
363
|
+
raise _fail(f"unknown profile {profile!r} (use redacted or full)", EXIT_CONFIG)
|
|
364
|
+
cfg, _ = _load_cli_config(config)
|
|
365
|
+
store = GraphStore(_db_path(db, cfg))
|
|
366
|
+
try:
|
|
367
|
+
report_model = build_report(store, cfg, profile=profile)
|
|
368
|
+
except ReportError as exc:
|
|
369
|
+
raise _fail(str(exc)) from exc
|
|
370
|
+
|
|
371
|
+
controls: list[dict[str, Any]] | None
|
|
372
|
+
try:
|
|
373
|
+
controls = load_controls(find_policies_dir(cfg))
|
|
374
|
+
except (EvaluateError, ReportError) as exc:
|
|
375
|
+
if fmt == "sarif":
|
|
376
|
+
raise _fail(f"SARIF needs the policy pack metadata: {exc}") from exc
|
|
377
|
+
controls = None
|
|
378
|
+
|
|
379
|
+
evidence_store = resolve_evidence_store(cfg)
|
|
380
|
+
extension = {"md": "md", "json": "json", "sarif": "sarif"}[fmt]
|
|
381
|
+
|
|
382
|
+
if profile == "full":
|
|
383
|
+
# The full profile NEVER leaves the evidence store (GAP-GDPR-03).
|
|
384
|
+
if evidence_store is None:
|
|
385
|
+
raise _fail(
|
|
386
|
+
"full profile requires an evidence store (set evidence_store "
|
|
387
|
+
"in the config or AGENTSPM_EVIDENCE_STORE)"
|
|
388
|
+
)
|
|
389
|
+
target = (
|
|
390
|
+
out
|
|
391
|
+
if out is not None
|
|
392
|
+
else evidence_store / "reports" / f"agentspm-report-full.{extension}"
|
|
393
|
+
)
|
|
394
|
+
if not _is_within(target, evidence_store):
|
|
395
|
+
raise _fail(
|
|
396
|
+
f"refusing to write a full-profile report outside the evidence "
|
|
397
|
+
f"store ({evidence_store}); target was {target}"
|
|
398
|
+
)
|
|
399
|
+
rendered = _render(report_model, fmt, controls)
|
|
400
|
+
else:
|
|
401
|
+
target = out if out is not None else Path(f"agentspm-report.{extension}")
|
|
402
|
+
key_path = resolve_pseudo_key_path(evidence_store)
|
|
403
|
+
if key_path is None:
|
|
404
|
+
raise _fail(
|
|
405
|
+
"redacted profile needs the pseudonymization key: set "
|
|
406
|
+
"AGENTSPM_PSEUDO_KEY_PATH or configure the evidence store "
|
|
407
|
+
"(evidence_store / AGENTSPM_EVIDENCE_STORE)"
|
|
408
|
+
)
|
|
409
|
+
pseudo = Pseudonymizer(load_or_create_key(key_path))
|
|
410
|
+
report_model = redact_structure(report_model, pseudo)
|
|
411
|
+
rendered = _render(report_model, fmt, controls)
|
|
412
|
+
try:
|
|
413
|
+
assert_no_personal_identifiers(rendered)
|
|
414
|
+
except RedactionError as exc:
|
|
415
|
+
raise _fail(str(exc)) from exc
|
|
416
|
+
# alias -> value mapping is personal data: evidence store only (RP-05).
|
|
417
|
+
pseudo.write_mapping(key_path.parent)
|
|
418
|
+
|
|
419
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
420
|
+
target.write_text(rendered, encoding="utf-8")
|
|
421
|
+
if profile == "full":
|
|
422
|
+
target.parent.chmod(0o700)
|
|
423
|
+
target.chmod(0o600)
|
|
424
|
+
typer.echo(f"report written: {target} (format {fmt}, profile {profile})")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# -- export ---------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@app.command()
|
|
431
|
+
def export(
|
|
432
|
+
encrypt: Annotated[
|
|
433
|
+
list[str] | None,
|
|
434
|
+
typer.Option(
|
|
435
|
+
"--encrypt",
|
|
436
|
+
help="age recipient as age:<recipient> (repeatable; >= 2 required).",
|
|
437
|
+
),
|
|
438
|
+
] = None,
|
|
439
|
+
out: Annotated[
|
|
440
|
+
Path | None, typer.Option("--out", help="Output path for the .tar.age snapshot.")
|
|
441
|
+
] = None,
|
|
442
|
+
config: ConfigOption = None,
|
|
443
|
+
db: DbOption = None,
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Write an age-encrypted snapshot (database + reports) — >= 2 recipients."""
|
|
446
|
+
cfg, _ = _load_cli_config(config)
|
|
447
|
+
recipients: list[str] = []
|
|
448
|
+
for entry in encrypt or []:
|
|
449
|
+
if not entry.startswith("age:"):
|
|
450
|
+
raise _fail(
|
|
451
|
+
f"--encrypt expects age:<recipient>, got {entry!r}", EXIT_CONFIG
|
|
452
|
+
)
|
|
453
|
+
value = entry[len("age:") :]
|
|
454
|
+
if value and value not in recipients:
|
|
455
|
+
recipients.append(value)
|
|
456
|
+
for value in age_recipients_from_config(cfg):
|
|
457
|
+
if value not in recipients:
|
|
458
|
+
recipients.append(value)
|
|
459
|
+
if len(recipients) < MIN_AGE_RECIPIENTS:
|
|
460
|
+
raise _fail(
|
|
461
|
+
f"export refused: {len(recipients)} age recipient(s) provided, "
|
|
462
|
+
f"at least {MIN_AGE_RECIPIENTS} required (a single lost key must "
|
|
463
|
+
"not strand the evidence)"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
age_path = resolve_age_path(dict(os.environ))
|
|
467
|
+
if age_path is None:
|
|
468
|
+
raise _fail(
|
|
469
|
+
"age binary not found (ASSUM-08: the export uses the system age "
|
|
470
|
+
"binary — install age or set AGENTSPM_AGE_PATH)"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
database = _db_path(db, cfg)
|
|
474
|
+
if not database.is_file():
|
|
475
|
+
raise _fail(f"database not found: {database} — run `agentspm scan` first")
|
|
476
|
+
evidence_store = resolve_evidence_store(cfg)
|
|
477
|
+
if out is not None:
|
|
478
|
+
target = out
|
|
479
|
+
elif evidence_store is not None:
|
|
480
|
+
target = evidence_store / "exports" / "agentspm-export.tar.age"
|
|
481
|
+
else:
|
|
482
|
+
raise _fail(
|
|
483
|
+
"no --out given and no evidence store configured for the default "
|
|
484
|
+
"export location"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
members: list[tuple[Path, str]] = [(database, f"db/{database.name}")]
|
|
488
|
+
freshness_path = _freshness_path(database)
|
|
489
|
+
if freshness_path.is_file():
|
|
490
|
+
members.append((freshness_path, f"db/{freshness_path.name}"))
|
|
491
|
+
if evidence_store is not None:
|
|
492
|
+
reports_dir = evidence_store / "reports"
|
|
493
|
+
if reports_dir.is_dir():
|
|
494
|
+
members.extend(
|
|
495
|
+
(path, f"reports/{path.relative_to(reports_dir)}")
|
|
496
|
+
for path in sorted(reports_dir.rglob("*"))
|
|
497
|
+
if path.is_file()
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
501
|
+
if evidence_store is not None and _is_within(target, evidence_store):
|
|
502
|
+
target.parent.chmod(0o700)
|
|
503
|
+
with tempfile.TemporaryDirectory(prefix="agentspm-export-") as tmp:
|
|
504
|
+
tar_path = Path(tmp) / "snapshot.tar"
|
|
505
|
+
with tarfile.open(tar_path, "w") as archive:
|
|
506
|
+
for source, arcname in members:
|
|
507
|
+
archive.add(source, arcname=arcname)
|
|
508
|
+
argv = [str(age_path)]
|
|
509
|
+
for recipient in recipients:
|
|
510
|
+
argv += ["-r", recipient]
|
|
511
|
+
argv += ["-o", str(target), str(tar_path)]
|
|
512
|
+
completed = subprocess.run(argv, capture_output=True, text=True) # no shell
|
|
513
|
+
if completed.returncode != 0:
|
|
514
|
+
raise _fail(f"age encryption failed: {completed.stderr.strip()}")
|
|
515
|
+
target.chmod(0o600)
|
|
516
|
+
typer.echo(
|
|
517
|
+
f"export written: {target} ({len(members)} file(s), "
|
|
518
|
+
f"{len(recipients)} age recipients)"
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# -- doctor ---------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# -- purge ----------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _parse_older_than(value: str) -> int:
|
|
529
|
+
"""``90d`` / ``90`` -> days; anything else is a config error."""
|
|
530
|
+
text = value.strip().lower()
|
|
531
|
+
if text.endswith("d"):
|
|
532
|
+
text = text[:-1]
|
|
533
|
+
if not text.isdigit():
|
|
534
|
+
raise _fail(
|
|
535
|
+
f"invalid --older-than {value!r}: use a day count like 90 or 90d",
|
|
536
|
+
EXIT_CONFIG,
|
|
537
|
+
)
|
|
538
|
+
return int(text)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@app.command()
|
|
542
|
+
def purge(
|
|
543
|
+
older_than: Annotated[
|
|
544
|
+
str | None,
|
|
545
|
+
typer.Option(
|
|
546
|
+
"--older-than",
|
|
547
|
+
help="Age threshold in days (e.g. 90d). Default: retention.raw_days "
|
|
548
|
+
"from the config (the same number the report header prints).",
|
|
549
|
+
),
|
|
550
|
+
] = None,
|
|
551
|
+
dry_run: Annotated[
|
|
552
|
+
bool,
|
|
553
|
+
typer.Option("--dry-run", help="Count what would be deleted; delete nothing."),
|
|
554
|
+
] = False,
|
|
555
|
+
config: ConfigOption = None,
|
|
556
|
+
db: DbOption = None,
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Enforce the declared retention: delete graph rows and full-profile
|
|
559
|
+
reports older than the threshold (GAP-GDPR-08).
|
|
560
|
+
|
|
561
|
+
Scope: the graph database and ``<evidence_store>/reports/``. NOT covered:
|
|
562
|
+
redacted reports written outside the evidence store (the tool cannot know
|
|
563
|
+
where they live) and the pseudonymization key/mapping (deleting the mapping
|
|
564
|
+
would break linkability of reports still within retention). Rows without a
|
|
565
|
+
parseable timestamp are never deleted. A real purge writes an enforcement
|
|
566
|
+
marker into ``meta`` (key ``purge:last``).
|
|
567
|
+
"""
|
|
568
|
+
from agentspm.purge import (
|
|
569
|
+
purge_report_files,
|
|
570
|
+
purge_store,
|
|
571
|
+
write_purge_marker,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
cfg, _ = _load_cli_config(config)
|
|
575
|
+
retention = active_retention(cfg)
|
|
576
|
+
days = _parse_older_than(older_than) if older_than is not None else retention["raw_days"]
|
|
577
|
+
cutoff = datetime.now(tz=UTC) - timedelta(days=days)
|
|
578
|
+
|
|
579
|
+
store = GraphStore(_db_path(db, cfg))
|
|
580
|
+
result = purge_store(store, cutoff, dry_run=dry_run)
|
|
581
|
+
result.report_files = purge_report_files(
|
|
582
|
+
resolve_evidence_store(cfg), cutoff, dry_run=dry_run
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
verb = "would delete" if dry_run else "deleted"
|
|
586
|
+
typer.echo(
|
|
587
|
+
f"purge ({'dry run, ' if dry_run else ''}older than {days}d, "
|
|
588
|
+
f"cutoff {result.cutoff}):"
|
|
589
|
+
)
|
|
590
|
+
typer.echo(
|
|
591
|
+
f" rows {verb}: assets={result.assets} contributions={result.contributions} "
|
|
592
|
+
f"edges={result.edges} provenance={result.provenance} findings={result.findings}"
|
|
593
|
+
)
|
|
594
|
+
typer.echo(f" report files {verb}: {len(result.report_files)}")
|
|
595
|
+
for path in result.report_files:
|
|
596
|
+
typer.echo(f" {path}")
|
|
597
|
+
if not dry_run:
|
|
598
|
+
write_purge_marker(store, result)
|
|
599
|
+
typer.echo(" enforcement marker written (meta key purge:last)")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@app.command()
|
|
603
|
+
def doctor(config: ConfigOption = None) -> None:
|
|
604
|
+
"""Preflight checks: OPA pin, capabilities, permissions, age, credentials."""
|
|
605
|
+
cfg, config_path = _load_cli_config(config)
|
|
606
|
+
is_example = config is None and config_path is not None and (
|
|
607
|
+
config_path.as_posix() == _EXAMPLE_CONFIG
|
|
608
|
+
)
|
|
609
|
+
results = run_doctor(cfg, config_path=config_path, config_is_example=is_example)
|
|
610
|
+
for result in results:
|
|
611
|
+
typer.echo(str(result))
|
|
612
|
+
failures = [result for result in results if result.status == "fail"]
|
|
613
|
+
if has_failures(results):
|
|
614
|
+
typer.echo(
|
|
615
|
+
f"doctor: {len(failures)} check(s) failed "
|
|
616
|
+
f"({', '.join(result.name for result in failures)})",
|
|
617
|
+
err=True,
|
|
618
|
+
)
|
|
619
|
+
raise typer.Exit(EXIT_ERROR)
|
|
620
|
+
typer.echo("doctor: all checks passed")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
if __name__ == "__main__": # pragma: no cover
|
|
624
|
+
app()
|