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,345 @@
|
|
|
1
|
+
"""Payload carriers: binaries, disguised files, archives, pickle-based model files (TC-09).
|
|
2
|
+
|
|
3
|
+
Archives are listed in memory only (never extracted). Pickles are disassembled with `pickletools`
|
|
4
|
+
(a static opcode reader); they are never loaded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import bz2
|
|
10
|
+
import io
|
|
11
|
+
import lzma
|
|
12
|
+
import pickletools
|
|
13
|
+
import tarfile
|
|
14
|
+
import zipfile
|
|
15
|
+
import zlib
|
|
16
|
+
|
|
17
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
18
|
+
from devguard.core.filetype import (
|
|
19
|
+
ARCHIVE_MAGIC,
|
|
20
|
+
EXECUTABLE_MAGIC,
|
|
21
|
+
EXPECTED_MAGIC,
|
|
22
|
+
TEXT_EXTENSIONS,
|
|
23
|
+
looks_like_code,
|
|
24
|
+
)
|
|
25
|
+
from devguard.core.models import Finding, Severity
|
|
26
|
+
from devguard.core.redact import make_excerpt
|
|
27
|
+
from devguard.rules.catalog import build_finding, rule
|
|
28
|
+
|
|
29
|
+
R_EXEC = rule(
|
|
30
|
+
"DG-CAR-EXEC-001",
|
|
31
|
+
"Executable binary committed to the repository",
|
|
32
|
+
"medium",
|
|
33
|
+
"high",
|
|
34
|
+
"carrier",
|
|
35
|
+
"TC-09.1, TC-09.8",
|
|
36
|
+
"The repository contains a compiled executable or library. Binaries cannot be reviewed like source and "
|
|
37
|
+
"are a common way to ship second-stage payloads.",
|
|
38
|
+
"Projects sometimes vendor tools or ship prebuilt native modules.",
|
|
39
|
+
"Do not run it. Check where it came from and whether a trigger or script references it.",
|
|
40
|
+
)
|
|
41
|
+
R_MISMATCH = rule(
|
|
42
|
+
"DG-CAR-DISGUISED-001",
|
|
43
|
+
"File content does not match its extension",
|
|
44
|
+
"high",
|
|
45
|
+
"high",
|
|
46
|
+
"carrier",
|
|
47
|
+
"TC-09.2",
|
|
48
|
+
"The file's bytes do not match its extension (for example JavaScript inside a .woff font or a program "
|
|
49
|
+
"inside a .png). Malware hides loaders as fonts and images so reviewers skip them.",
|
|
50
|
+
)
|
|
51
|
+
R_ARCHIVE = rule(
|
|
52
|
+
"DG-CAR-ARCHIVE-001",
|
|
53
|
+
"Archive committed to the repository",
|
|
54
|
+
"low",
|
|
55
|
+
"high",
|
|
56
|
+
"carrier",
|
|
57
|
+
"TC-09.5",
|
|
58
|
+
"The repository contains an archive. Its contents were listed (not extracted).",
|
|
59
|
+
)
|
|
60
|
+
R_PICKLE_DANGER = rule(
|
|
61
|
+
"DG-CAR-PICKLE-001",
|
|
62
|
+
"Pickle file references dangerous functions",
|
|
63
|
+
"critical",
|
|
64
|
+
"high",
|
|
65
|
+
"carrier",
|
|
66
|
+
"TC-09.4",
|
|
67
|
+
"A pickle-based file (ML model, cache) imports functions such as os.system, subprocess, eval, or exec. "
|
|
68
|
+
"Loading it with pickle/torch.load/joblib executes them.",
|
|
69
|
+
"Legitimate model files only reference library classes (torch, numpy, collections).",
|
|
70
|
+
"Never load this file. Use safetensors or inspect it with a static tool such as fickling.",
|
|
71
|
+
)
|
|
72
|
+
R_PICKLE_INFO = rule(
|
|
73
|
+
"DG-CAR-PICKLE-INFO-001",
|
|
74
|
+
"Pickle-based file present",
|
|
75
|
+
"info",
|
|
76
|
+
"high",
|
|
77
|
+
"carrier",
|
|
78
|
+
"TC-09.4",
|
|
79
|
+
"The file uses Python pickle serialization; loading a pickle can execute code by design. No dangerous "
|
|
80
|
+
"imports were found by static inspection.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
_PICKLE_EXT = (".pkl", ".pickle", ".joblib", ".pt", ".ckpt", ".bin", ".pth", ".dill", ".sav")
|
|
84
|
+
_ARCHIVE_EXEC_EXT = (
|
|
85
|
+
".exe",
|
|
86
|
+
".dll",
|
|
87
|
+
".scr",
|
|
88
|
+
".bat",
|
|
89
|
+
".cmd",
|
|
90
|
+
".ps1",
|
|
91
|
+
".vbs",
|
|
92
|
+
".js",
|
|
93
|
+
".jse",
|
|
94
|
+
".wsf",
|
|
95
|
+
".hta",
|
|
96
|
+
".jar",
|
|
97
|
+
".lnk",
|
|
98
|
+
".msi",
|
|
99
|
+
".sh",
|
|
100
|
+
".app",
|
|
101
|
+
".command",
|
|
102
|
+
".so",
|
|
103
|
+
".dylib",
|
|
104
|
+
".elf",
|
|
105
|
+
".py",
|
|
106
|
+
".apk",
|
|
107
|
+
)
|
|
108
|
+
_DANGEROUS_MODULES = {
|
|
109
|
+
"os",
|
|
110
|
+
"posix",
|
|
111
|
+
"nt",
|
|
112
|
+
"subprocess",
|
|
113
|
+
"sys",
|
|
114
|
+
"socket",
|
|
115
|
+
"shutil",
|
|
116
|
+
"runpy",
|
|
117
|
+
"importlib",
|
|
118
|
+
"webbrowser",
|
|
119
|
+
"pty",
|
|
120
|
+
"ctypes",
|
|
121
|
+
"requests",
|
|
122
|
+
"urllib",
|
|
123
|
+
"urllib.request",
|
|
124
|
+
"http.client",
|
|
125
|
+
"commands",
|
|
126
|
+
"code",
|
|
127
|
+
"pickle",
|
|
128
|
+
"marshal",
|
|
129
|
+
"types",
|
|
130
|
+
"multiprocessing",
|
|
131
|
+
"asyncio",
|
|
132
|
+
"pip",
|
|
133
|
+
"platform",
|
|
134
|
+
"builtins",
|
|
135
|
+
"__builtin__",
|
|
136
|
+
"operator",
|
|
137
|
+
}
|
|
138
|
+
_DANGEROUS_BUILTINS = {
|
|
139
|
+
"eval",
|
|
140
|
+
"exec",
|
|
141
|
+
"compile",
|
|
142
|
+
"__import__",
|
|
143
|
+
"open",
|
|
144
|
+
"getattr",
|
|
145
|
+
"setattr",
|
|
146
|
+
"globals",
|
|
147
|
+
"execfile",
|
|
148
|
+
"input",
|
|
149
|
+
"breakpoint",
|
|
150
|
+
"apply",
|
|
151
|
+
}
|
|
152
|
+
MAX_ARCHIVE_MEMBERS = 10_000
|
|
153
|
+
MAX_DECOMPRESSED = 64 * 1024 * 1024
|
|
154
|
+
_DECOMPRESSORS = ("gzip", "bzip2", "xz")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CarrierAnalyzer:
|
|
158
|
+
name = "carriers"
|
|
159
|
+
|
|
160
|
+
def applies(self, fv: FileView) -> bool:
|
|
161
|
+
return fv.record.size > 0
|
|
162
|
+
|
|
163
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
164
|
+
out: list[Finding] = []
|
|
165
|
+
suffix = fv.suffix
|
|
166
|
+
magic = fv.magic
|
|
167
|
+
expected = EXPECTED_MAGIC.get(suffix)
|
|
168
|
+
if expected is not None and magic not in expected:
|
|
169
|
+
if fv.text is not None and looks_like_code(fv.text):
|
|
170
|
+
out.append(
|
|
171
|
+
build_finding(
|
|
172
|
+
R_MISMATCH.id,
|
|
173
|
+
[fv.evidence_at_line(1)],
|
|
174
|
+
detail=f"'{suffix}' file contains source code ({fv.lang or 'text'}).",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
elif magic in EXECUTABLE_MAGIC:
|
|
178
|
+
out.append(
|
|
179
|
+
build_finding(
|
|
180
|
+
R_MISMATCH.id, [fv.evidence_at_line(None)], detail=f"'{suffix}' file is a {magic} executable."
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
elif suffix in TEXT_EXTENSIONS and magic in EXECUTABLE_MAGIC:
|
|
184
|
+
out.append(
|
|
185
|
+
build_finding(
|
|
186
|
+
R_MISMATCH.id, [fv.evidence_at_line(None)], detail=f"'{suffix}' file is a {magic} executable."
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
if magic in EXECUTABLE_MAGIC and not out:
|
|
190
|
+
sev = Severity.LOW if suffix in (".class", ".node", ".wasm") else None
|
|
191
|
+
out.append(
|
|
192
|
+
build_finding(
|
|
193
|
+
R_EXEC.id,
|
|
194
|
+
[fv.evidence_at_line(None)],
|
|
195
|
+
severity=sev,
|
|
196
|
+
detail=f"Format: {magic}, {fv.record.size} bytes.",
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
if magic in ARCHIVE_MAGIC:
|
|
200
|
+
out += self._archive(fv, ctx)
|
|
201
|
+
if suffix in _PICKLE_EXT and fv.text is None:
|
|
202
|
+
out += self._pickle(fv, ctx)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------ archives
|
|
206
|
+
def _archive(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
207
|
+
names: list[str] = []
|
|
208
|
+
encrypted = False
|
|
209
|
+
note = ""
|
|
210
|
+
try:
|
|
211
|
+
if fv.magic == "zip":
|
|
212
|
+
with zipfile.ZipFile(io.BytesIO(fv.data)) as zf:
|
|
213
|
+
infos = zf.infolist()[:MAX_ARCHIVE_MEMBERS]
|
|
214
|
+
names = [i.filename for i in infos]
|
|
215
|
+
encrypted = any(i.flag_bits & 0x1 for i in infos)
|
|
216
|
+
if fv.suffix in _PICKLE_EXT: # torch .pt / .pth are zip archives of pickles
|
|
217
|
+
return self._torch_zip(fv, zf)
|
|
218
|
+
elif fv.magic == "tar" or (
|
|
219
|
+
fv.magic in _DECOMPRESSORS and (".tar" in fv.name or fv.suffix in (".tgz", ".tbz2", ".txz"))
|
|
220
|
+
):
|
|
221
|
+
raw = fv.data if fv.magic == "tar" else _bounded_decompress(fv.magic, fv.data)
|
|
222
|
+
names = _tar_names(raw)
|
|
223
|
+
else:
|
|
224
|
+
note = f"{fv.magic} archive (contents not listed)."
|
|
225
|
+
except (zipfile.BadZipFile, tarfile.TarError, EOFError, OSError, ValueError, zlib.error, lzma.LZMAError) as exc:
|
|
226
|
+
note = f"Could not list contents: {type(exc).__name__}" + (
|
|
227
|
+
" (file truncated by size limit)" if fv.truncated else ""
|
|
228
|
+
)
|
|
229
|
+
exec_members = [n for n in names if n.lower().split(" -> ")[0].endswith(_ARCHIVE_EXEC_EXT)]
|
|
230
|
+
traversal = [n for n in names if n.startswith("/") or "../" in n or n.startswith("..")]
|
|
231
|
+
sev = Severity.LOW
|
|
232
|
+
parts = [note] if note else [f"{len(names)} member(s)."]
|
|
233
|
+
if encrypted:
|
|
234
|
+
sev = Severity.MEDIUM
|
|
235
|
+
parts.append("Password-protected (scanners cannot see inside; a password in the README is a red flag).")
|
|
236
|
+
if exec_members:
|
|
237
|
+
sev = Severity.HIGH if encrypted else Severity.MEDIUM
|
|
238
|
+
parts.append("Executable/script members: " + ", ".join(make_excerpt(n, limit=80) for n in exec_members[:8]))
|
|
239
|
+
if traversal:
|
|
240
|
+
sev = max(sev, Severity.MEDIUM, key=lambda s: s.rank)
|
|
241
|
+
parts.append(
|
|
242
|
+
"Members with absolute or parent-directory paths: "
|
|
243
|
+
+ ", ".join(make_excerpt(n, limit=80) for n in traversal[:5])
|
|
244
|
+
)
|
|
245
|
+
return [build_finding(R_ARCHIVE.id, [fv.evidence_at_line(None)], severity=sev, detail=" ".join(parts))]
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ pickles
|
|
248
|
+
def _torch_zip(self, fv: FileView, zf: zipfile.ZipFile) -> list[Finding]:
|
|
249
|
+
refs: set[str] = set()
|
|
250
|
+
for info in zf.infolist()[:MAX_ARCHIVE_MEMBERS]:
|
|
251
|
+
if info.filename.endswith(".pkl") and info.file_size <= 64 * 1024 * 1024:
|
|
252
|
+
refs |= pickle_globals(zf.read(info))
|
|
253
|
+
return self._pickle_findings(fv, refs)
|
|
254
|
+
|
|
255
|
+
def _pickle(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
256
|
+
if not fv.data[:1] == b"\x80" and fv.data[:1] not in (b"(", b"c", b"]", b"}"):
|
|
257
|
+
return []
|
|
258
|
+
return self._pickle_findings(fv, pickle_globals(fv.data))
|
|
259
|
+
|
|
260
|
+
def _pickle_findings(self, fv: FileView, refs: set[str]) -> list[Finding]:
|
|
261
|
+
dangerous = sorted(r for r in refs if _is_dangerous_global(r))
|
|
262
|
+
if dangerous:
|
|
263
|
+
return [
|
|
264
|
+
build_finding(
|
|
265
|
+
R_PICKLE_DANGER.id,
|
|
266
|
+
[fv.evidence_at_line(None)],
|
|
267
|
+
detail="Imports: " + ", ".join(dangerous[:10]) + ".",
|
|
268
|
+
)
|
|
269
|
+
]
|
|
270
|
+
return [
|
|
271
|
+
build_finding(
|
|
272
|
+
R_PICKLE_INFO.id, [fv.evidence_at_line(None)], detail=f"{len(refs)} distinct global reference(s)."
|
|
273
|
+
)
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def pickle_globals(data: bytes) -> set[str]:
|
|
278
|
+
"""Static extraction of module.name references from a pickle stream (no unpickling)."""
|
|
279
|
+
refs: set[str] = set()
|
|
280
|
+
strings: list[str] = []
|
|
281
|
+
try:
|
|
282
|
+
for opcode, arg, _pos in pickletools.genops(io.BytesIO(data)):
|
|
283
|
+
name = opcode.name
|
|
284
|
+
if name in (
|
|
285
|
+
"SHORT_BINUNICODE",
|
|
286
|
+
"BINUNICODE",
|
|
287
|
+
"UNICODE",
|
|
288
|
+
"BINUNICODE8",
|
|
289
|
+
"SHORT_BINSTRING",
|
|
290
|
+
"BINSTRING",
|
|
291
|
+
"STRING",
|
|
292
|
+
):
|
|
293
|
+
strings.append(str(arg))
|
|
294
|
+
strings = strings[-2:]
|
|
295
|
+
elif name in ("GLOBAL", "INST"):
|
|
296
|
+
mod, _, attr = str(arg).partition(" ")
|
|
297
|
+
refs.add(f"{mod}.{attr}")
|
|
298
|
+
elif name == "STACK_GLOBAL" and len(strings) == 2:
|
|
299
|
+
refs.add(f"{strings[0]}.{strings[1]}")
|
|
300
|
+
if len(refs) > 1000:
|
|
301
|
+
break
|
|
302
|
+
except Exception: # noqa: S110 - malformed/truncated streams: keep what was read
|
|
303
|
+
pass
|
|
304
|
+
return refs
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _is_dangerous_global(ref: str) -> bool:
|
|
308
|
+
module, _, attr = ref.rpartition(".")
|
|
309
|
+
if module in ("builtins", "__builtin__"):
|
|
310
|
+
return attr in _DANGEROUS_BUILTINS
|
|
311
|
+
root = module.split(".")[0]
|
|
312
|
+
return module in _DANGEROUS_MODULES or root in {
|
|
313
|
+
"os",
|
|
314
|
+
"subprocess",
|
|
315
|
+
"socket",
|
|
316
|
+
"pty",
|
|
317
|
+
"ctypes",
|
|
318
|
+
"runpy",
|
|
319
|
+
"webbrowser",
|
|
320
|
+
"requests",
|
|
321
|
+
"urllib",
|
|
322
|
+
"shutil",
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _bounded_decompress(magic: str | None, data: bytes) -> bytes:
|
|
327
|
+
"""Decompress at most MAX_DECOMPRESSED bytes (decompression-bomb guard). Truncation is fine for listing."""
|
|
328
|
+
if magic == "gzip":
|
|
329
|
+
return zlib.decompressobj(16 + zlib.MAX_WBITS).decompress(data, MAX_DECOMPRESSED)
|
|
330
|
+
if magic == "bzip2":
|
|
331
|
+
return bz2.BZ2Decompressor().decompress(data, max_length=MAX_DECOMPRESSED)
|
|
332
|
+
return lzma.LZMADecompressor().decompress(data, max_length=MAX_DECOMPRESSED)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _tar_names(raw: bytes) -> list[str]:
|
|
336
|
+
names: list[str] = []
|
|
337
|
+
try:
|
|
338
|
+
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:") as tf:
|
|
339
|
+
for i, member in enumerate(tf):
|
|
340
|
+
if i >= MAX_ARCHIVE_MEMBERS:
|
|
341
|
+
break
|
|
342
|
+
names.append(member.name + (f" -> {member.linkname}" if member.issym() else ""))
|
|
343
|
+
except tarfile.ReadError:
|
|
344
|
+
pass # truncated by the decompression cap: keep what was listed
|
|
345
|
+
return names
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Static analysis of command strings found in configuration (SURF-019).
|
|
2
|
+
|
|
3
|
+
Commands are tokenized with shlex (never handed to a shell) and checked for download-and-execute,
|
|
4
|
+
encoded payloads, inline interpreter code, reverse shells, LOLBins, hidden execution, and
|
|
5
|
+
on-the-fly package runners. Repository files the command references become evidence-graph edges.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from devguard.analyzers.base import FileView
|
|
15
|
+
from devguard.core.models import Evidence, Finding, Severity
|
|
16
|
+
from devguard.core.redact import make_excerpt
|
|
17
|
+
from devguard.core.safefs import Inventory, normalize_rel
|
|
18
|
+
from devguard.rules.catalog import build_finding, rule
|
|
19
|
+
|
|
20
|
+
_SPLIT_OPS = re.compile(r"\s*(?:&&|\|\||;|\||&(?!>)|\n)\s*")
|
|
21
|
+
_VAR_ROOT = re.compile(
|
|
22
|
+
r"\$\{(workspaceFolder|workspaceRoot|fileWorkspaceFolder|containerWorkspaceFolder|localWorkspaceFolder"
|
|
23
|
+
r"|CLAUDE_PROJECT_DIR|PWD|workspaceFolderBasename)\}|\$(CLAUDE_PROJECT_DIR|PWD)\b|%cd%|\$\(pwd\)"
|
|
24
|
+
)
|
|
25
|
+
_PIPE_SHELL = re.compile(
|
|
26
|
+
r"\b(curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^|\n]*\|\s*(sudo\s+)?"
|
|
27
|
+
r"((ba|z|da|k)?sh|python3?|node|perl|ruby|iex|Invoke-Expression|pwsh|powershell)\b",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
_SUBSHELL_FETCH = re.compile(r"\$\(\s*(curl|wget)\b|<\(\s*(curl|wget)\b|`\s*(curl|wget)\b", re.IGNORECASE)
|
|
31
|
+
_DOWNLOAD_CRADLE = re.compile(
|
|
32
|
+
r"\b(iex|Invoke-Expression)\b.*\b(DownloadString|Net\.WebClient|iwr|irm|Invoke-WebRequest)\b", re.IGNORECASE
|
|
33
|
+
)
|
|
34
|
+
_ENCODED = re.compile(
|
|
35
|
+
r"\s-(e|ec|en|enc|encodedcommand)\s+[A-Za-z0-9+/=]{16,}|base64\s+(-d|--decode|-D)\b|FromBase64String"
|
|
36
|
+
r"|\batob\s*\(|b64decode|Buffer\.from\([^)]*base64",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
_REVSHELL = re.compile(
|
|
40
|
+
r"/dev/tcp/|\bnc(at)?\s+(-[a-zA-Z]+\s+)*-[a-zA-Z]*e\b|\bsocat\b.*\bexec:|\b(ba)?sh\s+-i\s*>&|\bpty\.spawn\b"
|
|
41
|
+
)
|
|
42
|
+
_LOLBIN = re.compile(
|
|
43
|
+
r"\b(mshta|rundll32|regsvr32|bitsadmin|wmic)(\.exe)?\b|\bcertutil(\.exe)?\b.*-urlcache", re.IGNORECASE
|
|
44
|
+
)
|
|
45
|
+
_HIDDEN = re.compile(
|
|
46
|
+
r"\bnohup\b|\bdisown\b|&\s*$|>\s*/dev/null\s+2>&1\s*&|\bstart\s+/(b|min)\b|-w(indowstyle)?\s+h(idden)?\b"
|
|
47
|
+
r"|\bwindowsHide\b|\bsetsid\b",
|
|
48
|
+
re.IGNORECASE,
|
|
49
|
+
)
|
|
50
|
+
_INLINE = re.compile(
|
|
51
|
+
r"\b(node|deno|bun)\s+(-e|--eval|-p|--print)\b|\bpython[\d.]*\s+-c\b|\b(perl|ruby)\s+-e\b|\bphp\s+-r\b"
|
|
52
|
+
r"|\b(pwsh|powershell)(\.exe)?\b.*\s-(c|command)\s",
|
|
53
|
+
re.IGNORECASE,
|
|
54
|
+
)
|
|
55
|
+
_INLINE_CAPABILITY = re.compile(
|
|
56
|
+
r"require\s*\(|\bimport\b|https?://|\beval\b|\bexec\b|child_process|socket|base64|fetch\(|urlopen|"
|
|
57
|
+
r"Invoke-WebRequest|DownloadString|subprocess|os\.system",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
_FETCHERS = {
|
|
61
|
+
"curl",
|
|
62
|
+
"wget",
|
|
63
|
+
"iwr",
|
|
64
|
+
"irm",
|
|
65
|
+
"invoke-webrequest",
|
|
66
|
+
"invoke-restmethod",
|
|
67
|
+
"start-bitstransfer",
|
|
68
|
+
"aria2c",
|
|
69
|
+
}
|
|
70
|
+
_URL = re.compile(r"\b(?:https?|ftp)://[^\s'\"`)<>|]+", re.IGNORECASE)
|
|
71
|
+
_RAW_IP_URL = re.compile(
|
|
72
|
+
r"\b(?:https?|ftp)://(?!127\.|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)"
|
|
73
|
+
r"(\d{1,3}\.){3}\d{1,3}\b"
|
|
74
|
+
)
|
|
75
|
+
_NPM_INSTALL = {"install", "i", "ci", "add"}
|
|
76
|
+
|
|
77
|
+
R_PIPE = rule(
|
|
78
|
+
"DG-CMD-PIPE-SHELL-001",
|
|
79
|
+
"Command executes downloaded content",
|
|
80
|
+
"high",
|
|
81
|
+
"high",
|
|
82
|
+
"command",
|
|
83
|
+
"TC-07.1",
|
|
84
|
+
"The command downloads content and executes it immediately (pipe to a shell/interpreter or a download cradle).",
|
|
85
|
+
"Official installers are sometimes run this way, but never automatically from project configuration.",
|
|
86
|
+
)
|
|
87
|
+
R_ENCODED = rule(
|
|
88
|
+
"DG-CMD-ENCODED-001",
|
|
89
|
+
"Command contains an encoded payload",
|
|
90
|
+
"high",
|
|
91
|
+
"high",
|
|
92
|
+
"command",
|
|
93
|
+
"TC-07.2, TC-08.5",
|
|
94
|
+
"The command decodes base64 or uses an encoded PowerShell command, hiding what it really runs.",
|
|
95
|
+
)
|
|
96
|
+
R_INLINE = rule(
|
|
97
|
+
"DG-CMD-INLINE-CODE-001",
|
|
98
|
+
"Command runs inline interpreter code",
|
|
99
|
+
"medium",
|
|
100
|
+
"medium",
|
|
101
|
+
"command",
|
|
102
|
+
"TC-07.2",
|
|
103
|
+
"The command passes source code directly to an interpreter (node -e, python -c, powershell -c), "
|
|
104
|
+
"and that code uses networking, process, or decoding capabilities.",
|
|
105
|
+
)
|
|
106
|
+
R_REVSHELL = rule(
|
|
107
|
+
"DG-CMD-REVSHELL-001",
|
|
108
|
+
"Command opens a reverse shell",
|
|
109
|
+
"critical",
|
|
110
|
+
"high",
|
|
111
|
+
"command",
|
|
112
|
+
"TC-07.7",
|
|
113
|
+
"The command connects a shell to a remote host.",
|
|
114
|
+
)
|
|
115
|
+
R_LOLBIN = rule(
|
|
116
|
+
"DG-CMD-LOLBIN-001",
|
|
117
|
+
"Command uses a Windows LOLBin",
|
|
118
|
+
"high",
|
|
119
|
+
"medium",
|
|
120
|
+
"command",
|
|
121
|
+
"TC-07.1",
|
|
122
|
+
"The command invokes a Windows system binary commonly abused to fetch or execute payloads "
|
|
123
|
+
"(mshta, rundll32, regsvr32, bitsadmin, wmic, certutil -urlcache).",
|
|
124
|
+
)
|
|
125
|
+
R_FETCH = rule(
|
|
126
|
+
"DG-CMD-FETCH-001",
|
|
127
|
+
"Command downloads content",
|
|
128
|
+
"low",
|
|
129
|
+
"medium",
|
|
130
|
+
"command",
|
|
131
|
+
"TC-07.1",
|
|
132
|
+
"The command downloads content from the network when it runs.",
|
|
133
|
+
"Setup scripts often download pinned tools. Check the URL and what happens to the download.",
|
|
134
|
+
)
|
|
135
|
+
R_RAWIP = rule(
|
|
136
|
+
"DG-CMD-RAW-IP-001",
|
|
137
|
+
"Command contacts a public IP address",
|
|
138
|
+
"medium",
|
|
139
|
+
"medium",
|
|
140
|
+
"command",
|
|
141
|
+
"TC-07.5",
|
|
142
|
+
"The command talks to a hard-coded public IP address rather than a domain name.",
|
|
143
|
+
)
|
|
144
|
+
R_HIDDEN = rule(
|
|
145
|
+
"DG-CMD-HIDDEN-001",
|
|
146
|
+
"Command runs hidden or in the background",
|
|
147
|
+
"medium",
|
|
148
|
+
"medium",
|
|
149
|
+
"command",
|
|
150
|
+
"TC-07.3, TC-07.9",
|
|
151
|
+
"The command detaches into the background or hides its window, so it keeps running unnoticed.",
|
|
152
|
+
"Dev servers and watchers are commonly backgrounded.",
|
|
153
|
+
)
|
|
154
|
+
R_PKG_RUNNER = rule(
|
|
155
|
+
"DG-CMD-PKG-RUNNER-001",
|
|
156
|
+
"Command runs a package fetched on the fly",
|
|
157
|
+
"low",
|
|
158
|
+
"medium",
|
|
159
|
+
"command",
|
|
160
|
+
"TC-11.2, TC-03.2",
|
|
161
|
+
"The command downloads and runs a package from a registry at execution time (npx/uvx/pipx/bunx/dlx). "
|
|
162
|
+
"Whatever the registry serves at that moment is executed.",
|
|
163
|
+
"Common for MCP servers and one-off tools. Verify the exact package name (typosquats) and pin a version.",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class CommandAssessment:
|
|
169
|
+
command: str
|
|
170
|
+
pipe_to_shell: bool = False
|
|
171
|
+
encoded: bool = False
|
|
172
|
+
inline_code: bool = False
|
|
173
|
+
revshell: bool = False
|
|
174
|
+
lolbin: bool = False
|
|
175
|
+
hidden: bool = False
|
|
176
|
+
fetches: list[str] = field(default_factory=list)
|
|
177
|
+
raw_ip: bool = False
|
|
178
|
+
urls: list[str] = field(default_factory=list)
|
|
179
|
+
package_runs: list[str] = field(default_factory=list)
|
|
180
|
+
references: list[str] = field(default_factory=list)
|
|
181
|
+
npm_scripts: list[str] = field(default_factory=list)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def dangerous(self) -> bool:
|
|
185
|
+
return self.pipe_to_shell or self.encoded or self.revshell or self.lolbin
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _tokens(segment: str) -> list[str]:
|
|
189
|
+
try:
|
|
190
|
+
return shlex.split(segment, posix=True)
|
|
191
|
+
except ValueError:
|
|
192
|
+
return segment.split()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def resolve_ref(token: str, inventory: Inventory, base_dirs: list[str]) -> str | None:
|
|
196
|
+
token = token.strip().strip("'\"`")
|
|
197
|
+
if "=" in token and token.startswith("-"):
|
|
198
|
+
token = token.split("=", 1)[1]
|
|
199
|
+
if not token or token.startswith("-") or "://" in token:
|
|
200
|
+
return None
|
|
201
|
+
if _VAR_ROOT.search(token):
|
|
202
|
+
token = _VAR_ROOT.sub("", token).lstrip("/\\")
|
|
203
|
+
candidates = [f"{b}/{token}" if b else token for b in base_dirs] + [token]
|
|
204
|
+
else:
|
|
205
|
+
if token.startswith(("/", "~", "$")) or re.match(r"^[A-Za-z]:\\", token):
|
|
206
|
+
return None
|
|
207
|
+
candidates = [f"{b}/{token}" if b else token for b in base_dirs]
|
|
208
|
+
for cand in candidates:
|
|
209
|
+
norm = normalize_rel(cand)
|
|
210
|
+
if norm and inventory.exists(norm):
|
|
211
|
+
return norm
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def analyze_command(command: str, inventory: Inventory, base_dirs: list[str] | None = None) -> CommandAssessment:
|
|
216
|
+
bases = base_dirs or [""]
|
|
217
|
+
a = CommandAssessment(command=command)
|
|
218
|
+
a.pipe_to_shell = bool(
|
|
219
|
+
_PIPE_SHELL.search(command) or _SUBSHELL_FETCH.search(command) or _DOWNLOAD_CRADLE.search(command)
|
|
220
|
+
)
|
|
221
|
+
a.encoded = bool(_ENCODED.search(command))
|
|
222
|
+
a.revshell = bool(_REVSHELL.search(command))
|
|
223
|
+
a.lolbin = bool(_LOLBIN.search(command))
|
|
224
|
+
a.hidden = bool(_HIDDEN.search(command))
|
|
225
|
+
a.urls = _URL.findall(command)[:10]
|
|
226
|
+
a.raw_ip = bool(_RAW_IP_URL.search(command))
|
|
227
|
+
if _INLINE.search(command) and _INLINE_CAPABILITY.search(command):
|
|
228
|
+
a.inline_code = True
|
|
229
|
+
for segment in _SPLIT_OPS.split(command):
|
|
230
|
+
toks = _tokens(segment)
|
|
231
|
+
if not toks:
|
|
232
|
+
continue
|
|
233
|
+
# strip env assignments and common wrappers
|
|
234
|
+
while toks and (
|
|
235
|
+
re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[0]) or toks[0] in ("sudo", "env", "exec", "cross-env")
|
|
236
|
+
):
|
|
237
|
+
toks = toks[1:]
|
|
238
|
+
if not toks:
|
|
239
|
+
continue
|
|
240
|
+
prog = toks[0].rsplit("/", 1)[-1].rsplit("\\", 1)[-1].lower().removesuffix(".exe")
|
|
241
|
+
if prog in _FETCHERS:
|
|
242
|
+
a.fetches.append(prog)
|
|
243
|
+
_package_runner(prog, toks, a)
|
|
244
|
+
_npm_script_refs(prog, toks, a)
|
|
245
|
+
for tok in toks:
|
|
246
|
+
ref = resolve_ref(tok, inventory, bases)
|
|
247
|
+
if ref:
|
|
248
|
+
a.references.append(ref)
|
|
249
|
+
a.references = sorted(set(a.references))
|
|
250
|
+
a.npm_scripts = sorted(set(a.npm_scripts))
|
|
251
|
+
return a
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _first_positional(args: list[str]) -> str | None:
|
|
255
|
+
skip_next = {"-p", "--package", "--from", "--with", "-c", "--call"}
|
|
256
|
+
i = 0
|
|
257
|
+
while i < len(args):
|
|
258
|
+
arg = args[i]
|
|
259
|
+
if arg in skip_next:
|
|
260
|
+
i += 2
|
|
261
|
+
continue
|
|
262
|
+
if arg.startswith("-"):
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
return arg
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _package_runner(prog: str, toks: list[str], a: CommandAssessment) -> None:
|
|
270
|
+
pkg: str | None = None
|
|
271
|
+
if prog in ("npx", "bunx", "uvx", "pnpx"):
|
|
272
|
+
pkg = _first_positional(toks[1:])
|
|
273
|
+
elif prog == "pipx" and len(toks) > 1 and toks[1] == "run":
|
|
274
|
+
pkg = _first_positional(toks[2:])
|
|
275
|
+
elif prog in ("pnpm", "yarn") and len(toks) > 1 and toks[1] == "dlx":
|
|
276
|
+
pkg = _first_positional(toks[2:])
|
|
277
|
+
elif prog == "npm" and len(toks) > 1 and toks[1] in ("exec", "x"):
|
|
278
|
+
pkg = _first_positional(toks[2:])
|
|
279
|
+
elif prog == "uv" and len(toks) > 2 and toks[1] == "tool" and toks[2] == "run":
|
|
280
|
+
pkg = _first_positional(toks[3:])
|
|
281
|
+
if pkg and not pkg.startswith((".", "/")):
|
|
282
|
+
a.package_runs.append(pkg)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _npm_script_refs(prog: str, toks: list[str], a: CommandAssessment) -> None:
|
|
286
|
+
if prog not in ("npm", "yarn", "pnpm", "bun") or len(toks) < 2:
|
|
287
|
+
if prog in ("yarn", "pnpm") and len(toks) == 1:
|
|
288
|
+
a.npm_scripts.append("@install")
|
|
289
|
+
return
|
|
290
|
+
sub = toks[1]
|
|
291
|
+
if sub in ("run", "run-script") and len(toks) > 2:
|
|
292
|
+
a.npm_scripts.append(toks[2])
|
|
293
|
+
elif sub in _NPM_INSTALL and (len(toks) == 2 or toks[2].startswith("-")):
|
|
294
|
+
a.npm_scripts.append("@install") # lifecycle scripts of the local package run
|
|
295
|
+
elif sub in ("start", "test", "stop", "restart"):
|
|
296
|
+
a.npm_scripts.append(sub)
|
|
297
|
+
elif (
|
|
298
|
+
prog in ("yarn", "pnpm", "bun")
|
|
299
|
+
and not sub.startswith("-")
|
|
300
|
+
and sub not in ("add", "remove", "dlx", "exec", "x", "create", "init", "why", "info", "upgrade")
|
|
301
|
+
):
|
|
302
|
+
a.npm_scripts.append(sub)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def command_findings(
|
|
306
|
+
a: CommandAssessment,
|
|
307
|
+
fv: FileView,
|
|
308
|
+
line: int | None,
|
|
309
|
+
*,
|
|
310
|
+
autorun: bool,
|
|
311
|
+
context: str,
|
|
312
|
+
) -> list[Finding]:
|
|
313
|
+
"""Turn an assessment into findings. Autorun contexts are one severity tier higher."""
|
|
314
|
+
bump = 1 if autorun else 0
|
|
315
|
+
excerpt = make_excerpt(a.command)
|
|
316
|
+
ev = fv.evidence_at_line(line)
|
|
317
|
+
ev = Evidence(path=ev.path, line_start=ev.line_start, line_end=ev.line_end, excerpt=excerpt)
|
|
318
|
+
detail = f"Context: {context}{' (runs automatically)' if autorun else ''}."
|
|
319
|
+
out: list[Finding] = []
|
|
320
|
+
|
|
321
|
+
def add(meta_id: str, base: Severity, extra: str = "") -> None:
|
|
322
|
+
sev = base.bump(bump) if bump else base
|
|
323
|
+
out.append(build_finding(meta_id, [ev], severity=sev, detail=(detail + extra).strip()))
|
|
324
|
+
|
|
325
|
+
if a.revshell:
|
|
326
|
+
add(R_REVSHELL.id, R_REVSHELL.severity)
|
|
327
|
+
if a.pipe_to_shell:
|
|
328
|
+
add(R_PIPE.id, R_PIPE.severity)
|
|
329
|
+
if a.encoded:
|
|
330
|
+
add(R_ENCODED.id, R_ENCODED.severity)
|
|
331
|
+
if a.lolbin:
|
|
332
|
+
add(R_LOLBIN.id, R_LOLBIN.severity)
|
|
333
|
+
if a.inline_code:
|
|
334
|
+
add(R_INLINE.id, R_INLINE.severity)
|
|
335
|
+
if a.raw_ip:
|
|
336
|
+
add(R_RAWIP.id, R_RAWIP.severity)
|
|
337
|
+
if a.fetches and not a.pipe_to_shell:
|
|
338
|
+
add(R_FETCH.id, R_FETCH.severity, f" Tools: {', '.join(sorted(set(a.fetches)))}.")
|
|
339
|
+
if a.hidden and (autorun or a.dangerous or a.fetches):
|
|
340
|
+
add(R_HIDDEN.id, R_HIDDEN.severity)
|
|
341
|
+
if a.package_runs:
|
|
342
|
+
add(R_PKG_RUNNER.id, R_PKG_RUNNER.severity, f" Packages: {', '.join(sorted(set(a.package_runs)))}.")
|
|
343
|
+
return out
|