pysentix 0.1.0__tar.gz

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 (34) hide show
  1. pysentix-0.1.0/PKG-INFO +19 -0
  2. pysentix-0.1.0/README.md +44 -0
  3. pysentix-0.1.0/pyproject.toml +58 -0
  4. pysentix-0.1.0/pysentinel/__init__.py +1 -0
  5. pysentix-0.1.0/pysentinel/adversarial.py +119 -0
  6. pysentix-0.1.0/pysentinel/api.py +317 -0
  7. pysentix-0.1.0/pysentinel/classifier.py +248 -0
  8. pysentix-0.1.0/pysentinel/cli.py +576 -0
  9. pysentix-0.1.0/pysentinel/config.py +100 -0
  10. pysentix-0.1.0/pysentinel/data/top_packages.txt +272 -0
  11. pysentix-0.1.0/pysentinel/diff.py +128 -0
  12. pysentix-0.1.0/pysentinel/features_meta.py +116 -0
  13. pysentix-0.1.0/pysentinel/features_static.py +347 -0
  14. pysentix-0.1.0/pysentinel/fetcher.py +215 -0
  15. pysentix-0.1.0/pysentinel/graph.py +201 -0
  16. pysentix-0.1.0/pysentinel/logging.py +121 -0
  17. pysentix-0.1.0/pysentinel/scanner.py +150 -0
  18. pysentix-0.1.0/pysentinel/scorer.py +250 -0
  19. pysentix-0.1.0/pysentix.egg-info/PKG-INFO +19 -0
  20. pysentix-0.1.0/pysentix.egg-info/SOURCES.txt +32 -0
  21. pysentix-0.1.0/pysentix.egg-info/dependency_links.txt +1 -0
  22. pysentix-0.1.0/pysentix.egg-info/entry_points.txt +3 -0
  23. pysentix-0.1.0/pysentix.egg-info/requires.txt +16 -0
  24. pysentix-0.1.0/pysentix.egg-info/top_level.txt +1 -0
  25. pysentix-0.1.0/setup.cfg +4 -0
  26. pysentix-0.1.0/tests/test_api.py +125 -0
  27. pysentix-0.1.0/tests/test_classifier.py +92 -0
  28. pysentix-0.1.0/tests/test_cli.py +276 -0
  29. pysentix-0.1.0/tests/test_core.py +36 -0
  30. pysentix-0.1.0/tests/test_diff.py +58 -0
  31. pysentix-0.1.0/tests/test_graph.py +73 -0
  32. pysentix-0.1.0/tests/test_meta.py +88 -0
  33. pysentix-0.1.0/tests/test_scorer.py +72 -0
  34. pysentix-0.1.0/tests/test_static.py +263 -0
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysentix
3
+ Version: 0.1.0
4
+ Summary: Pre-install supply-chain malware and typosquat detection for PyPI packages
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: fastapi<1.0,>=0.110
7
+ Requires-Dist: uvicorn<1.0,>=0.29
8
+ Requires-Dist: pydantic<3.0,>=2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == "dev"
11
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
12
+ Requires-Dist: httpx>=0.27; extra == "dev"
13
+ Requires-Dist: ruff>=0.4; extra == "dev"
14
+ Requires-Dist: mypy>=1.10; extra == "dev"
15
+ Provides-Extra: ml
16
+ Requires-Dist: xgboost>=2.0; extra == "ml"
17
+ Requires-Dist: scikit-learn>=1.4; extra == "ml"
18
+ Requires-Dist: shap>=0.45; extra == "ml"
19
+ Requires-Dist: networkx>=3.2; extra == "ml"
@@ -0,0 +1,44 @@
1
+ # PyPI Sentinel (backend)
2
+
3
+ Pre-install supply-chain checks for Python packages. Give it a package name; it downloads the release
4
+ **without installing or executing anything**, inspects it, and returns a risk score with reasons.
5
+
6
+ ## What v0.1 does
7
+ - **Static analysis (AST):** hidden payloads (decode + exec), dynamic exec/eval, network calls, system commands,
8
+ credential-file paths, env-var reads, install hooks, high-entropy strings. Each finding is tagged with *when it runs*
9
+ (install / import / runtime); only install- and import-time behaviour is scored.
10
+ - **Metadata:** typosquat distance to ~120 popular packages (Damerau-Levenshtein), package age, linked repository.
11
+ - **Release diff:** compares the scanned version with the previous release and reports newly added risky behaviour.
12
+ - **Explainable score:** weighted rules (0-100) -> Likely safe / Suspicious / High risk, with evidence (file + line).
13
+ - **Interfaces:** CLI (`sentinel`) and FastAPI (`/api/scan`) for the frontend / CI.
14
+
15
+ ## Run
16
+ ```bash
17
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
18
+ pip install -e ".[dev]"
19
+ pytest # unit tests
20
+ sentinel scan requests # scan a real package
21
+ sentinel scan-dir samples/fake_malicious # harmless fake "malware" for demos
22
+ sentinel serve --port 8000 # API: http://127.0.0.1:8000/docs
23
+ ```
24
+
25
+ ## API
26
+ `GET /api/scan?package=<name>&version=<optional>&diff=true`
27
+ Returns `risk_score`, `verdict` (`low|suspicious|high`), `signals[]` (title, detail, severity, weight, evidence),
28
+ `metadata`, `diff`, `files_scanned`, `duration_ms`. `GET /api/health` for status.
29
+
30
+ ## Safety
31
+ Archives are read in memory; only `.py` files are decoded as text. Size, file-count and total-size limits guard against
32
+ zip bombs. Never install or import samples. Keep real malware datasets inside a network-less VM/container.
33
+
34
+ ## Known limits (be honest in the report)
35
+ v0.1 is heuristic. It cannot see payloads downloaded later from a remote server, and rules can flag legitimate
36
+ build tools. Phase 2 (see roadmap) adds a trained classifier, obfuscation-robustness testing and dynamic analysis.
37
+
38
+ ## Roadmap
39
+ 1. Dataset pipeline (malicious vs. benign, time-based split) and feature table
40
+ 2. Train XGBoost/RandomForest, add SHAP explanations, baseline comparison
41
+ 3. `scripts/obfuscate.py` + robustness experiment (recall before/after obfuscation)
42
+ 4. Hijack test set for release-diff evaluation
43
+ 5. GitHub Action / pre-commit hook for `requirements.txt`
44
+ 6. Stretch: code embeddings; sandboxed dynamic analysis
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pysentix"
7
+ version = "0.1.0"
8
+ description = "Pre-install supply-chain malware and typosquat detection for PyPI packages"
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "fastapi>=0.110,<1.0",
12
+ "uvicorn>=0.29,<1.0",
13
+ "pydantic>=2.0,<3.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=8.0",
19
+ "pytest-cov>=5.0",
20
+ "httpx>=0.27",
21
+ "ruff>=0.4",
22
+ "mypy>=1.10",
23
+ ]
24
+ ml = [
25
+ "xgboost>=2.0",
26
+ "scikit-learn>=1.4",
27
+ "shap>=0.45",
28
+ "networkx>=3.2",
29
+ ]
30
+
31
+ [project.scripts]
32
+ sentinel = "pysentinel.cli:main"
33
+ pip-sentinel = "pysentinel.cli:pip_sentinel_main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ include = ["pysentinel*"]
37
+
38
+ [tool.setuptools.package-data]
39
+ pysentinel = ["data/*.txt"]
40
+
41
+ [tool.ruff]
42
+ target-version = "py39"
43
+ line-length = 120
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "S"]
47
+ ignore = ["S101", "S603", "S607"] # assert in tests, subprocess args
48
+
49
+ [tool.mypy]
50
+ python_version = "3.9"
51
+ warn_return_any = true
52
+ warn_unused_configs = true
53
+ disallow_untyped_defs = false
54
+ check_untyped_defs = true
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ addopts = "-v --tb=short"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,119 @@
1
+ """Adversarial evasion generators and robustness evaluation tools.
2
+
3
+ Tests scanner resilience against malware evasion techniques:
4
+ - Variable and symbol renaming
5
+ - String splitting and join-based reconstruction
6
+ - Dead-code and dummy-branch insertion
7
+ - Multi-layer encoding (base64 / zlib wrappers)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import random
13
+ import re
14
+ from typing import Any, Dict, List, Optional
15
+
16
+
17
+ def rename_variables(code: str) -> str:
18
+ """Rename user variables to randomized hex-like identifiers."""
19
+ # Find simple assignment variables that aren't Python keywords
20
+ keywords = {
21
+ "import", "from", "as", "def", "class", "return", "if", "else", "elif",
22
+ "for", "while", "try", "except", "finally", "with", "in", "is", "not",
23
+ "and", "or", "True", "False", "None", "self", "setup",
24
+ }
25
+ vars_found = set(re.findall(r"\b([a-z_][a-z0-9_]{2,15})\s*=", code)) - keywords
26
+ transformed = code
27
+ for i, var in enumerate(sorted(vars_found)):
28
+ hex_name = f"_0x{i+1:04x}"
29
+ transformed = re.sub(rf"\b{re.escape(var)}\b", hex_name, transformed)
30
+ return transformed
31
+
32
+
33
+ def split_strings(code: str) -> str:
34
+ """Split string literals into concatenated chunks: 'abcde' -> 'ab' + 'cde'."""
35
+ def _splitter(m: re.Match) -> str:
36
+ quote = m.group(1)
37
+ content = m.group(2)
38
+ if len(content) < 6 or "\\" in content:
39
+ return m.group(0)
40
+ mid = len(content) // 2
41
+ return f"{quote}{content[:mid]}{quote} + {quote}{content[mid:]}{quote}"
42
+
43
+ return re.sub(r"(['\"])(.*?)\1", _splitter, code)
44
+
45
+
46
+ def inject_dead_code(code: str) -> str:
47
+ """Inject dummy dead code and conditional branches matching line indentation."""
48
+ dead_templates = [
49
+ "if 1 == 2:\n _dummy_unused = 42",
50
+ "_dummy_entropy_check = 'abc' * 3",
51
+ "_ = sum([1, 2, 3]) if False else None",
52
+ ]
53
+ lines = code.splitlines()
54
+ if len(lines) > 2:
55
+ idx = min(2, len(lines) - 1)
56
+ target_line = lines[idx]
57
+ indent = len(target_line) - len(target_line.lstrip())
58
+ prefix = " " * indent
59
+ template = random.choice(dead_templates)
60
+ indented = "\n".join(prefix + l if l.strip() else l for l in template.splitlines())
61
+ lines.insert(idx, indented)
62
+ return "\n".join(lines)
63
+
64
+
65
+ def wrap_b64_exec(code: str) -> str:
66
+ """Wrap code into a dynamic base64 execution snippet."""
67
+ b64_str = base64.b64encode(code.encode()).decode()
68
+ return f"import base64\nexec(base64.b64decode('{b64_str}'))\n"
69
+
70
+
71
+ def obfuscate_source(
72
+ code: str,
73
+ techniques: Optional[List[str]] = None,
74
+ ) -> str:
75
+ """Apply selected evasion techniques to source code."""
76
+ applied = techniques or ["rename", "split", "dead_code"]
77
+ res = code
78
+ if "rename" in applied:
79
+ res = rename_variables(res)
80
+ if "split" in applied:
81
+ res = split_strings(res)
82
+ if "dead_code" in applied:
83
+ res = inject_dead_code(res)
84
+ if "b64" in applied:
85
+ res = wrap_b64_exec(res)
86
+ return res
87
+
88
+
89
+ def evaluate_adversarial_recall(
90
+ detector_fn: Any,
91
+ samples: List[str],
92
+ ) -> Dict[str, Any]:
93
+ """Test detection recall on original vs obfuscated variants of malicious samples."""
94
+ original_detected = 0
95
+ obfuscated_detected = 0
96
+ b64_detected = 0
97
+
98
+ for sample in samples:
99
+ # Original
100
+ if detector_fn(sample):
101
+ original_detected += 1
102
+
103
+ # Mild obfuscation (rename + split + dead code)
104
+ mild = obfuscate_source(sample, techniques=["rename", "split", "dead_code"])
105
+ if detector_fn(mild):
106
+ obfuscated_detected += 1
107
+
108
+ # Heavy obfuscation (base64 wrap)
109
+ heavy = obfuscate_source(sample, techniques=["b64"])
110
+ if detector_fn(heavy):
111
+ b64_detected += 1
112
+
113
+ total = max(1, len(samples))
114
+ return {
115
+ "sample_count": len(samples),
116
+ "clean_recall": round(original_detected / total, 3),
117
+ "mild_obfuscation_recall": round(obfuscated_detected / total, 3),
118
+ "heavy_obfuscation_recall": round(b64_detected / total, 3),
119
+ }
@@ -0,0 +1,317 @@
1
+ """FastAPI application for PyPI Sentinel.
2
+
3
+ Provides:
4
+ - POST /v1/scan — start a new scan (async)
5
+ - GET /v1/scan/{id} — retrieve scan result by ID
6
+ - GET /v1/health — health check
7
+ - GET /v1/model-info — model / version info
8
+ - GET /api/scan — legacy endpoint (kept for backwards compat with existing frontends)
9
+ - GET /api/health — legacy health
10
+
11
+ Security:
12
+ - CORS locked to configured origins
13
+ - Per-IP rate limiting (in-memory token-bucket)
14
+ - Security headers middleware
15
+ - Pydantic validation on all inputs
16
+ - No stack traces to clients
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import time
24
+ import uuid
25
+ from collections import defaultdict
26
+ from enum import Enum
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from fastapi import FastAPI, HTTPException, Query, Request, Response
30
+ from fastapi.middleware.cors import CORSMiddleware
31
+ from fastapi.responses import JSONResponse
32
+ from pydantic import BaseModel, Field, field_validator
33
+
34
+ from . import __version__
35
+ from .config import settings
36
+ from .fetcher import FetchError, PackageNotFound, normalize_name
37
+ from .logging import get_logger, new_request_id
38
+ from .scanner import scan
39
+
40
+ log = get_logger(__name__)
41
+
42
+ # ── Pydantic models ──────────────────────────────────────────────────
43
+
44
+ class ScanRequest(BaseModel):
45
+ """Request body for POST /v1/scan."""
46
+ package: str = Field(
47
+ ...,
48
+ min_length=1,
49
+ max_length=100,
50
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$",
51
+ description="PyPI package name",
52
+ )
53
+ version: Optional[str] = Field(
54
+ None,
55
+ max_length=50,
56
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,49}$",
57
+ description="Specific version to scan (default: latest)",
58
+ )
59
+ diff: bool = Field(True, description="Compare with previous release")
60
+
61
+ @field_validator("package")
62
+ @classmethod
63
+ def normalize_package(cls, v: str) -> str:
64
+ return normalize_name(v)
65
+
66
+
67
+ class Verdict(str, Enum):
68
+ low = "low"
69
+ suspicious = "suspicious"
70
+ high = "high"
71
+
72
+
73
+ class Evidence(BaseModel):
74
+ file: str
75
+ line: int
76
+ snippet: str = ""
77
+
78
+
79
+ class Signal(BaseModel):
80
+ id: str
81
+ title: str
82
+ detail: str
83
+ weight: float
84
+ severity: str
85
+ evidence: List[Evidence] = []
86
+
87
+
88
+ class ScanResult(BaseModel):
89
+ scan_id: str
90
+ package: str
91
+ version: str
92
+ risk_score: int
93
+ verdict: Verdict
94
+ verdict_label: str
95
+ signals: List[Signal]
96
+ metadata: Dict[str, Any]
97
+ diff: Optional[Dict[str, Any]] = None
98
+ graph: Optional[Dict[str, Any]] = None
99
+ ml_risk_probability: float = 0.0
100
+ explainability: Optional[Dict[str, Any]] = None
101
+ files_scanned: int = 0
102
+ obfuscation_score: float = 0.0
103
+ duration_ms: int = 0
104
+
105
+
106
+ class ErrorResponse(BaseModel):
107
+ detail: str
108
+ request_id: str = ""
109
+
110
+
111
+ class HealthResponse(BaseModel):
112
+ status: str = "ok"
113
+ version: str
114
+
115
+
116
+ class ModelInfoResponse(BaseModel):
117
+ model_type: str = "calibrated-ensemble"
118
+ model_version: str = "1.0.0"
119
+ features: List[str] = []
120
+ ablation_results: Optional[Dict[str, Any]] = None
121
+ top_features: Optional[List[Dict[str, Any]]] = None
122
+
123
+
124
+ # ── Rate limiter (in-memory token bucket) ────────────────────────────
125
+
126
+ class _RateLimiter:
127
+ """Per-IP token-bucket rate limiter."""
128
+
129
+ def __init__(self, rpm: int, burst: int) -> None:
130
+ self.rate = rpm / 60.0 # tokens per second
131
+ self.burst = burst
132
+ self._buckets: Dict[str, tuple[float, float]] = {} # ip → (tokens, last_time)
133
+
134
+ def allow(self, ip: str) -> bool:
135
+ now = time.time()
136
+ tokens, last = self._buckets.get(ip, (float(self.burst), now))
137
+ elapsed = now - last
138
+ tokens = min(float(self.burst), tokens + elapsed * self.rate)
139
+ if tokens >= 1.0:
140
+ self._buckets[ip] = (tokens - 1.0, now)
141
+ return True
142
+ self._buckets[ip] = (tokens, now)
143
+ return False
144
+
145
+
146
+ _limiter = _RateLimiter(settings.rate_limit_rpm, settings.rate_limit_burst)
147
+
148
+ # ── Scan cache ───────────────────────────────────────────────────────
149
+
150
+ _cache: Dict[str, tuple[float, Dict[str, Any]]] = {}
151
+ _id_index: Dict[str, Dict[str, Any]] = {}
152
+
153
+
154
+ def _cache_key(package: str, version: Optional[str], diff: bool) -> str:
155
+ raw = f"{normalize_name(package)}:{version or ''}:{diff}"
156
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
157
+
158
+
159
+ # ── FastAPI app ──────────────────────────────────────────────────────
160
+
161
+ app = FastAPI(
162
+ title="PyPI Sentinel API",
163
+ version=__version__,
164
+ description="Pre-install supply-chain malware detection for Python packages.",
165
+ docs_url="/docs",
166
+ redoc_url="/redoc",
167
+ )
168
+
169
+ # CORS — locked to configured origins
170
+ app.add_middleware(
171
+ CORSMiddleware,
172
+ allow_origins=settings.cors_origins,
173
+ allow_methods=["GET", "POST", "OPTIONS"],
174
+ allow_headers=["*"],
175
+ max_age=600,
176
+ )
177
+
178
+
179
+ # ── Middleware ───────────────────────────────────────────────────────
180
+
181
+ @app.middleware("http")
182
+ async def security_headers(request: Request, call_next: Any) -> Response:
183
+ """Add security headers and request ID to every response."""
184
+ rid = new_request_id()
185
+ response: Response = await call_next(request)
186
+ response.headers["X-Request-ID"] = rid
187
+ response.headers["X-Content-Type-Options"] = "nosniff"
188
+ response.headers["X-Frame-Options"] = "DENY"
189
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
190
+ response.headers["Cache-Control"] = "no-store"
191
+ return response
192
+
193
+
194
+ @app.middleware("http")
195
+ async def rate_limit_middleware(request: Request, call_next: Any) -> Response:
196
+ """Enforce per-IP rate limits."""
197
+ if request.url.path.startswith(("/docs", "/redoc", "/openapi.json")):
198
+ return await call_next(request)
199
+
200
+ client_ip = request.client.host if request.client else "unknown"
201
+ if not _limiter.allow(client_ip):
202
+ log.warning("rate_limited", ip=client_ip, path=str(request.url.path))
203
+ return JSONResponse(
204
+ status_code=429,
205
+ content={"detail": "Too many requests. Please wait before trying again."},
206
+ )
207
+ return await call_next(request)
208
+
209
+
210
+ # ── Exception handlers ──────────────────────────────────────────────
211
+
212
+ @app.exception_handler(Exception)
213
+ async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
214
+ """Never leak stack traces to clients."""
215
+ log.error("unhandled_exception", error=str(exc), path=str(request.url.path))
216
+ return JSONResponse(
217
+ status_code=500,
218
+ content={"detail": "An internal error occurred. Please try again later."},
219
+ )
220
+
221
+
222
+ # ── v1 Endpoints ────────────────────────────────────────────────────
223
+
224
+ @app.get("/v1/health", response_model=HealthResponse)
225
+ def health_v1() -> HealthResponse:
226
+ """Health check endpoint."""
227
+ return HealthResponse(status="ok", version=__version__)
228
+
229
+
230
+ @app.get("/v1/model-info", response_model=ModelInfoResponse)
231
+ def model_info() -> ModelInfoResponse:
232
+ """Return info about the current scoring model, features, and ablation results."""
233
+ from .classifier import FEATURE_DEFINITIONS, classifier
234
+ metrics_path = os.path.join(os.path.dirname(__file__), "data", "model_metrics.json")
235
+ ablation = None
236
+ top_feats = None
237
+ if os.path.exists(metrics_path):
238
+ try:
239
+ with open(metrics_path, "r", encoding="utf-8") as f:
240
+ data = json.load(f)
241
+ ablation = data.get("ablation_results")
242
+ top_feats = data.get("top_features")
243
+ except Exception:
244
+ pass
245
+
246
+ return ModelInfoResponse(
247
+ model_type="calibrated-ensemble",
248
+ model_version=classifier.version,
249
+ features=[f[0] for f in FEATURE_DEFINITIONS],
250
+ ablation_results=ablation,
251
+ top_features=top_feats,
252
+ )
253
+
254
+
255
+ @app.post("/v1/scan", response_model=ScanResult)
256
+ def scan_v1(body: ScanRequest) -> ScanResult:
257
+ """Scan a package for supply-chain threats."""
258
+ key = _cache_key(body.package, body.version, body.diff)
259
+ hit = _cache.get(key)
260
+ if hit and time.time() - hit[0] < settings.cache_ttl:
261
+ return ScanResult(**hit[1])
262
+
263
+ try:
264
+ result = scan(body.package, body.version, body.diff)
265
+ except PackageNotFound:
266
+ raise HTTPException(404, f"'{body.package}' was not found on PyPI. Check the spelling.")
267
+ except FetchError as exc:
268
+ raise HTTPException(502, str(exc))
269
+
270
+ scan_id = uuid.uuid4().hex[:12]
271
+ result["scan_id"] = scan_id
272
+ _cache[key] = (time.time(), result)
273
+ _id_index[scan_id] = result
274
+ return ScanResult(**result)
275
+
276
+
277
+ @app.get("/v1/scan/{scan_id}", response_model=ScanResult)
278
+ def get_scan(scan_id: str) -> ScanResult:
279
+ """Retrieve a previous scan result by ID."""
280
+ result = _id_index.get(scan_id)
281
+ if not result:
282
+ raise HTTPException(404, "Scan not found.")
283
+ return ScanResult(**result)
284
+
285
+
286
+ # ── Legacy endpoints (backwards compat with existing frontends) ─────
287
+
288
+ @app.get("/api/health")
289
+ def health_legacy() -> Dict[str, str]:
290
+ """Legacy health check."""
291
+ return {"status": "ok", "version": __version__}
292
+
293
+
294
+ @app.get("/api/scan")
295
+ def scan_legacy(
296
+ package: str = Query(..., pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"),
297
+ version: Optional[str] = Query(None, pattern=r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,49}$"),
298
+ diff: bool = True,
299
+ ) -> Dict[str, Any]:
300
+ """Legacy scan endpoint (GET-based, kept for existing frontend compatibility)."""
301
+ key = _cache_key(package, version, diff)
302
+ hit = _cache.get(key)
303
+ if hit and time.time() - hit[0] < settings.cache_ttl:
304
+ return hit[1]
305
+
306
+ try:
307
+ result = scan(package, version, diff)
308
+ except PackageNotFound:
309
+ raise HTTPException(404, f"'{package}' was not found on PyPI. Check the spelling.")
310
+ except FetchError as exc:
311
+ raise HTTPException(502, str(exc))
312
+
313
+ scan_id = uuid.uuid4().hex[:12]
314
+ result["scan_id"] = scan_id
315
+ _cache[key] = (time.time(), result)
316
+ _id_index[scan_id] = result
317
+ return result