Files
stack/apps/orchestrator/src/git/secret-scanner.service.spec.ts
Jason Woltje 6bb9846cde fix(#337): Return error state from secret scanner on scan failures
- Add scanError field and scannedSuccessfully flag to SecretScanResult
- File read errors no longer falsely report as "clean"
- Callers can distinguish clean files from scan failures
- Update getScanSummary to track filesWithErrors count
- SecretsDetectedError now reports files that couldn't be scanned
- Add tests verifying error handling behavior for file access issues

Refs #337

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:30:06 -06:00

778 lines
24 KiB
TypeScript

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);
});
});
});