@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.
- package/README-zh-TW.md +7 -3
- package/README.md +42 -8
- package/bin/maggie.js +4 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
- package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
- package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
- package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
- package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
- package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
- package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
- package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
- package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
- package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
- package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-security/SKILL.md +173 -0
- package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
- package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
- package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
- package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
- package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
- package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
- package/bundled-skills/maggie-security/evals/evals.json +97 -0
- package/bundled-skills/maggie-security/references/commands.md +125 -0
- package/bundled-skills/maggie-security/references/cve-response.md +24 -0
- package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
- package/bundled-skills/maggie-security/references/findings.md +39 -0
- package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
- package/bundled-skills/maggie-security/references/site-types.md +22 -0
- package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
- package/bundled-skills/maggie-security/references/workflows.md +23 -0
- package/bundled-skills/maggie-security/registry/security-tools.registry.json +654 -0
- package/bundled-skills/maggie-security/routines.example.json +27 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
- package/bundled-tools/clis/maggie_security.py +342 -0
- package/bundled-tools/clis/maggie_security_agent.py +326 -0
- package/bundled-tools/clis/maggie_security_code.py +650 -0
- package/bundled-tools/clis/maggie_security_core.py +1512 -0
- package/bundled-tools/clis/maggie_security_deps.py +560 -0
- package/bundled-tools/clis/maggie_security_health.py +415 -0
- package/bundled-tools/clis/maggie_security_host.py +511 -0
- package/bundled-tools/clis/maggie_security_hosted.py +115 -0
- package/bundled-tools/clis/maggie_security_intel.py +1294 -0
- package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
- package/bundled-tools/clis/maggie_security_logs.py +777 -0
- package/bundled-tools/clis/maggie_security_mcp.py +484 -0
- package/bundled-tools/clis/maggie_security_mirror.py +122 -0
- package/bundled-tools/clis/maggie_security_protect.py +519 -0
- package/bundled-tools/clis/maggie_security_report.py +506 -0
- package/bundled-tools/clis/maggie_security_standards.py +214 -0
- package/bundled-tools/clis/maggie_security_versions.py +381 -0
- package/bundled-tools/clis/maggie_security_web.py +1247 -0
- package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
- package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
- package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
- package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
- package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
- package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
- package/bundled-tools/clis/security_data/tools.lock.json +358 -0
- 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}")
|