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.
- pysentix-0.1.0/PKG-INFO +19 -0
- pysentix-0.1.0/README.md +44 -0
- pysentix-0.1.0/pyproject.toml +58 -0
- pysentix-0.1.0/pysentinel/__init__.py +1 -0
- pysentix-0.1.0/pysentinel/adversarial.py +119 -0
- pysentix-0.1.0/pysentinel/api.py +317 -0
- pysentix-0.1.0/pysentinel/classifier.py +248 -0
- pysentix-0.1.0/pysentinel/cli.py +576 -0
- pysentix-0.1.0/pysentinel/config.py +100 -0
- pysentix-0.1.0/pysentinel/data/top_packages.txt +272 -0
- pysentix-0.1.0/pysentinel/diff.py +128 -0
- pysentix-0.1.0/pysentinel/features_meta.py +116 -0
- pysentix-0.1.0/pysentinel/features_static.py +347 -0
- pysentix-0.1.0/pysentinel/fetcher.py +215 -0
- pysentix-0.1.0/pysentinel/graph.py +201 -0
- pysentix-0.1.0/pysentinel/logging.py +121 -0
- pysentix-0.1.0/pysentinel/scanner.py +150 -0
- pysentix-0.1.0/pysentinel/scorer.py +250 -0
- pysentix-0.1.0/pysentix.egg-info/PKG-INFO +19 -0
- pysentix-0.1.0/pysentix.egg-info/SOURCES.txt +32 -0
- pysentix-0.1.0/pysentix.egg-info/dependency_links.txt +1 -0
- pysentix-0.1.0/pysentix.egg-info/entry_points.txt +3 -0
- pysentix-0.1.0/pysentix.egg-info/requires.txt +16 -0
- pysentix-0.1.0/pysentix.egg-info/top_level.txt +1 -0
- pysentix-0.1.0/setup.cfg +4 -0
- pysentix-0.1.0/tests/test_api.py +125 -0
- pysentix-0.1.0/tests/test_classifier.py +92 -0
- pysentix-0.1.0/tests/test_cli.py +276 -0
- pysentix-0.1.0/tests/test_core.py +36 -0
- pysentix-0.1.0/tests/test_diff.py +58 -0
- pysentix-0.1.0/tests/test_graph.py +73 -0
- pysentix-0.1.0/tests/test_meta.py +88 -0
- pysentix-0.1.0/tests/test_scorer.py +72 -0
- pysentix-0.1.0/tests/test_static.py +263 -0
pysentix-0.1.0/PKG-INFO
ADDED
|
@@ -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"
|
pysentix-0.1.0/README.md
ADDED
|
@@ -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
|