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
devguard/core/safefs.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Read-only, no-follow access to the scan target.
|
|
2
|
+
|
|
3
|
+
This is the only module that touches files inside the target. Guarantees:
|
|
4
|
+
- never follows symlinks (lstat-based walk; O_NOFOLLOW reads; parent components re-checked),
|
|
5
|
+
- never opens FIFOs, sockets, or device files,
|
|
6
|
+
- never writes, chmods, or creates anything,
|
|
7
|
+
- bounded reads, plus file-count, byte, depth, and time budgets.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
|
|
19
|
+
from devguard.core.config import Limits
|
|
20
|
+
|
|
21
|
+
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
22
|
+
_O_NONBLOCK = getattr(os, "O_NONBLOCK", 0)
|
|
23
|
+
_O_BINARY = getattr(os, "O_BINARY", 0)
|
|
24
|
+
_O_CLOEXEC = getattr(os, "O_CLOEXEC", 0)
|
|
25
|
+
|
|
26
|
+
# Directories skipped by default. Their existence is still recorded.
|
|
27
|
+
# Deliberately NOT excluded: .git/config, .git/hooks, .git/info, and any other dot-directory (INV-002).
|
|
28
|
+
DEFAULT_EXCLUDED_DIRS = {
|
|
29
|
+
".git/objects",
|
|
30
|
+
".git/lfs",
|
|
31
|
+
".git/logs",
|
|
32
|
+
".git/refs",
|
|
33
|
+
".git/modules",
|
|
34
|
+
"__pycache__",
|
|
35
|
+
".mypy_cache",
|
|
36
|
+
".pytest_cache",
|
|
37
|
+
".ruff_cache",
|
|
38
|
+
".tox",
|
|
39
|
+
".nox",
|
|
40
|
+
".gradle/caches",
|
|
41
|
+
".next/cache",
|
|
42
|
+
".parcel-cache",
|
|
43
|
+
".turbo",
|
|
44
|
+
}
|
|
45
|
+
# Vendored dependency directories: inventoried and reported, deep-scanned only on request (INV-003).
|
|
46
|
+
VENDORED_DIR_NAMES = {"node_modules", "bower_components", "vendor", ".venv", "venv", "site-packages", "Pods"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Kind:
|
|
50
|
+
FILE = "file"
|
|
51
|
+
DIR = "dir"
|
|
52
|
+
SYMLINK = "symlink"
|
|
53
|
+
SPECIAL = "special"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class FileRecord:
|
|
58
|
+
rel: str
|
|
59
|
+
kind: str
|
|
60
|
+
size: int
|
|
61
|
+
mode: int
|
|
62
|
+
vendored: bool = False
|
|
63
|
+
symlink_target: str | None = None
|
|
64
|
+
special_type: str | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def name(self) -> str:
|
|
68
|
+
return PurePosixPath(self.rel).name
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def hidden(self) -> bool:
|
|
72
|
+
return any(part.startswith(".") for part in PurePosixPath(self.rel).parts)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def executable_bit(self) -> bool:
|
|
76
|
+
return bool(self.mode & 0o111)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ReadError(Exception):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Inventory:
|
|
85
|
+
files: list[FileRecord] = field(default_factory=list)
|
|
86
|
+
symlinks: list[FileRecord] = field(default_factory=list)
|
|
87
|
+
special: list[FileRecord] = field(default_factory=list)
|
|
88
|
+
dirs: list[str] = field(default_factory=list)
|
|
89
|
+
excluded_dirs: list[str] = field(default_factory=list)
|
|
90
|
+
vendored_dirs: list[str] = field(default_factory=list)
|
|
91
|
+
vendored_files: dict[str, FileRecord] = field(default_factory=dict)
|
|
92
|
+
excluded_count: int = 0
|
|
93
|
+
unreadable: list[tuple[str, str]] = field(default_factory=list)
|
|
94
|
+
limit_hits: list[str] = field(default_factory=list)
|
|
95
|
+
by_rel: dict[str, FileRecord] = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
def exists(self, rel: str) -> bool:
|
|
98
|
+
return rel in self.by_rel or rel in self.vendored_files
|
|
99
|
+
|
|
100
|
+
def get(self, rel: str) -> FileRecord | None:
|
|
101
|
+
return self.by_rel.get(rel) or self.vendored_files.get(rel)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def normalize_rel(rel: str) -> str | None:
|
|
105
|
+
"""Normalize a repo-relative path. Returns None if it is absolute or escapes the root."""
|
|
106
|
+
rel = rel.replace("\\", "/").strip()
|
|
107
|
+
if not rel or rel.startswith("/") or (len(rel) > 1 and rel[1] == ":"):
|
|
108
|
+
return None
|
|
109
|
+
parts: list[str] = []
|
|
110
|
+
for part in rel.split("/"):
|
|
111
|
+
if part in ("", "."):
|
|
112
|
+
continue
|
|
113
|
+
if part == "..":
|
|
114
|
+
if not parts:
|
|
115
|
+
return None
|
|
116
|
+
parts.pop()
|
|
117
|
+
continue
|
|
118
|
+
parts.append(part)
|
|
119
|
+
return "/".join(parts) if parts else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SafeFS:
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
root: Path,
|
|
126
|
+
limits: Limits,
|
|
127
|
+
user_excludes: list[str] | None = None,
|
|
128
|
+
include_vendored: bool = False,
|
|
129
|
+
skip_dirs: list[Path] | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
self.root = root.resolve()
|
|
132
|
+
if not self.root.is_dir():
|
|
133
|
+
raise NotADirectoryError(str(root))
|
|
134
|
+
self.limits = limits
|
|
135
|
+
self.user_excludes = list(user_excludes or [])
|
|
136
|
+
self.include_vendored = include_vendored
|
|
137
|
+
self._skip_abs = {p.resolve() for p in (skip_dirs or [])}
|
|
138
|
+
self.bytes_read = 0
|
|
139
|
+
self.deadline = time.monotonic() + limits.timeout_s
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------ walking
|
|
142
|
+
def time_left(self) -> float:
|
|
143
|
+
return self.deadline - time.monotonic()
|
|
144
|
+
|
|
145
|
+
def _excluded(self, rel: str, is_dir: bool) -> bool:
|
|
146
|
+
if is_dir:
|
|
147
|
+
if rel in DEFAULT_EXCLUDED_DIRS or PurePosixPath(rel).name in DEFAULT_EXCLUDED_DIRS:
|
|
148
|
+
return True
|
|
149
|
+
if any(rel.endswith("/" + d) for d in DEFAULT_EXCLUDED_DIRS if "/" in d):
|
|
150
|
+
return True
|
|
151
|
+
return any(
|
|
152
|
+
fnmatch.fnmatchcase(rel, pat) or fnmatch.fnmatchcase(PurePosixPath(rel).name, pat)
|
|
153
|
+
for pat in self.user_excludes
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def walk(self) -> Inventory:
|
|
157
|
+
inv = Inventory()
|
|
158
|
+
# (absolute dir, rel dir, depth, inside-vendored)
|
|
159
|
+
stack: list[tuple[str, str, int, bool]] = [(str(self.root), "", 0, False)]
|
|
160
|
+
file_budget = self.limits.max_files
|
|
161
|
+
while stack:
|
|
162
|
+
abs_dir, rel_dir, depth, in_vendor = stack.pop()
|
|
163
|
+
if self.time_left() <= 0:
|
|
164
|
+
inv.limit_hits.append(f"time budget exhausted while walking ({self.limits.timeout_s}s)")
|
|
165
|
+
break
|
|
166
|
+
try:
|
|
167
|
+
with os.scandir(abs_dir) as it:
|
|
168
|
+
entries = sorted(it, key=lambda e: e.name)
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
inv.unreadable.append((rel_dir or ".", f"cannot list directory: {exc.strerror}"))
|
|
171
|
+
continue
|
|
172
|
+
subdirs: list[tuple[str, str, int, bool]] = []
|
|
173
|
+
for entry in entries:
|
|
174
|
+
rel = f"{rel_dir}/{entry.name}" if rel_dir else entry.name
|
|
175
|
+
try:
|
|
176
|
+
st = entry.stat(follow_symlinks=False)
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
inv.unreadable.append((rel, f"cannot stat: {exc.strerror}"))
|
|
179
|
+
continue
|
|
180
|
+
mode = st.st_mode
|
|
181
|
+
if stat.S_ISLNK(mode):
|
|
182
|
+
try:
|
|
183
|
+
target = os.readlink(entry.path)
|
|
184
|
+
except OSError:
|
|
185
|
+
target = "?"
|
|
186
|
+
rec = FileRecord(rel, Kind.SYMLINK, 0, mode, in_vendor, symlink_target=target)
|
|
187
|
+
inv.symlinks.append(rec)
|
|
188
|
+
continue
|
|
189
|
+
if stat.S_ISDIR(mode):
|
|
190
|
+
if Path(entry.path).resolve() in self._skip_abs:
|
|
191
|
+
inv.excluded_dirs.append(rel)
|
|
192
|
+
continue
|
|
193
|
+
if self._excluded(rel, True):
|
|
194
|
+
inv.excluded_dirs.append(rel)
|
|
195
|
+
continue
|
|
196
|
+
vendored_here = in_vendor or entry.name in VENDORED_DIR_NAMES
|
|
197
|
+
if entry.name in VENDORED_DIR_NAMES and not in_vendor:
|
|
198
|
+
inv.vendored_dirs.append(rel)
|
|
199
|
+
if depth + 1 > self.limits.max_depth:
|
|
200
|
+
inv.limit_hits.append(f"max depth {self.limits.max_depth} reached at {rel}")
|
|
201
|
+
continue
|
|
202
|
+
inv.dirs.append(rel)
|
|
203
|
+
# Vendored trees are always walked (so triggers can reference files inside them),
|
|
204
|
+
# but their files are only analyzed with --include-vendored.
|
|
205
|
+
subdirs.append((entry.path, rel, depth + 1, vendored_here))
|
|
206
|
+
continue
|
|
207
|
+
if stat.S_ISREG(mode):
|
|
208
|
+
if self._excluded(rel, False):
|
|
209
|
+
inv.excluded_count += 1
|
|
210
|
+
continue
|
|
211
|
+
rec = FileRecord(rel, Kind.FILE, st.st_size, mode, in_vendor)
|
|
212
|
+
if in_vendor and not self.include_vendored:
|
|
213
|
+
inv.vendored_files[rel] = rec
|
|
214
|
+
continue
|
|
215
|
+
if file_budget <= 0:
|
|
216
|
+
if not any("max files" in h for h in inv.limit_hits):
|
|
217
|
+
inv.limit_hits.append(f"max files {self.limits.max_files} reached")
|
|
218
|
+
continue
|
|
219
|
+
file_budget -= 1
|
|
220
|
+
inv.files.append(rec)
|
|
221
|
+
inv.by_rel[rel] = rec
|
|
222
|
+
continue
|
|
223
|
+
special = (
|
|
224
|
+
"fifo"
|
|
225
|
+
if stat.S_ISFIFO(mode)
|
|
226
|
+
else "socket"
|
|
227
|
+
if stat.S_ISSOCK(mode)
|
|
228
|
+
else "char-device"
|
|
229
|
+
if stat.S_ISCHR(mode)
|
|
230
|
+
else "block-device"
|
|
231
|
+
if stat.S_ISBLK(mode)
|
|
232
|
+
else "unknown"
|
|
233
|
+
)
|
|
234
|
+
inv.special.append(FileRecord(rel, Kind.SPECIAL, 0, mode, in_vendor, special_type=special))
|
|
235
|
+
stack.extend(reversed(subdirs))
|
|
236
|
+
inv.files.sort(key=lambda r: r.rel)
|
|
237
|
+
return inv
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------ reading
|
|
240
|
+
def _check_parents(self, rel: str) -> None:
|
|
241
|
+
current = self.root
|
|
242
|
+
parts = PurePosixPath(rel).parts
|
|
243
|
+
for part in parts[:-1]:
|
|
244
|
+
current = current / part
|
|
245
|
+
st = os.lstat(current)
|
|
246
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
247
|
+
raise ReadError("parent component is not a real directory")
|
|
248
|
+
|
|
249
|
+
def read(self, rel: str, max_bytes: int | None = None) -> bytes:
|
|
250
|
+
"""Read up to max_bytes of a regular file inside the root, never following links."""
|
|
251
|
+
norm = normalize_rel(rel)
|
|
252
|
+
if norm is None or norm != rel:
|
|
253
|
+
raise ReadError("path is not a normalized path inside the scan root")
|
|
254
|
+
limit = self.limits.max_file_size if max_bytes is None else max_bytes
|
|
255
|
+
if self.bytes_read >= self.limits.max_total_bytes:
|
|
256
|
+
raise ReadError("total byte budget exhausted")
|
|
257
|
+
self._check_parents(rel)
|
|
258
|
+
path = self.root / rel
|
|
259
|
+
flags = os.O_RDONLY | _O_NOFOLLOW | _O_NONBLOCK | _O_BINARY | _O_CLOEXEC
|
|
260
|
+
try:
|
|
261
|
+
fd = os.open(path, flags)
|
|
262
|
+
except OSError as exc:
|
|
263
|
+
raise ReadError(exc.strerror or "open failed") from exc
|
|
264
|
+
try:
|
|
265
|
+
st = os.fstat(fd)
|
|
266
|
+
if not stat.S_ISREG(st.st_mode):
|
|
267
|
+
raise ReadError("not a regular file")
|
|
268
|
+
chunks: list[bytes] = []
|
|
269
|
+
remaining = min(limit, self.limits.max_total_bytes - self.bytes_read)
|
|
270
|
+
while remaining > 0:
|
|
271
|
+
chunk = os.read(fd, min(65536, remaining))
|
|
272
|
+
if not chunk:
|
|
273
|
+
break
|
|
274
|
+
chunks.append(chunk)
|
|
275
|
+
remaining -= len(chunk)
|
|
276
|
+
data = b"".join(chunks)
|
|
277
|
+
finally:
|
|
278
|
+
os.close(fd)
|
|
279
|
+
self.bytes_read += len(data)
|
|
280
|
+
return data
|
devguard/core/scoring.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Deterministic post-processing: severity adjustments, de-duplication, stable ordering, verdict (COR-004/005)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devguard.core.models import Finding, Severity, Verdict
|
|
6
|
+
|
|
7
|
+
_AUTOLOADED_BUMP_CATEGORIES = {"capability", "obfuscation", "hidden"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def apply_autoload_bump(findings: list[Finding], autoloaded: set[str]) -> None:
|
|
11
|
+
"""Capabilities inside files that tools load automatically are one tier more severe (SURF-012)."""
|
|
12
|
+
for f in findings:
|
|
13
|
+
if f.category in _AUTOLOADED_BUMP_CATEGORIES and f.primary_path in autoloaded:
|
|
14
|
+
f.severity = f.severity.bump(1)
|
|
15
|
+
if "auto-loaded-file" not in f.tags:
|
|
16
|
+
f.tags.append("auto-loaded-file")
|
|
17
|
+
f.explanation += " This file is loaded automatically by tooling, which raises the severity."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def assign_ids(findings: list[Finding]) -> None:
|
|
21
|
+
for f in findings:
|
|
22
|
+
f.id = f.compute_id()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dedupe_and_sort(findings: list[Finding]) -> list[Finding]:
|
|
26
|
+
seen: dict[str, Finding] = {}
|
|
27
|
+
for f in findings:
|
|
28
|
+
key = f"{f.rule_id}|{f.primary_path}|{f.primary_line}"
|
|
29
|
+
prev = seen.get(key)
|
|
30
|
+
if prev is None or f.severity.rank > prev.severity.rank:
|
|
31
|
+
seen[key] = f
|
|
32
|
+
return sorted(
|
|
33
|
+
seen.values(),
|
|
34
|
+
key=lambda f: (-f.severity.rank, -f.confidence.rank, f.primary_path, f.primary_line, f.rule_id, f.id),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def verdict(findings: list[Finding]) -> Verdict:
|
|
39
|
+
"""block: do not open/install/run. review: read evidence first. indicators: weak signals only."""
|
|
40
|
+
if any(f.severity is Severity.CRITICAL for f in findings):
|
|
41
|
+
return "block"
|
|
42
|
+
if any(f.severity is Severity.HIGH and f.category == "chain" for f in findings):
|
|
43
|
+
return "block"
|
|
44
|
+
if any(f.severity is Severity.HIGH for f in findings):
|
|
45
|
+
return "review"
|
|
46
|
+
mediums = sum(1 for f in findings if f.severity is Severity.MEDIUM)
|
|
47
|
+
if mediums >= 3:
|
|
48
|
+
return "review"
|
|
49
|
+
if mediums or any(f.severity is Severity.LOW for f in findings):
|
|
50
|
+
return "indicators"
|
|
51
|
+
return "no-indicators"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
VERDICT_TEXT = {
|
|
55
|
+
"block": "Do not open, install, or run this project. Strong indicators of malicious behavior were found.",
|
|
56
|
+
"review": "Review the findings before opening this project in an IDE, installing dependencies, or running it.",
|
|
57
|
+
"indicators": "Only weak indicators were found. Skim the findings; they are often benign.",
|
|
58
|
+
"no-indicators": "No suspicious indicators were detected by the enabled checks. This does not prove the "
|
|
59
|
+
"project is safe.",
|
|
60
|
+
}
|
devguard/core/service.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The scan pipeline. Framework-agnostic: the CLI, MCP server, and future API all call `scan()`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from functools import cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from devguard import RULESET_VERSION, SCHEMA_VERSION, __version__
|
|
14
|
+
from devguard.analyzers.base import AnalysisContext, Analyzer, FileView
|
|
15
|
+
from devguard.analyzers.carriers import CarrierAnalyzer
|
|
16
|
+
from devguard.analyzers.content import ContentRuleEngine
|
|
17
|
+
from devguard.analyzers.filesystem import filesystem_findings
|
|
18
|
+
from devguard.analyzers.hidden import HiddenContentAnalyzer
|
|
19
|
+
from devguard.analyzers.imports import ImportCollector
|
|
20
|
+
from devguard.analyzers.surfaces.ai_assistants import AIAssistantConfigAnalyzer
|
|
21
|
+
from devguard.analyzers.surfaces.autoload import AutoloadedConfigAnalyzer, DocInstructionAnalyzer
|
|
22
|
+
from devguard.analyzers.surfaces.deps import LockfileAnalyzer
|
|
23
|
+
from devguard.analyzers.surfaces.devcontainer import DevcontainerAnalyzer
|
|
24
|
+
from devguard.analyzers.surfaces.env import EnvAnalyzer
|
|
25
|
+
from devguard.analyzers.surfaces.git import GitAnalyzer
|
|
26
|
+
from devguard.analyzers.surfaces.npm import NpmAnalyzer
|
|
27
|
+
from devguard.analyzers.surfaces.python_pkg import PythonPackagingAnalyzer
|
|
28
|
+
from devguard.analyzers.surfaces.vscode import VSCodeAnalyzer
|
|
29
|
+
from devguard.core.config import ScanConfig
|
|
30
|
+
from devguard.core.filetype import classify
|
|
31
|
+
from devguard.core.models import Finding, ScanIssue, ScanResult, ScanStats
|
|
32
|
+
from devguard.core.safefs import FileRecord, ReadError, SafeFS
|
|
33
|
+
from devguard.core.scoring import apply_autoload_bump, assign_ids, dedupe_and_sort, verdict
|
|
34
|
+
from devguard.correlate.chains import correlate, referenced_files
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ScanError(Exception):
|
|
38
|
+
"""The scan could not start (invalid target)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ScanRequest:
|
|
43
|
+
target: Path
|
|
44
|
+
config: ScanConfig = field(default_factory=ScanConfig)
|
|
45
|
+
label: str | None = None
|
|
46
|
+
skip_dirs: list[Path] = field(default_factory=list) # e.g. an output dir the user placed inside the target
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cache
|
|
50
|
+
def default_analyzers() -> tuple[Analyzer, ...]:
|
|
51
|
+
# Order matters only for readability of issues; analyzers are independent.
|
|
52
|
+
return (
|
|
53
|
+
VSCodeAnalyzer(),
|
|
54
|
+
AIAssistantConfigAnalyzer(),
|
|
55
|
+
DevcontainerAnalyzer(),
|
|
56
|
+
NpmAnalyzer(),
|
|
57
|
+
LockfileAnalyzer(),
|
|
58
|
+
PythonPackagingAnalyzer(),
|
|
59
|
+
GitAnalyzer(),
|
|
60
|
+
EnvAnalyzer(),
|
|
61
|
+
AutoloadedConfigAnalyzer(),
|
|
62
|
+
DocInstructionAnalyzer(),
|
|
63
|
+
ContentRuleEngine.load_builtin(),
|
|
64
|
+
HiddenContentAnalyzer(),
|
|
65
|
+
CarrierAnalyzer(),
|
|
66
|
+
ImportCollector(),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ScanOutcome:
|
|
72
|
+
"""The result plus the in-memory context that AI tools and the MCP server need (never serialized)."""
|
|
73
|
+
|
|
74
|
+
result: ScanResult
|
|
75
|
+
ctx: AnalysisContext
|
|
76
|
+
fs: SafeFS
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def scan(request: ScanRequest, analyzers: Sequence[Analyzer] | None = None) -> ScanResult:
|
|
80
|
+
return scan_detailed(request, analyzers).result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scan_detailed(request: ScanRequest, analyzers: Sequence[Analyzer] | None = None) -> ScanOutcome:
|
|
84
|
+
started = time.monotonic()
|
|
85
|
+
started_at = datetime.now(UTC).isoformat(timespec="seconds")
|
|
86
|
+
target = request.target.expanduser()
|
|
87
|
+
if not target.exists():
|
|
88
|
+
raise ScanError(f"Target does not exist: {request.target}")
|
|
89
|
+
if not target.is_dir():
|
|
90
|
+
raise ScanError(f"Target is not a directory: {request.target}")
|
|
91
|
+
cfg = request.config
|
|
92
|
+
fs = SafeFS(target, cfg.limits, cfg.exclude, cfg.include_vendored, request.skip_dirs)
|
|
93
|
+
inventory = fs.walk()
|
|
94
|
+
ctx = AnalysisContext(config=cfg, inventory=inventory)
|
|
95
|
+
stats = ScanStats(
|
|
96
|
+
considered=len(inventory.files) + len(inventory.vendored_files) + inventory.excluded_count,
|
|
97
|
+
excluded=inventory.excluded_count,
|
|
98
|
+
symlinks=len(inventory.symlinks),
|
|
99
|
+
special_files=len(inventory.special),
|
|
100
|
+
vendored_dirs=list(inventory.vendored_dirs),
|
|
101
|
+
excluded_dirs=list(inventory.excluded_dirs),
|
|
102
|
+
)
|
|
103
|
+
for rel, why in inventory.unreadable:
|
|
104
|
+
ctx.issues.append(ScanIssue(kind="error", stage="inventory", message=why, path=rel))
|
|
105
|
+
for hit in inventory.limit_hits:
|
|
106
|
+
ctx.issues.append(ScanIssue(kind="limit", stage="inventory", message=hit))
|
|
107
|
+
|
|
108
|
+
active = tuple(analyzers) if analyzers is not None else default_analyzers()
|
|
109
|
+
findings: list[Finding] = []
|
|
110
|
+
analyzed: set[str] = set()
|
|
111
|
+
timed_out = False
|
|
112
|
+
for rec in inventory.files:
|
|
113
|
+
if fs.time_left() <= 0:
|
|
114
|
+
timed_out = True
|
|
115
|
+
break
|
|
116
|
+
findings += _analyze_file(fs, rec, ctx, active, stats)
|
|
117
|
+
analyzed.add(rec.rel)
|
|
118
|
+
|
|
119
|
+
# Files referenced by triggers are always analyzed, even inside vendored directories (INV-003).
|
|
120
|
+
for _ in range(3):
|
|
121
|
+
if timed_out:
|
|
122
|
+
break
|
|
123
|
+
extra = [inventory.get(r) for r in sorted(referenced_files(ctx) - analyzed)]
|
|
124
|
+
extra_recs = [r for r in extra if r is not None][:500]
|
|
125
|
+
if not extra_recs:
|
|
126
|
+
break
|
|
127
|
+
for rec in extra_recs:
|
|
128
|
+
findings += _analyze_file(fs, rec, ctx, active, stats)
|
|
129
|
+
analyzed.add(rec.rel)
|
|
130
|
+
|
|
131
|
+
if timed_out:
|
|
132
|
+
ctx.issues.append(
|
|
133
|
+
ScanIssue(
|
|
134
|
+
kind="limit",
|
|
135
|
+
stage="analysis",
|
|
136
|
+
message=f"time budget of {cfg.limits.timeout_s}s exhausted; scan is partial",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
stats.skipped_limits += len(inventory.files) - len(analyzed)
|
|
140
|
+
|
|
141
|
+
findings += filesystem_findings(inventory)
|
|
142
|
+
apply_autoload_bump(findings, ctx.autoloaded)
|
|
143
|
+
assign_ids(findings)
|
|
144
|
+
findings += correlate(ctx, findings)
|
|
145
|
+
assign_ids(findings)
|
|
146
|
+
final = dedupe_and_sort(findings)
|
|
147
|
+
|
|
148
|
+
partial = timed_out or bool(inventory.limit_hits) or any(i.kind in ("error", "limit") for i in ctx.issues)
|
|
149
|
+
resolved = target.resolve()
|
|
150
|
+
result = ScanResult(
|
|
151
|
+
schema_version=SCHEMA_VERSION,
|
|
152
|
+
devguard_version=__version__,
|
|
153
|
+
ruleset_version=RULESET_VERSION,
|
|
154
|
+
scan_id=f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}",
|
|
155
|
+
started_at=started_at,
|
|
156
|
+
duration_s=round(time.monotonic() - started, 3),
|
|
157
|
+
target_label=request.label or resolved.name or str(resolved),
|
|
158
|
+
status="partial" if partial else "complete",
|
|
159
|
+
verdict=verdict(final),
|
|
160
|
+
stats=stats,
|
|
161
|
+
findings=final,
|
|
162
|
+
triggers=sorted(ctx.triggers, key=lambda t: (t.path, t.line or 0, t.kind, t.label)),
|
|
163
|
+
issues=ctx.issues,
|
|
164
|
+
config_summary=cfg.summary(),
|
|
165
|
+
)
|
|
166
|
+
return ScanOutcome(result, ctx, fs)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _analyze_file(
|
|
170
|
+
fs: SafeFS, rec: FileRecord, ctx: AnalysisContext, analyzers: Sequence[Analyzer], stats: ScanStats
|
|
171
|
+
) -> list[Finding]:
|
|
172
|
+
limit = ctx.config.limits.max_file_size
|
|
173
|
+
try:
|
|
174
|
+
data = fs.read(rec.rel)
|
|
175
|
+
except ReadError as exc:
|
|
176
|
+
stats.unreadable += 1
|
|
177
|
+
ctx.issues.append(ScanIssue(kind="error", stage="read", message=str(exc), path=rec.rel))
|
|
178
|
+
return []
|
|
179
|
+
truncated = rec.size > len(data)
|
|
180
|
+
if truncated:
|
|
181
|
+
stats.oversized += 1
|
|
182
|
+
if len(data) >= limit:
|
|
183
|
+
ctx.issues.append(
|
|
184
|
+
ScanIssue(
|
|
185
|
+
kind="limit",
|
|
186
|
+
stage="read",
|
|
187
|
+
path=rec.rel,
|
|
188
|
+
message=f"file is {rec.size} bytes; only the first {limit} were analyzed",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
stats.inspected += 1
|
|
192
|
+
stats.bytes_read += len(data)
|
|
193
|
+
c = classify(rec.rel, data)
|
|
194
|
+
if not c.is_text:
|
|
195
|
+
stats.binary += 1
|
|
196
|
+
fv = FileView(rec, data, c.text, c.lang, c.magic, c.lossy, truncated)
|
|
197
|
+
out: list[Finding] = []
|
|
198
|
+
for analyzer in analyzers:
|
|
199
|
+
try:
|
|
200
|
+
if analyzer.applies(fv):
|
|
201
|
+
out.extend(analyzer.analyze(fv, ctx))
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
ctx.issues.append(
|
|
204
|
+
ScanIssue(
|
|
205
|
+
kind="error",
|
|
206
|
+
stage=f"analyzer:{analyzer.name}",
|
|
207
|
+
path=rec.rel,
|
|
208
|
+
message=f"{type(exc).__name__}: {exc}",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return out
|
|
File without changes
|