@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
|
@@ -161,6 +161,32 @@ def why(finding: dict) -> str:
|
|
|
161
161
|
return f"{severity} known flaw (CVE) affects the version you run."
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
# SSVC (CISA Stakeholder-Specific Vulnerability Categorization) gives each finding a
|
|
165
|
+
# defensible "do it now vs watch it" decision from signals we already have, rather
|
|
166
|
+
# than a bare severity number. A faithful simplification of the deployer tree:
|
|
167
|
+
# Exploitation (KEV→active, EPSS→likely) × Automatable (EPSS/critical) × Impact (severity).
|
|
168
|
+
SSVC_HELP = {"Act": "fix now", "Attend": "fix soon (this cycle)", "Track*": "watch closely", "Track": "routine"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def ssvc_decision(finding: dict) -> dict:
|
|
172
|
+
kev = bool((finding.get("kev") or {}).get("listed"))
|
|
173
|
+
epss = (finding.get("epss") or {}).get("score") or 0
|
|
174
|
+
severity = finding.get("severity", "medium")
|
|
175
|
+
exploitation = "active" if kev else ("likely" if epss >= 0.1 else "none")
|
|
176
|
+
high_impact = severity in ("critical", "high")
|
|
177
|
+
automatable = epss >= 0.1 or severity == "critical"
|
|
178
|
+
if exploitation == "active":
|
|
179
|
+
decision = "Act" if high_impact else "Attend"
|
|
180
|
+
elif automatable and high_impact:
|
|
181
|
+
decision = "Attend"
|
|
182
|
+
elif high_impact or automatable:
|
|
183
|
+
decision = "Track*"
|
|
184
|
+
else:
|
|
185
|
+
decision = "Track"
|
|
186
|
+
return {"decision": decision, "exploitation": exploitation,
|
|
187
|
+
"automatable": bool(automatable), "impact": "high" if high_impact else "partial"}
|
|
188
|
+
|
|
189
|
+
|
|
164
190
|
def how(finding: dict) -> str:
|
|
165
191
|
steps = (finding.get("remediation") or {}).get("steps") or []
|
|
166
192
|
step = next((s for s in steps if not s.startswith("Listed by CISA")), None)
|
|
@@ -298,7 +324,8 @@ def build(ctx: core.Context) -> dict:
|
|
|
298
324
|
"verdict": verdict_info,
|
|
299
325
|
"counts": {"new": sum(1 for f in findings if f["status"] == "new"), "open": sum(1 for f in findings if f["status"] == "open"),
|
|
300
326
|
"fixed": len(fixed_ids), "by_priority": {p: sum(1 for f in actionable if f["priority"] == p) for p in core.PRIORITIES}},
|
|
301
|
-
"actions": [{"finding_id": a["ids"][0], "finding_ids": a["ids"], "priority": a["priority"], "title": a["title"]
|
|
327
|
+
"actions": [{"finding_id": a["ids"][0], "finding_ids": a["ids"], "priority": a["priority"], "title": a["title"],
|
|
328
|
+
"ssvc": ssvc_decision(a["lead"])["decision"]}
|
|
302
329
|
for a in group_actions(actions)[:5]],
|
|
303
330
|
"coverage": None,
|
|
304
331
|
"checked": checked, "not_checked": missing, "degraded_sources": degraded,
|
|
@@ -401,10 +428,12 @@ def render(data: dict, findings: list[dict], audience: str, site: str, clip: boo
|
|
|
401
428
|
lines.append(f" How: {action['how']}")
|
|
402
429
|
if not owner:
|
|
403
430
|
lead = action["lead"]
|
|
431
|
+
ssvc = ssvc_decision(lead)
|
|
404
432
|
lines.append(f" {action['count']} finding(s): " + ", ".join(f"`{i}`" for i in action["ids"][:6])
|
|
405
433
|
+ (" …" if len(action["ids"]) > 6 else "") + f" · {', '.join((lead.get('aliases') or [])[:3])}"
|
|
406
434
|
+ (f" · CVSS {lead['cvss']['score']}" if lead.get("cvss") else "")
|
|
407
|
-
+ (f" · EPSS {lead['epss']['score']:.2f}" if lead.get("epss") else "")
|
|
435
|
+
+ (f" · EPSS {lead['epss']['score']:.2f}" if lead.get("epss") else "")
|
|
436
|
+
+ f" · SSVC {ssvc['decision']} ({SSVC_HELP[ssvc['decision']]})")
|
|
408
437
|
if len(urgent_actions) > 5:
|
|
409
438
|
lines.append(f"\n…and {len(urgent_actions) - 5} more below.")
|
|
410
439
|
lines.append("")
|
|
@@ -547,6 +576,53 @@ def fix_category(finding: dict) -> str:
|
|
|
547
576
|
return "other"
|
|
548
577
|
|
|
549
578
|
|
|
579
|
+
# Fix-risk tiers answer "will this patch break something?", a separate axis from
|
|
580
|
+
# "how urgent is the flaw?" (severity/KEV/EPSS). The operator uses both: a KEV
|
|
581
|
+
# critical justifies a riskier tier sooner, but the tier decides HOW to apply it.
|
|
582
|
+
FIX_TIERS = [
|
|
583
|
+
("T1", "Safe to batch-apply",
|
|
584
|
+
"Distro security-only updates and file-permission fixes. ABI-stable within the release; very low breakage risk. Do these first."),
|
|
585
|
+
("T2", "Apply, then restart and smoke-test",
|
|
586
|
+
"App package upgrades (no major-version change) and plan-only setups. Low–moderate risk: restart the affected service and check it serves."),
|
|
587
|
+
("T3", "Apply one at a time — validate and auto-rollback",
|
|
588
|
+
"Config hardening (TLS, headers, firewall, vhosts). Changes behaviour; can break old clients or lock you out. Each step backs up, validates, and reverts on failure."),
|
|
589
|
+
("T4", "Do NOT auto-apply — stage first",
|
|
590
|
+
"Major-version jumps and end-of-life moves. High compatibility risk. Test in staging, schedule a change window; record as accepted-risk until then."),
|
|
591
|
+
]
|
|
592
|
+
TIER_LABEL = {t: f"{t} — {name}" for t, name, _ in FIX_TIERS}
|
|
593
|
+
TIER_ORDER = [t for t, _, _ in FIX_TIERS]
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _is_major_jump(current: str | None, fixed: str | None) -> bool:
|
|
597
|
+
"""A different leading integer between the installed and fixed version — the
|
|
598
|
+
semver signal for a breaking upgrade (nginx 1.x→2.x, OpenSSL 1.1→3.0, Node 18→22)."""
|
|
599
|
+
def major(v: str | None) -> str | None:
|
|
600
|
+
m = re.match(r"\D*(\d+)", v or "")
|
|
601
|
+
return m.group(1) if m else None
|
|
602
|
+
cm, fm = major(current), major(fixed)
|
|
603
|
+
return bool(cm and fm and cm != fm)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def fix_tier(action: dict) -> str:
|
|
607
|
+
"""Compatibility/reversibility tier T1–T4 for an action (see FIX_TIERS)."""
|
|
608
|
+
lead = action["lead"]
|
|
609
|
+
category = fix_category(lead)
|
|
610
|
+
if category == "end-of-life":
|
|
611
|
+
return "T4"
|
|
612
|
+
if category == "packages":
|
|
613
|
+
osfam = (action.get("ecosystem") or "").split(":")[0]
|
|
614
|
+
if osfam in ("Ubuntu", "Debian"):
|
|
615
|
+
return "T1" # distro security pocket, ABI-stable within the release
|
|
616
|
+
if _is_major_jump((lead.get("affected") or {}).get("version"), action.get("fix_version")):
|
|
617
|
+
return "T4" # app package with a breaking major-version jump
|
|
618
|
+
return "T2"
|
|
619
|
+
if category == "secrets-permissions":
|
|
620
|
+
return "T1"
|
|
621
|
+
if category in ("web-server", "firewall"):
|
|
622
|
+
return "T3" # config change that alters behaviour / can lock you out
|
|
623
|
+
return "T2"
|
|
624
|
+
|
|
625
|
+
|
|
550
626
|
def fix_commands(action: dict) -> tuple[list[str], list[tuple[str, str]], list[str]]:
|
|
551
627
|
"""(shell commands, (kind, snippet) paste-ins, TODO notes) faithfully from the lead finding."""
|
|
552
628
|
lead = action["lead"]
|
|
@@ -623,6 +699,33 @@ def nginx_fix_block(actions: list[dict]) -> tuple[list[str], list[str]]:
|
|
|
623
699
|
return sh, md
|
|
624
700
|
|
|
625
701
|
|
|
702
|
+
def impact_summary_sh(impact: dict) -> list[str]:
|
|
703
|
+
"""A blast-radius header for the runbook, from `host-impact`'s snapshot."""
|
|
704
|
+
svcs = impact.get("services_need_restart") or []
|
|
705
|
+
listening = impact.get("listening") or []
|
|
706
|
+
out = ["# =================== Blast radius (from host-impact) ===================",
|
|
707
|
+
f"# Pending updates: {len(impact.get('pending_updates') or [])} "
|
|
708
|
+
f"({len(impact.get('security_updates') or [])} security-only — safe to batch as T1).",
|
|
709
|
+
f"# Public-facing services (port -> process): "
|
|
710
|
+
+ (", ".join(f"{l['port']}:{l['process']}" for l in listening[:8]) or "none detected") + ".",
|
|
711
|
+
f"# Needs restart after patching ({len(svcs)}): " + (", ".join(svcs[:8]) or "none") + ".",
|
|
712
|
+
f"# Reboot pending: {'YES — schedule a maintenance window' if impact.get('reboot_required') else 'no'}.",
|
|
713
|
+
"# A patch is not live in a running service until that service restarts (or the box reboots).", ""]
|
|
714
|
+
return out
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def impact_summary_md(impact: dict) -> list[str]:
|
|
718
|
+
svcs = impact.get("services_need_restart") or []
|
|
719
|
+
listening = impact.get("listening") or []
|
|
720
|
+
return ["## Blast radius (from `host-impact`)", "",
|
|
721
|
+
f"- **Pending updates:** {len(impact.get('pending_updates') or [])} "
|
|
722
|
+
f"({len(impact.get('security_updates') or [])} security-only → safe to batch as T1)",
|
|
723
|
+
"- **Public-facing services:** " + (", ".join(f"`{l['port']}:{l['process']}`" for l in listening[:8]) or "none detected"),
|
|
724
|
+
f"- **Needs restart after patching ({len(svcs)}):** " + (", ".join(f"`{s}`" for s in svcs[:8]) or "none"),
|
|
725
|
+
f"- **Reboot pending:** {'**YES** — schedule a maintenance window' if impact.get('reboot_required') else 'no'}",
|
|
726
|
+
"", "_A patch is not live in a running service until that service restarts (or the box reboots)._", ""]
|
|
727
|
+
|
|
728
|
+
|
|
626
729
|
def fix_plan(ctx: core.Context) -> dict:
|
|
627
730
|
args = ctx.args
|
|
628
731
|
since = resolve_since(ctx, args.since)
|
|
@@ -636,6 +739,9 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
636
739
|
for action in actions:
|
|
637
740
|
buckets[fix_category(action["lead"])].append(action)
|
|
638
741
|
|
|
742
|
+
impact_path = ctx.state.path("inventory", "impact.json")
|
|
743
|
+
impact = core.read_json(impact_path) if impact_path.is_file() else None
|
|
744
|
+
|
|
639
745
|
host = next((f.get("target", {}).get("host") for f in actionable if (f.get("target") or {}).get("host")), None)
|
|
640
746
|
lock_target = host or "<user@host>"
|
|
641
747
|
sh: list[str] = [
|
|
@@ -648,15 +754,36 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
648
754
|
"# 2. opening a SECOND SSH session (so a bad sshd/firewall change cannot lock you out),",
|
|
649
755
|
f"# 3. taking the change lock: maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan",
|
|
650
756
|
"# Package upgrades belong in a maintenance window. Test nginx with `nginx -t` before every reload.",
|
|
757
|
+
"#",
|
|
758
|
+
"# FIX-RISK TIERS (will this patch break something? — separate from how urgent the flaw is):",
|
|
759
|
+
] + [f"# {TIER_LABEL[t]}: {note}" for t, _, note in FIX_TIERS] + [
|
|
760
|
+
"# Recommended order: T1 first, then T2, then T3 one at a time; schedule T4 separately.",
|
|
651
761
|
"set -euo pipefail",
|
|
652
762
|
"",
|
|
653
763
|
]
|
|
654
764
|
md: list[str] = [f"# Remediation runbook — {ctx.site}" + (f" ({host})" if host else ""), "",
|
|
655
765
|
"Generated by `maggie security fix-plan`. **Review before running — Maggie never runs these.** "
|
|
656
766
|
"Take a backup, keep a second SSH session open, and work under "
|
|
657
|
-
f"`maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan`.", ""
|
|
767
|
+
f"`maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan`.", "",
|
|
768
|
+
"**Fix-risk tiers** — how likely a patch is to break something (separate from how urgent the flaw is). "
|
|
769
|
+
"Apply T1 first, then T2, then T3 one at a time; schedule T4 separately.", ""]
|
|
770
|
+
md += [f"- **{TIER_LABEL[t]}** — {note}" for t, _, note in FIX_TIERS] + [""]
|
|
771
|
+
if impact:
|
|
772
|
+
sh += impact_summary_sh(impact)
|
|
773
|
+
md += impact_summary_md(impact)
|
|
774
|
+
else:
|
|
775
|
+
sh += ["# Blast radius not collected. For a sharper plan — which services a patch affects, what",
|
|
776
|
+
"# must restart (a patched library in memory is still the old one until restart), and whether",
|
|
777
|
+
"# a reboot is pending — run: maggie security host-impact --ssh "
|
|
778
|
+
+ (f"{host}=<user@host>" if host else "<alias>=<user@host>"), ""]
|
|
779
|
+
md += ["> Run `maggie security host-impact` first for a blast-radius-aware plan "
|
|
780
|
+
"(which services each patch affects, what must restart, whether a reboot is pending).", ""]
|
|
781
|
+
|
|
658
782
|
apt_packages: list[str] = []
|
|
659
783
|
total = 0
|
|
784
|
+
tier_counts: dict[str, int] = {t: 0 for t in TIER_ORDER}
|
|
785
|
+
for action in actions:
|
|
786
|
+
tier_counts[fix_tier(action)] += 1
|
|
660
787
|
for key, label, note in FIX_CATEGORIES:
|
|
661
788
|
items = buckets.get(key) or []
|
|
662
789
|
if not items:
|
|
@@ -664,15 +791,36 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
664
791
|
sh += [f"# =================== {label} ===================", f"# {note}", ""]
|
|
665
792
|
md += [f"## {label}", f"_{note}_", ""]
|
|
666
793
|
if key == "web-server":
|
|
794
|
+
sh.append("# Tier: T3 — each fix below backs up, validates with `nginx -t`, and auto-reverts on error.")
|
|
795
|
+
sh.append("")
|
|
796
|
+
md.append(f"_Risk tier: **{TIER_LABEL['T3']}** — each fix validates and auto-reverts._")
|
|
797
|
+
md.append("")
|
|
667
798
|
block_sh, block_md = nginx_fix_block(items)
|
|
668
799
|
sh += block_sh
|
|
669
800
|
md += block_md
|
|
670
801
|
total += len(items)
|
|
671
802
|
continue
|
|
803
|
+
if key == "end-of-life":
|
|
804
|
+
sh.append(f"# Tier: {TIER_LABEL['T4']}. DO NOT run these as part of this script.")
|
|
805
|
+
sh.append("# Test each move in staging and schedule a change window. Until then, record an accepted risk:")
|
|
806
|
+
md.append(f"_Risk tier: **{TIER_LABEL['T4']}** — do not auto-apply. Stage first; record as accepted-risk until scheduled._")
|
|
807
|
+
md.append("")
|
|
808
|
+
for action in items:
|
|
809
|
+
total += 1
|
|
810
|
+
sh.append(f"# [{action['priority']}] {action['title']} — {action['why']}")
|
|
811
|
+
for fid in action["ids"]:
|
|
812
|
+
sh.append(f"# maggie security ledger accept --id {fid} "
|
|
813
|
+
f"--reason {shlex.quote(action['title'] + ' — major/EOL upgrade, staging first')}")
|
|
814
|
+
sh.append("")
|
|
815
|
+
md.append(f"- **{action['title']}** — {action['why']}")
|
|
816
|
+
md.append(f" - accept until staged: `maggie security ledger accept --id {action['ids'][0]} --reason \"…\"`")
|
|
817
|
+
md.append("")
|
|
818
|
+
continue
|
|
672
819
|
for action in items:
|
|
673
820
|
total += 1
|
|
674
|
-
|
|
675
|
-
|
|
821
|
+
tier = fix_tier(action)
|
|
822
|
+
sh.append(f"# [{action['priority']}][{tier}] {action['title']} — {action['why']}")
|
|
823
|
+
md.append(f"- **[{tier}] {action['title']}** — {action['why']}")
|
|
676
824
|
if key == "packages":
|
|
677
825
|
# Build the fix command ourselves; never copy the finding's step text
|
|
678
826
|
# (it reads "apt-get install --only-upgrade X (fixed in Y)" — not valid shell).
|
|
@@ -702,11 +850,25 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
702
850
|
md.append("")
|
|
703
851
|
if apt_packages:
|
|
704
852
|
pkgs = " ".join(sorted(set(apt_packages)))
|
|
705
|
-
insert = ["#
|
|
853
|
+
insert = ["# Tier: T1 — distro security updates are ABI-stable within the release; safe to batch.",
|
|
854
|
+
"# Back up the package state first so you can roll back a bad upgrade:",
|
|
855
|
+
'sudo dpkg --get-selections > "$HOME/maggie-pkg-state.$(date +%F).txt"',
|
|
706
856
|
"sudo apt-get update",
|
|
707
857
|
f"sudo apt-get install --only-upgrade {pkgs}",
|
|
708
858
|
"# Or apply all pending security updates at once:",
|
|
709
|
-
"# sudo unattended-upgrade -d # or: sudo apt-get upgrade",
|
|
859
|
+
"# sudo unattended-upgrade -d # or: sudo apt-get upgrade",
|
|
860
|
+
"# Verify nothing broke after upgrading (and restart services if the kernel/libc changed):",
|
|
861
|
+
"systemctl list-units --state=failed --no-legend || true",
|
|
862
|
+
"sudo nginx -t 2>/dev/null && echo 'nginx config ok' || true"]
|
|
863
|
+
# A patched library on disk is not live until the services using it restart.
|
|
864
|
+
svcs = (impact or {}).get("services_need_restart") or []
|
|
865
|
+
insert += ["# Make the fix live — a patched library in memory is still the old one until restart:",
|
|
866
|
+
"sudo needrestart -r l # list what is still running old code; -r a to act on it"]
|
|
867
|
+
insert += [f"sudo systemctl restart {s}" for s in svcs[:8]]
|
|
868
|
+
if (impact or {}).get("reboot_required"):
|
|
869
|
+
insert += ["# A reboot is pending (kernel/core library) — schedule it in a maintenance window:",
|
|
870
|
+
"# sudo shutdown -r +1 'applying security updates'"]
|
|
871
|
+
insert += ["# If a service failed: downgrade the package (apt-get install pkg=<old>) or restore from the snapshot.", ""]
|
|
710
872
|
anchor = sh.index("# =================== Package updates ===================") + 3
|
|
711
873
|
sh[anchor:anchor] = insert
|
|
712
874
|
md.append("### apt batch")
|
|
@@ -724,6 +886,7 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
724
886
|
out.write_text(runbook, encoding="utf-8")
|
|
725
887
|
return {"schema": "maggie-security-fix-plan.v1", "site": ctx.site, "host": host, "generated_at": core.iso(),
|
|
726
888
|
"actions": total, "apt_packages": sorted(set(apt_packages)),
|
|
889
|
+
"tiers": {t: tier_counts[t] for t in TIER_ORDER if tier_counts[t]},
|
|
727
890
|
"script_path": str(args.script) if args.script else None,
|
|
728
891
|
"note": "Review before running. Maggie never applies these changes.", "markdown": runbook}
|
|
729
892
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""``maggie security scan`` — one command that runs the right detectors for the
|
|
3
|
+
site's profile and then builds a report.
|
|
4
|
+
|
|
5
|
+
The suite has many focused detectors; chaining them by hand (vuln-watch → deps →
|
|
6
|
+
secrets → headers → tls → host-quick → … → report) is the main source of workflow
|
|
7
|
+
friction, and it is what a scheduled routine or an MCP agent would otherwise have
|
|
8
|
+
to script. ``scan`` reads the profile (from the inventory, or ``--profile``), runs
|
|
9
|
+
exactly the detectors that profile expects, and finishes with a report — each
|
|
10
|
+
detector in its own process, best-effort, so one that degrades or refuses does not
|
|
11
|
+
abort the scan. It never does anything the individual commands would not: web
|
|
12
|
+
checks still need a verified domain, host checks still need ``--ssh`` consent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import maggie_security_core as core
|
|
24
|
+
import maggie_security_report as report
|
|
25
|
+
|
|
26
|
+
CLI = Path(__file__).resolve().parent / "maggie_security.py"
|
|
27
|
+
|
|
28
|
+
# Detectors that need a target beyond the project tree.
|
|
29
|
+
WEB = {"headers", "tls", "web-scan"} # need a verified domain
|
|
30
|
+
HOST = {"host-quick", "host-nginx-config", "host-impact"} # need --ssh ALIAS=user@host
|
|
31
|
+
# Interactive / heavy / uncontrolled-target commands are never auto-run by scan.
|
|
32
|
+
NEVER_AUTORUN = {"logs-remote", "host-audit", "zap-baseline", "public-check", "web-scan", "hosted-job"}
|
|
33
|
+
# Project detectors worth running for any profile even if EXPECTED omits them.
|
|
34
|
+
ALWAYS = ["secrets", "code-scan"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _argv_for(command: str, args: argparse.Namespace) -> tuple[list[str] | None, str | None]:
|
|
38
|
+
"""The sub-command argv for this scan, or (None, reason) to skip it."""
|
|
39
|
+
if command in WEB:
|
|
40
|
+
if not args.domain:
|
|
41
|
+
return None, "needs a verified domain (pass --domain)"
|
|
42
|
+
return [command, "--domain", args.domain], None
|
|
43
|
+
if command in HOST:
|
|
44
|
+
if not args.ssh:
|
|
45
|
+
return None, "needs a server (pass --ssh ALIAS=user@host)"
|
|
46
|
+
return [command, "--ssh", args.ssh], None
|
|
47
|
+
return [command], None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _findings_count(data: dict) -> int | None:
|
|
51
|
+
summary = (data or {}).get("summary") or {}
|
|
52
|
+
if not summary:
|
|
53
|
+
return None
|
|
54
|
+
return int(summary.get("new", 0)) + int(summary.get("open", 0))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def scan(ctx: core.Context) -> dict:
|
|
58
|
+
args = ctx.args
|
|
59
|
+
project = ctx.project
|
|
60
|
+
import maggie_security as dispatch
|
|
61
|
+
|
|
62
|
+
built = set(dispatch.area_commands())
|
|
63
|
+
|
|
64
|
+
def run_cmd(argv: list[str]) -> tuple[int, dict, str]:
|
|
65
|
+
full = [sys.executable, str(CLI), *argv, "--project", str(project), "--format", "json", "--max-items", "200"]
|
|
66
|
+
if args.offline:
|
|
67
|
+
full.append("--offline")
|
|
68
|
+
proc = subprocess.run(full, capture_output=True, text=True, timeout=1800, check=False)
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(proc.stdout) if proc.stdout.strip() else {}
|
|
71
|
+
except ValueError:
|
|
72
|
+
data = {}
|
|
73
|
+
return proc.returncode, (data if isinstance(data, dict) else {}), proc.stderr
|
|
74
|
+
|
|
75
|
+
steps: list[dict] = []
|
|
76
|
+
skipped: list[dict] = []
|
|
77
|
+
|
|
78
|
+
# 1. Inventory first, so the profile and every later detector see the same stack.
|
|
79
|
+
code, data, _ = run_cmd(["inventory"])
|
|
80
|
+
steps.append({"command": "inventory", "exit": code, "degraded": bool(data.get("degraded"))})
|
|
81
|
+
|
|
82
|
+
# 2. Decide the profile.
|
|
83
|
+
stack_path = ctx.state.path("inventory", "stack.json")
|
|
84
|
+
profile = args.profile
|
|
85
|
+
if not profile:
|
|
86
|
+
if stack_path.is_file():
|
|
87
|
+
profile = (core.read_json(stack_path).get("site") or {}).get("type") or "unknown"
|
|
88
|
+
else:
|
|
89
|
+
profile = core.detect_site_type(project)
|
|
90
|
+
|
|
91
|
+
# 3. Build the ordered detector plan from the profile, plus the always-useful ones.
|
|
92
|
+
plan: list[str] = []
|
|
93
|
+
for command in list(report.EXPECTED.get(profile, report.EXPECTED["unknown"])) + ALWAYS:
|
|
94
|
+
if command in built and command not in NEVER_AUTORUN and command not in plan:
|
|
95
|
+
plan.append(command)
|
|
96
|
+
|
|
97
|
+
# 4. Run each detector, best-effort.
|
|
98
|
+
for command in plan:
|
|
99
|
+
argv, reason = _argv_for(command, args)
|
|
100
|
+
if argv is None:
|
|
101
|
+
skipped.append({"command": command, "reason": reason})
|
|
102
|
+
continue
|
|
103
|
+
code, data, _ = run_cmd(argv)
|
|
104
|
+
steps.append({"command": command, "exit": code, "degraded": bool(data.get("degraded")),
|
|
105
|
+
"findings": _findings_count(data),
|
|
106
|
+
"refused": code in (core.EXIT_USAGE, core.EXIT_POLICY, core.EXIT_TOOL)})
|
|
107
|
+
|
|
108
|
+
# 5. Report (and optionally a fix-plan).
|
|
109
|
+
rcode, rdata, _ = run_cmd(["report"] + (["--kind", args.kind] if args.kind else []))
|
|
110
|
+
verdict = ((rdata.get("report") or {}).get("verdict") or {}) if rdata else {}
|
|
111
|
+
out: dict = {
|
|
112
|
+
"schema": "maggie-security-scan.v1", "site": ctx.site, "profile": profile,
|
|
113
|
+
"ran": [s["command"] for s in steps], "skipped": skipped, "steps": steps,
|
|
114
|
+
"degraded": [s["command"] for s in steps if s.get("degraded")],
|
|
115
|
+
"verdict": verdict, "report": rdata.get("report") if rdata else None,
|
|
116
|
+
}
|
|
117
|
+
if args.fix_plan:
|
|
118
|
+
_c, fdata, _e = run_cmd(["fix-plan"])
|
|
119
|
+
out["fix_plan"] = {"actions": fdata.get("actions"), "tiers": fdata.get("tiers")} if fdata else None
|
|
120
|
+
|
|
121
|
+
out["markdown"] = render_scan(out, rdata.get("markdown") if rdata else None)
|
|
122
|
+
# exit code: actionable report -> 1; else degraded -> 3; else 0
|
|
123
|
+
if verdict.get("code") in ("act-today", "possible-break-in"):
|
|
124
|
+
out["exit_code"] = core.EXIT_FINDINGS
|
|
125
|
+
elif out["degraded"]:
|
|
126
|
+
out["exit_code"] = core.EXIT_DEGRADED
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def render_scan(out: dict, report_md: str | None) -> str:
|
|
131
|
+
lines = [f"# Security scan — {out['site']} ({out['profile']})", ""]
|
|
132
|
+
ran = [s for s in out["steps"] if not s.get("refused")]
|
|
133
|
+
lines.append(f"Ran {len(ran)} checks" + (f", {len(out['degraded'])} degraded" if out["degraded"] else "")
|
|
134
|
+
+ (f", {len(out['skipped'])} skipped." if out["skipped"] else "."))
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines += ["| Check | Result |", "|---|---|"]
|
|
137
|
+
for step in out["steps"]:
|
|
138
|
+
if step["command"] == "inventory":
|
|
139
|
+
result = "degraded" if step["degraded"] else "ok"
|
|
140
|
+
elif step.get("refused"):
|
|
141
|
+
result = "refused"
|
|
142
|
+
elif step.get("findings"):
|
|
143
|
+
result = f"{step['findings']} finding(s)"
|
|
144
|
+
elif step["degraded"]:
|
|
145
|
+
result = "incomplete"
|
|
146
|
+
else:
|
|
147
|
+
result = "no issues"
|
|
148
|
+
lines.append(f"| {step['command']} | {result} |")
|
|
149
|
+
for skip in out["skipped"]:
|
|
150
|
+
lines.append(f"| {skip['command']} | skipped: {skip['reason']} |")
|
|
151
|
+
lines.append("")
|
|
152
|
+
if out.get("verdict"):
|
|
153
|
+
lines += [f"**Verdict:** {out['verdict'].get('text', '')}", ""]
|
|
154
|
+
if report_md:
|
|
155
|
+
lines += ["---", "", report_md]
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def configure(parser: argparse.ArgumentParser) -> None:
|
|
160
|
+
parser.add_argument("--profile", choices=sorted(report.EXPECTED), help="override the detected site profile")
|
|
161
|
+
parser.add_argument("--domain", help="verified domain for the web checks (headers, tls)")
|
|
162
|
+
parser.add_argument("--ssh", help="ALIAS=user@host for the host checks (needs ssh-read consent)")
|
|
163
|
+
parser.add_argument("--kind", choices=["weekly", "daily_ops"], help="report kind to build at the end (default: weekly)")
|
|
164
|
+
parser.add_argument("--fix-plan", action="store_true", dest="fix_plan", help="also build a remediation fix-plan")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
COMMANDS = [core.Command("scan", "run the detectors for the site's profile, then build a report (one-shot; each detector best-effort)", [],
|
|
168
|
+
configure, scan, findings=False)]
|
|
@@ -453,6 +453,50 @@ def csp_problems(value: str) -> list[str]:
|
|
|
453
453
|
return problems
|
|
454
454
|
|
|
455
455
|
|
|
456
|
+
# Letter grade for HTTP security headers, using the additive model from
|
|
457
|
+
# mdn-http-observatory (MPL-2.0; score table and grade chart ported as data):
|
|
458
|
+
# start at 100, apply the single worst penalty per category, map to a letter.
|
|
459
|
+
GRADE_CHART = [(100, "A+"), (85, "A"), (80, "B+"), (70, "B"), (65, "B-"),
|
|
460
|
+
(60, "C+"), (50, "C"), (45, "C-"), (40, "D+"), (30, "D"), (25, "D-"), (0, "F")]
|
|
461
|
+
# rule_id stem (after the `header-` prefix) -> (category, penalty). Worst per category counts once.
|
|
462
|
+
HEADER_PENALTIES = {
|
|
463
|
+
"csp-missing": ("csp", -25), "csp-": ("csp", -10),
|
|
464
|
+
"hsts-missing": ("hsts", -20), "hsts-short": ("hsts", -10),
|
|
465
|
+
"xcto": ("xcto", -5),
|
|
466
|
+
"framing": ("framing", -20), "xfo-allow-from": ("framing", -20),
|
|
467
|
+
"referrer-missing": ("referrer", -5), "referrer-weak": ("referrer", -5),
|
|
468
|
+
"cors-wildcard-credentials": ("cors", -50),
|
|
469
|
+
"cookie-": ("cookie", -20),
|
|
470
|
+
"version-": ("disclosure", -5),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def header_grade(findings: list[dict]) -> dict:
|
|
475
|
+
"""Observatory-style score (0–100) and letter grade from the header findings."""
|
|
476
|
+
worst: dict[str, int] = {}
|
|
477
|
+
for finding in findings:
|
|
478
|
+
stem = (finding["source"]["rule_id"] or "").removeprefix("header-")
|
|
479
|
+
match = HEADER_PENALTIES.get(stem) or next((v for k, v in HEADER_PENALTIES.items() if k.endswith("-") and stem.startswith(k)), None)
|
|
480
|
+
if match:
|
|
481
|
+
category, penalty = match
|
|
482
|
+
worst[category] = min(worst.get(category, 0), penalty)
|
|
483
|
+
score = max(0, min(100, 100 + sum(worst.values())))
|
|
484
|
+
grade = next(letter for threshold, letter in GRADE_CHART if score >= threshold)
|
|
485
|
+
return {"score": score, "grade": grade, "deductions": sorted(worst.items(), key=lambda kv: kv[1])}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def grade_finding(ctx: core.Context, url: str, findings: list[dict]) -> dict:
|
|
489
|
+
g = header_grade(findings)
|
|
490
|
+
gaps = ", ".join(c for c, _ in g["deductions"]) or "nothing major"
|
|
491
|
+
summary = (f"Security-header grade {g['grade']} ({g['score']}/100). "
|
|
492
|
+
+ (f"Biggest gaps: {gaps}. Fix the items below to raise it." if g["deductions"] else "No major header gaps."))
|
|
493
|
+
return core.make_finding(
|
|
494
|
+
kind="header", command="headers", primary_id="header-grade", site=ctx.site,
|
|
495
|
+
title=f"HTTP security headers grade: {g['grade']} ({g['score']}/100)", severity="info", severity_source="maggie",
|
|
496
|
+
confidence="confirmed", target={"url": url, "host": host_of(url)}, rule_id="header-grade",
|
|
497
|
+
remediation={"summary": summary, "steps": [], "snippets": [], "references": [], "cheat_sheet": None})
|
|
498
|
+
|
|
499
|
+
|
|
456
500
|
def grade_response(ctx: core.Context, url: str, response: dict, layer: str, https: bool) -> list[dict]:
|
|
457
501
|
headers = response["headers"]
|
|
458
502
|
first = lambda name: (headers.get(name) or [None])[0] # noqa: E731
|
|
@@ -530,7 +574,7 @@ def grade_response(ctx: core.Context, url: str, response: dict, layer: str, http
|
|
|
530
574
|
if origin and origin.strip() == "*" and (first("access-control-allow-credentials") or "").lower() == "true":
|
|
531
575
|
add("cors-wildcard-credentials", "CORS allows any origin with credentials", "high", ["V3.4.2"], kind="misconfiguration",
|
|
532
576
|
evidence="Access-Control-Allow-Origin: *; Allow-Credentials: true")
|
|
533
|
-
return findings
|
|
577
|
+
return [grade_finding(ctx, url, findings)] + findings
|
|
534
578
|
|
|
535
579
|
|
|
536
580
|
def headers_cmd(ctx: core.Context):
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "maggie-security-tools-lock.v1",
|
|
3
3
|
"updated": "2026-10-24",
|
|
4
|
+
"generated_at": "2026-10-24T00:00:00Z",
|
|
4
5
|
"tools": {
|
|
5
6
|
"osv-scanner": {
|
|
6
7
|
"version": "2.6.0",
|
|
@@ -104,7 +105,7 @@
|
|
|
104
105
|
"assets": {
|
|
105
106
|
"linux-amd64": {
|
|
106
107
|
"url": "https://github.com/projectdiscovery/nuclei/releases/download/v3.4.10/nuclei_3.4.10_linux_amd64.zip",
|
|
107
|
-
"sha256": "
|
|
108
|
+
"sha256": "234c12cc5288af071abdcd6f854245b6067345556e1235cf96b76725c1004357",
|
|
108
109
|
"archive": "zip",
|
|
109
110
|
"member": "nuclei"
|
|
110
111
|
}
|
|
@@ -137,7 +138,7 @@
|
|
|
137
138
|
"assets": {
|
|
138
139
|
"linux-amd64": {
|
|
139
140
|
"url": "https://github.com/projectdiscovery/httpx/releases/download/v1.7.1/httpx_1.7.1_linux_amd64.zip",
|
|
140
|
-
"sha256": "
|
|
141
|
+
"sha256": "865806a4eec321368e365b419baa3abdc0deb0d4ba598fe85a548a7c8682eb30",
|
|
141
142
|
"archive": "zip",
|
|
142
143
|
"member": "httpx"
|
|
143
144
|
}
|
|
@@ -240,7 +241,7 @@
|
|
|
240
241
|
"assets": {
|
|
241
242
|
"linux-amd64": {
|
|
242
243
|
"url": "https://github.com/anchore/syft/releases/download/v1.33.0/syft_1.33.0_linux_amd64.tar.gz",
|
|
243
|
-
"sha256": "
|
|
244
|
+
"sha256": "adc1b944a827ed3432bcd9f1dbdbc8fa3c0dca7d3d449e7084c90248c2c6cb50",
|
|
244
245
|
"archive": "tar.gz",
|
|
245
246
|
"member": "syft"
|
|
246
247
|
}
|
|
@@ -259,7 +260,7 @@
|
|
|
259
260
|
"assets": {
|
|
260
261
|
"linux-amd64": {
|
|
261
262
|
"url": "https://github.com/anchore/grype/releases/download/v0.100.0/grype_0.100.0_linux_amd64.tar.gz",
|
|
262
|
-
"sha256": "
|
|
263
|
+
"sha256": "9f4ed948b596a1978ec609eaa5538a9f2558836799b7b0a81f729408daf23e03",
|
|
263
264
|
"archive": "tar.gz",
|
|
264
265
|
"member": "grype"
|
|
265
266
|
}
|
|
@@ -271,14 +272,14 @@
|
|
|
271
272
|
}
|
|
272
273
|
},
|
|
273
274
|
"trivy": {
|
|
274
|
-
"version": "0.
|
|
275
|
+
"version": "0.76.1",
|
|
275
276
|
"licence": "Apache-2.0",
|
|
276
277
|
"class": "bundle-ok",
|
|
277
278
|
"homepage": "https://github.com/aquasecurity/trivy",
|
|
278
279
|
"assets": {
|
|
279
280
|
"linux-amd64": {
|
|
280
|
-
"url": "https://github.com/aquasecurity/trivy/releases/download/v0.
|
|
281
|
-
"sha256": "
|
|
281
|
+
"url": "https://github.com/aquasecurity/trivy/releases/download/v0.76.1/trivy_0.76.1_Linux-64bit.tar.gz",
|
|
282
|
+
"sha256": "c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0",
|
|
282
283
|
"archive": "tar.gz",
|
|
283
284
|
"member": "trivy"
|
|
284
285
|
}
|
|
@@ -290,11 +291,38 @@
|
|
|
290
291
|
"--server"
|
|
291
292
|
]
|
|
292
293
|
},
|
|
294
|
+
"copa": {
|
|
295
|
+
"version": "0.15.0",
|
|
296
|
+
"licence": "Apache-2.0",
|
|
297
|
+
"class": "bundle-ok",
|
|
298
|
+
"homepage": "https://github.com/project-copacetic/copacetic",
|
|
299
|
+
"binary": "copa",
|
|
300
|
+
"notes": "Patches OS-package CVEs in a container image from a Trivy report. Needs Docker/buildkit at run time. Used by `image-patch` (plan only; Maggie never runs it).",
|
|
301
|
+
"assets": {
|
|
302
|
+
"linux-amd64": {
|
|
303
|
+
"url": "https://github.com/project-copacetic/copacetic/releases/download/v0.15.0/copa_0.15.0_linux_amd64.tar.gz",
|
|
304
|
+
"sha256": "cff3fe9320f3bd24bafcfd0ce877ef57e23acad0836da0ac6c253919f71c9cad",
|
|
305
|
+
"archive": "tar.gz",
|
|
306
|
+
"member": "copa"
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
"required_flags": [],
|
|
310
|
+
"forbidden_flags": ["--push"]
|
|
311
|
+
},
|
|
293
312
|
"dockle": {
|
|
294
313
|
"version": "0.4.15",
|
|
295
314
|
"licence": "Apache-2.0",
|
|
296
315
|
"class": "bundle-ok",
|
|
297
316
|
"homepage": "https://github.com/goodwithtech/dockle",
|
|
317
|
+
"binary": "dockle",
|
|
318
|
+
"assets": {
|
|
319
|
+
"linux-amd64": {
|
|
320
|
+
"url": "https://github.com/goodwithtech/dockle/releases/download/v0.4.15/dockle_0.4.15_Linux-64bit.tar.gz",
|
|
321
|
+
"sha256": "8bfc9183d1d3d67800edb45bea4bb63ddf1d28bec5f89944075a07007d79caad",
|
|
322
|
+
"archive": "tar.gz",
|
|
323
|
+
"member": "dockle"
|
|
324
|
+
}
|
|
325
|
+
},
|
|
298
326
|
"required_flags": [],
|
|
299
327
|
"forbidden_flags": []
|
|
300
328
|
},
|
|
@@ -329,6 +357,7 @@
|
|
|
329
357
|
"licence": "MIT",
|
|
330
358
|
"class": "bundle-ok",
|
|
331
359
|
"homepage": "https://github.com/perplexityai/bumblebee",
|
|
360
|
+
"notes": "Optional deferred preinstall engine; not bundled. Install it yourself if you want it; preinstall works without it.",
|
|
332
361
|
"required_flags": [],
|
|
333
362
|
"forbidden_flags": []
|
|
334
363
|
},
|