Skip to content

Build/CI supply chain: unverified upstream script execution, schema-ref path traversal, and manifest-key TS injection #2425

Description

@bokelley

Found while verifying codex-security scan candidates. All four confirmed by reading the code; two were reproduced by executing the cited functions verbatim.

Shared trust boundary, stated up front: every item here requires control of adcontextprotocol.org, push access to adcontextprotocol/adcp, or HTTPS MITM. No untrusted third party can trigger them. That is why these are Medium rather than High — but each converts "I control the schema bundle" into "I execute code on developer machines and in privileged CI jobs," which is a meaningful escalation.

1. sync-agent-roles.yml executes a script it just downloaded (candidate-8eb3c32656ac1129)

.github/workflows/sync-agent-roles.yml:31-42 fetches refs/heads/main.tar.gze from adcontextprotocol/adcp unpinned, copies scripts/import-claude-agents.mjs out of it at :38, and runs it with Node at :42 — in a job holding contents: write + pull-requests: write. actions/checkout@v6 at :21 defaults to persist-credentials: true, so the job token is in .git/config where the downloaded script can read it.

The fix is free: the script is already committed at scripts/import-claude-agents.mjs, and validate-agent-roles-sync.yml proves it runs standalone. Regenerate with the HEAD copy before overwriting it — the upstream change then rides in the PR and only executes after a human approves it. Add persist-credentials: false too.

2. generate-manifest-derived.ts interpolates manifest keys as TypeScript syntax (candidate-52775008039b6fc4)

scripts/generate-manifest-derived.ts has two unquoted interpolation sites, and loadManifest (:86) validates only that three sections exist:

  • :159` ${code}: {\n description: …` emits a manifest key as a bare object property name.
  • :168 + :248`export const ${constName} = [` where protocolNameToConst only does .replace(/-/g,'_').toUpperCase(), so ;, =, and spaces survive into identifier position.

Both breakout shapes were compiled with the repo's own tsc --strict and parse clean. A working key for the first site closes the object literal, runs arbitrary statements, and reopens so the trailing } as const satisfies Record<…> still balances.

This is the one that matters most, because output is src/lib/types/manifest.generated.ts → imported by src/lib/types/error-codes.ts → bundled by build:libshipped. Injected statements execute at import time in every @adcp/sdk consumer process. It is the only item here where upstream-bundle control becomes code injection rather than data injection.

Review posture makes it worse: schema-sync.yml commits the regenerated file into an auto-PR, and CLAUDE.md explicitly tells reviewers not to read generated files. ci.yml:54 does fail on drift, so it's detected — but detection lands in a PR nobody is expected to read.

The fix pattern is already in the same file: the specialism block at :174 uses JSON.stringify(id) for its key. Do that at :159, and regex-validate error-code and protocol keys in loadManifest.

3. refToLocalPath writes outside the schema cache (candidate-8ea8e922cc44de82)

scripts/sync-schemas.ts:528-536. Executed verbatim:

/schemas/3.1.8/../../../../.npmrc          ->  /.npmrc
/schemas/3.1.8/../../.git/hooks/pre-commit ->  <repo>/schemas/.git/hooks/pre-commit

The .. normalizes away in the fetch URL (WHATWG parsing), so the attacker serves the payload at the normalized path — which they can, since they control the origin. Written content is always JSON, but that is not a constraint: package.json is JSON, and a "scripts": {"prepare": …} overwrite is code execution on the next npm install.

Gated behind the tarball endpoint 404ing (:371-375), though the same origin controls that too.

Fix: path.resolve + startsWith(cacheDir + path.sep) containment throw.

scripts/generate-3-1-beta-types.ts:63-76 (candidate-3e0df3f4af77ca14) has the same shape on the read side into readFileSync. Strictly lower severity — 3.1-beta appears in zero workflow files, so it needs a human running npm run generate-types:3.1-beta — but fix both together. While in that file: the JSDoc at :21-22 claims "CI's 'validate generated files in sync' gate runs both"; ci.yml:47-59 runs generate-types and generate-types:v2.5 only, so the stated guarantee doesn't exist.

4. Cosign verification is present only where it cannot protect anything (candidate-b4fe9a2c37f81153)

sync-schemas.ts:369 derives shaUrl as tgzUrl + '.sha256' — digest and artifact share an origin, so it's integrity, never authenticity. verifyCosignSignature skips at :194 (latest), :207 (404 sidecar), and :216-224 (cosign binary absent → warn and continue).

Which callers actually have cosign is the part the scan missed:

Workflow cosign installed? What it holds
ci.yml:36 yes (SHA-pinned installer) can only fail a build
schema-sync.yml no contents: write, opens the schema-bump PR
release.yml no npm OIDC publish + IPR App token

Signature verification is present exactly where it can't protect an artifact and absent in both places that produce one. Fix: install cosign in both, and fail closed via an ADCP_REQUIRE_SIGNATURE=1 style gate rather than warn-and-continue.

Correcting one sub-claim: the latest skip is currently unreachable (ADCP_VERSION is pinned to a release; no caller passes latest) — latent, not live. No preinstall/postinstall runs any of this, so npm consumers are unaffected; dev-and-CI only.

Related, lower priority

  • ai-review.yml:99 passes raw SECRETARIAT_APP_PRIVATE_KEY even though :42-45 already minted a scoped installation token in the same job. Pass steps.app-token.outputs.token instead — with the caveat that the Ladon composite may need the key to mint tokens with different permission sets, worth checking first.
  • ipr-agreement.yml:20 calls adcontextprotocol/adcp/.github/workflows/ipr-check-callable.yml@main and passes IPR_APP_PRIVATE_KEY — a raw, long-lived, org-wide App private key crossing a repo boundary, where release.yml:37-41 already demonstrates the scoped-installation-token pattern. SHA-pinning would defeat the purpose (the IPR ledger must track upstream), so this is really a governance question: whether the write-access sets for adcp and adcp-client are identical. If the spec repo has broader write access, this is a one-way trust grant that should be explicit.
  • Not a finding, recorded to close it out: the "unpinned action" framing does not hold as a repo-wide criticism. Exactly 1 of ~28 uses: is SHA-pinned, so there is no convention being violated, and the flagged action is same-org. Three genuinely third-party actions on mutable major tags receive higher-value secrets (peter-evans/slash-command-dispatch@v5 and peter-evans/create-or-update-comment@v5 get a repo-scope PAT; changesets/action@v1 gets an App token in a job with npm publish rights). If pinning is worth doing, start there, not with ladon/review.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions