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