devguard-scanner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. devguard/__init__.py +5 -0
  2. devguard/ai/__init__.py +0 -0
  3. devguard/ai/investigator.py +380 -0
  4. devguard/ai/prompts.py +113 -0
  5. devguard/ai/providers/__init__.py +0 -0
  6. devguard/ai/providers/anthropic.py +163 -0
  7. devguard/ai/providers/openai_compat.py +157 -0
  8. devguard/ai/registry.py +160 -0
  9. devguard/ai/run.py +119 -0
  10. devguard/ai/sdk.py +102 -0
  11. devguard/ai/testing.py +126 -0
  12. devguard/ai/tools.py +389 -0
  13. devguard/analyzers/__init__.py +0 -0
  14. devguard/analyzers/base.py +135 -0
  15. devguard/analyzers/carriers.py +345 -0
  16. devguard/analyzers/command.py +343 -0
  17. devguard/analyzers/content.py +240 -0
  18. devguard/analyzers/filesystem.py +142 -0
  19. devguard/analyzers/hidden.py +273 -0
  20. devguard/analyzers/imports.py +102 -0
  21. devguard/analyzers/jsonc.py +221 -0
  22. devguard/analyzers/surfaces/__init__.py +0 -0
  23. devguard/analyzers/surfaces/ai_assistants.py +318 -0
  24. devguard/analyzers/surfaces/autoload.py +142 -0
  25. devguard/analyzers/surfaces/common.py +242 -0
  26. devguard/analyzers/surfaces/deps.py +159 -0
  27. devguard/analyzers/surfaces/devcontainer.py +195 -0
  28. devguard/analyzers/surfaces/env.py +53 -0
  29. devguard/analyzers/surfaces/git.py +236 -0
  30. devguard/analyzers/surfaces/npm.py +304 -0
  31. devguard/analyzers/surfaces/python_pkg.py +138 -0
  32. devguard/analyzers/surfaces/vscode.py +515 -0
  33. devguard/cli.py +302 -0
  34. devguard/core/__init__.py +0 -0
  35. devguard/core/config.py +147 -0
  36. devguard/core/filetype.py +313 -0
  37. devguard/core/models.py +195 -0
  38. devguard/core/redact.py +72 -0
  39. devguard/core/safefs.py +280 -0
  40. devguard/core/scoring.py +60 -0
  41. devguard/core/service.py +211 -0
  42. devguard/correlate/__init__.py +0 -0
  43. devguard/correlate/chains.py +160 -0
  44. devguard/mcp/__init__.py +0 -0
  45. devguard/mcp/server.py +442 -0
  46. devguard/report/__init__.py +0 -0
  47. devguard/report/escape.py +31 -0
  48. devguard/report/markdown.py +242 -0
  49. devguard/report/sarif.py +96 -0
  50. devguard/report/terminal.py +47 -0
  51. devguard/report/writer.py +52 -0
  52. devguard/rules/__init__.py +0 -0
  53. devguard/rules/catalog.py +96 -0
  54. devguard/rules/content/ai_instructions.yaml +111 -0
  55. devguard/rules/content/cross_language.yaml +228 -0
  56. devguard/rules/content/docs.yaml +63 -0
  57. devguard/rules/content/javascript.yaml +171 -0
  58. devguard/rules/content/obfuscation.yaml +138 -0
  59. devguard/rules/content/python.yaml +144 -0
  60. devguard/rules/content/shell.yaml +109 -0
  61. devguard/rules/content/windows.yaml +95 -0
  62. devguard/rules/data/vscode_settings.yaml +81 -0
  63. devguard_scanner-0.1.0.dist-info/METADATA +84 -0
  64. devguard_scanner-0.1.0.dist-info/RECORD +67 -0
  65. devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
  66. devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
  67. devguard_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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