Ctrl K
ComunidadGuías…

action.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
name: 'agent-bom Scan'
description: 'AI supply-chain BOM: every agent, MCP server and AI package, and what each one can reach. CI gate, SARIF, SBOM.'
author: 'agent-bom'

branding:
  icon: 'shield'
  color: 'green'

inputs:
  scan-type:
    description: 'Scan mode. Default `scan` scans the checkout (packages, MCP configs, IaC, workflows, skills, secrets). Focused modes: image, fs, iac, sbom, secrets, code, and skills.'
    required: false
    default: 'scan'
  scan-ref:
    description: 'Scan scope or target. For scan: the directory to scan (default: the checkout, "."). For focused modes: image reference, filesystem path, SBOM file path, or IaC paths (comma-separated).'
    required: false
    default: ''
  severity-threshold:
    description: 'Fail if vulnerabilities at or above this severity (critical, high, medium, low). Default high. Empty means report-only: severity findings and a partial scan do not fail the step (a partial scan warns); a known-malicious package, a failed scan, and an explicit policy or fail-on-kev gate still do. The mcp-gate falls back to high.'
    required: false
    default: 'high'
  warn-on-severity:
    description: 'Warn (non-blocking) if vulnerabilities at or above this severity. Use with severity-threshold for two-tier gates.'
    required: false
    default: ''
  policy:
    description: 'Path to policy file (JSON/YAML) for policy-as-code gates.'
    required: false
    default: ''
  enrich:
    description: 'Enable NVD CVSS, EPSS, and CISA KEV enrichment.'
    required: false
    default: 'false'
  format:
    description: 'Output format. Core: sarif (default), json, html, cyclonedx, spdx. Plain/no-color: plain (alias: text). Other: console, mermaid, svg, graph, graph-html, badge, prometheus.'
    required: false
    default: 'sarif'
  output:
    description: 'Output file path for scan results. Default: agent-bom-results.sarif (for SARIF format).'
    required: false
    default: ''
  upload-sarif:
    description: 'Upload SARIF results to GitHub Security tab.'
    required: false
    default: 'false'
  auto-update-db:
    description: 'Refresh the local vulnerability database when it misses the daily freshness target. Disable for air-gapped or fully cached runs.'
    required: false
    default: 'true'
  vuln-db-scope:
    description: 'OSV data the refresh keeps locally. application (default) reads the application-ecosystem archives (about 0.3 GB, minutes on a first run); OS and other packages are answered by live OSV, and a scan where neither could answer stays incomplete rather than clean. full reads the all-ecosystems archive (about 2.9 GB, 15 to 70 minutes on a first run); use it when OS packages must be answered from local data, including in the offline sbom-artifact step.'
    required: false
    default: 'application'
  nvd-api-key:
    description: 'Optional NVD API key for the vulnerability database refresh (wider NVD window, higher rate limit). Pass it from a repository secret (secrets.NVD_API_KEY in the calling workflow); it reaches the scan only as the NVD_API_KEY environment variable and is never printed. Without it the refresh still reads NVD, keyless.'
    required: false
    default: ''
  fail-on-kev:
    description: 'Fail if any CISA Known Exploited Vulnerability is found.'
    required: false
    default: 'false'
  ai-enrich:
    description: 'Enable LLM-powered risk analysis (requires Ollama or API key).'
    required: false
    default: 'false'
  ai-model:
    description: 'LLM model for AI enrichment (e.g. ollama/llama3.2, openai/gpt-4o-mini).'
    required: false
    default: ''
  python-version:
    description: 'Python version to use.'
    required: false
    default: '3.11'
  agent-bom-version:
    description: 'agent-bom package version to install (for example 0.106.1). Empty = the release this action ref ships; installation fails if that version cannot be installed. Use latest to explicitly install the newest PyPI release.'
    required: false
    default: ''
  install-from-source:
    description: 'Install agent-bom from the workspace (pip install .) instead of PyPI. Works only when the workspace is a checkout of this repository, so it is for testing unreleased agent-bom code in this repository''s own CI. In any other repository it installs the wrong project or fails.'
    required: false
    default: 'false'
  image:
    description: 'Docker image to scan (e.g. nginx:1.25).'
    required: false
    default: ''
  config-dir:
    description: 'Path to MCP config directory to scan.'
    required: false
    default: ''
  sbom:
    description: 'Path to existing SBOM file (CycloneDX or SPDX JSON) to ingest.'
    required: false
    default: ''
  remediate:
    description: 'Generate remediation plan at this path (e.g. remediation.md).'
    required: false
    default: ''
  badge:
    description: 'Generate shields.io badge JSON at this path (e.g. agent-bom-badge.json).'
    required: false
    default: ''
  pr-comment:
    description: 'Pull request comment with the top findings: auto (default) posts on pull_request events when the token has pull-requests: write and skips with a notice otherwise; true fails the step if the comment cannot be posted; false never posts.'
    required: false
    default: 'auto'
  annotations:
    description: 'Emit GitHub Actions line-level annotations for SARIF and skills findings, highest severity first (critical/high = error, medium = warning, low = notice).'
    required: false
    default: 'true'
  max-annotations:
    description: 'Maximum annotations to emit; the remainder is reported as "N more finding(s)" in one notice. -1 emits all.'
    required: false
    default: '50'
  ignore-file:
    description: 'Path to ignore/allowlist file (default: .agent-bom-ignore.yaml). Suppress known false positives by CVE ID, package, or finding type.'
    required: false
    default: ''
  iac:
    description: 'Path(s) to scan for IaC misconfigurations (Dockerfile, Kubernetes, Terraform, CloudFormation). Comma-separated.'
    required: false
    default: ''
  ai-inventory:
    description: 'Path(s) to scan for AI component inventory (SDK imports, model references, deprecated models). Comma-separated.'
    required: false
    default: ''
  os-packages:
    description: 'Enable OS-level package scanning (dpkg/rpm/apk).'
    required: false
    default: 'false'
  baseline:
    description: 'Path to a baseline agent-bom JSON report (format: json) from the base branch. Runs scan --baseline <file> --delta, so the report and every gate cover only findings that are new versus the baseline. scan-type=scan only.'
    required: false
    default: ''
  skill-only:
    description: 'Scan ONLY skill/instruction files (CLAUDE.md, .cursorrules, AGENTS.md); skip agent/package/CVE scanning.'
    required: false
    default: 'false'
  fail-on-verdict:
    description: 'For scan-type=skills, fail if any scanned file reaches this trust verdict or worse (suspicious or malicious).'
    required: false
    default: ''
  warn-on-verdict:
    description: 'For scan-type=skills, warn without failing if any scanned file reaches this content verdict or worse (suspicious or malicious).'
    required: false
    default: ''
  fail-on-review-verdict:
    description: 'For scan-type=skills, fail if any scanned file reaches this handling verdict or worse (review, high_risk, blocked).'
    required: false
    default: ''
  warn-on-review-verdict:
    description: 'For scan-type=skills, warn without failing if any scanned file reaches this handling verdict or worse (review, high_risk, blocked).'
    required: false
    default: ''
  skills-ci:
    description: 'For scan-type=skills, enable the CI gate: exit non-zero on suspicious or malicious skills without authoring a policy file (equivalent to fail-on-verdict=suspicious). An explicit fail-on-verdict input takes precedence. Ignored for other scan types.'
    required: false
    default: 'false'
  gpu-scan:
    description: 'Enable GPU infrastructure scanning (NVIDIA NIM/CUDA containers, DCGM).'
    required: false
    default: 'false'
  exclude-unfixable:
    description: 'Exclude vulnerabilities without available fixes from SARIF output'
    required: false
    default: 'false'
  mcp-gate:
    description: 'On pull_request events, fail when the PR adds or redefines an MCP server (in .mcp.json or mcp.json) that is blocked by the launcher check, ships a known-malicious package, or reaches a vulnerability at or above severity-threshold.'
    required: false
    default: 'false'
  sbom-artifact:
    description: 'Also write an SBOM / AI-BOM of the scanned scope and upload it as a workflow artifact: cyclonedx (agent-bom-sbom.cdx.json), spdx (agent-bom-sbom.spdx.json), or none. Reuses the local vulnerability data offline. Supported for scan-type scan, image, fs and sbom.'
    required: false
    default: 'none'

outputs:
  sarif-file:
    description: 'Path to generated SARIF file (if format=sarif).'
    value: ${{ steps.scan.outputs.sarif_file }}
  sbom-file:
    description: 'Path to the SBOM / AI-BOM artifact when sbom-artifact is cyclonedx or spdx.'
    value: ${{ steps.scan.outputs.sbom_file }}
  exit-code:
    description: 'Scan process exit code.'
    value: ${{ steps.scan.outputs.exit_code }}
  scan-status:
    description: 'High-level scan status: clean, violations, partial (report-only step with incomplete coverage), usage_error, stale_data, or error.'
    value: ${{ steps.scan.outputs.scan_status }}
  vulnerability-count:
    description: 'Number of vulnerabilities found, or skill findings when scan-type=skills. Possibly-affected distro results (SARIF verdict possibly_affected) are not counted.'
    value: ${{ steps.scan.outputs.vuln_count }}
  badge-file:
    description: 'Path to generated badge JSON (if badge input is set).'
    value: ${{ steps.scan.outputs.badge_file }}
  graph-export-path:
    description: 'Path to generated graph export when format is mermaid, svg, graph, or graph-html.'
    value: ${{ steps.scan.outputs.graph_export_path }}
  exposure-paths-count:
    description: 'Exposure paths the report counted (JSON exposure_paths.path_count or SARIF run property exposure_path_count).'
    value: ${{ steps.scan.outputs.exposure_paths_count }}
  blast-radius-critical-count:
    description: 'Critical blast-radius rows in the JSON report, or SARIF results with security-severity >= 9.0 (possibly-affected rows excluded).'
    value: ${{ steps.scan.outputs.blast_radius_critical_count }}
  posture-grade:
    description: 'Posture grade read from the report itself (JSON posture_grade or SARIF run property posture_grade): A-F, N/A when nothing was gradable, or unknown when the artifact carries no grade. Never recomputed from finding counts.'
    value: ${{ steps.scan.outputs.posture_grade }}

runs:
  using: 'composite'
  steps:
    - name: Set up Python
      uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
      with:
        python-version: ${{ inputs.python-version }}
        # No pip cache: setup-python's pip cache needs Python manifests in the
        # caller's workspace and fails on repositories without them.

    - name: Compute vulnerability database cache bucket
      id: vulndb-cache-key
      shell: bash
      run: echo "day=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"

    # One cache entry per UTC day: the first run of a day restores the newest
    # earlier copy through restore-keys, refreshes it when stale, and saves it
    # under the new key, so later runs that day start warm.
    - name: Cache agent-bom vulnerability database
      uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
      with:
        path: ~/.agent-bom/db/vulns.db
        key: agent-bom-vulndb-v2-${{ inputs.vuln-db-scope }}-${{ runner.os }}-${{ steps.vulndb-cache-key.outputs.day }}
        restore-keys: |
          agent-bom-vulndb-v2-${{ inputs.vuln-db-scope }}-${{ runner.os }}-

    - name: Install agent-bom
      shell: bash
      env:
        AGENT_BOM_VERSION: ${{ inputs.agent-bom-version }}
        ACTION_PATH: ${{ github.action_path }}
        AI_ENRICH: ${{ inputs.ai-enrich }}
        INSTALL_FROM_SOURCE: ${{ inputs.install-from-source }}
        HTTP_PROXY: ${{ env.HTTP_PROXY }}
        HTTPS_PROXY: ${{ env.HTTPS_PROXY }}
        NO_PROXY: ${{ env.NO_PROXY }}
        SSL_CERT_FILE: ${{ env.SSL_CERT_FILE }}
        REQUESTS_CA_BUNDLE: ${{ env.REQUESTS_CA_BUNDLE }}
        CURL_CA_BUNDLE: ${{ env.CURL_CA_BUNDLE }}
        PIP_CERT: ${{ env.PIP_CERT }}
      run: |
        if [ "$INSTALL_FROM_SOURCE" = "true" ]; then
          # Install from local source (pyproject.toml) — used by CI dogfood to test unreleased code
          if [ "$AI_ENRICH" = "true" ]; then
            pip install ".[ai-enrich]" packaging
          else
            pip install "." packaging
          fi
        else
          EXTRAS=""
          if [ "$AI_ENRICH" = "true" ]; then
            EXTRAS="[ai-enrich]"
          fi
          if [ "$AGENT_BOM_VERSION" = "latest" ]; then
            pip install "agent-bom${EXTRAS}" packaging
          elif [ -n "$AGENT_BOM_VERSION" ]; then
            pip install "agent-bom${EXTRAS}==$AGENT_BOM_VERSION" packaging
          else
            # Default to the release this action ref ships: a tag checkout's
            # pyproject.toml carries that tag's version (release version-guard).
            REF_VERSION=""
            if [ -f "$ACTION_PATH/pyproject.toml" ]; then
              REF_VERSION=$(sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$ACTION_PATH/pyproject.toml" | head -n 1)
            fi
            if [ -z "$REF_VERSION" ]; then
              echo "::error::Could not read the agent-bom version from this action ref. Set agent-bom-version or install-from-source explicitly."
              exit 1
            fi
            # Preserve the selected version on all installation failures,
            # including index, TLS and dependency-resolution errors.
            pip install "agent-bom${EXTRAS}==$REF_VERSION" packaging
          fi
        fi

    - name: Run agent-bom
      id: scan
      shell: bash
      env:
        HTTP_PROXY: ${{ env.HTTP_PROXY }}
        HTTPS_PROXY: ${{ env.HTTPS_PROXY }}
        NO_PROXY: ${{ env.NO_PROXY }}
        SSL_CERT_FILE: ${{ env.SSL_CERT_FILE }}
        REQUESTS_CA_BUNDLE: ${{ env.REQUESTS_CA_BUNDLE }}
        CURL_CA_BUNDLE: ${{ env.CURL_CA_BUNDLE }}
        PIP_CERT: ${{ env.PIP_CERT }}
        NVD_API_KEY: ${{ inputs.nvd-api-key || env.NVD_API_KEY }}
        PYTHONUTF8: '1'
        INPUT_SCAN_TYPE: ${{ inputs.scan-type }}
        INPUT_SCAN_REF: ${{ inputs.scan-ref }}
        INPUT_FORMAT: ${{ inputs.format }}
        INPUT_OUTPUT: ${{ inputs.output }}
        INPUT_UPLOAD_SARIF: ${{ inputs.upload-sarif }}
        INPUT_SEVERITY: ${{ inputs.severity-threshold }}
        INPUT_WARN_ON: ${{ inputs.warn-on-severity }}
        INPUT_POLICY: ${{ inputs.policy }}
        INPUT_ENRICH: ${{ inputs.enrich }}
        INPUT_AUTO_UPDATE_DB: ${{ inputs.auto-update-db }}
        INPUT_VULN_DB_SCOPE: ${{ inputs.vuln-db-scope }}
        INPUT_FAIL_ON_KEV: ${{ inputs.fail-on-kev }}
        INPUT_AI_ENRICH: ${{ inputs.ai-enrich }}
        INPUT_AI_MODEL: ${{ inputs.ai-model }}
        INPUT_IMAGE: ${{ inputs.image }}
        INPUT_CONFIG_DIR: ${{ inputs.config-dir }}
        INPUT_SBOM: ${{ inputs.sbom }}
        INPUT_REMEDIATE: ${{ inputs.remediate }}
        INPUT_BADGE: ${{ inputs.badge }}
        INPUT_IGNORE_FILE: ${{ inputs.ignore-file }}
        INPUT_IAC: ${{ inputs.iac }}
        INPUT_AI_INVENTORY: ${{ inputs.ai-inventory }}
        INPUT_OS_PACKAGES: ${{ inputs.os-packages }}
        INPUT_BASELINE: ${{ inputs.baseline }}
        INPUT_SKILL_ONLY: ${{ inputs.skill-only }}
        INPUT_FAIL_ON_VERDICT: ${{ inputs.fail-on-verdict }}
        INPUT_WARN_ON_VERDICT: ${{ inputs.warn-on-verdict }}
        INPUT_FAIL_ON_REVIEW_VERDICT: ${{ inputs.fail-on-review-verdict }}
        INPUT_WARN_ON_REVIEW_VERDICT: ${{ inputs.warn-on-review-verdict }}
        INPUT_SKILLS_CI: ${{ inputs.skills-ci }}
        INPUT_GPU_SCAN: ${{ inputs.gpu-scan }}
        INPUT_EXCLUDE_UNFIXABLE: ${{ inputs.exclude-unfixable }}
        INPUT_SBOM_ARTIFACT: ${{ inputs.sbom-artifact }}
      run: |
        # Determine output file path
        if [ -n "$INPUT_OUTPUT" ]; then
          RESULT_FILE="$INPUT_OUTPUT"
        elif [ "$INPUT_FORMAT" = "json" ]; then
          RESULT_FILE="agent-bom-results.json"
        else
          RESULT_FILE="agent-bom-results.sarif"
        fi

        trim_arg() {
          printf '%s' "$1" | xargs
        }

        validate_severity_choice() {
          case "$1" in
            critical|high|medium|low) return 0 ;;
            *)
              echo "::error::$2 must be one of: critical, high, medium, low"
              exit 1
              ;;
          esac
        }
        fail_unsupported_focused_mode() {
          echo "::error::scan-type=$INPUT_SCAN_TYPE does not produce SARIF, so upload-sarif cannot be honoured. Set upload-sarif=false, or use scan-type=scan/image/fs/iac/sbom/skills."
          exit 1
        }
        # Map scan-type to focused command
        #   scan  → agent-bom scan (full auto-detect, default)
        #   image → agent-bom image <ref>
        #   fs    → agent-bom fs <path>
        #   sbom  → agent-bom sbom <path>
        #   iac   → agent-bom iac <paths...>
        #   skills → agent-bom skills scan <path>
        case "${INPUT_SCAN_TYPE:-scan}" in
          image)
            if [ -z "$INPUT_SCAN_REF" ] && [ -n "$INPUT_IMAGE" ]; then
              INPUT_SCAN_REF="$INPUT_IMAGE"
            fi
            if [ -z "$INPUT_SCAN_REF" ]; then
              echo "::error::scan-type=image requires scan-ref (image reference) or image input"
              exit 1
            fi
            ARGS=(image "$INPUT_SCAN_REF")
            ;;
          fs)
            ARGS=(fs "${INPUT_SCAN_REF:-.}")
            ;;
          sbom)
            if [ -z "$INPUT_SCAN_REF" ] && [ -n "$INPUT_SBOM" ]; then
              INPUT_SCAN_REF="$INPUT_SBOM"
            fi
            if [ -z "$INPUT_SCAN_REF" ]; then
              echo "::error::scan-type=sbom requires scan-ref (SBOM file path) or sbom input"
              exit 1
            fi
            ARGS=(sbom "$INPUT_SCAN_REF")
            ;;
          iac)
            if [ -z "$INPUT_SCAN_REF" ] && [ -n "$INPUT_IAC" ]; then
              INPUT_SCAN_REF="$INPUT_IAC"
            fi
            if [ -z "$INPUT_SCAN_REF" ]; then
              echo "::error::scan-type=iac requires scan-ref (IaC paths) or iac input"
              exit 1
            fi
            ARGS=(iac)
            IFS=',' read -ra IAC_REFS <<< "$INPUT_SCAN_REF"
            for p in "${IAC_REFS[@]}"; do
              p="$(trim_arg "$p")"
              if [ -n "$p" ]; then
                ARGS+=("$p")
              fi
            done
            ;;
          secrets)
            ARGS=(secrets "${INPUT_SCAN_REF:-.}")
            ;;
          code)
            ARGS=(code "${INPUT_SCAN_REF:-.}")
            ;;
          skills)
            ARGS=(skills scan "${INPUT_SCAN_REF:-.}")
            ;;
          scan|agents|"")
            # `agents` is the legacy name for the canonical scan command.
            # Scope the scan to the checkout so the recursive project detector
            # (Terraform, Kubernetes, Helm, CloudFormation, workflows, skills)
            # runs and the runner's home-directory MCP configs stay out of CI.
            # An explicit artifact (sbom/image) without a scope scans only that
            # artifact.
            if [ -n "$INPUT_SCAN_REF" ]; then
              ARGS=(scan "$INPUT_SCAN_REF")
            elif [ -n "$INPUT_SBOM" ] || [ -n "$INPUT_IMAGE" ]; then
              ARGS=(scan --no-discover)
            else
              ARGS=(scan .)
            fi
            ;;
          *)
            echo "::error::Unknown scan-type: $INPUT_SCAN_TYPE. Use: scan, image, fs, sbom, iac, secrets, code, skills"
            exit 1
            ;;
        esac

        if [ "$INPUT_SCAN_TYPE" = "skills" ] && [ -n "$INPUT_FORMAT" ] && [ "$INPUT_FORMAT" != "json" ] && [ "$INPUT_FORMAT" != "console" ] && [ "$INPUT_FORMAT" != "sarif" ]; then
          echo "::error::scan-type=skills only supports format=json, format=console, or format=sarif"
          exit 1
        fi

        # Flags each subcommand accepts, verified against `agent-bom <cmd> --help`.
        # An input the selected subcommand cannot honour is never forwarded:
        # a blocking gate or scan target fails closed, anything else warns.
        case "${INPUT_SCAN_TYPE:-scan}" in
          scan|agents|"") CAPS="severity exit-zero warn-on policy enrich auto-update-db kev image config-dir sbom remediate ai-enrich ignore-file iac ai-inventory os-packages baseline skill-only gpu-scan exclude-unfixable" ;;
          image|fs) CAPS="severity enrich exclude-unfixable" ;;
          sbom) CAPS="severity enrich" ;;
          iac) CAPS="severity" ;;
          skills) CAPS="policy" ;;
          *) CAPS="" ;;
        esac
        supports() {
          case " $CAPS " in
            *" $1 "*) return 0 ;;
            *) return 1 ;;
          esac
        }
        ignore_input() {
          echo "::warning::$1 is not supported by scan-type=${INPUT_SCAN_TYPE} and was ignored"
        }
        reject_input() {
          echo "::error::$1 cannot be enforced by scan-type=${INPUT_SCAN_TYPE}. Remove it or use scan-type=scan."
          exit 1
        }

        # Command and target without output or gate flags, reused for the SBOM artifact.
        BASE_ARGS=("${ARGS[@]}")

        if [ "$INPUT_SCAN_TYPE" = "secrets" ] || [ "$INPUT_SCAN_TYPE" = "code" ]; then
          # These commands emit console or JSON only. The default SARIF format
          # falls back to JSON; an explicit SARIF upload cannot be honoured.
          [ "$INPUT_UPLOAD_SARIF" = "true" ] && fail_unsupported_focused_mode
          if [ -z "$INPUT_FORMAT" ] || [ "$INPUT_FORMAT" = "sarif" ]; then
            echo "::notice::scan-type=$INPUT_SCAN_TYPE writes JSON (SARIF is not available for this command)"
            INPUT_FORMAT="json"
            if [ -z "$INPUT_OUTPUT" ]; then
              RESULT_FILE="agent-bom-results.json"
            fi
            ARGS+=(-f json -o "$RESULT_FILE")
            FORMAT_SET=true
          fi
        fi

        # Output format
        if [ "${FORMAT_SET:-false}" = "true" ]; then
          :
        elif [ "$INPUT_FORMAT" = "sarif" ]; then
          ARGS+=(-f sarif -o "$RESULT_FILE")
        elif [ -n "$INPUT_FORMAT" ]; then
          ARGS+=(-f "$INPUT_FORMAT")
          if [ -n "$INPUT_OUTPUT" ] || [ "$INPUT_SCAN_TYPE" = "skills" ] || [ "$INPUT_FORMAT" = "json" ]; then
            ARGS+=(-o "$RESULT_FILE")
          fi
        fi

        # Severity gates
        if [ -n "$INPUT_SEVERITY" ]; then
          validate_severity_choice "$INPUT_SEVERITY" "severity-threshold"
          if supports severity; then
            ARGS+=(--fail-on-severity "$INPUT_SEVERITY")
          else
            echo "::notice::severity-threshold does not apply to scan-type=${INPUT_SCAN_TYPE}; it gates with its own verdict and exit code"
          fi
        elif supports exit-zero; then
          ARGS+=(--exit-zero)
        fi
        if [ -n "$INPUT_WARN_ON" ]; then
          validate_severity_choice "$INPUT_WARN_ON" "warn-on-severity"
          if supports warn-on; then
            ARGS+=(--warn-on "$INPUT_WARN_ON")
          else
            ignore_input "warn-on-severity"
          fi
        fi

        # Policy file (scan policy, or the skills policy for scan-type=skills)
        if [ -n "$INPUT_POLICY" ]; then
          supports policy || reject_input "policy"
          ARGS+=(--policy "$INPUT_POLICY")
        fi

        if [ "$INPUT_FAIL_ON_KEV" = "true" ]; then
          supports kev || reject_input "fail-on-kev"
          ARGS+=(--fail-on-kev)
        fi

        # Delta scanning: report and gate on new findings only
        if [ -n "$INPUT_BASELINE" ]; then
          supports baseline || reject_input "baseline"
          ARGS+=(--baseline "$INPUT_BASELINE" --delta)
        fi

        # Extra scan targets (skip the one already in the focused command)
        if [ -n "$INPUT_IMAGE" ] && [ "$INPUT_SCAN_TYPE" != "image" ]; then
          supports image || reject_input "image"
          ARGS+=(--image "$INPUT_IMAGE")
        fi
        if [ -n "$INPUT_SBOM" ] && [ "$INPUT_SCAN_TYPE" != "sbom" ]; then
          supports sbom || reject_input "sbom"
          ARGS+=(--sbom "$INPUT_SBOM")
        fi
        if [ -n "$INPUT_CONFIG_DIR" ]; then
          supports config-dir || reject_input "config-dir"
          ARGS+=(--config-dir "$INPUT_CONFIG_DIR")
        fi
        if [ -n "$INPUT_IAC" ] && [ "$INPUT_SCAN_TYPE" != "iac" ]; then
          supports iac || reject_input "iac"
          IFS=',' read -ra IAC_PATHS <<< "$INPUT_IAC"
          for p in "${IAC_PATHS[@]}"; do
            p="$(trim_arg "$p")"
            if [ -n "$p" ]; then
              ARGS+=(--iac "$p")
            fi
          done
        fi

        if [ "$INPUT_ENRICH" = "true" ]; then
          if supports enrich; then ARGS+=(--enrich); else ignore_input "enrich"; fi
        fi

        # Auto-update is a scan-only flag; focused commands manage their own
        # advisory lookups, so the default is dropped silently for them.
        case "$INPUT_VULN_DB_SCOPE" in
          application|"") DB_OSV_SCOPE="application" ;;
          full) DB_OSV_SCOPE="all" ;;
          *)
            echo "::error::vuln-db-scope must be one of: application, full"
            exit 1
            ;;
        esac
        if [ "$INPUT_AUTO_UPDATE_DB" = "true" ] && supports auto-update-db; then
          ARGS+=(--auto-update-db --db-osv-scope "$DB_OSV_SCOPE")
        fi

        if [ -n "$INPUT_REMEDIATE" ]; then
          if supports remediate; then ARGS+=(--remediate "$INPUT_REMEDIATE"); else ignore_input "remediate"; fi
        fi

        if [ "$INPUT_AI_ENRICH" = "true" ]; then
          if supports ai-enrich; then
            ARGS+=(--ai-enrich)
            if [ -n "$INPUT_AI_MODEL" ]; then
              ARGS+=(--ai-model "$INPUT_AI_MODEL")
            fi
          else
            ignore_input "ai-enrich"
          fi
        fi

        if [ -n "$INPUT_IGNORE_FILE" ]; then
          if supports ignore-file; then ARGS+=(--ignore-file "$INPUT_IGNORE_FILE"); else ignore_input "ignore-file"; fi
        fi

        if [ -n "$INPUT_AI_INVENTORY" ]; then
          if supports ai-inventory; then
            IFS=',' read -ra AI_PATHS <<< "$INPUT_AI_INVENTORY"
            for p in "${AI_PATHS[@]}"; do
              p="$(trim_arg "$p")"
              if [ -n "$p" ]; then
                ARGS+=(--ai-inventory "$p")
              fi
            done
          else
            ignore_input "ai-inventory"
          fi
        fi

        if [ "$INPUT_OS_PACKAGES" = "true" ]; then
          if supports os-packages; then ARGS+=(--os-packages); else ignore_input "os-packages"; fi
        fi
        if [ "$INPUT_SKILL_ONLY" = "true" ]; then
          if supports skill-only; then ARGS+=(--skill-only); else ignore_input "skill-only"; fi
        fi
        if [ "$INPUT_GPU_SCAN" = "true" ]; then
          if supports gpu-scan; then ARGS+=(--gpu-scan); else ignore_input "gpu-scan"; fi
        fi
        if [ "$INPUT_EXCLUDE_UNFIXABLE" = "true" ]; then
          if supports exclude-unfixable; then ARGS+=(--exclude-unfixable); else ignore_input "exclude-unfixable"; fi
        fi

        validate_skill_verdict_choice() {
          case "$1" in
            suspicious|malicious) return 0 ;;
            *)
              echo "::error::$2 must be one of: suspicious, malicious"
              exit 1
              ;;
          esac
        }

        validate_skill_review_choice() {
          case "$1" in
            review|high_risk|blocked) return 0 ;;
            *)
              echo "::error::$2 must be one of: review, high_risk, blocked"
              exit 1
              ;;
          esac
        }

        add_skill_arg_or_warn() {
          if [ "$INPUT_SCAN_TYPE" = "skills" ]; then
            ARGS+=("$1" "$2")
          else
            echo "::warning::$1 only applies to scan-type=skills and will be ignored"
          fi
        }

        # Skills verdict gates
        if [ -n "$INPUT_FAIL_ON_VERDICT" ]; then
          validate_skill_verdict_choice "$INPUT_FAIL_ON_VERDICT" "fail-on-verdict"
          add_skill_arg_or_warn "--fail-on-verdict" "$INPUT_FAIL_ON_VERDICT"
        fi
        if [ -n "$INPUT_WARN_ON_VERDICT" ]; then
          validate_skill_verdict_choice "$INPUT_WARN_ON_VERDICT" "warn-on-verdict"
          add_skill_arg_or_warn "--warn-on-verdict" "$INPUT_WARN_ON_VERDICT"
        fi
        if [ -n "$INPUT_FAIL_ON_REVIEW_VERDICT" ]; then
          validate_skill_review_choice "$INPUT_FAIL_ON_REVIEW_VERDICT" "fail-on-review-verdict"
          add_skill_arg_or_warn "--fail-on-review-verdict" "$INPUT_FAIL_ON_REVIEW_VERDICT"
        fi
        if [ -n "$INPUT_WARN_ON_REVIEW_VERDICT" ]; then
          validate_skill_review_choice "$INPUT_WARN_ON_REVIEW_VERDICT" "warn-on-review-verdict"
          add_skill_arg_or_warn "--warn-on-review-verdict" "$INPUT_WARN_ON_REVIEW_VERDICT"
        fi

        # Skills CI gate: exit non-zero on suspicious/malicious without a policy
        # file. An explicit fail-on-verdict already covers this, so only add --ci
        # when fail-on-verdict was not supplied.
        if [ "$INPUT_SKILLS_CI" = "true" ]; then
          if [ "$INPUT_SCAN_TYPE" = "skills" ]; then
            if [ -z "$INPUT_FAIL_ON_VERDICT" ]; then
              ARGS+=(--ci)
            fi
          else
            echo "::warning::skills-ci only applies to scan-type=skills and will be ignored"
          fi
        fi

        # Optional SBOM / AI-BOM artifact of the same scope
        SBOM_ARGS=()
        SBOM_FILE=""
        case "${INPUT_SBOM_ARTIFACT:-none}" in
          none|"") ;;
          cyclonedx|spdx)
            case "${INPUT_SCAN_TYPE:-scan}" in
              scan|agents|""|image|fs|sbom) ;;
              *)
                echo "::error::sbom-artifact is not supported for scan-type=$INPUT_SCAN_TYPE. Use scan-type scan, image, fs or sbom."
                exit 1
                ;;
            esac
            if [ "$INPUT_SBOM_ARTIFACT" = "cyclonedx" ]; then
              SBOM_FILE="agent-bom-sbom.cdx.json"
            else
              SBOM_FILE="agent-bom-sbom.spdx.json"
            fi
            SBOM_ARGS=("${BASE_ARGS[@]}")
            if [ -n "$INPUT_IMAGE" ] && [ "$INPUT_SCAN_TYPE" != "image" ]; then
              SBOM_ARGS+=(--image "$INPUT_IMAGE")
            fi
            if [ -n "$INPUT_SBOM" ] && [ "$INPUT_SCAN_TYPE" != "sbom" ]; then
              SBOM_ARGS+=(--sbom "$INPUT_SBOM")
            fi
            SBOM_ARGS+=(-f "$INPUT_SBOM_ARTIFACT" -o "$SBOM_FILE" --offline)
            ;;
          *)
            echo "::error::sbom-artifact must be one of: cyclonedx, spdx, none"
            exit 1
            ;;
        esac

        # Run scan, capture exit code
        set +e
        echo "::group::agent-bom command"
        printf 'Running:'
        for arg in "${ARGS[@]}"; do
          printf ' %q' "$arg"
        done
        printf '\n'
        echo "::endgroup::"
        agent-bom "${ARGS[@]}"
        EXIT_CODE=$?
        if [ "${#SBOM_ARGS[@]}" -gt 0 ]; then
          echo "::group::agent-bom SBOM artifact"
          agent-bom "${SBOM_ARGS[@]}" -q
          SBOM_EXIT=$?
          echo "::endgroup::"
          if [ ! -f "$SBOM_FILE" ]; then
            echo "::warning::SBOM artifact was not written (exit $SBOM_EXIT)"
            SBOM_FILE=""
          fi
        fi
        set -e

        SARIF_FILE=""
        VULN_COUNT=0
        GRAPH_EXPORT_PATH=""
        EXPOSURE_PATHS_COUNT=0
        BLAST_RADIUS_CRITICAL_COUNT=0
        POSTURE_GRADE="unknown"
        if [ "$INPUT_FORMAT" = "sarif" ] && [ -f "$RESULT_FILE" ]; then
          SARIF_FILE="$RESULT_FILE"
          VULN_COUNT=$(RESULT_FILE="$RESULT_FILE" python3 -c "import json, os; d=json.load(open(os.environ['RESULT_FILE'], encoding='utf-8')); print(sum(1 for r in d.get('runs',[{}])[0].get('results',[]) if (r.get('properties') or {}).get('verdict') != 'possibly_affected'))" 2>/dev/null || echo 0)
        elif [ "$INPUT_FORMAT" = "json" ] && [ -f "$RESULT_FILE" ]; then
          # skills: summary.findings; secrets: findings list; scan: unified findings list
          VULN_COUNT=$(RESULT_FILE="$RESULT_FILE" python3 -c "import json, os; d=json.load(open(os.environ['RESULT_FILE'], encoding='utf-8')); s=d.get('summary'); f=d.get('findings'); print(s['findings'] if isinstance(s, dict) and isinstance(s.get('findings'), int) else len(f) if isinstance(f, list) else int(d.get('total') or 0))" 2>/dev/null || echo 0)
        fi

        case "$INPUT_FORMAT" in
          mermaid|svg|graph|graph-html)
            if [ -f "$RESULT_FILE" ]; then
              GRAPH_EXPORT_PATH="$RESULT_FILE"
            fi
            ;;
        esac

        if [ -f "$RESULT_FILE" ]; then
          while IFS='=' read -r key value; do
            case "$key" in
              exposure_paths_count) EXPOSURE_PATHS_COUNT="$value" ;;
              blast_radius_critical_count) BLAST_RADIUS_CRITICAL_COUNT="$value" ;;
              posture_grade) POSTURE_GRADE="$value" ;;
            esac
          done < <(RESULT_FILE="$RESULT_FILE" VULN_COUNT="$VULN_COUNT" python3 "$GITHUB_ACTION_PATH/scripts/action_result_metrics.py")
        fi

        case "$EXIT_CODE" in
          0) SCAN_STATUS="clean" ;;
          1)
            if [ "$VULN_COUNT" -gt 0 ]; then
              SCAN_STATUS="violations"
            else
              SCAN_STATUS="error"
            fi
            ;;
          2) SCAN_STATUS="usage_error" ;;
          3) SCAN_STATUS="stale_data" ;;
          *) SCAN_STATUS="error" ;;
        esac

        # Empty severity-threshold is report-only. The CLI still exits 1 on a partial
        # scan (and focused commands have no --exit-zero), so the step reads the report:
        # a documented baseline step must leave its report for the gate step, which
        # keeps failing closed. Malicious packages and unexplained exits stay blocking.
        GATE_EXIT="$EXIT_CODE"
        if [ -z "$INPUT_SEVERITY" ] && supports severity; then
          if [ "$EXIT_CODE" = "0" ] && [ "$VULN_COUNT" -gt 0 ]; then
            SCAN_STATUS="violations"
          elif [ "$EXIT_CODE" = "1" ] && [ -z "$INPUT_POLICY" ] && [ "$INPUT_FAIL_ON_KEV" != "true" ]; then
            case "$(python3 "$GITHUB_ACTION_PATH/scripts/action_report.py" report-only-verdict "$RESULT_FILE")" in
              complete)
                GATE_EXIT=0
                SCAN_STATUS="violations"
                echo "::notice::severity-threshold is empty: findings are reported and do not fail this step"
                ;;
              partial)
                GATE_EXIT=0
                SCAN_STATUS="partial"
                echo "::warning::agent-bom scan was partial, so this report is incomplete; severity-threshold is empty, so the step continues. See scan_run.issues."
                ;;
            esac
          fi
        fi

        # Generate badge from SARIF results (no second scan)
        BADGE_FILE=""
        if [ -n "$INPUT_BADGE" ] && [ -f "$SARIF_FILE" ]; then
          SARIF_FILE="$SARIF_FILE" INPUT_BADGE="$INPUT_BADGE" python3 -c "
        import json, os, sys
        try:
            sarif = json.load(open(os.environ['SARIF_FILE'], encoding='utf-8'))
            run = sarif.get('runs', [{}])[0]
            # Possibly-affected distro results stay in the SARIF but are not confirmed findings.
            results = [r for r in run.get('results', []) if (r.get('properties') or {}).get('verdict') != 'possibly_affected']
            rules = {r['id']: r for r in run.get('tool', {}).get('driver', {}).get('rules', [])}
            critical = high = 0
            for r in results:
                rid = r.get('ruleId', '')
                rule = rules.get(rid, {})
                score = float(rule.get('properties', {}).get('security-severity', '0'))
                if score > 9.0: critical += 1
                elif score >= 7.0: high += 1
            total = len(results)
            if critical > 0:
                color, msg = 'critical', f'{critical} critical, {high} high'
            elif high > 0:
                color, msg = 'orange', f'{high} high, {total} total'
            elif total > 0:
                color, msg = 'yellow', f'{total} findings'
            else:
                color, msg = 'brightgreen', 'clean'
            badge = {'schemaVersion': 1, 'label': 'agent-bom', 'message': msg, 'color': color, 'namedLogo': 'shield'}
            with open(os.environ['INPUT_BADGE'], 'w') as f:
                json.dump(badge, f, indent=2)
        except Exception as e:
            print(f'Badge generation failed: {e}', file=sys.stderr)
        " || echo "::warning::Badge generation failed"
          if [ -f "$INPUT_BADGE" ]; then
            BADGE_FILE="$INPUT_BADGE"
          fi
        fi

        echo "sarif_file=$SARIF_FILE" >> "$GITHUB_OUTPUT"
        echo "result_file=$RESULT_FILE" >> "$GITHUB_OUTPUT"
        echo "sbom_file=$SBOM_FILE" >> "$GITHUB_OUTPUT"
        echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
        echo "gate_exit_code=$GATE_EXIT" >> "$GITHUB_OUTPUT"
        echo "scan_status=$SCAN_STATUS" >> "$GITHUB_OUTPUT"
        echo "vuln_count=$VULN_COUNT" >> "$GITHUB_OUTPUT"
        echo "badge_file=$BADGE_FILE" >> "$GITHUB_OUTPUT"
        echo "graph_export_path=$GRAPH_EXPORT_PATH" >> "$GITHUB_OUTPUT"
        echo "exposure_paths_count=$EXPOSURE_PATHS_COUNT" >> "$GITHUB_OUTPUT"
        echo "blast_radius_critical_count=$BLAST_RADIUS_CRITICAL_COUNT" >> "$GITHUB_OUTPUT"
        echo "posture_grade=$POSTURE_GRADE" >> "$GITHUB_OUTPUT"

        if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
          SUMMARY_SOURCE="$RESULT_FILE"
          if [ -n "$SARIF_FILE" ]; then
            SUMMARY_SOURCE="$SARIF_FILE"
          fi
          python3 "$GITHUB_ACTION_PATH/scripts/action_report.py" summary "$SUMMARY_SOURCE" --exit-code "$GATE_EXIT" --scan-type "${INPUT_SCAN_TYPE:-scan}" >> "$GITHUB_STEP_SUMMARY" || echo "::warning::agent-bom job summary rendering failed"
          {
            echo ""
            echo "<details><summary>Scan details</summary>"
            echo ""
            echo "| Field | Value |"
            echo "|---|---|"
            echo "| Format | \`${INPUT_FORMAT:-sarif}\` |"
            echo "| Status | \`${SCAN_STATUS}\` |"
            echo "| Exit code | \`${EXIT_CODE}\` |"
            echo "| Finding count | \`${VULN_COUNT}\` |"
            echo "| Exposure paths | \`${EXPOSURE_PATHS_COUNT}\` |"
            echo "| Critical blast-radius findings | \`${BLAST_RADIUS_CRITICAL_COUNT}\` |"
            echo "| Posture grade | \`${POSTURE_GRADE}\` |"
            echo "| Proxy/CA env passthrough | \`HTTP_PROXY, HTTPS_PROXY, NO_PROXY, SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, PIP_CERT\` |"
            if [ -n "$RESULT_FILE" ] && [ -f "$RESULT_FILE" ]; then
              echo "| Result file | \`${RESULT_FILE}\` |"
            fi
            if [ -n "$GRAPH_EXPORT_PATH" ]; then
              echo "| Graph export | \`${GRAPH_EXPORT_PATH}\` |"
            fi
            if [ -n "$SARIF_FILE" ]; then
              echo "| SARIF file | \`${SARIF_FILE}\` |"
            fi
            if [ -n "$BADGE_FILE" ]; then
              echo "| Badge file | \`${BADGE_FILE}\` |"
            fi
            echo ""
            echo "</details>"
          } >> "$GITHUB_STEP_SUMMARY"
        fi

        exit "$GATE_EXIT"

    - name: Emit finding annotations
      if: inputs.annotations == 'true' && always()
      shell: bash
      env:
        RESULT_FILE: ${{ steps.scan.outputs.result_file }}
        INPUT_MAX_ANNOTATIONS: ${{ inputs.max-annotations }}
        PYTHONUTF8: '1'
      run: |
        if [ -n "$RESULT_FILE" ] && [ -f "$RESULT_FILE" ]; then
          python3 "$GITHUB_ACTION_PATH/scripts/emit_github_annotations.py" "$RESULT_FILE" --max "$INPUT_MAX_ANNOTATIONS"
        fi

    - name: MCP gate
      if: always() && inputs.mcp-gate == 'true' && github.event_name == 'pull_request'
      shell: bash
      env:
        BASE_SHA: ${{ github.event.pull_request.base.sha }}
        INPUT_SEVERITY: ${{ inputs.severity-threshold }}
        INPUT_AUTO_UPDATE_DB: ${{ inputs.auto-update-db }}
        INPUT_VULN_DB_SCOPE: ${{ inputs.vuln-db-scope }}
        PYTHONUTF8: '1'
      run: |
        # pull_request checkouts are the merge commit: its first parent is the
        # base the PR merges into. Fetch it when the clone is shallow.
        git fetch --no-tags --quiet --depth=2 origin "$(git rev-parse HEAD)" 2>/dev/null || true
        if git rev-parse --verify --quiet HEAD^2 > /dev/null; then
          GATE_BASE="$(git rev-parse HEAD^1)"
        else
          git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null || git fetch --no-tags --quiet --depth=1 origin "$BASE_SHA"
          GATE_BASE="$BASE_SHA"
        fi
        GATE_ARGS=(--base "$GATE_BASE" --head "$(git rev-parse HEAD)" --severity "${INPUT_SEVERITY:-high}")
        # A scoped database cannot answer every ecosystem a server may pull in, so
        # the gate stays online for those; its fresh local data still answers the rest.
        if [ "$INPUT_AUTO_UPDATE_DB" = "true" ] && [ "$INPUT_VULN_DB_SCOPE" = "full" ]; then
          GATE_ARGS+=(--offline)
        fi
        python3 "$GITHUB_ACTION_PATH/scripts/action_mcp_gate.py" "${GATE_ARGS[@]}"

    - name: Post PR comment with findings summary
      if: always() && inputs.pr-comment != 'false' && github.event_name == 'pull_request'
      shell: bash
      env:
        GH_TOKEN: ${{ github.token }}
        INPUT_PR_COMMENT: ${{ inputs.pr-comment }}
        SARIF_FILE: ${{ steps.scan.outputs.sarif_file }}
        RESULT_FILE: ${{ steps.scan.outputs.result_file }}
        EXIT_CODE: ${{ steps.scan.outputs.gate_exit_code }}
        PYTHONUTF8: '1'
        PR_NUMBER: ${{ github.event.pull_request.number }}
        REPO: ${{ github.repository }}
      run: |
        SOURCE="${SARIF_FILE:-$RESULT_FILE}"
        BODY_FILE="$(mktemp)"
        python3 "$GITHUB_ACTION_PATH/scripts/action_report.py" comment "$SOURCE" --exit-code "$EXIT_CODE" > "$BODY_FILE"
        skip_or_fail() {
          if [ "$INPUT_PR_COMMENT" = "true" ]; then
            echo "::error::Could not post the agent-bom PR comment: $1"
            exit 1
          fi
          echo "::notice::agent-bom PR comment skipped ($1). Grant the job pull-requests: write to enable it, or set pr-comment: false."
          exit 0
        }
        EXISTING_ID="$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate \
          --jq '.[] | select(.body | contains("<!-- agent-bom-scan-comment -->")) | .id' 2>/dev/null | head -n 1)" \
          || skip_or_fail "cannot read pull request comments"
        if [ -n "$EXISTING_ID" ]; then
          gh api "repos/$REPO/issues/comments/$EXISTING_ID" -X PATCH -F "body=@$BODY_FILE" > /dev/null \
            || skip_or_fail "token lacks pull-requests: write"
        else
          gh api "repos/$REPO/issues/$PR_NUMBER/comments" -F "body=@$BODY_FILE" > /dev/null \
            || skip_or_fail "token lacks pull-requests: write"
        fi

    - name: Upload SBOM artifact
      if: always() && steps.scan.outputs.sbom_file != ''
      uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
      with:
        name: agent-bom-sbom
        path: ${{ steps.scan.outputs.sbom_file }}
        if-no-files-found: warn

    - name: Detect SARIF output
      id: sarif
      if: always()
      shell: bash
      env:
        SARIF_FILE: ${{ steps.scan.outputs.sarif_file }}
      run: |
        if [ -n "$SARIF_FILE" ] && [ -f "$SARIF_FILE" ]; then
          echo "exists=true" >> "$GITHUB_OUTPUT"
          echo "path=$SARIF_FILE" >> "$GITHUB_OUTPUT"
        else
          echo "exists=false" >> "$GITHUB_OUTPUT"
        fi

    - name: Upload SARIF to GitHub Security tab
      if: inputs.upload-sarif == 'true' && always() && steps.sarif.outputs.exists == 'true'
      uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
      with:
        sarif_file: ${{ steps.sarif.outputs.path }}
        category: agent-bom