{
 "slug": "malicious-pull-request-injection-defeating-session-stealing",
 "topic_id": "TOPIC-042",
 "cluster": "CI/CD & Software Supply Chain",
 "tier": "Tier B",
 "title": "Package publishing authenticates the session, not the release",
 "summary": "Registry compromises usually start with a stolen maintainer session rather than a broken password. Two-factor authentication at login does not reach the publish endpoint.",
 "lede": "A maintainer's session token is lifted from their machine. Hours later a new version of a widely used package appears, published by their account, with a modified build script. No authentication failure occurred anywhere.",
 "date": "2024-04-23",
 "category": "Developer",
 "author_id": "august-moravec-diallo",
 "tags": [
  "package registry",
  "npm",
  "PyPI",
  "maintainer security",
  "supply chain",
  "publishing"
 ],
 "image_title": "Publishing Authenticates The Session",
 "schema": "Article",
 "key_takeaways": [
  "Registry two-factor authentication protects login. Publish tokens and live sessions bypass it entirely.",
  "The published tarball can differ from the repository tag, and most consumers never compare them.",
  "Binding a signature to the artefact digest at publish time makes a stolen session insufficient to release."
 ],
 "body": [
  {
   "type": "h2",
   "text": "The three publish paths"
  },
  {
   "type": "diagram",
   "kind": "flow",
   "alt": "Where the second factor sits, and where publishing happens",
   "caption": "The gap between the two is where every registry compromise lives.",
   "nodes": [
    {
     "label": "Login with second factor",
     "note": "strong",
     "good": true
    },
    {
     "label": "Session or token issued",
     "note": "long-lived",
     "bad": true
    },
    {
     "label": "Token stolen or leaked",
     "note": "phishing, CI logs, laptop",
     "bad": true
    },
    {
     "label": "Publish endpoint called",
     "note": "no second factor here",
     "bad": true
    },
    {
     "label": "Release propagates",
     "note": "to every consumer",
     "bad": true
    }
   ]
  },
  {
   "type": "table",
   "head": [
    "Path",
    "What authorises it",
    "Reaches 2FA?"
   ],
   "rows": [
    [
     "Interactive publish from a logged-in session",
     "Session token",
     "Sometimes, if the registry prompts"
    ],
    [
     "Automation token in CI",
     "A long-lived token",
     "No"
    ],
    [
     "Trusted publishing via CI identity",
     "An OIDC identity from the build system",
     "No — by design"
    ]
   ]
  },
  {
   "type": "p",
   "html": "Trusted publishing is a genuine improvement over long-lived tokens: there is no secret to steal. It binds publication to a workflow, which means it answers provenance and leaves authority to whoever can trigger that workflow."
  },
  {
   "type": "h2",
   "text": "The tarball-versus-tag gap"
  },
  {
   "type": "p",
   "html": "Most ecosystems publish a built artefact, not the repository contents. The build happens on the publisher's machine or runner, and what lands in the registry is whatever that process produced."
  },
  {
   "type": "p",
   "html": "A consumer installing the package receives the tarball. The repository tag they might inspect is a different object. Reproducible builds and provenance attestation address this where they are deployed, and coverage remains partial."
  },
  {
   "type": "p",
   "html": "The practical consequence: an attacker with publish capability does not need to touch the repository at all. The malicious code can exist only in the published artefact."
  },
  {
   "type": "h2",
   "text": "What binding at publish looks like"
  },
  {
   "type": "code",
   "text": "# The registry requires a signature over what is being published\n\nPOST /packages/example-lib/versions\n{\n  \"version\": \"4.2.1\",\n  \"tarball_sha256\": \"9c1f8a3e...d7b2\",\n  \"receipt\": {\n     \"statement\": {\n       \"action\":  \"package.publish\",\n       \"package\": \"example-lib\",\n       \"version\": \"4.2.1\",\n       \"digest\":  \"9c1f8a3e...d7b2\",\n       \"repository\": \"github.com/org/example-lib\",\n       \"commit\": \"7f3a91c8e2b7d4056891fa2c73e1b9d40\"\n     },\n     \"signature\": \"...\",          # maintainer's authenticator\n     \"user_verified\": true\n  }\n}\n\n# Registry verifies: signature valid, credential enrolled to a\n# maintainer of this package, digest matches the uploaded tarball."
  },
  {
   "type": "p",
   "html": "A stolen session can reach the endpoint and cannot produce the signature. The maintainer's authenticator is required, with a user-verification gesture, at publish time."
  },
  {
   "type": "h2",
   "text": "The automation objection"
  },
  {
   "type": "p",
   "html": "Real, and it is the reason this has not happened already. Most packages publish from CI, and a human signature per release means someone must be present at every release."
  },
  {
   "type": "p",
   "html": "Three positions, and they are not mutually exclusive."
  },
  {
   "type": "ol",
   "items": [
    "<strong style=\"font-weight:600\">Tier by download volume.</strong> A package with millions of weekly installs is infrastructure. A human signature per release is proportionate there and absurd for a package with forty users.",
    "<strong style=\"font-weight:600\">Sign the release, not the build.</strong> The maintainer signs a statement authorising a specific version from a specific commit, ahead of the build. CI produces the artefact and the registry verifies the digest matches what was authorised.",
    "<strong style=\"font-weight:600\">Delegate with bounds.</strong> A maintainer signs a scoped delegation — this workflow may publish patch versions of this package for ninety days — and the chain verifies to them."
   ]
  },
  {
   "type": "p",
   "html": "The third preserves automation and is the realistic answer for most maintainers. It also creates something that currently does not exist: a record of which human stands behind an automated release."
  },
  {
   "type": "h2",
   "text": "What a consumer could then check"
  },
  {
   "type": "p",
   "html": "Today an installation checks an integrity hash against a lockfile, which confirms you got the same bytes as last time and says nothing about who published them."
  },
  {
   "type": "ul",
   "items": [
    "Was this version signed by a credential enrolled to a maintainer?",
    "Did that signature cover this exact artefact digest?",
    "Is the signing credential the same one that signed previous versions?",
    "Was the release authorised directly, or under a delegation, and by whom?"
   ]
  },
  {
   "type": "p",
   "html": "The third question is the one that would catch the most incidents. A maintainer account publishing under a credential it has never used before is a detectable anomaly that nobody can currently detect."
  },
  {
   "type": "h2",
   "text": "The maintainer burden problem"
  },
  {
   "type": "p",
   "html": "Any proposal that increases the cost of maintaining open source deserves scepticism. Maintainers are volunteers, and security requirements imposed on them have a history of driving people away."
  },
  {
   "type": "p",
   "html": "This is why tiering matters and why delegation matters. The design that works is one where a maintainer signs once, infrequently, for a bounded window, and their existing automation continues to function. A design that demands a hardware key touch at every release will simply not be adopted."
  },
  {
   "type": "h2",
   "text": "A worked example: the tarball-versus-tag gap"
  },
  {
   "type": "table",
   "caption": "What a consumer can and cannot check",
   "head": [
    "Claim",
    "Checkable today?"
   ],
   "rows": [
    [
     "This version exists in the registry",
     "Yes"
    ],
    [
     "A git tag with this name exists",
     "Yes"
    ],
    [
     "The tarball matches that tag",
     "Rarely — build steps intervene"
    ],
    [
     "A named maintainer intended this release",
     "No"
    ],
    [
     "<strong style=\"font-weight:600\">A maintainer signed this artefact hash</strong>",
     "<strong style=\"font-weight:600\">Only if they were asked to</strong>"
    ]
   ]
  },
  {
   "type": "p",
   "html": "The fourth row is the one that matters and the one nothing currently produces. Reproducible builds help with the third; they do not touch the fourth, because intent is not a build property."
  },
  {
   "type": "h2",
   "text": "Objections and honest limits"
  },
  {
   "type": "p",
   "html": "<strong style=\"font-weight:600\">“This breaks automated releases.”</strong> It changes them. A maintainer signs a release intent — version, artefact hash, date window — and automation publishes within it. The human signs once per release, not once per commit."
  },
  {
   "type": "p",
   "html": "<strong style=\"font-weight:600\">“Maintainer burden is already the problem.”</strong> It is, and this is the strongest objection. The answer is scope: sign releases, not merges, and make the signing step take seconds on a device the maintainer already carries."
  }
 ],
 "faq": [
  {
   "q": "Doesn't registry 2FA already cover this?",
   "a": "It covers login. Publish tokens and live sessions reach the publish endpoint without a second factor, which is how most of these incidents occur."
  },
  {
   "q": "Isn't trusted publishing the answer?",
   "a": "It removes the stealable long-lived token, which is a real improvement. It binds publication to a workflow, so authority still rests with whoever can trigger that workflow."
  },
  {
   "q": "Won't this burden maintainers?",
   "a": "It would, if applied uniformly. Tier by download volume and allow bounded delegation so existing automation keeps working with one infrequent signature behind it."
  },
  {
   "q": "What could consumers verify that they cannot today?",
   "a": "Whether a version was signed by a credential enrolled to a maintainer, and whether it is the same credential as previous versions. A sudden change is a detectable signal."
  },
  {
   "q": "Does two-factor authentication help at all?",
   "a": "It protects the login. Publishing happens later through a token the session already holds, which is where compromises occur."
  },
  {
   "q": "Does this break CI-driven releases?",
   "a": "No. The maintainer signs a bounded release intent and automation publishes within it."
  },
  {
   "q": "What could a consumer then check?",
   "a": "That a named maintainer signed this artefact hash — the claim nothing currently supports."
  }
 ],
 "sources": [
  {
   "t": "PyPI — Trusted Publishers",
   "u": "https://docs.pypi.org/trusted-publishers/"
  },
  {
   "t": "npm — security best practices for package publishing",
   "u": "https://docs.npmjs.com/packages-and-modules/securing-your-code"
  },
  {
   "t": "SLSA v1.0 specification",
   "u": "https://slsa.dev/spec/v1.0/"
  },
  {
   "t": "Sigstore documentation",
   "u": "https://docs.sigstore.dev/"
  },
  {
   "t": "SLSA — Supply-chain Levels for Software Artifacts",
   "u": "https://slsa.dev/"
  }
 ],
 "related": [
  {
   "slug": "hugging-face-model-supply-chain-poisoning-gating-production",
   "title": "Gating production model weights",
   "category": "Developer"
  },
  {
   "slug": "sigstore-vs-manav-signing-artifacts-prove-approved-deployment",
   "title": "Signing artifacts does not prove who approved",
   "category": "Comparison"
  },
  {
   "slug": "session-hijacking-via-infostealers-mfa-login-protect-post",
   "title": "Why MFA at login does not protect post-login actions",
   "category": "Developer"
  }
 ],
 "image": "https://cdn.twc.sh/images/igcache/Publishing%20Authenticates%20The%20Session/1500_900/blog.jpg",
 "wordcount": 990,
 "url": "/blog/malicious-pull-request-injection-defeating-session-stealing.html",
 "reading_time": "4 min read",
 "meta_description": "Registry compromises usually start with a stolen maintainer session, not a broken password. Login two-factor never reaches the publish endpoint.",
 "hub": {
  "slug": "topics/software-supply-chain",
  "title": "Software supply chain authorization"
 },
 "answer": "Because it protects the login, and publishing happens later through a token the session already holds. A stolen session or a leaked automation token reaches the publish endpoint without touching the second factor again, and the registry sees a perfectly ordinary release.",
 "answer_q": "Why does two-factor authentication not protect a package release?",
 "glossary": [
  {
   "term": "Session stealing",
   "def": "Theft of an authenticated session or token, which bypasses the login-time second factor entirely."
  },
  {
   "term": "Release intent",
   "def": "A signed statement that a named maintainer meant to publish a specific artefact."
  },
  {
   "term": "Tarball-versus-tag gap",
   "def": "The gap between what is in source control and what is actually distributed."
  }
 ],
 "checklist": {
  "title": "Binding publish to a human",
  "id": "publish",
  "desc": "Five steps.",
  "steps": [
   {
    "name": "Require a signature at the publish endpoint.",
    "text": "Not at login."
   },
   {
    "name": "Sign the artefact hash, not the version string.",
    "text": "The tarball is what consumers get."
   },
   {
    "name": "Allow a release-intent grant for automation.",
    "text": "Bounded by version and time window."
   },
   {
    "name": "Publish maintainer keys where consumers can fetch them.",
    "text": "Otherwise nobody can check."
   },
   {
    "name": "Keep the signature retrievable with the release.",
    "text": "Years later, by anyone."
   }
  ]
 },
 "cta": {
  "title": "Where this fits in Manav",
  "html": "Manav binds the publish endpoint to a human signature over the artefact hash, with bounded grants so automation still works.",
  "href": "../docs.html",
  "label": "See publish gating"
 }
}