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.
@@ -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
+