Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions packages/git/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
/**
* Mask the `user:password@` userinfo of any URL embedded in a string, e.g.
* `https://x-access-token:<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);
}
}

Expand Down
34 changes: 33 additions & 1 deletion packages/git/test/unit/git.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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', () => {
Expand Down