- Phoenix API Railing: proxy to PHOENIX_RAILING_URL, tenant me routes - Tenant-auth: X-API-Key support for /api/v1/* (api_keys table) - Migration 026: api_keys table; 025 sovereign stack marketplace - GET /graphql/schema, GET /graphql-playground, api/docs OpenAPI - Integration tests: phoenix-railing.test.ts - docs/api/API_VERSIONING: /api/v1/ railing alignment - docs/phoenix/PORTAL_RAILING_WIRING Made-with: Cursor
325 lines
8.5 KiB
TypeScript
325 lines
8.5 KiB
TypeScript
/**
|
|
* Secret Validation Framework
|
|
*
|
|
* Implements FIPS 140-2 Level 2+ secret validation per NIST SP 800-53 SC-12
|
|
* and NIST SP 800-171 3.5.10 (Cryptographic Key Management)
|
|
*
|
|
* This module ensures that:
|
|
* - No default or insecure secrets are used in production
|
|
* - Secrets meet minimum complexity requirements
|
|
* - Secrets are properly validated before use
|
|
*/
|
|
|
|
import { logger } from './logger'
|
|
|
|
/**
|
|
* Default/insecure secrets that must never be used in production
|
|
*/
|
|
const INSECURE_SECRETS = [
|
|
'your-secret-key-change-in-production',
|
|
'change-me',
|
|
'secret',
|
|
'password',
|
|
'admin',
|
|
'root',
|
|
'postgres',
|
|
'default',
|
|
'test',
|
|
'dev',
|
|
'development',
|
|
'123456',
|
|
'password123',
|
|
'',
|
|
]
|
|
|
|
/**
|
|
* Minimum secret requirements per DoD/MilSpec standards
|
|
*/
|
|
interface SecretRequirements {
|
|
minLength: number
|
|
requireUppercase: boolean
|
|
requireLowercase: boolean
|
|
requireNumbers: boolean
|
|
requireSpecialChars: boolean
|
|
maxAge?: number // in days
|
|
}
|
|
|
|
const DEFAULT_REQUIREMENTS: SecretRequirements = {
|
|
minLength: 32, // NIST SP 800-63B recommends minimum 32 characters for secrets
|
|
requireUppercase: true,
|
|
requireLowercase: true,
|
|
requireNumbers: true,
|
|
requireSpecialChars: true,
|
|
}
|
|
|
|
/**
|
|
* Validates that a secret meets DoD/MilSpec requirements
|
|
*/
|
|
export class SecretValidationError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public code: string,
|
|
public requirements?: SecretRequirements
|
|
) {
|
|
super(message)
|
|
this.name = 'SecretValidationError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates a secret against security requirements
|
|
*/
|
|
export function validateSecret(
|
|
secret: string | undefined,
|
|
name: string,
|
|
requirements: Partial<SecretRequirements> = {}
|
|
): void {
|
|
const req = { ...DEFAULT_REQUIREMENTS, ...requirements }
|
|
|
|
// Check if secret is provided
|
|
if (!secret) {
|
|
throw new SecretValidationError(
|
|
`Secret '${name}' is required but not provided`,
|
|
'MISSING_SECRET',
|
|
req
|
|
)
|
|
}
|
|
|
|
// Check for insecure defaults
|
|
if (INSECURE_SECRETS.includes(secret.toLowerCase().trim())) {
|
|
throw new SecretValidationError(
|
|
`Secret '${name}' uses an insecure default value. This is not allowed in production.`,
|
|
'INSECURE_DEFAULT',
|
|
req
|
|
)
|
|
}
|
|
|
|
// Check minimum length
|
|
if (secret.length < req.minLength) {
|
|
throw new SecretValidationError(
|
|
`Secret '${name}' must be at least ${req.minLength} characters long (current: ${secret.length})`,
|
|
'INSUFFICIENT_LENGTH',
|
|
req
|
|
)
|
|
}
|
|
|
|
// Check complexity requirements
|
|
if (req.requireUppercase && !/[A-Z]/.test(secret)) {
|
|
throw new SecretValidationError(
|
|
`Secret '${name}' must contain at least one uppercase letter`,
|
|
'MISSING_UPPERCASE',
|
|
req
|
|
)
|
|
}
|
|
|
|
if (req.requireLowercase && !/[a-z]/.test(secret)) {
|
|
throw new SecretValidationError(
|
|
`Secret '${name}' must contain at least one lowercase letter`,
|
|
'MISSING_LOWERCASE',
|
|
req
|
|
)
|
|
}
|
|
|
|
if (req.requireNumbers && !/[0-9]/.test(secret)) {
|
|
throw new SecretValidationError(
|
|
`Secret '${name}' must contain at least one number`,
|
|
'MISSING_NUMBER',
|
|
req
|
|
)
|
|
}
|
|
|
|
if (req.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(secret)) {
|
|
throw new SecretValidationError(
|
|
`Secret '${name}' must contain at least one special character`,
|
|
'MISSING_SPECIAL_CHAR',
|
|
req
|
|
)
|
|
}
|
|
|
|
// Check for common patterns (optional but recommended)
|
|
if (isCommonPattern(secret)) {
|
|
logger.warn(`Secret '${name}' matches a common pattern and may be insecure`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a secret matches common insecure patterns
|
|
*/
|
|
function isCommonPattern(secret: string): boolean {
|
|
const patterns = [
|
|
/^[a-z]+$/i, // All same case
|
|
/^[0-9]+$/, // All numbers
|
|
/^(.)\1+$/, // All same character
|
|
/^12345/, // Sequential numbers
|
|
/^abcde/i, // Sequential letters
|
|
]
|
|
|
|
return patterns.some(pattern => pattern.test(secret))
|
|
}
|
|
|
|
/**
|
|
* Validates a secret and returns it, or throws if invalid
|
|
* This is the main function to use for secret validation
|
|
*/
|
|
export function requireSecret(
|
|
secret: string | undefined,
|
|
name: string,
|
|
requirements?: Partial<SecretRequirements>
|
|
): string {
|
|
validateSecret(secret, name, requirements)
|
|
return secret!
|
|
}
|
|
|
|
/**
|
|
* Validates a secret in production environment
|
|
* Fails fast if secret is insecure in production
|
|
*/
|
|
export function requireProductionSecret(
|
|
secret: string | undefined,
|
|
name: string,
|
|
requirements?: Partial<SecretRequirements>
|
|
): string {
|
|
const isProduction = process.env.NODE_ENV === 'production' ||
|
|
process.env.ENVIRONMENT === 'production' ||
|
|
process.env.PRODUCTION === 'true'
|
|
|
|
if (isProduction) {
|
|
// Stricter requirements for production
|
|
const prodRequirements: SecretRequirements = {
|
|
...DEFAULT_REQUIREMENTS,
|
|
minLength: 64, // Longer secrets for production
|
|
...requirements,
|
|
}
|
|
validateSecret(secret, name, prodRequirements)
|
|
} else {
|
|
validateSecret(secret, name, requirements)
|
|
}
|
|
|
|
return secret!
|
|
}
|
|
|
|
/**
|
|
* Validates JWT secret specifically
|
|
*/
|
|
export function requireJWTSecret(): string {
|
|
return requireProductionSecret(
|
|
process.env.JWT_SECRET,
|
|
'JWT_SECRET',
|
|
{
|
|
minLength: 64, // JWT secrets should be longer
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Validates database password specifically
|
|
* Relaxed requirements for development mode
|
|
*/
|
|
export function requireDatabasePassword(): string {
|
|
const isProduction = process.env.NODE_ENV === 'production' ||
|
|
process.env.ENVIRONMENT === 'production' ||
|
|
process.env.PRODUCTION === 'true'
|
|
|
|
if (isProduction) {
|
|
return requireProductionSecret(
|
|
process.env.DB_PASSWORD,
|
|
'DB_PASSWORD',
|
|
{
|
|
minLength: 32,
|
|
}
|
|
)
|
|
} else {
|
|
// Development mode: relaxed requirements
|
|
// Still validate but allow shorter passwords for local development
|
|
const password = process.env.DB_PASSWORD
|
|
if (!password) {
|
|
throw new SecretValidationError(
|
|
'DB_PASSWORD is required but not provided. Please set it in your .env file.',
|
|
'MISSING_SECRET',
|
|
{ minLength: 8, requireUppercase: false, requireLowercase: false, requireNumbers: false, requireSpecialChars: false }
|
|
)
|
|
}
|
|
|
|
// Basic validation for dev (just check it's not empty and not insecure)
|
|
if (password.length < 8) {
|
|
throw new SecretValidationError(
|
|
'DB_PASSWORD must be at least 8 characters long for development',
|
|
'INSUFFICIENT_LENGTH',
|
|
{ minLength: 8 }
|
|
)
|
|
}
|
|
|
|
if (INSECURE_SECRETS.includes(password.toLowerCase().trim())) {
|
|
throw new SecretValidationError(
|
|
'DB_PASSWORD uses an insecure default value',
|
|
'INSECURE_DEFAULT'
|
|
)
|
|
}
|
|
|
|
return password
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates all required secrets at application startup
|
|
* Call this during application initialization
|
|
*/
|
|
export function validateAllSecrets(): void {
|
|
const isProduction = process.env.NODE_ENV === 'production' ||
|
|
process.env.ENVIRONMENT === 'production' ||
|
|
process.env.PRODUCTION === 'true'
|
|
|
|
if (!isProduction) {
|
|
logger.warn('Not in production environment - secret validation may be relaxed')
|
|
return
|
|
}
|
|
|
|
logger.info('Validating all required secrets for production...')
|
|
|
|
const requiredSecrets = [
|
|
{ env: 'JWT_SECRET', name: 'JWT_SECRET', minLength: 64 },
|
|
{ env: 'DB_PASSWORD', name: 'DB_PASSWORD', minLength: 32 },
|
|
{ env: 'KEYCLOAK_CLIENT_SECRET', name: 'KEYCLOAK_CLIENT_SECRET', minLength: 32 },
|
|
]
|
|
|
|
const missing: string[] = []
|
|
const invalid: Array<{ name: string; error: string }> = []
|
|
|
|
for (const secret of requiredSecrets) {
|
|
const value = process.env[secret.env]
|
|
|
|
if (!value) {
|
|
missing.push(secret.name)
|
|
continue
|
|
}
|
|
|
|
try {
|
|
requireProductionSecret(value, secret.name, { minLength: secret.minLength })
|
|
} catch (error) {
|
|
if (error instanceof SecretValidationError) {
|
|
invalid.push({ name: secret.name, error: error.message })
|
|
} else {
|
|
invalid.push({ name: secret.name, error: String(error) })
|
|
}
|
|
}
|
|
}
|
|
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`Missing required secrets in production: ${missing.join(', ')}\n` +
|
|
'Please set all required environment variables before starting the application.'
|
|
)
|
|
}
|
|
|
|
if (invalid.length > 0) {
|
|
const errors = invalid.map(i => ` - ${i.name}: ${i.error}`).join('\n')
|
|
throw new Error(
|
|
`Invalid secrets in production:\n${errors}\n` +
|
|
'Please ensure all secrets meet security requirements.'
|
|
)
|
|
}
|
|
|
|
logger.info('All required secrets validated successfully')
|
|
}
|
|
|