#!/usr/bin/env node

import { createHash } from "node:crypto";
import { access, readFile, readdir, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

export const DEFAULT_FEED_URL =
  "https://socket.dev/api/public/supply-chain-attacks/keyv-and-cacheable-compromise/packages.csv";

export const CHAIN_DROP = Object.freeze({
  firstObservedAt: "2026-08-04T09:35:00.763Z",
  hashes: new Map([
    ["54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668", "setup.mjs npm loader"],
    ["fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb", "repository loader"],
    ["9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc", "Math_Symbol.js payload"],
  ]),
  suspiciousBasenames: new Set([
    "Math_Symbol.js",
    "math_init.js",
    "gh-token-monitor.sh",
    "com.user.gh-token-monitor.plist",
    "gh-token-monitor.service",
  ]),
  inspectedBasenames: new Set([
    "setup.mjs",
    "Math_Symbol.js",
    "math_init.js",
    "gh-token-monitor.sh",
    "com.user.gh-token-monitor.plist",
    "gh-token-monitor.service",
  ]),
});

function optionValue(args, index, name) {
  const value = args[index + 1];
  if (!value || value.startsWith("--")) throw new Error(`${name} requires a value.`);
  return value;
}

export function parseArgs(argv) {
  const options = { roots: [], feedUrl: DEFAULT_FEED_URL, feedFile: null, home: os.homedir() };
  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === "--root") options.roots.push(path.resolve(optionValue(argv, index++, arg)));
    else if (arg === "--feed-url") options.feedUrl = optionValue(argv, index++, arg);
    else if (arg === "--feed-file") options.feedFile = path.resolve(optionValue(argv, index++, arg));
    else if (arg === "--home") options.home = path.resolve(optionValue(argv, index++, arg));
    else throw new Error(`Unknown option: ${arg}`);
  }
  if (!options.roots.length) options.roots.push(process.cwd());
  return options;
}

export function parseCampaignCsv(csv) {
  const artifacts = new Set();
  for (const line of String(csv).trim().split(/\r?\n/u).slice(1)) {
    const match = line.match(/^npm,([^,]*),([^,]+),([^,]+),/u);
    if (!match) continue;
    artifacts.add(`${match[1] ? `${match[1]}/` : ""}${match[2]}@${match[3]}`);
  }
  if (!artifacts.size) throw new Error("Campaign feed contained no npm package artifacts.");
  return artifacts;
}

async function loadFeed(options) {
  if (options.feedFile) return readFile(options.feedFile, "utf8");
  const response = await fetch(options.feedUrl, { signal: AbortSignal.timeout(30_000) });
  if (!response.ok) throw new Error(`Campaign feed returned HTTP ${response.status}.`);
  return response.text();
}

function packageNameFromLockPath(lockPath) {
  return lockPath.replace(/^.*node_modules\//u, "");
}

export function inspectPackageLock(lock, affectedArtifacts, source) {
  const hits = [];
  let packageEntries = 0;
  for (const [lockPath, descriptor] of Object.entries(lock?.packages || {})) {
    if (!lockPath || !descriptor?.version) continue;
    packageEntries += 1;
    const artifact = `${packageNameFromLockPath(lockPath)}@${descriptor.version}`;
    if (affectedArtifacts.has(artifact)) hits.push({ source, artifact, lockPath });
  }
  return { packageEntries, hits };
}

async function sha256(file) {
  return createHash("sha256").update(await readFile(file)).digest("hex");
}

async function existing(pathname) {
  try {
    await access(pathname);
    return true;
  } catch {
    return false;
  }
}

async function inspectHook(file, report) {
  if (!file.endsWith("/.claude/settings.json") && !file.endsWith("/.vscode/tasks.json")) return;
  const info = await stat(file);
  if (info.size > 1_000_000) return;
  const contents = await readFile(file, "utf8");
  if (/math_init\.js|Math_Symbol\.js|bun-dl-|setup\.mjs/u.test(contents)) {
    report.hookHits.push({ file });
  }
}

async function inspectFile(file, affectedArtifacts, report) {
  const basename = path.basename(file);
  if (basename === "package-lock.json" || basename === "npm-shrinkwrap.json") {
    report.coverage.lockfiles += 1;
    try {
      const result = inspectPackageLock(JSON.parse(await readFile(file, "utf8")), affectedArtifacts, file);
      report.coverage.packageEntries += result.packageEntries;
      report.packageHits.push(...result.hits);
    } catch (error) {
      report.errors.push({ file, error: error instanceof Error ? error.message : String(error) });
    }
  }

  if (basename === "package.json" && file.includes(`${path.sep}node_modules${path.sep}`)) {
    report.coverage.installedManifests += 1;
    try {
      const descriptor = JSON.parse(await readFile(file, "utf8"));
      const artifact = `${descriptor.name}@${descriptor.version}`;
      if (descriptor.name && descriptor.version && affectedArtifacts.has(artifact)) {
        report.installedHits.push({ file, artifact });
      }
    } catch (error) {
      report.errors.push({ file, error: error instanceof Error ? error.message : String(error) });
    }
  }

  if (CHAIN_DROP.inspectedBasenames.has(basename)) {
    const digest = await sha256(file);
    const knownMalicious = CHAIN_DROP.hashes.get(digest) || null;
    if (knownMalicious || CHAIN_DROP.suspiciousBasenames.has(basename)) {
      report.fileHits.push({ file, sha256: digest, knownMalicious });
    }
  }

  await inspectHook(file, report);
}

async function walk(root, affectedArtifacts, report) {
  let entries;
  try {
    entries = await readdir(root, { withFileTypes: true });
  } catch (error) {
    report.errors.push({ file: root, error: error instanceof Error ? error.message : String(error) });
    return;
  }
  for (const entry of entries) {
    if (entry.name === ".git") continue;
    const target = path.join(root, entry.name);
    if (entry.isDirectory()) await walk(target, affectedArtifacts, report);
    else if (entry.isFile()) await inspectFile(target, affectedArtifacts, report);
  }
}

async function inspectPersistence(home, report) {
  const candidates = [
    path.join(home, ".local/bin/gh-token-monitor.sh"),
    path.join(home, ".config/gh-token-monitor"),
    path.join(home, "Library/LaunchAgents/com.user.gh-token-monitor.plist"),
    path.join(home, ".config/systemd/user/gh-token-monitor.service"),
    "/tmp/gh-token-monitor.out.log",
    "/tmp/gh-token-monitor.err.log",
  ];
  for (const candidate of candidates) {
    if (await existing(candidate)) report.persistenceHits.push({ path: candidate });
  }
}

export async function scan(options) {
  const affectedArtifacts = parseCampaignCsv(await loadFeed(options));
  const report = {
    type: "ok.security.npm-supply-chain-scan.v1",
    generatedAt: new Date().toISOString(),
    campaign: "ChainDrop",
    feed: options.feedFile || options.feedUrl,
    feedArtifacts: affectedArtifacts.size,
    roots: options.roots,
    coverage: { lockfiles: 0, packageEntries: 0, installedManifests: 0 },
    packageHits: [],
    installedHits: [],
    fileHits: [],
    hookHits: [],
    persistenceHits: [],
    errors: [],
  };
  for (const root of options.roots) await walk(root, affectedArtifacts, report);
  await inspectPersistence(options.home, report);
  report.compromiseIndicators = report.packageHits.length + report.installedHits.length
    + report.fileHits.length + report.hookHits.length + report.persistenceHits.length;
  report.ok = report.compromiseIndicators === 0 && report.errors.length === 0;
  return report;
}

async function main() {
  const report = await scan(parseArgs(process.argv.slice(2)));
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
  if (!report.ok) process.exitCode = report.compromiseIndicators ? 1 : 2;
}

if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
  main().catch((error) => {
    process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
    process.exitCode = 2;
  });
}
