Skip to content

fix(publish): harden gh calls against arg injection; lock down temp .npmrc - #584

Merged
goosewobbler merged 1 commit into
mainfrom
fix/publish-arg-injection-hardening
Jul 19, 2026
Merged

goosewobbler merged 1 commit into
mainfrom
fix/publish-arg-injection-hardening

Conversation

@goosewobbler

@goosewobbler goosewobbler commented Jul 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

Two small, self-contained publish-path hardening fixes from the security review (defense-in-depth):

  • Leading-- guard on gh calls. Adds assertNotOption to @releasekit/core (mirrors git's existing barrier) and applies it to the tag positional before it reaches the gh argv in packages/publish/src/stages/github-release.ts (both release view and release create) and packages/release/src/backfill/github-release.ts (getReleaseBody/editReleaseBody/getReleaseInfo). gh (cobra) would otherwise parse a --prefixed tag as a flag (-R/--repo, -F/--notes-file). Reachability is low in the publish pipeline (git creates the tag first and rejects leading--), but the backfill path feeds gh tags enumerated from existing repo refs, so an out-of-band refs/tags/-… reached gh unguarded. Not RCE — gh has no shell/exec flag. In the publish stage the guard records a failed per-tag result and continues (preserving the stage's per-tag non-critical error strategy); in backfill it throws (consistent with existing handling).
  • 0600 mode on the temp .npmrc. The token-bearing temp .npmrc is now written mode: 0o600 (packages/publish/src/utils/npm-env.ts), for parity with config's saveAuth.

The guard lives in @releasekit/core (both packages/publish and packages/release already depend on it) since the reachable backfill call site is in packages/release.

Test plan

  • New unit tests (all should …): dash-guard rejection before gh is invoked (core helper, the three backfill functions, and the publish stage), and the temp .npmrc 0600 mode.
  • Re-verified by the orchestrator the CI way: biome check . exit 0; forced turbo run typecheck test --force → 24/24, 0 cached.

Closes #561

🤖 Generated with Claude Code

https://claude.ai/code/session_01BT6fVBZrmExXZj7vQ29881

Greptile Summary

This PR hardens the publish and backfill paths against argument injection into gh CLI calls and tightens the file-permission posture of the token-bearing temp .npmrc. Both fixes are narrowly scoped and backed by new unit tests.

  • assertNotOption guard in @releasekit/core: rejects any tag value starting with - before it reaches a gh argv; applied to runGithubReleaseStage (publish, non-critical per-tag strategy) and all three backfill functions getReleaseBody/editReleaseBody/getReleaseInfo (throws, consistent with existing error handling there).
  • 0o600 mode on temp .npmrc: the token-bearing file is now written owner-only, matching the permission that config's saveAuth already enforces and adding a second layer of protection beyond the 0700 mkdtemp directory.

Confidence Score: 5/5

Safe to merge — both changes are narrowly scoped, well-tested, and do not alter any existing behaviour for valid inputs.

The guard correctly uses startsWith('-') which catches both short (-x) and long (--flag) forms. The per-tag try/catch in the publish stage preserves the existing non-critical error strategy. The backfill functions place assertNotOption before the try/catch so a bad tag throws rather than being absorbed. The 0o600 file-mode test masks mode & 0o777 correctly. Env-var cleanup in the npm-env test suite uses afterEach to delete NPM_TOKEN/NODE_AUTH_TOKEN, so no cross-test leakage. No issues found across all nine changed files.

No files require special attention.

Important Files Changed

Filename Overview
packages/core/src/utils.ts Adds assertNotOption guard that rejects any value starting with - before it can reach a CLI argv; well-documented and correctly scoped.
packages/core/src/index.ts Exports the new assertNotOption helper from the core public API.
packages/publish/src/stages/github-release.ts Wraps assertNotOption in a per-tag try/catch consistent with the existing non-critical error strategy; records failure and continues rather than aborting the stage.
packages/publish/src/utils/npm-env.ts Adds mode: 0o600 to the token-bearing temp .npmrc write, matching the owner-only permission that config's saveAuth already enforces.
packages/release/src/backfill/github-release.ts Guards all three gh call sites (getReleaseBody, editReleaseBody, getReleaseInfo) with assertNotOption; the guard throws synchronously before execFileSync is reached.
packages/core/test/unit/utils.spec.ts New assertNotOption tests cover dash-prefix rejection (single and double dash), kind inclusion in error message, and clean acceptance of normal values.
packages/publish/test/unit/stages/github-release.spec.ts New test verifies that a dash-prefixed tag is caught before either execCommand or execCommandSafe is invoked, and that a failure result is recorded in the output.
packages/publish/test/unit/utils/npm-env.spec.ts New test uses fs.statSync(...).mode & 0o777 to confirm the written .npmrc has exactly 0600 permissions; afterEach properly cleans up env vars and paths.
packages/release/test/unit/backfill/github-release.spec.ts New gh argument-injection guard suite tests all three guarded functions reject dash-prefixed tags without spawning execFileSync, plus a happy-path check for normal tags.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant assertNotOption
    participant gh

    Note over Caller,gh: publish stage (non-critical, per-tag)
    Caller->>assertNotOption: assertNotOption(tag, 'tag')
    alt tag starts with '-'
        assertNotOption-->>Caller: throws Error
        Caller->>Caller: "push {success:false, reason} → continue"
    else tag is safe
        assertNotOption-->>Caller: returns void
        Caller->>gh: execCommandSafe / execCommand
        gh-->>Caller: result
    end

    Note over Caller,gh: backfill path (throws to surface errors)
    Caller->>assertNotOption: assertNotOption(tag, 'tag')
    alt tag starts with '-'
        assertNotOption-->>Caller: throws Error (propagates up)
    else tag is safe
        assertNotOption-->>Caller: returns void
        Caller->>gh: execFileSync(gh, [...])
        gh-->>Caller: stdout
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant assertNotOption
    participant gh

    Note over Caller,gh: publish stage (non-critical, per-tag)
    Caller->>assertNotOption: assertNotOption(tag, 'tag')
    alt tag starts with '-'
        assertNotOption-->>Caller: throws Error
        Caller->>Caller: "push {success:false, reason} → continue"
    else tag is safe
        assertNotOption-->>Caller: returns void
        Caller->>gh: execCommandSafe / execCommand
        gh-->>Caller: result
    end

    Note over Caller,gh: backfill path (throws to surface errors)
    Caller->>assertNotOption: assertNotOption(tag, 'tag')
    alt tag starts with '-'
        assertNotOption-->>Caller: throws Error (propagates up)
    else tag is safe
        assertNotOption-->>Caller: returns void
        Caller->>gh: execFileSync(gh, [...])
        gh-->>Caller: stdout
    end
Loading

Reviews (1): Last reviewed commit: "fix(publish): harden gh calls against ar..." | Re-trigger Greptile

…p .npmrc

Add a leading-dash guard (assertNotOption in @releasekit/core, mirroring
git's barrier) on tags passed to gh release view/create/edit, so a tag
starting with '-' can never be parsed as a flag (gh exposes -R/--repo,
-F/--notes-file). The publish pipeline creates the tag via git first
(already guarded), but the notes-backfill path feeds gh tags enumerated
from existing repo refs, so an out-of-band refs/tags/-… reached gh
unguarded. Also write the token-bearing temp .npmrc with mode 0600, for
parity with config's saveAuth.

Closes #561

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BT6fVBZrmExXZj7vQ29881
@goosewobbler goosewobbler added this to the 0.42.0 milestone Jul 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Standing release PR: #582 · v0.41.1 queued (4 packages) · open 25m · ✅ ready to merge

Release Preview — v0.41.0 → v0.41.1

Note: Labels on this PR are advisory in standing-pr mode. Bumps come from conventional commits in the standing PR; override by editing labels on the standing PR itself. Add release:immediate to bypass the standing PR and release this PR directly.

These changes will be added to the release PR (#582) when merged:

Changelog

monorepo 0.41.0 → 0.41.1

Fixed

  • harden gh calls against arg injection and lock down temp .npmrc (publish) (#561)

All packages → 0.41.1 (sync versioning)

  • @releasekit/version
  • @releasekit/notes
  • @releasekit/publish
  • @releasekit/release

After merge — predicted release

No version escalation — this PR's changes will be included in the queued release without affecting the projected versions.

Package Standing PR This PR After merge
monorepo 0.41.1 0.41.1 0.41.1

Updated automatically by ReleaseKit

@goosewobbler
goosewobbler merged commit 192b36f into main Jul 19, 2026
31 checks passed
@goosewobbler
goosewobbler deleted the fix/publish-arg-injection-hardening branch July 19, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish hardening: leading-dash guard on gh calls; 0600 mode on temp .npmrc

1 participant