From cd8b73368b36d4d491e367269230160ff195f8e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 16:04:10 +0900 Subject: [PATCH 1/5] feat(strix): documented accepted-risk suppression (<= MEDIUM, never HIGH/CRITICAL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-finding accepted-risk mechanism so a scanned repository can document a specific, reasoned by-design finding and stop it from hard-failing the required gate — without weakening enforcement for genuine issues. Declaration lives in the scanned repo at .security/strix-accepted-risks.txt, one entry per line: `SEVERITY | title-substring | reason`. A finding is suppressed only when ALL hold: - its severity is at or below STRIX_MAX_SUPPRESSIBLE_SEVERITY (default MEDIUM) — HIGH and CRITICAL are never suppressible; - the entry's own declared severity is within that ceiling and >= the finding severity; - the finding title contains the entry substring; and - the entry carries a non-empty reason. Every suppression is emitted as a ::warning (title + reason) so it is auditable in the required check output, and the declaration is part of the PR diff so adding/loosening it is reviewable. The skip is wired into all three finding-blocking verdict paths (evaluate_pull_request_findings, has_blocking_vulnerability_reports, has_unmapped_threshold_report), and the console-log fallback in evaluate_pull_request_findings now only re-derives severity when NO per-finding reports exist — so accepted per-finding reports are not resurrected from the aggregate log. The declaration is read from the PR head (trusted) with a checkout fallback. Tests: static assertions for the caps/wiring/fallback guard, plus a functional matcher test proving a documented MEDIUM is suppressed while a title-matching HIGH and an undeclared MEDIUM both still block. Motivating case: ContextualWisdomLab/gyeot#11 VULN-0008 (on-device SQLite at-rest plaintext), a documented on-device-first design tradeoff. Co-Authored-By: Claude Fable 5 --- scripts/ci/strix_quick_gate.sh | 100 +++++++++++++++++++++++++++- scripts/ci/test_strix_quick_gate.sh | 54 +++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index b0d501e1..fed146b2 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,80 @@ vulnerability_file_is_below_threshold() { [ "$report_rank" -ge 0 ] && [ "$report_rank" -lt "$threshold_rank" ] } +# Read the scanned repo's accepted-risk declaration. Prefer the PR-head blob +# (trusted, immutable for this run) and fall back to the checkout. Emits raw +# lines; comment/blank filtering happens in the matcher. +accepted_risk_declaration_lines() { + local head_sha + 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; then + git show "$head_sha:$STRIX_ACCEPTED_RISKS_PATH" 2>/dev/null && return 0 + fi + if [ -f "$REPO_ROOT/$STRIX_ACCEPTED_RISKS_PATH" ] && [ ! -L "$REPO_ROOT/$STRIX_ACCEPTED_RISKS_PATH" ]; then + cat -- "$REPO_ROOT/$STRIX_ACCEPTED_RISKS_PATH" 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 -Ei '^[[:space:]]*Title[[:space:]]*:' "$source_path" 2>/dev/null | + head -n 1 | + sed -E 's/^[[:space:]]*[Tt]itle[[:space:]]*:[[:space:]]*//' | + sed -E 's/[[:space:]]+$//' +} + +# 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 + finding_rank="$(extract_max_severity_rank "$vuln_file")" + suppressible_rank="$(severity_rank "$STRIX_MAX_SUPPRESSIBLE_SEVERITY")" + # 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 + if [[ "${finding_title,,}" == *"${entry_substring,,}"* ]]; then + echo "::warning title=Strix accepted risk::Suppressing documented accepted-risk finding (${entry_severity}) '${finding_title}' — reason: ${entry_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 +2069,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 +2125,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 +2218,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 +3187,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..a2d4bd4e 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -164,6 +164,56 @@ 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")" + 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 + # 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" + + local out + out="$( + REPO_ROOT="$tmp_dir" STRIX_MAX_SUPPRESSIBLE_SEVERITY=MEDIUM \ + 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 '/^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" + ' + )" + 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 + rm -rf "$tmp_dir" +} + assert_strix_workflow_pr_trigger_hardened() { local workflow_file="$REPO_ROOT/.github/workflows/strix.yml" @@ -8556,6 +8606,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 From f136d35d2cd1157491474e5f84aa6877e8701bda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 16:11:51 +0900 Subject: [PATCH 2/5] fix(strix): literal accepted-risk matching + workflow-command escaping Addresses #609 review (Copilot): - Glob injection: the declared substring was used in a [[ == *"$s"* ]] pattern, so glob metacharacters (*, ?, [) in .security/strix-accepted-risks .txt could broaden the match. Switch to a fixed-string grep -F so a declared substring matches literally and can never over-match (a lone '*' now matches only titles containing an asterisk, not all findings). - Workflow-command injection: the ::warning message embedded the finding title and repo-supplied reason unescaped. Add escape_workflow_command_ message (%, CR, LF -> %25/%0D/%0A) so untrusted content cannot break the command format or inject additional workflow commands into the Actions log. - Reword the misleading "trusted" comment: PR-head blobs are immutable for the run but remain untrusted PR-controlled data, consistent with how the rest of this script treats them. Functional test extended with a bare-'*' declaration that must not match a non-asterisk title, locking in the literal-match fix. Co-Authored-By: Claude Fable 5 --- scripts/ci/strix_quick_gate.sh | 27 +++++++++++++++++++++++---- scripts/ci/test_strix_quick_gate.sh | 4 ++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index fed146b2..682ab54f 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -1957,9 +1957,22 @@ 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 -# (trusted, immutable for this run) and fall back to the checkout. Emits raw -# lines; comment/blank filtering happens in the matcher. +# (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 head_sha="$(trim_whitespace "${PR_HEAD_SHA:-}")" @@ -2022,8 +2035,14 @@ vulnerability_file_is_accepted_risk() { if [ "$entry_rank" -lt 0 ] || [ "$entry_rank" -gt "$suppressible_rank" ] || [ "$finding_rank" -gt "$entry_rank" ]; then continue fi - if [[ "${finding_title,,}" == *"${entry_substring,,}"* ]]; then - echo "::warning title=Strix accepted risk::Suppressing documented accepted-risk finding (${entry_severity}) '${finding_title}' — reason: ${entry_reason}. HIGH/CRITICAL findings are never suppressible; see ${STRIX_ACCEPTED_RISKS_PATH}." >&2 + # 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) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index a2d4bd4e..885d9267 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -185,6 +185,9 @@ assert_strix_accepted_risk_matcher_functional() { # 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" @@ -201,6 +204,7 @@ EOF '"$(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 '/^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" From ae11ab27e2d05c5ae007c70d227c68dc3fabbada Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 19:50:50 +0900 Subject: [PATCH 3/5] fix(strix): harden accepted-risk parsing --- scripts/ci/strix_quick_gate.sh | 7 +++---- scripts/ci/test_strix_quick_gate.sh | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 682ab54f..9432f81d 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -1978,7 +1978,7 @@ accepted_risk_declaration_lines() { 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; then + git rev-parse --verify --quiet "$head_sha^{commit}" >/dev/null 2>&1; then git show "$head_sha:$STRIX_ACCEPTED_RISKS_PATH" 2>/dev/null && return 0 fi if [ -f "$REPO_ROOT/$STRIX_ACCEPTED_RISKS_PATH" ] && [ ! -L "$REPO_ROOT/$STRIX_ACCEPTED_RISKS_PATH" ]; then @@ -1989,10 +1989,9 @@ accepted_risk_declaration_lines() { # First "Title:" value of a finding report (used to match acceptance entries). extract_finding_title() { local source_path="$1" - grep -Ei '^[[:space:]]*Title[[:space:]]*:' "$source_path" 2>/dev/null | - head -n 1 | + grep -Eim 1 '^[[:space:]]*Title[[:space:]]*:' "$source_path" 2>/dev/null | sed -E 's/^[[:space:]]*[Tt]itle[[:space:]]*:[[:space:]]*//' | - sed -E 's/[[:space:]]+$//' + sed -E 's/[[:space:]]+$//' || true } # Returns 0 when a finding is a documented accepted risk: severity is at or below diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 885d9267..ec5e8242 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -170,7 +170,7 @@ assert_strix_accepted_risk_suppression_wired() { 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")" + 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 @@ -192,6 +192,7 @@ EOF 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="$( @@ -210,11 +211,27 @@ EOF 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 '/^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 rm -rf "$tmp_dir" } From 44760dbf5ec1101882a5474411c5128b586b89d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 20:24:28 +0900 Subject: [PATCH 4/5] fix(strix): hard-cap accepted risk severity --- scripts/ci/strix_quick_gate.sh | 8 +++++++- scripts/ci/test_strix_quick_gate.sh | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 9432f81d..97673dfb 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -2001,9 +2001,15 @@ extract_finding_title() { # so it is auditable in the required check output. vulnerability_file_is_accepted_risk() { local vuln_file="$1" - local finding_rank suppressible_rank + 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 diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ec5e8242..a552b6a5 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -196,7 +196,7 @@ EOF local out out="$( - REPO_ROOT="$tmp_dir" STRIX_MAX_SUPPRESSIBLE_SEVERITY=MEDIUM \ + REPO_ROOT="$tmp_dir" STRIX_MAX_SUPPRESSIBLE_SEVERITY=CRITICAL \ STRIX_ACCEPTED_RISKS_PATH=".security/strix-accepted-risks.txt" \ bash -c ' set -uo pipefail From 72c1a9a939637d813b67ca77fe7bac4a780d3659 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 20:47:41 +0900 Subject: [PATCH 5/5] fix(strix): confine accepted risk declarations --- scripts/ci/strix_quick_gate.sh | 10 ++++++---- scripts/ci/test_strix_quick_gate.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 97673dfb..53c740cb 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -1974,15 +1974,17 @@ escape_workflow_command_message() { # 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 + 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:$STRIX_ACCEPTED_RISKS_PATH" 2>/dev/null && return 0 + git show "$head_sha:$accepted_risks_path" 2>/dev/null && return 0 fi - if [ -f "$REPO_ROOT/$STRIX_ACCEPTED_RISKS_PATH" ] && [ ! -L "$REPO_ROOT/$STRIX_ACCEPTED_RISKS_PATH" ]; then - cat -- "$REPO_ROOT/$STRIX_ACCEPTED_RISKS_PATH" 2>/dev/null || true + 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 } diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index a552b6a5..7af068ca 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -206,6 +206,7 @@ EOF '"$(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" @@ -226,12 +227,39 @@ EOF 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" }