diff --git a/CHANGELOG.md b/CHANGELOG.md index 7974767..7df0819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ This project follows [Keep a Changelog](https://keepachangelog.com/) and [Semant ### Fixed - +## [2.3.9] - 2025-12-09 +### Added +- Added expiration date warnings for environment variables in codebase scanner. +- Added inconsistent naming warnings for environment variables in codebase scanner. + +### Changed +- Changed health score calculation weights for better accuracy. +- Removed CSP detection from codebase scanner, as it was causing false positives in some cases for backend frameworks. + ## [2.3.8] - 2025-12-08 ### Added - Added variables not using uppercase letters warning to codebase scanner. diff --git a/README.md b/README.md index f7d3c35..8d8d99b 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,27 @@ The health score is calculated based on several factors, including: - Unused variables in your `.env` or `.env.example` file. - Framework specific warning for SvelteKit or Next.js (depending on detected framework). +## Expiration date warnings + +By default, `dotenv-diff` will detect environment variables with expiration dates and warn you if they are expired or about to expire. +To specify an expiration date for an environment variable, add a comment in the following format on the same line this an example of a .env.example file: + +```bash +# @expire YYYY-MM-DD +API_TOKEN= +``` + +When you run `dotenv-diff`, it will check the expiration dates and display warnings for any variables that are expired or will expire soon. + +## Inconsistent naming pattern warnings + +By default `dotenv-diff` will detect environment variables that have inconsistent naming patterns, fx `APIKEY` & `API_KEY` will give you are warning to only use the `API_KEY` +To disable this behavior, use the `--no-inconsistent-naming-warnings` flags respectively, or set it to false in the config file: + +```bash +"inconsistentNamingWarnings": false +``` + ## Show unused variables As default, `dotenv-diff` will list variables that are defined in `.env` but never used in your codebase. diff --git a/package.json b/package.json index 5d731b7..808ef1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dotenv-diff", - "version": "2.3.8", + "version": "2.3.9", "type": "module", "description": "Scan your codebase to find environment variables in use.", "bin": { diff --git a/src/cli/program.ts b/src/cli/program.ts index e868974..57fb31c 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -72,5 +72,18 @@ export function createProgram() { 'Enable uppercase key validation (enabled by default)', ) .option('--no-uppercase-keys', 'Disable uppercase key validation') + .option( + '--expire-warnings', + 'Enable expiration date warnings for environment variables (enabled by default)', + ) + .option('--no-expire-warnings', 'Disable expiration date warnings') + .option( + '--inconsistent-naming-warnings', + 'Enable inconsistent naming pattern warnings (enabled by default)', + ) + .option( + '--no-inconsistent-naming-warnings', + 'Disable inconsistent naming pattern warnings', + ) .option('--init', 'Create a sample dotenv-diff.config.json file'); } diff --git a/src/cli/run.ts b/src/cli/run.ts index c1f9314..8df06c6 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -43,6 +43,8 @@ async function runScanMode(opts: Options): Promise { ignoreUrls: opts.ignoreUrls ?? [], noCompare: opts.noCompare ?? false, uppercaseKeys: opts.uppercaseKeys ?? true, + expireWarnings: opts.expireWarnings, + inconsistentNamingWarnings: opts.inconsistentNamingWarnings, ...(opts.files ? { files: opts.files } : {}), }); diff --git a/src/commands/scanUsage.ts b/src/commands/scanUsage.ts index 8ca47d4..2b04d6d 100644 --- a/src/commands/scanUsage.ts +++ b/src/commands/scanUsage.ts @@ -13,7 +13,6 @@ import { printComparisonError } from '../ui/scan/printComparisonError.js'; import { hasIgnoreComment } from '../core/secretDetectors.js'; import { frameworkValidator } from '../core/frameworkValidator.js'; import { detectSecretsInExample } from '../core/exampleSecretDetector.js'; -import { detectUppercaseKeys } from '../core/detectUppercaseKeys.js'; /** * Filters out commented usages from the list. @@ -126,10 +125,6 @@ export async function scanUsage( scanResult.frameworkWarnings = frameworkWarnings; } - if (opts.uppercaseKeys) { - scanResult.uppercaseWarnings = detectUppercaseKeys(scanResult.used); - } - // Determine which file to compare against const compareFile = determineComparisonFile(opts); let envVariables: Record = {}; @@ -166,6 +161,13 @@ export async function scanUsage( if (result.uppercaseWarnings) { scanResult.uppercaseWarnings = result.uppercaseWarnings; } + if (result.expireWarnings) { + scanResult.expireWarnings = result.expireWarnings; + } + if (result.inconsistentNamingWarnings) { + scanResult.inconsistentNamingWarnings = + result.inconsistentNamingWarnings; + } if (result.exampleFull && result.comparedAgainst === '.env.example') { scanResult.exampleWarnings = detectSecretsInExample(result.exampleFull); } @@ -207,7 +209,9 @@ export async function scanUsage( (scanResult.exampleWarnings?.length ?? 0) > 0 || (scanResult.frameworkWarnings?.length ?? 0) > 0 || (scanResult.logged?.length ?? 0) > 0 || - (scanResult.uppercaseWarnings?.length ?? 0) > 0 + (scanResult.uppercaseWarnings?.length ?? 0) > 0 || + (scanResult.expireWarnings?.length ?? 0) > 0 || + (scanResult.inconsistentNamingWarnings?.length ?? 0) > 0 ), }; } diff --git a/src/config/options.ts b/src/config/options.ts index 8e4b674..e6e0cac 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -100,6 +100,8 @@ export function normalizeOptions(raw: RawOptions): Options { const ignoreUrls = parseList(raw.ignoreUrls); const uppercaseKeys = raw.uppercaseKeys !== false; + const expireWarnings = raw.expireWarnings !== false; + const inconsistentNamingWarnings = raw.inconsistentNamingWarnings !== false; const cwd = process.cwd(); const envFlag = @@ -139,5 +141,7 @@ export function normalizeOptions(raw: RawOptions): Options { ignoreUrls, noCompare, uppercaseKeys, + expireWarnings, + inconsistentNamingWarnings, }; } diff --git a/src/config/types.ts b/src/config/types.ts index 739085d..f7cb596 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -53,6 +53,8 @@ export type Options = { ignoreUrls?: string[]; noCompare: boolean; uppercaseKeys: boolean; + expireWarnings: boolean; + inconsistentNamingWarnings: boolean; }; /** Type representing the raw options for the comparison @@ -84,6 +86,8 @@ export type RawOptions = { noCompare?: boolean; init?: boolean; uppercaseKeys?: boolean; + expireWarnings?: boolean; + inconsistentNamingWarnings?: boolean; }; /** @@ -153,11 +157,12 @@ export interface ScanResult { env?: Array<{ key: string; count: number }>; example?: Array<{ key: string; count: number }>; }; - hasCsp?: boolean; frameworkWarnings?: frameworkWarning[]; exampleWarnings?: ExampleSecretWarning[]; logged: EnvUsage[]; uppercaseWarnings?: UppercaseWarning[]; + expireWarnings?: ExpireWarning[]; + inconsistentNamingWarnings?: InconsistentNamingWarning[]; } /** Options for scanning the codebase for environment variable usage. */ @@ -173,6 +178,8 @@ export interface ScanUsageOptions extends ScanOptions { allowDuplicates?: boolean; strict?: boolean; uppercaseKeys?: boolean; + expireWarnings?: boolean; + inconsistentNamingWarnings?: boolean; } export interface ScanJsonEntry { @@ -212,13 +219,26 @@ export interface ScanJsonEntry { env?: Array<{ key: string; count: number }>; example?: Array<{ key: string; count: number }>; }; - hasCsp?: boolean; logged?: Array<{ variable: string; file: string; line: number; context: string; }>; + expireWarnings?: Array<{ + key: string; + date: string; + daysLeft: number; + }>; + uppercaseWarnings?: Array<{ + key: string; + suggestion: string; + }>; + inconsistentNamingWarnings?: Array<{ + key1: string; + key2: string; + suggestion: string; + }>; } // Type for grouped usages by variable @@ -239,6 +259,8 @@ export interface ComparisonOptions { showStats?: boolean; strict?: boolean; uppercaseKeys?: boolean; + expireWarnings?: boolean; + inconsistentNamingWarnings?: boolean; } export interface FilePair { @@ -279,3 +301,15 @@ export interface UppercaseWarning { key: string; suggestion: string; } + +export interface ExpireWarning { + key: string; + date: string; + daysLeft: number; +} + +export interface InconsistentNamingWarning { + key1: string; + key2: string; + suggestion: string; +} diff --git a/src/core/computeHealthScore.ts b/src/core/computeHealthScore.ts index c45492a..348e6c0 100644 --- a/src/core/computeHealthScore.ts +++ b/src/core/computeHealthScore.ts @@ -12,27 +12,30 @@ export function computeHealthScore(scan: ScanResult): number { const highSecrets = scan.secrets?.filter((s) => s.severity === 'high') ?? []; const medSecrets = scan.secrets?.filter((s) => s.severity === 'medium') ?? []; - score -= highSecrets.length * 15; - score -= medSecrets.length * 5; + score -= highSecrets.length * 20; + score -= medSecrets.length * 10; // === 2. Missing environment variables === - score -= scan.missing.length * 5; + score -= scan.missing.length * 20; // === 3. Uppercase naming issues === score -= (scan.uppercaseWarnings?.length ?? 0) * 2; // === 4. Console logging === - score -= (scan.logged?.length ?? 0) * 5; + score -= (scan.logged?.length ?? 0) * 10; // === 5. Unused vars (less important) === score -= (scan.unused?.length ?? 0) * 1; // === 6. Framework warnings === - score -= (scan.frameworkWarnings?.length ?? 0) * 2; + score -= (scan.frameworkWarnings?.length ?? 0) * 5; // === 7. Example secrets === score -= (scan.exampleWarnings?.length ?? 0) * 10; + // === 8. Expiration warnings === + score -= (scan.expireWarnings?.length ?? 0) * 5; + // Never go below 0 or above 100 return Math.max(0, Math.min(100, score)); } diff --git a/src/core/cspDetector.ts b/src/core/cspDetector.ts deleted file mode 100644 index ac37f47..0000000 --- a/src/core/cspDetector.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Very lightweight CSP detectors – we only need to know "does some CSP exist?" -const META_CSP_PATTERN = - /]*http-equiv=["']Content-Security-Policy["'][^>]*>/i; - -const HEADER_CSP_PATTERN = - /(setHeader|header|append)\(\s*['"]Content-Security-Policy['"]/i; - -const HELMET_CSP_PATTERN = /\bcontentSecurityPolicy\b/; - -/** - * Returns true if the source code looks like it configures a CSP. - * We are deliberately generous: if we see "Content-Security-Policy" anywhere - * in a realistic pattern, we treat CSP as present. - */ -export function hasCspInSource(source: string): boolean { - // 1. META tag - if (META_CSP_PATTERN.test(source)) return true; - - // 2. Node header setters - if (HEADER_CSP_PATTERN.test(source)) return true; - - // 3. Helmet or similar middleware - if (HELMET_CSP_PATTERN.test(source)) return true; - - // 4. Plain fallback - if (/Content-Security-Policy/i.test(source)) return true; - - // 5. SvelteKit kit.csp - if (/kit\s*:\s*{[^}]*csp\s*:/s.test(source)) return true; - - // 6. A variable named Csp or cspConfig or sharedCsp - if (/\b(shared|global|site|app)[A-Z]?Csp\b/.test(source)) return true; - if (/\bcspConfig\b/i.test(source)) return true; - if (/\bcsp\s*:\s*{[^}]*['"]default-src['"]:/i.test(source)) return true; - - // 7. Directives object pattern (strong indicator) - if (/directives\s*:\s*{[^}]*['"]default-src['"]:/is.test(source)) return true; - - return false; -} diff --git a/src/core/detectExpirations.ts b/src/core/detectExpirations.ts new file mode 100644 index 0000000..b84b0e4 --- /dev/null +++ b/src/core/detectExpirations.ts @@ -0,0 +1,57 @@ +import fs from 'fs'; +import type { ExpireWarning } from '../config/types.js'; + +/** + * Detects expiration warnings in a dotenv file. + * fx: + * + * # @expire 2024-12-31 + * API_KEY= + * + * This will generate a warning that API_KEY expires on 2024-12-31. + * @param filePath - Path to the dotenv file + * @returns Array of expiration warnings + */ +export function detectExpirations(filePath: string): ExpireWarning[] { + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + + const warnings: ExpireWarning[] = []; + + const reg = /(\/\/|#)?\s*@?expire\s+(\d{4}-\d{2}-\d{2})/i; + + let pendingExpire: string | null = null; + + for (const raw of lines) { + const line = raw.trim(); + + const expireMatch = line.match(reg); + + if (expireMatch) { + pendingExpire = expireMatch[2] ?? null; // capture dato + continue; + } + + const isEnvKey = /^[A-Za-z0-9_.-]+=/.test(line); + + if (isEnvKey) { + const key = line.split('=')[0]; + + if (key && pendingExpire) { + const expireDate = new Date(pendingExpire); + const now = new Date(); + const diffMs = expireDate.getTime() - now.getTime(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + warnings.push({ + key, + date: pendingExpire, + daysLeft: diffDays, + }); + + pendingExpire = null; + } + } + } + + return warnings; +} diff --git a/src/core/detectInconsistentNaming.ts b/src/core/detectInconsistentNaming.ts new file mode 100644 index 0000000..426e04d --- /dev/null +++ b/src/core/detectInconsistentNaming.ts @@ -0,0 +1,75 @@ +import type { InconsistentNamingWarning } from '../config/types.js'; + +/** + * Detects inconsistent naming patterns in environment variable keys. + * For example: API_KEY vs APIKEY, DATABASE_URL vs DATABASEURL, etc. + * @param keys - Array of environment variable keys to analyze + * @returns Array of inconsistent naming warnings + */ +export function detectInconsistentNaming( + keys: string[], +): InconsistentNamingWarning[] { + const warnings: InconsistentNamingWarning[] = []; + const processedPairs = new Set(); + + for (let i = 0; i < keys.length; i++) { + for (let j = i + 1; j < keys.length; j++) { + const key1 = keys[i]; + const key2 = keys[j]; + + // Skip if either key is undefined + if (!key1 || !key2) continue; + + // Create a sorted pair key to avoid duplicate checking + const pairKey = [key1, key2].sort().join('|'); + if (processedPairs.has(pairKey)) continue; + processedPairs.add(pairKey); + + if (areInconsistentlyNamed(key1, key2)) { + // Always suggest the snake_case version (the one with underscores) + const snakeCaseKey = key1.includes('_') ? key1 : key2; + const suggestion = `Consider using snake_case naming: '${snakeCaseKey}'`; + + warnings.push({ + key1, + key2, + suggestion, + }); + } + } + } + + return warnings; +} + +/** + * Determines if two keys have inconsistent naming patterns. + * @param key1 - First key to compare + * @param key2 - Second key to compare + * @returns True if the keys are inconsistently named + */ +function areInconsistentlyNamed(key1: string, key2: string): boolean { + // Convert both to lowercase for comparison + const normalized1 = key1.toLowerCase().replace(/_/g, ''); + const normalized2 = key2.toLowerCase().replace(/_/g, ''); + + // Check if they are the same when underscores are removed + if (normalized1 === normalized2 && key1 !== key2) { + return true; + } + + // Check for common patterns like API_KEY vs APIKEY + const withoutUnderscores1 = key1.replace(/_/g, ''); + const withoutUnderscores2 = key2.replace(/_/g, ''); + + if ( + withoutUnderscores1.toLowerCase() === withoutUnderscores2.toLowerCase() && + key1 !== key2 + ) { + return true; + } + + // Check for partial matches that might indicate inconsistency + // e.g., DATABASE_URL vs DATABASEURL, JWT_SECRET vs JWTSECRET + return false; +} diff --git a/src/core/detectUppercaseKeys.ts b/src/core/detectUppercaseKeys.ts deleted file mode 100644 index 006dc4d..0000000 --- a/src/core/detectUppercaseKeys.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { EnvUsage } from '../config/types.js'; -import type { UppercaseWarning } from '../config/types.js'; - -/** Convert key to proper UPPER_SNAKE_CASE - * @param name - The environment variable name - * @returns The name converted to UPPER_SNAKE_CASE - */ -function toUpperSnakeCase(name: string): string { - return name - .replace(/([a-z0-9])([A-Z])/g, '$1_$2') // camelCase → camel_Case - .replace(/[-\s]+/g, '_') // dashes/spaces → underscore - .toUpperCase(); -} - -/** - * Detects environment variable keys that are not in uppercase format. - * @param usages - List of environment variable usages - * @returns List of warnings for non-uppercase keys - */ -export function detectUppercaseKeys(usages: EnvUsage[]): UppercaseWarning[] { - const warnings: UppercaseWarning[] = []; - - for (const u of usages) { - if (!/^[A-Z0-9_]+$/.test(u.variable)) { - warnings.push({ - key: u.variable, - suggestion: toUpperSnakeCase(u.variable), - }); - } - } - - return warnings; -} diff --git a/src/core/helpers/toUpperSnakeCase.ts b/src/core/helpers/toUpperSnakeCase.ts new file mode 100644 index 0000000..e81393f --- /dev/null +++ b/src/core/helpers/toUpperSnakeCase.ts @@ -0,0 +1,10 @@ +/** Convert key to proper UPPER_SNAKE_CASE + * @param name - The environment variable name + * @returns The name converted to UPPER_SNAKE_CASE + */ +export function toUpperSnakeCase(name: string): string { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') // camelCase → camel_Case + .replace(/[-\s]+/g, '_') // dashes/spaces → underscore + .toUpperCase(); +} diff --git a/src/core/processComparisonFile.ts b/src/core/processComparisonFile.ts index 6797efd..0819f95 100644 --- a/src/core/processComparisonFile.ts +++ b/src/core/processComparisonFile.ts @@ -4,7 +4,10 @@ import { filterIgnoredKeys } from './filterIgnoredKeys.js'; import { compareWithEnvFiles } from './compareScan.js'; import { findDuplicateKeys } from '../services/duplicates.js'; import { applyFixes } from './fixEnv.js'; +import { toUpperSnakeCase } from './helpers/toUpperSnakeCase.js'; import { resolveFromCwd } from './helpers/resolveFromCwd.js'; +import { detectExpirations } from './detectExpirations.js'; +import { detectInconsistentNaming } from './detectInconsistentNaming.js'; import type { ScanUsageOptions, ScanResult, @@ -26,6 +29,12 @@ export interface ProcessComparisonResult { gitignoreUpdated: boolean; exampleFull?: Record | undefined; uppercaseWarnings?: Array<{ key: string; suggestion: string }>; + expireWarnings?: Array<{ key: string; date: string; daysLeft: number }>; + inconsistentNamingWarnings?: Array<{ + key1: string; + key2: string; + suggestion: string; + }>; error?: { message: string; shouldExit: boolean }; } @@ -53,6 +62,13 @@ export function processComparisonFile( let gitignoreUpdated = false; let exampleFull: Record | undefined = undefined; let uppercaseWarnings: Array<{ key: string; suggestion: string }> = []; + let expireWarnings: Array<{ key: string; date: string; daysLeft: number }> = + []; + let inconsistentNamingWarnings: Array<{ + key1: string; + key2: string; + suggestion: string; + }> = []; try { // Load .env.example (if exists) @@ -78,7 +94,7 @@ export function processComparisonFile( if (opts.uppercaseKeys) { for (const key of envKeys) { if (!/^[A-Z0-9_]+$/.test(key)) { - uppercaseWarnings.push({ key, suggestion: key.toUpperCase() }); + uppercaseWarnings.push({ key, suggestion: toUpperSnakeCase(key) }); } } } @@ -91,6 +107,21 @@ export function processComparisonFile( duplicatesFound = dupsEnv.length > 0 || dupsEx.length > 0; } + if (opts.expireWarnings) { + expireWarnings = detectExpirations(compareFile.path); + } + + // Check for inconsistent naming across env + example keys + if (opts.inconsistentNamingWarnings) { + const envKeysList = Object.keys(envFull); + const exampleKeysList = exampleFull ? Object.keys(exampleFull) : []; + + // Combine all keys for naming analysis + const allKeys = [...envKeysList, ...exampleKeysList]; + + inconsistentNamingWarnings = detectInconsistentNaming(allKeys); + } + // Apply fixes (both duplicates + missing keys + gitignore) if ( opts.fix && @@ -142,6 +173,8 @@ export function processComparisonFile( gitignoreUpdated, exampleFull, uppercaseWarnings, + expireWarnings, + inconsistentNamingWarnings, error: { message: errorMessage, shouldExit: opts.isCiMode ?? false, @@ -163,6 +196,8 @@ export function processComparisonFile( gitignoreUpdated, exampleFull, uppercaseWarnings, + expireWarnings, + inconsistentNamingWarnings, }; } diff --git a/src/core/scanJsonOutput.ts b/src/core/scanJsonOutput.ts index 60c3733..226f177 100644 --- a/src/core/scanJsonOutput.ts +++ b/src/core/scanJsonOutput.ts @@ -51,6 +51,22 @@ export function createJsonOutput( })); } + if (scanResult.uppercaseWarnings?.length) { + output.uppercaseWarnings = scanResult.uppercaseWarnings.map((w) => ({ + key: w.key, + suggestion: w.suggestion, + })); + } + + if (scanResult.inconsistentNamingWarnings?.length) { + output.inconsistentNamingWarnings = + scanResult.inconsistentNamingWarnings.map((w) => ({ + key1: w.key1, + key2: w.key2, + suggestion: w.suggestion, + })); + } + // Add duplicates if found if (scanResult.duplicates) { output.duplicates = scanResult.duplicates; diff --git a/src/services/codeBaseScanner.ts b/src/services/codeBaseScanner.ts index 1eb167e..1506066 100644 --- a/src/services/codeBaseScanner.ts +++ b/src/services/codeBaseScanner.ts @@ -5,7 +5,6 @@ import { detectSecretsInSource, type SecretFinding, } from '../core/secretDetectors.js'; -import { hasCspInSource } from '../core/cspDetector.js'; import { DEFAULT_EXCLUDE_PATTERNS } from '../core/patterns.js'; import { scanFile } from '../core/scanFile.js'; import { findFiles } from './fileWalker.js'; @@ -26,16 +25,10 @@ export async function scanCodebase(opts: ScanOptions): Promise { let filesScanned = 0; const allSecrets: SecretFinding[] = []; - let hasCsp = false; - for (const filePath of files) { try { const content = await fs.readFile(filePath, 'utf-8'); - if (!hasCsp && hasCspInSource(content)) { - hasCsp = true; - } - const fileUsages = await scanFile(filePath, content, opts); allUsages.push(...fileUsages); if (opts.secrets) { @@ -80,7 +73,6 @@ export async function scanCodebase(opts: ScanOptions): Promise { env: [], example: [], }, - hasCsp: hasCsp, logged: loggedVariables, }; } diff --git a/src/services/scanOutputToConsole.ts b/src/services/scanOutputToConsole.ts index fd22052..1115112 100644 --- a/src/services/scanOutputToConsole.ts +++ b/src/services/scanOutputToConsole.ts @@ -14,13 +14,14 @@ import { printSuccess } from '../ui/shared/printSuccess.js'; import { printStrictModeError } from '../ui/shared/printStrictModeError.js'; import { printFixTips } from '../ui/shared/printFixTips.js'; import { printAutoFix } from '../ui/shared/printAutoFix.js'; -import { printCspWarning } from '../ui/scan/printCspWarning.js'; import { printFrameworkWarnings } from '../ui/scan/printFrameworkWarnings.js'; import { printExampleWarnings } from '../ui/scan/printExampleWarnings.js'; import { printConsolelogWarning } from '../ui/scan/printConsolelogWarning.js'; import { printUppercaseWarning } from '../ui/scan/printUppercaseWarning.js'; import { computeHealthScore } from '../core/computeHealthScore.js'; import { printHealthScore } from '../ui/scan/printHealthScore.js'; +import { printExpireWarnings } from '../ui/scan/printExpireWarnings.js'; +import { printInconsistentNamingWarning } from '../ui/scan/printInconsistentNamingWarning.js'; /** * Outputs the scan results to the console. @@ -86,6 +87,16 @@ export function outputToConsole( ); } + if ( + scanResult.inconsistentNamingWarnings && + scanResult.inconsistentNamingWarnings.length > 0 + ) { + printInconsistentNamingWarning( + scanResult.inconsistentNamingWarnings, + isJson, + ); + } + printExampleWarnings(scanResult.exampleWarnings ?? [], isJson); // Unused @@ -111,8 +122,8 @@ export function outputToConsole( // Console log usage warning printConsolelogWarning(scanResult.logged ?? [], isJson); - // CSP warning - printCspWarning(scanResult.hasCsp, isJson); + // Expiration warnings + printExpireWarnings(scanResult.expireWarnings ?? [], isJson); // Check for high severity secrets - ALWAYS exit with error const hasHighSeveritySecrets = (scanResult.secrets ?? []).some( @@ -175,6 +186,9 @@ export function outputToConsole( frameworkWarnings: scanResult.frameworkWarnings?.length ?? 0, logged: scanResult.logged?.length ?? 0, uppercaseWarnings: scanResult.uppercaseWarnings?.length ?? 0, + expireWarnings: scanResult.expireWarnings?.length ?? 0, + inconsistentNamingWarnings: + scanResult.inconsistentNamingWarnings?.length ?? 0, }, isJson, ); diff --git a/src/ui/scan/printCspWarning.ts b/src/ui/scan/printCspWarning.ts deleted file mode 100644 index b3bda30..0000000 --- a/src/ui/scan/printCspWarning.ts +++ /dev/null @@ -1,25 +0,0 @@ -import chalk from 'chalk'; - -/** - * Prints a warning if no CSP was detected. - * Does NOT affect exit code – soft security warning only. - * @param hasCsp Whether a Content-Security-Policy was detected (boolean or undefined) - * @param json Whether to output in JSON format - * @returns void - */ -export function printCspWarning( - hasCsp: boolean | undefined, - json: boolean, -): void { - // JSON mode: no pretty printing - if (json) return; - - // If CSP exists, remain silent to avoid noise - if (hasCsp) return; - - console.log(chalk.yellow('⚠️ CSP is missing')); - console.log( - chalk.yellow.dim(' No Content-Security-Policy detected in your project.'), - ); - console.log(); -} diff --git a/src/ui/scan/printExpireWarnings.ts b/src/ui/scan/printExpireWarnings.ts new file mode 100644 index 0000000..6d7e47a --- /dev/null +++ b/src/ui/scan/printExpireWarnings.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; +import type { ExpireWarning } from '../../config/types.js'; + +/** + * Prints expiration warnings for environment variables. + * @param warnings Array of expiration warnings + * @param isJson Whether to output in JSON format + * @returns void + */ +export function printExpireWarnings( + warnings: ExpireWarning[], + isJson: boolean, +) { + if (isJson) { + return; + } + + if (warnings.length === 0) return; + + console.log(chalk.yellow('⚠️ Expiration warnings:')); + + for (const warn of warnings) { + const severity = + warn.daysLeft < 0 + ? chalk.red(`EXPIRED ${Math.abs(warn.daysLeft)} days ago`) + : warn.daysLeft === 0 + ? chalk.red('EXPIRES TODAY') + : warn.daysLeft <= 3 + ? chalk.red(`expires in ${warn.daysLeft} days`) + : warn.daysLeft <= 7 + ? chalk.yellow(`expires in ${warn.daysLeft} days`) + : chalk.green(`expires in ${warn.daysLeft} days`); + + const keyLabel = chalk.yellow(`- ${warn.key.padEnd(15)}`); + console.log(` ${keyLabel} → ${severity} (${warn.date})`); + } + + console.log(); +} diff --git a/src/ui/scan/printInconsistentNamingWarning.ts b/src/ui/scan/printInconsistentNamingWarning.ts new file mode 100644 index 0000000..8ae1512 --- /dev/null +++ b/src/ui/scan/printInconsistentNamingWarning.ts @@ -0,0 +1,30 @@ +import chalk from 'chalk'; +import type { InconsistentNamingWarning } from '../../config/types.js'; + +/** + * Prints warnings about inconsistent naming patterns in environment variables. + * @param warnings Array of inconsistent naming warnings + * @param isJson Whether to output in JSON format + * @returns void + */ +export function printInconsistentNamingWarning( + warnings: InconsistentNamingWarning[], + isJson: boolean, +) { + if (isJson || warnings.length === 0) { + return; + } + + console.log(chalk.yellow('⚠️ Inconsistent naming found:')); + + for (const warning of warnings) { + console.log( + chalk.yellow( + ` You have both ${chalk.cyan(warning.key1)} and ${chalk.cyan(warning.key2)} (inconsistent naming)`, + ), + ); + console.log(chalk.gray(` ${warning.suggestion}`)); + } + + console.log(); +} diff --git a/src/ui/shared/printStrictModeError.ts b/src/ui/shared/printStrictModeError.ts index dbc16a5..a91162d 100644 --- a/src/ui/shared/printStrictModeError.ts +++ b/src/ui/shared/printStrictModeError.ts @@ -10,6 +10,8 @@ interface StrictModeContext { frameworkWarnings: number; logged: number; uppercaseWarnings?: number; + expireWarnings?: number; + inconsistentNamingWarnings?: number; } /** @@ -37,6 +39,10 @@ export function printStrictModeError( if (ctx.logged > 0) warnings.push('console logged environment variables'); if (ctx.uppercaseWarnings && ctx.uppercaseWarnings > 0) warnings.push('uppercase environment variable keys'); + if (ctx.expireWarnings && ctx.expireWarnings > 0) + warnings.push('expired environment variables'); + if (ctx.inconsistentNamingWarnings && ctx.inconsistentNamingWarnings > 0) + warnings.push('inconsistent naming patterns'); if (warnings.length === 0) return false; diff --git a/test/e2e/cli.autoscan.e2e.test.ts b/test/e2e/cli.autoscan.e2e.test.ts index 72f12d5..ef89a7d 100644 --- a/test/e2e/cli.autoscan.e2e.test.ts +++ b/test/e2e/cli.autoscan.e2e.test.ts @@ -246,37 +246,6 @@ describe('no-flag autoscan', () => { expect(res.stdout).not.toContain('SECRET_KEY'); }); - it('warns about missing CSP when scanning codebase', () => { - const cwd = tmpDir(); - - fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(cwd, 'src', 'index.ts'), - `const url = "https://example.com";`, - ); - - const res = runCli(cwd, []); - expect(res.status).toBe(0); - expect(res.stdout).toContain('CSP is missing'); - }); - - it('does not warn about CSP when CSP is present in codebase', () => { - const cwd = tmpDir(); - - fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(cwd, 'src', 'index.ts'), - ` - - - - `, - ); - - const res = runCli(cwd, []); - expect(res.status).toBe(0); - expect(res.stdout).not.toContain('CSP is missing'); - }); it('should warn about potential secret foound i .env.example', () => { const cwd = tmpDir(); diff --git a/test/e2e/cli.detectExpired.e2e.test.ts b/test/e2e/cli.detectExpired.e2e.test.ts new file mode 100644 index 0000000..d32de55 --- /dev/null +++ b/test/e2e/cli.detectExpired.e2e.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { makeTmpDir, rmrf } from '../utils/fs-helpers.js'; +import { buildOnce, runCli, cleanupBuild } from '../utils/cli-helpers.js'; + +const tmpDirs: string[] = []; + +beforeAll(() => { + buildOnce(); +}); + +afterAll(() => { + cleanupBuild(); +}); + +afterEach(() => { + while (tmpDirs.length) { + const dir = tmpDirs.pop(); + if (dir) rmrf(dir); + } +}); + +function tmpDir() { + const dir = makeTmpDir(); + tmpDirs.push(dir); + return dir; +} + +describe('Expiration Warnings', () => { + it('warns about expired environment variables', () => { + const cwd = tmpDir(); + + // Create .env with an expired variable (yesterday) + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const expiredDate = yesterday.toISOString().split('T')[0]; // YYYY-MM-DD format + + fs.writeFileSync( + path.join(cwd, '.env'), + `# @expire ${expiredDate}\nEXPIRED_API_KEY=secret123\n\nVALID_KEY=value456\n` + ); + + // Create a file that uses these env vars + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.EXPIRED_API_KEY, process.env.VALID_KEY);' + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('Expiration warnings'); + expect(res.stdout).toContain('EXPIRED_API_KEY'); + expect(res.stdout).toContain('EXPIRED'); + }); + + it('warns about soon-to-expire environment variables', () => { + const cwd = tmpDir(); + + // Create .env with a variable expiring in 3 days + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 3); + const expiringDate = futureDate.toISOString().split('T')[0]; + + fs.writeFileSync( + path.join(cwd, '.env'), + `# @expire ${expiringDate}\nSOON_EXPIRED_KEY=secret789\n\nVALID_KEY=value123\n` + ); + + // Create a file that uses these env vars + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.SOON_EXPIRED_KEY, process.env.VALID_KEY);' + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('Expiration warnings'); + expect(res.stdout).toContain('SOON_EXPIRED_KEY'); + expect(res.stdout).toContain('expires in 3 days'); + }); + + it('does not warn when no expiration comments are present', () => { + const cwd = tmpDir(); + + fs.writeFileSync( + path.join(cwd, '.env'), + `API_KEY=secret123\nVALID_KEY=value456\n` + ); + + // Create a file that uses these env vars + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.API_KEY, process.env.VALID_KEY);' + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).not.toContain('Environment variables with expiration dates'); + expect(res.stdout).not.toContain('expired'); + }); + + it('handles multiple expiration patterns correctly', () => { + const cwd = tmpDir(); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const expiredDate = yesterday.toISOString().split('T')[0]; + + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); + const validDate = futureDate.toISOString().split('T')[0]; + + fs.writeFileSync( + path.join(cwd, '.env'), + `# @expire ${expiredDate}\nOLD_TOKEN=expired123\n\n# This one is still valid\n# @expire ${validDate}\nNEW_TOKEN=valid456\n\nNO_EXPIRY=permanent\n` + ); + + // Create a file that uses these env vars + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.OLD_TOKEN, process.env.NEW_TOKEN, process.env.NO_EXPIRY);' + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('Expiration warnings'); + expect(res.stdout).toContain('OLD_TOKEN'); + expect(res.stdout).toContain('NEW_TOKEN'); + expect(res.stdout).toContain('EXPIRED'); + expect(res.stdout).toContain('expires in 7 days'); + }); + + it('does not warn about expiration when --no-expire-warnings is used', () => { + const cwd = tmpDir(); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const expiredDate = yesterday.toISOString().split('T')[0]; + + fs.writeFileSync( + path.join(cwd, '.env'), + `# @expire ${expiredDate}\nEXPIRED_KEY=secret123\n\nVALID_KEY=value456\n` + ); + + // Create a file that uses these env vars + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.EXPIRED_KEY, process.env.VALID_KEY);' + ); + + const res = runCli(cwd, ['--no-expire-warnings']); + + expect(res.status).toBe(0); + expect(res.stdout).not.toContain('Expiration warnings'); + }); + + it('supports different comment styles for expiration', () => { + const cwd = tmpDir(); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const expiredDate = yesterday.toISOString().split('T')[0]; + + fs.writeFileSync( + path.join(cwd, '.env'), + `// @expire ${expiredDate}\nJS_STYLE_KEY=value1\n\n# @expire ${expiredDate}\nSHELL_STYLE_KEY=value2\n\n@expire ${expiredDate}\nNO_COMMENT_KEY=value3\n` + ); + + // Create a file that uses these env vars + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.JS_STYLE_KEY, process.env.SHELL_STYLE_KEY, process.env.NO_COMMENT_KEY);' + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('Expiration warnings'); + expect(res.stdout).toContain('JS_STYLE_KEY'); + expect(res.stdout).toContain('SHELL_STYLE_KEY'); + expect(res.stdout).toContain('NO_COMMENT_KEY'); + }); + + it('exits with error in strict mode when expired variables exist', () => { + const cwd = tmpDir(); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const expiredDate = yesterday.toISOString().split('T')[0]; + + fs.writeFileSync( + path.join(cwd, '.env'), + `# @expire ${expiredDate}\nEXPIRED_KEY=secret123\n` + ); + + // Create a file that uses the env var + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.EXPIRED_KEY);' + ); + + const res = runCli(cwd, ['--strict']); + + expect(res.status).toBe(1); + expect(res.stdout).toContain('Expiration warnings'); + expect(res.stdout).toContain('EXPIRED_KEY'); + expect(res.stdout).toContain('EXPIRED'); + }); +}); \ No newline at end of file diff --git a/test/e2e/cli.inconsistentNaming.e2e.test.ts b/test/e2e/cli.inconsistentNaming.e2e.test.ts new file mode 100644 index 0000000..5014e8e --- /dev/null +++ b/test/e2e/cli.inconsistentNaming.e2e.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { makeTmpDir, rmrf } from '../utils/fs-helpers.js'; +import { buildOnce, runCli, cleanupBuild } from '../utils/cli-helpers.js'; + +const tmpDirs: string[] = []; + +beforeAll(() => { + buildOnce(); +}); + +afterAll(() => { + cleanupBuild(); +}); + +afterEach(() => { + while (tmpDirs.length) { + const dir = tmpDirs.pop(); + if (dir) rmrf(dir); + } +}); + +function tmpDir() { + const dir = makeTmpDir(); + tmpDirs.push(dir); + return dir; +} + +describe('Inconsistent Naming Warnings', () => { + it('warns about inconsistent naming patterns like API_KEY vs APIKEY', () => { + const cwd = tmpDir(); + + // Create .env with some values + fs.writeFileSync( + path.join(cwd, '.env'), + `API_KEY=secret1\nAPIKEY=secret2\nDATABASE_URL=url\nVALID_KEY=valid\n`, + ); + + // Create .env.example with inconsistent naming + fs.writeFileSync( + path.join(cwd, '.env.example'), + `APIKEY=\nAPI_KEY=\nDATABASE_URL=\nVALID_KEY=\n`, + ); + + // Create a file that uses these env vars + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'process.env.API_KEY, process.env.APIKEY, process.env.DATABASE_URL, process.env.VALID_KEY;', + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('Inconsistent naming found'); + expect(res.stdout).toContain( + 'You have both API_KEY and APIKEY (inconsistent naming)', + ); + }); + + it('warns about multiple inconsistent patterns in the same file', () => { + const cwd = tmpDir(); + + fs.writeFileSync( + path.join(cwd, '.env.example'), + `API_KEY=\nAPIKEY=\nDATABASE_URL=\nDATABASEURL=\nJWT_SECRET=\nJWTSECRET=\nVALID_KEY=\n`, + ); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.API_KEY);', + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('Inconsistent naming found'); + expect(res.stdout).toContain( + 'You have both API_KEY and APIKEY (inconsistent naming)', + ); + expect(res.stdout).toContain( + 'You have both DATABASE_URL and DATABASEURL (inconsistent naming)', + ); + expect(res.stdout).toContain( + 'You have both JWT_SECRET and JWTSECRET (inconsistent naming)', + ); + }); + + it('does not warn when all names are consistent', () => { + const cwd = tmpDir(); + + fs.writeFileSync( + path.join(cwd, '.env.example'), + `API_KEY=\nDATABASE_URL=\nJWT_SECRET=\nVALID_KEY=\n`, + ); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.API_KEY, process.env.DATABASE_URL, process.env.JWT_SECRET, process.env.VALID_KEY);', + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).not.toContain('Inconsistent naming found'); + }); + + it('is case sensitive - does not warn for different cases if underscores match', () => { + const cwd = tmpDir(); + + fs.writeFileSync( + path.join(cwd, '.env.example'), + `API_KEY=\napi_key=\nDATABASE_URL=\n`, // Different cases but both have underscores + ); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.API_KEY, process.env.api_key, process.env.DATABASE_URL);', + ); + + const res = runCli(cwd, []); + + expect(res.status).toBe(0); + expect(res.stdout).toContain('Inconsistent naming found'); + expect(res.stdout).toContain( + 'You have both API_KEY and api_key (inconsistent naming)', + ); + }); + + it('exits with error in strict mode when inconsistent naming exists', () => { + const cwd = tmpDir(); + + fs.writeFileSync(path.join(cwd, '.env.example'), `API_KEY=\nAPIKEY=\n`); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.API_KEY, process.env.APIKEY);', + ); + + const res = runCli(cwd, ['--strict']); + + expect(res.status).toBe(1); + expect(res.stdout).toContain('Inconsistent naming found'); + expect(res.stdout).toContain( + 'You have both API_KEY and APIKEY (inconsistent naming)', + ); + expect(res.stdout).toContain('inconsistent naming patterns'); + }); + + it('Will disable inconsistent naming warnings when config is set to false', () => { + const cwd = tmpDir(); + + fs.writeFileSync( + path.join(cwd, 'dotenv-diff.config.json'), + `{ + "inconsistentNamingWarnings": false + }`, + ); + + fs.writeFileSync( + path.join(cwd, '.env.example'), + `API_KEY=\nAPIKEY=\n`, + ); + + fs.mkdirSync(path.join(cwd, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cwd, 'src', 'index.js'), + 'console.log(process.env.API_KEY, process.env.APIKEY);', + ); + + const res = runCli(cwd, []); + expect(res.status).toBe(0); + expect(res.stdout).not.toContain('Inconsistent naming found'); + }); +}); \ No newline at end of file diff --git a/test/unit/core.detectInconsistentNaming.test.ts b/test/unit/core.detectInconsistentNaming.test.ts new file mode 100644 index 0000000..b898898 --- /dev/null +++ b/test/unit/core.detectInconsistentNaming.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { detectInconsistentNaming } from '../../src/core/detectInconsistentNaming.js'; + +describe('detectInconsistentNaming', () => { + it('detects multiple inconsistencies', () => { + const keys = ['API_KEY', 'APIKEY', 'DATABASE_URL', 'DATABASEURL', 'JWT_SECRET', 'JWTSECRET']; + const warnings = detectInconsistentNaming(keys); + + expect(warnings).toHaveLength(3); + + const pairs = warnings.map(w => [w.key1, w.key2].sort()); + expect(pairs).toContainEqual(['APIKEY', 'API_KEY']); + expect(pairs).toContainEqual(['DATABASEURL', 'DATABASE_URL']); + expect(pairs).toContainEqual(['JWTSECRET', 'JWT_SECRET']); + }); + + it('does not warn for consistent naming', () => { + const keys = ['API_KEY', 'DATABASE_URL', 'JWT_SECRET']; + const warnings = detectInconsistentNaming(keys); + + expect(warnings).toHaveLength(0); + }); + + it('does not warn for completely different keys', () => { + const keys = ['API_KEY', 'REDIS_HOST', 'SMTP_PASSWORD']; + const warnings = detectInconsistentNaming(keys); + + expect(warnings).toHaveLength(0); + }); + + it('handles empty input', () => { + const keys: string[] = []; + const warnings = detectInconsistentNaming(keys); + + expect(warnings).toHaveLength(0); + }); + + it('handles single key', () => { + const keys = ['API_KEY']; + const warnings = detectInconsistentNaming(keys); + + expect(warnings).toHaveLength(0); + }); + + it('detects case-sensitive inconsistencies', () => { + const keys = ['API_KEY', 'api_key']; + const warnings = detectInconsistentNaming(keys); + + expect(warnings).toHaveLength(1); + expect(warnings[0].key1).toBe('API_KEY'); + expect(warnings[0].key2).toBe('api_key'); + }); + + it('does not create duplicate warnings for same pair', () => { + const keys = ['API_KEY', 'APIKEY', 'DATABASE_URL']; + const warnings = detectInconsistentNaming(keys); + + expect(warnings).toHaveLength(1); + }); +}); \ No newline at end of file