Files
klarbild-oss/scripts/secret-scan.mjs
T
tillandClaude Opus 5 4ff9f8b7a0 Fix what an independent review of the published repo turned up
- 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
2026-09-15 15:16:51 +00:00

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.');