Skip to content
Open
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
33 changes: 33 additions & 0 deletions listener/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig, ExpirationConfig, ApiKey } from './types';
import { validateSecrets, SecretValidationError } from './config/validate-secrets';

// Re-export so that index.ts and tests can import everything from one place.
export { validateSecrets, SecretValidationError } from './config/validate-secrets';

export class ConfigError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -470,5 +474,34 @@ export function validateConfig(config: Config): void {
errors.map((e, i) => ` ${i + 1}. ${e}`).join('\n'),
);
}

// ── Secret validation (#692) ───────────────────────────────────────────────
// Run after structural checks so operators see both structural and secret
// problems in a single pass. Errors are reported by field name only; the
// actual secret values are never included in any message.
validateSecrets([
{
fieldName: 'DISCORD_WEBHOOK_URL',
value: config.discord?.webhookUrl,
required: false,
},
{
fieldName: 'DISCORD_WEBHOOK_ID',
value: config.discord?.webhookId,
required: false,
},
// Webhook signing secrets
...((config.webhookSecrets ?? []).map((ws, i) => ({
fieldName: `WEBHOOK_SECRETS[${i}].secret`,
value: ws.secret,
required: true,
}))),
// API keys
...((config.apiKeys ?? []).map((ak, i) => ({
fieldName: `API_KEYS[${i}].key`,
value: ak.key,
required: true,
}))),
]);
}

153 changes: 153 additions & 0 deletions listener/src/config/validate-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Startup secret validation (#692).
*
* Enforces that every required credential is present and, in production mode,
* does not use a known development placeholder. Designed to be called once
* during application bootstrap so the service fails fast rather than starting
* in an insecure state.
*
* ## Design principles
*
* - **Zero-leak diagnostics**: error messages name the *field* that failed and
* the *reason* (missing / placeholder) but never echo the actual value.
* - **Collect-all errors**: every violation is gathered before throwing so an
* operator sees all problems in a single restart, not one per restart.
* - **Production-only placeholder rejection**: placeholder detection is only
* active when `NODE_ENV === "production"` so development environments can
* use example values without being blocked.
*/

/** A ConfigError subclass raised by secret validation failures. */
export class SecretValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecretValidationError';
}
}

/**
* Known development placeholder strings that must never appear in a
* production configuration. Extend this list as new sentinel values emerge.
*
* All comparisons are **case-insensitive** and **trimmed**.
*/
export const KNOWN_PLACEHOLDERS: ReadonlyArray<string> = [
'your_secret_here',
'your-secret-here',
'changeme',
'change_me',
'change-me',
'admin',
'password',
'secret',
'123456',
'1234567890',
'abcdef',
'test',
'example',
'placeholder',
'todo',
'fixme',
'replace_me',
'replace-me',
'your_webhook_token',
'your_hmac_secret',
'your_api_key',
'whsec_your_secret_here',
'sk_live_abc123',
'your_webhook_id',
'xxxxxxxxxxxxxxxxxxxx',
];

/**
* Descriptor for a single secret field that must be validated on startup.
* Callers build a list of these and pass it to `validateSecrets`.
*/
export interface SecretField {
/**
* The environment variable name (e.g. `"DISCORD_WEBHOOK_URL"`).
* Used exclusively in diagnostic messages — the value is never included.
*/
fieldName: string;

/** The resolved value of the field (may be undefined/empty). */
value: string | undefined | null;

/**
* When `true` the field is required: a missing or empty value fails
* validation regardless of the current environment.
* When `false` the field is optional but still checked for placeholders in
* production if a non-empty value is present.
*/
required?: boolean;
}

/**
* Return `true` when `value` matches a known development placeholder.
* The comparison is case-insensitive and both sides are trimmed.
*/
export function isPlaceholder(value: string): boolean {
const normalised = value.trim().toLowerCase();
return KNOWN_PLACEHOLDERS.some((placeholder) => normalised === placeholder.toLowerCase());
}

/**
* Validate a list of secret fields and throw a `SecretValidationError` when
* any violation is found.
*
* Violations collected:
* 1. A required field is missing or empty → always fails.
* 2. Any field (required or optional) that has a non-empty value matching a
* known placeholder while `NODE_ENV === "production"` → fails in production.
*
* @param fields - List of secret fields to validate.
* @param isProduction - Override production detection (defaults to
* `process.env.NODE_ENV === "production"`). Useful in tests.
*
* @throws {SecretValidationError} when one or more fields fail validation.
*
* @example
* ```ts
* validateSecrets([
* { fieldName: 'DISCORD_WEBHOOK_URL', value: process.env.DISCORD_WEBHOOK_URL, required: true },
* { fieldName: 'WEBHOOK_SECRET', value: process.env.WEBHOOK_SECRET, required: false },
* ]);
* ```
*/
export function validateSecrets(
fields: SecretField[],
isProduction: boolean = process.env.NODE_ENV === 'production'
): void {
const errors: string[] = [];

for (const field of fields) {
const trimmedValue = field.value?.trim();
const isEmpty = !trimmedValue;

// 1. Required-field check.
if (field.required && isEmpty) {
errors.push(
`[Config Error] Required secret field '${field.fieldName}' is missing or empty. ` +
`Set the environment variable '${field.fieldName}' to a secure, non-placeholder value.`
);
// Skip placeholder check – there is nothing to check.
continue;
}

// 2. Placeholder check (production only, only when a value is present).
if (isProduction && !isEmpty && isPlaceholder(trimmedValue as string)) {
errors.push(
`[Config Error] Secret field '${field.fieldName}' contains a known development ` +
`placeholder value in production mode. ` +
`Update the environment variable '${field.fieldName}' with a secure, randomly-generated secret.`
);
}
}

if (errors.length > 0) {
throw new SecretValidationError(
`Secret validation failed with ${errors.length} error(s):\n` +
errors.map((e, i) => ` ${i + 1}. ${e}`).join('\n')
);
}
}
9 changes: 8 additions & 1 deletion listener/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { NotificationMetricsRunner } from './services/notification-metrics-runne
import { eventRegistry } from './store/event-registry';
import logger from './utils/logger';
import { loadConfig, validateConfig, ConfigError } from './config';
import { SecretValidationError } from './config/validate-secrets';
import { NotificationHealthMonitor } from './services/notification-health-monitor';
import { getWorkerManager } from './services/worker-manager';
import { EventDeduplicationService } from './services/event-deduplication-service';
Expand Down Expand Up @@ -232,7 +233,13 @@ async function main() {
}

main().catch((err) => {
if (err instanceof ConfigError) {
if (err instanceof SecretValidationError) {
// Secret validation failures are reported field-by-field without echoing
// actual secret values (#692).
logger.error('Startup secret validation failed — service will not start', {
error: err.message,
});
} else if (err instanceof ConfigError) {
logger.error('Configuration error', { error: err.message });
} else {
logger.error('Error starting service', { error: err });
Expand Down
23 changes: 16 additions & 7 deletions listener/src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import winston from 'winston';
import { redactObject } from './redact';

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -83,15 +84,23 @@ export function formatError(error: unknown): FormattedError | string {
// Internal helpers
// ---------------------------------------------------------------------------

/**
* Normalize the `error` field inside a meta object and then redact all
* sensitive fields so no credentials reach any log transport.
*
* The pipeline:
* 1. Expand `error` (if present) using `formatError`.
* 2. Redact sensitive keys / URL credentials / auth headers via the
* centralized redaction engine (`redactObject`).
*/
function formatMeta(meta: LogContext): LogContext {
if (!('error' in meta) || meta.error === undefined) {
return meta;
}
const normalized =
'error' in meta && meta.error !== undefined
? { ...meta, error: formatError(meta.error) }
: meta;

return {
...meta,
error: formatError(meta.error),
};
// Redact sensitive fields before any transport receives the object.
return redactObject(normalized as Record<string, unknown>) as LogContext;
}

function logWithMeta(
Expand Down
Loading