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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRequire } from 'node:module';
import { CliStructuredError } from '@internal/errors/control';
import { suppressIdleConnectionErrors } from '@internal/utils/suppress-idle-connection-errors';
import { join } from 'pathe';
import { redactUrlCredentials } from '../../utils/url-credentials';
import type { TargetId } from './templates/code-templates';

/**
Expand Down Expand Up @@ -126,7 +127,7 @@ export async function probeServerVersion(
message: `Skipped --probe-db: ${err.message}. (Run with install enabled, or install the driver yourself, then re-run \`prisma orm init --probe-db\`.)`,
};
}
const cause = redactDatabaseUrlSecrets(causeMessage(err));
const cause = redactUrlCredentials(causeMessage(err));
return {
kind: 'connection-failed',
minVersion,
Expand Down Expand Up @@ -194,20 +195,6 @@ function causeMessage(err: unknown): string {
return String(err);
}

/**
* Strips `user:password@` userinfo from any URL-shaped substring before
* we surface the cause to the user. Mirrors `redactSecrets` in
* `init.ts` — the probe path has its own redactor because the inputs
* here include the raw connection string by construction (driver
* errors echo the URL back).
*
* Exported for unit tests.
*/
export function redactDatabaseUrlSecrets(text: string): string {
if (!text) return text;
return text.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/@\s]+)@/g, '$1***@');
}

async function defaultProbePostgres(
databaseUrl: string,
baseDir: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { redactUrlCredentials } from '../../utils/url-credentials';

/**
* Strips credentials out of package-manager stderr before it reaches a warning,
* an error's meta, or a log. Two shapes carry them: userinfo inside a registry
Expand All @@ -11,7 +13,5 @@
* while trusting another layer to have done it is not.
*/
export function redactSecrets(stderr: string): string {
return stderr
.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/@\s]+)@/g, '$1***@')
.replace(/(\b_(?:authToken|auth|password)=)\S+/gi, '$1***');
return redactUrlCredentials(stderr).replace(/(\b_(?:authToken|auth|password)=)\S+/gi, '$1***');
}
66 changes: 44 additions & 22 deletions packages/1-framework/3-tooling/cli/src/utils/command-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import { APP_SPACE_ID, spaceMigrationDirectory } from '@internal/migration-tools
import { relative, resolve } from 'pathe';
import type { ControlClient } from '../control-api/types';
import { CliStructuredError, errorRuntime } from './cli-errors';
import {
hasUrlScheme,
passwordQueryValues,
redactUrlCredentials,
urlUserinfo,
} from './url-credentials';

const PASSWORD_QUERY_KEY = /password/i;

/**
* Resolves the absolute path to contract.json from the config.
Expand Down Expand Up @@ -195,7 +203,8 @@ export async function readContractEnvelope(config: {

/**
* Masks credentials in a database connection URL.
* Handles standard URLs (username + password + query params) and libpq-style key=value strings.
* Handles standard URLs (username + password + query params), URLs `new URL` rejects (such as
* multi-host URLs), and libpq-style key=value strings.
*/
export function maskConnectionUrl(url: string): string {
try {
Expand All @@ -208,16 +217,13 @@ export function maskConnectionUrl(url: string): string {
}
// Also mask password in query parameters (e.g., ?password=secret, ?sslpassword=secret)
for (const key of [...parsed.searchParams.keys()]) {
if (/password/i.test(key)) {
if (PASSWORD_QUERY_KEY.test(key)) {
parsed.searchParams.set(key, '****');
}
}
return parsed.toString();
} catch {
// Fallback for libpq-style key=value connection strings (e.g., "host=localhost password=secret user=admin")
return url
.replace(/password\s*=\s*\S+/gi, 'password=****')
.replace(/user\s*=\s*\S+/gi, 'user=****');
return hasUrlScheme(url) ? redactUrlCredentials(url) : maskKeyValueCredentials(url);
}
}

Expand All @@ -229,23 +235,39 @@ export function sanitizeErrorMessage(message: string, connectionUrl?: string): s
if (!connectionUrl) {
return message;
}
const secrets = connectionUrlSecrets(connectionUrl);
if (secrets === undefined) {
return maskKeyValueCredentials(message);
}
let sanitized = message.replaceAll(connectionUrl, maskConnectionUrl(connectionUrl));
for (const secret of secrets.filter(Boolean).sort(longestFirst)) {
sanitized = sanitized.replaceAll(secret, '****');
}
return sanitized;
}

function connectionUrlSecrets(connectionUrl: string): string[] | undefined {
try {
const parsed = new URL(connectionUrl);
// Replace the full URL (with and without trailing slash)
let sanitized = message;
sanitized = sanitized.replaceAll(connectionUrl, maskConnectionUrl(connectionUrl));
// Also replace the password and username individually if they appear
if (parsed.password) {
sanitized = sanitized.replaceAll(parsed.password, '****');
}
if (parsed.username) {
sanitized = sanitized.replaceAll(parsed.username, '****');
}
return sanitized;
const { username, password, searchParams } = new URL(connectionUrl);
const queryPasswords = [...searchParams]
.filter(([key]) => PASSWORD_QUERY_KEY.test(key))
.map(([, value]) => value);
return [username, password, ...queryPasswords];
} catch {
// For libpq-style strings, mask password and user values in the message
return message
.replace(/password\s*=\s*\S+/gi, 'password=****')
.replace(/user\s*=\s*\S+/gi, 'user=****');
if (!hasUrlScheme(connectionUrl)) {
return undefined;
}
const { username, password } = urlUserinfo(connectionUrl);
return [username, password, ...passwordQueryValues(connectionUrl)];
}
}

function longestFirst(a: string, b: string): number {
return b.length - a.length;
}

function maskKeyValueCredentials(text: string): string {
return text
.replace(/password\s*=\s*\S+/gi, 'password=****')
.replace(/user\s*=\s*\S+/gi, 'user=****');
}
58 changes: 58 additions & 0 deletions packages/1-framework/3-tooling/cli/src/utils/url-credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const MASK = '****';
const URL_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
const LEADING_URL_USERINFO = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/?#\s]+)@/;
const URL_USERINFO = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#\s]+)@/g;
const PASSWORD_QUERY_PARAMETER = /([?&][^=&#\s]*password[^=&#\s]*=)([^&#\s]*)/gi;

export interface UrlUserinfo {
readonly username: string;
readonly password: string;
}

export function hasUrlScheme(text: string): boolean {
return URL_SCHEME.test(text);
}

/**
* Reads the userinfo of a URL without parsing its hosts, so it works for URLs `new URL` rejects,
* such as a seed list of several `host:port` pairs. The userinfo ends at the last `@` before the
* first `/`, `?` or `#`.
*/
export function urlUserinfo(url: string): UrlUserinfo {
return splitUserinfo(url.match(LEADING_URL_USERINFO)?.[1] ?? '');
}

/**
* Reads the values of the query parameters whose key contains `password`, the same ones
* `redactUrlCredentials` masks, without parsing hosts.
*/
export function passwordQueryValues(url: string): string[] {
return [...url.matchAll(PASSWORD_QUERY_PARAMETER)].map((match) => match[2] ?? '');
}

/**
* Masks the userinfo and any password query parameter of every URL in `text`, without parsing
* hosts. The masked form matches what `new URL` gives when its username and password are set to
* `****`.
*/
export function redactUrlCredentials(text: string): string {
return text
.replace(
URL_USERINFO,
(_match, scheme: string, userinfo: string) =>
`${scheme}${maskUserinfo(splitUserinfo(userinfo))}@`,
)
.replace(PASSWORD_QUERY_PARAMETER, `$1${MASK}`);
}

function splitUserinfo(userinfo: string): UrlUserinfo {
const colon = userinfo.indexOf(':');
if (colon === -1) {
return { username: userinfo, password: '' };
}
return { username: userinfo.slice(0, colon), password: userinfo.slice(colon + 1) };
}

function maskUserinfo({ username, password }: UrlUserinfo): string {
return `${username ? MASK : ''}${password ? `:${MASK}` : ''}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
type ProbeOverrides,
parsePostgresVersion,
probeServerVersion,
redactDatabaseUrlSecrets,
} from '../../../src/commands/init/probe-db';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -71,18 +70,6 @@ describe('parsePostgresVersion (FR8.3)', () => {
});
});

describe('redactDatabaseUrlSecrets (FR8.3)', () => {
it('strips userinfo from any URL-shaped substring', () => {
expect(redactDatabaseUrlSecrets('failed: postgres://alice:hunter2@localhost:5432')).toBe(
'failed: postgres://***@localhost:5432',
);
});

it('passes empty input through untouched', () => {
expect(redactDatabaseUrlSecrets('')).toBe('');
});
});

// ---------------------------------------------------------------------------
// FR8.3 — probeServerVersion outcome surface
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -172,11 +159,10 @@ describe('probeServerVersion (FR8.3)', () => {
},
);

expect(outcome.kind).toBe('connection-failed');
if (outcome.kind === 'connection-failed') {
expect(outcome.cause).not.toContain('hunter2');
expect(outcome.cause).toContain('***@');
}
expect(outcome).toMatchObject({
kind: 'connection-failed',
cause: 'connect ECONNREFUSED postgres://****:****@localhost:5432',
});
});

it('returns driver-missing when require() cannot resolve the peer driver', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,32 @@ describe('redactSecrets', () => {
describe('credentials inside a URL', () => {
it('redacts userinfo from URLs in stderr', () => {
expect(redactSecrets('failed: https://user:pass@registry.example.com/foo')).toBe(
'failed: https://***@registry.example.com/foo',
'failed: https://****:****@registry.example.com/foo',
);
});

it('redacts a bare token URL', () => {
expect(redactSecrets('npm error: https://npm-token-123@registry.npmjs.org/')).toBe(
'npm error: https://***@registry.npmjs.org/',
'npm error: https://****@registry.npmjs.org/',
);
});

it('redacts even when the URL is in the middle of a longer line', () => {
expect(
redactSecrets('GET https://alice:secret@registry.example.com/foo failed: 401 Unauthorized'),
).toBe('GET https://***@registry.example.com/foo failed: 401 Unauthorized');
).toBe('GET https://****:****@registry.example.com/foo failed: 401 Unauthorized');
});

it('redacts up to the last @ of the authority', () => {
expect(redactSecrets('failed: https://user:p@ss@registry.example.com/foo')).toBe(
'failed: https://****:****@registry.example.com/foo',
);
});

it('leaves an @ in the path alone', () => {
expect(redactSecrets('404 GET https://registry.npmjs.org/@prisma%2fclient')).toBe(
'404 GET https://registry.npmjs.org/@prisma%2fclient',
);
});

it('leaves URLs without userinfo untouched', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('emitScaffoldedContract', () => {

expect(error.message).toContain('line-39');
expect(error.message).not.toContain('line-00');
expect(error.message).toContain('***@registry.example.com');
expect(error.message).toContain('https://****:****@registry.example.com/');
expect(error.message).not.toContain('hunter2');
expect(error.message).toContain('3');
},
Expand Down
Loading
Loading