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.
- oss_trust_framework/__init__.py +1 -0
- oss_trust_framework/age_check/__init__.py +0 -0
- oss_trust_framework/age_check/checker.py +197 -0
- oss_trust_framework/cicd_audit/__init__.py +2 -0
- oss_trust_framework/cicd_audit/orphan_commits.py +329 -0
- oss_trust_framework/cicd_audit/pr_provenance.py +295 -0
- oss_trust_framework/cicd_audit/workflow_permissions.py +249 -0
- oss_trust_framework/cli.py +235 -0
- oss_trust_framework/config.py +24 -0
- oss_trust_framework/name_similarity/__init__.py +0 -0
- oss_trust_framework/name_similarity/checker.py +264 -0
- oss_trust_framework/pipeline/__init__.py +0 -0
- oss_trust_framework/pipeline/cli.py +191 -0
- oss_trust_framework/pipeline/orchestrator.py +282 -0
- oss_trust_framework/sandbox/__init__.py +0 -0
- oss_trust_framework/sandbox/behavioral_patterns.py +457 -0
- oss_trust_framework/sandbox/runner.py +511 -0
- oss_trust_framework/sandbox/test_gate5_sandbox_runner.py +322 -0
- oss_trust_framework/sbom/__init__.py +1 -0
- oss_trust_framework/sbom/differ.py +372 -0
- oss_trust_framework/signature/__init__.py +1 -0
- oss_trust_framework/signature/gpg.py +292 -0
- oss_trust_framework/signature/provenance.py +461 -0
- oss_trust_framework/trust/__init__.py +0 -0
- oss_trust_framework/trust/aggregator.py +202 -0
- oss_trust_framework/zeroday/__init__.py +0 -0
- oss_trust_framework/zeroday/validator.py +362 -0
- oss_trust_framework-0.5.1.dist-info/METADATA +673 -0
- oss_trust_framework-0.5.1.dist-info/RECORD +32 -0
- oss_trust_framework-0.5.1.dist-info/WHEEL +4 -0
- oss_trust_framework-0.5.1.dist-info/entry_points.txt +2 -0
- 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,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
|