@topy-ai/maggie 0.7.61 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,7 @@ import maggie_security_core as core # noqa: E402
31
31
  AREA_MODULES = [
32
32
  "maggie_security_intel", "maggie_security_deps", "maggie_security_code", "maggie_security_web",
33
33
  "maggie_security_protect", "maggie_security_logs", "maggie_security_host", "maggie_security_health",
34
- "maggie_security_standards", "maggie_security_agent", "maggie_security_report", "maggie_security_mcp", "maggie_security_hosted",
34
+ "maggie_security_standards", "maggie_security_agent", "maggie_security_report", "maggie_security_scan", "maggie_security_mcp", "maggie_security_hosted",
35
35
  ]
36
36
 
37
37
  # Commands from the PRDs that are not built yet, with their milestone, so the
@@ -535,6 +535,53 @@ def semgrep_to_findings(ctx: core.Context, data: dict, project: Path) -> list[di
535
535
  return findings
536
536
 
537
537
 
538
+ # Bearer-inspired privacy lens (Bearer itself is Elastic-licensed and cannot be run
539
+ # or bundled, so its detection *patterns* are reimplemented as native rules): flag a
540
+ # log/print call that puts personal data in the output — a GDPR hazard (CWE-532).
541
+ PII_LOG_CALL = re.compile(
542
+ r"(?i)(console\.(log|info|warn|error|debug)|[^\w.](print|println|printf)\s*\(|fmt\.(Print|Printf|Println|Sprintf)|"
543
+ r"System\.out\.print|var_dump|print_r|error_log|\b(logger|log|logging|logr|slog|console)\s*\.\s*"
544
+ r"(info|debug|warn|warning|error|trace|notice|log)\b)")
545
+ PII_TOKEN = re.compile(
546
+ r"(?i)\b(e[-_]?mail(?:address|_address)?|ssn|social[_\s-]?security|passport(?:[_\s-]?(?:no|number))?|"
547
+ r"credit[_\s-]?card|card[_\s-]?number|cardnum|cvv|cvc|iban|phone[_\s-]?number|date[_\s-]?of[_\s-]?birth|"
548
+ r"\bdob\b|national[_\s-]?id|tax[_\s-]?id|driver'?s?[_\s-]?licen[cs]e)\b")
549
+ PII_SOURCE_EXT = re.compile(r"(?i)\.(js|jsx|ts|tsx|mjs|cjs|py|php|rb|go|java|kt|cs|scala|swift|rs|vue|svelte|ex|exs)$")
550
+
551
+
552
+ def native_pii_checks(ctx: core.Context, files: list[Path], project: Path) -> list[dict]:
553
+ """Logging of personal data (GDPR / CWE-532) — one finding per file, lines as evidence."""
554
+ findings = []
555
+ for path in files:
556
+ if not PII_SOURCE_EXT.search(path.name):
557
+ continue
558
+ text = read_text(path)
559
+ if not text:
560
+ continue
561
+ hits = []
562
+ for number, line in enumerate(text.splitlines(), 1):
563
+ if len(line) > 400:
564
+ continue
565
+ if PII_LOG_CALL.search(line) and PII_TOKEN.search(line):
566
+ hits.append((number, line.strip()[:200]))
567
+ if len(hits) >= 10:
568
+ break
569
+ if not hits:
570
+ continue
571
+ rel = path.relative_to(project).as_posix()
572
+ findings.append(core.make_finding(
573
+ kind="code", command="code-scan", primary_id="maggie.privacy.pii-in-logs", site=ctx.site,
574
+ title=f"Personal data may be written to logs ({rel})", severity="low", severity_source="maggie",
575
+ confidence="possible", cwe_ids=["CWE-532"], asvs_ids=["V7.1.1"], target={"path": rel}, tool="maggie-native",
576
+ rule_id="maggie.privacy.pii-in-logs",
577
+ evidence=[{"path": rel, "line": n, "excerpt": core.clean_excerpt(ln, 200)} for n, ln in hits[:5]],
578
+ remediation={"summary": "Avoid logging personal data (email, SSN, card, phone, DOB…). Redact or omit it, or log an opaque id instead. "
579
+ "Logs are often shipped to third parties and retained — this is a GDPR exposure.",
580
+ "steps": [], "snippets": [], "references": ["https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html"],
581
+ "cheat_sheet": "Logging"}))
582
+ return findings
583
+
584
+
538
585
  def native_config_checks(ctx: core.Context, files: list[Path], project: Path) -> list[dict]:
539
586
  """Two checks semgrep expresses poorly: Dockerfile final stage without USER, nginx serving dotfiles."""
540
587
  findings = []
@@ -598,7 +645,8 @@ def code_scan(ctx: core.Context):
598
645
  else:
599
646
  targets = [str(project / p) for p in (args.paths or ["."])]
600
647
  files = [Path(t) for t in targets if Path(t).is_file()] + [f for t in targets if Path(t).is_dir() for f in text_files(Path(t), project)]
601
- findings = native_config_checks(ctx, [f for f in files if not any(part in SKIP_DIRS for part in f.relative_to(project).parts)], project)
648
+ in_scope = [f for f in files if not any(part in SKIP_DIRS for part in f.relative_to(project).parts)]
649
+ findings = native_config_checks(ctx, in_scope, project) + native_pii_checks(ctx, in_scope, project)
602
650
  with tempfile.TemporaryDirectory() as tmp:
603
651
  out = Path(tmp) / "semgrep.json"
604
652
  argv = ["scan", "--json", "--json-output", str(out), "--timeout", "30", "--max-target-bytes", "1000000", "--quiet"]
@@ -361,6 +361,99 @@ def image(ctx: core.Context):
361
361
  return ctx.finish(findings, covered=[f"image:{ref}"])
362
362
 
363
363
 
364
+ # --------------------------------------------------------------------------- image-patch (copa remediation plan)
365
+
366
+ # image-patch completes the safe-patching story for containers: `image` detects the
367
+ # CVEs, image-patch turns a Trivy report into a reviewed copa command that rebuilds
368
+ # the image with the OS-package fixes applied — into a NEW tag, so the original is
369
+ # untouched. Plan only (like fix-plan/protect-plan): Maggie never runs copa; the
370
+ # operator reviews it and runs it where Docker/buildkit is available.
371
+
372
+
373
+ def _patched_tag(ref: str, override: str | None) -> str:
374
+ if override:
375
+ return override
376
+ name, _, _tag = ref.rpartition(":")
377
+ return f"{name}:patched" if name and "/" not in _tag else f"{ref}-patched"
378
+
379
+
380
+ def image_patch(ctx: core.Context) -> dict:
381
+ import shlex
382
+
383
+ args = ctx.args
384
+ ref = args.ref
385
+ tag = _patched_tag(ref, args.tag)
386
+ pkg_types = args.pkg_types
387
+ report = args.report or "scan.json"
388
+
389
+ fixable = total = None
390
+ if args.report:
391
+ data = core.read_json(Path(args.report))
392
+ fixed_ids, all_ids = set(), set()
393
+ for result in data.get("Results") or []:
394
+ for vuln in result.get("Vulnerabilities") or []:
395
+ vid = vuln.get("VulnerabilityID")
396
+ all_ids.add(vid)
397
+ if vuln.get("FixedVersion"):
398
+ fixed_ids.add(vid)
399
+ fixable, total = len(fixed_ids), len(all_ids)
400
+
401
+ scan_cmd = f"trivy image --quiet --scanners vuln --pkg-types os --format json -o {shlex.quote(report)} {shlex.quote(ref)}"
402
+ patch_cmd = f"copa patch -i {shlex.quote(ref)} -r {shlex.quote(report)} --pkg-types {pkg_types} -t {shlex.quote(tag)}"
403
+ verify_cmd = f"trivy image --quiet {shlex.quote(tag)}"
404
+
405
+ sh = [
406
+ "#!/usr/bin/env bash",
407
+ f"# Container patch plan for {ref} — generated {core.iso()[:10]} by maggie security image-patch.",
408
+ "# REVIEW BEFORE RUNNING. Maggie did not and will not run any of this.",
409
+ "# copa rebuilds the image with OS-package fixes into a NEW tag; the original image is untouched.",
410
+ "# Prerequisites: copa (maggie security tools install --tool copa) and a running Docker/buildkit.",
411
+ "set -euo pipefail",
412
+ "",
413
+ "# 1. Scan the image to a report copa can read:" if not args.report else f"# 1. Using the provided report {report}.",
414
+ ]
415
+ if not args.report:
416
+ sh.append(scan_cmd)
417
+ sh += [
418
+ "# 2. Patch OS packages into a new tag (original image untouched):",
419
+ patch_cmd,
420
+ "# 3. Re-scan the patched image to confirm the fixes, then test it before you deploy:",
421
+ verify_cmd,
422
+ "",
423
+ ]
424
+ script = "\n".join(sh).rstrip() + "\n"
425
+ if args.script:
426
+ path = Path(args.script)
427
+ path.parent.mkdir(parents=True, exist_ok=True)
428
+ path.write_text(script, encoding="utf-8")
429
+ path.chmod(0o755)
430
+
431
+ md = [f"# Container patch plan — {ref}", "",
432
+ "**Review before running — Maggie never runs copa.** copa rebuilds the image with OS-package "
433
+ "fixes into a **new tag** (`" + tag + "`); the original image is untouched. Needs Docker/buildkit.", ""]
434
+ if fixable is not None:
435
+ md.append(f"The report lists **{fixable} fixable** OS-package CVE(s)" + (f" of {total} total." if total else ".") + "")
436
+ md.append("")
437
+ md += ["```bash", (scan_cmd + "\n" if not args.report else "") + patch_cmd + "\n" + verify_cmd, "```", "",
438
+ "After patching: re-scan with `maggie security image --ref " + tag + "` and test the image before deploying."]
439
+ runbook = "\n".join(md)
440
+
441
+ return {"schema": "maggie-security-image-patch.v1", "ref": ref, "patched_tag": tag, "pkg_types": pkg_types,
442
+ "fixable": fixable, "total": total, "commands": {"scan": scan_cmd, "patch": patch_cmd, "verify": verify_cmd},
443
+ "script_path": str(args.script) if args.script else None,
444
+ "note": "Plan only. Maggie never runs copa; it rebuilds into a new tag, leaving the original image untouched.",
445
+ "markdown": runbook}
446
+
447
+
448
+ def _image_patch(p: argparse.ArgumentParser) -> None:
449
+ p.add_argument("ref", help="the image to patch, e.g. myapp:1.0")
450
+ p.add_argument("--report", help="a Trivy JSON report for the image (else the plan generates one with trivy)")
451
+ p.add_argument("--tag", help="output tag for the patched image (default <ref>-patched)")
452
+ p.add_argument("--pkg-types", choices=["os", "os,library"], default="os", dest="pkg_types",
453
+ help="os (default) or os,library to also patch application dependencies")
454
+ p.add_argument("--script", help="write the runnable plan to this path (chmod 755)")
455
+
456
+
364
457
  # --------------------------------------------------------------------------- preinstall (vet a package before installing it)
365
458
 
366
459
 
@@ -554,6 +647,8 @@ COMMANDS = [core.Command("deps", "known vulnerabilities in dependencies (every l
554
647
  ["osv-api", "retire-jsrepo", "kev", "epss"], configure, deps),
555
648
  core.Command("sbom", "CycloneDX SBOM of the project (or an image with syft)", [], _sbom, sbom, findings=False),
556
649
  core.Command("image", "container image vulnerabilities, secrets and best practices (trivy + dockle)", ["kev", "github-releases"], _image, image),
650
+ core.Command("image-patch", "a reviewed copa plan to rebuild a container image with OS-package fixes into a new tag; never applied by Maggie",
651
+ [], _image_patch, image_patch, findings=False, routine_safe=False),
557
652
  core.Command("preinstall", "vet an npm package before installing it (age, install scripts, typosquats, malware, vulnerabilities, Scorecard)",
558
653
  ["npm-registry", "osv-api", "scorecard", "kev", "epss"], _preinstall, preinstall, findings=False),
559
654
  core.Command("exposure-check", "known malicious packages already installed (OSV MAL- records and incident catalogs)", ["osv-api", "kev"],
@@ -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:
@@ -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":
@@ -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("")
@@ -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):