@topy-ai/maggie 0.7.61 → 0.8.1
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 +2 -2
- package/README.md +5 -5
- package/bundled-references/agent-goal-design.md +92 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-deployment/SKILL.md +21 -1
- package/bundled-skills/maggie-design/SKILL.md +29 -1
- 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-skills/maggie-spirit/SKILL.md +128 -0
- package/bundled-tools/clis/maggie_dash.py +12 -4
- 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/maggie_spirit.py +143 -0
- package/bundled-tools/clis/security_data/tools.lock.json +36 -7
- package/bundled-tools/runtime/maggie_dash_pg.py +126 -0
- package/bundled-tools/runtime/maggie_dash_store.py +17 -5
- package/bundled-tools/runtime/spirit_agent.py +262 -0
- package/bundled-tools/runtime/spirit_base.py +256 -0
- package/bundled-tools/runtime/spirit_face.py +222 -0
- package/package.json +1 -1
- package/references/agent-goal-design.md +92 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: maggie-spirit
|
|
3
|
+
description: Generate an AI brand's flat-pixel spirit (sprite) system - a logo mark that animates through a fixed 32-state base set, and per-agent avatar faces animated through the same states. Use for agent/brand avatars, logo marks, sprite-sheet and sprite-animation assets, and a shared motion vocabulary across Core and Service agents.
|
|
4
|
+
metadata:
|
|
5
|
+
version: 0.1.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Maggie Spirit
|
|
9
|
+
|
|
10
|
+
Give an agent brand one flat-pixel visual language: a **logo mark** and a set of
|
|
11
|
+
**agent avatars** that all animate through the **same fixed catalog of 32
|
|
12
|
+
states** (power, work, comms, outcome, gov). Agents differ only by their
|
|
13
|
+
**motif/face** and their **accent** hue; the motion vocabulary, semantic palette
|
|
14
|
+
and status badges stay identical so a state reads the same everywhere.
|
|
15
|
+
|
|
16
|
+
Everything is deterministic (seeded by accent + persona) and self-contained
|
|
17
|
+
(Pillow only - no model calls, no per-image cost). It pairs with
|
|
18
|
+
[agent goal-led design](../../references/agent-goal-design.md): design the agent,
|
|
19
|
+
then give it a spirit.
|
|
20
|
+
|
|
21
|
+
Follow the [Maggie Memory Hook](../../references/memory-hook.md) at invocation
|
|
22
|
+
and completion.
|
|
23
|
+
|
|
24
|
+
## When to use
|
|
25
|
+
|
|
26
|
+
- You are standing up a multi-agent product (a C-suite of AI cofounders, a set
|
|
27
|
+
of Service Apps) and want a consistent avatar + logo + status-animation set.
|
|
28
|
+
- You need a logo mark that can show live state (idle, working, deploying,
|
|
29
|
+
success, warning, secure, ...) rather than a single static glyph.
|
|
30
|
+
- You need sprite sheets / animation strips + a `states.json` manifest to drive
|
|
31
|
+
a web UI (the CSS `steps()` technique), not a one-off illustration.
|
|
32
|
+
|
|
33
|
+
For page/layout design use `maggie-design`; for marketplace templates use
|
|
34
|
+
`maggie-marketplace`; this skill produces the brand's character assets only.
|
|
35
|
+
|
|
36
|
+
## The 32-state base set
|
|
37
|
+
|
|
38
|
+
Five groups, fixed across every agent so severity and status read the same:
|
|
39
|
+
|
|
40
|
+
- **power:** off, booting, idle, active, paused, offline
|
|
41
|
+
- **work:** thinking, loading, syncing, writing, streaming, queued, scanning, deploying
|
|
42
|
+
- **comms:** speaking, listening, notification, mention, recording
|
|
43
|
+
- **outcome:** success, warning, critical, blocked, error, retrying
|
|
44
|
+
- **gov:** awaiting, approved, rejected, secure, vulnerable, maintenance, celebrating
|
|
45
|
+
|
|
46
|
+
Each state carries its own frame count, fps, motion and optional badge; every
|
|
47
|
+
state animates (none is static). The per-state `[frames, fps]` are written to
|
|
48
|
+
`states.json` beside the images so a UI can play each strip exactly.
|
|
49
|
+
|
|
50
|
+
## CLI
|
|
51
|
+
|
|
52
|
+
Run through the bundled tool so it uses the current project as the working
|
|
53
|
+
directory:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# logo mark + 32-state animated base set (anim-*.png, mark-*.svg, sprite-sheet.png, states.json)
|
|
57
|
+
maggie tool maggie_spirit.py mark --out public/assets/brand --name oneman \
|
|
58
|
+
--accent "#ff1212" --title ONEMAN
|
|
59
|
+
|
|
60
|
+
# one agent avatar animated through all 32 states (+ stills + sheet + states.json)
|
|
61
|
+
maggie tool maggie_spirit.py agent --out public/assets/brand --name cto \
|
|
62
|
+
--accent "#3b82f6" --spec cto.persona.json
|
|
63
|
+
|
|
64
|
+
# a single still avatar face
|
|
65
|
+
maggie tool maggie_spirit.py avatar --out public/assets/brand/cto/face.png \
|
|
66
|
+
--accent "#3b82f6" --spec cto.persona.json
|
|
67
|
+
|
|
68
|
+
# the reference base set to copy and recolour: default mark + example cast + founder
|
|
69
|
+
maggie tool maggie_spirit.py demo --out public/assets/brand
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`persona.json` (all keys optional except implied by your design):
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"gender": "m",
|
|
77
|
+
"hair": "#2a2018",
|
|
78
|
+
"skin": "light | mid | tan | deep",
|
|
79
|
+
"hairstyle": "short|slick|spiky|buzz|longwavy|longstraight|longbob|ponytail",
|
|
80
|
+
"eyes": "round|wide|lash|happy",
|
|
81
|
+
"mouth": "flat|smile|smirk|lips|open",
|
|
82
|
+
"prop": "glasses|shades|cigar|headset|earbuds|monocle|pen|bandana",
|
|
83
|
+
"propcol": "#f2c75a",
|
|
84
|
+
"hoodie": true, "band": "#e23b3b", "mask": "#12201a", "specs": true
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Output contract
|
|
89
|
+
|
|
90
|
+
For each target (`<out>/<name>/`):
|
|
91
|
+
|
|
92
|
+
- `anim-<state>.png` - one horizontal strip per state (frames laid left to right).
|
|
93
|
+
- `still-<state>.png` - a representative still per state (agents only).
|
|
94
|
+
- `mark-<state>.svg` - a crisp SVG still per state (mark only).
|
|
95
|
+
- `sprite-sheet.png` - an overview contact sheet.
|
|
96
|
+
- `states.json` - `{ title, accent, N, scale, groups, states: { <state>: {group, frames, fps, motion, badge} } }`.
|
|
97
|
+
|
|
98
|
+
## Driving the animation in a UI
|
|
99
|
+
|
|
100
|
+
Flat-pixel, pixel-exact. Use CSS `steps()` with the per-state frame count, never
|
|
101
|
+
percentage-position stepping:
|
|
102
|
+
|
|
103
|
+
```css
|
|
104
|
+
.sprite{
|
|
105
|
+
--fw: 48px; /* one frame = one cell */
|
|
106
|
+
width: var(--fw); height: var(--fw);
|
|
107
|
+
background: url(/assets/brand/cto/anim-active.png) no-repeat;
|
|
108
|
+
background-size: calc(var(--n) * var(--fw)) var(--fw);
|
|
109
|
+
image-rendering: pixelated;
|
|
110
|
+
animation: play calc(var(--n) / var(--fps) * 1s) steps(var(--n)) infinite;
|
|
111
|
+
}
|
|
112
|
+
@keyframes play { to { background-position-x: calc(-1 * var(--n) * var(--fw)); } }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Set `--n` and `--fps` per state from `states.json`. If you build the sprite
|
|
116
|
+
element with `innerHTML` in an Astro page, style it with `<style is:global>` -
|
|
117
|
+
Astro's scoped styles do not reach runtime-injected nodes (see `maggie-design`).
|
|
118
|
+
|
|
119
|
+
## Boundaries
|
|
120
|
+
|
|
121
|
+
- Deterministic and local: no LLM/image-model calls and no per-image billing.
|
|
122
|
+
Pillow is the only dependency.
|
|
123
|
+
- The mark is the brand's own; recolour via `--accent`, keep the semantic
|
|
124
|
+
palette (severity colours) fixed so status stays legible across agents.
|
|
125
|
+
- Do not hand-edit generated files; change the persona/accent and regenerate so
|
|
126
|
+
the whole set stays consistent.
|
|
127
|
+
- Store generated art under the host project's assets; keep source personas
|
|
128
|
+
(the `*.persona.json`) in the repo so the set is reproducible.
|
|
@@ -38,6 +38,11 @@ def project_root(args: argparse.Namespace) -> Path:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def store_for(root: Path) -> MaggieDashStore:
|
|
41
|
+
# ONEMAN forbids SQLite: when MAGGIEDASH_DATABASE_URL is set, back the store
|
|
42
|
+
# with Postgres. Otherwise fall back to the local SQLite dev store.
|
|
43
|
+
dsn = os.environ.get("MAGGIEDASH_DATABASE_URL")
|
|
44
|
+
if dsn:
|
|
45
|
+
return MaggieDashStore(dsn=dsn)
|
|
41
46
|
return MaggieDashStore(root / ".maggie" / "maggiedash.sqlite")
|
|
42
47
|
|
|
43
48
|
|
|
@@ -642,6 +647,9 @@ def command_recommend(args: argparse.Namespace) -> int:
|
|
|
642
647
|
def command_init(args: argparse.Namespace) -> int:
|
|
643
648
|
require_confirm(args)
|
|
644
649
|
root = project_root(args)
|
|
650
|
+
if str(getattr(args, "adapter", "")).endswith("postgres") and not os.environ.get("MAGGIEDASH_DATABASE_URL"):
|
|
651
|
+
print("POSTGRES_DSN_REQUIRED: adapter is postgres but MAGGIEDASH_DATABASE_URL is not set.", file=sys.stderr)
|
|
652
|
+
return 2
|
|
645
653
|
store = store_for(root)
|
|
646
654
|
try:
|
|
647
655
|
project = store.init_project(args.project_id, args.name, args.adapter, args.locale)
|
|
@@ -652,9 +660,9 @@ def command_init(args: argparse.Namespace) -> int:
|
|
|
652
660
|
"name": args.name,
|
|
653
661
|
"adapter": args.adapter,
|
|
654
662
|
"locale": args.locale,
|
|
655
|
-
"storage":
|
|
663
|
+
"storage": store.storage_label,
|
|
656
664
|
}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
657
|
-
emit({"status": "initialized", "project": project, "contract": str(contract), "storage":
|
|
665
|
+
emit({"status": "initialized", "project": project, "contract": str(contract), "storage": store.storage_label})
|
|
658
666
|
return 0
|
|
659
667
|
finally:
|
|
660
668
|
store.close()
|
|
@@ -667,7 +675,7 @@ def command_status(args: argparse.Namespace) -> int:
|
|
|
667
675
|
try:
|
|
668
676
|
project = store.connection.execute("SELECT * FROM maggiedash_projects WHERE id=?", (project_id,)).fetchone()
|
|
669
677
|
documents = store.connection.execute("SELECT id, kind, title, slug, status, updated_at FROM maggiedash_documents WHERE project_id=? ORDER BY updated_at DESC", (project_id,)).fetchall()
|
|
670
|
-
emit({"status": "ready" if project else "not_initialized", "project": dict(project) if project else None, "documents": [dict(row) for row in documents], "storage":
|
|
678
|
+
emit({"status": "ready" if project else "not_initialized", "project": dict(project) if project else None, "documents": [dict(row) for row in documents], "storage": store.storage_label})
|
|
671
679
|
return 0 if project else 1
|
|
672
680
|
finally:
|
|
673
681
|
store.close()
|
|
@@ -680,7 +688,7 @@ def command_migrate(args: argparse.Namespace) -> int:
|
|
|
680
688
|
try:
|
|
681
689
|
store.connection.execute("PRAGMA user_version = 1")
|
|
682
690
|
store.connection.commit()
|
|
683
|
-
emit({"status": "migrated", "schemaVersion": "maggiedash-storage.v1", "storage":
|
|
691
|
+
emit({"status": "migrated", "schemaVersion": "maggiedash-storage.v1", "storage": store.storage_label})
|
|
684
692
|
return 0
|
|
685
693
|
finally:
|
|
686
694
|
store.close()
|
|
@@ -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("")
|