112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const repoRoot = __dirname;
|
|
const SCAN_ROOTS = ['src', 'scripts', 'schema', 'docs', '.github', 'package.json', 'tsconfig.json', '.gitleaks.toml'] as const;
|
|
|
|
const TEXT_FILE_EXTENSIONS = new Set([
|
|
'.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.yml', '.yaml', '.toml', '.sql', '.txt'
|
|
]);
|
|
|
|
const EXCLUDED_PATH_SEGMENTS = ['node_modules', 'dist', '.git', '.vite'];
|
|
const PLACEHOLDER_HINTS = ['your_', 'your-', 'example', 'changeme', 'placeholder', '<redacted>', 'xxxx'];
|
|
|
|
const SECRET_ASSIGNMENT_PATTERNS: Array<{ name: string; regex: RegExp }> = [
|
|
{
|
|
name: 'env_secret_assignment',
|
|
regex: /\b(?:SUPABASE_(?:KEY|SERVICE_ROLE_KEY)|ALPACA_API_KEY|ALPACA_SECRET_KEY|REAL_ALPACA_API_KEY|REAL_ALPACA_SECRET_KEY|OPENAI_API_KEY|GEMINI_API_KEY|PERPLEXITY_API_KEY)\s*=\s*([^\s#'"]{12,})/gi
|
|
},
|
|
{
|
|
name: 'openai_like_secret',
|
|
regex: /\bsk-[a-z0-9]{20,}\b/gi
|
|
},
|
|
{
|
|
name: 'aws_access_key_like',
|
|
regex: /\bAKIA[0-9A-Z]{16}\b/g
|
|
}
|
|
];
|
|
|
|
function shouldScan(filePath: string): boolean {
|
|
const normalized = filePath.replace(/\\/g, '/');
|
|
if (EXCLUDED_PATH_SEGMENTS.some((segment) => normalized.includes(`/${segment}/`) || normalized.startsWith(`${segment}/`))) {
|
|
return false;
|
|
}
|
|
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if (TEXT_FILE_EXTENSIONS.has(ext)) return true;
|
|
const base = path.basename(filePath).toLowerCase();
|
|
return base.startsWith('.env');
|
|
}
|
|
|
|
function isLikelyPlaceholder(value: string): boolean {
|
|
const normalized = value.trim().toLowerCase();
|
|
return PLACEHOLDER_HINTS.some((hint) => normalized.includes(hint));
|
|
}
|
|
|
|
function main() {
|
|
const files: string[] = [];
|
|
const walk = (dirPath: string) => {
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const absolute = path.join(dirPath, entry.name);
|
|
const relative = path.relative(repoRoot, absolute);
|
|
if (!relative) continue;
|
|
if (!shouldScan(relative) && entry.isFile()) continue;
|
|
if (entry.isDirectory()) {
|
|
if (EXCLUDED_PATH_SEGMENTS.includes(entry.name)) continue;
|
|
walk(absolute);
|
|
} else {
|
|
if (shouldScan(relative)) {
|
|
files.push(relative);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
for (const rootEntry of SCAN_ROOTS) {
|
|
const absolutePath = path.join(repoRoot, rootEntry);
|
|
if (!fs.existsSync(absolutePath)) continue;
|
|
const stat = fs.statSync(absolutePath);
|
|
if (stat.isDirectory()) {
|
|
walk(absolutePath);
|
|
} else {
|
|
files.push(path.relative(repoRoot, absolutePath));
|
|
}
|
|
}
|
|
|
|
const findings: string[] = [];
|
|
|
|
for (const relativePath of files) {
|
|
const absolutePath = path.join(repoRoot, relativePath);
|
|
let content = '';
|
|
try {
|
|
content = fs.readFileSync(absolutePath, 'utf8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const pattern of SECRET_ASSIGNMENT_PATTERNS) {
|
|
let match: RegExpExecArray | null = null;
|
|
while ((match = pattern.regex.exec(content)) !== null) {
|
|
const rawValue = match[1] || match[0];
|
|
if (isLikelyPlaceholder(rawValue)) continue;
|
|
findings.push(`[${pattern.name}] ${relativePath}: ${match[0].slice(0, 120)}`);
|
|
}
|
|
pattern.regex.lastIndex = 0;
|
|
}
|
|
}
|
|
|
|
assert.equal(
|
|
findings.length,
|
|
0,
|
|
`Potential plaintext secrets detected:\n${findings.join('\n')}\n\nRotate credentials and scrub artifacts before release.`
|
|
);
|
|
|
|
console.log(`[secret-hygiene] OK: scanned ${files.length} tracked text files, no plaintext secret patterns found`);
|
|
}
|
|
|
|
main();
|