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,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