@topy-ai/maggie 0.7.55 → 0.7.57
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 +7 -3
- package/README.md +42 -8
- package/bin/maggie.js +4 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
- package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
- package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
- package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
- package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
- package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
- package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
- package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
- package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
- package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
- package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-security/SKILL.md +173 -0
- package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
- package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
- package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
- package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
- package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
- package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
- package/bundled-skills/maggie-security/evals/evals.json +97 -0
- package/bundled-skills/maggie-security/references/commands.md +125 -0
- package/bundled-skills/maggie-security/references/cve-response.md +24 -0
- package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
- package/bundled-skills/maggie-security/references/findings.md +39 -0
- package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
- package/bundled-skills/maggie-security/references/site-types.md +22 -0
- package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
- package/bundled-skills/maggie-security/references/workflows.md +23 -0
- package/bundled-skills/maggie-security/registry/security-tools.registry.json +654 -0
- package/bundled-skills/maggie-security/routines.example.json +27 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
- package/bundled-tools/clis/maggie_security.py +342 -0
- package/bundled-tools/clis/maggie_security_agent.py +326 -0
- package/bundled-tools/clis/maggie_security_code.py +650 -0
- package/bundled-tools/clis/maggie_security_core.py +1512 -0
- package/bundled-tools/clis/maggie_security_deps.py +560 -0
- package/bundled-tools/clis/maggie_security_health.py +415 -0
- package/bundled-tools/clis/maggie_security_host.py +511 -0
- package/bundled-tools/clis/maggie_security_hosted.py +115 -0
- package/bundled-tools/clis/maggie_security_intel.py +1294 -0
- package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
- package/bundled-tools/clis/maggie_security_logs.py +777 -0
- package/bundled-tools/clis/maggie_security_mcp.py +484 -0
- package/bundled-tools/clis/maggie_security_mirror.py +122 -0
- package/bundled-tools/clis/maggie_security_protect.py +519 -0
- package/bundled-tools/clis/maggie_security_report.py +506 -0
- package/bundled-tools/clis/maggie_security_standards.py +214 -0
- package/bundled-tools/clis/maggie_security_versions.py +381 -0
- package/bundled-tools/clis/maggie_security_web.py +1247 -0
- package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
- package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
- package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
- package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
- package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
- package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
- package/bundled-tools/clis/security_data/tools.lock.json +358 -0
- package/package.json +1 -1
|
@@ -125,6 +125,19 @@ def validate_auth_canary(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
|
125
125
|
return True, "authenticated API canary passed"
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def validate_security_gate(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
129
|
+
"""`maggie security report --kind gate --output FILE` (docs/maggie-security/12-workflows-prd.md section 7)."""
|
|
130
|
+
if payload.get("schema") != "maggie-security-report.v1" or payload.get("kind") != "gate" or not isinstance(payload.get("gate"), dict):
|
|
131
|
+
return False, "security gate evidence must come from `maggie security report --kind gate`"
|
|
132
|
+
gate = payload["gate"]
|
|
133
|
+
if gate.get("decision") == "block":
|
|
134
|
+
reasons = "; ".join(str(item.get("reason")) for item in gate.get("block") or [])
|
|
135
|
+
return False, f"security gate blocks the release: {reasons}"
|
|
136
|
+
if gate.get("decision") == "warn":
|
|
137
|
+
return True, "security gate passed with warnings: " + "; ".join(str(item.get("reason")) for item in gate.get("warn") or [])
|
|
138
|
+
return True, "security gate passed"
|
|
139
|
+
|
|
140
|
+
|
|
128
141
|
VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
|
|
129
142
|
"unitRegression": validate_unit,
|
|
130
143
|
"packageSmoke": validate_package,
|
|
@@ -136,8 +149,9 @@ VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
|
|
|
136
149
|
"releaseIdentity": validate_release_identity,
|
|
137
150
|
"rollbackEvidence": validate_rollback_check,
|
|
138
151
|
"authenticatedCanary": validate_auth_canary,
|
|
152
|
+
"securityGate": validate_security_gate,
|
|
139
153
|
}
|
|
140
|
-
OPTIONAL_CHECKS = ("runtimeParity", "hostHealth", "releaseIdentity", "rollbackEvidence", "authenticatedCanary")
|
|
154
|
+
OPTIONAL_CHECKS = ("runtimeParity", "hostHealth", "releaseIdentity", "rollbackEvidence", "authenticatedCanary", "securityGate")
|
|
141
155
|
|
|
142
156
|
|
|
143
157
|
def inspect_check(name: str, path: Path, project: Path) -> dict[str, Any]:
|
|
@@ -198,6 +212,7 @@ def main() -> int:
|
|
|
198
212
|
parser.add_argument("--release-identity", type=Path, help="maggie-release-identity.v1 evidence")
|
|
199
213
|
parser.add_argument("--rollback-check", type=Path, help="maggie-deployment-rollback-check.v1 evidence")
|
|
200
214
|
parser.add_argument("--auth-canary", type=Path, help="maggie-deployment-auth-canary.v1 evidence")
|
|
215
|
+
parser.add_argument("--security-report", type=Path, help="gate report from `maggie security report --kind gate --output FILE` (opt-in)")
|
|
201
216
|
parser.add_argument("--output", type=Path)
|
|
202
217
|
args = parser.parse_args()
|
|
203
218
|
project = args.project.resolve()
|
|
@@ -207,7 +222,7 @@ def main() -> int:
|
|
|
207
222
|
name: (getattr(args, option).resolve() if getattr(args, option).is_absolute() else project / getattr(args, option))
|
|
208
223
|
for name, option in CHECKS
|
|
209
224
|
}
|
|
210
|
-
for name, option in (("runtimeParity", "runtime_parity"), ("hostHealth", "host_health"), ("releaseIdentity", "release_identity"), ("rollbackEvidence", "rollback_check"), ("authenticatedCanary", "auth_canary")):
|
|
225
|
+
for name, option in (("runtimeParity", "runtime_parity"), ("hostHealth", "host_health"), ("releaseIdentity", "release_identity"), ("rollbackEvidence", "rollback_check"), ("authenticatedCanary", "auth_canary"), ("securityGate", "security_report")):
|
|
211
226
|
value = getattr(args, option)
|
|
212
227
|
if value:
|
|
213
228
|
paths[name] = value.resolve() if value.is_absolute() else project / value
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""``maggie security <command>``: one entry point for every security check.
|
|
3
|
+
|
|
4
|
+
Routes to the area modules (docs/maggie-security/00-foundation-prd.md section
|
|
5
|
+
6.1) and owns the housekeeping commands:
|
|
6
|
+
|
|
7
|
+
- ``doctor``: what applies to this project, which tools and feeds are ready,
|
|
8
|
+
ownership, consents, ledger size and last runs. ``--fix`` only writes
|
|
9
|
+
``.maggie/security/config.json`` and a ``.gitignore`` entry.
|
|
10
|
+
- ``tools list|install|verify``: the pinned external scanners.
|
|
11
|
+
- ``feeds status|sync|verify``: the local feed cache.
|
|
12
|
+
- ``ledger list|show|accept|false-positive|reopen|compact``: finding status
|
|
13
|
+
history; the marks are local file writes only.
|
|
14
|
+
|
|
15
|
+
Exit codes: 0 ok, 1 findings at/above --fail-on, 2 usage, 3 degraded,
|
|
16
|
+
4 refused by policy, 5 tool missing or checksum mismatch.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import importlib
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
+
|
|
29
|
+
import maggie_security_core as core # noqa: E402
|
|
30
|
+
|
|
31
|
+
AREA_MODULES = [
|
|
32
|
+
"maggie_security_intel", "maggie_security_deps", "maggie_security_code", "maggie_security_web",
|
|
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",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Commands from the PRDs that are not built yet, with their milestone, so the
|
|
38
|
+
# help screen and the skill can say "planned" instead of "unknown".
|
|
39
|
+
PLANNED: dict[str, str] = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def area_commands() -> dict[str, core.Command]:
|
|
43
|
+
commands: dict[str, core.Command] = {}
|
|
44
|
+
for name in AREA_MODULES:
|
|
45
|
+
try:
|
|
46
|
+
module = importlib.import_module(name)
|
|
47
|
+
except ModuleNotFoundError as error:
|
|
48
|
+
if error.name == name:
|
|
49
|
+
continue
|
|
50
|
+
raise
|
|
51
|
+
for command in getattr(module, "COMMANDS", []):
|
|
52
|
+
commands[command.name] = command
|
|
53
|
+
return commands
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------- doctor
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def doctor(ctx: core.Context) -> dict:
|
|
60
|
+
project, state = ctx.project, ctx.state
|
|
61
|
+
config = state.config()
|
|
62
|
+
site_type = config.get("site_type") or core.detect_site_type(project)
|
|
63
|
+
domains = config.get("domains") or []
|
|
64
|
+
lock = core.load_lock(state)
|
|
65
|
+
tools = []
|
|
66
|
+
for name, spec in sorted((lock.get("tools") or {}).items()):
|
|
67
|
+
if spec.get("class") == "reference-only":
|
|
68
|
+
continue
|
|
69
|
+
_spec, path, how = core.resolve_tool(name, lock)
|
|
70
|
+
tools.append({"name": name, "version": spec["version"], "class": spec["class"], "how": how})
|
|
71
|
+
ownership = []
|
|
72
|
+
for domain in domains:
|
|
73
|
+
record_path = state.path("ownership", f"{domain}.json")
|
|
74
|
+
record = core.read_json(record_path) if record_path.is_file() else None
|
|
75
|
+
status = "unverified"
|
|
76
|
+
if record and record.get("verified_at"):
|
|
77
|
+
age = core.utcnow() - core.parse_time(record["verified_at"])
|
|
78
|
+
status = "verified" if age.days <= 90 else "expired"
|
|
79
|
+
ownership.append({"domain": domain, "status": status, "verified_at": (record or {}).get("verified_at")})
|
|
80
|
+
consent_path = state.path("consent.json")
|
|
81
|
+
consents = core.read_json(consent_path).get("grants", []) if consent_path.is_file() else []
|
|
82
|
+
ledger = core.Ledger(state.path("ledger.jsonl"))
|
|
83
|
+
history = ledger.history()
|
|
84
|
+
statuses: dict[str, int] = {}
|
|
85
|
+
for events in history.values():
|
|
86
|
+
status = core.Ledger.derive(events)["status"] or "unknown"
|
|
87
|
+
statuses[status] = statuses.get(status, 0) + 1
|
|
88
|
+
last_runs: dict[str, str] = {}
|
|
89
|
+
runs_dir = state.path("runs")
|
|
90
|
+
if runs_dir.is_dir():
|
|
91
|
+
for path in sorted(runs_dir.glob("*.json")):
|
|
92
|
+
try:
|
|
93
|
+
run = json.loads(path.read_text(encoding="utf-8"))["run"]
|
|
94
|
+
except (ValueError, KeyError, OSError):
|
|
95
|
+
continue
|
|
96
|
+
last_runs[run["command"]] = run["finished_at"]
|
|
97
|
+
available = sorted(area_commands())
|
|
98
|
+
applicable = [c for c in core.APPLICABLE.get(site_type, core.APPLICABLE["unknown"])]
|
|
99
|
+
gitignore = project / ".gitignore"
|
|
100
|
+
ignored = gitignore.is_file() and any(line.strip().rstrip("/*") in (".maggie/security", "/.maggie/security", ".maggie")
|
|
101
|
+
for line in gitignore.read_text(encoding="utf-8").splitlines())
|
|
102
|
+
problems = []
|
|
103
|
+
if not ignored and state.root.exists():
|
|
104
|
+
problems.append("`.maggie/security/` is not in .gitignore (run: maggie security doctor --fix)")
|
|
105
|
+
if not domains:
|
|
106
|
+
problems.append("no domains configured; web checks are skipped (set domains in .maggie/security/config.json)")
|
|
107
|
+
report = {
|
|
108
|
+
"schema": "maggie-security-status.v1",
|
|
109
|
+
"maggie_version": core.maggie_version(),
|
|
110
|
+
"project": str(project),
|
|
111
|
+
"site": ctx.site,
|
|
112
|
+
"site_type": site_type,
|
|
113
|
+
"domains": domains,
|
|
114
|
+
"commands": {"applicable": applicable, "available": available,
|
|
115
|
+
"applicable_not_built": [c for c in applicable if c not in available and c not in ("report",)]},
|
|
116
|
+
"tools": tools,
|
|
117
|
+
"feeds": core.FeedCache(state, ctx.guard).status(),
|
|
118
|
+
"ownership": ownership,
|
|
119
|
+
"consents": consents,
|
|
120
|
+
"ledger": {"ids": len(history), "by_status": statuses},
|
|
121
|
+
"last_runs": last_runs,
|
|
122
|
+
"gitignored": ignored,
|
|
123
|
+
"mirror": core.mirror_base(),
|
|
124
|
+
"problems": problems,
|
|
125
|
+
}
|
|
126
|
+
if getattr(ctx.args, "fix", False):
|
|
127
|
+
report["fixed"] = doctor_fix(project, state, site_type, ctx.site)
|
|
128
|
+
return report
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def doctor_fix(project: Path, state: core.State, site_type: str, site: str) -> list[str]:
|
|
132
|
+
done = []
|
|
133
|
+
state.ensure()
|
|
134
|
+
config_path = state.path("config.json")
|
|
135
|
+
if not config_path.is_file():
|
|
136
|
+
core.write_json(config_path, {"schema": "maggie-security-config.v1", "site": site, "site_type": site_type, "domains": [],
|
|
137
|
+
"schedules": {}, "thresholds": {"fail_on": "high"}, "opt_ins": {}})
|
|
138
|
+
done.append("created .maggie/security/config.json")
|
|
139
|
+
gitignore = project / ".gitignore"
|
|
140
|
+
lines = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.is_file() else []
|
|
141
|
+
wanted = [".maggie/security/*", "!.maggie/security/config.json"]
|
|
142
|
+
missing = [line for line in wanted if line not in lines]
|
|
143
|
+
if missing:
|
|
144
|
+
with gitignore.open("a", encoding="utf-8") as handle:
|
|
145
|
+
if lines and lines[-1].strip():
|
|
146
|
+
handle.write("\n")
|
|
147
|
+
handle.write("# maggie security state (host evidence, findings); config.json is safe to commit\n")
|
|
148
|
+
handle.write("\n".join(missing) + "\n")
|
|
149
|
+
done.append("added .maggie/security/ to .gitignore")
|
|
150
|
+
ledger = core.Ledger(state.path("ledger.jsonl"))
|
|
151
|
+
if ledger.path.is_file():
|
|
152
|
+
result = ledger.compact()
|
|
153
|
+
done.append(f"compacted ledger {result['before']} -> {result['after']} events")
|
|
154
|
+
return done
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def doctor_text(report: dict) -> str:
|
|
158
|
+
lines = [f"maggie security doctor: {report['site']} ({report['site_type']})"]
|
|
159
|
+
lines.append(f" domains: {', '.join(report['domains']) or 'none'}")
|
|
160
|
+
lines.append(f" applicable: {', '.join(report['commands']['applicable'])}")
|
|
161
|
+
if report["commands"]["applicable_not_built"]:
|
|
162
|
+
lines.append(f" not built yet: {', '.join(report['commands']['applicable_not_built'])}")
|
|
163
|
+
for tool in report["tools"]:
|
|
164
|
+
lines.append(f" tool {tool['name']:<12} {tool['version']:<10} {tool['how']}")
|
|
165
|
+
for feed in report["feeds"]:
|
|
166
|
+
if feed["entries"]:
|
|
167
|
+
lines.append(f" feed {feed['id']:<16} {feed['fetched_at']}{' STALE' if feed['stale'] else ''}")
|
|
168
|
+
lines.append(f" ledger: {report['ledger']['ids']} findings {report['ledger']['by_status']}")
|
|
169
|
+
for problem in report["problems"]:
|
|
170
|
+
lines.append(f" ! {problem}")
|
|
171
|
+
for item in report.get("fixed", []):
|
|
172
|
+
lines.append(f" fixed: {item}")
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------- tools, feeds, ledger
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def tools_cmd(ctx: core.Context) -> dict:
|
|
180
|
+
args = ctx.args
|
|
181
|
+
lock = core.load_lock(ctx.state)
|
|
182
|
+
names = [args.tool] if args.tool else sorted(lock.get("tools", {}))
|
|
183
|
+
if args.action == "list":
|
|
184
|
+
return {"schema": "maggie-security-tools.v1", "platform": core.platform_key(), "cache": str(core.tools_cache_dir()),
|
|
185
|
+
"tools": [{"name": n, **{k: v for k, v in lock["tools"][n].items() if k in ("version", "licence", "class", "notes")},
|
|
186
|
+
"how": core.resolve_tool(n, lock)[2]} for n in names]}
|
|
187
|
+
if args.action == "verify":
|
|
188
|
+
results = [core.verify_tool(n, lock) for n in names if lock["tools"][n]["class"] != "reference-only"]
|
|
189
|
+
bad = [r for r in results if not r["ok"] and r["how"] != "missing"]
|
|
190
|
+
if bad:
|
|
191
|
+
raise core.ToolError("; ".join(f"{r['tool']}: {r['problem']}" for r in bad))
|
|
192
|
+
return {"schema": "maggie-security-tools.v1", "results": results}
|
|
193
|
+
if not args.tool:
|
|
194
|
+
raise core.UsageError("tools install needs --tool NAME")
|
|
195
|
+
result = core.install_tool(args.tool, lock, ctx.guard)
|
|
196
|
+
ctx.state.ensure()
|
|
197
|
+
core.write_json(ctx.state.path("tools.lock.json"), core.read_json(core.packaged_lock_path()))
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def feeds_cmd(ctx: core.Context) -> dict:
|
|
202
|
+
args = ctx.args
|
|
203
|
+
if args.action == "status":
|
|
204
|
+
return {"schema": "maggie-security-feeds.v1", "mirror": core.mirror_base(), "feeds": ctx.cache.status()}
|
|
205
|
+
if args.action == "mirror":
|
|
206
|
+
import maggie_security_mirror as mirror
|
|
207
|
+
|
|
208
|
+
if not args.dir:
|
|
209
|
+
raise core.UsageError("feeds mirror needs --dir DIR")
|
|
210
|
+
ecosystems = [e for e in (args.ecosystems or ",".join(mirror.DEFAULT_ECOSYSTEMS)).split(",") if e]
|
|
211
|
+
return mirror.build(ctx, Path(args.dir), ecosystems, args.days, args.to_project)
|
|
212
|
+
if args.action == "verify":
|
|
213
|
+
problems = ctx.cache.verify()
|
|
214
|
+
if problems:
|
|
215
|
+
raise core.DegradedError(json.dumps(problems))
|
|
216
|
+
return {"schema": "maggie-security-feeds.v1", "problems": []}
|
|
217
|
+
# sync: fetch fixed-URL sources (the ones that take parameters are fetched by their commands)
|
|
218
|
+
wanted = [args.source] if args.source else ["cvelistv5-delta", "kev", "node-core", "nuclei-cves"]
|
|
219
|
+
results = []
|
|
220
|
+
for source in wanted:
|
|
221
|
+
spec = core.SOURCES.get(source)
|
|
222
|
+
if not spec or "{" in spec["url"] or not spec["url"]:
|
|
223
|
+
raise core.UsageError(f"{source} cannot be synced on its own; it is fetched per query")
|
|
224
|
+
data = ctx.cache.get(source, spec["url"], ttl="0s" if args.force else None)
|
|
225
|
+
results.append({"id": source, "bytes": len(data) if data else 0})
|
|
226
|
+
degraded = [item for item in ctx.cache.report if item["status"] == "degraded"]
|
|
227
|
+
out = {"schema": "maggie-security-feeds.v1", "synced": results, "report": ctx.cache.report}
|
|
228
|
+
if degraded:
|
|
229
|
+
print(json.dumps(out, indent=2))
|
|
230
|
+
raise core.DegradedError(", ".join(d["id"] for d in degraded) + " unavailable")
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def ledger_cmd(ctx: core.Context) -> dict:
|
|
235
|
+
args = ctx.args
|
|
236
|
+
ledger = core.Ledger(ctx.state.path("ledger.jsonl"))
|
|
237
|
+
if args.action == "list":
|
|
238
|
+
items = []
|
|
239
|
+
for fid, events in ledger.history().items():
|
|
240
|
+
state = core.Ledger.derive(events)
|
|
241
|
+
if args.status and state["status"] != args.status:
|
|
242
|
+
continue
|
|
243
|
+
items.append({"id": fid, **state})
|
|
244
|
+
return {"schema": "maggie-security-ledger.v1", "items": items}
|
|
245
|
+
if args.action == "compact":
|
|
246
|
+
return ledger.compact()
|
|
247
|
+
if not args.id:
|
|
248
|
+
raise core.UsageError(f"ledger {args.action} needs --id")
|
|
249
|
+
if args.action == "show":
|
|
250
|
+
events = ledger.history().get(args.id)
|
|
251
|
+
if not events:
|
|
252
|
+
raise core.UsageError(f"unknown finding id {args.id}")
|
|
253
|
+
return {"id": args.id, **core.Ledger.derive(events), "events": events}
|
|
254
|
+
event = {"accept": "accepted-risk", "false-positive": "false-positive", "reopen": "reopened"}[args.action]
|
|
255
|
+
until = args.until
|
|
256
|
+
if event == "accepted-risk" and not until:
|
|
257
|
+
until = core.iso(core.utcnow() + core.parse_duration("90d"))[:10]
|
|
258
|
+
return ledger.mark(args.id, event, args.reason, until=until if event == "accepted-risk" else None)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
HOUSEKEEPING = [
|
|
262
|
+
core.Command("doctor", "what applies to this project, tool and feed readiness, ownership, consents, ledger",
|
|
263
|
+
[], lambda p: p.add_argument("--fix", action="store_true", help="create config.json and the .gitignore entry"),
|
|
264
|
+
doctor, findings=False),
|
|
265
|
+
core.Command("tools", "pinned external scanners: list, install, verify", ["github-releases"],
|
|
266
|
+
lambda p: (p.add_argument("action", choices=["list", "install", "verify"]), p.add_argument("--tool")),
|
|
267
|
+
tools_cmd, findings=False),
|
|
268
|
+
core.Command("feeds", "local feed cache: status, sync, verify; mirror builds an offline copy of every feed", list(core.SOURCES),
|
|
269
|
+
lambda p: (p.add_argument("action", choices=["status", "sync", "verify", "mirror"]), p.add_argument("--source"),
|
|
270
|
+
p.add_argument("--force", action="store_true", help="ignore TTL"),
|
|
271
|
+
p.add_argument("--dir", help="mirror: target directory"),
|
|
272
|
+
p.add_argument("--ecosystems", help="mirror: OSV ecosystems to download (default the common eight)"),
|
|
273
|
+
p.add_argument("--days", type=int, default=7, help="mirror: days of CVE delta zips"),
|
|
274
|
+
p.add_argument("--to-project", action="store_true", help="mirror: also place OSV zips in .maggie/security/db/osv")),
|
|
275
|
+
feeds_cmd, findings=False),
|
|
276
|
+
core.Command("ledger", "finding status history (local marks only)", [],
|
|
277
|
+
lambda p: (p.add_argument("action", choices=["list", "show", "accept", "false-positive", "reopen", "compact"]),
|
|
278
|
+
p.add_argument("--id"), p.add_argument("--reason"), p.add_argument("--until", help="YYYY-MM-DD for accept (default +90d)"),
|
|
279
|
+
p.add_argument("--status", choices=core.STATUSES)),
|
|
280
|
+
ledger_cmd, findings=False),
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def build_parser(commands: dict[str, core.Command]) -> argparse.ArgumentParser:
|
|
285
|
+
parser = argparse.ArgumentParser(prog="maggie security", description="Maggie security checks (docs/maggie-security).")
|
|
286
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")
|
|
287
|
+
common = core.common_parent()
|
|
288
|
+
for command in commands.values():
|
|
289
|
+
p = sub.add_parser(command.name, parents=[common], help=command.help, description=command.help)
|
|
290
|
+
command.configure(p)
|
|
291
|
+
return parser
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def main(argv: list[str] | None = None) -> int:
|
|
295
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
296
|
+
commands = {c.name: c for c in HOUSEKEEPING}
|
|
297
|
+
commands.update(area_commands())
|
|
298
|
+
if argv and argv[0] in PLANNED and argv[0] not in commands:
|
|
299
|
+
print(f"maggie security {argv[0]} is planned for {PLANNED[argv[0]]} and not built yet", file=sys.stderr)
|
|
300
|
+
return core.EXIT_USAGE
|
|
301
|
+
parser = build_parser(commands)
|
|
302
|
+
try:
|
|
303
|
+
args = parser.parse_args(argv)
|
|
304
|
+
except SystemExit as error:
|
|
305
|
+
return core.EXIT_OK if error.code == 0 else core.EXIT_USAGE
|
|
306
|
+
command = commands[args.command]
|
|
307
|
+
if args.explain_egress:
|
|
308
|
+
return core.explain_egress(command.sources, command.name)
|
|
309
|
+
try:
|
|
310
|
+
ctx = core.Context(args, command.name, command.sources)
|
|
311
|
+
if args.offline:
|
|
312
|
+
with core.socket_guard():
|
|
313
|
+
result = command.run(ctx)
|
|
314
|
+
else:
|
|
315
|
+
result = command.run(ctx)
|
|
316
|
+
if isinstance(result, dict) and "_raw" in result:
|
|
317
|
+
if result["_raw"] is not None:
|
|
318
|
+
print(result["_raw"])
|
|
319
|
+
return core.EXIT_OK
|
|
320
|
+
if not command.findings:
|
|
321
|
+
if isinstance(result, dict) and "_raw" in result:
|
|
322
|
+
if result["_raw"] is not None:
|
|
323
|
+
print(result["_raw"])
|
|
324
|
+
return core.EXIT_OK
|
|
325
|
+
fmt = core.output_format(args)
|
|
326
|
+
if fmt == "text" and command.name == "doctor":
|
|
327
|
+
print(doctor_text(result))
|
|
328
|
+
elif fmt == "md" and isinstance(result, dict) and "markdown" in result:
|
|
329
|
+
print(result["markdown"])
|
|
330
|
+
else:
|
|
331
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
332
|
+
return result.get("exit_code", core.EXIT_OK) if isinstance(result, dict) else core.EXIT_OK
|
|
333
|
+
if isinstance(result, int):
|
|
334
|
+
return result
|
|
335
|
+
return core.emit(ctx, result)
|
|
336
|
+
except core.SecurityError as error:
|
|
337
|
+
print(f"maggie security {args.command}: {error}", file=sys.stderr)
|
|
338
|
+
return error.code
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
sys.exit(main())
|