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,304 @@
|
|
|
1
|
+
"""npm / yarn / pnpm / bun: lifecycle scripts and package-manager config that executes code (TC-04.1, TC-04.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
11
|
+
from devguard.analyzers.command import resolve_ref
|
|
12
|
+
from devguard.analyzers.surfaces.common import assess_and_trigger, load_jsonc
|
|
13
|
+
from devguard.analyzers.surfaces.deps import package_json_dependency_findings
|
|
14
|
+
from devguard.core.models import Finding, Severity
|
|
15
|
+
from devguard.core.redact import make_excerpt
|
|
16
|
+
from devguard.rules.catalog import build_finding, rule
|
|
17
|
+
|
|
18
|
+
R_LIFECYCLE = rule(
|
|
19
|
+
"DG-NPM-LIFECYCLE-001",
|
|
20
|
+
"Install-time lifecycle script",
|
|
21
|
+
"low",
|
|
22
|
+
"high",
|
|
23
|
+
"lifecycle",
|
|
24
|
+
"TC-04.1",
|
|
25
|
+
"package.json defines a script that npm/yarn/pnpm run automatically during install (preinstall, install, "
|
|
26
|
+
"postinstall, prepare). It runs with the user's privileges as soon as dependencies are installed.",
|
|
27
|
+
"Native modules and git-hook managers use install scripts legitimately.",
|
|
28
|
+
"Review the script and every file it runs before installing. Use `npm install --ignore-scripts` if unsure.",
|
|
29
|
+
)
|
|
30
|
+
R_NPMRC_EXEC = rule(
|
|
31
|
+
"DG-NPM-RC-EXEC-001",
|
|
32
|
+
"npm configuration executes code",
|
|
33
|
+
"high",
|
|
34
|
+
"high",
|
|
35
|
+
"lifecycle",
|
|
36
|
+
"TC-04.2",
|
|
37
|
+
"The project .npmrc sets an option that injects code or replaces the script shell "
|
|
38
|
+
"(node-options, script-shell, onload-script, init-module).",
|
|
39
|
+
)
|
|
40
|
+
R_REGISTRY = rule(
|
|
41
|
+
"DG-NPM-REGISTRY-001",
|
|
42
|
+
"Package registry overridden",
|
|
43
|
+
"low",
|
|
44
|
+
"medium",
|
|
45
|
+
"deps",
|
|
46
|
+
"TC-11.4",
|
|
47
|
+
"The project points the package manager at a non-default registry. Every package then comes from that "
|
|
48
|
+
"server, which can serve modified packages.",
|
|
49
|
+
"Companies use private registries (Artifactory, Verdaccio, GitHub Packages).",
|
|
50
|
+
)
|
|
51
|
+
R_YARNPATH = rule(
|
|
52
|
+
"DG-YARN-PATH-001",
|
|
53
|
+
"Repository supplies the Yarn executable",
|
|
54
|
+
"high",
|
|
55
|
+
"medium",
|
|
56
|
+
"lifecycle",
|
|
57
|
+
"TC-04.2",
|
|
58
|
+
"`yarnPath` makes every `yarn` command run a JavaScript file from this repository instead of the installed Yarn.",
|
|
59
|
+
"Yarn Berry projects commit `.yarn/releases/yarn-<version>.cjs` legitimately; any other path is unusual. "
|
|
60
|
+
"Compare a committed release with the official one for that version.",
|
|
61
|
+
)
|
|
62
|
+
R_YARN_PLUGIN = rule(
|
|
63
|
+
"DG-YARN-PLUGIN-001",
|
|
64
|
+
"Yarn loads a plugin from the repository",
|
|
65
|
+
"medium",
|
|
66
|
+
"medium",
|
|
67
|
+
"lifecycle",
|
|
68
|
+
"TC-04.2",
|
|
69
|
+
"`.yarnrc.yml` loads a plugin file from this repository; plugins run inside every yarn command.",
|
|
70
|
+
"Official plugins live in `.yarn/plugins/@yarnpkg/`.",
|
|
71
|
+
)
|
|
72
|
+
R_PNPMFILE = rule(
|
|
73
|
+
"DG-PNPM-HOOKS-001",
|
|
74
|
+
"pnpm hook file runs on every install",
|
|
75
|
+
"medium",
|
|
76
|
+
"high",
|
|
77
|
+
"lifecycle",
|
|
78
|
+
"TC-04.2",
|
|
79
|
+
"`.pnpmfile.cjs` is executed by pnpm during every install to rewrite package manifests.",
|
|
80
|
+
)
|
|
81
|
+
R_BUN_PRELOAD = rule(
|
|
82
|
+
"DG-BUN-PRELOAD-001",
|
|
83
|
+
"Bun preloads a repository script",
|
|
84
|
+
"medium",
|
|
85
|
+
"high",
|
|
86
|
+
"lifecycle",
|
|
87
|
+
"TC-04.2",
|
|
88
|
+
"`bunfig.toml` preloads a script before every `bun` run or test.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
LIFECYCLE = ("preinstall", "install", "postinstall", "preprepare", "prepare", "postprepare", "prepublish")
|
|
92
|
+
_BENIGN_LIFECYCLE = re.compile(
|
|
93
|
+
r"^\s*(npx\s+)?(husky(\s+install)?(\s+\S+)?|node-gyp\s+rebuild|node-gyp-build|patch-package|tsc(\s+-[-\w]+(\s+\S+)?)*"
|
|
94
|
+
r"|ngcc|prebuild-install(\s+\|\|\s+node-gyp\s+rebuild)?|opencollective[-\s]postinstall.*|simple-git-hooks"
|
|
95
|
+
r"|lefthook\s+install|electron-builder\s+install-app-deps|svelte-kit\s+sync|nuxt\s+prepare|prisma\s+generate"
|
|
96
|
+
r"|is-ci\s+\|\|\s+husky(\s+install)?|lerna\s+bootstrap|npm\s+run\s+build)\s*$"
|
|
97
|
+
)
|
|
98
|
+
_DEFAULT_REGISTRIES = ("https://registry.npmjs.org", "https://registry.yarnpkg.com", "https://registry.npmmirror.com")
|
|
99
|
+
_CANONICAL_YARN = re.compile(r"^\.yarn/releases/yarn-[\w.-]+\.c?js$")
|
|
100
|
+
_CANONICAL_PLUGIN = re.compile(r"^\.yarn/plugins/@yarnpkg/plugin-[\w-]+\.c?js$")
|
|
101
|
+
_RAW_IP_OR_HTTP = re.compile(r"^http://|^https?://(\d{1,3}\.){3}\d{1,3}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NpmAnalyzer:
|
|
105
|
+
name = "npm"
|
|
106
|
+
|
|
107
|
+
def applies(self, fv: FileView) -> bool:
|
|
108
|
+
return (
|
|
109
|
+
fv.text is not None
|
|
110
|
+
and fv.name in ("package.json", ".npmrc", ".yarnrc.yml", ".yarnrc", ".pnpmfile.cjs", "bunfig.toml")
|
|
111
|
+
and not fv.record.vendored
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
115
|
+
if fv.name == "package.json":
|
|
116
|
+
return self._package_json(fv, ctx)
|
|
117
|
+
if fv.name == ".npmrc":
|
|
118
|
+
return self._npmrc(fv, ctx)
|
|
119
|
+
if fv.name == ".yarnrc.yml":
|
|
120
|
+
return self._yarnrc_yml(fv, ctx)
|
|
121
|
+
if fv.name == ".yarnrc":
|
|
122
|
+
return self._yarnrc_v1(fv, ctx)
|
|
123
|
+
if fv.name == ".pnpmfile.cjs":
|
|
124
|
+
ctx.add_trigger("pnpm-hooks", fv.rel, line=1, label=".pnpmfile.cjs", autorun=True, references=[fv.rel])
|
|
125
|
+
ctx.autoloaded.add(fv.rel)
|
|
126
|
+
return [build_finding(R_PNPMFILE.id, [fv.evidence_at_line(1)])]
|
|
127
|
+
return self._bunfig(fv, ctx)
|
|
128
|
+
|
|
129
|
+
def _package_json(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
130
|
+
out: list[Finding] = []
|
|
131
|
+
root = load_jsonc(fv, ctx, out)
|
|
132
|
+
if root is None or not isinstance(root.value, dict):
|
|
133
|
+
return out
|
|
134
|
+
base = [fv.parent]
|
|
135
|
+
scripts = root.get("scripts")
|
|
136
|
+
table: dict[str, str] = {}
|
|
137
|
+
for name, node in scripts.items() if scripts else []:
|
|
138
|
+
if not isinstance(node.value, str):
|
|
139
|
+
continue
|
|
140
|
+
table[name] = node.value
|
|
141
|
+
autorun = name in LIFECYCLE
|
|
142
|
+
if autorun:
|
|
143
|
+
benign = bool(_BENIGN_LIFECYCLE.match(node.value))
|
|
144
|
+
out.append(
|
|
145
|
+
build_finding(
|
|
146
|
+
R_LIFECYCLE.id,
|
|
147
|
+
[fv.evidence_at_line(node.line)],
|
|
148
|
+
severity=Severity.INFO if benign else None,
|
|
149
|
+
detail=f"{name}: {make_excerpt(node.value, limit=160)}",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
out += assess_and_trigger(
|
|
153
|
+
ctx,
|
|
154
|
+
fv,
|
|
155
|
+
node.value,
|
|
156
|
+
node.line,
|
|
157
|
+
kind="npm-lifecycle" if autorun else "npm-script",
|
|
158
|
+
label=name,
|
|
159
|
+
autorun=autorun,
|
|
160
|
+
context=f"npm script {name!r}",
|
|
161
|
+
base_dirs=base,
|
|
162
|
+
)
|
|
163
|
+
ctx.npm_scripts[fv.parent] = table
|
|
164
|
+
out += package_json_dependency_findings(root, fv)
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
def _npmrc(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
168
|
+
out: list[Finding] = []
|
|
169
|
+
for i, line in enumerate(fv.lines, 1):
|
|
170
|
+
s = line.strip()
|
|
171
|
+
if not s or s.startswith((";", "#")) or "=" not in s:
|
|
172
|
+
continue
|
|
173
|
+
key, value = (p.strip() for p in s.split("=", 1))
|
|
174
|
+
lkey = key.lower()
|
|
175
|
+
if lkey in ("node-options", "script-shell", "onload-script", "init-module", "shell"):
|
|
176
|
+
out.append(
|
|
177
|
+
build_finding(
|
|
178
|
+
R_NPMRC_EXEC.id, [fv.evidence_at_line(i)], detail=f"{key}={make_excerpt(value, limit=120)}"
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
ref = resolve_ref(value.split()[-1] if value.split() else value, ctx.inventory, [fv.parent])
|
|
182
|
+
ctx.add_trigger(
|
|
183
|
+
"npmrc",
|
|
184
|
+
fv.rel,
|
|
185
|
+
line=i,
|
|
186
|
+
label=key,
|
|
187
|
+
command=make_excerpt(value),
|
|
188
|
+
autorun=True,
|
|
189
|
+
references=[ref] if ref else [],
|
|
190
|
+
)
|
|
191
|
+
elif lkey == "registry" or lkey.endswith(":registry"):
|
|
192
|
+
out += _registry_finding(fv, i, key, value)
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
def _yarnrc_yml(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
196
|
+
try:
|
|
197
|
+
data = yaml.safe_load(fv.text or "") or {}
|
|
198
|
+
except yaml.YAMLError as exc:
|
|
199
|
+
ctx.warn("npm", f"invalid YAML: {exc}", fv.rel)
|
|
200
|
+
return []
|
|
201
|
+
if not isinstance(data, dict):
|
|
202
|
+
return []
|
|
203
|
+
out: list[Finding] = []
|
|
204
|
+
yarn_path = data.get("yarnPath")
|
|
205
|
+
if isinstance(yarn_path, str):
|
|
206
|
+
out += self._yarn_path(fv, ctx, yarn_path, _line_of(fv, "yarnPath"))
|
|
207
|
+
for plugin in data.get("plugins") or []:
|
|
208
|
+
path = plugin.get("path") if isinstance(plugin, dict) else plugin if isinstance(plugin, str) else None
|
|
209
|
+
if not isinstance(path, str):
|
|
210
|
+
continue
|
|
211
|
+
line = _line_of(fv, path)
|
|
212
|
+
ref = resolve_ref(path, ctx.inventory, [fv.parent])
|
|
213
|
+
canonical = bool(_CANONICAL_PLUGIN.match(path))
|
|
214
|
+
out.append(
|
|
215
|
+
build_finding(
|
|
216
|
+
R_YARN_PLUGIN.id,
|
|
217
|
+
[fv.evidence_at_line(line)],
|
|
218
|
+
severity=Severity.INFO if canonical else None,
|
|
219
|
+
detail=f"Plugin: {path}",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
ctx.add_trigger(
|
|
223
|
+
"yarn-plugin",
|
|
224
|
+
fv.rel,
|
|
225
|
+
line=line,
|
|
226
|
+
label=path,
|
|
227
|
+
autorun=True,
|
|
228
|
+
references=[ref] if ref and not canonical else [],
|
|
229
|
+
)
|
|
230
|
+
reg = data.get("npmRegistryServer")
|
|
231
|
+
if isinstance(reg, str):
|
|
232
|
+
out += _registry_finding(fv, _line_of(fv, "npmRegistryServer"), "npmRegistryServer", reg)
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
def _yarnrc_v1(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
236
|
+
out: list[Finding] = []
|
|
237
|
+
for i, line in enumerate(fv.lines, 1):
|
|
238
|
+
m = re.match(r'^\s*(yarn-path|registry|"registry")\s+"?([^"\s]+)"?', line)
|
|
239
|
+
if not m:
|
|
240
|
+
continue
|
|
241
|
+
if m.group(1) == "yarn-path":
|
|
242
|
+
out += self._yarn_path(fv, ctx, m.group(2), i)
|
|
243
|
+
else:
|
|
244
|
+
out += _registry_finding(fv, i, "registry", m.group(2))
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
def _yarn_path(self, fv: FileView, ctx: AnalysisContext, path: str, line: int | None) -> list[Finding]:
|
|
248
|
+
canonical = bool(_CANONICAL_YARN.match(path))
|
|
249
|
+
ref = resolve_ref(path, ctx.inventory, [fv.parent])
|
|
250
|
+
ctx.add_trigger(
|
|
251
|
+
"yarn-path",
|
|
252
|
+
fv.rel,
|
|
253
|
+
line=line,
|
|
254
|
+
label="yarnPath",
|
|
255
|
+
command=make_excerpt(path),
|
|
256
|
+
autorun=True,
|
|
257
|
+
references=[ref] if ref and not canonical else [],
|
|
258
|
+
)
|
|
259
|
+
return [
|
|
260
|
+
build_finding(
|
|
261
|
+
R_YARNPATH.id,
|
|
262
|
+
[fv.evidence_at_line(line)],
|
|
263
|
+
severity=Severity.INFO if canonical else None,
|
|
264
|
+
confidence=None,
|
|
265
|
+
detail=f"yarnPath: {make_excerpt(path, limit=120)}",
|
|
266
|
+
)
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
def _bunfig(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
270
|
+
try:
|
|
271
|
+
data = tomllib.loads(fv.text or "")
|
|
272
|
+
except tomllib.TOMLDecodeError as exc:
|
|
273
|
+
ctx.warn("npm", f"invalid TOML: {exc}", fv.rel)
|
|
274
|
+
return []
|
|
275
|
+
out: list[Finding] = []
|
|
276
|
+
preloads: list[str] = []
|
|
277
|
+
for section in (data, data.get("test") or {}, data.get("run") or {}):
|
|
278
|
+
value = section.get("preload") if isinstance(section, dict) else None
|
|
279
|
+
preloads += [value] if isinstance(value, str) else [v for v in value or [] if isinstance(v, str)]
|
|
280
|
+
for p in preloads:
|
|
281
|
+
line = _line_of(fv, p)
|
|
282
|
+
ref = resolve_ref(p, ctx.inventory, [fv.parent])
|
|
283
|
+
out.append(build_finding(R_BUN_PRELOAD.id, [fv.evidence_at_line(line)], detail=f"Preload: {p}"))
|
|
284
|
+
ctx.add_trigger("bun-preload", fv.rel, line=line, label=p, autorun=True, references=[ref] if ref else [])
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _registry_finding(fv: FileView, line: int | None, key: str, value: str) -> list[Finding]:
|
|
289
|
+
v = value.strip().strip("\"'").rstrip("/")
|
|
290
|
+
if not v or v.startswith(_DEFAULT_REGISTRIES):
|
|
291
|
+
return []
|
|
292
|
+
sev = Severity.MEDIUM if _RAW_IP_OR_HTTP.match(v) else None
|
|
293
|
+
return [
|
|
294
|
+
build_finding(
|
|
295
|
+
R_REGISTRY.id, [fv.evidence_at_line(line)], severity=sev, detail=f"{key} = {make_excerpt(v, limit=120)}"
|
|
296
|
+
)
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _line_of(fv: FileView, needle: str) -> int | None:
|
|
301
|
+
for i, line in enumerate(fv.lines, 1):
|
|
302
|
+
if needle in line:
|
|
303
|
+
return i
|
|
304
|
+
return None
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Python packaging and interpreter auto-execution points (TC-04.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
8
|
+
from devguard.core.models import Finding, Severity
|
|
9
|
+
from devguard.core.redact import make_excerpt
|
|
10
|
+
from devguard.core.safefs import normalize_rel
|
|
11
|
+
from devguard.rules.catalog import build_finding, rule
|
|
12
|
+
|
|
13
|
+
R_SETUP_PY = rule(
|
|
14
|
+
"DG-PY-SETUP-001",
|
|
15
|
+
"setup.py executes during installation",
|
|
16
|
+
"info",
|
|
17
|
+
"high",
|
|
18
|
+
"lifecycle",
|
|
19
|
+
"TC-04.3",
|
|
20
|
+
"setup.py is a Python program that runs when the package is built or installed from source. Its "
|
|
21
|
+
"contents are checked by the Python rules and linked to this trigger.",
|
|
22
|
+
)
|
|
23
|
+
R_PTH = rule(
|
|
24
|
+
"DG-PY-PTH-EXEC-001",
|
|
25
|
+
".pth file executes code at interpreter start-up",
|
|
26
|
+
"high",
|
|
27
|
+
"medium",
|
|
28
|
+
"lifecycle",
|
|
29
|
+
"TC-04.3",
|
|
30
|
+
"Lines starting with `import` in a .pth file run every time Python starts if the file is placed in "
|
|
31
|
+
"site-packages. Committing such a file is unusual.",
|
|
32
|
+
"Some packages (e.g. coverage, editable installs) ship .pth hooks inside site-packages.",
|
|
33
|
+
)
|
|
34
|
+
R_STARTUP = rule(
|
|
35
|
+
"DG-PY-STARTUP-HOOK-001",
|
|
36
|
+
"Python start-up customization module",
|
|
37
|
+
"medium",
|
|
38
|
+
"medium",
|
|
39
|
+
"lifecycle",
|
|
40
|
+
"TC-04.3",
|
|
41
|
+
"sitecustomize.py / usercustomize.py are imported automatically by every Python process whose path "
|
|
42
|
+
"includes this directory.",
|
|
43
|
+
)
|
|
44
|
+
R_BACKEND = rule(
|
|
45
|
+
"DG-PY-INTREE-BACKEND-001",
|
|
46
|
+
"Build backend loaded from the repository",
|
|
47
|
+
"medium",
|
|
48
|
+
"medium",
|
|
49
|
+
"lifecycle",
|
|
50
|
+
"TC-04.3",
|
|
51
|
+
"pyproject.toml sets `backend-path`, so pip runs a build backend that lives inside this repository "
|
|
52
|
+
"when installing from source.",
|
|
53
|
+
"A few projects bootstrap their own backend this way.",
|
|
54
|
+
)
|
|
55
|
+
R_CONFTEST = rule(
|
|
56
|
+
"DG-PY-CONFTEST-001",
|
|
57
|
+
"pytest conftest.py runs during test collection",
|
|
58
|
+
"info",
|
|
59
|
+
"high",
|
|
60
|
+
"lifecycle",
|
|
61
|
+
"TC-04.3",
|
|
62
|
+
"conftest.py is imported automatically by pytest (and by IDE test discovery). Its contents are checked "
|
|
63
|
+
"by the Python rules and linked to this trigger.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PythonPackagingAnalyzer:
|
|
68
|
+
name = "python-packaging"
|
|
69
|
+
|
|
70
|
+
def applies(self, fv: FileView) -> bool:
|
|
71
|
+
if fv.text is None or fv.record.vendored:
|
|
72
|
+
return False
|
|
73
|
+
return fv.name in ("setup.py", "pyproject.toml", "sitecustomize.py", "usercustomize.py", "conftest.py") or (
|
|
74
|
+
fv.suffix == ".pth"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
78
|
+
name = fv.name
|
|
79
|
+
if name == "setup.py":
|
|
80
|
+
ctx.add_trigger("python-setup", fv.rel, line=1, label="setup.py", autorun=True, references=[fv.rel])
|
|
81
|
+
return [build_finding(R_SETUP_PY.id, [fv.evidence_at_line(1)])]
|
|
82
|
+
if name == "conftest.py":
|
|
83
|
+
ctx.add_trigger("pytest-conftest", fv.rel, line=1, label="conftest.py", autorun=False, references=[fv.rel])
|
|
84
|
+
return [build_finding(R_CONFTEST.id, [fv.evidence_at_line(1)])]
|
|
85
|
+
if name in ("sitecustomize.py", "usercustomize.py"):
|
|
86
|
+
ctx.add_trigger("python-startup", fv.rel, line=1, label=name, autorun=True, references=[fv.rel])
|
|
87
|
+
ctx.autoloaded.add(fv.rel)
|
|
88
|
+
return [build_finding(R_STARTUP.id, [fv.evidence_at_line(1)])]
|
|
89
|
+
if fv.suffix == ".pth":
|
|
90
|
+
out: list[Finding] = []
|
|
91
|
+
for i, line in enumerate(fv.lines, 1):
|
|
92
|
+
if line.lstrip().startswith(("import ", "import\t")):
|
|
93
|
+
in_site = "site-packages/" in fv.rel
|
|
94
|
+
out.append(
|
|
95
|
+
build_finding(R_PTH.id, [fv.evidence_at_line(i)], severity=Severity.MEDIUM if in_site else None)
|
|
96
|
+
)
|
|
97
|
+
ctx.add_trigger(
|
|
98
|
+
"python-pth",
|
|
99
|
+
fv.rel,
|
|
100
|
+
line=i,
|
|
101
|
+
label=fv.name,
|
|
102
|
+
command=make_excerpt(line),
|
|
103
|
+
autorun=True,
|
|
104
|
+
references=[fv.rel],
|
|
105
|
+
)
|
|
106
|
+
break
|
|
107
|
+
return out
|
|
108
|
+
return self._pyproject(fv, ctx)
|
|
109
|
+
|
|
110
|
+
def _pyproject(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
111
|
+
try:
|
|
112
|
+
data = tomllib.loads(fv.text or "")
|
|
113
|
+
except tomllib.TOMLDecodeError as exc:
|
|
114
|
+
ctx.warn("python-packaging", f"invalid TOML: {exc}", fv.rel)
|
|
115
|
+
return []
|
|
116
|
+
bs = data.get("build-system") or {}
|
|
117
|
+
paths = bs.get("backend-path") if isinstance(bs, dict) else None
|
|
118
|
+
if not paths:
|
|
119
|
+
return []
|
|
120
|
+
line = next((i for i, ln in enumerate(fv.lines, 1) if "backend-path" in ln), None)
|
|
121
|
+
backend = str(bs.get("build-backend", ""))
|
|
122
|
+
refs = []
|
|
123
|
+
module = backend.split(":")[0].replace(".", "/")
|
|
124
|
+
for p in paths if isinstance(paths, list) else [paths]:
|
|
125
|
+
if not isinstance(p, str):
|
|
126
|
+
continue
|
|
127
|
+
for cand in (f"{p}/{module}.py", f"{p}/{module}/__init__.py"):
|
|
128
|
+
full = normalize_rel(f"{fv.parent}/{cand}" if fv.parent else cand)
|
|
129
|
+
if full and ctx.inventory.exists(full):
|
|
130
|
+
refs.append(full)
|
|
131
|
+
ctx.add_trigger("python-backend", fv.rel, line=line, label=backend, autorun=True, references=refs)
|
|
132
|
+
return [
|
|
133
|
+
build_finding(
|
|
134
|
+
R_BACKEND.id,
|
|
135
|
+
[fv.evidence_at_line(line)],
|
|
136
|
+
detail=f"backend {make_excerpt(backend, limit=80)!r} from {paths!r}.",
|
|
137
|
+
)
|
|
138
|
+
]
|