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,142 @@
|
|
|
1
|
+
"""Files that tools load automatically (TC-05) and commands that documentation tells the reader to run (TC-13).
|
|
2
|
+
|
|
3
|
+
These analyzers mostly register triggers; correlation turns "trigger reaches dangerous code" into findings.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
12
|
+
from devguard.analyzers.command import analyze_command
|
|
13
|
+
from devguard.core.models import Finding
|
|
14
|
+
from devguard.core.redact import make_excerpt
|
|
15
|
+
|
|
16
|
+
# Config-as-code: JS/TS/Python files imported by build tools, test runners, linters, or IDE extensions.
|
|
17
|
+
CONFIG_AS_CODE = (
|
|
18
|
+
"eslint.config.*",
|
|
19
|
+
".eslintrc.js",
|
|
20
|
+
".eslintrc.cjs",
|
|
21
|
+
"prettier.config.*",
|
|
22
|
+
".prettierrc.js",
|
|
23
|
+
".prettierrc.cjs",
|
|
24
|
+
"babel.config.*",
|
|
25
|
+
".babelrc.js",
|
|
26
|
+
"postcss.config.*",
|
|
27
|
+
"tailwind.config.*",
|
|
28
|
+
"next.config.*",
|
|
29
|
+
"vite.config.*",
|
|
30
|
+
"vitest.config.*",
|
|
31
|
+
"webpack.config.*",
|
|
32
|
+
"rollup.config.*",
|
|
33
|
+
"jest.config.*",
|
|
34
|
+
"playwright.config.*",
|
|
35
|
+
"nuxt.config.*",
|
|
36
|
+
"svelte.config.*",
|
|
37
|
+
"astro.config.*",
|
|
38
|
+
"remix.config.*",
|
|
39
|
+
"metro.config.*",
|
|
40
|
+
"craco.config.*",
|
|
41
|
+
"commitlint.config.*",
|
|
42
|
+
"lint-staged.config.*",
|
|
43
|
+
".lintstagedrc.js",
|
|
44
|
+
".lintstagedrc.cjs",
|
|
45
|
+
"stylelint.config.*",
|
|
46
|
+
".stylelintrc.js",
|
|
47
|
+
"gatsby-config.*",
|
|
48
|
+
"gatsby-node.*",
|
|
49
|
+
"quasar.config.*",
|
|
50
|
+
"electron.vite.config.*",
|
|
51
|
+
"tsup.config.*",
|
|
52
|
+
"esbuild.config.*",
|
|
53
|
+
"karma.conf.js",
|
|
54
|
+
"protractor.conf.js",
|
|
55
|
+
"gulpfile.*",
|
|
56
|
+
"Gruntfile.*",
|
|
57
|
+
"*.storybook/main.*",
|
|
58
|
+
"*.storybook/preview.*",
|
|
59
|
+
)
|
|
60
|
+
_CODE_SUFFIXES = (".js", ".cjs", ".mjs", ".ts", ".cts", ".mts", ".jsx", ".tsx")
|
|
61
|
+
|
|
62
|
+
_DOC_NAMES = re.compile(
|
|
63
|
+
r"^(readme|install|setup|contributing|getting[-_]started|quickstart|usage)([-_.].*)?\.(md|markdown|txt|rst)$",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
_FENCE = re.compile(r"^(```|~~~)\s*([\w+-]*)\s*$")
|
|
67
|
+
_SHELL_FENCES = {"", "bash", "sh", "shell", "zsh", "console", "terminal", "powershell", "ps1", "pwsh", "cmd", "bat"}
|
|
68
|
+
_INLINE = re.compile(r"`([^`\n]{3,240})`")
|
|
69
|
+
_COMMANDISH = re.compile(
|
|
70
|
+
r"^\s*(\$\s+|>\s+|PS>\s+)?(npm|yarn|pnpm|bun|npx|node|deno|python3?|pip3?|uv|bash|sh|zsh|\./|source|\.\s|"
|
|
71
|
+
r"powershell|pwsh|make|docker|docker-compose|curl|wget|iwr|go\s+run|cargo\s+run|ruby|php)\b"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AutoloadedConfigAnalyzer:
|
|
76
|
+
name = "config-as-code"
|
|
77
|
+
|
|
78
|
+
def applies(self, fv: FileView) -> bool:
|
|
79
|
+
if fv.record.vendored or fv.text is None:
|
|
80
|
+
return False
|
|
81
|
+
return _is_config_as_code(fv.rel) or "/.husky/" in f"/{fv.rel}"
|
|
82
|
+
|
|
83
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
84
|
+
if "/.husky/" in f"/{fv.rel}":
|
|
85
|
+
if not fv.name.startswith("_") and fv.name not in (".gitignore",):
|
|
86
|
+
ctx.add_trigger("husky-hook", fv.rel, line=1, label=fv.name, autorun=False, references=[fv.rel])
|
|
87
|
+
return []
|
|
88
|
+
ctx.autoloaded.add(fv.rel)
|
|
89
|
+
ctx.add_trigger("config-as-code", fv.rel, line=1, label=fv.name, autorun=True, references=[fv.rel])
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_config_as_code(rel: str) -> bool:
|
|
94
|
+
name = rel.rsplit("/", 1)[-1]
|
|
95
|
+
for pat in CONFIG_AS_CODE:
|
|
96
|
+
target = rel if "/" in pat else name
|
|
97
|
+
if fnmatch.fnmatchcase(target, pat) and name.endswith(_CODE_SUFFIXES):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DocInstructionAnalyzer:
|
|
103
|
+
"""Registers commands from README-like files as manual triggers (no findings of its own)."""
|
|
104
|
+
|
|
105
|
+
name = "doc-instructions"
|
|
106
|
+
|
|
107
|
+
def applies(self, fv: FileView) -> bool:
|
|
108
|
+
return fv.text is not None and not fv.record.vendored and bool(_DOC_NAMES.match(fv.name))
|
|
109
|
+
|
|
110
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
111
|
+
in_fence = False
|
|
112
|
+
fence_lang = ""
|
|
113
|
+
count = 0
|
|
114
|
+
for i, line in enumerate(fv.lines, 1):
|
|
115
|
+
m = _FENCE.match(line.strip())
|
|
116
|
+
if m:
|
|
117
|
+
in_fence = not in_fence
|
|
118
|
+
fence_lang = m.group(2).lower() if in_fence else ""
|
|
119
|
+
continue
|
|
120
|
+
commands: list[str] = []
|
|
121
|
+
if in_fence and fence_lang in _SHELL_FENCES and _COMMANDISH.match(line):
|
|
122
|
+
commands.append(line)
|
|
123
|
+
elif not in_fence:
|
|
124
|
+
commands += [c for c in _INLINE.findall(line) if _COMMANDISH.match(c)]
|
|
125
|
+
for cmd in commands:
|
|
126
|
+
cmd = re.sub(r"^\s*(\$|>|PS>)\s+", "", cmd)
|
|
127
|
+
a = analyze_command(cmd, ctx.inventory, [fv.parent])
|
|
128
|
+
if a.references or a.npm_scripts:
|
|
129
|
+
ctx.add_trigger(
|
|
130
|
+
"doc-instruction",
|
|
131
|
+
fv.rel,
|
|
132
|
+
line=i,
|
|
133
|
+
label=fv.name,
|
|
134
|
+
command=make_excerpt(cmd),
|
|
135
|
+
autorun=False,
|
|
136
|
+
references=a.references,
|
|
137
|
+
npm_scripts=a.npm_scripts,
|
|
138
|
+
)
|
|
139
|
+
count += 1
|
|
140
|
+
if count >= 50:
|
|
141
|
+
return []
|
|
142
|
+
return []
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Helpers shared by surface analyzers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
|
|
9
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
10
|
+
from devguard.analyzers.command import analyze_command, command_findings
|
|
11
|
+
from devguard.analyzers.jsonc import JsoncError, Node, parse_jsonc
|
|
12
|
+
from devguard.core.models import Finding, Severity
|
|
13
|
+
from devguard.core.redact import make_excerpt
|
|
14
|
+
from devguard.rules.catalog import build_finding, rule
|
|
15
|
+
|
|
16
|
+
R_DUPKEY = rule(
|
|
17
|
+
"DG-JSONC-DUPKEY-001",
|
|
18
|
+
"Duplicate key in configuration file",
|
|
19
|
+
"medium",
|
|
20
|
+
"high",
|
|
21
|
+
"config",
|
|
22
|
+
"TC-01.6",
|
|
23
|
+
"A key appears twice in the same object. Tools such as VS Code use the last value, so a harmless "
|
|
24
|
+
"first value can hide a different effective one.",
|
|
25
|
+
"Usually an editing mistake.",
|
|
26
|
+
)
|
|
27
|
+
R_INVALID = rule(
|
|
28
|
+
"DG-JSONC-INVALID-001",
|
|
29
|
+
"Configuration file could not be parsed",
|
|
30
|
+
"info",
|
|
31
|
+
"high",
|
|
32
|
+
"config",
|
|
33
|
+
"TC-01.6",
|
|
34
|
+
"The file is not valid JSON/JSONC, so its structured checks were skipped. Tools may still read it "
|
|
35
|
+
"with a more lenient parser.",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_jsonc(fv: FileView, ctx: AnalysisContext, findings: list[Finding]) -> Node | None:
|
|
40
|
+
if fv.text is None:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
root, dups = parse_jsonc(fv.text)
|
|
44
|
+
except JsoncError as exc:
|
|
45
|
+
findings.append(build_finding(R_INVALID.id, [fv.evidence_at_line(exc.line)], detail=str(exc)))
|
|
46
|
+
return None
|
|
47
|
+
for key, first, dup in dups:
|
|
48
|
+
findings.append(
|
|
49
|
+
build_finding(
|
|
50
|
+
R_DUPKEY.id,
|
|
51
|
+
[fv.evidence_at_line(first), fv.evidence_at_line(dup)],
|
|
52
|
+
detail=f"Key {make_excerpt(key, limit=80)!r} on lines {first} and {dup}.",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
return root
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def command_text(node: Node | None) -> str | None:
|
|
59
|
+
"""A command may be a string, an argv array, or (devcontainer) an object of named commands."""
|
|
60
|
+
if node is None:
|
|
61
|
+
return None
|
|
62
|
+
if isinstance(node.value, str):
|
|
63
|
+
return node.value
|
|
64
|
+
if isinstance(node.value, list):
|
|
65
|
+
parts = [str(n.value) for n in node.value if isinstance(n.value, (str, int, float))]
|
|
66
|
+
return shlex.join(parts) if parts else None
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def args_text(node: Node | None) -> list[str]:
|
|
71
|
+
out: list[str] = []
|
|
72
|
+
for el in node.elements() if node else []:
|
|
73
|
+
if isinstance(el.value, str):
|
|
74
|
+
out.append(el.value)
|
|
75
|
+
elif isinstance(el.value, dict):
|
|
76
|
+
quoted = el.get("value") # VS Code {"value": "...", "quoting": "..."} form
|
|
77
|
+
if quoted is not None and isinstance(quoted.value, str):
|
|
78
|
+
out.append(quoted.value)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def workspace_root_of(rel: str) -> str:
|
|
83
|
+
"""Directory that `${workspaceFolder}` refers to for a file inside `.vscode/` (or next to it)."""
|
|
84
|
+
p = PurePosixPath(rel)
|
|
85
|
+
parent = p.parent
|
|
86
|
+
if parent.name in (".vscode", ".devcontainer", ".claude", ".cursor", ".gemini", ".windsurf", ".github"):
|
|
87
|
+
parent = parent.parent
|
|
88
|
+
s = str(parent)
|
|
89
|
+
return "" if s == "." else s
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def assess_and_trigger(
|
|
93
|
+
ctx: AnalysisContext,
|
|
94
|
+
fv: FileView,
|
|
95
|
+
command: str,
|
|
96
|
+
line: int | None,
|
|
97
|
+
*,
|
|
98
|
+
kind: str,
|
|
99
|
+
label: str,
|
|
100
|
+
autorun: bool,
|
|
101
|
+
context: str,
|
|
102
|
+
base_dirs: list[str],
|
|
103
|
+
) -> list[Finding]:
|
|
104
|
+
a = analyze_command(command, ctx.inventory, base_dirs)
|
|
105
|
+
ctx.add_trigger(
|
|
106
|
+
kind,
|
|
107
|
+
fv.rel,
|
|
108
|
+
line=line,
|
|
109
|
+
label=label,
|
|
110
|
+
command=make_excerpt(command),
|
|
111
|
+
autorun=autorun,
|
|
112
|
+
references=a.references,
|
|
113
|
+
npm_scripts=a.npm_scripts,
|
|
114
|
+
)
|
|
115
|
+
return command_findings(a, fv, line, autorun=autorun, context=context)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- environment variables
|
|
119
|
+
|
|
120
|
+
R_ENV_INJECT = rule(
|
|
121
|
+
"DG-ENV-INJECTION-001",
|
|
122
|
+
"Environment variable injects code into processes",
|
|
123
|
+
"high",
|
|
124
|
+
"high",
|
|
125
|
+
"env",
|
|
126
|
+
"TC-10.3",
|
|
127
|
+
"The variable makes interpreters or the dynamic linker load extra code (for example NODE_OPTIONS=--require, "
|
|
128
|
+
"LD_PRELOAD, JAVA_TOOL_OPTIONS=-javaagent, BASH_ENV), so every matching process runs it.",
|
|
129
|
+
"Debugging setups occasionally use these; in a cloned project they are a strong warning sign.",
|
|
130
|
+
)
|
|
131
|
+
R_ENV_EXEC = rule(
|
|
132
|
+
"DG-ENV-EXEC-VAR-001",
|
|
133
|
+
"Environment variable sets an executable or command",
|
|
134
|
+
"medium",
|
|
135
|
+
"medium",
|
|
136
|
+
"env",
|
|
137
|
+
"TC-10.3",
|
|
138
|
+
"The variable tells a tool (git, ssh, the shell prompt) to run a command or program.",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
_INJECTION_VARS: dict[str, re.Pattern[str] | None] = {
|
|
142
|
+
"NODE_OPTIONS": re.compile(r"(^|\s)(--require|-r|--import|--loader|--experimental-loader)\b"),
|
|
143
|
+
"LD_PRELOAD": None,
|
|
144
|
+
"LD_AUDIT": None,
|
|
145
|
+
"DYLD_INSERT_LIBRARIES": None,
|
|
146
|
+
"JAVA_TOOL_OPTIONS": re.compile(r"-javaagent|-agentpath|-agentlib"),
|
|
147
|
+
"_JAVA_OPTIONS": re.compile(r"-javaagent|-agentpath|-agentlib"),
|
|
148
|
+
"JDK_JAVA_OPTIONS": re.compile(r"-javaagent|-agentpath|-agentlib"),
|
|
149
|
+
"BASH_ENV": None,
|
|
150
|
+
"ENV": None,
|
|
151
|
+
"PROMPT_COMMAND": None,
|
|
152
|
+
"PERL5OPT": None,
|
|
153
|
+
"RUBYOPT": re.compile(r"-r"),
|
|
154
|
+
"PYTHONSTARTUP": None,
|
|
155
|
+
"ELECTRON_RUN_AS_NODE": None,
|
|
156
|
+
}
|
|
157
|
+
_EXEC_VARS = {
|
|
158
|
+
"GIT_SSH_COMMAND",
|
|
159
|
+
"GIT_SSH",
|
|
160
|
+
"GIT_ASKPASS",
|
|
161
|
+
"SSH_ASKPASS",
|
|
162
|
+
"GIT_EXTERNAL_DIFF",
|
|
163
|
+
"GIT_PAGER",
|
|
164
|
+
"GIT_EDITOR",
|
|
165
|
+
"EDITOR",
|
|
166
|
+
"VISUAL",
|
|
167
|
+
"PAGER",
|
|
168
|
+
"NODE_PATH",
|
|
169
|
+
"PYTHONPATH",
|
|
170
|
+
"PYTHONHOME",
|
|
171
|
+
"LD_LIBRARY_PATH",
|
|
172
|
+
"DYLD_LIBRARY_PATH",
|
|
173
|
+
"npm_config_script_shell",
|
|
174
|
+
"NPM_CONFIG_SCRIPT_SHELL",
|
|
175
|
+
"npm_config_node_options",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def env_findings(
|
|
180
|
+
ctx: AnalysisContext,
|
|
181
|
+
fv: FileView,
|
|
182
|
+
pairs: list[tuple[str, str, int | None]],
|
|
183
|
+
*,
|
|
184
|
+
context: str,
|
|
185
|
+
autorun: bool,
|
|
186
|
+
base_dirs: list[str],
|
|
187
|
+
) -> list[Finding]:
|
|
188
|
+
out: list[Finding] = []
|
|
189
|
+
for name, value, line in pairs:
|
|
190
|
+
key = name.strip()
|
|
191
|
+
injection = key.upper() in _INJECTION_VARS
|
|
192
|
+
pattern = _INJECTION_VARS.get(key.upper())
|
|
193
|
+
if injection and value.strip() and (pattern is None or pattern.search(value)):
|
|
194
|
+
sev = Severity.HIGH.bump(1) if autorun else Severity.HIGH
|
|
195
|
+
out.append(
|
|
196
|
+
build_finding(
|
|
197
|
+
R_ENV_INJECT.id,
|
|
198
|
+
[fv.evidence_at_line(line)],
|
|
199
|
+
severity=sev,
|
|
200
|
+
detail=f"{key}={make_excerpt(value, limit=120)} ({context}).",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
a = analyze_command(value, ctx.inventory, base_dirs)
|
|
204
|
+
ctx.add_trigger(
|
|
205
|
+
"env-injection",
|
|
206
|
+
fv.rel,
|
|
207
|
+
line=line,
|
|
208
|
+
label=key,
|
|
209
|
+
command=make_excerpt(value),
|
|
210
|
+
autorun=autorun,
|
|
211
|
+
references=a.references,
|
|
212
|
+
)
|
|
213
|
+
elif key in _EXEC_VARS and value.strip():
|
|
214
|
+
out.append(
|
|
215
|
+
build_finding(
|
|
216
|
+
R_ENV_EXEC.id,
|
|
217
|
+
[fv.evidence_at_line(line)],
|
|
218
|
+
detail=f"{key}={make_excerpt(value, limit=120)} ({context}).",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
out.extend(
|
|
222
|
+
assess_and_trigger(
|
|
223
|
+
ctx,
|
|
224
|
+
fv,
|
|
225
|
+
value,
|
|
226
|
+
line,
|
|
227
|
+
kind="env-exec",
|
|
228
|
+
label=key,
|
|
229
|
+
autorun=autorun,
|
|
230
|
+
context=f"{context}: {key}",
|
|
231
|
+
base_dirs=base_dirs,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def env_pairs_from_node(node: Node | None) -> list[tuple[str, str, int | None]]:
|
|
238
|
+
pairs: list[tuple[str, str, int | None]] = []
|
|
239
|
+
for k, v in node.items() if node else []:
|
|
240
|
+
if isinstance(v.value, (str, int, float)):
|
|
241
|
+
pairs.append((k, str(v.value), v.line))
|
|
242
|
+
return pairs
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Dependency sources: off-registry, VCS, URL, and file dependencies in manifests and lockfiles (DEP-001, DEP-002)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from devguard.analyzers.base import AnalysisContext, FileView
|
|
8
|
+
from devguard.analyzers.jsonc import Node
|
|
9
|
+
from devguard.analyzers.surfaces.common import load_jsonc
|
|
10
|
+
from devguard.core.models import Finding, Severity
|
|
11
|
+
from devguard.core.redact import make_excerpt
|
|
12
|
+
from devguard.rules.catalog import build_finding, rule
|
|
13
|
+
|
|
14
|
+
R_NONREGISTRY = rule(
|
|
15
|
+
"DG-DEP-NONREGISTRY-001",
|
|
16
|
+
"Dependency installed from outside the package registry",
|
|
17
|
+
"low",
|
|
18
|
+
"medium",
|
|
19
|
+
"deps",
|
|
20
|
+
"TC-11.3",
|
|
21
|
+
"A dependency is fetched from a Git repository, URL, or local path instead of the registry. Its code is "
|
|
22
|
+
"not covered by registry malware scanning and can change at any time.",
|
|
23
|
+
"Monorepos (file:/workspace:) and forks pinned to a commit are common.",
|
|
24
|
+
)
|
|
25
|
+
R_LOCK_OFFREG = rule(
|
|
26
|
+
"DG-DEP-LOCK-OFFREGISTRY-001",
|
|
27
|
+
"Lockfile resolves packages from an unexpected host",
|
|
28
|
+
"medium",
|
|
29
|
+
"medium",
|
|
30
|
+
"deps",
|
|
31
|
+
"TC-11.3, TC-11.5",
|
|
32
|
+
"The lockfile downloads packages from a host other than the public registry. A tampered lockfile can "
|
|
33
|
+
"silently swap a dependency for a malicious tarball.",
|
|
34
|
+
"Private registries and mirrors appear here legitimately.",
|
|
35
|
+
)
|
|
36
|
+
R_PIP_INDEX = rule(
|
|
37
|
+
"DG-DEP-PIP-INDEX-001",
|
|
38
|
+
"pip package index overridden",
|
|
39
|
+
"medium",
|
|
40
|
+
"medium",
|
|
41
|
+
"deps",
|
|
42
|
+
"TC-11.4",
|
|
43
|
+
"The requirements file changes where pip downloads packages from (--index-url / --extra-index-url / "
|
|
44
|
+
"--trusted-host). `--extra-index-url` in particular enables dependency-confusion attacks.",
|
|
45
|
+
"Companies use private indexes.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_NPM_SPEC_OFFREG = re.compile(r"^(git\+|git:|github:|gitlab:|bitbucket:|https?://|file:|link:|[\w.-]+/[\w.-]+(#.*)?$)")
|
|
49
|
+
_OK_HOSTS = (
|
|
50
|
+
"https://registry.npmjs.org/",
|
|
51
|
+
"https://registry.yarnpkg.com/",
|
|
52
|
+
"https://pypi.org/",
|
|
53
|
+
"https://files.pythonhosted.org/",
|
|
54
|
+
"https://registry.npmmirror.com/",
|
|
55
|
+
)
|
|
56
|
+
_RAW_IP = re.compile(r"^(https?|git)\+?\w*://(\d{1,3}\.){3}\d{1,3}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def package_json_dependency_findings(root: Node, fv: FileView) -> list[Finding]:
|
|
60
|
+
out: list[Finding] = []
|
|
61
|
+
for section in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
|
|
62
|
+
deps = root.get(section)
|
|
63
|
+
for name, spec in deps.items() if deps else []:
|
|
64
|
+
if not isinstance(spec.value, str):
|
|
65
|
+
continue
|
|
66
|
+
v = spec.value.strip()
|
|
67
|
+
if v.startswith(("workspace:", "npm:", "link:")) or (v.startswith("file:") and ".." not in v):
|
|
68
|
+
continue
|
|
69
|
+
if _NPM_SPEC_OFFREG.match(v):
|
|
70
|
+
sev = Severity.MEDIUM if _RAW_IP.match(v) or v.startswith("http://") else None
|
|
71
|
+
out.append(
|
|
72
|
+
build_finding(
|
|
73
|
+
R_NONREGISTRY.id,
|
|
74
|
+
[fv.evidence_at_line(spec.line)],
|
|
75
|
+
severity=sev,
|
|
76
|
+
detail=f"{section}: {make_excerpt(name, limit=60)} → {make_excerpt(v, limit=120)}",
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LockfileAnalyzer:
|
|
83
|
+
name = "lockfiles"
|
|
84
|
+
|
|
85
|
+
_RESOLVED = re.compile(r'"?(resolved|tarball)"?\s*[:=]?\s*"?((?:https?|git\+[\w]+|git|ssh)://[^"\s,]+)')
|
|
86
|
+
|
|
87
|
+
def applies(self, fv: FileView) -> bool:
|
|
88
|
+
return (
|
|
89
|
+
fv.text is not None
|
|
90
|
+
and not fv.record.vendored
|
|
91
|
+
and (
|
|
92
|
+
fv.name in ("package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock")
|
|
93
|
+
or (fv.name.startswith("requirements") and fv.suffix in (".txt", ".in"))
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def analyze(self, fv: FileView, ctx: AnalysisContext) -> list[Finding]:
|
|
98
|
+
if fv.name.startswith("requirements"):
|
|
99
|
+
return self._requirements(fv)
|
|
100
|
+
if fv.name in ("package-lock.json", "npm-shrinkwrap.json"):
|
|
101
|
+
out: list[Finding] = []
|
|
102
|
+
root = load_jsonc(fv, ctx, out)
|
|
103
|
+
if root is None:
|
|
104
|
+
return out
|
|
105
|
+
hosts: dict[str, int] = {}
|
|
106
|
+
packages = root.get("packages")
|
|
107
|
+
for _, pkg in packages.items() if packages else []:
|
|
108
|
+
r = pkg.get("resolved")
|
|
109
|
+
if r is not None and isinstance(r.value, str) and not r.value.startswith(_OK_HOSTS):
|
|
110
|
+
hosts.setdefault(r.value, r.line)
|
|
111
|
+
return out + self._report(fv, hosts)
|
|
112
|
+
hosts = {}
|
|
113
|
+
for i, line in enumerate(fv.lines, 1):
|
|
114
|
+
for m in self._RESOLVED.finditer(line):
|
|
115
|
+
url = m.group(2)
|
|
116
|
+
if not url.startswith(_OK_HOSTS):
|
|
117
|
+
hosts.setdefault(url, i)
|
|
118
|
+
return self._report(fv, hosts)
|
|
119
|
+
|
|
120
|
+
def _report(self, fv: FileView, urls: dict[str, int]) -> list[Finding]:
|
|
121
|
+
by_host: dict[str, tuple[int, list[str]]] = {}
|
|
122
|
+
for url, line in urls.items():
|
|
123
|
+
host = re.sub(r"^[\w+]+://", "", url).split("/", 1)[0]
|
|
124
|
+
entry = by_host.setdefault(host, (line, []))
|
|
125
|
+
entry[1].append(url)
|
|
126
|
+
out = []
|
|
127
|
+
for host, (line, list_) in sorted(by_host.items()):
|
|
128
|
+
sev = (
|
|
129
|
+
Severity.HIGH
|
|
130
|
+
if re.match(r"^(\d{1,3}\.){3}\d{1,3}", host) or any(u.startswith("http://") for u in list_)
|
|
131
|
+
else None
|
|
132
|
+
)
|
|
133
|
+
out.append(
|
|
134
|
+
build_finding(
|
|
135
|
+
R_LOCK_OFFREG.id,
|
|
136
|
+
[fv.evidence_at_line(line)],
|
|
137
|
+
severity=sev,
|
|
138
|
+
detail=f"{len(list_)} package(s) from {make_excerpt(host, limit=80)}.",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
def _requirements(self, fv: FileView) -> list[Finding]:
|
|
144
|
+
out: list[Finding] = []
|
|
145
|
+
for i, line in enumerate(fv.lines, 1):
|
|
146
|
+
s = line.strip()
|
|
147
|
+
if re.match(r"^(-i|--index-url|--extra-index-url|--trusted-host|-f|--find-links)\b", s):
|
|
148
|
+
value = s.split(None, 1)[1] if " " in s else s.split("=", 1)[-1]
|
|
149
|
+
if value.strip().rstrip("/").startswith("https://pypi.org/simple"):
|
|
150
|
+
continue
|
|
151
|
+
sev = Severity.HIGH if "extra-index-url" in s and _RAW_IP.match(value.strip()) else None
|
|
152
|
+
out.append(build_finding(R_PIP_INDEX.id, [fv.evidence_at_line(i)], severity=sev))
|
|
153
|
+
elif re.search(r"(^|@\s*)(git\+|https?://|file:)", s) and not s.startswith("#"):
|
|
154
|
+
out.append(
|
|
155
|
+
build_finding(
|
|
156
|
+
R_NONREGISTRY.id, [fv.evidence_at_line(i)], detail=f"requirement: {make_excerpt(s, limit=120)}"
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return out
|