devguard-scanner 0.1.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.
Files changed (67) hide show
  1. devguard/__init__.py +5 -0
  2. devguard/ai/__init__.py +0 -0
  3. devguard/ai/investigator.py +380 -0
  4. devguard/ai/prompts.py +113 -0
  5. devguard/ai/providers/__init__.py +0 -0
  6. devguard/ai/providers/anthropic.py +163 -0
  7. devguard/ai/providers/openai_compat.py +157 -0
  8. devguard/ai/registry.py +160 -0
  9. devguard/ai/run.py +119 -0
  10. devguard/ai/sdk.py +102 -0
  11. devguard/ai/testing.py +126 -0
  12. devguard/ai/tools.py +389 -0
  13. devguard/analyzers/__init__.py +0 -0
  14. devguard/analyzers/base.py +135 -0
  15. devguard/analyzers/carriers.py +345 -0
  16. devguard/analyzers/command.py +343 -0
  17. devguard/analyzers/content.py +240 -0
  18. devguard/analyzers/filesystem.py +142 -0
  19. devguard/analyzers/hidden.py +273 -0
  20. devguard/analyzers/imports.py +102 -0
  21. devguard/analyzers/jsonc.py +221 -0
  22. devguard/analyzers/surfaces/__init__.py +0 -0
  23. devguard/analyzers/surfaces/ai_assistants.py +318 -0
  24. devguard/analyzers/surfaces/autoload.py +142 -0
  25. devguard/analyzers/surfaces/common.py +242 -0
  26. devguard/analyzers/surfaces/deps.py +159 -0
  27. devguard/analyzers/surfaces/devcontainer.py +195 -0
  28. devguard/analyzers/surfaces/env.py +53 -0
  29. devguard/analyzers/surfaces/git.py +236 -0
  30. devguard/analyzers/surfaces/npm.py +304 -0
  31. devguard/analyzers/surfaces/python_pkg.py +138 -0
  32. devguard/analyzers/surfaces/vscode.py +515 -0
  33. devguard/cli.py +302 -0
  34. devguard/core/__init__.py +0 -0
  35. devguard/core/config.py +147 -0
  36. devguard/core/filetype.py +313 -0
  37. devguard/core/models.py +195 -0
  38. devguard/core/redact.py +72 -0
  39. devguard/core/safefs.py +280 -0
  40. devguard/core/scoring.py +60 -0
  41. devguard/core/service.py +211 -0
  42. devguard/correlate/__init__.py +0 -0
  43. devguard/correlate/chains.py +160 -0
  44. devguard/mcp/__init__.py +0 -0
  45. devguard/mcp/server.py +442 -0
  46. devguard/report/__init__.py +0 -0
  47. devguard/report/escape.py +31 -0
  48. devguard/report/markdown.py +242 -0
  49. devguard/report/sarif.py +96 -0
  50. devguard/report/terminal.py +47 -0
  51. devguard/report/writer.py +52 -0
  52. devguard/rules/__init__.py +0 -0
  53. devguard/rules/catalog.py +96 -0
  54. devguard/rules/content/ai_instructions.yaml +111 -0
  55. devguard/rules/content/cross_language.yaml +228 -0
  56. devguard/rules/content/docs.yaml +63 -0
  57. devguard/rules/content/javascript.yaml +171 -0
  58. devguard/rules/content/obfuscation.yaml +138 -0
  59. devguard/rules/content/python.yaml +144 -0
  60. devguard/rules/content/shell.yaml +109 -0
  61. devguard/rules/content/windows.yaml +95 -0
  62. devguard/rules/data/vscode_settings.yaml +81 -0
  63. devguard_scanner-0.1.0.dist-info/METADATA +84 -0
  64. devguard_scanner-0.1.0.dist-info/RECORD +67 -0
  65. devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
  66. devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
  67. devguard_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,313 @@
1
+ """File classification by magic bytes, extension, and shebang. Never executes anything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import PurePosixPath
9
+
10
+ # (magic id, offset, signature)
11
+ _MAGIC: list[tuple[str, int, bytes]] = [
12
+ ("elf", 0, b"\x7fELF"),
13
+ ("pe", 0, b"MZ"),
14
+ ("macho", 0, b"\xfe\xed\xfa\xce"),
15
+ ("macho", 0, b"\xfe\xed\xfa\xcf"),
16
+ ("macho", 0, b"\xce\xfa\xed\xfe"),
17
+ ("macho", 0, b"\xcf\xfa\xed\xfe"),
18
+ ("java-class-or-fat-macho", 0, b"\xca\xfe\xba\xbe"),
19
+ ("dex", 0, b"dex\n"),
20
+ ("wasm", 0, b"\x00asm"),
21
+ ("ole", 0, b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"),
22
+ ("zip", 0, b"PK\x03\x04"),
23
+ ("zip", 0, b"PK\x05\x06"),
24
+ ("gzip", 0, b"\x1f\x8b"),
25
+ ("bzip2", 0, b"BZh"),
26
+ ("xz", 0, b"\xfd7zXZ\x00"),
27
+ ("7z", 0, b"7z\xbc\xaf\x27\x1c"),
28
+ ("rar", 0, b"Rar!\x1a\x07"),
29
+ ("tar", 257, b"ustar"),
30
+ ("png", 0, b"\x89PNG\r\n\x1a\n"),
31
+ ("jpeg", 0, b"\xff\xd8\xff"),
32
+ ("gif", 0, b"GIF87a"),
33
+ ("gif", 0, b"GIF89a"),
34
+ ("webp", 8, b"WEBP"),
35
+ ("ico", 0, b"\x00\x00\x01\x00"),
36
+ ("bmp", 0, b"BM"),
37
+ ("pdf", 0, b"%PDF"),
38
+ ("woff", 0, b"wOFF"),
39
+ ("woff2", 0, b"wOF2"),
40
+ ("ttf", 0, b"\x00\x01\x00\x00"),
41
+ ("otf", 0, b"OTTO"),
42
+ ("sqlite", 0, b"SQLite format 3\x00"),
43
+ ]
44
+
45
+ EXECUTABLE_MAGIC = {"elf", "pe", "macho", "java-class-or-fat-macho", "dex", "wasm"}
46
+ ARCHIVE_MAGIC = {"zip", "gzip", "bzip2", "xz", "7z", "rar", "tar"}
47
+
48
+ # Extensions whose content must carry a specific magic.
49
+ EXPECTED_MAGIC: dict[str, set[str]] = {
50
+ ".png": {"png"},
51
+ ".jpg": {"jpeg"},
52
+ ".jpeg": {"jpeg"},
53
+ ".gif": {"gif"},
54
+ ".webp": {"webp"},
55
+ ".ico": {"ico", "png"},
56
+ ".bmp": {"bmp"},
57
+ ".pdf": {"pdf"},
58
+ ".woff": {"woff"},
59
+ ".woff2": {"woff2"},
60
+ ".ttf": {"ttf"},
61
+ ".otf": {"otf", "ttf"},
62
+ ".zip": {"zip"},
63
+ ".jar": {"zip"},
64
+ ".whl": {"zip"},
65
+ ".gz": {"gzip"},
66
+ ".tgz": {"gzip"},
67
+ ".7z": {"7z"},
68
+ ".rar": {"rar"},
69
+ }
70
+
71
+ # Extensions that are expected to be plain text; executable magic in them is deceptive.
72
+ TEXT_EXTENSIONS = {
73
+ ".txt",
74
+ ".md",
75
+ ".json",
76
+ ".js",
77
+ ".mjs",
78
+ ".cjs",
79
+ ".ts",
80
+ ".css",
81
+ ".html",
82
+ ".yml",
83
+ ".yaml",
84
+ ".toml",
85
+ ".xml",
86
+ ".csv",
87
+ ".py",
88
+ ".sh",
89
+ ".svg",
90
+ ".lock",
91
+ ".cfg",
92
+ ".ini",
93
+ ".env",
94
+ }
95
+
96
+ _EXT_LANG = {
97
+ ".js": "javascript",
98
+ ".mjs": "javascript",
99
+ ".cjs": "javascript",
100
+ ".jsx": "javascript",
101
+ ".ts": "javascript",
102
+ ".mts": "javascript",
103
+ ".cts": "javascript",
104
+ ".tsx": "javascript",
105
+ ".vue": "javascript",
106
+ ".svelte": "javascript",
107
+ ".py": "python",
108
+ ".pyw": "python",
109
+ ".pyi": "python",
110
+ ".sh": "shell",
111
+ ".bash": "shell",
112
+ ".zsh": "shell",
113
+ ".ksh": "shell",
114
+ ".command": "shell",
115
+ ".ps1": "powershell",
116
+ ".psm1": "powershell",
117
+ ".psd1": "powershell",
118
+ ".bat": "batch",
119
+ ".cmd": "batch",
120
+ ".json": "json",
121
+ ".jsonc": "json",
122
+ ".code-workspace": "json",
123
+ ".yml": "yaml",
124
+ ".yaml": "yaml",
125
+ ".toml": "toml",
126
+ ".xml": "xml",
127
+ ".md": "markdown",
128
+ ".mdc": "markdown",
129
+ ".markdown": "markdown",
130
+ ".rst": "markdown",
131
+ ".txt": "text",
132
+ ".rb": "ruby",
133
+ ".php": "php",
134
+ ".go": "go",
135
+ ".java": "java",
136
+ ".kt": "kotlin",
137
+ ".kts": "kotlin",
138
+ ".gradle": "groovy",
139
+ ".groovy": "groovy",
140
+ ".cs": "csharp",
141
+ ".rs": "rust",
142
+ ".lua": "lua",
143
+ ".pl": "perl",
144
+ ".pm": "perl",
145
+ ".vbs": "vbscript",
146
+ ".ipynb": "json",
147
+ }
148
+
149
+ _NAME_LANG = {
150
+ "makefile": "make",
151
+ "gnumakefile": "make",
152
+ "dockerfile": "dockerfile",
153
+ "rakefile": "ruby",
154
+ "gemfile": "ruby",
155
+ "vagrantfile": "ruby",
156
+ "jenkinsfile": "groovy",
157
+ ".envrc": "shell",
158
+ ".bashrc": "shell",
159
+ ".zshrc": "shell",
160
+ ".profile": "shell",
161
+ ".bash_profile": "shell",
162
+ ".npmrc": "ini",
163
+ ".yarnrc": "ini",
164
+ ".gitconfig": "ini",
165
+ "config": None, # type: ignore[dict-item]
166
+ }
167
+
168
+ _SHEBANG_LANG = [
169
+ ("node", "javascript"),
170
+ ("deno", "javascript"),
171
+ ("bun", "javascript"),
172
+ ("python", "python"),
173
+ ("pwsh", "powershell"),
174
+ ("powershell", "powershell"),
175
+ ("bash", "shell"),
176
+ ("zsh", "shell"),
177
+ ("/sh", "shell"),
178
+ (" sh", "shell"),
179
+ ("ruby", "ruby"),
180
+ ("perl", "perl"),
181
+ ("php", "php"),
182
+ ]
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class Classification:
187
+ magic: str | None
188
+ is_text: bool
189
+ encoding: str | None
190
+ lossy: bool
191
+ lang: str | None
192
+ text: str | None = None
193
+
194
+
195
+ def detect_magic(data: bytes) -> str | None:
196
+ for magic, offset, sig in _MAGIC:
197
+ if data[offset : offset + len(sig)] == sig:
198
+ if magic == "pe" and len(data) < 64:
199
+ continue
200
+ if magic == "bmp" and len(data) < 26:
201
+ continue
202
+ if magic == "ttf" and len(data) < 12:
203
+ continue
204
+ return magic
205
+ return None
206
+
207
+
208
+ def decode_text(data: bytes) -> tuple[str | None, str | None, bool]:
209
+ """Return (text, encoding, lossy). (None, None, False) for binary content."""
210
+ if data.startswith(codecs.BOM_UTF8):
211
+ return data[3:].decode("utf-8", "replace"), "utf-8-sig", False
212
+ for bom, enc in (
213
+ (codecs.BOM_UTF32_LE, "utf-32"),
214
+ (codecs.BOM_UTF32_BE, "utf-32"),
215
+ (codecs.BOM_UTF16_LE, "utf-16"),
216
+ (codecs.BOM_UTF16_BE, "utf-16"),
217
+ ):
218
+ if data.startswith(bom):
219
+ try:
220
+ return data.decode(enc), enc, False
221
+ except UnicodeDecodeError:
222
+ return data.decode(enc, "replace"), enc, True
223
+ sample = data[:8192]
224
+ if b"\x00" in sample:
225
+ return None, None, False
226
+ try:
227
+ return data.decode("utf-8"), "utf-8", False
228
+ except UnicodeDecodeError as exc:
229
+ # A truncated multi-byte char at the very end (bounded read) is not a reason to call it lossy.
230
+ if exc.start >= len(data) - 3:
231
+ return data[: exc.start].decode("utf-8"), "utf-8", False
232
+ printable = sum(1 for b in sample if b >= 0x20 or b in (9, 10, 13))
233
+ if sample and printable / len(sample) > 0.95:
234
+ return data.decode("latin-1"), "latin-1", True
235
+ return None, None, False
236
+
237
+
238
+ def language_for(rel: str, text: str | None) -> str | None:
239
+ p = PurePosixPath(rel)
240
+ name = p.name.lower()
241
+ if _NAME_LANG.get(name):
242
+ return _NAME_LANG[name]
243
+ if name.startswith(".env"):
244
+ return "dotenv"
245
+ if name.startswith("dockerfile") or name.endswith(".dockerfile"):
246
+ return "dockerfile"
247
+ if p.parent.name == "hooks" and p.parent.parent.name == ".git":
248
+ return _shebang_lang(text) or "shell"
249
+ lang = _EXT_LANG.get(p.suffix.lower())
250
+ if lang:
251
+ return lang
252
+ return _shebang_lang(text)
253
+
254
+
255
+ def _shebang_lang(text: str | None) -> str | None:
256
+ if text and text.startswith("#!"):
257
+ first = text.split("\n", 1)[0].lower()
258
+ for needle, lang in _SHEBANG_LANG:
259
+ if needle in first:
260
+ return lang
261
+ return None
262
+
263
+
264
+ def classify(rel: str, data: bytes) -> Classification:
265
+ magic = detect_magic(data)
266
+ if magic is not None and magic not in _WEAK_MAGIC:
267
+ return Classification(magic, False, None, False, None, None)
268
+ text, enc, lossy = decode_text(data)
269
+ if text is not None and magic in _WEAK_MAGIC:
270
+ magic = None # "MZ…" / "BM…" that decodes as text is just text
271
+ lang = language_for(rel, text) if text else None
272
+ if text and lang is None and looks_like_code(text):
273
+ lang = guess_code_lang(text) # e.g. JavaScript disguised as a font or image
274
+ return Classification(magic, text is not None, enc, lossy, lang, text)
275
+
276
+
277
+ # Two-byte signatures that ordinary text can start with.
278
+ _WEAK_MAGIC = {"pe", "bmp"}
279
+
280
+
281
+ _JS_HINT = re.compile(r"\brequire\(|module\.exports|\bconst\s|\blet\s|=>|\bfunction\s*\w*\(")
282
+ _PY_HINT = re.compile(r"^\s*(import \w|from [\w.]+ import|def \w+\(|exec\()", re.MULTILINE)
283
+
284
+
285
+ def guess_code_lang(text: str) -> str | None:
286
+ head = text[:8192]
287
+ if _JS_HINT.search(head):
288
+ return "javascript"
289
+ if _PY_HINT.search(head):
290
+ return "python"
291
+ return None
292
+
293
+
294
+ def looks_like_code(text: str) -> bool:
295
+ """Cheap heuristic: does this text look like JS/Python/shell source rather than data?"""
296
+ head = text[:4096]
297
+ tokens = (
298
+ "function",
299
+ "require(",
300
+ "import ",
301
+ "const ",
302
+ "var ",
303
+ "let ",
304
+ "=>",
305
+ "eval(",
306
+ "def ",
307
+ "exec(",
308
+ "#!/",
309
+ "child_process",
310
+ "module.exports",
311
+ "Buffer.from",
312
+ )
313
+ return sum(1 for t in tokens if t in head) >= 2
@@ -0,0 +1,195 @@
1
+ """Validated data model shared by the scanner, reporters, AI layer, and MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from enum import StrEnum
7
+ from typing import Any, Literal
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class Severity(StrEnum):
13
+ CRITICAL = "critical"
14
+ HIGH = "high"
15
+ MEDIUM = "medium"
16
+ LOW = "low"
17
+ INFO = "info"
18
+
19
+ @property
20
+ def rank(self) -> int:
21
+ return _SEVERITY_RANK[self]
22
+
23
+ def bump(self, tiers: int = 1) -> Severity:
24
+ """Raise by `tiers`, capped at critical. Info is never bumped (it carries no risk on its own)."""
25
+ if self is Severity.INFO:
26
+ return self
27
+ return _BY_RANK[min(self.rank + tiers, 4)]
28
+
29
+ @classmethod
30
+ def from_rank(cls, rank: int) -> Severity:
31
+ return _BY_RANK[max(0, min(rank, 4))]
32
+
33
+
34
+ _SEVERITY_RANK = {
35
+ Severity.INFO: 0,
36
+ Severity.LOW: 1,
37
+ Severity.MEDIUM: 2,
38
+ Severity.HIGH: 3,
39
+ Severity.CRITICAL: 4,
40
+ }
41
+ _BY_RANK = {v: k for k, v in _SEVERITY_RANK.items()}
42
+
43
+
44
+ class Confidence(StrEnum):
45
+ HIGH = "high"
46
+ MEDIUM = "medium"
47
+ LOW = "low"
48
+
49
+ @property
50
+ def rank(self) -> int:
51
+ return {"low": 0, "medium": 1, "high": 2}[self.value]
52
+
53
+
54
+ class Source(StrEnum):
55
+ RULE = "rule"
56
+ ANALYZER = "analyzer"
57
+ CORRELATION = "correlation"
58
+ EXTERNAL = "external"
59
+ AI = "ai"
60
+
61
+
62
+ class Evidence(BaseModel):
63
+ path: str
64
+ line_start: int | None = None
65
+ line_end: int | None = None
66
+ column: int | None = None
67
+ excerpt: str | None = None
68
+
69
+
70
+ class Finding(BaseModel):
71
+ id: str = ""
72
+ rule_id: str
73
+ title: str
74
+ severity: Severity
75
+ confidence: Confidence
76
+ category: str
77
+ threat_refs: list[str] = Field(default_factory=list)
78
+ evidence: list[Evidence] = Field(default_factory=list)
79
+ explanation: str
80
+ benign_context: str = ""
81
+ next_step: str = ""
82
+ source: Source = Source.ANALYZER
83
+ related: list[str] = Field(default_factory=list)
84
+ tags: list[str] = Field(default_factory=list)
85
+
86
+ @property
87
+ def primary_path(self) -> str:
88
+ return self.evidence[0].path if self.evidence else ""
89
+
90
+ @property
91
+ def primary_line(self) -> int:
92
+ if self.evidence and self.evidence[0].line_start is not None:
93
+ return self.evidence[0].line_start
94
+ return 0
95
+
96
+ def compute_id(self) -> str:
97
+ first = self.evidence[0] if self.evidence else Evidence(path="")
98
+ key = f"{self.rule_id}|{first.path}|{first.line_start}|{first.excerpt or ''}"
99
+ return hashlib.sha256(key.encode("utf-8", "surrogatepass")).hexdigest()[:12]
100
+
101
+
102
+ class Trigger(BaseModel):
103
+ """Something that causes code to run without the developer deliberately running it (or with one click)."""
104
+
105
+ id: str
106
+ kind: str
107
+ path: str
108
+ line: int | None = None
109
+ label: str = ""
110
+ command: str | None = None
111
+ autorun: bool = False
112
+ references: list[str] = Field(default_factory=list)
113
+ npm_scripts: list[str] = Field(default_factory=list)
114
+
115
+
116
+ class ScanStats(BaseModel):
117
+ considered: int = 0
118
+ inspected: int = 0
119
+ excluded: int = 0
120
+ unreadable: int = 0
121
+ oversized: int = 0
122
+ skipped_limits: int = 0
123
+ binary: int = 0
124
+ symlinks: int = 0
125
+ special_files: int = 0
126
+ bytes_read: int = 0
127
+ vendored_dirs: list[str] = Field(default_factory=list)
128
+ excluded_dirs: list[str] = Field(default_factory=list)
129
+
130
+
131
+ class ScanIssue(BaseModel):
132
+ kind: Literal["error", "warning", "limit"]
133
+ stage: str
134
+ message: str
135
+ path: str | None = None
136
+
137
+
138
+ ScanStatus = Literal["complete", "partial", "failed"]
139
+ Verdict = Literal["block", "review", "indicators", "no-indicators"]
140
+
141
+
142
+ class AIAnnotation(BaseModel):
143
+ finding_id: str | None = None
144
+ status: Literal["supported", "plausible", "unverified", "likely-benign"]
145
+ summary: str
146
+ cited_evidence: list[Evidence] = Field(default_factory=list)
147
+ verification_steps: list[str] = Field(default_factory=list)
148
+
149
+
150
+ class AIReport(BaseModel):
151
+ status: Literal["disabled", "completed", "failed", "skipped"] = "disabled"
152
+ profile: str | None = None
153
+ provider: str | None = None
154
+ plugin: str | None = None
155
+ model: str | None = None
156
+ overall_assessment: str | None = None
157
+ annotations: list[AIAnnotation] = Field(default_factory=list)
158
+ hypotheses: list[AIAnnotation] = Field(default_factory=list)
159
+ sent_summary: dict[str, Any] = Field(default_factory=dict)
160
+ usage: dict[str, Any] = Field(default_factory=dict)
161
+ audit: list[dict[str, Any]] = Field(default_factory=list)
162
+ error: str | None = None
163
+
164
+
165
+ class ScanResult(BaseModel):
166
+ schema_version: str
167
+ devguard_version: str
168
+ ruleset_version: str
169
+ scan_id: str
170
+ started_at: str
171
+ duration_s: float
172
+ target_label: str
173
+ status: ScanStatus
174
+ verdict: Verdict
175
+ stats: ScanStats
176
+ findings: list[Finding]
177
+ triggers: list[Trigger] = Field(default_factory=list)
178
+ issues: list[ScanIssue] = Field(default_factory=list)
179
+ config_summary: dict[str, Any] = Field(default_factory=dict)
180
+ ai: AIReport = Field(default_factory=AIReport)
181
+
182
+ def severity_counts(self) -> dict[str, int]:
183
+ counts = {s.value: 0 for s in Severity}
184
+ for f in self.findings:
185
+ counts[f.severity.value] += 1
186
+ return counts
187
+
188
+ def deterministic_view(self) -> dict[str, Any]:
189
+ """Fields that must be byte-identical across runs on identical input (NFR-005)."""
190
+ return {
191
+ "verdict": self.verdict,
192
+ "status": self.status,
193
+ "findings": [f.model_dump(mode="json") for f in self.findings],
194
+ "triggers": [t.model_dump(mode="json") for t in self.triggers],
195
+ }
@@ -0,0 +1,72 @@
1
+ """Secret masking and safe excerpt construction (SAFE-010, REP-006)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import unicodedata
8
+
9
+ _SECRET_PATTERNS = [
10
+ re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"),
11
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"),
12
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{40,}\b"),
13
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
14
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b"),
15
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
16
+ re.compile(r"\bnpm_[A-Za-z0-9]{36}\b"),
17
+ re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
18
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)"),
19
+ re.compile(r"(?i)\b(?:_authToken|api[_-]?key|secret|password|passwd|token)\b\s*[=:]\s*['\"]?([^\s'\"]{8,})"),
20
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
21
+ ]
22
+
23
+ MAX_EXCERPT = 300
24
+
25
+
26
+ def _mask(value: str) -> str:
27
+ digest = hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()[:8]
28
+ return f"{value[:4]}…[REDACTED sha256:{digest}]"
29
+
30
+
31
+ def redact(text: str) -> str:
32
+ def repl(m: re.Match[str]) -> str:
33
+ if m.lastindex:
34
+ secret = m.group(1)
35
+ return m.group(0).replace(secret, _mask(secret))
36
+ return _mask(m.group(0))
37
+
38
+ for pat in _SECRET_PATTERNS:
39
+ text = pat.sub(repl, text)
40
+ return text
41
+
42
+
43
+ def visible(text: str) -> str:
44
+ """Make control, bidi, and invisible format characters visible as <U+XXXX>."""
45
+ out = []
46
+ for ch in text:
47
+ cat = unicodedata.category(ch)
48
+ if ch in "\t":
49
+ out.append(ch)
50
+ elif (
51
+ cat in ("Cc", "Cf", "Co", "Cs", "Zl", "Zp")
52
+ or 0xFE00 <= ord(ch) <= 0xFE0F
53
+ or (0xE0100 <= ord(ch) <= 0xE01EF)
54
+ ):
55
+ out.append(f"<U+{ord(ch):04X}>")
56
+ else:
57
+ out.append(ch)
58
+ return "".join(out)
59
+
60
+
61
+ def make_excerpt(line: str, column: int | None = None, limit: int = MAX_EXCERPT) -> str:
62
+ """Bounded, redacted, visible-character excerpt of one line, centred on `column` if long."""
63
+ line = line.rstrip("\r\n")
64
+ if len(line) > limit:
65
+ start = 0
66
+ if column is not None and column > limit // 2:
67
+ start = max(0, min(column - limit // 3, len(line) - limit))
68
+ chunk = line[start : start + limit]
69
+ prefix = "…" if start > 0 else ""
70
+ suffix = "…[truncated]" if start + limit < len(line) else ""
71
+ line = prefix + chunk + suffix
72
+ return visible(redact(line))