Files
stack/apps/orchestrator/src/git/secret-scanner.service.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

323 lines
9.7 KiB
TypeScript

import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as fs from "fs/promises";
import * as path from "path";
import { SecretPattern, SecretMatch, SecretScanResult, SecretScannerConfig } from "./types";
/**
* Service for scanning files and content for secrets
*/
@Injectable()
export class SecretScannerService {
private readonly logger = new Logger(SecretScannerService.name);
private readonly patterns: SecretPattern[];
private readonly config: SecretScannerConfig;
// Whitelist patterns - these are placeholder patterns, not actual secrets
private readonly whitelistPatterns = [
/your-.*-here/i,
/^xxxx+$/i,
/^\*\*\*\*+$/i,
/^example$/i, // Just the word "example" alone
/placeholder/i,
/change-me/i,
/replace-me/i,
/^<.*>$/, // <your-key-here>
/^\$\{.*\}$/, // ${YOUR_KEY}
/test/i, // "test" indicator
/sample/i, // "sample" indicator
/demo/i, // "demo" indicator
/^xxxx.*xxxx$/i, // multiple xxxx pattern
];
constructor(private readonly configService: ConfigService) {
this.config = {
customPatterns:
this.configService.get<SecretPattern[]>("orchestrator.secretScanner.customPatterns") ?? [],
excludePatterns:
this.configService.get<string[]>("orchestrator.secretScanner.excludePatterns") ?? [],
scanBinaryFiles:
this.configService.get<boolean>("orchestrator.secretScanner.scanBinaryFiles") ?? false,
maxFileSize:
this.configService.get<number>("orchestrator.secretScanner.maxFileSize") ??
10 * 1024 * 1024, // 10MB default
};
this.patterns = this.loadPatterns();
}
/**
* Load built-in and custom secret patterns
*/
private loadPatterns(): SecretPattern[] {
const builtInPatterns: SecretPattern[] = [
{
name: "AWS Access Key",
pattern: /AKIA[0-9A-Z]{16}/g,
description: "AWS Access Key ID",
severity: "critical",
},
{
name: "Claude API Key",
pattern: /sk-ant-[a-zA-Z0-9\-_]{40,}/g,
description: "Anthropic Claude API Key",
severity: "critical",
},
{
name: "Generic API Key",
pattern: /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9]{10,}['"]?/gi,
description: "Generic API Key",
severity: "high",
},
{
name: "Password Assignment",
pattern: /password\s*[:=]\s*['"]?[a-zA-Z0-9!@#$%^&*]{8,}['"]?/gi,
description: "Password in code",
severity: "high",
},
{
name: "Private Key",
pattern: /-----BEGIN[\s\w]*PRIVATE KEY-----/g,
description: "Private cryptographic key",
severity: "critical",
},
{
name: "JWT Token",
pattern: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
description: "JSON Web Token",
severity: "high",
},
{
name: "Bearer Token",
pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/g,
description: "Bearer authentication token",
severity: "high",
},
{
name: "Generic Secret",
pattern: /secret\s*[:=]\s*['"]?[a-zA-Z0-9]{16,}['"]?/gi,
description: "Generic secret value",
severity: "medium",
},
];
// Add custom patterns from config
return [...builtInPatterns, ...(this.config.customPatterns ?? [])];
}
/**
* Check if a match should be whitelisted
*/
private isWhitelisted(match: string, filePath?: string): boolean {
// Extract the value part from patterns like 'api_key="value"' or 'password=value'
// This regex extracts quoted or unquoted values after = or :
const valueMatch = /[:=]\s*['"]?([^'"\s]+)['"]?$/.exec(match);
const value = valueMatch ? valueMatch[1] : match;
// Check if it's an AWS example key specifically
// AWS documentation uses keys like AKIAIOSFODNN7EXAMPLE, AKIATESTSAMPLE, etc.
if (value.startsWith("AKIA") && /EXAMPLE|SAMPLE|TEST|DEMO/i.test(value)) {
return true;
}
// AWS EXAMPLE keys are documented examples, not real secrets
// But we still want to catch them unless in .example files
const isExampleFile =
filePath &&
(path.basename(filePath).toLowerCase().includes(".example") ||
path.basename(filePath).toLowerCase().includes("sample") ||
path.basename(filePath).toLowerCase().includes("template"));
// Only whitelist obvious placeholders
const isObviousPlaceholder = this.whitelistPatterns.some((pattern) => pattern.test(value));
// If it's an example file AND has placeholder text, whitelist it
if (isExampleFile && isObviousPlaceholder) {
return true;
}
// Otherwise, whitelist if it's an obvious placeholder
if (isObviousPlaceholder) {
return true;
}
return false;
}
/**
* Match a single pattern against content
*/
private matchPattern(content: string, pattern: SecretPattern, filePath?: string): SecretMatch[] {
const matches: SecretMatch[] = [];
const lines = content.split("\n");
// Reset regex lastIndex to ensure clean matching
pattern.pattern.lastIndex = 0;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
const lineNumber = lineIndex + 1;
// Create a new regex from the pattern to avoid state issues
// eslint-disable-next-line security/detect-non-literal-regexp -- Pattern source comes from validated config, not user input
const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
let regexMatch: RegExpExecArray | null;
while ((regexMatch = regex.exec(line)) !== null) {
const matchText = regexMatch[0];
// Skip if whitelisted
if (this.isWhitelisted(matchText, filePath)) {
continue;
}
matches.push({
patternName: pattern.name,
match: matchText,
line: lineNumber,
column: regexMatch.index + 1,
severity: pattern.severity,
context: line.trim(),
});
// Prevent infinite loops on zero-width matches
if (regexMatch.index === regex.lastIndex) {
regex.lastIndex++;
}
}
}
return matches;
}
/**
* Scan content for secrets
*/
scanContent(content: string, filePath?: string): SecretScanResult {
const allMatches: SecretMatch[] = [];
// Scan with each pattern
for (const pattern of this.patterns) {
const matches = this.matchPattern(content, pattern, filePath);
allMatches.push(...matches);
}
return {
filePath,
hasSecrets: allMatches.length > 0,
matches: allMatches,
count: allMatches.length,
scannedSuccessfully: true,
};
}
/**
* Scan a file for secrets
*/
async scanFile(filePath: string): Promise<SecretScanResult> {
try {
// Check if file should be excluded
const fileName = path.basename(filePath);
for (const excludePattern of this.config.excludePatterns ?? []) {
// Convert glob pattern to regex if needed
const pattern =
typeof excludePattern === "string"
? excludePattern.replace(/\./g, "\\.").replace(/\*/g, ".*")
: excludePattern;
if (fileName.match(pattern)) {
this.logger.debug(`Skipping excluded file: ${filePath}`);
return {
filePath,
hasSecrets: false,
matches: [],
count: 0,
scannedSuccessfully: true,
};
}
}
// Check file size
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Scanner must access arbitrary files by design
const stats = await fs.stat(filePath);
if (this.config.maxFileSize && stats.size > this.config.maxFileSize) {
this.logger.warn(
`File ${filePath} exceeds max size (${stats.size.toString()} bytes), skipping`
);
return {
filePath,
hasSecrets: false,
matches: [],
count: 0,
scannedSuccessfully: true,
};
}
// Read file content
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Scanner must access arbitrary files by design
const content = await fs.readFile(filePath, "utf-8");
// Scan content
return this.scanContent(content, filePath);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to scan file ${filePath}: ${errorMessage}`);
// Return error state - file could not be scanned, NOT clean
return {
filePath,
hasSecrets: false,
matches: [],
count: 0,
scannedSuccessfully: false,
scanError: errorMessage,
};
}
}
/**
* Scan multiple files for secrets
*/
async scanFiles(filePaths: string[]): Promise<SecretScanResult[]> {
const results: SecretScanResult[] = [];
for (const filePath of filePaths) {
const result = await this.scanFile(filePath);
results.push(result);
}
return results;
}
/**
* Get a summary of scan results
*/
getScanSummary(results: SecretScanResult[]): {
totalFiles: number;
filesWithSecrets: number;
totalSecrets: number;
filesWithErrors: number;
bySeverity: Record<string, number>;
} {
const summary = {
totalFiles: results.length,
filesWithSecrets: results.filter((r) => r.hasSecrets).length,
totalSecrets: results.reduce((sum, r) => sum + r.count, 0),
filesWithErrors: results.filter((r) => !r.scannedSuccessfully).length,
bySeverity: {
critical: 0,
high: 0,
medium: 0,
low: 0,
},
};
for (const result of results) {
for (const match of result.matches) {
summary.bySeverity[match.severity]++;
}
}
return summary;
}
}