Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion scripts/ci/strix_quick_gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
seonghobae marked this conversation as resolved.
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
Comment on lines +2049 to +2052
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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions scripts/ci/test_strix_quick_gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
seonghobae marked this conversation as resolved.
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"

Expand Down Expand Up @@ -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
Expand Down
Loading