python-checkup 0.0.1__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.
- python_checkup/__init__.py +9 -0
- python_checkup/__main__.py +3 -0
- python_checkup/analysis_request.py +35 -0
- python_checkup/analyzer_catalog.py +100 -0
- python_checkup/analyzers/__init__.py +54 -0
- python_checkup/analyzers/bandit.py +158 -0
- python_checkup/analyzers/basedpyright.py +103 -0
- python_checkup/analyzers/cached.py +106 -0
- python_checkup/analyzers/dependency_vulns.py +298 -0
- python_checkup/analyzers/deptry.py +142 -0
- python_checkup/analyzers/detect_secrets.py +101 -0
- python_checkup/analyzers/mypy.py +217 -0
- python_checkup/analyzers/radon.py +150 -0
- python_checkup/analyzers/registry.py +69 -0
- python_checkup/analyzers/ruff.py +256 -0
- python_checkup/analyzers/typos.py +80 -0
- python_checkup/analyzers/vulture.py +151 -0
- python_checkup/cache.py +244 -0
- python_checkup/cli.py +763 -0
- python_checkup/config.py +87 -0
- python_checkup/dedup.py +119 -0
- python_checkup/dependencies/discovery.py +192 -0
- python_checkup/detection.py +298 -0
- python_checkup/diff.py +130 -0
- python_checkup/discovery.py +180 -0
- python_checkup/formatters/__init__.py +0 -0
- python_checkup/formatters/badge.py +38 -0
- python_checkup/formatters/json_fmt.py +22 -0
- python_checkup/formatters/terminal.py +396 -0
- python_checkup/mcp/__init__.py +3 -0
- python_checkup/mcp/installer.py +119 -0
- python_checkup/mcp/server.py +411 -0
- python_checkup/models.py +114 -0
- python_checkup/plan.py +109 -0
- python_checkup/progress.py +95 -0
- python_checkup/runner.py +438 -0
- python_checkup/scoring/__init__.py +0 -0
- python_checkup/scoring/engine.py +397 -0
- python_checkup/skills/SKILL.md +416 -0
- python_checkup/skills/__init__.py +0 -0
- python_checkup/skills/agents.py +98 -0
- python_checkup/skills/installer.py +248 -0
- python_checkup/skills/rule_db.py +806 -0
- python_checkup/web/__init__.py +0 -0
- python_checkup/web/server.py +285 -0
- python_checkup/web/static/__init__.py +0 -0
- python_checkup/web/static/index.html +959 -0
- python_checkup/web/template.py +26 -0
- python_checkup-0.0.1.dist-info/METADATA +250 -0
- python_checkup-0.0.1.dist-info/RECORD +53 -0
- python_checkup-0.0.1.dist-info/WHEEL +4 -0
- python_checkup-0.0.1.dist-info/entry_points.txt +14 -0
- python_checkup-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from python_checkup.analysis_request import AnalysisRequest
|
|
12
|
+
from python_checkup.dependencies.discovery import (
|
|
13
|
+
DependencySource,
|
|
14
|
+
LockedPackage,
|
|
15
|
+
discover_dependency_source,
|
|
16
|
+
)
|
|
17
|
+
from python_checkup.models import Category, Diagnostic, Severity
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("python_checkup")
|
|
20
|
+
|
|
21
|
+
OSV_QUERY_BATCH_URL = "https://api.osv.dev/v1/querybatch"
|
|
22
|
+
|
|
23
|
+
# Advisory cache lives in a separate namespace from the per-file cache.
|
|
24
|
+
ADVISORY_CACHE_DIR = "v2/advisories"
|
|
25
|
+
# Cache entries are valid for 24 hours.
|
|
26
|
+
ADVISORY_CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AdvisoryCache:
|
|
30
|
+
"""Local filesystem cache for OSV advisory responses.
|
|
31
|
+
|
|
32
|
+
Each entry is keyed by ``{package_name}:{version}`` and stores the
|
|
33
|
+
raw list of vulnerability dicts returned by OSV. Entries older than
|
|
34
|
+
``ADVISORY_CACHE_TTL_SECONDS`` are treated as stale and ignored.
|
|
35
|
+
|
|
36
|
+
Cache directory: ``.python-checkup-cache/v2/advisories/``
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, project_root: Path, *, enabled: bool = True) -> None:
|
|
40
|
+
self.enabled = enabled
|
|
41
|
+
self.cache_dir = project_root / ".python-checkup-cache" / ADVISORY_CACHE_DIR
|
|
42
|
+
self._hits = 0
|
|
43
|
+
self._misses = 0
|
|
44
|
+
if self.enabled:
|
|
45
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
# -- key helpers --------------------------------------------------
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _safe_key(name: str, version: str) -> str:
|
|
51
|
+
"""Deterministic, filesystem-safe key for a package+version."""
|
|
52
|
+
raw = f"{name}:{version}"
|
|
53
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:24]
|
|
54
|
+
|
|
55
|
+
def _path(self, name: str, version: str) -> Path:
|
|
56
|
+
return self.cache_dir / f"{self._safe_key(name, version)}.json"
|
|
57
|
+
|
|
58
|
+
# -- public API ---------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def get(self, name: str, version: str) -> list[dict[str, object]] | None:
|
|
61
|
+
"""Return cached advisory list or ``None`` on miss / stale."""
|
|
62
|
+
if not self.enabled:
|
|
63
|
+
return None
|
|
64
|
+
path = self._path(name, version)
|
|
65
|
+
if not path.exists():
|
|
66
|
+
self._misses += 1
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(path.read_text())
|
|
70
|
+
ts = data.get("ts", 0)
|
|
71
|
+
if time.time() - ts > ADVISORY_CACHE_TTL_SECONDS:
|
|
72
|
+
path.unlink(missing_ok=True)
|
|
73
|
+
self._misses += 1
|
|
74
|
+
return None
|
|
75
|
+
self._hits += 1
|
|
76
|
+
vulns: list[dict[str, object]] = data.get("vulns", [])
|
|
77
|
+
return vulns
|
|
78
|
+
except (json.JSONDecodeError, OSError, KeyError, TypeError):
|
|
79
|
+
path.unlink(missing_ok=True)
|
|
80
|
+
self._misses += 1
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def set(self, name: str, version: str, vulns: list[dict[str, object]]) -> None:
|
|
84
|
+
"""Persist advisory list for ``name==version``."""
|
|
85
|
+
if not self.enabled:
|
|
86
|
+
return
|
|
87
|
+
path = self._path(name, version)
|
|
88
|
+
try:
|
|
89
|
+
path.write_text(json.dumps({"ts": time.time(), "vulns": vulns}))
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
logger.debug("advisory cache write failed: %s", exc)
|
|
92
|
+
|
|
93
|
+
def stats(self) -> dict[str, int]:
|
|
94
|
+
total = self._hits + self._misses
|
|
95
|
+
return {
|
|
96
|
+
"hits": self._hits,
|
|
97
|
+
"misses": self._misses,
|
|
98
|
+
"total": total,
|
|
99
|
+
"hit_rate_pct": round(self._hits / total * 100) if total else 0,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _extract_fix_versions(vuln: dict[str, object]) -> list[str]:
|
|
104
|
+
"""Extract fix version strings from an OSV vulnerability dict.
|
|
105
|
+
|
|
106
|
+
OSV stores fix info in ``affected[].ranges[].events`` where each event
|
|
107
|
+
is either ``{"introduced": "..."}`` or ``{"fixed": "..."}``. We collect
|
|
108
|
+
all unique ``fixed`` values across all affected entries and ranges.
|
|
109
|
+
"""
|
|
110
|
+
fixes: list[str] = []
|
|
111
|
+
seen: set[str] = set()
|
|
112
|
+
affected_list = vuln.get("affected", [])
|
|
113
|
+
if not isinstance(affected_list, list):
|
|
114
|
+
return fixes
|
|
115
|
+
for affected in affected_list:
|
|
116
|
+
if not isinstance(affected, dict):
|
|
117
|
+
continue
|
|
118
|
+
for rng in affected.get("ranges", []):
|
|
119
|
+
if not isinstance(rng, dict):
|
|
120
|
+
continue
|
|
121
|
+
for event in rng.get("events", []):
|
|
122
|
+
if not isinstance(event, dict):
|
|
123
|
+
continue
|
|
124
|
+
fixed = event.get("fixed")
|
|
125
|
+
if fixed and isinstance(fixed, str) and fixed not in seen:
|
|
126
|
+
seen.add(fixed)
|
|
127
|
+
fixes.append(fixed)
|
|
128
|
+
return fixes
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DependencyVulnerabilityAnalyzer:
|
|
132
|
+
"""Scan dependency versions from the target project against OSV."""
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def name(self) -> str:
|
|
136
|
+
return "dependency-vulns"
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def category(self) -> Category:
|
|
140
|
+
return Category.DEPENDENCIES
|
|
141
|
+
|
|
142
|
+
async def is_available(self) -> bool:
|
|
143
|
+
try:
|
|
144
|
+
import httpx as _httpx # noqa: F401
|
|
145
|
+
|
|
146
|
+
return True
|
|
147
|
+
except ImportError:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
async def analyze(self, request: AnalysisRequest) -> list[Diagnostic]:
|
|
151
|
+
source = discover_dependency_source(request.project_root)
|
|
152
|
+
if source is None or not source.packages:
|
|
153
|
+
request.metadata["dependency_vulns_note"] = (
|
|
154
|
+
"no lockfile or dependency manifest with version data"
|
|
155
|
+
)
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
request.metadata["dependency_vulns_source"] = source.kind
|
|
159
|
+
request.metadata["dependency_vulns_locked"] = source.is_locked
|
|
160
|
+
request.metadata["dependency_vulns_package_count"] = len(source.packages)
|
|
161
|
+
|
|
162
|
+
pinned = [pkg for pkg in source.packages if pkg.version]
|
|
163
|
+
if not pinned:
|
|
164
|
+
request.metadata["dependency_vulns_note"] = (
|
|
165
|
+
"dependencies are declared but not pinned"
|
|
166
|
+
)
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
cache = AdvisoryCache(request.project_root, enabled=not request.no_cache)
|
|
170
|
+
|
|
171
|
+
# Split packages into cached vs uncached
|
|
172
|
+
cached_vulns: dict[int, list[dict[str, object]]] = {}
|
|
173
|
+
uncached_indices: list[int] = []
|
|
174
|
+
for i, pkg in enumerate(pinned):
|
|
175
|
+
if pkg.version is None: # guaranteed False by filter above
|
|
176
|
+
continue
|
|
177
|
+
hit = cache.get(pkg.name, pkg.version)
|
|
178
|
+
if hit is not None:
|
|
179
|
+
cached_vulns[i] = hit
|
|
180
|
+
else:
|
|
181
|
+
uncached_indices.append(i)
|
|
182
|
+
|
|
183
|
+
# Query OSV only for uncached packages
|
|
184
|
+
remote_vulns = await self._query_osv(
|
|
185
|
+
pinned, uncached_indices, cache, request.config.timeout
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Merge cached + remote and build diagnostics
|
|
189
|
+
all_vulns = {**cached_vulns, **remote_vulns}
|
|
190
|
+
diagnostics = self._build_vulnerability_diagnostics(pinned, all_vulns, source)
|
|
191
|
+
|
|
192
|
+
# Store cache stats for reporting
|
|
193
|
+
stats = cache.stats()
|
|
194
|
+
if stats["total"] > 0:
|
|
195
|
+
logger.info(
|
|
196
|
+
"Advisory cache: %d hits, %d misses (%d%% hit rate)",
|
|
197
|
+
stats["hits"],
|
|
198
|
+
stats["misses"],
|
|
199
|
+
stats["hit_rate_pct"],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return diagnostics
|
|
203
|
+
|
|
204
|
+
async def _query_osv(
|
|
205
|
+
self,
|
|
206
|
+
pinned: list[LockedPackage],
|
|
207
|
+
uncached_indices: list[int],
|
|
208
|
+
cache: AdvisoryCache,
|
|
209
|
+
timeout: float,
|
|
210
|
+
) -> dict[int, list[dict[str, object]]]:
|
|
211
|
+
"""Call the OSV batch API for *uncached_indices* and update *cache*.
|
|
212
|
+
|
|
213
|
+
Returns a mapping from pinned-list index to vulnerability dicts.
|
|
214
|
+
"""
|
|
215
|
+
if not uncached_indices:
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
payload = {
|
|
219
|
+
"queries": [
|
|
220
|
+
{
|
|
221
|
+
"package": {
|
|
222
|
+
"ecosystem": "PyPI",
|
|
223
|
+
"name": pinned[i].name,
|
|
224
|
+
},
|
|
225
|
+
"version": pinned[i].version,
|
|
226
|
+
}
|
|
227
|
+
for i in uncached_indices
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
231
|
+
response = await client.post(OSV_QUERY_BATCH_URL, json=payload)
|
|
232
|
+
response.raise_for_status()
|
|
233
|
+
data = response.json()
|
|
234
|
+
|
|
235
|
+
remote_vulns: dict[int, list[dict[str, object]]] = {}
|
|
236
|
+
results = data.get("results", [])
|
|
237
|
+
if not isinstance(results, list):
|
|
238
|
+
return remote_vulns
|
|
239
|
+
|
|
240
|
+
for j, result in enumerate(results):
|
|
241
|
+
if j >= len(uncached_indices):
|
|
242
|
+
break
|
|
243
|
+
idx = uncached_indices[j]
|
|
244
|
+
pkg = pinned[idx]
|
|
245
|
+
vulns: list[dict[str, object]] = []
|
|
246
|
+
if isinstance(result, dict):
|
|
247
|
+
raw = result.get("vulns", [])
|
|
248
|
+
if isinstance(raw, list):
|
|
249
|
+
vulns = raw
|
|
250
|
+
remote_vulns[idx] = vulns
|
|
251
|
+
cache.set(pkg.name, pkg.version, vulns)
|
|
252
|
+
|
|
253
|
+
return remote_vulns
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _build_vulnerability_diagnostics(
|
|
257
|
+
pinned: list[LockedPackage],
|
|
258
|
+
all_vulns: dict[int, list[dict[str, object]]],
|
|
259
|
+
source: DependencySource,
|
|
260
|
+
) -> list[Diagnostic]:
|
|
261
|
+
"""Build :class:`Diagnostic` objects from aggregated vulnerability data."""
|
|
262
|
+
diagnostics: list[Diagnostic] = []
|
|
263
|
+
for i, pkg in enumerate(pinned):
|
|
264
|
+
vulns = all_vulns.get(i, [])
|
|
265
|
+
for vuln in vulns:
|
|
266
|
+
if not isinstance(vuln, dict):
|
|
267
|
+
continue
|
|
268
|
+
vuln_id = str(vuln.get("id", "UNKNOWN"))
|
|
269
|
+
fix_versions = _extract_fix_versions(vuln)
|
|
270
|
+
if fix_versions:
|
|
271
|
+
fix_text = (
|
|
272
|
+
f"Upgrade {pkg.name} to {' or '.join(fix_versions)} "
|
|
273
|
+
f"and refresh your lockfile."
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
fix_text = (
|
|
277
|
+
"Upgrade the package to a non-vulnerable version "
|
|
278
|
+
"and refresh your lockfile."
|
|
279
|
+
)
|
|
280
|
+
diagnostics.append(
|
|
281
|
+
Diagnostic(
|
|
282
|
+
file_path=source.path,
|
|
283
|
+
line=0,
|
|
284
|
+
column=0,
|
|
285
|
+
severity=Severity.ERROR,
|
|
286
|
+
rule_id=vuln_id,
|
|
287
|
+
tool="dependency-vulns",
|
|
288
|
+
category=Category.DEPENDENCIES,
|
|
289
|
+
message=(
|
|
290
|
+
f"{pkg.name}=={pkg.version} has known vulnerability "
|
|
291
|
+
f"{vuln_id}"
|
|
292
|
+
),
|
|
293
|
+
fix=fix_text,
|
|
294
|
+
help_url=f"https://osv.dev/vulnerability/{vuln_id}",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return diagnostics
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from python_checkup.analysis_request import AnalysisRequest
|
|
10
|
+
from python_checkup.models import Category, Diagnostic, Severity
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("python_checkup")
|
|
13
|
+
|
|
14
|
+
# Error code descriptions and severity mappings
|
|
15
|
+
DEPTRY_CODES: dict[str, tuple[str, Severity]] = {
|
|
16
|
+
"DEP001": ("Missing dependency", Severity.ERROR),
|
|
17
|
+
"DEP002": ("Unused dependency", Severity.WARNING),
|
|
18
|
+
"DEP003": ("Transitive dependency", Severity.WARNING),
|
|
19
|
+
"DEP004": ("Misplaced dev dependency", Severity.WARNING),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DeptryAnalyzer:
|
|
24
|
+
"""Check for unused, missing, and transitive dependencies."""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
return "deptry"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def category(self) -> Category:
|
|
32
|
+
return Category.DEPENDENCIES
|
|
33
|
+
|
|
34
|
+
async def is_available(self) -> bool:
|
|
35
|
+
"""Check if deptry is on PATH."""
|
|
36
|
+
return shutil.which("deptry") is not None
|
|
37
|
+
|
|
38
|
+
async def analyze(
|
|
39
|
+
self,
|
|
40
|
+
request: AnalysisRequest,
|
|
41
|
+
) -> list[Diagnostic]:
|
|
42
|
+
"""Run deptry with JSON output.
|
|
43
|
+
|
|
44
|
+
deptry analyzes the project root, not individual files.
|
|
45
|
+
"""
|
|
46
|
+
config = request.config_dict()
|
|
47
|
+
project_root = str(request.project_root)
|
|
48
|
+
|
|
49
|
+
cmd = ["deptry", project_root, "--json-output", "-"]
|
|
50
|
+
|
|
51
|
+
timeout: int = 30
|
|
52
|
+
if "timeout" in config and isinstance(config["timeout"], int | float):
|
|
53
|
+
timeout = int(config["timeout"])
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
proc = await asyncio.create_subprocess_exec(
|
|
57
|
+
*cmd,
|
|
58
|
+
stdout=asyncio.subprocess.PIPE,
|
|
59
|
+
stderr=asyncio.subprocess.PIPE,
|
|
60
|
+
)
|
|
61
|
+
stdout, stderr = await asyncio.wait_for(
|
|
62
|
+
proc.communicate(),
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
)
|
|
65
|
+
except asyncio.TimeoutError:
|
|
66
|
+
logger.warning("deptry timed out")
|
|
67
|
+
return []
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
logger.warning("deptry not found")
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
# deptry writes JSON to stdout when --json-output - is used
|
|
73
|
+
output = stdout.decode()
|
|
74
|
+
if not output.strip():
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
issues: list[dict[str, object]] = json.loads(output)
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
logger.error("Failed to parse deptry JSON")
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
diagnostics: list[Diagnostic] = []
|
|
84
|
+
for issue in issues:
|
|
85
|
+
if not isinstance(issue, dict):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
error = issue.get("error", {})
|
|
89
|
+
if not isinstance(error, dict):
|
|
90
|
+
error = {}
|
|
91
|
+
code = str(error.get("code", "DEP000"))
|
|
92
|
+
|
|
93
|
+
location = issue.get("location", {})
|
|
94
|
+
if not isinstance(location, dict):
|
|
95
|
+
location = {}
|
|
96
|
+
module = str(issue.get("module", "unknown"))
|
|
97
|
+
|
|
98
|
+
desc, severity = DEPTRY_CODES.get(
|
|
99
|
+
code, ("Dependency issue", Severity.WARNING)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
file_path = str(location.get("file", "pyproject.toml"))
|
|
103
|
+
line_val = location.get("line")
|
|
104
|
+
col_val = location.get("column")
|
|
105
|
+
|
|
106
|
+
diagnostics.append(
|
|
107
|
+
Diagnostic(
|
|
108
|
+
file_path=Path(file_path),
|
|
109
|
+
line=int(line_val) if isinstance(line_val, int | float) else 0,
|
|
110
|
+
column=int(col_val) if isinstance(col_val, int | float) else 0,
|
|
111
|
+
severity=severity,
|
|
112
|
+
rule_id=code,
|
|
113
|
+
tool="deptry",
|
|
114
|
+
category=Category.DEPENDENCIES,
|
|
115
|
+
message=f"{desc}: {module}",
|
|
116
|
+
fix=_fix_suggestion(code, module),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return diagnostics
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _fix_suggestion(code: str, module: str) -> str:
|
|
124
|
+
match code:
|
|
125
|
+
case "DEP001":
|
|
126
|
+
return f"Add '{module}' to your project dependencies in pyproject.toml"
|
|
127
|
+
case "DEP002":
|
|
128
|
+
return (
|
|
129
|
+
f"Remove '{module}' from your project dependencies (it's not imported)"
|
|
130
|
+
)
|
|
131
|
+
case "DEP003":
|
|
132
|
+
return (
|
|
133
|
+
f"Add '{module}' as a direct dependency instead of "
|
|
134
|
+
"relying on it transitively"
|
|
135
|
+
)
|
|
136
|
+
case "DEP004":
|
|
137
|
+
return (
|
|
138
|
+
f"Move '{module}' from dev dependencies to main dependencies "
|
|
139
|
+
"(it's imported in non-dev code)"
|
|
140
|
+
)
|
|
141
|
+
case _:
|
|
142
|
+
return f"Review the dependency '{module}'"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from python_checkup.analysis_request import AnalysisRequest
|
|
8
|
+
from python_checkup.models import Category, Diagnostic, Severity
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("python_checkup")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DetectSecretsAnalyzer:
|
|
14
|
+
"""Find hardcoded secrets using entropy and pattern analysis."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def name(self) -> str:
|
|
18
|
+
return "detect-secrets"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def category(self) -> Category:
|
|
22
|
+
return Category.SECURITY
|
|
23
|
+
|
|
24
|
+
async def is_available(self) -> bool:
|
|
25
|
+
"""Check if detect-secrets is importable."""
|
|
26
|
+
try:
|
|
27
|
+
from detect_secrets.core.secrets_collection import (
|
|
28
|
+
SecretsCollection, # noqa: F401
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return True
|
|
32
|
+
except ImportError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
async def analyze(
|
|
36
|
+
self,
|
|
37
|
+
request: AnalysisRequest,
|
|
38
|
+
) -> list[Diagnostic]:
|
|
39
|
+
"""Scan files for hardcoded secrets.
|
|
40
|
+
|
|
41
|
+
Uses the detect-secrets library API with default settings.
|
|
42
|
+
Runs in a thread because the scan is CPU-bound.
|
|
43
|
+
"""
|
|
44
|
+
files = request.files
|
|
45
|
+
if not files:
|
|
46
|
+
return []
|
|
47
|
+
return await asyncio.to_thread(self._analyze_sync, files)
|
|
48
|
+
|
|
49
|
+
def _analyze_sync(self, files: list[Path]) -> list[Diagnostic]:
|
|
50
|
+
"""Synchronous scan — called via ``asyncio.to_thread``."""
|
|
51
|
+
try:
|
|
52
|
+
from detect_secrets.core.secrets_collection import SecretsCollection
|
|
53
|
+
from detect_secrets.settings import (
|
|
54
|
+
default_settings,
|
|
55
|
+
)
|
|
56
|
+
except ImportError:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
secrets = SecretsCollection()
|
|
60
|
+
|
|
61
|
+
with default_settings():
|
|
62
|
+
for f in files:
|
|
63
|
+
try:
|
|
64
|
+
secrets.scan_file(str(f))
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
logger.debug("detect-secrets failed on %s: %s", f, exc)
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
diagnostics: list[Diagnostic] = []
|
|
70
|
+
for filename, secret_list in secrets.json().items():
|
|
71
|
+
if not isinstance(secret_list, list):
|
|
72
|
+
continue
|
|
73
|
+
for secret in secret_list:
|
|
74
|
+
if not isinstance(secret, dict):
|
|
75
|
+
continue
|
|
76
|
+
secret_type = str(secret.get("type", "unknown"))
|
|
77
|
+
rule_id = f"SECRET-{secret_type.upper().replace(' ', '-')}"
|
|
78
|
+
|
|
79
|
+
line_number = secret.get("line_number", 0)
|
|
80
|
+
if not isinstance(line_number, int):
|
|
81
|
+
line_number = 0
|
|
82
|
+
|
|
83
|
+
diagnostics.append(
|
|
84
|
+
Diagnostic(
|
|
85
|
+
file_path=Path(str(filename)),
|
|
86
|
+
line=line_number,
|
|
87
|
+
column=0,
|
|
88
|
+
severity=Severity.ERROR,
|
|
89
|
+
rule_id=rule_id,
|
|
90
|
+
tool="detect-secrets",
|
|
91
|
+
category=Category.SECURITY,
|
|
92
|
+
message=f"Potential {secret_type} detected",
|
|
93
|
+
fix=(
|
|
94
|
+
"Move this secret to an environment variable or a "
|
|
95
|
+
"secrets manager (e.g., AWS Secrets Manager, HashiCorp "
|
|
96
|
+
"Vault, or a .env file excluded from version control)."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return diagnostics
|