fix: pin the PR diff to a commit SHA, deny Bash outright, tighten secret redaction - #50
Conversation
Follow-up to #49. #49 closed the reported TOCTOU on `issues` events; this closes the same class of hole in the PR diff path and hardens the privilege and secret-handling claims #49 makes. Trust boundary: - prompt-builder: `fetchPRDiffContext` read live PR state, so a fork PR author could push new commits between a maintainer authorizing the run and the diff fetch, landing attacker-authored patch text in the privileged prompt. This is the same shape as V2337707056 with the diff instead of comments as the carrier. The diff is now pinned to a commit SHA - from the immutable webhook payload for `pull_request*` events, resolved once otherwise - read via compareCommits rather than listFiles, and omitted entirely if the head commit is dated at or after the authorizing event. The SHA is recorded in the prompt so the analyzed snapshot is auditable. Requires a valid cutoff, fails closed. A truncated diff now says so instead of silently under-reporting; the old listFiles call also capped at 30 files with no notice. - prompt-builder: move the fail-closed guard into `filterCommentsByTriggerTime`, which still returned every comment on a falsy cutoff. #49 guarded the one call site; the invariant now lives in the function so a future caller cannot reopen it. - prompt-builder: fence untrusted sections with an explicit data-not- instructions notice. Model instruction, not a control - labelled as such. Privilege: - mcp-config: add `getDisallowedToolsForClaude()` (Bash, WebFetch, WebSearch), wired through a new `disallowed_tools` action output to claude-code-base-action. #49 removed the `Bash(cat:*)` grants but left the boundary resting on the CLI defaulting to deny un-allowed tools, while the file's own comment claimed the opposite ("Bash tools are already allowed by claude-code-base-action"). Bash arguments cannot be path-scoped, so any grant reaches every file the runner user can read. Denied explicitly; stale comment removed. - mcp-config: scope `LS`, which was the one file tool with no path pattern, and drop the stray leading slash that produced `Read(//home/runner/...)`. - template: `persist-credentials: false` on checkout. The default writes an `AUTHORIZATION: basic <base64>` extraheader into `.git/config`, inside the directory the agent's file tools are scoped to, in a form value-based redaction misses. The action uses the REST API, so nothing needs it. Secrets: - prepare-claude-config: `chmodSync` after the write. `writeFileSync`'s `mode` only applies when it creates the file, so on a reused RUNNER_TEMP path the old mode survived and #49's 0600 silently did not apply. - post-result: add ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN, an `sk-ant-` pattern, and the base64 and git-extraheader forms of known secret values. - init: `core.setSecret` on the resolved token so Actions masks it; it is exposed as a step output that downstream steps need. Correctness: - post-result: narrow `isBackgroundAgentPlaceholder`. It matched "waiting on the background" followed by "agent" anywhere in the result, and the bare phrase "background exploration agent", then discarded the entire result. Since the result is influenced by issue and comment text, anyone could suppress an investigation by getting the phrase echoed, and legitimate results discussing background agents were eaten. Now requires a short result with the phrase at the start. Docs: - security.md: split protections into enforced-in-code and best-effort model instruction, and correct two claims from #49 - the 0600 mode does not keep the credential file from the agent (same OS user; the path scoping and Bash denial do), and redaction is a backstop that misses transformed values and only covers the comment channel. Add a residual-risk section for the `issue_comment`-on-PR window, and document that `allowed_non_write_users: '*'` removes the trust boundary entirely. Tests: 187 pass, up from 175. Adds coverage for the diff SHA pinning and late-push rejection, diff truncation, fail-closed filtering at the function level, the LS scope and single-slash patterns, the Bash denial, the redaction additions, and the placeholder false positives.
…t base.sha Three defects in the previous commit, found while validating it. 1. Truncation detection was dead logic. `allFiles.length > files.length` can never be true, since GitHub caps compare `files` at 300 and the slice is also 300; `total_commits > MAX_DIFF_FILES` compared a commit count against a file limit. A response of exactly 300 is the only signal the API offers, so that is what is used now, with a warning logged. It can over-warn on a PR touching exactly 300 files, which is the safe direction. Also removed `per_page: 300` from the compareCommits call: on that endpoint per_page paginates commits rather than files, and 100 is the maximum accepted value, so the argument was both ineffective and out of range. The test for this passed against a 301-file mock, which the API cannot return. Replaced with 300-file (capped) and 299-file (not capped) cases. 2. The residual-risk note named `pull_request_review` as the windowless trigger. `init.js` has no branch for that event, so it never fires, and prompt-builder's cutoff for it is unreachable today. The event that does fire and does carry the full pull_request object - so the head SHA is part of the authorizing event and no live lookup happens - is `pull_request_review_comment`. Corrected in docs and the function comment, and covered by a test asserting pulls.get is never called on that path. 3. Documented why the base endpoint is `pull_request.base.sha` rather than the base branch name: both ends immutable means the comparison is reproducible. The diff diverges from GitHub's "Files changed" only when the PR author merged the base branch into their branch, in which case it is larger by those base-branch commits. That content comes from the protected base branch, so it is maintainer-authored and not a new injection surface. Also documented that the head-commit date check reads a committer-supplied timestamp: forgeable by an attacker, and a fast contributor clock can get a legitimate commit rejected. It is a tripwire that fails closed, not a boundary. Tests: 189 pass, up from 187.
3379e79 to
57a2bc7
Compare
How to validate this with a live runTwo prerequisites, because the action needs an AWS account for both the model and the telemetry:
1. Point a workflow at this branch
name: awsapm (PR 50 validation)
on:
issue_comment:
types: [created, edited]
issues:
types: [opened, assigned, edited]
jobs:
awsapm-investigation:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@awsapm')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@awsapm') || contains(github.event.issue.title, '@awsapm')))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false # part of what this PR changes
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
aws-region: ${{ vars.AWS_REGION || 'us-east-1' }}
- name: Prepare Investigation Context
id: prepare
uses: aws-actions/application-observability-for-aws@harden/pr-diff-toctou-and-least-privilege
with:
bot_name: "@awsapm"
cli_tool: "claude_code"
- name: Run Claude Investigation
id: claude
uses: anthropics/claude-code-base-action@beta
with:
use_bedrock: "true"
model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
prompt_file: ${{ steps.prepare.outputs.prompt_file }}
mcp_config: ${{ steps.prepare.outputs.mcp_config_file }}
allowed_tools: ${{ steps.prepare.outputs.allowed_tools }}
disallowed_tools: ${{ steps.prepare.outputs.disallowed_tools }} # new in this PR
- name: Post Investigation Results
if: always()
uses: aws-actions/application-observability-for-aws@harden/pr-diff-toctou-and-least-privilege
with:
cli_tool: "claude_code"
comment_id: ${{ steps.prepare.outputs.awsapm_comment_id }}
output_file: ${{ steps.claude.outputs.execution_file }}
output_status: ${{ steps.claude.outputs.conclusion }}Note: if an existing 2. Test A — does an ordinary investigation still work?Open an issue whose body names a real service in your account: Check three things:
Item 1 is the one worth judging carefully — Bash is now denied, so if the agent needed it, quality drops here. 3. Test B — the security fix, on a fork PRThis is the part unit tests cannot reach. Needs a PR from a fork, not a same-repo branch.
If step 5 shows the new commit's contents instead, the fix is not working. 4. What a failure means
5. CleanupDelete the validation workflow, the test issue, and any tracking comments the bot posted. If an OIDC role was created for the test, remove that too. |
Summary
Follow-up to #49 (shipped in v1.2.0 via #51). Rebased onto
main; the diff here is only new work.#49 closed the reported TOCTOU on
issuesevents. This PR does three things:docs/security.mdthat overstate what the code does.1. The PR diff is the reported vulnerability with a different carrier
fetchPRDiffContextread live PR state viapulls.listFiles, with no SHA pin and no timestamp binding, and the returnedpatchtext goes verbatim into<changed_files>in the agent prompt. The exploit path is the ordinary review flow:@awsapm review this PR. Authorization is checked against the maintainer.pulls.listFilesand receives the new diff.Same shape as V2337707056, with the diff rather than comments as the carrier.
Fix. The diff is pinned to a commit SHA instead of read from live state:
pull_request_review_commentcarries the fullpull_requestobject in its payload, sohead.shais part of the authorizing event and no live read happens at all.issue_commenton a PR carries no head SHA, so it is resolved once and every read is pinned to it.repos.compareCommitsreplacespulls.listFiles, becauselistFilesalways returns live PR state and cannot be pinned.Base endpoint.
pull_request.base.shais used rather than the branch name, so both ends are immutable and the comparison is reproducible. Three-dot compare derives the merge base from it, which matches the merge base against the current branch tip in the normal case. They diverge only when the PR author merged the base branch into their branch, where the diff is larger by those base-branch commits — content from the protected base branch, so maintainer-authored, not a new injection surface.Coverage trade-off, stated.
compareCommitscapsfilesat 300 and reports no total, so a full-length response is the only truncation signal available; a capped diff is now labelled in the prompt.listFilespaginates to 3000 but cannot be pinned. In practice this is 300 vs the 30 the shipped code actually saw, since it never paginated.2. Bash is denied outright, not left to a CLI default
src/config/mcp-config.jsstill carried the comment "Bash tools are already allowed by claude-code-base-action." If that were true, #49 removing theBash(cat:*)grants would have been a no-op and the exfiltration path in the report would still be open.Upstream check:
claude-code-base-actionpasses["-p","--verbose","--output-format","stream-json"]plus--allowedTools, with no--dangerously-skip-permissions, andsetup-claude-code-settings.tswrites onlyenableAllProjectMcpServers. So Bash should be denied — but only because the CLI defaults that way, and@betapins the CLI to1.0.88, making that a version-specific behaviour the action does not control.New
getDisallowedToolsForClaude()→ newdisallowed_toolsaction output →claude-code-base-action'sdisallowed_toolsinput, which becomes--disallowedToolsand takes precedence over the allow list. Denies:Bash— its arguments cannot be path-scoped, so any grant reaches every file the runner user can read, including the generated MCP credential config.WebFetch/WebSearch— a direct exfiltration channel for anything the agent has read, and the investigation needs neither.Stale comment removed. Also:
LSwas the only file tool with no path scope, which is the reconnaissance step for locating the credential config, and the patterns carried a stray leading slash producingRead(//home/runner/...). Both fixed.3.
.git/configholds a usable token inside the agent's read scopetemplate/awsapm.yamlchecked out with the defaultpersist-credentials: true, which writesAUTHORIZATION: basic <base64(x-access-token:TOKEN)>into.git/config— inside the directory the agent's file tools are scoped to, in a base64 form that value-based redaction does not match. That token carriescontents: write,issues: write,pull-requests: write.Set
persist-credentials: false. The action talks to GitHub through the REST API, so nothing needs git credentials.4. The
0600mode silently did not applyfs.writeFileSync'smodeapplies only when it creates the file. On an existing path it is ignored:RUNNER_TEMPpersists across steps within a job and is reused on self-hosted runners, so the old mode survived. Addedfs.chmodSync.Separately, the mode was never what kept that file from the agent — the agent runs as the same OS user that writes it. The path placement plus the Bash denial are what do that. Code comments and docs corrected accordingly.
5. Redaction gaps
AKIA…/ASIA…,gh*_, lowercase MCP JSONAUTHORIZATION: basic <base64>(the.git/configform)sk-ant-…ANTHROPIC_API_KEYandCLAUDE_CODE_OAUTH_TOKENwere absent fromsecretEnvVarsdespite both being supported auth paths forclaude-code-base-action. Added.The remaining bypasses are inherent to regex redaction. It also covers only one channel: the agent holds
mcp__github__create_or_update_fileandpush_fileswithcontents: write, so it could commit rather than comment. Documented as a backstop, not a boundary.6. Two correctness fixes
isBackgroundAgentPlaceholderwas too broad. It matchedwaiting on the background…agentanywhere in the result, plus the bare phrasebackground exploration agent, then discarded the entire result. Confirmed false positives:"## Root Cause\nThe worker is waiting on the background queue.\n\n## Fix\nRestart the agent.""The action disables the built-in background exploration agent via an env var."Because the result is influenced by issue and comment text, anyone could suppress an investigation by getting the phrase echoed. Now requires a short result with the phrase at the start.
filterCommentsByTriggerTimestill failed open. It returned every comment on a falsy cutoff, and it is exported. #49 guarded the single call site; the guard now lives inside the function so a future caller cannot reopen it.7. Docs
docs/security.mdnow separates protections enforced in code from best-effort model instructions, since the latter are exactly what a prompt-injection attack targets. Two claims from #49 are corrected:0600mode is not why the credential config is unreachable from the agent (same OS user); the path scoping and Bash denial are.Adds a Residual risk section, and documents that
allowed_non_write_users: '*'allows any user to trigger the action, removing the trust boundary entirely. That wildcard was undocumented in bothaction.ymland the docs.Also adds an untrusted-data notice fencing the user-content sections of the prompt. This is a model instruction, not a control, and the docs classify it that way.
Verification
Unit tests: 189 pass, up from 175 on
main. New coverage: diff SHA pinning, late-push rejection, the no-live-read path forpull_request_review_comment, diff capping at and below the limit, fail-closed filtering at the function level, theLSscope and single-slash patterns, the Bash denial, each redaction addition, and the placeholder false positives.The reported vulnerability, reproduced and confirmed fixed. Using the VDP reporter's own harness from the V2337707056 attachment, which drives the real
init.js→prepare-claude-config.js→post-result.js:Bash(cat:*)granted23ce62f(pre-#49)Four mechanical adaptations were required, none touching the injection-detection logic: no-op
setSecret/exportVariableon the@actions/corestub (both are called by #49 and this PR), parameterising theassert(catAutoApproved)expectation since that grant is the thing being removed, and skipping the Linux-only/sys/class/netcheck when not containerised. Docker was unavailable so it ran under local Node; the harness makes no network calls.That run also confirms the full real chain still executes, including
chmodSyncagainst a real filesystem andpost-resultparsing a real execution file — neither of which the Jest mocks cover.The PR diff path, end to end through the real
init.js. The reporter's harness setsissue.pull_request = null, so it never reachesfetchPRDiffContext. A separate check covers it:23ce62flistFiles, not pinnedlistFiles, not pinnedissue_commentissue_commentpull_request_review_commentpull_request_review_commentThe fork-PR assumption, against real GitHub. The riskiest assumption here is that
compareCommitson the base repo can resolve a head SHA living in a contributor's fork. If it cannot, the fetch fails, the code returns null, and PR review silently proceeds with no knowledge of the changes. Mocks cannot catch that, so it was checked against 10 real fork PRs across 4 repositories, including this one:All resolve and return patches, including the
divergedcase (the base-branch-moved scenario described in section 1).The resulting file set was also compared against
pulls/{n}/files— what the UI shows — for 7 of these. It matched exactly every time, diverged case included, so usingbase.sharather than the branch name does not change what the agent sees in practice.Output-name wiring.
disallowed_toolstraverses the same three hops asallowed_tools(core.setOutput→action.ymloutput mapping → template input), with names verified matching at each hop.allowed_toolsalready works in production over that exact path, so this is not a new mechanism.Not verified
No live GitHub Actions run. #49 had one; this does not. Two things remain open:
1.0.88) acceptsLS(<path>/**)as a permission rule. The failure mode is benign: an unrecognised rule meansLSis simply not granted,LSis read-only, and the scopedGlob/Grepgrants cover the same ground. Worst case is marginally less capability, not a break and not a security gap.Bashmeasurably degrades investigation quality. This is a quality question rather than a correctness one, and fix: harden issues-event comment trust boundary, reduce agent privilege, keep headless runs synchronous #49 already removed the Bash grants that mattered.Reproduction steps for a live run are in a comment below. Neither verification script is committed here: the first is derived from a VDP submission and is not mine to publish, and the second is a research artifact rather than a test. Both can be attached to V2337707056.
Out of scope, noted for follow-up
core.setOutput('GITHUB_TOKEN', …)insrc/init.jspublishes the token as an action output.action.ymlconsumes it internally at three points and re-exposes it as thegithub_tokenoutput, so removing it is a breaking change.core.setSecretis added here so Actions masks it in logs meanwhile.containsTriggerPhraseinsrc/init.jshardcodes@awsapmand ignores thebot_nameinput.bot_nameonly strips the name from the request text; it does not control what triggers the action. A caller settingbot_name: "@myteam"is still triggered by@awsapmand never by@myteam.