@topy-ai/maggie 0.7.55 → 0.7.57

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 (73) hide show
  1. package/README-zh-TW.md +7 -3
  2. package/README.md +42 -8
  3. package/bin/maggie.js +4 -0
  4. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
  5. package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
  6. package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
  7. package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
  8. package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
  9. package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
  10. package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
  11. package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
  12. package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
  13. package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
  14. package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
  15. package/bundled-skills/catalog.json +4 -0
  16. package/bundled-skills/maggie-security/SKILL.md +173 -0
  17. package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
  18. package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
  19. package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
  20. package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
  21. package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
  22. package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
  23. package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
  24. package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
  25. package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
  26. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
  27. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
  28. package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
  29. package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
  30. package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
  31. package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
  32. package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
  33. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
  34. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
  35. package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
  36. package/bundled-skills/maggie-security/evals/evals.json +97 -0
  37. package/bundled-skills/maggie-security/references/commands.md +125 -0
  38. package/bundled-skills/maggie-security/references/cve-response.md +24 -0
  39. package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
  40. package/bundled-skills/maggie-security/references/findings.md +39 -0
  41. package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
  42. package/bundled-skills/maggie-security/references/site-types.md +22 -0
  43. package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
  44. package/bundled-skills/maggie-security/references/workflows.md +23 -0
  45. package/bundled-skills/maggie-security/registry/security-tools.registry.json +654 -0
  46. package/bundled-skills/maggie-security/routines.example.json +27 -0
  47. package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
  48. package/bundled-tools/clis/maggie_security.py +342 -0
  49. package/bundled-tools/clis/maggie_security_agent.py +326 -0
  50. package/bundled-tools/clis/maggie_security_code.py +650 -0
  51. package/bundled-tools/clis/maggie_security_core.py +1512 -0
  52. package/bundled-tools/clis/maggie_security_deps.py +560 -0
  53. package/bundled-tools/clis/maggie_security_health.py +415 -0
  54. package/bundled-tools/clis/maggie_security_host.py +511 -0
  55. package/bundled-tools/clis/maggie_security_hosted.py +115 -0
  56. package/bundled-tools/clis/maggie_security_intel.py +1294 -0
  57. package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
  58. package/bundled-tools/clis/maggie_security_logs.py +777 -0
  59. package/bundled-tools/clis/maggie_security_mcp.py +484 -0
  60. package/bundled-tools/clis/maggie_security_mirror.py +122 -0
  61. package/bundled-tools/clis/maggie_security_protect.py +519 -0
  62. package/bundled-tools/clis/maggie_security_report.py +506 -0
  63. package/bundled-tools/clis/maggie_security_standards.py +214 -0
  64. package/bundled-tools/clis/maggie_security_versions.py +381 -0
  65. package/bundled-tools/clis/maggie_security_web.py +1247 -0
  66. package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
  67. package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
  68. package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
  69. package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
  70. package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
  71. package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
  72. package/bundled-tools/clis/security_data/tools.lock.json +358 -0
  73. package/package.json +1 -1
@@ -0,0 +1,1512 @@
1
+ #!/usr/bin/env python3
2
+ """Shared helpers for every ``maggie security`` command.
3
+
4
+ One place for the pieces that must behave the same everywhere
5
+ (docs/maggie-security/00-foundation-prd.md):
6
+
7
+ - findings: builder, fingerprint id, severity from CVSS, priority rule,
8
+ within-run merge, findings-set envelope, clipping;
9
+ - the ledger (``.maggie/security/ledger.jsonl``) that turns a run into
10
+ ``new`` / ``open`` / ``fixed`` and never marks a finding fixed from a
11
+ degraded run;
12
+ - the feed cache with conditional GET, TTLs, cursors and the
13
+ ``MAGGIE_SECURITY_MIRROR`` override;
14
+ - the egress guard: every network call names a declared source, and
15
+ ``--offline`` refuses all of them (a socket guard backs this up);
16
+ - the external tool lock and runner: sha256 before exec, required flags
17
+ injected, forbidden flags refused;
18
+ - redaction, so a secret value never reaches stdout, files, the ledger or a
19
+ report;
20
+ - a small JSON Schema validator (the subset our contracts use), because the
21
+ CLIs stay standard-library only.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import contextlib
28
+ import hashlib
29
+ import io
30
+ import json
31
+ import os
32
+ import platform
33
+ import re
34
+ import shutil
35
+ import socket
36
+ import stat
37
+ import subprocess
38
+ import sys
39
+ import tarfile
40
+ import threading
41
+ import time
42
+ import urllib.error
43
+ import urllib.parse
44
+ import urllib.request
45
+ import zipfile
46
+ from dataclasses import dataclass
47
+ from datetime import date, datetime, timedelta, timezone
48
+ from pathlib import Path
49
+ from typing import Any, Callable, Iterable
50
+
51
+ HERE = Path(__file__).resolve().parent
52
+ ROOT = HERE.parents[1]
53
+ DATA_DIR = HERE / "security_data"
54
+
55
+ SEVERITIES = ["critical", "high", "medium", "low", "info"]
56
+ PRIORITIES = ["p0", "p1", "p2", "p3", "p4"]
57
+ CONFIDENCE = ["confirmed", "likely", "possible"]
58
+ STATUSES = ["new", "open", "fixed", "accepted-risk", "false-positive", "suppressed"]
59
+ KINDS = ["vulnerability", "eol", "malicious-package", "misconfiguration", "exposure", "secret", "code", "tls", "header",
60
+ "attack-signal", "host-hardening", "availability", "agent-skill", "checklist"]
61
+
62
+ EXIT_OK, EXIT_FINDINGS, EXIT_USAGE, EXIT_DEGRADED, EXIT_POLICY, EXIT_TOOL = 0, 1, 2, 3, 4, 5
63
+
64
+
65
+ class SecurityError(Exception):
66
+ """An error with a security-CLI exit code (PRD 00 section 6.3)."""
67
+
68
+ code = EXIT_USAGE
69
+
70
+ def __init__(self, message: str, code: int | None = None) -> None:
71
+ super().__init__(message)
72
+ if code is not None:
73
+ self.code = code
74
+
75
+
76
+ class UsageError(SecurityError):
77
+ code = EXIT_USAGE
78
+
79
+
80
+ class DegradedError(SecurityError):
81
+ code = EXIT_DEGRADED
82
+
83
+
84
+ class PolicyError(SecurityError):
85
+ code = EXIT_POLICY
86
+
87
+
88
+ class ToolError(SecurityError):
89
+ code = EXIT_TOOL
90
+
91
+
92
+ # --------------------------------------------------------------------------- basics
93
+
94
+
95
+ def maggie_version() -> str:
96
+ for candidate in (ROOT / "VERSION",):
97
+ if candidate.is_file():
98
+ return candidate.read_text(encoding="utf-8").strip()
99
+ package = ROOT / "package.json"
100
+ if package.is_file():
101
+ with contextlib.suppress(ValueError, OSError):
102
+ return str(json.loads(package.read_text(encoding="utf-8")).get("version") or "0.0.0")
103
+ return "0.0.0"
104
+
105
+
106
+ def utcnow() -> datetime:
107
+ override = os.environ.get("MAGGIE_SECURITY_NOW")
108
+ if override:
109
+ return parse_time(override)
110
+ return datetime.now(timezone.utc).replace(microsecond=0)
111
+
112
+
113
+ def iso(moment: datetime | None = None) -> str:
114
+ return (moment or utcnow()).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
115
+
116
+
117
+ def parse_time(value: str) -> datetime:
118
+ text = value.strip()
119
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
120
+ return datetime.fromisoformat(text).replace(tzinfo=timezone.utc)
121
+ if text.endswith("Z"):
122
+ text = text[:-1] + "+00:00"
123
+ moment = datetime.fromisoformat(text)
124
+ if moment.tzinfo is None:
125
+ moment = moment.replace(tzinfo=timezone.utc)
126
+ return moment.astimezone(timezone.utc)
127
+
128
+
129
+ def parse_duration(value: str) -> timedelta:
130
+ match = re.fullmatch(r"\s*(\d+)\s*([smhdw])\s*", value or "")
131
+ if not match:
132
+ raise UsageError(f"bad duration {value!r}; use e.g. 30m, 24h, 7d")
133
+ amount, unit = int(match.group(1)), match.group(2)
134
+ units = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
135
+ return timedelta(**{units[unit]: amount})
136
+
137
+
138
+ def sha256_bytes(data: bytes) -> str:
139
+ return hashlib.sha256(data).hexdigest()
140
+
141
+
142
+ def sha256_file(path: Path) -> str:
143
+ digest = hashlib.sha256()
144
+ with path.open("rb") as handle:
145
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
146
+ digest.update(chunk)
147
+ return digest.hexdigest()
148
+
149
+
150
+ def canonical_json(value: Any) -> str:
151
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
152
+
153
+
154
+ def read_json(path: Path) -> Any:
155
+ try:
156
+ return json.loads(path.read_text(encoding="utf-8"))
157
+ except FileNotFoundError as error:
158
+ raise UsageError(f"file not found: {path}") from error
159
+ except json.JSONDecodeError as error:
160
+ raise UsageError(f"{path}: invalid JSON ({error.msg} at line {error.lineno})") from error
161
+
162
+
163
+ def write_json(path: Path, value: Any) -> None:
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ tmp = path.with_suffix(path.suffix + f".{os.getpid()}.{threading.get_ident()}.tmp")
166
+ tmp.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
167
+ tmp.replace(path)
168
+
169
+
170
+ # --------------------------------------------------------------------------- schema validation
171
+
172
+
173
+ def contracts_dir() -> Path:
174
+ for candidate in (ROOT / "contracts" / "maggie-security", ROOT / "bundled-contracts" / "maggie-security"):
175
+ if candidate.is_dir():
176
+ return candidate
177
+ raise UsageError("maggie-security contracts not found (expected contracts/maggie-security)")
178
+
179
+
180
+ def load_contract(name: str) -> dict:
181
+ return read_json(contracts_dir() / f"{name}.schema.json")
182
+
183
+
184
+ _TYPES: dict[str, Callable[[Any], bool]] = {
185
+ "object": lambda v: isinstance(v, dict),
186
+ "array": lambda v: isinstance(v, list),
187
+ "string": lambda v: isinstance(v, str),
188
+ "integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
189
+ "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
190
+ "boolean": lambda v: isinstance(v, bool),
191
+ "null": lambda v: v is None,
192
+ }
193
+
194
+
195
+ def schema_errors(instance: Any, schema: dict, root: dict | None = None, path: str = "$") -> list[str]:
196
+ """Validate the JSON Schema subset our contracts use; returns error strings."""
197
+ root = root if root is not None else schema
198
+ errors: list[str] = []
199
+ if "$ref" in schema:
200
+ ref = schema["$ref"]
201
+ if not ref.startswith("#/"):
202
+ return [f"{path}: unsupported $ref {ref}"]
203
+ target: Any = root
204
+ for part in ref[2:].split("/"):
205
+ target = target[part]
206
+ return schema_errors(instance, target, root, path)
207
+ if "const" in schema and instance != schema["const"]:
208
+ errors.append(f"{path}: expected {schema['const']!r}")
209
+ if "enum" in schema and instance not in schema["enum"]:
210
+ errors.append(f"{path}: {instance!r} not in {schema['enum']}")
211
+ if "type" in schema:
212
+ types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
213
+ if not any(_TYPES[t](instance) for t in types):
214
+ return errors + [f"{path}: expected {'/'.join(types)}"]
215
+ if isinstance(instance, str):
216
+ if "pattern" in schema and not re.search(schema["pattern"], instance):
217
+ errors.append(f"{path}: does not match {schema['pattern']}")
218
+ if "minLength" in schema and len(instance) < schema["minLength"]:
219
+ errors.append(f"{path}: shorter than {schema['minLength']}")
220
+ if "maxLength" in schema and len(instance) > schema["maxLength"]:
221
+ errors.append(f"{path}: longer than {schema['maxLength']}")
222
+ if _TYPES["number"](instance):
223
+ if "minimum" in schema and instance < schema["minimum"]:
224
+ errors.append(f"{path}: below {schema['minimum']}")
225
+ if "maximum" in schema and instance > schema["maximum"]:
226
+ errors.append(f"{path}: above {schema['maximum']}")
227
+ if isinstance(instance, dict):
228
+ for key in schema.get("required", []):
229
+ if key not in instance:
230
+ errors.append(f"{path}: missing {key}")
231
+ props = schema.get("properties", {})
232
+ for key, value in instance.items():
233
+ if key in props:
234
+ errors.extend(schema_errors(value, props[key], root, f"{path}.{key}"))
235
+ elif schema.get("additionalProperties") is False:
236
+ errors.append(f"{path}: unexpected {key}")
237
+ elif isinstance(schema.get("additionalProperties"), dict):
238
+ errors.extend(schema_errors(value, schema["additionalProperties"], root, f"{path}.{key}"))
239
+ if isinstance(instance, list) and isinstance(schema.get("items"), dict):
240
+ for index, item in enumerate(instance):
241
+ errors.extend(schema_errors(item, schema["items"], root, f"{path}[{index}]"))
242
+ return errors
243
+
244
+
245
+ def validate_contract(instance: Any, name: str) -> list[str]:
246
+ schema = load_contract(name)
247
+ errors = schema_errors(instance, schema)
248
+ if name == "findings-set-v1" and isinstance(instance, dict):
249
+ finding_schema = load_contract("finding-v1")
250
+ for index, item in enumerate(instance.get("findings") or []):
251
+ errors.extend(schema_errors(item, finding_schema, path=f"$.findings[{index}]"))
252
+ return errors
253
+
254
+
255
+ # --------------------------------------------------------------------------- findings
256
+
257
+
258
+ def severity_from_cvss(score: float | None) -> str | None:
259
+ if score is None:
260
+ return None
261
+ if score >= 9.0:
262
+ return "critical"
263
+ if score >= 7.0:
264
+ return "high"
265
+ if score >= 4.0:
266
+ return "medium"
267
+ if score > 0:
268
+ return "low"
269
+ return "info"
270
+
271
+
272
+ _CVSS3 = {
273
+ "AV": {"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}, "AC": {"L": 0.77, "H": 0.44},
274
+ "UI": {"N": 0.85, "R": 0.62}, "C": {"H": 0.56, "L": 0.22, "N": 0.0}, "I": {"H": 0.56, "L": 0.22, "N": 0.0},
275
+ "A": {"H": 0.56, "L": 0.22, "N": 0.0},
276
+ }
277
+
278
+
279
+ def _roundup(value: float) -> float:
280
+ integer = round(value * 100000)
281
+ return integer / 100000.0 if integer % 10000 == 0 else (int(integer / 10000) + 1) / 10.0
282
+
283
+
284
+ def cvss3_base_score(vector: str) -> float | None:
285
+ """CVSS v3.0/3.1 base score from a vector string (FIRST specification formula)."""
286
+ if not vector or not vector.startswith("CVSS:3"):
287
+ return None
288
+ try:
289
+ metrics = dict(part.split(":", 1) for part in vector.split("/")[1:])
290
+ scope_changed = metrics["S"] == "C"
291
+ pr = {"N": 0.85, "L": 0.68 if scope_changed else 0.62, "H": 0.5 if scope_changed else 0.27}[metrics["PR"]]
292
+ iss = 1 - (1 - _CVSS3["C"][metrics["C"]]) * (1 - _CVSS3["I"][metrics["I"]]) * (1 - _CVSS3["A"][metrics["A"]])
293
+ impact = 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02) ** 15 if scope_changed else 6.42 * iss
294
+ exploitability = 8.22 * _CVSS3["AV"][metrics["AV"]] * _CVSS3["AC"][metrics["AC"]] * pr * _CVSS3["UI"][metrics["UI"]]
295
+ except (KeyError, ValueError):
296
+ return None
297
+ if impact <= 0:
298
+ return 0.0
299
+ total = 1.08 * (impact + exploitability) if scope_changed else impact + exploitability
300
+ return _roundup(min(total, 10))
301
+
302
+
303
+ def purl_without_version(purl: str | None) -> str | None:
304
+ if not purl:
305
+ return None
306
+ base = purl.split("?", 1)[0].split("#", 1)[0]
307
+ head, sep, _version = base.rpartition("@")
308
+ # "pkg:npm/@scope/name" has an @ that is part of the name, not a version.
309
+ if sep and "/" not in _version and not head.endswith("/"):
310
+ return head
311
+ return base
312
+
313
+
314
+ def make_purl(ecosystem: str, name: str, version: str | None = None) -> str | None:
315
+ eco = (ecosystem or "").lower()
316
+ # Ecosystem -> purl type, after osv.dev purl_helpers.ECOSYSTEM_PURL_DATA (Apache-2.0).
317
+ kind = {"npm": "npm", "packagist": "composer", "composer": "composer", "pypi": "pypi", "go": "golang",
318
+ "crates.io": "cargo", "maven": "maven", "nuget": "nuget", "rubygems": "gem", "hex": "hex", "pub": "pub",
319
+ "wordpress": "wordpress", "debian": "deb", "deb": "deb", "alpine": "apk", "apk": "apk",
320
+ "github-actions": "github"}.get(eco)
321
+ if not kind or not name:
322
+ return None
323
+ if kind == "npm" and name.startswith("@"):
324
+ scope, _, rest = name[1:].partition("/")
325
+ encoded = f"%40{scope}/{rest}"
326
+ else:
327
+ encoded = name
328
+ return f"pkg:{kind}/{encoded}" + (f"@{version}" if version else "")
329
+
330
+
331
+ def fingerprint(kind: str, primary: str, locator: str, site: str | None) -> str:
332
+ material = "\x1f".join([kind, primary.upper() if primary else "", locator or "", site or ""])
333
+ return "msf_" + sha256_bytes(material.encode("utf-8"))[:24]
334
+
335
+
336
+ def finding_locator(affected: dict | None, target: dict) -> str:
337
+ """What a finding is about, without the version; host-scoped for runtimes on a host."""
338
+ host = f"#{target['host']}" if target.get("host") else ""
339
+ if affected:
340
+ purl = purl_without_version(affected.get("purl"))
341
+ if purl:
342
+ return purl + host
343
+ if affected.get("name"):
344
+ return f"{affected.get('component_type') or 'component'}:{affected['name']}{host}"
345
+ for key in ("url", "host", "path", "image"):
346
+ if target.get(key):
347
+ return f"{key}:{target[key]}"
348
+ return ""
349
+
350
+
351
+ def make_finding(*, kind: str, title: str, command: str, primary_id: str, site: str | None = None,
352
+ severity: str | None = None, severity_source: str | None = None, cvss: dict | None = None,
353
+ confidence: str = "likely", aliases: Iterable[str] = (), epss: dict | None = None,
354
+ kev: dict | None = None, cwe_ids: Iterable[str] = (), asvs_ids: Iterable[str] = (),
355
+ affected: dict | None = None, fix: dict | None = None, target: dict | None = None,
356
+ evidence: Iterable[dict] = (), tool: str | None = None, tool_version: str | None = None,
357
+ rule_id: str | None = None, feed: str | None = None, fetched_at: str | None = None,
358
+ remediation: dict | None = None, signal: dict | None = None, internet_facing: bool = True,
359
+ untrusted: dict | None = None) -> dict:
360
+ """Build one finding-v1 with id, severity and priority filled in."""
361
+ if kind not in KINDS:
362
+ raise ValueError(f"unknown finding kind {kind}")
363
+ target = {"site": site, "host": None, "url": None, "path": None, "image": None, **(target or {})}
364
+ if site and not target.get("site"):
365
+ target["site"] = site
366
+ cvss_sev = severity_from_cvss((cvss or {}).get("score")) if cvss else None
367
+ if cvss_sev:
368
+ severity, severity_source = cvss_sev, "cvss"
369
+ severity = severity if severity in SEVERITIES else "info"
370
+ severity_source = severity_source or ("tool" if tool else "maggie")
371
+ alias_list = sorted({a for a in aliases if a} | ({primary_id} if primary_id else set()),
372
+ key=lambda a: (not a.upper().startswith("CVE-"), a))
373
+ finding = {
374
+ "schema": "maggie-security-finding.v1",
375
+ "id": fingerprint(kind, primary_id, finding_locator(affected, target), target.get("site")),
376
+ "kind": kind,
377
+ "title": title[:300],
378
+ "severity": severity,
379
+ "severity_source": severity_source,
380
+ "priority": "p4",
381
+ "confidence": confidence,
382
+ "aliases": alias_list,
383
+ "cvss": cvss,
384
+ "epss": epss,
385
+ "kev": kev,
386
+ "cwe_ids": sorted(set(cwe_ids)),
387
+ "asvs_ids": sorted(set(asvs_ids)),
388
+ "affected": affected,
389
+ "fix": fix,
390
+ "target": target,
391
+ "evidence": [clean_evidence(item) for item in evidence],
392
+ "source": {"command": command, "tool": tool, "tool_version": tool_version, "rule_id": rule_id or primary_id,
393
+ "feed": feed, "fetched_at": fetched_at},
394
+ "remediation": remediation,
395
+ "signal": signal,
396
+ "status": "new",
397
+ "first_seen": None,
398
+ "last_seen": None,
399
+ }
400
+ if untrusted:
401
+ finding["untrusted"] = untrusted
402
+ finding["priority"] = compute_priority(finding, internet_facing=internet_facing)
403
+ return finding
404
+
405
+
406
+ def _step(priority: str, delta: int) -> str:
407
+ index = min(max(PRIORITIES.index(priority) + delta, 0), len(PRIORITIES) - 1)
408
+ return PRIORITIES[index]
409
+
410
+
411
+ def compute_priority(finding: dict, internet_facing: bool = True, today: date | None = None) -> str:
412
+ """PRD 00 section 7.4; first match wins, dev scope lowers one step except secrets."""
413
+ kind = finding.get("kind")
414
+ severity = finding.get("severity")
415
+ confidence = finding.get("confidence")
416
+ kev_listed = bool((finding.get("kev") or {}).get("listed"))
417
+ epss = float((finding.get("epss") or {}).get("score") or 0)
418
+ affected = finding.get("affected") or {}
419
+ fix = finding.get("fix") or {}
420
+ signal = finding.get("signal") or {}
421
+ target = finding.get("target") or {}
422
+ scope = affected.get("scope")
423
+ eol_days = finding.get("_eol_days")
424
+ public_artifact = any(str(target.get("path") or "").startswith(p) for p in ("dist/", "public/", "build/", ".output/")) or bool(target.get("url"))
425
+
426
+ if (kev_listed and confidence in ("confirmed", "likely") and internet_facing) \
427
+ or (kind == "secret" and confidence == "confirmed" and public_artifact) \
428
+ or signal.get("type") == "compromise-indicator":
429
+ priority = "p0"
430
+ elif severity == "critical" or (severity == "high" and epss >= 0.1) \
431
+ or (kind == "eol" and eol_days is not None and eol_days < 0 and internet_facing):
432
+ priority = "p1"
433
+ elif severity == "high" or (severity == "medium" and fix.get("available") and affected.get("direct")) \
434
+ or (kind == "eol" and eol_days is not None and eol_days <= 30):
435
+ priority = "p2"
436
+ elif severity in ("medium", "low") or (kind == "eol" and eol_days is not None and eol_days <= 90) \
437
+ or (kind in ("host-hardening", "header", "misconfiguration") and severity != "info"):
438
+ priority = "p3"
439
+ else:
440
+ priority = "p4"
441
+ if confidence == "possible" and priority != "p0" and kind != "attack-signal" and not kev_listed:
442
+ priority = "p4" # no corroboration: "worth a look", never a notification
443
+ if kev_listed and fix and fix.get("available") is False and not fix.get("fixed_versions"):
444
+ priority = _step(priority, -1)
445
+ if scope == "dev" and kind != "secret":
446
+ priority = _step(priority, 1)
447
+ return priority
448
+
449
+
450
+ def _better_confidence(a: str, b: str) -> str:
451
+ return a if CONFIDENCE.index(a) <= CONFIDENCE.index(b) else b
452
+
453
+
454
+ def merge_findings(findings: Iterable[dict]) -> list[dict]:
455
+ """Merge same-fingerprint findings within one run (PRD 00 section 7.5)."""
456
+ merged: dict[str, dict] = {}
457
+ order: list[str] = []
458
+ for finding in findings:
459
+ fid = finding["id"]
460
+ if fid not in merged:
461
+ merged[fid] = json.loads(json.dumps(finding))
462
+ order.append(fid)
463
+ continue
464
+ base = merged[fid]
465
+ base["aliases"] = sorted(set(base.get("aliases") or []) | set(finding.get("aliases") or []),
466
+ key=lambda a: (not a.upper().startswith("CVE-"), a))
467
+ if CONFIDENCE.index(finding["confidence"]) < CONFIDENCE.index(base["confidence"]):
468
+ base["affected"] = finding.get("affected") or base.get("affected")
469
+ base["confidence"] = _better_confidence(base["confidence"], finding["confidence"])
470
+ if SEVERITIES.index(finding["severity"]) < SEVERITIES.index(base["severity"]):
471
+ base["severity"], base["severity_source"] = finding["severity"], finding["severity_source"]
472
+ for key in ("cvss", "epss", "kev", "fix", "remediation"):
473
+ if not base.get(key) and finding.get(key):
474
+ base[key] = finding[key]
475
+ base["cwe_ids"] = sorted(set(base.get("cwe_ids") or []) | set(finding.get("cwe_ids") or []))
476
+ base["asvs_ids"] = sorted(set(base.get("asvs_ids") or []) | set(finding.get("asvs_ids") or []))
477
+ base["evidence"] = (base.get("evidence") or []) + [e for e in finding.get("evidence") or [] if e not in (base.get("evidence") or [])]
478
+ also = base["source"].setdefault("also", [])
479
+ src = {k: v for k, v in finding["source"].items() if k != "also"}
480
+ if src not in also and src != {k: v for k, v in base["source"].items() if k != "also"}:
481
+ also.append(src)
482
+ base["priority"] = compute_priority(base)
483
+ return [merged[fid] for fid in order]
484
+
485
+
486
+ def sort_findings(findings: list[dict]) -> list[dict]:
487
+ return sorted(findings, key=lambda f: (PRIORITIES.index(f.get("priority", "p4")), SEVERITIES.index(f.get("severity", "info")),
488
+ STATUSES.index(f.get("status", "new")), f.get("title", "")))
489
+
490
+
491
+ def new_run(command: str, site: str | None, inputs: dict | None = None) -> dict:
492
+ started = utcnow()
493
+ suffix = sha256_bytes(f"{command}{time.time_ns()}{os.getpid()}".encode())[:4]
494
+ return {"id": f"{iso(started)}-{command}-{suffix}", "command": command, "maggie_version": maggie_version(),
495
+ "started_at": iso(started), "finished_at": iso(started), "site": site, "inputs": inputs or {},
496
+ "sources": [], "tools": [], "degraded": [], "refused": [], "covered": []}
497
+
498
+
499
+ def findings_set(run: dict, findings: list[dict], max_items: int = 200, fixed_ids: list[str] | None = None) -> dict:
500
+ run = dict(run)
501
+ run["finished_at"] = iso()
502
+ ordered = sort_findings(findings)
503
+ by_severity = {s: 0 for s in SEVERITIES}
504
+ by_priority = {p: 0 for p in PRIORITIES}
505
+ for finding in ordered:
506
+ by_severity[finding["severity"]] += 1
507
+ by_priority[finding["priority"]] += 1
508
+ summary = {"new": sum(1 for f in ordered if f["status"] == "new"),
509
+ "open": sum(1 for f in ordered if f["status"] == "open"),
510
+ "fixed": len(fixed_ids or []), "by_severity": by_severity, "by_priority": by_priority}
511
+ clipped = len(ordered) > max_items
512
+ return {"schema": "maggie-security-findings.v1", "run": run, "summary": summary,
513
+ "clipped": {"findings": clipped, "total": len(ordered)},
514
+ "findings": ordered[:max_items], "fixed": list(fixed_ids or [])}
515
+
516
+
517
+ # --------------------------------------------------------------------------- redaction
518
+
519
+ SECRET_QUERY = re.compile(r"(?i)([?&;](?:[a-z0-9_.-]*?(?:token|key|password|passwd|pwd|secret|session|auth|signature|sig)[a-z0-9_.-]*)=)([^&;#\s\"']+)")
520
+
521
+
522
+ def redact_secret(value: str) -> dict:
523
+ """Never store the value: a short preview and a hash for de-duplication."""
524
+ preview = f"{value[:4]}…{value[-4:]}" if len(value) >= 12 else "…"
525
+ return {"redacted": preview, "value_sha256": sha256_bytes(value.encode("utf-8"))}
526
+
527
+
528
+ def mask_query(text: str) -> str:
529
+ return SECRET_QUERY.sub(lambda m: m.group(1) + "***", text)
530
+
531
+
532
+ def clean_excerpt(text: str, limit: int = 200) -> str:
533
+ text = mask_query(str(text)).replace("\r", " ").replace("\n", " ")
534
+ return text if len(text) <= limit else text[: limit - 1] + "…"
535
+
536
+
537
+ def clean_evidence(item: dict) -> dict:
538
+ out = dict(item)
539
+ if "excerpt" in out and out["excerpt"] is not None:
540
+ out["excerpt"] = clean_excerpt(out["excerpt"])
541
+ return out
542
+
543
+
544
+ def assert_no_secret(texts: Iterable[str], secrets: Iterable[str]) -> list[str]:
545
+ """Return the secrets that leaked into any of the texts (tests and report-check)."""
546
+ leaked = []
547
+ blob = "\n".join(texts)
548
+ for secret in secrets:
549
+ if secret and secret in blob:
550
+ leaked.append(secret[:4] + "…")
551
+ return leaked
552
+
553
+
554
+ # --------------------------------------------------------------------------- state and config
555
+
556
+
557
+ @dataclass
558
+ class State:
559
+ project: Path
560
+
561
+ @property
562
+ def root(self) -> Path:
563
+ return self.project / ".maggie" / "security"
564
+
565
+ def path(self, *parts: str) -> Path:
566
+ return self.root.joinpath(*parts)
567
+
568
+ def config(self) -> dict:
569
+ path = self.path("config.json")
570
+ return read_json(path) if path.is_file() else {}
571
+
572
+ def ensure(self) -> None:
573
+ self.root.mkdir(parents=True, exist_ok=True)
574
+
575
+
576
+ def site_id(project: Path, explicit: str | None = None) -> str:
577
+ if explicit:
578
+ return explicit
579
+ state = State(project)
580
+ cfg = state.config()
581
+ if cfg.get("site"):
582
+ return str(cfg["site"])
583
+ meta = project / ".maggie" / "project.json"
584
+ if meta.is_file():
585
+ with contextlib.suppress(UsageError):
586
+ data = read_json(meta)
587
+ for key in ("site", "id", "slug", "name"):
588
+ if isinstance(data.get(key), str) and data[key]:
589
+ return re.sub(r"[^a-z0-9-]+", "-", data[key].lower()).strip("-") or "site"
590
+ return re.sub(r"[^a-z0-9-]+", "-", project.resolve().name.lower()).strip("-") or "site"
591
+
592
+
593
+ # --------------------------------------------------------------------------- ledger
594
+
595
+
596
+ class Ledger:
597
+ """Append-only status history (PRD 00 section 9.1)."""
598
+
599
+ USER_EVENTS = ("accepted-risk", "false-positive", "suppressed", "reopened")
600
+
601
+ def __init__(self, path: Path) -> None:
602
+ self.path = path
603
+
604
+ def events(self) -> list[dict]:
605
+ if not self.path.is_file():
606
+ return []
607
+ out = []
608
+ for line in self.path.read_text(encoding="utf-8").splitlines():
609
+ line = line.strip()
610
+ if line:
611
+ with contextlib.suppress(json.JSONDecodeError):
612
+ out.append(json.loads(line))
613
+ return out
614
+
615
+ def append(self, events: Iterable[dict]) -> None:
616
+ events = list(events)
617
+ if not events:
618
+ return
619
+ self.path.parent.mkdir(parents=True, exist_ok=True)
620
+ with self.path.open("a", encoding="utf-8") as handle:
621
+ for event in events:
622
+ handle.write(canonical_json(event) + "\n")
623
+
624
+ def history(self) -> dict[str, list[dict]]:
625
+ out: dict[str, list[dict]] = {}
626
+ for event in self.events():
627
+ out.setdefault(event.get("id", ""), []).append(event)
628
+ return out
629
+
630
+ @staticmethod
631
+ def derive(events: list[dict], now: datetime | None = None) -> dict:
632
+ """Current state of one id from its history."""
633
+ now = now or utcnow()
634
+ status = None
635
+ first_seen = last_seen = None
636
+ severity = kev = target = command = None
637
+ for event in events:
638
+ kind = event.get("event")
639
+ if kind == "seen":
640
+ first_seen = first_seen or event.get("at")
641
+ last_seen = event.get("at")
642
+ severity = event.get("severity", severity)
643
+ kev = event.get("kev", kev)
644
+ target = event.get("target", target)
645
+ command = event.get("command", command)
646
+ if status in (None, "fixed"):
647
+ status = "open"
648
+ elif kind == "fixed":
649
+ status = "fixed"
650
+ elif kind == "accepted-risk":
651
+ until = event.get("until")
652
+ status = "accepted-risk" if not until or parse_time(until) > now else "open"
653
+ elif kind in ("false-positive", "suppressed"):
654
+ status = kind
655
+ elif kind == "reopened":
656
+ status = "open"
657
+ return {"status": status, "first_seen": first_seen, "last_seen": last_seen, "severity": severity, "kev": kev,
658
+ "target": target, "command": command}
659
+
660
+ def apply_run(self, findings: list[dict], run: dict, record: bool = True) -> tuple[list[dict], list[str]]:
661
+ """Set status on each finding, append seen/fixed events, return (findings, fixed ids).
662
+
663
+ A previously open finding is marked fixed only when this run covered its
664
+ target (``run.covered``), it is absent, and no source was degraded.
665
+ """
666
+ now = utcnow()
667
+ history = self.history()
668
+ present = {f["id"] for f in findings}
669
+ new_events: list[dict] = []
670
+ out = []
671
+ for finding in findings:
672
+ prior = self.derive(history.get(finding["id"], []), now) if finding["id"] in history else None
673
+ finding = dict(finding)
674
+ stamp = iso(now)
675
+ if prior is None or prior["status"] is None:
676
+ finding["status"] = "new"
677
+ finding["first_seen"] = stamp
678
+ else:
679
+ finding["first_seen"] = prior["first_seen"] or stamp
680
+ if prior["status"] in ("accepted-risk", "false-positive", "suppressed"):
681
+ finding["status"] = prior["status"]
682
+ elif prior["status"] == "fixed":
683
+ finding["status"] = "new"
684
+ else:
685
+ finding["status"] = "open"
686
+ escalated = (prior.get("severity") in SEVERITIES and SEVERITIES.index(finding["severity"]) < SEVERITIES.index(prior["severity"])) \
687
+ or (bool((finding.get("kev") or {}).get("listed")) and not prior.get("kev"))
688
+ if escalated and finding["status"] in ("open", "accepted-risk"):
689
+ finding["status"] = "new"
690
+ finding["change"] = "escalated"
691
+ finding["last_seen"] = stamp
692
+ if finding.get("change") is None:
693
+ finding.pop("change", None)
694
+ out.append(finding)
695
+ new_events.append({"id": finding["id"], "at": stamp, "event": "seen", "run": run["id"], "command": run["command"],
696
+ "severity": finding["severity"], "kev": bool((finding.get("kev") or {}).get("listed")),
697
+ "version": (finding.get("affected") or {}).get("version"),
698
+ "target": finding_locator(finding.get("affected"), finding.get("target") or {}) or None})
699
+ fixed: list[str] = []
700
+ degraded = bool(run.get("degraded"))
701
+ covered = set(run.get("covered") or [])
702
+ if not degraded and covered:
703
+ for fid, events in history.items():
704
+ if fid in present or not fid:
705
+ continue
706
+ state = self.derive(events, now)
707
+ if state["status"] != "open":
708
+ continue
709
+ if state.get("command") not in (None, run["command"]):
710
+ continue
711
+ if (state.get("target") or "") in covered or "*" in covered:
712
+ fixed.append(fid)
713
+ new_events.append({"id": fid, "at": iso(now), "event": "fixed", "run": run["id"]})
714
+ if record:
715
+ self.append(new_events)
716
+ return out, fixed
717
+
718
+ def mark(self, fid: str, event: str, reason: str | None, until: str | None = None, by: str = "user") -> dict:
719
+ if event not in self.USER_EVENTS:
720
+ raise UsageError(f"unknown ledger event {event}")
721
+ if event in ("accepted-risk", "false-positive") and not (reason or "").strip():
722
+ raise UsageError("--reason is required")
723
+ if fid not in self.history():
724
+ raise UsageError(f"unknown finding id {fid}")
725
+ entry = {"id": fid, "at": iso(), "event": event, "by": by}
726
+ if reason:
727
+ entry["reason"] = clean_excerpt(reason, 300)
728
+ if until:
729
+ entry["until"] = until
730
+ self.append([entry])
731
+ return entry
732
+
733
+ def compact(self, keep_days: int = 365) -> dict:
734
+ """Keep the last event per id plus everything newer than keep_days."""
735
+ events = self.events()
736
+ cutoff = utcnow() - timedelta(days=keep_days)
737
+ last: dict[str, dict] = {}
738
+ for event in events:
739
+ last[event.get("id", "")] = event
740
+ kept = [e for e in events if e is last.get(e.get("id", "")) or parse_time(e.get("at", iso())) >= cutoff]
741
+ if len(kept) != len(events):
742
+ self.path.write_text("".join(canonical_json(e) + "\n" for e in kept), encoding="utf-8")
743
+ return {"before": len(events), "after": len(kept)}
744
+
745
+
746
+ # --------------------------------------------------------------------------- egress and feeds
747
+
748
+ USER_AGENT = f"maggie-security/{maggie_version()} (+https://cmo.so)"
749
+
750
+ # Every network source a command may use. Commands declare a list of these
751
+ # ids; --explain-egress prints the hosts and --offline refuses them all.
752
+ SOURCES: dict[str, dict] = {
753
+ "cvelistv5-delta": {"hosts": ["raw.githubusercontent.com"], "ttl": "1h",
754
+ "url": "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/deltaLog.json"},
755
+ "cve-record": {"hosts": ["cveawg.mitre.org"], "ttl": "7d", "url": "https://cveawg.mitre.org/api/cve/{id}"},
756
+ "osv-modified": {"hosts": ["storage.googleapis.com"], "ttl": "1h",
757
+ "url": "https://storage.googleapis.com/osv-vulnerabilities/{ecosystem}/modified_id.csv"},
758
+ "osv-zip": {"hosts": ["storage.googleapis.com"], "ttl": "24h", "url": "https://storage.googleapis.com/osv-vulnerabilities/{ecosystem}/all.zip"},
759
+ "osv-api": {"hosts": ["api.osv.dev"], "ttl": "24h", "url": "https://api.osv.dev/v1/querybatch"},
760
+ "kev": {"hosts": ["raw.githubusercontent.com"], "ttl": "6h",
761
+ "url": "https://raw.githubusercontent.com/cisagov/kev-data/main/known_exploited_vulnerabilities.json"},
762
+ "eol": {"hosts": ["endoflife.date"], "ttl": "24h", "url": "https://endoflife.date/api/v1/products/{product}/"},
763
+ "node-core": {"hosts": ["raw.githubusercontent.com"], "ttl": "6h",
764
+ "url": "https://raw.githubusercontent.com/nodejs/security-wg/main/vuln/core/index.json"},
765
+ "wpvulnerability": {"hosts": ["www.wpvulnerability.net"], "ttl": "12h", "url": "https://www.wpvulnerability.net/{kind}/{slug}/"},
766
+ "nuclei-cves": {"hosts": ["raw.githubusercontent.com"], "ttl": "24h",
767
+ "url": "https://raw.githubusercontent.com/projectdiscovery/nuclei-templates/main/cves.json"},
768
+ "ossf-malicious": {"hosts": ["api.osv.dev"], "ttl": "24h", "url": "https://api.osv.dev/v1/querybatch"},
769
+ "retire-jsrepo": {"hosts": ["raw.githubusercontent.com"], "ttl": "24h",
770
+ "url": "https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository-v4.json"},
771
+ "github-releases": {"hosts": ["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"], "ttl": "0s",
772
+ "url": "https://github.com/"},
773
+ "target-site": {"hosts": ["<verified domain>"], "ttl": "0s", "url": ""},
774
+ "dns": {"hosts": ["<system resolver>"], "ttl": "0s", "url": ""},
775
+ }
776
+
777
+
778
+ class EgressGuard:
779
+ """Checks every request against the command's declared sources."""
780
+
781
+ def __init__(self, declared: Iterable[str], offline: bool = False, extra_hosts: Iterable[str] = ()) -> None:
782
+ self.declared = list(declared)
783
+ self.offline = offline
784
+ self.extra_hosts = set(extra_hosts)
785
+
786
+ def hosts(self) -> list[str]:
787
+ out: list[str] = []
788
+ for source in self.declared:
789
+ for host in SOURCES.get(source, {}).get("hosts", []):
790
+ if host not in out:
791
+ out.append(host)
792
+ return out + sorted(self.extra_hosts - set(out))
793
+
794
+ def check(self, source: str, url: str) -> str:
795
+ if url.startswith("file://") and mirror_base() and url.startswith(mirror_base()):
796
+ return url # a local mirror directory is not network egress
797
+ if self.offline:
798
+ raise DegradedError(f"--offline: not contacting {source}")
799
+ if source not in self.declared:
800
+ raise PolicyError(f"egress to undeclared source {source}")
801
+ mirror = mirror_base()
802
+ host = urllib.parse.urlsplit(url).hostname or ""
803
+ if mirror and url.startswith(mirror):
804
+ return url
805
+ allowed = set(SOURCES.get(source, {}).get("hosts", [])) | self.extra_hosts
806
+ if host not in allowed and not any(h.startswith("<") for h in allowed):
807
+ raise PolicyError(f"egress to undeclared host {host} for {source}")
808
+ return url
809
+
810
+ def explain(self) -> dict:
811
+ return {"sources": [{"id": s, "hosts": SOURCES.get(s, {}).get("hosts", [])} for s in self.declared],
812
+ "hosts": self.hosts(), "offline_ok": False}
813
+
814
+
815
+ def mirror_base() -> str | None:
816
+ value = os.environ.get("MAGGIE_SECURITY_MIRROR", "").strip().rstrip("/")
817
+ return value or None
818
+
819
+
820
+ def mirrored(url: str) -> str:
821
+ base = mirror_base()
822
+ if not base:
823
+ return url
824
+ parts = urllib.parse.urlsplit(url)
825
+ return f"{base}/{parts.hostname}{parts.path}" + (f"?{parts.query}" if parts.query else "")
826
+
827
+
828
+ @contextlib.contextmanager
829
+ def socket_guard():
830
+ """Make any socket connection raise; used by --offline and the tests."""
831
+ original_connect = socket.socket.connect
832
+ original_create = socket.create_connection
833
+
834
+ def refuse(*_args, **_kwargs):
835
+ raise OSError("network disabled by maggie security --offline")
836
+
837
+ socket.socket.connect = refuse # type: ignore[assignment]
838
+ socket.create_connection = refuse # type: ignore[assignment]
839
+ try:
840
+ yield
841
+ finally:
842
+ socket.socket.connect = original_connect # type: ignore[assignment]
843
+ socket.create_connection = original_create # type: ignore[assignment]
844
+
845
+
846
+ def http_request(url: str, *, method: str = "GET", data: bytes | None = None, headers: dict | None = None,
847
+ timeout: float = 60.0, retries: int = 2) -> tuple[int, dict, bytes]:
848
+ """Plain HTTP with retries and backoff; returns (status, headers, body). 304 is not an error."""
849
+ if url.startswith("file://"):
850
+ local = Path(urllib.parse.unquote(urllib.parse.urlsplit(url).path))
851
+ if method != "GET":
852
+ return 404, {}, b""
853
+ return (200, {}, local.read_bytes()) if local.is_file() else (404, {}, b"")
854
+ hdrs = {"User-Agent": USER_AGENT, "Accept": "application/json, text/plain, */*"}
855
+ hdrs.update(headers or {})
856
+ last: Exception | None = None
857
+ for attempt in range(retries + 1):
858
+ request = urllib.request.Request(url, data=data, headers=hdrs, method=method)
859
+ try:
860
+ with urllib.request.urlopen(request, timeout=timeout) as response:
861
+ return response.status, {k.lower(): v for k, v in response.headers.items()}, response.read()
862
+ except urllib.error.HTTPError as error:
863
+ if error.code == 304:
864
+ return 304, {k.lower(): v for k, v in error.headers.items()}, b""
865
+ if error.code in (404, 410):
866
+ return error.code, {k.lower(): v for k, v in (error.headers or {}).items()}, b""
867
+ last = error
868
+ if error.code < 500 and error.code != 429:
869
+ break
870
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
871
+ last = error
872
+ if attempt < retries:
873
+ time.sleep(min(2 ** attempt, 8) * float(os.environ.get("MAGGIE_SECURITY_BACKOFF", "1")))
874
+ raise DegradedError(f"{urllib.parse.urlsplit(url).hostname}: {last}")
875
+
876
+
877
+ class FeedCache:
878
+ """Cached feed payloads under .maggie/security/feeds/<source>/ (PRD 00 section 10)."""
879
+
880
+ def __init__(self, state: State, guard: EgressGuard) -> None:
881
+ self.state = state
882
+ self.guard = guard
883
+ self.report: list[dict] = []
884
+ self._lock = threading.RLock() # commands fetch in parallel; state.json is read-modify-write
885
+
886
+ def _dir(self, source: str) -> Path:
887
+ return self.state.path("feeds", source)
888
+
889
+ def _key(self, url: str) -> str:
890
+ return sha256_bytes(url.encode())[:16]
891
+
892
+ def meta(self, source: str) -> dict:
893
+ path = self._dir(source) / "state.json"
894
+ return read_json(path) if path.is_file() else {"entries": {}}
895
+
896
+ def save_meta(self, source: str, meta: dict) -> None:
897
+ with self._lock:
898
+ write_json(self._dir(source) / "state.json", meta)
899
+
900
+ def _update_entry(self, source: str, key: str, entry: dict) -> None:
901
+ with self._lock:
902
+ meta = self.meta(source)
903
+ meta["entries"][key] = entry
904
+ self.save_meta(source, meta)
905
+
906
+ def cursor(self, source: str) -> str | None:
907
+ return self.meta(source).get("cursor")
908
+
909
+ def set_cursor(self, source: str, cursor: str | None) -> None:
910
+ with self._lock:
911
+ meta = self.meta(source)
912
+ meta["cursor"] = cursor
913
+ self.save_meta(source, meta)
914
+
915
+ def _note(self, source: str, status: str, fetched_at: str | None = None, error: str | None = None, version: str | None = None) -> None:
916
+ with self._lock:
917
+ self._note_locked(source, status, fetched_at, error, version)
918
+
919
+ def _note_locked(self, source: str, status: str, fetched_at: str | None, error: str | None, version: str | None) -> None:
920
+ for item in self.report:
921
+ if item["id"] == source:
922
+ if status == "degraded" or item["status"] != "degraded":
923
+ item.update(status=status, fetched_at=fetched_at or item.get("fetched_at"), error=error or item.get("error"))
924
+ return
925
+ self.report.append({"id": source, "status": status, "fetched_at": fetched_at, "error": error, "version": version,
926
+ "cursor": self.cursor(source)})
927
+
928
+ def get(self, source: str, url: str, *, ttl: str | None = None, method: str = "GET", body: bytes | None = None,
929
+ headers: dict | None = None, key: str | None = None, allow_stale: bool = True) -> bytes | None:
930
+ """Return the payload (fresh, revalidated or cached); None when unavailable."""
931
+ ttl_delta = parse_duration(ttl or SOURCES.get(source, {}).get("ttl", "1h"))
932
+ meta = self.meta(source)
933
+ entry_key = key or self._key(url + (sha256_bytes(body) if body else ""))
934
+ entry = meta["entries"].get(entry_key)
935
+ payload_path = self._dir(source) / f"{entry_key}.bin"
936
+ now = utcnow()
937
+ if entry and payload_path.is_file() and ttl_delta.total_seconds() > 0 \
938
+ and now - parse_time(entry["fetched_at"]) < ttl_delta:
939
+ self._note(source, "cached", entry["fetched_at"])
940
+ return payload_path.read_bytes()
941
+ try:
942
+ real_url = mirrored(url)
943
+ self.guard.check(source, real_url)
944
+ hdrs = dict(headers or {})
945
+ if entry and payload_path.is_file() and method == "GET":
946
+ if entry.get("etag"):
947
+ hdrs["If-None-Match"] = entry["etag"]
948
+ if entry.get("last_modified"):
949
+ hdrs["If-Modified-Since"] = entry["last_modified"]
950
+ status, resp_headers, data = http_request(real_url, method=method, data=body, headers=hdrs)
951
+ if status == 304 and payload_path.is_file():
952
+ entry["fetched_at"] = iso(now)
953
+ self._update_entry(source, entry_key, entry)
954
+ self._note(source, "ok", iso(now))
955
+ return payload_path.read_bytes()
956
+ if status in (404, 410):
957
+ self._note(source, "ok", iso(now))
958
+ return None
959
+ payload_path.parent.mkdir(parents=True, exist_ok=True)
960
+ tmp_payload = payload_path.with_suffix(f".{threading.get_ident()}.tmp")
961
+ tmp_payload.write_bytes(data)
962
+ tmp_payload.replace(payload_path)
963
+ self._update_entry(source, entry_key, {"url": url, "fetched_at": iso(now), "etag": resp_headers.get("etag"),
964
+ "last_modified": resp_headers.get("last-modified"), "bytes": len(data)})
965
+ self._note(source, "ok", iso(now))
966
+ return data
967
+ except DegradedError as error:
968
+ if allow_stale and payload_path.is_file() and entry:
969
+ self._note(source, "degraded", entry.get("fetched_at"), f"{error} (using cache from {entry.get('fetched_at')})")
970
+ return payload_path.read_bytes()
971
+ self._note(source, "degraded", None, str(error))
972
+ return None
973
+
974
+ def get_json(self, source: str, url: str, **kwargs: Any) -> Any:
975
+ data = self.get(source, url, **kwargs)
976
+ if data is None:
977
+ return None
978
+ try:
979
+ return json.loads(data.decode("utf-8"))
980
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
981
+ self._note(source, "degraded", None, f"unparseable payload: {error}")
982
+ return None
983
+
984
+ def status(self) -> list[dict]:
985
+ out = []
986
+ root = self.state.path("feeds")
987
+ for source, spec in SOURCES.items():
988
+ if spec.get("hosts", [""])[0].startswith("<"):
989
+ continue
990
+ meta = self.meta(source) if (root / source).is_dir() else {"entries": {}}
991
+ entries = meta.get("entries", {})
992
+ newest = max((e["fetched_at"] for e in entries.values()), default=None)
993
+ ttl = parse_duration(spec["ttl"])
994
+ stale = None
995
+ if newest and ttl.total_seconds() > 0:
996
+ stale = (utcnow() - parse_time(newest)) > ttl * 3
997
+ out.append({"id": source, "entries": len(entries), "fetched_at": newest, "cursor": meta.get("cursor"),
998
+ "ttl": spec["ttl"], "stale": stale})
999
+ return out
1000
+
1001
+ def verify(self) -> list[dict]:
1002
+ problems = []
1003
+ root = self.state.path("feeds")
1004
+ if not root.is_dir():
1005
+ return problems
1006
+ for source_dir in sorted(p for p in root.iterdir() if p.is_dir()):
1007
+ meta = self.meta(source_dir.name)
1008
+ for key, entry in meta.get("entries", {}).items():
1009
+ payload = source_dir / f"{key}.bin"
1010
+ if not payload.is_file():
1011
+ problems.append({"source": source_dir.name, "key": key, "problem": "payload missing"})
1012
+ continue
1013
+ if payload.stat().st_size and payload.read_bytes()[:1] in (b"{", b"["):
1014
+ try:
1015
+ json.loads(payload.read_text(encoding="utf-8"))
1016
+ except (ValueError, UnicodeDecodeError):
1017
+ problems.append({"source": source_dir.name, "key": key, "problem": "invalid JSON"})
1018
+ for item in self.status():
1019
+ if item["stale"]:
1020
+ problems.append({"source": item["id"], "problem": f"older than 3x TTL ({item['ttl']})"})
1021
+ return problems
1022
+
1023
+
1024
+ # --------------------------------------------------------------------------- tool lock and runner
1025
+
1026
+
1027
+ def packaged_lock_path() -> Path:
1028
+ return DATA_DIR / "tools.lock.json"
1029
+
1030
+
1031
+ def load_lock(state: State | None = None) -> dict:
1032
+ """The project copy if present (written by tools install), else the packaged lock."""
1033
+ if state is not None:
1034
+ local = state.path("tools.lock.json")
1035
+ if local.is_file():
1036
+ return read_json(local)
1037
+ return read_json(packaged_lock_path())
1038
+
1039
+
1040
+ def platform_key() -> str:
1041
+ system = platform.system().lower()
1042
+ machine = platform.machine().lower()
1043
+ arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(machine, machine)
1044
+ return f"{system}-{arch}"
1045
+
1046
+
1047
+ def tools_cache_dir() -> Path:
1048
+ base = os.environ.get("MAGGIE_SECURITY_TOOLS_DIR")
1049
+ if base:
1050
+ return Path(base)
1051
+ xdg = os.environ.get("XDG_CACHE_HOME")
1052
+ return (Path(xdg) if xdg else Path.home() / ".cache") / "maggie" / "security-tools"
1053
+
1054
+
1055
+ def tool_install_path(name: str, spec: dict) -> Path:
1056
+ return tools_cache_dir() / name / spec["version"] / (spec.get("binary") or name)
1057
+
1058
+
1059
+ def resolve_tool(name: str, lock: dict) -> tuple[dict, Path | None, str]:
1060
+ """Return (spec, path, how) where how is 'pinned', 'system' or 'missing'."""
1061
+ spec = (lock.get("tools") or {}).get(name)
1062
+ if not spec:
1063
+ raise UsageError(f"tool {name} is not in the tools lock")
1064
+ pinned = tool_install_path(name, spec)
1065
+ if pinned.is_file():
1066
+ return spec, pinned, "pinned"
1067
+ if spec.get("class") == "external-only" or not spec.get("assets"):
1068
+ found = shutil.which(spec.get("binary") or name)
1069
+ if found:
1070
+ return spec, Path(found), "system"
1071
+ return spec, None, "missing"
1072
+
1073
+
1074
+ def check_flags(spec: dict, args: list[str]) -> list[str]:
1075
+ """Refuse forbidden flags (exit 4) and inject required ones."""
1076
+ forbidden = set(spec.get("forbidden_flags") or [])
1077
+ for arg in args:
1078
+ flag = arg.split("=", 1)[0]
1079
+ bare = flag.lstrip("-")
1080
+ if flag in forbidden or bare in forbidden or f"-{bare}" in forbidden or f"--{bare}" in forbidden:
1081
+ raise PolicyError(f"flag {flag} is forbidden for {spec.get('binary') or 'this tool'} by the tools lock")
1082
+ required = [f for f in spec.get("required_flags") or [] if f not in args]
1083
+ if spec.get("flags_after_subcommand") and args and not args[0].startswith("-"):
1084
+ return [args[0]] + required + list(args[1:]) # e.g. `semgrep scan --metrics=off …`
1085
+ return required + list(args)
1086
+
1087
+
1088
+ def verify_tool(name: str, lock: dict) -> dict:
1089
+ spec, path, how = resolve_tool(name, lock)
1090
+ result = {"tool": name, "version": spec["version"], "class": spec["class"], "how": how, "path": str(path) if path else None,
1091
+ "ok": False, "problem": None}
1092
+ if path is None:
1093
+ result["problem"] = "not installed (run: maggie security tools install --tool %s)" % name
1094
+ return result
1095
+ if how == "pinned":
1096
+ asset = (spec.get("assets") or {}).get(platform_key()) or {}
1097
+ expected = asset.get("binary_sha256") or asset.get("sha256")
1098
+ marker = path.parent / ".sha256"
1099
+ recorded = marker.read_text(encoding="utf-8").strip() if marker.is_file() else None
1100
+ actual = sha256_file(path)
1101
+ if recorded and recorded != actual:
1102
+ result["problem"] = f"sha256 mismatch: binary changed since install ({actual[:12]}…)"
1103
+ return result
1104
+ if not recorded and expected and expected != "UNPINNED" and expected != actual:
1105
+ result["problem"] = f"sha256 mismatch: expected {expected[:12]}…, got {actual[:12]}…"
1106
+ return result
1107
+ result["ok"] = True
1108
+ return result
1109
+
1110
+
1111
+ def run_tool(name: str, args: list[str], lock: dict, *, timeout: int = 900, cwd: Path | None = None,
1112
+ stdin: bytes | None = None, env: dict | None = None) -> subprocess.CompletedProcess:
1113
+ """Run a pinned external tool after checksum and flag checks. Never via a shell."""
1114
+ spec, path, how = resolve_tool(name, lock)
1115
+ if path is None:
1116
+ if spec.get("class") == "external-only" or not spec.get("assets"):
1117
+ raise ToolError(f"{name} is not installed. {spec.get('notes') or 'Install it with your package manager'} "
1118
+ f"(version {spec['version']}; Maggie runs it as a separate program)")
1119
+ raise ToolError(f"{name} is not installed; run: maggie security tools install --tool {name}")
1120
+ verdict = verify_tool(name, lock)
1121
+ if not verdict["ok"]:
1122
+ raise ToolError(f"{name}: {verdict['problem']}")
1123
+ argv = [str(path)] + check_flags(spec, args)
1124
+ full_env = {**os.environ, **(spec.get("env") or {}), **(env or {})}
1125
+ try:
1126
+ return subprocess.run(argv, cwd=str(cwd) if cwd else None, input=stdin, capture_output=True, timeout=timeout, env=full_env, check=False)
1127
+ except subprocess.TimeoutExpired as error:
1128
+ raise DegradedError(f"{name} timed out after {timeout}s") from error
1129
+
1130
+
1131
+ def install_tool(name: str, lock: dict, guard: EgressGuard) -> dict:
1132
+ spec = (lock.get("tools") or {}).get(name)
1133
+ if not spec:
1134
+ raise UsageError(f"tool {name} is not in the tools lock")
1135
+ if spec.get("class") == "reference-only":
1136
+ raise PolicyError(f"{name} is reference-only: Maggie does not run it")
1137
+ asset = (spec.get("assets") or {}).get(platform_key())
1138
+ if not asset:
1139
+ if spec.get("class") == "external-only" or not spec.get("assets"):
1140
+ found = shutil.which(spec.get("binary") or name)
1141
+ return {"tool": name, "installed": bool(found), "how": "system" if found else "missing",
1142
+ "note": spec.get("notes") or "install it with your OS package manager; Maggie calls it as a separate process"}
1143
+ raise ToolError(f"{name} has no pinned asset for {platform_key()}")
1144
+ if asset["sha256"] == "UNPINNED":
1145
+ raise ToolError(f"{name} {spec['version']} has no pinned sha256 for {platform_key()} yet; refusing to install an unverified binary")
1146
+ url = mirrored(asset["url"])
1147
+ guard.check("github-releases", url)
1148
+ status, _headers, data = http_request(url, timeout=300)
1149
+ if status != 200:
1150
+ raise DegradedError(f"download failed: HTTP {status}")
1151
+ actual = sha256_bytes(data)
1152
+ if actual != asset["sha256"]:
1153
+ raise ToolError(f"{name}: download sha256 {actual[:12]}… does not match the lock {asset['sha256'][:12]}…")
1154
+ target = tool_install_path(name, spec)
1155
+ target.parent.mkdir(parents=True, exist_ok=True)
1156
+ archive = asset.get("archive", "none")
1157
+ member = asset.get("member") or spec.get("binary") or name
1158
+ if archive == "none":
1159
+ target.write_bytes(data)
1160
+ elif archive == "tar.gz":
1161
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
1162
+ match = next((m for m in tar.getmembers() if m.isfile() and (m.name == member or m.name.endswith("/" + member))), None)
1163
+ if match is None:
1164
+ raise ToolError(f"{member} not found in archive")
1165
+ extracted = tar.extractfile(match)
1166
+ target.write_bytes(extracted.read() if extracted else b"")
1167
+ elif archive == "zip":
1168
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
1169
+ match = next((n for n in zf.namelist() if n == member or n.endswith("/" + member)), None)
1170
+ if match is None:
1171
+ raise ToolError(f"{member} not found in archive")
1172
+ target.write_bytes(zf.read(match))
1173
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
1174
+ (target.parent / ".sha256").write_text(sha256_file(target) + "\n", encoding="utf-8")
1175
+ return {"tool": name, "installed": True, "how": "pinned", "path": str(target), "version": spec["version"]}
1176
+
1177
+
1178
+ # --------------------------------------------------------------------------- output
1179
+
1180
+
1181
+ def exit_code_for(findings_doc: dict, fail_on: str | None) -> int:
1182
+ if fail_on in (None, "never", "none"):
1183
+ threshold = None
1184
+ elif fail_on in SEVERITIES:
1185
+ threshold = SEVERITIES.index(fail_on)
1186
+ else:
1187
+ raise UsageError(f"--fail-on must be one of {', '.join(SEVERITIES)} or never")
1188
+ if threshold is not None:
1189
+ for finding in findings_doc.get("findings", []):
1190
+ if finding.get("status") in ("new", "open") and SEVERITIES.index(finding["severity"]) <= threshold:
1191
+ return EXIT_FINDINGS
1192
+ if findings_doc.get("run", {}).get("refused"):
1193
+ return EXIT_POLICY
1194
+ if findings_doc.get("run", {}).get("degraded"):
1195
+ return EXIT_DEGRADED
1196
+ return EXIT_OK
1197
+
1198
+
1199
+ SARIF_LEVEL = {"critical": "error", "high": "error", "medium": "warning", "low": "note", "info": "note"}
1200
+
1201
+
1202
+ def to_sarif(doc: dict) -> dict:
1203
+ rules: dict[str, dict] = {}
1204
+ results = []
1205
+ for finding in doc.get("findings", []):
1206
+ rule_id = finding["source"].get("rule_id") or finding["id"]
1207
+ rules.setdefault(rule_id, {"id": rule_id, "shortDescription": {"text": finding["title"][:200]},
1208
+ "properties": {"security-severity": str((finding.get("cvss") or {}).get("score") or
1209
+ {"critical": 9.5, "high": 8.0, "medium": 5.5, "low": 3.0, "info": 0.0}[finding["severity"]])}})
1210
+ location = {}
1211
+ evidence = (finding.get("evidence") or [{}])[0]
1212
+ path = evidence.get("path") or finding["target"].get("path")
1213
+ if path:
1214
+ location = {"physicalLocation": {"artifactLocation": {"uri": path}}}
1215
+ if evidence.get("line"):
1216
+ location["physicalLocation"]["region"] = {"startLine": int(evidence["line"])}
1217
+ result = {"ruleId": rule_id, "level": SARIF_LEVEL[finding["severity"]],
1218
+ "message": {"text": f"{finding['title']} [{finding['priority']}, {finding['status']}]"},
1219
+ "partialFingerprints": {"maggieFinding": finding["id"]}}
1220
+ if location:
1221
+ result["locations"] = [location]
1222
+ results.append(result)
1223
+ return {"version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1224
+ "runs": [{"tool": {"driver": {"name": "maggie-security", "version": doc["run"].get("maggie_version", ""),
1225
+ "informationUri": "https://cmo.so", "rules": list(rules.values())}},
1226
+ "results": results}]}
1227
+
1228
+
1229
+ PRIORITY_FOOTER = ("Priority: p0 = exploited in the wild (KEV) on an internet-facing component, a confirmed secret in a public file, "
1230
+ "or a compromise indicator; p1 = critical, or high with EPSS >= 0.1, or an internet-facing runtime past end of life; "
1231
+ "p2 = high, or medium with a fix on a direct dependency, or EOL within 30 days; p3 = medium/low, EOL within 90 days, "
1232
+ "hardening; p4 = info, unconfirmed or dev-only.")
1233
+
1234
+
1235
+ def to_markdown(doc: dict, title: str | None = None) -> str:
1236
+ run = doc["run"]
1237
+ lines = [f"# {title or 'Maggie security: ' + run['command']}", "",
1238
+ f"Site: `{run.get('site') or '-'}` · run `{run['id']}` · {run['finished_at']}", ""]
1239
+ summary = doc["summary"]
1240
+ lines.append(f"**{summary['new']} new**, {summary['open']} open, {summary['fixed']} fixed. "
1241
+ + ", ".join(f"{k}: {v}" for k, v in summary["by_severity"].items() if v))
1242
+ if run.get("degraded"):
1243
+ lines += ["", f"> Incomplete: {', '.join(run['degraded'])} could not be checked. Nothing was marked fixed."]
1244
+ if run.get("refused"):
1245
+ lines += ["", f"> Refused by policy: {'; '.join(run['refused'])}"]
1246
+ lines.append("")
1247
+ if not doc["findings"]:
1248
+ lines += ["No findings.", ""]
1249
+ for finding in doc["findings"]:
1250
+ marker = " (new)" if finding["status"] == "new" else ""
1251
+ lines.append(f"## [{finding['priority']}] {finding['title']}{marker}")
1252
+ lines.append("")
1253
+ facts = [f"severity {finding['severity']}", f"confidence {finding['confidence']}"]
1254
+ if (finding.get("kev") or {}).get("listed"):
1255
+ facts.append("**actively exploited (CISA KEV)**")
1256
+ if finding.get("epss"):
1257
+ facts.append(f"EPSS {finding['epss'].get('score')}")
1258
+ if finding.get("aliases"):
1259
+ facts.append(", ".join(finding["aliases"][:4]))
1260
+ lines.append("- " + " · ".join(facts))
1261
+ affected = finding.get("affected") or {}
1262
+ if affected:
1263
+ lines.append(f"- Affects `{affected.get('name')}` {affected.get('version') or ''}".rstrip())
1264
+ target = {k: v for k, v in (finding.get("target") or {}).items() if v and k != "site"}
1265
+ if target:
1266
+ lines.append("- Where: " + ", ".join(f"{k} `{v}`" for k, v in target.items()))
1267
+ signal = finding.get("signal")
1268
+ if signal:
1269
+ label = "attack attempts (not a compromise)" if signal.get("type") == "attempt" else "possible compromise indicator"
1270
+ lines.append(f"- Signal: {signal.get('count')} {label} in {signal.get('window')}")
1271
+ remediation = finding.get("remediation") or {}
1272
+ if remediation.get("summary"):
1273
+ lines.append(f"- Fix: {remediation['summary']}")
1274
+ for step in remediation.get("steps") or []:
1275
+ lines.append(f" - {step}")
1276
+ if finding.get("asvs_ids"):
1277
+ lines.append(f"- ASVS: {', '.join(finding['asvs_ids'])}")
1278
+ lines.append("")
1279
+ if doc["clipped"]["findings"]:
1280
+ lines += [f"_Showing {len(doc['findings'])} of {doc['clipped']['total']} findings._", ""]
1281
+ lines += ["---", "", PRIORITY_FOOTER, ""]
1282
+ return "\n".join(lines)
1283
+
1284
+
1285
+ def to_text(doc: dict) -> str:
1286
+ run = doc["run"]
1287
+ summary = doc["summary"]
1288
+ out = [f"maggie security {run['command']}: {summary['new']} new, {summary['open']} open, {summary['fixed']} fixed"
1289
+ + (f" (degraded: {', '.join(run['degraded'])})" if run.get("degraded") else "")]
1290
+ for finding in doc["findings"]:
1291
+ out.append(f" {finding['priority']} {finding['severity']:<8} {finding['status']:<14} {finding['title']}")
1292
+ if doc["clipped"]["findings"]:
1293
+ out.append(f" … {doc['clipped']['total'] - len(doc['findings'])} more (raise --max-items)")
1294
+ return "\n".join(out)
1295
+
1296
+
1297
+ def render(doc: dict, fmt: str) -> str:
1298
+ if fmt == "json":
1299
+ return json.dumps(doc, indent=2, ensure_ascii=False)
1300
+ if fmt == "sarif":
1301
+ return json.dumps(to_sarif(doc), indent=2, ensure_ascii=False)
1302
+ if fmt == "md":
1303
+ return to_markdown(doc)
1304
+ return to_text(doc)
1305
+
1306
+
1307
+ def common_parent() -> argparse.ArgumentParser:
1308
+ parent = argparse.ArgumentParser(add_help=False)
1309
+ parent.add_argument("--project", type=Path, default=Path("."), help="site project root (state in PATH/.maggie/security)")
1310
+ parent.add_argument("--site", help="site id recorded on findings")
1311
+ parent.add_argument("--format", choices=["json", "sarif", "md", "text"], help="output format (default: text on a TTY, else json)")
1312
+ parent.add_argument("--output", type=Path, help="also write the findings set to this file")
1313
+ parent.add_argument("--fail-on", help="exit 1 when a new/open finding is at or above this severity (default: high with --ci, else never)")
1314
+ parent.add_argument("--ci", action="store_true", help="non-interactive: SARIF output and --fail-on high unless set")
1315
+ parent.add_argument("--offline", action="store_true", help="no network; cache and mirror only")
1316
+ parent.add_argument("--since", help="lookback for feed/log commands, e.g. 24h, 7d")
1317
+ parent.add_argument("--max-items", type=int, default=200, help="clip the findings list")
1318
+ parent.add_argument("--no-ledger", action="store_true", help="do not read or write the ledger")
1319
+ parent.add_argument("--explain-egress", action="store_true", help="print every host this command would contact and exit")
1320
+ return parent
1321
+
1322
+
1323
+ def output_format(args: argparse.Namespace) -> str:
1324
+ if args.format:
1325
+ return args.format
1326
+ if getattr(args, "ci", False):
1327
+ return "sarif"
1328
+ return "text" if sys.stdout.isatty() else "json"
1329
+
1330
+
1331
+ def fail_threshold(args: argparse.Namespace) -> str | None:
1332
+ if args.fail_on:
1333
+ return args.fail_on
1334
+ return "high" if getattr(args, "ci", False) else None
1335
+
1336
+
1337
+ @dataclass
1338
+ class Context:
1339
+ """What a command needs: parsed args, state, guard, cache, lock."""
1340
+
1341
+ args: argparse.Namespace
1342
+ command: str
1343
+ sources: list[str]
1344
+
1345
+ def __post_init__(self) -> None:
1346
+ self.project = Path(self.args.project).resolve()
1347
+ self.state = State(self.project)
1348
+ self.site = site_id(self.project, getattr(self.args, "site", None))
1349
+ self.guard = EgressGuard(self.sources, offline=getattr(self.args, "offline", False))
1350
+ self.cache = FeedCache(self.state, self.guard)
1351
+ self.run = new_run(self.command, self.site, {})
1352
+ self._lock: dict | None = None
1353
+
1354
+ @property
1355
+ def lock(self) -> dict:
1356
+ if self._lock is None:
1357
+ self._lock = load_lock(self.state)
1358
+ return self._lock
1359
+
1360
+ def degrade(self, source: str, error: str) -> None:
1361
+ if source not in self.run["degraded"]:
1362
+ self.run["degraded"].append(source)
1363
+ if not any(s["id"] == source for s in self.run["sources"]):
1364
+ self.run["sources"].append({"id": source, "status": "degraded", "error": error})
1365
+
1366
+ def refuse(self, reason: str) -> None:
1367
+ self.run["refused"].append(reason)
1368
+
1369
+ def finish(self, findings: list[dict], covered: Iterable[str] = ()) -> dict:
1370
+ """Merge, apply the ledger, write runs/<id>.json and return the findings set."""
1371
+ for item in self.cache.report:
1372
+ existing = next((s for s in self.run["sources"] if s["id"] == item["id"]), None)
1373
+ if existing:
1374
+ existing.update({k: v for k, v in item.items() if v is not None})
1375
+ else:
1376
+ self.run["sources"].append({k: v for k, v in item.items()})
1377
+ if item["status"] == "degraded" and item["id"] not in self.run["degraded"]:
1378
+ self.run["degraded"].append(item["id"])
1379
+ self.run["covered"] = sorted(set(covered))
1380
+ merged = merge_findings(findings)
1381
+ fixed: list[str] = []
1382
+ if not getattr(self.args, "no_ledger", False):
1383
+ merged, fixed = Ledger(self.state.path("ledger.jsonl")).apply_run(merged, self.run)
1384
+ doc = findings_set(self.run, merged, max_items=getattr(self.args, "max_items", 200), fixed_ids=fixed)
1385
+ if not getattr(self.args, "no_ledger", False):
1386
+ write_json(self.state.path("runs", f"{self.run['id'].replace(':', '')}.json"), doc)
1387
+ return doc
1388
+
1389
+
1390
+ def emit(ctx: Context, doc: dict) -> int:
1391
+ fmt = output_format(ctx.args)
1392
+ text = render(doc, fmt)
1393
+ print(text)
1394
+ if getattr(ctx.args, "output", None):
1395
+ out: Path = ctx.args.output
1396
+ out.parent.mkdir(parents=True, exist_ok=True)
1397
+ if out.suffix == ".md":
1398
+ out.write_text(to_markdown(doc), encoding="utf-8")
1399
+ elif out.suffix == ".sarif":
1400
+ out.write_text(json.dumps(to_sarif(doc), indent=2) + "\n", encoding="utf-8")
1401
+ else:
1402
+ out.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
1403
+ return exit_code_for(doc, fail_threshold(ctx.args))
1404
+
1405
+
1406
+ def explain_egress(sources: list[str], command: str) -> int:
1407
+ guard = EgressGuard(sources)
1408
+ print(json.dumps({"command": command, **guard.explain(),
1409
+ "never_sent": ["lockfile contents beyond package name+version to OSV", "source code", "logs", "host evidence",
1410
+ "secrets", "findings"]}, indent=2))
1411
+ return EXIT_OK
1412
+
1413
+
1414
+ # --------------------------------------------------------------------------- commands and site detection
1415
+
1416
+
1417
+ @dataclass
1418
+ class Command:
1419
+ """One ``maggie security <name>`` command contributed by an area module."""
1420
+
1421
+ name: str
1422
+ help: str
1423
+ sources: list[str]
1424
+ configure: Callable[[argparse.ArgumentParser], None]
1425
+ run: Callable[["Context"], Any]
1426
+ findings: bool = True # False: the command prints its own JSON (doctor, tools, feeds…)
1427
+ routine_safe: bool = True
1428
+
1429
+
1430
+ def detect_site_type(project: Path) -> str:
1431
+ """Best guess from files on disk: wordpress, astro, workers, node-app, php-app, static, unknown."""
1432
+ if (project / "wp-config.php").is_file() or (project / "wp-content").is_dir() or (project / "wp-includes").is_dir():
1433
+ return "wordpress"
1434
+ package = project / "package.json"
1435
+ deps: dict = {}
1436
+ if package.is_file():
1437
+ with contextlib.suppress(UsageError, AttributeError):
1438
+ data = read_json(package)
1439
+ deps = {**(data.get("dependencies") or {}), **(data.get("devDependencies") or {})}
1440
+ if any((project / name).is_file() for name in ("wrangler.toml", "wrangler.json", "wrangler.jsonc")) and not (project / "Dockerfile").is_file():
1441
+ return "workers"
1442
+ if "astro" in deps:
1443
+ return "astro"
1444
+ if package.is_file():
1445
+ return "node-app"
1446
+ if (project / "composer.json").is_file() or any(project.glob("*.php")):
1447
+ return "php-app"
1448
+ if any((project / n).is_file() for n in ("pyproject.toml", "requirements.txt", "Pipfile", "poetry.lock", "uv.lock", "setup.py", "manage.py")):
1449
+ return "python-app"
1450
+ if any((project / n).is_file() for n in ("pom.xml", "build.gradle", "build.gradle.kts", "gradle.lockfile")):
1451
+ return "java-app"
1452
+ if (project / "go.mod").is_file():
1453
+ return "go-app"
1454
+ if (project / "Gemfile").is_file() or (project / "Gemfile.lock").is_file():
1455
+ return "ruby-app"
1456
+ if any(project.glob("*.csproj")) or any(project.glob("*.sln")):
1457
+ return "dotnet-app"
1458
+ if (project / "index.html").is_file() or (project / "dist").is_dir() or (project / "public").is_dir():
1459
+ return "static"
1460
+ return "unknown"
1461
+
1462
+
1463
+ # Which command families make sense per site type (doctor prints this; PRD 12 site-type matrix).
1464
+ APPLICABLE = {
1465
+ "wordpress": ["inventory", "vuln-watch", "wp-check", "eol-watch", "osv-query", "secrets", "headers", "tls", "web-scan",
1466
+ "logs-traffic", "logs-sigma", "host-quick", "host-audit", "host-nginx-config", "protect-plan", "report"],
1467
+ "node-app": ["inventory", "vuln-watch", "osv-query", "deps", "node-check", "eol-watch", "secrets", "code-scan", "headers",
1468
+ "tls", "web-scan", "logs-traffic", "host-quick", "protect-plan", "report"],
1469
+ "php-app": ["inventory", "vuln-watch", "osv-query", "deps", "eol-watch", "secrets", "code-scan", "headers", "tls", "web-scan",
1470
+ "logs-traffic", "logs-sigma", "host-quick", "host-nginx-config", "protect-plan", "report"],
1471
+ "astro": ["inventory", "vuln-watch", "osv-query", "deps", "node-check", "eol-watch", "secrets", "code-scan", "headers", "tls", "report"],
1472
+ "workers": ["inventory", "vuln-watch", "osv-query", "deps", "secrets", "code-scan", "headers", "tls", "report"],
1473
+ "python-app": ["inventory", "vuln-watch", "osv-query", "deps", "eol-watch", "secrets", "code-scan", "headers", "tls", "web-scan",
1474
+ "logs-traffic", "host-quick", "protect-plan", "report"],
1475
+ "java-app": ["inventory", "vuln-watch", "osv-query", "deps", "eol-watch", "secrets", "headers", "tls", "web-scan", "logs-traffic",
1476
+ "host-quick", "protect-plan", "report"],
1477
+ "go-app": ["inventory", "vuln-watch", "osv-query", "deps", "eol-watch", "secrets", "headers", "tls", "web-scan", "host-quick", "report"],
1478
+ "ruby-app": ["inventory", "vuln-watch", "osv-query", "deps", "eol-watch", "secrets", "headers", "tls", "web-scan", "host-quick", "report"],
1479
+ "dotnet-app": ["inventory", "vuln-watch", "osv-query", "deps", "eol-watch", "secrets", "headers", "tls", "web-scan", "host-quick", "report"],
1480
+ "static": ["inventory", "deps", "secrets", "headers", "tls", "report"],
1481
+ "unknown": ["inventory", "deps", "secrets", "report"],
1482
+ }
1483
+
1484
+
1485
+ # --------------------------------------------------------------------------- consent (PRD 00 section 13.3)
1486
+
1487
+ CONSENT_LEVELS = ["read-local", "read-public", "scan-owned", "ssh-read", "ssh-temp-write", "host-change", "third-party-verify"]
1488
+ IMPLICIT_CONSENT = {"read-local", "read-public"}
1489
+
1490
+
1491
+ def consents(state: State) -> list[dict]:
1492
+ path = state.path("consent.json")
1493
+ return (read_json(path).get("grants") or []) if path.is_file() else []
1494
+
1495
+
1496
+ def has_consent(state: State, host: str, level: str) -> bool:
1497
+ if level in IMPLICIT_CONSENT:
1498
+ return True
1499
+ now = utcnow()
1500
+ for grant in consents(state):
1501
+ if grant.get("host") == host and grant.get("level") == level and not grant.get("revoked_at"):
1502
+ if not grant.get("until") or parse_time(grant["until"]) > now:
1503
+ return True
1504
+ return False
1505
+
1506
+
1507
+ def require_consent(state: State, host: str, level: str) -> None:
1508
+ if level == "host-change":
1509
+ raise PolicyError("no security command changes a host; use protect-plan and run the plan yourself under host-lock")
1510
+ if not has_consent(state, host, level):
1511
+ raise PolicyError(f"no {level} consent for host {host}; grant it interactively with: "
1512
+ f"maggie security consent grant --host {host} --level {level}")