name: "HOL AI Plugin Scanner" description: "Scan AI plugin repositories for security, publishability, and best-practice quality signals." author: "HOL" inputs: mode: description: "Execution mode: scan, lint, verify, or submit" required: false default: "scan" plugin_dir: description: "Path to a single plugin directory or a repo marketplace root (default: repository root)" required: false default: "." format: description: "Output format: text, json, markdown, or sarif" required: false default: "text" output: description: "Write report to this file path" required: false default: "" profile: description: "Policy profile to apply: default, public-marketplace, or strict-security" required: false default: "default" config: description: "Optional path to a scanner config file such as .plugin-scanner.toml" required: false default: "" baseline: description: "Optional path to a baseline suppression file" required: false default: "" trust_repository_policy: description: "Allow repository-owned scanner config and baselines to affect Action verdicts" required: false default: "false" online: description: "Permit network access: live probing for verify mode and env-configured external analyzer credentials (MCP_SCANNER_API_KEY, MCP_SCANNER_LLM_API_KEY). When false those credentials are removed from the scan environment so they cannot enable external analyzers." required: false default: "false" upload_sarif: description: "Upload the generated SARIF report to GitHub code scanning. Requires security-events: write in the calling workflow." required: false default: "false" sarif_category: description: "SARIF category used when upload_sarif is enabled" required: false default: "ai-plugin-scanner" write_step_summary: description: "Write the full report inline for markdown scans, otherwise write a concise GitHub Actions job summary" required: false default: "true" registry_payload_output: description: "Write a machine-readable plugin ecosystem payload JSON file for registry or awesome-list automation" required: false default: "" min_score: description: "Fail the job if the score is below this threshold (0-100)" required: false default: "0" fail_on_severity: description: "Fail if any finding meets or exceeds this severity (none, critical, high, medium, low, info)" required: false default: "none" cisco_skill_scan: description: "Cisco skill-scanner mode: auto, on, or off" required: false default: "auto" cisco_policy: description: "Cisco skill-scanner policy preset: permissive, balanced, or strict" required: false default: "balanced" install_source: description: "Package install source: pypi for the reviewed release path, or local for source-repo dogfooding" required: false default: "pypi" install_cisco: description: "Install the opt-in Cisco skill-scanner dependency used by this repo" required: false default: "false" submission_enabled: description: "Open submission issues for awesome-list and registry automation when the plugin clears the submission threshold" required: false default: "false" submission_score_threshold: description: "Minimum score required before a submission issue is created" required: false default: "80" submission_repos: description: "Comma-separated GitHub repositories that should receive the submission issue" required: false default: "hashgraph-online/awesome-codex-plugins" submission_token: description: "Required when submission_enabled is true; use a token with issues:write access to the submission repositories" required: false default: "" submission_labels: description: "Comma-separated labels to apply when creating submission issues" required: false default: "plugin-submission" submission_category: description: "Listing category to include in the submission issue body" required: false default: "Community Plugins" submission_plugin_name: description: "Override the plugin name used in the submission issue" required: false default: "" submission_plugin_url: description: "Override the plugin repository URL used in the submission issue" required: false default: "" submission_plugin_description: description: "Override the plugin description used in the submission issue" required: false default: "" submission_author: description: "Override the plugin author used in the submission issue" required: false default: "" pr_comment: description: "PR comment mode: auto, always, or off" required: false default: "auto" pr_comment_style: description: "PR comment style: concise or detailed" required: false default: "concise" pr_comment_max_findings: description: "Maximum findings to include in PR comment summaries" required: false default: "5" outputs: score: description: "The numeric score (0-100)" value: ${{ steps.scan.outputs.score }} grade: description: "The letter grade (A-F)" value: ${{ steps.scan.outputs.grade }} grade_label: description: "The human-readable grade label" value: ${{ steps.scan.outputs.grade_label }} policy_pass: description: "Whether the selected policy profile passed" value: ${{ steps.scan.outputs.policy_pass }} verify_pass: description: "Whether runtime verification passed" value: ${{ steps.scan.outputs.verify_pass }} max_severity: description: "The most severe finding in the scan result, or none" value: ${{ steps.scan.outputs.max_severity }} findings_total: description: "The total number of findings across all severities" value: ${{ steps.scan.outputs.findings_total }} report_path: description: "The path to the rendered report file, if output was requested" value: ${{ steps.scan.outputs.report_path }} registry_payload_path: description: "The path to the machine-readable plugin ecosystem payload file, if requested" value: ${{ steps.scan.outputs.registry_payload_path }} submission_eligible: description: "Whether the plugin met the submission threshold and passed the configured severity gate" value: ${{ steps.scan.outputs.submission_eligible }} submission_performed: description: "Whether a submission issue was created or reused" value: ${{ steps.scan.outputs.submission_performed }} submission_issue_urls: description: "Comma-separated URLs for submission issues created or reused" value: ${{ steps.scan.outputs.submission_issue_urls }} submission_issue_numbers: description: "Comma-separated issue numbers for submission issues created or reused" value: ${{ steps.scan.outputs.submission_issue_numbers }} action_exit_code: description: "Action execution exit code" value: ${{ steps.scan.outputs.action_exit_code }} pr_comment_status: description: "PR comment status: created, updated, unchanged, skipped, or disabled" value: ${{ steps.scan.outputs.pr_comment_status }} pr_comment_id: description: "Created or updated PR comment id when available" value: ${{ steps.scan.outputs.pr_comment_id }} pr_comment_url: description: "Created or updated PR comment URL when available" value: ${{ steps.scan.outputs.pr_comment_url }} branding: icon: "check-circle" color: "blue" runs: using: "composite" steps: - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: "3.12" - name: Install scanner shell: bash env: INSTALL_SOURCE: ${{ inputs.install_source }} INSTALL_CISCO: ${{ inputs.install_cisco }} PYTHONNOUSERSITE: "1" PYTHONSAFEPATH: "1" run: | ACTION_RUNTIME_ROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" ACTION_RUNTIME_DIR="$(mktemp -d "$ACTION_RUNTIME_ROOT/hol-guard-action-XXXXXX")" trap 'cd "$ACTION_RUNTIME_ROOT" 2>/dev/null || cd /; rm -rf "$ACTION_RUNTIME_DIR"' EXIT cd "$ACTION_RUNTIME_DIR" LOCAL_SOURCE="" for candidate in "$GITHUB_ACTION_PATH" "$GITHUB_ACTION_PATH/.."; do if [ -f "$candidate/pyproject.toml" ] && [ -d "$candidate/src/codex_plugin_scanner" ]; then LOCAL_SOURCE="$candidate" break fi done if [ "$INSTALL_SOURCE" = "local" ]; then if [ -z "$LOCAL_SOURCE" ]; then echo "install_source=local requires the hol-guard source checkout (for example: uses: ./action inside hol-guard)." >&2 exit 1 fi if [ "$INSTALL_CISCO" = "true" ]; then python3 -P -m pip install "$LOCAL_SOURCE[cisco]" else python3 -P -m pip install "$LOCAL_SOURCE" fi elif [ "$INSTALL_SOURCE" = "pypi" ]; then SCANNER_VERSION_FILE="$GITHUB_ACTION_PATH/scanner-version.txt" SCANNER_SHA256_FILE="$GITHUB_ACTION_PATH/scanner-sha256.txt" CISCO_VERSION_FILE="$GITHUB_ACTION_PATH/cisco-version.txt" PYPI_ATTESTATIONS_VERSION_FILE="$GITHUB_ACTION_PATH/pypi-attestations-version.txt" PYPI_ATTESTATIONS_REQUIREMENTS_FILE="$GITHUB_ACTION_PATH/pypi-attestations-requirements.txt" SCANNER_RUNTIME_REQUIREMENTS_FILE="$GITHUB_ACTION_PATH/scanner-runtime-requirements.txt" SCANNER_CISCO_RUNTIME_REQUIREMENTS_FILE="$GITHUB_ACTION_PATH/scanner-cisco-runtime-requirements.txt" if [ ! -f "$SCANNER_VERSION_FILE" ] || [ ! -f "$SCANNER_SHA256_FILE" ] || [ ! -f "$CISCO_VERSION_FILE" ] || [ ! -f "$PYPI_ATTESTATIONS_VERSION_FILE" ] || [ ! -f "$PYPI_ATTESTATIONS_REQUIREMENTS_FILE" ] || [ ! -f "$SCANNER_RUNTIME_REQUIREMENTS_FILE" ] || [ ! -f "$SCANNER_CISCO_RUNTIME_REQUIREMENTS_FILE" ]; then echo "Action install metadata files are missing from the Marketplace bundle." >&2 exit 1 fi SCANNER_VERSION="$(tr -d '[:space:]' < "$SCANNER_VERSION_FILE")" EXPECTED_SHA256="$(tr -d '[:space:]' < "$SCANNER_SHA256_FILE")" CISCO_VERSION="$(tr -d '[:space:]' < "$CISCO_VERSION_FILE")" PYPI_ATTESTATIONS_VERSION="$(tr -d '[:space:]' < "$PYPI_ATTESTATIONS_VERSION_FILE")" SCANNER_REPOSITORY="https://github.com/hashgraph-online/hol-guard" DIST_DIR="$ACTION_RUNTIME_DIR/ai-plugin-scanner-dist" mkdir -p "$DIST_DIR" if ! grep -q "^pypi-attestations==${PYPI_ATTESTATIONS_VERSION} " "$PYPI_ATTESTATIONS_REQUIREMENTS_FILE"; then echo "pypi-attestations requirements lock does not match pypi-attestations-version.txt." >&2 exit 1 fi python3 -P -m pip install --require-hashes --only-binary=:all: -r "$PYPI_ATTESTATIONS_REQUIREMENTS_FILE" python3 -P -m pip download --only-binary=:all: --no-deps --dest "$DIST_DIR" "plugin-scanner==${SCANNER_VERSION}" DIST_BASENAME="$( DIST_DIR="$DIST_DIR" python3 -P -c "import os, sys; from pathlib import Path; candidates = sorted(Path(os.environ['DIST_DIR']).glob('plugin_scanner-*.whl')); (len(candidates) == 1) or sys.exit(f'Expected exactly one downloaded scanner wheel, found {len(candidates)}'); print(candidates[0].name)" )" ACTUAL_SHA256="$( DIST_DIR="$DIST_DIR" DIST_BASENAME="$DIST_BASENAME" python3 -P -c "import hashlib, os; from pathlib import Path; wheel = Path(os.environ['DIST_DIR']) / os.environ['DIST_BASENAME']; print(hashlib.sha256(wheel.read_bytes()).hexdigest())" )" if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then echo "Downloaded scanner wheel SHA256 does not match scanner-sha256.txt." >&2 exit 1 fi WHEEL_PATH="$DIST_DIR/$DIST_BASENAME" if ! python3 -P -m pypi_attestations verify pypi \ --repository "$SCANNER_REPOSITORY" \ "$WHEEL_PATH"; then echo "Failed to verify PyPI provenance for plugin-scanner==${SCANNER_VERSION}." >&2 exit 1 fi if [ "$INSTALL_CISCO" = "true" ]; then if ! grep -q "^cisco-ai-skill-scanner==${CISCO_VERSION} " "$SCANNER_CISCO_RUNTIME_REQUIREMENTS_FILE"; then echo "Cisco runtime requirements lock does not match cisco-version.txt." >&2 exit 1 fi python3 -P -m pip install --no-deps --require-hashes --only-binary=:all: -r "$SCANNER_CISCO_RUNTIME_REQUIREMENTS_FILE" else python3 -P -m pip install --no-deps --require-hashes --only-binary=:all: -r "$SCANNER_RUNTIME_REQUIREMENTS_FILE" fi python3 -P -m pip install --no-deps "$WHEEL_PATH" else echo "Unsupported install_source: $INSTALL_SOURCE" >&2 exit 1 fi - name: Run scanner id: scan shell: bash env: MODE: ${{ inputs.mode }} PLUGIN_DIR: ${{ inputs.plugin_dir }} FORMAT: ${{ inputs.format }} OUTPUT: ${{ inputs.output }} PROFILE: ${{ inputs.profile }} CONFIG: ${{ inputs.config }} BASELINE: ${{ inputs.baseline }} TRUST_REPOSITORY_POLICY: ${{ inputs.trust_repository_policy }} ONLINE: ${{ inputs.online }} UPLOAD_SARIF: ${{ inputs.upload_sarif }} WRITE_STEP_SUMMARY: ${{ inputs.write_step_summary == 'true' && inputs.format != 'markdown' }} REGISTRY_PAYLOAD_OUTPUT: ${{ inputs.registry_payload_output }} MIN_SCORE: ${{ inputs.min_score }} FAIL_ON: ${{ inputs.fail_on_severity }} CISCO_SCAN: ${{ inputs.cisco_skill_scan }} CISCO_POLICY: ${{ inputs.cisco_policy }} SUBMISSION_ENABLED: ${{ inputs.submission_enabled }} SUBMISSION_SCORE_THRESHOLD: ${{ inputs.submission_score_threshold }} SUBMISSION_REPOS: ${{ inputs.submission_repos }} SUBMISSION_TOKEN: ${{ inputs.submission_token }} SUBMISSION_LABELS: ${{ inputs.submission_labels }} SUBMISSION_CATEGORY: ${{ inputs.submission_category }} SUBMISSION_PLUGIN_NAME: ${{ inputs.submission_plugin_name }} SUBMISSION_PLUGIN_URL: ${{ inputs.submission_plugin_url }} SUBMISSION_PLUGIN_DESCRIPTION: ${{ inputs.submission_plugin_description }} SUBMISSION_AUTHOR: ${{ inputs.submission_author }} PR_COMMENT: ${{ inputs.pr_comment }} PR_COMMENT_STYLE: ${{ inputs.pr_comment_style }} PR_COMMENT_MAX_FINDINGS: ${{ inputs.pr_comment_max_findings }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} PYTHONNOUSERSITE: "1" PYTHONSAFEPATH: "1" run: python3 -P -m codex_plugin_scanner.action_runner - name: Publish Markdown report to job summary if: ${{ inputs.write_step_summary == 'true' && inputs.mode == 'scan' && inputs.format == 'markdown' && steps.scan.outputs.report_path != '' }} shell: bash env: REPORT_PATH: ${{ steps.scan.outputs.report_path }} run: | set -euo pipefail if [ ! -s "$REPORT_PATH" ]; then echo "Markdown report was requested for the job summary but was not generated: $REPORT_PATH" >&2 exit 1 fi cat "$REPORT_PATH" >> "$GITHUB_STEP_SUMMARY" printf '\n' >> "$GITHUB_STEP_SUMMARY" - name: Upload SARIF if: ${{ inputs.upload_sarif == 'true' && inputs.mode == 'scan' && steps.scan.outputs.report_path != '' }} uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 with: sarif_file: ${{ steps.scan.outputs.report_path }} category: ${{ inputs.sarif_category }}