← back to Site Factory

critic/checks/security.js

110 lines

// security.js — secret scanner.
//   * /sk_live_[A-Za-z0-9]{20,}/                                     → critical
//   * /STRIPE_SECRET_KEY\s*=\s*sk_/                                  → critical
//   * /password\s*[:=]\s*["'][^"']{6,}["']/  (skip .env.example/md)  → high
const fs = require('fs');
const path = require('path');
const { glob } = require('glob');

const SOURCE = 'security';

// scan only "text-ish" files; avoid binaries
const TEXT_EXTS = new Set([
  '.js', '.ts', '.tsx', '.jsx',
  '.json', '.yml', '.yaml', '.toml',
  '.html', '.css', '.scss',
  '.env', '.sh', '.py', '.rb', '.go',
  '.txt', '.cfg', '.ini', '.conf',
]);

async function expandPaths(paths) {
  const files = new Set();
  for (const p of paths || []) {
    if (!p) continue;
    let stat;
    try { stat = fs.statSync(p); } catch { continue; }
    if (stat.isFile()) {
      files.add(p);
    } else if (stat.isDirectory()) {
      const matches = await glob('**/*', {
        cwd: p,
        ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**', '**/.git/**'],
        absolute: true,
        nodir: true,
      });
      for (const m of matches) files.add(m);
    }
  }
  return [...files];
}

function isPasswordSkipFile(file) {
  const base = path.basename(file).toLowerCase();
  if (base === '.env.example') return true;
  if (file.toLowerCase().endsWith('.md')) return true;
  if (file.includes('/node_modules/')) return true;
  return false;
}

const RX_STRIPE_LIVE   = /sk_live_[A-Za-z0-9]{20,}/g;
const RX_STRIPE_HARDCODED = /STRIPE_SECRET_KEY\s*=\s*sk_[A-Za-z0-9_]+/g;
const RX_PASSWORD      = /password\s*[:=]\s*["'][^"']{6,}["']/gi;

function lineOf(src, idx) {
  return src.slice(0, idx).split('\n').length;
}

async function run({ paths /* , screenshots, domain */ }) {
  const findings = [];
  const files = await expandPaths(paths);

  for (const file of files) {
    const ext = path.extname(file).toLowerCase();
    if (ext && !TEXT_EXTS.has(ext)) continue;

    let src;
    try { src = fs.readFileSync(file, 'utf8'); } catch { continue; }

    let m;

    while ((m = RX_STRIPE_LIVE.exec(src)) != null) {
      findings.push({
        source: SOURCE,
        severity: 'critical',
        title: `Live Stripe key in source — ${path.basename(file)}`,
        detail: `${file}:${lineOf(src, m.index)} matches sk_live_… — never commit live secrets.`,
        suggested_fix: 'ROTATE the key in Stripe dashboard immediately, remove from source, store in env (Vercel envs / 1Password), purge git history.',
      });
    }
    RX_STRIPE_LIVE.lastIndex = 0;

    while ((m = RX_STRIPE_HARDCODED.exec(src)) != null) {
      findings.push({
        source: SOURCE,
        severity: 'critical',
        title: `Hardcoded Stripe secret — ${path.basename(file)}`,
        detail: `${file}:${lineOf(src, m.index)} hardcodes STRIPE_SECRET_KEY=sk_…`,
        suggested_fix: 'Move to environment variable (process.env.STRIPE_SECRET_KEY); rotate the key if it ever hit git.',
      });
    }
    RX_STRIPE_HARDCODED.lastIndex = 0;

    if (!isPasswordSkipFile(file)) {
      while ((m = RX_PASSWORD.exec(src)) != null) {
        findings.push({
          source: SOURCE,
          severity: 'high',
          title: `Hardcoded password — ${path.basename(file)}`,
          detail: `${file}:${lineOf(src, m.index)} matches password = "…".`,
          suggested_fix: 'Move secret to env var or secret manager; rotate if it has been committed.',
        });
      }
      RX_PASSWORD.lastIndex = 0;
    }
  }

  return findings;
}

module.exports = { run };