whiff-python 0.1.0__cp311-abi3-win_amd64.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.
- whiff/__init__.py +64 -0
- whiff/_native.pyd +0 -0
- whiff/_native.pyi +11 -0
- whiff/engine.py +385 -0
- whiff/errors.py +153 -0
- whiff/input.py +65 -0
- whiff/limiter.py +123 -0
- whiff/merge.py +381 -0
- whiff/options.py +238 -0
- whiff/plugins.py +315 -0
- whiff/privacy.py +185 -0
- whiff/py.typed +0 -0
- whiff/report.py +96 -0
- whiff/schemas/whiff-1.schema.json +1735 -0
- whiff/scratch.py +294 -0
- whiff_python-0.1.0.dist-info/METADATA +183 -0
- whiff_python-0.1.0.dist-info/RECORD +20 -0
- whiff_python-0.1.0.dist-info/WHEEL +4 -0
- whiff_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- whiff_python-0.1.0.dist-info/sboms/whiff_native.cyclonedx.json +5681 -0
whiff/privacy.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Redaction for Python plugin patches.
|
|
2
|
+
|
|
3
|
+
Upstream whiff does not re-scan data added by plugins. With ``privacy="strict"``
|
|
4
|
+
(the default), each patch is scrubbed before it is merged. Patterns and
|
|
5
|
+
``[redacted:]`` markers follow whiff 0.8.0 ``src/text/sensitive.rs``. A private
|
|
6
|
+
key is redacted from its BEGIN line through its END line.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from whiff.errors import InvalidOptionError, InvalidReportError
|
|
15
|
+
|
|
16
|
+
_Check = Callable[[str], bool]
|
|
17
|
+
|
|
18
|
+
_PATTERNS: list[tuple[str, re.Pattern[str], str]] = [
|
|
19
|
+
(
|
|
20
|
+
"private_key",
|
|
21
|
+
re.compile(
|
|
22
|
+
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY"
|
|
23
|
+
r"(?: BLOCK)?-----(?:.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?"
|
|
24
|
+
r"PRIVATE KEY(?: BLOCK)?-----)?",
|
|
25
|
+
re.DOTALL,
|
|
26
|
+
),
|
|
27
|
+
"always",
|
|
28
|
+
),
|
|
29
|
+
("aws_access_key_id", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), "always"),
|
|
30
|
+
(
|
|
31
|
+
"github_token",
|
|
32
|
+
re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b"),
|
|
33
|
+
"always",
|
|
34
|
+
),
|
|
35
|
+
("slack_token", re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}"), "always"),
|
|
36
|
+
("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), "always"),
|
|
37
|
+
("stripe_secret_key", re.compile(r"\b[rs]k_live_[0-9A-Za-z]{20,}"), "always"),
|
|
38
|
+
("anthropic_api_key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}"), "always"),
|
|
39
|
+
("openai_api_key", re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}"), "always"),
|
|
40
|
+
(
|
|
41
|
+
"jwt",
|
|
42
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
|
|
43
|
+
"always",
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
"url_credentials",
|
|
47
|
+
re.compile(r"\b[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s:@]+:[^/\s@]+@[^\s/]+"),
|
|
48
|
+
"always",
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"credential_assignment",
|
|
52
|
+
re.compile(
|
|
53
|
+
r"(?i)\b[\w.-]*?(?:password|passwd|pwd|secret|token|api[_-]?key|"
|
|
54
|
+
r"access[_-]?token|auth[_-]?token|client[_-]?secret)"
|
|
55
|
+
r"[\"']?\s*[:=]\s*[\"']?[^\s\"',;]{6,}"
|
|
56
|
+
),
|
|
57
|
+
"not_placeholder",
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
"email",
|
|
61
|
+
re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b"),
|
|
62
|
+
"always",
|
|
63
|
+
),
|
|
64
|
+
(
|
|
65
|
+
"phone",
|
|
66
|
+
re.compile(
|
|
67
|
+
r"(?:\+\d{1,3}[ .-]?(?:\(\d{1,4}\)|\d{1,4})|\(\d{2,4}\))"
|
|
68
|
+
r"[ .-]?\d{3,4}[ .-]?\d{3,4}\b"
|
|
69
|
+
),
|
|
70
|
+
"always",
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
"iban",
|
|
74
|
+
re.compile(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b"),
|
|
75
|
+
"iban",
|
|
76
|
+
),
|
|
77
|
+
("payment_card", re.compile(r"\b(?:\d[ -]?){12,18}\d\b"), "luhn"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
_PLACEHOLDERS = ("changeme", "xxxxxx", "password", "secret", "example", "redacted", "******")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scrub_patch(patch: dict, mode: str) -> dict:
|
|
84
|
+
if mode == "off":
|
|
85
|
+
return patch
|
|
86
|
+
if mode != "strict":
|
|
87
|
+
raise InvalidOptionError("privacy must be 'strict' or 'off'")
|
|
88
|
+
if _sensitive_key(patch):
|
|
89
|
+
raise InvalidReportError("plugin patch key matches a sensitive pattern")
|
|
90
|
+
walked = _walk(patch)
|
|
91
|
+
if not isinstance(walked, dict):
|
|
92
|
+
raise InvalidReportError("plugin patch must be an object")
|
|
93
|
+
return walked
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def scrub_string(text: str, mode: str) -> str:
|
|
97
|
+
if mode == "off":
|
|
98
|
+
return text
|
|
99
|
+
if mode != "strict":
|
|
100
|
+
raise InvalidOptionError("privacy must be 'strict' or 'off'")
|
|
101
|
+
for kind, pattern, check in _PATTERNS:
|
|
102
|
+
text = pattern.sub(lambda match, kind=kind, check=check: _replace(kind, check, match), text)
|
|
103
|
+
return text
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _replace(kind: str, check: str, match: re.Match[str]) -> str:
|
|
107
|
+
value = match.group(0)
|
|
108
|
+
if _valid(check, value):
|
|
109
|
+
return f"[redacted:{kind}]"
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _walk(value: object) -> object:
|
|
114
|
+
if isinstance(value, dict):
|
|
115
|
+
return {key: _walk(item) for key, item in value.items()}
|
|
116
|
+
if isinstance(value, list):
|
|
117
|
+
return [_walk(item) for item in value]
|
|
118
|
+
if isinstance(value, str):
|
|
119
|
+
return scrub_string(value, "strict")
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _sensitive_key(value: object) -> bool:
|
|
124
|
+
if isinstance(value, dict):
|
|
125
|
+
return any(
|
|
126
|
+
(isinstance(key, str) and scrub_string(key, "strict") != key) or _sensitive_key(item)
|
|
127
|
+
for key, item in value.items()
|
|
128
|
+
)
|
|
129
|
+
if isinstance(value, list):
|
|
130
|
+
return any(_sensitive_key(item) for item in value)
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _valid(check: str, match: str) -> bool:
|
|
135
|
+
if check == "always":
|
|
136
|
+
return True
|
|
137
|
+
if check == "luhn":
|
|
138
|
+
return _luhn(match)
|
|
139
|
+
if check == "iban":
|
|
140
|
+
return _iban(match)
|
|
141
|
+
if check == "not_placeholder":
|
|
142
|
+
return not _placeholder(match)
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _placeholder(match: str) -> bool:
|
|
147
|
+
value = match.split("=")[-1].split(":")[-1].strip("\"' ").lower()
|
|
148
|
+
if value.startswith(("${", "<", "{{", "$", "%(")):
|
|
149
|
+
return True
|
|
150
|
+
return any(word in value for word in _PLACEHOLDERS)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _luhn(match: str) -> bool:
|
|
154
|
+
digits = [ord(char) - 48 for char in match if "0" <= char <= "9"]
|
|
155
|
+
if len(digits) not in range(13, 20):
|
|
156
|
+
return False
|
|
157
|
+
if len(set(digits)) == 1:
|
|
158
|
+
return False
|
|
159
|
+
total = 0
|
|
160
|
+
for index, digit in enumerate(reversed(digits)):
|
|
161
|
+
if index % 2 == 1:
|
|
162
|
+
doubled = digit * 2
|
|
163
|
+
total += doubled - 9 if doubled > 9 else doubled
|
|
164
|
+
else:
|
|
165
|
+
total += digit
|
|
166
|
+
return total % 10 == 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _iban(match: str) -> bool:
|
|
170
|
+
compact = re.sub(r"\s", "", match)
|
|
171
|
+
if not 15 <= len(compact) <= 34:
|
|
172
|
+
return False
|
|
173
|
+
moved = compact[4:] + compact[:4]
|
|
174
|
+
digits: list[int] = []
|
|
175
|
+
for char in moved:
|
|
176
|
+
if "A" <= char <= "Z":
|
|
177
|
+
digits.extend(divmod(ord(char) - ord("A") + 10, 10))
|
|
178
|
+
elif "0" <= char <= "9":
|
|
179
|
+
digits.append(ord(char) - 48)
|
|
180
|
+
else:
|
|
181
|
+
return False
|
|
182
|
+
remainder = 0
|
|
183
|
+
for digit in digits:
|
|
184
|
+
remainder = (remainder * 10 + digit) % 97
|
|
185
|
+
return remainder == 1
|
whiff/py.typed
ADDED
|
File without changes
|
whiff/report.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Decode and inspect ``whiff/1`` reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from whiff.errors import InvalidReportError
|
|
9
|
+
|
|
10
|
+
SCHEMA = "whiff/1"
|
|
11
|
+
|
|
12
|
+
TOP_LEVEL = (
|
|
13
|
+
"whiff",
|
|
14
|
+
"source",
|
|
15
|
+
"identification",
|
|
16
|
+
"system",
|
|
17
|
+
"temporal",
|
|
18
|
+
"security",
|
|
19
|
+
"rights",
|
|
20
|
+
"physical",
|
|
21
|
+
"integrity",
|
|
22
|
+
"content",
|
|
23
|
+
"metadata",
|
|
24
|
+
"format",
|
|
25
|
+
"embedded",
|
|
26
|
+
"diagnostics",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def decode(raw: bytes | str) -> dict[str, Any]:
|
|
31
|
+
try:
|
|
32
|
+
value = json.loads(raw)
|
|
33
|
+
except json.JSONDecodeError as err:
|
|
34
|
+
raise InvalidReportError(f"failed to decode report JSON: {err}") from err
|
|
35
|
+
if not isinstance(value, dict):
|
|
36
|
+
raise InvalidReportError("report JSON root must be an object")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def native_covered(report: dict[str, Any]) -> bool:
|
|
41
|
+
"""Whether native extraction covered the identified format.
|
|
42
|
+
|
|
43
|
+
Coverage requires a non-empty ``identification.extractor``, an extension
|
|
44
|
+
that was not reported as a mismatch, a detection that is not a bare
|
|
45
|
+
heuristic, and an ``ok`` diagnostics entry for that extractor.
|
|
46
|
+
"""
|
|
47
|
+
ident = report.get("identification") or {}
|
|
48
|
+
if not isinstance(ident, dict):
|
|
49
|
+
return False
|
|
50
|
+
extractor = ident.get("extractor")
|
|
51
|
+
if not isinstance(extractor, str) or not extractor:
|
|
52
|
+
return False
|
|
53
|
+
if ident.get("extension_matches") is False:
|
|
54
|
+
return False
|
|
55
|
+
if ident.get("detected_by") == "heuristic" and ident.get("extension_matches") is not True:
|
|
56
|
+
return False
|
|
57
|
+
return _diag_ok(report, extractor)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def unreadable(report: dict[str, Any]) -> bool:
|
|
61
|
+
ident = report.get("identification")
|
|
62
|
+
return isinstance(ident, dict) and ident.get("format") == "unreadable"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def extractor_ids(report: dict[str, Any]) -> list[str]:
|
|
66
|
+
runs = _runs(report)
|
|
67
|
+
return [run["id"] for run in runs if isinstance(run, dict) and isinstance(run.get("id"), str)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def append_extractor(report: dict[str, Any], entry: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
+
diagnostics = report.get("diagnostics")
|
|
72
|
+
if not isinstance(diagnostics, dict):
|
|
73
|
+
diagnostics = {}
|
|
74
|
+
else:
|
|
75
|
+
diagnostics = dict(diagnostics)
|
|
76
|
+
runs = list(diagnostics.get("extractors") or [])
|
|
77
|
+
runs.append(entry)
|
|
78
|
+
diagnostics["extractors"] = runs
|
|
79
|
+
updated = dict(report)
|
|
80
|
+
updated["diagnostics"] = diagnostics
|
|
81
|
+
return updated
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _diag_ok(report: dict[str, Any], extractor: str) -> bool:
|
|
85
|
+
return any(
|
|
86
|
+
isinstance(run, dict) and run.get("id") == extractor and run.get("status") == "ok"
|
|
87
|
+
for run in _runs(report)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _runs(report: dict[str, Any]) -> list[Any]:
|
|
92
|
+
diagnostics = report.get("diagnostics")
|
|
93
|
+
if not isinstance(diagnostics, dict):
|
|
94
|
+
return []
|
|
95
|
+
runs = diagnostics.get("extractors") or []
|
|
96
|
+
return runs if isinstance(runs, list) else []
|