diff --git a/scripts/ci/collect_failed_check_evidence.sh b/scripts/ci/collect_failed_check_evidence.sh index 1b420a423..c0926d4ee 100755 --- a/scripts/ci/collect_failed_check_evidence.sh +++ b/scripts/ci/collect_failed_check_evidence.sh @@ -191,6 +191,178 @@ emit_strix_vulnerability_evidence() { done <"$merged_ranges_tmp" } +supply_chain_tool_for_label() { + # Map a failed supply-chain check label to the code-scanning SARIF tool name + # used to file its alerts, so their package/advisory/fixed-version detail can + # be pulled into the evidence as source-backed canonical lines. + local label_lower + label_lower="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + case "$label_lower" in + *osv*) printf 'OSV-Scanner' ;; + *trivy*) printf 'Trivy' ;; + *) printf '' ;; + esac +} + +emit_supply_chain_alert_evidence() { + # Supply-chain scanners (osv-scanner, trivy-fs) upload SARIF to code scanning + # rather than printing findings in the failed job log. Their advisories are + # fully source-backed: each alert names the exact vulnerable package, the + # manifest file + line it is pinned on, the CVE/GHSA id, the severity, and the + # fixed version. Pull those alerts for the current head and emit canonical + # supply-chain lines so the OpenCode failed-check fallback can map them to + # concrete "bump from to " findings instead of a + # URL-only review. Best-effort: never fail the collector. + local label="$1" + local tool + local alerts_json + local canonical + local ref + + tool="$(supply_chain_tool_for_label "$label")" + if [ -z "$tool" ]; then + return 0 + fi + + alerts_json="$(mktemp)" + canonical="$(mktemp)" + tmp_files+=("$alerts_json" "$canonical") + + for ref in "$HEAD_SHA" "refs/pull/${PR_NUMBER}/head"; do + if gh api -X GET "repos/${GH_REPOSITORY}/code-scanning/alerts" \ + -f "tool_name=${tool}" \ + -f "ref=${ref}" \ + -f "state=open" \ + -f "per_page=100" \ + --paginate >"$alerts_json" 2>/dev/null && [ -s "$alerts_json" ]; then + if jq -e 'type == "array" and length > 0' "$alerts_json" >/dev/null 2>&1; then + break + fi + fi + : >"$alerts_json" + done + + if [ ! -s "$alerts_json" ]; then + return 0 + fi + + python3 - "$alerts_json" >"$canonical" 2>/dev/null <<'PYEOF' || true +import json +import re +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as handle: + alerts = json.load(handle) +except Exception: + sys.exit(0) + +if not isinstance(alerts, list): + sys.exit(0) + +ID_RE = re.compile(r"(CVE-\d{4}-\d{3,}|GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4})", re.I) + + +def first(patterns, text): + for pattern in patterns: + match = re.search(pattern, text, re.I) + if match: + return match.group(1).strip().strip("`'\"") + return "" + + +def clean_version(value): + return value.strip().strip("`'\"").rstrip(".,;)") + + +seen = set() +for alert in alerts: + if not isinstance(alert, dict): + continue + rule = alert.get("rule") or {} + instance = alert.get("most_recent_instance") or {} + location = (instance.get("location") or {}) + manifest = (location.get("path") or "").strip() + line = location.get("start_line") or 0 + message = ((instance.get("message") or {}).get("text") or "") + text = " ".join( + str(part) + for part in ( + message, + rule.get("description") or "", + rule.get("full_description") or "", + rule.get("name") or "", + ) + ) + id_match = ID_RE.search(str(rule.get("id") or "")) or ID_RE.search(text) + vuln_id = id_match.group(1) if id_match else str(rule.get("id") or "").strip() + severity = ( + rule.get("security_severity_level") + or rule.get("severity") + or "high" + ).upper() + package = first( + [ + r"Package:\s*([A-Za-z0-9._/+-]+)", + r"['\"`]([A-Za-z0-9._/+-]+)@[0-9]", + r"Package\s+['\"]([A-Za-z0-9._/+-]+?)(?:@[^'\"]*)?['\"]", + r"for (?:the )?package[:\s]+['\"`]?([A-Za-z0-9._/+-]+)['\"`]?", + ], + text, + ) + # A package name may still arrive as pkg@version; keep only the name. + package = package.split("@", 1)[0] + installed = clean_version( + first( + [ + r"Installed Version:\s*([^\s,;]+)", + r"@([0-9][A-Za-z0-9._+-]*)", + r"currently[:\s]+([0-9][A-Za-z0-9._+-]*)", + ], + text, + ) + ) + fixed = clean_version( + first( + [ + r"Fixed Version:\s*([^\s,;]+)", + r"[Ff]ixed in[:\s]+([0-9][A-Za-z0-9._+-]*)", + r"[Pp]atched in[:\s]+([0-9][A-Za-z0-9._+-]*)", + ], + text, + ) + ) + if not (vuln_id and package and manifest): + continue + key = (manifest.lower(), package.lower(), vuln_id.lower()) + if key in seen: + continue + seen.add(key) + fields = [ + f"id={vuln_id}", + f"severity={severity}", + f"package={package}", + ] + if installed: + fields.append(f"installed={installed}") + if fixed: + fields.append(f"fixed={fixed}") + fields.append(f"manifest={manifest}") + if isinstance(line, int) and line > 0: + fields.append(f"line={line}") + print("- Supply-chain vulnerability: " + " ".join(fields)) +PYEOF + + if [ ! -s "$canonical" ]; then + return 0 + fi + + printf '### Supply-chain vulnerability findings\n\n' + printf 'Source-backed code-scanning alerts for this failed supply-chain check (package, manifest line, advisory id, and fixed version):\n\n' + emit_bounded_file "$canonical" 100 + printf '\n' +} + owner="${GH_REPOSITORY%%/*}" repo="${GH_REPOSITORY#*/}" pr_node_id="$( @@ -626,6 +798,8 @@ done <"$failed_contexts" fi fi + emit_supply_chain_alert_evidence "$label" || true + log_raw="$(mktemp)" log_clean="$(mktemp)" tmp_files+=("$log_raw" "$log_clean") diff --git a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh index b2cdd04a0..13fd315e7 100755 --- a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh +++ b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh @@ -685,6 +685,218 @@ emit_strix_cancelled_without_log_finding() { printf -- '- Suggested edit: preserve `%s:%s` with `cancel-in-progress: false`, cancel only superseded non-current-head runs when needed, and rerun current-head Strix until logs exist.\n\n' "$path" "$line" } +extract_supply_chain_records() { + # Parse the failed-check EVIDENCE for supply-chain scanner results + # (osv-scanner, trivy-fs, dependency-review) and emit one record per + # distinct vulnerability. Fields are joined with the ASCII Unit Separator + # (\x1f), not a tab, so empty interior fields (e.g. a missing installed or + # fixed version) survive read-back without shifting later columns. Supply-chain findings are source-backed because the + # scanners name the exact vulnerable package, its manifest file, the + # CVE/GHSA advisory id, and the fixed version. Two evidence shapes are + # recognized inside a supply-chain failed-check block: + # + # 1. Canonical structured line (emitted by the failed-check evidence + # collector after it normalizes osv/trivy/dependency-review SARIF and + # dependency-review summaries): + # - Supply-chain vulnerability: id=CVE-2023-32681 severity=HIGH \ + # package=requests installed=2.19.0 fixed=2.31.0 manifest=requirements.txt + # 2. A Trivy filesystem findings table logged to the job log, grouped by a + # manifest header such as "requirements.txt (pip)". + # + # Record columns (\x1f-separated): manifest, package, installed, fixed, id, severity, evidence, label, line_hint + local source_file="$1" + + perl -CS -ne ' + BEGIN { our (%seen, $in_block, $manifest, $label); $in_block = 0; } + sub trim { my ($s) = @_; $s =~ s/^\s+//; $s =~ s/\s+$//; return $s; } + sub is_manifest { + my ($p) = @_; + my $base = $p; $base =~ s#.*/##; + return 1 if $base =~ /^(Cargo\.(lock|toml)|uv\.lock|poetry\.lock|Pipfile(\.lock)?|pyproject\.toml|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|go\.(mod|sum)|Gemfile(\.lock)?|composer(\.lock|\.json)|Package\.resolved|Package\.swift|mix\.lock|pubspec\.(yaml|lock)|gradle\.lockfile|conda-lock\.yml)$/i; + return 1 if $base =~ /^requirements[\w.-]*\.(txt|in)$/i; + return 0; + } + sub emit { + my ($m,$p,$inst,$fix,$id,$sev,$ev,$lab,$hint) = @_; + return unless length $id && length $p && length $m; + $hint = "" unless defined $hint && $hint =~ /^[0-9]+$/ && $hint > 0; + my $key = lc("$m|$p|$id"); + return if $seen{$key}++; + for my $f ($m,$p,$inst,$fix,$id,$sev,$ev,$lab,$hint) { $f //= ""; $f =~ s/[\x1f\r\n]/ /g; } + print join("\x1f", $m,$p,$inst,$fix,$id,$sev,$ev,$lab,$hint), "\n"; + } + my $line = $_; + $line =~ s/\r//g; + $line =~ s/\x1b\[[0-9;?]*[A-Za-z]//g; + if ($line =~ /^## Failed check:\s*(.+?)\s*$/) { + $label = $1; + $in_block = ($label =~ /osv|trivy|dependency[ _-]?review/i) ? 1 : 0; + $manifest = ""; + next; + } + # A new non-supply-chain section header ends any manifest context. + next unless $in_block; + my $clean = trim($line); + + # Shape 1: canonical structured supply-chain line (order-independent). + if ($clean =~ /Supply-chain vulnerability:/i) { + my %kv; + while ($clean =~ /(\w+)=([^\s|]+)/g) { $kv{lc $1} = $2; } + my $id = $kv{id} // $kv{vuln} // $kv{cve} // $kv{ghsa} // ""; + my $pkg = $kv{package} // $kv{pkg} // $kv{library} // ""; + my $man = $kv{manifest} // $kv{file} // $kv{path} // ""; + my $inst = $kv{installed} // $kv{version} // ""; + my $fix = $kv{fixed} // $kv{patched} // ""; + my $sev = uc($kv{severity} // "HIGH"); + my $hint = $kv{line} // ""; + emit($man,$pkg,$inst,$fix,$id,$sev,$clean,$label,$hint); + next; + } + + # Track a Trivy manifest header such as "requirements.txt (pip)". + if ($clean =~ m{^([\w./\-]+?)\s+\(([\w.\-]+)\)\s*$}) { + $manifest = $1 if is_manifest($1); + next; + } + + # Shape 2: Trivy findings table row. Cells are separated by the box + # drawing bar; the vulnerability id occupies its own cell. + if ($clean =~ /[\x{2502}|]/ && + $clean =~ /(CVE-\d{4}-\d{3,}|GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4})/i) { + my @cells = map { trim($_) } split /[\x{2502}|]/, $clean; + @cells = grep { length } @cells; + my ($idx) = grep { + $cells[$_] =~ /^(CVE-\d{4}-\d{3,}|GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4})$/i + } 0 .. $#cells; + next unless defined $idx; + my $id = $cells[$idx]; + my $pkg = ($idx >= 1) ? $cells[$idx - 1] : ""; + my $man = $manifest; + for my $c (@cells) { if (is_manifest($c)) { $man = $c; last; } } + my $sev = "HIGH"; + my @after = @cells[$idx + 1 .. $#cells]; + for my $c (@after) { if ($c =~ /^(CRITICAL|HIGH|MEDIUM|LOW)$/i) { $sev = uc $c; last; } } + my @vers = grep { /^v?\d[\w.\-+]*$/ } @after; + my $inst = @vers ? $vers[0] : ""; + my $fix = (@vers > 1) ? $vers[1] : ""; + emit($man,$pkg,$inst,$fix,$id,$sev,$clean,$label,""); + next; + } + ' <"$source_file" +} + +emit_supply_chain_findings() { + local evidence_file="$1" + local records_file + local manifest package installed fixed vuln_id severity evidence_line check_label line_hint + local resolved base found matches best line pin_line_text + local source suggested_line + + records_file="$(mktemp)" + tmp_files+=("$records_file") + extract_supply_chain_records "$evidence_file" >"$records_file" + if [ ! -s "$records_file" ]; then + return 0 + fi + + # Records are joined with the ASCII Unit Separator (\x1f), NOT a tab. Tab is an + # IFS-whitespace character, so `read` would collapse consecutive tabs and shift + # every column left whenever an interior field (e.g. installed or fixed) is + # empty. \x1f is not IFS-whitespace, so empty interior fields are preserved + # positionally and each value lands in its correct column. + while IFS=$'\x1f' read -r manifest package installed fixed vuln_id severity evidence_line check_label line_hint; do + if [ -z "$vuln_id" ] || [ -z "$package" ] || [ -z "$manifest" ]; then + continue + fi + + case "$(printf '%s' "$check_label" | tr '[:upper:]' '[:lower:]')" in + *trivy*) source="trivy" ;; + *osv*) source="osv-scanner" ;; + *dependency*) source="dependency-review" ;; + *) source="supply-chain scanner" ;; + esac + + # Resolve the manifest under the checked-out repository root. The scanner + # names a repo-relative path; if that path is absent (path was reported + # relative to a scan subdirectory) fall back to the basename. + resolved="$manifest" + if [ ! -f "${REPO_ROOT%/}/$resolved" ]; then + base="${manifest##*/}" + found="$(cd "${REPO_ROOT%/}" 2>/dev/null && git ls-files -- "**/$base" "$base" 2>/dev/null | head -n 1 || true)" + if [ -z "$found" ]; then + found="$(cd "${REPO_ROOT%/}" 2>/dev/null && find . -name "$base" -not -path '*/.git/*' 2>/dev/null | sed 's#^\./##' | head -n 1 || true)" + fi + if [ -n "$found" ]; then + resolved="$found" + fi + fi + + # Locate the exact manifest line that pins the vulnerable package. Never + # emit line 0; default to line 1 while still citing the scanner evidence. + line="1" + pin_line_text="" + if [ -f "${REPO_ROOT%/}/$resolved" ]; then + matches="$(grep -niF -- "$package" "${REPO_ROOT%/}/$resolved" 2>/dev/null || true)" + if [ -n "$matches" ] && [ -n "$installed" ]; then + best="$(printf '%s\n' "$matches" | grep -F -- "$installed" | head -n 1 || true)" + else + best="" + fi + if [ -z "$best" ]; then + best="$(printf '%s\n' "$matches" | head -n 1 || true)" + fi + if [ -n "$best" ]; then + line="${best%%:*}" + pin_line_text="${best#*:}" + elif [[ "$line_hint" =~ ^[0-9]+$ ]] && [ "$line_hint" -ge 1 ]; then + # Package name was not grep-locatable in the manifest (common for + # transitive lockfile entries); trust the scanner-provided line + # from the SARIF location instead of falling back to line 1. + line="$line_hint" + fi + elif [[ "$line_hint" =~ ^[0-9]+$ ]] && [ "$line_hint" -ge 1 ]; then + line="$line_hint" + fi + if ! [[ "$line" =~ ^[0-9]+$ ]] || [ "$line" -lt 1 ]; then + line="1" + fi + + # Build a GitHub-suggestion-ready diff when the pin line is a simple + # version pin that contains the installed version literally. + suggested_line="" + if [ -n "$pin_line_text" ] && [ -n "$installed" ] && [ -n "$fixed" ] && + printf '%s' "$pin_line_text" | grep -Fq -- "$installed"; then + suggested_line="$(INSTALLED="$installed" FIXED="$fixed" perl -pe 's/\Q$ENV{INSTALLED}\E/$ENV{FIXED}/g' <<<"$pin_line_text")" + if [ "$suggested_line" = "$pin_line_text" ]; then + suggested_line="" + fi + fi + + finding_index=$((finding_index + 1)) + printf '### %s. %s %s:%s - Supply-chain vulnerability %s in %s\n' "$finding_index" "${severity:-HIGH}" "$resolved" "$line" "$vuln_id" "$package" + printf -- '- Problem: The failed check `%s` reported a supply-chain vulnerability: `%s` affects `%s` %s. Scanner evidence: `%s`.\n' "$check_label" "$vuln_id" "$package" "${installed:-(version reported by scanner)}" "$evidence_line" + printf -- '- Root cause: `%s:%s` pins `%s` at %s, which the %s scan flags as vulnerable under `%s` (severity %s). This is a supply-chain/dependency vulnerability, not a scanner infrastructure failure, so it must be fixed in the manifest.\n' "$resolved" "$line" "$package" "${installed:-the affected version}" "$source" "$vuln_id" "${severity:-HIGH}" + # The upgrade target must always be a version (or an instruction), never a + # CVE/GHSA id. Phrase the fix around which of installed/fixed we actually + # have so a missing version never produces a broken sentence. + if [ -n "$fixed" ] && [ -n "$installed" ]; then + printf -- '- Fix: bump `%s` from %s to %s in `%s:%s`, regenerate the lockfile if applicable, then rerun the failed `%s` scan.\n' "$package" "$installed" "$fixed" "$resolved" "$line" "$check_label" + elif [ -n "$fixed" ]; then + printf -- '- Fix: upgrade `%s` to %s in `%s:%s`, regenerate the lockfile if applicable, then rerun the failed `%s` scan.\n' "$package" "$fixed" "$resolved" "$line" "$check_label" + else + printf -- '- Fix: no fixed version is available upstream for `%s` %s; remove or replace the dependency, or pin to a patched fork, in `%s:%s`, then rerun the failed `%s` scan.\n' "$package" "${installed:-(version reported by scanner)}" "$resolved" "$line" "$check_label" + fi + printf -- '- Regression test: after bumping, rerun the %s scan (osv-scanner / trivy-fs / dependency-review) on the PR head and confirm `%s` for `%s` no longer appears; keep the non-vulnerable version pinned so the advisory cannot regress.\n' "$source" "$vuln_id" "$package" + if [ -n "$suggested_line" ]; then + printf -- '- Suggested edit: apply this GitHub suggestion on `%s:%s`:\n\n```suggestion\n%s\n```\n\n' "$resolved" "$line" "$suggested_line" + elif [ -n "$fixed" ]; then + printf -- '- Suggested edit: update `%s:%s` so `%s` requires `%s` or later.\n\n' "$resolved" "$line" "$package" "$fixed" + else + printf -- '- Suggested edit: update `%s:%s` so `%s` requires the first non-vulnerable release for `%s`.\n\n' "$resolved" "$line" "$package" "$vuln_id" + fi + done <"$records_file" +} + strix_evidence_file="$(mktemp)" tmp_files+=("$strix_evidence_file") extract_strix_failed_check_block "$EVIDENCE_FILE" "$strix_evidence_file" @@ -710,6 +922,7 @@ emit_known_missing_string_finding \ emit_github_billing_lock_finding emit_pytest_failure_findings "$EVIDENCE_FILE" +emit_supply_chain_findings "$EVIDENCE_FILE" emit_cancelled_check_findings "$EVIDENCE_FILE" emit_strix_report_findings "$strix_evidence_file" emit_strix_provider_failure_finding "$strix_evidence_file" diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index b29772934..7b6b19f91 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -869,6 +869,11 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'select((.checkSuite.workflowRun.workflow.name // "") != "OpenCode PR Review")' "failed-check evidence excludes OpenCode's own workflow by legacy name" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'gh run view "$run_id"' "failed-check evidence collector reads failed GitHub Actions job logs" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'check-runs/${check_run_id}/annotations' "failed-check evidence collector reads GitHub Check annotations" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "emit_supply_chain_alert_evidence" "failed-check evidence collector pulls supply-chain scanner alerts for osv/trivy checks" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "code-scanning/alerts" "failed-check evidence collector reads code-scanning alerts to recover package/CVE/fixed-version detail" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "Supply-chain vulnerability findings" "failed-check evidence collector emits a source-backed supply-chain findings section" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "- Supply-chain vulnerability: " "failed-check evidence collector emits canonical package/manifest/advisory/fixed lines the fallback can map" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "supply_chain_tool_for_label" "failed-check evidence collector maps osv-scanner and trivy checks to their code-scanning tool names" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "Line-specific repair contract" "failed-check evidence requires line-specific repairs" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "Failed log signal summary" "failed-check evidence collector preserves fail/error signal lines outside bounded excerpts" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "Strix model attempt and finding summary" "failed-check evidence collector summarizes every Strix model attempt" @@ -958,6 +963,12 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" "emit_pytest_failure_findings" "failed-check fallback explains pytest failures instead of posting URL-only evidence" assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" "emit_cancelled_check_findings" "failed-check fallback explains cancelled check queue states separately from source fixes" assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" "do not approve or post a URL-only review" "failed-check fallback rejects URL-only GitHub Check reviews" + assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" "emit_supply_chain_findings" "failed-check fallback defines a supply-chain scanner emitter for osv/trivy/dependency-review" + assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" 'emit_supply_chain_findings "$EVIDENCE_FILE"' "failed-check fallback wires the supply-chain emitter into the dispatch sequence" + assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" "osv|trivy|dependency[ _-]?review" "failed-check supply-chain emitter scopes to osv-scanner, trivy-fs, and dependency-review checks" + assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" 'bump `%s` from %s to %s' "failed-check supply-chain emitter states the concrete package version bump instead of a URL" + assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" 'Supply-chain vulnerability %s in %s' "failed-check supply-chain emitter titles each finding with the advisory id and package" + assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" '```suggestion' "failed-check supply-chain emitter offers a GitHub-suggestion-ready diff for simple version pins" assert_file_contains "$REPO_ROOT/opencode.jsonc" '"bash": "allow"' "opencode config enables bash so reviewers can run proof commands" assert_file_contains "$REPO_ROOT/opencode.jsonc" '"task": "allow"' "opencode config enables task delegation for deeper review work" assert_file_contains "$REPO_ROOT/opencode.jsonc" '"webfetch": "allow"' "opencode config enables webfetch for source-backed fact checks" @@ -2022,6 +2033,202 @@ EOF rm -rf "$tmp_dir" } +assert_opencode_failed_check_fallback_maps_supply_chain_vulnerabilities() { + local tmp_dir + local fixture_repo + local evidence_file + local output_file + local stderr_file + tmp_dir="$(mktemp -d)" + fixture_repo="$tmp_dir/repo" + evidence_file="$tmp_dir/failed-check-evidence.md" + output_file="$tmp_dir/fallback.md" + stderr_file="$tmp_dir/fallback.err" + mkdir -p "$fixture_repo" + + cat >"$fixture_repo/requirements.txt" <<'EOF' +flask==2.0.1 +requests==2.19.0 +urllib3==1.25.0 +EOF + + cat >"$evidence_file" <<'EOF' +# Failed GitHub Check Evidence + +- PR: #23 +- Head SHA: `abc123def456abc123def456abc123def456abcd` +- Repository: `ContextualWisdomLab/clearfolio` + +## Failed check: OSV-Scanner/osv-scan + +- Type: `check_run` +- Conclusion: `FAILURE` +- Details URL: https://github.com/ContextualWisdomLab/clearfolio/actions/runs/28863381355 + +### Supply-chain vulnerability findings + +- Supply-chain vulnerability: id=GHSA-j8r2-6x86-q33q severity=HIGH package=requests installed=2.19.0 fixed=2.31.0 manifest=requirements.txt + +## Failed check: Security Scan/trivy-fs + +- Type: `check_run` +- Conclusion: `FAILURE` +- Details URL: https://github.com/ContextualWisdomLab/clearfolio/actions/runs/28863381999 + +### Failed log excerpt + +```text +requirements.txt (pip) +======================= +Total: 1 (HIGH: 1, CRITICAL: 0) + +┌──────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┐ +│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ +├──────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┤ +│ urllib3 │ CVE-2023-43804 │ HIGH │ fixed │ 1.25.0 │ 1.26.18 │ +└──────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┘ +``` +EOF + + bash "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" \ + "$evidence_file" "$fixture_repo" >"$output_file" 2>"$stderr_file" + + # osv-scanner canonical evidence: source-backed finding with the exact manifest line and from->to bump. + assert_file_contains "$output_file" "requirements.txt:2 - Supply-chain vulnerability GHSA-j8r2-6x86-q33q in requests" "supply-chain fallback maps the osv-scanner advisory to the exact manifest line" + assert_file_contains "$output_file" "bump \`requests\` from 2.19.0 to 2.31.0" "supply-chain fallback states the concrete requests version bump" + assert_file_contains "$output_file" "OSV-Scanner/osv-scan" "supply-chain fallback preserves the failed osv-scanner check label as evidence" + # trivy-fs job-log table: source-backed finding located under the manifest header. + assert_file_contains "$output_file" "requirements.txt:3 - Supply-chain vulnerability CVE-2023-43804 in urllib3" "supply-chain fallback maps the trivy table row to the exact manifest line" + assert_file_contains "$output_file" "bump \`urllib3\` from 1.25.0 to 1.26.18" "supply-chain fallback states the concrete urllib3 version bump" + assert_file_contains "$output_file" "urllib3==1.26.18" "supply-chain fallback offers a GitHub-suggestion-ready pin for the trivy finding" + assert_file_contains "$output_file" "requests==2.31.0" "supply-chain fallback offers a GitHub-suggestion-ready pin for the osv finding" + # Never line 0, and no URL-only deflection. + assert_file_not_contains "$output_file" ":0 - Supply-chain" "supply-chain fallback never emits a line-zero finding" + assert_file_not_contains "$output_file" "see the Actions run URL" "supply-chain fallback does not post URL-only supply-chain reviews" + + rm -rf "$tmp_dir" +} + +assert_opencode_failed_check_fallback_preserves_empty_supply_chain_columns() { + # Regression for the record-delimiter bug: the internal per-vulnerability + # record was joined with a TAB and read back with `IFS=$'\t'`. Tab is an + # IFS-whitespace character, so `read` collapsed consecutive tabs and any empty + # interior field (missing installed OR missing fixed) shifted every later + # column left by one — producing garbled findings such as a severity word in + # the advisory-id slot and a CVE id in the version slot. The collector appends + # installed=/fixed= only when present, so both are common real inputs. + local tmp_dir + local fixture_repo + local evidence_file + local output_file + local stderr_file + tmp_dir="$(mktemp -d)" + fixture_repo="$tmp_dir/repo" + evidence_file="$tmp_dir/failed-check-evidence.md" + output_file="$tmp_dir/fallback.md" + stderr_file="$tmp_dir/fallback.err" + mkdir -p "$fixture_repo" + + cat >"$fixture_repo/requirements.txt" <<'EOF' +flask==2.0.1 +requests==2.19.0 +EOF + + # Record 1: installed is MISSING (osv/trivy SARIF alert with no installed + # version). Record 2: fixed is MISSING (no-fix advisory). Both interior gaps + # used to collapse and shift columns. + cat >"$evidence_file" <<'EOF' +# Failed GitHub Check Evidence + +- PR: #77 +- Head SHA: `abc123def456abc123def456abc123def456abcd` +- Repository: `ContextualWisdomLab/clearfolio` + +## Failed check: OSV-Scanner/osv-scan + +- Type: `check_run` +- Conclusion: `FAILURE` +- Details URL: https://github.com/ContextualWisdomLab/clearfolio/actions/runs/28863381355 + +### Supply-chain vulnerability findings + +- Supply-chain vulnerability: id=CVE-2020-0001 severity=CRITICAL package=flask fixed=2.0.2 manifest=requirements.txt +- Supply-chain vulnerability: id=GHSA-aaaa-bbbb-cccc severity=HIGH package=requests installed=2.19.0 manifest=requirements.txt +EOF + + bash "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" \ + "$evidence_file" "$fixture_repo" >"$output_file" 2>"$stderr_file" + + # Record 1 (installed missing): the advisory id must be the CVE (NOT the + # severity word), the package must be flask, and the fix target must be the + # fixed VERSION (2.0.2), never the CVE id in the version slot. + assert_file_contains "$output_file" "Supply-chain vulnerability CVE-2020-0001 in flask" "empty installed keeps the advisory id in the title, not the severity word" + assert_file_not_contains "$output_file" "Supply-chain vulnerability CRITICAL in flask" "empty installed does not shift the severity word into the advisory-id slot" + assert_file_contains "$output_file" "upgrade \`flask\` to 2.0.2" "empty installed still names the concrete fixed version as the upgrade target" + assert_file_not_contains "$output_file" "to CVE-2020-0001" "the CVE id never appears in the upgrade/version slot" + + # Record 2 (fixed missing): the advisory id must be the GHSA (NOT the severity + # word), installed must be the real version, and the fix must say no upstream + # fix is available — never 'bump ... to '. + assert_file_contains "$output_file" "Supply-chain vulnerability GHSA-aaaa-bbbb-cccc in requests" "empty fixed keeps the advisory id in the title, not the severity word" + assert_file_contains "$output_file" "no fixed version is available upstream for \`requests\` 2.19.0" "empty fixed produces a sensible no-fix instruction with the real installed version" + assert_file_not_contains "$output_file" "to GHSA-aaaa-bbbb-cccc" "the GHSA id never appears in the upgrade/version slot" + assert_file_not_contains "$output_file" "from GHSA-aaaa-bbbb-cccc" "the GHSA id never appears in the from-version slot" + + # Columns are not shifted: severity lands in the severity slot for both. + assert_file_contains "$output_file" "CRITICAL requirements.txt" "record 1 severity stays in the severity column" + assert_file_contains "$output_file" "HIGH requirements.txt" "record 2 severity stays in the severity column" + + # Line numbers stay positive (never 0), even with empty interior fields. + assert_file_not_contains "$output_file" ":0 - Supply-chain" "empty interior fields never produce a line-zero finding" + + rm -rf "$tmp_dir" +} + +assert_opencode_failed_check_fallback_rejects_url_only_supply_chain() { + local tmp_dir + local fixture_repo + local evidence_file + local output_file + local stderr_file + local rc + tmp_dir="$(mktemp -d)" + fixture_repo="$tmp_dir/repo" + evidence_file="$tmp_dir/failed-check-evidence.md" + output_file="$tmp_dir/fallback.md" + stderr_file="$tmp_dir/fallback.err" + mkdir -p "$fixture_repo" + + # A supply-chain check failed, but the evidence carries only the check name + # and a run URL — no package, advisory id, manifest, or fixed version. This + # must stay fail-closed: no source-backed finding can be invented. + cat >"$evidence_file" <<'EOF' +# Failed GitHub Check Evidence + +- PR: #24 +- Head SHA: `abc123def456abc123def456abc123def456abcd` +- Repository: `ContextualWisdomLab/clearfolio` + +## Failed check: OSV-Scanner/osv-scan + +- Type: `check_run` +- Conclusion: `FAILURE` +- Details URL: https://github.com/ContextualWisdomLab/clearfolio/actions/runs/28863381355 +EOF + + set +e + bash "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" \ + "$evidence_file" "$fixture_repo" >"$output_file" 2>"$stderr_file" + rc=$? + set -e + + assert_equals "1" "$rc" "URL-only supply-chain evidence does not produce a REQUEST_CHANGES finding" + assert_file_not_contains "$output_file" "Supply-chain vulnerability" "URL-only supply-chain evidence emits no supply-chain finding" + assert_file_contains "$stderr_file" "No source-backed failed-check fallback finding matched" "URL-only supply-chain evidence stays fail-closed and asks for rerun or newer logs" + + rm -rf "$tmp_dir" +} + assert_opencode_failed_check_fallback_rejects_cancelled_queue_only_reviews() { local tmp_dir local fixture_repo @@ -7231,6 +7438,12 @@ assert_opencode_failed_check_fallback_emits_each_strix_report assert_opencode_failed_check_fallback_explains_pytest_and_cancelled_checks +assert_opencode_failed_check_fallback_maps_supply_chain_vulnerabilities + +assert_opencode_failed_check_fallback_preserves_empty_supply_chain_columns + +assert_opencode_failed_check_fallback_rejects_url_only_supply_chain + assert_opencode_failed_check_fallback_rejects_cancelled_queue_only_reviews assert_opencode_failed_check_fallback_explains_trusted_base_strix_prs