#!/usr/bin/env node
/**
 * credscan - what could an agent on this machine reach, and how far would it get?
 *
 * Coding agents do not need to be given a credential. They find the ones already
 * sitting there: a CLI somebody logged into months ago, a token in a dotfile, an
 * environment variable inherited from the shell. Helpfully, which is worse than
 * maliciously, because helpful is harder to argue with.
 *
 * This lists what is reachable and what the blast radius is. It NEVER prints a
 * secret - only whether one exists, where, and what it would let somebody do.
 *
 *   node credscan.js
 *   node credscan.js --json
 *
 * Read-only. It runs `<tool> auth status`-style commands and stats files. It
 * does not open sockets and it does not send anything anywhere.
 */
'use strict';

const { execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');

const HOME = os.homedir();
const isWin = process.platform === 'win32';

/** Returns {ok, out}. Telling success from failure is the whole job here: a
 *  failing execFileSync also writes to stderr, and treating that output as a
 *  valid answer reports a CLI that is not even installed as authenticated. That
 *  is precisely the mistake this script exists to prevent. */
function run(cmd, args) {
  // On Windows most of these CLIs are .cmd shims and will not start without a
  // shell. Every command and argument here is a literal in this file, so the
  // string form is safe; passing an args array with shell:true is deprecated.
  const opts = { stdio: ['ignore', 'pipe', 'pipe'], timeout: 8000, encoding: 'utf8' };
  try {
    const out = isWin
      ? execFileSync(`${cmd} ${args.join(' ')}`, { ...opts, shell: true })
      : execFileSync(cmd, args, opts);
    return { ok: true, out: (out || '').trim() };
  } catch (e) {
    return { ok: false, out: `${e.stdout || ''}${e.stderr || ''}`.trim() };
  }
}

const has = (cmd) => run(isWin ? 'where' : 'which', [cmd]).ok;
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };

/** The value is never printed. Only whether it is there, and how big. */
const present = (p) => (exists(p) ? { path: p.replace(HOME, '~'), bytes: fs.statSync(p).size } : null);

const FINDINGS = [];
const add = (f) => FINDINGS.push(f);

// ---------------------------------------------------------------- git / GitHub

if (has('gh')) {
  const status = run('gh', ['auth', 'status']).out;
  const account = (status.match(/account\s+([\w-]+)/) || [])[1];
  const scopes = (status.match(/Token scopes:\s*(.+)/) || [])[1];
  if (/Logged in/i.test(status)) {
    const wide = /\brepo\b/.test(scopes || '');
    add({
      tool: 'GitHub CLI (gh)',
      state: `logged in as ${account || 'unknown'}`,
      detail: scopes ? `scopes: ${scopes}` : 'scopes not reported',
      radius: wide
        ? 'read and write on every repository this account can see, including private ones'
        : 'limited by the scopes above',
      level: wide ? 'HIGH' : 'MEDIUM',
      advice: 'Agents should use a fine-grained token scoped to one repository, not the interactive session.',
    });
  }
}

const gitCfgRes = run('git', ['config', '--global', 'credential.helper']);
const gitCfg = gitCfgRes.ok ? gitCfgRes.out : '';
if (gitCfg) {
  add({
    tool: 'git credential helper',
    state: `configured: ${gitCfg}`,
    detail: 'stores credentials for every host you have pushed to',
    radius: 'git push to any remote whose credentials are cached',
    level: 'HIGH',
    advice: 'A helper is fine for you. It is also inherited by anything running as you.',
  });
}

// ---------------------------------------------------------------- packages

if (has('npm')) {
  const whoRes = run('npm', ['whoami']);
  const who = whoRes.out;
  if (whoRes.ok && who && !/ENEEDAUTH|need auth/i.test(who)) {
    add({
      tool: 'npm',
      state: `logged in as ${who}`,
      radius: 'publish and deprecate any package this account owns',
      detail: 'a published version cannot be unpublished after 72 hours',
      level: 'HIGH',
      advice: 'Publishing is irreversible in practice. Use a granular access token in CI instead.',
    });
  }
}

const npmrc = present(path.join(HOME, '.npmrc'));
if (npmrc && /_authToken/.test(fs.readFileSync(path.join(HOME, '.npmrc'), 'utf8'))) {
  add({
    tool: '~/.npmrc',
    state: 'contains an auth token',
    detail: `${npmrc.bytes} bytes at ${npmrc.path}`,
    radius: 'registry publish rights, readable by any process running as you',
    level: 'HIGH',
    advice: 'Token in a plain file. Nothing stops a subprocess reading it.',
  });
}

// ---------------------------------------------------------------- cloud

const CLOUD = [
  ['aws', ['sts', 'get-caller-identity'], 'AWS CLI', 'the entire account this identity can reach'],
  ['gcloud', ['auth', 'list'], 'gcloud', 'every Google Cloud project this account can reach'],
  ['az', ['account', 'show'], 'Azure CLI', 'every subscription this account can reach'],
  ['doctl', ['account', 'get'], 'DigitalOcean CLI', 'the whole DigitalOcean account'],
  ['flyctl', ['auth', 'whoami'], 'Fly.io CLI', 'every app on the account'],
  ['heroku', ['auth', 'whoami'], 'Heroku CLI', 'every app on the account'],
  ['vercel', ['whoami'], 'Vercel CLI', 'every project and deployment'],
  ['netlify', ['status'], 'Netlify CLI', 'every site on the account'],
  ['wrangler', ['whoami'], 'Cloudflare Wrangler', 'workers, DNS and zones on the account'],
  ['stripe', ['config', '--list'], 'Stripe CLI', 'live payment data if a live key is configured'],
];

for (const [cmd, args, label, radius] of CLOUD) {
  if (!has(cmd)) continue;
  const res = run(cmd, args);
  // Authenticated = the identity command exited successfully. An error message
  // on stderr is not an answer; it is the opposite of an answer.
  const authed = res.ok && res.out &&
    !/not logged|no credentials|unable to locate|command not found|please run|not authenticated|login/i.test(res.out);
  add({
    tool: label,
    state: authed ? 'installed and authenticated' : 'installed, not authenticated',
    detail: authed ? 'responded to an identity call without prompting' : '',
    radius: authed ? radius : 'none right now',
    level: authed ? 'HIGH' : 'INFO',
    advice: authed ? 'Anything running as you inherits this. Scope it or log out when not using it.' : '',
  });
}

// ---------------------------------------------------------------- files

const FILES = [
  ['~/.netrc', path.join(HOME, '.netrc'), 'plaintext logins for arbitrary hosts', 'HIGH'],
  ['~/.aws/credentials', path.join(HOME, '.aws', 'credentials'), 'long-lived AWS keys in plaintext', 'HIGH'],
  ['~/.docker/config.json', path.join(HOME, '.docker', 'config.json'), 'registry push credentials', 'MEDIUM'],
  ['~/.kube/config', path.join(HOME, '.kube', 'config'), 'cluster admin, often production', 'HIGH'],
  ['~/.ssh/id_rsa', path.join(HOME, '.ssh', 'id_rsa'), 'private key, possibly without a passphrase', 'HIGH'],
  ['~/.ssh/id_ed25519', path.join(HOME, '.ssh', 'id_ed25519'), 'private key, possibly without a passphrase', 'HIGH'],
  ['~/.config/gh/hosts.yml', path.join(HOME, '.config', 'gh', 'hosts.yml'), 'GitHub tokens on disk', 'HIGH'],
];

for (const [label, p, radius, level] of FILES) {
  const f = present(p);
  if (f) add({ tool: label, state: 'present', detail: `${f.bytes} bytes`, radius, level,
    advice: 'On disk, readable by every process running as you.' });
}

// ---------------------------------------------------------------- environment

// Names only. The value is never read, never stored and never printed.
const ENV_HINT = /(TOKEN|SECRET|PASSWORD|APIKEY|API_KEY|ACCESSKEY|ACCESS_KEY|CREDENTIAL|_PAT$|PRIVATE_KEY)/i;
// _PATH ends in "_PAT" and is not a secret. False positives in a credential
// scanner are worse than gaps: people stop reading it.
const SAFE = /(PATH$|PATHEXT$|_DIR$|_HOME$|CACHE)/i;
const SAFE_PREFIX = /^(npm_|NODE_|VSCODE_|ELECTRON_)/i;
const envNames = Object.keys(process.env)
  .filter((k) => ENV_HINT.test(k) && !SAFE.test(k) && !SAFE_PREFIX.test(k));
if (envNames.length) {
  add({
    tool: 'environment variables',
    state: `${envNames.length} look like credentials`,
    detail: envNames.sort().join(', '),
    radius: 'inherited by every subprocess this shell starts, including any agent',
    level: 'MEDIUM',
    advice: 'Names only are shown above; values were never read. Load secrets per-command instead of exporting them.',
  });
}

// ---------------------------------------------------------------- files .env

const dotenvs = [];
(function walk(dir, depth) {
  if (depth > 2) return;
  let entries = [];
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
  for (const e of entries) {
    if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
    const full = path.join(dir, e.name);
    if (e.isDirectory()) walk(full, depth + 1);
    else if (/^\.?(env|secrets)(\..+)?$/i.test(e.name) || /\.env$/i.test(e.name)) dotenvs.push(full);
  }
})(process.cwd(), 0);

if (dotenvs.length) {
  add({
    tool: 'secret files in this directory tree',
    state: `${dotenvs.length} found`,
    detail: dotenvs.slice(0, 10).map((p) => path.relative(process.cwd(), p)).join(', ') +
      (dotenvs.length > 10 ? `, and ${dotenvs.length - 10} more` : ''),
    radius: 'whatever those services allow; an agent working here will read them as data',
    level: 'MEDIUM',
    advice: 'Contents were not opened. If an agent works in this directory, assume it can.',
  });
}

// ---------------------------------------------------------------- output

const ORDER = { HIGH: 0, MEDIUM: 1, INFO: 2 };
FINDINGS.sort((a, b) => ORDER[a.level] - ORDER[b.level]);

if (process.argv.includes('--json')) {
  console.log(JSON.stringify({ platform: process.platform, findings: FINDINGS }, null, 2));
} else {
  const colour = process.stdout.isTTY && !process.env.NO_COLOR;
  const paint = (s, c) => (colour ? `[${c}m${s}[0m` : s);
  const badge = (l) => (l === 'HIGH' ? paint(' HIGH ', 31)
    : l === 'MEDIUM' ? paint(' MED  ', 33) : paint(' INFO ', 90));

  console.log('');
  if (!FINDINGS.length) {
    console.log('Nothing reachable found. Either this machine is clean or the tools are elsewhere.');
  }
  for (const f of FINDINGS) {
    console.log(`${badge(f.level)} ${f.tool} - ${f.state}`);
    if (f.detail) console.log(`        ${paint(f.detail, 90)}`);
    if (f.radius) console.log(`        reach: ${f.radius}`);
    if (f.advice) console.log(`        ${paint(f.advice, 36)}`);
    console.log('');
  }
  const high = FINDINGS.filter((f) => f.level === 'HIGH').length;
  console.log(`${FINDINGS.length} findings, ${high} that an agent could use today.`);
  console.log(paint('No secret value was read or printed by this script.', 90));
  console.log('');
}

process.exitCode = FINDINGS.some((f) => f.level === 'HIGH') ? 1 : 0;
