action.yml
Rama main · réplica de lectura del repositorio original.
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