From 45be7d492965dc36e9a61136b1be00089015ec17 Mon Sep 17 00:00:00 2001 From: The tech guy Date: Sun, 18 Oct 2026 12:17:39 +0100 Subject: [PATCH] =?UTF-8?q?security:=20SW-030=20=E2=80=94=20Add=20bounded?= =?UTF-8?q?=20doctor=20output=20regression=20tests=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/doctor.ts | 44 ++++++++++++++++++++++++++++++-- src/diagnostics/run.ts | 37 +++++++++++++++++++++++++-- src/output/redaction.ts | 13 +++++++--- tests/helpers/secret-fixtures.ts | 6 ++--- 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 505e362..3344878 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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 { @@ -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}`); } } diff --git a/src/diagnostics/run.ts b/src/diagnostics/run.ts index e8ccef1..fa84d19 100644 --- a/src/diagnostics/run.ts +++ b/src/diagnostics/run.ts @@ -1,3 +1,8 @@ +import { + boundDiagnosticText, + MAX_DIAGNOSTIC_MESSAGE_LENGTH, + MAX_DIAGNOSTIC_REMEDIATION_LENGTH, +} from './bounds.js'; import type { DiagnosticCheck, DiagnosticReport, @@ -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 @@ -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))); } } diff --git a/src/output/redaction.ts b/src/output/redaction.ts index 21a65f4..3267788 100644 --- a/src/output/redaction.ts +++ b/src/output/redaction.ts @@ -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. @@ -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}`); } diff --git a/tests/helpers/secret-fixtures.ts b/tests/helpers/secret-fixtures.ts index cec814d..958e5ab 100644 --- a/tests/helpers/secret-fixtures.ts +++ b/tests/helpers/secret-fixtures.ts @@ -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. */ @@ -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'; @@ -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;