@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,560 @@
1
+ #!/usr/bin/env python3
2
+ """``maggie security deps`` — docs/maggie-security/02-dependencies-supply-chain-prd.md section 6.1.
3
+
4
+ Two parts, one findings set:
5
+
6
+ 1. Lockfiles and manifests of every ecosystem: the pinned osv-scanner binary
7
+ when it is installed (``scan source -r PATH --format json``, offline with
8
+ the mirrored DB), otherwise Maggie's native engine (the inventory parsers
9
+ plus OSV ``querybatch``, the same path ``vuln-watch`` uses).
10
+ 2. JavaScript copied into the site (``dist/``, ``public/``, theme and plugin
11
+ folders): libraries found by retire.js extractors and matched against
12
+ retire.js's own vulnerability repository (``jsrepository-v4.json``,
13
+ Apache-2.0, cached daily), which covers libraries that have no npm
14
+ advisory; OSV npm adds the rest.
15
+
16
+ The CI gate uses ``--ci`` (SARIF, ``--fail-on high``, dev dependencies included).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import re
24
+ import tempfile
25
+ from pathlib import Path
26
+
27
+ import maggie_security_core as core
28
+ import maggie_security_intel as intel
29
+ import maggie_security_inventory as inventory
30
+ import maggie_security_versions as versions
31
+
32
+ RETIRE_SEVERITY = {"critical": "critical", "high": "high", "medium": "medium", "low": "low", "none": "info"}
33
+
34
+
35
+ def project_stack(ctx: core.Context) -> dict:
36
+ inv = inventory.Inventory(ctx.project, ctx.site, core.detect_site_type(ctx.project))
37
+ inv.scan_project()
38
+ stack = inv.stack([], [])
39
+ stack["runtimes"] = [r for r in stack["runtimes"] if r.get("source") not in ("runtime-inventory",)]
40
+ ctx.run["inputs"]["lockfiles"] = sorted({s["path"] for s in stack["sources"]})
41
+ ctx.run["inputs"]["parse_problems"] = inv.problems
42
+ return stack
43
+
44
+
45
+ # --------------------------------------------------------------------------- osv-scanner binary
46
+
47
+
48
+ def osv_scanner(ctx: core.Context, stack_site: str) -> list[dict] | None:
49
+ args = ["scan", "source", "-r", str(ctx.project), "--format", "json"]
50
+ env = {}
51
+ db = ctx.state.path("db", "osv")
52
+ if ctx.args.offline:
53
+ if not db.is_dir():
54
+ ctx.degrade("osv-scanner", "offline needs the mirrored OSV database in .maggie/security/db/osv (feeds mirror)")
55
+ return None
56
+ args.append("--offline-vulnerabilities")
57
+ env["OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY"] = str(db)
58
+ proc = core.run_tool("osv-scanner", args, ctx.lock, cwd=ctx.project, env=env)
59
+ if proc.returncode == 128:
60
+ ctx.run["inputs"]["osv_scanner"] = "no packages found"
61
+ return []
62
+ if proc.returncode not in (0, 1):
63
+ ctx.degrade("osv-scanner", core.clean_excerpt(proc.stderr.decode(errors="replace"), 200))
64
+ return None
65
+ spec = ctx.lock["tools"]["osv-scanner"]
66
+ ctx.run["tools"].append({"name": "osv-scanner", "version": spec["version"], "sha256": None})
67
+ return osv_scanner_to_findings(json.loads(proc.stdout.decode() or "{}"), ctx, stack_site)
68
+
69
+
70
+ def osv_scanner_to_findings(data: dict, ctx: core.Context, site: str) -> list[dict]:
71
+ """Adapter for osv-scanner --format json (PRD 02 section 7.1)."""
72
+ stack = {"site": {"id": site}, "hosts": []}
73
+ findings = []
74
+ for result in data.get("results") or []:
75
+ source = (result.get("source") or {}).get("path") or ""
76
+ try:
77
+ rel = Path(source).resolve().relative_to(ctx.project).as_posix()
78
+ except ValueError:
79
+ rel = Path(source).name
80
+ for package in result.get("packages") or []:
81
+ pkg = package.get("package") or {}
82
+ groups = package.get("dependency_groups") or package.get("depGroups") or pkg.get("depGroups") or [] # osv-scanner 2.x: dependency_groups
83
+ comp = {"ecosystem": pkg.get("ecosystem", ""), "name": pkg.get("name", ""), "version": pkg.get("version", ""),
84
+ "purl": core.make_purl(pkg.get("ecosystem", ""), pkg.get("name", ""), pkg.get("version", "")),
85
+ "scope": "dev" if "dev" in groups else "runtime", "direct": None, "location": rel}
86
+ max_severity = {i: g.get("max_severity") for g in package.get("groups") or [] for i in g.get("ids") or []}
87
+ for record in package.get("vulnerabilities") or []:
88
+ finding = intel.osv_component_finding(ctx, stack, comp, record, "deps")
89
+ if not finding:
90
+ continue
91
+ finding.pop("_how", None)
92
+ finding["source"]["tool"] = "osv-scanner"
93
+ score = max_severity.get(record.get("id"))
94
+ if not finding.get("cvss") and score not in (None, ""):
95
+ try:
96
+ finding["cvss"] = {"version": "3.1", "score": float(score), "vector": ""}
97
+ finding["severity"], finding["severity_source"] = core.severity_from_cvss(float(score)), "cvss"
98
+ finding["priority"] = core.compute_priority(finding)
99
+ except ValueError:
100
+ pass
101
+ findings.append(finding)
102
+ return findings
103
+
104
+
105
+ # --------------------------------------------------------------------------- retire.js repository (native)
106
+
107
+
108
+ def retire_repo(ctx: core.Context) -> dict | None:
109
+ return ctx.cache.get_json("retire-jsrepo", core.SOURCES["retire-jsrepo"]["url"], ttl="24h")
110
+
111
+
112
+ def in_retire_range(version: str, vuln: dict) -> bool:
113
+ low = vuln.get("atOrAbove")
114
+ high = vuln.get("below")
115
+ if low and versions.compare("npm", version, low) < 0:
116
+ return False
117
+ if high and versions.compare("npm", version, high) >= 0:
118
+ return False
119
+ return bool(low or high)
120
+
121
+
122
+ def retire_findings(ctx: core.Context, stack: dict, repo: dict) -> list[dict]:
123
+ npm_to_lib = {}
124
+ for lib, spec in repo.items():
125
+ npm_to_lib[(spec.get("npmname") or lib).lower()] = lib
126
+ npm_to_lib[lib.lower()] = lib
127
+ findings = []
128
+ for comp in stack.get("components") or []:
129
+ if not str(comp.get("location") or "").endswith(".js"):
130
+ continue
131
+ lib = npm_to_lib.get(comp["name"].lower())
132
+ if not lib:
133
+ continue
134
+ for vuln in repo[lib].get("vulnerabilities") or []:
135
+ if not in_retire_range(comp["version"], vuln):
136
+ continue
137
+ ids = vuln.get("identifiers") or {}
138
+ cves = ids.get("CVE") or []
139
+ aliases = cves + [i for i in [ids.get("githubID"), ids.get("issue"), ids.get("bug")] if i] + ([ids["retid"]] if ids.get("retid") else [])
140
+ primary = cves[0] if cves else (ids.get("githubID") or f"retire-{lib}-{vuln.get('below') or vuln.get('atOrAbove')}")
141
+ fixed = [vuln["below"]] if vuln.get("below") else []
142
+ summary = ids.get("summary") or (aliases[0] if aliases else "known vulnerability")
143
+ findings.append(core.make_finding(
144
+ kind="vulnerability", command="deps", primary_id=primary, site=ctx.site, title=f"{comp['name']} {comp['version']}: {intel.short(summary, 120)}",
145
+ severity=RETIRE_SEVERITY.get(str(vuln.get("severity", "")).lower(), "medium"), severity_source="tool", confidence="confirmed",
146
+ aliases=aliases or [primary], cwe_ids=[c for c in vuln.get("cwe") or [] if re.match(r"^CWE-\d+$", c)], asvs_ids=["V15.2.1"],
147
+ affected={"purl": comp.get("purl"), "ecosystem": "npm", "name": comp["name"], "version": comp["version"], "component_type": "js-bundle",
148
+ "direct": True, "scope": "runtime"},
149
+ fix={"fixed_versions": fixed, "recommended": fixed[0] if fixed else None, "available": bool(fixed)},
150
+ target={"path": comp["location"]}, evidence=[{"path": comp["location"], "excerpt": f"{comp['name']} {comp['version']}"}],
151
+ tool="retire.js-repository", feed="retire-jsrepo", fetched_at=core.iso(),
152
+ remediation={"summary": f"Replace the copied {comp['name']} with {fixed[0]} or later." if fixed else f"Remove or replace {comp['name']}.",
153
+ "steps": [f"Swap {comp['location']} for {comp['name']} {fixed[0]}+ (or install it from npm and bundle it)"] if fixed else [],
154
+ "snippets": [], "references": (vuln.get("info") or [])[:4], "cheat_sheet": None},
155
+ untrusted={"summary": intel.short(summary, 300)}))
156
+ return findings
157
+
158
+
159
+ # --------------------------------------------------------------------------- command
160
+
161
+
162
+ def deps(ctx: core.Context):
163
+ args = ctx.args
164
+ if args.ci:
165
+ args.include_dev = True
166
+ stack = project_stack(ctx)
167
+ findings: list[dict] = []
168
+ engine = args.engine
169
+ if engine == "auto":
170
+ engine = "osv-scanner" if core.resolve_tool("osv-scanner", ctx.lock)[2] == "pinned" else "native"
171
+ covered: set[str] = set()
172
+ if not args.bundles_only:
173
+ lock_stack = dict(stack, components=[c for c in stack["components"] if not str(c.get("location") or "").endswith(".js")])
174
+ if engine == "osv-scanner":
175
+ found = osv_scanner(ctx, ctx.site)
176
+ ok = found is not None
177
+ findings += found or []
178
+ else:
179
+ ctx.run["tools"].append({"name": "maggie-osv-native", "version": core.maggie_version(), "sha256": None})
180
+ found, ok = intel.match_osv(ctx, dict(lock_stack, runtimes=[]), "deps")
181
+ findings += found
182
+ if ok:
183
+ covered |= intel._locators(dict(lock_stack, runtimes=[]), True)
184
+ if not args.lockfiles_only:
185
+ bundle_stack = dict(stack, components=[c for c in stack["components"] if str(c.get("location") or "").endswith(".js")])
186
+ if bundle_stack["components"]:
187
+ found, ok = intel.match_osv(ctx, dict(bundle_stack, runtimes=[]), "deps")
188
+ for finding in found:
189
+ finding["affected"]["component_type"] = "js-bundle"
190
+ findings += found
191
+ repo = retire_repo(ctx)
192
+ if repo:
193
+ findings += retire_findings(ctx, bundle_stack, repo)
194
+ if ok and repo:
195
+ covered |= intel._locators(dict(bundle_stack, runtimes=[]), True)
196
+ if not args.include_dev:
197
+ findings = [f for f in findings if (f.get("affected") or {}).get("scope") != "dev"]
198
+ stack_for_kev = {"hosts": []}
199
+ intel.apply_kev(findings, intel.load_kev(ctx), stack_for_kev)
200
+ intel.join_epss(ctx, findings)
201
+ ctx.run["inputs"].update({"engine": engine, "include_dev": bool(args.include_dev)})
202
+ return ctx.finish(findings, covered=covered)
203
+
204
+
205
+ # --------------------------------------------------------------------------- sbom
206
+
207
+
208
+ def sbom(ctx: core.Context) -> dict:
209
+ """CycloneDX 1.5 JSON: syft when installed (or for --image), else built from Maggie's inventory."""
210
+ args = ctx.args
211
+ name = args.name or ctx.site
212
+ out_dir = ctx.state.path("sbom")
213
+ out_dir.mkdir(parents=True, exist_ok=True)
214
+ target = out_dir / f"{name}-{core.iso()[:10]}.cdx.json"
215
+ engine = args.engine
216
+ if engine == "auto":
217
+ engine = "syft" if (args.image or core.resolve_tool("syft", ctx.lock)[1] is not None) else "native"
218
+ if engine == "syft":
219
+ source = args.image if args.image else f"dir:{ctx.project}"
220
+ proc = core.run_tool("syft", [source, "-o", f"cyclonedx-json={target}", "-q"], ctx.lock, timeout=1800)
221
+ if proc.returncode != 0 or not target.is_file():
222
+ raise core.DegradedError(f"syft failed: {core.clean_excerpt(proc.stderr.decode(errors='replace'), 200)}")
223
+ else:
224
+ stack = project_stack(ctx)
225
+ components = []
226
+ for comp in stack["components"]:
227
+ entry = {"type": "library", "name": comp["name"], "version": comp["version"], "bom-ref": comp.get("purl") or f"{comp['ecosystem']}:{comp['name']}@{comp['version']}",
228
+ "properties": [{"name": "maggie:ecosystem", "value": comp["ecosystem"]}, {"name": "maggie:location", "value": str(comp.get("location") or "")},
229
+ {"name": "maggie:scope", "value": comp.get("scope") or "runtime"}]}
230
+ if comp.get("purl"):
231
+ entry["purl"] = comp["purl"]
232
+ components.append(entry)
233
+ wp = stack.get("wordpress") or {}
234
+ for kind, items in (("plugin", wp.get("plugins") or []), ("theme", wp.get("themes") or [])):
235
+ for item in items:
236
+ components.append({"type": "library", "name": item["slug"], "version": item.get("version") or "", "bom-ref": f"wordpress-{kind}:{item['slug']}",
237
+ "purl": f"pkg:wordpress/{item['slug']}@{item.get('version') or ''}", "properties": [{"name": "maggie:wordpress", "value": kind}]})
238
+ for runtime in stack.get("runtimes") or []:
239
+ components.append({"type": "platform" if runtime["name"] in intel.OS_RUNTIMES else "application", "name": runtime["name"],
240
+ "version": runtime["version"], "bom-ref": f"runtime:{runtime['name']}@{runtime['version']}"})
241
+ bom = {"bomFormat": "CycloneDX", "specVersion": "1.5", "serialNumber": f"urn:uuid:{_uuid(name)}", "version": 1,
242
+ "metadata": {"timestamp": core.iso(), "tools": {"components": [{"type": "application", "name": "maggie-security", "version": core.maggie_version()}]},
243
+ "component": {"type": "application", "name": name, "bom-ref": f"site:{name}"}},
244
+ "components": components}
245
+ core.write_json(target, bom)
246
+ kept = sorted(out_dir.glob(f"{name}-*.cdx.json"))
247
+ for old in kept[:-10]:
248
+ old.unlink()
249
+ data = core.read_json(target)
250
+ return {"schema": "maggie-security-sbom-result.v1", "path": str(target), "engine": engine, "components": len(data.get("components") or []),
251
+ "format": f"CycloneDX {data.get('specVersion')}"}
252
+
253
+
254
+ def _uuid(seed: str) -> str:
255
+ digest = core.sha256_bytes(f"{seed}{core.iso()}".encode())
256
+ return f"{digest[:8]}-{digest[8:12]}-4{digest[13:16]}-a{digest[17:20]}-{digest[20:32]}"
257
+
258
+
259
+ # --------------------------------------------------------------------------- image (trivy + dockle)
260
+
261
+
262
+ TRIVY_SEVERITY = {"CRITICAL": "critical", "HIGH": "high", "MEDIUM": "medium", "LOW": "low", "UNKNOWN": "info"}
263
+ DOCKLE_SEVERITY = {"FATAL": "high", "WARN": "medium", "INFO": "low", "SKIP": "info", "PASS": "info"}
264
+
265
+
266
+ def trivy_to_findings(ctx: core.Context, data: dict, ref: str) -> list[dict]:
267
+ findings = []
268
+ os_meta = (data.get("Metadata") or {}).get("OS") or {}
269
+ if os_meta.get("EOSL"):
270
+ findings.append(core.make_finding(
271
+ kind="eol", command="image", primary_id=f"eol-image-os-{os_meta.get('Family')}", site=ctx.site,
272
+ title=f"The image's base OS {os_meta.get('Family')} {os_meta.get('Name')} no longer gets security fixes", severity="high",
273
+ severity_source="tool", confidence="confirmed", asvs_ids=["V15.2.1"],
274
+ affected={"purl": None, "ecosystem": None, "name": str(os_meta.get("Family")), "version": str(os_meta.get("Name")), "component_type": "os",
275
+ "direct": True, "scope": "os"}, target={"image": ref}, tool="trivy",
276
+ remediation={"summary": "Rebuild on a supported base image (e.g. a current alpine/debian tag).", "steps": [], "snippets": [], "references": [],
277
+ "cheat_sheet": None}))
278
+ for result in data.get("Results") or []:
279
+ for vuln in result.get("Vulnerabilities") or []:
280
+ purl = (vuln.get("PkgIdentifier") or {}).get("PURL")
281
+ fixed = [v.strip() for v in str(vuln.get("FixedVersion") or "").split(",") if v.strip()]
282
+ cvss = None
283
+ for source in (vuln.get("CVSS") or {}).values():
284
+ if source.get("V3Score"):
285
+ cvss = {"version": "3.1", "score": float(source["V3Score"]), "vector": source.get("V3Vector") or ""}
286
+ break
287
+ findings.append(core.make_finding(
288
+ kind="vulnerability", command="image", primary_id=vuln.get("VulnerabilityID"), site=ctx.site,
289
+ title=f"{vuln.get('PkgName')} {vuln.get('InstalledVersion')}: {intel.short(vuln.get('Title') or vuln.get('VulnerabilityID'), 120)}",
290
+ severity=TRIVY_SEVERITY.get(str(vuln.get("Severity")).upper(), "info") if not cvss else core.severity_from_cvss(cvss["score"]),
291
+ severity_source="cvss" if cvss else "tool", cvss=cvss, confidence="confirmed", aliases=[vuln.get("VulnerabilityID")],
292
+ cwe_ids=[c for c in vuln.get("CweIDs") or [] if str(c).startswith("CWE-")], asvs_ids=["V15.2.1"],
293
+ affected={"purl": purl.split("?")[0] if purl else None, "ecosystem": result.get("Type"), "name": vuln.get("PkgName"),
294
+ "version": vuln.get("InstalledVersion"), "component_type": "image", "direct": None, "scope": "os" if result.get("Class") == "os-pkgs" else "runtime"},
295
+ fix={"fixed_versions": fixed, "recommended": fixed[0] if fixed else None, "available": bool(fixed)},
296
+ target={"image": ref, "path": result.get("Target")}, tool="trivy", rule_id=vuln.get("VulnerabilityID"),
297
+ remediation={"summary": f"Update {vuln.get('PkgName')} to {fixed[0]} (rebuild the image)." if fixed else "No fix yet; consider another base image.",
298
+ "steps": [], "snippets": [], "references": [vuln["PrimaryURL"]] if vuln.get("PrimaryURL") else [], "cheat_sheet": None},
299
+ untrusted={"description": intel.short(vuln.get("Description"), 400)} if vuln.get("Description") else None))
300
+ for secret in result.get("Secrets") or []:
301
+ findings.append(core.make_finding(
302
+ kind="secret", command="image", primary_id=str(secret.get("RuleID")), site=ctx.site,
303
+ title=f"A secret ({secret.get('Title') or secret.get('RuleID')}) is baked into the image at {result.get('Target')}", severity="high",
304
+ severity_source="tool", confidence="likely", asvs_ids=["V13.3.1"], target={"image": ref, "path": result.get("Target")}, tool="trivy",
305
+ evidence=[{"path": str(result.get("Target")), "line": secret.get("StartLine")}], # the Match line holds the value: dropped
306
+ remediation={"summary": "Rotate it, remove it from the image build, and pass it at run time.", "steps": [], "snippets": [], "references": [],
307
+ "cheat_sheet": None}))
308
+ for misconfig in result.get("Misconfigurations") or []:
309
+ if misconfig.get("Status") == "PASS":
310
+ continue
311
+ findings.append(core.make_finding(
312
+ kind="misconfiguration", command="image", primary_id=str(misconfig.get("ID") or misconfig.get("AVDID")), site=ctx.site,
313
+ title=intel.short(misconfig.get("Title") or misconfig.get("ID"), 200), severity=TRIVY_SEVERITY.get(str(misconfig.get("Severity")).upper(), "low"),
314
+ severity_source="tool", confidence="likely", target={"image": ref, "path": result.get("Target")}, tool="trivy",
315
+ remediation={"summary": intel.short(misconfig.get("Resolution") or "", 300), "steps": [], "snippets": [], "references": [], "cheat_sheet": None}))
316
+ return findings
317
+
318
+
319
+ def dockle_to_findings(ctx: core.Context, data: dict, ref: str) -> list[dict]:
320
+ findings = []
321
+ for detail in data.get("details") or []:
322
+ level = str(detail.get("level", "")).upper()
323
+ if level in ("PASS", "SKIP"):
324
+ continue
325
+ alerts = [core.mask_query(re.sub(r"(?i)((?:password|secret|token|key)[^=\s]*=)\S+", r"\1***", a)) for a in detail.get("alerts") or []]
326
+ findings.append(core.make_finding(
327
+ kind="misconfiguration", command="image", primary_id=str(detail.get("code")), site=ctx.site, title=f"{detail.get('code')}: {detail.get('title')}",
328
+ severity=DOCKLE_SEVERITY.get(level, "low"), severity_source="tool", confidence="likely", target={"image": ref}, tool="dockle",
329
+ evidence=[{"path": ref, "excerpt": core.clean_excerpt(a, 200)} for a in alerts[:3]],
330
+ remediation={"summary": str(detail.get("title")), "steps": [], "snippets": [],
331
+ "references": [f"https://github.com/goodwithtech/dockle/blob/master/CHECKPOINT.md#{str(detail.get('code')).lower()}"], "cheat_sheet": None}))
332
+ return findings
333
+
334
+
335
+ def image(ctx: core.Context):
336
+ args = ctx.args
337
+ ref = args.ref
338
+ findings: list[dict] = []
339
+ if args.trivy_json:
340
+ findings += trivy_to_findings(ctx, core.read_json(Path(args.trivy_json)), ref)
341
+ else:
342
+ db = ctx.state.path("db", "trivy")
343
+ argv = ["image", "--scanners", "vuln,secret,misconfig", "--format", "json", "--cache-dir", str(db), "--exit-on-eol", "0"]
344
+ if args.offline:
345
+ argv += ["--skip-db-update", "--skip-java-db-update", "--offline-scan"]
346
+ argv.append(ref)
347
+ proc = core.run_tool("trivy", argv, ctx.lock, timeout=1800)
348
+ if proc.returncode not in (0, 1) or not proc.stdout:
349
+ raise core.DegradedError(f"trivy failed: {core.clean_excerpt(proc.stderr.decode(errors='replace'), 200)}")
350
+ findings += trivy_to_findings(ctx, json.loads(proc.stdout.decode()), ref)
351
+ ctx.run["tools"].append({"name": "trivy", "version": ctx.lock["tools"]["trivy"]["version"], "sha256": None})
352
+ if args.dockle_json:
353
+ findings += dockle_to_findings(ctx, core.read_json(Path(args.dockle_json)), ref)
354
+ elif not args.trivy_json and core.resolve_tool("dockle", ctx.lock)[1] is not None:
355
+ proc = core.run_tool("dockle", ["--format", "json", "--exit-code", "0", ref], ctx.lock, timeout=900)
356
+ if proc.stdout:
357
+ findings += dockle_to_findings(ctx, json.loads(proc.stdout.decode()), ref)
358
+ if args.ignore_unfixed:
359
+ findings = [f for f in findings if f["kind"] != "vulnerability" or (f.get("fix") or {}).get("available")]
360
+ intel.apply_kev(findings, intel.load_kev(ctx), {"hosts": []})
361
+ return ctx.finish(findings, covered=[f"image:{ref}"])
362
+
363
+
364
+ # --------------------------------------------------------------------------- preinstall (vet a package before installing it)
365
+
366
+
367
+ POPULAR_NPM = ["react", "react-dom", "lodash", "express", "axios", "next", "vue", "typescript", "webpack", "vite", "astro", "eslint", "prettier", "jest",
368
+ "moment", "dayjs", "chalk", "commander", "dotenv", "debug", "uuid", "request", "body-parser", "cors", "jsonwebtoken", "bcrypt", "mongoose",
369
+ "mysql2", "pg", "redis", "sharp", "nodemon", "ts-node", "zod", "yup", "tailwindcss", "postcss", "autoprefixer", "babel-core", "@babel/core",
370
+ "rxjs", "underscore", "async", "bluebird", "socket.io", "ws", "node-fetch", "cross-env", "rimraf", "glob", "minimist", "yargs", "colors",
371
+ "electron", "puppeteer", "playwright", "cheerio", "nodemailer", "stripe", "openai", "@anthropic-ai/sdk", "prisma", "@prisma/client",
372
+ "sequelize", "knex", "passport", "helmet", "morgan", "multer", "date-fns", "classnames", "styled-components", "graphql", "apollo-server"]
373
+ core.SOURCES.setdefault("npm-registry", {"hosts": ["registry.npmjs.org"], "ttl": "6h", "url": "https://registry.npmjs.org/{name}"})
374
+ core.SOURCES.setdefault("scorecard", {"hosts": ["api.scorecard.dev"], "ttl": "7d", "url": "https://api.scorecard.dev/projects/github.com/{owner}/{repo}"})
375
+
376
+
377
+ def edit_distance(a: str, b: str) -> int:
378
+ """Optimal string alignment distance: a swap of two neighbouring letters counts as one edit (lodash/lodahs)."""
379
+ rows = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
380
+ for i in range(len(a) + 1):
381
+ rows[i][0] = i
382
+ for j in range(len(b) + 1):
383
+ rows[0][j] = j
384
+ for i in range(1, len(a) + 1):
385
+ for j in range(1, len(b) + 1):
386
+ cost = a[i - 1] != b[j - 1]
387
+ rows[i][j] = min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost)
388
+ if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
389
+ rows[i][j] = min(rows[i][j], rows[i - 2][j - 2] + 1)
390
+ return rows[-1][-1]
391
+
392
+
393
+ def typosquat_of(name: str) -> str | None:
394
+ bare = name.split("/")[-1].lower()
395
+ if name in POPULAR_NPM:
396
+ return None
397
+ for popular in POPULAR_NPM:
398
+ target = popular.split("/")[-1]
399
+ if bare != target and len(target) >= 4 and (edit_distance(bare, target) == 1 or bare.replace("-", "") == target.replace("-", "")
400
+ or bare in (target + "js", target + "-js", "node-" + target)):
401
+ return popular
402
+ return None
403
+
404
+
405
+ def preinstall(ctx: core.Context) -> dict:
406
+ args = ctx.args
407
+ verdicts = []
408
+ for spec in args.packages:
409
+ name, _, version = spec.rpartition("@") if spec.rfind("@") > 0 else (spec, "", "")
410
+ meta = ctx.cache.get_json("npm-registry", f"https://registry.npmjs.org/{name.replace('/', '%2F')}", ttl="6h")
411
+ reasons_block, reasons_warn, info = [], [], {}
412
+ if meta is None:
413
+ verdicts.append({"package": spec, "verdict": "unknown", "block": [], "warn": ["npm registry did not answer or the package does not exist"]})
414
+ continue
415
+ version = version or (meta.get("dist-tags") or {}).get("latest")
416
+ manifest = (meta.get("versions") or {}).get(version)
417
+ if not manifest:
418
+ verdicts.append({"package": spec, "verdict": "block", "block": [f"version {version} does not exist"], "warn": []})
419
+ continue
420
+ times = meta.get("time") or {}
421
+ created = times.get("created")
422
+ published = times.get(version)
423
+ now = core.utcnow()
424
+ age_days = (now - core.parse_time(created)).days if created else None
425
+ version_hours = (now - core.parse_time(published)).total_seconds() / 3600 if published else None
426
+ scripts = {k: v for k, v in (manifest.get("scripts") or {}).items() if k in ("preinstall", "install", "postinstall", "prepare")}
427
+ info.update({"version": version, "package_age_days": age_days, "version_age_hours": round(version_hours or 0, 1),
428
+ "maintainers": len(meta.get("maintainers") or []), "install_scripts": sorted(scripts), "license": manifest.get("license")})
429
+ squat = typosquat_of(name)
430
+ if squat:
431
+ reasons_block.append(f"name looks like a typo of the popular package {squat}")
432
+ if scripts and age_days is not None and age_days < 30:
433
+ reasons_block.append(f"runs install scripts ({', '.join(sorted(scripts))}) and the package is only {age_days} days old")
434
+ elif scripts:
435
+ reasons_warn.append(f"runs install scripts: {', '.join(sorted(scripts))}")
436
+ if age_days is not None and age_days < 30:
437
+ reasons_warn.append(f"the package is only {age_days} days old")
438
+ if version_hours is not None and version_hours < 72:
439
+ hours = round(version_hours)
440
+ reasons_warn.append(f"this version was published {hours} hour{'s' if hours != 1 else ''} ago (wait 3 days for new releases, or pin the previous one)")
441
+ if manifest.get("deprecated"):
442
+ reasons_warn.append(f"deprecated: {core.clean_excerpt(str(manifest['deprecated']), 120)}")
443
+ stack = {"site": {"id": ctx.site}, "hosts": [], "runtimes": [],
444
+ "components": [{"purl": core.make_purl("npm", name, version), "ecosystem": "npm", "name": name, "version": version, "scope": "runtime",
445
+ "direct": True, "location": "preinstall"}]}
446
+ found, ok = intel.match_osv(ctx, stack, "preinstall")
447
+ for finding in found:
448
+ if finding["kind"] == "malicious-package":
449
+ reasons_block.append(f"known malicious package ({finding['source']['rule_id']})")
450
+ elif finding["severity"] in ("critical", "high") and not (finding.get("fix") or {}).get("available"):
451
+ reasons_block.append(f"{finding['aliases'][0]}: {finding['severity']} with no fixed version")
452
+ else:
453
+ reasons_warn.append(f"{finding['aliases'][0]} ({finding['severity']}); fixed in {(finding.get('fix') or {}).get('recommended')}")
454
+ repo = str((manifest.get("repository") or {}).get("url") if isinstance(manifest.get("repository"), dict) else manifest.get("repository") or "")
455
+ match = re.search(r"github\.com[/:]([^/]+)/([^/.#]+)", repo)
456
+ if match and not args.no_scorecard:
457
+ score = ctx.cache.get_json("scorecard", f"https://api.scorecard.dev/projects/github.com/{match.group(1)}/{match.group(2)}", ttl="7d")
458
+ if score and score.get("score") is not None:
459
+ info["scorecard"] = score["score"]
460
+ if float(score["score"]) < 4.0:
461
+ reasons_warn.append(f"OpenSSF Scorecard {score['score']} (below 4)")
462
+ if args.threshold == "strict":
463
+ reasons_block, reasons_warn = reasons_block + reasons_warn, []
464
+ verdict = "block" if reasons_block else "warn" if reasons_warn else "allow"
465
+ if not ok and verdict == "allow":
466
+ verdict = "unknown"
467
+ verdicts.append({"package": f"{name}@{version}", "verdict": verdict, "block": reasons_block, "warn": reasons_warn, "facts": info,
468
+ "next": f"npm install {name}@{version} --save-exact" if verdict in ("allow", "warn") else None})
469
+ worst = "block" if any(v["verdict"] == "block" for v in verdicts) else "unknown" if any(v["verdict"] == "unknown" for v in verdicts) else "warn" if any(v["verdict"] == "warn" for v in verdicts) else "allow"
470
+ return {"schema": "maggie-security-preinstall.v1", "verdict": worst, "packages": verdicts,
471
+ "note": "Maggie never installs anything; run the `next` command yourself if you accept the verdict.",
472
+ "exit_code": {"block": core.EXIT_FINDINGS, "unknown": core.EXIT_DEGRADED}.get(worst, core.EXIT_OK)}
473
+
474
+
475
+ # --------------------------------------------------------------------------- exposure-check (known malicious packages already installed)
476
+
477
+
478
+ def exposure_check(ctx: core.Context):
479
+ args = ctx.args
480
+ roots = [Path(r) for r in (args.root or [str(ctx.project)])]
481
+ components = []
482
+ for root in roots:
483
+ if not root.is_dir():
484
+ raise core.UsageError(f"not a folder: {root}")
485
+ inv = inventory.Inventory(root, ctx.site, "unknown")
486
+ inv.scan_project()
487
+ for lock in root.rglob("node_modules/.package-lock.json") if args.deep else []:
488
+ inv.safe(inv.parse_package_lock_json, lock)
489
+ components += [c for c in inv.stack([], [])["components"]]
490
+ catalogs = []
491
+ catalog_dir = Path(args.catalog_dir) if args.catalog_dir else ctx.state.path("db", "exposure")
492
+ for path in sorted(catalog_dir.glob("*.json")) if catalog_dir.is_dir() else []:
493
+ try:
494
+ catalogs.append(core.read_json(path))
495
+ except core.UsageError:
496
+ ctx.degrade("exposure-catalog", f"{path.name} is not valid JSON")
497
+ findings = []
498
+ for catalog in catalogs:
499
+ for entry in catalog.get("entries") or []:
500
+ for comp in components:
501
+ if comp["name"] == entry.get("name") and comp["ecosystem"].lower() == str(entry.get("ecosystem", comp["ecosystem"])).lower() and entry.get("version") in ("*", comp["version"]):
502
+ findings.append(core.make_finding(
503
+ kind="malicious-package", command="exposure-check", primary_id=str(entry.get("id") or f"catalog-{entry['name']}"), site=ctx.site,
504
+ title=f"{comp['name']} {comp['version']} is on the incident catalog ({catalog.get('name') or 'local'})", severity="critical",
505
+ severity_source="maggie", confidence="confirmed",
506
+ affected={"purl": comp.get("purl"), "ecosystem": comp["ecosystem"], "name": comp["name"], "version": comp["version"],
507
+ "component_type": "library", "direct": comp.get("direct"), "scope": comp.get("scope")},
508
+ target={"path": comp.get("location")}, remediation={"summary": "Remove it, rotate credentials on machines that installed it, and check how it got in.",
509
+ "steps": [], "snippets": [], "references": [], "cheat_sheet": None}))
510
+ stack = {"site": {"id": ctx.site}, "hosts": [], "runtimes": [], "components": components}
511
+ found, ok = intel.match_osv(ctx, stack, "exposure-check")
512
+ findings += [f for f in found if f["kind"] == "malicious-package"]
513
+ ctx.run["inputs"].update({"roots": [str(r) for r in roots], "components": len(components), "catalogs": len(catalogs)})
514
+ return ctx.finish(findings, covered=["*"] if ok else [])
515
+
516
+
517
+ def configure(p: argparse.ArgumentParser) -> None:
518
+ p.add_argument("--engine", choices=["auto", "osv-scanner", "native"], default="auto",
519
+ help="auto: the pinned osv-scanner if installed, else Maggie's native OSV engine")
520
+ p.add_argument("--lockfiles-only", action="store_true")
521
+ p.add_argument("--bundles-only", action="store_true")
522
+ p.add_argument("--include-dev", action="store_true", help="report dev dependencies too (always on with --ci)")
523
+ p.add_argument("--no-epss", action="store_true")
524
+
525
+
526
+ def _sbom(p: argparse.ArgumentParser) -> None:
527
+ p.add_argument("--image", help="image reference (needs syft)")
528
+ p.add_argument("--name")
529
+ p.add_argument("--engine", choices=["auto", "syft", "native"], default="auto")
530
+
531
+
532
+ def _image(p: argparse.ArgumentParser) -> None:
533
+ p.add_argument("ref")
534
+ p.add_argument("--ignore-unfixed", action="store_true")
535
+ p.add_argument("--trivy-json", help="parse a saved trivy JSON report instead of running trivy")
536
+ p.add_argument("--dockle-json", help="parse a saved dockle JSON report")
537
+
538
+
539
+ def _preinstall(p: argparse.ArgumentParser) -> None:
540
+ p.add_argument("packages", nargs="+", help="npm package names, optionally @version")
541
+ p.add_argument("--threshold", choices=["default", "strict"], default="default")
542
+ p.add_argument("--no-scorecard", action="store_true")
543
+ p.add_argument("--no-epss", action="store_true")
544
+
545
+
546
+ def _exposure(p: argparse.ArgumentParser) -> None:
547
+ p.add_argument("--root", action="append", help="folder to check (default the project); repeatable")
548
+ p.add_argument("--deep", action="store_true", help="also read node_modules/.package-lock.json files")
549
+ p.add_argument("--catalog-dir", help="folder of incident catalogs (default .maggie/security/db/exposure)")
550
+ p.add_argument("--no-epss", action="store_true")
551
+
552
+
553
+ COMMANDS = [core.Command("deps", "known vulnerabilities in dependencies (every lockfile ecosystem) and in JavaScript copied into the site",
554
+ ["osv-api", "retire-jsrepo", "kev", "epss"], configure, deps),
555
+ core.Command("sbom", "CycloneDX SBOM of the project (or an image with syft)", [], _sbom, sbom, findings=False),
556
+ core.Command("image", "container image vulnerabilities, secrets and best practices (trivy + dockle)", ["kev", "github-releases"], _image, image),
557
+ core.Command("preinstall", "vet an npm package before installing it (age, install scripts, typosquats, malware, vulnerabilities, Scorecard)",
558
+ ["npm-registry", "osv-api", "scorecard", "kev", "epss"], _preinstall, preinstall, findings=False),
559
+ core.Command("exposure-check", "known malicious packages already installed (OSV MAL- records and incident catalogs)", ["osv-api", "kev"],
560
+ _exposure, exposure_check)]