import { ConfigService } from "@nestjs/config"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { SecretScannerService } from "./secret-scanner.service"; import { SecretsDetectedError } from "./types"; describe("SecretScannerService", () => { let service: SecretScannerService; let mockConfigService: ConfigService; beforeEach(() => { // Reset all mocks vi.clearAllMocks(); // Create mock config service mockConfigService = { get: vi.fn().mockReturnValue(undefined), } as unknown as ConfigService; // Create service with mock service = new SecretScannerService(mockConfigService); }); it("should be defined", () => { expect(service).toBeDefined(); }); describe("scanContent", () => { describe("AWS Access Keys", () => { it("should detect real AWS access keys", () => { const content = 'const AWS_KEY = "AKIAREALKEY123456789";'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); expect(result.count).toBe(1); expect(result.matches).toHaveLength(1); expect(result.matches[0].patternName).toBe("AWS Access Key"); expect(result.matches[0].severity).toBe("critical"); }); it("should not detect fake AWS keys with wrong format", () => { const content = 'const FAKE_KEY = "AKIA1234";'; // Too short const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); expect(result.count).toBe(0); }); }); describe("Claude API Keys", () => { it("should detect Claude API keys", () => { const content = 'CLAUDE_API_KEY="sk-ant-abc123def456ghi789jkl012mno345pqr678stu901vwx";'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); expect(result.count).toBeGreaterThan(0); const claudeMatch = result.matches.find((m) => m.patternName.includes("Claude")); expect(claudeMatch).toBeDefined(); expect(claudeMatch?.severity).toBe("critical"); }); it("should not detect placeholder Claude keys", () => { const content = 'CLAUDE_API_KEY="sk-ant-xxxx-your-key-here"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); }); describe("Generic API Keys", () => { it("should detect API keys with various formats", () => { const testCases = [ 'api_key = "abc123def456"', "apiKey: 'xyz789uvw123'", 'API_KEY="prod123key456"', ]; testCases.forEach((testCase) => { const result = service.scanContent(testCase); expect(result.hasSecrets).toBe(true); }); }); }); describe("Passwords", () => { it("should detect password assignments", () => { const content = 'password = "mySecretPassword123"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); const passwordMatch = result.matches.find((m) => m.patternName.toLowerCase().includes("password") ); expect(passwordMatch).toBeDefined(); }); it("should not detect password placeholders", () => { const content = 'password = "your-password-here"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); }); describe("Private Keys", () => { it("should detect RSA private keys", () => { const content = `-----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEA1234567890abcdef -----END RSA PRIVATE KEY-----`; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); const privateKeyMatch = result.matches.find((m) => m.patternName.toLowerCase().includes("private key") ); expect(privateKeyMatch).toBeDefined(); expect(privateKeyMatch?.severity).toBe("critical"); }); it("should detect various private key types", () => { const keyTypes = [ "RSA PRIVATE KEY", "PRIVATE KEY", "EC PRIVATE KEY", "OPENSSH PRIVATE KEY", ]; keyTypes.forEach((keyType) => { const content = `-----BEGIN ${keyType}----- MIIEpAIBAAKCAQEA1234567890abcdef -----END ${keyType}-----`; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); }); }); }); describe("JWT Tokens", () => { it("should detect JWT tokens", () => { const content = 'token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); const jwtMatch = result.matches.find((m) => m.patternName.toLowerCase().includes("jwt")); expect(jwtMatch).toBeDefined(); }); }); describe("Bearer Tokens", () => { it("should detect Bearer tokens", () => { const content = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); const bearerMatch = result.matches.find((m) => m.patternName.toLowerCase().includes("bearer") ); expect(bearerMatch).toBeDefined(); }); }); describe("Multiple Secrets", () => { it("should detect multiple secrets in the same content", () => { const content = ` const config = { awsKey: "AKIAREALKEY123456789", apiKey: "abc123def456", password: "mySecret123" }; `; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); expect(result.count).toBeGreaterThanOrEqual(3); }); }); describe("Line and Column Tracking", () => { it("should track line numbers correctly", () => { const content = `line 1 line 2 const secret = "AKIAREALKEY123456789"; line 4`; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); expect(result.matches[0].line).toBe(3); expect(result.matches[0].column).toBeGreaterThan(0); }); it("should provide context for matches", () => { const content = 'const key = "AKIAREALKEY123456789";'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); expect(result.matches[0].context).toBeDefined(); }); }); describe("Clean Content", () => { it("should return no secrets for clean content", () => { const content = ` const greeting = "Hello World"; const number = 42; function add(a, b) { return a + b; } `; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); expect(result.count).toBe(0); expect(result.matches).toHaveLength(0); }); it("should handle empty content", () => { const result = service.scanContent(""); expect(result.hasSecrets).toBe(false); expect(result.count).toBe(0); }); }); describe("Whitelisting", () => { it("should not flag .env.example placeholder values", () => { const content = ` DATABASE_URL=postgresql://user:password@localhost:5432/dbname API_KEY=your-api-key-here SECRET_KEY=xxxxxxxxxxxx `; const result = service.scanContent(content, ".env.example"); expect(result.hasSecrets).toBe(false); }); it("should flag real secrets even in .env files", () => { const content = 'API_KEY="AKIAIOSFODNN7REALKEY123"'; const result = service.scanContent(content, ".env"); expect(result.hasSecrets).toBe(true); }); it("should whitelist placeholders in example files", () => { const content = 'API_KEY="xxxxxxxxxxxx"'; const result = service.scanContent(content, "config.example.ts"); expect(result.hasSecrets).toBe(false); }); it("should whitelist obvious placeholders like xxxx", () => { const content = 'secret="xxxxxxxxxxxxxxxxxxxx"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); it("should whitelist your-*-here patterns", () => { const content = 'secret="your-secret-here"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); it("should whitelist AWS EXAMPLE keys (official AWS documentation)", () => { const content = 'const AWS_KEY = "AKIAIOSFODNN7EXAMPLE";'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); it("should whitelist AWS keys with TEST suffix", () => { const content = "AWS_ACCESS_KEY_ID=AKIATESTSECRET123456"; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); it("should whitelist AWS keys with SAMPLE suffix", () => { const content = 'key="AKIASAMPLEKEY1234567"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); it("should whitelist AWS keys with DEMO suffix", () => { const content = 'const demo = "AKIADEMOKEY123456789";'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); it("should still detect real AWS keys without example markers", () => { const content = "AWS_ACCESS_KEY_ID=AKIAREALKEY123456789"; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); }); it("should whitelist test/demo/sample placeholder patterns", () => { const testCases = [ 'password="test-password-123"', 'api_key="demo-api-key"', 'secret="sample-secret-value"', ]; testCases.forEach((testCase) => { const result = service.scanContent(testCase); expect(result.hasSecrets).toBe(false); }); }); it("should whitelist multiple xxxx patterns", () => { const content = 'token="xxxx-some-text-xxxx"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(false); }); it("should not whitelist real secrets just because they contain word test", () => { // "test" in the key name should not whitelist the actual secret value const content = 'test_password="MyRealPassword123"'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); }); it("should handle case-insensitive EXAMPLE detection", () => { const testCases = [ 'key="AKIAexample12345678"', 'key="AKIAEXAMPLE12345678"', 'key="AKIAExample12345678"', ]; testCases.forEach((testCase) => { const result = service.scanContent(testCase); expect(result.hasSecrets).toBe(false); }); }); it("should not flag placeholder secrets in example files even without obvious patterns", () => { const content = ` API_KEY=your-api-key-here PASSWORD=change-me SECRET=replace-me `; const result = service.scanContent(content, "config.example.yml"); expect(result.hasSecrets).toBe(false); }); }); }); describe("scanFile", () => { it("should scan a file and return results with secrets", async () => { // Create a temp file with secrets const fs = await import("fs/promises"); const path = await import("path"); const os = await import("os"); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "secret-test-")); const testFile = path.join(tmpDir, "test.ts"); await fs.writeFile(testFile, 'const key = "AKIAREALKEY123456789";\n'); const result = await service.scanFile(testFile); expect(result.filePath).toBe(testFile); expect(result.hasSecrets).toBe(true); expect(result.count).toBeGreaterThan(0); // Cleanup await fs.unlink(testFile); await fs.rmdir(tmpDir); }); it("should handle files without secrets", async () => { const fs = await import("fs/promises"); const path = await import("path"); const os = await import("os"); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "secret-test-")); const testFile = path.join(tmpDir, "clean.ts"); await fs.writeFile(testFile, 'const message = "Hello World";\n'); const result = await service.scanFile(testFile); expect(result.filePath).toBe(testFile); expect(result.hasSecrets).toBe(false); expect(result.count).toBe(0); // Cleanup await fs.unlink(testFile); await fs.rmdir(tmpDir); }); it("should return error state for non-existent files", async () => { const result = await service.scanFile("/non/existent/file.ts"); expect(result.hasSecrets).toBe(false); expect(result.count).toBe(0); expect(result.scannedSuccessfully).toBe(false); expect(result.scanError).toBeDefined(); expect(result.scanError).toContain("ENOENT"); }); it("should return scannedSuccessfully true for successful scans", async () => { const fs = await import("fs/promises"); const path = await import("path"); const os = await import("os"); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "secret-test-")); const testFile = path.join(tmpDir, "clean.ts"); await fs.writeFile(testFile, 'const message = "Hello World";\n'); const result = await service.scanFile(testFile); expect(result.scannedSuccessfully).toBe(true); expect(result.scanError).toBeUndefined(); // Cleanup await fs.unlink(testFile); await fs.rmdir(tmpDir); }); it("should return error state for unreadable files", async () => { const fs = await import("fs/promises"); const path = await import("path"); const os = await import("os"); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "secret-test-")); const testFile = path.join(tmpDir, "unreadable.ts"); await fs.writeFile(testFile, 'const key = "AKIAREALKEY123456789";\n'); // Remove read permissions await fs.chmod(testFile, 0o000); const result = await service.scanFile(testFile); expect(result.scannedSuccessfully).toBe(false); expect(result.scanError).toBeDefined(); expect(result.hasSecrets).toBe(false); // Not "clean", just unscanned // Cleanup - restore permissions first await fs.chmod(testFile, 0o644); await fs.unlink(testFile); await fs.rmdir(tmpDir); }); }); describe("scanFiles", () => { it("should scan multiple files", async () => { const fs = await import("fs/promises"); const path = await import("path"); const os = await import("os"); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "secret-test-")); const file1 = path.join(tmpDir, "file1.ts"); const file2 = path.join(tmpDir, "file2.ts"); await fs.writeFile(file1, 'const key = "AKIAREALKEY123456789";\n'); await fs.writeFile(file2, 'const msg = "Hello";\n'); const results = await service.scanFiles([file1, file2]); expect(results).toHaveLength(2); expect(results[0].hasSecrets).toBe(true); expect(results[1].hasSecrets).toBe(false); // Cleanup await fs.unlink(file1); await fs.unlink(file2); await fs.rmdir(tmpDir); }); }); describe("getScanSummary", () => { it("should provide summary of scan results", () => { const results = [ { filePath: "file1.ts", hasSecrets: true, count: 2, scannedSuccessfully: true, matches: [ { patternName: "AWS Access Key", match: "AKIA...", line: 1, column: 1, severity: "critical" as const, }, { patternName: "API Key", match: "api_key", line: 2, column: 1, severity: "high" as const, }, ], }, { filePath: "file2.ts", hasSecrets: false, count: 0, scannedSuccessfully: true, matches: [], }, ]; const summary = service.getScanSummary(results); expect(summary.totalFiles).toBe(2); expect(summary.filesWithSecrets).toBe(1); expect(summary.totalSecrets).toBe(2); expect(summary.filesWithErrors).toBe(0); expect(summary.bySeverity.critical).toBe(1); expect(summary.bySeverity.high).toBe(1); expect(summary.bySeverity.medium).toBe(0); }); it("should count files with scan errors", () => { const results = [ { filePath: "file1.ts", hasSecrets: true, count: 1, scannedSuccessfully: true, matches: [ { patternName: "AWS Access Key", match: "AKIA...", line: 1, column: 1, severity: "critical" as const, }, ], }, { filePath: "file2.ts", hasSecrets: false, count: 0, scannedSuccessfully: false, scanError: "ENOENT: no such file or directory", matches: [], }, { filePath: "file3.ts", hasSecrets: false, count: 0, scannedSuccessfully: false, scanError: "EACCES: permission denied", matches: [], }, ]; const summary = service.getScanSummary(results); expect(summary.totalFiles).toBe(3); expect(summary.filesWithSecrets).toBe(1); expect(summary.filesWithErrors).toBe(2); expect(summary.totalSecrets).toBe(1); }); }); describe("SecretsDetectedError", () => { it("should create error with results", () => { const results = [ { filePath: "test.ts", hasSecrets: true, count: 1, scannedSuccessfully: true, matches: [ { patternName: "AWS Access Key", match: "AKIAREALKEY123456789", line: 1, column: 10, severity: "critical" as const, }, ], }, ]; const error = new SecretsDetectedError(results); expect(error.results).toBe(results); expect(error.message).toContain("Secrets detected"); }); it("should provide detailed error message", () => { const results = [ { filePath: "config.ts", hasSecrets: true, count: 1, scannedSuccessfully: true, matches: [ { patternName: "API Key", match: "abc123", line: 5, column: 15, severity: "high" as const, context: 'const apiKey = "abc123"', }, ], }, ]; const error = new SecretsDetectedError(results); const detailed = error.getDetailedMessage(); expect(detailed).toContain("SECRETS DETECTED"); expect(detailed).toContain("config.ts"); expect(detailed).toContain("Line 5:15"); expect(detailed).toContain("API Key"); }); it("should include scan errors in detailed message", () => { const results = [ { filePath: "config.ts", hasSecrets: true, count: 1, scannedSuccessfully: true, matches: [ { patternName: "API Key", match: "abc123", line: 5, column: 15, severity: "high" as const, context: 'const apiKey = "abc123"', }, ], }, { filePath: "unreadable.ts", hasSecrets: false, count: 0, scannedSuccessfully: false, scanError: "EACCES: permission denied", matches: [], }, ]; const error = new SecretsDetectedError(results); const detailed = error.getDetailedMessage(); expect(detailed).toContain("SECRETS DETECTED"); expect(detailed).toContain("config.ts"); expect(detailed).toContain("could not be scanned"); expect(detailed).toContain("unreadable.ts"); expect(detailed).toContain("EACCES: permission denied"); }); }); describe("Custom Patterns", () => { it("should support adding custom patterns via config", () => { // Create service with custom patterns const customMockConfig = { get: vi.fn((key: string) => { if (key === "orchestrator.secretScanner.customPatterns") { return [ { name: "Custom Token", pattern: /CUSTOM-[A-Z0-9]{10}/g, description: "Custom token pattern", severity: "high", }, ]; } return undefined; }), } as unknown as ConfigService; const customService = new SecretScannerService(customMockConfig); const result = customService.scanContent("token = CUSTOM-ABCD123456"); expect(result.hasSecrets).toBe(true); expect(result.matches.some((m) => m.patternName === "Custom Token")).toBe(true); }); it("should respect exclude patterns from config", async () => { const fs = await import("fs/promises"); const path = await import("path"); const os = await import("os"); const excludeMockConfig = { get: vi.fn((key: string) => { if (key === "orchestrator.secretScanner.excludePatterns") { return ["*.test.ts"]; } return undefined; }), } as unknown as ConfigService; const excludeService = new SecretScannerService(excludeMockConfig); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "secret-test-")); const testFile = path.join(tmpDir, "file.test.ts"); await fs.writeFile(testFile, 'const key = "AKIAREALKEY123456789";\n'); const result = await excludeService.scanFile(testFile); expect(result.hasSecrets).toBe(false); // Excluded files return no secrets // Cleanup await fs.unlink(testFile); await fs.rmdir(tmpDir); }); it("should respect max file size limit", async () => { const fs = await import("fs/promises"); const path = await import("path"); const os = await import("os"); const sizeMockConfig = { get: vi.fn((key: string) => { if (key === "orchestrator.secretScanner.maxFileSize") { return 10; // 10 bytes max } return undefined; }), } as unknown as ConfigService; const sizeService = new SecretScannerService(sizeMockConfig); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "secret-test-")); const testFile = path.join(tmpDir, "large.ts"); // Create a file larger than 10 bytes await fs.writeFile(testFile, 'const key = "AKIAREALKEY123456789";\n'); const result = await sizeService.scanFile(testFile); expect(result.hasSecrets).toBe(false); // Large files are skipped // Cleanup await fs.unlink(testFile); await fs.rmdir(tmpDir); }); }); describe("Edge Cases", () => { it("should handle very long lines", () => { const longLine = "a".repeat(10000) + 'key="AKIAREALKEY123456789"'; const result = service.scanContent(longLine); expect(result.hasSecrets).toBe(true); }); it("should handle multiline private keys correctly", () => { const content = ` Some text before -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEA1234567890abcdef ghijklmnopqrstuvwxyz123456789012 -----END RSA PRIVATE KEY----- Some text after `; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); expect(result.count).toBeGreaterThan(0); }); it("should handle content with special characters", () => { const content = 'key="AKIAREALKEY123456789" # Comment with émojis 🔑'; const result = service.scanContent(content); expect(result.hasSecrets).toBe(true); }); }); });