oss-trust-framework 0.5.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.
Files changed (32) hide show
  1. oss_trust_framework/__init__.py +1 -0
  2. oss_trust_framework/age_check/__init__.py +0 -0
  3. oss_trust_framework/age_check/checker.py +197 -0
  4. oss_trust_framework/cicd_audit/__init__.py +2 -0
  5. oss_trust_framework/cicd_audit/orphan_commits.py +329 -0
  6. oss_trust_framework/cicd_audit/pr_provenance.py +295 -0
  7. oss_trust_framework/cicd_audit/workflow_permissions.py +249 -0
  8. oss_trust_framework/cli.py +235 -0
  9. oss_trust_framework/config.py +24 -0
  10. oss_trust_framework/name_similarity/__init__.py +0 -0
  11. oss_trust_framework/name_similarity/checker.py +264 -0
  12. oss_trust_framework/pipeline/__init__.py +0 -0
  13. oss_trust_framework/pipeline/cli.py +191 -0
  14. oss_trust_framework/pipeline/orchestrator.py +282 -0
  15. oss_trust_framework/sandbox/__init__.py +0 -0
  16. oss_trust_framework/sandbox/behavioral_patterns.py +457 -0
  17. oss_trust_framework/sandbox/runner.py +511 -0
  18. oss_trust_framework/sandbox/test_gate5_sandbox_runner.py +322 -0
  19. oss_trust_framework/sbom/__init__.py +1 -0
  20. oss_trust_framework/sbom/differ.py +372 -0
  21. oss_trust_framework/signature/__init__.py +1 -0
  22. oss_trust_framework/signature/gpg.py +292 -0
  23. oss_trust_framework/signature/provenance.py +461 -0
  24. oss_trust_framework/trust/__init__.py +0 -0
  25. oss_trust_framework/trust/aggregator.py +202 -0
  26. oss_trust_framework/zeroday/__init__.py +0 -0
  27. oss_trust_framework/zeroday/validator.py +362 -0
  28. oss_trust_framework-0.5.1.dist-info/METADATA +673 -0
  29. oss_trust_framework-0.5.1.dist-info/RECORD +32 -0
  30. oss_trust_framework-0.5.1.dist-info/WHEEL +4 -0
  31. oss_trust_framework-0.5.1.dist-info/entry_points.txt +2 -0
  32. oss_trust_framework-0.5.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ # oss_trust_framework
File without changes
@@ -0,0 +1,197 @@
1
+ """
2
+ Gate 1 — Release age validation.
3
+
4
+ Checks the publication timestamp of a package release against configurable
5
+ hard-block and hold thresholds. The zero-day bypass path is handled by the
6
+ pipeline orchestrator before this gate is reached.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta, timezone
14
+ from enum import Enum
15
+
16
+ import httpx
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ REGISTRY_APIS = {
21
+ "PyPI": "https://pypi.org/pypi/{package}/{version}/json",
22
+ "npm": "https://registry.npmjs.org/{package}/{version}",
23
+ "Cargo": "https://crates.io/api/v1/crates/{package}/{version}",
24
+ "Maven": "https://search.maven.org/solrsearch/select?q=g:{group}+AND+a:{artifact}+AND+v:{version}&rows=1&wt=json",
25
+ "Go": "https://proxy.golang.org/{package}/@v/{version}.info",
26
+ }
27
+
28
+
29
+ class AgeDecision(str, Enum):
30
+ PASS = "pass"
31
+ HOLD = "hold" # 24–72 h: human approval needed
32
+ BLOCK = "block" # < 24 h: auto-blocked
33
+ ZERO_DAY_ELIGIBLE = "zd_eligible" # < 24 h but CVE exists — pipeline routes to ZD lane
34
+
35
+
36
+ @dataclass
37
+ class AgeCheckResult:
38
+ decision: AgeDecision
39
+ age_hours: float
40
+ release_time: datetime
41
+ package: str
42
+ version: str
43
+ ecosystem: str
44
+ message: str
45
+
46
+
47
+ async def check_release_age(
48
+ package: str,
49
+ version: str,
50
+ ecosystem: str,
51
+ hard_block_hours: int = 24,
52
+ hold_hours: int = 72,
53
+ http_client: httpx.AsyncClient | None = None,
54
+ ) -> AgeCheckResult:
55
+ """
56
+ Fetch the canonical release timestamp for a package version and evaluate
57
+ it against the configured age thresholds.
58
+
59
+ Args:
60
+ package: Package name (e.g. "requests", "express").
61
+ version: Exact version string (e.g. "2.32.3").
62
+ ecosystem: One of: PyPI, npm, Cargo, Maven, Go.
63
+ hard_block_hours: Releases younger than this are auto-blocked.
64
+ hold_hours: Releases younger than this (but older than hard_block)
65
+ require human approval before proceeding.
66
+ http_client: Optional pre-configured httpx.AsyncClient (for testing).
67
+
68
+ Returns:
69
+ AgeCheckResult with the decision and supporting metadata.
70
+ """
71
+ own_client = http_client is None
72
+ client = http_client or httpx.AsyncClient(timeout=15)
73
+
74
+ try:
75
+ release_time = await _fetch_release_time(client, package, version, ecosystem)
76
+ finally:
77
+ if own_client:
78
+ await client.aclose()
79
+
80
+ now = datetime.now(timezone.utc)
81
+ age = now - release_time
82
+ age_hours = age.total_seconds() / 3600
83
+
84
+ if age < timedelta(hours=hard_block_hours):
85
+ decision = AgeDecision.BLOCK
86
+ message = (
87
+ f"{package}@{version} is only {age_hours:.1f}h old — "
88
+ f"hard block threshold is {hard_block_hours}h. "
89
+ "File a CVE reference to route through the zero-day expedited lane."
90
+ )
91
+ elif age < timedelta(hours=hold_hours):
92
+ decision = AgeDecision.HOLD
93
+ message = (
94
+ f"{package}@{version} is {age_hours:.1f}h old — "
95
+ f"within the {hold_hours}h hold window. Human approval required."
96
+ )
97
+ else:
98
+ decision = AgeDecision.PASS
99
+ message = f"{package}@{version} is {age_hours:.1f}h old — age gate cleared."
100
+
101
+ logger.info(
102
+ "age_check decision=%s package=%s version=%s age_hours=%.2f",
103
+ decision.value,
104
+ package,
105
+ version,
106
+ age_hours,
107
+ )
108
+
109
+ return AgeCheckResult(
110
+ decision=decision,
111
+ age_hours=age_hours,
112
+ release_time=release_time,
113
+ package=package,
114
+ version=version,
115
+ ecosystem=ecosystem,
116
+ message=message,
117
+ )
118
+
119
+
120
+ async def _fetch_release_time(
121
+ client: httpx.AsyncClient,
122
+ package: str,
123
+ version: str,
124
+ ecosystem: str,
125
+ ) -> datetime:
126
+ """Dispatch to the appropriate registry API and return the release datetime."""
127
+
128
+ if ecosystem == "PyPI":
129
+ return await _pypi_release_time(client, package, version)
130
+ elif ecosystem == "npm":
131
+ return await _npm_release_time(client, package, version)
132
+ elif ecosystem == "Cargo":
133
+ return await _cargo_release_time(client, package, version)
134
+ elif ecosystem == "Go":
135
+ return await _go_release_time(client, package, version)
136
+ else:
137
+ raise ValueError(f"Unsupported ecosystem: {ecosystem!r}")
138
+
139
+
140
+ async def _pypi_release_time(
141
+ client: httpx.AsyncClient, package: str, version: str
142
+ ) -> datetime:
143
+ url = f"https://pypi.org/pypi/{package}/{version}/json"
144
+ resp = client.get(url)
145
+ resp = await resp
146
+ resp.raise_for_status()
147
+ data = resp.json()
148
+
149
+ upload_times = [
150
+ datetime.fromisoformat(f["upload_time_iso_8601"].replace("Z", "+00:00"))
151
+ for f in data["urls"]
152
+ ]
153
+ if not upload_times:
154
+ raise ValueError(f"No upload records found for {package}=={version} on PyPI")
155
+ return min(upload_times)
156
+
157
+
158
+ async def _npm_release_time(
159
+ client: httpx.AsyncClient, package: str, version: str
160
+ ) -> datetime:
161
+ url = f"https://registry.npmjs.org/{package}"
162
+ resp = await client.get(url)
163
+ resp.raise_for_status()
164
+ data = resp.json()
165
+
166
+ time_str = data.get("time", {}).get(version)
167
+ if not time_str:
168
+ raise ValueError(f"No time record for {package}@{version} on npm")
169
+ return datetime.fromisoformat(time_str.replace("Z", "+00:00"))
170
+
171
+
172
+ async def _cargo_release_time(
173
+ client: httpx.AsyncClient, package: str, version: str
174
+ ) -> datetime:
175
+ url = f"https://crates.io/api/v1/crates/{package}/{version}"
176
+ resp = await client.get(url, headers={"User-Agent": "oss-trust-framework/0.1"})
177
+ resp.raise_for_status()
178
+ data = resp.json()
179
+
180
+ time_str = data.get("version", {}).get("created_at")
181
+ if not time_str:
182
+ raise ValueError(f"No created_at for {package}@{version} on crates.io")
183
+ return datetime.fromisoformat(time_str.replace("Z", "+00:00"))
184
+
185
+
186
+ async def _go_release_time(
187
+ client: httpx.AsyncClient, package: str, version: str
188
+ ) -> datetime:
189
+ url = f"https://proxy.golang.org/{package}/@v/{version}.info"
190
+ resp = await client.get(url)
191
+ resp.raise_for_status()
192
+ data = resp.json()
193
+
194
+ time_str = data.get("Time")
195
+ if not time_str:
196
+ raise ValueError(f"No Time field for {package}@{version} from Go proxy")
197
+ return datetime.fromisoformat(time_str.replace("Z", "+00:00"))
@@ -0,0 +1,2 @@
1
+ # cicd_audit — Gate 2.5 controls targeting CI/CD pipeline compromise vectors
2
+ # (Miasma / Shai-Hulud / TanStack / Bitwarden attack class)
@@ -0,0 +1,329 @@
1
+ """
2
+ Gate 2.5a -- Orphan commit detection.
3
+
4
+ Fix 2026-06-06c: Added trusted_repos allowlist support. Repos listed in
5
+ config/pipeline.yaml under cicd_audit.orphan_commit_trusted_repos skip
6
+ the orphan commit check entirely -- these are well-established projects
7
+ whose release workflows legitimately produce commits off the default branch.
8
+
9
+ Fix 2026-06-06b: Added version-targeted checking and 180-day age filter.
10
+ Fix 2026-06-06a: Added graceful 403 handling for external repos.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone, timedelta
19
+ from typing import Optional
20
+
21
+ import httpx
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ GRAPH_WALK_DEPTH = 300
26
+ RECENT_TAGS = 5
27
+ LOOKBACK_DAYS = 180
28
+
29
+
30
+ @dataclass
31
+ class OrphanFinding:
32
+ tag: str
33
+ sha: str
34
+ reason: str
35
+ risk: str
36
+
37
+
38
+ @dataclass
39
+ class OrphanCommitResult:
40
+ passed: bool
41
+ orphans_found: int
42
+ findings: list[OrphanFinding] = field(default_factory=list)
43
+ reachable_commits_checked: int = 0
44
+ skipped: bool = False
45
+ message: str = ""
46
+
47
+
48
+ async def detect_orphan_commits(
49
+ owner: str,
50
+ repo: str,
51
+ github_token: str,
52
+ version: Optional[str] = None,
53
+ recent_tags: int = RECENT_TAGS,
54
+ graph_depth: int = GRAPH_WALK_DEPTH,
55
+ lookback_days: int = LOOKBACK_DAYS,
56
+ trusted_repos: Optional[list[str]] = None,
57
+ http_client: Optional[httpx.AsyncClient] = None,
58
+ ) -> OrphanCommitResult:
59
+ """
60
+ Walk the commit graph from the default branch tip and check whether the
61
+ release tag for the specified version is reachable.
62
+
63
+ Args:
64
+ trusted_repos: List of "owner/repo" strings to skip entirely.
65
+ Load from config/pipeline.yaml:
66
+ cicd_audit.orphan_commit_trusted_repos
67
+ """
68
+ # Skip trusted repos entirely
69
+ repo_slug = f"{owner}/{repo}"
70
+ if trusted_repos and repo_slug in trusted_repos:
71
+ logger.info(
72
+ "orphan_commits skipping trusted repo %s -- in allowlist", repo_slug
73
+ )
74
+ return OrphanCommitResult(
75
+ passed=True,
76
+ orphans_found=0,
77
+ skipped=True,
78
+ message=(
79
+ f"Gate 2.5a skipped for {repo_slug} -- repo is in the "
80
+ f"trusted_repos allowlist (established project with known "
81
+ f"non-standard release workflow)."
82
+ ),
83
+ )
84
+
85
+ own_client = http_client is None
86
+ client = http_client or httpx.AsyncClient(
87
+ timeout=20,
88
+ headers={
89
+ "Authorization": f"Bearer {github_token}",
90
+ "Accept": "application/vnd.github+json",
91
+ "X-GitHub-Api-Version": "2022-11-28",
92
+ },
93
+ )
94
+
95
+ cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days)
96
+
97
+ try:
98
+ repo_resp = await client.get(
99
+ f"https://api.github.com/repos/{owner}/{repo}"
100
+ )
101
+ if repo_resp.status_code in (403, 404):
102
+ logger.warning(
103
+ "orphan_commits %d for %s -- skipping gate.",
104
+ repo_resp.status_code, repo_slug
105
+ )
106
+ return OrphanCommitResult(
107
+ passed=True, orphans_found=0, skipped=True,
108
+ message=(
109
+ f"Gate 2.5a skipped for {repo_slug} -- "
110
+ f"HTTP {repo_resp.status_code}. "
111
+ f"Use a PAT with repo:read scope for full coverage."
112
+ ),
113
+ )
114
+ repo_resp.raise_for_status()
115
+ default_branch = repo_resp.json()["default_branch"]
116
+
117
+ branch_resp = await client.get(
118
+ f"https://api.github.com/repos/{owner}/{repo}/branches/{default_branch}"
119
+ )
120
+ if branch_resp.status_code in (403, 404):
121
+ logger.warning(
122
+ "orphan_commits %d on branch %s for %s -- skipping.",
123
+ branch_resp.status_code, default_branch, repo_slug
124
+ )
125
+ return OrphanCommitResult(
126
+ passed=True, orphans_found=0, skipped=True,
127
+ message=f"Gate 2.5a skipped -- cannot read branch for {repo_slug}.",
128
+ )
129
+ branch_resp.raise_for_status()
130
+ tip_sha = branch_resp.json()["commit"]["sha"]
131
+
132
+ reachable = await _walk_graph(client, owner, repo, tip_sha, graph_depth)
133
+ logger.debug("orphan_check reachable=%d tip=%s", len(reachable), tip_sha[:8])
134
+
135
+ tags_resp = await client.get(
136
+ f"https://api.github.com/repos/{owner}/{repo}/tags",
137
+ params={"per_page": 30},
138
+ )
139
+ if tags_resp.status_code in (403, 404):
140
+ return OrphanCommitResult(
141
+ passed=True, orphans_found=0, skipped=True,
142
+ message=f"Gate 2.5a skipped -- cannot read tags for {repo_slug}.",
143
+ )
144
+ tags_resp.raise_for_status()
145
+ all_tags = tags_resp.json()
146
+
147
+ tags_to_check = []
148
+ version_tag = None
149
+
150
+ if version:
151
+ version_variants = {
152
+ version, f"v{version}",
153
+ f"{version}.0", f"v{version}.0",
154
+ version.replace(".", "_"),
155
+ }
156
+ for tag in all_tags:
157
+ if tag["name"] in version_variants:
158
+ version_tag = tag
159
+ break
160
+
161
+ if version_tag:
162
+ tags_to_check.append(version_tag)
163
+
164
+ recent_count = 0
165
+ for tag in all_tags:
166
+ if recent_count >= recent_tags:
167
+ break
168
+ if version_tag and tag["name"] == version_tag["name"]:
169
+ continue
170
+
171
+ tag_sha = tag["commit"]["sha"]
172
+ resolved_sha = await _resolve_to_commit(client, owner, repo, tag_sha)
173
+ commit_date = await _get_commit_date(client, owner, repo, resolved_sha)
174
+
175
+ if commit_date and commit_date >= cutoff:
176
+ tags_to_check.append(tag)
177
+ recent_count += 1
178
+ else:
179
+ logger.debug(
180
+ "orphan_check skipping old tag %s (date: %s)",
181
+ tag["name"],
182
+ commit_date.isoformat() if commit_date else "unknown",
183
+ )
184
+
185
+ if not tags_to_check:
186
+ return OrphanCommitResult(
187
+ passed=True, orphans_found=0,
188
+ reachable_commits_checked=len(reachable),
189
+ message=f"No recent tags to evaluate for {repo_slug} (lookback: {lookback_days}d).",
190
+ )
191
+
192
+ findings: list[OrphanFinding] = []
193
+
194
+ for tag in tags_to_check:
195
+ tag_name = tag["name"]
196
+ tag_sha = tag["commit"]["sha"]
197
+ resolved_sha = await _resolve_to_commit(client, owner, repo, tag_sha)
198
+
199
+ if resolved_sha not in reachable:
200
+ is_version_match = (
201
+ version_tag is not None and tag_name == version_tag["name"]
202
+ )
203
+ risk = "HIGH" if is_version_match else "MEDIUM"
204
+ findings.append(OrphanFinding(
205
+ tag=tag_name, sha=resolved_sha,
206
+ reason=(
207
+ f"Commit {resolved_sha[:8]} backing tag '{tag_name}' "
208
+ f"is not reachable from {default_branch} tip ({tip_sha[:8]}). "
209
+ + ("EXACT VERSION -- high confidence."
210
+ if is_version_match else "Recent tag with orphan commit.")
211
+ ),
212
+ risk=risk,
213
+ ))
214
+ logger.warning(
215
+ "orphan_commit_found tag=%s sha=%s repo=%s risk=%s",
216
+ tag_name, resolved_sha[:8], repo_slug, risk,
217
+ )
218
+
219
+ except httpx.HTTPStatusError as exc:
220
+ logger.warning(
221
+ "orphan_commits HTTP %s for %s -- skipping.",
222
+ exc.response.status_code, repo_slug
223
+ )
224
+ return OrphanCommitResult(
225
+ passed=True, orphans_found=0, skipped=True,
226
+ message=(
227
+ f"Gate 2.5a skipped -- HTTP {exc.response.status_code} "
228
+ f"for {repo_slug}."
229
+ ),
230
+ )
231
+ except Exception as exc:
232
+ logger.warning("orphan_commits error for %s: %s", repo_slug, exc)
233
+ return OrphanCommitResult(
234
+ passed=True, orphans_found=0, skipped=True,
235
+ message=f"Gate 2.5a skipped -- error: {exc}",
236
+ )
237
+ finally:
238
+ if own_client:
239
+ await client.aclose()
240
+
241
+ high_risk = [f for f in findings if f.risk == "HIGH"]
242
+ medium_risk = [f for f in findings if f.risk == "MEDIUM"]
243
+ passed = len(high_risk) == 0 and len(medium_risk) < 2
244
+
245
+ if not findings:
246
+ message = f"No orphan commits found across {len(tags_to_check)} evaluated tags."
247
+ elif passed:
248
+ message = (
249
+ f"{len(findings)} historical orphan commit(s) found but below "
250
+ f"block threshold. Tags: {[f.tag for f in findings]}"
251
+ )
252
+ else:
253
+ message = (
254
+ f"{len(findings)} orphan commit(s) detected -- direct push "
255
+ f"bypassing PR review. This matches the Miasma/Shai-Hulud attack pattern."
256
+ )
257
+
258
+ return OrphanCommitResult(
259
+ passed=passed, orphans_found=len(findings),
260
+ findings=findings, reachable_commits_checked=len(reachable),
261
+ message=message,
262
+ )
263
+
264
+
265
+ async def _walk_graph(
266
+ client: httpx.AsyncClient, owner: str, repo: str, start_sha: str, depth: int,
267
+ ) -> set[str]:
268
+ reachable: set[str] = set()
269
+ queue = [start_sha]
270
+ sem = asyncio.Semaphore(8)
271
+
272
+ async def fetch_parents(sha: str) -> list[str]:
273
+ async with sem:
274
+ try:
275
+ resp = await client.get(
276
+ f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}"
277
+ )
278
+ if resp.status_code == 200:
279
+ return [p["sha"] for p in resp.json().get("parents", [])]
280
+ except Exception as exc:
281
+ logger.debug("commit fetch failed sha=%s: %s", sha[:8], exc)
282
+ return []
283
+
284
+ while queue and len(reachable) < depth:
285
+ batch = queue[:16]
286
+ queue = queue[16:]
287
+ parent_lists = await asyncio.gather(*[fetch_parents(sha) for sha in batch])
288
+ for sha, parents in zip(batch, parent_lists):
289
+ reachable.add(sha)
290
+ for p in parents:
291
+ if p not in reachable:
292
+ queue.append(p)
293
+
294
+ return reachable
295
+
296
+
297
+ async def _resolve_to_commit(
298
+ client: httpx.AsyncClient, owner: str, repo: str, sha: str
299
+ ) -> str:
300
+ try:
301
+ resp = await client.get(
302
+ f"https://api.github.com/repos/{owner}/{repo}/git/tags/{sha}"
303
+ )
304
+ if resp.status_code == 200:
305
+ obj = resp.json().get("object", {})
306
+ if obj.get("type") == "commit":
307
+ return obj["sha"]
308
+ except Exception:
309
+ pass
310
+ return sha
311
+
312
+
313
+ async def _get_commit_date(
314
+ client: httpx.AsyncClient, owner: str, repo: str, sha: str
315
+ ) -> Optional[datetime]:
316
+ try:
317
+ resp = await client.get(
318
+ f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}"
319
+ )
320
+ if resp.status_code == 200:
321
+ date_str = (
322
+ resp.json().get("commit", {})
323
+ .get("committer", {}).get("date", "")
324
+ )
325
+ if date_str:
326
+ return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
327
+ except Exception as exc:
328
+ logger.debug("commit date fetch failed sha=%s: %s", sha[:8], exc)
329
+ return None