diff --git a/.github/workflows/agent-restricted.yml b/.github/workflows/agent-restricted.yml index 4e0468449..46538fbb6 100644 --- a/.github/workflows/agent-restricted.yml +++ b/.github/workflows/agent-restricted.yml @@ -1,23 +1,20 @@ name: Restricted Agent -# RESTRICTED ACCESS VERSION -# Only specific users can trigger this workflow manually - on: workflow_dispatch: inputs: prompt: - description: 'Prompt for the agent' + description: Prompt for the agent required: true - default: 'What are my active pull requests?' + default: What are my active pull requests? type: string system_prompt: - description: 'System prompt' + description: System prompt required: false type: string provider: - description: 'Provider' - default: 'bedrock' + description: Provider + default: bedrock required: false type: choice options: @@ -26,22 +23,22 @@ on: - openai - anthropic model: - description: 'Model ID (e.g., us.anthropic.claude-sonnet-4-5-20250929-v1:0)' + description: Model ID (e.g., us.anthropic.claude-sonnet-4-5-20250929-v1:0) required: false - default: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0' + default: us.anthropic.claude-sonnet-4-5-20250929-v1:0 type: string max_tokens: - description: 'Maximum tokens for model response' + description: Maximum tokens for model response required: false default: '10000' type: string tools: - description: 'Tool config (e.g., strands_tools:shell;strands_action:use_github)' + description: Tool config (e.g., strands_tools:shell;strands_action:use_github) required: false - default: 'strands_tools:shell;tools:use_github' + default: strands_tools:shell;tools:use_github type: string agent_runner: - description: 'Custom agent runner URL' + description: Custom agent runner URL required: false type: string @@ -52,39 +49,15 @@ permissions: issues: write jobs: - agent: - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - steps: - - name: Check authorization - run: | - AUTHORIZED_USERS="${{ secrets.AUTHORIZED_USERS }}" - if [[ ",$AUTHORIZED_USERS," != *",${{ github.actor }},"* ]]; then - echo "❌ User ${{ github.actor }} is NOT authorized" - exit 1 - fi - echo "✅ User ${{ github.actor }} is authorized" - - - uses: actions/checkout@v7 - - - name: Generate GitHub App Token - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - name: Run Strands Agent - uses: ./.github/actions/strands-action - with: - prompt: ${{ inputs.prompt }} - provider: ${{ inputs.provider }} - model: ${{ inputs.model }} - max_tokens: ${{ inputs.max_tokens }} - system_prompt: ${{ inputs.system_prompt }} - tools: ${{ inputs.tools }} - agent_runner: ${{ inputs.agent_runner }} - aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} - aws_region: 'us-west-2' - pat_token: ${{ steps.app-token.outputs.token }} - env: - STRANDS_TOOLS_DIRECTORY: 'true' + call: + uses: aws/agentcore-devx-devtools/.github/workflows/reusable-agent-restricted.yml@458c0a684af0f9e3a013ec05cd23851def4f9cab + with: + runner: codebuild + prompt: ${{ inputs.prompt || 'What are my active pull requests?' }} + system_prompt: ${{ inputs.system_prompt || '' }} + provider: ${{ inputs.provider || 'bedrock' }} + model: ${{ inputs.model || 'us.anthropic.claude-sonnet-4-5-20250929-v1:0' }} + max_tokens: ${{ inputs.max_tokens || '10000' }} + tools: ${{ inputs.tools || 'strands_tools:shell;tools:use_github' }} + agent_runner: ${{ inputs.agent_runner || '' }} + secrets: inherit diff --git a/.github/workflows/ci-failure-issue.yml b/.github/workflows/ci-failure-issue.yml index b7da58980..3a366f9af 100644 --- a/.github/workflows/ci-failure-issue.yml +++ b/.github/workflows/ci-failure-issue.yml @@ -2,53 +2,18 @@ name: CI Failure Issue on: workflow_run: - # These names must match the `name:` field in each workflow file exactly workflows: - - 'Build and Test' - - 'Quality and Safety Checks' - - 'E2E Tests (Full Suite)' - - 'CodeQL' + - Build and Test + - Quality and Safety Checks + - E2E Tests (Full Suite) + - CodeQL types: [completed] -jobs: - create-issue: - concurrency: ci-failure-issue-${{ github.event.workflow_run.name }} - # Stays on a GitHub-hosted runner on purpose: this workflow is the alarm that - # opens an issue when main CI fails, so it must not depend on the CodeBuild - # runner it may need to report on. If CodeBuild is unhealthy (webhook drift, - # quota exhaustion, broken image), a CodeBuild-hosted alarm couldn't start - # either and the failure would be silently swallowed. It's GitHub-API-only - # (no AWS calls), so it gains nothing from running inside the AWS network. - runs-on: ubuntu-latest - timeout-minutes: 5 - if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.head_branch == 'main' }} - permissions: - issues: write - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: '20.x' - - - name: Generate GitHub App Token - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} +permissions: + contents: read + issues: write - - name: Create CI failure issue - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - WORKFLOW_NAME: ${{ github.event.workflow_run.name }} - HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - RUN_URL: ${{ github.event.workflow_run.html_url }} - run: | - npx -y tsx .github/scripts/create-failure-issue.ts \ - --title-prefix "CI Failure" \ - --name "$WORKFLOW_NAME" \ - --branch "$HEAD_BRANCH" \ - --commit "$HEAD_SHA" \ - --run-url "$RUN_URL" \ - --labels "high-severity,ci" +jobs: + call: + uses: aws/agentcore-devx-devtools/.github/workflows/reusable-ci-failure-issue.yml@458c0a684af0f9e3a013ec05cd23851def4f9cab + secrets: inherit diff --git a/.github/workflows/github-slack-notifications.yml b/.github/workflows/github-slack-notifications.yml deleted file mode 100644 index 2be6b43ad..000000000 --- a/.github/workflows/github-slack-notifications.yml +++ /dev/null @@ -1,220 +0,0 @@ -name: GitHub Slack Notifications - -# Central GitHub -> Slack integration point. Two triggers feed one shared -# Slack workflow (via SLACK_WEBHOOK_URL), which branches on event_type: -# - issues opened -> notify oncall of a new issue -# - comments on closed PRs -> redirect the commenter to open an issue, -# and notify oncall (closed-PR comments are -# otherwise easy to miss) -# Every payload sends the same key set so the Slack workflow can branch -# reliably; fields that don't apply to an event are sent empty. - -on: - issues: - types: [opened] - issue_comment: - types: [created] - -jobs: - notify-issue-opened: - if: github.event_name == 'issues' - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - permissions: {} - steps: - - name: Send issue details to Slack - # Attacker-controlled fields are passed through env: rather than - # interpolated into the YAML payload, to prevent workflow injection. - # For issue_opened, the issue_* fields carry the data and the - # pr_*/comment_* fields are empty. - env: - REPOSITORY: ${{ github.repository }} - CREATED_AT: ${{ github.event.issue.created_at }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - ISSUE_TITLE: ${{ github.event.issue.title }} - ISSUE_URL: ${{ github.event.issue.html_url }} - ISSUE_AUTHOR: ${{ github.event.issue.user.login }} - ISSUE_BODY: ${{ github.event.issue.body }} - LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }} - uses: slackapi/slack-github-action@v4.0.0 - with: - webhook: ${{ secrets.SLACK_WEBHOOK_URL }} - webhook-type: webhook-trigger - payload: | - event_type: "issue_opened" - repository: "${{ env.REPOSITORY }}" - created_at: "${{ env.CREATED_AT }}" - issue_number: "${{ env.ISSUE_NUMBER }}" - issue_title: ${{ toJSON(env.ISSUE_TITLE) }} - issue_url: "${{ env.ISSUE_URL }}" - issue_author: "${{ env.ISSUE_AUTHOR }}" - issue_body: ${{ toJSON(env.ISSUE_BODY) }} - labels: ${{ toJSON(env.LABELS) }} - pr_number: "" - pr_title: "" - pr_url: "" - pr_author: "" - pr_state: "" - pr_closed_at: "" - pr_merged_at: "" - comment_id: "" - comment_url: "" - comment_author: "" - comment_body: "" - - closed-pr-comment-redirect: - # Only fire on comments left on PRs (issue_comment fires for issues too) - # that are already closed, and skip comments left by bots. - if: >- - github.event_name == 'issue_comment' && github.event.issue.pull_request != null && github.event.issue.state == - 'closed' && github.event.comment.user.type != 'Bot' - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - permissions: - pull-requests: write - issues: read - # Serialize per-PR so the marker-comment dedup is race-free (otherwise two - # rapid-fire comments could both see "no marker" and both post a redirect). - # cancel-in-progress is false so the second run still executes after the - # first finishes -- we want to evaluate dedup against the just-posted marker. - concurrency: - group: closed-pr-comment-${{ github.event.issue.number }} - cancel-in-progress: false - steps: - - name: Check commenter permission - id: perm - uses: actions/github-script@v9 - with: - script: | - // External users on private repos can 404 here; treat any - // failure as "not a maintainer" so the redirect still fires. - let permission = 'none'; - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: context.payload.comment.user.login, - }); - permission = data.permission; - } catch (err) { - core.info(`Permission lookup failed for ${context.payload.comment.user.login}: ${err.message}. Treating as non-maintainer.`); - } - const skip = ['admin', 'maintain', 'write'].includes(permission); - core.setOutput('skip', String(skip)); - core.info(`Commenter ${context.payload.comment.user.login} permission=${permission} skip=${skip}`); - - - name: Check for existing redirect comment - id: existing - if: steps.perm.outputs.skip != 'true' - uses: actions/github-script@v9 - with: - script: | - // Marker we embed in our reply so we don't double-post on the same PR. - const marker = ''; - const comments = await github.paginate( - github.rest.issues.listComments, - { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - per_page: 100, - } - ); - const alreadyPosted = comments.some(c => c.body && c.body.includes(marker)); - core.setOutput('already_posted', String(alreadyPosted)); - - - name: Post redirect comment - if: steps.perm.outputs.skip != 'true' && steps.existing.outputs.already_posted != 'true' - # The Slack notification is the load-bearing part of this job. If - # posting the bot reply fails (rate limit, transient error), don't - # block the Slack notification. - continue-on-error: true - uses: actions/github-script@v9 - env: - # Repos with issue templates use /issues/new/choose; repos without - # templates should change this to /issues/new. - ISSUES_NEW_URL: https://github.com/${{ github.repository }}/issues/new/choose - with: - script: | - const commenter = context.payload.comment.user.login; - const issuesNewUrl = process.env.ISSUES_NEW_URL; - const body = [ - '', - '', - `Thanks for the report, @${commenter} — feedback like this is exactly`, - "how we catch the things we missed. Because this PR is already", - "closed, the team won't see follow-up comments here.", - '', - 'Would you mind opening a new issue so we can track it properly?', - issuesNewUrl, - '', - 'If this is a security issue, please report it privately via', - 'https://aws.amazon.com/security/vulnerability-reporting/ instead', - 'of a public issue.', - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - body, - }); - - - name: Compute PR state - id: pr_state - if: steps.perm.outputs.skip != 'true' - uses: actions/github-script@v9 - with: - script: | - // PRs surface as `issue` events; merged_at is null when closed-not-merged. - const mergedAt = context.payload.issue.pull_request && - context.payload.issue.pull_request.merged_at; - core.setOutput('state', mergedAt ? 'merged' : 'closed'); - - - name: Notify Slack - # Notify oncall only on the FIRST external comment per PR (gated by - # already_posted). Subsequent comments on the same PR don't notify -- - # the redirect comment has already directed the commenter to open an - # issue, and issues notify oncall via the issue path. This bounds - # notification volume regardless of how chatty a thread becomes. - if: steps.perm.outputs.skip != 'true' && steps.existing.outputs.already_posted != 'true' - # Attacker-controlled fields are passed through env: rather than - # interpolated into the YAML payload, to prevent workflow injection. - # For closed-PR comments, the issue_* fields are empty (this isn't - # an issue) and the pr_*/comment_* fields carry the real data. - env: - REPOSITORY: ${{ github.repository }} - CREATED_AT: ${{ github.event.comment.created_at }} - PR_NUMBER: ${{ github.event.issue.number }} - PR_TITLE: ${{ github.event.issue.title }} - PR_URL: ${{ github.event.issue.html_url }} - PR_AUTHOR: ${{ github.event.issue.user.login }} - PR_CLOSED_AT: ${{ github.event.issue.closed_at }} - PR_MERGED_AT: ${{ github.event.issue.pull_request.merged_at }} - COMMENT_ID: ${{ github.event.comment.id }} - COMMENT_URL: ${{ github.event.comment.html_url }} - COMMENT_AUTHOR: ${{ github.event.comment.user.login }} - COMMENT_BODY: ${{ github.event.comment.body }} - uses: slackapi/slack-github-action@v4.0.0 - with: - webhook: ${{ secrets.SLACK_WEBHOOK_URL }} - webhook-type: webhook-trigger - payload: | - event_type: "closed_pr_comment" - repository: "${{ env.REPOSITORY }}" - created_at: "${{ env.CREATED_AT }}" - issue_number: "" - issue_title: "" - issue_url: "" - issue_author: "" - issue_body: "" - labels: "" - pr_number: "${{ env.PR_NUMBER }}" - pr_title: ${{ toJSON(env.PR_TITLE) }} - pr_url: "${{ env.PR_URL }}" - pr_author: "${{ env.PR_AUTHOR }}" - pr_state: "${{ steps.pr_state.outputs.state }}" - pr_closed_at: "${{ env.PR_CLOSED_AT }}" - pr_merged_at: "${{ env.PR_MERGED_AT }}" - comment_id: "${{ env.COMMENT_ID }}" - comment_url: "${{ env.COMMENT_URL }}" - comment_author: "${{ env.COMMENT_AUTHOR }}" - comment_body: ${{ toJSON(env.COMMENT_BODY) }} diff --git a/.github/workflows/pr-security-review.yml b/.github/workflows/pr-security-review.yml index e63e9839e..04a5f15b1 100644 --- a/.github/workflows/pr-security-review.yml +++ b/.github/workflows/pr-security-review.yml @@ -1,21 +1,8 @@ name: Claude Security Review -# This workflow inlines the security-review prompt rather than calling the -# bundled /security-review slash command. The bundled skill silently bombs -# whenever the runner's clone gets shallowed mid-run (claude-code-action's -# restoreConfigFromBase does this on every PR by design — see -# https://github.com/anthropics/claude-code-action/blob/v1/src/github/operations/restore-config.ts), -# because its first action is `git diff origin/HEAD...` and a shallow clone -# has no merge base. Computing the diff ourselves before the action starts -# eliminates that whole class of failure. - on: pull_request_target: types: [opened, reopened, synchronize, labeled] - # Only review PRs targeting our two long-lived release branches. PRs - # into short-lived feature branches don't need a security gate — they - # get reviewed when those features are merged into main or - # feat/summit_release. branches: - main - feat/summit_release @@ -23,7 +10,7 @@ on: inputs: pr_number: description: - PR number to review (workflow_dispatch will NOT post inline comments — use only for prompt smoke tests) + PR number to review (workflow_dispatch will NOT post inline comments - use only for prompt smoke tests) required: true type: string @@ -33,477 +20,10 @@ permissions: issues: write contents: read -concurrency: - # Don't cancel-in-progress: a cancelled run that has already started its labels/checkout - # but not the actual review still triggers always() steps and ends up posting a misleading - # "no findings" summary (since the inline-comment buffer is empty when the analysis step - # was skipped due to cancellation). Letting both runs complete is the safer default. - group: pr-security-review-${{ github.event.pull_request.number || inputs.pr_number }} - cancel-in-progress: false - jobs: - authorize: - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - # On 'labeled' events, only proceed when the label is exactly 'safe-to-review'. - # Other labels (e.g. size/m) are filtered out so we don't spawn API calls. - if: | - github.event_name != 'pull_request_target' || - github.event.action != 'labeled' || - github.event.label.name == 'safe-to-review' - outputs: - authorized: ${{ steps.auth.outputs.authorized || steps.dispatch-auth.outputs.authorized }} - steps: - - name: Check authorization - id: auth - if: github.event_name == 'pull_request_target' - uses: actions/github-script@v9 - with: - script: | - const isLabel = context.payload.action === 'labeled'; - const user = isLabel - ? context.payload.sender.login - : context.payload.pull_request.user.login; - const reason = isLabel ? `labeler ${user}` : `PR author ${user}`; - try { - await github.rest.teams.getMembershipForUserInOrg({ - org: context.repo.owner, - team_slug: 'agentcore-cli-devs', - username: user, - }); - core.setOutput('authorized', 'true'); - } catch { - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: user, - }); - core.setOutput('authorized', ['write', 'admin'].includes(data.permission) ? 'true' : 'false'); - } catch { - core.setOutput('authorized', 'false'); - } - } - - name: Auto-authorize workflow_dispatch - id: dispatch-auth - if: github.event_name == 'workflow_dispatch' - run: echo "authorized=true" >> "$GITHUB_OUTPUT" - - review: - needs: authorize - if: needs.authorize.outputs.authorized == 'true' - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - timeout-minutes: 30 - env: - AWS_REGION: us-west-2 - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - name: Resolve PR - id: pr - uses: actions/github-script@v9 - env: - PR_NUMBER_INPUT: ${{ inputs.pr_number }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const num = context.eventName === 'workflow_dispatch' - ? parseInt(process.env.PR_NUMBER_INPUT, 10) - : context.payload.pull_request.number; - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: num, - }); - core.setOutput('number', num); - core.setOutput('head_sha', pr.head.sha); - core.setOutput('base_ref', pr.base.ref); - - - name: Add reviewing label - uses: actions/github-script@v9 - env: - PR_NUMBER: ${{ steps.pr.outputs.number }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const prNumber = parseInt(process.env.PR_NUMBER, 10); - try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'claude-security-reviewing', - }); - } catch (e) { - if (e.status === 404) { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'claude-security-reviewing', - color: 'D73A4A', - description: 'Claude Code security review in progress', - }); - } - } - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['claude-security-reviewing'], - }); - - - name: Checkout PR head - uses: actions/checkout@v7 - with: - ref: ${{ steps.pr.outputs.head_sha }} - fetch-depth: 0 - # Safe to opt in here: the `authorize` job already gates on the PR AUTHOR having write/admin - # (getCollaboratorPermissionLevel), and this job only READS the code (compute diff, build - # prompt, run the review) — it never executes fork code (no npm ci / npm scripts). checkout@v7 - # otherwise refuses fork-PR checkout in pull_request_target, which would break the review on - # an authorized author's fork PR. Reviewing the PR head is the whole point of this job. - allow-unsafe-pr-checkout: true - - - name: Compute diff - id: diff - env: - BASE_REF: ${{ steps.pr.outputs.base_ref }} - run: | - set -euo pipefail - # Compute the diff *before* claude-code-action shallows the repo. - # The action's restoreConfigFromBase() runs `git fetch --depth=1` - # against the base branch on startup, which strips history and - # would break any base-vs-head diff after that point. Doing it - # here means the model gets a frozen artifact that can't be - # invalidated by anything the action does later. - git fetch --no-tags origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" - git diff "origin/$BASE_REF...HEAD" > /tmp/pr.diff - BYTES=$(wc -c < /tmp/pr.diff) - FILES=$(git diff --name-only "origin/$BASE_REF...HEAD" | wc -l | tr -d ' ') - echo "bytes=$BYTES" >> "$GITHUB_OUTPUT" - echo "files=$FILES" >> "$GITHUB_OUTPUT" - echo "Diff: $BYTES bytes across $FILES files" - - - name: Build prompt - if: steps.diff.outputs.bytes != '0' - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/prompt" - cat > "$RUNNER_TEMP/prompt/prompt.md" <<'PROMPT_EOF' - You are performing a HIGH-CONFIDENCE security code review of a pull - request. The complete diff is at `/tmp/pr.diff` — read it first - using the Read tool. That file is the ground truth for what the - PR changes; do not run `git diff` or any other git commands. To - understand context — callers of a changed function, existing - sanitization patterns, the project's threat model — use Grep, - Glob, and Read against the repository working tree. Do not use - Bash. - - # OBJECTIVE - - Identify HIGH-CONFIDENCE security vulnerabilities newly introduced - by this PR that have real exploitation potential. This is NOT a - general code review. Focus ONLY on security implications added by - the PR. Do not comment on pre-existing issues. - - # CRITICAL INSTRUCTIONS - - 1. MINIMIZE FALSE POSITIVES: Only flag issues where you are >80% - confident of actual exploitability. - 2. AVOID NOISE: Skip theoretical issues, style concerns, or - low-impact findings. - 3. FOCUS ON IMPACT: Prioritize vulnerabilities that lead to - unauthorized access, data breach, or system compromise. - 4. DO NOT report any of: - - Denial of service / resource exhaustion / rate limiting - - Secrets at rest on disk (handled by other tooling) - - Memory consumption or CPU exhaustion - - # CATEGORIES TO EXAMINE - - **Input validation**: SQL injection, command injection, XXE, - template injection, NoSQL injection, path traversal. - **AuthN/AuthZ**: authentication bypass, privilege escalation, - session/JWT flaws, authorization-logic bypasses. - **Crypto & secrets**: hardcoded keys/passwords/tokens, weak - algorithms, improper key storage, weak randomness, certificate - validation bypass. - **Code execution**: deserialization RCE (pickle, YAML, etc.), - eval injection, XSS (reflected/stored/DOM) — only in unsafe paths - (see precedents). - **Data exposure**: sensitive logging, PII handling violations, - API leakage, debug-info exposure. - - A finding can still be HIGH severity if only exploitable from the - local network. - - # METHODOLOGY - - Phase 1 — Repository context: identify existing security - libraries/frameworks, sanitization patterns, the project's threat - model. Use search tools. - - Phase 2 — Comparative analysis: compare new changes against - established patterns; flag deviations and net-new attack surface. - - Phase 3 — Vulnerability assessment: for each modified file, - trace user input → sensitive operations, look for unsafe privilege - boundary crossings, identify injection points. - - # FALSE-POSITIVE FILTER (apply hard) - - Read the code (Read/Grep/Glob); do not run commands to reproduce - or write files. - - HARD EXCLUSIONS — drop any finding matching: - 1. DoS / resource exhaustion. - 2. Secrets/credentials on disk if otherwise secured. - 3. Rate limiting or service overload. - 4. Memory/CPU exhaustion. - 5. Missing input validation on non-security-critical fields. - 6. Input sanitization in GitHub Actions workflows unless clearly - triggerable via untrusted input. - 7. Lack of hardening; only flag concrete vulns. - 8. Theoretical race conditions or timing attacks. - 9. Outdated third-party libraries (handled separately). - 10. Memory-safety issues in memory-safe languages (Rust, Go, - JS/TS, Python). - 11. Files that are unit tests or test-only. - 12. Log spoofing — un-sanitized user input to logs is not a vuln. - 13. SSRF that only controls the path (host/protocol control is - required). - 14. User-controlled content in AI system prompts is not a vuln. - 15. Regex injection. - 16. Regex DoS. - 17. Insecure documentation (.md and similar). - 18. Lack of audit logs. - - PRECEDENTS: - 1. Plaintext-logging high-value secrets IS a vuln; logging URLs - is assumed safe. - 2. UUIDs are unguessable and need no validation. - 3. Env vars and CLI flags are trusted inputs. - 4. Resource leaks (memory, fd) are not vulns. - 5. Tabnabbing, XS-Leaks, prototype pollution, open redirects: - only with extremely high confidence. - 6. React / Angular: do not report XSS in components or .tsx files - unless using `dangerouslySetInnerHTML`, - `bypassSecurityTrustHtml`, or equivalents. - 7. GitHub Actions workflow vulns: only when a concrete attack - path through untrusted input exists. - 8. Missing AuthN/AuthZ in client-side code is not a vuln — - validation is the server's job. - 9. MEDIUM findings only when obvious and concrete. - 10. .ipynb notebook vulns: only with a concrete attack path - through untrusted input. - 11. Logging non-PII data is not a vuln. Only flag when the data - is secrets, passwords, or PII. - 12. Command injection in shell scripts: only when there is a - concrete attack path through untrusted input. - - For each surviving finding, score confidence 1–10: - - 1–3: low / likely noise — drop - - 4–6: medium — drop unless obvious and concrete - - 7–10: high — keep - - # PROCESS - - Run this in three steps, exactly: - - 1. Spawn a Task sub-agent to identify candidate vulnerabilities. - Pass the full instructions above (objective, categories, - methodology, hard exclusions, precedents). Have it return a - structured list of candidates with file/line/category/ - description/exploit/fix. - - 2. For EACH candidate from step 1, spawn an independent Task - sub-agent IN PARALLEL to adversarially verify it. Each - verifier gets the full FALSE-POSITIVE FILTER above and is - told to default to "drop" if uncertain. Each returns a - confidence score 1–10. - - 3. Drop any finding with confidence < 8. For every finding that - survives, call: - - mcp__github_inline_comment__create_inline_comment - - with `{ path, line, body }` pointing at the exact file and - line in the diff. The body should follow: - - **: ** - - **Recommendation:** - - Do NOT post a single summary comment listing all findings — - the workflow handles a top-level summary after this run - completes. If zero findings survive Phase 3, exit without - calling any tool. - - Begin. - PROMPT_EOF - - - name: Configure AWS credentials - if: steps.diff.outputs.bytes != '0' - uses: aws-actions/configure-aws-credentials@v6 - with: - role-to-assume: ${{ secrets.BEDROCK_SECURITY_REVIEW_ROLE_ARN }} - aws-region: us-west-2 - - - name: Load prompt into env - id: load-prompt - if: steps.diff.outputs.bytes != '0' - # The action only accepts `prompt:` (a string), not a file path — - # passing prompt_file silently no-ops, leaves the action with no - # trigger, and skips the run with "No trigger found". Read the - # built prompt into an environment variable so we can pass it - # inline below. Using GITHUB_ENV with a randomized heredoc - # sentinel is the standard Actions idiom for multi-line values. - env: - PROMPT_FILE: ${{ runner.temp }}/prompt/prompt.md - run: | - set -euo pipefail - DELIM="EOF_$(uuidgen)" - { - echo "PROMPT_BODY<<$DELIM" - cat "$PROMPT_FILE" - echo "$DELIM" - } >> "$GITHUB_ENV" - - - name: Run Claude Code - id: review - if: steps.diff.outputs.bytes != '0' - uses: anthropics/claude-code-action@v1 - with: - github_token: ${{ steps.app-token.outputs.token }} - use_bedrock: 'true' - prompt: ${{ env.PROMPT_BODY }} - show_full_output: 'true' - # Read/Grep/Glob let the model explore the repo for context - # (existing sanitization patterns, threat model, callers of a - # changed function). Task is needed for the parallel verifier - # sub-agents in Phase 2. The github_inline_comment MCP tool is - # the output channel; allow-listing it is also what tells the - # action to attach the inline-comment MCP server. Bash is - # intentionally NOT allowed: the prompt forbids running - # commands, and keeping Bash off the list makes the diff at - # /tmp/pr.diff the only ground truth (no `git diff` re-runs - # against a possibly-shallow clone). - claude_args: >- - --model us.anthropic.claude-opus-4-7 --max-turns 60 --allowedTools "Read Grep Glob Task - mcp__github_inline_comment__create_inline_comment" - - - name: Verify model ran productively - id: model-ran - if: - steps.diff.outputs.bytes != '0' && (steps.review.conclusion == 'success' || steps.review.conclusion == - 'failure') - env: - OUTPUT_JSON: - ${{ steps.review.outputs.execution_file || format('{0}/claude-execution-output.json', runner.temp) }} - run: | - set -euo pipefail - if [ ! -s "$OUTPUT_JSON" ]; then - echo "::warning::No execution transcript at $OUTPUT_JSON — cannot verify" - echo "ran=unknown" >> "$GITHUB_OUTPUT" - echo "num_turns=0" >> "$GITHUB_OUTPUT" - exit 0 - fi - NUM_TURNS=$(jq -r '.[-1].num_turns // 0' "$OUTPUT_JSON") - IS_ERROR=$(jq -r '.[-1].is_error // false' "$OUTPUT_JSON") - OUTPUT_TOKENS=$(jq -r '.[-1].usage.output_tokens // 0' "$OUTPUT_JSON") - echo "num_turns=$NUM_TURNS, is_error=$IS_ERROR, output_tokens=$OUTPUT_TOKENS" - echo "num_turns=$NUM_TURNS" >> "$GITHUB_OUTPUT" - if [ "$IS_ERROR" = "true" ] || [ "$NUM_TURNS" = "0" ] || [ "$OUTPUT_TOKENS" = "0" ]; then - echo "::group::Last messages from SDK transcript" - jq -r '.[] | select(.type == "user" or .type == "system") | .message.content // .subtype' "$OUTPUT_JSON" | tail -40 - echo "::endgroup::" - echo "::error::Model did not run productively (turns=$NUM_TURNS, output_tokens=$OUTPUT_TOKENS, is_error=$IS_ERROR)" - echo "ran=false" >> "$GITHUB_OUTPUT" - exit 1 - fi - echo "ran=true" >> "$GITHUB_OUTPUT" - - - name: Count findings - id: findings - if: - steps.diff.outputs.bytes != '0' && (steps.review.conclusion == 'success' || steps.review.conclusion == - 'failure') - run: | - set -euo pipefail - BUFFER=/tmp/inline-comments-buffer.jsonl - if [ -s "$BUFFER" ]; then - COUNT=$(wc -l < "$BUFFER" | tr -d ' ') - else - COUNT=0 - fi - echo "count=$COUNT" >> "$GITHUB_OUTPUT" - echo "Buffered findings: $COUNT" - - - name: Post summary - if: always() - uses: actions/github-script@v9 - env: - PR_NUMBER: ${{ steps.pr.outputs.number }} - FINDING_COUNT: ${{ steps.findings.outputs.count }} - REVIEW_CONCLUSION: ${{ steps.review.conclusion }} - MODEL_RAN: ${{ steps.model-ran.outputs.ran }} - NUM_TURNS: ${{ steps.model-ran.outputs.num_turns }} - DIFF_BYTES: ${{ steps.diff.outputs.bytes }} - DIFF_FILES: ${{ steps.diff.outputs.files }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const prNumber = parseInt(process.env.PR_NUMBER, 10); - const count = parseInt(process.env.FINDING_COUNT || '0', 10); - const conclusion = process.env.REVIEW_CONCLUSION || 'skipped'; - const modelRan = process.env.MODEL_RAN || 'unknown'; - const numTurns = process.env.NUM_TURNS || '0'; - const runUrl = process.env.RUN_URL; - const diffBytes = parseInt(process.env.DIFF_BYTES || '0', 10); - - let body; - if (diffBytes === 0) { - body = `**Claude Security Review:** PR has an empty diff against base — nothing to review. ([run](${runUrl}))`; - } else if (modelRan !== 'true') { - body = `**Claude Security Review:** the review did not analyze this PR (model took ${numTurns} turn${numTurns === '1' ? '' : 's'}). See the [run](${runUrl}) for details; a later push or re-run is needed.`; - } else if (conclusion === 'success') { - body = count > 0 - ? `**Claude Security Review:** posted ${count} inline finding${count === 1 ? '' : 's'} on this PR. ([run](${runUrl}))` - : `**Claude Security Review:** no high-confidence findings. ([run](${runUrl}))`; - } else if (conclusion === 'failure') { - body = `**Claude Security Review:** the review run failed before completing. See the [run](${runUrl}) for details.`; - } else { - body = `**Claude Security Review:** the review run was ${conclusion} before analysis could complete. See the [run](${runUrl}); a later run on this PR will replace this status.`; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }); - - - name: Remove reviewing label - if: always() - uses: actions/github-script@v9 - env: - PR_NUMBER: ${{ steps.pr.outputs.number }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const prNumber = parseInt(process.env.PR_NUMBER, 10); - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: 'claude-security-reviewing', - }); - } catch (error) { - console.log('Label removal failed (may not exist):', error.message); - } + call: + uses: aws/agentcore-devx-devtools/.github/workflows/reusable-pr-security-review.yml@458c0a684af0f9e3a013ec05cd23851def4f9cab + with: + runner: codebuild + pr_number: ${{ inputs.pr_number || format('{0}', github.event.pull_request.number) }} + secrets: inherit diff --git a/.github/workflows/pr-size-title.yml b/.github/workflows/pr-size-title.yml new file mode 100644 index 000000000..92260f7b5 --- /dev/null +++ b/.github/workflows/pr-size-title.yml @@ -0,0 +1,18 @@ +name: PR Size and Title + +on: + pull_request_target: + branches: [main, feat/**] + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + statuses: write + +jobs: + call: + uses: aws/agentcore-devx-devtools/.github/workflows/reusable-pr-size-title.yml@458c0a684af0f9e3a013ec05cd23851def4f9cab + with: + runner: codebuild + secrets: inherit diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml deleted file mode 100644 index 2c8e59063..000000000 --- a/.github/workflows/pr-size.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: PR Size Check and Label - -# Uses pull_request_target so it works on fork PRs (write access to labels). -# Safe because this workflow only reads PR metadata — it never checks out untrusted code. -on: - pull_request_target: - branches: [main, feat/**] - -jobs: - label-size: - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - permissions: - pull-requests: write - steps: - - name: Calculate PR size and apply label - uses: actions/github-script@v9 - with: - script: | - const prNumber = context.payload.pull_request.number; - const { owner, repo } = context.repo; - - // Fetch file-level stats so we can exclude generated/lock files - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, - repo, - pull_number: prNumber, - per_page: 100 - }); - - const EXCLUDED = [ - /package-lock\.json$/, - /yarn\.lock$/, - /pnpm-lock\.yaml$/, - /\.snap$/ - ]; - - let additions = 0; - let deletions = 0; - for (const file of files) { - if (EXCLUDED.some(re => re.test(file.filename))) continue; - additions += file.additions; - deletions += file.deletions; - } - const totalChanges = additions + deletions; - - // Remove existing size labels (catch 404 in case of race) - const labels = await github.rest.issues.listLabelsOnIssue({ - owner, repo, issue_number: prNumber - }); - - for (const label of labels.data) { - if (label.name.startsWith('size/')) { - try { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: prNumber, name: label.name - }); - } catch (e) { - if (e.status !== 404) throw e; - } - } - } - - // Determine size label - let sizeLabel; - if (totalChanges <= 20) sizeLabel = 'size/xs'; - else if (totalChanges <= 100) sizeLabel = 'size/s'; - else if (totalChanges <= 500) sizeLabel = 'size/m'; - else if (totalChanges <= 1000) sizeLabel = 'size/l'; - else sizeLabel = 'size/xl'; - - // Ensure the label exists before applying it - const LABEL_COLORS = { - 'size/xs': '3CBF00', - 'size/s': '5D9801', - 'size/m': 'FBCA04', - 'size/l': 'E67409', - 'size/xl': 'D93F0B' - }; - - try { - await github.rest.issues.getLabel({ owner, repo, name: sizeLabel }); - } catch (e) { - if (e.status === 404) { - await github.rest.issues.createLabel({ - owner, repo, - name: sizeLabel, - color: LABEL_COLORS[sizeLabel], - description: `PR size: ${sizeLabel.split('/')[1].toUpperCase()}` - }); - } else { - throw e; - } - } - - await github.rest.issues.addLabels({ - owner, repo, issue_number: prNumber, labels: [sizeLabel] - }); - - core.info(`PR has ${totalChanges} changed lines (excluding lockfiles/snapshots) → ${sizeLabel}`); - - if (sizeLabel === 'size/xl') { - core.warning(`PR is large (${totalChanges} lines). Consider splitting into smaller PRs.`); - } diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml deleted file mode 100644 index eb2bb34e7..000000000 --- a/.github/workflows/pr-title.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Validate PR Title - -on: - pull_request_target: - branches: [main, feat/**] - types: [opened, edited, synchronize, reopened] - -permissions: - statuses: write - -jobs: - validate-pr-title: - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - permissions: - pull-requests: read - steps: - - uses: amannn/action-semantic-pull-request@v6.1.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - # Types aligned with this repo's commit conventions (see AGENTS.md) - types: | - feat - fix - docs - style - refactor - perf - test - build - ci - chore - revert - requireScope: false - # Repo convention: lowercase subjects (e.g., "feat: add new command") - subjectPattern: ^[a-z].+$ - subjectPatternError: | - The subject "{subject}" found in the pull request title "{title}" - must start with a lowercase letter. - Example: "feat: add deploy command" - # Validate the commit message when a PR has a single commit, since - # GitHub suggests using it as the merge commit message on squash-merge - validateSingleCommit: true - validateSingleCommitMatchesPrTitle: true - # Skip validation for bot/dependency PRs - ignoreLabels: | - bot - dependencies diff --git a/.github/workflows/slack-issue-notification.yml b/.github/workflows/slack-issue-notification.yml new file mode 100644 index 000000000..eb229c2ff --- /dev/null +++ b/.github/workflows/slack-issue-notification.yml @@ -0,0 +1,20 @@ +name: Slack Issue Notification + +on: + issues: + types: [opened] + issue_comment: + types: [created] + +permissions: + contents: read + id-token: write + pull-requests: write + issues: read + +jobs: + call: + uses: aws/agentcore-devx-devtools/.github/workflows/reusable-slack-issue-notification.yml@458c0a684af0f9e3a013ec05cd23851def4f9cab + with: + runner: codebuild + secrets: inherit diff --git a/.github/workflows/slack-open-prs-notification.yml b/.github/workflows/slack-open-prs-notification.yml index 5fa1ffc8b..a13609ecf 100644 --- a/.github/workflows/slack-open-prs-notification.yml +++ b/.github/workflows/slack-open-prs-notification.yml @@ -2,50 +2,17 @@ name: Slack Open PRs Notification on: schedule: - - cron: '0 13 * * *' # 8:00 AM EST (13:00 UTC) + - cron: '0 13 * * *' # 8:00 AM EST / 9:00 AM EDT (13:00 UTC) workflow_dispatch: permissions: + contents: read pull-requests: read + id-token: write jobs: - notify-slack: - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - steps: - - name: Get open PRs - id: open-prs - uses: actions/github-script@v9 - with: - script: | - const { data: prs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - }); - - const count = prs.length; - - // Format each PR with plain text and bare URL (Slack auto-links URLs) - const prList = prs.map(pr => - `• #${pr.number} - ${pr.title} (by ${pr.user.login})\n ${pr.html_url}` - ).join('\n'); - - core.setOutput('count', count); - - // Use GITHUB_OUTPUT delimiter for multiline support - const fs = require('fs'); - fs.appendFileSync( - process.env.GITHUB_OUTPUT, - `pr_list<