diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index b0d501e1..53c740cb 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -45,6 +45,13 @@ STRIX_TRANSIENT_RETRY_PER_MODEL="${STRIX_TRANSIENT_RETRY_PER_MODEL:-0}" STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS="${STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS:-3}" STRIX_FAIL_ON_MIN_SEVERITY="${STRIX_FAIL_ON_MIN_SEVERITY:-MEDIUM}" STRIX_FAIL_ON_PROVIDER_SIGNAL="${STRIX_FAIL_ON_PROVIDER_SIGNAL:-0}" +# Highest severity a scanned repository may document as an accepted risk. Findings +# above this rank (HIGH, CRITICAL) are NEVER suppressible — genuine high-severity +# issues always fail the gate. The scanned repo declares specific accepted findings +# in .security/strix-accepted-risks.txt (one per line: SEVERITY | title-substring | +# reason); each acceptance is logged as a ::warning for audit and requires a reason. +STRIX_MAX_SUPPRESSIBLE_SEVERITY="${STRIX_MAX_SUPPRESSIBLE_SEVERITY:-MEDIUM}" +STRIX_ACCEPTED_RISKS_PATH="${STRIX_ACCEPTED_RISKS_PATH:-.security/strix-accepted-risks.txt}" RUN_START_EPOCH=0 TOTAL_TIMEOUT_EXCEEDED=0 ATTEMPT_LOG_SEQUENCE=0 @@ -1950,6 +1957,106 @@ vulnerability_file_is_below_threshold() { [ "$report_rank" -ge 0 ] && [ "$report_rank" -lt "$threshold_rank" ] } +# Escape a string for safe inclusion in a GitHub Actions workflow command +# (::warning ...::message). Untrusted content (finding titles, repo-supplied +# reasons) must not be able to break the command format or inject additional +# workflow commands via %, CR, or LF. +escape_workflow_command_message() { + local text="$1" + text="${text//'%'/%25}" + text="${text//$'\r'/%0D}" + text="${text//$'\n'/%0A}" + printf '%s' "$text" +} + +# Read the scanned repo's accepted-risk declaration. Prefer the PR-head blob +# (immutable for this run, but still untrusted PR-controlled data — like every +# other PR-head blob this script copies as scanner input) and fall back to the +# checkout. Emits raw lines; comment/blank filtering happens in the matcher. +accepted_risk_declaration_lines() { + local head_sha accepted_risks_path accepted_risks_file + accepted_risks_path="$(normalize_changed_file_path "$STRIX_ACCEPTED_RISKS_PATH" 2>/dev/null)" || return 0 + head_sha="$(trim_whitespace "${PR_HEAD_SHA:-}")" + if [ -n "$head_sha" ] && + is_valid_git_commit_sha "$head_sha" && + git rev-parse --verify --quiet "$head_sha^{commit}" >/dev/null 2>&1; then + git show "$head_sha:$accepted_risks_path" 2>/dev/null && return 0 + fi + accepted_risks_file="$REPO_ROOT/$accepted_risks_path" + if [ -f "$accepted_risks_file" ] && [ ! -L "$accepted_risks_file" ]; then + cat -- "$accepted_risks_file" 2>/dev/null || true + fi +} + +# First "Title:" value of a finding report (used to match acceptance entries). +extract_finding_title() { + local source_path="$1" + grep -Eim 1 '^[[:space:]]*Title[[:space:]]*:' "$source_path" 2>/dev/null | + sed -E 's/^[[:space:]]*[Tt]itle[[:space:]]*:[[:space:]]*//' | + sed -E 's/[[:space:]]+$//' || true +} + +# Returns 0 when a finding is a documented accepted risk: severity is at or below +# STRIX_MAX_SUPPRESSIBLE_SEVERITY (HIGH/CRITICAL never qualify), the finding title +# contains a declared entry's substring, the declared entry's own severity cap is +# respected, and the entry carries a non-empty reason. Every acceptance is logged +# so it is auditable in the required check output. +vulnerability_file_is_accepted_risk() { + local vuln_file="$1" + local finding_rank suppressible_rank medium_rank + finding_rank="$(extract_max_severity_rank "$vuln_file")" + suppressible_rank="$(severity_rank "$STRIX_MAX_SUPPRESSIBLE_SEVERITY")" + medium_rank="$(severity_rank MEDIUM)" + # Environment overrides may narrow accepted risk, but can never make HIGH or + # CRITICAL findings suppressible. Unknown ceilings remain fail-closed at -1. + if [ "$suppressible_rank" -gt "$medium_rank" ]; then + suppressible_rank="$medium_rank" + fi + # Never suppress unknown-severity or above-cap (HIGH/CRITICAL) findings. + if [ "$finding_rank" -lt 0 ] || [ "$finding_rank" -gt "$suppressible_rank" ]; then + return 1 + fi + + local finding_title + finding_title="$(extract_finding_title "$vuln_file")" + if [ -z "$finding_title" ]; then + return 1 + fi + + local entry_line entry_severity entry_substring entry_reason entry_rank + while IFS= read -r entry_line; do + case "$entry_line" in + '' | '#'*) continue ;; + esac + IFS='|' read -r entry_severity entry_substring entry_reason <<<"$entry_line" + entry_severity="$(trim_whitespace "$entry_severity")" + entry_substring="$(trim_whitespace "$entry_substring")" + entry_reason="$(trim_whitespace "$entry_reason")" + # A malformed or reasonless entry never suppresses (fail closed on the entry). + if [ -z "$entry_severity" ] || [ -z "$entry_substring" ] || [ -z "$entry_reason" ]; then + continue + fi + entry_rank="$(severity_rank "$entry_severity")" + # The entry may not claim a cap above the global suppressible ceiling, and the + # finding must be at or below the entry's own declared severity. + if [ "$entry_rank" -lt 0 ] || [ "$entry_rank" -gt "$suppressible_rank" ] || [ "$finding_rank" -gt "$entry_rank" ]; then + continue + fi + # Literal (fixed-string) substring match — grep -F so glob metacharacters + # (*, ?, [) in a declared substring match literally and cannot broaden the + # match (e.g. a lone '*' matches only titles containing an asterisk, never all). + if printf '%s' "${finding_title,,}" | grep -Fq -- "${entry_substring,,}"; then + local safe_title safe_reason + safe_title="$(escape_workflow_command_message "$finding_title")" + safe_reason="$(escape_workflow_command_message "$entry_reason")" + echo "::warning title=Strix accepted risk::Suppressing documented accepted-risk finding (${entry_severity}) '${safe_title}' — reason: ${safe_reason}. HIGH/CRITICAL findings are never suppressible; see ${STRIX_ACCEPTED_RISKS_PATH}." >&2 + return 0 + fi + done < <(accepted_risk_declaration_lines) + + return 1 +} + evaluate_pull_request_findings() { PR_FINDINGS_DECISION="not_applicable" if ! is_pull_request_event; then @@ -1988,6 +2095,11 @@ evaluate_pull_request_findings() { found_retryable_model_inconsistency=1 continue fi + # Documented accepted risk (<= MEDIUM, with reason) — logged and skipped so it + # does not block, while HIGH/CRITICAL and undeclared findings still fail closed. + if vulnerability_file_is_accepted_risk "$vuln_file"; then + continue + fi rank="$(extract_max_severity_rank "$vuln_file")" if [ "$rank" -lt 0 ]; then PR_FINDINGS_DECISION="block_unmapped" @@ -2039,7 +2151,13 @@ evaluate_pull_request_findings() { done done - if [ "$found_baseline_threshold_finding" -eq 0 ] && [ "$found_changed_manifest_only_threshold_finding" -eq 0 ]; then + # Console-log re-derivation is a fallback for when Strix wrote NO per-finding + # reports. When per-finding reports existed and were all below threshold, + # accepted, or retryable, do not resurrect them from the aggregate console log — + # the structured per-finding files are authoritative and already evaluated above. + if [ "$found_baseline_threshold_finding" -eq 0 ] && + [ "$found_changed_manifest_only_threshold_finding" -eq 0 ] && + [ "$found_any_vuln_file" -eq 0 ]; then rank="$(extract_max_severity_rank "$STRIX_LOG")" if [ "$rank" -lt 0 ]; then if [ "$found_retryable_model_inconsistency" -eq 1 ]; then @@ -2126,6 +2244,9 @@ has_unmapped_threshold_report() { if [ ! -f "$vuln_file" ] || [ -L "$vuln_file" ]; then continue fi + if vulnerability_file_is_accepted_risk "$vuln_file"; then + continue + fi rank="$(extract_max_severity_rank "$vuln_file")" if [ "$rank" -lt "$threshold_rank" ]; then continue @@ -3092,6 +3213,9 @@ has_blocking_vulnerability_reports() { if vulnerability_file_is_retryable_model_inconsistency "$vuln_file"; then continue fi + if vulnerability_file_is_accepted_risk "$vuln_file"; then + continue + fi rank="$(extract_max_severity_rank "$vuln_file")" if [ "$rank" -lt 0 ] || [ "$rank" -ge "$threshold_rank" ]; then diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index d5ff2795..7af068ca 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -164,6 +164,105 @@ assert_strix_pr_scope_includes_deployment_context() { assert_file_contains "$GATE_SCRIPT" "scripts/ci/test_*.sh" "strix gate excludes large CI self-test harnesses from PR scan targets" } +assert_strix_accepted_risk_suppression_wired() { + assert_file_contains "$GATE_SCRIPT" "STRIX_MAX_SUPPRESSIBLE_SEVERITY=\"\${STRIX_MAX_SUPPRESSIBLE_SEVERITY:-MEDIUM}\"" "accepted-risk suppression caps at MEDIUM by default" + assert_file_contains "$GATE_SCRIPT" "vulnerability_file_is_accepted_risk() {" "gate defines the accepted-risk matcher" + assert_file_contains "$GATE_SCRIPT" "HIGH/CRITICAL findings are never suppressible" "accepted-risk logging states HIGH/CRITICAL are never suppressible" + # Wired into every finding-blocking verdict path, not just one. + local wired_count + wired_count="$(grep -c 'if vulnerability_file_is_accepted_risk "\$vuln_file"; then' "$GATE_SCRIPT" || true)" + if [ "$wired_count" -lt 3 ]; then + record_failure "accepted-risk skip wired into only $wired_count verdict paths (expected >= 3)" + fi + assert_file_contains "$GATE_SCRIPT" "[ \"\$found_any_vuln_file\" -eq 0 ]" "console-log fallback does not resurrect accepted per-finding reports" +} + +assert_strix_accepted_risk_matcher_functional() { + local tmp_dir + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/strix-accepted-risk.XXXXXX")" + mkdir -p "$tmp_dir/.security" + cat >"$tmp_dir/.security/strix-accepted-risks.txt" <<'EOF' +# severity | title-substring | reason +MEDIUM | stored unencrypted in Expo SQLite | On-device-first design; documented tradeoff. +EOF + # A declaration whose substring is a bare glob metacharacter must match only + # titles literally containing it — never every finding. + printf '%s\n' 'MEDIUM | * | wildcard must not match everything' >>"$tmp_dir/.security/strix-accepted-risks.txt" + # Fixture findings. + printf 'Title: Sensitive data stored unencrypted in Expo SQLite\nSeverity: MEDIUM\n' >"$tmp_dir/med_match.md" + printf 'Title: Sensitive data stored unencrypted in Expo SQLite\nSeverity: HIGH\n' >"$tmp_dir/high_match.md" + printf 'Title: SQL injection in login handler\nSeverity: MEDIUM\n' >"$tmp_dir/med_nomatch.md" + printf 'Severity: MEDIUM\nEvidence: no title field\n' >"$tmp_dir/med_untitled.md" + + local out + out="$( + REPO_ROOT="$tmp_dir" STRIX_MAX_SUPPRESSIBLE_SEVERITY=CRITICAL \ + STRIX_ACCEPTED_RISKS_PATH=".security/strix-accepted-risks.txt" \ + bash -c ' + set -uo pipefail + trim_whitespace() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf "%s" "$s"; } + is_valid_git_commit_sha() { [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]]; } + '"$(sed -n '/^severity_rank()/,/^}/p' "$GATE_SCRIPT")"' + '"$(sed -n '/^extract_max_severity_rank()/,/^}/p' "$GATE_SCRIPT")"' + '"$(sed -n '/^extract_finding_title()/,/^}/p' "$GATE_SCRIPT")"' + '"$(sed -n '/^escape_workflow_command_message()/,/^}/p' "$GATE_SCRIPT")"' + '"$(sed -n '/^normalize_changed_file_path()/,/^}/p' "$GATE_SCRIPT")"' + '"$(sed -n '/^accepted_risk_declaration_lines()/,/^}/p' "$GATE_SCRIPT")"' + '"$(sed -n '/^vulnerability_file_is_accepted_risk()/,/^}/p' "$GATE_SCRIPT")"' + vulnerability_file_is_accepted_risk "'"$tmp_dir"'/med_match.md" 2>/dev/null && echo "MED_MATCH=accepted" || echo "MED_MATCH=blocked" + vulnerability_file_is_accepted_risk "'"$tmp_dir"'/high_match.md" 2>/dev/null && echo "HIGH_MATCH=accepted" || echo "HIGH_MATCH=blocked" + vulnerability_file_is_accepted_risk "'"$tmp_dir"'/med_nomatch.md" 2>/dev/null && echo "MED_NOMATCH=accepted" || echo "MED_NOMATCH=blocked" + vulnerability_file_is_accepted_risk "'"$tmp_dir"'/med_untitled.md" 2>/dev/null && echo "MED_UNTITLED=accepted" || echo "MED_UNTITLED=blocked" + ' + )" + case "$out" in *"MED_MATCH=accepted"*) ;; *) record_failure "documented MEDIUM accepted-risk was not suppressed (out=$out)" ;; esac + case "$out" in *"HIGH_MATCH=blocked"*) ;; *) record_failure "HIGH finding was suppressed despite title match (out=$out)" ;; esac + case "$out" in *"MED_NOMATCH=blocked"*) ;; *) record_failure "undeclared MEDIUM finding did not block (out=$out)" ;; esac + case "$out" in *"MED_UNTITLED=blocked"*) ;; *) record_failure "untitled MEDIUM finding crashed or was suppressed (out=$out)" ;; esac + + local git_stderr="$tmp_dir/git-stderr.txt" + PR_HEAD_SHA=0000000000000000000000000000000000000000 \ + REPO_ROOT="$tmp_dir" STRIX_ACCEPTED_RISKS_PATH=".security/strix-accepted-risks.txt" \ + bash -c ' + set -euo pipefail + trim_whitespace() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf "%s" "$s"; } + is_valid_git_commit_sha() { [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]]; } + '"$(sed -n '/^normalize_changed_file_path()/,/^}/p' "$GATE_SCRIPT")"' + '"$(sed -n '/^accepted_risk_declaration_lines()/,/^}/p' "$GATE_SCRIPT")"' + accepted_risk_declaration_lines >/dev/null + ' 2>"$git_stderr" + if [ -s "$git_stderr" ]; then + record_failure "accepted-risk checkout fallback leaked git diagnostics outside a repository: $(cat "$git_stderr")" + fi + + local outside_secret="$tmp_dir-outside-secret.txt" + printf '%s\n' 'OUTSIDE_ACCEPTED_RISK_SECRET' >"$outside_secret" + ln -s "$outside_secret" "$tmp_dir/.security/outside-link.txt" + local unsafe_path unsafe_output unsafe_stderr="$tmp_dir/unsafe-stderr.txt" + for unsafe_path in "$outside_secret" "../${outside_secret##*/}" ".security/outside-link.txt"; do + : >"$unsafe_stderr" + unsafe_output="$( + REPO_ROOT="$tmp_dir" STRIX_ACCEPTED_RISKS_PATH="$unsafe_path" \ + bash -c ' + set -euo pipefail + trim_whitespace() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf "%s" "$s"; } + is_valid_git_commit_sha() { [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]]; } + '"$(sed -n '/^normalize_changed_file_path()/,/^}/p' "$GATE_SCRIPT")"' + '"$(sed -n '/^accepted_risk_declaration_lines()/,/^}/p' "$GATE_SCRIPT")"' + accepted_risk_declaration_lines + ' 2>"$unsafe_stderr" + )" + if [ -n "$unsafe_output" ]; then + record_failure "unsafe accepted-risk path produced output: $unsafe_path" + fi + if [ -s "$unsafe_stderr" ]; then + record_failure "unsafe accepted-risk path leaked diagnostics: $unsafe_path: $(cat "$unsafe_stderr")" + fi + done + rm -f "$outside_secret" + rm -rf "$tmp_dir" +} + assert_strix_workflow_pr_trigger_hardened() { local workflow_file="$REPO_ROOT/.github/workflows/strix.yml" @@ -8556,6 +8655,10 @@ assert_strix_workflow_pr_trigger_hardened assert_strix_pr_scope_includes_deployment_context +assert_strix_accepted_risk_suppression_wired + +assert_strix_accepted_risk_matcher_functional + assert_strix_gpt54_model_guard_cases assert_strix_gate_target_scope_separated