-
Notifications
You must be signed in to change notification settings - Fork 0
fix(security): fail closed formal PyPI dependency evidence #2344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
seonghobae
wants to merge
7
commits into
main
Choose a base branch
from
fix/formal-pypi-release-security-gate
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d548fad
fix(strix): resolve evidence binder from trusted source
seonghobae 2db8684
test(strix): bind evidence helper to trusted source root
seonghobae 747fc3e
fix(security): require exact-head release dependency evidence
seonghobae 1a86be0
fix(security): close release evidence review gaps
seonghobae b5b3b2c
fix(security): keep dependency review fail closed
seonghobae 28afd9e
test: restore Strix fixture evidence contracts
seonghobae ca51715
test: emit evidence for PR head scope fixtures
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,241 @@ | ||
| #!/usr/bin/env python3 | ||
| """Bind release dependency-review evidence to an exact pull-request revision. | ||
|
|
||
| The GitHub dependency-graph compare response contains one row per changed | ||
| dependency, including direct/transitive relationship, manifest, license, and | ||
| known vulnerabilities. This verifier rejects incomplete rows and forbidden | ||
| GNU-family licenses before emitting a deterministic machine-readable receipt. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import json | ||
| import re | ||
| import sys | ||
| from pathlib import Path | ||
| from typing import Any, Sequence | ||
|
|
||
| from packaging.licenses import InvalidLicenseExpression, canonicalize_license_expression | ||
|
|
||
|
|
||
| FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$") | ||
| FORBIDDEN_LICENSE_RE = re.compile( | ||
| r"(?:^|[^A-Z])(?:A?GPL|LGPL)(?:[-+.0-9]|$)", re.IGNORECASE | ||
| ) | ||
| UNVERIFIABLE_LICENSE_RE = re.compile(r"(?:^|[^A-Za-z])LicenseRef-", re.IGNORECASE) | ||
|
|
||
|
|
||
| class EvidenceError(ValueError): | ||
| """Raised when release dependency evidence is absent or incomplete.""" | ||
|
|
||
|
|
||
| def _required_text(row: dict[str, Any], key: str, index: int) -> str: | ||
| """Return a non-empty string field or fail with its row location.""" | ||
| value = row.get(key) | ||
| if not isinstance(value, str) or not value.strip(): | ||
| raise EvidenceError(f"dependency[{index}].{key} is required") | ||
| return value.strip() | ||
|
|
||
|
|
||
| def dependency_rows(payload: Any) -> list[dict[str, Any]]: | ||
| """Extract the compare API's dependency rows without accepting ambiguity.""" | ||
| rows = payload.get("dependencies") if isinstance(payload, dict) else payload | ||
| if not isinstance(rows, list): | ||
| raise EvidenceError("dependency evidence must be a JSON array or dependencies array") | ||
| if any(not isinstance(row, dict) for row in rows): | ||
| raise EvidenceError("every dependency evidence row must be an object") | ||
| return rows | ||
|
|
||
|
|
||
| def validate_license_expression(value: str, *, dependency_name: str) -> str: | ||
| """Return canonical SPDX or fail closed on unknown/custom license evidence.""" | ||
| if UNVERIFIABLE_LICENSE_RE.search(value): | ||
| raise EvidenceError( | ||
| f"unverifiable release dependency license: {dependency_name}: {value}" | ||
| ) | ||
| try: | ||
| canonical = canonicalize_license_expression(value) | ||
| except InvalidLicenseExpression as error: | ||
| raise EvidenceError( | ||
| f"invalid or unknown release dependency license: {dependency_name}: {value}" | ||
| ) from error | ||
| if UNVERIFIABLE_LICENSE_RE.search(canonical): | ||
| raise EvidenceError( | ||
| f"unverifiable release dependency license: {dependency_name}: {value}" | ||
| ) | ||
| return canonical | ||
|
|
||
|
|
||
| def build_receipt( | ||
| payload: Any, *, repository: str, base_sha: str, head_sha: str | ||
| ) -> dict[str, Any]: | ||
| """Validate every dependency and build an exact-head evidence receipt.""" | ||
| if repository.count("/") != 1: | ||
| raise EvidenceError("repository must be owner/name") | ||
| if not FULL_SHA_RE.fullmatch(base_sha) or not FULL_SHA_RE.fullmatch(head_sha): | ||
| raise EvidenceError("base_sha and head_sha must be full lowercase commit SHAs") | ||
| if base_sha == head_sha: | ||
| raise EvidenceError("base_sha and head_sha must differ") | ||
|
|
||
| dependencies: list[dict[str, Any]] = [] | ||
| for index, row in enumerate(dependency_rows(payload)): | ||
| name = _required_text(row, "name", index) | ||
| manifest = _required_text(row, "manifest", index) | ||
| license_expression = validate_license_expression( | ||
| _required_text(row, "license", index), dependency_name=name | ||
| ) | ||
| if FORBIDDEN_LICENSE_RE.search(license_expression): | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| raise EvidenceError( | ||
| f"forbidden release dependency license: {name}: {license_expression}" | ||
| ) | ||
| vulnerabilities = row.get("vulnerabilities") | ||
| if not isinstance(vulnerabilities, list): | ||
| raise EvidenceError(f"dependency[{index}].vulnerabilities must be an array") | ||
| dependencies.append( | ||
| { | ||
| "name": name, | ||
| "version": str(row.get("version") or ""), | ||
| "manifest": manifest, | ||
| # GitHub's compare API returns direct and transitive changes but | ||
| # does not expose that relationship in its response schema. | ||
| "relationship": str(row.get("relationship") or "not_reported_by_compare_api"), | ||
| "license": license_expression, | ||
| "change_type": str(row.get("change_type") or "unknown"), | ||
| "vulnerabilities": vulnerabilities, | ||
| } | ||
| ) | ||
|
|
||
| dependencies.sort( | ||
| key=lambda item: (item["manifest"], item["name"].lower(), item["version"]) | ||
| ) | ||
| return { | ||
| "schema": "cwl-release-dependency-evidence/v1", | ||
| "binding": { | ||
| "repository": repository, | ||
| "base_sha": base_sha, | ||
| "head_sha": head_sha, | ||
| }, | ||
| "policy": { | ||
| "coverage": "all direct and transitive changes returned by GitHub dependency review", | ||
| "denied_license_families": ["GPL", "LGPL", "AGPL"], | ||
| }, | ||
| "dependency_count": len(dependencies), | ||
| "dependencies": dependencies, | ||
| } | ||
|
|
||
|
|
||
| def build_rejection_receipt( | ||
| payload: Any, | ||
| *, | ||
| repository: str, | ||
| base_sha: str, | ||
| head_sha: str, | ||
| reason: str, | ||
| ) -> dict[str, Any]: | ||
| """Preserve deterministic per-dependency evidence for a rejected review.""" | ||
| rows = dependency_rows(payload) | ||
| dependencies = [ | ||
| { | ||
| "name": str(row.get("name") or ""), | ||
| "version": str(row.get("version") or ""), | ||
| "manifest": str(row.get("manifest") or ""), | ||
| "relationship": str( | ||
| row.get("relationship") or "not_reported_by_compare_api" | ||
| ), | ||
| "license": str(row.get("license") or ""), | ||
| "change_type": str(row.get("change_type") or "unknown"), | ||
| "vulnerabilities": ( | ||
| row.get("vulnerabilities") | ||
| if isinstance(row.get("vulnerabilities"), list) | ||
| else [] | ||
| ), | ||
| } | ||
| for row in rows | ||
| ] | ||
| dependencies.sort( | ||
| key=lambda item: (item["manifest"], item["name"].lower(), item["version"]) | ||
| ) | ||
| return { | ||
| "schema": "cwl-release-dependency-evidence/v1", | ||
| "binding": { | ||
| "repository": repository, | ||
| "base_sha": base_sha, | ||
| "head_sha": head_sha, | ||
| }, | ||
| "result": "rejected", | ||
| "rejection_reason": reason, | ||
| "dependency_count": len(dependencies), | ||
| "dependencies": dependencies, | ||
| } | ||
|
|
||
|
|
||
| def write_receipt(output: Path, receipt: dict[str, Any]) -> None: | ||
| """Atomically publish one accepted or rejected exact-head receipt.""" | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| temporary = output.with_suffix(output.suffix + ".tmp") | ||
| temporary.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") | ||
| temporary.replace(output) | ||
|
|
||
|
|
||
| def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: | ||
| """Parse the exact-head evidence verifier CLI.""" | ||
| parser = argparse.ArgumentParser() | ||
| parser.add_argument("--input", required=True, type=Path) | ||
| parser.add_argument("--output", required=True, type=Path) | ||
| parser.add_argument("--repository", required=True) | ||
| parser.add_argument("--base-sha", required=True) | ||
| parser.add_argument("--head-sha", required=True) | ||
| parser.add_argument( | ||
| "--dependency-review-outcome", | ||
| choices=("success", "failure", "cancelled", "skipped"), | ||
| default="success", | ||
| ) | ||
| return parser.parse_args(argv) | ||
|
|
||
|
|
||
| def main(argv: Sequence[str] | None = None) -> int: | ||
| """Validate compare evidence and atomically publish its structured receipt.""" | ||
| args = parse_args(argv) | ||
| payload: Any = None | ||
| try: | ||
| if not args.input.is_file() or args.input.is_symlink(): | ||
| raise EvidenceError("dependency evidence input is missing or unsafe") | ||
| payload = json.loads(args.input.read_text(encoding="utf-8")) | ||
| receipt = build_receipt( | ||
| payload, | ||
| repository=args.repository, | ||
| base_sha=args.base_sha, | ||
| head_sha=args.head_sha, | ||
| ) | ||
| if args.dependency_review_outcome != "success": | ||
| receipt["result"] = "rejected" | ||
| receipt["rejection_reason"] = ( | ||
| "dependency-review action outcome: " + args.dependency_review_outcome | ||
| ) | ||
| write_receipt(args.output, receipt) | ||
| return 2 | ||
| receipt["result"] = "accepted" | ||
| write_receipt(args.output, receipt) | ||
| except (EvidenceError, OSError, json.JSONDecodeError) as error: | ||
| print(f"ERROR: {error}", file=sys.stderr) | ||
| if payload is not None: | ||
| try: | ||
| write_receipt( | ||
| args.output, | ||
| build_rejection_receipt( | ||
| payload, | ||
| repository=args.repository, | ||
| base_sha=args.base_sha, | ||
| head_sha=args.head_sha, | ||
| reason=str(error), | ||
| ), | ||
| ) | ||
| except (EvidenceError, OSError): | ||
| pass | ||
| return 2 | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: ContextualWisdomLab/.github
Length of output: 32544
🤖 get_repo_knowledge executed:
get_repo_knowledge ContextualWisdomLab/.github /tmp/coderabbit-repo-knowledge/contextualwisdomlab-github-bad40478/architecture /tmp/coderabbit-repo-knowledge/contextualwisdomlab-github-bad40478/conventionsLength of output: 44596
🏁 Script executed:
Repository: ContextualWisdomLab/.github
Length of output: 42754
🌐 Web query:
GitHub Actions official documentation github.workflow_sha reusable workflow workflow_call called workflow context💡 Result:
<source_evidence>
Citations:
🏁 Script executed:
Repository: ContextualWisdomLab/.github
Length of output: 32487
재사용 workflow의 자체 소스에는
job.workflow_sha를 사용하세요.외부 caller에서
github.workflow_sha는 caller workflow의 커밋을 가리킵니다. 현재 코드는 이 SHA를ContextualWisdomLab/.github의 checkout ref로 사용합니다. 해당 커밋이 중앙 저장소에 없으면 checkout이 실패합니다. 그러면 trusted verifier가 생성되지 않고, dependency graph 단계와 증거 업로드도 실행되지 않습니다.수정 예시
🤖 Prompt for AI Agents
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🤖 get_repo_knowledge executed:
get_repo_knowledge ContextualWisdomLab/.github /tmp/coderabbit-repo-knowledge/contextualwisdomlab-github-bad40478/architecture /tmp/coderabbit-repo-knowledge/contextualwisdomlab-github-bad40478/conventionsLength of output: 46580
🏁 Script executed:
Repository: ContextualWisdomLab/.github
Length of output: 44566
🏁 Script executed:
Repository: ContextualWisdomLab/.github
Length of output: 42742
🏁 Script executed:
Repository: ContextualWisdomLab/.github
Length of output: 33368
🏁 Script executed:
Repository: ContextualWisdomLab/.github
Length of output: 42712
직접
dependency-review경로를 exact-head 증적 workflow로 통합하십시오..github/workflows/security-scan.yml:352-429는 의존성 변경 PR에서actions/dependency-review-action을 직접 실행합니다. 이 경로는.github/workflows/dependency-review.yml의workflow_call을 호출하지 않습니다.따라서 dependency review가 성공해도
release-dependency-evidence-<head-sha>JSON receipt가 생성되지 않습니다. 필수 보안 검사는 통과하지만cwl-release-dependency-evidence/v1의 repository/base/head 바인딩 증적은 남지 않습니다.이 job을 변경된 reusable workflow의 exact commit SHA caller로 교체하십시오. 또는 기존 비교 응답을 파일로 보존하고,
release_dependency_evidence.py에 action outcome과 exact base/head를 전달한 뒤 동일한 artifact를 업로드하는 로직을 추가하십시오. 현재 직접 경로의curl -o /dev/null만으로는 equivalent receipt를 만들 수 없습니다.🤖 Prompt for AI Agents