@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,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
|
+
]
|