diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 8528d56155..9ee43cf6d4 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"869b3b7da437d108347555f117c98d89b01ab2ed5a2be53b6a2742578863909b","strict":true,"agent_id":"claude"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"318bcb2dd35e5e2b1e5b5d43aa49a612812b5558e34de889032c8cfcdd19a6cb","strict":true,"agent_id":"claude"} # gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"668228422ae6a00e4ad889ee87cd7109ec5666a7","version":"v5.0.4"},{"repo":"actions/cache/save","sha":"668228422ae6a00e4ad889ee87cd7109ec5666a7","version":"v5.0.4"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"ed597411d8f924073f98dfc5c65a23a2325f34cd","version":"v8"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/setup-node","sha":"53b83947a5a98c8d113130e565377fae1a50d02f","version":"v6.3.0"},{"repo":"actions/upload-artifact","sha":"bbbca2ddaa5d8feaa63e36b76fdaad77386f024f","version":"v7"},{"repo":"docker/build-push-action","sha":"d08e5c354a6adb9ed34480a06d141179aa583294","version":"v7"},{"repo":"docker/setup-buildx-action","sha":"4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd","version":"v4"}]} # ___ _ _ # / _ \ | | (_) @@ -161,18 +161,18 @@ jobs: run: | bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh { - cat << 'GH_AW_PROMPT_c31a1f75b7e5048e_EOF' + cat << 'GH_AW_PROMPT_444cafb4b664f02f_EOF' - GH_AW_PROMPT_c31a1f75b7e5048e_EOF + GH_AW_PROMPT_444cafb4b664f02f_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/agentic_workflows_guide.md" cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_c31a1f75b7e5048e_EOF' + cat << 'GH_AW_PROMPT_444cafb4b664f02f_EOF' - Tools: create_issue(max:3), create_discussion, missing_tool, missing_data, noop + Tools: create_issue(max:4), missing_tool, missing_data, noop The following GitHub context information is available for this workflow: @@ -202,13 +202,13 @@ jobs: {{/if}} - GH_AW_PROMPT_c31a1f75b7e5048e_EOF + GH_AW_PROMPT_444cafb4b664f02f_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_c31a1f75b7e5048e_EOF' + cat << 'GH_AW_PROMPT_444cafb4b664f02f_EOF' {{#runtime-import .github/workflows/shared/reporting.md}} {{#runtime-import .github/workflows/static-analysis-report.md}} - GH_AW_PROMPT_c31a1f75b7e5048e_EOF + GH_AW_PROMPT_444cafb4b664f02f_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -287,9 +287,7 @@ jobs: retention-days: 1 agent: - needs: - - activation - - runner_guard + needs: activation runs-on: ubuntu-latest permissions: actions: read @@ -385,16 +383,11 @@ jobs: fi gh aw --version - name: Pull static analysis Docker images - run: "set -e\necho \"Pulling Docker images for static analysis tools...\"\n\n# Pull zizmor Docker image\necho \"Pulling zizmor image...\"\ndocker pull ghcr.io/zizmorcore/zizmor:latest\n\n# Pull poutine Docker image\necho \"Pulling poutine image...\"\ndocker pull ghcr.io/boostsecurityio/poutine:latest\n\necho \"All static analysis Docker images pulled successfully\"\n" + run: "set -e\necho \"Pulling Docker images for static analysis tools...\"\n\n# Pull zizmor Docker image\necho \"Pulling zizmor image...\"\ndocker pull ghcr.io/zizmorcore/zizmor:latest\n\n# Pull poutine Docker image\necho \"Pulling poutine image...\"\ndocker pull ghcr.io/boostsecurityio/poutine:latest\n\n# Pull runner-guard Docker image\necho \"Pulling runner-guard image...\"\ndocker pull ghcr.io/vigilant-llc/runner-guard:latest\n\necho \"All static analysis Docker images pulled successfully\"\n" - name: Verify static analysis tools - run: "set -e\necho \"Verifying static analysis tools are available...\"\n\n# Verify zizmor\necho \"Testing zizmor...\"\ndocker run --rm ghcr.io/zizmorcore/zizmor:latest --version || echo \"Warning: zizmor version check failed\"\n\n# Verify poutine\necho \"Testing poutine...\"\ndocker run --rm ghcr.io/boostsecurityio/poutine:latest --version || echo \"Warning: poutine version check failed\"\n\necho \"Static analysis tools verification complete\"\n" + run: "set -e\necho \"Verifying static analysis tools are available...\"\n\n# Verify zizmor\necho \"Testing zizmor...\"\ndocker run --rm ghcr.io/zizmorcore/zizmor:latest --version || echo \"Warning: zizmor version check failed\"\n\n# Verify poutine\necho \"Testing poutine...\"\ndocker run --rm ghcr.io/boostsecurityio/poutine:latest --version || echo \"Warning: poutine version check failed\"\n\n# Verify runner-guard\necho \"Testing runner-guard...\"\ndocker run --rm ghcr.io/vigilant-llc/runner-guard:latest --version || echo \"Warning: runner-guard version check failed\"\n\necho \"Static analysis tools verification complete\"\n" - name: Run compile with security tools - run: "set -e\necho \"Running gh aw compile with security tools to download Docker images...\"\n\n# Run compile with all security scanner flags to download Docker images\n# Store the output in a file for inspection\ngh aw compile --zizmor --poutine --actionlint 2>&1 | tee /tmp/gh-aw/compile-output.txt\n\necho \"Compile with security tools completed\"\necho \"Output saved to /tmp/gh-aw/compile-output.txt\"\n" - - name: Download runner-guard results - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: runner-guard-results - path: /tmp/gh-aw/ + run: "set -e\necho \"Running gh aw compile with security tools to download Docker images...\"\n\n# Run compile with all security scanner flags to download Docker images\n# Store the output in a file for inspection\ngh aw compile --zizmor --poutine --actionlint --runner-guard 2>&1 | tee /tmp/gh-aw/compile-output.txt\n\necho \"Compile with security tools completed\"\necho \"Output saved to /tmp/gh-aw/compile-output.txt\"" # Cache memory file share configuration from frontmatter processed below - name: Create cache-memory directory @@ -488,48 +481,21 @@ jobs: mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_e6b1577951691eef_EOF' - {"create_discussion":{"category":"security","close_older_discussions":true,"expires":24,"fallback_to_issue":true,"max":1},"create_issue":{"expires":168,"labels":["security","automation"],"max":3,"title_prefix":"[runner-guard] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_e6b1577951691eef_EOF + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_1bb50cfd96d32a60_EOF' + {"create_issue":{"close_older_issues":true,"expires":168,"labels":["security","automation"],"max":4,"title_prefix":"[static-analysis] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_1bb50cfd96d32a60_EOF - name: Write Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | { "description_suffixes": { - "create_discussion": " CONSTRAINTS: Maximum 1 discussion(s) can be created. Discussions will be created in category \"security\".", - "create_issue": " CONSTRAINTS: Maximum 3 issue(s) can be created. Title will be prefixed with \"[runner-guard] \". Labels [\"security\" \"automation\"] will be automatically added." + "create_issue": " CONSTRAINTS: Maximum 4 issue(s) can be created. Title will be prefixed with \"[static-analysis] \". Labels [\"security\" \"automation\"] will be automatically added." }, "repo_params": {}, "dynamic_tools": [] } GH_AW_VALIDATION_JSON: | { - "create_discussion": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "category": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, "create_issue": { "defaultMax": 1, "fields": { @@ -712,7 +678,7 @@ jobs: export GH_AW_ENGINE="claude" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.16' - cat << GH_AW_MCP_CONFIG_a00060c74c91676a_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_e6f57169bc15e587_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh { "mcpServers": { "agenticworkflows": { @@ -770,7 +736,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_a00060c74c91676a_EOF + GH_AW_MCP_CONFIG_e6f57169bc15e587_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -1042,14 +1008,12 @@ jobs: - activation - agent - detection - - runner_guard - safe_outputs - update_cache_memory if: always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true') runs-on: ubuntu-slim permissions: contents: read - discussions: write issues: write concurrency: group: "gh-aw-conclusion-static-analysis-report" @@ -1146,8 +1110,6 @@ jobs: GH_AW_ENGINE_ID: "claude" GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - GH_AW_CREATE_DISCUSSION_ERRORS: ${{ needs.safe_outputs.outputs.create_discussion_errors }} - GH_AW_CREATE_DISCUSSION_ERROR_COUNT: ${{ needs.safe_outputs.outputs.create_discussion_error_count }} GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} GH_AW_GROUP_REPORTS: "false" GH_AW_FAILURE_REPORT_AS_ISSUE: "true" @@ -1332,48 +1294,6 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); await main(); - runner_guard: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Configure GH_HOST for enterprise compatibility - id: ghes-host-config - shell: bash - run: | - # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct - # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. - GH_HOST="${GITHUB_SERVER_URL#https://}" - GH_HOST="${GH_HOST#http://}" - echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Install runner-guard - run: go install github.com/Vigilant-LLC/runner-guard/v2/cmd/runner-guard@v2.6.0 - - name: Run runner-guard scan - run: | - RUNNER_GUARD="$(go env GOPATH)/bin/runner-guard" - if [ ! -x "$RUNNER_GUARD" ]; then - echo '{"findings":[],"error":"runner-guard binary not found after install"}' > /tmp/runner-guard-results.json - else - "$RUNNER_GUARD" scan . --format json > /tmp/runner-guard-results.json 2>/tmp/runner-guard-stderr.log || true - # If output is empty or not valid JSON, write empty result - if ! python3 -c "import json,sys; json.load(open('/tmp/runner-guard-results.json'))" 2>/dev/null; then - echo '{"findings":[],"stderr":"'"$(cat /tmp/runner-guard-stderr.log | head -20 | tr '"' "'")"'"}' > /tmp/runner-guard-results.json - fi - fi - - name: Upload runner-guard results - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: runner-guard-results - path: /tmp/runner-guard-results.json - retention-days: 1 - safe_outputs: needs: - activation @@ -1383,7 +1303,6 @@ jobs: runs-on: ubuntu-slim permissions: contents: read - discussions: write issues: write timeout-minutes: 15 env: @@ -1448,7 +1367,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_discussion\":{\"category\":\"security\",\"close_older_discussions\":true,\"expires\":24,\"fallback_to_issue\":true,\"max\":1},\"create_issue\":{\"expires\":168,\"labels\":[\"security\",\"automation\"],\"max\":3,\"title_prefix\":\"[runner-guard] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"close_older_issues\":true,\"expires\":168,\"labels\":[\"security\",\"automation\"],\"max\":4,\"title_prefix\":\"[static-analysis] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/static-analysis-report.md b/.github/workflows/static-analysis-report.md index d8614b3a11..71d6a06a57 100644 --- a/.github/workflows/static-analysis-report.md +++ b/.github/workflows/static-analysis-report.md @@ -18,51 +18,16 @@ tools: cache-memory: true timeout: 600 safe-outputs: - create-discussion: - expires: 1d - category: "security" - max: 1 - close-older-discussions: true create-issue: expires: 7d - title-prefix: "[runner-guard] " + title-prefix: "[static-analysis] " labels: [security, automation] - max: 3 + max: 4 + close-older-issues: true timeout-minutes: 45 strict: true imports: - shared/reporting.md -jobs: - runner_guard: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@v6.0.2 - with: - persist-credentials: false - - name: Install runner-guard - run: go install github.com/Vigilant-LLC/runner-guard/v2/cmd/runner-guard@v2.6.0 - - name: Run runner-guard scan - run: | - RUNNER_GUARD="$(go env GOPATH)/bin/runner-guard" - if [ ! -x "$RUNNER_GUARD" ]; then - echo '{"findings":[],"error":"runner-guard binary not found after install"}' > /tmp/runner-guard-results.json - else - "$RUNNER_GUARD" scan . --format json > /tmp/runner-guard-results.json 2>/tmp/runner-guard-stderr.log || true - # If output is empty or not valid JSON, write empty result - if ! python3 -c "import json,sys; json.load(open('/tmp/runner-guard-results.json'))" 2>/dev/null; then - echo '{"findings":[],"stderr":"'"$(cat /tmp/runner-guard-stderr.log | head -20 | tr '"' "'")"'"}' > /tmp/runner-guard-results.json - fi - fi - - name: Upload runner-guard results - if: always() - uses: actions/upload-artifact@v7 - with: - name: runner-guard-results - path: /tmp/runner-guard-results.json - retention-days: 1 steps: - name: Install gh-aw CLI env: @@ -87,6 +52,10 @@ steps: echo "Pulling poutine image..." docker pull ghcr.io/boostsecurityio/poutine:latest + # Pull runner-guard Docker image + echo "Pulling runner-guard image..." + docker pull ghcr.io/vigilant-llc/runner-guard:latest + echo "All static analysis Docker images pulled successfully" - name: Verify static analysis tools run: | @@ -101,6 +70,10 @@ steps: echo "Testing poutine..." docker run --rm ghcr.io/boostsecurityio/poutine:latest --version || echo "Warning: poutine version check failed" + # Verify runner-guard + echo "Testing runner-guard..." + docker run --rm ghcr.io/vigilant-llc/runner-guard:latest --version || echo "Warning: runner-guard version check failed" + echo "Static analysis tools verification complete" - name: Run compile with security tools run: | @@ -109,15 +82,10 @@ steps: # Run compile with all security scanner flags to download Docker images # Store the output in a file for inspection - gh aw compile --zizmor --poutine --actionlint 2>&1 | tee /tmp/gh-aw/compile-output.txt + gh aw compile --zizmor --poutine --actionlint --runner-guard 2>&1 | tee /tmp/gh-aw/compile-output.txt echo "Compile with security tools completed" echo "Output saved to /tmp/gh-aw/compile-output.txt" - - name: Download runner-guard results - uses: actions/download-artifact@v8.0.1 - with: - name: runner-guard-results - path: /tmp/gh-aw/ --- # Static Analysis Report @@ -288,11 +256,11 @@ Use the cache memory folder `/tmp/gh-aw/cache-memory/` to build persistent knowl ``` ``` -### Phase 5: Create Discussion Report +### Phase 5: Create Issue Report -**ALWAYS create a comprehensive discussion report** with your static analysis findings, regardless of whether issues were found or not. +**ALWAYS create a comprehensive issue report** with your static analysis findings, regardless of whether issues were found or not. -Create a discussion with: +Create an issue with: - **Summary**: Overview of static analysis findings from all three tools - **Statistics**: Total findings by tool, by severity, by type - **Clustered Findings**: Issues grouped by tool and type with counts @@ -301,7 +269,7 @@ Create a discussion with: - **Recommendations**: Prioritized actions to improve security and code quality - **Historical Trends**: Comparison with previous scans -**Discussion Template**: +**Issue Template**: ```markdown # 🔍 Static Analysis Report - [DATE] @@ -424,12 +392,14 @@ Issues created: [list of issue links for Critical/High findings, or "none"] - [ ] Consider adding all three tools to pre-commit hooks ``` +Use the title `[static-analysis] Report - [DATE]` for the issue. + ### Phase 6: Analyze Runner-Guard Findings -Runner-guard has performed source-to-sink vulnerability scanning on the repository's GitHub Actions workflows. The results are available at `/tmp/gh-aw/runner-guard-results.json`. +Runner-guard has performed source-to-sink vulnerability scanning as part of the compile step. The results are included in the compilation output at `/tmp/gh-aw/compile-output.txt`. 1. **Read Runner-Guard Output**: - Read the file `/tmp/gh-aw/runner-guard-results.json` which contains findings from runner-guard's taint analysis (detection rules covering fork checkout exploits, expression injection, secret exfiltration, unpinned actions, AI config injection, and supply chain steganography). + Parse the runner-guard findings from `/tmp/gh-aw/compile-output.txt` — runner-guard findings are included alongside zizmor, poutine, and actionlint results (detection rules covering fork checkout exploits, expression injection, secret exfiltration, unpinned actions, AI config injection, and supply chain steganography). 2. **Analyze Findings**: - Parse the JSON to extract findings @@ -441,14 +411,14 @@ Runner-guard has performed source-to-sink vulnerability scanning on the reposito For up to 3 of the most critical findings (by severity, then rule ID), create a GitHub issue. Before creating issues: - - Search for existing open issues whose title contains `[runner-guard]` and the rule ID (e.g. `RGS-001`) to avoid duplicates + - Search for existing open issues whose title contains `[static-analysis]` and the rule ID (e.g. `RGS-001`) to avoid duplicates - Only create issues for Critical and High severity findings - Do not create an issue if a matching open issue already exists for the same rule ID - Maximum 3 issues total across all runner-guard findings per run Issue format: ``` - Title: [runner-guard] : in + Title: [static-analysis] : in ## 🚨 Runner-Guard Security Finding @@ -472,7 +442,7 @@ Runner-guard has performed source-to-sink vulnerability scanning on the reposito ``` 4. **Add to Discussion**: - Include a "Runner-Guard Analysis" section in the Phase 5 discussion report (see updated discussion template below). + Include a "Runner-Guard Analysis" section in the Phase 5 issue report. ## Important Guidelines @@ -514,7 +484,7 @@ Organize your persistent data in `/tmp/gh-aw/cache-memory/`: ## Output Requirements -Your output must be well-structured and actionable. **You must create a discussion** for every scan with the findings from all three tools. +Your output must be well-structured and actionable. **You must create an issue** for every scan with the findings from all three tools. Update cache memory with today's scan data for future reference and trend analysis. @@ -525,13 +495,13 @@ A successful static analysis scan: - ✅ Clusters findings by tool and issue type - ✅ Generates a detailed fix prompt for at least one issue type - ✅ Updates cache memory with findings from all tools -- ✅ Creates a comprehensive discussion report with findings +- ✅ Creates a comprehensive issue report with findings - ✅ Provides actionable recommendations - ✅ Maintains historical context for trend analysis - ✅ Reads and analyzes runner-guard source-to-sink findings - ✅ Creates up to 3 GitHub issues for Critical/High runner-guard findings (avoiding duplicates) -Begin your static analysis scan now. Read and parse the compilation output from `/tmp/gh-aw/compile-output.txt`, analyze the findings from all four tools (zizmor, poutine, actionlint, runner-guard), cluster them, generate fix suggestions, create up to 3 issues for critical runner-guard findings, and create a discussion with your complete analysis. +Begin your static analysis scan now. Read and parse the compilation output from `/tmp/gh-aw/compile-output.txt`, analyze the findings from all four tools (zizmor, poutine, actionlint, runner-guard), cluster them, generate fix suggestions, create up to 3 issues for critical runner-guard findings, and create an issue with your complete analysis. **Important**: If no action is needed after completing your analysis, you **MUST** call the `noop` safe-output tool with a brief explanation. Failing to call any safe-output tool is the most common cause of safe-output workflow failures. diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 0c075f501c..b11e897702 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -276,6 +276,7 @@ Examples: zizmor, _ := cmd.Flags().GetBool("zizmor") poutine, _ := cmd.Flags().GetBool("poutine") actionlint, _ := cmd.Flags().GetBool("actionlint") + runnerGuard, _ := cmd.Flags().GetBool("runner-guard") jsonOutput, _ := cmd.Flags().GetBool("json") fix, _ := cmd.Flags().GetBool("fix") stats, _ := cmd.Flags().GetBool("stats") @@ -333,6 +334,7 @@ Examples: Zizmor: zizmor, Poutine: poutine, Actionlint: actionlint, + RunnerGuard: runnerGuard, JSONOutput: jsonOutput, Stats: stats, FailFast: failFast, @@ -679,6 +681,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all compileCmd.Flags().Bool("zizmor", false, "Run zizmor security scanner on generated .lock.yml files") compileCmd.Flags().Bool("poutine", false, "Run poutine security scanner on generated .lock.yml files") compileCmd.Flags().Bool("actionlint", false, "Run actionlint linter on generated .lock.yml files") + compileCmd.Flags().Bool("runner-guard", false, "Run runner-guard taint analysis scanner on generated .lock.yml files (uses Docker image "+cli.RunnerGuardImage+")") compileCmd.Flags().Bool("fix", false, "Apply automatic codemod fixes to workflows before compiling") compileCmd.Flags().BoolP("json", "j", false, "Output results in JSON format") compileCmd.Flags().Bool("stats", false, "Display statistics table sorted by workflow file size (shows jobs, steps, scripts, and shells)") diff --git a/pkg/cli/compile_batch_operations.go b/pkg/cli/compile_batch_operations.go index ab22cfe8a2..3e68509a34 100644 --- a/pkg/cli/compile_batch_operations.go +++ b/pkg/cli/compile_batch_operations.go @@ -61,6 +61,12 @@ func RunPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error return runPoutineOnDirectory(workflowDir, verbose, strict) } +// RunRunnerGuardOnDirectory runs runner-guard taint analysis scanner once on a directory. +// Runner-guard scans all workflows in a directory, so it only needs to run once. +func RunRunnerGuardOnDirectory(workflowDir string, verbose bool, strict bool) error { + return runRunnerGuardOnDirectory(workflowDir, verbose, strict) +} + // runBatchLockFileTool runs a batch tool on lock files with uniform error handling func runBatchLockFileTool(toolName string, lockFiles []string, verbose bool, strict bool, runner func([]string, bool, bool) error) error { if len(lockFiles) == 0 { @@ -110,6 +116,23 @@ func runBatchPoutine(workflowDir string, verbose bool, strict bool) error { return nil } +// runBatchRunnerGuard runs runner-guard taint analysis scanner once for the entire directory +func runBatchRunnerGuard(workflowDir string, verbose bool, strict bool) error { + compileBatchOperationsLog.Printf("Running batch runner-guard on directory: %s", workflowDir) + + if err := RunRunnerGuardOnDirectory(workflowDir, verbose, strict); err != nil { + if strict { + return fmt.Errorf("runner-guard taint analysis failed: %w", err) + } + // In non-strict mode, runner-guard errors are warnings + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("runner-guard warnings: %v", err))) + } + } + + return nil +} + // purgeOrphanedLockFiles removes orphaned .lock.yml files // These are lock files that exist but don't have a corresponding .md file func purgeOrphanedLockFiles(workflowsDir string, expectedLockFiles []string, verbose bool) error { diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index 26576bef19..c0baf4236d 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -21,6 +21,7 @@ type CompileConfig struct { Zizmor bool // Run zizmor security scanner on generated .lock.yml files Poutine bool // Run poutine security scanner on generated .lock.yml files Actionlint bool // Run actionlint linter on generated .lock.yml files + RunnerGuard bool // Run runner-guard taint analysis scanner on generated .lock.yml files JSONOutput bool // Output validation results as JSON ActionMode string // Action script inlining mode: inline, dev, or release ActionTag string // Override action SHA or tag for actions/setup (overrides action-mode to release) diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go index a3fbd3c13b..9121b88c6a 100644 --- a/pkg/cli/compile_pipeline.go +++ b/pkg/cli/compile_pipeline.go @@ -58,6 +58,7 @@ func compileSpecificFiles( var errorCount int var lockFilesForActionlint []string var lockFilesForZizmor []string + var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) // Compile each specified file for _, markdownFile := range config.MarkdownFiles { @@ -122,6 +123,9 @@ func compileSpecificFiles( if config.Zizmor { lockFilesForZizmor = append(lockFilesForZizmor, fileResult.lockFile) } + if config.Poutine || config.RunnerGuard { + lockFilesForDirTools = append(lockFilesForDirTools, fileResult.lockFile) + } } } } @@ -149,8 +153,8 @@ func compileSpecificFiles( // Run batch poutine once on the workflow directory // Get the directory from the first lock file (all should be in same directory) - if config.Poutine && !config.NoEmit && len(lockFilesForZizmor) > 0 { - workflowDir := filepath.Dir(lockFilesForZizmor[0]) + if config.Poutine && !config.NoEmit && len(lockFilesForDirTools) > 0 { + workflowDir := filepath.Dir(lockFilesForDirTools[0]) if err := runBatchPoutine(workflowDir, config.Verbose && !config.JSONOutput, config.Strict); err != nil { if config.Strict { return workflowDataList, err @@ -158,6 +162,17 @@ func compileSpecificFiles( } } + // Run batch runner-guard once on the workflow directory + // Get the directory from the first lock file (all should be in same directory) + if config.RunnerGuard && !config.NoEmit && len(lockFilesForDirTools) > 0 { + workflowDir := filepath.Dir(lockFilesForDirTools[0]) + if err := runBatchRunnerGuard(workflowDir, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + // Get warning count from compiler stats.Warnings = compiler.GetWarningCount() @@ -247,6 +262,7 @@ func compileAllFilesInDirectory( var errorCount int var lockFilesForActionlint []string var lockFilesForZizmor []string + var lockFilesForDirTools []string // lock files for directory-based tools (poutine, runner-guard) for _, file := range mdFiles { stats.Total++ @@ -280,6 +296,9 @@ func compileAllFilesInDirectory( if config.Zizmor { lockFilesForZizmor = append(lockFilesForZizmor, fileResult.lockFile) } + if config.Poutine || config.RunnerGuard { + lockFilesForDirTools = append(lockFilesForDirTools, fileResult.lockFile) + } } } } @@ -306,7 +325,7 @@ func compileAllFilesInDirectory( } // Run batch poutine once on the workflow directory - if config.Poutine && !config.NoEmit && len(lockFilesForZizmor) > 0 { + if config.Poutine && !config.NoEmit && len(lockFilesForDirTools) > 0 { if err := runBatchPoutine(workflowsDir, config.Verbose && !config.JSONOutput, config.Strict); err != nil { if config.Strict { return workflowDataList, err @@ -314,6 +333,15 @@ func compileAllFilesInDirectory( } } + // Run batch runner-guard once on the workflow directory + if config.RunnerGuard && !config.NoEmit && len(lockFilesForDirTools) > 0 { + if err := runBatchRunnerGuard(workflowsDir, config.Verbose && !config.JSONOutput, config.Strict); err != nil { + if config.Strict { + return workflowDataList, err + } + } + } + // Get warning count from compiler stats.Warnings = compiler.GetWarningCount() diff --git a/pkg/cli/docker_images.go b/pkg/cli/docker_images.go index 83654675a4..b51d987406 100644 --- a/pkg/cli/docker_images.go +++ b/pkg/cli/docker_images.go @@ -29,9 +29,10 @@ func (e *DockerUnavailableError) Error() string { // DockerImages defines the Docker images used by the compile tool's static analysis scanners const ( - ZizmorImage = "ghcr.io/zizmorcore/zizmor:latest" - PoutineImage = "ghcr.io/boostsecurityio/poutine:latest" - ActionlintImage = "rhysd/actionlint:latest" + ZizmorImage = "ghcr.io/zizmorcore/zizmor:latest" + PoutineImage = "ghcr.io/boostsecurityio/poutine:latest" + ActionlintImage = "rhysd/actionlint:latest" + RunnerGuardImage = "ghcr.io/vigilant-llc/runner-guard:latest" ) // dockerPullState tracks the state of docker pull operations @@ -204,9 +205,9 @@ func StartDockerImageDownload(ctx context.Context, image string) bool { // Returns: // - nil if all required images are available // - error if Docker is unavailable or images are downloading/need to be downloaded -func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint bool) error { +func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, useActionlint, useRunnerGuard bool) error { // If no tools requested, nothing to do - if !useZizmor && !usePoutine && !useActionlint { + if !useZizmor && !usePoutine && !useActionlint && !useRunnerGuard { return nil } @@ -229,6 +230,11 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use requestedTools = append(requestedTools, tool) paramsList = append(paramsList, tool+": false") } + if useRunnerGuard { + tool := "runner-guard" + requestedTools = append(requestedTools, tool) + paramsList = append(paramsList, tool+": false") + } verb := "requires" if len(requestedTools) > 1 { verb = "require" @@ -250,6 +256,7 @@ func CheckAndPrepareDockerImages(ctx context.Context, useZizmor, usePoutine, use {useZizmor, ZizmorImage, "zizmor"}, {usePoutine, PoutineImage, "poutine"}, {useActionlint, ActionlintImage, "actionlint"}, + {useRunnerGuard, RunnerGuardImage, "runner-guard"}, } for _, img := range imagesToCheck { diff --git a/pkg/cli/docker_images_test.go b/pkg/cli/docker_images_test.go index 0b9b9d776d..33022ac995 100644 --- a/pkg/cli/docker_images_test.go +++ b/pkg/cli/docker_images_test.go @@ -15,7 +15,7 @@ func TestCheckAndPrepareDockerImages_NoToolsRequested(t *testing.T) { ResetDockerPullState() // When no tools are requested, should return nil - err := CheckAndPrepareDockerImages(context.Background(), false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested, got: %v", err) } @@ -31,7 +31,7 @@ func TestCheckAndPrepareDockerImages_ImageAlreadyDownloading(t *testing.T) { SetDockerImageDownloading(ZizmorImage, true) // Should return an error indicating to retry - err := CheckAndPrepareDockerImages(context.Background(), true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) if err == nil { t.Error("Expected error when image is downloading, got nil") } @@ -101,12 +101,16 @@ func TestDockerImageConstants(t *testing.T) { if ActionlintImage == "" { t.Error("ActionlintImage constant should not be empty") } + if RunnerGuardImage == "" { + t.Error("RunnerGuardImage constant should not be empty") + } // Verify they are docker image references expectedImages := map[string]string{ - "zizmor": ZizmorImage, - "poutine": PoutineImage, - "actionlint": ActionlintImage, + "zizmor": ZizmorImage, + "poutine": PoutineImage, + "actionlint": ActionlintImage, + "runner-guard": RunnerGuardImage, } for name, image := range expectedImages { @@ -130,7 +134,7 @@ func TestCheckAndPrepareDockerImages_MultipleImages(t *testing.T) { SetDockerImageDownloading(PoutineImage, true) // Request all tools - err := CheckAndPrepareDockerImages(context.Background(), true, true, true) + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, false) if err == nil { t.Error("Expected error when images are downloading, got nil") } @@ -156,7 +160,7 @@ func TestCheckAndPrepareDockerImages_RetryMessageFormat(t *testing.T) { // Simulate zizmor downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -191,7 +195,7 @@ func TestCheckAndPrepareDockerImages_StartedDownloadingMessage(t *testing.T) { // when the image is marked as downloading SetDockerImageDownloading(ZizmorImage, true) - err := CheckAndPrepareDockerImages(context.Background(), true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) if err == nil { t.Fatal("Expected error when image is downloading") } @@ -215,7 +219,7 @@ func TestCheckAndPrepareDockerImages_ImageAlreadyAvailable(t *testing.T) { SetMockImageAvailable(ZizmorImage, true) // Should not return an error since the image is available - err := CheckAndPrepareDockerImages(context.Background(), true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) if err != nil { t.Errorf("Expected no error when image is available, got: %v", err) } @@ -460,7 +464,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable(t *testing.T) { SetMockDockerAvailable(false) // Should return a clear error about Docker not being available - err := CheckAndPrepareDockerImages(context.Background(), true, false, false) + err := CheckAndPrepareDockerImages(context.Background(), true, false, false, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -498,7 +502,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_MultipleTools(t *testing. SetMockDockerAvailable(false) // Request multiple tools - err := CheckAndPrepareDockerImages(context.Background(), true, false, true) + err := CheckAndPrepareDockerImages(context.Background(), true, false, true, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -537,7 +541,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_NoTools(t *testing.T) { SetMockDockerAvailable(false) // When no tools requested, should return nil even if Docker is unavailable - err := CheckAndPrepareDockerImages(context.Background(), false, false, false) + err := CheckAndPrepareDockerImages(context.Background(), false, false, false, false) if err != nil { t.Errorf("Expected no error when no tools requested (even with Docker unavailable), got: %v", err) } @@ -569,7 +573,7 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *test ResetDockerPullState() SetMockDockerAvailable(false) - err := CheckAndPrepareDockerImages(context.Background(), false, false, true) + err := CheckAndPrepareDockerImages(context.Background(), false, false, true, false) if err == nil { t.Fatal("Expected error when Docker is unavailable, got nil") } @@ -584,3 +588,36 @@ func TestCheckAndPrepareDockerImages_DockerUnavailable_ReturnsTypedError(t *test // Clean up ResetDockerPullState() } + +func TestCheckAndPrepareDockerImages_RunnerGuardImageDownloading(t *testing.T) { + // Reset state before test + ResetDockerPullState() + + // Mock runner-guard image as not available + SetMockImageAvailable(RunnerGuardImage, false) + + // Simulate multiple images already downloading + SetDockerImageDownloading(ZizmorImage, true) + SetDockerImageDownloading(PoutineImage, true) + SetDockerImageDownloading(RunnerGuardImage, true) + + // Request all tools, including runner-guard + err := CheckAndPrepareDockerImages(context.Background(), true, true, true, true) + if err == nil { + t.Error("Expected error when images are downloading, got nil") + } + + // Error should mention downloading images and runner-guard + if err != nil { + errMsg := err.Error() + if !strings.Contains(errMsg, "downloading") && !strings.Contains(errMsg, "retry") { + t.Errorf("Expected error to mention downloading and retry, got: %s", errMsg) + } + if !strings.Contains(errMsg, RunnerGuardImage) && !strings.Contains(errMsg, "runner-guard") { + t.Errorf("Expected error to mention runner-guard image %q or \"runner-guard\", got: %s", RunnerGuardImage, errMsg) + } + } + + // Clean up + ResetDockerPullState() +} diff --git a/pkg/cli/mcp_tools_readonly.go b/pkg/cli/mcp_tools_readonly.go index 65ba0d3725..7af2c902ae 100644 --- a/pkg/cli/mcp_tools_readonly.go +++ b/pkg/cli/mcp_tools_readonly.go @@ -76,12 +76,13 @@ Returns a JSON array where each element has the following structure: // Returns an error if schema generation fails, which causes the server to stop registering tools. func registerCompileTool(server *mcp.Server, execCmd execCmdFunc, manifestCacheFile string) error { type compileArgs struct { - Workflows []string `json:"workflows,omitempty" jsonschema:"Workflow files to compile (empty for all)"` - Strict bool `json:"strict,omitempty" jsonschema:"Override frontmatter to enforce strict mode validation for all workflows. Note: Workflows default to strict mode unless frontmatter sets strict: false"` - Zizmor bool `json:"zizmor,omitempty" jsonschema:"Run zizmor security scanner on generated .lock.yml files"` - Poutine bool `json:"poutine,omitempty" jsonschema:"Run poutine security scanner on generated .lock.yml files"` - Actionlint bool `json:"actionlint,omitempty" jsonschema:"Run actionlint linter on generated .lock.yml files"` - Fix bool `json:"fix,omitempty" jsonschema:"Apply automatic codemod fixes to workflows before compiling"` + Workflows []string `json:"workflows,omitempty" jsonschema:"Workflow files to compile (empty for all)"` + Strict bool `json:"strict,omitempty" jsonschema:"Override frontmatter to enforce strict mode validation for all workflows. Note: Workflows default to strict mode unless frontmatter sets strict: false"` + Zizmor bool `json:"zizmor,omitempty" jsonschema:"Run zizmor security scanner on generated .lock.yml files"` + Poutine bool `json:"poutine,omitempty" jsonschema:"Run poutine security scanner on generated .lock.yml files"` + Actionlint bool `json:"actionlint,omitempty" jsonschema:"Run actionlint linter on generated .lock.yml files"` + RunnerGuard bool `json:"runner-guard,omitempty" jsonschema:"Run runner-guard taint analysis scanner on generated .lock.yml files"` + Fix bool `json:"fix,omitempty" jsonschema:"Apply automatic codemod fixes to workflows before compiling"` } // Generate schema with elicitation defaults @@ -138,9 +139,9 @@ Returns JSON array with validation results for each workflow: var dockerUnavailableWarning string // Check if any static analysis tools are requested that require Docker images - if args.Zizmor || args.Poutine || args.Actionlint { + if args.Zizmor || args.Poutine || args.Actionlint || args.RunnerGuard { // Check if Docker images are available; if not, start downloading and return retry message - if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint); err != nil { + if err := CheckAndPrepareDockerImages(ctx, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard); err != nil { var dockerUnavailableErr *DockerUnavailableError if errors.As(err, &dockerUnavailableErr) { // Docker daemon is not running. Instead of failing every workflow, @@ -150,6 +151,7 @@ Returns JSON array with validation results for each workflow: args.Zizmor = false args.Poutine = false args.Actionlint = false + args.RunnerGuard = false } else { // Images are still downloading — ask the caller to retry. // Build per-workflow validation errors instead of throwing an MCP protocol error, @@ -197,6 +199,9 @@ Returns JSON array with validation results for each workflow: if args.Actionlint { cmdArgs = append(cmdArgs, "--actionlint") } + if args.RunnerGuard { + cmdArgs = append(cmdArgs, "--runner-guard") + } cmdArgs = append(cmdArgs, args.Workflows...) @@ -206,8 +211,8 @@ Returns JSON array with validation results for each workflow: cmdArgs = append(cmdArgs, "--prior-manifest-file", manifestCacheFile) } - mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v", - args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint) + mcpLog.Printf("Executing compile tool: workflows=%v, strict=%v, fix=%v, zizmor=%v, poutine=%v, actionlint=%v, runner-guard=%v", + args.Workflows, args.Strict, args.Fix, args.Zizmor, args.Poutine, args.Actionlint, args.RunnerGuard) // Execute the CLI command // Use separate stdout/stderr capture instead of CombinedOutput because: diff --git a/pkg/cli/runner_guard.go b/pkg/cli/runner_guard.go new file mode 100644 index 0000000000..c8f3895ec9 --- /dev/null +++ b/pkg/cli/runner_guard.go @@ -0,0 +1,269 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/gitutil" + "github.com/github/gh-aw/pkg/logger" +) + +var runnerGuardLog = logger.New("cli:runner_guard") + +// runnerGuardFinding represents a single finding from runner-guard JSON output +type runnerGuardFinding struct { + RuleID string `json:"rule_id"` + Name string `json:"name"` + Severity string `json:"severity"` + Description string `json:"description"` + Remediation string `json:"remediation"` + File string `json:"file"` + Line int `json:"line"` +} + +// runnerGuardOutput represents the complete JSON output from runner-guard +type runnerGuardOutput struct { + Findings []runnerGuardFinding `json:"findings"` + Score int `json:"score,omitempty"` + Grade string `json:"grade,omitempty"` +} + +// runRunnerGuardOnDirectory runs the runner-guard taint analysis scanner on a directory +// containing workflows using the Docker image. +func runRunnerGuardOnDirectory(workflowDir string, verbose bool, strict bool) error { + runnerGuardLog.Printf("Running runner-guard taint analysis on directory: %s", workflowDir) + + // Find git root to get the absolute path for Docker volume mount + gitRoot, err := gitutil.FindGitRoot() + if err != nil { + return fmt.Errorf("failed to find git root: %w", err) + } + + // Validate gitRoot is an absolute path (security: ensure trusted path from git) + if !filepath.IsAbs(gitRoot) { + return fmt.Errorf("git root is not an absolute path: %s", gitRoot) + } + + // Determine the scan path: use workflowDir relative to gitRoot when possible, + // so the scan is scoped to the compiled workflows directory. + scanPath := "." + if workflowDir != "" { + relDir, relErr := filepath.Rel(gitRoot, workflowDir) + if relErr == nil && relDir != ".." && !strings.HasPrefix(relDir, ".."+string(filepath.Separator)) { + scanPath = relDir + } + } + + // Build the Docker command + // docker run --rm -v "$gitRoot:/workdir" -w /workdir ghcr.io/vigilant-llc/runner-guard:latest scan --format json + // #nosec G204 -- gitRoot comes from git rev-parse (trusted source) and is validated as absolute path. + // exec.Command with separate args (not shell execution) prevents command injection. + cmd := exec.Command( + "docker", + "run", + "--rm", + "-v", gitRoot+":/workdir", + "-w", "/workdir", + RunnerGuardImage, + "scan", + scanPath, + "--format", "json", + ) + + // Always show that runner-guard is running (regular verbosity) + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Running runner-guard taint analysis scanner")) + + // In verbose mode, also show the command that users can run directly + if verbose { + dockerCmd := fmt.Sprintf("docker run --rm -v \"%s:/workdir\" -w /workdir %s scan %s --format json", + gitRoot, RunnerGuardImage, scanPath) + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage("Run runner-guard directly: "+dockerCmd)) + } + + // Capture output + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Run the command + err = cmd.Run() + + // Parse and display output + totalFindings, parseErr := parseAndDisplayRunnerGuardOutput(stdout.String(), verbose, gitRoot) + if parseErr != nil { + runnerGuardLog.Printf("Failed to parse runner-guard output: %v", parseErr) + // Fall back to showing raw output + if stdout.Len() > 0 { + fmt.Fprint(os.Stderr, stdout.String()) + } + if stderr.Len() > 0 { + fmt.Fprint(os.Stderr, stderr.String()) + } + } + + // Check if the error is due to findings or actual failure + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode := exitErr.ExitCode() + runnerGuardLog.Printf("runner-guard exited with code %d (findings=%d)", exitCode, totalFindings) + // Exit code 1 typically indicates findings in the repository + if exitCode == 1 { + if strict { + if parseErr != nil { + // JSON parsing failed but exit code confirms findings exist + return fmt.Errorf("strict mode: runner-guard exited with code 1 (findings present) and output could not be parsed: %w", parseErr) + } + if totalFindings > 0 { + return fmt.Errorf("strict mode: runner-guard found %d security findings - workflows must have no runner-guard findings in strict mode", totalFindings) + } + // Exit code 1 with no parseable findings is still a failure in strict mode + return errors.New("strict mode: runner-guard exited with code 1 indicating findings are present") + } + // In non-strict mode, findings are logged but not treated as errors + return nil + } + // Other exit codes are actual errors + return fmt.Errorf("runner-guard failed with exit code %d", exitCode) + } + // Non-ExitError errors (e.g., command not found) + return fmt.Errorf("runner-guard failed: %w", err) + } + + return nil +} + +// parseAndDisplayRunnerGuardOutput parses runner-guard JSON output and displays findings. +// Returns the total number of findings found. +func parseAndDisplayRunnerGuardOutput(stdout string, verbose bool, gitRoot string) (int, error) { + if stdout == "" { + return 0, nil // No output means no findings + } + + trimmed := strings.TrimSpace(stdout) + if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") { + if len(trimmed) > 0 { + return 0, fmt.Errorf("unexpected runner-guard output format: %s", trimmed) + } + return 0, nil + } + + var output runnerGuardOutput + if err := json.Unmarshal([]byte(stdout), &output); err != nil { + return 0, fmt.Errorf("failed to parse runner-guard JSON output: %w", err) + } + + totalFindings := len(output.Findings) + if totalFindings == 0 { + return 0, nil + } + + // Display score/grade if present + if output.Score > 0 || output.Grade != "" { + fmt.Fprintf(os.Stderr, "%s\n", console.FormatInfoMessage( + fmt.Sprintf("Runner-Guard Score: %d/100 (Grade: %s)", output.Score, output.Grade), + )) + } + + // Group findings by file for better readability + findingsByFile := make(map[string][]runnerGuardFinding) + for _, finding := range output.Findings { + findingsByFile[finding.File] = append(findingsByFile[finding.File], finding) + } + + // Display findings for each file + for filePath, findings := range findingsByFile { + // Validate and sanitize file path to prevent path traversal + cleanPath := filepath.Clean(filePath) + + absPath := cleanPath + if !filepath.IsAbs(cleanPath) { + absPath = filepath.Join(gitRoot, cleanPath) + } + + absGitRoot, err := filepath.Abs(gitRoot) + if err != nil { + runnerGuardLog.Printf("Failed to get absolute path for git root: %v", err) + continue + } + + absPath, err = filepath.Abs(absPath) + if err != nil { + runnerGuardLog.Printf("Failed to get absolute path for %s: %v", filePath, err) + continue + } + + // Check if the resolved path is within gitRoot to prevent path traversal + relPath, err := filepath.Rel(absGitRoot, absPath) + if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + runnerGuardLog.Printf("Skipping file outside git root: %s", filePath) + continue + } + + // Read file content for context display + // #nosec G304 -- absPath is validated through: 1) filepath.Clean() normalization, + // 2) absolute path resolution, and 3) filepath.Rel() check ensuring it's within gitRoot. + // Path traversal attacks are prevented by the boundary validation above. + fileContent, err := os.ReadFile(absPath) + var fileLines []string + if err == nil { + fileLines = strings.Split(string(fileContent), "\n") + } + + for _, finding := range findings { + lineNum := finding.Line + if lineNum == 0 { + lineNum = 1 + } + + // Create context lines around the finding + var context []string + if len(fileLines) > 0 && lineNum > 0 && lineNum <= len(fileLines) { + startLine := max(1, lineNum-2) + endLine := min(len(fileLines), lineNum+2) + for i := startLine; i <= endLine; i++ { + if i-1 < len(fileLines) { + context = append(context, fileLines[i-1]) + } + } + } + + // Map severity to error type + errorType := "warning" + switch strings.ToLower(finding.Severity) { + case "critical", "high", "error": + errorType = "error" + case "note", "info": + errorType = "info" + } + + // Build message + message := fmt.Sprintf("[%s] %s: %s", finding.Severity, finding.RuleID, finding.Name) + if finding.Description != "" { + message = fmt.Sprintf("%s - %s", message, finding.Description) + } + + compilerErr := console.CompilerError{ + Position: console.ErrorPosition{ + File: finding.File, + Line: lineNum, + Column: 1, + }, + Type: errorType, + Message: message, + Context: context, + } + + fmt.Fprint(os.Stderr, console.FormatError(compilerErr)) + } + } + + return totalFindings, nil +} diff --git a/pkg/cli/runner_guard_test.go b/pkg/cli/runner_guard_test.go new file mode 100644 index 0000000000..60fae8c8c0 --- /dev/null +++ b/pkg/cli/runner_guard_test.go @@ -0,0 +1,353 @@ +//go:build !integration + +package cli + +import ( + "bytes" + "os" + "strings" + "testing" +) + +func TestParseAndDisplayRunnerGuardOutput(t *testing.T) { + tests := []struct { + name string + stdout string + verbose bool + expectedOutput []string + expectError bool + expectedCount int + }{ + { + name: "single high severity finding", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-001", + "name": "Unsafe Runner Usage", + "severity": "high", + "description": "Runner pulls from untrusted source", + "remediation": "Pin runner image digest", + "file": ".github/workflows/test.lock.yml", + "line": 15 + } + ] +}`, + expectedOutput: []string{ + ".github/workflows/test.lock.yml:15:1", + "error", + "RGS-001", + "Unsafe Runner Usage", + "Runner pulls from untrusted source", + }, + expectError: false, + expectedCount: 1, + }, + { + name: "critical severity maps to error type", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-002", + "name": "Critical Finding", + "severity": "critical", + "description": "Dangerous configuration", + "file": ".github/workflows/test.lock.yml", + "line": 10 + } + ] +}`, + expectedOutput: []string{ + "error", + "RGS-002", + "Critical Finding", + }, + expectError: false, + expectedCount: 1, + }, + { + name: "note severity maps to info type", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-003", + "name": "Informational Finding", + "severity": "note", + "description": "Minor configuration note", + "file": ".github/workflows/test.lock.yml", + "line": 5 + } + ] +}`, + expectedOutput: []string{ + "info", + "RGS-003", + "Informational Finding", + }, + expectError: false, + expectedCount: 1, + }, + { + name: "info severity maps to info type", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-004", + "name": "Info Finding", + "severity": "info", + "file": ".github/workflows/test.lock.yml", + "line": 5 + } + ] +}`, + expectedOutput: []string{ + "info", + "RGS-004", + }, + expectError: false, + expectedCount: 1, + }, + { + name: "warning severity maps to warning type", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-005", + "name": "Warning Finding", + "severity": "warning", + "description": "A warning", + "file": ".github/workflows/test.lock.yml", + "line": 20 + } + ] +}`, + expectedOutput: []string{ + "warning", + "RGS-005", + }, + expectError: false, + expectedCount: 1, + }, + { + name: "finding with score and grade displayed", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-001", + "name": "Finding", + "severity": "high", + "file": ".github/workflows/test.lock.yml", + "line": 5 + } + ], + "score": 80, + "grade": "B" +}`, + expectedOutput: []string{ + "Runner-Guard Score: 80/100 (Grade: B)", + "RGS-001", + }, + expectError: false, + expectedCount: 1, + }, + { + name: "finding without line number defaults to 1", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-006", + "name": "No Line Finding", + "severity": "high", + "file": ".github/workflows/test.lock.yml", + "line": 0 + } + ] +}`, + expectedOutput: []string{ + ".github/workflows/test.lock.yml:1:1", + "RGS-006", + }, + expectError: false, + expectedCount: 1, + }, + { + name: "multiple findings", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-001", + "name": "First Finding", + "severity": "high", + "file": ".github/workflows/test.lock.yml", + "line": 10 + }, + { + "rule_id": "RGS-002", + "name": "Second Finding", + "severity": "warning", + "file": ".github/workflows/test.lock.yml", + "line": 20 + } + ] +}`, + expectedOutput: []string{ + "RGS-001", + "First Finding", + "RGS-002", + "Second Finding", + }, + expectError: false, + expectedCount: 2, + }, + { + name: "no findings returns zero count", + stdout: `{ + "findings": [] +}`, + expectedOutput: []string{}, + expectError: false, + expectedCount: 0, + }, + { + name: "empty output returns zero count", + stdout: "", + expectedOutput: []string{}, + expectError: false, + expectedCount: 0, + }, + { + name: "invalid JSON returns error", + stdout: "not valid json", + expectedOutput: []string{}, + expectError: true, + expectedCount: 0, + }, + { + name: "finding without description omits description from message", + stdout: `{ + "findings": [ + { + "rule_id": "RGS-007", + "name": "No Description", + "severity": "high", + "description": "", + "file": ".github/workflows/test.lock.yml", + "line": 5 + } + ] +}`, + expectedOutput: []string{ + "[high] RGS-007: No Description", + }, + expectError: false, + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stderr output + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + // Use a temp dir as gitRoot (no actual files — context display is skipped gracefully) + tmpDir := t.TempDir() + count, err := parseAndDisplayRunnerGuardOutput(tt.stdout, tt.verbose, tmpDir) + + // Restore stderr + w.Close() + os.Stderr = oldStderr + + // Read captured output + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + // Check error expectation + if tt.expectError && err == nil { + t.Errorf("Expected an error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Verify finding count + if count != tt.expectedCount { + t.Errorf("Expected count %d, got %d", tt.expectedCount, count) + } + + // Check expected output strings + for _, expected := range tt.expectedOutput { + if !strings.Contains(output, expected) { + t.Errorf("Expected output to contain %q, got:\n%s", expected, output) + } + } + }) + } +} + +func TestRunnerGuardPathTraversalGuard(t *testing.T) { + tests := []struct { + name string + filePath string + skip bool // whether the finding should be skipped (outside git root) + }{ + { + name: "normal workflow file", + filePath: ".github/workflows/test.lock.yml", + skip: false, + }, + { + name: "file outside git root via ..", + filePath: "../outside/file.yml", + skip: true, + }, + { + name: "file with .. prefix but inside root", + filePath: "..foo/file.yml", // should NOT be skipped — not a parent traversal + skip: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + + stdout := `{"findings":[{"rule_id":"RGS-TEST","name":"Test","severity":"high","file":"` + + tt.filePath + `","line":1}]}` + + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + count, err := parseAndDisplayRunnerGuardOutput(stdout, false, tmpDir) + + w.Close() + os.Stderr = oldStderr + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if tt.skip { + // Skipped findings still count toward totalFindings but won't appear in output + // The finding is parsed (count=1) but display is skipped + if count != 1 { + t.Errorf("Expected count 1 (finding parsed even if skipped for display), got %d", count) + } + if strings.Contains(output, "RGS-TEST") { + t.Errorf("Expected skipped finding not to appear in output, got:\n%s", output) + } + } else { + if count != 1 { + t.Errorf("Expected count 1, got %d", count) + } + } + }) + } +}