#!/usr/bin/env node
/* redactkit -- redact secrets out of logs and keep the log readable.
 *
 * Built by Levain, an autonomous AI agent. https://levain.bmac.io
 * No network calls, no telemetry, no dependencies. Read the source; it is short.
 */
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { resolve, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
  redactRun,
  buildMap,
  restore,
  applyPolicy,
  detectorTable,
} from "../lib/redact.mjs";
import { sniff } from "../lib/sniff.mjs";
import { countInvisible, countConfusables } from "../lib/detectors.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const VERSION = JSON.parse(
  readFileSync(join(HERE, "..", "package.json"), "utf8")
).version;

const USAGE = `redactkit ${VERSION} -- redact secrets out of logs, keep the log readable

USAGE
  redactkit [options] [file ...]        read files, or stdin when no file is given
  redactkit --restore --map keys.json   turn a redacted log back into the original
  redactkit --list                      show every detector and whether it is on

OPTIONS
  -m, --map FILE      write the reverse map (contains the real secrets: keep it local)
  -o, --out FILE      write redacted output here instead of stdout
      --restore       reverse a redaction; requires --map
  -c, --check         exit 1 if anything was found; report goes to stderr
      --json          emit {outputs, findings} as JSON instead of text
  -q, --quiet         suppress the stderr summary
      --no-review     skip the second look (see below); scan only what can be redacted
      --review-all    also review long random strings and UUIDs; noisy by design
      --plain         [TAG] instead of [TAG_1]; loses correlation, keeps brevity
      --prefix STR    prepend STR inside every placeholder, e.g. --prefix ACME_
      --enable  ids   comma-separated detector ids to force on
      --disable ids   comma-separated detector ids to force off
      --config FILE   JSON policy file (default: .redactkitrc.json if present)
  -e, --encoding L    force the input encoding (utf-8, utf-16le, windows-1251, ...)
                      by default the bytes are sniffed; this overrides that
  -l, --list          list detectors
  -h, --help          this text
  -V, --version       print version

EXIT CODES
  0  ran fine (with --check: nothing found)
  1  --check found at least one secret, or the second look named one inside a blob
  2  bad usage, unreadable file, or a detector id that does not exist

EXAMPLES
  npm test 2>&1 | redactkit                    scrub before you paste it anywhere
  redactkit app.log -m keys.json -o safe.log   share safe.log, keep keys.json
  redactkit --restore -m keys.json < reply.txt read a reply that quotes placeholders
  git diff --cached | redactkit --check -q     pre-commit guard
  redactkit -e windows-1251 old.log            legacy bytes, decoded not destroyed

THE SECOND LOOK
  A secret inside a base64 or hex run is invisible to every detector here, because none
  of them can see through the encoding. The old answer was "nothing matched" printed
  over a live token, and --check exited 0, so the pre-commit hook waved the commit
  through. redactkit now decodes each run it did not already redact, re-scans it with
  the same detectors, and tells you what is in there -- by line. It does not rewrite
  the blob: it may be a certificate or a config you need whole, and guessing is worse
  than telling you. --check counts these. --no-review turns it off.

INPUT IS READ AS BYTES, NOT AS UTF-8
  A log written by PowerShell (> or Out-File) is UTF-16: read as UTF-8 every secret in
  it hides behind a zero byte and the scan reports clean. redactkit reads the bytes,
  decodes what they actually are, and says so on stderr. Bytes that are not text at
  all -- gzip, zip, PDF, .evtx, an ELF binary -- are refused by name rather than
  scanned into a meaningless all-clear. Bytes that are text in a legacy encoding are
  refused too, with the flag that reads them, because decoding them as UTF-8 destroys
  the log around the secrets it finds. Output is always UTF-8.
`;

function fail(msg) {
  process.stderr.write("redactkit: " + msg + "\n");
  process.exit(2);
}

function parseArgs(argv) {
  const o = {
    files: [],
    map: null,
    out: null,
    restore: false,
    check: false,
    json: false,
    quiet: false,
    numbered: true,
    prefix: "",
    enable: [],
    disable: [],
    config: null,
    review: true,
    reviewAll: false,
    encoding: null,
    list: false,
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    const need = () => {
      const v = argv[++i];
      if (v === undefined) fail(`${a} needs a value`);
      return v;
    };
    switch (a) {
      case "-h": case "--help": process.stdout.write(USAGE); process.exit(0);
      case "-V": case "--version": process.stdout.write(VERSION + "\n"); process.exit(0);
      case "-m": case "--map": o.map = need(); break;
      case "-o": case "--out": o.out = need(); break;
      case "--restore": o.restore = true; break;
      case "-c": case "--check": o.check = true; break;
      case "--json": o.json = true; break;
      case "-q": case "--quiet": o.quiet = true; break;
      case "--plain": o.numbered = false; break;
      case "--no-review": o.review = false; break;
      case "--review-all": o.reviewAll = true; break;
      case "--prefix": o.prefix = need(); break;
      case "--enable": o.enable.push(...splitIds(need())); break;
      case "--disable": o.disable.push(...splitIds(need())); break;
      case "--config": o.config = need(); break;
      case "-e": case "--encoding": o.encoding = need(); break;
      case "-l": case "--list": o.list = true; break;
      case "--": o.files.push(...argv.slice(i + 1)); i = argv.length; break;
      default:
        if (a.startsWith("-") && a !== "-") fail(`unknown option ${a}`);
        o.files.push(a);
    }
  }
  return o;
}

const splitIds = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);

function loadConfig(opts) {
  const path = opts.config || (existsSync(".redactkitrc.json") ? ".redactkitrc.json" : null);
  if (!path) return;
  let cfg;
  try {
    cfg = JSON.parse(readFileSync(path, "utf8"));
  } catch (e) {
    fail(`cannot read config ${path}: ${e.message}`);
  }
  /* Command-line flags win over the file, so a one-off override never needs an edit. */
  opts.enable = [...(cfg.enable || []), ...opts.enable];
  opts.disable = [...(cfg.disable || []), ...opts.disable];
  if (cfg.prefix && !opts.prefix) opts.prefix = cfg.prefix;
  if (cfg.numbered === false) opts.numbered = false;
}

/* --- reading, as bytes -------------------------------------------------------------
 *
 * The old version of this function was readFileSync(path, "utf8"), and it was the worst
 * bug this tool ever had, because it never failed. A log PowerShell wrote with > is
 * UTF-16: every character sits behind a zero byte, so nothing matches, and the tool
 * printed "nothing matched" over a file full of live credentials. A log in Windows-1251
 * or Shift_JIS is worse: the secrets ARE found, and the utf-8 read has already replaced
 * every non-ASCII byte with U+FFFD, so writing the result back destroys the log.
 * Both look exactly like success. So: read bytes, ask what they are, refuse what cannot
 * be read honestly. The sniffer is the browser tool's, generated into lib/sniff.mjs.
 */

const COMMON_LABELS =
  "utf-8, utf-16le, utf-16be, windows-1252, windows-1251, windows-1250, " +
  "iso-8859-2, koi8-r, shift_jis, euc-jp, euc-kr, big5";

function stripBom(t) {
  return t.charCodeAt(0) === 0xfeff ? t.slice(1) : t;
}

function nonAsciiCount(b) {
  let n = 0;
  for (let i = 0; i < b.length; i++) if (b[i] > 0x7f) n++;
  return n;
}

/* Returns {text, note} -- note is a stderr line, or null when the bytes were plain UTF-8
 * and there is nothing a reader needs to be told. */
function decodeBytes(bytes, where, opts) {
  if (opts.encoding) {
    let dec;
    try {
      dec = new TextDecoder(opts.encoding);
    } catch {
      fail(`unknown encoding "${opts.encoding}". Try one of: ${COMMON_LABELS}`);
    }
    return {
      text: stripBom(dec.decode(bytes)),
      note: `${where}: decoded as ${dec.encoding} because --encoding said so; output is UTF-8`,
    };
  }

  const g = sniff(bytes);

  if (g.enc === null) {
    fail(
      `${where}: ${g.why}\n` +
        `  If you know what it really is, say so: --encoding LABEL`
    );
  }

  if (g.label === "not UTF-8") {
    const bad = nonAsciiCount(bytes);
    fail(
      `${where} is not valid UTF-8, so it was written in a legacy encoding ` +
        `(${bad} of ${bytes.length} bytes are outside ASCII).\n` +
        `  Decoding it as UTF-8 would replace every one of them before the scan ran. The ` +
        `secrets would still be found\n  and the log around them would already be ` +
        `destroyed, so saving the output over the original would lose it.\n` +
        `  Say which encoding it is:  redactkit --encoding windows-1251 ${where}\n` +
        `  Labels: ${COMMON_LABELS}\n` +
        `  To read it as UTF-8 anyway and accept the loss: --encoding utf-8`
    );
  }

  const text = stripBom(new TextDecoder(g.enc).decode(bytes));
  const note = g.enc === "utf-8" ? null : `${where}: decoded as ${g.label}; output is UTF-8`;
  return { text, note };
}

function readStdin() {
  try {
    return readFileSync(0);
  } catch {
    return Buffer.alloc(0);
  }
}

function readChunks(files, opts) {
  const notes = [];
  const take = (bytes, name, where) => {
    const { text, note } = decodeBytes(bytes, where, opts);
    if (note) notes.push(note);
    return { name, text };
  };
  const chunks = !files.length
    ? [take(readStdin(), "-", "stdin")]
    : files.map((f) => {
        const p = resolve(f);
        if (!existsSync(p)) fail(`no such file: ${f}`);
        return take(readFileSync(p), f, f);
      });
  if (!opts.quiet) {
    for (const n of notes) process.stderr.write("redactkit: " + n + "\n");
  }
  return chunks;
}

function main() {
  const opts = parseArgs(process.argv.slice(2));
  loadConfig(opts);

  const { unknown } = applyPolicy(opts);
  if (unknown.length) {
    fail(`unknown detector id(s): ${unknown.join(", ")} (see --list)`);
  }

  if (opts.list) {
    const rows = detectorTable();
    const w = Math.max(...rows.map((r) => r.id.length));
    for (const r of rows) {
      process.stdout.write(
        `${r.on ? "on " : "off"}  ${r.id.padEnd(w)}  ${r.tag.padEnd(16)}  ${r.label}\n`
      );
    }
    const on = rows.filter((r) => r.on).length;
    process.stderr.write(
      `\n${rows.length} detectors, ${on} on by default, ` +
        `${new Set(rows.map((r) => r.tag)).size} distinct tags\n`
    );
    return 0;
  }

  if (opts.restore) {
    if (!opts.map) fail("--restore needs --map FILE");
    let map;
    try {
      map = JSON.parse(readFileSync(resolve(opts.map), "utf8"));
    } catch (e) {
      fail(`cannot read map ${opts.map}: ${e.message}`);
    }
    if (map.kind !== "redactkit-map") fail(`${opts.map} is not a redactkit map`);
    const chunks = readChunks(opts.files, opts);
    let total = 0;
    const text = chunks
      .map((c) => {
        const r = restore(c.text, map);
        total += r.replaced;
        return r.text;
      })
      .join("");
    emit(text, opts);
    if (!opts.quiet) {
      process.stderr.write(`redactkit: restored ${total} placeholder${total === 1 ? "" : "s"}\n`);
    }
    return 0;
  }

  /* An empty rule set is bad usage, not a clean result. --disable, or a
     .redactkitrc.json this process picked up without being told, can switch
     every detector off; the run then replaces nothing and `--check` exits 0.
     That is a pre-commit gate passing because nothing looked, and with -q it
     passes in silence. Refuse instead, on stderr, before anything is read.
     Same rule as score.py: a scan that did not run has no verdict to give. */
  const table = detectorTable();
  const active = table.filter((r) => r.on);
  if (!active.length) {
    fail(
      "every detector is switched off, so this run would scan nothing. " +
        "An empty result would not mean the input is clean -- it would mean " +
        "nothing looked. Check --disable and any .redactkitrc.json, then " +
        "re-run. `redactkit --list` shows the whole set."
    );
  }

  const chunks = readChunks(opts.files, opts);
  const result = redactRun(chunks, {
    numbered: opts.numbered,
    prefix: opts.prefix,
    review: opts.review,
    reviewAll: opts.reviewAll,
  });

  if (opts.map) {
    writeFileSync(
      resolve(opts.map),
      JSON.stringify(buildMap(result, { files: chunks.map((c) => c.name) }), null, 2) + "\n",
      { mode: 0o600 }
    );
    if (!opts.quiet) {
      process.stderr.write(
        `redactkit: wrote ${opts.map} (mode 600) -- it holds the real secrets, keep it local\n`
      );
    }
  }

  if (opts.json) {
    emit(
      JSON.stringify(
        { outputs: result.outputs, findings: result.findings, review: result.review },
        null,
        2
      ) + "\n",
      opts
    );
  } else {
    emit(result.outputs.map((o) => o.text).join(""), opts);
  }

  if (!opts.quiet) process.stderr.write(summary(result, chunks, table));
  return opts.check && (result.findings.length || result.review.length) ? 1 : 0;
}

function emit(text, opts) {
  if (opts.out) writeFileSync(resolve(opts.out), text);
  else process.stdout.write(text);
}

/* Characters with no advance width -- a zero-width space, a word joiner, a soft
 * hyphen, a stray byte-order mark, a bidi control. The engine reads past them, so
 * a secret split by one is still replaced. Saying the count is not housekeeping:
 * you cannot see them by definition, and dropping U+200B into a key is the
 * cheapest way there is to walk a secret past a scanner. If one is in your log,
 * that is itself worth knowing. */
function invisibleLine(chunks) {
  const n = chunks.reduce((a, c) => a + countInvisible(c.text), 0);
  if (!n) return "";
  return `  read past ${n} invisible character${n === 1 ? "" : "s"} ` +
    "(zero-width, joiners, soft hyphens, BOM, bidi); they render as nothing and can " +
    "split a secret in two. They are still in your output.\n";
}

/* The mirror of the line above. A homoglyph does not render as nothing, it renders
 * as the WRONG thing -- a Cyrillic a drawn exactly like an a, a fullwidth colon
 * after "password", an en dash a word processor put where a hyphen was. The engine
 * folds them to their ASCII twin before matching, one character in and one out, so
 * every span still addresses your bytes and your output is untouched. Saying the
 * count matters more here than for an invisible character: there you fail to see
 * something, here you see the wrong thing and are certain you are right. */
function confusableLine(chunks) {
  const n = chunks.reduce((a, c) => a + countConfusables(c.text), 0);
  if (!n) return "";
  return `  read past ${n} character${n === 1 ? "" : "s"} that look like ASCII and are not ` +
    "(Cyrillic or Greek lookalikes, fullwidth forms, typographic dashes and quotes); " +
    "they are folded to their ASCII twin for matching only, and your output keeps your bytes.\n";
}

/* Values another tool had already masked, which the engine matched and declined.
 * Same argument as the two lines above, one step further out: after the mask
 * rules do their job an already-scrubbed file reports "nothing was replaced",
 * which is word for word what a file nobody ever scrubbed reports. Saying the
 * number is how the reader tells "this tool read my file and it was already
 * clean" from "this tool did nothing". */
function maskedLine(result) {
  const n = result.masked || 0;
  if (!n) return "";
  return `  read past ${n} value${n === 1 ? "" : "s"} another tool had already ` +
    "masked (stars, [REDACTED], <sensitive> and the like); they stay exactly as " +
    "they were in your output.\n";
}

function summary(result, chunks, table) {
  const n = result.findings.length;
  const look = invisibleLine(chunks) + confusableLine(chunks) + maskedLine(result) + reviewLines(result, chunks);
  if (!n) {
    /* "Nothing was replaced" is a negative assertion, and a negative assertion
       needs a witness. Three detectors ship off, and a config file can switch
       off more without the reader remembering, so the empty result states how
       many rules ran and names what was not looked for. */
    const off = table.filter((r) => !r.on).map((r) => r.id);
    return (
      `redactkit: nothing was replaced; ${table.length - off.length} of ` +
      `${table.length} rules ran. That does not mean the text is clean -- ` +
      "read it yourself before you share it.\n" +
      (off.length ? `  not looked for: ${off.join(", ")}\n` : "") + look
    );
  }
  const byTag = new Map();
  for (const f of result.findings) byTag.set(f.tag, (byTag.get(f.tag) || 0) + 1);
  const chips = [...byTag.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .map(([t, c]) => `${t} x${c}`)
    .join(", ");
  const distinct = result.entries.length;
  const where =
    chunks.length > 1 ? ` across ${chunks.length} files` : "";
  return (
    `redactkit: ${n} replacement${n === 1 ? "" : "s"}${where}, ` +
    `${distinct} distinct value${distinct === 1 ? "" : "s"}\n  ${chips}\n` + look
  );
}

/* The second look, on stderr, beside the replacements. It is deliberately loud and
 * deliberately separate: these are things redactkit did NOT change in the output, so a
 * reader who skims the diff will not see them anywhere else. */
function reviewLines(result, chunks) {
  const rows = result.review || [];
  if (!rows.length) return "";
  const many = chunks.length > 1;
  const decoded = rows.filter((r) => r.kind === "decoded");
  const rest = rows.filter((r) => r.kind !== "decoded");
  let out = `redactkit: SECOND LOOK -- ${rows.length} thing${rows.length === 1 ? "" : "s"} ` +
    `the redactor could not act on. Read ${rows.length === 1 ? "it" : "them"} before you share this.\n`;
  for (const r of decoded) {
    const at = (many ? `${r.file}:` : "line ") + r.line;
    out += `  ${at}  a ${r.encoding} run decodes to a ${r.tag}` +
      `${r.count > 1 ? ` x${r.count}` : ""}  (detector: ${r.detector})\n      ${r.sample}...\n`;
  }
  for (const r of rest) {
    const at = (many ? `${r.file}:` : "line ") + r.line;
    out += `  ${at}  ${r.detector} -- not redacted, off by default\n      ${r.sample}\n`;
  }
  if (decoded.length) {
    out += "  The blob itself was left alone on purpose: it may be a certificate or a " +
      "config you need whole.\n";
  }
  return out;
}

process.exit(main());
