ci: report-only corresponding-source check for copyleft packages (inbox#283)#415
ci: report-only corresponding-source check for copyleft packages (inbox#283)#415bryan-minimal wants to merge 2 commits into
Conversation
For every copyleft package (classified from license_spdx via prefix match on expression tokens), verify its exact source archive is durably present on the public mirror - the corresponding-source offer that distributing copyleft binaries from the cache obligates. Presence is probed with unauthenticated HTTPS HEADs against the public bucket (fixed host, charset-validated paths, no secrets); exactness rides on the build's own sha256 pin. Doubled slashes are normalized to match minimal's gs:// fetcher (graphviz's build.ncl has a literal // that builds fine). First full-catalog run: 127 copyleft packages -> 89 mirrored / 0 mirror-missing / 38 unmirrored (ffmpeg, ghostscript+grafana both AGPL, jdk, gcc-arm-none-eabi, libvips, x264/x265, ...) - the concrete #283 work list. Runs on PRs (changed packages), dispatch, and a weekly schedule: mirror objects can vanish independently of any PR, and that silent drift is the main thing to catch. Seventh sibling of the report-only check family; MUST NOT be required. Co-Authored-By: Claude Fable 5 <[email protected]>
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 2 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds a Python reporter that classifies copyleft package source URLs against a public mirror and publishes markdown summaries. Adds a GitHub Actions workflow for pull requests, scheduled runs, and manual execution with non-blocking failure handling. ChangesCorresponding-source reporting
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant GitHubActions
participant PackageSet
participant Reporter
participant BuildFiles
participant PublicMirror
participant StepSummary
GitHubActions->>PackageSet: determine changed or all packages
PackageSet->>Reporter: pass package list or --all
Reporter->>BuildFiles: read license_spdx and source URLs
Reporter->>PublicMirror: probe mirror object with HTTPS HEAD
PublicMirror-->>Reporter: return presence status
Reporter->>StepSummary: write markdown verdict report
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/scripts/corresponding_source_report.py:
- Around line 194-205: Fix the --all filtering around flagged and shown so
unmirrored rows are retained instead of matching the mirrored suffix check.
Preserve each row’s raw verdict separately from the formatted r[2] cell, and
filter using exact verdict equality for mirrored; keep mirror-miss, unmirrored,
and unresolved/unknown rows visible in full-sweep reports while preserving the
existing default-mode behavior.
- Around line 43-90: Update the path validation in head_mirror_object to reject
dot-segment components "." and ".." before invoking curl, while preserving the
existing _SAFE_PATH character validation and None result for invalid paths.
Ensure paths such as "../other-bucket/obj" cannot reach the mirror HEAD request.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 050120c6-6669-40b5-b0ea-4b84d7a844e3
📒 Files selected for processing (2)
.github/scripts/corresponding_source_report.py.github/workflows/corresponding-source.yml
| _SAFE_PATH = re.compile(r"^[A-Za-z0-9._/+~-]+$") | ||
|
|
||
| # SPDX id prefixes that carry source obligations on binary distribution. | ||
| # Strong and weak/file-level copyleft both create a corresponding-source duty | ||
| # for the covered code, so both are in scope. Prefix-matched against expression | ||
| # tokens (GPL-2.0-only, LGPL-2.1-or-later, AGPL-3.0-only, MPL-2.0, ...). | ||
| _COPYLEFT_PREFIXES = ( | ||
| "GPL-", "AGPL-", "LGPL-", "SSPL-", "MPL-", "EPL-", "CDDL-", "EUPL-", | ||
| "CeCILL", "OSL-", "MS-RL", "CPL-", "IPL-", "RPL-", "CC-BY-SA-", | ||
| ) | ||
| _TOKEN_SPLIT = re.compile(r"[()\s]+") | ||
|
|
||
|
|
||
| def copyleft_tokens(expr: str) -> list[str]: | ||
| toks = [t for t in _TOKEN_SPLIT.split(expr) if t and t not in ("AND", "OR", "WITH")] | ||
| return [t for t in toks if t.startswith(_COPYLEFT_PREFIXES)] | ||
|
|
||
|
|
||
| def resolve_interpolations(url: str, lets: dict[str, str]) -> str: | ||
| # let-bindings can reference each other (dl_base uses %{version}); a few | ||
| # passes of textual substitution resolves the practical cases. | ||
| for _ in range(5): | ||
| expanded = _INTERP_RE.sub(lambda m: lets.get(m.group(1), m.group(0)), url) | ||
| if expanded == url: | ||
| break | ||
| url = expanded | ||
| return url | ||
|
|
||
|
|
||
| def head_mirror_object(path: str) -> bool | None: | ||
| """True/False = object present/absent on the public mirror; None = probe | ||
| failed (network/5xx) — reported as unknown rather than missing.""" | ||
| if not _SAFE_PATH.match(path): | ||
| return None | ||
| try: | ||
| p = subprocess.run( | ||
| ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", | ||
| "--head", "--proto", "=https", "--max-time", "20", | ||
| _MIRROR_HTTP + path], | ||
| capture_output=True, text=True, timeout=30) | ||
| except subprocess.TimeoutExpired: | ||
| return None | ||
| code = p.stdout.strip() | ||
| if code == "200": | ||
| return True | ||
| if code in ("403", "404"): # public bucket: absent objects read as 403/404 | ||
| return False | ||
| return None |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== File outline ==\n'
ast-grep outline .github/scripts/corresponding_source_report.py --view expanded || true
printf '\n== Relevant file excerpt ==\n'
nl -ba .github/scripts/corresponding_source_report.py | sed -n '1,180p'
printf '\n== curl path normalization probe ==\n'
python3 - <<'PY'
import subprocess, json, os, textwrap, shlex, sys
tests = [
"https://example.com/a/../b",
"https://example.com/a/./b",
"https://example.com/../b",
"https://example.com/b/../../c",
]
for u in tests:
out = subprocess.run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{url_effective}", u],
capture_output=True, text=True
)
print(f"input={u}")
print(f"rc={out.returncode}")
print(f"url_effective={out.stdout.strip()}")
if out.stderr.strip():
print(f"stderr={out.stderr.strip()}")
print("-" * 40)
print("\n-- path-as-is comparison --")
for u in tests:
out = subprocess.run(
["curl", "--path-as-is", "-sS", "-o", "/dev/null", "-w", "%{url_effective}", u],
capture_output=True, text=True
)
print(f"input={u}")
print(f"rc={out.returncode}")
print(f"url_effective={out.stdout.strip()}")
if out.stderr.strip():
print(f"stderr={out.stderr.strip()}")
print("-" * 40)
PYRepository: gominimal/pkgs
Length of output: 1113
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Focused search for the surrounding constants and any other URL validation.
rg -n "_SAFE_PATH|_MIRROR_HTTP|head_mirror_object|resolve_interpolations|copyleft_tokens|SSRF|path-as-is" .github/scripts/corresponding_source_report.py
# Show the file slice around the relevant logic.
nl -ba .github/scripts/corresponding_source_report.py | sed -n '1,130p'Repository: gominimal/pkgs
Length of output: 826
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== Relevant file excerpt ==\n'
sed -n '37,85p' .github/scripts/corresponding_source_report.py | cat -n
printf '\n== curl path normalization probe ==\n'
python3 - <<'PY'
import subprocess
tests = [
"https://example.com/a/../b",
"https://example.com/a/./b",
"https://example.com/../b",
"https://example.com/b/../../c",
]
for extra in ([], ["--path-as-is"]):
print("ARGS:", " ".join(extra) or "(default)")
for u in tests:
p = subprocess.run(
["curl", *extra, "-sS", "-o", "/dev/null", "-w", "%{url_effective}", u],
capture_output=True, text=True
)
print(f"input={u}")
print(f"rc={p.returncode}")
print(f"url_effective={p.stdout.strip()}")
if p.stderr.strip():
print(f"stderr={p.stderr.strip()}")
print("-" * 30)
PYRepository: gominimal/pkgs
Length of output: 3624
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect how paths are derived and how the result is emitted.
sed -n '130,180p' .github/scripts/corresponding_source_report.py | cat -nRepository: gominimal/pkgs
Length of output: 2894
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '130,180p' .github/scripts/corresponding_source_report.py | cat -nRepository: gominimal/pkgs
Length of output: 2894
Reject dot-segments in mirror paths _SAFE_PATH still allows . and .., and curl normalizes them by default. A path like ../other-bucket/obj can be rewritten to a different bucket before the HEAD request is sent, so reject dot-segments before probing the mirror.
🔒️ Proposed fix
def head_mirror_object(path: str) -> bool | None:
"""True/False = object present/absent on the public mirror; None = probe
failed (network/5xx) — reported as unknown rather than missing."""
- if not _SAFE_PATH.match(path):
+ if not _SAFE_PATH.match(path) or any(seg in ("", ".", "..") for seg in path.split("/")):
return None📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| _SAFE_PATH = re.compile(r"^[A-Za-z0-9._/+~-]+$") | |
| # SPDX id prefixes that carry source obligations on binary distribution. | |
| # Strong and weak/file-level copyleft both create a corresponding-source duty | |
| # for the covered code, so both are in scope. Prefix-matched against expression | |
| # tokens (GPL-2.0-only, LGPL-2.1-or-later, AGPL-3.0-only, MPL-2.0, ...). | |
| _COPYLEFT_PREFIXES = ( | |
| "GPL-", "AGPL-", "LGPL-", "SSPL-", "MPL-", "EPL-", "CDDL-", "EUPL-", | |
| "CeCILL", "OSL-", "MS-RL", "CPL-", "IPL-", "RPL-", "CC-BY-SA-", | |
| ) | |
| _TOKEN_SPLIT = re.compile(r"[()\s]+") | |
| def copyleft_tokens(expr: str) -> list[str]: | |
| toks = [t for t in _TOKEN_SPLIT.split(expr) if t and t not in ("AND", "OR", "WITH")] | |
| return [t for t in toks if t.startswith(_COPYLEFT_PREFIXES)] | |
| def resolve_interpolations(url: str, lets: dict[str, str]) -> str: | |
| # let-bindings can reference each other (dl_base uses %{version}); a few | |
| # passes of textual substitution resolves the practical cases. | |
| for _ in range(5): | |
| expanded = _INTERP_RE.sub(lambda m: lets.get(m.group(1), m.group(0)), url) | |
| if expanded == url: | |
| break | |
| url = expanded | |
| return url | |
| def head_mirror_object(path: str) -> bool | None: | |
| """True/False = object present/absent on the public mirror; None = probe | |
| failed (network/5xx) — reported as unknown rather than missing.""" | |
| if not _SAFE_PATH.match(path): | |
| return None | |
| try: | |
| p = subprocess.run( | |
| ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", | |
| "--head", "--proto", "=https", "--max-time", "20", | |
| _MIRROR_HTTP + path], | |
| capture_output=True, text=True, timeout=30) | |
| except subprocess.TimeoutExpired: | |
| return None | |
| code = p.stdout.strip() | |
| if code == "200": | |
| return True | |
| if code in ("403", "404"): # public bucket: absent objects read as 403/404 | |
| return False | |
| return None | |
| def head_mirror_object(path: str) -> bool | None: | |
| """True/False = object present/absent on the public mirror; None = probe | |
| failed (network/5xx) — reported as unknown rather than missing.""" | |
| if not _SAFE_PATH.match(path) or any(seg in ("", ".", "..") for seg in path.split("/")): | |
| return None | |
| try: | |
| p = subprocess.run( | |
| ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", | |
| "--head", "--proto", "=https", "--max-time", "20", | |
| _MIRROR_HTTP + path], | |
| capture_output=True, text=True, timeout=30) | |
| except subprocess.TimeoutExpired: | |
| return None | |
| code = p.stdout.strip() | |
| if code == "200": | |
| return True | |
| if code in ("403", "404"): # public bucket: absent objects read as 403/404 | |
| return False | |
| return None |
🧰 Tools
🪛 ast-grep (0.44.1)
[error] 77-81: Command coming from incoming request
Context: subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--head", "--proto", "=https", "--max-time", "20",
_MIRROR_HTTP + path],
capture_output=True, text=True, timeout=30)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(subprocess-from-request)
🪛 Ruff (0.15.21)
[error] 78-78: subprocess call: check for execution of untrusted input
(S603)
[error] 79-81: Starting a process with a partial executable path
(S607)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/scripts/corresponding_source_report.py around lines 43 - 90, Update
the path validation in head_mirror_object to reject dot-segment components "."
and ".." before invoking curl, while preserving the existing _SAFE_PATH
character validation and None result for invalid paths. Ensure paths such as
"../other-bucket/obj" cannot reach the mirror HEAD request.
…fixes The inbox#283 backfill uploads archives to the mirror WITHOUT touching build.ncl (repointing changes spec_hashes -> rebuild cascades; URLs get repointed lazily at natural version bumps). Teach the check to probe the conventional mirror path (github -> owner/repo/basename, else flat basename) for upstream-URL packages and report 'mirrored (out-of-band)' instead of falsely flagging them unmirrored. Probe fixes caught by the backfill itself: underscore was missing from the path charset (jdk's OpenJDK21U-jdk_x64_... failed validation before probing), and a failed probe in the out-of-band branch collapsed into 'unmirrored' instead of 'probe-unknown'. Full-catalog state after backfill: 127/127 copyleft packages mirrored (89 by URL, 38 out-of-band), 0 missing, 0 unmirrored. Co-Authored-By: Claude Fable 5 <[email protected]>
For every copyleft package (classified from license_spdx via prefix match on
expression tokens), verify its exact source archive is durably present on the
public mirror - the corresponding-source offer that distributing copyleft
binaries from the cache obligates. Presence is probed with unauthenticated
HTTPS HEADs against the public bucket (fixed host, charset-validated paths,
no secrets); exactness rides on the build's own sha256 pin. Doubled slashes
are normalized to match minimal's gs:// fetcher (graphviz's build.ncl has a
literal // that builds fine).
First full-catalog run: 127 copyleft packages -> 89 mirrored / 0
mirror-missing / 38 unmirrored (ffmpeg, ghostscript+grafana both AGPL, jdk,
gcc-arm-none-eabi, libvips, x264/x265, ...) - the concrete #283 work list.
Runs on PRs (changed packages), dispatch, and a weekly schedule: mirror
objects can vanish independently of any PR, and that silent drift is the
main thing to catch.
Seventh sibling of the report-only check family; MUST NOT be required.
Co-Authored-By: Claude Fable 5 [email protected]
Summary by CodeRabbit
New Features
Chores