name: Claude Codebase Security Scan on: workflow_dispatch: inputs: target_ref: description: "Branch, tag, or SHA to scan" required: false default: "" common_misc: description: "Run common/misc security scan" required: false type: boolean default: true mongoose_core: description: "Run Mongoose core security scan" required: false type: boolean default: true web_http_auth: description: "Run web/HTTP/auth security scan" required: false type: boolean default: true tls: description: "Run TLS security scan" required: false type: boolean default: true net_builtin: description: "Run built-in network security scan" required: false type: boolean default: true protocols: description: "Run protocol security scan" required: false type: boolean default: true #schedule: # Weekly scan: Sunday 02:00 UTC # - cron: "0 2 * * 0" permissions: contents: read id-token: write concurrency: group: claude-security-scans cancel-in-progress: false queue: max jobs: prepare: name: Prepare selected scan matrix runs-on: ubuntu-latest outputs: target_ref: ${{ steps.plan.outputs.target_ref }} matrix: ${{ steps.plan.outputs.matrix }} has_scans: ${{ steps.plan.outputs.has_scans }} steps: - name: Resolve scan inputs and build matrix id: plan shell: bash run: | set -euo pipefail if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then TARGET_REF="${{ inputs.target_ref }}" RUN_COMMON_MISC="${{ inputs.common_misc }}" RUN_MONGOOSE_CORE="${{ inputs.mongoose_core }}" RUN_WEB_HTTP_AUTH="${{ inputs.web_http_auth }}" RUN_TLS="${{ inputs.tls }}" RUN_NET_BUILTIN="${{ inputs.net_builtin }}" RUN_PROTOCOLS="${{ inputs.protocols }}" else TARGET_REF="" RUN_COMMON_MISC="true" RUN_MONGOOSE_CORE="true" RUN_WEB_HTTP_AUTH="true" RUN_TLS="true" RUN_NET_BUILTIN="true" RUN_PROTOCOLS="true" fi if [ -z "$TARGET_REF" ]; then TARGET_REF="${{ github.ref }}" fi MATRIX="$(jq -cn \ --arg common_misc "$RUN_COMMON_MISC" \ --arg mongoose_core "$RUN_MONGOOSE_CORE" \ --arg web_http_auth "$RUN_WEB_HTTP_AUTH" \ --arg tls "$RUN_TLS" \ --arg net_builtin "$RUN_NET_BUILTIN" \ --arg protocols "$RUN_PROTOCOLS" \ '{ include: ([ { enabled: ($common_misc == "true"), area: "common_misc", display_name: "Common / Misc", area_prompt: "resources/specs/areas/common_misc.md" }, { enabled: ($mongoose_core == "true"), area: "mongoose_core", display_name: "Mongoose Core", area_prompt: "resources/specs/areas/mongoose_core.md" }, { enabled: ($web_http_auth == "true"), area: "web_http_auth", display_name: "Web / HTTP / Auth", area_prompt: "resources/specs/areas/web_http_auth.md" }, { enabled: ($tls == "true"), area: "tls", display_name: "TLS", area_prompt: "resources/specs/areas/tls.md" }, { enabled: ($net_builtin == "true"), area: "net_builtin", display_name: "Built-In Network", area_prompt: "resources/specs/areas/net_builtin.md" }, { enabled: ($protocols == "true"), area: "protocols", display_name: "Protocols", area_prompt: "resources/specs/areas/protocols.md" } ] | map(select(.enabled)) | map(del(.enabled))) }' )" HAS_SCANS="$(jq -r '.include | length > 0' <<< "$MATRIX")" echo "target_ref=$TARGET_REF" >> "$GITHUB_OUTPUT" echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" echo "has_scans=$HAS_SCANS" >> "$GITHUB_OUTPUT" scan: name: ${{ matrix.display_name }} security scan needs: prepare if: needs.prepare.outputs.has_scans == 'true' runs-on: ubuntu-latest timeout-minutes: 60 strategy: fail-fast: false max-parallel: 1 matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} env: MASTER_PROMPT_PATH: "resources/specs/claude-security-scan.md" FILTERING_PROMPT_PATH: "resources/specs/claude-false-positive-filtering.md" SCAN_AREA: ${{ matrix.area }} AREA_PROMPT_PATH: ${{ matrix.area_prompt }} steps: - name: Checkout repository uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.target_ref }} fetch-depth: 0 - name: Verify required prompt files exist shell: bash run: | for prompt_file in "$MASTER_PROMPT_PATH" "$AREA_PROMPT_PATH" "$FILTERING_PROMPT_PATH"; do test -f "$prompt_file" || { echo "Required prompt file not found: $prompt_file" exit 1 } done - name: Run Claude ${{ matrix.display_name }} security scan id: claude_scan uses: anthropics/claude-code-action@v1 timeout-minutes: 30 with: anthropic_api_key: ${{ secrets.CLAUDE_API_KEY }} prompt: | REPO: ${{ github.repository }} REF: ${{ needs.prepare.outputs.target_ref }} MASTER_PROMPT_FILE: ${{ env.MASTER_PROMPT_PATH }} SCAN_AREA: ${{ matrix.area }} AREA_PROMPT_FILE: ${{ matrix.area_prompt }} FILTERING_PROMPT_FILE: ${{ env.FILTERING_PROMPT_PATH }} You are running a focused security scan for the assigned area in GitHub Actions. First, read the master prompt at ${{ env.MASTER_PROMPT_PATH }}. Then read the assigned area prompt at ${{ matrix.area_prompt }}. Read the false-positive filtering prompt at ${{ env.FILTERING_PROMPT_PATH }} as required by the master prompt. Perform the security review for the ${{ matrix.area }} area. Follow relevant supporting code when necessary, but do not independently scan unrelated areas. Return only the JSON required by the master prompt. Do not modify files. Do not create commits. Do not open pull requests. Do not post GitHub comments. Do not create GitHub issues. claude_args: | --allowedTools "Read,Glob,Grep,Bash(git:*),Bash(find:*),Bash(grep:*),Bash(sed:*),Bash(cat:*)" --model claude-opus-5 - name: Prepare private security scan outputs if: always() && steps.claude_scan.outputs.execution_file != '' id: prepare_outputs shell: bash run: | set -u mkdir -p private-scan-results TODAY="$(date -u +%Y%m%d)" EXECUTION_OUTPUT_FILE="claude-execution-output_${SCAN_AREA}_${TODAY}.json" FINDINGS_FILE="security-findings_${SCAN_AREA}_${TODAY}.json" HAS_EXECUTION_OUTPUT="false" HAS_JSON_FINDINGS="false" echo "date_suffix=${TODAY}" >> "$GITHUB_OUTPUT" echo "execution_output_file=${EXECUTION_OUTPUT_FILE}" >> "$GITHUB_OUTPUT" echo "findings_file=${FINDINGS_FILE}" >> "$GITHUB_OUTPUT" # First check whether the raw Claude execution output exists. if [ -f "${{ steps.claude_scan.outputs.execution_file }}" ]; then jq ' map(select(.type == "result")) ' "${{ steps.claude_scan.outputs.execution_file }}" > "private-scan-results/${EXECUTION_OUTPUT_FILE}" HAS_EXECUTION_OUTPUT="true" if jq -r ' .[] | select(.type == "result") | .result | sub("^[^{]*"; "") ' "${{ steps.claude_scan.outputs.execution_file }}" \ | jq . > "private-scan-results/${FINDINGS_FILE}" then if jq -e '.findings and .analysis_summary' "private-scan-results/${FINDINGS_FILE}" > /dev/null; then HAS_JSON_FINDINGS="true" else rm -f "private-scan-results/${FINDINGS_FILE}" fi else rm -f "private-scan-results/${FINDINGS_FILE}" fi fi echo "has_execution_output=${HAS_EXECUTION_OUTPUT}" >> "$GITHUB_OUTPUT" echo "has_json_findings=${HAS_JSON_FINDINGS}" >> "$GITHUB_OUTPUT" - name: Push scan outputs to private security repo if: always() && steps.prepare_outputs.outputs.has_execution_output == 'true' env: SECURITY_RESULTS_TOKEN: ${{ secrets.SECURITY_RESULTS_TOKEN }} shell: bash run: | set -euo pipefail RESULTS_REPO="cesanta/security" WORKDIR="$(mktemp -d)" git clone "https://x-access-token:${SECURITY_RESULTS_TOKEN}@github.com/${RESULTS_REPO}.git" "$WORKDIR" mkdir -p "$WORKDIR/files" cp "private-scan-results/${{ steps.prepare_outputs.outputs.execution_output_file }}" "$WORKDIR/files/" if [ "${{ steps.prepare_outputs.outputs.has_json_findings }}" = "true" ]; then cp "private-scan-results/${{ steps.prepare_outputs.outputs.findings_file }}" "$WORKDIR/files/" fi cd "$WORKDIR" git config user.name "security-scan-bot" git config user.email "security-scan-bot@users.noreply.github.com" git add files/ if git diff --cached --quiet; then echo "No scan outputs to commit." exit 0 fi git commit -m "Add ${SCAN_AREA} security scan results ${{ steps.prepare_outputs.outputs.date_suffix }}" git push