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,515 @@
|
|
|
1
|
+
"""VS Code family (VS Code, Cursor, Windsurf, VSCodium) workspace configuration (TC-01)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
13
|
+
from devguard.analyzers.command import resolve_ref
|
|
14
|
+
from devguard.analyzers.jsonc import Node
|
|
15
|
+
from devguard.analyzers.surfaces.ai_assistants import mcp_servers_findings
|
|
16
|
+
from devguard.analyzers.surfaces.common import (
|
|
17
|
+
args_text,
|
|
18
|
+
assess_and_trigger,
|
|
19
|
+
command_text,
|
|
20
|
+
env_findings,
|
|
21
|
+
env_pairs_from_node,
|
|
22
|
+
load_jsonc,
|
|
23
|
+
workspace_root_of,
|
|
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_AUTORUN = rule(
|
|
30
|
+
"DG-VSC-TASK-AUTORUN-001",
|
|
31
|
+
"Task runs automatically when the folder is opened",
|
|
32
|
+
"medium",
|
|
33
|
+
"high",
|
|
34
|
+
"ide-autorun",
|
|
35
|
+
"TC-01.1",
|
|
36
|
+
'The task has `runOptions.runOn: "folderOpen"`, so VS Code runs it as soon as the folder is opened '
|
|
37
|
+
"in a trusted window. This is the most common delivery method of malicious 'interview' repositories.",
|
|
38
|
+
"Some projects start watchers or dev servers on open. Check the command and everything it references.",
|
|
39
|
+
"Do not open this folder in a trusted VS Code window until the task command is reviewed.",
|
|
40
|
+
)
|
|
41
|
+
R_HIDDEN_TASK = rule(
|
|
42
|
+
"DG-VSC-TASK-HIDDEN-001",
|
|
43
|
+
"Automatic task hides its terminal output",
|
|
44
|
+
"high",
|
|
45
|
+
"medium",
|
|
46
|
+
"ide-autorun",
|
|
47
|
+
"TC-01.1, TC-07.9",
|
|
48
|
+
"An automatically started task suppresses its terminal (reveal: never / echo: false / close: true), "
|
|
49
|
+
"so the user does not see what it runs.",
|
|
50
|
+
)
|
|
51
|
+
R_OSDIFF = rule(
|
|
52
|
+
"DG-VSC-TASK-OSDIFF-001",
|
|
53
|
+
"Task runs a different program per operating system",
|
|
54
|
+
"low",
|
|
55
|
+
"medium",
|
|
56
|
+
"ide-autorun",
|
|
57
|
+
"TC-01.1",
|
|
58
|
+
"The task's windows/linux/osx override runs a different program than the base command, which can "
|
|
59
|
+
"hide a platform-specific payload from reviewers on another OS.",
|
|
60
|
+
"Cross-platform tasks often switch between `.cmd` and `.sh` wrappers.",
|
|
61
|
+
)
|
|
62
|
+
R_SETTING_EXEC = rule(
|
|
63
|
+
"DG-VSC-SETTING-EXEC-001",
|
|
64
|
+
"Workspace setting points an extension at a program in the repository",
|
|
65
|
+
"high",
|
|
66
|
+
"medium",
|
|
67
|
+
"ide-autorun",
|
|
68
|
+
"TC-01.3",
|
|
69
|
+
"A workspace setting tells an extension to run an interpreter, language server, or tool that is "
|
|
70
|
+
"committed inside this repository. The extension starts it automatically when the workspace is trusted.",
|
|
71
|
+
"Usually such settings point to `.venv` or `node_modules`, which are not committed. A committed target is unusual.",
|
|
72
|
+
)
|
|
73
|
+
R_SETTING_CMD = rule(
|
|
74
|
+
"DG-VSC-SETTING-CMD-001",
|
|
75
|
+
"Workspace setting defines a command run by an extension",
|
|
76
|
+
"low",
|
|
77
|
+
"medium",
|
|
78
|
+
"ide-autorun",
|
|
79
|
+
"TC-01.3",
|
|
80
|
+
"A workspace setting overrides a command that an extension runs (for example rust-analyzer build "
|
|
81
|
+
"script or Jest command line).",
|
|
82
|
+
)
|
|
83
|
+
R_SETTING_AUTOTASKS = rule(
|
|
84
|
+
"DG-VSC-SETTING-AUTOTASKS-001",
|
|
85
|
+
"Workspace tries to allow automatic tasks",
|
|
86
|
+
"medium",
|
|
87
|
+
"high",
|
|
88
|
+
"ide-autorun",
|
|
89
|
+
"TC-01.1, TC-14.2",
|
|
90
|
+
"`task.allowAutomaticTasks` is set in workspace settings, an attempt to make folder-open tasks run "
|
|
91
|
+
"without a prompt. (Recent VS Code versions only honour it in user settings.)",
|
|
92
|
+
)
|
|
93
|
+
R_SETTING_TRUST = rule(
|
|
94
|
+
"DG-VSC-SETTING-TRUST-001",
|
|
95
|
+
"Workspace changes security-related settings",
|
|
96
|
+
"medium",
|
|
97
|
+
"high",
|
|
98
|
+
"ide-autorun",
|
|
99
|
+
"TC-14.2",
|
|
100
|
+
"Workspace settings touch workspace-trust or extension-security options. A project has no reason to change these.",
|
|
101
|
+
)
|
|
102
|
+
R_SETTING_HIDE = rule(
|
|
103
|
+
"DG-VSC-SETTING-HIDE-001",
|
|
104
|
+
"Workspace hides repository code files from the editor",
|
|
105
|
+
"medium",
|
|
106
|
+
"medium",
|
|
107
|
+
"ide-autorun",
|
|
108
|
+
"TC-01.3, TC-12.3",
|
|
109
|
+
"`files.exclude` or `search.exclude` hides code files that exist in this repository, so they do not "
|
|
110
|
+
"show up in the explorer or search. Attackers use this to hide payload files.",
|
|
111
|
+
"Generated code is sometimes hidden on purpose.",
|
|
112
|
+
)
|
|
113
|
+
R_EXT_RECOMMEND = rule(
|
|
114
|
+
"DG-VSC-EXT-RECOMMEND-001",
|
|
115
|
+
"Workspace recommends extensions",
|
|
116
|
+
"info",
|
|
117
|
+
"high",
|
|
118
|
+
"ide-autorun",
|
|
119
|
+
"TC-01.4",
|
|
120
|
+
"The workspace recommends installing extensions. Extensions run with full user privileges; verify "
|
|
121
|
+
"each publisher and ID (watch for look-alike names) before installing.",
|
|
122
|
+
)
|
|
123
|
+
R_LAUNCH_RUNTIME = rule(
|
|
124
|
+
"DG-VSC-LAUNCH-RUNTIME-001",
|
|
125
|
+
"Debug configuration uses a runtime committed in the repository",
|
|
126
|
+
"medium",
|
|
127
|
+
"medium",
|
|
128
|
+
"ide-config",
|
|
129
|
+
"TC-01.2",
|
|
130
|
+
"A launch configuration's `runtimeExecutable` points to a file inside the repository instead of an "
|
|
131
|
+
"installed runtime, so starting the debugger runs that file.",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
_CODE_LANGS = {"javascript", "python", "shell", "powershell", "batch", "vbscript", "ruby", "php", "perl"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@cache
|
|
138
|
+
def _settings_data() -> dict[str, Any]:
|
|
139
|
+
raw = resources.files("devguard.rules").joinpath("data/vscode_settings.yaml").read_text("utf-8")
|
|
140
|
+
return yaml.safe_load(raw)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_exec_key(key: str) -> bool:
|
|
144
|
+
d = _settings_data()
|
|
145
|
+
return key in d["executable_keys"] or any(key.endswith(s) for s in d["executable_suffixes"])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_cmd_key(key: str) -> bool:
|
|
149
|
+
d = _settings_data()
|
|
150
|
+
return key in d["command_keys"] or any(key.endswith(s) for s in d["command_suffixes"])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class VSCodeAnalyzer:
|
|
154
|
+
name = "vscode"
|
|
155
|
+
|
|
156
|
+
def applies(self, fv: FileView) -> bool:
|
|
157
|
+
if fv.text is None:
|
|
158
|
+
return False
|
|
159
|
+
rel = fv.rel
|
|
160
|
+
return fv.suffix == ".code-workspace" or (
|
|
161
|
+
"/.vscode/" in f"/{rel}" and fv.name in ("tasks.json", "launch.json", "settings.json", "extensions.json")
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
165
|
+
out: list[Finding] = []
|
|
166
|
+
root = load_jsonc(fv, ctx, out)
|
|
167
|
+
if root is None or not isinstance(root.value, dict):
|
|
168
|
+
return out
|
|
169
|
+
base = [workspace_root_of(fv.rel)]
|
|
170
|
+
if fv.suffix == ".code-workspace":
|
|
171
|
+
base = [fv.parent]
|
|
172
|
+
for folder in root.list_at("folders"):
|
|
173
|
+
path = folder.get("path")
|
|
174
|
+
if path is not None and isinstance(path.value, str):
|
|
175
|
+
base.append(f"{fv.parent}/{path.value}" if fv.parent else path.value)
|
|
176
|
+
if (s := root.get("settings")) is not None:
|
|
177
|
+
out += self._settings(s, fv, ctx, base)
|
|
178
|
+
if (t := root.get("tasks")) is not None:
|
|
179
|
+
out += self._tasks(t, fv, ctx, base)
|
|
180
|
+
if (lc := root.get("launch")) is not None:
|
|
181
|
+
out += self._launch(lc, fv, ctx, base)
|
|
182
|
+
if (e := root.get("extensions")) is not None:
|
|
183
|
+
out += self._extensions(e, fv)
|
|
184
|
+
return out
|
|
185
|
+
if fv.name == "tasks.json":
|
|
186
|
+
out += self._tasks(root, fv, ctx, base)
|
|
187
|
+
elif fv.name == "launch.json":
|
|
188
|
+
out += self._launch(root, fv, ctx, base)
|
|
189
|
+
elif fv.name == "settings.json":
|
|
190
|
+
out += self._settings(root, fv, ctx, base)
|
|
191
|
+
elif fv.name == "extensions.json":
|
|
192
|
+
out += self._extensions(root, fv)
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------ tasks
|
|
196
|
+
def _tasks(self, root: Node, fv: FileView, ctx: AnalysisContext, base: list[str]) -> list[Finding]:
|
|
197
|
+
out: list[Finding] = []
|
|
198
|
+
tasks = root.list_at("tasks")
|
|
199
|
+
by_label: dict[str, Node] = {}
|
|
200
|
+
for t in tasks:
|
|
201
|
+
label_of = t.get("label")
|
|
202
|
+
if label_of is not None and isinstance(label_of.value, str):
|
|
203
|
+
by_label[label_of.value] = t
|
|
204
|
+
autorun: set[int] = set()
|
|
205
|
+
pending = [t for t in tasks if _run_on(t) == "folderOpen"]
|
|
206
|
+
while pending:
|
|
207
|
+
t = pending.pop()
|
|
208
|
+
if id(t) in autorun:
|
|
209
|
+
continue
|
|
210
|
+
autorun.add(id(t))
|
|
211
|
+
deps = t.get("dependsOn")
|
|
212
|
+
dep_labels = (
|
|
213
|
+
[deps.value]
|
|
214
|
+
if deps and isinstance(deps.value, str)
|
|
215
|
+
else [d.value for d in (deps.elements() if deps else []) if isinstance(d.value, str)]
|
|
216
|
+
)
|
|
217
|
+
pending.extend(by_label[d] for d in dep_labels if d in by_label)
|
|
218
|
+
|
|
219
|
+
for idx, t in enumerate(tasks):
|
|
220
|
+
is_auto = id(t) in autorun
|
|
221
|
+
label_node = t.get("label")
|
|
222
|
+
label = label_node.value if label_node and isinstance(label_node.value, str) else f"task #{idx + 1}"
|
|
223
|
+
commands = _task_commands(t)
|
|
224
|
+
if is_auto:
|
|
225
|
+
shown = "; ".join(make_excerpt(c, limit=120) for _, c, _ in commands) or "(no command)"
|
|
226
|
+
direct = _run_on(t) == "folderOpen"
|
|
227
|
+
out.append(
|
|
228
|
+
build_finding(
|
|
229
|
+
R_AUTORUN.id,
|
|
230
|
+
[fv.evidence_at_line(t.line)],
|
|
231
|
+
detail=f"Task {make_excerpt(label, limit=80)!r}"
|
|
232
|
+
f"{'' if direct else ' (dependency of a folder-open task)'} runs: {shown}.",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
if _hides_output(t):
|
|
236
|
+
out.append(
|
|
237
|
+
build_finding(
|
|
238
|
+
R_HIDDEN_TASK.id,
|
|
239
|
+
[fv.evidence_at_line(t.line)],
|
|
240
|
+
detail=f"Task {make_excerpt(label, limit=80)!r}.",
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
base_prog = _program(commands[0][1]) if commands and commands[0][0] == "base" else None
|
|
244
|
+
for scope, cmd, line in commands:
|
|
245
|
+
if scope != "base" and base_prog and _program(cmd) != base_prog:
|
|
246
|
+
out.append(
|
|
247
|
+
build_finding(
|
|
248
|
+
R_OSDIFF.id,
|
|
249
|
+
[fv.evidence_at_line(line)],
|
|
250
|
+
severity=Severity.MEDIUM if is_auto else None,
|
|
251
|
+
detail=f"Base program {base_prog!r}, {scope} program {_program(cmd)!r}.",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
out += assess_and_trigger(
|
|
255
|
+
ctx,
|
|
256
|
+
fv,
|
|
257
|
+
cmd,
|
|
258
|
+
line,
|
|
259
|
+
kind="vscode-task",
|
|
260
|
+
label=label,
|
|
261
|
+
autorun=is_auto,
|
|
262
|
+
context=f"VS Code task {make_excerpt(label, limit=60)!r} ({scope})",
|
|
263
|
+
base_dirs=base,
|
|
264
|
+
)
|
|
265
|
+
opts = t.get("options")
|
|
266
|
+
if opts is not None and (env := opts.get("env")) is not None:
|
|
267
|
+
out += env_findings(
|
|
268
|
+
ctx, fv, env_pairs_from_node(env), context=f"task {label!r} env", autorun=is_auto, base_dirs=base
|
|
269
|
+
)
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------ launch
|
|
273
|
+
def _launch(self, root: Node, fv: FileView, ctx: AnalysisContext, base: list[str]) -> list[Finding]:
|
|
274
|
+
out: list[Finding] = []
|
|
275
|
+
configs = root.list_at("configurations")
|
|
276
|
+
for c in configs:
|
|
277
|
+
name_node = c.get("name")
|
|
278
|
+
name = name_node.value if name_node and isinstance(name_node.value, str) else "debug config"
|
|
279
|
+
runtime = c.get("runtimeExecutable")
|
|
280
|
+
if runtime is not None and isinstance(runtime.value, str):
|
|
281
|
+
ref = resolve_ref(runtime.value, ctx.inventory, base)
|
|
282
|
+
if ref:
|
|
283
|
+
out.append(
|
|
284
|
+
build_finding(
|
|
285
|
+
R_LAUNCH_RUNTIME.id, [fv.evidence_at_line(runtime.line)], detail=f"{name!r} uses {ref}."
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
cmd = " ".join([runtime.value, *args_text(c.get("runtimeArgs"))])
|
|
289
|
+
out += assess_and_trigger(
|
|
290
|
+
ctx,
|
|
291
|
+
fv,
|
|
292
|
+
cmd,
|
|
293
|
+
runtime.line,
|
|
294
|
+
kind="vscode-launch",
|
|
295
|
+
label=name,
|
|
296
|
+
autorun=False,
|
|
297
|
+
context=f"debug configuration {name!r}",
|
|
298
|
+
base_dirs=base,
|
|
299
|
+
)
|
|
300
|
+
program = c.get("program")
|
|
301
|
+
if program is not None and isinstance(program.value, str):
|
|
302
|
+
ref = resolve_ref(program.value, ctx.inventory, base)
|
|
303
|
+
ctx.add_trigger(
|
|
304
|
+
"vscode-launch",
|
|
305
|
+
fv.rel,
|
|
306
|
+
line=program.line,
|
|
307
|
+
label=name,
|
|
308
|
+
command=make_excerpt(program.value),
|
|
309
|
+
autorun=False,
|
|
310
|
+
references=[ref] if ref else [],
|
|
311
|
+
)
|
|
312
|
+
if (env := c.get("env")) is not None:
|
|
313
|
+
out += env_findings(
|
|
314
|
+
ctx,
|
|
315
|
+
fv,
|
|
316
|
+
env_pairs_from_node(env),
|
|
317
|
+
context=f"debug configuration {name!r}",
|
|
318
|
+
autorun=False,
|
|
319
|
+
base_dirs=base,
|
|
320
|
+
)
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
# ------------------------------------------------------------------ settings
|
|
324
|
+
def _settings(self, root: Node, fv: FileView, ctx: AnalysisContext, base: list[str]) -> list[Finding]:
|
|
325
|
+
out: list[Finding] = []
|
|
326
|
+
for key, node in _flatten_settings(root):
|
|
327
|
+
if key == "task.allowAutomaticTasks" and node.value in ("on", True):
|
|
328
|
+
out.append(build_finding(R_SETTING_AUTOTASKS.id, [fv.evidence_at_line(node.line)]))
|
|
329
|
+
elif key.startswith(
|
|
330
|
+
(
|
|
331
|
+
"security.workspace.trust",
|
|
332
|
+
"extensions.verifySignature",
|
|
333
|
+
"extensions.autoUpdate",
|
|
334
|
+
"chat.tools.autoApprove",
|
|
335
|
+
"chat.tools.global.autoApprove",
|
|
336
|
+
)
|
|
337
|
+
):
|
|
338
|
+
out.append(
|
|
339
|
+
build_finding(R_SETTING_TRUST.id, [fv.evidence_at_line(node.line)], detail=f"Setting {key!r}.")
|
|
340
|
+
)
|
|
341
|
+
elif key in ("files.exclude", "search.exclude", "explorer.excludeGitIgnore"):
|
|
342
|
+
out += self._hidden_files(key, node, fv, ctx)
|
|
343
|
+
elif key.startswith("terminal.integrated.env."):
|
|
344
|
+
out += env_findings(
|
|
345
|
+
ctx, fv, env_pairs_from_node(node), context=f"setting {key}", autorun=False, base_dirs=base
|
|
346
|
+
)
|
|
347
|
+
elif key.startswith("terminal.integrated.profiles."):
|
|
348
|
+
for pname, prof in node.items():
|
|
349
|
+
path = prof.get("path")
|
|
350
|
+
cmd_parts = []
|
|
351
|
+
if path is not None:
|
|
352
|
+
cmd_parts.append(command_text(path) or "")
|
|
353
|
+
cmd_parts += args_text(prof.get("args"))
|
|
354
|
+
if any(cmd_parts):
|
|
355
|
+
out += assess_and_trigger(
|
|
356
|
+
ctx,
|
|
357
|
+
fv,
|
|
358
|
+
" ".join(cmd_parts),
|
|
359
|
+
prof.line,
|
|
360
|
+
kind="vscode-terminal",
|
|
361
|
+
label=pname,
|
|
362
|
+
autorun=False,
|
|
363
|
+
context=f"terminal profile {pname!r}",
|
|
364
|
+
base_dirs=base,
|
|
365
|
+
)
|
|
366
|
+
elif key == "mcp" and (servers := node.get("servers")) is not None:
|
|
367
|
+
out += mcp_servers_findings(servers, fv, ctx, base, client="VS Code settings")
|
|
368
|
+
elif _is_cmd_key(key):
|
|
369
|
+
cmd = command_text(node)
|
|
370
|
+
if cmd:
|
|
371
|
+
out.append(
|
|
372
|
+
build_finding(
|
|
373
|
+
R_SETTING_CMD.id,
|
|
374
|
+
[fv.evidence_at_line(node.line)],
|
|
375
|
+
detail=f"{key} = {make_excerpt(cmd, limit=120)}",
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
out += assess_and_trigger(
|
|
379
|
+
ctx,
|
|
380
|
+
fv,
|
|
381
|
+
cmd,
|
|
382
|
+
node.line,
|
|
383
|
+
kind="vscode-setting",
|
|
384
|
+
label=key,
|
|
385
|
+
autorun=True,
|
|
386
|
+
context=f"setting {key}",
|
|
387
|
+
base_dirs=base,
|
|
388
|
+
)
|
|
389
|
+
elif _is_exec_key(key):
|
|
390
|
+
out += self._exec_setting(key, node, fv, ctx, base)
|
|
391
|
+
return out
|
|
392
|
+
|
|
393
|
+
def _exec_setting(self, key: str, node: Node, fv: FileView, ctx: AnalysisContext, base: list[str]) -> list[Finding]:
|
|
394
|
+
values: list[tuple[str, int]] = []
|
|
395
|
+
if isinstance(node.value, str):
|
|
396
|
+
values.append((node.value, node.line))
|
|
397
|
+
elif isinstance(node.value, list):
|
|
398
|
+
values += [(e.value, e.line) for e in node.elements() if isinstance(e.value, str)]
|
|
399
|
+
elif isinstance(node.value, dict):
|
|
400
|
+
values += [(v.value, v.line) for _, v in node.items() if isinstance(v.value, str)]
|
|
401
|
+
out: list[Finding] = []
|
|
402
|
+
for value, line in values:
|
|
403
|
+
ref = resolve_ref(value, ctx.inventory, base)
|
|
404
|
+
if ref is None:
|
|
405
|
+
continue
|
|
406
|
+
out.append(build_finding(R_SETTING_EXEC.id, [fv.evidence_at_line(line)], detail=f"{key} → {ref}."))
|
|
407
|
+
ctx.add_trigger(
|
|
408
|
+
"vscode-setting-exec",
|
|
409
|
+
fv.rel,
|
|
410
|
+
line=line,
|
|
411
|
+
label=key,
|
|
412
|
+
command=make_excerpt(value),
|
|
413
|
+
autorun=True,
|
|
414
|
+
references=[ref],
|
|
415
|
+
)
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
def _hidden_files(self, key: str, node: Node, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
419
|
+
benign = set(_settings_data()["benign_exclude_patterns"])
|
|
420
|
+
out: list[Finding] = []
|
|
421
|
+
for pattern, val in node.items():
|
|
422
|
+
if val.value is not True or pattern in benign:
|
|
423
|
+
continue
|
|
424
|
+
glob = pattern.replace("**/", "*").replace("/**", "/*")
|
|
425
|
+
hidden = [
|
|
426
|
+
r.rel
|
|
427
|
+
for r in ctx.inventory.files
|
|
428
|
+
if (fnmatch.fnmatchcase(r.rel, glob) or fnmatch.fnmatchcase(r.rel, glob + "/*"))
|
|
429
|
+
and _looks_like_code_path(r.rel)
|
|
430
|
+
]
|
|
431
|
+
if hidden:
|
|
432
|
+
shown = ", ".join(hidden[:5]) + (" …" if len(hidden) > 5 else "")
|
|
433
|
+
out.append(
|
|
434
|
+
build_finding(
|
|
435
|
+
R_SETTING_HIDE.id,
|
|
436
|
+
[fv.evidence_at_line(val.line)],
|
|
437
|
+
detail=f"{key} pattern {pattern!r} hides: {shown}.",
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
# ------------------------------------------------------------------ extensions
|
|
443
|
+
def _extensions(self, root: Node, fv: FileView) -> list[Finding]:
|
|
444
|
+
recs = root.get("recommendations")
|
|
445
|
+
ids = [e.value for e in (recs.elements() if recs else []) if isinstance(e.value, str)]
|
|
446
|
+
if not ids:
|
|
447
|
+
return []
|
|
448
|
+
shown = ", ".join(make_excerpt(i, limit=80) for i in ids[:15])
|
|
449
|
+
return [
|
|
450
|
+
build_finding(
|
|
451
|
+
R_EXT_RECOMMEND.id, [fv.evidence_at_line(recs.line if recs else None)], detail=f"Recommended: {shown}."
|
|
452
|
+
)
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _run_on(task: Node) -> str | None:
|
|
457
|
+
ro = task.get("runOptions")
|
|
458
|
+
run_on = ro.get("runOn") if ro is not None else None
|
|
459
|
+
return run_on.value if run_on is not None and isinstance(run_on.value, str) else None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _hides_output(task: Node) -> bool:
|
|
463
|
+
pres = task.get("presentation")
|
|
464
|
+
if pres is None:
|
|
465
|
+
return False
|
|
466
|
+
reveal = pres.get("reveal")
|
|
467
|
+
echo = pres.get("echo")
|
|
468
|
+
close = pres.get("close")
|
|
469
|
+
return (
|
|
470
|
+
(reveal is not None and reveal.value == "never")
|
|
471
|
+
or (echo is not None and echo.value is False)
|
|
472
|
+
or (close is not None and close.value is True)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _task_commands(task: Node) -> list[tuple[str, str, int]]:
|
|
477
|
+
out: list[tuple[str, str, int]] = []
|
|
478
|
+
ttype = task.get("type")
|
|
479
|
+
if ttype is not None and ttype.value == "npm":
|
|
480
|
+
script = task.get("script")
|
|
481
|
+
if script is not None and isinstance(script.value, str):
|
|
482
|
+
out.append(("base", f"npm run {script.value}", script.line))
|
|
483
|
+
for scope, node in [("base", task), *[(o, task.get(o)) for o in ("windows", "linux", "osx")]]:
|
|
484
|
+
if node is None:
|
|
485
|
+
continue
|
|
486
|
+
cmd_node = node.get("command")
|
|
487
|
+
cmd = command_text(cmd_node)
|
|
488
|
+
if cmd is None:
|
|
489
|
+
continue
|
|
490
|
+
full = " ".join([cmd, *args_text(node.get("args"))])
|
|
491
|
+
out.append((scope, full, cmd_node.line if cmd_node else node.line))
|
|
492
|
+
return out
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _program(cmd: str) -> str:
|
|
496
|
+
first = cmd.strip().split()[0] if cmd.strip() else ""
|
|
497
|
+
return first.rsplit("/", 1)[-1].rsplit("\\", 1)[-1].lower()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _flatten_settings(root: Node) -> list[tuple[str, Node]]:
|
|
501
|
+
out: list[tuple[str, Node]] = []
|
|
502
|
+
for key, node in root.items():
|
|
503
|
+
if key.startswith("[") and key.endswith("]"): # language-specific block
|
|
504
|
+
out.extend(_flatten_settings(node))
|
|
505
|
+
else:
|
|
506
|
+
out.append((key, node))
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _looks_like_code_path(rel: str) -> bool:
|
|
511
|
+
from devguard.core.filetype import language_for
|
|
512
|
+
|
|
513
|
+
return language_for(rel, None) in _CODE_LANGS or rel.endswith(
|
|
514
|
+
(".exe", ".dll", ".so", ".dylib", ".woff", ".woff2", ".ttf")
|
|
515
|
+
)
|