Skip to content

fix(release): pin mcp-publisher + verify signature before install - #68

Merged
asachs01 merged 1 commit into
mainfrom
fix/mcp-publisher-verify-signature
Aug 27, 2026
Merged

fix(release): pin mcp-publisher + verify signature before install#68
asachs01 merged 1 commit into
mainfrom
fix/mcp-publisher-verify-signature

Conversation

@asachs01

@asachs01 asachs01 commented Aug 26, 2026

Copy link
Copy Markdown
Member

Vulnerability

mcp-server-release.yml's mcp-registry job (called by every *-mcp repo on
every release) runs with permissions: id-token: write (GitHub OIDC token)
and publish rights to the MCP Registry. The "Install mcp-publisher" step did:

curl -fsSL \
  "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" \
  | tar xz mcp-publisher

No version pin, no checksum, no signature verification — it downloads and
extracts whatever is currently tagged latest on
modelcontextprotocol/registry's releases, then the workflow immediately runs
./mcp-publisher login github-oidc and ./mcp-publisher publish with that
binary, using the job's OIDC token. If upstream's release pipeline (or a
single release asset) were ever compromised, this executes arbitrary code
with our publish credentials, fleet-wide, on every release.

Credit: reported by murph, found while investigating an unrelated, older
stale task about mcp-publisher signature verification.

Fix

  • Pin mcp-publisher to an exact release tag (v1.8.1, current latest as of
    today) instead of .../releases/latest/download/....
  • Install cosign via sigstore/cosign-installer, SHA-pinned
    (6f9f17788090df1f26f669e9d70d6ae9567deba6 = v4.1.2), matching this
    file's existing action-pinning convention (actions/checkout@<sha> # vX.Y.Z
    etc.).
  • Download the pinned tarball and its official .sigstore.json bundle
    (there's no plain checksums.txt for the mcp-publisher binary family —
    only Sigstore bundles + SBOMs — verified live via
    gh api repos/modelcontextprotocol/registry/releases/tags/v1.8.1).
  • Run cosign verify-blob against the tarball using that bundle, before
    extracting or executing anything. Verification failure propagates cosign's
    own non-zero exit and fails the step (no swallowing).
  • Then extract and proceed exactly as before.

New step (replaces "Install mcp-publisher"):

- name: Install cosign
  uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6  # v4.1.2

- name: Download mcp-publisher (pinned release)
  env:
    MCP_PUBLISHER_VERSION: v1.8.1
    MCP_PUBLISHER_ASSET: mcp-publisher_linux_amd64.tar.gz
  run: |
    BASE_URL="https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}"
    curl -fsSL -o "${MCP_PUBLISHER_ASSET}"               "${BASE_URL}/${MCP_PUBLISHER_ASSET}"
    curl -fsSL -o "${MCP_PUBLISHER_ASSET}.sigstore.json" "${BASE_URL}/${MCP_PUBLISHER_ASSET}.sigstore.json"

- name: Verify mcp-publisher signature (cosign / Sigstore keyless)
  env:
    MCP_PUBLISHER_VERSION: v1.8.1
    MCP_PUBLISHER_ASSET: mcp-publisher_linux_amd64.tar.gz
  run: |
    cosign verify-blob \
      --bundle "${MCP_PUBLISHER_ASSET}.sigstore.json" \
      --certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/${MCP_PUBLISHER_VERSION}" \
      --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
      "${MCP_PUBLISHER_ASSET}"

- name: Extract mcp-publisher
  env:
    MCP_PUBLISHER_ASSET: mcp-publisher_linux_amd64.tar.gz
  run: tar xz -f "${MCP_PUBLISHER_ASSET}" mcp-publisher

Hardcoded to linux_amd64: this job always runs on ubuntu-latest
(GitHub-hosted x86_64), so the old uname-based OS/arch detection is dropped
as unneeded complexity for a pinned single-version install. Flagged in-file:
if this workflow ever moves to an ARM runner, MCP_PUBLISHER_ASSET must
change too — it will not auto-detect the way the old curl command did.

Where the identity/issuer came from (not guessed)

Read directly out of the real signing certificate embedded in
mcp-publisher_linux_amd64.tar.gz.sigstore.json for v1.8.1
(openssl x509 -text on the decoded cert):

X509v3 Subject Alternative Name: critical
    URI:https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/v1.8.1
1.3.6.1.4.1.57264.1.1 (OIDC Issuer):
    https://token.actions.githubusercontent.com
1.3.6.1.4.1.57264.1.11 (Runner Environment):
    github-hosted
1.3.6.1.4.1.57264.1.5 (Source Repository):
    modelcontextprotocol/registry

Cross-checked against upstream's own .github/workflows/release.yml
(on: release: types: [published], goreleaser job) and .goreleaser.yaml
(signs: section running cosign sign-blob --bundle=... --yes in keyless
mode on the archive artifacts) — confirms the cert's
.github/workflows/release.yml@refs/tags/vX.Y.Z SAN shape is the actual
signer, not an assumption. Used the exact identity string for the pinned
tag (not a broad regex) since the version is already pinned — no reason to
accept a wider match.

Verification — positive and negative controls (evidence, not just "it worked")

Run in an isolated scratch dir, cosign v3.1.3, against the real v1.8.1
release assets:

Positive control — real tarball + real bundle:

$ cosign verify-blob \
    --bundle mcp-publisher_linux_amd64.tar.gz.sigstore.json \
    --certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/v1.8.1" \
    --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
    mcp-publisher_linux_amd64.tar.gz
Verified OK
EXIT CODE: 0

Negative control A — single-byte flip in the tarball, same bundle:

Error: failed to verify signature: could not verify message: invalid signature when validating ASN.1 encoded signature
error during command execution: failed to verify signature: could not verify message: invalid signature when validating ASN.1 encoded signature
EXIT CODE: 1

Negative control B — substituted a different, legitimately-signed asset
(mcp-publisher_linux_arm64.tar.gz) against the linux_amd64 bundle:

Error: failed to verify signature: could not verify message: invalid signature when validating ASN.1 encoded signature
EXIT CODE: 1

Negative control C — correct file + bundle, wrong --certificate-identity
(wrong tag), to prove the identity check itself discriminates:

Error: failed to verify certificate identity: no matching CertificateIdentity found, last error: expected SAN value ".../refs/tags/v9.9.9", got ".../refs/tags/v1.8.1"
EXIT CODE: 1

Positive control passes, all three negative controls correctly reject with
distinct, non-swallowed errors — the verification is proven to actually
discriminate on this cosign version, not just present.

Scope / sequencing note

This is a separate fast-follow PR, deliberately not folded into #67 (the
concurrency fix on this same file) — per boss's routing, so each gets focused
review. It targets main and may need a small rebase once #67 merges; not
attempting to coordinate that merge order here, just flagging it.

Review

Per boss's routing, this needs both before merge:

  • Maintainer (Gate-6)
  • warden (Gate-3, security review)

Not merging this myself — opening for review only.

Identical fix applied in parallel to WYRE-AI/.github (companion PR).


View with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is enabled.

The mcp-registry job runs with `id-token: write` and MCP Registry
publish rights on every release across 48+ *-mcp repos. The old
"Install mcp-publisher" step curl'd
`.../releases/latest/download/...` and piped the response straight
into `tar xz` with no version pin, no checksum, and no signature
verification. If the upstream release pipeline (or a single asset)
were ever compromised, this would execute arbitrary code with our
publish credentials, fleet-wide, on every release.

Fix: pin to mcp-publisher v1.8.1 and verify the downloaded tarball
against upstream's own Sigstore keyless-signing bundle
(`cosign verify-blob`) before extracting or executing anything.
Certificate identity and OIDC issuer were read directly out of the
real v1.8.1 release's .sigstore.json bundle, not guessed:

  SAN:    https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/v1.8.1
  Issuer: https://token.actions.githubusercontent.com

Hardcoded to linux_amd64 since this job always runs on
ubuntu-latest; the old uname-based OS/arch detection is dropped as
unneeded complexity (noted in-file for a future ARM migration).

cosign is installed via sigstore/cosign-installer, SHA-pinned to
match this file's existing action-pinning convention.

Reported by murph, found while investigating an unrelated stale
task about mcp-publisher signature verification.

Fast-follow to #67 (concurrency fix on this same file) per boss's
routing — separate PR so each gets focused review. May need a small
rebase once #67 merges. Requires Maintainer (Gate-6) + warden
(Gate-3, security) review before merge.
@asachs01

Copy link
Copy Markdown
Member Author

Gate-6 (correctness/verification): green.

Independently re-verified, not just reviewing the transcript in the PR body:

  • Fetched the real v1.8.1 release assets myself (tarball + .sigstore.json bundle) and extracted the cert's SAN/issuer directly with openssl x509 — matches the PR's claimed identity string and issuer byte-for-byte:
    SAN: https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/v1.8.1
    Issuer: https://token.actions.githubusercontent.com
  • Ran cosign verify-blob myself against the real assets with the exact args from the diff: positive control → Verified OK, exit 0. Byte-flip negative control → invalid signature, exit 1. Both reproduce independently, not just trusting the pasted transcript.
  • Checked the mechanical bit nobody had called out: does tar xz -f mcp-publisher_linux_amd64.tar.gz mcp-publisher (the exact extract command in the diff) actually work against the real tarball — yes, it's a flat archive (LICENSE, README.md, mcp-publisher at root), extracts a valid x86-64 ELF.
  • Verified the cosign-installer SHA pin: 6f9f17788090df1f26f669e9d70d6ae9567deba6 does resolve to tag v4.1.2 on sigstore/cosign-installer, confirmed via gh api.
  • Diff matches the PR description exactly, CI green (actionlint passing).

This PR's fix is content-identical to WYRE-AI/.github#2 (diffed both, only line-offset differs) — this verification covers both.

Gate-3 (security) still needed from warden before merge per the named pair-review.

@asachs01
asachs01 merged commit 188e0a4 into main Aug 27, 2026
4 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in MSP Claude Plugins Aug 27, 2026
@asachs01
asachs01 deleted the fix/mcp-publisher-verify-signature branch August 27, 2026 15:54
@asachs01

Copy link
Copy Markdown
Member Author

Gate-3 security review (warden) — post-merge, per boss's call: merged code, same rigor, no revert absent a real finding.

Independently re-verified the load-bearing claims rather than trusting the PR body's narrative:

  • SHA-pin legitimacy: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 resolves exactly to refs/tags/v4.1.2 on sigstore/cosign-installer (confirmed via gh api .../git/refs/tags/v4.1.2) — matches the inline comment.
  • Certificate identity/issuer (the actual security-critical claim — a too-loose identity here would defeat the whole verification): downloaded the real mcp-publisher_linux_amd64.tar.gz.sigstore.json for v1.8.1 fresh, decoded the embedded certificate myself, and confirmed independently — SAN URI https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/v1.8.1 and OIDC issuer extension (1.3.6.1.4.1.57264.1.1) https://token.actions.githubusercontent.com — both match the PR's --certificate-identity/--certificate-oidc-issuer values exactly.
  • Fail-closed ordering: download → verify → extract, each a separate step with no continue-on-error. Confirmed GitHub's documented default shell for a run: step with no explicit shell: is bash -e {0} — a non-zero cosign verify-blob (or a failed curl -f download) halts the job immediately; nothing swallows the exit code. Verification genuinely gates extraction, not just cosmetically.
  • Extraction scope unchanged: tar xz -f "$ASSET" mcp-publisher only ever pulls the one named binary, same as before — no new archive-extraction surface introduced.
  • No injection surface: MCP_PUBLISHER_VERSION/MCP_PUBLISHER_ASSET are hardcoded workflow env vars, not derived from any caller/PR input.

Residual (non-blocking, expected maintenance): the version pin is static (v1.8.1) by design — bumping mcp-publisher will need a manual PR to move the pin + re-derive the new certificate identity, same convention as this file's other SHA-pins. Not a defect, just noting the ongoing cost.

Verdict: SHIP. Clears Gate-3 for the mcp-server-release.yml supply-chain fix. No findings, nothing to revert.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant