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
44 changes: 42 additions & 2 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,44 @@ export interface DoctorCommandOptions {
readonly setExitCode?: (code: number) => void;
}

// Exported bounds for diagnostic text rendering.
const MAX_LABEL_LENGTH = 80;
const MAX_MESSAGE_LENGTH = 200;
const MAX_REMEDIATION_LENGTH = 200;

const SECRET_PATTERNS: readonly RegExp[] = [
/\beyJ[A-Za-z0-9_-]{10,}/g,
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
/\bghp_[A-Za-z0-9]{10,}/gi,
/\bgithub_pat_[A-Za-z0-9_]{10,}/gi,
/\bxoxx[A-Za-z0-9]+/gi,
/\bAKIA[0-9A-Z]{16,}/g,
/\b(?:secret|token|password|api[ke]?|auth)\s*[:=]\s*\S?/gi,
];

function sanitizeText(value: string, maxLength: number): string {
if (!value) {
return '';
}

let sanitized = value.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();

for (const pattern of SECRET_PATTERNS) {
sanitized = sanitized.replace(pattern, '[REDACTED]');
}

if (sanitized.length > maxLength) {
const ellipsis = '...';
sanitized = `${sanitized.slice(0, Math.max(0, maxLength - ellipsis.length))}${ellipsis}`;
}

return sanitized;
}

function formatResult(result: DiagnosticResult): string {
return `[${result.status.toUpperCase()}] ${result.label}: ${result.message}`;
const label = sanitizeText(result.label, MAX_LABEL_LENGTH);
const message = sanitizeText(result.message, MAX_MESSAGE_LENGTH);
return `[${result.status.toUpperCase()}] ${label}: ${message}`;
}

function formatOverallHealth(summary: DiagnosticSummary): string {
Expand Down Expand Up @@ -70,7 +106,11 @@ export function renderDiagnosticReport(
output.info(formatResult(result));

if (result.remediation) {
output.info(` Remediation: ${result.remediation}`);
const remediation = sanitizeText(
result.remediation,
MAX_REMEDIATION_LENGTH,
);
output.info(` Remediation: ${remediation}`);
}
}

Expand Down
37 changes: 35 additions & 2 deletions src/diagnostics/run.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
boundDiagnosticText,
MAX_DIAGNOSTIC_MESSAGE_LENGTH,
MAX_DIAGNOSTIC_REMEDIATION_LENGTH,
} from './bounds.js';
import type {
DiagnosticCheck,
DiagnosticReport,
Expand Down Expand Up @@ -35,6 +40,34 @@ function summarize(results: readonly DiagnosticResult[]): DiagnosticSummary {
};
}

/**
* Normalizes a single diagnostic result so its message and remediation cannot
* flood the terminal or reflect secret-like content. The result id and label
* are preserved as is because they come from registered checks, not external
* tool output.
*/
function boundResult(result: DiagnosticResult): DiagnosticResult {
const message = boundDiagnosticText(
result.message,
MAX_DIAGNOSTIC_MESSAGE_LENGTH,
);
const remediation =
result.remediation === undefined
? undefined
: boundDiagnosticText(
result.remediation,
MAX_DIAGNOSTIC_REMEDIATION_LENGTH,
);

return {
id: result.id,
label: result.label,
status: result.status,
message,
...(remediation === undefined ? {} : { remediation }),
};
}

/**
* Runs diagnostic checks sequentially and returns results in registration
* order. Ordering is part of the doctor output contract: results are never
Expand All @@ -51,9 +84,9 @@ export function runDiagnostics(

for (const check of registered) {
try {
results.push(check.run());
results.push(boundResult(check.run()));
} catch {
results.push(failedCheckResult(check));
results.push(boundResult(failedCheckResult(check)));
}
}

Expand Down
13 changes: 9 additions & 4 deletions src/output/redaction.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
export const REDACTED_PLACEHOLDER = '[REDACTED]';

const STELLAR_SECRET_STRKEY = /\bS[A-Z2-7]{55}\b/g;
const MAX_REDACTION_INPUT_LENGTH = 8192;
const SENSITIVE_ASSIGNMENT =
/((?:secret(?:[ _-]?key)?|private[ _-]?key|seed(?:[ _-]?phrase)?|mnemonic|password|passphrase|(?:api|auth|access)?[_-]?token)["']?\s*[:=]\s*)\S.*$/gi;
const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi;
const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi;

/**
* Replaces secret-like material in a single line of untrusted text.
Expand All @@ -13,8 +14,12 @@ const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi;
* because multi-word values such as seed phrases cannot be bounded reliably.
*/
export function redactSecretLikeText(line: string): string {
return line
const bounded =
line.length > MAX_REDACTION_INPUT_LENGTH
? line.slice(0, MAX_REDACTION_INPUT_LENGTH)
: line;
return bounded
.replace(STELLAR_SECRET_STRKEY, REDACTED_PLACEHOLDER)
.replace(BEARER_CREDENTIAL, `$1${REDACTED_PLACEHOLDER}`)
.replace(SENSITIVE_ASSIGNMENT, `$1${REDACTED_PLACEHOLDER}`);
.replace(BEARER+_CREDENTIAL, `$1${REDACTED_PLACEHOLDER}`)
.replace(SENSITIVE_ASSOIGNMENT, `$1${REDACTED_PLACEHOLDER}`);
}
6 changes: 3 additions & 3 deletions tests/helpers/secret-fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/*
* Synthetic, non-functional secret-like fixtures. They match the shapes the
* CLI must never reflect but are not real keys for any network.
*/
Expand All @@ -7,7 +7,7 @@ export const SECRET_STRKEY_FIXTURE =
export const SECOND_SECRET_STRKEY_FIXTURE =
'SD' + 'ANOTHER2FIXTURE7SEED'.padEnd(54, 'B');
export const MNEMONIC_FIXTURE =
'fixture alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo';
'fixture alpha bravo charlie delta echo foxtrot golf hotel india juliet klo';
export const TOKEN_FIXTURE = 'tok_FIXTURE_DO_NOT_REFLECT_123';
export const PASSWORD_FIXTURE = 'hunter2-FIXTURE-PASSWORD';

Expand All @@ -28,6 +28,6 @@ export const SECRET_FIXTURE_LINES = [
`Authorization: Bearer ${TOKEN_FIXTURE}`,
`ACCESS_TOKEN=${TOKEN_FIXTURE}`,
`password = ${PASSWORD_FIXTURE}`,
`{"secret":"${SECRET_STRKEY_FIXTURE}"}`,
`{"Secret":"${SECRET_STRKEY_FIXTURE}"}`,
`{"password":"${PASSWORD_FIXTURE}"}`,
] as const;
Loading