You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Disclosure: This review was done fully autonomously. Wanted to share if it can be of help.
Reviewed against the 12 attack techniques document here: npm-supply-chain-attack-techniques. Review covers package.json, pnpm-lock.yaml, all GitHub Actions workflows, and the postinstall script.
What's Done Right
Before the findings, these defensive controls are already in place and worth preserving:
--ignore-scripts on all CI installs — every workflow (including the shared setup composite action and release.yml) runs pnpm install --frozen-lockfile --ignore-scripts. Transitive dependency lifecycle hooks cannot execute during the install phase.
Lockfile committed and frozen — pnpm-lock.yaml is committed and --frozen-lockfile is enforced in CI, preventing silent resolution drift.
Separate postinstall step — postinstall.js is invoked explicitly as its own step after install, not triggered automatically by dependency resolution. This is the correct pattern.
No pull_request_target triggers anywhere — none of the six workflow files use this dangerous event.
No npm publish tokens — no NPM_TOKEN, no long-lived classic npm tokens anywhere in workflow files.
Actor guard on claude.yml — the job condition requires github.actor == 'Jocs', limiting trigger surface to the repo owner.
Git dependency pinned to full commit SHA — the file-icons GitHub tarball resolves to commit 28520868 in the lockfile.
axios@1.16.1 — not the compromised 1.14.1/0.30.4 versions from the March 2026 attack.
If any of these action repositories suffers a maintainer account takeover, the attacker can overwrite the tag in place — no PR to the MarkText repo is needed. The attack in the TanStack postmortem obtained OIDC publish identity by running attacker-controlled code inside a legitimate workflow. Here the claude.yml workflow grants id-token: write and runs anthropics/claude-code-action@v1 without a SHA pin. A compromised @v1 tag is all an attacker needs to obtain an OIDC token from that job.
Remediation — pin every action to its current SHA. The canonical pattern:
StepSecurity's harden-runner can auto-generate pinned refs and also add egress network restrictions to CI runners at the same time. Running npx @step-security/harden-runner in each workflow is the fastest way to pin all refs and add outbound allowlists simultaneously.
HIGH — release.yml Has No permissions Block (Technique 4)
release.yml has no permissions: declaration at the job or workflow level. It inherits the repository default, which for many repositories is contents: write plus other default-write scopes.
permissions:
contents: write # needed for creating the GitHub release
All other workflows (build.yml, test.yml, lint.yml, e2e.yml) already declare explicit per-job permissions — release.yml is the outlier.
MEDIUM — claude.yml Grants id-token: write to a Floating Action (Technique 4)
# claude.ymlpermissions:
contents: writepull-requests: writeissues: writeid-token: write # ← OIDC capability on a non-publishing jobactions: read
The job invokes anthropics/claude-code-action@v1 (not SHA-pinned). id-token: write allows requesting OIDC JWT tokens from GitHub's identity provider — the same mechanism used in the TanStack attack to obtain an npm publish identity. Even without a publish step, an OIDC token can be used to impersonate the workflow to downstream services. If id-token: write is required by the Claude action for its own OAuth flow, the action vendor should document why, and the ref should be SHA-pinned before granting it.
Remediation — If id-token: write is not strictly required by the Claude action for its function in this repo, remove it. Either way, pin anthropics/claude-code-action to a commit SHA.
MEDIUM — Transitive Git-Hosted Dependency (file-icons) (Technique 5)
This resolves through @marktext/file-icons@1.0.6, a MarkText-team-owned npm package. The lockfile pins the exact commit SHA, which is good. However:
The @marktext/file-icons registry package itself carries a GitHub-hosted non-registry dependency. If @marktext/file-icons were published with a new version pointing to an attacker-controlled GitHub commit, a pnpm update would pull it.
GitHub-hosted tarballs do not carry integrity metadata in the same way as registry packages (sha512 hash). If GitHub serves a different tarball for the same URL (unlikely but possible through cache/CDN manipulation), pnpm has less protection.
Remediation — Since @marktext/file-icons is a MarkText-maintained package, consider replacing the file-icons git dependency inside it with a regular npm publish of just the icon files needed, removing the external non-registry dependency entirely.
MEDIUM — pnpm Store Cache Shared Across PR and Release Trust Boundaries (Technique 4)
Both the PR workflows (build.yml, test.yml, lint.yml, e2e.yml) and release.yml use actions/setup-node with cache: 'pnpm'. This writes and reads the pnpm content-addressable store from the same GitHub Actions cache namespace (keyed on pnpm-lock.yaml hash).
The TanStack attack exploited cache restoration in a trusted release workflow. Here the risk is lower because:
pnpm verifies SHA-512 integrity of every package before using it from the store
--frozen-lockfile prevents manifest changes
However, the pnpm store directory itself (not just tarballs) is cached, and if a PR workflow run somehow wrote adversarial data into non-package areas of the store, it could be restored by a later release run. This is a low-probability path but is worth separating for defense-in-depth.
Remediation — Add a cache-dependency-path scoped to a release-specific salt, or simply remove cache: 'pnpm' from release.yml and accept the slightly slower dependency install on release runs:
# release.yml setup-node — remove the pnpm cache
- uses: actions/setup-node@v4with:
node-version: 24.14.1# no cache: 'pnpm'
pnpm's isolation model normally prevents a package from require()-ing another package it doesn't explicitly depend on. shamefully-hoist=true flattens node_modules like npm/yarn, removing this protection. If any transitive dependency were compromised, it gains access to every other package in the tree, including packages that might expose sensitive information or APIs.
This setting is likely required for Electron's native module loading. If that's the case, document the requirement in .npmrc so reviewers understand why it's there. Consider whether a narrower public-hoist-pattern could replace the blanket hoist:
# .npmrc — less permissive alternative
public-hoist-pattern[]=*electron*
public-hoist-pattern[]=native-keymap
LOW — GITHUB_TOKEN Available in Postinstall Step Environment (Technique 8)
In both build.yml and release.yml, the GITHUB_TOKEN is passed to the node scripts/postinstall.js execution:
- name: Run postinstallrun: node scripts/postinstall.jsenv:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # token in env
The token is required for Electron's binary download (to avoid GitHub API rate limits). postinstall.js is in-repo and trustworthy. However, if a dependency bundled by electron-rebuild or invoked by patch-package were malicious, it would run within the same environment as the token. The risk is mitigated by --ignore-scripts on the initial install, but it's worth knowing the token is present in this step.
Remediation — No code change needed, but consider whether ELECTRON_MIRROR could replace direct GitHub API access so the token isn't required. If the token is only needed to avoid rate limits, that tradeoff may not be worth the exposure.
INFORMATIONAL — webfontloader@1.6.28 Is Abandoned (Technique 1 / 12)
This package has not been updated since ~2017. While it has no lifecycle scripts and is a runtime UI dependency (not a build tool), unmaintained packages are acquisition targets — a new maintainer with publish rights could add a lifecycle hook in a "1.6.29" patch. Consider replacing with a modern alternative or pinning the exact version with an overrides entry to prevent accidental updates.
INFORMATIONAL — No Automated Dependency Update Monitoring
No Dependabot, Renovate, or equivalent is configured in .github/. Security-relevant dependency updates (e.g., axios after the March 2026 compromise was resolved) would only be discovered manually. Adding Dependabot for both npm packages and GitHub Actions refs would surface patches quickly and, when combined with SHA-pinned actions, provides a sustainable update path.
Summary Table
Finding
Severity
Technique
File
All 10 actions use floating version tags
HIGH
T4
all workflows
release.yml missing permissions block
HIGH
T4
release.yml
id-token: write on floating action in claude.yml
MEDIUM
T4
claude.yml
Transitive git-hosted file-icons dependency
MEDIUM
T5
pnpm-lock.yaml
pnpm cache shared across PR/release trust boundary
MEDIUM
T4
build.yml, release.yml
shamefully-hoist=true removes package isolation
LOW
T2/T8
.npmrc
GITHUB_TOKEN in postinstall env
LOW
T8
build.yml, release.yml
webfontloader abandoned
INFO
T1/T12
package.json
No Dependabot/Renovate
INFO
T1
—
The two highest-priority actions are: pin all action refs to SHAs and add an explicit minimal permissions block to release.yml. Both are low-effort changes with immediate impact.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
-
Disclosure: This review was done fully autonomously. Wanted to share if it can be of help.
Reviewed against the 12 attack techniques document here: npm-supply-chain-attack-techniques. Review covers
package.json,pnpm-lock.yaml, all GitHub Actions workflows, and the postinstall script.What's Done Right
Before the findings, these defensive controls are already in place and worth preserving:
--ignore-scriptson all CI installs — every workflow (including the sharedsetupcomposite action andrelease.yml) runspnpm install --frozen-lockfile --ignore-scripts. Transitive dependency lifecycle hooks cannot execute during the install phase.pnpm-lock.yamlis committed and--frozen-lockfileis enforced in CI, preventing silent resolution drift.postinstall.jsis invoked explicitly as its own step after install, not triggered automatically by dependency resolution. This is the correct pattern.pull_request_targettriggers anywhere — none of the six workflow files use this dangerous event.NPM_TOKEN, no long-lived classic npm tokens anywhere in workflow files.claude.yml— the job condition requiresgithub.actor == 'Jocs', limiting trigger surface to the repo owner.file-iconsGitHub tarball resolves to commit28520868in the lockfile.axios@1.16.1— not the compromised1.14.1/0.30.4versions from the March 2026 attack.HIGH — Floating Action References (Technique 4 / CI/CD Identity Plane)
All 10 third-party actions across all workflows are pinned to mutable version tags, not SHA digests.
If any of these action repositories suffers a maintainer account takeover, the attacker can overwrite the tag in place — no PR to the MarkText repo is needed. The attack in the TanStack postmortem obtained OIDC publish identity by running attacker-controlled code inside a legitimate workflow. Here the
claude.ymlworkflow grantsid-token: writeand runsanthropics/claude-code-action@v1without a SHA pin. A compromised@v1tag is all an attacker needs to obtain an OIDC token from that job.Remediation — pin every action to its current SHA. The canonical pattern:
StepSecurity's
harden-runnercan auto-generate pinned refs and also add egress network restrictions to CI runners at the same time. Runningnpx @step-security/harden-runnerin each workflow is the fastest way to pin all refs and add outbound allowlists simultaneously.HIGH —
release.ymlHas NopermissionsBlock (Technique 4)release.ymlhas nopermissions:declaration at the job or workflow level. It inherits the repository default, which for many repositories iscontents: writeplus other default-write scopes.A minimal permissions block for this workflow:
All other workflows (
build.yml,test.yml,lint.yml,e2e.yml) already declare explicit per-job permissions —release.ymlis the outlier.MEDIUM —
claude.ymlGrantsid-token: writeto a Floating Action (Technique 4)The job invokes
anthropics/claude-code-action@v1(not SHA-pinned).id-token: writeallows requesting OIDC JWT tokens from GitHub's identity provider — the same mechanism used in the TanStack attack to obtain an npm publish identity. Even without a publish step, an OIDC token can be used to impersonate the workflow to downstream services. Ifid-token: writeis required by the Claude action for its own OAuth flow, the action vendor should document why, and the ref should be SHA-pinned before granting it.Remediation — If
id-token: writeis not strictly required by the Claude action for its function in this repo, remove it. Either way, pinanthropics/claude-code-actionto a commit SHA.MEDIUM — Transitive Git-Hosted Dependency (
file-icons) (Technique 5)This resolves through
@marktext/file-icons@1.0.6, a MarkText-team-owned npm package. The lockfile pins the exact commit SHA, which is good. However:@marktext/file-iconsregistry package itself carries a GitHub-hosted non-registry dependency. If@marktext/file-iconswere published with a new version pointing to an attacker-controlled GitHub commit, apnpm updatewould pull it.sha512hash). If GitHub serves a different tarball for the same URL (unlikely but possible through cache/CDN manipulation), pnpm has less protection.Remediation — Since
@marktext/file-iconsis a MarkText-maintained package, consider replacing thefile-iconsgit dependency inside it with a regular npm publish of just the icon files needed, removing the external non-registry dependency entirely.MEDIUM — pnpm Store Cache Shared Across PR and Release Trust Boundaries (Technique 4)
Both the PR workflows (
build.yml,test.yml,lint.yml,e2e.yml) andrelease.ymluseactions/setup-nodewithcache: 'pnpm'. This writes and reads the pnpm content-addressable store from the same GitHub Actions cache namespace (keyed onpnpm-lock.yamlhash).The TanStack attack exploited cache restoration in a trusted release workflow. Here the risk is lower because:
--frozen-lockfileprevents manifest changesHowever, the pnpm store directory itself (not just tarballs) is cached, and if a PR workflow run somehow wrote adversarial data into non-package areas of the store, it could be restored by a later release run. This is a low-probability path but is worth separating for defense-in-depth.
Remediation — Add a
cache-dependency-pathscoped to a release-specific salt, or simply removecache: 'pnpm'fromrelease.ymland accept the slightly slower dependency install on release runs:LOW —
shamefully-hoist=trueReduces Package Isolation (Technique 2 / 8)pnpm's isolation model normally prevents a package from
require()-ing another package it doesn't explicitly depend on.shamefully-hoist=trueflattensnode_moduleslike npm/yarn, removing this protection. If any transitive dependency were compromised, it gains access to every other package in the tree, including packages that might expose sensitive information or APIs.This setting is likely required for Electron's native module loading. If that's the case, document the requirement in
.npmrcso reviewers understand why it's there. Consider whether a narrowerpublic-hoist-patterncould replace the blanket hoist:LOW —
GITHUB_TOKENAvailable in Postinstall Step Environment (Technique 8)In both
build.ymlandrelease.yml, theGITHUB_TOKENis passed to thenode scripts/postinstall.jsexecution:The token is required for Electron's binary download (to avoid GitHub API rate limits).
postinstall.jsis in-repo and trustworthy. However, if a dependency bundled byelectron-rebuildor invoked bypatch-packagewere malicious, it would run within the same environment as the token. The risk is mitigated by--ignore-scriptson the initial install, but it's worth knowing the token is present in this step.Remediation — No code change needed, but consider whether
ELECTRON_MIRRORcould replace direct GitHub API access so the token isn't required. If the token is only needed to avoid rate limits, that tradeoff may not be worth the exposure.INFORMATIONAL —
webfontloader@1.6.28Is Abandoned (Technique 1 / 12)This package has not been updated since ~2017. While it has no lifecycle scripts and is a runtime UI dependency (not a build tool), unmaintained packages are acquisition targets — a new maintainer with publish rights could add a lifecycle hook in a "1.6.29" patch. Consider replacing with a modern alternative or pinning the exact version with an
overridesentry to prevent accidental updates.INFORMATIONAL — No Automated Dependency Update Monitoring
No Dependabot, Renovate, or equivalent is configured in
.github/. Security-relevant dependency updates (e.g.,axiosafter the March 2026 compromise was resolved) would only be discovered manually. Adding Dependabot for both npm packages and GitHub Actions refs would surface patches quickly and, when combined with SHA-pinned actions, provides a sustainable update path.Summary Table
release.ymlmissingpermissionsblockrelease.ymlid-token: writeon floating action inclaude.ymlclaude.ymlfile-iconsdependencypnpm-lock.yamlbuild.yml,release.ymlshamefully-hoist=trueremoves package isolation.npmrcGITHUB_TOKENin postinstall envbuild.yml,release.ymlwebfontloaderabandonedpackage.jsonThe two highest-priority actions are: pin all action refs to SHAs and add an explicit minimal
permissionsblock torelease.yml. Both are low-effort changes with immediate impact.Beta Was this translation helpful? Give feedback.
All reactions