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 --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 # fs → agent-bom fs # sbom → agent-bom sbom # iac → agent-bom iac # skills → agent-bom skills scan 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 --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 "
Scan details" 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 "
" } >> "$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("")) | .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