Skip to content

feat!: safe, consistent --json error documents - #461

Merged
eablack merged 11 commits into
v14.0.0from
eb/W-24877112/json-error-documents
Nov 13, 2026
Merged

eablack merged 11 commits into
v14.0.0from
eb/W-24877112/json-error-documents

Conversation

@eablack

@eablack eablack commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Under --json, oclif's default toErrorJson spreads the error into the document. A parse error carries parse.input.context, which is the command itself: its Config and its API client, loaded token included. That makes documents of several MB that contain the user's API token. This PR fixes that and makes --json errors the same shape for every command.

  • Allowlisted error document. toErrorJson returns {error: {message, name, exitCode, code?, statusCode?, requestId?, retryAfter?, id?, resource?, url?, suggestions?, cause?}} (src/json-error-document.ts). The JsonErrorDocument type is exported, and the README describes the document.
    • Strings are stripped of ANSI codes and capped in size. The whole document is capped at 16 KB.
    • Secrets are redacted: the loaded token and HEROKU_API_KEY by exact value, then @heroku/js-blanket's CREDENTIAL_PATTERNS (1.4.0, feat(patterns): add CREDENTIAL_PATTERNS for credentials in free text js-blanket#77). Those cover HRKU- tokens, Authorization header values in header, JSON and object-literal form, standalone Bearer/Basic credentials, URL passwords and PEM private keys.
    • For API errors, statusCode, id, resource, url, the Request-Id (requestId) and a rate limit's retryAfter are promoted from the response. The body is never copied.
    • For API errors, message is the API's own message (body.message) when there is one. heroku-fetch's errors have fixed messages such as Resource not found, so the API's text would otherwise be lost.
    • name is the error's name, or its class name when that's just Error (oclif's errors), such as NonExistentFlagsError. It's documented as informational: scripts match on exitCode, statusCode, id and code.
    • cause is followed up to 3 levels and stops on cycles.
    • exitCode is the code the process actually exits with. This needs @oclif/core 5.1.9 or later, whose catch() exits with the error's own code (2 for a parse error), so the dependency floor is now ^5.1.9. Older versions exited 1 for every error under --json.
    • A thrown non-Error (undefined, null, a plain object) becomes an Error first. oclif's catch() crashed reading its properties, so throw undefined printed no document.
    • With HEROKU_DEBUG=1 or true, the full error (stack included, secrets redacted) goes to stderr. stdout still gets only the document. DEBUG doesn't turn this on, since other tools use it for their own namespaces.
  • Credential patterns in the credential Sentry scrubber. beforeSend now applies CREDENTIAL_PATTERNS before PII_PATTERNS. Credentials go first because the email pattern otherwise matches pass@host in a database URL, which leaks the username and still leaves part of the URL behind.
  • this.exit(n) under JSON output prints no document and exits with n. oclif printed an EEXIT: 0 error document, even for this.exit(0).
  • Spinner stopped in catch(). oclif only stops a running spinner when JSON output is off. A running spinner buffers stdout, so under --json the error document was never written.
  • Quiet spinners under JSON output. ux.action writes nothing and doesn't take over stdout/stderr. A spinner already running when the command starts (a prerun hook, a parent command) is stopped first. The originals are put back in finally().
  • static jsonSuppressedBy. Lists flags that print raw output (e.g. authorizations:create --short). When one is set, jsonEnabled() is false. The parsed flags decide: an error before or during parsing is still a JSON document, and a value from the flag's default doesn't count. The README documents it.
  • --prompt rejected under JSON output. Prompts wait for answers a script never types. init() throws "--prompt can't be used with --json" (code HEROKU_PROMPT_WITH_JSON) before any prompt, so it renders as the error document. exclusive: ['json'] alone isn't enough: prompting runs in init(), before parse() enforces it, and HEROKU_CONTENT_TYPE=json sets no flag.

Behavior changes worth noting:

  • --json error documents now carry only the fields listed above.
  • For a failed API request, message is the API's message. http-call errors no longer append the Error ID, App and See … for more information. lines; id and url carry those, and the app name is dropped.
  • jsonEnabled() can now return false under --json.
  • --prompt with --json or HEROKU_CONTENT_TYPE=json now fails instead of prompting.
  • this.exit(n) under --json prints nothing.
  • DEBUG alone no longer writes debug output under --json.
  • Redaction, in the error document, HEROKU_DEBUG output and credential Sentry events:
    • An Authorization value with a scheme other than Bearer or Basic (Digest, AWS4-HMAC-SHA256, …) is redacted to the end of the header value. The scheme word is kept for Digest, Negotiate, NTLM, OAuth and Token.
    • PEM private keys are redacted. The -----BEGIN … PRIVATE KEY----- line is kept.
    • A Bearer/Basic value outside an Authorization header is redacted only when it looks like a credential, so prose like "Basic dynos" is left alone. Inside an Authorization header it is always redacted.
  • @oclif/core 5.1.9 or later is required. Under --json, a parse error or the --prompt rejection now exits 2 instead of 1, matching text mode. heroku/cli already requires ^5.1.9.

Type of Change

Breaking Changes (major semver update)

  • Add a ! after your change type to denote a change that breaks current behavior

Feature Additions (minor semver update)

  • feat: Introduces a new feature to the codebase

Patch Updates (patch semver update)

  • fix: Bug fix
  • deps: Dependency upgrade
  • revert: Revert a previous commit
  • chore: Change that does not affect production code
  • refactor: Refactoring existing code without changing behavior
  • test: Add/update/remove tests

Testing

Notes: Run npm run build here. Then, in a heroku/cli checkout on a v12 branch with --json commands (e.g. eb/W-23478633/certs-json), point @heroku-cli/command at this build: npm install --no-save ../heroku-cli-command. All steps below are read-only.

Steps:

  1. ./bin/run regions --json --bogus; echo $?. Before this change, the document is several MB and contains your token. Now it's a few lines: message names --bogus, name is NonExistentFlagsError, exitCode is 2 and matches the exit status printed by echo $?, and there's no parse key.
  2. ./bin/run certs:info --app <an app you can read> --json --name does-not-exist, or any request that fails while a spinner is running. The JSON error document prints, and no spinner text appears on stderr.
  3. ./bin/run authorizations:info 00000000-0000-0000-0000-000000000000 --json. The document has statusCode: 404, id: "not_found" and a requestId, and no body. message is the API's text, not Resource not found, and has no Error ID line.
  4. Repeat step 3 with HEROKU_DEBUG=1. stderr shows the full error with a stack, and any token appears as [REDACTED]. stdout is still just the document. With DEBUG=express:* instead, stderr is empty.
  5. ./bin/run certs:info --app <app> without --json still shows the spinner as before.
  6. ./bin/run certs:info --json --prompt; echo $?. No prompt appears; stdout is an error document whose message says --prompt can't be used with --json, with code: "HEROKU_PROMPT_WITH_JSON", and echo $? prints 2, matching exitCode. Repeat as HEROKU_CONTENT_TYPE=json ./bin/run certs:info --prompt for the same result.
  7. ./bin/run certs:info --prompt without --json still prompts (Ctrl-C to quit).

Related Issues

GUS work item: W-24877112

- toErrorJson returns an allowlisted error document (message, name, exitCode,
  code, statusCode, id, resource, url, suggestions, cause). Strings are
  ANSI-stripped, size-capped and redacted (the loaded token, HEROKU_API_KEY,
  HRKU- tokens, Authorization values, URL credentials). The parser context,
  Config and API client never reach stdout. HEROKU_DEBUG/DEBUG writes the full
  redacted error to stderr.
- catch() stops a running spinner first, so the JSON error document isn't
  buffered and lost.
- Spinners are quiet under JSON output.
- static jsonSuppressedBy lists flags that print raw output and turn JSON off.

BREAKING CHANGE: --json error documents carry only the documented fields, and
jsonEnabled() is false when a jsonSuppressedBy flag is set.
@eablack
eablack requested a review from a team as a code owner November 12, 2026 21:10
A loaded Config sends getAuth() through credential storage under the data
dir, which hung on the Windows runners. Also restore sinon stubs after each
test so one timeout can't fail the rest.
… env

The static Command.run reloads the Config it is given; with process.env wiped,
that reload hung on Windows. Construct the command and call _run() instead,
and keep the platform environment, removing only HEROKU_* and DEBUG.
--prompt asks questions a script running with --json will never answer.
Prompting runs in init(), before parse() enforces exclusive, and
HEROKU_CONTENT_TYPE=json sets no flag, so init() checks jsonEnabled()
and throws before any prompt. exclusive: ['json'] on the flag documents
the conflict.
- Stop a spinner already running when quiet mode starts, so neither the
  error document nor the result lands in its buffer.
- this.exit(n) under JSON output prints no document and exits with n.
- name falls back to the error's class name; documented as informational.
- Add requestId and retryAfter; export the ErrorDocument type; README
  section on the document and its compatibility rules.
- jsonSuppressedBy is decided by the parsed flags only, and ignores a
  value that comes from the flag's default.
- Debug output on stderr only for HEROKU_DEBUG=1|true, not any DEBUG.
- A thrown non-Error (undefined, null, a plain object) becomes an Error
  before oclif's catch(), which crashed reading its properties.
- The --prompt rejection has the code HEROKU_PROMPT_WITH_JSON.

@michaelmalave michaelmalave left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-tested change: the --json error document is now small, consistent and token-free, and the spinner and --prompt handling are clear improvements. Tests and typecheck pass on this branch. A few small things below.

Comment thread src/json-error-document.ts Resolved
Comment thread README.md Resolved
Comment thread src/json-error-document.ts Resolved
Comment thread src/json-error-document.ts Resolved

@michaelmalave michaelmalave left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments but overall nice work. LGTM

Bump @heroku/js-blanket to ^1.4.0 and use CREDENTIAL_PATTERNS in redactSecrets,
replacing the hand-rolled Heroku token, Bearer/Basic and URL credential regexes.
This covers --json error documents and HEROKU_DEBUG error output, and adds
Digest and other Authorization schemes, quoted header values, and PEM private
keys.

Add CREDENTIAL_PATTERNS ahead of PII_PATTERNS in the credential Sentry
scrubber, so a URL password is replaced as a whole rather than partly matched
by the email pattern.
heroku-fetch errors have fixed messages such as "Resource not found", so the
API's own text, which only exists in body.message, was lost. Use body.message
when it is a non-empty string, through the same strip, redact and cap steps.
http-call errors now drop the appended Error ID and URL lines, which are
already in the id and url fields, so both clients give the same message.
@eablack
eablack merged commit 5d5bdc0 into v14.0.0 Nov 13, 2026
12 checks passed
@eablack
eablack deleted the eb/W-24877112/json-error-documents branch November 13, 2026 17:54
@eablack eablack mentioned this pull request Nov 13, 2026
1 of 7 tasks
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.

2 participants