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.
- devguard/__init__.py +5 -0
- devguard/ai/__init__.py +0 -0
- devguard/ai/investigator.py +380 -0
- devguard/ai/prompts.py +113 -0
- devguard/ai/providers/__init__.py +0 -0
- devguard/ai/providers/anthropic.py +163 -0
- devguard/ai/providers/openai_compat.py +157 -0
- devguard/ai/registry.py +160 -0
- devguard/ai/run.py +119 -0
- devguard/ai/sdk.py +102 -0
- devguard/ai/testing.py +126 -0
- devguard/ai/tools.py +389 -0
- devguard/analyzers/__init__.py +0 -0
- devguard/analyzers/base.py +135 -0
- devguard/analyzers/carriers.py +345 -0
- devguard/analyzers/command.py +343 -0
- devguard/analyzers/content.py +240 -0
- devguard/analyzers/filesystem.py +142 -0
- devguard/analyzers/hidden.py +273 -0
- devguard/analyzers/imports.py +102 -0
- devguard/analyzers/jsonc.py +221 -0
- devguard/analyzers/surfaces/__init__.py +0 -0
- devguard/analyzers/surfaces/ai_assistants.py +318 -0
- devguard/analyzers/surfaces/autoload.py +142 -0
- devguard/analyzers/surfaces/common.py +242 -0
- devguard/analyzers/surfaces/deps.py +159 -0
- devguard/analyzers/surfaces/devcontainer.py +195 -0
- devguard/analyzers/surfaces/env.py +53 -0
- devguard/analyzers/surfaces/git.py +236 -0
- devguard/analyzers/surfaces/npm.py +304 -0
- devguard/analyzers/surfaces/python_pkg.py +138 -0
- devguard/analyzers/surfaces/vscode.py +515 -0
- devguard/cli.py +302 -0
- devguard/core/__init__.py +0 -0
- devguard/core/config.py +147 -0
- devguard/core/filetype.py +313 -0
- devguard/core/models.py +195 -0
- devguard/core/redact.py +72 -0
- devguard/core/safefs.py +280 -0
- devguard/core/scoring.py +60 -0
- devguard/core/service.py +211 -0
- devguard/correlate/__init__.py +0 -0
- devguard/correlate/chains.py +160 -0
- devguard/mcp/__init__.py +0 -0
- devguard/mcp/server.py +442 -0
- devguard/report/__init__.py +0 -0
- devguard/report/escape.py +31 -0
- devguard/report/markdown.py +242 -0
- devguard/report/sarif.py +96 -0
- devguard/report/terminal.py +47 -0
- devguard/report/writer.py +52 -0
- devguard/rules/__init__.py +0 -0
- devguard/rules/catalog.py +96 -0
- devguard/rules/content/ai_instructions.yaml +111 -0
- devguard/rules/content/cross_language.yaml +228 -0
- devguard/rules/content/docs.yaml +63 -0
- devguard/rules/content/javascript.yaml +171 -0
- devguard/rules/content/obfuscation.yaml +138 -0
- devguard/rules/content/python.yaml +144 -0
- devguard/rules/content/shell.yaml +109 -0
- devguard/rules/content/windows.yaml +95 -0
- devguard/rules/data/vscode_settings.yaml +81 -0
- devguard_scanner-0.1.0.dist-info/METADATA +84 -0
- devguard_scanner-0.1.0.dist-info/RECORD +67 -0
- devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
- devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
- 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
|
devguard/core/models.py
ADDED
|
@@ -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
|
+
}
|
devguard/core/redact.py
ADDED
|
@@ -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))
|