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,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
@@ -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
+ }
@@ -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