provctl 0.1.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.
- provctl/__init__.py +3 -0
- provctl/check.py +392 -0
- provctl/cli.py +394 -0
- provctl/enrich/__init__.py +1 -0
- provctl/enrich/pypi.py +163 -0
- provctl/gitio.py +134 -0
- provctl/http.py +62 -0
- provctl/index/__init__.py +1 -0
- provctl/index/adoption.py +100 -0
- provctl/index/refresh.py +104 -0
- provctl/index/store.py +192 -0
- provctl/model.py +171 -0
- provctl/naming.py +35 -0
- provctl/paths.py +51 -0
- provctl/policy/__init__.py +1 -0
- provctl/policy/config.py +97 -0
- provctl/policy/decisions.py +171 -0
- provctl/policy/verdict.py +191 -0
- provctl/provenance/__init__.py +1 -0
- provctl/provenance/gitai.py +160 -0
- provctl/provenance/hook.py +174 -0
- provctl/provenance/model.py +76 -0
- provctl/report.py +122 -0
- provctl/resolve/__init__.py +1 -0
- provctl/resolve/distmap.py +171 -0
- provctl/resolve/firstparty.py +201 -0
- provctl/resolve/imports.py +130 -0
- provctl/resolve/manifests.py +335 -0
- provctl-0.1.1.dist-info/METADATA +314 -0
- provctl-0.1.1.dist-info/RECORD +33 -0
- provctl-0.1.1.dist-info/WHEEL +4 -0
- provctl-0.1.1.dist-info/entry_points.txt +2 -0
- provctl-0.1.1.dist-info/licenses/LICENSE +202 -0
provctl/__init__.py
ADDED
provctl/check.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Changed files in, findings out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from provctl import gitio
|
|
10
|
+
from provctl.enrich.pypi import MetadataCache
|
|
11
|
+
from provctl.index import adoption as adoption_mod
|
|
12
|
+
from provctl.index.refresh import is_stale, staleness
|
|
13
|
+
from provctl.index.store import IndexMissingError, IndexStore, NameIndex
|
|
14
|
+
from provctl.model import (
|
|
15
|
+
BandCounts,
|
|
16
|
+
CheckResult,
|
|
17
|
+
Finding,
|
|
18
|
+
Origin,
|
|
19
|
+
Reason,
|
|
20
|
+
Verdict,
|
|
21
|
+
)
|
|
22
|
+
from provctl.paths import decisions_path
|
|
23
|
+
from provctl.policy import verdict as verdict_mod
|
|
24
|
+
from provctl.policy.config import Policy
|
|
25
|
+
from provctl.policy.decisions import DecisionLog
|
|
26
|
+
from provctl.provenance.gitai import GitAiAdapter
|
|
27
|
+
from provctl.provenance.hook import HookProvenanceReader
|
|
28
|
+
from provctl.provenance.model import UNTRACKED, ProvenanceMap
|
|
29
|
+
from provctl.policy.verdict import MetadataSignals
|
|
30
|
+
from provctl.resolve import firstparty, imports as imports_mod, manifests
|
|
31
|
+
from provctl.resolve.distmap import DistributionResolver
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CheckOptions:
|
|
36
|
+
scope: str = "staged" # staged | worktree | all | range
|
|
37
|
+
rev_range: str | None = None
|
|
38
|
+
use_provenance: bool = True
|
|
39
|
+
enrich: bool = False # Phase 2 metadata lookup (off the hot path)
|
|
40
|
+
include_imports: bool = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Checker:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
repo: Path,
|
|
47
|
+
policy: Policy,
|
|
48
|
+
store: IndexStore,
|
|
49
|
+
*,
|
|
50
|
+
resolver: DistributionResolver | None = None,
|
|
51
|
+
adoption: adoption_mod.AdoptionSet | None = None,
|
|
52
|
+
metadata_cache: MetadataCache | None = None,
|
|
53
|
+
):
|
|
54
|
+
self.repo = repo
|
|
55
|
+
self.policy = policy
|
|
56
|
+
self.store = store
|
|
57
|
+
self.resolver = resolver or DistributionResolver(overrides=policy.mapping_overrides)
|
|
58
|
+
self.adoption = adoption if adoption is not None else adoption_mod.load(store)
|
|
59
|
+
self._metadata_cache = metadata_cache
|
|
60
|
+
self._owns_cache = False
|
|
61
|
+
if metadata_cache is None:
|
|
62
|
+
# Missing cache just means no age signal.
|
|
63
|
+
cache_path = store.cache_dir / "metadata.sqlite3"
|
|
64
|
+
if cache_path.exists():
|
|
65
|
+
try:
|
|
66
|
+
self._metadata_cache = MetadataCache(cache_path)
|
|
67
|
+
self._owns_cache = True
|
|
68
|
+
except Exception:
|
|
69
|
+
self._metadata_cache = None
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
if self._owns_cache and self._metadata_cache is not None:
|
|
73
|
+
self._metadata_cache.close()
|
|
74
|
+
self._metadata_cache = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run(self, options: CheckOptions) -> CheckResult:
|
|
78
|
+
result = CheckResult()
|
|
79
|
+
|
|
80
|
+
changes = self._collect_changes(options, result)
|
|
81
|
+
if changes is None:
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
index = self.store.open_index()
|
|
85
|
+
meta = self.store.read_meta()
|
|
86
|
+
if meta is not None:
|
|
87
|
+
result.index_project_count = meta.project_count
|
|
88
|
+
age = staleness(meta)
|
|
89
|
+
result.index_age_days = round(age.total_seconds() / 86400, 2) if age else None
|
|
90
|
+
if is_stale(meta):
|
|
91
|
+
result.warnings.append(
|
|
92
|
+
"local PyPI index is more than 7 days old; "
|
|
93
|
+
"run `provctl index refresh` (checks still ran)"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
self._probe_index(index, result)
|
|
98
|
+
except IndexMissingError as exc:
|
|
99
|
+
result.warnings.append(str(exc))
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
manifest_scan = self._scan_manifests(changes, options)
|
|
103
|
+
first_party = self._build_first_party(manifest_scan)
|
|
104
|
+
private_index = self._detect_private_index(manifest_scan)
|
|
105
|
+
|
|
106
|
+
declared = {entry.name for entry in manifest_scan.entries}
|
|
107
|
+
|
|
108
|
+
findings: list[Finding] = []
|
|
109
|
+
findings.extend(
|
|
110
|
+
self._check_manifest_entries(manifest_scan, index, first_party, private_index, changes)
|
|
111
|
+
)
|
|
112
|
+
if options.include_imports:
|
|
113
|
+
findings.extend(
|
|
114
|
+
self._check_imports(changes, options, index, first_party, private_index, declared, result)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
findings = self._deduplicate(findings)
|
|
118
|
+
findings = self._apply_provenance(findings, options, result)
|
|
119
|
+
findings = self._apply_acknowledgements(findings)
|
|
120
|
+
result.findings = findings
|
|
121
|
+
result.warnings.extend(manifest_scan.parse_errors)
|
|
122
|
+
result.warnings.extend(self.policy.warnings)
|
|
123
|
+
result.bands = self._count_bands(findings)
|
|
124
|
+
index.close()
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _collect_changes(self, options: CheckOptions, result: CheckResult):
|
|
129
|
+
# Don't let git dump usage text into a commit hook.
|
|
130
|
+
if not gitio.is_git_repo(self.repo):
|
|
131
|
+
result.warnings.append(f"{self.repo} is not a git repository")
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
if options.scope == "staged":
|
|
135
|
+
return gitio.staged_changes(self.repo)
|
|
136
|
+
if options.scope == "worktree":
|
|
137
|
+
return gitio.worktree_changes(self.repo)
|
|
138
|
+
if options.scope == "range" and options.rev_range:
|
|
139
|
+
return gitio.range_changes(self.repo, options.rev_range)
|
|
140
|
+
return gitio.all_tracked_files(self.repo)
|
|
141
|
+
except gitio.GitError as exc:
|
|
142
|
+
result.warnings.append(f"could not read git changes: {exc}")
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _probe_index(index: NameIndex, result: CheckResult) -> None:
|
|
147
|
+
# Open the mmap once so a missing index is one warning, not N.
|
|
148
|
+
index.contains_normalized("pip")
|
|
149
|
+
|
|
150
|
+
def _scan_manifests(self, changes, options: CheckOptions) -> manifests.ManifestScan:
|
|
151
|
+
merged = manifests.ManifestScan()
|
|
152
|
+
staged = options.scope == "staged"
|
|
153
|
+
|
|
154
|
+
for rel in changes.files:
|
|
155
|
+
if not manifests.is_manifest(rel):
|
|
156
|
+
continue
|
|
157
|
+
text = gitio.file_content(self.repo, rel, staged=staged)
|
|
158
|
+
if text is None:
|
|
159
|
+
continue
|
|
160
|
+
sub = manifests.parse_manifest(rel, text)
|
|
161
|
+
|
|
162
|
+
# requirements.txt: only added lines. TOML has no reliable line
|
|
163
|
+
# numbers, so we re-check the whole file.
|
|
164
|
+
if not changes.is_fully_new(rel):
|
|
165
|
+
added = changes.lines_for(rel)
|
|
166
|
+
if added and rel.endswith((".txt", ".in")):
|
|
167
|
+
sub.entries = [e for e in sub.entries if e.lineno in added]
|
|
168
|
+
|
|
169
|
+
merged.entries.extend(sub.entries)
|
|
170
|
+
merged.index_urls.extend(sub.index_urls)
|
|
171
|
+
merged.parse_errors.extend(sub.parse_errors)
|
|
172
|
+
merged.url_requirements |= sub.url_requirements
|
|
173
|
+
merged.workspace_members |= sub.workspace_members
|
|
174
|
+
if sub.project_name and not merged.project_name:
|
|
175
|
+
merged.project_name = sub.project_name
|
|
176
|
+
|
|
177
|
+
# Root pyproject still names first-party packages if it wasn't edited.
|
|
178
|
+
if merged.project_name is None:
|
|
179
|
+
root_pyproject = self.repo / "pyproject.toml"
|
|
180
|
+
if root_pyproject.exists():
|
|
181
|
+
try:
|
|
182
|
+
scan = manifests.parse_pyproject(
|
|
183
|
+
root_pyproject.read_text(encoding="utf-8", errors="replace"),
|
|
184
|
+
"pyproject.toml",
|
|
185
|
+
)
|
|
186
|
+
merged.project_name = scan.project_name
|
|
187
|
+
merged.index_urls.extend(scan.index_urls)
|
|
188
|
+
merged.url_requirements |= scan.url_requirements
|
|
189
|
+
merged.workspace_members |= scan.workspace_members
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
return merged
|
|
193
|
+
|
|
194
|
+
def _build_first_party(self, scan: manifests.ManifestScan) -> firstparty.FirstPartyIndex:
|
|
195
|
+
return firstparty.discover(
|
|
196
|
+
self.repo,
|
|
197
|
+
project_name=scan.project_name,
|
|
198
|
+
prefixes=self.policy.first_party_prefixes,
|
|
199
|
+
allowlist=self.policy.first_party_allow,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _detect_private_index(self, scan: manifests.ManifestScan) -> bool:
|
|
203
|
+
if self.policy.private_index:
|
|
204
|
+
return True
|
|
205
|
+
if scan.has_private_index:
|
|
206
|
+
return True
|
|
207
|
+
env_urls = manifests.environment_index_urls(dict(os.environ))
|
|
208
|
+
return any(not manifests._is_public_pypi(url) for url in env_urls)
|
|
209
|
+
|
|
210
|
+
def _check_manifest_entries(
|
|
211
|
+
self, scan, index: NameIndex, first_party, private_index: bool, changes
|
|
212
|
+
) -> list[Finding]:
|
|
213
|
+
findings = []
|
|
214
|
+
seen: set[str] = set()
|
|
215
|
+
for entry in scan.entries:
|
|
216
|
+
if entry.name in seen:
|
|
217
|
+
continue
|
|
218
|
+
seen.add(entry.name)
|
|
219
|
+
|
|
220
|
+
exists = index.contains_normalized(entry.name)
|
|
221
|
+
finding = verdict_mod.evaluate_manifest_entry(
|
|
222
|
+
name=entry.name,
|
|
223
|
+
raw_name=entry.raw_name,
|
|
224
|
+
location=f"{entry.manifest}:{entry.lineno}" if entry.lineno else entry.manifest,
|
|
225
|
+
exists=exists,
|
|
226
|
+
is_first_party=(
|
|
227
|
+
first_party.is_first_party_dist(entry.name)
|
|
228
|
+
or entry.name in scan.workspace_members
|
|
229
|
+
),
|
|
230
|
+
private_index=private_index,
|
|
231
|
+
is_url_requirement=entry.is_url or entry.name in scan.url_requirements,
|
|
232
|
+
signals=self._signals_for(entry.name) if exists else None,
|
|
233
|
+
policy=self.policy,
|
|
234
|
+
)
|
|
235
|
+
findings.append(finding)
|
|
236
|
+
return findings
|
|
237
|
+
|
|
238
|
+
def _check_imports(
|
|
239
|
+
self, changes, options, index, first_party, private_index, declared, result
|
|
240
|
+
) -> list[Finding]:
|
|
241
|
+
findings = []
|
|
242
|
+
staged = options.scope == "staged"
|
|
243
|
+
seen: set[str] = set()
|
|
244
|
+
siblings = firstparty.LocalSiblingResolver(self.repo)
|
|
245
|
+
|
|
246
|
+
for rel in changes.files:
|
|
247
|
+
if not imports_mod.is_python_file(rel):
|
|
248
|
+
continue
|
|
249
|
+
text = gitio.file_content(self.repo, rel, staged=staged)
|
|
250
|
+
if text is None:
|
|
251
|
+
continue
|
|
252
|
+
result.scanned_files += 1
|
|
253
|
+
|
|
254
|
+
sites = imports_mod.extract_third_party(text, rel)
|
|
255
|
+
if not changes.is_fully_new(rel):
|
|
256
|
+
added = changes.lines_for(rel)
|
|
257
|
+
if added:
|
|
258
|
+
sites = [s for s in sites if any(n in added for n in s.lines)]
|
|
259
|
+
|
|
260
|
+
for site in sites:
|
|
261
|
+
if site.module in seen:
|
|
262
|
+
continue
|
|
263
|
+
# Sibling modules are first-party; skip the index lookup.
|
|
264
|
+
if siblings.resolves(rel, site.module):
|
|
265
|
+
continue
|
|
266
|
+
seen.add(site.module)
|
|
267
|
+
|
|
268
|
+
resolution = self.resolver.resolve(site.module)
|
|
269
|
+
any_exists = any(
|
|
270
|
+
index.contains_normalized(c) for c in resolution.candidates
|
|
271
|
+
)
|
|
272
|
+
declared_here = any(c in declared for c in resolution.candidates)
|
|
273
|
+
|
|
274
|
+
finding = verdict_mod.evaluate_import(
|
|
275
|
+
module=site.module,
|
|
276
|
+
candidates=resolution.candidates,
|
|
277
|
+
confidence=resolution.confidence.value,
|
|
278
|
+
location=f"{rel}:{site.lineno}",
|
|
279
|
+
any_candidate_exists=any_exists,
|
|
280
|
+
is_first_party=first_party.is_first_party_module(site.module),
|
|
281
|
+
private_index=private_index,
|
|
282
|
+
declared=declared_here,
|
|
283
|
+
)
|
|
284
|
+
if finding is not None:
|
|
285
|
+
findings.append(finding)
|
|
286
|
+
return findings
|
|
287
|
+
|
|
288
|
+
def _signals_for(self, name: str) -> MetadataSignals | None:
|
|
289
|
+
"""Age/adoption signals from the local cache only. Miss = no signal."""
|
|
290
|
+
widely_adopted = None
|
|
291
|
+
if self.policy.use_adoption_signal and len(self.adoption):
|
|
292
|
+
widely_adopted = self.adoption.is_widely_adopted(name)
|
|
293
|
+
|
|
294
|
+
age_days = None
|
|
295
|
+
if self._metadata_cache is not None:
|
|
296
|
+
meta = self._metadata_cache.get(name)
|
|
297
|
+
if meta is not None:
|
|
298
|
+
age_days = meta.age_days()
|
|
299
|
+
|
|
300
|
+
return MetadataSignals(widely_adopted=widely_adopted, age_days=age_days)
|
|
301
|
+
|
|
302
|
+
def _load_provenance(self) -> ProvenanceMap:
|
|
303
|
+
"""Hook first (per-line, uncommitted); git-ai only as fallback."""
|
|
304
|
+
git_dir = self.repo / ".git"
|
|
305
|
+
hook_map = HookProvenanceReader(git_dir).load()
|
|
306
|
+
if not hook_map.is_empty:
|
|
307
|
+
return hook_map
|
|
308
|
+
|
|
309
|
+
adapter = GitAiAdapter(self.repo)
|
|
310
|
+
if adapter.available:
|
|
311
|
+
return adapter.committed_provenance()
|
|
312
|
+
return ProvenanceMap()
|
|
313
|
+
|
|
314
|
+
def _apply_provenance(
|
|
315
|
+
self, findings: list[Finding], options: CheckOptions, result: CheckResult
|
|
316
|
+
) -> list[Finding]:
|
|
317
|
+
"""Apply provenance only inside the suspicious band."""
|
|
318
|
+
if not options.use_provenance or not self.policy.provenance_enabled:
|
|
319
|
+
result.provenance_source = "disabled"
|
|
320
|
+
for finding in findings:
|
|
321
|
+
finding.baseline_verdict = finding.verdict
|
|
322
|
+
return findings
|
|
323
|
+
|
|
324
|
+
provenance = self._load_provenance()
|
|
325
|
+
result.provenance_source = provenance.source or "none"
|
|
326
|
+
|
|
327
|
+
for finding in findings:
|
|
328
|
+
summary = UNTRACKED
|
|
329
|
+
if finding.location and ":" in finding.location:
|
|
330
|
+
path, _, lineno = finding.location.rpartition(":")
|
|
331
|
+
if lineno.isdigit():
|
|
332
|
+
summary = provenance.summarize(path, int(lineno))
|
|
333
|
+
verdict_mod.apply_provenance(finding, summary, self.policy)
|
|
334
|
+
return findings
|
|
335
|
+
|
|
336
|
+
def _apply_acknowledgements(self, findings: list[Finding]) -> list[Finding]:
|
|
337
|
+
"""Drop findings already acked. Fingerprint ignores line numbers."""
|
|
338
|
+
log = DecisionLog(decisions_path(self.repo))
|
|
339
|
+
acknowledged = log.acknowledged_fingerprints()
|
|
340
|
+
if not acknowledged:
|
|
341
|
+
return findings
|
|
342
|
+
|
|
343
|
+
for finding in findings:
|
|
344
|
+
if finding.fingerprint() in acknowledged and finding.verdict is not Verdict.OK:
|
|
345
|
+
finding.verdict = Verdict.INFO
|
|
346
|
+
finding.reason = Reason.ACKNOWLEDGED
|
|
347
|
+
finding.detail = (
|
|
348
|
+
f"previously acknowledged; original finding: {finding.detail}"
|
|
349
|
+
if finding.detail else "previously acknowledged"
|
|
350
|
+
)
|
|
351
|
+
return findings
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _deduplicate(findings: list[Finding]) -> list[Finding]:
|
|
355
|
+
"""Keep the worst finding per (name, origin)."""
|
|
356
|
+
best: dict[tuple[str, str], Finding] = {}
|
|
357
|
+
for finding in findings:
|
|
358
|
+
key = (finding.name, finding.origin.value)
|
|
359
|
+
current = best.get(key)
|
|
360
|
+
if current is None or finding.verdict.rank > current.verdict.rank:
|
|
361
|
+
best[key] = finding
|
|
362
|
+
|
|
363
|
+
# Manifest finding already covers this name.
|
|
364
|
+
manifest_names = {
|
|
365
|
+
f.name for f in best.values() if f.origin is Origin.MANIFEST
|
|
366
|
+
}
|
|
367
|
+
ordered = [
|
|
368
|
+
f for f in best.values()
|
|
369
|
+
if not (f.origin is Origin.IMPORT and f.name in manifest_names)
|
|
370
|
+
]
|
|
371
|
+
ordered.sort(key=lambda f: (-f.verdict.rank, f.origin.value, f.name))
|
|
372
|
+
return ordered
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _count_bands(findings: list[Finding]) -> BandCounts:
|
|
376
|
+
bands = BandCounts(total=len(findings))
|
|
377
|
+
for finding in findings:
|
|
378
|
+
if finding.reason is Reason.ABSENT_FROM_INDEX and finding.verdict is Verdict.BLOCK:
|
|
379
|
+
bands.hard_absent += 1
|
|
380
|
+
elif finding.reason is Reason.ABSENT_BUT_FIRST_PARTY:
|
|
381
|
+
bands.first_party_suppressed += 1
|
|
382
|
+
elif finding.reason is Reason.ABSENT_BUT_PRIVATE_INDEX:
|
|
383
|
+
bands.private_index_suppressed += 1
|
|
384
|
+
elif finding.reason is Reason.ABSENT_UNRESOLVABLE_IMPORT:
|
|
385
|
+
bands.unresolvable_imports += 1
|
|
386
|
+
elif finding.reason is Reason.AMBIGUOUS_MAPPING:
|
|
387
|
+
bands.ambiguous_imports += 1
|
|
388
|
+
elif finding.reason is Reason.ACKNOWLEDGED:
|
|
389
|
+
bands.acknowledged += 1
|
|
390
|
+
if finding.provenance_sensitive:
|
|
391
|
+
bands.provenance_sensitive += 1
|
|
392
|
+
return bands
|