diff --git a/packages/git/src/errors.ts b/packages/git/src/errors.ts index 33b72820..50d682e3 100644 --- a/packages/git/src/errors.ts +++ b/packages/git/src/errors.ts @@ -1,23 +1,40 @@ +/** + * Mask the `user:password@` userinfo of any URL embedded in a string, e.g. + * `https://x-access-token:@github.com/o/r` → `https://***@github.com/o/r`. A push/fetch remote + * can be an authenticated HTTPS URL, and git also echoes that URL in its own stderr on an auth/ref + * failure — so the token would otherwise ride the failed argv and stderr into logs and the + * failure-report PR comment. Anchors on the literal `://` and matches the userinfo with a negated + * class terminated by the excluded `@`, so there is no ambiguous prefix to backtrack on (linear — + * avoids the polynomial-regex ReDoS a `scheme` prefix like `[a-z]+://` would introduce). + */ +export function redactUrlCredentials(text: string): string { + return text.replace(/:\/\/[^/@\s]+@/g, '://***@'); +} + /** * Raised when a `git` invocation fails unexpectedly. Carries the full argv that was run, the exit * code, and stderr, so a caller (or a failure report) can show exactly what was attempted without the * adapter having to log. The argv is the array passed to `execFile` — never a shell string — so there - * is nothing to un-escape and no shell interpolation to reconstruct. + * is nothing to un-escape and no shell interpolation to reconstruct. Credentials are the one thing an + * argv *can* carry (an authenticated remote URL), so `message`, `args`, and `stderr` are run through + * {@link redactUrlCredentials} here — every construction site is covered without each caller having to + * remember. The array handed to the runner is untouched (this stores a redacted copy), so the real + * push still uses the real URL. */ export class GitError extends Error { - /** The argv that failed, e.g. `['tag', '-a', 'v1.0.0', '-m', 'release']`. */ + /** The argv that failed, e.g. `['tag', '-a', 'v1.0.0', '-m', 'release']`; URL userinfo redacted. */ readonly args: string[]; /** The process exit code, or undefined when git could not be spawned at all. */ readonly exitCode: number | undefined; - /** Captured stderr, trimmed; empty when none. */ + /** Captured stderr, trimmed; empty when none; URL userinfo redacted. */ readonly stderr: string; constructor(message: string, args: string[], exitCode: number | undefined, stderr: string) { - super(message); + super(redactUrlCredentials(message)); this.name = 'GitError'; - this.args = args; + this.args = args.map(redactUrlCredentials); this.exitCode = exitCode; - this.stderr = stderr; + this.stderr = redactUrlCredentials(stderr); } } diff --git a/packages/git/test/unit/git.spec.ts b/packages/git/test/unit/git.spec.ts index c10e0ec0..edff51cc 100644 --- a/packages/git/test/unit/git.spec.ts +++ b/packages/git/test/unit/git.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { GitError, gitExitCode, isCommandNotFound, isExecTimeout } from '../../src/errors.js'; +import { GitError, gitExitCode, isCommandNotFound, isExecTimeout, redactUrlCredentials } from '../../src/errors.js'; import { createGitCli, GitCli, type GitRunner } from '../../src/git.js'; interface RunOptions { @@ -369,6 +369,38 @@ describe('GitError mapping', () => { expect(error.stderr).toBe('fatal: bad object'); expect(error.message).toContain('fatal: bad object'); }); + + it('should redact URL userinfo in a push failure message, argv, and stderr', async () => { + const remote = 'https://x-access-token:SECRETTOKEN@github.com/o/r.git'; + const { run } = makeRunner({ + reject: exitError(128, `fatal: unable to access '${remote}/': The requested URL returned error: 403`), + }); + const error = await new GitCli(run).push({ remote, ref: 'main' }).catch((e) => e as GitError); + expect(error).toBeInstanceOf(GitError); + expect(error.message).not.toContain('SECRETTOKEN'); + expect(error.stderr).not.toContain('SECRETTOKEN'); + expect(error.args.join(' ')).not.toContain('SECRETTOKEN'); + expect(error.message).toContain('https://***@github.com/o/r.git'); + }); +}); + +describe('redactUrlCredentials', () => { + it('should mask user:password userinfo while leaving credential-free URLs intact', () => { + expect(redactUrlCredentials('https://x-access-token:tok@github.com/o/r')).toBe('https://***@github.com/o/r'); + expect(redactUrlCredentials('git push https://user:pw@host/a failed: https://user:pw@host/a')).toBe( + 'git push https://***@host/a failed: https://***@host/a', + ); + // No userinfo → unchanged; scp-style remotes have no scheme:// and carry no inline secret. + expect(redactUrlCredentials('https://github.com/o/r.git')).toBe('https://github.com/o/r.git'); + expect(redactUrlCredentials('git@github.com:o/r.git')).toBe('git@github.com:o/r.git'); + }); + + it('should scan a long adversarial input linearly (no polynomial-regex ReDoS)', () => { + // A long scheme-like run with no `://…@` span must return promptly and unchanged; the fixed + // `://` anchor + negated class give a linear scan (guards the CodeQL polynomial-regex alert). + const long = 'a'.repeat(200_000); + expect(redactUrlCredentials(long)).toBe(long); + }); }); describe('createGitCli', () => {