@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,1294 @@
1
+ #!/usr/bin/env python3
2
+ """Vulnerability intelligence (docs/maggie-security/01-vuln-intel-prd.md).
3
+
4
+ Commands: ``inventory``, ``vuln-watch``, ``osv-query``, ``wp-check``,
5
+ ``eol-watch``, ``node-check``, ``advisory``, ``consent``.
6
+
7
+ Coverage across stacks:
8
+
9
+ - every package ecosystem OSV knows (npm, PyPI, Maven, Go, RubyGems,
10
+ crates.io, NuGet, Packagist) and OS packages on Debian, Ubuntu, Alpine,
11
+ Rocky and Alma, through OSV ``querybatch`` (OSV evaluates the ranges; we
12
+ re-check locally to pick the fix);
13
+ - the Go toolchain through OSV ``Go/stdlib``; Node.js core through the Node
14
+ security-wg index;
15
+ - PHP, nginx, Apache, MySQL, MariaDB, Redis, Memcached, SQLite, curl and
16
+ ImageMagick versions, plus WordPress core/plugins/themes, through
17
+ WPVulnerability (endpoint shapes and fields as the upstream plugin reads
18
+ them: ``data.vulnerability[].operator``, ``closed``, ``unfixed``);
19
+ - anything else in ``watch.products`` (Java, Python, PostgreSQL, MongoDB,
20
+ Tomcat, Elasticsearch, ...) through the cvelistV5 delta with a product-name
21
+ match, and every runtime/database/server/distro through endoflife.date
22
+ (packaged snapshot when offline);
23
+ - CISA KEV and FIRST EPSS joined onto every CVE.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import concurrent.futures
30
+ import json
31
+ import re
32
+ import sys
33
+ from datetime import timedelta
34
+ from pathlib import Path
35
+
36
+ import maggie_security_core as core
37
+ import maggie_security_inventory as inventory
38
+ import maggie_security_versions as versions
39
+
40
+ OSV_API = "https://api.osv.dev/v1"
41
+ WPV = "https://www.wpvulnerability.net"
42
+ EOL_API = "https://endoflife.date/api/v1/products"
43
+ EPSS_API = "https://api.first.org/data/v1/epss"
44
+ core.SOURCES.setdefault("epss", {"hosts": ["api.first.org"], "ttl": "24h", "url": EPSS_API})
45
+ core.SOURCES["cvelistv5-zip"] = {"hosts": ["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"], "ttl": "30d",
46
+ "url": "https://github.com/CVEProject/cvelistV5/releases/download/{tag}/{asset}"}
47
+ core.SOURCES["cvelistv5-record"] = {"hosts": ["raw.githubusercontent.com"], "ttl": "7d",
48
+ "url": "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/{year}/{group}xxx/{id}.json"}
49
+
50
+ # Ecosystem names as OSV spells them.
51
+ OSV_ECOSYSTEMS = {"npm": "npm", "pypi": "PyPI", "maven": "Maven", "go": "Go", "rubygems": "RubyGems", "crates.io": "crates.io",
52
+ "nuget": "NuGet", "packagist": "Packagist", "debian": "Debian", "ubuntu": "Ubuntu", "alpine": "Alpine",
53
+ "rocky linux": "Rocky Linux", "almalinux": "AlmaLinux"}
54
+
55
+ # Runtime/service names -> WPVulnerability software endpoint.
56
+ WPV_SOFTWARE = {"php": "php", "nginx": "nginx", "apache": "apache", "mysql": "mysql", "mariadb": "mariadb", "redis": "redis",
57
+ "memcached": "memcached", "sqlite": "sqlite", "curl": "curl", "imagemagick": "imagemagick"}
58
+
59
+ # Maggie runtime name -> endoflife.date product.
60
+ EOL_PRODUCTS = {
61
+ "node": "nodejs", "python": "python", "php": "php", "ruby": "ruby", "go": "go", "java": "eclipse-temurin", "dotnet": "dotnet",
62
+ "rust": "rust", "perl": "perl", "deno": "deno", "bun": "bun", "mysql": "mysql", "mariadb": "mariadb", "postgresql": "postgresql",
63
+ "redis": "redis", "valkey": "valkey", "mongodb": "mongodb", "memcached": "memcached", "sqlite": "sqlite", "elasticsearch": "elasticsearch",
64
+ "opensearch": "opensearch", "mssqlserver": "mssqlserver", "rabbitmq": "rabbitmq", "kafka": "apache-kafka", "nginx": "nginx",
65
+ "apache": "apache-http-server", "tomcat": "tomcat", "caddy": "caddy", "haproxy": "haproxy", "traefik": "traefik", "openssl": "openssl",
66
+ "docker-engine": "docker-engine", "kubernetes": "kubernetes", "ubuntu": "ubuntu", "debian": "debian", "alpine": "alpine-linux",
67
+ "rhel": "rhel", "rocky-linux": "rocky-linux", "almalinux": "almalinux", "centos": "centos", "amazon-linux": "amazon-linux",
68
+ "fedora": "fedora", "sles": "sles", "opensuse": "opensuse", "wordpress": "wordpress", "drupal": "drupal",
69
+ }
70
+ # Framework packages that also have an EOL cycle.
71
+ EOL_PACKAGES = {("npm", "next"): "nextjs", ("npm", "nuxt"): "nuxt", ("npm", "@angular/core"): "angular", ("npm", "react"): "react",
72
+ ("npm", "vue"): "vue", ("npm", "express"): "express", ("Packagist", "laravel/framework"): "laravel",
73
+ ("Packagist", "symfony/symfony"): "symfony", ("Packagist", "symfony/http-kernel"): "symfony", ("PyPI", "django"): "django",
74
+ ("RubyGems", "rails"): "ruby-on-rails", ("Maven", "org.springframework.boot:spring-boot"): "spring-boot",
75
+ ("Maven", "org.springframework.boot:spring-boot-starter-parent"): "spring-boot",
76
+ ("Maven", "org.springframework:spring-core"): "spring-framework"}
77
+ # Runtimes with a version-aware source of their own (Node security-wg, WPVulnerability, OSV Go/stdlib).
78
+ DEDICATED_SOURCES = {"node", "go", "php", "nginx", "apache", "mysql", "mariadb", "redis", "memcached", "sqlite", "curl", "imagemagick", "wordpress"}
79
+ OS_RUNTIMES = {"ubuntu", "debian", "alpine", "rhel", "rocky-linux", "almalinux", "centos", "amazon-linux", "fedora", "sles", "opensuse"}
80
+
81
+ # CVE vendor/product strings that mean one of our product names (PRD 01 section 8.2).
82
+ PRODUCT_ALIASES = {
83
+ "nginx": ["nginx", "f5 nginx", "nginx open source"], "php": ["php", "php group"], "node": ["node.js", "nodejs", "node"],
84
+ "wordpress": ["wordpress"], "openssl": ["openssl"], "next": ["next.js", "nextjs"], "apache": ["http server", "apache http server", "httpd"],
85
+ "mysql": ["mysql", "mysql server"], "mariadb": ["mariadb", "mariadb server"], "postgresql": ["postgresql"], "redis": ["redis"],
86
+ "mongodb": ["mongodb", "mongodb server"], "tomcat": ["tomcat", "apache tomcat"], "java": ["java se", "jdk", "openjdk", "jre"],
87
+ "python": ["python", "cpython"], "ruby": ["ruby"], "go": ["go", "golang"], "elasticsearch": ["elasticsearch"], "memcached": ["memcached"],
88
+ "docker-engine": ["docker", "moby", "docker engine"], "kubernetes": ["kubernetes"], "astro": ["astro"], "dotnet": [".net", ".net core", "asp.net core"],
89
+ "sqlite": ["sqlite"], "haproxy": ["haproxy"], "caddy": ["caddy"], "traefik": ["traefik"], "rabbitmq": ["rabbitmq", "rabbitmq server"],
90
+ "openssh": ["openssh"], "valkey": ["valkey"], "opensearch": ["opensearch"], "drupal": ["drupal", "drupal core"],
91
+ }
92
+ SEVERITY_WORDS = {"critical": "critical", "high": "high", "important": "high", "moderate": "medium", "medium": "medium", "low": "low",
93
+ "none": "info", "info": "info"}
94
+
95
+
96
+ # --------------------------------------------------------------------------- shared helpers
97
+
98
+
99
+ def cvss_from_osv(record: dict) -> dict | None:
100
+ for item in record.get("severity") or []:
101
+ if item.get("type") == "CVSS_V3":
102
+ score = core.cvss3_base_score(item.get("score", ""))
103
+ if score is not None:
104
+ return {"version": item["score"].split("/")[0].split(":")[1], "score": score, "vector": item["score"]}
105
+ for affected in record.get("affected") or []:
106
+ for item in affected.get("severity") or []:
107
+ if item.get("type") == "CVSS_V3":
108
+ score = core.cvss3_base_score(item.get("score", ""))
109
+ if score is not None:
110
+ return {"version": "3.1", "score": score, "vector": item["score"]}
111
+ return None
112
+
113
+
114
+ def cve_ids(ids) -> list[str]:
115
+ return [i for i in ids if isinstance(i, str) and i.upper().startswith("CVE-")]
116
+
117
+
118
+ def primary_of(record_id: str, aliases) -> str:
119
+ cves = sorted(cve_ids([record_id, *aliases]))
120
+ return cves[0] if cves else record_id
121
+
122
+
123
+ def short(text: str | None, limit: int = 140) -> str:
124
+ text = re.sub(r"\s+", " ", (text or "").strip())
125
+ return text if len(text) <= limit else text[: limit - 1] + "…"
126
+
127
+
128
+ def target_for(comp_or_runtime: dict, site: str) -> dict:
129
+ location = comp_or_runtime.get("location")
130
+ host = comp_or_runtime.get("host")
131
+ if location and "#" in str(location):
132
+ location = location.split("#", 1)[0]
133
+ return {"path": location if location and not host else None, "host": host,
134
+ "image": comp_or_runtime.get("image")}
135
+
136
+
137
+ def internet_facing(stack: dict, host: str | None) -> bool:
138
+ if not host:
139
+ return True
140
+ return any(h.get("id") == host and h.get("public") is not False for h in stack.get("hosts") or []) or not stack.get("hosts")
141
+
142
+
143
+ def load_kev(ctx: core.Context) -> dict[str, dict]:
144
+ data = ctx.cache.get_json("kev", core.SOURCES["kev"]["url"])
145
+ if not data:
146
+ return {}
147
+ return {v["cveID"]: v for v in data.get("vulnerabilities") or [] if v.get("cveID")}
148
+
149
+
150
+ def kev_entry(kev: dict, aliases) -> dict | None:
151
+ for cve in cve_ids(aliases):
152
+ if cve in kev:
153
+ item = kev[cve]
154
+ return {"listed": True, "date_added": item.get("dateAdded"), "ransomware": (item.get("knownRansomwareCampaignUse") or "unknown").lower(),
155
+ "due_date": item.get("dueDate")}
156
+ return None
157
+
158
+
159
+ def join_epss(ctx: core.Context, findings: list[dict]) -> None:
160
+ """FIRST EPSS for every CVE alias, 100 per request, cached 24 h. No key needed."""
161
+ if "epss" not in ctx.sources or getattr(ctx.args, "no_epss", False):
162
+ return
163
+ wanted = sorted({c for f in findings for c in cve_ids(f.get("aliases") or [])})
164
+ scores: dict[str, dict] = {}
165
+ for start in range(0, len(wanted), 100):
166
+ chunk = wanted[start:start + 100]
167
+ data = ctx.cache.get_json("epss", f"{EPSS_API}?cve={','.join(chunk)}")
168
+ for row in (data or {}).get("data") or []:
169
+ try:
170
+ scores[row["cve"]] = {"score": float(row["epss"]), "percentile": float(row["percentile"]), "date": row.get("date")}
171
+ except (KeyError, ValueError):
172
+ continue
173
+ for finding in findings:
174
+ best = max((scores[c] for c in cve_ids(finding.get("aliases") or []) if c in scores), key=lambda e: e["score"], default=None)
175
+ if best:
176
+ finding["epss"] = best
177
+ finding["priority"] = core.compute_priority(finding)
178
+
179
+
180
+ def apply_kev(findings: list[dict], kev: dict, stack: dict) -> None:
181
+ for finding in findings:
182
+ entry = kev_entry(kev, finding.get("aliases") or [])
183
+ if entry:
184
+ finding["kev"] = entry
185
+ remediation = finding.setdefault("remediation", {"summary": "", "steps": [], "snippets": [], "references": [], "cheat_sheet": None}) or {}
186
+ note = f"Listed by CISA as exploited in the wild on {entry['date_added']}."
187
+ if note not in (remediation.get("steps") or []):
188
+ remediation.setdefault("steps", []).append(note)
189
+ finding["remediation"] = remediation
190
+ finding["priority"] = core.compute_priority(finding, internet_facing=internet_facing(stack, (finding.get("target") or {}).get("host")))
191
+
192
+
193
+ # --------------------------------------------------------------------------- OSV
194
+
195
+
196
+ def osv_query_batch(ctx: core.Context, queries: list[dict]) -> list[list[str]]:
197
+ """Return vulnerability ids per query; [] for each query when OSV is unavailable."""
198
+ results: list[list[str]] = [[] for _ in queries]
199
+ for start in range(0, len(queries), 1000):
200
+ chunk = queries[start:start + 1000]
201
+ body = json.dumps({"queries": chunk}, sort_keys=True).encode()
202
+ data = ctx.cache.get_json("osv-api", f"{OSV_API}/querybatch", method="POST", body=body,
203
+ headers={"Content-Type": "application/json"}, ttl=getattr(ctx.args, "osv_ttl", None) or "6h")
204
+ if data is None:
205
+ continue
206
+ for index, result in enumerate(data.get("results") or []):
207
+ results[start + index] = [v["id"] for v in result.get("vulns") or [] if v.get("id")]
208
+ # Pages: OSV returns next_page_token per query when a package has very many records.
209
+ token = result.get("next_page_token")
210
+ guard = 0
211
+ while token and guard < 10:
212
+ guard += 1
213
+ page = ctx.cache.get_json("osv-api", f"{OSV_API}/query", method="POST",
214
+ body=json.dumps({**chunk[index], "page_token": token}).encode(), headers={"Content-Type": "application/json"})
215
+ if not page:
216
+ break
217
+ results[start + index] += [v["id"] for v in page.get("vulns") or []]
218
+ token = page.get("next_page_token")
219
+ return results
220
+
221
+
222
+ def osv_record(ctx: core.Context, vuln_id: str) -> dict | None:
223
+ return ctx.cache.get_json("osv-api", f"{OSV_API}/vulns/{vuln_id}", ttl="24h")
224
+
225
+
226
+ def osv_records(ctx: core.Context, ids: set[str]) -> dict[str, dict]:
227
+ out: dict[str, dict] = {}
228
+ with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
229
+ for vid, record in zip(sorted(ids), pool.map(lambda i: osv_record(ctx, i), sorted(ids))):
230
+ if record:
231
+ out[vid] = record
232
+ return out
233
+
234
+
235
+ def osv_component_finding(ctx: core.Context, stack: dict, comp: dict, record: dict, command: str) -> dict | None:
236
+ if record.get("withdrawn"):
237
+ return None
238
+ eco = comp["ecosystem"]
239
+ entries = [a for a in record.get("affected") or []
240
+ if (a.get("package") or {}).get("name", "").lower() == comp["name"].lower()
241
+ and (a.get("package") or {}).get("ecosystem", "").split(":", 1)[0].lower() == eco.split(":", 1)[0].lower()]
242
+ hit, fixed, how = False, [], "none"
243
+ for entry in entries:
244
+ h, f, w = versions.osv_affected(comp["version"], entry, eco)
245
+ fixed += f
246
+ if h:
247
+ hit, how = True, w
248
+ # OSV said it matches; if our local evaluation cannot confirm (unparseable version, GIT-only ranges), keep it as "likely".
249
+ confidence = "confirmed" if hit else "likely"
250
+ if entries and not hit and all(r.get("type") in ("SEMVER", "ECOSYSTEM") for e in entries for r in e.get("ranges") or []) and any(e.get("ranges") for e in entries):
251
+ return None # local evaluation says not affected: a stale OSV index, do not report
252
+ aliases = [record["id"], *(record.get("aliases") or [])]
253
+ cvss = cvss_from_osv(record)
254
+ vendor = ((record.get("database_specific") or {}).get("severity") or "").lower()
255
+ severity = core.severity_from_cvss(cvss["score"]) if cvss else SEVERITY_WORDS.get(vendor)
256
+ kind = "malicious-package" if record["id"].startswith("MAL-") else "vulnerability"
257
+ if kind == "malicious-package":
258
+ severity = "critical"
259
+ recommended = versions.recommended_fix(comp["version"], fixed, eco)
260
+ fix_available = bool(recommended)
261
+ is_os = comp.get("scope") == "os"
262
+ component_type = "os" if is_os else ("js-bundle" if str(comp.get("location", "")).endswith(".js") else "library")
263
+ steps = []
264
+ if recommended:
265
+ steps.append(_upgrade_step(comp, recommended))
266
+ references = [r["url"] for r in record.get("references") or [] if r.get("type") in ("ADVISORY", "FIX", "WEB")][:4]
267
+ references.append(f"https://osv.dev/vulnerability/{record['id']}")
268
+ finding = core.make_finding(
269
+ kind=kind, command=command, primary_id=primary_of(record["id"], record.get("aliases") or []), site=stack["site"]["id"],
270
+ title=f"{comp['name']} {comp['version']}: {short(record.get('summary') or record.get('details') or record['id'], 120)}",
271
+ severity=severity or "medium", severity_source="cvss" if cvss else ("vendor" if vendor else "maggie"), cvss=cvss,
272
+ confidence=confidence, aliases=aliases, cwe_ids=[c for c in (record.get("database_specific") or {}).get("cwe_ids") or [] if re.match(r"^CWE-\d+$", c)],
273
+ asvs_ids=["V15.2.1"],
274
+ affected={"purl": comp.get("purl"), "ecosystem": eco, "name": comp["name"], "version": comp["version"], "component_type": component_type,
275
+ "direct": comp.get("direct"), "scope": comp.get("scope")},
276
+ fix={"fixed_versions": fixed, "recommended": recommended, "available": fix_available},
277
+ target=target_for(comp, stack["site"]["id"]) if not is_os else {"host": comp.get("host")},
278
+ evidence=[{"path": str(comp.get("location") or ""), "excerpt": f"{comp['name']} {comp['version']}"}],
279
+ tool=None, feed="osv", fetched_at=core.iso(), rule_id=record["id"],
280
+ remediation={"summary": f"Upgrade {comp['name']} to {recommended} or later." if recommended else
281
+ f"No fixed version of {comp['name']} is published yet; consider removing or replacing it.",
282
+ "steps": steps, "snippets": [], "references": references, "cheat_sheet": None},
283
+ internet_facing=True, untrusted={"details": short(record.get("details"), 500)} if record.get("details") else None)
284
+ finding["_how"] = how
285
+ return finding
286
+
287
+
288
+ def _upgrade_step(comp: dict, version: str) -> str:
289
+ eco = comp["ecosystem"].split(":", 1)[0].lower()
290
+ name = comp["name"]
291
+ return {
292
+ "npm": f"npm install {name}@{version} (or pnpm add / yarn add)", "pypi": f"pip install '{name}>={version}' and update your lock file",
293
+ "packagist": f"composer require {name}:^{version}", "maven": f"set {name} to {version} in pom.xml / build.gradle",
294
+ "go": f"go get {name}@v{version} && go mod tidy", "rubygems": f"bundle update {name} (needs >= {version})",
295
+ "crates.io": f"cargo update -p {name} --precise {version}", "nuget": f"dotnet add package {name} --version {version}",
296
+ "debian": f"apt-get install --only-upgrade {name} (fixed in {version})", "ubuntu": f"apt-get install --only-upgrade {name} (fixed in {version})",
297
+ "alpine": f"apk upgrade {name} (fixed in {version})",
298
+ }.get(eco, f"upgrade {name} to {version}")
299
+
300
+
301
+ def osv_queries_for(stack: dict) -> tuple[list[dict], list[dict]]:
302
+ """(queries, the component or runtime each query is for)."""
303
+ queries, owners = [], []
304
+ for comp in stack.get("components") or []:
305
+ eco_key = comp["ecosystem"].split(":", 1)[0].lower()
306
+ osv_eco = OSV_ECOSYSTEMS.get(eco_key)
307
+ if not osv_eco:
308
+ continue
309
+ ecosystem = comp["ecosystem"] if ":" in comp["ecosystem"] else osv_eco
310
+ if eco_key in ("rocky linux", "almalinux"):
311
+ ecosystem = f"{osv_eco}:{versions.cycle_of(comp['ecosystem'].split(':', 1)[1], 1)}" if ":" in comp["ecosystem"] else osv_eco
312
+ queries.append({"package": {"ecosystem": ecosystem, "name": comp["name"]}, "version": comp["version"]})
313
+ owners.append(comp)
314
+ for runtime in stack.get("runtimes") or []:
315
+ if runtime["name"] == "go" and runtime["version"].count(".") >= 1:
316
+ version = runtime["version"] if runtime["version"].count(".") == 2 else runtime["version"] + ".0"
317
+ queries.append({"package": {"ecosystem": "Go", "name": "stdlib"}, "version": version})
318
+ owners.append({"ecosystem": "Go", "name": "stdlib", "version": version, "scope": "runtime", "direct": True,
319
+ "location": runtime.get("source"), "host": runtime.get("host"), "purl": "pkg:golang/stdlib@" + version})
320
+ return queries, owners
321
+
322
+
323
+ def match_osv(ctx: core.Context, stack: dict, command: str) -> tuple[list[dict], bool]:
324
+ queries, owners = osv_queries_for(stack)
325
+ if not queries:
326
+ return [], True
327
+ import maggie_security_mirror as mirror
328
+
329
+ local = mirror.LocalOsv(ctx.state)
330
+ if getattr(ctx.args, "offline", False) and any(local.available(q["package"]["ecosystem"]) for q in queries):
331
+ return match_osv_local(ctx, stack, command, queries, owners, local)
332
+ ids_per = osv_query_batch(ctx, queries)
333
+ if any(item["id"] == "osv-api" and item["status"] == "degraded" for item in ctx.cache.report):
334
+ return [], False
335
+ records = osv_records(ctx, {i for ids in ids_per for i in ids})
336
+ findings = []
337
+ for owner, ids in zip(owners, ids_per):
338
+ for vid in ids:
339
+ record = records.get(vid)
340
+ if record:
341
+ finding = osv_component_finding(ctx, stack, owner, record, command)
342
+ if finding:
343
+ finding.pop("_how", None)
344
+ findings.append(finding)
345
+ return findings, True
346
+
347
+
348
+ def match_osv_local(ctx: core.Context, stack: dict, command: str, queries: list[dict], owners: list[dict], local) -> tuple[list[dict], bool]:
349
+ """--offline with OSV ecosystem zips in .maggie/security/db/osv (feeds mirror --to-project)."""
350
+ findings, missing = [], set()
351
+ for query, owner in zip(queries, owners):
352
+ ecosystem = query["package"]["ecosystem"]
353
+ if not local.available(ecosystem):
354
+ missing.add(ecosystem.split(":", 1)[0])
355
+ continue
356
+ for record in local.query(ecosystem, query["package"]["name"], query["version"]):
357
+ finding = osv_component_finding(ctx, stack, owner, record, command)
358
+ if finding:
359
+ finding.pop("_how", None)
360
+ finding["source"]["feed"] = "osv-local"
361
+ findings.append(finding)
362
+ ctx.run["sources"].append({"id": "osv-local", "status": "ok" if not missing else "degraded",
363
+ "error": f"no local OSV database for {', '.join(sorted(missing))}" if missing else None})
364
+ if missing:
365
+ ctx.run["degraded"].append("osv-local")
366
+ return findings, not missing
367
+
368
+
369
+ # --------------------------------------------------------------------------- runtimes: Node core, WPVulnerability software, CVE product match
370
+
371
+
372
+ def runtime_finding(stack: dict, runtime: dict, command: str, *, primary: str, aliases, title: str, severity: str | None,
373
+ cvss: dict | None, fixed: list[str], confidence: str, feed: str, references: list[str],
374
+ cwe_ids=(), unfixed: bool = False, untrusted: dict | None = None) -> dict:
375
+ name = runtime["name"]
376
+ component_type = "os" if name in OS_RUNTIMES else ("wordpress-core" if name == "wordpress" else
377
+ "service" if name in WPV_SOFTWARE or name in ("postgresql", "mongodb", "elasticsearch", "tomcat") else "runtime")
378
+ recommended = None if unfixed else versions.recommended_fix(runtime["version"], fixed)
379
+ return core.make_finding(
380
+ kind="vulnerability", command=command, primary_id=primary, site=stack["site"]["id"], title=f"{name} {runtime['version']}: {short(title, 120)}",
381
+ severity=severity or "medium", severity_source="cvss" if cvss else "vendor", cvss=cvss, confidence=confidence, aliases=aliases,
382
+ cwe_ids=[c for c in cwe_ids if re.match(r"^CWE-\d+$", str(c))], asvs_ids=["V15.2.1"],
383
+ affected={"purl": None, "ecosystem": None, "name": name, "version": runtime["version"], "component_type": component_type,
384
+ "direct": True, "scope": "runtime"},
385
+ fix={"fixed_versions": fixed, "recommended": recommended, "available": bool(recommended)},
386
+ target={"host": runtime.get("host"), "image": runtime.get("image"),
387
+ "path": None if runtime.get("host") or runtime.get("image") or runtime.get("source") in ("image", "declared", "files", "flag") else runtime.get("source")},
388
+ evidence=[{"path": runtime.get("source") or "", "excerpt": f"{name} {runtime['version']}"}],
389
+ feed=feed, fetched_at=core.iso(),
390
+ remediation={"summary": f"Upgrade {name} to {recommended} or later." if recommended else f"No fixed {name} release known for this line; plan an upgrade to a supported line.",
391
+ "steps": [f"maggie security advisory --cve {primary} --component {name}"] if primary.startswith("CVE-") else [],
392
+ "snippets": [], "references": references[:5], "cheat_sheet": None},
393
+ internet_facing=internet_facing(stack, runtime.get("host")), untrusted=untrusted)
394
+
395
+
396
+ def load_node_index(ctx: core.Context) -> dict:
397
+ return ctx.cache.get_json("node-core", core.SOURCES["node-core"]["url"]) or {}
398
+
399
+
400
+ def node_core_findings(ctx: core.Context, stack: dict, index: dict, command: str, only_version: str | None = None) -> list[dict]:
401
+ findings = []
402
+ runtimes = [r for r in stack.get("runtimes") or [] if r["name"] == "node"]
403
+ if only_version:
404
+ runtimes = [{"name": "node", "version": only_version, "source": "flag", "host": getattr(ctx.args, "host", None)}]
405
+ for runtime in runtimes:
406
+ version = runtime["version"] if runtime["version"].count(".") == 2 else None
407
+ if not version:
408
+ continue
409
+ for key, entry in index.items():
410
+ vulnerable, patched = entry.get("vulnerable") or "", entry.get("patched") or ""
411
+ if not vulnerable or not versions.satisfies(version, vulnerable) or (patched and versions.satisfies(version, patched)):
412
+ continue
413
+ fixed = []
414
+ for clause in patched.split("||"):
415
+ match = re.search(r"(\d+\.\d+\.\d+)", clause)
416
+ if match:
417
+ fixed.append(match.group(1))
418
+ cves = entry.get("cve") or []
419
+ findings.append(runtime_finding(stack, runtime, command, primary=(cves[0] if cves else f"nodejs-core-{key}"),
420
+ aliases=[f"nodejs-core-{key}", *cves], title=entry.get("description") or entry.get("overview") or key,
421
+ severity=SEVERITY_WORDS.get((entry.get("severity") or "").lower()), cvss=None, fixed=fixed,
422
+ confidence="confirmed", feed="node-core", references=[entry.get("ref")] if entry.get("ref") else []))
423
+ return findings
424
+
425
+
426
+ PHP_OPS = {"lt": "<", "<": "<", "le": "<=", "<=": "<=", "gt": ">", ">": ">", "ge": ">=", ">=": ">=", "eq": "==", "==": "==", "=": "==",
427
+ "ne": "!=", "!=": "!=", "<>": "!="}
428
+
429
+
430
+ def php_version_compare(a: str, b: str, op: str) -> bool:
431
+ c = versions.compare_generic(a, b)
432
+ op = PHP_OPS.get((op or "").strip().lower(), "")
433
+ return {"<": c < 0, "<=": c <= 0, ">": c > 0, ">=": c >= 0, "==": c == 0, "!=": c != 0}.get(op, False)
434
+
435
+
436
+ def wpv_applicable(entry: dict, version: str | None) -> bool:
437
+ """Port of wpvulnerability_is_vulnerability_applicable() (EUPL-1.2 plugin; logic re-implemented)."""
438
+ op = entry.get("operator") or {}
439
+ if not version:
440
+ return False
441
+ if op.get("min_operator") and op.get("max_operator"):
442
+ return php_version_compare(version, op.get("min_version", "0"), op["min_operator"]) and \
443
+ php_version_compare(version, op.get("max_version", "0"), op["max_operator"])
444
+ if op.get("max_operator"):
445
+ return php_version_compare(version, op.get("max_version", "0"), op["max_operator"])
446
+ if op.get("min_operator"):
447
+ return php_version_compare(version, op.get("min_version", "0"), op["min_operator"])
448
+ return False
449
+
450
+
451
+ def wpv_fetch(ctx: core.Context, kind: str, slug: str) -> dict | None:
452
+ data = ctx.cache.get_json("wpvulnerability", f"{WPV}/{kind}/{slug}/", ttl="12h")
453
+ if data is None or data.get("error"):
454
+ return None
455
+ return data.get("data") or {}
456
+
457
+
458
+ WPV_LETTERS = {"c": "critical", "h": "high", "m": "medium", "l": "low", "n": "info"}
459
+
460
+
461
+ def wpv_severity(value) -> str | None:
462
+ text = str(value or "").strip().lower()
463
+ return WPV_LETTERS.get(text) or SEVERITY_WORDS.get(text)
464
+
465
+
466
+ def wpv_entry_to_finding(stack: dict, comp: dict, entry: dict, command: str) -> dict | None:
467
+ """One WPVulnerability entry. Entries with an ``operator`` carry their own range
468
+ (software and plugin endpoints return every advisory for the product);
469
+ core entries have none because the core endpoint is already per version."""
470
+ version = comp.get("version")
471
+ operator = entry.get("operator") or {}
472
+ if (operator.get("min_operator") or operator.get("max_operator")) and not wpv_applicable(entry, version):
473
+ return None
474
+ if not operator and comp.get("kind") != "wordpress-core" and not comp.get("runtime"):
475
+ return None # no range on a plugin/theme entry: cannot tell
476
+ impact = entry.get("impact") if isinstance(entry.get("impact"), dict) else {}
477
+ cvss_info = impact.get("cvss3") or impact.get("cvss") or {}
478
+ cvss = None
479
+ try:
480
+ if cvss_info.get("score"):
481
+ cvss = {"version": str(cvss_info.get("version") or "3.1"), "score": float(cvss_info["score"]), "vector": cvss_info.get("vector") or ""}
482
+ except (TypeError, ValueError):
483
+ cvss = None
484
+ sources = [s for s in entry.get("source") or [] if isinstance(s, dict)]
485
+ cves = cve_ids([s.get("id") for s in sources] + [s.get("name") for s in sources])
486
+ others = [f"wpvuln-{str(s.get('id'))[:12]}" for s in sources if s.get("id") and not str(s.get("id")).upper().startswith("CVE-")]
487
+ fixed = [operator["max_version"]] if operator.get("max_operator") in ("lt", "<") and operator.get("max_version") else []
488
+ unfixed = str(operator.get("unfixed", entry.get("unfixed", "0"))) == "1"
489
+ title = entry.get("name") or ""
490
+ if comp.get("runtime") and re.search(r"(<=?|>=?|unfixed|\d+\.\d+)", title) and cves:
491
+ # Software entries are named by range ("PHP 7.4 <= 7.4.33 (unfixed)"); the CVE says more.
492
+ title = ", ".join(cves[:2]) + (" — no fixed release on this line; move to a supported line" if unfixed
493
+ else f" — fixed in {operator.get('max_version')}" if operator.get("max_operator") in ("lt", "<") else "")
494
+ if comp.get("kind") == "wordpress-core" or re.fullmatch(r"[\d.]+", title or ""):
495
+ title = next((s.get("name") for s in sources if s.get("name") and not str(s.get("name")).upper().startswith("CVE-")), title)
496
+ references = [s.get("link") for s in sources if s.get("link")]
497
+ primary = cves[0] if cves else (others[0] if others else f"wpvuln-{str(entry.get('uuid', 'x'))[:12]}")
498
+ cwe = [c.get("cwe") for c in impact.get("cwe") or [] if isinstance(c, dict) and c.get("cwe")]
499
+ description = next((s.get("description") for s in sources if s.get("description")), None) or entry.get("description")
500
+ untrusted = {"description": short(description, 500)} if description else None
501
+ severity = wpv_severity(cvss_info.get("severity")) or (core.severity_from_cvss(cvss["score"]) if cvss else None)
502
+ if comp.get("kind") in ("wordpress-plugin", "wordpress-theme", "wordpress-core"):
503
+ slug = comp["name"]
504
+ recommended = None if unfixed else versions.recommended_fix(version or "0", fixed)
505
+ fix_text = (f"Update {slug} to {recommended} or later in WordPress > Updates." if recommended else
506
+ f"No fixed release of {slug} is known; deactivate and replace it." if unfixed or comp["kind"] != "wordpress-core" else
507
+ "Update WordPress to the latest release in Dashboard > Updates.")
508
+ finding = core.make_finding(
509
+ kind="vulnerability", command=command, primary_id=primary, site=stack["site"]["id"], title=f"{slug} {version}: {short(title, 120)}",
510
+ severity=severity or "medium", severity_source="cvss" if cvss else "vendor", cvss=cvss, confidence="confirmed" if version else "possible",
511
+ aliases=cves + others or [primary], cwe_ids=[c for c in cwe if re.match(r"^CWE-\d+$", str(c))], asvs_ids=["V15.2.1"],
512
+ affected={"purl": f"pkg:wordpress/{slug}@{version}" if version else None, "ecosystem": "wordpress", "name": slug, "version": version,
513
+ "component_type": comp["kind"], "direct": True, "scope": "runtime"},
514
+ fix={"fixed_versions": fixed, "recommended": recommended, "available": bool(recommended)},
515
+ target={"path": comp.get("location")}, feed="wpvulnerability", fetched_at=core.iso(), untrusted=untrusted,
516
+ remediation={"summary": fix_text, "steps": [], "snippets": [], "references": references[:5], "cheat_sheet": None},
517
+ internet_facing=comp.get("active") is not False)
518
+ else:
519
+ finding = runtime_finding(stack, comp["runtime"], command, primary=primary, aliases=cves + others or [primary], title=title or primary,
520
+ severity=severity, cvss=cvss, fixed=fixed, confidence="confirmed", feed="wpvulnerability",
521
+ references=references, cwe_ids=cwe, unfixed=unfixed, untrusted=untrusted)
522
+ if impact.get("kev") is True and not finding.get("kev"):
523
+ finding["kev"] = {"listed": True}
524
+ finding["priority"] = core.compute_priority(finding)
525
+ return finding
526
+
527
+
528
+ def wordpress_findings(ctx: core.Context, stack: dict, command: str, include_inactive: bool = True, budget: int = 300) -> tuple[list[dict], bool]:
529
+ findings: list[dict] = []
530
+ ok = True
531
+ wp = stack.get("wordpress") or {}
532
+ items: list[dict] = []
533
+ if wp.get("core"):
534
+ items.append({"kind": "wordpress-core", "name": "wordpress", "version": wp["core"], "location": "wp-includes/version.php"})
535
+ for kind, bucket in (("wordpress-plugin", "plugins"), ("wordpress-theme", "themes")):
536
+ for item in wp.get(bucket) or []:
537
+ items.append({"kind": kind, "name": item["slug"], "version": item.get("version"), "active": item.get("active"),
538
+ "location": f"wp-content/{bucket}/{item['slug']}/"})
539
+ for item in items[:budget]:
540
+ api_kind = {"wordpress-core": "core", "wordpress-plugin": "plugin", "wordpress-theme": "theme"}[item["kind"]]
541
+ data = wpv_fetch(ctx, api_kind, item["version"] if api_kind == "core" else item["name"])
542
+ if data is None:
543
+ continue
544
+ if api_kind != "core" and str(data.get("closed", "0")) == "1":
545
+ findings.append(core.make_finding(
546
+ kind="exposure", command=command, primary_id=f"wp-closed-{item['name']}", site=stack["site"]["id"],
547
+ title=f"{item['name']} is closed on WordPress.org: replace it", severity="high", severity_source="vendor", confidence="confirmed",
548
+ affected={"purl": None, "ecosystem": "wordpress", "name": item["name"], "version": item.get("version"),
549
+ "component_type": item["kind"], "direct": True, "scope": "runtime"},
550
+ target={"path": item["location"]}, feed="wpvulnerability", asvs_ids=["V15.2.1"],
551
+ remediation={"summary": "Closed plugins/themes get no security fixes. Replace it with a maintained alternative.", "steps": [],
552
+ "snippets": [], "references": [f"https://wordpress.org/plugins/{item['name']}/"], "cheat_sheet": None}))
553
+ for entry in data.get("vulnerability") or []:
554
+ finding = wpv_entry_to_finding(stack, item, entry, command)
555
+ if finding:
556
+ if item.get("active") is False and not include_inactive:
557
+ finding["severity"] = "info"
558
+ findings.append(finding)
559
+ if len(items) > budget:
560
+ ok = False
561
+ ctx.degrade("wpvulnerability", f"request budget: {len(items) - budget} items left for the next run")
562
+ if any(s["id"] == "wpvulnerability" and s["status"] == "degraded" for s in ctx.cache.report):
563
+ ok = False
564
+ return findings, ok
565
+
566
+
567
+ def software_findings(ctx: core.Context, stack: dict, command: str) -> tuple[list[dict], set[str]]:
568
+ """PHP, nginx, Apache, MySQL/MariaDB, Redis, Memcached, SQLite, curl, ImageMagick through WPVulnerability."""
569
+ findings, covered = [], set()
570
+ for runtime in stack.get("runtimes") or []:
571
+ endpoint = WPV_SOFTWARE.get(runtime["name"])
572
+ if not endpoint or runtime["version"].count(".") < 2:
573
+ continue
574
+ data = wpv_fetch(ctx, endpoint, runtime["version"])
575
+ if data is None:
576
+ continue
577
+ covered.add(core.finding_locator({"name": runtime["name"], "component_type": "service"}, {"host": runtime.get("host")}))
578
+ for entry in data.get("vulnerability") or []:
579
+ finding = wpv_entry_to_finding(stack, {"runtime": runtime, "version": runtime["version"]}, entry, command)
580
+ if finding:
581
+ findings.append(finding)
582
+ return findings, covered
583
+
584
+
585
+ # CVE JSON 5 ------------------------------------------------------------------
586
+
587
+
588
+ def cve_record_url(cve: str) -> str:
589
+ year, number = cve.split("-")[1], cve.split("-")[2]
590
+ group = number[:-3] if len(number) > 3 else "0"
591
+ return f"https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/{year}/{group}xxx/{cve}.json"
592
+
593
+
594
+ def norm_product(text: str) -> str:
595
+ return re.sub(r"[^a-z0-9.+ ]+", " ", (text or "").lower()).strip()
596
+
597
+
598
+ def products_in(record: dict, watch: list[str]) -> list[tuple[str, dict]]:
599
+ """(watch product, CNA affected entry) pairs that name one of our products."""
600
+ out = []
601
+ containers = record.get("containers") or {}
602
+ affected = list((containers.get("cna") or {}).get("affected") or [])
603
+ for adp in containers.get("adp") or []:
604
+ affected += adp.get("affected") or []
605
+ for entry in affected:
606
+ product = norm_product(entry.get("product", ""))
607
+ vendor = norm_product(entry.get("vendor", ""))
608
+ package = norm_product(entry.get("packageName", ""))
609
+ for name in watch:
610
+ aliases = PRODUCT_ALIASES.get(name, [name])
611
+ if product in aliases or package in aliases or f"{vendor} {product}".strip() in aliases:
612
+ out.append((name, entry))
613
+ return out
614
+
615
+
616
+ def cve_metrics(record: dict) -> tuple[dict | None, str | None]:
617
+ containers = record.get("containers") or {}
618
+ blocks = [(containers.get("cna") or {})] + list(containers.get("adp") or [])
619
+ for block in blocks:
620
+ for metric in block.get("metrics") or []:
621
+ for key in ("cvssV3_1", "cvssV3_0"):
622
+ if key in metric:
623
+ data = metric[key]
624
+ return {"version": data.get("version", "3.1"), "score": float(data.get("baseScore", 0)), "vector": data.get("vectorString", "")}, None
625
+ if "cvssV4_0" in metric:
626
+ data = metric["cvssV4_0"]
627
+ return {"version": "4.0", "score": float(data.get("baseScore", 0)), "vector": data.get("vectorString", "")}, None
628
+ if "other" in metric:
629
+ content = (metric["other"].get("content") or {})
630
+ text = str(content.get("text") or content.get("value") or "").lower()
631
+ if text in SEVERITY_WORDS:
632
+ return None, SEVERITY_WORDS[text]
633
+ return None, None
634
+
635
+
636
+ def cve_title(record: dict) -> str:
637
+ cna = (record.get("containers") or {}).get("cna") or {}
638
+ if cna.get("title"):
639
+ return cna["title"]
640
+ for item in cna.get("descriptions") or []:
641
+ if item.get("lang", "en").startswith("en"):
642
+ return item.get("value", "")
643
+ return record.get("cveMetadata", {}).get("cveId", "")
644
+
645
+
646
+ def cve_findings_for_record(stack: dict, record: dict, command: str) -> list[dict]:
647
+ meta = record.get("cveMetadata") or {}
648
+ if meta.get("state") == "REJECTED":
649
+ return []
650
+ cve = meta.get("cveId")
651
+ if not cve:
652
+ return []
653
+ watch = (stack.get("watch") or {}).get("products") or []
654
+ runtimes = stack.get("runtimes") or []
655
+ findings = []
656
+ cvss, vendor_sev = cve_metrics(record)
657
+ cna = (record.get("containers") or {}).get("cna") or {}
658
+ cwes = [d.get("cweId") for p in cna.get("problemTypes") or [] for d in p.get("descriptions") or [] if d.get("cweId")]
659
+ refs = [r.get("url") for r in cna.get("references") or [] if r.get("url")]
660
+ by_runtime: dict[int, list[dict]] = {}
661
+ for name, entry in products_in(record, watch):
662
+ for index, runtime in enumerate(runtimes):
663
+ if runtime["name"] == name:
664
+ by_runtime.setdefault(index, []).append(entry)
665
+ for index, entries in by_runtime.items():
666
+ runtime = runtimes[index]
667
+ results, fixes = [], []
668
+ for entry in entries:
669
+ usable = [v for v in entry.get("versions") or []
670
+ if str(v.get("version", "0")).strip() in ("0", "*", "n/a", "unspecified", "") or re.match(r"^v?\d", str(v.get("version", "")))]
671
+ if entry.get("versions") and not usable:
672
+ continue # product-specific version names (e.g. "R36"): not comparable
673
+ result, entry_fixes = versions.cve_versions_affected(runtime["version"], usable, entry.get("defaultStatus", "unknown"))
674
+ results.append(result)
675
+ fixes += [f for f in entry_fixes if re.match(r"^\d", f) and versions.cycle_of(f, 1) == versions.cycle_of(runtime["version"], 1)]
676
+ if not results or (False in results and True not in results):
677
+ continue
678
+ result = True if True in results else None
679
+ if result is None and runtime["name"] in DEDICATED_SOURCES:
680
+ continue # a better source covers this runtime; a rangeless CVE adds only noise
681
+ confidence = "likely" if result else "possible"
682
+ findings.append(runtime_finding(stack, runtime, command, primary=cve, aliases=[cve], title=cve_title(record),
683
+ severity=core.severity_from_cvss(cvss["score"]) if cvss else vendor_sev, cvss=cvss, fixed=sorted(set(fixes), key=versions.sort_key(None)),
684
+ confidence=confidence, feed="cvelistv5", references=refs, cwe_ids=cwes,
685
+ untrusted={"description": short(cve_title(record), 400)}))
686
+ return findings
687
+
688
+
689
+ CVE_ZIP_MEMBER_LIMIT = 4 * 1024 * 1024
690
+ CVE_ZIP_TOTAL_LIMIT = 400 * 1024 * 1024
691
+
692
+
693
+ def delta_zip_urls(floor, now) -> list[tuple[str, str, str]]:
694
+ """(url, ttl, label) for the cvelistV5 release zips that cover floor..now.
695
+
696
+ Past days use the end-of-day delta; today uses the newest hourly delta
697
+ (records changed since midnight). Asset names as published in the
698
+ cvelistV5 Releases (README "Releases").
699
+ """
700
+ base = "https://github.com/CVEProject/cvelistV5/releases/download"
701
+ out = []
702
+ day = floor.date()
703
+ while day < now.date():
704
+ d = day.isoformat()
705
+ out.append((f"{base}/cve_{d}_at_end_of_day/{d}_delta_CVEs_at_end_of_day.zip", "30d", d))
706
+ day += timedelta(days=1)
707
+ today = now.date().isoformat()
708
+ for hour in range(now.hour, -1, -1):
709
+ out.append((f"{base}/cve_{today}_{hour:02d}00Z/{today}_delta_CVEs_at_{hour:02d}00Z.zip", "1h", f"{today}T{hour:02d}"))
710
+ return out
711
+
712
+
713
+ def records_from_zip(data: bytes) -> list[dict]:
714
+ import io
715
+ import zipfile
716
+
717
+ records, total = [], 0
718
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
719
+ for info in archive.infolist():
720
+ if not info.filename.endswith(".json") or "/CVE-" not in "/" + info.filename.replace("\\", "/"):
721
+ continue
722
+ if info.file_size > CVE_ZIP_MEMBER_LIMIT:
723
+ continue
724
+ total += info.file_size
725
+ if total > CVE_ZIP_TOTAL_LIMIT:
726
+ raise core.DegradedError("cvelistV5 delta zip larger than expected; refusing to unpack more")
727
+ try:
728
+ records.append(json.loads(archive.read(info).decode("utf-8")))
729
+ except (ValueError, UnicodeDecodeError):
730
+ continue
731
+ return records
732
+
733
+
734
+ def cve_delta(ctx: core.Context, stack: dict, command: str, since: timedelta, budget: int) -> tuple[list[dict], bool]:
735
+ """New and updated CVE records since the cursor, matched by product name.
736
+
737
+ Reads the cvelistV5 release delta zips (one per past day plus today's
738
+ latest hourly zip); falls back to deltaLog.json + one request per record
739
+ (bounded by --cve-budget) when the zips are unavailable.
740
+ """
741
+ watch = (stack.get("watch") or {}).get("products") or []
742
+ if not watch or not any(r["name"] in watch for r in stack.get("runtimes") or []):
743
+ ctx.run.setdefault("inputs", {})["cve_delta"] = {"note": "no watched runtime in the stack"}
744
+ return [], True
745
+ cursor = ctx.cache.cursor("cvelistv5-delta")
746
+ now = core.utcnow()
747
+ floor = core.parse_time(cursor) if cursor else now - since
748
+ records: list[dict] = []
749
+ ok = True
750
+ today_found = False
751
+ for url, ttl, label in delta_zip_urls(floor, now):
752
+ if label.startswith(now.date().isoformat()) and today_found:
753
+ continue
754
+ data = ctx.cache.get("cvelistv5-zip", url, ttl=ttl, allow_stale=False)
755
+ if data is None:
756
+ if "T" not in label: # an end-of-day zip should exist
757
+ ok = False
758
+ continue
759
+ if "T" in label:
760
+ today_found = True
761
+ try:
762
+ records += records_from_zip(data)
763
+ except Exception as error: # noqa: BLE001 - a bad archive degrades the source
764
+ ok = False
765
+ ctx.degrade("cvelistv5-zip", f"{label}: {error}")
766
+ via = "release-zips"
767
+ if not ok or not records:
768
+ records, ok = cve_records_one_by_one(ctx, floor, budget)
769
+ via = "deltaLog"
770
+ fresh = []
771
+ for record in records:
772
+ updated = (record.get("cveMetadata") or {}).get("dateUpdated") or (record.get("cveMetadata") or {}).get("datePublished")
773
+ try:
774
+ if updated and core.parse_time(updated) <= floor:
775
+ continue
776
+ except ValueError:
777
+ pass
778
+ fresh.append(record)
779
+ findings: list[dict] = []
780
+ for record in fresh:
781
+ findings += cve_findings_for_record(stack, record, command)
782
+ ctx.run.setdefault("inputs", {})["cve_delta"] = {"via": via, "records": len(fresh), "since": core.iso(floor)}
783
+ # Zip runs drop the cvelistv5-zip 404 noise from today's hours that are not published yet.
784
+ ctx.cache.report = [r for r in ctx.cache.report if not (r["id"] == "cvelistv5-zip" and r["status"] == "degraded" and ok)]
785
+ if ok:
786
+ ctx._pending_cursor = ("cvelistv5-delta", core.iso(now)) # type: ignore[attr-defined]
787
+ return findings, ok
788
+
789
+
790
+ def cve_records_one_by_one(ctx: core.Context, floor, budget: int) -> tuple[list[dict], bool]:
791
+ delta = ctx.cache.get_json("cvelistv5-delta", core.SOURCES["cvelistv5-delta"]["url"])
792
+ if delta is None:
793
+ return [], False
794
+ wanted: dict[str, str] = {}
795
+ for batch in delta if isinstance(delta, list) else []:
796
+ fetched = batch.get("fetchTime")
797
+ if not fetched or core.parse_time(fetched) <= floor:
798
+ continue
799
+ for item in (batch.get("new") or []) + (batch.get("updated") or []):
800
+ if item.get("cveId"):
801
+ wanted[item["cveId"]] = item.get("githubLink") or cve_record_url(item["cveId"])
802
+ ids = sorted(wanted)
803
+ complete = len(ids) <= budget
804
+
805
+ def fetch(cve: str):
806
+ url = wanted[cve].replace("https://github.com/CVEProject/cvelistV5/blob/main/", "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/")
807
+ return ctx.cache.get_json("cvelistv5-record", url, ttl="7d")
808
+
809
+ with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
810
+ records = [r for r in pool.map(fetch, ids[:budget]) if r]
811
+ if not complete:
812
+ ctx.degrade("cvelistv5-delta", f"record budget {budget} reached; {len(ids) - budget} records left")
813
+ return records, complete
814
+
815
+
816
+ def kev_product_findings(ctx: core.Context, stack: dict, kev: dict, existing: list[dict], command: str, since: timedelta) -> list[dict]:
817
+ """KEV additions that name a product we run but no version match: 'exploited elsewhere; confirm your version' (PRD 01 8.5)."""
818
+ known = {a for f in existing for a in f.get("aliases") or []}
819
+ cursor = ctx.cache.cursor("kev")
820
+ floor = (core.parse_time(cursor) if cursor else core.utcnow() - since).date()
821
+ out = []
822
+ for cve, entry in kev.items():
823
+ try:
824
+ added = core.parse_time(entry.get("dateAdded", "1970-01-01")).date()
825
+ except ValueError:
826
+ continue
827
+ if added < floor or cve in known:
828
+ continue
829
+ product = norm_product(entry.get("product", ""))
830
+ vendor = norm_product(entry.get("vendorProject", ""))
831
+ for runtime in stack.get("runtimes") or []:
832
+ aliases = PRODUCT_ALIASES.get(runtime["name"], [runtime["name"]])
833
+ if product in aliases or f"{vendor} {product}" in aliases:
834
+ finding = runtime_finding(stack, runtime, command, primary=cve, aliases=[cve],
835
+ title=f"exploited in the wild elsewhere; confirm your version ({entry.get('vulnerabilityName') or cve})",
836
+ severity="high", cvss=None, fixed=[], confidence="possible", feed="kev",
837
+ references=[f"https://www.cve.org/CVERecord?id={cve}"])
838
+ finding["kev"] = {"listed": True, "date_added": entry.get("dateAdded"), "ransomware": (entry.get("knownRansomwareCampaignUse") or "unknown").lower()}
839
+ finding["priority"] = "p2"
840
+ out.append(finding)
841
+ ctx._kev_cursor = core.iso() # type: ignore[attr-defined]
842
+ return out
843
+
844
+
845
+ # --------------------------------------------------------------------------- EOL
846
+
847
+
848
+ def eol_product(ctx: core.Context, product: str) -> list[dict] | None:
849
+ """Release cycles in snapshot shape: releaseCycle, eol, eoas, latest. API first, packaged snapshot as fallback."""
850
+ if not getattr(ctx.args, "offline", False):
851
+ data = ctx.cache.get_json("eol", f"{EOL_API}/{product}/", ttl="24h")
852
+ result = (data or {}).get("result") or {}
853
+ if result.get("releases"):
854
+ return [{"releaseCycle": r.get("name"), "eol": r.get("eolFrom") if r.get("eolFrom") else r.get("isEol"),
855
+ "isEol": r.get("isEol"), "eoas": r.get("eoasFrom"), "latest": (r.get("latest") or {}).get("name"),
856
+ "isMaintained": r.get("isMaintained")} for r in result["releases"]]
857
+ snapshot = core.read_json(core.DATA_DIR / "eol-snapshot.json")
858
+ releases = snapshot["products"].get(product)
859
+ if releases is not None:
860
+ ctx.run.setdefault("inputs", {}).setdefault("eol_snapshot", snapshot["snapshot"])
861
+ return releases
862
+
863
+
864
+ def pick_cycle(releases: list[dict], version: str) -> dict | None:
865
+ cycles = sorted((r for r in releases if r.get("releaseCycle")), key=lambda r: -len(str(r["releaseCycle"])))
866
+ for release in cycles:
867
+ cycle = str(release["releaseCycle"])
868
+ if version == cycle or version.startswith(cycle + ".") or version.startswith(cycle + "-") or (cycle.isdigit() and version.split(".")[0] == cycle):
869
+ return release
870
+ return None
871
+
872
+
873
+ def eol_findings(ctx: core.Context, stack: dict, command: str, warn_days: tuple[int, int] = (90, 30)) -> tuple[list[dict], set[str]]:
874
+ today = core.utcnow().date()
875
+ findings, covered = [], set()
876
+ subjects = [(r, EOL_PRODUCTS.get(r["name"])) for r in stack.get("runtimes") or []]
877
+ for comp in stack.get("components") or []:
878
+ product = EOL_PACKAGES.get((comp["ecosystem"], comp["name"]))
879
+ if product and comp.get("direct") is not False:
880
+ subjects.append(({"name": comp["name"], "version": comp["version"], "source": comp.get("location"), "host": None, "_package": comp}, product))
881
+ for override in getattr(ctx.args, "product", None) or []:
882
+ name, _, cycle = override.partition("=")
883
+ subjects.append(({"name": name, "version": cycle, "source": "flag", "host": None}, EOL_PRODUCTS.get(name, name)))
884
+ cache: dict[str, list[dict] | None] = {}
885
+ for subject, product in subjects:
886
+ if not product:
887
+ continue
888
+ if product not in cache:
889
+ cache[product] = eol_product(ctx, product)
890
+ releases = cache[product]
891
+ if not releases:
892
+ continue
893
+ release = pick_cycle(releases, subject["version"])
894
+ locator_affected = {"name": subject["name"], "component_type": "runtime"}
895
+ covered.add(core.finding_locator(locator_affected, {"host": subject.get("host")}))
896
+ if not release:
897
+ continue
898
+ eol = release.get("eol")
899
+ eol_date = None
900
+ if isinstance(eol, str) and re.match(r"\d{4}-\d{2}-\d{2}", eol):
901
+ eol_date = core.parse_time(eol).date()
902
+ is_eol = (eol is True) or (eol_date is not None and eol_date <= today) or release.get("isEol") is True
903
+ days = (eol_date - today).days if eol_date else (-1 if is_eol else None)
904
+ public = internet_facing(stack, subject.get("host"))
905
+ latest = release.get("latest")
906
+ cycle = str(release["releaseCycle"])
907
+ if is_eol:
908
+ severity = "high" if public else "medium"
909
+ title = f"{subject['name']} {cycle} reached end of life" + (f" on {eol_date}" if eol_date else "")
910
+ elif days is not None and days <= warn_days[1]:
911
+ severity, title = "medium", f"{subject['name']} {cycle} reaches end of life on {eol_date} ({days} days)"
912
+ elif days is not None and days <= warn_days[0]:
913
+ severity, title = "low", f"{subject['name']} {cycle} reaches end of life on {eol_date} ({days} days)"
914
+ elif latest and versions.compare_generic(subject["version"], str(latest)) < 0 and subject["version"].count(".") >= 1 \
915
+ and str(latest).startswith(cycle) and subject.get("source") not in ("declared",):
916
+ severity, title = "info", f"{subject['name']} {subject['version']} is behind the latest {cycle} patch ({latest})"
917
+ else:
918
+ continue
919
+ supported = sorted((str(r["releaseCycle"]) for r in releases if not (r.get("eol") is True or
920
+ (isinstance(r.get("eol"), str) and re.match(r"\d{4}", r["eol"]) and core.parse_time(r["eol"]).date() <= today))),
921
+ key=versions.sort_key(None))
922
+ finding = core.make_finding(
923
+ kind="eol", command=command, primary_id=f"eol-{product}-{cycle}" + ("-patch" if severity == "info" else ""),
924
+ site=stack["site"]["id"], title=title, severity=severity, severity_source="maggie", confidence="confirmed",
925
+ asvs_ids=["V15.2.1"],
926
+ affected={"purl": (subject.get("_package") or {}).get("purl"), "ecosystem": (subject.get("_package") or {}).get("ecosystem"),
927
+ "name": subject["name"], "version": subject["version"],
928
+ "component_type": "os" if subject["name"] in OS_RUNTIMES else ("library" if subject.get("_package") else "runtime"),
929
+ "direct": True, "scope": "runtime"},
930
+ fix={"fixed_versions": [str(latest)] if latest else [], "recommended": (str(latest) if severity == "info" else (supported[-1] if supported else None)),
931
+ "available": bool(supported) or bool(latest)},
932
+ target={"host": subject.get("host"), "image": subject.get("image"),
933
+ "path": None if subject.get("host") or subject.get("image") or subject.get("source") in ("image", "declared", "files", "flag") else subject.get("source")},
934
+ feed="eol", fetched_at=core.iso(), internet_facing=public,
935
+ remediation={"summary": (f"Move to a supported {subject['name']} line ({', '.join(supported[-3:])})." if severity != "info" and supported
936
+ else f"Update to {latest}." if latest else "Plan an upgrade."),
937
+ "steps": [], "snippets": [], "references": [f"https://endoflife.date/{product}"], "cheat_sheet": None})
938
+ finding["_eol_days"] = days
939
+ finding["priority"] = core.compute_priority(finding, internet_facing=public)
940
+ finding.pop("_eol_days", None)
941
+ findings.append(finding)
942
+ return findings, covered
943
+
944
+
945
+ # --------------------------------------------------------------------------- commands
946
+
947
+
948
+ def _since(ctx: core.Context, default: str = "24h") -> timedelta:
949
+ span = core.parse_duration(ctx.args.since or default)
950
+ return min(span, timedelta(days=30))
951
+
952
+
953
+ def _commit_cursors(ctx: core.Context) -> None:
954
+ """Cursors move only after the run file is written (PRD 01 6.2)."""
955
+ pending = getattr(ctx, "_pending_cursor", None)
956
+ if pending and pending[1]:
957
+ ctx.cache.set_cursor(*pending)
958
+ kev_cursor = getattr(ctx, "_kev_cursor", None)
959
+ if kev_cursor:
960
+ ctx.cache.set_cursor("kev", kev_cursor)
961
+
962
+
963
+ def _locators(stack: dict, ok_osv: bool) -> set[str]:
964
+ covered = set()
965
+ if ok_osv:
966
+ _, owners = osv_queries_for(stack)
967
+ for owner in owners:
968
+ affected = {"purl": owner.get("purl"), "name": owner["name"], "component_type": "library"}
969
+ covered.add(core.finding_locator(affected, {}))
970
+ return covered
971
+
972
+
973
+ def vuln_watch(ctx: core.Context):
974
+ args = ctx.args
975
+ stack = inventory.load_stack(ctx, rebuild=args.rebuild_inventory)
976
+ enabled = set((args.sources or "osv,node,wp,software,cvelistv5,kev,epss").split(","))
977
+ kev = load_kev(ctx) # always joined onto CVE findings; "kev" in --sources adds product-only alerts
978
+ findings: list[dict] = []
979
+ covered: set[str] = set()
980
+ if "osv" in enabled:
981
+ found, ok = match_osv(ctx, stack, "vuln-watch")
982
+ findings += found
983
+ covered |= _locators(stack, ok)
984
+ if "node" in enabled and any(r["name"] == "node" for r in stack.get("runtimes") or []):
985
+ index = load_node_index(ctx)
986
+ if index:
987
+ findings += node_core_findings(ctx, stack, index, "vuln-watch")
988
+ covered |= {core.finding_locator({"name": "node", "component_type": "runtime"}, {"host": r.get("host")})
989
+ for r in stack["runtimes"] if r["name"] == "node"}
990
+ if "software" in enabled:
991
+ found, cov = software_findings(ctx, stack, "vuln-watch")
992
+ findings += found
993
+ covered |= cov
994
+ if "wp" in enabled and stack.get("wordpress"):
995
+ found, ok = wordpress_findings(ctx, stack, "vuln-watch", budget=args.wp_budget)
996
+ findings += found
997
+ if ok:
998
+ wp = stack["wordpress"]
999
+ covered |= {f"wordpress-plugin:{p['slug']}" for p in wp.get("plugins") or []} | {f"wordpress-theme:{t['slug']}" for t in wp.get("themes") or []}
1000
+ covered.add("wordpress-core:wordpress")
1001
+ if "cvelistv5" in enabled:
1002
+ found, _ok = cve_delta(ctx, stack, "vuln-watch", _since(ctx), args.cve_budget)
1003
+ findings += found
1004
+ apply_kev(findings, kev, stack)
1005
+ if "kev" in enabled and kev:
1006
+ findings += kev_product_findings(ctx, stack, kev, findings, "vuln-watch", _since(ctx, "7d"))
1007
+ join_epss(ctx, findings)
1008
+ ctx.run["inputs"].update({"stack": str(ctx.state.path("inventory", "stack.json").relative_to(ctx.project)), "stack_hash": stack["hash"],
1009
+ "sources": sorted(enabled)})
1010
+ doc = ctx.finish(findings, covered=covered)
1011
+ _commit_cursors(ctx)
1012
+ write_runtime_advisories(ctx, doc, Path(args.advisory_out) if args.advisory_out else ctx.state.path("advisories"))
1013
+ return doc
1014
+
1015
+
1016
+ def osv_query(ctx: core.Context):
1017
+ args = ctx.args
1018
+ site = ctx.site
1019
+ if args.id:
1020
+ record = osv_record(ctx, args.id)
1021
+ if record is None:
1022
+ ctx.degrade("osv-api", f"{args.id} not found or OSV unavailable")
1023
+ args.no_ledger = True
1024
+ return ctx.finish([])
1025
+ cvss = cvss_from_osv(record)
1026
+ finding = core.make_finding(kind="malicious-package" if record["id"].startswith("MAL-") else "vulnerability", command="osv-query",
1027
+ primary_id=primary_of(record["id"], record.get("aliases") or []), site=site,
1028
+ title=short(record.get("summary") or record.get("details") or record["id"], 200), cvss=cvss,
1029
+ severity=SEVERITY_WORDS.get(((record.get("database_specific") or {}).get("severity") or "").lower(), "medium"),
1030
+ severity_source="cvss" if cvss else "vendor", confidence="possible",
1031
+ aliases=[record["id"], *(record.get("aliases") or [])], target={}, feed="osv",
1032
+ remediation={"summary": "Lookup only; run vuln-watch to match it against your stack.", "steps": [], "snippets": [],
1033
+ "references": [f"https://osv.dev/vulnerability/{record['id']}"], "cheat_sheet": None},
1034
+ untrusted={"details": short(record.get("details"), 1000)})
1035
+ apply_kev([finding], load_kev(ctx), {"hosts": []})
1036
+ args.no_ledger = True
1037
+ return ctx.finish([finding])
1038
+ if args.stack:
1039
+ stack = inventory.load_stack(ctx)
1040
+ else:
1041
+ if args.purl:
1042
+ match = re.match(r"^pkg:([^/]+)/(.+)@([^@?#]+)$", args.purl)
1043
+ if not match:
1044
+ raise core.UsageError("--purl must include a version, e.g. pkg:npm/next@14.2.10")
1045
+ kind, name, version = match.groups()
1046
+ eco = {"npm": "npm", "pypi": "PyPI", "maven": "Maven", "golang": "Go", "gem": "RubyGems", "cargo": "crates.io", "nuget": "NuGet",
1047
+ "composer": "Packagist"}.get(kind, kind)
1048
+ name = name.replace("%40", "@")
1049
+ if eco == "Maven":
1050
+ name = name.replace("/", ":")
1051
+ elif args.ecosystem and args.name and args.version:
1052
+ eco, name, version = args.ecosystem, args.name, args.version
1053
+ else:
1054
+ raise core.UsageError("give --purl, --ecosystem/--name/--version, --id or --stack")
1055
+ stack = {"site": {"id": site}, "hosts": [], "runtimes": [],
1056
+ "components": [{"purl": core.make_purl(eco, name, version), "ecosystem": eco, "name": name, "version": version,
1057
+ "scope": "runtime", "direct": True, "location": "query"}]}
1058
+ args.no_ledger = True
1059
+ findings, ok = match_osv(ctx, stack, "osv-query")
1060
+ apply_kev(findings, load_kev(ctx), stack)
1061
+ join_epss(ctx, findings)
1062
+ return ctx.finish(findings, covered=_locators(stack, ok) if args.stack else ())
1063
+
1064
+
1065
+ def wp_check(ctx: core.Context):
1066
+ args = ctx.args
1067
+ if args.wordpress:
1068
+ inv = inventory.Inventory(ctx.project, ctx.site, "wordpress")
1069
+ inv.parse_wordpress(Path(args.wordpress))
1070
+ if args.wp_cli_json:
1071
+ inv.apply_wp_cli(Path(args.wp_cli_json))
1072
+ stack = inv.stack([], [])
1073
+ else:
1074
+ stack = inventory.load_stack(ctx)
1075
+ if not stack.get("wordpress"):
1076
+ raise core.UsageError("no WordPress install found (pass --wordpress PATH)")
1077
+ findings, ok = wordpress_findings(ctx, stack, "wp-check", include_inactive=args.include_inactive, budget=args.wp_budget)
1078
+ soft, soft_cov = software_findings(ctx, stack, "wp-check")
1079
+ findings += soft
1080
+ apply_kev(findings, load_kev(ctx), stack)
1081
+ join_epss(ctx, findings)
1082
+ covered = set(soft_cov)
1083
+ if ok:
1084
+ wp = stack["wordpress"]
1085
+ covered |= {f"wordpress-plugin:{p['slug']}" for p in wp.get("plugins") or []} | {f"wordpress-theme:{t['slug']}" for t in wp.get("themes") or []}
1086
+ covered.add("wordpress-core:wordpress")
1087
+ return ctx.finish(findings, covered=covered)
1088
+
1089
+
1090
+ def eol_watch(ctx: core.Context):
1091
+ stack = inventory.load_stack(ctx)
1092
+ warn = tuple(int(x) for x in (ctx.args.warn_days or "90,30").split(","))
1093
+ findings, covered = eol_findings(ctx, stack, "eol-watch", (max(warn), min(warn)))
1094
+ if not any(r["name"] == "astro" for r in stack.get("runtimes") or []) and any(c["name"] == "astro" for c in stack.get("components") or []):
1095
+ ctx.run["inputs"]["note"] = "astro has no endoflife.date entry; it is covered by OSV through vuln-watch"
1096
+ return ctx.finish(findings, covered=covered)
1097
+
1098
+
1099
+ def node_check(ctx: core.Context):
1100
+ args = ctx.args
1101
+ version = args.node_version
1102
+ if args.local and not version:
1103
+ import subprocess
1104
+
1105
+ out = subprocess.run(["node", "--version"], capture_output=True, text=True, check=False).stdout.strip()
1106
+ version = out.lstrip("v") or None
1107
+ stack = {"site": {"id": ctx.site}, "hosts": [], "runtimes": [], "components": []} if version else inventory.load_stack(ctx)
1108
+ index = load_node_index(ctx)
1109
+ if not index:
1110
+ ctx.degrade("node-core", "Node security-wg index unavailable")
1111
+ return ctx.finish([])
1112
+ findings = node_core_findings(ctx, stack, index, "node-check", only_version=version)
1113
+ apply_kev(findings, load_kev(ctx), stack)
1114
+ covered = {core.finding_locator({"name": "node", "component_type": "runtime"}, {"host": args.host if version else r.get("host")})
1115
+ for r in (stack.get("runtimes") or [{"host": args.host}]) if version or r["name"] == "node"}
1116
+ return ctx.finish(findings, covered=covered)
1117
+
1118
+
1119
+ # --------------------------------------------------------------------------- advisory bridge (PRD 01 6.7)
1120
+
1121
+
1122
+ def advisory_from(component: str, cve: str, fixed: list[str], eol_releases: list[dict] | None) -> dict:
1123
+ by_line: dict[str, str] = {}
1124
+ for version in sorted(set(fixed), key=versions.sort_key(None)):
1125
+ line = versions.cycle_of(version, 2)
1126
+ by_line.setdefault(line, version)
1127
+ unsupported = []
1128
+ today = core.utcnow().date()
1129
+ for release in eol_releases or []:
1130
+ eol = release.get("eol")
1131
+ if eol is True or (isinstance(eol, str) and re.match(r"\d{4}-\d{2}-\d{2}", eol) and core.parse_time(eol).date() <= today):
1132
+ unsupported.append(str(release["releaseCycle"]))
1133
+ data = {"id": cve, "component": component, "fixed": by_line, "unsupportedLines": sorted(unsupported, key=versions.sort_key(None))}
1134
+ if component not in ("php",):
1135
+ data["planner"] = "manual"
1136
+ return data
1137
+
1138
+
1139
+ def advisory(ctx: core.Context) -> dict:
1140
+ args = ctx.args
1141
+ if args.finding:
1142
+ finding = None
1143
+ runs = sorted(ctx.state.path("runs").glob("*.json"), reverse=True) if ctx.state.path("runs").is_dir() else []
1144
+ for path in runs:
1145
+ doc = core.read_json(path)
1146
+ finding = next((f for f in doc.get("findings") or [] if f["id"] == args.finding), None)
1147
+ if finding:
1148
+ break
1149
+ if not finding:
1150
+ raise core.UsageError(f"finding {args.finding} not found in .maggie/security/runs/")
1151
+ component = (finding.get("affected") or {}).get("name")
1152
+ cve = next(iter(cve_ids(finding.get("aliases") or [])), finding["source"].get("rule_id"))
1153
+ fixed = (finding.get("fix") or {}).get("fixed_versions") or []
1154
+ else:
1155
+ if not (args.cve and args.component):
1156
+ raise core.UsageError("give --finding ID, or --cve and --component")
1157
+ component, cve = args.component, args.cve.upper()
1158
+ fixed = []
1159
+ record = ctx.cache.get_json("cvelistv5-record", cve_record_url(cve), ttl="7d")
1160
+ if record:
1161
+ for name, entry in products_in(record, [component]):
1162
+ for item in entry.get("versions") or []:
1163
+ if item.get("lessThan") and item.get("lessThan") != "*":
1164
+ fixed.append(str(item["lessThan"]))
1165
+ if item.get("status") == "unaffected" and item.get("version"):
1166
+ fixed.append(str(item["version"]))
1167
+ if not fixed:
1168
+ ctx.degrade("cvelistv5-record", f"no fixed versions for {component} in {cve}")
1169
+ data = advisory_from(component, cve, fixed, eol_product(ctx, EOL_PRODUCTS.get(component, component)))
1170
+ out = Path(args.out) if args.out else ctx.state.path("advisories", f"{cve}-{component}.json")
1171
+ core.write_json(out, data)
1172
+ return {"schema": "maggie-security-advisory-result.v1", "advisory": data, "path": str(out),
1173
+ "next": f"maggie deployment runtime-inventory --ssh deploy@<host> --advisory {out}",
1174
+ "note": "Maggie does not run that for you: it needs ssh-read consent, and any migration needs host-lock.",
1175
+ "exit_code": core.EXIT_DEGRADED if ctx.run["degraded"] else core.EXIT_OK}
1176
+
1177
+
1178
+ def write_runtime_advisories(ctx: core.Context, doc: dict, directory: Path) -> None:
1179
+ for finding in doc.get("findings") or []:
1180
+ affected = finding.get("affected") or {}
1181
+ if finding.get("status") != "new" or affected.get("component_type") not in ("runtime", "service", "os"):
1182
+ continue
1183
+ cves = cve_ids(finding.get("aliases") or [])
1184
+ if not cves:
1185
+ continue
1186
+ data = advisory_from(affected["name"], cves[0], (finding.get("fix") or {}).get("fixed_versions") or [], None)
1187
+ core.write_json(directory / f"{cves[0]}-{affected['name']}.json", data)
1188
+
1189
+
1190
+ # --------------------------------------------------------------------------- consent (H-1)
1191
+
1192
+
1193
+ def consent_cmd(ctx: core.Context) -> dict:
1194
+ args = ctx.args
1195
+ path = ctx.state.path("consent.json")
1196
+ data = core.read_json(path) if path.is_file() else {"schema": "maggie-security-consent.v1", "grants": []}
1197
+ if args.action == "list":
1198
+ return data
1199
+ if not args.host or not args.level:
1200
+ raise core.UsageError("consent grant/revoke needs --host and --level")
1201
+ if args.level not in core.CONSENT_LEVELS or args.level in core.IMPLICIT_CONSENT:
1202
+ raise core.UsageError(f"--level must be one of {', '.join(l for l in core.CONSENT_LEVELS if l not in core.IMPLICIT_CONSENT)}")
1203
+ if args.action == "grant":
1204
+ if args.level == "host-change":
1205
+ raise core.PolicyError("host-change is never granted to a security command")
1206
+ if not sys.stdin.isatty() and not args.yes_i_am_human:
1207
+ raise core.PolicyError("consent is granted interactively only (routines and agents cannot grant it)")
1208
+ if sys.stdin.isatty():
1209
+ print(f"Grant {args.level} on host '{args.host}'? This lets maggie security run read-only commands over SSH. Type the host alias to confirm: ",
1210
+ end="", file=sys.stderr, flush=True)
1211
+ if sys.stdin.readline().strip() != args.host:
1212
+ raise core.PolicyError("not confirmed")
1213
+ grant = {"host": args.host, "level": args.level, "granted_at": core.iso(), "by": "user",
1214
+ "until": core.iso(core.utcnow() + core.parse_duration(args.days + "d"))[:10] if args.days else None}
1215
+ data["grants"].append(grant)
1216
+ else:
1217
+ for grant in data["grants"]:
1218
+ if grant["host"] == args.host and grant["level"] == args.level and not grant.get("revoked_at"):
1219
+ grant["revoked_at"] = core.iso()
1220
+ core.write_json(path, data)
1221
+ return data
1222
+
1223
+
1224
+ # --------------------------------------------------------------------------- registration
1225
+
1226
+
1227
+ def _vw(p: argparse.ArgumentParser) -> None:
1228
+ p.add_argument("--sources", help="comma list: osv,node,wp,software,cvelistv5,kev,epss (default all)")
1229
+ p.add_argument("--rebuild-inventory", action="store_true")
1230
+ p.add_argument("--full-rematch", action="store_true", help="ignore the OSV query cache")
1231
+ p.add_argument("--advisory-out", help="directory for runtime advisories (default .maggie/security/advisories)")
1232
+ p.add_argument("--cve-budget", type=int, default=1500, help="max CVE records fetched per run")
1233
+ p.add_argument("--wp-budget", type=int, default=300)
1234
+ p.add_argument("--no-epss", action="store_true")
1235
+
1236
+
1237
+ def _oq(p: argparse.ArgumentParser) -> None:
1238
+ p.add_argument("--purl")
1239
+ p.add_argument("--ecosystem")
1240
+ p.add_argument("--name")
1241
+ p.add_argument("--version")
1242
+ p.add_argument("--id", help="OSV/GHSA/CVE id lookup")
1243
+ p.add_argument("--stack", action="store_true", help="query every component in stack.json")
1244
+ p.add_argument("--no-epss", action="store_true")
1245
+
1246
+
1247
+ def _wp(p: argparse.ArgumentParser) -> None:
1248
+ p.add_argument("--wordpress")
1249
+ p.add_argument("--wp-cli-json")
1250
+ p.add_argument("--include-inactive", action="store_true", default=True)
1251
+ p.add_argument("--wp-budget", type=int, default=300)
1252
+ p.add_argument("--no-epss", action="store_true")
1253
+
1254
+
1255
+ def _eol(p: argparse.ArgumentParser) -> None:
1256
+ p.add_argument("--warn-days", default="90,30")
1257
+ p.add_argument("--product", action="append", help="NAME=CYCLE to check without an inventory, e.g. php=8.1")
1258
+
1259
+
1260
+ def _node(p: argparse.ArgumentParser) -> None:
1261
+ p.add_argument("--node-version")
1262
+ p.add_argument("--host")
1263
+ p.add_argument("--local", action="store_true", help="use `node --version` on this machine")
1264
+
1265
+
1266
+ def _adv(p: argparse.ArgumentParser) -> None:
1267
+ p.add_argument("--finding")
1268
+ p.add_argument("--cve")
1269
+ p.add_argument("--component")
1270
+ p.add_argument("--out")
1271
+
1272
+
1273
+ def _consent(p: argparse.ArgumentParser) -> None:
1274
+ p.add_argument("action", choices=["grant", "revoke", "list"])
1275
+ p.add_argument("--host")
1276
+ p.add_argument("--level")
1277
+ p.add_argument("--days", help="grant expiry in days")
1278
+ p.add_argument("--yes-i-am-human", action="store_true", help=argparse.SUPPRESS)
1279
+
1280
+
1281
+ WATCH_SOURCES = ["osv-api", "kev", "node-core", "wpvulnerability", "cvelistv5-zip", "cvelistv5-delta", "cvelistv5-record", "epss", "eol"]
1282
+
1283
+ COMMANDS = [
1284
+ core.Command("inventory", "build the stack inventory (lockfiles, manifests, images, WordPress, host evidence)", [],
1285
+ inventory.configure, inventory.run, findings=False),
1286
+ core.Command("vuln-watch", "daily watch: OSV, Node core, WPVulnerability, CVE delta, KEV, EPSS against the stack", WATCH_SOURCES, _vw, vuln_watch),
1287
+ core.Command("osv-query", "look up one package, one advisory id, or the whole stack in OSV", ["osv-api", "kev", "epss"], _oq, osv_query),
1288
+ core.Command("wp-check", "WordPress core, plugins, themes and server software via WPVulnerability", ["wpvulnerability", "kev", "epss"], _wp, wp_check),
1289
+ core.Command("eol-watch", "end-of-life and patch-behind for runtimes, databases, servers, distros and frameworks", ["eol"], _eol, eol_watch),
1290
+ core.Command("node-check", "Node.js core advisories for the node versions in use", ["node-core", "kev"], _node, node_check),
1291
+ core.Command("advisory", "write a runtime-inventory --advisory file for a CVE or finding", ["cvelistv5-record", "eol", "osv-api"], _adv, advisory,
1292
+ findings=False),
1293
+ core.Command("consent", "grant, revoke or list per-host consent (interactive only)", [], _consent, consent_cmd, findings=False, routine_safe=False),
1294
+ ]