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 +3 -0
- modvet/badge.py +69 -0
- modvet/cli.py +159 -0
- modvet/core/__init__.py +19 -0
- modvet/core/engine.py +2351 -0
- modvet/core/mapping-patterns.csv +237 -0
- modvet/detectors_extra.py +226 -0
- modvet/mods.py +550 -0
- modvet/sarif.py +68 -0
- modvet-0.2.0.dist-info/METADATA +62 -0
- modvet-0.2.0.dist-info/RECORD +13 -0
- modvet-0.2.0.dist-info/WHEEL +4 -0
- modvet-0.2.0.dist-info/entry_points.txt +2 -0
modvet/__init__.py
ADDED
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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())
|
modvet/core/__init__.py
ADDED
|
@@ -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
|
+
]
|