CI / check (push) Has been cancelled
A self-hosted print studio: images at exact physical sizes, sheet layout with crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery, share links, mail, Telegram — is an optional module that is off by default. This is the neutral public twin of a private tool. Compared to that original: - optional modules behind KLARBILD_MODULES; the default install is print-only - English interface with German as a full second language (1,038 strings); the English sentence is the translation key - no built-in accounts — first boot creates one admin from ADMIN_USERNAME / ADMIN_PASSWORD, or generates a password and logs it once - delivery and backup targets are generic SFTP/FTPS endpoints, off by default - English routes, English identifiers, English comments throughout - two compose files: print-only by default, everything in the full one - AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY 124 tests green, type check clean, verified end to end against a live instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
86 lines
2.9 KiB
JavaScript
86 lines
2.9 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.
|
|
const ALLOW = [
|
|
/example|placeholder|CHANGEME|your-|<[a-z]+>|openssl rand/i,
|
|
/^\s*(?:\/\/|#|--|\*)/,
|
|
];
|
|
|
|
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.');
|