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/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 []