- CI pinned Node 20, but `node --test` with type stripping needs 22.6, so the workflow could never have gone green. CI and `engines` now say 22.6. - `docker compose up -d` failed on the documented path: the compose files require DB_PASSWORD and .env.example never mentioned it. Documented, and the README no longer claims "three secrets". - The secret scanner treated any comment line as safe, so a pasted `.env` fragment — the usual way a key gets committed — slipped through. Verified against planted keys before and after. - Module pages were not guarded: with KLARBILD_MODULES=print a signed-in user could still open /shares, /library and /queue and read about features the instance does not have. They now redirect, matching the API's 404 behaviour. - German left in the English build: `bild.<ext>` as a download fallback, and 31 t() keys with no German at all — the whole main navigation among them, so a German instance rendered its nav in English. Dictionary now covers every key in the source (1,067 entries). - Third-party names removed from the data as well as the labels: the `theframe` format id is now `tv169`, and the dead Cricut admin field is gone. - Dockerfile ignored the committed lockfile; it now copies it and uses `npm ci`. - Dropped an unused dependency (@aws-sdk/s3-request-presigner — storage.ts explains why presigned URLs are not needed), a dead `/g/` entry in the unauthenticated path list, and an undocumented N8N_WEBHOOK_URL that nothing read. Documented TRUSTED_PROXIES, which the rate limiter does read. - Migration 004 still had "nas" in its filename; README said A6–A2 where the list runs A6–A1; two committed screenshots were referenced nowhere. 124 tests green, type check clean, build clean, secret scan clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
88 lines
3.1 KiB
JavaScript
88 lines
3.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* A small guard against committing a credential.
|
|
*
|
|
* This is not a replacement for gitleaks — run that too if you have it. It
|
|
* exists because the cheapest moment to catch a leaked key is before the push,
|
|
* and because a project that ships `.env.example` invites the mistake of
|
|
* shipping `.env` next to it.
|
|
*
|
|
* Exits non-zero on the first finding, printing the file and line.
|
|
*/
|
|
import { readFileSync, statSync } from 'node:fs';
|
|
import { readdir } from 'node:fs/promises';
|
|
import { join, relative, extname } from 'node:path';
|
|
|
|
const ROOT = new URL('..', import.meta.url).pathname;
|
|
|
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.astro', 'coverage']);
|
|
const SKIP_FILES = new Set(['package-lock.json', 'LICENSE', 'secret-scan.mjs']);
|
|
const TEXT_EXT = new Set([
|
|
'.ts', '.tsx', '.js', '.mjs', '.cjs', '.astro', '.json', '.sql', '.md',
|
|
'.yaml', '.yml', '.css', '.html', '.txt', '.example', '',
|
|
]);
|
|
|
|
const RULES = [
|
|
[/sk-[A-Za-z0-9]{20,}/, 'OpenAI-style API key'],
|
|
[/sk-or-v1-[A-Za-z0-9]{20,}/, 'OpenRouter API key'],
|
|
[/gh[pousr]_[A-Za-z0-9]{30,}/, 'GitHub token'],
|
|
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
|
|
[/AKIA[0-9A-Z]{16}/, 'AWS access key id'],
|
|
[/-----BEGIN (?:RSA |OPENSSH |EC |PGP )?PRIVATE KEY-----/, 'private key'],
|
|
[/\b\d{6,}:[A-Za-z0-9_-]{30,}\b/, 'Telegram bot token'],
|
|
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, 'JWT'],
|
|
// Assignments that look like a real value rather than a placeholder.
|
|
[/(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["'][^"'${}\s]{12,}["']/i,
|
|
'hardcoded credential'],
|
|
];
|
|
|
|
// Lines that are allowed to match: documentation of the shape of a secret,
|
|
// and the example file, which is meant to show the names without the values.
|
|
// Lines that are allowed to match: documentation of the *shape* of a secret.
|
|
// Being a comment is deliberately not a free pass — a pasted `.env` fragment is
|
|
// usually commented out, and that is exactly the mistake worth catching.
|
|
const ALLOW = [
|
|
/example|placeholder|CHANGEME|your-|<[a-z]+>|openssl rand|\.\.\.|xxxx/i,
|
|
];
|
|
|
|
let findings = 0;
|
|
|
|
async function walk(dir) {
|
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) {
|
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
await walk(join(dir, entry.name));
|
|
continue;
|
|
}
|
|
if (SKIP_FILES.has(entry.name)) continue;
|
|
const path = join(dir, entry.name);
|
|
if (entry.name !== '.env.example' && !TEXT_EXT.has(extname(entry.name))) continue;
|
|
if (statSync(path).size > 2_000_000) continue;
|
|
scan(path);
|
|
}
|
|
}
|
|
|
|
function scan(path) {
|
|
const rel = relative(ROOT, path);
|
|
const lines = readFileSync(path, 'utf8').split('\n');
|
|
lines.forEach((line, i) => {
|
|
if (ALLOW.some((a) => a.test(line))) return;
|
|
for (const [re, what] of RULES) {
|
|
if (re.test(line)) {
|
|
console.error(`${rel}:${i + 1} possible ${what}`);
|
|
console.error(` ${line.trim().slice(0, 120)}`);
|
|
findings++;
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
await walk(ROOT);
|
|
|
|
if (findings) {
|
|
console.error(`\n${findings} possible secret(s) found. Nothing was committed.`);
|
|
process.exit(1);
|
|
}
|
|
console.log('Secret scan clean.');
|