Strix Security Scan ContextualWisdomLab/ContextualWisdomLab.github.io#119@a70e02cdc72462b4a3216fac4a62ab97d807aae7 #263
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Strix Security Scan | |
| run-name: >- | |
| Strix Security Scan ${{ github.event.client_payload.target_repository || | |
| github.event.pull_request.base.repo.full_name || github.repository }}#${{ | |
| github.event.client_payload.pr_number || github.event.pull_request.number || 'event' }}@${{ | |
| github.event.client_payload.pr_head_sha || github.event.pull_request.head.sha || github.sha }} | |
| on: | |
| push: | |
| branches: [main, develop, master] | |
| # Skip scans for changes that touch ONLY non-executable documentation and | |
| # image assets. A change whose entire diff is these paths has no source, | |
| # build, config, or workflow logic for a code security scanner to analyze, | |
| # so skipping it loses no coverage while freeing shared runner capacity. | |
| # Conservative by design: only file EXTENSIONS/paths that can never contain | |
| # executable logic are listed (no source, no *.txt, no *.svg, no CODEOWNERS, | |
| # no build scripts). A diff touching even one non-listed file still scans. | |
| # The weekly full-tree schedule below re-scans protected branches with no | |
| # path filter, backstopping every path. | |
| paths-ignore: | |
| - '**/*.md' | |
| - '**/*.markdown' | |
| - '**/*.rst' | |
| - '**/*.png' | |
| - '**/*.jpg' | |
| - '**/*.jpeg' | |
| - '**/*.gif' | |
| - '**/*.webp' | |
| - '**/*.bmp' | |
| - '**/*.ico' | |
| - 'LICENSE' | |
| - 'LICENSE.*' | |
| - 'COPYING' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| pull_request_target: | |
| types: [opened, synchronize, reopened, ready_for_review, closed] | |
| # Same conservative doc/image-only skip for PR scans. GitHub evaluates these | |
| # path filters against the PR's full base..head diff, so a PR is skipped only | |
| # when EVERY changed file is a non-executable doc/image asset; any code, | |
| # config, build, or workflow change still triggers the scan. Concurrency is | |
| # PR-number based for status grouping, but Strix runs intentionally do not | |
| # cancel in progress because a pre-job cancellation leaves no scanner log to | |
| # review. Queue pressure should be handled by stale-run cleanup outside this | |
| # current-head evidence path. For PRs the merge scheduler manages, same-head | |
| # Strix evidence is still forced at merge time via repository_dispatch (which | |
| # paths-ignore does not affect), so merged code never loses evidence. | |
| paths-ignore: | |
| - '**/*.md' | |
| - '**/*.markdown' | |
| - '**/*.rst' | |
| - '**/*.png' | |
| - '**/*.jpg' | |
| - '**/*.jpeg' | |
| - '**/*.gif' | |
| - '**/*.webp' | |
| - '**/*.bmp' | |
| - '**/*.ico' | |
| - 'LICENSE' | |
| - 'LICENSE.*' | |
| - 'COPYING' | |
| - '.github/ISSUE_TEMPLATE/**' | |
| schedule: | |
| # Weekly scan on protected branches (Mondays at 03:00 UTC). | |
| - cron: '0 3 * * 1' | |
| # Default-branch-only retry entrypoint; no caller-selected workflow ref. | |
| repository_dispatch: | |
| types: [strix-scan] | |
| concurrency: | |
| # Include the event name so default-branch repository_dispatch evidence cannot cancel | |
| # the required pull_request_target Strix context that branch protection reads. | |
| # PR-number scope keeps the queue on the current HEAD within each event class. | |
| group: >- | |
| strix-${{ github.event_name }}-${{ github.event.client_payload.target_repository || github.event.pull_request.base.repo.full_name || github.repository }}-${{ | |
| github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || | |
| github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && format('pr-{0}', github.event.client_payload.pr_number) || github.ref }} | |
| cancel-in-progress: true | |
| # Scorecard Token-Permissions (alert #43): keep the workflow-level token | |
| # read-only and scope same-repo status publication to the Strix scan job. | |
| permissions: | |
| actions: read | |
| contents: read | |
| models: read | |
| jobs: | |
| cancel-closed-pr-runs: | |
| if: github.event_name == 'pull_request_target' && github.event.action == 'closed' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - run: echo "PR closed; this run only cancels older runs through workflow concurrency." | |
| strix: | |
| if: github.event_name != 'pull_request_target' || github.event.action != 'closed' | |
| # Large repositories can require a legitimate full-hour review. The scanner | |
| # gets a 90-minute process budget and a 95-minute total retry budget; the | |
| # 100-minute step and 120-minute job leave deterministic time to preserve | |
| # partial reports and publish a concrete failure reason. Hitting any cap is | |
| # fail-closed and never turns an incomplete scan into an approval. | |
| timeout-minutes: 120 | |
| runs-on: ubuntu-latest | |
| # Least-privilege token scoped to this job (Scorecard alert #43): the scan | |
| # exchanges an OIDC token (id-token) and publishes same-repo status evidence | |
| # from the scan job only. | |
| permissions: | |
| actions: read | |
| contents: read | |
| id-token: write | |
| models: read | |
| statuses: write | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| steps: | |
| - name: Harden runner | |
| uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 | |
| with: | |
| egress-policy: audit | |
| disable-file-monitoring: true | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 | |
| with: | |
| python-version: "3.13" | |
| - name: Resolve trusted Strix source ref | |
| id: trusted_source | |
| env: | |
| JOB_CONTEXT_JSON: ${{ toJSON(job) }} | |
| GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} | |
| run: | | |
| set -euo pipefail | |
| python3 <<'PY' >>"$GITHUB_OUTPUT" | |
| import json | |
| import os | |
| import re | |
| import sys | |
| try: | |
| job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}") | |
| github_context = json.loads(os.environ.get("GITHUB_CONTEXT_JSON") or "{}") | |
| except json.JSONDecodeError as exc: | |
| print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr) | |
| raise SystemExit(1) | |
| trusted_repository = str( | |
| job_context.get("workflow_repository") or "ContextualWisdomLab/.github" | |
| ).strip() | |
| trusted_ref = str( | |
| job_context.get("workflow_sha") or github_context.get("workflow_sha") or "" | |
| ).strip() | |
| workflow_ref = str( | |
| job_context.get("workflow_ref") or github_context.get("workflow_ref") or "" | |
| ).strip() | |
| if not trusted_ref: | |
| trusted_ref = "main" | |
| prefix = "ContextualWisdomLab/.github/.github/workflows/strix.yml@" | |
| if workflow_ref.startswith(prefix): | |
| trusted_ref = workflow_ref.split("@", 1)[1] | |
| if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", trusted_repository): | |
| print("::error::Trusted workflow repository resolved to an invalid name.", file=sys.stderr) | |
| raise SystemExit(1) | |
| if not re.fullmatch(r"[0-9a-fA-F]{40}|refs/[^\s]+|[A-Za-z0-9._/-]+", trusted_ref): | |
| print("::error::Trusted workflow ref resolved to an invalid value.", file=sys.stderr) | |
| raise SystemExit(1) | |
| print(f"repository={trusted_repository}") | |
| print(f"ref={trusted_ref}") | |
| PY | |
| - name: Checkout trusted Strix source | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| repository: ${{ steps.trusted_source.outputs.repository }} | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| ref: ${{ steps.trusted_source.outputs.ref }} | |
| path: trusted-strix-source | |
| - name: Export trusted Strix source paths | |
| run: | | |
| set -euo pipefail | |
| trusted_strix_source="$GITHUB_WORKSPACE/trusted-strix-source" | |
| test -f "$trusted_strix_source/scripts/ci/strix_quick_gate.sh" | |
| test -f "$trusted_strix_source/scripts/ci/test_strix_quick_gate.sh" | |
| test -f "$trusted_strix_source/scripts/ci/strix_required_workflow_smoke.sh" | |
| { | |
| echo "TRUSTED_STRIX_SOURCE=$trusted_strix_source" | |
| echo "TRUSTED_STRIX_GATE=$trusted_strix_source/scripts/ci/strix_quick_gate.sh" | |
| echo "TRUSTED_STRIX_GATE_TEST=$trusted_strix_source/scripts/ci/test_strix_quick_gate.sh" | |
| echo "TRUSTED_STRIX_REQUIRED_SMOKE=$trusted_strix_source/scripts/ci/strix_required_workflow_smoke.sh" | |
| } >> "$GITHUB_ENV" | |
| - name: Exchange OpenCode app token for target repository reads | |
| id: target_app_token | |
| env: | |
| OIDC_AUDIENCE: opencode-github-action | |
| OPENCODE_API_BASE_URL: https://api.opencode.ai | |
| run: | | |
| set -euo pipefail | |
| mark_unavailable() { | |
| echo "available=false" >>"$GITHUB_OUTPUT" | |
| } | |
| if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC request environment is missing." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" | |
| separator="&" | |
| case "$request_url" in | |
| *\?*) ;; | |
| *) separator="?" ;; | |
| esac | |
| if ! oidc_response="$( | |
| curl -fsS \ | |
| -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ | |
| "${request_url}${separator}audience=${OIDC_AUDIENCE}" | |
| )"; then | |
| echo "OpenCode app token exchange unavailable: OIDC token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" | |
| if [ -z "$oidc_token" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| if ! token_response="$( | |
| curl -fsS \ | |
| -X POST \ | |
| -H "Authorization: Bearer ${oidc_token}" \ | |
| "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | |
| )"; then | |
| echo "OpenCode app token exchange unavailable: app token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| app_token="$(jq -r '.token // empty' <<<"$token_response")" | |
| if [ -z "$app_token" ]; then | |
| echo "OpenCode app token exchange unavailable: app token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| echo "::add-mask::$app_token" | |
| { | |
| echo "available=true" | |
| echo "token=$app_token" | |
| } >>"$GITHUB_OUTPUT" | |
| - name: Materialize target workspace | |
| if: github.event_name != 'repository_dispatch' | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} | |
| TARGET_WORKSPACE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} | |
| run: | | |
| set -euo pipefail | |
| trusted_workspace="$RUNNER_TEMP/trusted-workspace" | |
| mkdir -p "$trusted_workspace" | |
| git init -q "$trusted_workspace" | |
| gh auth setup-git | |
| git -C "$trusted_workspace" remote add origin "$GITHUB_SERVER_URL/$REPOSITORY.git" | |
| git -C "$trusted_workspace" fetch --no-tags --depth=1 origin "$TARGET_WORKSPACE_SHA" | |
| git -C "$trusted_workspace" checkout --detach --quiet "$TARGET_WORKSPACE_SHA" | |
| git -C "$trusted_workspace" cat-file -e "$TARGET_WORKSPACE_SHA^{commit}" | |
| echo "TRUSTED_WORKSPACE=$trusted_workspace" >> "$GITHUB_ENV" | |
| - name: Validate repository dispatch against live pull request metadata | |
| if: github.event_name == 'repository_dispatch' | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| REPOSITORY: ${{ github.event.client_payload.target_repository }} | |
| PR_NUMBER: ${{ github.event.client_payload.pr_number }} | |
| SUPPLIED_BASE_REF: ${{ github.event.client_payload.pr_base_ref }} | |
| SUPPLIED_BASE_SHA: ${{ github.event.client_payload.pr_base_sha }} | |
| SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || | |
| ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || | |
| ! [[ "$SUPPLIED_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || | |
| ! [[ "$SUPPLIED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || | |
| [ -z "$SUPPLIED_BASE_REF" ]; then | |
| echo "::error::repository_dispatch Strix metadata is incomplete or malformed." | |
| exit 1 | |
| fi | |
| pull_request_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" | |
| live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" | |
| live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_request_json")" | |
| live_head_repository="$(jq -r '.head.repo.full_name // empty' <<<"$pull_request_json")" | |
| live_base_ref="$(jq -r '.base.ref // empty' <<<"$pull_request_json")" | |
| live_base_sha="$(jq -r '.base.sha // empty' <<<"$pull_request_json")" | |
| live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" | |
| if [ "$live_state" != "open" ] || | |
| [ "$live_base_repository" != "$REPOSITORY" ] || | |
| [ "$live_head_repository" != "$REPOSITORY" ] || | |
| [ "$live_base_ref" != "$SUPPLIED_BASE_REF" ] || | |
| [ "$live_base_sha" != "$SUPPLIED_BASE_SHA" ] || | |
| [ "$live_head_sha" != "$SUPPLIED_HEAD_SHA" ]; then | |
| printf '::error::repository_dispatch Strix metadata does not match live PR %s#%s. supplied base=%s/%s head=%s; live state=%s base_repo=%s base=%s/%s head_repo=%s head=%s.\n' \ | |
| "$REPOSITORY" "$PR_NUMBER" "$SUPPLIED_BASE_REF" "$SUPPLIED_BASE_SHA" "$SUPPLIED_HEAD_SHA" \ | |
| "${live_state:-missing}" "${live_base_repository:-missing}" "${live_base_ref:-missing}" "${live_base_sha:-missing}" \ | |
| "${live_head_repository:-missing}" "${live_head_sha:-missing}" | |
| exit 1 | |
| fi | |
| trusted_workspace="$RUNNER_TEMP/trusted-workspace" | |
| mkdir -p "$trusted_workspace" | |
| git init -q "$trusted_workspace" | |
| gh auth setup-git | |
| git -C "$trusted_workspace" remote add origin "$GITHUB_SERVER_URL/$REPOSITORY.git" | |
| git -C "$trusted_workspace" fetch --no-tags --depth=1 origin "$live_base_sha" | |
| git -C "$trusted_workspace" checkout --detach --quiet "$live_base_sha" | |
| git -C "$trusted_workspace" cat-file -e "$live_base_sha^{commit}" | |
| echo "TRUSTED_WORKSPACE=$trusted_workspace" >> "$GITHUB_ENV" | |
| - name: Fetch pull request head for trusted scan | |
| if: github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '' | |
| env: | |
| GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} | |
| PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event.client_payload.pr_number }} | |
| PR_BASE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.event.client_payload.pr_base_sha }} | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$PR_NUMBER" ] || [ -z "$PR_HEAD_SHA" ]; then | |
| echo "::error::PR number and head SHA are required for trusted PR-scope Strix evidence." | |
| exit 1 | |
| fi | |
| gh auth setup-git | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if [ -n "$PR_BASE_SHA" ] && ! [[ "$PR_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR base SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if [ -n "$PR_BASE_SHA" ]; then | |
| git -C "$TRUSTED_WORKSPACE" fetch --no-tags --depth=1 origin "$PR_BASE_SHA" | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_BASE_SHA^{commit}" | |
| fi | |
| # Fetching the expected head SHA directly avoids false failures when | |
| # refs/pull/<n>/head has already advanced before this queued run starts. | |
| if git -C "$TRUSTED_WORKSPACE" fetch --no-tags --depth=1 origin "$PR_HEAD_SHA"; then | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA^{commit}" | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:.github/workflows/strix.yml" 2>/dev/null; then | |
| mkdir -p "$TRUSTED_WORKSPACE/.github/workflows" | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:.github/workflows/strix.yml" > "$TRUSTED_WORKSPACE/.github/workflows/strix.yml" | |
| echo "Materialized PR-head Strix workflow for self-test." | |
| fi | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" 2>/dev/null; then | |
| mkdir -p "$TRUSTED_WORKSPACE/scripts/ci" | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" > "$TRUSTED_WORKSPACE/scripts/ci/pr_review_merge_scheduler.py" | |
| fi | |
| git -C "$TRUSTED_WORKSPACE" update-ref "refs/remotes/pull/${PR_NUMBER}/head" "$PR_HEAD_SHA" | |
| exit 0 | |
| fi | |
| for pr_head_fetch_attempt in 1 2 3 4 5 6; do | |
| git -C "$TRUSTED_WORKSPACE" fetch --no-tags --prune origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head" | |
| fetched_head_sha="$(git -C "$TRUSTED_WORKSPACE" rev-parse "refs/remotes/pull/${PR_NUMBER}/head")" | |
| if [ "$fetched_head_sha" = "$PR_HEAD_SHA" ]; then | |
| git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA^{commit}" | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:.github/workflows/strix.yml" 2>/dev/null; then | |
| mkdir -p "$TRUSTED_WORKSPACE/.github/workflows" | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:.github/workflows/strix.yml" > "$TRUSTED_WORKSPACE/.github/workflows/strix.yml" | |
| echo "Materialized PR-head Strix workflow for self-test." | |
| fi | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" 2>/dev/null; then | |
| mkdir -p "$TRUSTED_WORKSPACE/scripts/ci" | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:scripts/ci/pr_review_merge_scheduler.py" > "$TRUSTED_WORKSPACE/scripts/ci/pr_review_merge_scheduler.py" | |
| fi | |
| exit 0 | |
| fi | |
| if [ "$pr_head_fetch_attempt" -lt 6 ]; then | |
| echo "Fetched PR head $fetched_head_sha, expected $PR_HEAD_SHA; retrying after propagation delay." >&2 | |
| sleep 10 | |
| fi | |
| done | |
| echo "::error::PR head ref did not resolve to expected commit $PR_HEAD_SHA after retries." >&2 | |
| exit 1 | |
| - name: Self-test Strix required workflow contract | |
| timeout-minutes: 2 | |
| working-directory: trusted-strix-source | |
| run: | | |
| set -euo pipefail | |
| printf 'Running bounded Strix required-workflow smoke test.\n' | |
| bash "$TRUSTED_STRIX_REQUIRED_SMOKE" | |
| - name: Materialize central Strix dependency lock from PR head | |
| if: >- | |
| github.event_name == 'pull_request_target' | |
| && github.repository == 'ContextualWisdomLab/.github' | |
| && github.event.pull_request.base.repo.full_name == 'ContextualWisdomLab/.github' | |
| && github.event.pull_request.head.repo.full_name == 'ContextualWisdomLab/.github' | |
| env: | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| if git -C "$TRUSTED_WORKSPACE" cat-file -e "$PR_HEAD_SHA:requirements-strix-ci-hashes.txt" 2>/dev/null; then | |
| git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:requirements-strix-ci-hashes.txt" > "$TRUSTED_STRIX_SOURCE/requirements-strix-ci-hashes.txt" | |
| printf 'Materialized central Strix dependency lock from same-repository PR head.\n' | |
| fi | |
| - name: Gate Strix secrets | |
| id: gate | |
| env: | |
| STRIX_MODEL: ${{ github.event.client_payload.strix_llm || 'gpt-5.6-luna' }} | |
| STRIX_OPENAI_API_KEY: ${{ secrets.STRIX_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} | |
| STRIX_OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} | |
| STRIX_VERTEX_CREDENTIALS: ${{ secrets.GCP_SA_KEY }} | |
| STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} | |
| run: | | |
| strix_model="$(printf '%s' "$STRIX_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| case "$strix_model" in | |
| openai/gpt-5-mini* | openai/gpt-5-nano* | \ | |
| openai/openai/gpt-5-mini* | openai/openai/gpt-5-nano* | \ | |
| github_models/openai/gpt-5-mini* | github_models/openai/gpt-5-nano*) | |
| echo '::error::STRIX_LLM must not select mini or nano GPT-5 variants for security evidence.' | |
| exit 1 | |
| ;; | |
| openai/gpt-5* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \ | |
| openai/openai/gpt-5* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]* | \ | |
| github_models/openai/gpt-5* | github_models/openai/gpt-[6-9]* | github_models/openai/gpt-[1-9][0-9]*) | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=github_models' >> "$GITHUB_OUTPUT" | |
| sanitized_github_models_token="$(printf '%s' "$STRIX_GITHUB_MODELS_TOKEN" | tr -d '\r\n')" | |
| trimmed_github_models_token="$(printf '%s' "$sanitized_github_models_token" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_github_models_token" ]; then | |
| echo '::error::STRIX_GITHUB_MODELS_TOKEN is required for GitHub Models Strix scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| gpt-5.[4-9]* | gpt-5.[1-9][0-9]* | gpt-[6-9]* | gpt-[1-9][0-9]* | \ | |
| openai-direct/gpt-5.[4-9]* | openai-direct/gpt-5.[1-9][0-9]* | openai-direct/gpt-[6-9]* | openai-direct/gpt-[1-9][0-9]*) | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=openai_direct' >> "$GITHUB_OUTPUT" | |
| sanitized_openai_key="$(printf '%s' "$STRIX_OPENAI_API_KEY" | tr -d '\r\n')" | |
| trimmed_openai_key="$(printf '%s' "$sanitized_openai_key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_openai_key" ]; then | |
| echo '::error::STRIX_OPENAI_API_KEY is required for Strix OpenAI Platform scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| openrouter/free | openrouter/openrouter/free) | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=openrouter' >> "$GITHUB_OUTPUT" | |
| sanitized_openrouter_key="$(printf '%s' "$STRIX_OPENROUTER_API_KEY" | tr -d '\r\n')" | |
| trimmed_openrouter_key="$(printf '%s' "$sanitized_openrouter_key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_openrouter_key" ]; then | |
| echo '::error::OPENROUTER_API_KEY is required for Strix OpenRouter scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| vertex_ai/gemini-3.1-pro-preview-customtools | vertex_ai/gemini-2.5-flash) | |
| echo 'enabled=true' >> "$GITHUB_OUTPUT" | |
| echo 'provider_mode=vertex_ai' >> "$GITHUB_OUTPUT" | |
| sanitized_vertex_credentials="$(printf '%s' "$STRIX_VERTEX_CREDENTIALS" | tr -d '\r')" | |
| trimmed_vertex_credentials="$(printf '%s' "$sanitized_vertex_credentials" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed_vertex_credentials" ]; then | |
| echo '::error::GCP_SA_KEY is required for Vertex AI Strix scans.' | |
| exit 1 | |
| fi | |
| ;; | |
| *) | |
| echo '::error::STRIX_LLM must select GitHub Models openai/gpt-5 or newer, direct OpenAI GPT-5.4 or newer, OpenRouter openrouter/free, or an approved organization Vertex AI model.' | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Set up Python | |
| if: steps.gate.outputs.enabled == 'true' | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 | |
| with: | |
| python-version: "3.13" | |
| - name: Install Strix | |
| if: steps.gate.outputs.enabled == 'true' | |
| working-directory: trusted-strix-source | |
| run: | | |
| set -euo pipefail | |
| # GitHub-hosted runners may inherit a collaborative umask (0002), | |
| # which makes pip-generated console scripts group-writable. Pin a | |
| # private install umask before creating the credential-bearing Strix | |
| # entry point; the runtime gate still rejects any later relaxation. | |
| umask 022 | |
| python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes -r requirements-strix-ci-hashes.txt | |
| strix_executable="$(command -v strix || true)" | |
| if [ -z "$strix_executable" ] || [[ "$strix_executable" != /* ]] \ | |
| || [ ! -f "$strix_executable" ] || [ -L "$strix_executable" ] \ | |
| || [ ! -x "$strix_executable" ]; then | |
| echo "::error::Pinned Strix installation did not produce a trusted absolute executable path." | |
| exit 1 | |
| fi | |
| case "$strix_executable" in | |
| "$GITHUB_WORKSPACE"/*|"$RUNNER_TEMP"/*) | |
| echo "::error::Refusing a Strix executable from a workspace or runner-temp path." | |
| exit 1 | |
| ;; | |
| esac | |
| strix_scripts_root="$(python3 -c 'import sysconfig; print(sysconfig.get_path("scripts"))')" | |
| if [ -z "$strix_scripts_root" ] || [[ "$strix_scripts_root" != /* ]] \ | |
| || [ ! -d "$strix_scripts_root" ] || [ -L "$strix_scripts_root" ]; then | |
| echo "::error::Pinned Strix installation did not produce a trusted absolute scripts root." | |
| exit 1 | |
| fi | |
| case "$strix_executable" in | |
| "$strix_scripts_root"/*) ;; | |
| *) | |
| echo "::error::Pinned Strix executable is outside the trusted scripts root." | |
| exit 1 | |
| ;; | |
| esac | |
| # pip and the hosted tool cache can preserve collaborative write bits | |
| # even after a private install umask. Normalize both the containing | |
| # scripts root and resolved console script before pinning their | |
| # identity; the runtime gate still fails closed on later relaxation. | |
| chmod go-w -- "$strix_scripts_root" "$strix_executable" | |
| strix_executable_sha256="$(python3 - "$strix_executable" <<'PY' | |
| import hashlib | |
| from pathlib import Path | |
| import sys | |
| print(hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()) | |
| PY | |
| )" | |
| { | |
| printf 'STRIX_EXECUTABLE_PATH=%s\n' "$strix_executable" | |
| printf 'STRIX_EXECUTABLE_ROOT=%s\n' "$strix_scripts_root" | |
| printf 'STRIX_EXECUTABLE_SHA256=%s\n' "$strix_executable_sha256" | |
| } >> "$GITHUB_ENV" | |
| - name: Mask LLM API key | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| LLM_API_KEY: ${{ steps.gate.outputs.provider_mode == 'github_models' && (secrets.STRIX_GITHUB_MODELS_TOKEN || github.token) || steps.gate.outputs.provider_mode == 'openai_direct' && (secrets.STRIX_OPENAI_API_KEY || secrets.OPENAI_API_KEY) || steps.gate.outputs.provider_mode == 'openrouter' && secrets.OPENROUTER_API_KEY || '' }} | |
| run: | | |
| # Sanitize CR/LF before masking to prevent broken ::add-mask:: | |
| # commands and potential workflow command injection. | |
| sanitized="$(printf '%s' "$LLM_API_KEY" | tr -d '\r\n')" | |
| if [ -n "$sanitized" ]; then | |
| echo "::add-mask::${sanitized}" | |
| trimmed="$(printf '%s' "$sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -n "$trimmed" ] && [ "$trimmed" != "$sanitized" ]; then | |
| echo "::add-mask::${trimmed}" | |
| fi | |
| fi | |
| - name: Prepare LLM API key input file | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| LLM_API_KEY_SECRET: ${{ steps.gate.outputs.provider_mode == 'github_models' && (secrets.STRIX_GITHUB_MODELS_TOKEN || github.token) || steps.gate.outputs.provider_mode == 'openai_direct' && (secrets.STRIX_OPENAI_API_KEY || secrets.OPENAI_API_KEY) || steps.gate.outputs.provider_mode == 'openrouter' && secrets.OPENROUTER_API_KEY || '' }} | |
| PROVIDER_MODE: ${{ steps.gate.outputs.provider_mode }} | |
| run: | | |
| sanitized="$(printf '%s' "$LLM_API_KEY_SECRET" | tr -d '\r\n')" | |
| trimmed="$(printf '%s' "$sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed" ] && [ "$PROVIDER_MODE" = "github_models" ]; then | |
| echo '::error::STRIX_GITHUB_MODELS_TOKEN is required for GitHub Models Strix scans.' | |
| exit 1 | |
| fi | |
| if [ -z "$trimmed" ] && [ "$PROVIDER_MODE" = "openai_direct" ]; then | |
| echo '::error::STRIX_OPENAI_API_KEY is required for Strix OpenAI Platform scans.' | |
| exit 1 | |
| fi | |
| if [ -z "$trimmed" ] && [ "$PROVIDER_MODE" = "openrouter" ]; then | |
| echo '::error::OPENROUTER_API_KEY is required for Strix OpenRouter scans.' | |
| exit 1 | |
| fi | |
| umask 077 | |
| llm_api_key_file="$RUNNER_TEMP/llm_api_key.txt" | |
| printf '%s' "$trimmed" > "$llm_api_key_file" | |
| echo "LLM_API_KEY_FILE=$llm_api_key_file" >> "$GITHUB_ENV" | |
| - name: Prepare OpenRouter API base | |
| if: steps.gate.outputs.provider_mode == 'openrouter' | |
| run: | | |
| umask 077 | |
| llm_api_base_file="$RUNNER_TEMP/llm_api_base.txt" | |
| printf '%s' 'https://openrouter.ai/api/v1' > "$llm_api_base_file" | |
| echo "LLM_API_BASE_FILE=$llm_api_base_file" >> "$GITHUB_ENV" | |
| - name: Prepare GitHub Models API base | |
| if: steps.gate.outputs.provider_mode == 'github_models' | |
| run: | | |
| umask 077 | |
| llm_api_base_file="$RUNNER_TEMP/llm_api_base.txt" | |
| printf '%s' 'https://models.github.ai/inference' > "$llm_api_base_file" | |
| echo "LLM_API_BASE_FILE=$llm_api_base_file" >> "$GITHUB_ENV" | |
| - name: Prepare GitHub Models fallback credentials | |
| if: steps.gate.outputs.provider_mode == 'openai_direct' || steps.gate.outputs.provider_mode == 'openrouter' | |
| env: | |
| GITHUB_MODELS_FALLBACK_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} | |
| run: | | |
| # Direct-OpenAI scans keep GitHub Models candidates as fallbacks, so | |
| # a provider quota outage degrades to a slower model instead of a | |
| # neutral skip with no security evidence. github_models/* fallback | |
| # models read this token and endpoint; the primary keeps its own key. | |
| umask 077 | |
| sanitized="$(printf '%s' "$GITHUB_MODELS_FALLBACK_TOKEN" | tr -d '\r\n')" | |
| trimmed="$(printf '%s' "$sanitized" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| if [ -z "$trimmed" ]; then | |
| echo '::notice::No GitHub Models token available; direct-OpenAI Strix scans run without GitHub Models fallbacks.' | |
| exit 0 | |
| fi | |
| github_models_key_file="$RUNNER_TEMP/github_models_fallback_key.txt" | |
| printf '%s' "$sanitized" > "$github_models_key_file" | |
| echo "STRIX_GITHUB_MODELS_KEY_FILE=$github_models_key_file" >> "$GITHUB_ENV" | |
| github_models_api_base_file="$RUNNER_TEMP/github_models_api_base.txt" | |
| printf '%s' 'https://models.github.ai/inference' > "$github_models_api_base_file" | |
| echo "STRIX_GITHUB_MODELS_API_BASE_FILE=$github_models_api_base_file" >> "$GITHUB_ENV" | |
| - name: Prepare Vertex AI credentials | |
| if: steps.gate.outputs.provider_mode == 'vertex_ai' | |
| env: | |
| GCP_SA_KEY_JSON: ${{ secrets.GCP_SA_KEY }} | |
| run: | | |
| umask 077 | |
| credentials_file="$RUNNER_TEMP/gcp-sa-key.json" | |
| printf '%s' "$GCP_SA_KEY_JSON" > "$credentials_file" | |
| python3 - "$credentials_file" >> "$GITHUB_ENV" <<'PY' | |
| import json | |
| import pathlib | |
| import sys | |
| credentials_path = pathlib.Path(sys.argv[1]) | |
| def reject_duplicate_json_keys(pairs): | |
| parsed = {} | |
| for key, value in pairs: | |
| if key in parsed: | |
| raise ValueError("duplicate credential key") | |
| parsed[key] = value | |
| return parsed | |
| try: | |
| credentials_text = credentials_path.read_text(encoding="utf-8") | |
| credentials = json.loads( | |
| credentials_text, | |
| object_pairs_hook=reject_duplicate_json_keys, | |
| ) | |
| except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError): | |
| raise SystemExit( | |
| "GCP_SA_KEY must be valid service account JSON for Vertex AI Strix scans." | |
| ) | |
| if not isinstance(credentials, dict): | |
| raise SystemExit( | |
| "GCP_SA_KEY must be a JSON object for Vertex AI Strix scans." | |
| ) | |
| project_id = str(credentials.get("project_id", "")).strip() | |
| if not project_id: | |
| raise SystemExit("GCP_SA_KEY must include project_id for Vertex AI Strix scans.") | |
| print(f"GOOGLE_APPLICATION_CREDENTIALS={credentials_path}") | |
| print(f"CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE={credentials_path}") | |
| print(f"VERTEXAI_PROJECT={project_id}") | |
| print(f"GOOGLE_CLOUD_PROJECT={project_id}") | |
| print(f"GCP_PROJECT={project_id}") | |
| print(f"GCLOUD_PROJECT={project_id}") | |
| print(f"CLOUDSDK_CORE_PROJECT={project_id}") | |
| print(f"CLOUDSDK_PROJECT={project_id}") | |
| PY | |
| - name: Prepare Strix model input file | |
| if: steps.gate.outputs.enabled == 'true' | |
| env: | |
| STRIX_MODEL: ${{ github.event.client_payload.strix_llm || 'gpt-5.6-luna' }} | |
| run: | | |
| umask 077 | |
| strix_llm_file="$RUNNER_TEMP/strix_llm.txt" | |
| strix_model="$(printf '%s' "$STRIX_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" | |
| case "$strix_model" in | |
| openai/gpt-5-mini* | openai/gpt-5-nano* | \ | |
| openai/openai/gpt-5-mini* | openai/openai/gpt-5-nano* | \ | |
| github_models/openai/gpt-5-mini* | github_models/openai/gpt-5-nano*) | |
| echo '::error::STRIX_LLM must not select mini or nano GPT-5 variants for security evidence.' | |
| exit 1 | |
| ;; | |
| openai/gpt-5* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \ | |
| openai/openai/gpt-5* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]* | \ | |
| github_models/openai/gpt-5* | github_models/openai/gpt-[6-9]* | github_models/openai/gpt-[1-9][0-9]*) | |
| printf '%s' "${strix_model#github_models/}" > "$strix_llm_file" | |
| ;; | |
| openai/*) | |
| printf '%s' "$strix_model" > "$strix_llm_file" | |
| ;; | |
| openai-direct/gpt-*) | |
| printf 'openai_direct/%s' "${strix_model#openai-direct/}" > "$strix_llm_file" | |
| ;; | |
| gpt-*) | |
| printf 'openai_direct/%s' "$strix_model" > "$strix_llm_file" | |
| ;; | |
| openrouter/free | openrouter/openrouter/free) | |
| printf '%s' 'openrouter/free' > "$strix_llm_file" | |
| ;; | |
| vertex_ai/gemini-3.1-pro-preview-customtools | vertex_ai/gemini-2.5-flash) | |
| printf '%s' "$strix_model" > "$strix_llm_file" | |
| ;; | |
| *) | |
| echo '::error::STRIX_LLM must select GitHub Models openai/gpt-5 or newer, direct OpenAI GPT-5.4 or newer, OpenRouter openrouter/free, or an approved organization Vertex AI model.' | |
| exit 1 | |
| ;; | |
| esac | |
| echo "STRIX_LLM_FILE=$strix_llm_file" >> "$GITHUB_ENV" | |
| - name: Run Strix (quick) | |
| if: steps.gate.outputs.enabled == 'true' | |
| timeout-minutes: 100 | |
| # Security invariant for pull_request_target: execute only from the | |
| # trusted base checkout. The gate copies PR-head blobs into an isolated | |
| # temporary scope with execute bits stripped, then scans that scope as | |
| # data. PR evidence uses the __PR_SCOPE__ sentinel so the scanner target | |
| # cannot accidentally remain the trusted base checkout. | |
| working-directory: ${{ runner.temp }}/trusted-workspace | |
| env: | |
| STRIX_LLM_FILE: ${{ env.STRIX_LLM_FILE }} | |
| STRIX_REPO_ROOT: ${{ runner.temp }}/trusted-workspace | |
| LLM_API_BASE_FILE: ${{ env.LLM_API_BASE_FILE }} | |
| STRIX_LLM_DEFAULT_PROVIDER: ${{ steps.gate.outputs.provider_mode == 'vertex_ai' && 'vertex_ai' || 'openai' }} | |
| LLM_API_KEY_FILE: ${{ env.LLM_API_KEY_FILE }} | |
| GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }} | |
| CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: ${{ env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE }} | |
| VERTEXAI_PROJECT: ${{ env.VERTEXAI_PROJECT }} | |
| GOOGLE_CLOUD_PROJECT: ${{ env.GOOGLE_CLOUD_PROJECT }} | |
| GCP_PROJECT: ${{ env.GCP_PROJECT }} | |
| GCLOUD_PROJECT: ${{ env.GCLOUD_PROJECT }} | |
| CLOUDSDK_CORE_PROJECT: ${{ env.CLOUDSDK_CORE_PROJECT }} | |
| CLOUDSDK_PROJECT: ${{ env.CLOUDSDK_PROJECT }} | |
| VERTEXAI_LOCATION: ${{ secrets.VERTEX_LOCATION || 'us-central1' }} | |
| VERTEX_LOCATION: ${{ secrets.VERTEX_LOCATION || 'us-central1' }} | |
| STRIX_TARGET_PATH: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && '__PR_SCOPE__' || './' }} | |
| STRIX_SOURCE_DIRS: ". backend frontend" | |
| STRIX_REASONING_EFFORT: high | |
| STRIX_LLM_MAX_RETRIES: 1 | |
| STRIX_TRANSIENT_RETRY_PER_MODEL: 2 | |
| STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS: 60 | |
| STRIX_FALLBACK_MODELS: ${{ steps.gate.outputs.provider_mode == 'github_models' && 'github_models/openai/o3 github_models/openai/gpt-5-chat' || steps.gate.outputs.provider_mode == 'openai_direct' && 'github_models/openai/o3 github_models/openai/gpt-5-chat' || steps.gate.outputs.provider_mode == 'openrouter' && 'github_models/openai/o3 github_models/openai/gpt-5-chat' || '' }} | |
| STRIX_GITHUB_MODELS_API_BASE_FILE: ${{ env.STRIX_GITHUB_MODELS_API_BASE_FILE }} | |
| STRIX_GITHUB_MODELS_KEY_FILE: ${{ env.STRIX_GITHUB_MODELS_KEY_FILE }} | |
| STRIX_FAIL_ON_PROVIDER_SIGNAL: "1" | |
| STRIX_VERTEX_FALLBACK_MODELS: "" | |
| NPM_CONFIG_IGNORE_SCRIPTS: "true" | |
| PNPM_CONFIG_IGNORE_SCRIPTS: "true" | |
| YARN_ENABLE_SCRIPTS: "false" | |
| BUN_CONFIG_IGNORE_SCRIPTS: "true" | |
| STRIX_FAIL_ON_MIN_SEVERITY: MEDIUM | |
| STRIX_DISABLE_PR_SCOPING: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && '0' || '1' }} | |
| # A repository_dispatch executes in this central repository, so its | |
| # github.token cannot read the target repository's PR. Reuse the | |
| # target-app token that already validated and fetched that exact PR; | |
| # preserve the target-repository token for pull_request_target runs. | |
| GH_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && (steps.target_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token) || github.event_name == 'pull_request_target' && github.token || '' }} | |
| PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event.client_payload.pr_number }} | |
| PR_BASE_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.event.client_payload.pr_base_sha }} | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| IS_PR_EVIDENCE_RUN: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && 'true' || 'false' }} | |
| run: | | |
| budget_suffix="TIME""OUT" | |
| process_budget_seconds="5400" | |
| export "LLM_${budget_suffix}=900" | |
| export "STRIX_MEMORY_COMPRESSOR_${budget_suffix}=300" | |
| export "STRIX_PROCESS_${budget_suffix}_SECONDS=$process_budget_seconds" | |
| export "STRIX_TOTAL_${budget_suffix}_SECONDS=5700" | |
| # Capture the gate exit code plus its console output. The gate returns | |
| # exit 1 both for genuine blocking vulnerabilities AND for | |
| # LLM-backend-unavailable outcomes (GitHub Models "Too many requests" | |
| # rate limits, OpenAI quota starvation, 413 tokens_limit_reached | |
| # token-cap, connection/warm-up failures) that could not complete a scan. A backend outage is CI | |
| # infrastructure noise, not a security finding, so it must not fail | |
| # the required check and block merges. | |
| strix_run_log="$RUNNER_TEMP/strix_gate_console.log" | |
| strix_rc=0 | |
| set +e | |
| bash "$TRUSTED_STRIX_GATE" 2>&1 | tee "$strix_run_log" | |
| strix_rc="${PIPESTATUS[0]}" | |
| set -e | |
| if [ "$strix_rc" -eq 0 ]; then | |
| exit 0 | |
| fi | |
| # Preserve configuration failures (exit 2) and any unexpected exit | |
| # code as hard failures — only the scan-failure code (1) can be an | |
| # infrastructure/backend-unavailability outcome. | |
| if [ "$strix_rc" -ne 1 ]; then | |
| exit "$strix_rc" | |
| fi | |
| # Recognized signals that the LLM backend was unavailable / starved. | |
| backend_unavailable_signal='RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure' | |
| # Any evidence that a vulnerability was actually reported. Its presence | |
| # forces a hard failure so real findings are NEVER downgraded. Keep the | |
| # severity branch anchored away from identifiers so environment lines | |
| # such as STRIX_FAIL_ON_MIN_SEVERITY do not look like findings. | |
| reported_vulnerability_signal='Vulnerabilities[[:space:]]+[1-9]|(^|[^A-Za-z0-9_])severity[[:space:]]*:' | |
| # Neutral skip only when ALL hold: a backend-unavailability signal is | |
| # present and no vulnerability was reported anywhere. This preserves | |
| # real security gating while keeping uncontrollable provider outages | |
| # from blocking current-head merge progress. | |
| if grep -Eiq "$backend_unavailable_signal" "$strix_run_log" \ | |
| && ! grep -Eiq "$reported_vulnerability_signal" "$strix_run_log"; then | |
| echo "::warning title=Strix backend unavailable::Strix could not complete because its LLM backend was unavailable (rate limit / token cap / connection or warm-up failure) before producing a vulnerability report. Treating as a neutral skip so an infrastructure outage does not block merges; genuine findings still fail the check. See the strix-reports artifact and the run log." | |
| exit 0 | |
| fi | |
| echo "Strix reported security findings or failed for a non-backend reason; failing the required check (gate exit ${strix_rc})." >&2 | |
| exit "$strix_rc" | |
| - name: Collect Strix reports for artifact upload | |
| if: ${{ always() && steps.gate.outputs.enabled == 'true' }} | |
| env: | |
| PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$GITHUB_WORKSPACE/strix_runs" | |
| copied_reports=0 | |
| for candidate_dir in "$TRUSTED_WORKSPACE/strix_runs" "$RUNNER_TEMP/strix_runs"; do | |
| if [ -d "$candidate_dir" ] && [ -n "$(find "$candidate_dir" -mindepth 1 -print -quit)" ]; then | |
| cp -R "$candidate_dir"/. "$GITHUB_WORKSPACE/strix_runs"/ | |
| copied_reports=1 | |
| fi | |
| done | |
| if [ -f "$RUNNER_TEMP/strix_gate_console.log" ]; then | |
| cp "$RUNNER_TEMP/strix_gate_console.log" "$GITHUB_WORKSPACE/strix_runs/gate-console.log" | |
| copied_reports=1 | |
| fi | |
| if [ -n "$(find "$GITHUB_WORKSPACE/strix_runs" -mindepth 1 -print -quit)" ]; then | |
| copied_reports=1 | |
| fi | |
| if [ "$copied_reports" -eq 0 ]; then | |
| summary_head_sha="${PR_HEAD_SHA:-$GITHUB_SHA}" | |
| { | |
| echo "Strix scan completed without structured report files." | |
| echo "run_id=$GITHUB_RUN_ID" | |
| echo "head_sha=$summary_head_sha" | |
| } > "$GITHUB_WORKSPACE/strix_runs/scan-summary.txt" | |
| fi | |
| - name: Upload Strix reports artifact | |
| if: ${{ always() && steps.gate.outputs.enabled == 'true' }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: strix-reports | |
| path: strix_runs/ | |
| if-no-files-found: error | |
| retention-days: 5 | |
| - name: Publish same-head manual Strix status | |
| if: ${{ always() && !cancelled() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} | |
| env: | |
| TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} | |
| GITHUB_STATUS_TOKEN: ${{ (github.event.client_payload.target_repository == '' || github.event.client_payload.target_repository == github.repository) && github.token || '' }} | |
| PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} | |
| OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} | |
| TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.repository }} | |
| PR_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} | |
| STRIX_RESULT: ${{ job.status }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| case "$STRIX_RESULT" in | |
| success) | |
| state="success" | |
| description="Default-branch repository_dispatch Strix evidence passed" | |
| ;; | |
| failure|cancelled|skipped) | |
| state="failure" | |
| description="Default-branch repository_dispatch Strix evidence failed" | |
| ;; | |
| *) | |
| state="error" | |
| description="Default-branch repository_dispatch Strix evidence inconclusive" | |
| ;; | |
| esac | |
| post_strix_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api -X POST "repos/${TARGET_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ | |
| -f state="$state" \ | |
| -f context="strix" \ | |
| -f description="$description" \ | |
| -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ | |
| >"$status_response" 2>"$status_error"; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Published manual Strix status to ${TARGET_REPOSITORY}@${PR_HEAD_SHA} using ${token_label}." | |
| return 0 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed: ${error_summary}" | |
| else | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed." | |
| fi | |
| return 1 | |
| } | |
| if post_strix_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "github-token" "$GITHUB_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| echo "::warning::Could not publish manual Strix status from scan job; keeping scan evidence result authoritative in the workflow run." | |
| publish-manual-pr-evidence-status: | |
| name: publish-manual-pr-evidence-status | |
| needs: strix | |
| if: ${{ always() && !cancelled() && github.event_name == 'repository_dispatch' && github.event.client_payload.pr_head_sha != '' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| id-token: write | |
| steps: | |
| - name: Exchange OpenCode app token for target repository status | |
| id: target_app_token | |
| env: | |
| OIDC_AUDIENCE: opencode-github-action | |
| OPENCODE_API_BASE_URL: https://api.opencode.ai | |
| run: | | |
| set -euo pipefail | |
| mark_unavailable() { | |
| echo "available=false" >>"$GITHUB_OUTPUT" | |
| } | |
| if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC request environment is missing." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" | |
| separator="&" | |
| case "$request_url" in | |
| *\?*) ;; | |
| *) separator="?" ;; | |
| esac | |
| if ! oidc_response="$( | |
| curl -fsS \ | |
| -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ | |
| "${request_url}${separator}audience=${OIDC_AUDIENCE}" | |
| )"; then | |
| echo "OpenCode app token exchange unavailable: OIDC token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" | |
| if [ -z "$oidc_token" ]; then | |
| echo "OpenCode app token exchange unavailable: OIDC token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| if ! token_response="$( | |
| curl -fsS \ | |
| -X POST \ | |
| -H "Authorization: Bearer ${oidc_token}" \ | |
| "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | |
| )"; then | |
| echo "OpenCode app token exchange unavailable: app token request did not complete." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| app_token="$(jq -r '.token // empty' <<<"$token_response")" | |
| if [ -z "$app_token" ]; then | |
| echo "OpenCode app token exchange unavailable: app token response was empty." | |
| mark_unavailable | |
| exit 0 | |
| fi | |
| echo "::add-mask::$app_token" | |
| { | |
| echo "available=true" | |
| echo "token=$app_token" | |
| } >>"$GITHUB_OUTPUT" | |
| - name: Publish same-head manual Strix status | |
| env: | |
| TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} | |
| GITHUB_STATUS_READ_TOKEN: ${{ github.token }} | |
| PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} | |
| OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} | |
| TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.repository }} | |
| PR_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha }} | |
| STRIX_RESULT: ${{ needs.strix.result }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "::error::PR head SHA must be a 40-character git SHA." | |
| exit 1 | |
| fi | |
| case "$STRIX_RESULT" in | |
| success) | |
| state="success" | |
| description="Default-branch repository_dispatch Strix evidence passed" | |
| ;; | |
| failure|cancelled|skipped) | |
| state="failure" | |
| description="Default-branch repository_dispatch Strix evidence failed" | |
| ;; | |
| *) | |
| state="error" | |
| description="Default-branch repository_dispatch Strix evidence inconclusive" | |
| ;; | |
| esac | |
| post_strix_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api -X POST "repos/${TARGET_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ | |
| -f state="$state" \ | |
| -f context="strix" \ | |
| -f description="$description" \ | |
| -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ | |
| >"$status_response" 2>"$status_error"; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Published manual Strix status to ${TARGET_REPOSITORY}@${PR_HEAD_SHA} using ${token_label}." | |
| return 0 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed: ${error_summary}" | |
| else | |
| echo "::notice::Manual Strix status publish using ${token_label} did not succeed." | |
| fi | |
| return 1 | |
| } | |
| existing_current_run_success_status() { | |
| if [ "$state" != "success" ]; then | |
| return 1 | |
| fi | |
| target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" | |
| check_existing_status() { | |
| token_label="$1" | |
| token="$2" | |
| if [ -z "$token" ]; then | |
| return 1 | |
| fi | |
| status_response="$(mktemp)" | |
| status_error="$(mktemp)" | |
| if GH_TOKEN="$token" gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses" \ | |
| >"$status_response" 2>"$status_error"; then | |
| if jq -e --arg target_url "$target_url" \ | |
| 'any(.[]; .context == "strix" and .state == "success" and ((.target_url // "") == $target_url))' \ | |
| "$status_response" >/dev/null; then | |
| rm -f "$status_response" "$status_error" | |
| echo "Existing current-run Strix success status is already present on ${TARGET_REPOSITORY}@${PR_HEAD_SHA}; follow-up status publication is complete." | |
| return 0 | |
| fi | |
| rm -f "$status_response" "$status_error" | |
| echo "::notice::No current-run Strix success status was visible using ${token_label}." | |
| return 1 | |
| fi | |
| error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" | |
| rm -f "$status_response" "$status_error" | |
| if [ -n "$error_summary" ]; then | |
| echo "::notice::Could not inspect existing Strix status using ${token_label}: ${error_summary}" | |
| else | |
| echo "::notice::Could not inspect existing Strix status using ${token_label}." | |
| fi | |
| return 1 | |
| } | |
| if check_existing_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then | |
| return 0 | |
| fi | |
| if check_existing_status "github-token" "$GITHUB_STATUS_READ_TOKEN"; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| if post_strix_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if post_strix_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then | |
| exit 0 | |
| fi | |
| if existing_current_run_success_status; then | |
| exit 0 | |
| fi | |
| # A successful scan remains authoritative evidence even when an | |
| # external target repository does not grant any configured token | |
| # permission to create commit statuses. Keep every credential- | |
| # specific failure visible above, but do not turn a clean security | |
| # scan into a failed workflow solely because of target settings. | |
| if [ "$STRIX_RESULT" = "success" ]; then | |
| echo "::warning title=Manual Strix status unavailable::Strix scan succeeded, but no configured credential could publish or read the target commit status. Preserving the successful scan result; the target repository's branch protection remains authoritative. See the preceding token-specific notices." | |
| exit 0 | |
| fi | |
| echo "::error::Could not publish manual Strix status from follow-up job after all configured credentials failed after a non-successful scan; the target PR head is missing required Strix status evidence. See the preceding notices for token-specific reasons." | |
| exit 1 |