modvet 0.2.0__py3-none-any.whl

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.
modvet/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ModVet — deterministic pre-install security scanner for Claude Code mods."""
2
+
3
+ __version__ = "0.1.0"
modvet/badge.py ADDED
@@ -0,0 +1,69 @@
1
+ """ModVet — deterministischer SVG-Badge-Generator im Shields.io-Stil (stdlib only).
2
+
3
+ Ausführen: from modvet.badge import badge_svg
4
+ """
5
+
6
+ # risk_level -> (Statuswort, Farbe)
7
+ LEVELS = {
8
+ "CLEAN": ("clean", "#4caf50"),
9
+ "LOW": ("low", "#8bc34a"),
10
+ "MEDIUM": ("warn", "#ffc107"),
11
+ "HIGH": ("high", "#ff9800"),
12
+ "CRITICAL": ("critical", "#f44336"),
13
+ }
14
+ UNKNOWN = ("unknown", "#9e9e9e")
15
+
16
+ LABEL = "modvet"
17
+ LABEL_COLOR = "#555555"
18
+ TEXT_COLOR = "#ffffff"
19
+ FONT_SIZE = 11
20
+ CHAR_WIDTH = 7 # ~7px pro Zeichen bei Schriftgröße 11
21
+ PAD_X = 5 # Innenabstand je Segmentrand
22
+ HEIGHT = 20
23
+ RADIUS = 3
24
+
25
+
26
+ def _esc(text):
27
+ """Minimales XML-Escaping für Textinhalte."""
28
+ return str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
29
+
30
+
31
+ def _seg_width(text):
32
+ return CHAR_WIDTH * len(str(text)) + 2 * PAD_X
33
+
34
+
35
+ def badge_svg(risk_level: str, findings: int, source: str = "mod") -> str:
36
+ """Erzeugt ein 2-Segment-SVG-Badge (Label 'modvet' + Status).
37
+
38
+ Deterministisch: gleiche Eingabe -> byte-identische Ausgabe.
39
+ `source` ist für künftige Nutzung reserviert und fließt nicht ins SVG ein.
40
+ """
41
+ key = str(risk_level).strip().upper()
42
+ status_word, status_color = LEVELS.get(key, UNKNOWN)
43
+ status_text = f"{status_word} · {findings}"
44
+
45
+ label_w = _seg_width(LABEL)
46
+ status_w = _seg_width(status_text)
47
+ total_w = label_w + status_w
48
+ label_cx = label_w / 2
49
+ status_cx = label_w + status_w / 2
50
+ baseline = HEIGHT - 6 # 14 bei Höhe 20
51
+
52
+ return (
53
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" '
54
+ f'height="{HEIGHT}" role="img">\n'
55
+ f" <title>modvet: {_esc(status_text)}</title>\n"
56
+ f' <rect width="{total_w}" height="{HEIGHT}" rx="{RADIUS}" '
57
+ f'fill="{LABEL_COLOR}"/>\n'
58
+ f' <rect x="{label_w}" width="{status_w}" height="{HEIGHT}" '
59
+ f'rx="{RADIUS}" fill="{status_color}"/>\n'
60
+ f' <rect x="{label_w}" width="{RADIUS}" height="{HEIGHT}" '
61
+ f'fill="{status_color}"/>\n'
62
+ f' <g fill="{TEXT_COLOR}" text-anchor="middle" '
63
+ f'font-family="Verdana,Geneva,DejaVu Sans,sans-serif" '
64
+ f'font-size="{FONT_SIZE}">\n'
65
+ f' <text x="{label_cx}" y="{baseline}">{LABEL}</text>\n'
66
+ f' <text x="{status_cx}" y="{baseline}">{_esc(status_text)}</text>\n'
67
+ f" </g>\n"
68
+ "</svg>\n"
69
+ )
modvet/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ """ModVet CLI — `modvet scan` / `modvet snapshot` / `modvet diff`.
2
+
3
+ Exit-Codes: 0 = clean, 1 = Findings >= --fail-on, 2 = usage/error.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import hashlib
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from .core.engine import Severity, format_human, format_json
14
+ from .mods import discover_mod_files, scan_mod
15
+
16
+ SNAPSHOT_VERSION = 1
17
+
18
+
19
+ def _manifest(root: Path) -> dict:
20
+ out = {}
21
+ for p in discover_mod_files(root):
22
+ rel = str(p.relative_to(root)).replace("\\", "/")
23
+ try:
24
+ data = p.read_bytes()
25
+ except OSError:
26
+ continue
27
+ out[rel] = {"sha256": hashlib.sha256(data).hexdigest(), "size": len(data)}
28
+ return out
29
+
30
+
31
+ def cmd_scan(args: argparse.Namespace) -> int:
32
+ root = Path(args.path)
33
+ if not root.is_dir():
34
+ print(f"error: not a directory: {root}", file=sys.stderr)
35
+ return 2
36
+ report = scan_mod(root)
37
+ if getattr(args, "format", "human") == "sarif":
38
+ from .sarif import format_sarif
39
+ print(json.dumps(format_sarif(report), indent=2, ensure_ascii=False))
40
+ elif args.json:
41
+ print(format_json(report))
42
+ else:
43
+ print(format_human(report))
44
+ threshold = Severity[args.fail_on.upper()].score()
45
+ return 1 if any(f.severity.score() >= threshold for f in report.findings) else 0
46
+
47
+
48
+ def cmd_badge(args: argparse.Namespace) -> int:
49
+ from .badge import badge_svg
50
+ src = Path(args.input)
51
+ try:
52
+ rep = json.loads(src.read_text(encoding="utf-8"))
53
+ except (OSError, json.JSONDecodeError) as e:
54
+ print(f"error: report unreadable: {e}", file=sys.stderr)
55
+ return 2
56
+ level = rep.get("risk_level", "UNKNOWN")
57
+ findings = int(rep.get("total_findings", 0))
58
+ svg = badge_svg(level, findings)
59
+ out = Path(args.out) if args.out else Path("badge.svg")
60
+ out.write_text(svg, encoding="utf-8")
61
+ print(f"badge: {level} ({findings} findings) -> {out}")
62
+ return 0
63
+
64
+
65
+ def cmd_snapshot(args: argparse.Namespace) -> int:
66
+ root = Path(args.path)
67
+ if not root.is_dir():
68
+ print(f"error: not a directory: {root}", file=sys.stderr)
69
+ return 2
70
+ snap = {"version": SNAPSHOT_VERSION, "files": _manifest(root)}
71
+ out = Path(args.out) if args.out else Path(".modvet-snapshot.json")
72
+ out.write_text(json.dumps(snap, indent=2, sort_keys=True), encoding="utf-8")
73
+ print(f"snapshot: {len(snap['files'])} files -> {out}")
74
+ return 0
75
+
76
+
77
+ def cmd_diff(args: argparse.Namespace) -> int:
78
+ root = Path(args.path)
79
+ if not root.is_dir():
80
+ print(f"error: not a directory: {root}", file=sys.stderr)
81
+ return 2
82
+ try:
83
+ snap = json.loads(Path(args.against).read_text(encoding="utf-8"))
84
+ old = snap["files"]
85
+ except (OSError, KeyError, json.JSONDecodeError) as e:
86
+ print(f"error: snapshot unreadable: {e}", file=sys.stderr)
87
+ return 2
88
+ new = _manifest(root)
89
+ changed = sorted(set(new) - set(old))
90
+ changed += [f for f in sorted(set(new) & set(old)) if new[f]["sha256"] != old[f]["sha256"]]
91
+ removed = sorted(set(old) - set(new))
92
+ print("=== Update-Delta ===")
93
+ for f in changed:
94
+ print(f" CHANGED {f}")
95
+ for f in removed:
96
+ print(f" REMOVED {f}")
97
+ if not changed and not removed:
98
+ print(" (kein Delta — installierte Version identisch mit Snapshot)")
99
+ return 0
100
+ # Findings nur auf geänderten/neuen Dateien
101
+ findings = []
102
+ for rel in changed:
103
+ p = root / rel
104
+ if not p.is_file():
105
+ continue
106
+ try:
107
+ text = p.read_text(encoding="utf-8", errors="replace")
108
+ except OSError:
109
+ continue
110
+ findings.extend(scan_mod_report_on_single_file(rel, text))
111
+ if findings:
112
+ print(f"=== Delta-Findings ({len(findings)}) ===")
113
+ for f in findings:
114
+ print(f" [{f.severity.value}] {f.pattern_name} ({f.category}) {f.line_number}: {f.matched_text[:80]}")
115
+ else:
116
+ print(" Delta-Findings: keine")
117
+ threshold = Severity[args.fail_on.upper()].score()
118
+ return 1 if any(f.severity.score() >= threshold for f in findings) else 0
119
+
120
+
121
+ def scan_mod_report_on_single_file(rel: str, text: str):
122
+ """Engine-Scan + Capability-Kombinationslogik für genau eine Datei (Delta-Pfad)."""
123
+ from .mods import scan_single_file
124
+ return scan_single_file(rel, text)
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> int:
128
+ parser = argparse.ArgumentParser(prog="modvet", description="ModVet — pre-install security scanner for Claude Code mods")
129
+ sub = parser.add_subparsers(dest="cmd", required=True)
130
+
131
+ p_scan = sub.add_parser("scan", help="Scan a mod directory")
132
+ p_scan.add_argument("path")
133
+ p_scan.add_argument("--json", action="store_true", help="JSON output")
134
+ p_scan.add_argument("--format", choices=["human", "sarif"], default="human", help="Output format (SARIF 2.1.0 for GitHub Code Scanning)")
135
+ p_scan.add_argument("--fail-on", default="HIGH", choices=["LOW", "MEDIUM", "HIGH", "CRITICAL"])
136
+ p_scan.set_defaults(fn=cmd_scan)
137
+
138
+ p_badge = sub.add_parser("badge", help="Generate a status badge SVG from a scan report")
139
+ p_badge.add_argument("--input", required=True, help="modvet scan --json report file")
140
+ p_badge.add_argument("--out", help="output .svg path (default: badge.svg)")
141
+ p_badge.set_defaults(fn=cmd_badge)
142
+
143
+ p_snap = sub.add_parser("snapshot", help="Baseline a clean mod install")
144
+ p_snap.add_argument("path")
145
+ p_snap.add_argument("--out", help="snapshot file (default: .modvet-snapshot.json)")
146
+ p_snap.set_defaults(fn=cmd_snapshot)
147
+
148
+ p_diff = sub.add_parser("diff", help="Diff installed mod against snapshot")
149
+ p_diff.add_argument("path")
150
+ p_diff.add_argument("--against", required=True, help="snapshot file")
151
+ p_diff.add_argument("--fail-on", default="HIGH", choices=["LOW", "MEDIUM", "HIGH", "CRITICAL"])
152
+ p_diff.set_defaults(fn=cmd_diff)
153
+
154
+ args = parser.parse_args(argv)
155
+ return args.fn(args)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ sys.exit(main())
@@ -0,0 +1,19 @@
1
+ """Core engine (ported ClawGuard scanner: deterministic patterns, offline, no LLM)."""
2
+
3
+ from .engine import (
4
+ Finding,
5
+ ScanReport,
6
+ Severity,
7
+ scan_text,
8
+ format_human,
9
+ format_json,
10
+ )
11
+
12
+ __all__ = [
13
+ "Finding",
14
+ "ScanReport",
15
+ "Severity",
16
+ "scan_text",
17
+ "format_human",
18
+ "format_json",
19
+ ]