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