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/ai/tools.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Read-only investigation tools and the policy layer that executes them (AI-006, SAFE-012).
|
|
2
|
+
|
|
3
|
+
The registry is a fixed allowlist. There is no shell, network, write, or code-evaluation tool. Every call is
|
|
4
|
+
validated against its schema, confined to the scan root through SafeFS, capped in output size, redacted,
|
|
5
|
+
and recorded in the audit log. Tool calls are proposals from an untrusted model, not commands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import codecs
|
|
13
|
+
import fnmatch
|
|
14
|
+
import re
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import zlib
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from devguard.ai.sdk import ToolCall, ToolResult, ToolSpec
|
|
22
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
23
|
+
from devguard.analyzers.content import ContentRuleEngine
|
|
24
|
+
from devguard.analyzers.hidden import shannon_entropy
|
|
25
|
+
from devguard.core.filetype import classify, guess_code_lang
|
|
26
|
+
from devguard.core.redact import make_excerpt, redact, visible
|
|
27
|
+
from devguard.core.safefs import FileRecord, Kind, ReadError, SafeFS, normalize_rel
|
|
28
|
+
|
|
29
|
+
MAX_OUTPUT = 32 * 1024
|
|
30
|
+
MAX_DECODED = 64 * 1024
|
|
31
|
+
MAX_LINES = 400
|
|
32
|
+
MAX_SEARCH_BYTES = 20 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolDenied(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ToolEnv:
|
|
41
|
+
fs: SafeFS
|
|
42
|
+
ctx: AnalysisContext
|
|
43
|
+
engine: ContentRuleEngine
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ToolDef:
|
|
48
|
+
spec: ToolSpec
|
|
49
|
+
fn: Callable[[ToolEnv, dict[str, Any]], str]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _schema(props: dict[str, Any], required: list[str]) -> dict[str, Any]:
|
|
53
|
+
return {"type": "object", "properties": props, "required": required, "additionalProperties": False}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_PATH = {"type": "string", "maxLength": 1024, "description": "Repository-relative path, e.g. src/index.js"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ----------------------------------------------------------------------------------------------- helpers
|
|
60
|
+
def _record(env: ToolEnv, path: str) -> FileRecord:
|
|
61
|
+
norm = normalize_rel(path)
|
|
62
|
+
if norm is None:
|
|
63
|
+
raise ToolDenied("path must be relative and inside the repository")
|
|
64
|
+
rec = env.ctx.inventory.get(norm)
|
|
65
|
+
if rec is None:
|
|
66
|
+
raise ToolDenied(f"no such file in the scanned inventory: {norm}")
|
|
67
|
+
return rec
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _text_of(env: ToolEnv, rec: FileRecord) -> tuple[str | None, bytes]:
|
|
71
|
+
try:
|
|
72
|
+
data = env.fs.read(rec.rel)
|
|
73
|
+
except ReadError as exc:
|
|
74
|
+
raise ToolDenied(f"cannot read {rec.rel}: {exc}") from exc
|
|
75
|
+
return classify(rec.rel, data).text, data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _safe(text: str) -> str:
|
|
79
|
+
return visible(redact(text))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ----------------------------------------------------------------------------------------------- tools
|
|
83
|
+
def list_files(env: ToolEnv, a: dict[str, Any]) -> str:
|
|
84
|
+
pattern = a.get("glob", "*")
|
|
85
|
+
limit = min(int(a.get("limit", 200)), 500)
|
|
86
|
+
recs = [*env.ctx.inventory.files, *env.ctx.inventory.vendored_files.values()]
|
|
87
|
+
hits = [r for r in recs if fnmatch.fnmatchcase(r.rel, pattern) or fnmatch.fnmatchcase(r.name, pattern)]
|
|
88
|
+
lines = [f"{_safe(r.rel)}\t{r.size} bytes{' (vendored)' if r.vendored else ''}" for r in hits[:limit]]
|
|
89
|
+
more = f"\n… {len(hits) - limit} more" if len(hits) > limit else ""
|
|
90
|
+
return "\n".join(lines) + more if lines else "no matching files"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_file(env: ToolEnv, a: dict[str, Any]) -> str:
|
|
94
|
+
rec = _record(env, a["path"])
|
|
95
|
+
text, _ = _text_of(env, rec)
|
|
96
|
+
if text is None:
|
|
97
|
+
raise ToolDenied("binary file; use file_info instead")
|
|
98
|
+
lines = text.split("\n")
|
|
99
|
+
start = max(1, int(a.get("start_line", 1)))
|
|
100
|
+
end = min(len(lines), int(a.get("end_line", start + MAX_LINES - 1)), start + MAX_LINES - 1)
|
|
101
|
+
out = [
|
|
102
|
+
f"{i:>6}| {_safe(lines[i - 1][:2000])}{' …[line truncated]' if len(lines[i - 1]) > 2000 else ''}"
|
|
103
|
+
for i in range(start, end + 1)
|
|
104
|
+
]
|
|
105
|
+
header = f"{rec.rel} lines {start}-{end} of {len(lines)} (invisible characters shown as <U+XXXX>)"
|
|
106
|
+
return header + "\n" + "\n".join(out)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def search(env: ToolEnv, a: dict[str, Any]) -> str:
|
|
110
|
+
needle = a["text"]
|
|
111
|
+
ci = bool(a.get("case_insensitive", False))
|
|
112
|
+
pattern = a.get("glob", "*")
|
|
113
|
+
limit = min(int(a.get("limit", 100)), 200)
|
|
114
|
+
hits: list[str] = []
|
|
115
|
+
scanned = 0
|
|
116
|
+
for rec in env.ctx.inventory.files:
|
|
117
|
+
if not (fnmatch.fnmatchcase(rec.rel, pattern) or fnmatch.fnmatchcase(rec.name, pattern)):
|
|
118
|
+
continue
|
|
119
|
+
if scanned > MAX_SEARCH_BYTES:
|
|
120
|
+
hits.append("… search byte budget reached")
|
|
121
|
+
break
|
|
122
|
+
try:
|
|
123
|
+
text, data = _text_of(env, rec)
|
|
124
|
+
except ToolDenied:
|
|
125
|
+
continue
|
|
126
|
+
scanned += len(data)
|
|
127
|
+
if text is None:
|
|
128
|
+
continue
|
|
129
|
+
hay, n = (text.lower(), needle.lower()) if ci else (text, needle)
|
|
130
|
+
idx = hay.find(n)
|
|
131
|
+
while idx != -1 and len(hits) < limit:
|
|
132
|
+
line_no = text.count("\n", 0, idx) + 1
|
|
133
|
+
line = text.split("\n")[line_no - 1]
|
|
134
|
+
col = idx - (text.rfind("\n", 0, idx) + 1)
|
|
135
|
+
hits.append(f"{_safe(rec.rel)}:{line_no}: {make_excerpt(line, col, limit=240)}")
|
|
136
|
+
idx = hay.find(n, idx + len(n))
|
|
137
|
+
if len(hits) >= limit:
|
|
138
|
+
break
|
|
139
|
+
return "\n".join(hits) if hits else "no matches"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _charcodes(s: str) -> bytes:
|
|
143
|
+
nums = [int(x) for x in re.findall(r"\d+", s)]
|
|
144
|
+
return "".join(chr(n) for n in nums if 0 <= n < 0x110000).encode("utf-8", "replace")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _b64(s: str) -> bytes:
|
|
148
|
+
compact = re.sub(r"\s+", "", s)
|
|
149
|
+
return base64.b64decode(compact + "=" * (-len(compact) % 4))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _inflate(data: bytes, wbits: int) -> bytes:
|
|
153
|
+
return zlib.decompressobj(wbits).decompress(data, MAX_DECODED) # bounded: decompression-bomb safe
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_DECODERS: dict[str, Callable[[str], bytes]] = {
|
|
157
|
+
"base64": _b64,
|
|
158
|
+
"base64url": lambda s: base64.urlsafe_b64decode(s.strip() + "=" * (-len(s.strip()) % 4)),
|
|
159
|
+
"hex": lambda s: binascii.unhexlify(re.sub(r"(\\x|0x|\s|,)", "", s)),
|
|
160
|
+
"url": lambda s: urllib.parse.unquote_to_bytes(s),
|
|
161
|
+
"charcodes": _charcodes,
|
|
162
|
+
"rot13": lambda s: codecs.encode(s, "rot13").encode(),
|
|
163
|
+
"zlib_base64": lambda s: _inflate(_b64(s), zlib.MAX_WBITS),
|
|
164
|
+
"gzip_base64": lambda s: _inflate(_b64(s), 16 + zlib.MAX_WBITS),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def decode(env: ToolEnv, a: dict[str, Any]) -> str:
|
|
169
|
+
scheme = a["scheme"]
|
|
170
|
+
try:
|
|
171
|
+
raw = _DECODERS[scheme](a["data"])[:MAX_DECODED]
|
|
172
|
+
except (ValueError, binascii.Error, zlib.error, OSError, EOFError) as exc:
|
|
173
|
+
raise ToolDenied(f"decode failed: {exc}") from exc
|
|
174
|
+
c = classify("decoded", raw)
|
|
175
|
+
if c.text is None:
|
|
176
|
+
return f"binary result, {len(raw)} bytes, format={c.magic or 'unknown'}, first bytes: {raw[:48].hex()}"
|
|
177
|
+
note = f"decoded {len(raw)} bytes as text ({guess_code_lang(c.text) or 'unknown language'}):\n"
|
|
178
|
+
return note + _safe(c.text)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def file_info(env: ToolEnv, a: dict[str, Any]) -> str:
|
|
182
|
+
rec = _record(env, a["path"])
|
|
183
|
+
_, data = _text_of(env, rec)
|
|
184
|
+
c = classify(rec.rel, data)
|
|
185
|
+
parts = [
|
|
186
|
+
f"path: {_safe(rec.rel)}",
|
|
187
|
+
f"size: {rec.size} bytes",
|
|
188
|
+
f"format (magic): {c.magic or 'none'}",
|
|
189
|
+
f"text: {c.is_text} (encoding {c.encoding}, lossy={c.lossy})",
|
|
190
|
+
f"language: {c.lang or 'unknown'}",
|
|
191
|
+
f"entropy (first 64KB, bits/byte): {shannon_entropy(data[:65536].decode('latin-1')):.2f}",
|
|
192
|
+
f"executable bit: {rec.executable_bit}",
|
|
193
|
+
f"vendored: {rec.vendored}",
|
|
194
|
+
]
|
|
195
|
+
if not c.is_text:
|
|
196
|
+
strings = re.findall(rb"[\x20-\x7e]{6,}", data[:1_000_000])[:60]
|
|
197
|
+
parts.append("printable strings: " + " | ".join(_safe(s.decode()) for s in strings))
|
|
198
|
+
return "\n".join(parts)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def run_rules(env: ToolEnv, a: dict[str, Any]) -> str:
|
|
202
|
+
rec = _record(env, a["path"])
|
|
203
|
+
text, data = _text_of(env, rec)
|
|
204
|
+
if text is None:
|
|
205
|
+
return "binary file: content rules apply to text only"
|
|
206
|
+
c = classify(rec.rel, data)
|
|
207
|
+
fv = FileView(rec, data, text, c.lang, c.magic, c.lossy)
|
|
208
|
+
return _format_findings(env.engine.analyze(fv, env.ctx))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def scan_text(env: ToolEnv, a: dict[str, Any]) -> str:
|
|
212
|
+
text = a["text"][:MAX_DECODED]
|
|
213
|
+
lang = a.get("language") or guess_code_lang(text)
|
|
214
|
+
rec = FileRecord("<supplied-text>", Kind.FILE, len(text), 0o644)
|
|
215
|
+
fv = FileView(rec, text.encode(), text, lang, None, False)
|
|
216
|
+
return _format_findings(env.engine.analyze(fv, env.ctx))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _format_findings(findings: list[Any]) -> str:
|
|
220
|
+
if not findings:
|
|
221
|
+
return "no content rules matched"
|
|
222
|
+
return "\n".join(f"{f.rule_id} [{f.severity.value}] {f.title} (line {f.primary_line})" for f in findings)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
REGISTRY: dict[str, ToolDef] = {
|
|
226
|
+
"list_files": ToolDef(
|
|
227
|
+
ToolSpec(
|
|
228
|
+
"list_files",
|
|
229
|
+
"List repository files matching a glob (read-only).",
|
|
230
|
+
_schema(
|
|
231
|
+
{
|
|
232
|
+
"glob": {"type": "string", "maxLength": 256},
|
|
233
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 500},
|
|
234
|
+
},
|
|
235
|
+
[],
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
list_files,
|
|
239
|
+
),
|
|
240
|
+
"read_file": ToolDef(
|
|
241
|
+
ToolSpec(
|
|
242
|
+
"read_file",
|
|
243
|
+
"Read up to 400 lines of a repository text file. Secrets are "
|
|
244
|
+
"redacted and invisible characters are shown as <U+XXXX>.",
|
|
245
|
+
_schema(
|
|
246
|
+
{
|
|
247
|
+
"path": _PATH,
|
|
248
|
+
"start_line": {"type": "integer", "minimum": 1},
|
|
249
|
+
"end_line": {"type": "integer", "minimum": 1},
|
|
250
|
+
},
|
|
251
|
+
["path"],
|
|
252
|
+
),
|
|
253
|
+
),
|
|
254
|
+
read_file,
|
|
255
|
+
),
|
|
256
|
+
"search": ToolDef(
|
|
257
|
+
ToolSpec(
|
|
258
|
+
"search",
|
|
259
|
+
"Literal (non-regex) text search across repository files.",
|
|
260
|
+
_schema(
|
|
261
|
+
{
|
|
262
|
+
"text": {"type": "string", "minLength": 2, "maxLength": 200},
|
|
263
|
+
"glob": {"type": "string", "maxLength": 256},
|
|
264
|
+
"case_insensitive": {"type": "boolean"},
|
|
265
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
|
|
266
|
+
},
|
|
267
|
+
["text"],
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
search,
|
|
271
|
+
),
|
|
272
|
+
"decode": ToolDef(
|
|
273
|
+
ToolSpec(
|
|
274
|
+
"decode",
|
|
275
|
+
"Decode a supplied string (one layer). Nothing is executed.",
|
|
276
|
+
_schema(
|
|
277
|
+
{
|
|
278
|
+
"data": {"type": "string", "maxLength": 200_000},
|
|
279
|
+
"scheme": {"type": "string", "enum": sorted(_DECODERS)},
|
|
280
|
+
},
|
|
281
|
+
["data", "scheme"],
|
|
282
|
+
),
|
|
283
|
+
),
|
|
284
|
+
decode,
|
|
285
|
+
),
|
|
286
|
+
"file_info": ToolDef(
|
|
287
|
+
ToolSpec(
|
|
288
|
+
"file_info",
|
|
289
|
+
"Metadata for a file: size, format, entropy, strings sample.",
|
|
290
|
+
_schema({"path": _PATH}, ["path"]),
|
|
291
|
+
),
|
|
292
|
+
file_info,
|
|
293
|
+
),
|
|
294
|
+
"run_rules": ToolDef(
|
|
295
|
+
ToolSpec(
|
|
296
|
+
"run_rules", "Run DevGuard's deterministic content rules on one file.", _schema({"path": _PATH}, ["path"])
|
|
297
|
+
),
|
|
298
|
+
run_rules,
|
|
299
|
+
),
|
|
300
|
+
"scan_text": ToolDef(
|
|
301
|
+
ToolSpec(
|
|
302
|
+
"scan_text",
|
|
303
|
+
"Run DevGuard's content rules on supplied text, e.g. a decoded payload.",
|
|
304
|
+
_schema(
|
|
305
|
+
{
|
|
306
|
+
"text": {"type": "string", "maxLength": 65536},
|
|
307
|
+
"language": {"type": "string", "enum": ["javascript", "python", "shell", "powershell", "batch"]},
|
|
308
|
+
},
|
|
309
|
+
["text"],
|
|
310
|
+
),
|
|
311
|
+
),
|
|
312
|
+
scan_text,
|
|
313
|
+
),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def validate_args(schema: dict[str, Any], args: Any) -> str | None:
|
|
318
|
+
"""Minimal JSON-Schema subset validator for our own tool schemas. Returns an error or None."""
|
|
319
|
+
if not isinstance(args, dict):
|
|
320
|
+
return "arguments must be an object"
|
|
321
|
+
props = schema.get("properties", {})
|
|
322
|
+
for key in schema.get("required", []):
|
|
323
|
+
if key not in args:
|
|
324
|
+
return f"missing required argument {key!r}"
|
|
325
|
+
for key, value in args.items():
|
|
326
|
+
spec = props.get(key)
|
|
327
|
+
if spec is None:
|
|
328
|
+
return f"unknown argument {key!r}"
|
|
329
|
+
t = spec.get("type")
|
|
330
|
+
if t == "string":
|
|
331
|
+
if not isinstance(value, str):
|
|
332
|
+
return f"{key} must be a string"
|
|
333
|
+
if len(value) > spec.get("maxLength", 10**9) or len(value) < spec.get("minLength", 0):
|
|
334
|
+
return f"{key} has invalid length"
|
|
335
|
+
if "enum" in spec and value not in spec["enum"]:
|
|
336
|
+
return f"{key} must be one of {spec['enum']}"
|
|
337
|
+
elif t == "integer":
|
|
338
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
339
|
+
return f"{key} must be an integer"
|
|
340
|
+
if value < spec.get("minimum", -(10**12)) or value > spec.get("maximum", 10**12):
|
|
341
|
+
return f"{key} out of range"
|
|
342
|
+
elif t == "boolean" and not isinstance(value, bool):
|
|
343
|
+
return f"{key} must be a boolean"
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass
|
|
348
|
+
class ToolPolicy:
|
|
349
|
+
env: ToolEnv
|
|
350
|
+
max_calls: int
|
|
351
|
+
calls: int = 0
|
|
352
|
+
audit: list[dict[str, Any]] = field(default_factory=list)
|
|
353
|
+
|
|
354
|
+
def specs(self) -> list[ToolSpec]:
|
|
355
|
+
return [d.spec for d in REGISTRY.values()]
|
|
356
|
+
|
|
357
|
+
def execute(self, call: ToolCall) -> ToolResult:
|
|
358
|
+
args_preview = make_excerpt(repr(call.arguments), limit=200)
|
|
359
|
+
tool = REGISTRY.get(call.name)
|
|
360
|
+
if tool is None:
|
|
361
|
+
return self._deny(call, args_preview, f"unknown tool {call.name!r}; only read-only tools exist")
|
|
362
|
+
if self.calls >= self.max_calls:
|
|
363
|
+
return self._deny(call, args_preview, "tool-call budget exhausted; give your final answer now")
|
|
364
|
+
error = validate_args(tool.spec.input_schema, call.arguments)
|
|
365
|
+
if error:
|
|
366
|
+
return self._deny(call, args_preview, f"invalid arguments: {error}")
|
|
367
|
+
self.calls += 1
|
|
368
|
+
try:
|
|
369
|
+
output = tool.fn(self.env, call.arguments)
|
|
370
|
+
except ToolDenied as exc:
|
|
371
|
+
return self._deny(call, args_preview, str(exc))
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
return self._deny(call, args_preview, f"tool error: {type(exc).__name__}")
|
|
374
|
+
if len(output) > MAX_OUTPUT:
|
|
375
|
+
output = output[:MAX_OUTPUT] + "\n…[output truncated]"
|
|
376
|
+
self.audit.append(
|
|
377
|
+
{
|
|
378
|
+
"tool": call.name,
|
|
379
|
+
"args": args_preview,
|
|
380
|
+
"allowed": True,
|
|
381
|
+
"summary": f"{len(output)} chars",
|
|
382
|
+
"bytes": len(output),
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
return ToolResult(call.id, output)
|
|
386
|
+
|
|
387
|
+
def _deny(self, call: ToolCall, args_preview: str, reason: str) -> ToolResult:
|
|
388
|
+
self.audit.append({"tool": call.name, "args": args_preview, "allowed": False, "summary": reason})
|
|
389
|
+
return ToolResult(call.id, f"DENIED: {reason}", is_error=True)
|
|
File without changes
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Analyzer protocol, the per-file view handed to analyzers, and the shared analysis context.
|
|
2
|
+
|
|
3
|
+
Analyzers never open files. They receive bytes/text through `FileView` and report findings and triggers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import bisect
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from functools import cached_property
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
from devguard.core.config import ScanConfig
|
|
16
|
+
from devguard.core.models import Evidence, Finding, ScanIssue, Trigger
|
|
17
|
+
from devguard.core.redact import make_excerpt
|
|
18
|
+
from devguard.core.safefs import FileRecord, Inventory
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FileView:
|
|
23
|
+
record: FileRecord
|
|
24
|
+
data: bytes
|
|
25
|
+
text: str | None
|
|
26
|
+
lang: str | None
|
|
27
|
+
magic: str | None
|
|
28
|
+
lossy: bool
|
|
29
|
+
truncated: bool = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def rel(self) -> str:
|
|
33
|
+
return self.record.rel
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def name(self) -> str:
|
|
37
|
+
return PurePosixPath(self.record.rel).name
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def suffix(self) -> str:
|
|
41
|
+
return PurePosixPath(self.record.rel).suffix.lower()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def parent(self) -> str:
|
|
45
|
+
parent = str(PurePosixPath(self.record.rel).parent)
|
|
46
|
+
return "" if parent == "." else parent
|
|
47
|
+
|
|
48
|
+
@cached_property
|
|
49
|
+
def lines(self) -> list[str]:
|
|
50
|
+
return (self.text or "").split("\n")
|
|
51
|
+
|
|
52
|
+
@cached_property
|
|
53
|
+
def _line_starts(self) -> list[int]:
|
|
54
|
+
starts = [0]
|
|
55
|
+
text = self.text or ""
|
|
56
|
+
idx = text.find("\n")
|
|
57
|
+
while idx != -1:
|
|
58
|
+
starts.append(idx + 1)
|
|
59
|
+
idx = text.find("\n", idx + 1)
|
|
60
|
+
return starts
|
|
61
|
+
|
|
62
|
+
def line_col(self, offset: int) -> tuple[int, int]:
|
|
63
|
+
"""1-based line and column for a character offset."""
|
|
64
|
+
i = bisect.bisect_right(self._line_starts, offset) - 1
|
|
65
|
+
return i + 1, offset - self._line_starts[i] + 1
|
|
66
|
+
|
|
67
|
+
def evidence_at_offset(self, offset: int, end: int | None = None) -> Evidence:
|
|
68
|
+
line, col = self.line_col(offset)
|
|
69
|
+
end_line = self.line_col(end)[0] if end is not None and end > offset else line
|
|
70
|
+
return self.evidence_at_line(line, col, end_line)
|
|
71
|
+
|
|
72
|
+
def evidence_at_line(self, line: int | None, column: int | None = None, end_line: int | None = None) -> Evidence:
|
|
73
|
+
if line is None or not self.text:
|
|
74
|
+
return Evidence(path=self.rel)
|
|
75
|
+
text_line = self.lines[line - 1] if 0 < line <= len(self.lines) else ""
|
|
76
|
+
return Evidence(
|
|
77
|
+
path=self.rel,
|
|
78
|
+
line_start=line,
|
|
79
|
+
line_end=end_line if end_line and end_line != line else None,
|
|
80
|
+
column=column,
|
|
81
|
+
excerpt=make_excerpt(text_line, column),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class AnalysisContext:
|
|
87
|
+
config: ScanConfig
|
|
88
|
+
inventory: Inventory
|
|
89
|
+
triggers: list[Trigger] = field(default_factory=list)
|
|
90
|
+
issues: list[ScanIssue] = field(default_factory=list)
|
|
91
|
+
# package.json directory ("" = root) → script name → command
|
|
92
|
+
npm_scripts: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
93
|
+
# files loaded automatically by tools/IDE extensions (config-as-code, SURF-012)
|
|
94
|
+
autoloaded: set[str] = field(default_factory=set)
|
|
95
|
+
# static file → referenced files (imports, sourced scripts, files read by loaders)
|
|
96
|
+
imports: dict[str, list[str]] = field(default_factory=dict)
|
|
97
|
+
_trigger_seq: int = 0
|
|
98
|
+
|
|
99
|
+
def add_trigger(
|
|
100
|
+
self,
|
|
101
|
+
kind: str,
|
|
102
|
+
path: str,
|
|
103
|
+
*,
|
|
104
|
+
line: int | None = None,
|
|
105
|
+
label: str = "",
|
|
106
|
+
command: str | None = None,
|
|
107
|
+
autorun: bool = False,
|
|
108
|
+
references: Iterable[str] = (),
|
|
109
|
+
npm_scripts: Iterable[str] = (),
|
|
110
|
+
) -> Trigger:
|
|
111
|
+
self._trigger_seq += 1
|
|
112
|
+
trig = Trigger(
|
|
113
|
+
id=f"T{self._trigger_seq:04d}",
|
|
114
|
+
kind=kind,
|
|
115
|
+
path=path,
|
|
116
|
+
line=line,
|
|
117
|
+
label=label,
|
|
118
|
+
command=command,
|
|
119
|
+
autorun=autorun,
|
|
120
|
+
references=sorted(set(references)),
|
|
121
|
+
npm_scripts=sorted(set(npm_scripts)),
|
|
122
|
+
)
|
|
123
|
+
self.triggers.append(trig)
|
|
124
|
+
return trig
|
|
125
|
+
|
|
126
|
+
def warn(self, stage: str, message: str, path: str | None = None) -> None:
|
|
127
|
+
self.issues.append(ScanIssue(kind="warning", stage=stage, message=message, path=path))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Analyzer(Protocol):
|
|
131
|
+
name: str
|
|
132
|
+
|
|
133
|
+
def applies(self, fv: FileView) -> bool: ...
|
|
134
|
+
|
|
135
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> Iterable[Finding]: ...
|