@profullstack/threatcrush 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -2022,8 +2022,8 @@ var require_toml = __commonJS({
2022
2022
  });
2023
2023
 
2024
2024
  // src/daemon/index.ts
2025
- var import_node_fs15 = require("fs");
2026
- var import_node_path10 = require("path");
2025
+ var import_node_fs17 = require("fs");
2026
+ var import_node_path12 = require("path");
2027
2027
 
2028
2028
  // src/daemon/paths.ts
2029
2029
  var import_node_fs = require("fs");
@@ -2107,7 +2107,28 @@ function findRunningDaemon() {
2107
2107
 
2108
2108
  // src/daemon/ipc-server.ts
2109
2109
  var import_node_net = require("net");
2110
+ var import_node_fs4 = require("fs");
2111
+
2112
+ // src/daemon/control-token.ts
2113
+ var import_node_crypto = require("crypto");
2110
2114
  var import_node_fs3 = require("fs");
2115
+ var import_node_path2 = require("path");
2116
+ var CONTROL_TOKEN_FILE = (0, import_node_path2.join)(PATHS.runDir, "control.token");
2117
+ function issueControlToken() {
2118
+ const token = (0, import_node_crypto.randomBytes)(32).toString("hex");
2119
+ (0, import_node_fs3.writeFileSync)(CONTROL_TOKEN_FILE, token, { mode: 384 });
2120
+ try {
2121
+ (0, import_node_fs3.chmodSync)(CONTROL_TOKEN_FILE, 384);
2122
+ } catch {
2123
+ }
2124
+ return token;
2125
+ }
2126
+ function tokensMatch(expected, provided) {
2127
+ if (typeof provided !== "string" || !provided) return false;
2128
+ const a = Buffer.from(expected);
2129
+ const b = Buffer.from(provided);
2130
+ return a.length === b.length && (0, import_node_crypto.timingSafeEqual)(a, b);
2131
+ }
2111
2132
 
2112
2133
  // src/daemon/event-bus.ts
2113
2134
  var import_node_events = require("events");
@@ -2303,10 +2324,12 @@ var IpcServer = class {
2303
2324
  nextClientId = 1;
2304
2325
  startedAt = /* @__PURE__ */ new Date();
2305
2326
  counters = { events: 0, threats: 0, alerts: 0 };
2327
+ controlToken = "";
2306
2328
  async start() {
2307
- if ((0, import_node_fs3.existsSync)(PATHS.socket)) {
2329
+ this.controlToken = issueControlToken();
2330
+ if ((0, import_node_fs4.existsSync)(PATHS.socket)) {
2308
2331
  try {
2309
- (0, import_node_fs3.unlinkSync)(PATHS.socket);
2332
+ (0, import_node_fs4.unlinkSync)(PATHS.socket);
2310
2333
  } catch {
2311
2334
  }
2312
2335
  }
@@ -2342,14 +2365,14 @@ var IpcServer = class {
2342
2365
  return new Promise((resolve3) => {
2343
2366
  if (!this.server) {
2344
2367
  try {
2345
- if ((0, import_node_fs3.existsSync)(PATHS.socket)) (0, import_node_fs3.unlinkSync)(PATHS.socket);
2368
+ if ((0, import_node_fs4.existsSync)(PATHS.socket)) (0, import_node_fs4.unlinkSync)(PATHS.socket);
2346
2369
  } catch {
2347
2370
  }
2348
2371
  return resolve3();
2349
2372
  }
2350
2373
  this.server.close(() => {
2351
2374
  try {
2352
- if ((0, import_node_fs3.existsSync)(PATHS.socket)) (0, import_node_fs3.unlinkSync)(PATHS.socket);
2375
+ if ((0, import_node_fs4.existsSync)(PATHS.socket)) (0, import_node_fs4.unlinkSync)(PATHS.socket);
2353
2376
  } catch {
2354
2377
  }
2355
2378
  resolve3();
@@ -2435,6 +2458,13 @@ var IpcServer = class {
2435
2458
  for (const ch of req.params.channels) client.subscriptions.add(ch);
2436
2459
  return this.send(client, { id: req.id, ok: true, result: { subscribed: [...client.subscriptions] } });
2437
2460
  case "shutdown":
2461
+ if (!tokensMatch(this.controlToken, req.params?.token)) {
2462
+ return this.send(client, {
2463
+ id: req.id,
2464
+ ok: false,
2465
+ error: "shutdown requires the daemon control token (run as root, or use systemctl)"
2466
+ });
2467
+ }
2438
2468
  this.send(client, { id: req.id, ok: true, result: "shutting down" });
2439
2469
  setTimeout(() => process.emit("SIGTERM"), 50);
2440
2470
  return;
@@ -2455,13 +2485,134 @@ var IpcServer = class {
2455
2485
  };
2456
2486
 
2457
2487
  // src/daemon/module-host.ts
2458
- var import_node_fs8 = require("fs");
2459
- var import_node_path3 = require("path");
2488
+ var import_node_fs10 = require("fs");
2489
+ var import_node_path5 = require("path");
2460
2490
  var import_node_url = require("url");
2461
2491
  var import_toml2 = __toESM(require_toml());
2462
2492
 
2493
+ // src/daemon/module-trust.ts
2494
+ var import_node_crypto2 = require("crypto");
2495
+ var import_node_fs5 = require("fs");
2496
+ var import_node_path3 = require("path");
2497
+ var TRUST_FILE = (0, import_node_path3.join)(PATHS.configDir, "trusted-modules.json");
2498
+ var PUBLISHER_KEYS_FILE = (0, import_node_path3.join)(PATHS.configDir, "publisher-keys.json");
2499
+ var DIGEST_EXCLUDED_DIRS = /* @__PURE__ */ new Set([".git"]);
2500
+ function collectFiles(root, dir = root, out = []) {
2501
+ for (const entry of (0, import_node_fs5.readdirSync)(dir, { withFileTypes: true }).sort(
2502
+ (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
2503
+ )) {
2504
+ const full = (0, import_node_path3.join)(dir, entry.name);
2505
+ if (entry.isSymbolicLink()) {
2506
+ out.push({ path: full, isSymlink: true });
2507
+ } else if (entry.isDirectory()) {
2508
+ if (DIGEST_EXCLUDED_DIRS.has(entry.name)) continue;
2509
+ collectFiles(root, full, out);
2510
+ } else if (entry.isFile()) {
2511
+ out.push({ path: full, isSymlink: false });
2512
+ }
2513
+ }
2514
+ return out;
2515
+ }
2516
+ function computeModuleDigest(modulePath) {
2517
+ const hash = (0, import_node_crypto2.createHash)("sha256");
2518
+ for (const entry of collectFiles(modulePath)) {
2519
+ const relPath = (0, import_node_path3.relative)(modulePath, entry.path).split(import_node_path3.sep).join("/");
2520
+ hash.update(relPath);
2521
+ hash.update("\0");
2522
+ if (entry.isSymlink) {
2523
+ hash.update("symlink:");
2524
+ hash.update((0, import_node_fs5.readlinkSync)(entry.path));
2525
+ } else {
2526
+ hash.update("file:");
2527
+ hash.update((0, import_node_fs5.readFileSync)(entry.path));
2528
+ }
2529
+ hash.update("\0");
2530
+ }
2531
+ return hash.digest("hex");
2532
+ }
2533
+ function readTrustFile() {
2534
+ if (!(0, import_node_fs5.existsSync)(TRUST_FILE)) return { version: 1, modules: {} };
2535
+ try {
2536
+ const parsed = JSON.parse((0, import_node_fs5.readFileSync)(TRUST_FILE, "utf-8"));
2537
+ if (parsed.version !== 1 || typeof parsed.modules !== "object" || !parsed.modules) {
2538
+ return { version: 1, modules: {} };
2539
+ }
2540
+ return { version: 1, modules: parsed.modules };
2541
+ } catch {
2542
+ return { version: 1, modules: {} };
2543
+ }
2544
+ }
2545
+ function readPublisherKeys() {
2546
+ if (!(0, import_node_fs5.existsSync)(PUBLISHER_KEYS_FILE)) return {};
2547
+ try {
2548
+ const parsed = JSON.parse((0, import_node_fs5.readFileSync)(PUBLISHER_KEYS_FILE, "utf-8"));
2549
+ return parsed && typeof parsed === "object" ? parsed : {};
2550
+ } catch {
2551
+ return {};
2552
+ }
2553
+ }
2554
+ function verifyModuleSignature(modulePath, digest) {
2555
+ const keys = readPublisherKeys();
2556
+ const pinnedKeyIds = Object.keys(keys);
2557
+ const sigPath = (0, import_node_path3.join)(modulePath, "mod.sig");
2558
+ if (pinnedKeyIds.length === 0) {
2559
+ return { ok: true };
2560
+ }
2561
+ if (!(0, import_node_fs5.existsSync)(sigPath)) {
2562
+ return { ok: false, reason: "publisher keys are pinned but the module ships no mod.sig" };
2563
+ }
2564
+ let parsed;
2565
+ try {
2566
+ parsed = JSON.parse((0, import_node_fs5.readFileSync)(sigPath, "utf-8"));
2567
+ } catch {
2568
+ return { ok: false, reason: "mod.sig is not valid JSON" };
2569
+ }
2570
+ if (!parsed.keyId || !parsed.signature) {
2571
+ return { ok: false, reason: "mod.sig is missing keyId or signature" };
2572
+ }
2573
+ const publicKey = keys[parsed.keyId];
2574
+ if (!publicKey) {
2575
+ return { ok: false, reason: `mod.sig references unpinned key "${parsed.keyId}"` };
2576
+ }
2577
+ try {
2578
+ const valid = (0, import_node_crypto2.verify)(
2579
+ null,
2580
+ Buffer.from(digest, "hex"),
2581
+ publicKey,
2582
+ Buffer.from(parsed.signature, "base64")
2583
+ );
2584
+ return valid ? { ok: true } : { ok: false, reason: "mod.sig signature does not match" };
2585
+ } catch (err) {
2586
+ return { ok: false, reason: `signature check failed: ${String(err.message || err)}` };
2587
+ }
2588
+ }
2589
+ function verifyModuleTrust(name, modulePath) {
2590
+ let digest;
2591
+ try {
2592
+ digest = computeModuleDigest(modulePath);
2593
+ } catch (err) {
2594
+ return { ok: false, reason: `could not hash module: ${String(err.message || err)}` };
2595
+ }
2596
+ const signature = verifyModuleSignature(modulePath, digest);
2597
+ if (!signature.ok) return signature;
2598
+ const record = readTrustFile().modules[name];
2599
+ if (!record) {
2600
+ return {
2601
+ ok: false,
2602
+ reason: `not trusted \u2014 review it, then run: threatcrush modules trust ${name}`
2603
+ };
2604
+ }
2605
+ if (record.digest !== digest) {
2606
+ return {
2607
+ ok: false,
2608
+ reason: `contents changed since it was trusted on ${record.trustedAt} \u2014 re-review it, then run: threatcrush modules trust ${name}`
2609
+ };
2610
+ }
2611
+ return { ok: true };
2612
+ }
2613
+
2463
2614
  // src/daemon/watchers/log-watcher.ts
2464
- var import_node_fs4 = require("fs");
2615
+ var import_node_fs6 = require("fs");
2465
2616
  var import_node_readline = require("readline");
2466
2617
 
2467
2618
  // src/core/log-parser.ts
@@ -2618,9 +2769,9 @@ var LogWatcher = class {
2618
2769
  start() {
2619
2770
  const started = [];
2620
2771
  for (const src of this.sources) {
2621
- if (!(0, import_node_fs4.existsSync)(src.path)) continue;
2772
+ if (!(0, import_node_fs6.existsSync)(src.path)) continue;
2622
2773
  try {
2623
- (0, import_node_fs4.accessSync)(src.path, import_node_fs4.constants.R_OK);
2774
+ (0, import_node_fs6.accessSync)(src.path, import_node_fs6.constants.R_OK);
2624
2775
  } catch {
2625
2776
  continue;
2626
2777
  }
@@ -2640,7 +2791,7 @@ var LogWatcher = class {
2640
2791
  }
2641
2792
  tail(src) {
2642
2793
  try {
2643
- this.positions.set(src.path, (0, import_node_fs4.statSync)(src.path).size);
2794
+ this.positions.set(src.path, (0, import_node_fs6.statSync)(src.path).size);
2644
2795
  } catch {
2645
2796
  this.positions.set(src.path, 0);
2646
2797
  }
@@ -2651,7 +2802,7 @@ var LogWatcher = class {
2651
2802
  poll(src) {
2652
2803
  let stat;
2653
2804
  try {
2654
- stat = (0, import_node_fs4.statSync)(src.path);
2805
+ stat = (0, import_node_fs6.statSync)(src.path);
2655
2806
  } catch {
2656
2807
  return;
2657
2808
  }
@@ -2661,7 +2812,7 @@ var LogWatcher = class {
2661
2812
  return;
2662
2813
  }
2663
2814
  if (stat.size === prev) return;
2664
- const stream = (0, import_node_fs4.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
2815
+ const stream = (0, import_node_fs6.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
2665
2816
  stream.on("error", () => this.positions.set(src.path, stat.size));
2666
2817
  const rl = (0, import_node_readline.createInterface)({ input: stream });
2667
2818
  rl.on("error", () => {
@@ -2853,7 +3004,7 @@ function realtimeToDate(rt) {
2853
3004
 
2854
3005
  // src/modules/network-monitor/index.ts
2855
3006
  var import_node_child_process2 = require("child_process");
2856
- var import_node_fs5 = require("fs");
3007
+ var import_node_fs7 = require("fs");
2857
3008
  var NetworkMonitor = class {
2858
3009
  constructor(bus2) {
2859
3010
  this.bus = bus2;
@@ -2891,7 +3042,7 @@ var NetworkMonitor = class {
2891
3042
  hasConntrackOrSs() {
2892
3043
  const ss = (0, import_node_child_process2.spawnSync)("ss", ["--version"], { stdio: "pipe" });
2893
3044
  if (ss.status === 0) return true;
2894
- return (0, import_node_fs5.existsSync)("/proc/net/tcp");
3045
+ return (0, import_node_fs7.existsSync)("/proc/net/tcp");
2895
3046
  }
2896
3047
  poll() {
2897
3048
  try {
@@ -3030,7 +3181,7 @@ var NetworkMonitor = class {
3030
3181
  };
3031
3182
 
3032
3183
  // src/modules/dns-monitor/index.ts
3033
- var import_node_fs6 = require("fs");
3184
+ var import_node_fs8 = require("fs");
3034
3185
  var import_node_readline2 = require("readline");
3035
3186
  var DNS_LOG_SOURCES = [
3036
3187
  "/var/log/syslog",
@@ -3064,9 +3215,9 @@ var DnsMonitor = class {
3064
3215
  entropyThreshold = 3.5;
3065
3216
  start() {
3066
3217
  const sources = DNS_LOG_SOURCES.filter((p) => {
3067
- if (!(0, import_node_fs6.existsSync)(p)) return false;
3218
+ if (!(0, import_node_fs8.existsSync)(p)) return false;
3068
3219
  try {
3069
- (0, import_node_fs6.accessSync)(p, import_node_fs6.constants.R_OK);
3220
+ (0, import_node_fs8.accessSync)(p, import_node_fs8.constants.R_OK);
3070
3221
  return true;
3071
3222
  } catch {
3072
3223
  return false;
@@ -3090,7 +3241,7 @@ var DnsMonitor = class {
3090
3241
  }
3091
3242
  tailLog(path) {
3092
3243
  try {
3093
- this.positions.set(path, (0, import_node_fs6.statSync)(path).size);
3244
+ this.positions.set(path, (0, import_node_fs8.statSync)(path).size);
3094
3245
  } catch {
3095
3246
  this.positions.set(path, 0);
3096
3247
  }
@@ -3100,7 +3251,7 @@ var DnsMonitor = class {
3100
3251
  pollLog(path) {
3101
3252
  let stat;
3102
3253
  try {
3103
- stat = (0, import_node_fs6.statSync)(path);
3254
+ stat = (0, import_node_fs8.statSync)(path);
3104
3255
  } catch {
3105
3256
  return;
3106
3257
  }
@@ -3110,7 +3261,7 @@ var DnsMonitor = class {
3110
3261
  return;
3111
3262
  }
3112
3263
  if (stat.size === prev) return;
3113
- const stream = (0, import_node_fs6.createReadStream)(path, { start: prev, encoding: "utf-8" });
3264
+ const stream = (0, import_node_fs8.createReadStream)(path, { start: prev, encoding: "utf-8" });
3114
3265
  stream.on("error", () => this.positions.set(path, stat.size));
3115
3266
  const rl = (0, import_node_readline2.createInterface)({ input: stream });
3116
3267
  rl.on("line", (line) => this.parseDnsLine(line));
@@ -3239,8 +3390,8 @@ var DnsMonitor = class {
3239
3390
  };
3240
3391
 
3241
3392
  // src/core/config.ts
3242
- var import_node_fs7 = require("fs");
3243
- var import_node_path2 = require("path");
3393
+ var import_node_fs9 = require("fs");
3394
+ var import_node_path4 = require("path");
3244
3395
  var import_toml = __toESM(require_toml());
3245
3396
  var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
3246
3397
  var DEFAULT_CONFDIR = "/etc/threatcrush/threatcrushd.conf.d";
@@ -3266,11 +3417,11 @@ var DEFAULT_CONFIG = {
3266
3417
  };
3267
3418
  function loadConfig(configPath) {
3268
3419
  const path = configPath || DEFAULT_CONFIG_PATH;
3269
- if (!(0, import_node_fs7.existsSync)(path)) {
3420
+ if (!(0, import_node_fs9.existsSync)(path)) {
3270
3421
  return { ...DEFAULT_CONFIG };
3271
3422
  }
3272
3423
  try {
3273
- const raw = (0, import_node_fs7.readFileSync)(path, "utf-8");
3424
+ const raw = (0, import_node_fs9.readFileSync)(path, "utf-8");
3274
3425
  const parsed = import_toml.default.parse(raw);
3275
3426
  return {
3276
3427
  daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
@@ -3286,13 +3437,13 @@ function loadConfig(configPath) {
3286
3437
  function loadModuleConfigs(confDir) {
3287
3438
  const dir = confDir || DEFAULT_CONFDIR;
3288
3439
  const configs = /* @__PURE__ */ new Map();
3289
- if (!(0, import_node_fs7.existsSync)(dir)) {
3440
+ if (!(0, import_node_fs9.existsSync)(dir)) {
3290
3441
  return configs;
3291
3442
  }
3292
- const files = (0, import_node_fs7.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
3443
+ const files = (0, import_node_fs9.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
3293
3444
  for (const file of files) {
3294
3445
  try {
3295
- const raw = (0, import_node_fs7.readFileSync)((0, import_node_path2.join)(dir, file), "utf-8");
3446
+ const raw = (0, import_node_fs9.readFileSync)((0, import_node_path4.join)(dir, file), "utf-8");
3296
3447
  const parsed = import_toml.default.parse(raw);
3297
3448
  for (const [name, config] of Object.entries(parsed)) {
3298
3449
  configs.set(name, config);
@@ -3406,15 +3557,15 @@ var ModuleHost = class {
3406
3557
  for (const m of builtins) this.modules.set(m.name, m);
3407
3558
  }
3408
3559
  async discoverAndStartInstalled() {
3409
- if (!(0, import_node_fs8.existsSync)(PATHS.moduleDir)) return;
3560
+ if (!(0, import_node_fs10.existsSync)(PATHS.moduleDir)) return;
3410
3561
  const configs = loadModuleConfigs(PATHS.confD);
3411
- const entries = (0, import_node_fs8.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
3562
+ const entries = (0, import_node_fs10.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
3412
3563
  for (const entry of entries) {
3413
3564
  if (!entry.isDirectory()) continue;
3414
- const manifestPath = (0, import_node_path3.join)(PATHS.moduleDir, entry.name, "mod.toml");
3415
- if (!(0, import_node_fs8.existsSync)(manifestPath)) continue;
3565
+ const manifestPath = (0, import_node_path5.join)(PATHS.moduleDir, entry.name, "mod.toml");
3566
+ if (!(0, import_node_fs10.existsSync)(manifestPath)) continue;
3416
3567
  try {
3417
- const manifest = import_toml2.default.parse((0, import_node_fs8.readFileSync)(manifestPath, "utf-8"));
3568
+ const manifest = import_toml2.default.parse((0, import_node_fs10.readFileSync)(manifestPath, "utf-8"));
3418
3569
  const name = manifest.module?.name || entry.name;
3419
3570
  const defaults = manifest.module?.config?.defaults || {};
3420
3571
  const config = {
@@ -3428,7 +3579,7 @@ var ModuleHost = class {
3428
3579
  source: "installed",
3429
3580
  status: config.enabled === false ? "disabled" : "loaded",
3430
3581
  events: 0,
3431
- path: (0, import_node_path3.join)(PATHS.moduleDir, entry.name),
3582
+ path: (0, import_node_path5.join)(PATHS.moduleDir, entry.name),
3432
3583
  config
3433
3584
  };
3434
3585
  this.modules.set(name, hosted);
@@ -3443,7 +3594,7 @@ var ModuleHost = class {
3443
3594
  status: "error",
3444
3595
  events: 0,
3445
3596
  detail: `manifest load failed: ${String(err.message || err)}`,
3446
- path: (0, import_node_path3.join)(PATHS.moduleDir, entry.name)
3597
+ path: (0, import_node_path5.join)(PATHS.moduleDir, entry.name)
3447
3598
  });
3448
3599
  }
3449
3600
  }
@@ -3455,6 +3606,13 @@ var ModuleHost = class {
3455
3606
  hosted.detail = "no built entrypoint found; run npm install && npm run build in the module directory";
3456
3607
  return;
3457
3608
  }
3609
+ const trust = verifyModuleTrust(hosted.name, hosted.path);
3610
+ if (!trust.ok) {
3611
+ hosted.status = "error";
3612
+ hosted.detail = `refusing to load: ${trust.reason}`;
3613
+ this.bus.announceModule(hosted.name, "error", hosted.detail);
3614
+ return;
3615
+ }
3458
3616
  try {
3459
3617
  const imported = await import((0, import_node_url.pathToFileURL)(entrypoint).href);
3460
3618
  const exported = imported.default || imported.module || imported;
@@ -3475,17 +3633,17 @@ var ModuleHost = class {
3475
3633
  }
3476
3634
  }
3477
3635
  installedEntrypoint(modulePath) {
3478
- const packageJson = (0, import_node_path3.join)(modulePath, "package.json");
3636
+ const packageJson = (0, import_node_path5.join)(modulePath, "package.json");
3479
3637
  const candidates = [];
3480
- if ((0, import_node_fs8.existsSync)(packageJson)) {
3638
+ if ((0, import_node_fs10.existsSync)(packageJson)) {
3481
3639
  try {
3482
- const pkg = JSON.parse((0, import_node_fs8.readFileSync)(packageJson, "utf-8"));
3483
- if (pkg.main) candidates.push((0, import_node_path3.join)(modulePath, pkg.main));
3640
+ const pkg = JSON.parse((0, import_node_fs10.readFileSync)(packageJson, "utf-8"));
3641
+ if (pkg.main) candidates.push((0, import_node_path5.join)(modulePath, pkg.main));
3484
3642
  } catch {
3485
3643
  }
3486
3644
  }
3487
- candidates.push((0, import_node_path3.join)(modulePath, "dist", "index.js"), (0, import_node_path3.join)(modulePath, "index.js"));
3488
- return candidates.find((candidate) => (0, import_node_fs8.existsSync)(candidate)) || null;
3645
+ candidates.push((0, import_node_path5.join)(modulePath, "dist", "index.js"), (0, import_node_path5.join)(modulePath, "index.js"));
3646
+ return candidates.find((candidate) => (0, import_node_fs10.existsSync)(candidate)) || null;
3489
3647
  }
3490
3648
  isThreatCrushModule(value) {
3491
3649
  return Boolean(
@@ -3757,14 +3915,14 @@ function slackChannel(webhookUrl) {
3757
3915
  }
3758
3916
 
3759
3917
  // src/core/cli-config.ts
3760
- var import_node_fs9 = require("fs");
3761
- var import_node_path4 = require("path");
3918
+ var import_node_fs11 = require("fs");
3919
+ var import_node_path6 = require("path");
3762
3920
  var import_node_os2 = require("os");
3763
- var CLI_CONFIG_DIR = (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".threatcrush");
3764
- var CLI_CONFIG_PATH = (0, import_node_path4.join)(CLI_CONFIG_DIR, "config.json");
3921
+ var CLI_CONFIG_DIR = (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".threatcrush");
3922
+ var CLI_CONFIG_PATH = (0, import_node_path6.join)(CLI_CONFIG_DIR, "config.json");
3765
3923
  function readCliConfig() {
3766
3924
  try {
3767
- return JSON.parse((0, import_node_fs9.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
3925
+ return JSON.parse((0, import_node_fs11.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
3768
3926
  } catch {
3769
3927
  return {};
3770
3928
  }
@@ -3783,8 +3941,8 @@ function authHeaders() {
3783
3941
  }
3784
3942
 
3785
3943
  // src/commands/scan.ts
3786
- var import_node_fs12 = require("fs");
3787
- var import_node_path8 = require("path");
3944
+ var import_node_fs14 = require("fs");
3945
+ var import_node_path10 = require("path");
3788
3946
 
3789
3947
  // ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
3790
3948
  var ANSI_BACKGROUND_OFFSET = 10;
@@ -4310,6 +4468,357 @@ function workerId() {
4310
4468
  return `${import_node_os4.default.hostname()}/${process.pid}`;
4311
4469
  }
4312
4470
 
4471
+ // ../../packages/scan/src/node-rules.ts
4472
+ var PATH_CONTAINMENT_GUARD = /\bstartsWith\s*\(|\brelative\s*\(|\bnormalize\s*\(|\brealpath\b|\bisInside\b|\bwithin\s*\(|\bsanitiz\w*\b/i;
4473
+ var ARCHIVE_LIBRARY = /\b(?:adm-zip|unzipper|yauzl|node-stream-zip|extract-zip|decompress|tar-stream|tar-fs)\b|require\s*\(\s*['"]tar['"]|from\s+['"]tar['"]/;
4474
+ var HEADLESS_BROWSER = /\b(?:puppeteer|playwright|phantom|phantomjs|wkhtmltopdf|wkhtmltoimage|chrome-aws-lambda|html-pdf)\b/;
4475
+ var ORIGIN_ALLOWLIST_GUARD = /\b(?:includes|indexOf|has|test|find|some)\s*\(/;
4476
+ var FUNCTION_DESERIALIZER = /\b(?:node-serialize|serialize-to-js|funcster|cryo)\b/;
4477
+ var CONCATENATED_STRING = String.raw`(?:"[^"\n]*"|'[^'\n]*')\s*\+`;
4478
+ var INTERPOLATED_TEMPLATE = "`[^`\\n]*\\$\\{";
4479
+ var NODE_RULES = [
4480
+ // ── Code execution ───────────────────────────────────────────────────────
4481
+ {
4482
+ id: "js-vm-untrusted-execution",
4483
+ title: "script compiled and run by the `vm` module",
4484
+ consequence: "Node\u2019s `vm` is not a security boundary \u2014 it isolates globals, not the process. Code reaching it can walk back out through any object it is handed and runs with the server\u2019s full privileges.",
4485
+ cwe: "CWE-94",
4486
+ severity: "critical",
4487
+ languages: ["javascript", "typescript"],
4488
+ pattern: /\bvm\s*\.\s*(?:runInNewContext|runInThisContext|runInContext|compileFunction)\s*\(|\bnew\s+vm\s*\.\s*Script\s*\(/,
4489
+ // A `vm` call whose source is a build-time constant is a plugin loader, not
4490
+ // a vulnerability. The class only becomes real once the script text can be
4491
+ // influenced, so require that evidence before reporting at full severity.
4492
+ needsContext: true
4493
+ },
4494
+ {
4495
+ id: "js-vm2-sandbox",
4496
+ title: "`vm2` used as a sandbox",
4497
+ consequence: "vm2 was discontinued after a series of escapes that its maintainer judged unfixable by design. Any code it runs should be assumed to run on the host.",
4498
+ cwe: "CWE-1104",
4499
+ severity: "high",
4500
+ languages: ["javascript", "typescript"],
4501
+ pattern: /\bnew\s+(?:NodeVM|VMScript)\s*\(|\bfrom\s+['"]vm2['"]|require\s*\(\s*['"]vm2['"]\s*\)/,
4502
+ // Nothing on the surrounding lines changes the answer: the package itself
4503
+ // is the finding, the same way a broken cipher is.
4504
+ inherent: true
4505
+ },
4506
+ {
4507
+ id: "js-function-deserialization",
4508
+ title: "deserialiser that reconstructs functions",
4509
+ consequence: "These formats encode functions alongside data and invoke them on load, so parsing an attacker\u2019s payload is executing it. No amount of validation after the parse call helps \u2014 the code has already run.",
4510
+ cwe: "CWE-502",
4511
+ severity: "critical",
4512
+ languages: ["javascript", "typescript"],
4513
+ pattern: /\b(?:unserialize|deepDeserialize|deserialize)\s*\(/,
4514
+ // Without this the rule fires on every project that happens to own a
4515
+ // function called `deserialize`, which is most of them. The import is what
4516
+ // makes the call the dangerous one.
4517
+ fileRequires: FUNCTION_DESERIALIZER,
4518
+ // The parse *is* the execution, so nearby input cannot make it worse and
4519
+ // its absence cannot make it safe. Nobody round-trips a constant through
4520
+ // these libraries.
4521
+ inherent: true
4522
+ },
4523
+ {
4524
+ id: "js-template-injection",
4525
+ title: "template compiled from a non-constant source",
4526
+ consequence: "Template languages are programming languages. A user-supplied template body is remote code execution, not cross-site scripting \u2014 the expression runs on the server before any output is escaped.",
4527
+ cwe: "CWE-1336",
4528
+ severity: "critical",
4529
+ languages: ["javascript", "typescript"],
4530
+ pattern: /\b(?:handlebars|Handlebars|hbs|ejs|pug|jade|nunjucks|eta|twig|dot|doT|liquid|mustache)\s*\.\s*(?:compile|compileFile|render|renderString)\s*\(\s*(?:`[^`\n]*\$\{|[a-zA-Z_$][\w$.]*\s*[,)]|[a-zA-Z_$][\w$.]*\s*\+)/,
4531
+ // Rendering a template held in a variable is the normal case — it was read
4532
+ // from a file at boot. Only the version where request data reaches the
4533
+ // template *body* is this class.
4534
+ needsContext: true
4535
+ },
4536
+ {
4537
+ id: "js-shelljs-command-execution",
4538
+ title: "shelljs command assembled from a string",
4539
+ consequence: "`shell.exec` runs its argument through a shell, so a `;` or backtick in an interpolated value runs as the server user.",
4540
+ cwe: "CWE-78",
4541
+ severity: "critical",
4542
+ languages: ["javascript", "typescript"],
4543
+ pattern: new RegExp(
4544
+ `\\b(?:shell|shelljs|sh)\\s*\\.\\s*exec\\s*\\(\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING}|[a-zA-Z_$][\\w$]*\\s*\\+)`
4545
+ ),
4546
+ fileRequires: /\bshelljs\b/
4547
+ },
4548
+ // ── Injection ────────────────────────────────────────────────────────────
4549
+ {
4550
+ id: "js-nosql-where-expression",
4551
+ title: "MongoDB `$where` built by string assembly",
4552
+ consequence: "`$where` is evaluated as JavaScript by the database server, once per document. An interpolated value can rewrite the predicate to `true` or run a denial-of-service loop inside the database.",
4553
+ cwe: "CWE-943",
4554
+ severity: "critical",
4555
+ languages: ["javascript", "typescript"],
4556
+ pattern: new RegExp(
4557
+ `\\$where\\s*['"]?\\s*:\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING})|\\.\\s*\\$where\\s*=\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING})`
4558
+ )
4559
+ },
4560
+ {
4561
+ id: "js-xpath-injection",
4562
+ title: "XPath expression built by concatenation",
4563
+ consequence: "A quote in the interpolated value closes the predicate early, so the query selects nodes the caller was never meant to read \u2014 the XML equivalent of `OR 1=1`.",
4564
+ cwe: "CWE-643",
4565
+ severity: "high",
4566
+ languages: ["javascript", "typescript"],
4567
+ // An XPath expression is recognisable by its own syntax — a descendant
4568
+ // axis or an attribute predicate. Matching on the *call* name alone would
4569
+ // flag every `select(` in the ecosystem. The quoted forms are split per
4570
+ // quote character for the reason given at CONCATENATED_STRING: the
4571
+ // predicate that makes it XPath usually contains the other quote.
4572
+ pattern: new RegExp(
4573
+ `\\b(?:xpath|xpathSelect|select|selectNodes|selectSingleNode|evaluate|find)\\s*\\(\\s*(?:\`[^\`\\n]*(?:\\/\\/|\\[@)[^\`\\n]*\\$\\{|"[^"\\n]*(?:\\/\\/|\\[@)[^"\\n]*"\\s*\\+|'[^'\\n]*(?:\\/\\/|\\[@)[^'\\n]*'\\s*\\+)`
4574
+ ),
4575
+ fileRequires: /\bxpath\b|\bxmldom\b|\blibxmljs\b|\bxpath\.js\b/
4576
+ },
4577
+ {
4578
+ id: "js-regex-from-input",
4579
+ title: "regular expression compiled from a variable",
4580
+ consequence: "A caller who controls the pattern controls the matcher: they can supply catastrophic backtracking to hang the event loop, or a permissive pattern that defeats whatever the regex was validating.",
4581
+ cwe: "CWE-1333",
4582
+ severity: "medium",
4583
+ languages: ["javascript", "typescript"],
4584
+ pattern: new RegExp(
4585
+ `\\bnew\\s+RegExp\\s*\\(\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING}|(?!['"\`/])[a-zA-Z_$][\\w$.]*\\s*[,)])`
4586
+ ),
4587
+ needsContext: true
4588
+ },
4589
+ // ── XML ──────────────────────────────────────────────────────────────────
4590
+ {
4591
+ id: "js-xml-external-entities",
4592
+ title: "XML parser configured to resolve entities",
4593
+ consequence: "An entity declaration in the document body makes the parser fetch a local file or an internal URL and paste the result into the parsed output \u2014 file disclosure and server-side request forgery from a document upload.",
4594
+ cwe: "CWE-611",
4595
+ severity: "high",
4596
+ languages: ["javascript", "typescript"],
4597
+ // The option name is the finding. Every parser in the ecosystem defaults
4598
+ // these off, so an explicit `true` is a deliberate re-enable.
4599
+ pattern: /\b(?:noent|resolveEntities|expandEntities|externalEntities|resolveExternals)\s*:\s*(?:true|1)\b/,
4600
+ inherent: true
4601
+ },
4602
+ // ── Authentication and tokens ────────────────────────────────────────────
4603
+ {
4604
+ id: "js-jwt-none-algorithm",
4605
+ title: "JWT algorithm set to `none`",
4606
+ consequence: "The `none` algorithm means the signature is not checked. Anyone can mint a token with any claims \u2014 including another user\u2019s id or an admin role \u2014 by base64-encoding a header and a body.",
4607
+ cwe: "CWE-347",
4608
+ severity: "critical",
4609
+ languages: ["javascript", "typescript"],
4610
+ pattern: /\balgorithms?\s*:\s*\[?\s*['"]none['"]/i,
4611
+ inherent: true
4612
+ },
4613
+ // ── Cryptography ─────────────────────────────────────────────────────────
4614
+ {
4615
+ id: "js-broken-cipher-algorithm",
4616
+ title: "broken cipher selected",
4617
+ consequence: "DES, 3DES, RC2, RC4, Blowfish and IDEA are all breakable with commodity hardware or have practical plaintext-recovery attacks. Data encrypted with them should be treated as encoded, not encrypted.",
4618
+ cwe: "CWE-327",
4619
+ severity: "high",
4620
+ languages: ["javascript", "typescript"],
4621
+ pattern: /\bcreate(?:Cipher|Decipher)(?:iv)?\s*\(\s*['"](?:des|des3|des-ede\w*|3des|rc2|rc4|bf|blowfish|cast5?|idea|seed)\b/i,
4622
+ inherent: true
4623
+ },
4624
+ {
4625
+ id: "js-ecb-mode-cipher",
4626
+ title: "block cipher in ECB mode",
4627
+ consequence: "ECB encrypts every block independently, so identical plaintext blocks produce identical ciphertext. Structure in the data survives encryption and blocks can be reordered or replayed without detection.",
4628
+ cwe: "CWE-327",
4629
+ severity: "high",
4630
+ languages: ["javascript", "typescript"],
4631
+ pattern: /\bcreate(?:Cipher|Decipher)(?:iv)?\s*\(\s*['"][^'"\n]*-ecb\b/i,
4632
+ inherent: true
4633
+ },
4634
+ {
4635
+ id: "js-legacy-cipher-api",
4636
+ title: "deprecated `createCipher` used",
4637
+ consequence: "`createCipher` derives the key from a passphrase with a single unsalted MD5 pass and uses a fixed all-zero IV, so the same passphrase always produces the same keystream. It was removed in Node 22.",
4638
+ cwe: "CWE-327",
4639
+ severity: "high",
4640
+ languages: ["javascript", "typescript"],
4641
+ // `createCipheriv` is the correct API and shares the prefix, so the
4642
+ // negative look-ahead is what separates the finding from the fix.
4643
+ pattern: /\bcrypto\s*\.\s*create(?:Cipher|Decipher)\s*\(/,
4644
+ lineGuard: /create(?:Cipher|Decipher)iv\s*\(/,
4645
+ inherent: true
4646
+ },
4647
+ // ── Cross-site scripting ─────────────────────────────────────────────────
4648
+ {
4649
+ id: "js-template-autoescape-disabled",
4650
+ title: "template auto-escaping turned off",
4651
+ consequence: "Auto-escaping is the control that makes a template engine safe by default. Disabling it globally means every interpolation in every template becomes an injection point, including ones written later by someone who assumed the default.",
4652
+ cwe: "CWE-79",
4653
+ severity: "high",
4654
+ languages: ["javascript", "typescript"],
4655
+ pattern: /\bautoescape\s*:\s*false\b|\bescape\s*:\s*false\b|\bnoEscape\s*:\s*true\b/,
4656
+ inherent: true
4657
+ },
4658
+ {
4659
+ id: "js-serialize-javascript-unsafe",
4660
+ title: "`serialize-javascript` in unsafe mode",
4661
+ consequence: "The `unsafe` flag turns off escaping of HTML-significant characters in the output. Embedding the result in a `<script>` block lets a string value close the tag and start a new one.",
4662
+ cwe: "CWE-79",
4663
+ severity: "high",
4664
+ languages: ["javascript", "typescript"],
4665
+ pattern: /\bunsafe\s*:\s*true\b/,
4666
+ fileRequires: /\bserialize-javascript\b/,
4667
+ inherent: true
4668
+ },
4669
+ {
4670
+ id: "js-cors-origin-reflected",
4671
+ title: "CORS origin reflected from the request",
4672
+ consequence: "Echoing the caller\u2019s `Origin` header back as `Access-Control-Allow-Origin` allows every site, while looking like an allow-list. Combined with credentials, any page the victim visits can read their authenticated responses.",
4673
+ cwe: "CWE-942",
4674
+ severity: "high",
4675
+ languages: ["javascript", "typescript"],
4676
+ // Both spellings of the same mistake: writing the header directly, and
4677
+ // handing the request's origin to a CORS middleware's `origin` option.
4678
+ //
4679
+ // An earlier draft also matched a bare variable — `setHeader('…', origin)`
4680
+ // — which is wrong. A variable holding a *validated* origin is exactly
4681
+ // what the correct implementation looks like, and the rule cannot tell the
4682
+ // two apart. Only the visible read from the request qualifies.
4683
+ pattern: /\bAccess-Control-Allow-Origin['"]\s*,\s*(?:req|request|ctx)\s*\.|\borigin\s*:\s*(?:req|request|ctx)\s*\./,
4684
+ // Reflecting the header is the defect whatever surrounds it; there is no
4685
+ // arrangement of nearby lines that makes an echoed origin safe — except a
4686
+ // membership test on the value, which is what the guard looks for.
4687
+ inherent: true,
4688
+ guard: ORIGIN_ALLOWLIST_GUARD,
4689
+ /**
4690
+ * Logging the origin is not reflecting it.
4691
+ *
4692
+ * `origin: request.headers.origin` is the CORS mistake *and* the ordinary
4693
+ * way to record who called — the two are character-for-character
4694
+ * identical, and only what encloses them differs. A response header goes
4695
+ * out to the browser; a log line goes to stdout, where it grants nobody
4696
+ * anything.
4697
+ */
4698
+ enclosingCallGuard: /(?:^|\.)(?:log|debug|info|warn|error|trace|verbose|fatal)$/
4699
+ },
4700
+ // ── Server-side request forgery ──────────────────────────────────────────
4701
+ {
4702
+ id: "js-headless-browser-navigation",
4703
+ title: "headless browser sent to a non-constant URL",
4704
+ consequence: "The browser runs on the server, inside the private network. A controlled URL reaches the cloud metadata endpoint, localhost admin panels and internal services \u2014 and `file://` reads the disk.",
4705
+ cwe: "CWE-918",
4706
+ severity: "high",
4707
+ languages: ["javascript", "typescript"],
4708
+ pattern: /\.\s*(?:goto|setContent|navigate)\s*\(\s*(?:`[^`\n]*\$\{|(?!['"`])[a-zA-Z_$][\w$.]*\s*[,)])/,
4709
+ fileRequires: HEADLESS_BROWSER,
4710
+ needsContext: true
4711
+ },
4712
+ // ── Path handling ────────────────────────────────────────────────────────
4713
+ {
4714
+ id: "js-archive-entry-path",
4715
+ title: "archive entry written to a path built from its own name",
4716
+ consequence: "An entry named `../../etc/cron.d/x` escapes the extraction directory when its name is joined to the destination. Overwriting a file outside the target \u2014 a systemd unit, an SSH key, a deployed script \u2014 is code execution on the next run.",
4717
+ cwe: "CWE-22",
4718
+ severity: "high",
4719
+ languages: ["javascript", "typescript"],
4720
+ pattern: /\b(?:join|resolve)\s*\(\s*[^,\n)]+,\s*[a-zA-Z_$][\w$]*\s*\.\s*(?:entryName|fileName|filename|name|path)\b/,
4721
+ fileRequires: ARCHIVE_LIBRARY,
4722
+ guard: PATH_CONTAINMENT_GUARD,
4723
+ // The containment check comes *after* the join — you build the path, then
4724
+ // verify it stayed inside. A backwards-only window would miss every
4725
+ // correct implementation and report the safe code alongside the unsafe.
4726
+ guardForward: 3
4727
+ },
4728
+ // ── Electron ─────────────────────────────────────────────────────────────
4729
+ {
4730
+ id: "js-electron-node-integration",
4731
+ title: "Electron renderer given Node access",
4732
+ consequence: "With node integration on \u2014 or context isolation off \u2014 any script that reaches the page reaches `require`. A single XSS in rendered content becomes `child_process.exec` on the user\u2019s machine.",
4733
+ cwe: "CWE-1188",
4734
+ severity: "high",
4735
+ languages: ["javascript", "typescript"],
4736
+ pattern: /\bnodeIntegration(?:InWorker|InSubFrames)?\s*:\s*true\b|\bcontextIsolation\s*:\s*false\b|\benableRemoteModule\s*:\s*true\b|\bsandbox\s*:\s*false\b/,
4737
+ inherent: true
4738
+ },
4739
+ {
4740
+ id: "js-electron-web-security-disabled",
4741
+ title: "Electron web security disabled",
4742
+ consequence: "Turning off `webSecurity` drops the same-origin policy for the window, so remote content can read local files and every other origin the app has loaded.",
4743
+ cwe: "CWE-1173",
4744
+ severity: "high",
4745
+ languages: ["javascript", "typescript"],
4746
+ pattern: /\bwebSecurity\s*:\s*false\b|\ballowRunningInsecureContent\s*:\s*true\b|\bwebviewTag\s*:\s*true\b/,
4747
+ inherent: true
4748
+ },
4749
+ {
4750
+ id: "js-electron-open-external",
4751
+ title: "Electron `openExternal` with a non-constant URL",
4752
+ consequence: "`shell.openExternal` hands the string to the operating system\u2019s handler. A `file://` path executes a local binary and, on Windows, an SMB path executes a remote one.",
4753
+ cwe: "CWE-749",
4754
+ severity: "high",
4755
+ languages: ["javascript", "typescript"],
4756
+ pattern: /\bshell\s*\.\s*openExternal\s*\(\s*(?:`[^`\n]*\$\{|(?!['"`])[a-zA-Z_$][\w$.]*\s*[,)])/,
4757
+ needsContext: true
4758
+ },
4759
+ // ── Hardening and resource limits ────────────────────────────────────────
4760
+ {
4761
+ id: "js-helmet-protection-disabled",
4762
+ title: "security header explicitly disabled",
4763
+ consequence: "Each of these switches off a browser-side protection that was already on. The header stops being sent, so the defence it enables \u2014 framing, sniffing, referrer leakage, transport downgrade \u2014 is available to an attacker again.",
4764
+ cwe: "CWE-693",
4765
+ severity: "medium",
4766
+ languages: ["javascript", "typescript"],
4767
+ pattern: /\b(?:contentSecurityPolicy|frameguard|hsts|noSniff|xssFilter|hidePoweredBy|referrerPolicy|dnsPrefetchControl|ieNoOpen|permittedCrossDomainPolicies|crossOriginEmbedderPolicy|crossOriginOpenerPolicy|crossOriginResourcePolicy|originAgentCluster)\s*:\s*false\b/,
4768
+ inherent: true
4769
+ },
4770
+ {
4771
+ id: "js-buffer-bounds-check-disabled",
4772
+ title: "buffer bounds checking turned off",
4773
+ consequence: "With `noAssert` the read or write is not range-checked, so an offset past the end of the buffer returns adjacent heap memory or corrupts it instead of throwing.",
4774
+ cwe: "CWE-125",
4775
+ severity: "medium",
4776
+ languages: ["javascript", "typescript"],
4777
+ // The trailing `true` on a Buffer numeric accessor *is* `noAssert` — it is
4778
+ // the last positional parameter of every one of these methods. Anchoring
4779
+ // on the accessor name is what keeps this from matching `, true)` on any
4780
+ // call in the codebase.
4781
+ pattern: /\b(?:read|write)(?:U?Int(?:8|16|32)(?:[BL]E)?|U?Int[BL]E|Float[BL]E|Double[BL]E)\s*\([^)\n]*,\s*true\s*\)|\bnoAssert\s*:\s*true\b/,
4782
+ inherent: true
4783
+ },
4784
+ {
4785
+ id: "js-uninitialized-buffer",
4786
+ title: "buffer allocated without zeroing",
4787
+ consequence: "`allocUnsafe` and the old `new Buffer(size)` hand back whatever was previously in that heap memory \u2014 keys, session tokens, other users\u2019 request bodies. Anything not overwritten before the buffer is sent leaks it.",
4788
+ cwe: "CWE-908",
4789
+ severity: "medium",
4790
+ languages: ["javascript", "typescript"],
4791
+ pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
4792
+ inherent: true
4793
+ },
4794
+ {
4795
+ id: "js-oversized-request-body-limit",
4796
+ title: "request body limit raised to a very large value",
4797
+ consequence: "A body limit in the tens of megabytes lets a handful of concurrent requests exhaust memory, and the parse happens before any authentication check the route performs.",
4798
+ cwe: "CWE-400",
4799
+ severity: "medium",
4800
+ languages: ["javascript", "typescript"],
4801
+ // Two or more digits before `mb` — 50mb and up. A limit is a *good* thing;
4802
+ // only an ineffective one is the finding.
4803
+ pattern: /\blimit\s*:\s*['"]\s*(?:[5-9]\d|\d{3,})\s*mb\s*['"]|\blimit\s*:\s*['"]\s*\d+\s*gb\s*['"]/i,
4804
+ inherent: true
4805
+ },
4806
+ // ── Information disclosure ───────────────────────────────────────────────
4807
+ {
4808
+ id: "js-error-detail-returned",
4809
+ title: "error object or stack trace sent to the client",
4810
+ consequence: "A stack trace names absolute paths, package versions and internal function names, and framework errors often carry the failing query or connection string with them. It is a map of the server, handed out on request.",
4811
+ cwe: "CWE-209",
4812
+ severity: "medium",
4813
+ languages: ["javascript", "typescript"],
4814
+ // `res.status(500).send(…)` is the overwhelmingly common spelling, so the
4815
+ // optional `.status(…)` hop is not a nicety — without it the rule misses
4816
+ // nearly every real occurrence.
4817
+ pattern: /\bres\s*\.\s*(?:status\s*\([^)\n]*\)\s*\.\s*)?(?:send|json|end|write)\s*\(\s*(?:[a-zA-Z_$][\w$]*\s*\.\s*stack\b|(?:error|err|e)\s*[,)])|\.\s*(?:send|json)\s*\(\s*\{[^}\n]*\b(?:stack|err|error)\s*:\s*(?:error|err|e)\s*[,}]/,
4818
+ inherent: true
4819
+ }
4820
+ ];
4821
+
4313
4822
  // ../../packages/scan/src/types.ts
4314
4823
  var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
4315
4824
  function severityRank(severity) {
@@ -4356,7 +4865,20 @@ var GENERIC_GUARD = (
4356
4865
  // The identifier must END at the escaper (with at most a known output-context
4357
4866
  // suffix). An earlier, looser form also matched `describe(`, which would have
4358
4867
  // silenced findings across every test file in every repository.
4359
- /\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\b\w{0,6}[Ee]sc(?:ape)?(?:[Hh]tml|HTML|[Xx]ml|XML|[Ss]ql|[Aa]ttr|[Jj]s|[Uu]ri|[Uu]rl)?\s*\(|\bhtml_escape\b|\bhtmlspecialchars\s*\(|\bsanitiz\w*\b|\bencoded\b|\brealpath\b|\bcommonpath\b|\bresolve\(\)\.startsWith\b|\bprocess\.env\b|\bos\.environ\b|\bgetenv\b|\bENV\s*\[|setObjectInputFilter|ObjectInputFilter/i
4868
+ // The `Access-Control-` lookbehind is not a nicety. This regex is
4869
+ // case-insensitive, and `Access-Control-Allow-Origin` contains the word
4870
+ // "Allow" followed by a non-word character — so the CORS header name matched
4871
+ // the allow-list heuristic. Because the guard is tested against an 8-line
4872
+ // *window*, one header line silently disabled every guardable rule near it:
4873
+ // in a four-line Express error handler, the header on one line suppressed
4874
+ // both the `none`-algorithm JWT finding and the returned stack trace below
4875
+ // it. The failure mode is the dangerous kind — not fewer findings, none, and
4876
+ // indistinguishable from clean code.
4877
+ //
4878
+ // Scoped to the header prefix rather than to a bare `allow(?!-)`, because
4879
+ // this guard also runs over `.yml` and `.conf` files, where `allow-list:`
4880
+ // and `allowed-hosts:` are ordinary keys that should still guard.
4881
+ /(?<!Access-Control-)\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\b\w{0,6}[Ee]sc(?:ape)?(?:[Hh]tml|HTML|[Xx]ml|XML|[Ss]ql|[Aa]ttr|[Jj]s|[Uu]ri|[Uu]rl)?\s*\(|\bhtml_escape\b|\bhtmlspecialchars\s*\(|\bsanitiz\w*\b|\bencoded\b|\brealpath\b|\bcommonpath\b|\bresolve\(\)\.startsWith\b|\bprocess\.env\b|\bos\.environ\b|\bgetenv\b|\bENV\s*\[|setObjectInputFilter|ObjectInputFilter/i
4360
4882
  );
4361
4883
  var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
4362
4884
  var XML_PARSING_FILE = /\b(?:javax\.xml|org\.xml\.sax|org\.w3c\.dom|org\.jdom2?|org\.dom4j|XmlPullParser|DocumentBuilderFactory|DocumentBuilder|SAXParserFactory|SAXParser|XMLInputFactory|XMLReaderFactory|XMLReader|SAXBuilder|SAXReader)\b/;
@@ -4422,7 +4944,8 @@ var CODE_RULES = [
4422
4944
  cwe: "CWE-78",
4423
4945
  severity: "critical",
4424
4946
  languages: ["javascript", "typescript"],
4425
- pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/
4947
+ pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/,
4948
+ constantInterpolationGuard: true
4426
4949
  },
4427
4950
  {
4428
4951
  id: "py-shell-command-string",
@@ -4784,6 +5307,20 @@ var CODE_RULES = [
4784
5307
  consequence: "Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.",
4785
5308
  cwe: "CWE-1333",
4786
5309
  severity: "medium",
5310
+ // Scoped, because unscoped this rule reads C as if it were a regex.
5311
+ //
5312
+ // `(void *)*memptr64` — a cast to a pointer type, then a dereference — is
5313
+ // the single most ordinary line in a C file, and it matches the first
5314
+ // alternative exactly: `(`, some text, `*`, `)`, `*`. Every `*(char *)*p`
5315
+ // in a codebase became a ReDoS finding. Caught on inspektor-gadget, where
5316
+ // the rule's one and only hit across 1186 files was a `bpf_probe_read_user`
5317
+ // call in an eBPF C program.
5318
+ //
5319
+ // Scoping is the fix rather than a cleverer pattern: C has no regex
5320
+ // literals, so there is nothing here for the rule to find no matter how
5321
+ // the pattern is written. `other` also covers C++, Rust and Zig, which
5322
+ // share the cast-then-deref spelling.
5323
+ languages: ["javascript", "typescript", "python", "ruby", "go", "java", "php"],
4787
5324
  pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
4788
5325
  },
4789
5326
  // ── Temporary files ──────────────────────────────────────────────────────
@@ -5307,8 +5844,27 @@ var CODE_RULES = [
5307
5844
  cwe: "CWE-346",
5308
5845
  severity: "high",
5309
5846
  languages: ["javascript", "typescript"],
5310
- pattern: /\$\{[^}\n]*\breq(?:uest)?\s*\.\s*(?:headers\s*\.\s*host|hostname|host)\b|['"`]\s*\+\s*req(?:uest)?\s*\.\s*(?:headers\s*\.\s*host|hostname)\b/
5311
- }
5847
+ pattern: /\$\{[^}\n]*\breq(?:uest)?\s*\.\s*(?:headers\s*\.\s*host|hostname|host)\b|['"`]\s*\+\s*req(?:uest)?\s*\.\s*(?:headers\s*\.\s*host|hostname)\b/,
5848
+ /**
5849
+ * Parsing the request's own URL is not building a link from the Host
5850
+ * header, even though it is spelled with one.
5851
+ *
5852
+ * const url = new URL(request.url, `http://${request.headers.host}`);
5853
+ * const token = url.searchParams.get('token');
5854
+ *
5855
+ * `request.url` on a Node server is a path — `/ws?token=…` — and `new URL`
5856
+ * refuses a relative input without a base. The base exists to satisfy the
5857
+ * parser and is thrown away; only the path and query are ever read. Every
5858
+ * Node HTTP handler that wants a query parameter is written this way, so
5859
+ * the rule fired on the framework idiom rather than on the defect.
5860
+ *
5861
+ * Narrow on purpose: the first argument must be `req.url` itself. The
5862
+ * dangerous shape passes a *path* the application chose —
5863
+ * `new URL('/reset?t=…', `https://${req.headers.host}`)` — and that is what
5864
+ * produces an attacker-controlled link. It does not match this guard.
5865
+ */
5866
+ lineGuard: /\bnew\s+URL\s*\(\s*(?:req|request|ctx)(?:uest)?\s*\.\s*url\b\s*,/
5867
+ },
5312
5868
  // A recursive-merge prototype-pollution rule (`target[key] = source[key]`
5313
5869
  // with no `__proto__` guard) was built and dropped. The bare copy-by-key is
5314
5870
  // the safe allow-listed shape (`updates[field] = body[field]` over an
@@ -5318,6 +5874,11 @@ var CODE_RULES = [
5318
5874
  // answer. It flagged legitimate merges in Capacitor and in this repo's own
5319
5875
  // web app. `js-prototype-pollution` still catches the explicit `__proto__`
5320
5876
  // literal; the recursive-merge case is left to KNOWN_GAPS.
5877
+ // Node-ecosystem classes — vm2, Electron, JWT, archive extraction, headless
5878
+ // browsers — are kept in their own table because each has to know which
5879
+ // package it is looking at before it can claim anything. Folded in here so
5880
+ // there stays exactly one rule list for every consumer to iterate.
5881
+ ...NODE_RULES
5321
5882
  ];
5322
5883
  var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
5323
5884
  var DEFINITION_PREFIX = /^\s*(?:(?:export|public|private|protected|static|final|async|abstract)\s+)*(?:def|function|func|class|module|interface|struct|impl|fn|sub)\b/;
@@ -5370,6 +5931,55 @@ function fileTextOf(lines) {
5370
5931
  function withoutSingleQuoted(text) {
5371
5932
  return text.replace(/'[^'\n]*'/g, "''");
5372
5933
  }
5934
+ function calleeEndingAt(text, open) {
5935
+ let end = open;
5936
+ while (end > 0 && (text[end - 1] === " " || text[end - 1] === " ")) end -= 1;
5937
+ let start = end;
5938
+ while (start > 0 && /[\w$.]/.test(text[start - 1])) start -= 1;
5939
+ return text.slice(start, end);
5940
+ }
5941
+ function enclosingCallees(lines, index, back) {
5942
+ const before = lines.slice(Math.max(0, index - back), index).join("\n");
5943
+ const stack = [];
5944
+ let quote = null;
5945
+ for (let i = 0; i < before.length; i += 1) {
5946
+ const ch = before[i];
5947
+ if (quote) {
5948
+ if (ch === "\\") i += 1;
5949
+ else if (ch === quote) quote = null;
5950
+ continue;
5951
+ }
5952
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
5953
+ else if (ch === "(") stack.push(calleeEndingAt(before, i));
5954
+ else if (ch === ")") stack.pop();
5955
+ }
5956
+ return stack;
5957
+ }
5958
+ function interpolations(line) {
5959
+ const found = [];
5960
+ for (let i = 0; ; ) {
5961
+ const start = line.indexOf("${", i);
5962
+ if (start === -1) break;
5963
+ const end = line.indexOf("}", start + 2);
5964
+ if (end === -1) return null;
5965
+ found.push(line.slice(start + 2, end).trim());
5966
+ i = end + 1;
5967
+ }
5968
+ return found.length > 0 ? found : null;
5969
+ }
5970
+ function isConstantString(name, fileText) {
5971
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5972
+ return new RegExp(
5973
+ `\\bconst\\s+${escaped}\\s*(?::[^=\\n]+)?=\\s*(?:'[^'\\n]*'|"[^"\\n]*"|\`[^\`$\\n]*\`)`
5974
+ ).test(fileText);
5975
+ }
5976
+ function interpolationsAreConstant(line, fileText) {
5977
+ const found = interpolations(line);
5978
+ if (!found) return false;
5979
+ return found.every(
5980
+ (expr) => /^[A-Za-z_$][\w$]*$/.test(expr) && isConstantString(expr, fileText)
5981
+ );
5982
+ }
5373
5983
  function evaluateRule(rule, ctx) {
5374
5984
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
5375
5985
  const line = ctx.lines[ctx.index] ?? "";
@@ -5381,6 +5991,13 @@ function evaluateRule(rule, ctx) {
5381
5991
  const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
5382
5992
  if (rule.requires && !rule.requires.test(context)) return null;
5383
5993
  if (rule.lineGuard?.test(line)) return null;
5994
+ if (rule.enclosingCallGuard) {
5995
+ const callees = enclosingCallees(ctx.lines, ctx.index, back);
5996
+ if (callees.some((callee) => rule.enclosingCallGuard.test(callee))) return null;
5997
+ }
5998
+ if (rule.constantInterpolationGuard && interpolationsAreConstant(line, fileTextOf(ctx.lines))) {
5999
+ return null;
6000
+ }
5384
6001
  const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
5385
6002
  if (guard && (guard.test(line) || guard.test(context))) return null;
5386
6003
  const untrusted = untrustedPatternFor(ctx.language);
@@ -5392,6 +6009,188 @@ function evaluateRule(rule, ctx) {
5392
6009
  return { rule, confidence, severity: severityFor(rule.severity, confidence) };
5393
6010
  }
5394
6011
 
6012
+ // ../../packages/scan/src/template-rules.ts
6013
+ var LAYOUT_SLOT = /\{\{\{\s*(?:body|content|outlet|children)\s*\}\}\}/;
6014
+ var XSS_CONSEQUENCE = "The value is written into the page as markup rather than as text, so a `<script>` or an `onerror=` attribute in it executes in the visitor\u2019s session \u2014 with their cookies, and their privileges.";
6015
+ var TEMPLATE_RULES = [
6016
+ {
6017
+ id: "tpl-handlebars-unescaped",
6018
+ title: "Handlebars/Mustache interpolation that skips escaping",
6019
+ consequence: XSS_CONSEQUENCE,
6020
+ cwe: "CWE-79",
6021
+ severity: "high",
6022
+ extensions: [".hbs", ".handlebars", ".hdbs", ".mustache", ".ms"],
6023
+ // Two spellings of the same opt-out: the triple-stache and the `&` prefix.
6024
+ pattern: /\{\{\{(?!\{)[^}]*\}\}\}|\{\{\s*&\s*[^}]+\}\}/,
6025
+ lineGuard: LAYOUT_SLOT
6026
+ },
6027
+ {
6028
+ id: "tpl-vue-v-html",
6029
+ title: "`v-html` binding",
6030
+ consequence: XSS_CONSEQUENCE,
6031
+ cwe: "CWE-79",
6032
+ severity: "high",
6033
+ extensions: [".vue", ".html", ".htm"],
6034
+ pattern: /\bv-html\s*=|\s:inner-html\.prop\s*=/
6035
+ },
6036
+ {
6037
+ id: "tpl-pug-unescaped",
6038
+ title: "Pug/Jade unescaped interpolation",
6039
+ consequence: XSS_CONSEQUENCE,
6040
+ cwe: "CWE-79",
6041
+ severity: "high",
6042
+ extensions: [".pug", ".jade"],
6043
+ // `!{…}` is unescaped interpolation anywhere on the line. `!=` is
6044
+ // unescaped buffered code, but only in the tag position — anchored to the
6045
+ // start of the line and preceded by nothing but tag, class and id
6046
+ // characters, so the `!==` inside `- if (a !== b)` cannot reach it.
6047
+ pattern: /!\{[^}\n]*\}|^\s*[\w.#%-]*!=(?!=)\s*\S/
6048
+ },
6049
+ {
6050
+ id: "tpl-ejs-raw-output",
6051
+ title: "EJS/ECT raw output tag",
6052
+ consequence: XSS_CONSEQUENCE,
6053
+ cwe: "CWE-79",
6054
+ severity: "high",
6055
+ extensions: [".ejs", ".ect"],
6056
+ // In EJS `<%= %>` escapes and `<%-` does not, which is the reverse of
6057
+ // Underscore's convention — see KNOWN_GAPS.
6058
+ pattern: /<%-(?!\s*(?:include|-))/,
6059
+ // `<%- include('partial') %>` splices another template, not user data.
6060
+ lineGuard: /<%-\s*(?:include|partial)\s*\(/
6061
+ },
6062
+ {
6063
+ id: "tpl-dust-escape-filter-off",
6064
+ title: "Dust reference with escaping suppressed",
6065
+ consequence: XSS_CONSEQUENCE,
6066
+ cwe: "CWE-79",
6067
+ severity: "high",
6068
+ extensions: [".dust", ".tl"],
6069
+ // The `|s` filter means "suppress the default HTML escape".
6070
+ pattern: /\{[^{}\n]+\|\s*s\s*\}/
6071
+ },
6072
+ {
6073
+ id: "tpl-jinja-safe-filter",
6074
+ title: "Nunjucks/Twig/Jinja `safe` filter or autoescape block off",
6075
+ consequence: XSS_CONSEQUENCE,
6076
+ cwe: "CWE-79",
6077
+ severity: "high",
6078
+ extensions: [".njk", ".nunjucks", ".twig", ".jinja", ".jinja2", ".j2"],
6079
+ pattern: /\|\s*(?:safe|raw)\s*(?:\}\}|\|)|\{%\s*autoescape\s+(?:false|off)\s*%\}/
6080
+ },
6081
+ {
6082
+ id: "tpl-haml-unescaped",
6083
+ title: "Haml unescaped output",
6084
+ consequence: XSS_CONSEQUENCE,
6085
+ cwe: "CWE-79",
6086
+ severity: "high",
6087
+ extensions: [".haml"],
6088
+ pattern: /^\s*[\w.#%-]*!=(?!=)\s*\S/
6089
+ }
6090
+ ];
6091
+ var TEMPLATE_EXTENSIONS = new Set(
6092
+ TEMPLATE_RULES.flatMap((rule) => rule.extensions)
6093
+ );
6094
+ function evaluateTemplateRules(extension, lines) {
6095
+ const ext = extension.toLowerCase();
6096
+ const applicable = TEMPLATE_RULES.filter((rule) => rule.extensions.includes(ext));
6097
+ if (applicable.length === 0) return [];
6098
+ const matches = [];
6099
+ lines.forEach((line, index) => {
6100
+ for (const rule of applicable) {
6101
+ if (!rule.pattern.test(line)) continue;
6102
+ if (rule.lineGuard?.test(line)) continue;
6103
+ matches.push({
6104
+ rule,
6105
+ // A template cannot show that the value is untrusted, so the claim
6106
+ // never rises above `pattern` and the shared cap holds it at medium.
6107
+ severity: severityFor(rule.severity, "pattern"),
6108
+ line: index + 1
6109
+ });
6110
+ }
6111
+ });
6112
+ return matches;
6113
+ }
6114
+
6115
+ // ../../packages/scan/src/controls.ts
6116
+ var SERVER_FRAMEWORK = /\bexpress\s*\(\s*\)|\bnew\s+Koa\s*\(|\bfastify\s*\(|\brequire\s*\(\s*['"](?:express|koa|@hapi\/hapi|restify)['"]\s*\)|\bfrom\s+['"](?:express|koa|@hapi\/hapi|restify)['"]|\bhttp\s*\.\s*createServer\s*\(|\bNestFactory\s*\.\s*create\s*\(/;
6117
+ var SECURITY_CONTROLS = [
6118
+ {
6119
+ id: "control-security-headers-absent",
6120
+ title: "no security header middleware",
6121
+ consequence: "Without them the browser applies no framing protection, sniffs content types, sends full referrers cross-origin and never learns to require HTTPS \u2014 a set of defences that cost one line to enable.",
6122
+ cwe: "CWE-693",
6123
+ severity: "medium",
6124
+ evidence: /\bhelmet\b|\bkoa-helmet\b|\b@fastify\/helmet\b|\blusca\b|Strict-Transport-Security|Content-Security-Policy|X-Frame-Options|X-Content-Type-Options/i
6125
+ },
6126
+ {
6127
+ id: "control-anti-csrf-absent",
6128
+ title: "no cross-site request forgery protection",
6129
+ consequence: "A cookie-authenticated endpoint with no token check can be driven by a form on any other site \u2014 the browser attaches the session automatically, so the victim only has to visit a page.",
6130
+ cwe: "CWE-352",
6131
+ severity: "medium",
6132
+ // SameSite counts. A cookie the browser refuses to send cross-site is not
6133
+ // reachable by the attack this control exists to stop, so a project that
6134
+ // chose that route instead of tokens has the control, not a gap.
6135
+ evidence: /\bcsurf\b|\bcsrf\b|\bxsrf\b|\blusca\b|\b@fastify\/csrf\b|\bdouble-csrf\b|\bsameSite\s*:\s*['"](?:strict|lax)['"]/i
6136
+ },
6137
+ {
6138
+ id: "control-rate-limiting-absent",
6139
+ title: "no request rate limiting",
6140
+ consequence: "Login and password-reset endpoints with no limiter can be tried at the speed of the network \u2014 credential stuffing, token brute force and enumeration all become a matter of waiting.",
6141
+ cwe: "CWE-770",
6142
+ severity: "medium",
6143
+ evidence: /\bexpress-rate-limit\b|\brateLimit\b|\brate-limiter\b|\bratelimit\b|\bexpress-slow-down\b|\bslowDown\b|\bbottleneck\b|\bthrottle\b/i
6144
+ },
6145
+ {
6146
+ id: "control-body-size-limit-absent",
6147
+ title: "no request body size limit",
6148
+ consequence: "The body is parsed into memory before any handler \u2014 and before any authentication check \u2014 so unbounded parsing lets a few concurrent requests exhaust the process.",
6149
+ cwe: "CWE-400",
6150
+ severity: "medium",
6151
+ evidence: /\blimit\s*:\s*['"]?\d+\s*(?:kb|mb|b)\b|\bbodyLimit\b|\bmaxRequestBodySize\b|\bclient_max_body_size\b/i
6152
+ }
6153
+ ];
6154
+ var ControlAudit = class {
6155
+ seen = /* @__PURE__ */ new Set();
6156
+ /** Where the server is built. Anchors the findings somewhere meaningful. */
6157
+ serverFile = null;
6158
+ observe(relativePath, text) {
6159
+ if (this.serverFile === null && SERVER_FRAMEWORK.test(text)) {
6160
+ this.serverFile = relativePath;
6161
+ }
6162
+ for (const control of SECURITY_CONTROLS) {
6163
+ if (this.seen.has(control.id)) continue;
6164
+ if (control.evidence.test(text)) this.seen.add(control.id);
6165
+ }
6166
+ }
6167
+ /** Controls with no evidence anywhere. Empty when the tree serves no HTTP. */
6168
+ missing() {
6169
+ if (this.serverFile === null) return [];
6170
+ return SECURITY_CONTROLS.filter((control) => !this.seen.has(control.id));
6171
+ }
6172
+ findings() {
6173
+ const anchor = this.serverFile;
6174
+ if (anchor === null) return [];
6175
+ return this.missing().map((control) => ({
6176
+ ruleId: control.id,
6177
+ title: control.title,
6178
+ file: anchor,
6179
+ line: 1,
6180
+ // `pattern` is doing real work here: it caps the severity, and it says
6181
+ // in the report itself how much the scanner is claiming. An absence is
6182
+ // never `evidence`.
6183
+ severity: severityFor(control.severity, "pattern"),
6184
+ confidence: "pattern",
6185
+ message: `${control.title} \u2014 no evidence of one anywhere in the scanned tree (${control.cwe})`,
6186
+ consequence: control.consequence,
6187
+ cwe: control.cwe,
6188
+ excerpt: "",
6189
+ category: "code"
6190
+ }));
6191
+ }
6192
+ };
6193
+
5395
6194
  // ../../packages/scan/src/manifest-rules.ts
5396
6195
  var POPULAR_NPM = [
5397
6196
  "react",
@@ -5781,7 +6580,8 @@ var SECRET_RULES = [
5781
6580
  pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_.+/=-]*/,
5782
6581
  severity: "medium",
5783
6582
  cwe: "CWE-798",
5784
- consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived."
6583
+ consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived.",
6584
+ keywordShaped: true
5785
6585
  },
5786
6586
  {
5787
6587
  id: "secret-generic-api-key",
@@ -5792,7 +6592,8 @@ var SECRET_RULES = [
5792
6592
  pattern: /(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9\-_.]{20,})['"]/i,
5793
6593
  severity: "high",
5794
6594
  cwe: "CWE-798",
5795
- consequence: "Whatever the third-party service lets the key do, for as long as it stays valid."
6595
+ consequence: "Whatever the third-party service lets the key do, for as long as it stays valid.",
6596
+ keywordShaped: true
5796
6597
  },
5797
6598
  {
5798
6599
  id: "secret-generic-credential",
@@ -5800,7 +6601,8 @@ var SECRET_RULES = [
5800
6601
  pattern: /(?:secret|password|passwd|pwd|token)\s*[=:]\s*['"]([^'"\s]{8,})['"]/i,
5801
6602
  severity: "high",
5802
6603
  cwe: "CWE-798",
5803
- consequence: "A password in source is a password in every clone, fork and CI cache of that source."
6604
+ consequence: "A password in source is a password in every clone, fork and CI cache of that source.",
6605
+ keywordShaped: true
5804
6606
  },
5805
6607
  {
5806
6608
  id: "secret-hex-token",
@@ -5808,7 +6610,8 @@ var SECRET_RULES = [
5808
6610
  pattern: /(?:token|key|secret|auth|signing)\w*\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/i,
5809
6611
  severity: "medium",
5810
6612
  cwe: "CWE-798",
5811
- consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign."
6613
+ consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign.",
6614
+ keywordShaped: true
5812
6615
  }
5813
6616
  ];
5814
6617
  var KNOWN_PLACEHOLDERS = [
@@ -5823,11 +6626,70 @@ var KNOWN_PLACEHOLDERS = [
5823
6626
  /\bEXAMPLE_?KEY\b/i,
5824
6627
  /\bYOUR_[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)\b/,
5825
6628
  /\b(?:xxx+|X{4,}|\*{4,}|<[a-z-]+>)\b/,
5826
- /\bchangeme\b/i
6629
+ /\bchangeme\b/i,
6630
+ // The metasyntactic pair in a connection string, `postgres://user:pass@host`.
6631
+ // This is not the AWS case above and the distinction is the whole reason it
6632
+ // is allowed: `AKIAIOSFODNN7EXAMPLE` is a real credential *format* carrying a
6633
+ // fake value, so it arrives by way of a pasted template. `user:pass` is the
6634
+ // English words sitting where a credential goes, which is how every database
6635
+ // driver writes its DSN in its own README, and nobody pastes that out of a
6636
+ // secret manager.
6637
+ //
6638
+ // Both halves have to be metasyntactic. `root:hunter2@` is not exempt, since
6639
+ // a real password beside a common username is the case this must not swallow.
6640
+ /:\/\/(?:user(?:name)?|admin|root|dbuser|myuser):(?:pass(?:word|wd)?|secret|dbpass|mypassword)@/i
5827
6641
  ];
5828
6642
  function isKnownPlaceholder(text) {
5829
6643
  return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
5830
6644
  }
6645
+ function isVariableReference(value) {
6646
+ const trimmed = value.trim();
6647
+ const braced = /^\$\{\s*([A-Za-z_][\w.]*)\s*(?::?[-=+?]([\s\S]*))?\}$/.exec(trimmed);
6648
+ if (braced) {
6649
+ const fallback = braced[2];
6650
+ if (fallback === void 0 || fallback.trim() === "") return true;
6651
+ return /^\$\{?[A-Za-z_][\w.]*\}?$/.test(fallback.trim());
6652
+ }
6653
+ return /^\$[A-Za-z_]\w*$/.test(trimmed) || // $VAR
6654
+ /^\$\([\s\S]*\)$/.test(trimmed) || // $(command substitution)
6655
+ /^%[A-Za-z_]\w*%$/.test(trimmed) || // %VAR% on Windows
6656
+ /^\{\{[\s\S]*\}\}$/.test(trimmed) || // {{ template }}
6657
+ /^#\{[\s\S]*\}$/.test(trimmed) || // #{ruby}
6658
+ /^<%=?[\s\S]*%>$/.test(trimmed);
6659
+ }
6660
+ var FIXTURE_STEMS = [
6661
+ "test",
6662
+ "mock",
6663
+ "fake",
6664
+ "dummy",
6665
+ "stub",
6666
+ "sample",
6667
+ "example",
6668
+ "placeholder",
6669
+ "fixture",
6670
+ "invalid",
6671
+ "expired",
6672
+ "forged",
6673
+ "bogus",
6674
+ "notreal",
6675
+ "nonexistent",
6676
+ "changeme",
6677
+ "foobar",
6678
+ "lorem"
6679
+ ];
6680
+ var KEY_NOISE = /* @__PURE__ */ new Set(["const", "this", "return", "await", "async", "expect", "value"]);
6681
+ function words(text) {
6682
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^A-Za-z]+/g, " ").toLowerCase().split(" ").filter(Boolean);
6683
+ }
6684
+ function isTestFixtureValue(line, value) {
6685
+ const valueWords = words(value);
6686
+ if (valueWords.some((word) => FIXTURE_STEMS.some((stem) => word.startsWith(stem)))) return true;
6687
+ if (value.length > 48) return false;
6688
+ const valueAt = line.lastIndexOf(value);
6689
+ const key = valueAt === -1 ? line : line.slice(0, valueAt);
6690
+ const flattened = valueWords.join("");
6691
+ return words(key).filter((word) => word.length >= 4 && !KEY_NOISE.has(word)).some((word) => flattened.includes(word));
6692
+ }
5831
6693
  function redactSecret(line) {
5832
6694
  return line.replace(/([A-Za-z0-9+/=_.-]{12,})/g, (match) => {
5833
6695
  if (match.length <= 12) return match;
@@ -5928,7 +6790,12 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
5928
6790
  ".erb",
5929
6791
  ".ejs",
5930
6792
  ".vue",
5931
- ".svelte"
6793
+ ".svelte",
6794
+ // Template files, so the engine in `template-rules.ts` has something to
6795
+ // read. Sourced from the rules themselves rather than repeated here: an
6796
+ // engine added there becomes scannable without a second edit that could be
6797
+ // forgotten, which is how a rule ends up quietly never firing.
6798
+ ...TEMPLATE_EXTENSIONS
5932
6799
  ]);
5933
6800
  var LANGUAGE_BY_EXTENSION = {
5934
6801
  ".js": "javascript",
@@ -6000,6 +6867,10 @@ function languageOfShebang(firstLine) {
6000
6867
  }
6001
6868
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
6002
6869
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
6870
+ var FOREIGN_CREDENTIAL = /\b(?:nolint:[\w,]*gosec|nosec)\b[^\n]*\bG101\b|\bG101\b[^\n]*\b(?:nolint:[\w,]*gosec|nosec)\b/;
6871
+ function foreignCredentialMark(line) {
6872
+ return FOREIGN_CREDENTIAL.test(line);
6873
+ }
6003
6874
  function collectSuppressions(lines) {
6004
6875
  const byLine = /* @__PURE__ */ new Map();
6005
6876
  let count = 0;
@@ -6037,16 +6908,21 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6037
6908
  if (!match) continue;
6038
6909
  if (isKnownPlaceholder(match[0])) continue;
6039
6910
  if (isSuppressed(suppressions, index, rule.id)) continue;
6911
+ const value = match[1] ?? match[0];
6912
+ if (isVariableReference(value)) continue;
6913
+ if (inTests && rule.keywordShaped && isTestFixtureValue(line, value)) continue;
6914
+ const marked = foreignCredentialMark(line);
6040
6915
  findings.push({
6041
6916
  ruleId: rule.id,
6042
6917
  title: rule.name,
6043
6918
  file: relativePath,
6044
6919
  line: index + 1,
6045
- // Reported but not blocking in tests — see isTestPath.
6046
- severity: inTests ? "low" : rule.severity,
6920
+ // Reported but not blocking in tests — see isTestPath. The same goes
6921
+ // for a line another linter's credential rule was already told about.
6922
+ severity: inTests || marked ? "low" : rule.severity,
6047
6923
  // A matched credential format is the finding, not a proxy for one.
6048
6924
  confidence: "evidence",
6049
- message: inTests ? `Possible ${rule.name} detected in a test file \u2014 usually a fixture, still worth confirming it is not a live credential` : `Possible ${rule.name} detected`,
6925
+ message: inTests ? `Possible ${rule.name} detected in a test file \u2014 usually a fixture, still worth confirming it is not a live credential` : marked ? `Possible ${rule.name} detected on a line already marked as a false positive for another linter's credential rule` : `Possible ${rule.name} detected`,
6050
6926
  consequence: rule.consequence,
6051
6927
  cwe: rule.cwe,
6052
6928
  excerpt: redactSecret(line.trim()).slice(0, 200),
@@ -6076,6 +6952,23 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6076
6952
  });
6077
6953
  }
6078
6954
  });
6955
+ for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
6956
+ const index = match.line - 1;
6957
+ if (isSuppressed(suppressions, index, match.rule.id)) continue;
6958
+ findings.push({
6959
+ ruleId: match.rule.id,
6960
+ title: match.rule.title,
6961
+ file: relativePath,
6962
+ line: match.line,
6963
+ severity: match.severity,
6964
+ confidence: "pattern",
6965
+ message: `${match.rule.title} (${match.rule.cwe})`,
6966
+ consequence: match.rule.consequence,
6967
+ cwe: match.rule.cwe,
6968
+ excerpt: (lines[index] ?? "").trim().slice(0, 200),
6969
+ category: "code"
6970
+ });
6971
+ }
6079
6972
  return findings;
6080
6973
  }
6081
6974
  function scanManifest(relativePath, filename, text) {
@@ -6096,8 +6989,8 @@ function scanManifest(relativePath, filename, text) {
6096
6989
  }
6097
6990
 
6098
6991
  // ../../packages/scan/src/node/walk.ts
6099
- var import_node_fs10 = require("fs");
6100
- var import_node_path5 = require("path");
6992
+ var import_node_fs12 = require("fs");
6993
+ var import_node_path7 = require("path");
6101
6994
  function compileExcludes(patterns) {
6102
6995
  const matchers = patterns.map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#")).map(compilePattern);
6103
6996
  if (matchers.length === 0) return () => false;
@@ -6168,7 +7061,7 @@ function matchPrefix(parts, segs) {
6168
7061
  }
6169
7062
  function readIgnoreFile(root) {
6170
7063
  try {
6171
- return (0, import_node_fs10.readFileSync)((0, import_node_path5.join)(root, ".threatcrushignore"), "utf-8").split("\n");
7064
+ return (0, import_node_fs12.readFileSync)((0, import_node_path7.join)(root, ".threatcrushignore"), "utf-8").split("\n");
6172
7065
  } catch {
6173
7066
  return [];
6174
7067
  }
@@ -6177,22 +7070,23 @@ function scanPath(targetPath, options = {}) {
6177
7070
  const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
6178
7071
  const allowed = options.categories ? new Set(options.categories) : null;
6179
7072
  const findings = [];
7073
+ const controls = new ControlAudit();
6180
7074
  const unreadable = [];
6181
7075
  let filesScanned = 0;
6182
7076
  let suppressed = 0;
6183
7077
  let excluded = 0;
6184
7078
  const rootIsDirectory = (() => {
6185
7079
  try {
6186
- return (0, import_node_fs10.statSync)(targetPath).isDirectory();
7080
+ return (0, import_node_fs12.statSync)(targetPath).isDirectory();
6187
7081
  } catch {
6188
7082
  return true;
6189
7083
  }
6190
7084
  })();
6191
- const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path5.dirname)(targetPath);
7085
+ const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path7.dirname)(targetPath);
6192
7086
  const isExcluded = compileExcludes([...options.exclude ?? [], ...readIgnoreFile(walkRoot)]);
6193
7087
  const scanFile = (fullPath, filename) => {
6194
7088
  const relativePath = toRelative(walkRoot, fullPath);
6195
- const extension = (0, import_node_path5.extname)(filename).toLowerCase();
7089
+ const extension = (0, import_node_path7.extname)(filename).toLowerCase();
6196
7090
  const isManifest = filename === "package.json" || filename === "requirements.txt";
6197
7091
  const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
6198
7092
  const mayDeclareInterpreter = !scannable && !isManifest && extension === "";
@@ -6204,32 +7098,33 @@ function scanPath(targetPath, options = {}) {
6204
7098
  let handle;
6205
7099
  let declared = null;
6206
7100
  try {
6207
- handle = (0, import_node_fs10.openSync)(fullPath, "r");
7101
+ handle = (0, import_node_fs12.openSync)(fullPath, "r");
6208
7102
  } catch {
6209
7103
  unreadable.push(relativePath);
6210
7104
  return;
6211
7105
  }
6212
7106
  try {
6213
- if ((0, import_node_fs10.fstatSync)(handle).size > maxFileBytes) return;
7107
+ if ((0, import_node_fs12.fstatSync)(handle).size > maxFileBytes) return;
6214
7108
  if (mayDeclareInterpreter) {
6215
7109
  const prefix = Buffer.alloc(128);
6216
- const read = (0, import_node_fs10.readSync)(handle, prefix, 0, prefix.length, 0);
7110
+ const read = (0, import_node_fs12.readSync)(handle, prefix, 0, prefix.length, 0);
6217
7111
  declared = languageOfShebang(prefix.subarray(0, read).toString("utf-8").split("\n", 1)[0] ?? "");
6218
7112
  if (!declared) return;
6219
7113
  }
6220
- text = (0, import_node_fs10.readFileSync)(handle, "utf-8");
7114
+ text = (0, import_node_fs12.readFileSync)(handle, "utf-8");
6221
7115
  } catch {
6222
7116
  unreadable.push(relativePath);
6223
7117
  return;
6224
7118
  } finally {
6225
7119
  try {
6226
- (0, import_node_fs10.closeSync)(handle);
7120
+ (0, import_node_fs12.closeSync)(handle);
6227
7121
  } catch {
6228
7122
  }
6229
7123
  }
6230
7124
  filesScanned += 1;
6231
7125
  options.onFile?.(relativePath);
6232
7126
  suppressed += collectSuppressions(text.split("\n")).count;
7127
+ controls.observe(relativePath, text);
6233
7128
  const fileFindings = [
6234
7129
  ...scanText(relativePath, text, declared ?? languageOf(filename)),
6235
7130
  ...isManifest ? scanManifest(relativePath, filename, text) : []
@@ -6240,13 +7135,13 @@ function scanPath(targetPath, options = {}) {
6240
7135
  const walk = (currentPath) => {
6241
7136
  let entries;
6242
7137
  try {
6243
- entries = (0, import_node_fs10.readdirSync)(currentPath, { withFileTypes: true });
7138
+ entries = (0, import_node_fs12.readdirSync)(currentPath, { withFileTypes: true });
6244
7139
  } catch {
6245
7140
  unreadable.push(toRelative(walkRoot, currentPath));
6246
7141
  return;
6247
7142
  }
6248
7143
  for (const entry of entries) {
6249
- const fullPath = (0, import_node_path5.join)(currentPath, entry.name);
7144
+ const fullPath = (0, import_node_path7.join)(currentPath, entry.name);
6250
7145
  const relativePath = toRelative(walkRoot, fullPath);
6251
7146
  if (entry.isDirectory()) {
6252
7147
  if (SKIP_DIRS.has(entry.name)) continue;
@@ -6270,8 +7165,9 @@ function scanPath(targetPath, options = {}) {
6270
7165
  } else if (isExcluded(toRelative(walkRoot, targetPath))) {
6271
7166
  excluded += 1;
6272
7167
  } else {
6273
- scanFile(targetPath, (0, import_node_path5.basename)(targetPath));
7168
+ scanFile(targetPath, (0, import_node_path7.basename)(targetPath));
6274
7169
  }
7170
+ if (options.missingControls) findings.push(...controls.findings());
6275
7171
  const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
6276
7172
  filtered.sort(
6277
7173
  (a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
@@ -6301,32 +7197,37 @@ function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
6301
7197
  }
6302
7198
  }
6303
7199
  function toRelative(base, target) {
6304
- const rel = (0, import_node_path5.relative)(base, target);
6305
- return (rel === "" ? target : rel).split(import_node_path5.sep).join("/");
7200
+ const rel = (0, import_node_path7.relative)(base, target);
7201
+ return (rel === "" ? target : rel).split(import_node_path7.sep).join("/");
6306
7202
  }
6307
7203
 
6308
7204
  // ../../packages/scan/src/node/dependencies.ts
6309
- var import_node_fs11 = require("fs");
6310
- var import_node_path6 = require("path");
6311
- var LOCKFILES = [
6312
- { file: "package-lock.json", ecosystem: "npm" },
6313
- { file: "pnpm-lock.yaml", ecosystem: "npm" },
6314
- { file: "yarn.lock", ecosystem: "npm" },
6315
- { file: "requirements.txt", ecosystem: "PyPI" },
6316
- { file: "Pipfile.lock", ecosystem: "PyPI" }
6317
- ];
7205
+ var import_node_fs13 = require("fs");
7206
+ var import_node_path8 = require("path");
6318
7207
  var MAX_DEPS_PER_LOCKFILE = 50;
6319
7208
  async function scanDependencies(targetPath) {
6320
7209
  const findings = [];
6321
- for (const { file, ecosystem } of LOCKFILES) {
6322
- const lockPath = (0, import_node_path6.join)(targetPath, file);
6323
- if (!(0, import_node_fs11.existsSync)(lockPath)) continue;
7210
+ for (const { file, ecosystem, parse } of LOCKFILES) {
7211
+ const lockPath = (0, import_node_path8.join)(targetPath, file);
7212
+ if (!(0, import_node_fs13.existsSync)(lockPath)) continue;
6324
7213
  let deps;
6325
7214
  try {
6326
- deps = parseDependencies(lockPath, file);
7215
+ deps = dedupe(parse((0, import_node_fs13.readFileSync)(lockPath, "utf-8")));
6327
7216
  } catch {
6328
7217
  continue;
6329
7218
  }
7219
+ if (deps.length === 0) {
7220
+ findings.push(incompleteFinding(file, "No dependencies could be read from this lockfile."));
7221
+ continue;
7222
+ }
7223
+ if (deps.length > MAX_DEPS_PER_LOCKFILE) {
7224
+ findings.push(
7225
+ incompleteFinding(
7226
+ file,
7227
+ `Only the first ${MAX_DEPS_PER_LOCKFILE} of ${deps.length} locked packages were checked against OSV.`
7228
+ )
7229
+ );
7230
+ }
6330
7231
  for (const dep of deps.slice(0, MAX_DEPS_PER_LOCKFILE)) {
6331
7232
  let vulns;
6332
7233
  try {
@@ -6353,6 +7254,20 @@ async function scanDependencies(targetPath) {
6353
7254
  }
6354
7255
  return findings;
6355
7256
  }
7257
+ function incompleteFinding(file, message) {
7258
+ return {
7259
+ ruleId: "dependency-scan-incomplete",
7260
+ title: "Dependency scan incomplete",
7261
+ file,
7262
+ line: 1,
7263
+ severity: "low",
7264
+ confidence: "evidence",
7265
+ message,
7266
+ consequence: "Advisories affecting the unchecked packages would not appear in this report.",
7267
+ excerpt: file,
7268
+ category: "dependency"
7269
+ };
7270
+ }
6356
7271
  function severityFromCvss(score) {
6357
7272
  if (!score) return "medium";
6358
7273
  const value = Number.parseFloat(score);
@@ -6362,26 +7277,131 @@ function severityFromCvss(score) {
6362
7277
  if (value >= 4) return "medium";
6363
7278
  return "low";
6364
7279
  }
6365
- function parseDependencies(lockPath, filename) {
7280
+ function dedupe(deps) {
7281
+ const seen = /* @__PURE__ */ new Set();
7282
+ const unique = [];
7283
+ for (const dep of deps) {
7284
+ const key = `${dep.name}@${dep.version}`;
7285
+ if (seen.has(key)) continue;
7286
+ seen.add(key);
7287
+ unique.push(dep);
7288
+ }
7289
+ return unique;
7290
+ }
7291
+ function splitNameVersion(spec) {
7292
+ const at = spec.lastIndexOf("@");
7293
+ if (at <= 0) return null;
7294
+ const name = spec.slice(0, at);
7295
+ const version = spec.slice(at + 1);
7296
+ if (!name || !version) return null;
7297
+ return { name, version };
7298
+ }
7299
+ function exactVersion(raw) {
7300
+ const version = raw.trim().replace(/^[=v]+/, "");
7301
+ return /^[0-9][0-9a-zA-Z.+-]*$/.test(version) ? version : null;
7302
+ }
7303
+ function parsePackageLock(content) {
7304
+ const lock = JSON.parse(content);
7305
+ const packages = lock.packages ?? lock.dependencies ?? {};
7306
+ const deps = [];
7307
+ for (const [key, value] of Object.entries(packages)) {
7308
+ const name = key.replace(/^.*node_modules\//, "");
7309
+ const version = value?.version;
7310
+ if (name && version && !name.startsWith(".")) deps.push({ name, version });
7311
+ }
7312
+ return deps;
7313
+ }
7314
+ function parsePnpmLock(content) {
6366
7315
  const deps = [];
6367
- if (filename === "package-lock.json") {
6368
- const lock = JSON.parse((0, import_node_fs11.readFileSync)(lockPath, "utf-8"));
6369
- const packages = lock.packages ?? lock.dependencies ?? {};
6370
- for (const [key, value] of Object.entries(packages)) {
6371
- const name = key.replace(/^node_modules\//, "");
6372
- const version = value?.version;
6373
- if (name && version && !name.startsWith(".")) deps.push({ name, version });
7316
+ let inPackages = false;
7317
+ for (const line of content.split("\n")) {
7318
+ if (/^[a-zA-Z]/.test(line)) {
7319
+ inPackages = line.startsWith("packages:");
7320
+ continue;
7321
+ }
7322
+ if (!inPackages) continue;
7323
+ const match = /^ {2}(?! )(.+):\s*$/.exec(line);
7324
+ if (!match?.[1]) continue;
7325
+ let key = match[1].trim().replace(/^['"]|['"]$/g, "");
7326
+ key = key.replace(/^\//, "");
7327
+ key = key.replace(/\(.*$/, "");
7328
+ let name;
7329
+ let rawVersion;
7330
+ const slashed = /^(@?[^@]+)\/([0-9][^/]*)$/.exec(key);
7331
+ if (slashed?.[1] && slashed[2]) {
7332
+ name = slashed[1];
7333
+ rawVersion = slashed[2];
7334
+ } else {
7335
+ const dep = splitNameVersion(key);
7336
+ if (!dep) continue;
7337
+ name = dep.name;
7338
+ rawVersion = dep.version;
6374
7339
  }
6375
- return deps;
7340
+ const version = exactVersion(rawVersion.replace(/_.*$/, ""));
7341
+ if (version) deps.push({ name, version });
6376
7342
  }
6377
- if (filename === "requirements.txt") {
6378
- for (const line of (0, import_node_fs11.readFileSync)(lockPath, "utf-8").split("\n")) {
6379
- const match = /^([a-zA-Z0-9_.-]+)==([0-9.]+)/.exec(line);
6380
- if (match?.[1] && match[2]) deps.push({ name: match[1], version: match[2] });
7343
+ return deps;
7344
+ }
7345
+ function parseYarnLock(content) {
7346
+ const deps = [];
7347
+ let pendingName = null;
7348
+ for (const line of content.split("\n")) {
7349
+ if (line.startsWith("#") || line.trim() === "") continue;
7350
+ if (!/^\s/.test(line)) {
7351
+ pendingName = null;
7352
+ const header = line.replace(/:\s*$/, "");
7353
+ const first = header.split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
7354
+ if (!first) continue;
7355
+ if (!first.includes("@") || first === "__metadata") continue;
7356
+ if (/@(?:workspace|file|link|portal|exec|patch):/.test(first)) continue;
7357
+ const dep = splitNameVersion(first.replace(/@npm:/, "@"));
7358
+ if (dep) pendingName = dep.name;
7359
+ continue;
7360
+ }
7361
+ if (!pendingName) continue;
7362
+ const version = /^\s+version:?\s+["']?([^"'\s]+)["']?\s*$/.exec(line);
7363
+ if (!version?.[1]) continue;
7364
+ const exact = exactVersion(version[1]);
7365
+ if (exact) deps.push({ name: pendingName, version: exact });
7366
+ pendingName = null;
7367
+ }
7368
+ return deps;
7369
+ }
7370
+ function parsePipfileLock(content) {
7371
+ const lock = JSON.parse(content);
7372
+ const deps = [];
7373
+ for (const section of ["default", "develop"]) {
7374
+ const packages = lock[section];
7375
+ if (!packages || typeof packages !== "object") continue;
7376
+ for (const [name, value] of Object.entries(packages)) {
7377
+ const version = exactVersion(String(value?.version ?? "").replace(/^==/, ""));
7378
+ if (name && version) deps.push({ name, version });
6381
7379
  }
6382
7380
  }
6383
7381
  return deps;
6384
7382
  }
7383
+ function parseRequirementsTxt(content) {
7384
+ const deps = [];
7385
+ for (const raw of content.split("\n")) {
7386
+ const line = raw.split("#")[0]?.split(";")[0]?.trim();
7387
+ if (!line || line.startsWith("-")) continue;
7388
+ const match = /^([a-zA-Z0-9._-]+)\s*(?:\[[^\]]*\])?\s*==\s*([^\s,]+)/.exec(line);
7389
+ if (!match?.[1] || !match[2]) continue;
7390
+ const version = exactVersion(match[2]);
7391
+ if (version) deps.push({ name: match[1], version });
7392
+ }
7393
+ return deps;
7394
+ }
7395
+ var LOCKFILES = [
7396
+ { file: "package-lock.json", ecosystem: "npm", parse: parsePackageLock },
7397
+ { file: "pnpm-lock.yaml", ecosystem: "npm", parse: parsePnpmLock },
7398
+ { file: "yarn.lock", ecosystem: "npm", parse: parseYarnLock },
7399
+ { file: "requirements.txt", ecosystem: "PyPI", parse: parseRequirementsTxt },
7400
+ { file: "Pipfile.lock", ecosystem: "PyPI", parse: parsePipfileLock }
7401
+ ];
7402
+ var LOCKFILE_PARSERS = Object.fromEntries(
7403
+ LOCKFILES.map((entry) => [entry.file, entry.parse])
7404
+ );
6385
7405
  function isValidPackageName(name) {
6386
7406
  return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
6387
7407
  }
@@ -6406,17 +7426,17 @@ async function queryOsv(name, version, ecosystem) {
6406
7426
  }
6407
7427
 
6408
7428
  // ../../packages/scan/src/node/sarif.ts
6409
- var import_node_crypto = require("crypto");
6410
- var import_node_path7 = require("path");
7429
+ var import_node_crypto3 = require("crypto");
7430
+ var import_node_path9 = require("path");
6411
7431
 
6412
7432
  // src/commands/scan.ts
6413
7433
  function readVersion() {
6414
7434
  for (const candidate of [
6415
- (0, import_node_path8.join)(__dirname, "..", "package.json"),
6416
- (0, import_node_path8.join)(__dirname, "..", "..", "package.json")
7435
+ (0, import_node_path10.join)(__dirname, "..", "package.json"),
7436
+ (0, import_node_path10.join)(__dirname, "..", "..", "package.json")
6417
7437
  ]) {
6418
7438
  try {
6419
- return JSON.parse((0, import_node_fs12.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
7439
+ return JSON.parse((0, import_node_fs14.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
6420
7440
  } catch {
6421
7441
  }
6422
7442
  }
@@ -6877,8 +7897,8 @@ var RuleEngine = class {
6877
7897
  };
6878
7898
 
6879
7899
  // src/daemon/rules/loader.ts
6880
- var import_node_fs13 = require("fs");
6881
- var import_node_path9 = require("path");
7900
+ var import_node_fs15 = require("fs");
7901
+ var import_node_path11 = require("path");
6882
7902
 
6883
7903
  // src/daemon/rules/default-rules.ts
6884
7904
  var DEFAULT_RULES = [
@@ -7162,11 +8182,11 @@ var RULES_DIR = "/etc/threatcrush/rules.d";
7162
8182
  function loadAllRules(customDir) {
7163
8183
  const rules = [...DEFAULT_RULES];
7164
8184
  const dir = customDir || RULES_DIR;
7165
- if ((0, import_node_fs13.existsSync)(dir)) {
7166
- const files = (0, import_node_fs13.readdirSync)(dir).filter((f) => f.endsWith(".json"));
8185
+ if ((0, import_node_fs15.existsSync)(dir)) {
8186
+ const files = (0, import_node_fs15.readdirSync)(dir).filter((f) => f.endsWith(".json"));
7167
8187
  for (const file of files) {
7168
8188
  try {
7169
- const raw = (0, import_node_fs13.readFileSync)((0, import_node_path9.join)(dir, file), "utf-8");
8189
+ const raw = (0, import_node_fs15.readFileSync)((0, import_node_path11.join)(dir, file), "utf-8");
7170
8190
  const parsed = JSON.parse(raw);
7171
8191
  const customRules = Array.isArray(parsed) ? parsed : [parsed];
7172
8192
  for (const rule of customRules) {
@@ -7329,7 +8349,7 @@ function detectFirewallAdapter() {
7329
8349
  }
7330
8350
 
7331
8351
  // src/daemon/firewall/remediation.ts
7332
- var import_node_fs14 = require("fs");
8352
+ var import_node_fs16 = require("fs");
7333
8353
  var DEFAULT_CONFIG2 = {
7334
8354
  enabled: true,
7335
8355
  dry_run: true,
@@ -7484,7 +8504,7 @@ var RemediationManager = class {
7484
8504
  }
7485
8505
  logLine(line) {
7486
8506
  try {
7487
- (0, import_node_fs14.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
8507
+ (0, import_node_fs16.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
7488
8508
  `);
7489
8509
  } catch {
7490
8510
  }
@@ -7540,7 +8560,7 @@ async function flushTelemetry(timeoutMs = 2e3) {
7540
8560
  // src/daemon/index.ts
7541
8561
  function readVersion2() {
7542
8562
  try {
7543
- const pkg = JSON.parse((0, import_node_fs15.readFileSync)((0, import_node_path10.join)(__dirname, "..", "package.json"), "utf-8"));
8563
+ const pkg = JSON.parse((0, import_node_fs17.readFileSync)((0, import_node_path12.join)(__dirname, "..", "package.json"), "utf-8"));
7544
8564
  return pkg.version || "0.0.0";
7545
8565
  } catch {
7546
8566
  return "0.0.0";
@@ -7548,7 +8568,7 @@ function readVersion2() {
7548
8568
  }
7549
8569
  function logLine(line) {
7550
8570
  try {
7551
- (0, import_node_fs15.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
8571
+ (0, import_node_fs17.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
7552
8572
  `);
7553
8573
  } catch {
7554
8574
  }
@@ -7576,7 +8596,7 @@ async function runDaemon() {
7576
8596
  } catch (err) {
7577
8597
  logLine(`[daemon] state db unavailable: ${err.message}`);
7578
8598
  }
7579
- const config = loadConfig((0, import_node_fs15.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
8599
+ const config = loadConfig((0, import_node_fs17.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
7580
8600
  bus.on("event", (event) => {
7581
8601
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
7582
8602
  });