@topy-ai/maggie 0.7.60 → 0.8.0

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.
@@ -31,7 +31,7 @@ import maggie_security_core as core # noqa: E402
31
31
  AREA_MODULES = [
32
32
  "maggie_security_intel", "maggie_security_deps", "maggie_security_code", "maggie_security_web",
33
33
  "maggie_security_protect", "maggie_security_logs", "maggie_security_host", "maggie_security_health",
34
- "maggie_security_standards", "maggie_security_agent", "maggie_security_report", "maggie_security_mcp", "maggie_security_hosted",
34
+ "maggie_security_standards", "maggie_security_agent", "maggie_security_report", "maggie_security_scan", "maggie_security_mcp", "maggie_security_hosted",
35
35
  ]
36
36
 
37
37
  # Commands from the PRDs that are not built yet, with their milestone, so the
@@ -535,6 +535,53 @@ def semgrep_to_findings(ctx: core.Context, data: dict, project: Path) -> list[di
535
535
  return findings
536
536
 
537
537
 
538
+ # Bearer-inspired privacy lens (Bearer itself is Elastic-licensed and cannot be run
539
+ # or bundled, so its detection *patterns* are reimplemented as native rules): flag a
540
+ # log/print call that puts personal data in the output — a GDPR hazard (CWE-532).
541
+ PII_LOG_CALL = re.compile(
542
+ r"(?i)(console\.(log|info|warn|error|debug)|[^\w.](print|println|printf)\s*\(|fmt\.(Print|Printf|Println|Sprintf)|"
543
+ r"System\.out\.print|var_dump|print_r|error_log|\b(logger|log|logging|logr|slog|console)\s*\.\s*"
544
+ r"(info|debug|warn|warning|error|trace|notice|log)\b)")
545
+ PII_TOKEN = re.compile(
546
+ r"(?i)\b(e[-_]?mail(?:address|_address)?|ssn|social[_\s-]?security|passport(?:[_\s-]?(?:no|number))?|"
547
+ r"credit[_\s-]?card|card[_\s-]?number|cardnum|cvv|cvc|iban|phone[_\s-]?number|date[_\s-]?of[_\s-]?birth|"
548
+ r"\bdob\b|national[_\s-]?id|tax[_\s-]?id|driver'?s?[_\s-]?licen[cs]e)\b")
549
+ PII_SOURCE_EXT = re.compile(r"(?i)\.(js|jsx|ts|tsx|mjs|cjs|py|php|rb|go|java|kt|cs|scala|swift|rs|vue|svelte|ex|exs)$")
550
+
551
+
552
+ def native_pii_checks(ctx: core.Context, files: list[Path], project: Path) -> list[dict]:
553
+ """Logging of personal data (GDPR / CWE-532) — one finding per file, lines as evidence."""
554
+ findings = []
555
+ for path in files:
556
+ if not PII_SOURCE_EXT.search(path.name):
557
+ continue
558
+ text = read_text(path)
559
+ if not text:
560
+ continue
561
+ hits = []
562
+ for number, line in enumerate(text.splitlines(), 1):
563
+ if len(line) > 400:
564
+ continue
565
+ if PII_LOG_CALL.search(line) and PII_TOKEN.search(line):
566
+ hits.append((number, line.strip()[:200]))
567
+ if len(hits) >= 10:
568
+ break
569
+ if not hits:
570
+ continue
571
+ rel = path.relative_to(project).as_posix()
572
+ findings.append(core.make_finding(
573
+ kind="code", command="code-scan", primary_id="maggie.privacy.pii-in-logs", site=ctx.site,
574
+ title=f"Personal data may be written to logs ({rel})", severity="low", severity_source="maggie",
575
+ confidence="possible", cwe_ids=["CWE-532"], asvs_ids=["V7.1.1"], target={"path": rel}, tool="maggie-native",
576
+ rule_id="maggie.privacy.pii-in-logs",
577
+ evidence=[{"path": rel, "line": n, "excerpt": core.clean_excerpt(ln, 200)} for n, ln in hits[:5]],
578
+ remediation={"summary": "Avoid logging personal data (email, SSN, card, phone, DOB…). Redact or omit it, or log an opaque id instead. "
579
+ "Logs are often shipped to third parties and retained — this is a GDPR exposure.",
580
+ "steps": [], "snippets": [], "references": ["https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html"],
581
+ "cheat_sheet": "Logging"}))
582
+ return findings
583
+
584
+
538
585
  def native_config_checks(ctx: core.Context, files: list[Path], project: Path) -> list[dict]:
539
586
  """Two checks semgrep expresses poorly: Dockerfile final stage without USER, nginx serving dotfiles."""
540
587
  findings = []
@@ -598,7 +645,8 @@ def code_scan(ctx: core.Context):
598
645
  else:
599
646
  targets = [str(project / p) for p in (args.paths or ["."])]
600
647
  files = [Path(t) for t in targets if Path(t).is_file()] + [f for t in targets if Path(t).is_dir() for f in text_files(Path(t), project)]
601
- findings = native_config_checks(ctx, [f for f in files if not any(part in SKIP_DIRS for part in f.relative_to(project).parts)], project)
648
+ in_scope = [f for f in files if not any(part in SKIP_DIRS for part in f.relative_to(project).parts)]
649
+ findings = native_config_checks(ctx, in_scope, project) + native_pii_checks(ctx, in_scope, project)
602
650
  with tempfile.TemporaryDirectory() as tmp:
603
651
  out = Path(tmp) / "semgrep.json"
604
652
  argv = ["scan", "--json", "--json-output", str(out), "--timeout", "30", "--max-target-bytes", "1000000", "--quiet"]
@@ -361,6 +361,99 @@ def image(ctx: core.Context):
361
361
  return ctx.finish(findings, covered=[f"image:{ref}"])
362
362
 
363
363
 
364
+ # --------------------------------------------------------------------------- image-patch (copa remediation plan)
365
+
366
+ # image-patch completes the safe-patching story for containers: `image` detects the
367
+ # CVEs, image-patch turns a Trivy report into a reviewed copa command that rebuilds
368
+ # the image with the OS-package fixes applied — into a NEW tag, so the original is
369
+ # untouched. Plan only (like fix-plan/protect-plan): Maggie never runs copa; the
370
+ # operator reviews it and runs it where Docker/buildkit is available.
371
+
372
+
373
+ def _patched_tag(ref: str, override: str | None) -> str:
374
+ if override:
375
+ return override
376
+ name, _, _tag = ref.rpartition(":")
377
+ return f"{name}:patched" if name and "/" not in _tag else f"{ref}-patched"
378
+
379
+
380
+ def image_patch(ctx: core.Context) -> dict:
381
+ import shlex
382
+
383
+ args = ctx.args
384
+ ref = args.ref
385
+ tag = _patched_tag(ref, args.tag)
386
+ pkg_types = args.pkg_types
387
+ report = args.report or "scan.json"
388
+
389
+ fixable = total = None
390
+ if args.report:
391
+ data = core.read_json(Path(args.report))
392
+ fixed_ids, all_ids = set(), set()
393
+ for result in data.get("Results") or []:
394
+ for vuln in result.get("Vulnerabilities") or []:
395
+ vid = vuln.get("VulnerabilityID")
396
+ all_ids.add(vid)
397
+ if vuln.get("FixedVersion"):
398
+ fixed_ids.add(vid)
399
+ fixable, total = len(fixed_ids), len(all_ids)
400
+
401
+ scan_cmd = f"trivy image --quiet --scanners vuln --pkg-types os --format json -o {shlex.quote(report)} {shlex.quote(ref)}"
402
+ patch_cmd = f"copa patch -i {shlex.quote(ref)} -r {shlex.quote(report)} --pkg-types {pkg_types} -t {shlex.quote(tag)}"
403
+ verify_cmd = f"trivy image --quiet {shlex.quote(tag)}"
404
+
405
+ sh = [
406
+ "#!/usr/bin/env bash",
407
+ f"# Container patch plan for {ref} — generated {core.iso()[:10]} by maggie security image-patch.",
408
+ "# REVIEW BEFORE RUNNING. Maggie did not and will not run any of this.",
409
+ "# copa rebuilds the image with OS-package fixes into a NEW tag; the original image is untouched.",
410
+ "# Prerequisites: copa (maggie security tools install --tool copa) and a running Docker/buildkit.",
411
+ "set -euo pipefail",
412
+ "",
413
+ "# 1. Scan the image to a report copa can read:" if not args.report else f"# 1. Using the provided report {report}.",
414
+ ]
415
+ if not args.report:
416
+ sh.append(scan_cmd)
417
+ sh += [
418
+ "# 2. Patch OS packages into a new tag (original image untouched):",
419
+ patch_cmd,
420
+ "# 3. Re-scan the patched image to confirm the fixes, then test it before you deploy:",
421
+ verify_cmd,
422
+ "",
423
+ ]
424
+ script = "\n".join(sh).rstrip() + "\n"
425
+ if args.script:
426
+ path = Path(args.script)
427
+ path.parent.mkdir(parents=True, exist_ok=True)
428
+ path.write_text(script, encoding="utf-8")
429
+ path.chmod(0o755)
430
+
431
+ md = [f"# Container patch plan — {ref}", "",
432
+ "**Review before running — Maggie never runs copa.** copa rebuilds the image with OS-package "
433
+ "fixes into a **new tag** (`" + tag + "`); the original image is untouched. Needs Docker/buildkit.", ""]
434
+ if fixable is not None:
435
+ md.append(f"The report lists **{fixable} fixable** OS-package CVE(s)" + (f" of {total} total." if total else ".") + "")
436
+ md.append("")
437
+ md += ["```bash", (scan_cmd + "\n" if not args.report else "") + patch_cmd + "\n" + verify_cmd, "```", "",
438
+ "After patching: re-scan with `maggie security image --ref " + tag + "` and test the image before deploying."]
439
+ runbook = "\n".join(md)
440
+
441
+ return {"schema": "maggie-security-image-patch.v1", "ref": ref, "patched_tag": tag, "pkg_types": pkg_types,
442
+ "fixable": fixable, "total": total, "commands": {"scan": scan_cmd, "patch": patch_cmd, "verify": verify_cmd},
443
+ "script_path": str(args.script) if args.script else None,
444
+ "note": "Plan only. Maggie never runs copa; it rebuilds into a new tag, leaving the original image untouched.",
445
+ "markdown": runbook}
446
+
447
+
448
+ def _image_patch(p: argparse.ArgumentParser) -> None:
449
+ p.add_argument("ref", help="the image to patch, e.g. myapp:1.0")
450
+ p.add_argument("--report", help="a Trivy JSON report for the image (else the plan generates one with trivy)")
451
+ p.add_argument("--tag", help="output tag for the patched image (default <ref>-patched)")
452
+ p.add_argument("--pkg-types", choices=["os", "os,library"], default="os", dest="pkg_types",
453
+ help="os (default) or os,library to also patch application dependencies")
454
+ p.add_argument("--script", help="write the runnable plan to this path (chmod 755)")
455
+
456
+
364
457
  # --------------------------------------------------------------------------- preinstall (vet a package before installing it)
365
458
 
366
459
 
@@ -554,6 +647,8 @@ COMMANDS = [core.Command("deps", "known vulnerabilities in dependencies (every l
554
647
  ["osv-api", "retire-jsrepo", "kev", "epss"], configure, deps),
555
648
  core.Command("sbom", "CycloneDX SBOM of the project (or an image with syft)", [], _sbom, sbom, findings=False),
556
649
  core.Command("image", "container image vulnerabilities, secrets and best practices (trivy + dockle)", ["kev", "github-releases"], _image, image),
650
+ core.Command("image-patch", "a reviewed copa plan to rebuild a container image with OS-package fixes into a new tag; never applied by Maggie",
651
+ [], _image_patch, image_patch, findings=False, routine_safe=False),
557
652
  core.Command("preinstall", "vet an npm package before installing it (age, install scripts, typosquats, malware, vulnerabilities, Scorecard)",
558
653
  ["npm-registry", "osv-api", "scorecard", "kev", "epss"], _preinstall, preinstall, findings=False),
559
654
  core.Command("exposure-check", "known malicious packages already installed (OSV MAL- records and incident catalogs)", ["osv-api", "kev"],
@@ -215,6 +215,125 @@ def host_quick(ctx: core.Context):
215
215
  return ctx.finish(findings, covered=[f"host:{host}", "*"])
216
216
 
217
217
 
218
+ # --------------------------------------------------------------------------- host-impact (patch blast-radius)
219
+
220
+ # The blast-radius collector answers "how big is the impact of patching X on this
221
+ # host?" — the input the fix-risk tiers need to judge a patch against what is
222
+ # actually running. Read-only. Techniques ported from PatchMon (apt security
223
+ # simulate + origin parsing; /var/run/reboot-required + kernel comparison;
224
+ # needrestart for the patched-but-not-restarted gap).
225
+ IMPACT_COLLECTOR = r"""set +e
226
+ s() { printf '\n### %s\n' "$1"; }
227
+ SUDO=""; [ "$(id -u)" != 0 ] && sudo -n true 2>/dev/null && SUDO="sudo -n"
228
+ s os-release; cat /etc/os-release 2>/dev/null
229
+ s security-updates; LANG=C apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null | grep '^Inst ' | head -300
230
+ s reboot-required; [ -f /var/run/reboot-required ] && echo yes || echo no
231
+ s reboot-pkgs; cat /var/run/reboot-required.pkgs 2>/dev/null | head -50
232
+ s kernel; uname -r; ls -1 /boot/vmlinuz-* 2>/dev/null | sed 's#.*/vmlinuz-##' | sort -V | tail -1
233
+ s needrestart; $SUDO needrestart -b 2>/dev/null | grep -E '^NEEDRESTART-(KCUR|KEXP|KSTA|SVC):' | head -200
234
+ s listen; ss -H -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null
235
+ s running-services; systemctl list-units --type=service --state=running --no-legend 2>/dev/null | awk '{print $1}' | head -200
236
+ s timers; systemctl list-timers --all --no-legend 2>/dev/null | awk '{print $NF}' | grep -v '^$' | head -60
237
+ s cron; ls -1 /etc/cron.d /etc/cron.daily /etc/cron.hourly 2>/dev/null | grep -v ':$' | sort -u | head -40; $SUDO ls -1 /var/spool/cron/crontabs 2>/dev/null | head -20
238
+ """
239
+
240
+ LISTEN_PROC_RE = re.compile(r'users:\(\("([^"]+)"')
241
+ APT_INST_RE = re.compile(r"^Inst\s+(\S+)\s+(?:\[[^\]]*\]\s+)?\(\S+\s*([^)]*)\)") # name, then the origin tail
242
+
243
+
244
+ def parse_impact(sections: dict[str, str]) -> dict:
245
+ """Turn the collector sections into a patch blast-radius snapshot."""
246
+ def lines(name: str) -> list[str]:
247
+ return [l.strip() for l in sections.get(name, "").splitlines() if l.strip()]
248
+
249
+ security, pending = [], []
250
+ for line in lines("security-updates"):
251
+ m = APT_INST_RE.match(line)
252
+ if not m:
253
+ continue
254
+ name, origins = m.group(1), m.group(2) or ""
255
+ pending.append(name)
256
+ if "security" in origins.lower(): # only the origin tail — package names can contain "security"
257
+ security.append(name)
258
+
259
+ reboot = (sections.get("reboot-required", "").strip().lower().startswith("yes"))
260
+ reboot_pkgs = lines("reboot-pkgs")
261
+ kl = lines("kernel")
262
+ running_k = kl[0] if kl else None
263
+ latest_k = kl[1] if len(kl) > 1 else None
264
+
265
+ svc_restart, kcur, kexp, ksta = [], running_k, None, None
266
+ for line in lines("needrestart"):
267
+ tag, _, val = line.partition(":")
268
+ val = val.strip()
269
+ if tag == "NEEDRESTART-SVC":
270
+ svc_restart.append(val)
271
+ elif tag == "NEEDRESTART-KCUR":
272
+ kcur = val
273
+ elif tag == "NEEDRESTART-KEXP":
274
+ kexp = val
275
+ elif tag == "NEEDRESTART-KSTA":
276
+ ksta = val
277
+ if kexp and not latest_k:
278
+ latest_k = kexp
279
+ kernel_stale = bool(ksta == "3") or bool(running_k and latest_k and running_k != latest_k)
280
+
281
+ listening = []
282
+ for line in lines("listen"):
283
+ proc = LISTEN_PROC_RE.search(line)
284
+ addr = line.split()[3] if len(line.split()) > 3 else ""
285
+ port = addr.rsplit(":", 1)[-1] if ":" in addr else addr
286
+ if proc and port:
287
+ listening.append({"port": port, "process": proc.group(1)})
288
+
289
+ return {"schema": "maggie-security-impact.v1",
290
+ "reboot_required": reboot or kernel_stale, "reboot_pkgs": reboot_pkgs,
291
+ "kernel": {"running": kcur, "latest": latest_k, "stale": kernel_stale},
292
+ "services_need_restart": sorted(set(svc_restart)),
293
+ "security_updates": sorted(set(security)), "pending_updates": sorted(set(pending)),
294
+ "listening": listening, "running_services": lines("running-services"),
295
+ "timers": lines("timers"), "cron": lines("cron"),
296
+ "needrestart_available": bool(sections.get("needrestart", "").strip())}
297
+
298
+
299
+ def impact_findings(ctx: core.Context, snapshot: dict, host: str) -> list[dict]:
300
+ findings: list[dict] = []
301
+ svcs = snapshot["services_need_restart"]
302
+ if svcs:
303
+ shown = ", ".join(svcs[:8]) + (f" (+{len(svcs) - 8} more)" if len(svcs) > 8 else "")
304
+ findings.append(check(
305
+ "hq-needs-restart", f"{len(svcs)} running service(s) still use pre-update libraries — restart needed", "medium",
306
+ "A library was updated on disk but these services are still running the old copy in memory, so the fix is not live yet. "
307
+ "Restart them (or reboot if the kernel changed).", host=host, ctx=ctx,
308
+ evidence=shown, steps=["sudo needrestart -r a # review, then restart the services it lists"] +
309
+ [f"sudo systemctl restart {shlex.quote(s)}" for s in svcs[:8]]))
310
+ if snapshot["reboot_required"]:
311
+ why_pkgs = (": " + ", ".join(snapshot["reboot_pkgs"][:6])) if snapshot["reboot_pkgs"] else ""
312
+ kern = snapshot["kernel"]
313
+ detail = f"running {kern['running']}, installed {kern['latest']}" if kern.get("stale") and kern.get("latest") else why_pkgs
314
+ findings.append(check(
315
+ "hq-reboot-required", "The server needs a reboot to finish applying updates", "medium",
316
+ "A kernel or core library update is only fully applied after a reboot. Schedule one in a maintenance window.",
317
+ host=host, ctx=ctx, evidence=detail.strip(" :"),
318
+ steps=["# schedule a reboot in a maintenance window, then:", "sudo shutdown -r +1 'Maggie: applying security updates'"]))
319
+ return findings
320
+
321
+
322
+ def host_impact(ctx: core.Context):
323
+ if ctx.args.print_collector:
324
+ return {"_raw": IMPACT_COLLECTOR}
325
+ text, host = evidence_text(ctx, IMPACT_COLLECTOR)
326
+ sections = parse_sections(text)
327
+ if not sections:
328
+ raise core.UsageError("the evidence has no '### section' blocks; run the collector from --print-collector")
329
+ snapshot = parse_impact(sections)
330
+ snapshot.update({"host": host, "collected_at": core.iso()})
331
+ core.write_json(ctx.state.path("inventory", "impact.json"), snapshot)
332
+ findings = impact_findings(ctx, snapshot, host)
333
+ ctx.run["inputs"].update({"host": host, "impact": "inventory/impact.json"})
334
+ return ctx.finish(findings, covered=[f"host:{host}", "*"])
335
+
336
+
218
337
  # --------------------------------------------------------------------------- host-audit (lynis report.dat)
219
338
 
220
339
 
@@ -540,4 +659,6 @@ COMMANDS = [
540
659
  routine_safe=False),
541
660
  core.Command("host-nginx-config", "nginx and PHP configuration rules (hidden files, PHP in uploads, TLS, headers, rate limits)", [], _quick,
542
661
  host_nginx_config),
662
+ core.Command("host-impact", "patch blast-radius: which pending updates are security-only, what needs restart/reboot, and what is running "
663
+ "(read-only; feeds fix-plan's risk tiers)", [], _quick, host_impact),
543
664
  ]
@@ -73,7 +73,7 @@ SCHEMAS: dict[str, dict] = {
73
73
  "mode": {"enum": ["traffic", "attacks", "ioc"]}, "file": LOGFILE, "since": SINCE,
74
74
  "rules": {"type": "string", "pattern": r"^(web|proxy|emerging)(,(web|proxy|emerging))*$"}}},
75
75
  "security_host_check": {"type": "object", "required": ["host"], "additionalProperties": False, "properties": {
76
- "host": ALIAS, "depth": {"enum": ["quick", "nginx"]}}},
76
+ "host": ALIAS, "depth": {"enum": ["quick", "nginx", "impact"]}}},
77
77
  "security_protect_plan": {"type": "object", "required": ["host"], "additionalProperties": False, "properties": {
78
78
  "host": ALIAS, "engine": {"enum": ["auto", "crowdsec", "fail2ban", "cloudflare"]}, "site_type": {"enum": ["wordpress", "php-app", "node-app", "static", "workers"]}}},
79
79
  "security_uptime_status": {"type": "object", "additionalProperties": False, "properties": {"window": {"enum": ["1h", "24h", "7d", "30d"]}}},
@@ -95,11 +95,29 @@ SCHEMAS: dict[str, dict] = {
95
95
  "id": {"type": "string", "pattern": r"^msf_[0-9a-f]{24}$"}, "action": {"enum": ["accept", "false-positive", "reopen"]},
96
96
  "reason": {"type": "string", "maxLength": 300}, "until": {"type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}$"},
97
97
  "confirm": {"type": "boolean"}, "confirmation": {"type": "string", "maxLength": 80}}},
98
+ "security_image_patch": {"type": "object", "required": ["ref"], "additionalProperties": False, "properties": {
99
+ "ref": {"type": "string", "pattern": r"^[a-z0-9][a-z0-9._/:-]*$", "description": "the image to patch, e.g. myapp:1.0"},
100
+ "report": {"type": "string", "maxLength": 300, "description": "a Trivy JSON report inside the project"},
101
+ "tag": {"type": "string", "maxLength": 200}, "pkg_types": {"enum": ["os", "os,library"]}}},
102
+ "security_scan": {"type": "object", "additionalProperties": False, "properties": {
103
+ "profile": {"type": "string", "maxLength": 20, "description": "override the detected site profile, e.g. wordpress, node-app"},
104
+ "domain": {"type": "string", "pattern": DOMAIN, "description": "verified domain for the web checks (headers, tls)"},
105
+ "kind": {"enum": ["weekly", "daily_ops"]}, "fix_plan": {"type": "boolean"}}},
106
+ "security_node_check": {"type": "object", "additionalProperties": False, "properties": {
107
+ "node_version": {"type": "string", "pattern": r"^\d+(\.\d+){0,2}$", "description": "Node.js version (default: the versions in the inventory)"}}},
108
+ "security_fix_plan": {"type": "object", "additionalProperties": False, "properties": {
109
+ "priority": {"enum": ["p0", "p1", "p2", "p3", "p4"], "description": "only include findings at or above this priority"},
110
+ "since": SINCE}},
111
+ "security_incident": {"type": "object", "required": ["action"], "additionalProperties": False, "properties": {
112
+ "action": {"enum": ["start", "log", "close"]}, "id": {"type": "string", "maxLength": 80},
113
+ "title": {"type": "string", "maxLength": 200}, "cve": {"type": "string", "maxLength": 40},
114
+ "finding": {"type": "string", "pattern": r"^msf_[0-9a-f]{24}$"}, "event": {"type": "string", "maxLength": 40},
115
+ "note": {"type": "string", "maxLength": 300}, "confirm": {"type": "boolean"}, "confirmation": {"type": "string", "maxLength": 80}}},
98
116
  }
99
117
  NETWORK_TOOLS = {"security_vuln_watch", "security_osv_query", "security_wp_check", "security_eol_check", "security_deps_scan",
100
118
  "security_domain_verify_check", "security_headers_check", "security_tls_check", "security_web_scan", "security_public_check",
101
119
  "security_uptime_status",
102
- "security_checklist_get", "security_package_vet", "security_exposure_check", "security_image_scan"}
120
+ "security_checklist_get", "security_package_vet", "security_exposure_check", "security_image_scan", "security_node_check", "security_scan"}
103
121
 
104
122
 
105
123
  def registry_path() -> Path:
@@ -229,7 +247,7 @@ class Server:
229
247
  argv += ["--rules", args["rules"]]
230
248
  return argv
231
249
  if name == "security_host_check":
232
- command = "host-nginx-config" if args.get("depth") == "nginx" else "host-quick"
250
+ command = {"nginx": "host-nginx-config", "impact": "host-impact"}.get(args.get("depth"), "host-quick")
233
251
  return [command] if dry else [command, "--ssh", f"{args['host']}={self.host_target(args['host'])}"]
234
252
  if name == "security_protect_plan":
235
253
  argv = ["protect-plan", "--host", args.get("host", "web1"), "--engine", args.get("engine", "auto")]
@@ -269,6 +287,54 @@ class Server:
269
287
  return ["image", args.get("ref", "x")]
270
288
  if name == "security_sbom_build":
271
289
  return ["sbom"]
290
+ if name == "security_image_patch":
291
+ argv = ["image-patch"]
292
+ if dry:
293
+ return argv
294
+ argv.append(args["ref"])
295
+ if args.get("report"):
296
+ argv += ["--report", self.safe_log_path(args["report"])]
297
+ if args.get("tag"):
298
+ argv += ["--tag", args["tag"]]
299
+ if args.get("pkg_types"):
300
+ argv += ["--pkg-types", args["pkg_types"]]
301
+ return argv
302
+ if name == "security_scan":
303
+ argv = ["scan"]
304
+ if dry:
305
+ return argv
306
+ if args.get("profile"):
307
+ argv += ["--profile", args["profile"]]
308
+ if args.get("domain"):
309
+ argv += ["--domain", args["domain"]]
310
+ if args.get("kind"):
311
+ argv += ["--kind", args["kind"]]
312
+ if args.get("fix_plan"):
313
+ argv += ["--fix-plan"]
314
+ return argv
315
+ if name == "security_node_check":
316
+ argv = ["node-check"]
317
+ if not dry and args.get("node_version"):
318
+ argv += ["--node-version", args["node_version"]]
319
+ return argv
320
+ if name == "security_fix_plan":
321
+ argv = ["fix-plan"]
322
+ if dry:
323
+ return argv
324
+ if args.get("priority"):
325
+ argv += ["--priority", args["priority"]]
326
+ if args.get("since"):
327
+ argv += ["--since", args["since"]]
328
+ return argv
329
+ if name == "security_incident":
330
+ if dry:
331
+ return ["incident"]
332
+ argv = ["incident", args["action"]]
333
+ for key, flag in (("id", "--id"), ("title", "--title"), ("cve", "--cve"), ("finding", "--finding"),
334
+ ("event", "--event"), ("note", "--note")):
335
+ if args.get(key):
336
+ argv += [flag, args[key]]
337
+ return argv
272
338
  if name == "security_report_build":
273
339
  return ["report", "--kind", args.get("kind", "weekly"), "--audience", args.get("audience", "owner")]
274
340
  if name == "security_ledger_mark":