@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.
- package/README-zh-TW.md +1 -1
- package/README.md +4 -4
- package/bundled-skills/maggie-security/SKILL.md +10 -2
- package/bundled-skills/maggie-security/references/commands.md +12 -4
- package/bundled-skills/maggie-security/references/explaining-findings.md +7 -0
- package/bundled-skills/maggie-security/references/tools-and-licences.md +8 -0
- package/bundled-skills/maggie-security/registry/security-tools.registry.json +748 -652
- package/bundled-tools/clis/maggie_security.py +1 -1
- package/bundled-tools/clis/maggie_security_code.py +49 -1
- package/bundled-tools/clis/maggie_security_deps.py +95 -0
- package/bundled-tools/clis/maggie_security_host.py +121 -0
- package/bundled-tools/clis/maggie_security_mcp.py +69 -3
- package/bundled-tools/clis/maggie_security_report.py +170 -7
- package/bundled-tools/clis/maggie_security_scan.py +168 -0
- package/bundled-tools/clis/maggie_security_web.py +45 -1
- package/bundled-tools/clis/security_data/tools.lock.json +36 -7
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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"
|
|
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":
|