From 723525f0be4adb4be0cd7f72afb1845ffae1a7d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 8 Jul 2026 08:28:08 +0900 Subject: [PATCH 1/2] fix(opencode): add source-backed supply-chain fallback emitter so approve gate reaches a conclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When OpenCode's Review Overview control is APPROVE but a PR has failing peer supply-chain checks (osv-scan, trivy-fs, dependency-review), the approve gate collects the failed checks and must convert them into a source-backed REQUEST_CHANGES. The fallback helper (emit_opencode_failed_check_fallback_findings.sh) had emitters for pytest, cancelled checks, Strix reports, and billing-lock, but NONE for supply-chain scanners. So finding_index stayed 0, build_failed_check_fallback_body returned 1, stop_failed_check_fallback_unavailable fired, no review was posted, and the gate printed NO_CONCLUSION / FAILED_CHECK_DIAGNOSIS_UNAVAILABLE and exited 1 — the "Run merge scheduler" step was skipped and the PR was stuck forever (opencode-review = failure with no actionable review). Supply-chain findings ARE source-backed: osv/trivy/dependency-review name the exact vulnerable package, its manifest file, the CVE/GHSA id, and the fixed version. This maps those to concrete findings instead of weakening the fail-closed contract. Changes: - emit_opencode_failed_check_fallback_findings.sh: add emit_supply_chain_findings (+ extract_supply_chain_records) that parses osv-scanner / trivy-fs / dependency-review failed-check blocks for two evidence shapes — canonical "- Supply-chain vulnerability: id=... severity=... package=... installed=... fixed=... manifest=... line=..." lines and raw Trivy findings tables — and emits one source-backed finding per distinct vulnerability with: manifest path resolved under repo_root, a positive line number (locate the package in the manifest, or honor the scanner's SARIF line hint; never line 0), severity, a CVE/GHSA + package title, problem/root_cause quoting the scanner evidence and failed check label, fix_direction "bump from to ", a GitHub-suggestion-ready ```suggestion diff for simple version pins, and a regression_test_direction. Wired into the dispatch sequence next to the other emit_* calls. URL-only supply-chain evidence still yields no finding (fail-closed preserved). - collect_failed_check_evidence.sh: extend evidence collection so the emitter has real data in production. For failed osv-scanner / trivy checks (which upload SARIF to code scanning rather than logging findings), pull the current-head code-scanning alerts and normalize each into a canonical "- Supply-chain vulnerability:" line (package, manifest path+line, advisory id, severity, fixed version). Best-effort and guarded; never fails the collector. - test_strix_quick_gate.sh: add static assertions that the helper defines emit_supply_chain_findings, scopes to osv/trivy/dependency-review, states a concrete "bump from to ", offers a ```suggestion diff, and is wired into the dispatch; add static assertions that the collector defines emit_supply_chain_alert_evidence, reads code-scanning/alerts, and emits canonical supply-chain lines. Add two functional tests: one feeds synthetic osv (canonical) + trivy (table) evidence plus a matching manifest and asserts source-backed findings (path, positive line, CVE/GHSA, from->to bump, suggestion); the other asserts URL-only supply-chain evidence stays fail-closed. No existing test assertion was relaxed or deleted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P --- scripts/ci/collect_failed_check_evidence.sh | 174 +++++++++++++++ ...opencode_failed_check_fallback_findings.sh | 201 ++++++++++++++++++ scripts/ci/test_strix_quick_gate.sh | 135 ++++++++++++ 3 files changed, 510 insertions(+) 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..1c9556faa 100755 --- a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh +++ b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh @@ -685,6 +685,206 @@ 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 TSV record per + # distinct vulnerability. 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)". + # + # TSV columns: 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/\t/ /g; } + print join("\t", $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 + + while IFS=$'\t' 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}" + if [ -n "$fixed" ]; 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:-the affected version}" "$fixed" "$resolved" "$line" "$check_label" + else + printf -- '- Fix: upgrade `%s` in `%s:%s` to the first release that resolves `%s` (no fixed version was reported in the evidence; consult the advisory), regenerate the lockfile if applicable, then rerun the failed `%s` scan.\n' "$package" "$resolved" "$line" "$vuln_id" "$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 +910,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 f51cfff59..2e844237a 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -853,6 +853,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" @@ -942,6 +947,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" @@ -2006,6 +2017,126 @@ 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_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 @@ -7215,6 +7346,10 @@ 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_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 From 082cf8997fb5634189d2e077725a488e9e64c204 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 8 Jul 2026 08:41:22 +0900 Subject: [PATCH 2/2] fix: preserve empty supply-chain record columns with US delimiter The supply-chain fallback joined per-vulnerability records with a TAB and read them back with IFS=$'\t'. Tab is an IFS-whitespace character, so read collapses consecutive tabs: an empty interior field (missing installed OR missing fixed) shifted every later column left by one. Since the collector appends installed=/fixed= only when present, no-installed-version SARIF alerts and no-fix advisories are common real inputs, producing garbled findings (severity word in the advisory-id slot, CVE id in the version slot). Switch the internal record delimiter to the ASCII Unit Separator (\x1f) for both the perl join and the read loop. \x1f is not IFS-whitespace, so empty interior fields are preserved positionally. Guard the emitter so a missing installed or fixed never yields a broken sentence or a CVE id in a version slot: "upgrade to " when installed is absent, and "no fixed version is available upstream for ; remove or replace the dependency, or pin to a patched fork" when there is no fix. The canonical human-readable evidence line format is unchanged. Add assert_opencode_failed_check_fallback_preserves_empty_supply_chain_columns, a regression test feeding one record with no installed version and one with no fixed version; it asserts the advisory id lands in the title (not the severity word), no CVE/GHSA id appears in a version slot, columns are not shifted, and line numbers stay positive. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P --- ...opencode_failed_check_fallback_findings.sh | 30 ++++--- scripts/ci/test_strix_quick_gate.sh | 78 +++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh index 1c9556faa..13fd315e7 100755 --- a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh +++ b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh @@ -687,8 +687,10 @@ emit_strix_cancelled_without_log_finding() { extract_supply_chain_records() { # Parse the failed-check EVIDENCE for supply-chain scanner results - # (osv-scanner, trivy-fs, dependency-review) and emit one TSV record per - # distinct vulnerability. Supply-chain findings are source-backed because the + # (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: @@ -701,7 +703,7 @@ extract_supply_chain_records() { # 2. A Trivy filesystem findings table logged to the job log, grouped by a # manifest header such as "requirements.txt (pip)". # - # TSV columns: manifest, package, installed, fixed, id, severity, evidence, label, line_hint + # Record columns (\x1f-separated): manifest, package, installed, fixed, id, severity, evidence, label, line_hint local source_file="$1" perl -CS -ne ' @@ -720,8 +722,8 @@ extract_supply_chain_records() { $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/\t/ /g; } - print join("\t", $m,$p,$inst,$fix,$id,$sev,$ev,$lab,$hint), "\n"; + 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; @@ -797,7 +799,12 @@ emit_supply_chain_findings() { return 0 fi - while IFS=$'\t' read -r manifest package installed fixed vuln_id severity evidence_line check_label line_hint; do + # 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 @@ -869,10 +876,15 @@ emit_supply_chain_findings() { 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}" - if [ -n "$fixed" ]; 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:-the affected version}" "$fixed" "$resolved" "$line" "$check_label" + # 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: upgrade `%s` in `%s:%s` to the first release that resolves `%s` (no fixed version was reported in the evidence; consult the advisory), regenerate the lockfile if applicable, then rerun the failed `%s` scan.\n' "$package" "$resolved" "$line" "$vuln_id" "$check_label" + 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 diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 2e844237a..6d8bdcd18 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -2093,6 +2093,82 @@ EOF 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 @@ -7348,6 +7424,8 @@ 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