feat: npm provenance + publish-token health actions#3
Conversation
Asserts both the npm publish and SLSA provenance predicates are present AND that the SLSA source claim names the expected repo/workflow/commit — 'an attestation exists' is a weaker claim than 'attested to us'. Fails closed: any unrecognised shape is indeterminate, never a false ok.
`node --test <dir>` accepted a bare directory on Node 20 but Node 24 treats the positional arg as a module path and fails with MODULE_NOT_FOUND. GitHub runners now default to Node 24, so this would have gone red on the first CI run. An explicit quoted glob works on both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 'detail never contains the raw statement blob' test only bounded the length, which would pass equally on a truncated blob and on a real summary — it did not test what its name claimed. Now asserts the exact summary string and explicitly rejects base64 payload material and envelope internals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Distinguishes absent (404 throughout) from error (5xx/network/unparseable) so callers can map them to different severities. Never surfaces a thrown fetch error: those can embed request headers and this runs in public logs.
Two tests claimed to verify retry exhaustion by their names ('5xx exhausts
retries...' and 'network throw is error...') but never asserted call counts.
A regression that made those paths return immediately instead of retrying
would pass silently, defeating the test's purpose.
Add call-count assertions to both tests, matching the pattern already used
by the 404 and invalid-JSON siblings. Each now verifies that all retries
are consumed (calls == BACKOFF_MS.length + 1) before reporting error.
Gate severity fails on any non-ok status; monitor severity always exits 0 and lets the caller classify — so a registry hiccup cannot spam the weekly issue filer while a degraded release still fails loudly. Prints an operator runbook on gate failure, including the 72h unpublish window and the deprecate path.
…xit() process.exit() right after an await fetch() tears the process down while undici's keep-alive sockets/timers are still open, which crashed with a libuv UV_HANDLE_CLOSING assertion on Windows/Node 24 (exit 127 instead of the intended 0), and can generally truncate buffered stdout / the GITHUB_OUTPUT write. Assigning process.exitCode lets Node exit naturally once the event loop drains, preserving the exact same exit-code contract. exitCodeFor() is unchanged; only how its return value is applied changes.
Uses each CLI's own verify-pat with the token in env, never argv, and discards all command output — these CLIs can echo request context in errors and this runs in public logs. Our code never handles the token itself.
The empty-token branch reported status=dead without ever contacting the vendor CLI or running the reachability check, making the "token revoked" alert lie when the secret was simply not configured. Introduce a fourth status, not-configured, emitted only from that branch; dead now stays reserved for a confirmed rejection by a reachable service. Severity is still the caller's decision.
📝 WalkthroughWalkthroughAdds npm provenance verification with retrying attestation retrieval, DSSE claim classification, gate/monitor severity handling, and tests. Adds a marketplace token liveness action and a GitHub Actions CI workflow with explicit Node test discovery. Changesnpm Provenance Verification
Marketplace Publish-Token Probe
CI Test Workflow
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Workflow
participant ProvenanceAction
participant NpmRegistry
participant Classifier
Workflow->>ProvenanceAction: provide package and expected source
ProvenanceAction->>NpmRegistry: fetch attestations with retries
NpmRegistry-->>ProvenanceAction: attestation response
ProvenanceAction->>Classifier: decode and classify claims
Classifier-->>ProvenanceAction: status and detail
ProvenanceAction-->>Workflow: outputs and exit code
sequenceDiagram
participant Workflow
participant ProbeAction
participant VendorCLI
participant Marketplace
Workflow->>ProbeAction: provide token and namespace
ProbeAction->>VendorCLI: execute verify-pat
VendorCLI-->>ProbeAction: verification result
ProbeAction->>Marketplace: check endpoint status after failure
Marketplace-->>ProbeAction: HTTP response
ProbeAction-->>Workflow: status output
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
.github/workflows/ci.yml (2)
3-7: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueConsider adding a concurrency block to cancel stale runs.
Without a concurrency group, rapid pushes to the same branch can queue redundant CI runs. A
concurrencyblock would cancel in-flight jobs when a newer commit arrives, saving runner minutes.♻️ Suggested addition
on: push: branches: [main] pull_request: +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + permissions:🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/ci.yml around lines 3 - 7, Add a concurrency configuration to the workflow alongside the existing on trigger so runs are grouped by workflow and branch or pull request, with in-progress runs canceled when a newer commit starts. Preserve the current push and pull_request triggers while preventing stale CI runs from continuing.
25-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPin the Node version with
setup-nodefor reproducibility.The comment on lines 26-27 explicitly acknowledges Node version differences (20 vs 24), yet the workflow relies on the runner's default Node version. A runner image update could silently change the Node version and break tests. Adding
actions/setup-nodewith a pinned version (e.g.,lts/*or a specific major) would make CI deterministic and prevent future surprises.♻️ Suggested addition
- name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + - name: Setup Node + uses: actions/setup-node@<pinned-sha> # v5.x + with: + node-version: lts/* - name: Run action tests🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/ci.yml around lines 25 - 28, Update the CI job containing the “Run action tests” step to add an actions/setup-node step before it, pinning Node to the intended supported version rather than relying on the runner default. Keep the existing explicit test glob and test command unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@actions/probe-publish-token/action.yml`:
- Around line 36-56: In the probe script, validate TOOL before the PUBLISH_TOKEN
presence check, accepting only “vsce” or “ovsx”; for any other value, write
status=indeterminate to GITHUB_OUTPUT, emit the unsupported-tool message, and
exit successfully. Keep the existing token handling and vendor-specific
verification branches unchanged for supported tools.
- Around line 64-74: The non-zero verify-pat path incorrectly marks all failures
as dead when the registry is reachable. Update the failure handling around the
verify-pat result and reachability probe so status=dead is emitted only when the
CLI explicitly reports an authentication rejection; keep install, runtime,
timeout, and other failures indeterminate, including when the registry returns
200.
In `@actions/verify-npm-provenance/src/main.js`:
- Around line 26-39: Sanitize untrusted provenance detail before both
GITHUB_OUTPUT writes and log emission in the verification flow around runbook
and the output-writing logic: prevent carriage returns/newlines from creating
output records or workflow commands, using a unique heredoc delimiter for
multiline outputs or replacing control characters. Ensure the runbook error
message never logs raw detail, while preserving the status and package/version
context.
---
Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 3-7: Add a concurrency configuration to the workflow alongside the
existing on trigger so runs are grouped by workflow and branch or pull request,
with in-progress runs canceled when a newer commit starts. Preserve the current
push and pull_request triggers while preventing stale CI runs from continuing.
- Around line 25-28: Update the CI job containing the “Run action tests” step to
add an actions/setup-node step before it, pinning Node to the intended supported
version rather than relying on the runner default. Keep the existing explicit
test glob and test command unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6f6b2d42-463f-45bc-bd88-7107008769d7
📒 Files selected for processing (10)
.github/workflows/ci.ymlactions/probe-publish-token/action.ymlactions/verify-npm-provenance/action.ymlactions/verify-npm-provenance/src/classify.jsactions/verify-npm-provenance/src/fetch-attestations.jsactions/verify-npm-provenance/src/main.jsactions/verify-npm-provenance/test/classify.test.jsactions/verify-npm-provenance/test/fetch-attestations.test.jsactions/verify-npm-provenance/test/fixtures/sdk-1.3.0.jsonactions/verify-npm-provenance/test/main.test.js
| if [ -z "${PUBLISH_TOKEN}" ]; then | ||
| # An unset secret cannot be assessed as live or revoked: this branch | ||
| # never contacts the vendor CLI and never runs the reachability | ||
| # check below, so it has no evidence the token was rejected. | ||
| # `dead` is reserved for a confirmed rejection by a reachable | ||
| # service — reporting it here would be a false revocation alarm. | ||
| # Whether an unconfigured token is a hard failure is the caller's | ||
| # call; this probe's job is only to report the signal honestly. | ||
| echo "status=not-configured" >> "$GITHUB_OUTPUT" | ||
| echo "probe ${TOOL}: secret not configured (no token to check)" | ||
| exit 0 | ||
| fi | ||
| if [ "${TOOL}" = "vsce" ]; then | ||
| VSCE_PAT="${PUBLISH_TOKEN}" npx --yes @vscode/vsce@3.9.2 verify-pat "${NAMESPACE}" >/dev/null 2>&1 | ||
| code=$? | ||
| reach_url="https://marketplace.visualstudio.com/" | ||
| else | ||
| OVSX_PAT="${PUBLISH_TOKEN}" npx --yes ovsx@1.0.2 verify-pat "${NAMESPACE}" >/dev/null 2>&1 | ||
| code=$? | ||
| reach_url="https://open-vsx.org/api/-/search?size=1" | ||
| fi |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Reject unsupported tool values before reading the token.
Any typo other than vsce falls into the Open VSX branch, potentially sending a VSCE token to Open VSX. Validate vsce|ovsx before the token-presence check and return indeterminate for unsupported values.
Proposed fix
set +e
+ case "$TOOL" in
+ vsce|ovsx) ;;
+ *)
+ echo "status=indeterminate" >> "$GITHUB_OUTPUT"
+ echo "probe: unsupported tool"
+ exit 0
+ ;;
+ esac
+
if [ -z "${PUBLISH_TOKEN}" ]; then📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if [ -z "${PUBLISH_TOKEN}" ]; then | |
| # An unset secret cannot be assessed as live or revoked: this branch | |
| # never contacts the vendor CLI and never runs the reachability | |
| # check below, so it has no evidence the token was rejected. | |
| # `dead` is reserved for a confirmed rejection by a reachable | |
| # service — reporting it here would be a false revocation alarm. | |
| # Whether an unconfigured token is a hard failure is the caller's | |
| # call; this probe's job is only to report the signal honestly. | |
| echo "status=not-configured" >> "$GITHUB_OUTPUT" | |
| echo "probe ${TOOL}: secret not configured (no token to check)" | |
| exit 0 | |
| fi | |
| if [ "${TOOL}" = "vsce" ]; then | |
| VSCE_PAT="${PUBLISH_TOKEN}" npx --yes @vscode/vsce@3.9.2 verify-pat "${NAMESPACE}" >/dev/null 2>&1 | |
| code=$? | |
| reach_url="https://marketplace.visualstudio.com/" | |
| else | |
| OVSX_PAT="${PUBLISH_TOKEN}" npx --yes ovsx@1.0.2 verify-pat "${NAMESPACE}" >/dev/null 2>&1 | |
| code=$? | |
| reach_url="https://open-vsx.org/api/-/search?size=1" | |
| fi | |
| set +e | |
| case "$TOOL" in | |
| vsce|ovsx) ;; | |
| *) | |
| echo "status=indeterminate" >> "$GITHUB_OUTPUT" | |
| echo "probe: unsupported tool" | |
| exit 0 | |
| ;; | |
| esac | |
| if [ -z "${PUBLISH_TOKEN}" ]; then | |
| # An unset secret cannot be assessed as live or revoked: this branch | |
| # never contacts the vendor CLI and never runs the reachability | |
| # check below, so it has no evidence the token was rejected. | |
| # `dead` is reserved for a confirmed rejection by a reachable | |
| # service — reporting it here would be a false revocation alarm. | |
| # Whether an unconfigured token is a hard failure is the caller's | |
| # call; this probe's job is only to report the signal honestly. | |
| echo "status=not-configured" >> "$GITHUB_OUTPUT" | |
| echo "probe ${TOOL}: secret not configured (no token to check)" | |
| exit 0 | |
| fi | |
| if [ "${TOOL}" = "vsce" ]; then | |
| VSCE_PAT="${PUBLISH_TOKEN}" npx --yes `@vscode/vsce`@3.9.2 verify-pat "${NAMESPACE}" >/dev/null 2>&1 | |
| code=$? | |
| reach_url="https://marketplace.visualstudio.com/" | |
| else | |
| OVSX_PAT="${PUBLISH_TOKEN}" npx --yes ovsx@1.0.2 verify-pat "${NAMESPACE}" >/dev/null 2>&1 | |
| code=$? | |
| reach_url="https://open-vsx.org/api/-/search?size=1" | |
| fi |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@actions/probe-publish-token/action.yml` around lines 36 - 56, In the probe
script, validate TOOL before the PUBLISH_TOKEN presence check, accepting only
“vsce” or “ovsx”; for any other value, write status=indeterminate to
GITHUB_OUTPUT, emit the unsupported-tool message, and exit successfully. Keep
the existing token handling and vendor-specific verification branches unchanged
for supported tools.
| # A non-zero exit alone does NOT mean the token is revoked — registry | ||
| # downtime, a timeout or rate-limiting look identical. Only call it dead | ||
| # if the service is actually reachable; otherwise report indeterminate | ||
| # so the caller warns instead of raising a false revocation alarm. | ||
| http="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "$reach_url" 2>/dev/null)" | ||
| if [ "$http" = "200" ]; then | ||
| echo "status=dead" >> "$GITHUB_OUTPUT" | ||
| echo "probe ${TOOL} (${NAMESPACE}): failed (exit=${code}) and service reachable -> dead" | ||
| else | ||
| echo "status=indeterminate" >> "$GITHUB_OUTPUT" | ||
| echo "probe ${TOOL} (${NAMESPACE}): failed (exit=${code}) and service unreachable (http=${http}) -> indeterminate" |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
For @vscode/vsce 3.9.2 and ovsx 1.0.2 verify-pat commands, are authentication rejection exit codes documented separately from network, service, or command setup failures?
💡 Result:
For the @vscode/vsce 3.9.2 and ovsx 1.0.2 verify-pat commands, authentication rejection exit codes are not documented separately from other failure types. Official documentation for both tools does not provide a specific mapping of unique exit codes for different failure scenarios (e.g., authentication rejection versus network issues) [1][2][3]. In practice, both CLI tools generally follow a behavior where they exit with a non-zero status code when a command fails [1][2][4]. While individual commands like verify-pat are designed to validate tokens and will report failure if the token is invalid or unauthorized, these errors are typically presented as messages in the console rather than through differentiated exit codes [1][2]. No authoritative technical specification or documentation exists that breaks down exit codes by error category for these commands.
Citations:
- 1: https://deepwiki.com/microsoft/vscode-vsce/4.1-command-line-interface
- 2: https://deepwiki.com/eclipse/openvsx/4.2-cli-commands-reference
- 3: https://github.com/eclipse/openvsx/blob/master/cli/README.md
- 4: https://github.com/microsoft/vscode-vsce/blob/2589114ed647fc63f0501be95f9dcdff794ddffa/src/main.ts
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the action file, then inspect the relevant range with line numbers.
wc -l actions/probe-publish-token/action.yml
sed -n '1,220p' actions/probe-publish-token/action.yml | cat -nRepository: nimbus-agent/.github
Length of output: 4286
Don’t map every non-zero verify-pat failure to dead (actions/probe-publish-token/action.yml:64-74). A failed install or CLI/runtime error also lands here; if the registry responds 200, this will report a revoked token even though the token was never successfully checked. Keep these cases indeterminate unless you can detect an explicit auth rejection.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@actions/probe-publish-token/action.yml` around lines 64 - 74, The non-zero
verify-pat path incorrectly marks all failures as dead when the registry is
reachable. Update the failure handling around the verify-pat result and
reachability probe so status=dead is emitted only when the CLI explicitly
reports an authentication rejection; keep install, runtime, timeout, and other
failures indeterminate, including when the registry returns 200.
| function runbook(pkg, version, status, detail) { | ||
| return [ | ||
| "::error::npm provenance verification FAILED", | ||
| `::error::package=${pkg}@${version} status=${status} detail=${detail}`, | ||
| "::error::RUNBOOK:", | ||
| "::error:: 1. This version is already on the registry. npm allows unpublish", | ||
| "::error:: only within 72h of publish — check the publish timestamp now.", | ||
| "::error:: 2. Within 72h: `npm unpublish <pkg>@<version>`, fix the cause, republish.", | ||
| '::error:: 3. After 72h: `npm deprecate <pkg>@<version> "no provenance; use <next>"`', | ||
| "::error:: then publish a patch version with provenance.", | ||
| "::error:: 4. Common causes: `id-token: write` missing from the publish job;", | ||
| "::error:: npm older than 11.5.1; trusted-publisher binding removed on npmjs.com.", | ||
| ].join("\n"); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
set -euo pipefail
printf '--- files ---\n'
git ls-files actions/verify-npm-provenance/src/main.js actions/verify-npm-provenance/src/classify.js actions/verify-npm-provenance/.github/workflows 2>/dev/null || true
printf '\n--- main.js outline ---\n'
ast-grep outline actions/verify-npm-provenance/src/main.js --view expanded || true
printf '\n--- classify.js outline ---\n'
ast-grep outline actions/verify-npm-provenance/src/classify.js --view expanded || true
printf '\n--- relevant lines main.js ---\n'
cat -n actions/verify-npm-provenance/src/main.js | sed -n '1,220p'
printf '\n--- relevant lines classify.js ---\n'
cat -n actions/verify-npm-provenance/src/classify.js | sed -n '1,220p'
printf '\n--- search for GITHUB_OUTPUT usage ---\n'
rg -n "GITHUB_OUTPUT|appendFileSync\\(|console\\.log\\(|runbook\\(" actions/verify-npm-provenance/srcRepository: nimbus-agent/.github
Length of output: 249
🏁 Script executed:
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('actions/verify-npm-provenance/src/main.js')
print(p.exists(), p.stat().st_size if p.exists() else None)
PYRepository: nimbus-agent/.github
Length of output: 168
🏁 Script executed:
set -euo pipefail
echo '--- main.js outline ---'
ast-grep outline actions/verify-npm-provenance/src/main.js --view expanded || true
echo
echo '--- classify.js outline ---'
ast-grep outline actions/verify-npm-provenance/src/classify.js --view expanded || true
echo
echo '--- main.js lines ---'
cat -n actions/verify-npm-provenance/src/main.js | sed -n '1,220p'
echo
echo '--- classify.js lines ---'
cat -n actions/verify-npm-provenance/src/classify.js | sed -n '1,220p'
echo
echo '--- output consumption search ---'
rg -n "steps\\.verify\\.outputs\\.status|outputs\\.status|GITHUB_OUTPUT|appendFileSync\\(|console\\.log\\(|runbook\\(" actions/verify-npm-provenance/src .github actions -g '!**/node_modules/**'Repository: nimbus-agent/.github
Length of output: 10093
🌐 Web query:
GitHub Actions GITHUB_OUTPUT multiline delimiter @actions/core setOutput escaping %0A %0D current behavior
💡 Result:
To set a multiline output in GitHub Actions using the GITHUB_OUTPUT environment file, you should use a heredoc-style delimiter [1][2]. The official and recommended approach is to append the output name followed by a unique delimiter, the multiline value, and the closing delimiter to the file specified by the $GITHUB_OUTPUT environment variable [1][3][4]. The standard syntax is as follows: {name}<<{delimiter} {value} {delimiter} For example, using bash: { echo 'MY_OUTPUT<<EOF' echo 'line 1' echo 'line 2' echo 'EOF' } >> "$GITHUB_OUTPUT" Important Considerations: 1. Delimiter Uniqueness: Ensure the chosen delimiter (e.g., EOF) does not appear within your actual value [1][5][6]. If the value contains arbitrary text that might include the delimiter, this method is unsafe, and you should instead write the output to a temporary file [1]. 2. Closing Delimiter Requirements: The closing delimiter must be on its own line [1][4]. A common point of failure is when the value being output does not end with a newline character, causing the closing delimiter to be appended immediately after the content [4]. To ensure reliability, especially when cat-ing files or command outputs, explicitly add a newline before the closing delimiter, for example by using printf '\n' [4]. 3. Escaping: Unlike older methods (like the deprecated::set-output command which required manual escaping of characters like %0A or %0D), the GITHUB_OUTPUT file mechanism natively handles multiline strings through the delimiter syntax [3][7]. You do not need to manually escape newline or carriage return characters when using the heredoc approach [4][7]. 4. Actions/Toolkit: The @actions/core library's setOutput function internally handles writing to the file specified by GITHUB_OUTPUT [8]. While the toolkit provides a high-level API, it does not have a specialized function for multiline outputs, so users often implement the heredoc approach manually when complex multiline values are required [2][8]. If you are using the output in a subsequent job, remember that multiline strings are preserved, but you may need to handle them carefully in expressions, potentially using fromJSON if the data was serialized as JSON [9].
Citations:
- 1: https://github.com/github/docs/blob/main/content/actions/reference/workflows-and-actions/workflow-commands.md
- 2: https://github.com/actions/toolkit/blob/main/docs/commands.md
- 3: Clarify how multiline output values can be set via
GITHUB_OUTPUTgithub/docs#21529 - 4: https://kabads.monkeez.org/devops/2026-01-08-github-actions-multi-line-output.html
- 5: https://docs.github.com/en/enterprise-server@3.2/actions/reference/workflow-commands-for-github-actions/
- 6: https://tech.aabouzaid.com/2025/06/dgithub-actions-multiline-environment-variable-or-output.html
- 7: https://gist.github.com/RupertBarrow/ef8be6b2051db0fa22447863942f9f93
- 8: https://github.com/actions/toolkit/blob/main/packages/core/src/core.ts
- 9: https://jstrieb.github.io/posts/github-actions-multiline-outputs/
🌐 Web query:
GitHub Actions environment file GITHUB_OUTPUT output parsing duplicate keys newline injection @actions/core setOutput delimiter
💡 Result:
Writing to the GITHUB_OUTPUT environment file is a common method for passing data between steps, but it introduces significant security and parsing risks if not handled correctly [1][2]. ### Core Mechanisms and Risks When you append content to the GITHUB_OUTPUT file, the runner parses the file to identify new outputs [3][4]. The primary risks involve: 1. Newline Injection: If untrusted input containing newlines is written to GITHUB_OUTPUT using the simple name=value format, an attacker can inject additional output variables [1]. Because GitHub Actions follows a last-write-wins rule for duplicate keys, an attacker can overwrite existing outputs or introduce arbitrary ones [1][5]. 2. Heredoc Delimiter Collision: For multiline strings, developers typically use heredoc syntax (e.g., name<<EOF... EOF). If a fixed delimiter like EOF appears within the untrusted content, the parser terminates the value prematurely [6][5]. The remaining part of the content is then parsed as new output commands, allowing for arbitrary variable injection [6]. ### Best Practices for Secure Parsing To prevent these vulnerabilities, implement the following strategies: - Use Random Delimiters: Always generate a unique, cryptographically random delimiter for each heredoc block [6][5]. This ensures that the input content cannot contain a string that prematurely terminates the block [6]. Example (Bash): bash delimiter="$(openssl rand -hex 16)" echo "my_output<<$delimiter" >> "$GITHUB_OUTPUT" echo "$MY_CONTENT" >> "$GITHUB_OUTPUT" echo "$delimiter" >> "$GITHUB_OUTPUT" - Sanitize Inputs: If using the simple name=value format, remove or replace newline characters (\n or \r) from any untrusted data before writing it [1]. - Use Official Libraries: When building custom actions, the @actions/core toolkit handles these concerns automatically [7][8]. The library's setOutput function uses a randomly generated delimiter and validates keys and values to ensure they do not contain that delimiter, mitigating injection risks [9]. - Input Validation: If you are building custom logic that writes to GITHUB_OUTPUT, validate that your output variable names conform to a strict pattern (e.g., ^[a-zA-Z_][a-zA-Z0-9_-]*$) to prevent malformed lines from being interpreted as commands [10]. When possible, prefer using the official @actions/core toolkit rather than manually manipulating GITHUB_OUTPUT via shell redirection, as the toolkit implements these security measures by default [7][9].
Citations:
- 1: https://sisaku-security.github.io/lint/docs/rules/outputclobbering/
- 2: https://oneuptime.com/blog/post/2026-01-30-github-actions-workflow-commands/view
- 3: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
- 4: https://github.com/github/docs/blob/main/content/actions/reference/workflows-and-actions/workflow-commands.md
- 5: fix(workflows): randomize $GITHUB_OUTPUT heredoc delimiter (defense in depth) Uniswap/hooklist#545
- 6: Security: fixed EOF heredoc delimiter in action.yml enables step output injection google-github-actions/run-gemini-cli#526
- 7: https://github.com/actions/toolkit/blob/main/packages/core/src/core.ts
- 8: https://github.com/actions/toolkit/blob/main/docs/commands.md
- 9: GITHUB_OUTPUT variables work from workflow echo but not by core API call actions/toolkit#1906
- 10: fix: harden GITHUB_OUTPUT writes against injection (BIPS-35830, BIPS-35845) BeyondTrust/secrets-safe-action#62
Untrusted provenance details can clobber step outputs
actions/verify-npm-provenance/src/main.js:57-60 writes detail straight into GITHUB_OUTPUT and the log stream. A forged attestation value containing \n can inject extra output lines and overwrite status; the ::error:: log path can also be turned into workflow commands. Use a unique heredoc delimiter for GITHUB_OUTPUT or sanitize \r/\n before emitting raw values, and avoid logging untrusted detail verbatim.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@actions/verify-npm-provenance/src/main.js` around lines 26 - 39, Sanitize
untrusted provenance detail before both GITHUB_OUTPUT writes and log emission in
the verification flow around runbook and the output-writing logic: prevent
carriage returns/newlines from creating output records or workflow commands,
using a unique heredoc delimiter for multiline outputs or replacing control
characters. Ensure the runbook error message never logs raw detail, while
preserving the status and package/version context.
Adds two dependency-free composite actions consumed by the Nimbus release pipeline. Sub-project 3 of the org secrets-management program.
verify-npm-provenanceAsserts a published npm version carries both the npm publish attestation and a SLSA provenance predicate, and that the provenance source claim names the expected repo / workflow / commit — "an attestation exists" is a weaker claim than "attested to us".
bundle.dsseEnvelope.payload), not the unsigned outer wrapper.severity: gatefails the job on any non-okstatus;severity: monitoralways exits 0 and lets the caller classify — so a registry hiccup cannot spam the weekly issue filer while a degraded release still fails loudly.absent(genuinely no attestation) fromerror(registry/network), retrying a deterministic 5s→30s backoff (~2.5 min) before concluding absence.probe-publish-tokenWeekly liveness probe for
VSCE_PAT/OVSX_PAT, which cannot be retired — neither marketplace supports OIDC trusted publishing.verify-pat, with the token passed via environment only, never argv; all CLI output is discarded (these CLIs can echo request context, and this repo's logs are public).@vscode/vsce@3.9.2,ovsx@1.0.2): this hands a live publish credential to whatevernpxresolves, so a floating range would put an unreviewed third-party release inside the credential's trust boundary.ok/dead/not-configured/indeterminate. A non-zero CLI exit alone is notdead— that requires the service to be independently confirmed reachable. An unset secret isnot-configured, neverdead, so the alert never says "rotate" for a credential that was never provisioned.Testing
26 tests, dependency-free (
node --test), run in CI. Fixtures are real captured registry responses so a shape change surfaces as a test diff. Failure paths are covered explicitly: missing predicates, source/workflow/commit mismatch, 404 exhaustion, 5xx, network throw, malformed JSON, and the gate-vs-monitor severity split.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests