@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.
@@ -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
- sh.append(f"# [{action['priority']}] {action['title']} — {action['why']}")
675
- md.append(f"- **{action['title']}** — {action['why']}")
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 = ["# Apply available Ubuntu/Debian security updates for the affected packages:",
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": "UNPINNED",
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": "UNPINNED",
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": "UNPINNED",
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": "UNPINNED",
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.67.2",
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.67.2/trivy_0.67.2_Linux-64bit.tar.gz",
281
- "sha256": "UNPINNED",
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.60",
3
+ "version": "0.8.0",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",