verfy 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.
- verfy/__init__.py +14 -0
- verfy/__main__.py +4 -0
- verfy/_util.py +191 -0
- verfy/canonical.py +85 -0
- verfy/cli.py +1026 -0
- verfy/data/default_policy.json +104 -0
- verfy/data/patterns.json +737 -0
- verfy/demo_server.py +66 -0
- verfy/discover.py +197 -0
- verfy/ed25519.py +159 -0
- verfy/fileio.py +91 -0
- verfy/gateway.py +295 -0
- verfy/hook.py +150 -0
- verfy/lock.py +188 -0
- verfy/mcp_client.py +80 -0
- verfy/policy.py +53 -0
- verfy/precheck.py +847 -0
- verfy/receipts.py +238 -0
- verfy/scanner.py +211 -0
- verfy/scitt.py +181 -0
- verfy/team.py +203 -0
- verfy-0.2.0.dist-info/METADATA +150 -0
- verfy-0.2.0.dist-info/RECORD +27 -0
- verfy-0.2.0.dist-info/WHEEL +5 -0
- verfy-0.2.0.dist-info/entry_points.txt +2 -0
- verfy-0.2.0.dist-info/licenses/LICENSE +9 -0
- verfy-0.2.0.dist-info/top_level.txt +1 -0
verfy/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Verfy: security, policy and verifiable evidence for coding agents and MCP tools.
|
|
2
|
+
|
|
3
|
+
Guard every tool call with deterministic rules, scan and lock MCP tool
|
|
4
|
+
definitions, and sign a tamper-evident receipt for everything that happened.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .canonical import canonical, digest
|
|
8
|
+
from .policy import DEFAULT_POLICY, load_policy, policy_digest
|
|
9
|
+
from .precheck import check, classify
|
|
10
|
+
from .scanner import scan, tool_pin
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["canonical", "digest", "DEFAULT_POLICY", "load_policy", "policy_digest", "check", "classify", "scan", "tool_pin", "__version__"]
|
verfy/__main__.py
ADDED
verfy/_util.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Small helpers shared by the scanner and the pre-check.
|
|
2
|
+
|
|
3
|
+
Every function here has a twin in ``web/verfy-engine.js``. Keep them in step:
|
|
4
|
+
the parity tests run the same fixtures through both.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterable
|
|
15
|
+
|
|
16
|
+
PATTERNS: dict = json.loads((Path(__file__).parent / "data" / "patterns.json").read_text("utf-8"))
|
|
17
|
+
|
|
18
|
+
SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
|
|
19
|
+
VERDICT_RANK = {"deny": 2, "ask": 1, "allow": 0}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lru_cache(maxsize=None)
|
|
23
|
+
def rx(pattern: str, flags: str = "") -> "re.Pattern[str]":
|
|
24
|
+
f = 0
|
|
25
|
+
if "i" in flags:
|
|
26
|
+
f |= re.IGNORECASE
|
|
27
|
+
return re.compile(pattern, f)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def words(name: str) -> list[str]:
|
|
31
|
+
"""Split ``readFile``, ``read_file`` and ``read-file`` into lowercase words."""
|
|
32
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name or "")
|
|
33
|
+
return [w for w in re.split(r"[^A-Za-z0-9]+", s.lower()) if w]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_invisible(cp: int) -> bool:
|
|
37
|
+
return any(a <= cp <= b for a, b in PATTERNS["invisible_ranges"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def entropy(v: str) -> float:
|
|
41
|
+
if not v:
|
|
42
|
+
return 0.0
|
|
43
|
+
counts: dict[str, int] = {}
|
|
44
|
+
for ch in v:
|
|
45
|
+
counts[ch] = counts.get(ch, 0) + 1
|
|
46
|
+
n = len(v)
|
|
47
|
+
return -sum(c / n * math.log2(c / n) for c in counts.values())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def scan_secrets(text: str) -> list[dict]:
|
|
51
|
+
out: list[dict] = []
|
|
52
|
+
if not text or not isinstance(text, str):
|
|
53
|
+
return out
|
|
54
|
+
seen = set()
|
|
55
|
+
for rid, label, pat, flags, sev in PATTERNS["secrets"]:
|
|
56
|
+
for m in rx(pat, flags).finditer(text):
|
|
57
|
+
value = m.group(1) if m.groups() and m.group(1) is not None else m.group(0)
|
|
58
|
+
low = value.lower()
|
|
59
|
+
if any(h in low for h in PATTERNS["placeholders"]):
|
|
60
|
+
continue
|
|
61
|
+
if sev == "medium" and entropy(value) < 3.0:
|
|
62
|
+
continue
|
|
63
|
+
if rid in seen:
|
|
64
|
+
continue
|
|
65
|
+
seen.add(rid)
|
|
66
|
+
out.append({"id": rid, "label": label, "severity": sev, "start": m.start(), "end": m.end()})
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def redact(text: str) -> str:
|
|
71
|
+
hits = scan_secrets(text)
|
|
72
|
+
for h in sorted(hits, key=lambda h: -h["start"]):
|
|
73
|
+
text = text[: h["start"]] + "[REDACTED:" + h["id"] + "]" + text[h["end"]:]
|
|
74
|
+
return text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def redact_value(v: Any) -> Any:
|
|
78
|
+
if isinstance(v, str):
|
|
79
|
+
return redact(v) if scan_secrets(v) else v
|
|
80
|
+
if isinstance(v, list):
|
|
81
|
+
return [redact_value(x) for x in v]
|
|
82
|
+
if isinstance(v, dict):
|
|
83
|
+
return {k: redact_value(x) for k, x in v.items()}
|
|
84
|
+
return v
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def flatten_strings(v: Any, path: str = "") -> list[tuple[str, str]]:
|
|
88
|
+
"""Every string inside ``v`` with its dotted key path."""
|
|
89
|
+
out: list[tuple[str, str]] = []
|
|
90
|
+
if isinstance(v, str):
|
|
91
|
+
out.append((path, v))
|
|
92
|
+
elif isinstance(v, list):
|
|
93
|
+
for i, x in enumerate(v):
|
|
94
|
+
out.extend(flatten_strings(x, f"{path}[{i}]"))
|
|
95
|
+
elif isinstance(v, dict):
|
|
96
|
+
for k, x in v.items():
|
|
97
|
+
out.extend(flatten_strings(x, f"{path}.{k}" if path else k))
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------- paths
|
|
102
|
+
|
|
103
|
+
def normpath(p: str) -> str:
|
|
104
|
+
"""POSIX normpath that never touches the real filesystem."""
|
|
105
|
+
absolute = p.startswith("/")
|
|
106
|
+
parts: list[str] = []
|
|
107
|
+
for part in p.split("/"):
|
|
108
|
+
if part in ("", "."):
|
|
109
|
+
continue
|
|
110
|
+
if part == "..":
|
|
111
|
+
if parts and parts[-1] != "..":
|
|
112
|
+
parts.pop()
|
|
113
|
+
elif not absolute:
|
|
114
|
+
parts.append("..")
|
|
115
|
+
continue
|
|
116
|
+
parts.append(part)
|
|
117
|
+
s = "/".join(parts)
|
|
118
|
+
return ("/" + s) if absolute else (s or ".")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def resolve(p: str, home: str, cwd: str) -> str:
|
|
122
|
+
p = (p or "").strip()
|
|
123
|
+
if p == "~" or p.startswith("~/"):
|
|
124
|
+
p = home + p[1:]
|
|
125
|
+
elif p.startswith("$HOME") or p.startswith("${HOME}"):
|
|
126
|
+
p = home + p[(5 if p.startswith("$HOME") else 7):]
|
|
127
|
+
if not p.startswith("/"):
|
|
128
|
+
p = cwd.rstrip("/") + "/" + p
|
|
129
|
+
return normpath(p)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_within(path: str, root: str) -> bool:
|
|
133
|
+
root = root.rstrip("/") or "/"
|
|
134
|
+
return path == root or path.startswith(root + "/") or root == "/"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@lru_cache(maxsize=None)
|
|
138
|
+
def _glob_rx(pattern: str) -> "re.Pattern[str]":
|
|
139
|
+
i, out = 0, ""
|
|
140
|
+
if pattern.startswith("**/"):
|
|
141
|
+
out = "(?:.*/)?"
|
|
142
|
+
i = 3
|
|
143
|
+
while i < len(pattern):
|
|
144
|
+
c = pattern[i]
|
|
145
|
+
if pattern.startswith("/**/", i):
|
|
146
|
+
out += "/(?:.*/)?"
|
|
147
|
+
i += 4
|
|
148
|
+
continue
|
|
149
|
+
if pattern.startswith("/**", i) and i + 3 == len(pattern):
|
|
150
|
+
out += "(?:/.*)?"
|
|
151
|
+
i += 3
|
|
152
|
+
continue
|
|
153
|
+
if pattern.startswith("**", i):
|
|
154
|
+
out += ".*"
|
|
155
|
+
i += 2
|
|
156
|
+
continue
|
|
157
|
+
if c == "*":
|
|
158
|
+
out += "[^/]*"
|
|
159
|
+
elif c == "?":
|
|
160
|
+
out += "[^/]"
|
|
161
|
+
else:
|
|
162
|
+
out += re.escape(c)
|
|
163
|
+
i += 1
|
|
164
|
+
return re.compile("^" + out + "$")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def glob_match(pattern: str, path: str, home: str) -> bool:
|
|
168
|
+
if pattern == "~" or pattern.startswith("~/"):
|
|
169
|
+
pattern = home + pattern[1:]
|
|
170
|
+
if pattern.startswith("**/"):
|
|
171
|
+
return bool(_glob_rx(pattern).match(path.lstrip("/"))) or bool(_glob_rx(pattern).match(path))
|
|
172
|
+
return bool(_glob_rx(pattern).match(path))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def first_match(patterns: Iterable[str], path: str, home: str) -> str | None:
|
|
176
|
+
for pat in patterns or []:
|
|
177
|
+
if glob_match(pat, path, home):
|
|
178
|
+
return pat
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def name_match(pattern: str, name: str) -> bool:
|
|
183
|
+
"""Glob for tool names (``*`` matches anything, including ``/``)."""
|
|
184
|
+
return bool(re.match("^" + re.escape(pattern).replace(r"\*", ".*") + "$", name or ""))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def host_match(pattern: str, host: str) -> bool:
|
|
188
|
+
pattern, host = pattern.lower(), host.lower()
|
|
189
|
+
if pattern.startswith("*."):
|
|
190
|
+
return host == pattern[2:] or host.endswith(pattern[1:])
|
|
191
|
+
return host == pattern
|
verfy/canonical.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Canonical JSON: the exact bytes Verfy hashes and signs.
|
|
2
|
+
|
|
3
|
+
Keys are sorted, there is no whitespace and numbers are written the way
|
|
4
|
+
JavaScript's ``JSON.stringify`` writes them, so the Python and browser
|
|
5
|
+
engines produce identical bytes for identical data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _number(x: float) -> str:
|
|
17
|
+
if isinstance(x, bool):
|
|
18
|
+
raise TypeError("bool is not a number")
|
|
19
|
+
if isinstance(x, int):
|
|
20
|
+
return str(x)
|
|
21
|
+
if math.isnan(x) or math.isinf(x):
|
|
22
|
+
raise ValueError("NaN and Infinity cannot be signed")
|
|
23
|
+
if x == int(x) and abs(x) < 1e21:
|
|
24
|
+
return str(int(x))
|
|
25
|
+
# ECMAScript Number::toString, built from Python's shortest repr digits.
|
|
26
|
+
r = repr(abs(x))
|
|
27
|
+
mant, _, exp = r.partition("e")
|
|
28
|
+
e = int(exp) if exp else 0
|
|
29
|
+
if "." in mant:
|
|
30
|
+
ip, fp = mant.split(".")
|
|
31
|
+
else:
|
|
32
|
+
ip, fp = mant, ""
|
|
33
|
+
digits = (ip + fp).lstrip("0")
|
|
34
|
+
point = len(ip) + e # position of the decimal point relative to digits start
|
|
35
|
+
lead = len(ip + fp) - len((ip + fp).lstrip("0"))
|
|
36
|
+
point -= lead
|
|
37
|
+
digits = digits.rstrip("0") or "0"
|
|
38
|
+
k, n = len(digits), point
|
|
39
|
+
sign = "-" if x < 0 else ""
|
|
40
|
+
if k <= n <= 21:
|
|
41
|
+
return sign + digits + "0" * (n - k)
|
|
42
|
+
if 0 < n <= 21:
|
|
43
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
44
|
+
if -6 < n <= 0:
|
|
45
|
+
return sign + "0." + "0" * (-n) + digits
|
|
46
|
+
e2 = n - 1
|
|
47
|
+
es = ("+" if e2 >= 0 else "-") + str(abs(e2))
|
|
48
|
+
if k == 1:
|
|
49
|
+
return sign + digits + "e" + es
|
|
50
|
+
return sign + digits[0] + "." + digits[1:] + "e" + es
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def canonical(obj: Any) -> str:
|
|
54
|
+
if obj is None:
|
|
55
|
+
return "null"
|
|
56
|
+
if obj is True:
|
|
57
|
+
return "true"
|
|
58
|
+
if obj is False:
|
|
59
|
+
return "false"
|
|
60
|
+
if isinstance(obj, (int, float)):
|
|
61
|
+
return _number(obj)
|
|
62
|
+
if isinstance(obj, str):
|
|
63
|
+
return json.dumps(obj, ensure_ascii=False)
|
|
64
|
+
if isinstance(obj, (list, tuple)):
|
|
65
|
+
return "[" + ",".join(canonical(v) for v in obj) + "]"
|
|
66
|
+
if isinstance(obj, dict):
|
|
67
|
+
for k in obj:
|
|
68
|
+
if not isinstance(k, str):
|
|
69
|
+
raise TypeError("object keys must be strings")
|
|
70
|
+
keys = sorted(obj, key=lambda s: s.encode("utf-16-be"))
|
|
71
|
+
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(obj[k]) for k in keys) + "}"
|
|
72
|
+
raise TypeError(f"cannot canonicalize {type(obj).__name__}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def canonical_bytes(obj: Any) -> bytes:
|
|
76
|
+
return canonical(obj).encode("utf-8")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sha256_hex(data: bytes) -> str:
|
|
80
|
+
return hashlib.sha256(data).hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def digest(obj: Any) -> str:
|
|
84
|
+
"""``sha256:<hex>`` of the canonical form of ``obj``."""
|
|
85
|
+
return "sha256:" + sha256_hex(canonical_bytes(obj))
|