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,102 @@
|
|
|
1
|
+
"""Static file-to-file references for multi-hop correlation (COR-003).
|
|
2
|
+
|
|
3
|
+
Only literal relative paths are resolved; nothing is evaluated. Besides imports this records files that
|
|
4
|
+
code reads (e.g. a loader that reads `./assets/x.woff` and evals it) because payloads are often stored
|
|
5
|
+
in a second, innocent-looking file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
13
|
+
from devguard.core.models import Finding
|
|
14
|
+
from devguard.core.safefs import normalize_rel
|
|
15
|
+
|
|
16
|
+
_JS_PATTERNS = [
|
|
17
|
+
re.compile(r"""\brequire\s*\(\s*['"`](\.{1,2}/[^'"`\n]+)['"`]\s*\)"""),
|
|
18
|
+
re.compile(r"""\bimport\s+(?:[^'"`;]*?\s+from\s+)?['"`](\.{1,2}/[^'"`\n]+)['"`]"""),
|
|
19
|
+
re.compile(r"""\bimport\s*\(\s*['"`](\.{1,2}/[^'"`\n]+)['"`]\s*\)"""),
|
|
20
|
+
re.compile(r"""\bexport\s+[^'"`;]*?\s+from\s+['"`](\.{1,2}/[^'"`\n]+)['"`]"""),
|
|
21
|
+
re.compile(
|
|
22
|
+
r"""\b(?:readFileSync|readFile|createReadStream)\s*\(\s*(?:path\.(?:join|resolve)\(\s*__dirname\s*,\s*)?['"`](\.{0,2}/?[\w@][^'"`\n]*\.\w+)['"`]"""
|
|
23
|
+
),
|
|
24
|
+
]
|
|
25
|
+
_PY_PATTERNS = [
|
|
26
|
+
re.compile(r"^\s*from\s+(\.+)([\w.]*)\s+import\s+([\w, ]+)", re.MULTILINE),
|
|
27
|
+
re.compile(r"""\b(?:open|run_path|exec_file)\s*\(\s*['"]([^'"\n]+\.\w+)['"]"""),
|
|
28
|
+
]
|
|
29
|
+
_SH_PATTERN = re.compile(
|
|
30
|
+
r"(?:^|[;&|(]\s*|\bthen\s+|\bdo\s+)(?:source|\.|bash|sh|zsh|node|python3?|pwsh|powershell)\s+['\"]?([\w./-]+\.\w+)",
|
|
31
|
+
re.MULTILINE,
|
|
32
|
+
)
|
|
33
|
+
_JS_EXTS = (
|
|
34
|
+
"",
|
|
35
|
+
".js",
|
|
36
|
+
".cjs",
|
|
37
|
+
".mjs",
|
|
38
|
+
".ts",
|
|
39
|
+
".tsx",
|
|
40
|
+
".jsx",
|
|
41
|
+
".json",
|
|
42
|
+
"/index.js",
|
|
43
|
+
"/index.ts",
|
|
44
|
+
"/index.cjs",
|
|
45
|
+
"/index.mjs",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ImportCollector:
|
|
50
|
+
name = "imports"
|
|
51
|
+
|
|
52
|
+
def applies(self, fv: FileView) -> bool:
|
|
53
|
+
return fv.text is not None and fv.lang in ("javascript", "python", "shell", "powershell", "batch")
|
|
54
|
+
|
|
55
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
56
|
+
assert fv.text is not None
|
|
57
|
+
text = fv.text[: ctx.config.limits.max_regex_text]
|
|
58
|
+
refs: set[str] = set()
|
|
59
|
+
if fv.lang == "javascript":
|
|
60
|
+
for pat in _JS_PATTERNS:
|
|
61
|
+
for m in pat.finditer(text):
|
|
62
|
+
refs |= _resolve(ctx, fv.parent, m.group(1), _JS_EXTS)
|
|
63
|
+
elif fv.lang == "python":
|
|
64
|
+
for m in _PY_PATTERNS[0].finditer(text):
|
|
65
|
+
dots, mod, names = m.group(1), m.group(2), m.group(3)
|
|
66
|
+
base = fv.parent
|
|
67
|
+
for _ in range(len(dots) - 1):
|
|
68
|
+
base = base.rsplit("/", 1)[0] if "/" in base else ""
|
|
69
|
+
mod_path = mod.replace(".", "/")
|
|
70
|
+
cands = [mod_path] if mod_path else [n.strip() for n in names.split(",")]
|
|
71
|
+
for c in cands:
|
|
72
|
+
refs |= _resolve(ctx, base, c, (".py", "/__init__.py"))
|
|
73
|
+
for m in _PY_PATTERNS[1].finditer(text):
|
|
74
|
+
refs |= _resolve(ctx, fv.parent, m.group(1), ("",))
|
|
75
|
+
# Scripts usually run with the repository (or package) root as working directory, not their own dir.
|
|
76
|
+
sh_bases = [fv.parent, _repo_root_of(fv.rel), ""]
|
|
77
|
+
for m in _SH_PATTERN.finditer(text):
|
|
78
|
+
for base in sh_bases:
|
|
79
|
+
found = _resolve(ctx, base, m.group(1), ("",))
|
|
80
|
+
if found:
|
|
81
|
+
refs |= found
|
|
82
|
+
break
|
|
83
|
+
refs.discard(fv.rel)
|
|
84
|
+
if refs:
|
|
85
|
+
ctx.imports[fv.rel] = sorted(refs)
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve(ctx: AnalysisContext, base: str, spec: str, exts: tuple[str, ...]) -> set[str]:
|
|
90
|
+
spec = spec.split("?", 1)[0]
|
|
91
|
+
for ext in exts:
|
|
92
|
+
cand = normalize_rel(f"{base}/{spec}{ext}" if base else f"{spec}{ext}")
|
|
93
|
+
if cand and ctx.inventory.exists(cand):
|
|
94
|
+
return {cand}
|
|
95
|
+
return set()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _repo_root_of(rel: str) -> str:
|
|
99
|
+
"""Directory containing `.git` for files inside it (hooks), else ""."""
|
|
100
|
+
if "/.git/" in f"/{rel}":
|
|
101
|
+
return rel.split(".git/", 1)[0].rstrip("/")
|
|
102
|
+
return ""
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Small JSONC parser (JSON + comments + trailing commas) that keeps line numbers and duplicate keys.
|
|
2
|
+
|
|
3
|
+
Duplicate keys matter: VS Code uses the last value, so an attacker can put a harmless value first
|
|
4
|
+
and a malicious one later (TC-01.6). Never evaluates anything; depth and size are bounded (SAFE-007).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsoncError(ValueError):
|
|
14
|
+
def __init__(self, message: str, line: int) -> None:
|
|
15
|
+
super().__init__(f"{message} (line {line})")
|
|
16
|
+
self.line = line
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Node:
|
|
21
|
+
value: Any # dict[str, Node] | list[Node] | str | int | float | bool | None
|
|
22
|
+
line: int
|
|
23
|
+
duplicates: list[tuple[str, int, int]] = field(default_factory=list) # (key, first line, dup line)
|
|
24
|
+
|
|
25
|
+
def get(self, key: str) -> Node | None:
|
|
26
|
+
if isinstance(self.value, dict):
|
|
27
|
+
return self.value.get(key)
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def items(self) -> list[tuple[str, Node]]:
|
|
31
|
+
return list(self.value.items()) if isinstance(self.value, dict) else []
|
|
32
|
+
|
|
33
|
+
def elements(self) -> list[Node]:
|
|
34
|
+
return list(self.value) if isinstance(self.value, list) else []
|
|
35
|
+
|
|
36
|
+
def list_at(self, key: str) -> list[Node]:
|
|
37
|
+
"""Elements of the array under `key`, or [] if missing / not an array."""
|
|
38
|
+
child = self.get(key)
|
|
39
|
+
return child.elements() if child is not None else []
|
|
40
|
+
|
|
41
|
+
def str_value(self) -> str | None:
|
|
42
|
+
return self.value if isinstance(self.value, str) else None
|
|
43
|
+
|
|
44
|
+
def plain(self) -> Any:
|
|
45
|
+
if isinstance(self.value, dict):
|
|
46
|
+
return {k: v.plain() for k, v in self.value.items()}
|
|
47
|
+
if isinstance(self.value, list):
|
|
48
|
+
return [v.plain() for v in self.value]
|
|
49
|
+
return self.value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
MAX_DEPTH = 64
|
|
53
|
+
_ESC = {'"': '"', "\\": "\\", "/": "/", "b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Parser:
|
|
57
|
+
def __init__(self, text: str) -> None:
|
|
58
|
+
self.s = text
|
|
59
|
+
self.i = 0
|
|
60
|
+
self.line = 1
|
|
61
|
+
self.n = len(text)
|
|
62
|
+
self.duplicates: list[tuple[str, int, int]] = []
|
|
63
|
+
|
|
64
|
+
def error(self, msg: str) -> JsoncError:
|
|
65
|
+
return JsoncError(msg, self.line)
|
|
66
|
+
|
|
67
|
+
def ws(self) -> None:
|
|
68
|
+
s, n = self.s, self.n
|
|
69
|
+
while self.i < n:
|
|
70
|
+
c = s[self.i]
|
|
71
|
+
if c == "\n":
|
|
72
|
+
self.line += 1
|
|
73
|
+
self.i += 1
|
|
74
|
+
elif c in " \t\r\ufeff":
|
|
75
|
+
self.i += 1
|
|
76
|
+
elif s.startswith("//", self.i):
|
|
77
|
+
end = s.find("\n", self.i)
|
|
78
|
+
self.i = n if end == -1 else end
|
|
79
|
+
elif s.startswith("/*", self.i):
|
|
80
|
+
end = s.find("*/", self.i + 2)
|
|
81
|
+
if end == -1:
|
|
82
|
+
raise self.error("unterminated comment")
|
|
83
|
+
self.line += s.count("\n", self.i, end)
|
|
84
|
+
self.i = end + 2
|
|
85
|
+
else:
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
def parse(self) -> Node:
|
|
89
|
+
self.ws()
|
|
90
|
+
node = self.value(0)
|
|
91
|
+
self.ws()
|
|
92
|
+
if self.i != self.n:
|
|
93
|
+
raise self.error("trailing content")
|
|
94
|
+
return node
|
|
95
|
+
|
|
96
|
+
def value(self, depth: int) -> Node:
|
|
97
|
+
if depth > MAX_DEPTH:
|
|
98
|
+
raise self.error("nesting too deep")
|
|
99
|
+
self.ws()
|
|
100
|
+
if self.i >= self.n:
|
|
101
|
+
raise self.error("unexpected end of input")
|
|
102
|
+
c = self.s[self.i]
|
|
103
|
+
line = self.line
|
|
104
|
+
if c == "{":
|
|
105
|
+
return self.obj(depth)
|
|
106
|
+
if c == "[":
|
|
107
|
+
return self.arr(depth)
|
|
108
|
+
if c == '"':
|
|
109
|
+
return Node(self.string(), line)
|
|
110
|
+
if self.s.startswith("true", self.i):
|
|
111
|
+
self.i += 4
|
|
112
|
+
return Node(True, line)
|
|
113
|
+
if self.s.startswith("false", self.i):
|
|
114
|
+
self.i += 5
|
|
115
|
+
return Node(False, line)
|
|
116
|
+
if self.s.startswith("null", self.i):
|
|
117
|
+
self.i += 4
|
|
118
|
+
return Node(None, line)
|
|
119
|
+
return Node(self.number(), line)
|
|
120
|
+
|
|
121
|
+
def obj(self, depth: int) -> Node:
|
|
122
|
+
node = Node({}, self.line)
|
|
123
|
+
key_lines: dict[str, int] = {}
|
|
124
|
+
self.i += 1
|
|
125
|
+
while True:
|
|
126
|
+
self.ws()
|
|
127
|
+
if self.i < self.n and self.s[self.i] == "}":
|
|
128
|
+
self.i += 1
|
|
129
|
+
return node
|
|
130
|
+
if self.i >= self.n or self.s[self.i] != '"':
|
|
131
|
+
raise self.error("expected string key")
|
|
132
|
+
key_line = self.line
|
|
133
|
+
key = self.string()
|
|
134
|
+
self.ws()
|
|
135
|
+
if self.i >= self.n or self.s[self.i] != ":":
|
|
136
|
+
raise self.error("expected ':'")
|
|
137
|
+
self.i += 1
|
|
138
|
+
val = self.value(depth + 1)
|
|
139
|
+
if key in key_lines:
|
|
140
|
+
self.duplicates.append((key, key_lines[key], key_line))
|
|
141
|
+
key_lines[key] = key_line
|
|
142
|
+
node.value[key] = val # last one wins, like VS Code
|
|
143
|
+
self.ws()
|
|
144
|
+
if self.i < self.n and self.s[self.i] == ",":
|
|
145
|
+
self.i += 1
|
|
146
|
+
continue
|
|
147
|
+
if self.i < self.n and self.s[self.i] == "}":
|
|
148
|
+
self.i += 1
|
|
149
|
+
return node
|
|
150
|
+
raise self.error("expected ',' or '}'")
|
|
151
|
+
|
|
152
|
+
def arr(self, depth: int) -> Node:
|
|
153
|
+
node = Node([], self.line)
|
|
154
|
+
self.i += 1
|
|
155
|
+
while True:
|
|
156
|
+
self.ws()
|
|
157
|
+
if self.i < self.n and self.s[self.i] == "]":
|
|
158
|
+
self.i += 1
|
|
159
|
+
return node
|
|
160
|
+
node.value.append(self.value(depth + 1))
|
|
161
|
+
self.ws()
|
|
162
|
+
if self.i < self.n and self.s[self.i] == ",":
|
|
163
|
+
self.i += 1
|
|
164
|
+
continue
|
|
165
|
+
if self.i < self.n and self.s[self.i] == "]":
|
|
166
|
+
self.i += 1
|
|
167
|
+
return node
|
|
168
|
+
raise self.error("expected ',' or ']'")
|
|
169
|
+
|
|
170
|
+
def string(self) -> str:
|
|
171
|
+
self.i += 1
|
|
172
|
+
out: list[str] = []
|
|
173
|
+
s = self.s
|
|
174
|
+
while self.i < self.n:
|
|
175
|
+
c = s[self.i]
|
|
176
|
+
if c == '"':
|
|
177
|
+
self.i += 1
|
|
178
|
+
return "".join(out)
|
|
179
|
+
if c == "\\":
|
|
180
|
+
self.i += 1
|
|
181
|
+
if self.i >= self.n:
|
|
182
|
+
break
|
|
183
|
+
e = s[self.i]
|
|
184
|
+
if e == "u":
|
|
185
|
+
hexs = s[self.i + 1 : self.i + 5]
|
|
186
|
+
try:
|
|
187
|
+
out.append(chr(int(hexs, 16)))
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
raise self.error("bad \\u escape") from exc
|
|
190
|
+
self.i += 5
|
|
191
|
+
continue
|
|
192
|
+
out.append(_ESC.get(e, e))
|
|
193
|
+
self.i += 1
|
|
194
|
+
continue
|
|
195
|
+
if c == "\n":
|
|
196
|
+
self.line += 1
|
|
197
|
+
out.append(c)
|
|
198
|
+
self.i += 1
|
|
199
|
+
raise self.error("unterminated string")
|
|
200
|
+
|
|
201
|
+
def number(self) -> int | float:
|
|
202
|
+
start = self.i
|
|
203
|
+
while self.i < self.n and self.s[self.i] in "+-0123456789.eExXabcdefABCDEF":
|
|
204
|
+
self.i += 1
|
|
205
|
+
tok = self.s[start : self.i]
|
|
206
|
+
if not tok:
|
|
207
|
+
raise self.error(f"unexpected character {self.s[start]!r}")
|
|
208
|
+
try:
|
|
209
|
+
return int(tok)
|
|
210
|
+
except ValueError:
|
|
211
|
+
try:
|
|
212
|
+
return float(tok)
|
|
213
|
+
except ValueError as exc:
|
|
214
|
+
raise self.error(f"bad number {tok!r}") from exc
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def parse_jsonc(text: str) -> tuple[Node, list[tuple[str, int, int]]]:
|
|
218
|
+
"""Parse JSONC. Returns (root node, duplicate keys as (key, first line, duplicate line))."""
|
|
219
|
+
p = _Parser(text)
|
|
220
|
+
root = p.parse()
|
|
221
|
+
return root, p.duplicates
|
|
File without changes
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""AI coding-assistant configuration: hooks, MCP servers, permissions (TC-03).
|
|
2
|
+
|
|
3
|
+
Instruction files (CLAUDE.md, AGENTS.md, .cursorrules, ...) are covered by the `ai-instructions`
|
|
4
|
+
content-rule pack and by the hidden-Unicode analyzer, which treats them as high-risk.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
14
|
+
from devguard.analyzers.jsonc import Node
|
|
15
|
+
from devguard.analyzers.surfaces.common import (
|
|
16
|
+
args_text,
|
|
17
|
+
assess_and_trigger,
|
|
18
|
+
command_text,
|
|
19
|
+
env_findings,
|
|
20
|
+
env_pairs_from_node,
|
|
21
|
+
load_jsonc,
|
|
22
|
+
workspace_root_of,
|
|
23
|
+
)
|
|
24
|
+
from devguard.core.models import Finding, Severity
|
|
25
|
+
from devguard.core.redact import make_excerpt
|
|
26
|
+
from devguard.rules.catalog import build_finding, rule
|
|
27
|
+
|
|
28
|
+
R_HOOK = rule(
|
|
29
|
+
"DG-AI-CLAUDE-HOOK-001",
|
|
30
|
+
"Claude Code project config runs shell commands",
|
|
31
|
+
"medium",
|
|
32
|
+
"high",
|
|
33
|
+
"ai-config",
|
|
34
|
+
"TC-03.1",
|
|
35
|
+
"Project-level Claude Code settings define hooks or helper commands (statusLine, apiKeyHelper) that "
|
|
36
|
+
"run shell commands automatically during agent sessions.",
|
|
37
|
+
"Teams use hooks for formatting and linting. Check every command and the scripts it calls.",
|
|
38
|
+
"Do not start an AI assistant session in this repository until the hook commands are reviewed.",
|
|
39
|
+
)
|
|
40
|
+
R_PERMS = rule(
|
|
41
|
+
"DG-AI-PERMISSIONS-001",
|
|
42
|
+
"Project config grants an AI assistant broad permissions",
|
|
43
|
+
"medium",
|
|
44
|
+
"high",
|
|
45
|
+
"ai-config",
|
|
46
|
+
"TC-03.1",
|
|
47
|
+
"The project's assistant settings pre-approve broad tool use (for example unrestricted Bash, "
|
|
48
|
+
"bypassPermissions mode, or auto-accept), so the assistant can run commands without asking.",
|
|
49
|
+
)
|
|
50
|
+
R_MCP_AUTO = rule(
|
|
51
|
+
"DG-AI-MCP-AUTOAPPROVE-001",
|
|
52
|
+
"Project auto-approves its own MCP servers",
|
|
53
|
+
"medium",
|
|
54
|
+
"high",
|
|
55
|
+
"ai-config",
|
|
56
|
+
"TC-03.2",
|
|
57
|
+
"The project settings enable the repository's MCP servers automatically, skipping the approval prompt.",
|
|
58
|
+
)
|
|
59
|
+
R_MCP_SERVER = rule(
|
|
60
|
+
"DG-AI-MCP-SERVER-001",
|
|
61
|
+
"Project defines an MCP server that starts a local command",
|
|
62
|
+
"low",
|
|
63
|
+
"high",
|
|
64
|
+
"ai-config",
|
|
65
|
+
"TC-03.2",
|
|
66
|
+
"An MCP configuration in the repository starts a program when the assistant loads the project. "
|
|
67
|
+
"MCP servers run with the user's privileges.",
|
|
68
|
+
"Many projects ship MCP configs for real tools; verify the command, package names, and versions.",
|
|
69
|
+
)
|
|
70
|
+
R_MCP_REMOTE = rule(
|
|
71
|
+
"DG-AI-MCP-REMOTE-001",
|
|
72
|
+
"Project connects the assistant to a remote MCP server",
|
|
73
|
+
"low",
|
|
74
|
+
"medium",
|
|
75
|
+
"ai-config",
|
|
76
|
+
"TC-03.2",
|
|
77
|
+
"An MCP configuration points the assistant at a remote server. Its tools and responses can steer "
|
|
78
|
+
"the assistant, and data from the session may be sent to it.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
MCP_CONFIG_FILES = (
|
|
82
|
+
".mcp.json",
|
|
83
|
+
".cursor/mcp.json",
|
|
84
|
+
".vscode/mcp.json",
|
|
85
|
+
".windsurf/mcp.json",
|
|
86
|
+
".roo/mcp.json",
|
|
87
|
+
".kiro/settings/mcp.json",
|
|
88
|
+
".amazonq/mcp.json",
|
|
89
|
+
".gemini/settings.json",
|
|
90
|
+
".claude/settings.json",
|
|
91
|
+
".claude/settings.local.json",
|
|
92
|
+
".codeium/windsurf/mcp_config.json",
|
|
93
|
+
"mcp.json",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
INSTRUCTION_PATTERNS = (
|
|
97
|
+
"CLAUDE.md",
|
|
98
|
+
"CLAUDE.local.md",
|
|
99
|
+
"AGENTS.md",
|
|
100
|
+
"GEMINI.md",
|
|
101
|
+
".cursorrules",
|
|
102
|
+
".windsurfrules",
|
|
103
|
+
".clinerules",
|
|
104
|
+
"*.mdc",
|
|
105
|
+
"copilot-instructions.md",
|
|
106
|
+
"*.instructions.md",
|
|
107
|
+
"*.prompt.md",
|
|
108
|
+
"SKILL.md",
|
|
109
|
+
)
|
|
110
|
+
INSTRUCTION_DIRS = (
|
|
111
|
+
".claude/",
|
|
112
|
+
".cursor/rules/",
|
|
113
|
+
".windsurf/rules/",
|
|
114
|
+
".clinerules/",
|
|
115
|
+
".github/instructions/",
|
|
116
|
+
".github/prompts/",
|
|
117
|
+
".roo/rules/",
|
|
118
|
+
".kiro/steering/",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
_BROAD_ALLOW = re.compile(
|
|
122
|
+
r"^(Bash|Bash\(\s*\*?\s*\)|Bash\(\s*(sh|bash|zsh|curl|wget|python3?|node|npx|"
|
|
123
|
+
r"powershell|pwsh)\s*(:\s*\*)?\s*\)|\*|WebFetch|mcp__\*)$"
|
|
124
|
+
)
|
|
125
|
+
_PUBLIC_HTTP = re.compile(r"^http://(?!localhost|127\.|\[::1\])", re.IGNORECASE)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_ai_instruction_file(rel: str) -> bool:
|
|
129
|
+
name = PurePosixPath(rel).name
|
|
130
|
+
padded = f"/{rel}"
|
|
131
|
+
if any(fnmatch.fnmatchcase(name, p) for p in INSTRUCTION_PATTERNS):
|
|
132
|
+
return True
|
|
133
|
+
return any(f"/{d}" in padded for d in INSTRUCTION_DIRS) and name.endswith((".md", ".mdc", ".txt"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _matches_config(rel: str) -> bool:
|
|
137
|
+
padded = f"/{rel}"
|
|
138
|
+
return any(padded.endswith("/" + c) for c in MCP_CONFIG_FILES)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def mcp_servers_findings(
|
|
142
|
+
servers: Node, fv: FileView, ctx: AnalysisContext, base: list[str], *, client: str
|
|
143
|
+
) -> list[Finding]:
|
|
144
|
+
out: list[Finding] = []
|
|
145
|
+
for name, server in servers.items():
|
|
146
|
+
if not isinstance(server.value, dict):
|
|
147
|
+
continue
|
|
148
|
+
label = make_excerpt(name, limit=60)
|
|
149
|
+
cmd = command_text(server.get("command"))
|
|
150
|
+
if cmd:
|
|
151
|
+
full = " ".join([cmd, *args_text(server.get("args"))])
|
|
152
|
+
out.append(
|
|
153
|
+
build_finding(
|
|
154
|
+
R_MCP_SERVER.id,
|
|
155
|
+
[fv.evidence_at_line(server.line)],
|
|
156
|
+
detail=f"{client} server {label!r}: {make_excerpt(full, limit=160)}",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
out += assess_and_trigger(
|
|
160
|
+
ctx,
|
|
161
|
+
fv,
|
|
162
|
+
full,
|
|
163
|
+
server.line,
|
|
164
|
+
kind="mcp-server",
|
|
165
|
+
label=name,
|
|
166
|
+
autorun=True,
|
|
167
|
+
context=f"MCP server {label!r} ({client})",
|
|
168
|
+
base_dirs=base,
|
|
169
|
+
)
|
|
170
|
+
for key in ("url", "serverUrl", "httpUrl"):
|
|
171
|
+
u = server.get(key)
|
|
172
|
+
if u is not None and isinstance(u.value, str):
|
|
173
|
+
sev = Severity.MEDIUM if _PUBLIC_HTTP.match(u.value) else None
|
|
174
|
+
out.append(
|
|
175
|
+
build_finding(
|
|
176
|
+
R_MCP_REMOTE.id,
|
|
177
|
+
[fv.evidence_at_line(u.line)],
|
|
178
|
+
severity=sev,
|
|
179
|
+
detail=f"{client} server {label!r} → {make_excerpt(u.value, limit=120)}",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
if (env := server.get("env")) is not None:
|
|
183
|
+
out += env_findings(
|
|
184
|
+
ctx, fv, env_pairs_from_node(env), context=f"MCP server {label!r} env", autorun=True, base_dirs=base
|
|
185
|
+
)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class AIAssistantConfigAnalyzer:
|
|
190
|
+
name = "ai-assistant-config"
|
|
191
|
+
|
|
192
|
+
def applies(self, fv: FileView) -> bool:
|
|
193
|
+
return fv.text is not None and _matches_config(fv.rel)
|
|
194
|
+
|
|
195
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
196
|
+
out: list[Finding] = []
|
|
197
|
+
root = load_jsonc(fv, ctx, out)
|
|
198
|
+
if root is None or not isinstance(root.value, dict):
|
|
199
|
+
return out
|
|
200
|
+
base = [workspace_root_of(fv.rel)]
|
|
201
|
+
client = _client_name(fv.rel)
|
|
202
|
+
for key in ("mcpServers", "servers"):
|
|
203
|
+
if (servers := root.get(key)) is not None:
|
|
204
|
+
out += mcp_servers_findings(servers, fv, ctx, base, client=client)
|
|
205
|
+
if fv.rel.endswith((".claude/settings.json", ".claude/settings.local.json")):
|
|
206
|
+
out += self._claude(root, fv, ctx, base)
|
|
207
|
+
if fv.rel.endswith(".gemini/settings.json"):
|
|
208
|
+
out += self._gemini(root, fv)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
def _claude(self, root: Node, fv: FileView, ctx: AnalysisContext, base: list[str]) -> list[Finding]:
|
|
212
|
+
out: list[Finding] = []
|
|
213
|
+
hooks = root.get("hooks")
|
|
214
|
+
for event, groups in hooks.items() if hooks else []:
|
|
215
|
+
for group in groups.elements():
|
|
216
|
+
for h in group.list_at("hooks"):
|
|
217
|
+
cmd = command_text(h.get("command"))
|
|
218
|
+
if cmd:
|
|
219
|
+
out += self._claude_command(fv, ctx, base, cmd, h.line, f"hook {event}")
|
|
220
|
+
for key in ("statusLine", "fileSuggestion"):
|
|
221
|
+
node = root.get(key)
|
|
222
|
+
if node is not None and (cmd := command_text(node.get("command"))):
|
|
223
|
+
out += self._claude_command(fv, ctx, base, cmd, node.line, key)
|
|
224
|
+
for key in ("apiKeyHelper", "awsAuthRefresh", "awsCredentialExport", "otelHeadersHelper"):
|
|
225
|
+
node = root.get(key)
|
|
226
|
+
if node is not None and (cmd := command_text(node)):
|
|
227
|
+
out += self._claude_command(fv, ctx, base, cmd, node.line, key)
|
|
228
|
+
perms = root.get("permissions")
|
|
229
|
+
if perms is not None:
|
|
230
|
+
allow = perms.get("allow")
|
|
231
|
+
broad = [
|
|
232
|
+
e.value
|
|
233
|
+
for e in (allow.elements() if allow else [])
|
|
234
|
+
if isinstance(e.value, str) and _BROAD_ALLOW.match(e.value.strip())
|
|
235
|
+
]
|
|
236
|
+
if broad:
|
|
237
|
+
out.append(
|
|
238
|
+
build_finding(
|
|
239
|
+
R_PERMS.id,
|
|
240
|
+
[fv.evidence_at_line(allow.line if allow else None)],
|
|
241
|
+
detail=f"Allowed without prompting: {', '.join(broad[:8])}.",
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
mode = perms.get("defaultMode")
|
|
245
|
+
if mode is not None and mode.value == "bypassPermissions":
|
|
246
|
+
out.append(
|
|
247
|
+
build_finding(
|
|
248
|
+
R_PERMS.id,
|
|
249
|
+
[fv.evidence_at_line(mode.line)],
|
|
250
|
+
severity=Severity.HIGH,
|
|
251
|
+
detail="defaultMode is bypassPermissions.",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
for key in ("enableAllProjectMcpServers", "enabledMcpjsonServers"):
|
|
255
|
+
node = root.get(key)
|
|
256
|
+
if node is not None and node.value not in (False, None, []):
|
|
257
|
+
out.append(build_finding(R_MCP_AUTO.id, [fv.evidence_at_line(node.line)], detail=f"{key} is set."))
|
|
258
|
+
if (env := root.get("env")) is not None:
|
|
259
|
+
out += env_findings(
|
|
260
|
+
ctx, fv, env_pairs_from_node(env), context="Claude Code settings env", autorun=True, base_dirs=base
|
|
261
|
+
)
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
def _claude_command(
|
|
265
|
+
self, fv: FileView, ctx: AnalysisContext, base: list[str], cmd: str, line: int | None, what: str
|
|
266
|
+
) -> list[Finding]:
|
|
267
|
+
out = [build_finding(R_HOOK.id, [fv.evidence_at_line(line)], detail=f"{what}: {make_excerpt(cmd, limit=160)}")]
|
|
268
|
+
out += assess_and_trigger(
|
|
269
|
+
ctx,
|
|
270
|
+
fv,
|
|
271
|
+
cmd,
|
|
272
|
+
line,
|
|
273
|
+
kind="claude-hook",
|
|
274
|
+
label=what,
|
|
275
|
+
autorun=True,
|
|
276
|
+
context=f"Claude Code {what}",
|
|
277
|
+
base_dirs=base,
|
|
278
|
+
)
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
def _gemini(self, root: Node, fv: FileView) -> list[Finding]:
|
|
282
|
+
out: list[Finding] = []
|
|
283
|
+
for key in ("autoAccept", "yolo"):
|
|
284
|
+
node = root.get(key)
|
|
285
|
+
if node is not None and node.value is True:
|
|
286
|
+
out.append(build_finding(R_PERMS.id, [fv.evidence_at_line(node.line)], detail=f"{key} is true."))
|
|
287
|
+
tools = root.get("tools")
|
|
288
|
+
if tools is not None and (allowed := tools.get("allowed")) is not None:
|
|
289
|
+
broad = [
|
|
290
|
+
e.value
|
|
291
|
+
for e in allowed.elements()
|
|
292
|
+
if isinstance(e.value, str) and re.match(r"^run_shell_command(\(\s*\))?$", e.value)
|
|
293
|
+
]
|
|
294
|
+
if broad:
|
|
295
|
+
out.append(
|
|
296
|
+
build_finding(
|
|
297
|
+
R_PERMS.id,
|
|
298
|
+
[fv.evidence_at_line(allowed.line)],
|
|
299
|
+
detail=f"Allowed without prompting: {', '.join(broad)}.",
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
return out
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _client_name(rel: str) -> str:
|
|
306
|
+
for marker, name in (
|
|
307
|
+
(".cursor/", "Cursor"),
|
|
308
|
+
(".vscode/", "VS Code"),
|
|
309
|
+
(".gemini/", "Gemini CLI"),
|
|
310
|
+
(".windsurf/", "Windsurf"),
|
|
311
|
+
(".roo/", "Roo"),
|
|
312
|
+
(".kiro/", "Kiro"),
|
|
313
|
+
(".amazonq/", "Amazon Q"),
|
|
314
|
+
(".claude/", "Claude Code"),
|
|
315
|
+
):
|
|
316
|
+
if marker in f"/{rel}":
|
|
317
|
+
return name
|
|
318
|
+
return "MCP client (.mcp.json)"
|