deployproof 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.
- deployproof/__init__.py +3 -0
- deployproof/cli.py +288 -0
- deployproof/dependencies.py +874 -0
- deployproof/diff.py +234 -0
- deployproof/mutator.py +475 -0
- deployproof/reporter.py +281 -0
- deployproof/secrets.py +221 -0
- deployproof/symlinks.py +156 -0
- deployproof/wsl.py +147 -0
- deployproof-0.1.0.dist-info/METADATA +111 -0
- deployproof-0.1.0.dist-info/RECORD +15 -0
- deployproof-0.1.0.dist-info/WHEEL +5 -0
- deployproof-0.1.0.dist-info/entry_points.txt +2 -0
- deployproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- deployproof-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"""Dependency and import extraction for slopsquatting / hallucination scanning."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Optional, Set
|
|
11
|
+
|
|
12
|
+
# Standard library module names (Python 3.10+)
|
|
13
|
+
STDLIB_MODULES: Set[str] = getattr(sys, "stdlib_module_names", set()) | {
|
|
14
|
+
"__future__",
|
|
15
|
+
"_thread",
|
|
16
|
+
"abc",
|
|
17
|
+
"aifc",
|
|
18
|
+
"argparse",
|
|
19
|
+
"array",
|
|
20
|
+
"ast",
|
|
21
|
+
"asynchat",
|
|
22
|
+
"asyncio",
|
|
23
|
+
"asyncore",
|
|
24
|
+
"atexit",
|
|
25
|
+
"audioop",
|
|
26
|
+
"base64",
|
|
27
|
+
"bdb",
|
|
28
|
+
"binascii",
|
|
29
|
+
"binhex",
|
|
30
|
+
"bisect",
|
|
31
|
+
"builtins",
|
|
32
|
+
"bz2",
|
|
33
|
+
"calendar",
|
|
34
|
+
"cgi",
|
|
35
|
+
"cgitb",
|
|
36
|
+
"chunk",
|
|
37
|
+
"cmath",
|
|
38
|
+
"cmd",
|
|
39
|
+
"code",
|
|
40
|
+
"codecs",
|
|
41
|
+
"codeop",
|
|
42
|
+
"collections",
|
|
43
|
+
"colorsys",
|
|
44
|
+
"compileall",
|
|
45
|
+
"concurrent",
|
|
46
|
+
"configparser",
|
|
47
|
+
"contextlib",
|
|
48
|
+
"contextvars",
|
|
49
|
+
"copy",
|
|
50
|
+
"copyreg",
|
|
51
|
+
"cProfile",
|
|
52
|
+
"crypt",
|
|
53
|
+
"csv",
|
|
54
|
+
"ctypes",
|
|
55
|
+
"curses",
|
|
56
|
+
"dataclasses",
|
|
57
|
+
"datetime",
|
|
58
|
+
"dbm",
|
|
59
|
+
"decimal",
|
|
60
|
+
"difflib",
|
|
61
|
+
"dis",
|
|
62
|
+
"distutils",
|
|
63
|
+
"doctest",
|
|
64
|
+
"email",
|
|
65
|
+
"encodings",
|
|
66
|
+
"enum",
|
|
67
|
+
"errno",
|
|
68
|
+
"faulthandler",
|
|
69
|
+
"fcntl",
|
|
70
|
+
"filecmp",
|
|
71
|
+
"fileinput",
|
|
72
|
+
"fnmatch",
|
|
73
|
+
"fractions",
|
|
74
|
+
"ftplib",
|
|
75
|
+
"functools",
|
|
76
|
+
"gc",
|
|
77
|
+
"getopt",
|
|
78
|
+
"getpass",
|
|
79
|
+
"gettext",
|
|
80
|
+
"glob",
|
|
81
|
+
"grp",
|
|
82
|
+
"gzip",
|
|
83
|
+
"hashlib",
|
|
84
|
+
"heapq",
|
|
85
|
+
"hmac",
|
|
86
|
+
"html",
|
|
87
|
+
"http",
|
|
88
|
+
"idlelib",
|
|
89
|
+
"imaplib",
|
|
90
|
+
"imghdr",
|
|
91
|
+
"imp",
|
|
92
|
+
"importlib",
|
|
93
|
+
"inspect",
|
|
94
|
+
"io",
|
|
95
|
+
"ipaddress",
|
|
96
|
+
"itertools",
|
|
97
|
+
"json",
|
|
98
|
+
"keyword",
|
|
99
|
+
"lib2to3",
|
|
100
|
+
"linecache",
|
|
101
|
+
"locale",
|
|
102
|
+
"logging",
|
|
103
|
+
"lzma",
|
|
104
|
+
"mailbox",
|
|
105
|
+
"mailcap",
|
|
106
|
+
"marshal",
|
|
107
|
+
"math",
|
|
108
|
+
"mimetypes",
|
|
109
|
+
"mmap",
|
|
110
|
+
"modulefinder",
|
|
111
|
+
"msilib",
|
|
112
|
+
"msvcrt",
|
|
113
|
+
"multiprocessing",
|
|
114
|
+
"netrc",
|
|
115
|
+
"nntplib",
|
|
116
|
+
"numbers",
|
|
117
|
+
"operator",
|
|
118
|
+
"optparse",
|
|
119
|
+
"os",
|
|
120
|
+
"ossaudiodev",
|
|
121
|
+
"parser",
|
|
122
|
+
"pathlib",
|
|
123
|
+
"pdb",
|
|
124
|
+
"pickle",
|
|
125
|
+
"pickletools",
|
|
126
|
+
"pipes",
|
|
127
|
+
"pkgutil",
|
|
128
|
+
"platform",
|
|
129
|
+
"plistlib",
|
|
130
|
+
"poplib",
|
|
131
|
+
"posix",
|
|
132
|
+
"posixpath",
|
|
133
|
+
"pprint",
|
|
134
|
+
"profile",
|
|
135
|
+
"pstats",
|
|
136
|
+
"pty",
|
|
137
|
+
"pwd",
|
|
138
|
+
"py_compile",
|
|
139
|
+
"pyclbr",
|
|
140
|
+
"pydoc",
|
|
141
|
+
"queue",
|
|
142
|
+
"quopri",
|
|
143
|
+
"random",
|
|
144
|
+
"re",
|
|
145
|
+
"readline",
|
|
146
|
+
"reprlib",
|
|
147
|
+
"resource",
|
|
148
|
+
"rlcompleter",
|
|
149
|
+
"runpy",
|
|
150
|
+
"sched",
|
|
151
|
+
"secrets",
|
|
152
|
+
"select",
|
|
153
|
+
"selectors",
|
|
154
|
+
"shelve",
|
|
155
|
+
"shlex",
|
|
156
|
+
"shutil",
|
|
157
|
+
"signal",
|
|
158
|
+
"site",
|
|
159
|
+
"smtpd",
|
|
160
|
+
"smtplib",
|
|
161
|
+
"sndhdr",
|
|
162
|
+
"socket",
|
|
163
|
+
"socketserver",
|
|
164
|
+
"spwd",
|
|
165
|
+
"sqlite3",
|
|
166
|
+
"ssl",
|
|
167
|
+
"stat",
|
|
168
|
+
"statistics",
|
|
169
|
+
"string",
|
|
170
|
+
"stringprep",
|
|
171
|
+
"struct",
|
|
172
|
+
"subprocess",
|
|
173
|
+
"sunau",
|
|
174
|
+
"symbol",
|
|
175
|
+
"symtable",
|
|
176
|
+
"sys",
|
|
177
|
+
"sysconfig",
|
|
178
|
+
"syslog",
|
|
179
|
+
"tabnanny",
|
|
180
|
+
"tarfile",
|
|
181
|
+
"telnetlib",
|
|
182
|
+
"tempfile",
|
|
183
|
+
"termios",
|
|
184
|
+
"test",
|
|
185
|
+
"textwrap",
|
|
186
|
+
"threading",
|
|
187
|
+
"time",
|
|
188
|
+
"timeit",
|
|
189
|
+
"tkinter",
|
|
190
|
+
"token",
|
|
191
|
+
"tokenize",
|
|
192
|
+
"tomllib",
|
|
193
|
+
"trace",
|
|
194
|
+
"traceback",
|
|
195
|
+
"tracemalloc",
|
|
196
|
+
"tty",
|
|
197
|
+
"turtle",
|
|
198
|
+
"turtledemo",
|
|
199
|
+
"types",
|
|
200
|
+
"typing",
|
|
201
|
+
"unicodedata",
|
|
202
|
+
"unittest",
|
|
203
|
+
"urllib",
|
|
204
|
+
"uu",
|
|
205
|
+
"uuid",
|
|
206
|
+
"venv",
|
|
207
|
+
"warnings",
|
|
208
|
+
"wave",
|
|
209
|
+
"weakref",
|
|
210
|
+
"webbrowser",
|
|
211
|
+
"winreg",
|
|
212
|
+
"winsound",
|
|
213
|
+
"wsgiref",
|
|
214
|
+
"xdrlib",
|
|
215
|
+
"xml",
|
|
216
|
+
"xmlrpc",
|
|
217
|
+
"zipapp",
|
|
218
|
+
"zipfile",
|
|
219
|
+
"zipimport",
|
|
220
|
+
"zlib",
|
|
221
|
+
"zoneinfo",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# Mapping of common top-level Python import names to their PyPI distribution package names
|
|
225
|
+
IMPORT_TO_PYPI_MAP: Dict[str, str] = {
|
|
226
|
+
"yaml": "PyYAML",
|
|
227
|
+
"PIL": "Pillow",
|
|
228
|
+
"cv2": "opencv-python",
|
|
229
|
+
"sklearn": "scikit-learn",
|
|
230
|
+
"bs4": "beautifulsoup4",
|
|
231
|
+
"dotenv": "python-dotenv",
|
|
232
|
+
"attr": "attrs",
|
|
233
|
+
"jwt": "PyJWT",
|
|
234
|
+
"serial": "pyserial",
|
|
235
|
+
"usb": "pyusb",
|
|
236
|
+
"websocket": "websocket-client",
|
|
237
|
+
"magic": "python-magic",
|
|
238
|
+
"dateutil": "python-dateutil",
|
|
239
|
+
"fitz": "PyMuPDF",
|
|
240
|
+
"Crypto": "pycryptodome",
|
|
241
|
+
"Cryptodome": "pycryptodome",
|
|
242
|
+
"jose": "python-jose",
|
|
243
|
+
"docx": "python-docx",
|
|
244
|
+
"pptx": "python-pptx",
|
|
245
|
+
"git": "GitPython",
|
|
246
|
+
"Bio": "biopython",
|
|
247
|
+
"psycopg2": "psycopg2",
|
|
248
|
+
"google": "protobuf",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
MANIFEST_FILENAMES = {
|
|
252
|
+
"pyproject.toml",
|
|
253
|
+
"requirements.txt",
|
|
254
|
+
"setup.py",
|
|
255
|
+
"setup.cfg",
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class ExtractedDependency:
|
|
261
|
+
"""Represents a newly introduced external dependency."""
|
|
262
|
+
|
|
263
|
+
name: str # Normalized PyPI package name
|
|
264
|
+
import_name: str # Raw import name in code / manifest
|
|
265
|
+
source_file: Path # File where dependency was found
|
|
266
|
+
lineno: Optional[int] = None # Line number in file
|
|
267
|
+
source_type: str = "import" # "import" | "requirements.txt" | "pyproject.toml" | "setup.py"
|
|
268
|
+
unscanned_reason: Optional[str] = None # Reason if source is seen but not checked against PyPI
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_local_module_names(root: Path) -> Set[str]:
|
|
272
|
+
"""
|
|
273
|
+
Discover all local module and package names defined within the workspace repository.
|
|
274
|
+
|
|
275
|
+
Identifies root and src/ Python files, folders containing __init__.py or .py files,
|
|
276
|
+
and configured project package names to prevent flagging local imports.
|
|
277
|
+
"""
|
|
278
|
+
local_names: Set[str] = set()
|
|
279
|
+
|
|
280
|
+
search_dirs = [root, root / "src", root / "lib", root / "app"]
|
|
281
|
+
for s_dir in search_dirs:
|
|
282
|
+
if not s_dir.is_dir():
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
for entry in s_dir.iterdir():
|
|
286
|
+
if entry.name.startswith((".", "_")) and entry.name != "__init__.py":
|
|
287
|
+
continue
|
|
288
|
+
if entry.is_file() and entry.suffix == ".py":
|
|
289
|
+
local_names.add(entry.stem)
|
|
290
|
+
elif entry.is_dir():
|
|
291
|
+
# Check if directory contains python files or __init__.py
|
|
292
|
+
has_py = any(p.suffix == ".py" for p in entry.glob("*.py"))
|
|
293
|
+
if has_py or (entry / "__init__.py").exists():
|
|
294
|
+
local_names.add(entry.name)
|
|
295
|
+
except (PermissionError, OSError):
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
# Also scan for all subfolders in the root tree that have __init__.py or py files (up to depth 3)
|
|
299
|
+
try:
|
|
300
|
+
for py_file in root.glob("*/*.py"):
|
|
301
|
+
if not any(part.startswith(".") or part in ("venv", ".venv", "build", "dist", "node_modules") for part in py_file.parts):
|
|
302
|
+
local_names.add(py_file.parent.name)
|
|
303
|
+
except (PermissionError, OSError):
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
return local_names
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def get_added_linenos_for_file(file_path: Path, root: Path, base: Optional[str] = None) -> Optional[Set[int]]:
|
|
310
|
+
"""
|
|
311
|
+
Get the set of 1-indexed line numbers added to a file based on git diff.
|
|
312
|
+
|
|
313
|
+
Returns None if the file is untracked/new (meaning all lines are new).
|
|
314
|
+
"""
|
|
315
|
+
try:
|
|
316
|
+
rel_path = file_path.relative_to(root)
|
|
317
|
+
except ValueError:
|
|
318
|
+
rel_path = file_path
|
|
319
|
+
|
|
320
|
+
# Check if untracked in git status
|
|
321
|
+
res_status = subprocess.run(
|
|
322
|
+
["git", "status", "--porcelain", "--", str(rel_path)],
|
|
323
|
+
cwd=root,
|
|
324
|
+
capture_output=True,
|
|
325
|
+
text=True,
|
|
326
|
+
encoding="utf-8",
|
|
327
|
+
errors="replace",
|
|
328
|
+
)
|
|
329
|
+
if res_status.returncode == 0:
|
|
330
|
+
output = res_status.stdout.strip()
|
|
331
|
+
if output.startswith("??"):
|
|
332
|
+
return None # Entirely new/untracked file
|
|
333
|
+
|
|
334
|
+
# Run git diff to find added lines
|
|
335
|
+
diff_cmd = ["git", "diff", "-U0"]
|
|
336
|
+
if base:
|
|
337
|
+
diff_cmd.append(base)
|
|
338
|
+
else:
|
|
339
|
+
diff_cmd.append("HEAD")
|
|
340
|
+
diff_cmd.extend(["--", str(rel_path)])
|
|
341
|
+
|
|
342
|
+
res_diff = subprocess.run(
|
|
343
|
+
diff_cmd,
|
|
344
|
+
cwd=root,
|
|
345
|
+
capture_output=True,
|
|
346
|
+
text=True,
|
|
347
|
+
encoding="utf-8",
|
|
348
|
+
errors="replace",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if res_diff.returncode != 0:
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
diff_text = res_diff.stdout
|
|
355
|
+
if not diff_text.strip():
|
|
356
|
+
# Diff against HEAD is empty, check cached (staged) diff
|
|
357
|
+
res_cached = subprocess.run(
|
|
358
|
+
["git", "diff", "--cached", "-U0", "--", str(rel_path)],
|
|
359
|
+
cwd=root,
|
|
360
|
+
capture_output=True,
|
|
361
|
+
text=True,
|
|
362
|
+
encoding="utf-8",
|
|
363
|
+
errors="replace",
|
|
364
|
+
)
|
|
365
|
+
if res_cached.returncode == 0:
|
|
366
|
+
diff_text = res_cached.stdout
|
|
367
|
+
|
|
368
|
+
if not diff_text.strip():
|
|
369
|
+
# No diff detected, treat all lines as relevant if in session
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
added_lines: Set[int] = set()
|
|
373
|
+
# Parse unified diff hunk headers: @@ -old,count +new,count @@
|
|
374
|
+
hunk_re = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
375
|
+
current_line = 0
|
|
376
|
+
in_hunk = False
|
|
377
|
+
|
|
378
|
+
for line in diff_text.splitlines():
|
|
379
|
+
hunk_m = hunk_re.match(line)
|
|
380
|
+
if hunk_m:
|
|
381
|
+
in_hunk = True
|
|
382
|
+
current_line = int(hunk_m.group(1))
|
|
383
|
+
count = int(hunk_m.group(2)) if hunk_m.group(2) is not None else 1
|
|
384
|
+
if count == 0:
|
|
385
|
+
in_hunk = False
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
if in_hunk:
|
|
389
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
390
|
+
added_lines.add(current_line)
|
|
391
|
+
current_line += 1
|
|
392
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
393
|
+
pass # Deleted line does not advance added line counter
|
|
394
|
+
else:
|
|
395
|
+
current_line += 1
|
|
396
|
+
|
|
397
|
+
return added_lines
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def normalize_package_name(raw_name: str) -> str:
|
|
401
|
+
"""Normalize import or package name to canonical PyPI package identifier."""
|
|
402
|
+
cleaned = raw_name.strip()
|
|
403
|
+
if cleaned in IMPORT_TO_PYPI_MAP:
|
|
404
|
+
return IMPORT_TO_PYPI_MAP[cleaned]
|
|
405
|
+
return cleaned
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def extract_new_imports_from_py_file(
|
|
409
|
+
file_path: Path,
|
|
410
|
+
root: Path,
|
|
411
|
+
local_modules: Set[str],
|
|
412
|
+
base: Optional[str] = None,
|
|
413
|
+
) -> List[ExtractedDependency]:
|
|
414
|
+
"""
|
|
415
|
+
Extract newly added external imports from a modified or new Python file.
|
|
416
|
+
|
|
417
|
+
Filters out Python standard library modules, relative imports, and local repo packages.
|
|
418
|
+
"""
|
|
419
|
+
if not file_path.is_file() or file_path.suffix != ".py":
|
|
420
|
+
return []
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
source_code = file_path.read_text(encoding="utf-8", errors="replace")
|
|
424
|
+
tree = ast.parse(source_code, filename=str(file_path))
|
|
425
|
+
except Exception:
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
added_linenos = get_added_linenos_for_file(file_path, root, base=base)
|
|
429
|
+
extracted: List[ExtractedDependency] = []
|
|
430
|
+
seen: Set[str] = set()
|
|
431
|
+
|
|
432
|
+
for node in ast.walk(tree):
|
|
433
|
+
if isinstance(node, ast.Import):
|
|
434
|
+
# Check line number
|
|
435
|
+
if added_linenos is not None and node.lineno not in added_linenos:
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
for alias in node.names:
|
|
439
|
+
top_name = alias.name.split(".")[0]
|
|
440
|
+
if top_name in STDLIB_MODULES or top_name in local_modules:
|
|
441
|
+
continue
|
|
442
|
+
pypi_name = normalize_package_name(top_name)
|
|
443
|
+
key = f"{pypi_name}:{node.lineno}"
|
|
444
|
+
if key not in seen:
|
|
445
|
+
seen.add(key)
|
|
446
|
+
extracted.append(
|
|
447
|
+
ExtractedDependency(
|
|
448
|
+
name=pypi_name,
|
|
449
|
+
import_name=alias.name,
|
|
450
|
+
source_file=file_path,
|
|
451
|
+
lineno=node.lineno,
|
|
452
|
+
source_type="import",
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
elif isinstance(node, ast.ImportFrom):
|
|
457
|
+
# Relative imports (node.level > 0) are always internal/local
|
|
458
|
+
if node.level > 0 or not node.module:
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
if added_linenos is not None and node.lineno not in added_linenos:
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
top_name = node.module.split(".")[0]
|
|
465
|
+
if top_name in STDLIB_MODULES or top_name in local_modules:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
pypi_name = normalize_package_name(top_name)
|
|
469
|
+
key = f"{pypi_name}:{node.lineno}"
|
|
470
|
+
if key not in seen:
|
|
471
|
+
seen.add(key)
|
|
472
|
+
extracted.append(
|
|
473
|
+
ExtractedDependency(
|
|
474
|
+
name=pypi_name,
|
|
475
|
+
import_name=node.module,
|
|
476
|
+
source_file=file_path,
|
|
477
|
+
lineno=node.lineno,
|
|
478
|
+
source_type="import",
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return extracted
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def extract_new_manifest_dependencies(
|
|
486
|
+
file_path: Path,
|
|
487
|
+
root: Path,
|
|
488
|
+
local_modules: Set[str],
|
|
489
|
+
base: Optional[str] = None,
|
|
490
|
+
) -> List[ExtractedDependency]:
|
|
491
|
+
"""
|
|
492
|
+
Extract newly added dependencies from manifest files (requirements.txt, pyproject.toml, setup.py).
|
|
493
|
+
"""
|
|
494
|
+
if not file_path.is_file():
|
|
495
|
+
return []
|
|
496
|
+
|
|
497
|
+
name = file_path.name.lower()
|
|
498
|
+
is_requirements = name.startswith("requirements") and name.endswith(".txt")
|
|
499
|
+
is_pyproject = name == "pyproject.toml"
|
|
500
|
+
is_setup_py = name == "setup.py"
|
|
501
|
+
is_setup_cfg = name == "setup.cfg"
|
|
502
|
+
|
|
503
|
+
if not (is_requirements or is_pyproject or is_setup_py or is_setup_cfg):
|
|
504
|
+
return []
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
508
|
+
except Exception:
|
|
509
|
+
return []
|
|
510
|
+
|
|
511
|
+
added_linenos = get_added_linenos_for_file(file_path, root, base=base)
|
|
512
|
+
extracted: List[ExtractedDependency] = []
|
|
513
|
+
lines = content.splitlines()
|
|
514
|
+
|
|
515
|
+
req_name_re = re.compile(r"^\s*([A-Za-z0-9_\-\.]+)(?:[>=<!~;\[\s]|$)")
|
|
516
|
+
dep_str_re = re.compile(r"""["']([A-Za-z0-9_\-\.]+)(?:[>=<!~;\[\s"']|$)""")
|
|
517
|
+
|
|
518
|
+
in_dependency_section = False
|
|
519
|
+
in_dependency_array = False
|
|
520
|
+
|
|
521
|
+
for idx, line in enumerate(lines, start=1):
|
|
522
|
+
raw_line = line.strip()
|
|
523
|
+
lower_line = raw_line.lower()
|
|
524
|
+
|
|
525
|
+
if is_requirements:
|
|
526
|
+
if added_linenos is not None and idx not in added_linenos:
|
|
527
|
+
continue
|
|
528
|
+
if not raw_line or raw_line.startswith("#"):
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
# Handle -r / --requirement includes
|
|
532
|
+
if raw_line.startswith(("-r ", "--requirement ")):
|
|
533
|
+
extracted.append(
|
|
534
|
+
ExtractedDependency(
|
|
535
|
+
name=raw_line,
|
|
536
|
+
import_name=raw_line,
|
|
537
|
+
source_file=file_path,
|
|
538
|
+
lineno=idx,
|
|
539
|
+
source_type="requirements.txt",
|
|
540
|
+
unscanned_reason="External requirements file include (-r) - seen, not checked",
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
# Handle VCS lines (git+, hg+, svn+, bzr+) and direct URLs
|
|
546
|
+
if any(raw_line.startswith(prefix) for prefix in ("git+", "hg+", "svn+", "bzr+", "-e git+", "-e hg+", "-e svn+", "-e bzr+", "http://", "https://", "-e http://", "-e https://")):
|
|
547
|
+
extracted.append(
|
|
548
|
+
ExtractedDependency(
|
|
549
|
+
name=raw_line,
|
|
550
|
+
import_name=raw_line,
|
|
551
|
+
source_file=file_path,
|
|
552
|
+
lineno=idx,
|
|
553
|
+
source_type="requirements.txt",
|
|
554
|
+
unscanned_reason="VCS / direct URL dependency - seen, not checked",
|
|
555
|
+
)
|
|
556
|
+
)
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
# Other pip flags like -i, -f, --extra-index-url, --find-links
|
|
560
|
+
if raw_line.startswith(("-f ", "-i ", "--", "-e ")):
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
m = req_name_re.match(raw_line)
|
|
564
|
+
if m:
|
|
565
|
+
pkg_name = m.group(1).strip()
|
|
566
|
+
if pkg_name not in STDLIB_MODULES and pkg_name not in local_modules:
|
|
567
|
+
extracted.append(
|
|
568
|
+
ExtractedDependency(
|
|
569
|
+
name=normalize_package_name(pkg_name),
|
|
570
|
+
import_name=pkg_name,
|
|
571
|
+
source_file=file_path,
|
|
572
|
+
lineno=idx,
|
|
573
|
+
source_type="requirements.txt",
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
elif is_pyproject:
|
|
578
|
+
# Check section headers
|
|
579
|
+
if raw_line.startswith("[") and raw_line.endswith("]"):
|
|
580
|
+
header = raw_line.strip("[]").strip().lower()
|
|
581
|
+
if "dependencies" in header or header.endswith(".dependencies"):
|
|
582
|
+
in_dependency_section = True
|
|
583
|
+
else:
|
|
584
|
+
in_dependency_section = False
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
# Check array openers like dependencies = [ or dev = [
|
|
588
|
+
if "dependencies" in lower_line and "=" in raw_line and "[" in raw_line:
|
|
589
|
+
in_dependency_array = True
|
|
590
|
+
elif ("requires" in lower_line or "install_requires" in lower_line) and "=" in raw_line and "[" in raw_line:
|
|
591
|
+
in_dependency_array = True
|
|
592
|
+
|
|
593
|
+
if in_dependency_array and "]" in raw_line:
|
|
594
|
+
# End of array on this line (we still parse this line below)
|
|
595
|
+
array_ended_here = True
|
|
596
|
+
else:
|
|
597
|
+
array_ended_here = False
|
|
598
|
+
|
|
599
|
+
if in_dependency_section or in_dependency_array:
|
|
600
|
+
if added_linenos is None or idx in added_linenos:
|
|
601
|
+
m = dep_str_re.search(raw_line)
|
|
602
|
+
if m:
|
|
603
|
+
pkg_name = m.group(1).strip()
|
|
604
|
+
if pkg_name.lower() not in ("dependencies", "install_requires", "requires", "version", "name"):
|
|
605
|
+
if pkg_name not in STDLIB_MODULES and pkg_name not in local_modules:
|
|
606
|
+
extracted.append(
|
|
607
|
+
ExtractedDependency(
|
|
608
|
+
name=normalize_package_name(pkg_name),
|
|
609
|
+
import_name=pkg_name,
|
|
610
|
+
source_file=file_path,
|
|
611
|
+
lineno=idx,
|
|
612
|
+
source_type=file_path.name,
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
if array_ended_here:
|
|
617
|
+
in_dependency_array = False
|
|
618
|
+
|
|
619
|
+
elif is_setup_py or is_setup_cfg:
|
|
620
|
+
if "install_requires" in lower_line or "extras_require" in lower_line:
|
|
621
|
+
in_dependency_section = True
|
|
622
|
+
if in_dependency_section and (")" in raw_line or "]" in raw_line):
|
|
623
|
+
in_dependency_section = False
|
|
624
|
+
|
|
625
|
+
if in_dependency_section:
|
|
626
|
+
if added_linenos is None or idx in added_linenos:
|
|
627
|
+
m = dep_str_re.search(raw_line)
|
|
628
|
+
if m:
|
|
629
|
+
pkg_name = m.group(1).strip()
|
|
630
|
+
if pkg_name.lower() not in ("install_requires", "extras_require", "setup", "name", "version"):
|
|
631
|
+
if pkg_name not in STDLIB_MODULES and pkg_name not in local_modules:
|
|
632
|
+
extracted.append(
|
|
633
|
+
ExtractedDependency(
|
|
634
|
+
name=normalize_package_name(pkg_name),
|
|
635
|
+
import_name=pkg_name,
|
|
636
|
+
source_file=file_path,
|
|
637
|
+
lineno=idx,
|
|
638
|
+
source_type=file_path.name,
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
return extracted
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def extract_all_new_dependencies(
|
|
646
|
+
session_files: List[Path],
|
|
647
|
+
root: Path,
|
|
648
|
+
base: Optional[str] = None,
|
|
649
|
+
) -> List[ExtractedDependency]:
|
|
650
|
+
"""
|
|
651
|
+
Extract all newly added external dependencies from modified Python files and manifests in session.
|
|
652
|
+
"""
|
|
653
|
+
local_modules = get_local_module_names(root)
|
|
654
|
+
all_deps: List[ExtractedDependency] = []
|
|
655
|
+
seen_unique: Set[str] = set()
|
|
656
|
+
|
|
657
|
+
for p in session_files:
|
|
658
|
+
if p.suffix == ".py":
|
|
659
|
+
py_deps = extract_new_imports_from_py_file(p, root, local_modules=local_modules, base=base)
|
|
660
|
+
for d in py_deps:
|
|
661
|
+
key = f"{d.name}:{d.source_file}:{d.lineno}"
|
|
662
|
+
if key not in seen_unique:
|
|
663
|
+
seen_unique.add(key)
|
|
664
|
+
all_deps.append(d)
|
|
665
|
+
else:
|
|
666
|
+
manifest_deps = extract_new_manifest_dependencies(p, root, local_modules=local_modules, base=base)
|
|
667
|
+
for d in manifest_deps:
|
|
668
|
+
key = f"{d.name}:{d.source_file}:{d.lineno}"
|
|
669
|
+
if key not in seen_unique:
|
|
670
|
+
seen_unique.add(key)
|
|
671
|
+
all_deps.append(d)
|
|
672
|
+
|
|
673
|
+
return all_deps
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@dataclass(frozen=True)
|
|
677
|
+
class DependencyCheckResult:
|
|
678
|
+
"""Detailed verification result for a single extracted dependency."""
|
|
679
|
+
|
|
680
|
+
package_name: str
|
|
681
|
+
import_name: str
|
|
682
|
+
source_file: Path
|
|
683
|
+
lineno: Optional[int]
|
|
684
|
+
source_type: str
|
|
685
|
+
status: str # "HIGH_RISK" | "MEDIUM_RISK" | "OK" | "UNKNOWN"
|
|
686
|
+
age_days: Optional[int]
|
|
687
|
+
first_release_date: Optional[str]
|
|
688
|
+
details: str
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@dataclass(frozen=True)
|
|
692
|
+
class DependencyScanSummary:
|
|
693
|
+
"""Aggregated scan summary for all newly introduced dependencies."""
|
|
694
|
+
|
|
695
|
+
total_scanned: int
|
|
696
|
+
high_risk_count: int
|
|
697
|
+
medium_risk_count: int
|
|
698
|
+
ok_count: int
|
|
699
|
+
unknown_count: int
|
|
700
|
+
findings: List[DependencyCheckResult]
|
|
701
|
+
duration_seconds: float
|
|
702
|
+
unscanned_count: int = 0
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def query_pypi_registry(
|
|
706
|
+
package_name: str,
|
|
707
|
+
cache: Optional[Dict[str, tuple]] = None,
|
|
708
|
+
timeout: float = 5.0,
|
|
709
|
+
) -> tuple:
|
|
710
|
+
"""
|
|
711
|
+
Query the real PyPI JSON API to verify package existence and registration age.
|
|
712
|
+
|
|
713
|
+
Returns (status, age_days, first_release_date, details):
|
|
714
|
+
- status: "HIGH_RISK" (404 / doesn't exist), "MEDIUM_RISK" (<30 days old), "OK" (>=30 days old), "UNKNOWN" (network error)
|
|
715
|
+
- age_days: int or None
|
|
716
|
+
- first_release_date: str or None
|
|
717
|
+
- details: str
|
|
718
|
+
"""
|
|
719
|
+
import datetime
|
|
720
|
+
import json
|
|
721
|
+
import urllib.error
|
|
722
|
+
import urllib.request
|
|
723
|
+
|
|
724
|
+
norm_name = package_name.strip()
|
|
725
|
+
if cache is not None and norm_name in cache:
|
|
726
|
+
return cache[norm_name]
|
|
727
|
+
|
|
728
|
+
url = f"https://pypi.org/pypi/{norm_name}/json"
|
|
729
|
+
req = urllib.request.Request(
|
|
730
|
+
url,
|
|
731
|
+
headers={"User-Agent": "DeployProof/0.1.0 (https://github.com/SVSPraveen/DeployProof)"},
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
try:
|
|
735
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
736
|
+
if resp.status == 200:
|
|
737
|
+
raw_bytes = resp.read()
|
|
738
|
+
data = json.loads(raw_bytes.decode("utf-8"))
|
|
739
|
+
releases = data.get("releases", {})
|
|
740
|
+
timestamps: List[datetime.datetime] = []
|
|
741
|
+
|
|
742
|
+
for ver, files in releases.items():
|
|
743
|
+
for f in files:
|
|
744
|
+
t_str = f.get("upload_time_iso_8601") or f.get("upload_time")
|
|
745
|
+
if t_str:
|
|
746
|
+
try:
|
|
747
|
+
dt = datetime.datetime.fromisoformat(t_str.replace("Z", "+00:00"))
|
|
748
|
+
timestamps.append(dt)
|
|
749
|
+
except Exception:
|
|
750
|
+
pass
|
|
751
|
+
|
|
752
|
+
if not timestamps:
|
|
753
|
+
# Package exists on PyPI but has no release files
|
|
754
|
+
result = ("MEDIUM_RISK", 0, None, "Package exists on PyPI but has 0 uploaded releases")
|
|
755
|
+
else:
|
|
756
|
+
earliest_dt = min(timestamps)
|
|
757
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
758
|
+
age_days = max((now - earliest_dt).days, 0)
|
|
759
|
+
first_date_str = earliest_dt.strftime("%Y-%m-%d")
|
|
760
|
+
|
|
761
|
+
if age_days < 30:
|
|
762
|
+
result = (
|
|
763
|
+
"MEDIUM_RISK",
|
|
764
|
+
age_days,
|
|
765
|
+
first_date_str,
|
|
766
|
+
f"Recently registered package ({age_days} day{'s' if age_days != 1 else ''} old, first published {first_date_str}) - potential slopsquat / supply chain risk",
|
|
767
|
+
)
|
|
768
|
+
else:
|
|
769
|
+
result = (
|
|
770
|
+
"OK",
|
|
771
|
+
age_days,
|
|
772
|
+
first_date_str,
|
|
773
|
+
f"Established package ({age_days} days old, first published {first_date_str})",
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
else:
|
|
777
|
+
result = ("UNKNOWN", None, None, f"PyPI returned HTTP status {resp.status}")
|
|
778
|
+
|
|
779
|
+
except urllib.error.HTTPError as e:
|
|
780
|
+
if e.code == 404:
|
|
781
|
+
result = (
|
|
782
|
+
"HIGH_RISK",
|
|
783
|
+
None,
|
|
784
|
+
None,
|
|
785
|
+
"Package does NOT exist on PyPI (HTTP 404) - hallucinated package name / slopsquatting vulnerability",
|
|
786
|
+
)
|
|
787
|
+
else:
|
|
788
|
+
result = ("UNKNOWN", None, None, f"PyPI HTTP Error {e.code}: {e.reason}")
|
|
789
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
790
|
+
result = ("UNKNOWN", None, None, f"Network / registry connection error: {str(e)}")
|
|
791
|
+
except Exception as e:
|
|
792
|
+
result = ("UNKNOWN", None, None, f"Unexpected error querying PyPI: {str(e)}")
|
|
793
|
+
|
|
794
|
+
if cache is not None:
|
|
795
|
+
cache[norm_name] = result
|
|
796
|
+
|
|
797
|
+
return result
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def scan_dependencies(
|
|
801
|
+
extracted_deps: List[ExtractedDependency],
|
|
802
|
+
timeout: float = 5.0,
|
|
803
|
+
) -> DependencyScanSummary:
|
|
804
|
+
"""
|
|
805
|
+
Perform slopsquatting and hallucination verification against PyPI for all extracted dependencies.
|
|
806
|
+
"""
|
|
807
|
+
import time
|
|
808
|
+
|
|
809
|
+
start_time = time.time()
|
|
810
|
+
cache: Dict[str, tuple] = {}
|
|
811
|
+
findings: List[DependencyCheckResult] = []
|
|
812
|
+
|
|
813
|
+
high_risk = 0
|
|
814
|
+
medium_risk = 0
|
|
815
|
+
ok_count = 0
|
|
816
|
+
unknown_count = 0
|
|
817
|
+
unscanned_count = 0
|
|
818
|
+
|
|
819
|
+
for dep in extracted_deps:
|
|
820
|
+
if dep.unscanned_reason:
|
|
821
|
+
unscanned_count += 1
|
|
822
|
+
findings.append(
|
|
823
|
+
DependencyCheckResult(
|
|
824
|
+
package_name=dep.name,
|
|
825
|
+
import_name=dep.import_name,
|
|
826
|
+
source_file=dep.source_file,
|
|
827
|
+
lineno=dep.lineno,
|
|
828
|
+
source_type=dep.source_type,
|
|
829
|
+
status="UNSCANNED",
|
|
830
|
+
age_days=None,
|
|
831
|
+
first_release_date=None,
|
|
832
|
+
details=dep.unscanned_reason,
|
|
833
|
+
)
|
|
834
|
+
)
|
|
835
|
+
continue
|
|
836
|
+
|
|
837
|
+
status, age_days, first_date, details = query_pypi_registry(dep.name, cache=cache, timeout=timeout)
|
|
838
|
+
|
|
839
|
+
if status == "HIGH_RISK":
|
|
840
|
+
high_risk += 1
|
|
841
|
+
elif status == "MEDIUM_RISK":
|
|
842
|
+
medium_risk += 1
|
|
843
|
+
elif status == "OK":
|
|
844
|
+
ok_count += 1
|
|
845
|
+
else:
|
|
846
|
+
unknown_count += 1
|
|
847
|
+
|
|
848
|
+
findings.append(
|
|
849
|
+
DependencyCheckResult(
|
|
850
|
+
package_name=dep.name,
|
|
851
|
+
import_name=dep.import_name,
|
|
852
|
+
source_file=dep.source_file,
|
|
853
|
+
lineno=dep.lineno,
|
|
854
|
+
source_type=dep.source_type,
|
|
855
|
+
status=status,
|
|
856
|
+
age_days=age_days,
|
|
857
|
+
first_release_date=first_date,
|
|
858
|
+
details=details,
|
|
859
|
+
)
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
duration = round(time.time() - start_time, 2)
|
|
863
|
+
|
|
864
|
+
return DependencyScanSummary(
|
|
865
|
+
total_scanned=len(extracted_deps),
|
|
866
|
+
high_risk_count=high_risk,
|
|
867
|
+
medium_risk_count=medium_risk,
|
|
868
|
+
ok_count=ok_count,
|
|
869
|
+
unknown_count=unknown_count,
|
|
870
|
+
findings=findings,
|
|
871
|
+
duration_seconds=duration,
|
|
872
|
+
unscanned_count=unscanned_count,
|
|
873
|
+
)
|
|
874
|
+
|