@profullstack/threatcrush 0.11.1 → 0.11.3

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;
@@ -4527,7 +4685,17 @@ var NODE_RULES = [
4527
4685
  // arrangement of nearby lines that makes an echoed origin safe — except a
4528
4686
  // membership test on the value, which is what the guard looks for.
4529
4687
  inherent: true,
4530
- guard: ORIGIN_ALLOWLIST_GUARD
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)$/
4531
4699
  },
4532
4700
  // ── Server-side request forgery ──────────────────────────────────────────
4533
4701
  {
@@ -4621,7 +4789,11 @@ var NODE_RULES = [
4621
4789
  severity: "medium",
4622
4790
  languages: ["javascript", "typescript"],
4623
4791
  pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
4624
- inherent: true
4792
+ inherent: true,
4793
+ // Filling the buffer yourself is the whole reason to call `allocUnsafe`,
4794
+ // so reporting every call reports correct code. What is left reported is
4795
+ // an allocation whose bytes are never written before it escapes.
4796
+ filledBeforeUseGuard: true
4625
4797
  },
4626
4798
  {
4627
4799
  id: "js-oversized-request-body-limit",
@@ -4731,8 +4903,19 @@ var CODE_RULES = [
4731
4903
  // The tail is what distinguishes assembly from parameterisation. A bound
4732
4904
  // query leaves a comma after the closing quote (`"… = $1", [id]`) and
4733
4905
  // matches none of these.
4906
+ //
4907
+ // The clause alternative anchors the keyword to the *start* of the
4908
+ // concatenated fragment, because that is where a clause being appended
4909
+ // actually sits: `sql + "WHERE id = " + id`, `sql + " ORDER BY " + col`.
4910
+ //
4911
+ // Allowing it anywhere in the fragment made the rule read English. `WHERE`
4912
+ // and `SET` are ordinary words, and without a leading `\b` they were not
4913
+ // even required to be whole ones — "any`where`" and "sub`set`" both
4914
+ // matched. A help string reading "lists them anywhere" was reported as
4915
+ // critical SQL injection, which is the kind of finding that teaches a team
4916
+ // the scanner is not worth reading.
4734
4917
  pattern: new RegExp(
4735
- `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"[^"\\n]*|'[^'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
4918
+ `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"|')\\s*\\b(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
4736
4919
  "i"
4737
4920
  )
4738
4921
  },
@@ -4745,7 +4928,26 @@ var CODE_RULES = [
4745
4928
  pattern: new RegExp(
4746
4929
  `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
4747
4930
  "i"
4748
- )
4931
+ ),
4932
+ /**
4933
+ * Interpolating a column list is not interpolating a value.
4934
+ *
4935
+ * const COLS = `tld, user_id, owner_email, price_usd`;
4936
+ * get(`SELECT ${COLS} FROM moshpit_tlds WHERE tld = ?`, [tld]);
4937
+ *
4938
+ * That query *is* parameterised: every value the caller supplies rides a
4939
+ * `?`, and the only thing spliced into the text is a constant written a
4940
+ * few lines up. Naming the column list once instead of repeating it in
4941
+ * fourteen queries is ordinary hygiene, and it is the shape this rule met
4942
+ * most often in practice — one repository produced eighteen findings this
4943
+ * way and not one of them could be injected into.
4944
+ *
4945
+ * The guard resolves the interpolations rather than trusting the shape, so
4946
+ * the moment a query mixes a constant with anything else —
4947
+ * `` `SELECT ${COLS} FROM t WHERE id = ${req.query.id}` `` — it is reported
4948
+ * again. `interpolationsAreConstant` requires *every* `${…}` to resolve.
4949
+ */
4950
+ constantInterpolationGuard: true
4749
4951
  },
4750
4952
  {
4751
4953
  id: "sql-format-call",
@@ -4776,7 +4978,8 @@ var CODE_RULES = [
4776
4978
  cwe: "CWE-78",
4777
4979
  severity: "critical",
4778
4980
  languages: ["javascript", "typescript"],
4779
- pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/
4981
+ pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/,
4982
+ constantInterpolationGuard: true
4780
4983
  },
4781
4984
  {
4782
4985
  id: "py-shell-command-string",
@@ -4794,7 +4997,22 @@ var CODE_RULES = [
4794
4997
  cwe: "CWE-78",
4795
4998
  severity: "critical",
4796
4999
  languages: ["go"],
4797
- pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
5000
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/,
5001
+ // A call whose whole argv is string literals cannot be injected into.
5002
+ // `exec.Command("cmd", "/c", "ver")` reads the Windows version; there is no
5003
+ // value in it for an attacker to reach, and the consequence above — that a
5004
+ // shell will interpret metacharacters — describes metacharacters nobody can
5005
+ // supply. gosec's G204 draws the line in the same place, and this was the
5006
+ // single finding a Go project got out of a whole scan before declining the
5007
+ // offer, which is an expensive way to report nothing.
5008
+ //
5009
+ // The guard has to end at the closing paren, so a literal followed by
5010
+ // anything else still reports: `"ls " + dir` leaves a `+` before the `)`,
5011
+ // `fmt.Sprintf(…)` leaves an identifier, and a bare variable leaves a name.
5012
+ // `(?:[^"\\]|\\.)*` rather than `[^"]*` so an escaped quote inside a
5013
+ // literal — `"echo \"hi\""` — does not end the literal early and drop the
5014
+ // guard on a line it should have covered.
5015
+ lineGuard: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"\s*(?:,\s*"(?:[^"\\]|\\.)*")*\s*,?\s*\)/
4798
5016
  },
4799
5017
  {
4800
5018
  id: "rb-backtick-interpolation",
@@ -4863,17 +5081,43 @@ var CODE_RULES = [
4863
5081
  cwe: "CWE-79",
4864
5082
  severity: "high",
4865
5083
  languages: ["javascript", "typescript"],
4866
- pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/,
4867
5084
  /**
5085
+ * The assignment alternative carries its own exemption, as a lookahead, so
5086
+ * that it is decided per assignment rather than per line.
5087
+ *
4868
5088
  * A whole-statement assignment of a string with no interpolation and no
4869
- * concatenation carries no data, so it cannot carry attacker data. This
4870
- * was the single largest source of noise: a codebase that builds its UI
4871
- * with innerHTML reports every static heading and spinner as XSS, and a
4872
- * rule that flags 40 safe lines to catch one real one gets switched off.
5089
+ * concatenation carries no data, so it cannot carry attacker data. This was
5090
+ * the single largest source of noise: a codebase that builds its UI with
5091
+ * innerHTML reports every static heading and spinner as XSS, and a rule
5092
+ * that flags 40 safe lines to catch one real one gets switched off.
5093
+ *
5094
+ * Two things this has to get right, and a `lineGuard` could get neither:
5095
+ *
5096
+ * A statement ends at its semicolon, not at the newline. Anchoring to `$`
5097
+ * held the exemption for `el.innerHTML = '';` alone on a line and dropped
5098
+ * it the moment anything followed:
4873
5099
  *
4874
- * Line-scoped on purpose see `lineGuard`.
5100
+ * function hide() { out.hidden = true; out.innerHTML = ''; rendered = null; }
5101
+ *
5102
+ * — the same assignment, clearing a node, reported as high-severity XSS
5103
+ * because two neighbours shared its line.
5104
+ *
5105
+ * And an exemption must not become a line-wide amnesty. A guard is tested
5106
+ * against the whole line, so one safe clear would exonerate a real sink
5107
+ * beside it:
5108
+ *
5109
+ * a.innerHTML = ''; b.innerHTML = userInput;
5110
+ *
5111
+ * As a lookahead the regex decides at each `=` it reaches, so the first
5112
+ * assignment is exempt and the second is still reported.
5113
+ *
5114
+ * The whitespace after `=` is matched *inside* the lookahead rather than
5115
+ * before it. Left outside, `\s*` backtracks to zero width, the lookahead
5116
+ * then starts on the space instead of the quote, fails to see a literal,
5117
+ * and the negative lookahead succeeds — reinstating every finding the
5118
+ * exemption was written to remove.
4875
5119
  */
4876
- lineGuard: /(?:innerHTML|outerHTML)\s*=\s*(?:'[^'\\]*'|"[^"\\]*"|`[^`$\\]*`)\s*;?\s*$/
5120
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=(?!\s*(?:'[^'\\\n]*'|"[^"\\\n]*"|`[^`$\\\n]*`)\s*(?:;|$))|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/
4877
5121
  },
4878
5122
  {
4879
5123
  id: "java-html-writer-concatenation",
@@ -5675,7 +5919,26 @@ var CODE_RULES = [
5675
5919
  cwe: "CWE-346",
5676
5920
  severity: "high",
5677
5921
  languages: ["javascript", "typescript"],
5678
- 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/
5922
+ 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/,
5923
+ /**
5924
+ * Parsing the request's own URL is not building a link from the Host
5925
+ * header, even though it is spelled with one.
5926
+ *
5927
+ * const url = new URL(request.url, `http://${request.headers.host}`);
5928
+ * const token = url.searchParams.get('token');
5929
+ *
5930
+ * `request.url` on a Node server is a path — `/ws?token=…` — and `new URL`
5931
+ * refuses a relative input without a base. The base exists to satisfy the
5932
+ * parser and is thrown away; only the path and query are ever read. Every
5933
+ * Node HTTP handler that wants a query parameter is written this way, so
5934
+ * the rule fired on the framework idiom rather than on the defect.
5935
+ *
5936
+ * Narrow on purpose: the first argument must be `req.url` itself. The
5937
+ * dangerous shape passes a *path* the application chose —
5938
+ * `new URL('/reset?t=…', `https://${req.headers.host}`)` — and that is what
5939
+ * produces an attacker-controlled link. It does not match this guard.
5940
+ */
5941
+ lineGuard: /\bnew\s+URL\s*\(\s*(?:req|request|ctx)(?:uest)?\s*\.\s*url\b\s*,/
5679
5942
  },
5680
5943
  // A recursive-merge prototype-pollution rule (`target[key] = source[key]`
5681
5944
  // with no `__proto__` guard) was built and dropped. The bare copy-by-key is
@@ -5743,6 +6006,94 @@ function fileTextOf(lines) {
5743
6006
  function withoutSingleQuoted(text) {
5744
6007
  return text.replace(/'[^'\n]*'/g, "''");
5745
6008
  }
6009
+ function calleeEndingAt(text, open) {
6010
+ let end = open;
6011
+ while (end > 0 && (text[end - 1] === " " || text[end - 1] === " ")) end -= 1;
6012
+ let start = end;
6013
+ while (start > 0 && /[\w$.]/.test(text[start - 1])) start -= 1;
6014
+ return text.slice(start, end);
6015
+ }
6016
+ function enclosingCallees(lines, index, back) {
6017
+ const before = lines.slice(Math.max(0, index - back), index).join("\n");
6018
+ const stack = [];
6019
+ let quote = null;
6020
+ for (let i = 0; i < before.length; i += 1) {
6021
+ const ch = before[i];
6022
+ if (quote) {
6023
+ if (ch === "\\") i += 1;
6024
+ else if (ch === quote) quote = null;
6025
+ continue;
6026
+ }
6027
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
6028
+ else if (ch === "(") stack.push(calleeEndingAt(before, i));
6029
+ else if (ch === ")") stack.pop();
6030
+ }
6031
+ return stack;
6032
+ }
6033
+ function interpolations(line) {
6034
+ const found = [];
6035
+ for (let i = 0; ; ) {
6036
+ const start = line.indexOf("${", i);
6037
+ if (start === -1) break;
6038
+ const end = line.indexOf("}", start + 2);
6039
+ if (end === -1) return null;
6040
+ found.push(line.slice(start + 2, end).trim());
6041
+ i = end + 1;
6042
+ }
6043
+ return found.length > 0 ? found : null;
6044
+ }
6045
+ function isConstantString(name, fileText) {
6046
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6047
+ return new RegExp(
6048
+ `\\bconst\\s+${escaped}\\s*(?::[^=\\n]+)?=\\s*(?:'[^'\\n]*'|"[^"\\n]*"|\`[^\`$\\n]*\`)`
6049
+ ).test(fileText);
6050
+ }
6051
+ function interpolationsAreConstant(line, fileText) {
6052
+ const found = interpolations(line);
6053
+ if (!found) return false;
6054
+ return found.every(
6055
+ (expr) => /^[A-Za-z_$][\w$]*$/.test(expr) && isConstantString(expr, fileText)
6056
+ );
6057
+ }
6058
+ var FILL_LOOKAHEAD = 16;
6059
+ var NAME = String.raw`(?<![\w$])([A-Za-z_$][\w$]*)`;
6060
+ var ALLOCATION_BINDING = new RegExp(
6061
+ `${NAME}\\s*=\\s*(?:new\\s+Buffer\\s*\\(|Buffer\\s*\\.\\s*allocUnsafe(?:Slow)?\\s*\\()`
6062
+ );
6063
+ function allocationBinding(line) {
6064
+ return ALLOCATION_BINDING.exec(line)?.[1] ?? null;
6065
+ }
6066
+ var WRITE_SHAPES = [
6067
+ // `src.copy(name, …)` — name is the destination.
6068
+ /\.\s*copy\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]/g,
6069
+ // `name.fill(…)`, `name.write*(…)`, `name.set(…)`.
6070
+ new RegExp(`${NAME}\\s*\\.\\s*(?:fill|set|write[A-Za-z0-9]*)\\s*\\(`, "g"),
6071
+ // `name[i] = …`, but not `name[i] === …`.
6072
+ new RegExp(`${NAME}\\s*\\[[^\\]\\n]*\\]\\s*=(?!=)`, "g")
6073
+ ];
6074
+ function writesInto(text, name) {
6075
+ for (const shape of WRITE_SHAPES) {
6076
+ shape.lastIndex = 0;
6077
+ for (let found = shape.exec(text); found !== null; found = shape.exec(text)) {
6078
+ if (found[1] === name) return true;
6079
+ }
6080
+ }
6081
+ return false;
6082
+ }
6083
+ function bufferFilledBeforeUse(ctx) {
6084
+ const line = ctx.lines[ctx.index] ?? "";
6085
+ if (/\ballocUnsafe(?:Slow)?\s*\([^)\n]*\)\s*\.\s*fill\s*\(/.test(line)) return true;
6086
+ const name = allocationBinding(line);
6087
+ if (!name) return false;
6088
+ if (writesInto(line.slice(line.indexOf("=") + 1), name)) return true;
6089
+ const last = Math.min(ctx.lines.length - 1, ctx.index + FILL_LOOKAHEAD);
6090
+ for (let i = ctx.index + 1; i <= last; i += 1) {
6091
+ const next = ctx.lines[i] ?? "";
6092
+ if (skippable(next, i, ctx.prose)) continue;
6093
+ if (writesInto(next, name)) return true;
6094
+ }
6095
+ return false;
6096
+ }
5746
6097
  function evaluateRule(rule, ctx) {
5747
6098
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
5748
6099
  const line = ctx.lines[ctx.index] ?? "";
@@ -5754,6 +6105,14 @@ function evaluateRule(rule, ctx) {
5754
6105
  const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
5755
6106
  if (rule.requires && !rule.requires.test(context)) return null;
5756
6107
  if (rule.lineGuard?.test(line)) return null;
6108
+ if (rule.enclosingCallGuard) {
6109
+ const callees = enclosingCallees(ctx.lines, ctx.index, back);
6110
+ if (callees.some((callee) => rule.enclosingCallGuard.test(callee))) return null;
6111
+ }
6112
+ if (rule.constantInterpolationGuard && interpolationsAreConstant(line, fileTextOf(ctx.lines))) {
6113
+ return null;
6114
+ }
6115
+ if (rule.filledBeforeUseGuard && bufferFilledBeforeUse(ctx)) return null;
5757
6116
  const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
5758
6117
  if (guard && (guard.test(line) || guard.test(context))) return null;
5759
6118
  const untrusted = untrustedPatternFor(ctx.language);
@@ -6336,7 +6695,8 @@ var SECRET_RULES = [
6336
6695
  pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_.+/=-]*/,
6337
6696
  severity: "medium",
6338
6697
  cwe: "CWE-798",
6339
- consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived."
6698
+ consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived.",
6699
+ keywordShaped: true
6340
6700
  },
6341
6701
  {
6342
6702
  id: "secret-generic-api-key",
@@ -6347,7 +6707,8 @@ var SECRET_RULES = [
6347
6707
  pattern: /(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9\-_.]{20,})['"]/i,
6348
6708
  severity: "high",
6349
6709
  cwe: "CWE-798",
6350
- consequence: "Whatever the third-party service lets the key do, for as long as it stays valid."
6710
+ consequence: "Whatever the third-party service lets the key do, for as long as it stays valid.",
6711
+ keywordShaped: true
6351
6712
  },
6352
6713
  {
6353
6714
  id: "secret-generic-credential",
@@ -6355,7 +6716,8 @@ var SECRET_RULES = [
6355
6716
  pattern: /(?:secret|password|passwd|pwd|token)\s*[=:]\s*['"]([^'"\s]{8,})['"]/i,
6356
6717
  severity: "high",
6357
6718
  cwe: "CWE-798",
6358
- consequence: "A password in source is a password in every clone, fork and CI cache of that source."
6719
+ consequence: "A password in source is a password in every clone, fork and CI cache of that source.",
6720
+ keywordShaped: true
6359
6721
  },
6360
6722
  {
6361
6723
  id: "secret-hex-token",
@@ -6363,7 +6725,8 @@ var SECRET_RULES = [
6363
6725
  pattern: /(?:token|key|secret|auth|signing)\w*\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/i,
6364
6726
  severity: "medium",
6365
6727
  cwe: "CWE-798",
6366
- consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign."
6728
+ consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign.",
6729
+ keywordShaped: true
6367
6730
  }
6368
6731
  ];
6369
6732
  var KNOWN_PLACEHOLDERS = [
@@ -6394,6 +6757,62 @@ var KNOWN_PLACEHOLDERS = [
6394
6757
  function isKnownPlaceholder(text) {
6395
6758
  return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
6396
6759
  }
6760
+ function isPlaceholderAttribute(line, value) {
6761
+ const at = line.lastIndexOf(value);
6762
+ if (at === -1) return false;
6763
+ return /(?:^|\s)(?:aria-)?placeholder\s*=\s*[{("'`]*$/i.test(line.slice(0, at));
6764
+ }
6765
+ function isVariableReference(value) {
6766
+ const trimmed = value.trim();
6767
+ const braced = /^\$\{\s*([A-Za-z_][\w.]*)\s*(?::?[-=+?]([\s\S]*))?\}$/.exec(trimmed);
6768
+ if (braced) {
6769
+ const fallback = braced[2];
6770
+ if (fallback === void 0 || fallback.trim() === "") return true;
6771
+ return /^\$\{?[A-Za-z_][\w.]*\}?$/.test(fallback.trim());
6772
+ }
6773
+ return /^\$[A-Za-z_]\w*$/.test(trimmed) || // $VAR
6774
+ /^\$\([\s\S]*\)$/.test(trimmed) || // $(command substitution)
6775
+ /^%[A-Za-z_]\w*%$/.test(trimmed) || // %VAR% on Windows
6776
+ /^\{\{[\s\S]*\}\}$/.test(trimmed) || // {{ template }}
6777
+ /^#\{[\s\S]*\}$/.test(trimmed) || // #{ruby}
6778
+ /^<%=?[\s\S]*%>$/.test(trimmed);
6779
+ }
6780
+ var FIXTURE_STEMS = [
6781
+ "test",
6782
+ "mock",
6783
+ "fake",
6784
+ "dummy",
6785
+ "stub",
6786
+ "sample",
6787
+ "example",
6788
+ "placeholder",
6789
+ "fixture",
6790
+ "invalid",
6791
+ "expired",
6792
+ "forged",
6793
+ "bogus",
6794
+ "notreal",
6795
+ "nonexistent",
6796
+ "changeme",
6797
+ "foobar",
6798
+ "lorem"
6799
+ ];
6800
+ var KEY_NOISE = /* @__PURE__ */ new Set(["const", "this", "return", "await", "async", "expect", "value"]);
6801
+ function words(text) {
6802
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^A-Za-z]+/g, " ").toLowerCase().split(" ").filter(Boolean);
6803
+ }
6804
+ function isTestFixtureValue(line, value) {
6805
+ const valueWords = words(value);
6806
+ if (valueWords.some((word) => FIXTURE_STEMS.some((stem) => word.startsWith(stem)))) return true;
6807
+ return describesItsOwnKey(line, value);
6808
+ }
6809
+ function describesItsOwnKey(line, value) {
6810
+ if (value.length > 48) return false;
6811
+ const valueAt = line.lastIndexOf(value);
6812
+ const key = valueAt === -1 ? line : line.slice(0, valueAt);
6813
+ const flattened = words(value).join("");
6814
+ return words(key).filter((word) => word.length >= 4 && !KEY_NOISE.has(word)).some((word) => flattened.includes(word));
6815
+ }
6397
6816
  function redactSecret(line) {
6398
6817
  return line.replace(/([A-Za-z0-9+/=_.-]{12,})/g, (match) => {
6399
6818
  if (match.length <= 12) return match;
@@ -6571,9 +6990,9 @@ function languageOfShebang(firstLine) {
6571
6990
  }
6572
6991
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
6573
6992
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
6574
- var FOREIGN_CREDENTIAL = /\b(?:nolint:[\w,]*gosec|nosec)\b[^\n]*\bG101\b|\bG101\b[^\n]*\b(?:nolint:[\w,]*gosec|nosec)\b/;
6575
- function foreignCredentialMark(line) {
6576
- return FOREIGN_CREDENTIAL.test(line);
6993
+ var FOREIGN_SECURITY = /\b(?:nolint:[\w,]*gosec|nosec)\b/;
6994
+ function foreignSecurityMark(line) {
6995
+ return FOREIGN_SECURITY.test(line);
6577
6996
  }
6578
6997
  function collectSuppressions(lines) {
6579
6998
  const byLine = /* @__PURE__ */ new Map();
@@ -6599,31 +7018,68 @@ function isSuppressed(suppressions, index, ruleId) {
6599
7018
  }
6600
7019
  function isTestPath(relativePath) {
6601
7020
  const p = relativePath.replace(/\\/g, "/");
6602
- return /(?:^|\/)(?:tests?|__tests__|__mocks__|spec|specs|fixtures?|mocks?|e2e|testdata)\//i.test(p) || /(?:^|\/)(?:test|conftest)_[^/]+$/i.test(p) || /[._-](?:test|spec)\.[a-z]+$/i.test(p) || /_test\.[a-z]+$/i.test(p);
7021
+ return /(?:^|\/)(?:tests?|__tests__|__mocks__|spec|specs|fixtures?|mocks?|e2e|testdata|testutils?|harness)\//i.test(p) || /(?:^|\/)(?:test|conftest)_[^/]+$/i.test(p) || /[._-](?:test|spec)\.[a-z]+$/i.test(p) || /_test\.[a-z]+$/i.test(p);
7022
+ }
7023
+ function isDocPath(relativePath) {
7024
+ const p = relativePath.replace(/\\/g, "/");
7025
+ return /(?:^|\/)(?:docs?|examples?|samples?)\//i.test(p) || /\.(?:md|mdx|markdown|rst|adoc)$/i.test(p);
7026
+ }
7027
+ var SOFTENING_ORDER = [
7028
+ "suppressed",
7029
+ "placeholder",
7030
+ "self-describing",
7031
+ "test",
7032
+ "docs"
7033
+ ];
7034
+ function softening(reasons) {
7035
+ return SOFTENING_ORDER.find((reason) => reasons[reason]) ?? null;
6603
7036
  }
7037
+ var SECRET_SOFTENING = {
7038
+ test: "in a test file \u2014 usually a fixture, still worth confirming it is not a live credential",
7039
+ docs: "in documentation \u2014 usually an illustrative example, still worth confirming it is not a live credential",
7040
+ suppressed: "on a line already marked as a false positive for another linter's security rule",
7041
+ placeholder: "in example text an empty input field shows, not in data",
7042
+ "self-describing": "in a value that repeats the name of the field holding it \u2014 usually a description of a credential rather than one"
7043
+ };
7044
+ var CODE_SOFTENING = {
7045
+ test: "in a test file, where the construct is ordinary",
7046
+ docs: "in documentation or example code, which nothing runs",
7047
+ suppressed: "on a line another linter's security suppression already covers",
7048
+ placeholder: "in example text rather than in data",
7049
+ "self-describing": "in a value that describes itself"
7050
+ };
6604
7051
  function scanText(relativePath, text, language = languageOf(relativePath)) {
6605
7052
  const findings = [];
6606
7053
  const lines = text.split("\n");
6607
7054
  const suppressions = collectSuppressions(lines);
6608
7055
  const inTests = isTestPath(relativePath);
7056
+ const inDocs = isDocPath(relativePath);
6609
7057
  lines.forEach((line, index) => {
6610
7058
  for (const rule of SECRET_RULES) {
6611
7059
  const match = rule.pattern.exec(line);
6612
7060
  if (!match) continue;
6613
7061
  if (isKnownPlaceholder(match[0])) continue;
6614
7062
  if (isSuppressed(suppressions, index, rule.id)) continue;
6615
- const marked = foreignCredentialMark(line);
7063
+ const value = match[1] ?? match[0];
7064
+ if (isVariableReference(value)) continue;
7065
+ if (inTests && rule.keywordShaped && isTestFixtureValue(line, value)) continue;
7066
+ const soft = softening({
7067
+ test: inTests,
7068
+ suppressed: foreignSecurityMark(line),
7069
+ placeholder: isPlaceholderAttribute(line, value),
7070
+ "self-describing": rule.keywordShaped === true && describesItsOwnKey(line, value)
7071
+ });
6616
7072
  findings.push({
6617
7073
  ruleId: rule.id,
6618
7074
  title: rule.name,
6619
7075
  file: relativePath,
6620
7076
  line: index + 1,
6621
- // Reported but not blocking in tests see isTestPath. The same goes
6622
- // for a line another linter's credential rule was already told about.
6623
- severity: inTests || marked ? "low" : rule.severity,
7077
+ // Reported but not blocking wherever context weakens the claim see
7078
+ // `softening`. Never dropped: the count is the same either way.
7079
+ severity: soft ? "low" : rule.severity,
6624
7080
  // A matched credential format is the finding, not a proxy for one.
6625
7081
  confidence: "evidence",
6626
- 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`,
7082
+ message: soft ? `Possible ${rule.name} detected ${SECRET_SOFTENING[soft]}` : `Possible ${rule.name} detected`,
6627
7083
  consequence: rule.consequence,
6628
7084
  cwe: rule.cwe,
6629
7085
  excerpt: redactSecret(line.trim()).slice(0, 200),
@@ -6638,14 +7094,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6638
7094
  if (isSuppressed(suppressions, index, rule.id)) continue;
6639
7095
  const match = evaluateRule(rule, { lines, index, language, prose });
6640
7096
  if (!match) continue;
7097
+ const soft = softening({
7098
+ test: inTests,
7099
+ docs: inDocs,
7100
+ suppressed: foreignSecurityMark(lines[index] ?? "")
7101
+ });
6641
7102
  findings.push({
6642
7103
  ruleId: rule.id,
6643
7104
  title: rule.title,
6644
7105
  file: relativePath,
6645
7106
  line: index + 1,
6646
- severity: match.severity,
7107
+ severity: soft ? "low" : match.severity,
6647
7108
  confidence: match.confidence,
6648
- message: `${rule.title} (${rule.cwe})`,
7109
+ message: soft ? `${rule.title} (${rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${rule.title} (${rule.cwe})`,
6649
7110
  consequence: rule.consequence,
6650
7111
  cwe: rule.cwe,
6651
7112
  excerpt: (lines[index] ?? "").trim().slice(0, 200),
@@ -6656,14 +7117,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
6656
7117
  for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
6657
7118
  const index = match.line - 1;
6658
7119
  if (isSuppressed(suppressions, index, match.rule.id)) continue;
7120
+ const soft = softening({
7121
+ test: inTests,
7122
+ docs: inDocs,
7123
+ suppressed: foreignSecurityMark(lines[index] ?? "")
7124
+ });
6659
7125
  findings.push({
6660
7126
  ruleId: match.rule.id,
6661
7127
  title: match.rule.title,
6662
7128
  file: relativePath,
6663
7129
  line: match.line,
6664
- severity: match.severity,
7130
+ severity: soft ? "low" : match.severity,
6665
7131
  confidence: "pattern",
6666
- message: `${match.rule.title} (${match.rule.cwe})`,
7132
+ message: soft ? `${match.rule.title} (${match.rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${match.rule.title} (${match.rule.cwe})`,
6667
7133
  consequence: match.rule.consequence,
6668
7134
  cwe: match.rule.cwe,
6669
7135
  excerpt: (lines[index] ?? "").trim().slice(0, 200),
@@ -6690,8 +7156,8 @@ function scanManifest(relativePath, filename, text) {
6690
7156
  }
6691
7157
 
6692
7158
  // ../../packages/scan/src/node/walk.ts
6693
- var import_node_fs10 = require("fs");
6694
- var import_node_path5 = require("path");
7159
+ var import_node_fs12 = require("fs");
7160
+ var import_node_path7 = require("path");
6695
7161
  function compileExcludes(patterns) {
6696
7162
  const matchers = patterns.map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#")).map(compilePattern);
6697
7163
  if (matchers.length === 0) return () => false;
@@ -6762,7 +7228,7 @@ function matchPrefix(parts, segs) {
6762
7228
  }
6763
7229
  function readIgnoreFile(root) {
6764
7230
  try {
6765
- return (0, import_node_fs10.readFileSync)((0, import_node_path5.join)(root, ".threatcrushignore"), "utf-8").split("\n");
7231
+ return (0, import_node_fs12.readFileSync)((0, import_node_path7.join)(root, ".threatcrushignore"), "utf-8").split("\n");
6766
7232
  } catch {
6767
7233
  return [];
6768
7234
  }
@@ -6778,16 +7244,16 @@ function scanPath(targetPath, options = {}) {
6778
7244
  let excluded = 0;
6779
7245
  const rootIsDirectory = (() => {
6780
7246
  try {
6781
- return (0, import_node_fs10.statSync)(targetPath).isDirectory();
7247
+ return (0, import_node_fs12.statSync)(targetPath).isDirectory();
6782
7248
  } catch {
6783
7249
  return true;
6784
7250
  }
6785
7251
  })();
6786
- const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path5.dirname)(targetPath);
7252
+ const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path7.dirname)(targetPath);
6787
7253
  const isExcluded = compileExcludes([...options.exclude ?? [], ...readIgnoreFile(walkRoot)]);
6788
7254
  const scanFile = (fullPath, filename) => {
6789
7255
  const relativePath = toRelative(walkRoot, fullPath);
6790
- const extension = (0, import_node_path5.extname)(filename).toLowerCase();
7256
+ const extension = (0, import_node_path7.extname)(filename).toLowerCase();
6791
7257
  const isManifest = filename === "package.json" || filename === "requirements.txt";
6792
7258
  const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
6793
7259
  const mayDeclareInterpreter = !scannable && !isManifest && extension === "";
@@ -6799,26 +7265,26 @@ function scanPath(targetPath, options = {}) {
6799
7265
  let handle;
6800
7266
  let declared = null;
6801
7267
  try {
6802
- handle = (0, import_node_fs10.openSync)(fullPath, "r");
7268
+ handle = (0, import_node_fs12.openSync)(fullPath, "r");
6803
7269
  } catch {
6804
7270
  unreadable.push(relativePath);
6805
7271
  return;
6806
7272
  }
6807
7273
  try {
6808
- if ((0, import_node_fs10.fstatSync)(handle).size > maxFileBytes) return;
7274
+ if ((0, import_node_fs12.fstatSync)(handle).size > maxFileBytes) return;
6809
7275
  if (mayDeclareInterpreter) {
6810
7276
  const prefix = Buffer.alloc(128);
6811
- const read = (0, import_node_fs10.readSync)(handle, prefix, 0, prefix.length, 0);
7277
+ const read = (0, import_node_fs12.readSync)(handle, prefix, 0, prefix.length, 0);
6812
7278
  declared = languageOfShebang(prefix.subarray(0, read).toString("utf-8").split("\n", 1)[0] ?? "");
6813
7279
  if (!declared) return;
6814
7280
  }
6815
- text = (0, import_node_fs10.readFileSync)(handle, "utf-8");
7281
+ text = (0, import_node_fs12.readFileSync)(handle, "utf-8");
6816
7282
  } catch {
6817
7283
  unreadable.push(relativePath);
6818
7284
  return;
6819
7285
  } finally {
6820
7286
  try {
6821
- (0, import_node_fs10.closeSync)(handle);
7287
+ (0, import_node_fs12.closeSync)(handle);
6822
7288
  } catch {
6823
7289
  }
6824
7290
  }
@@ -6836,13 +7302,13 @@ function scanPath(targetPath, options = {}) {
6836
7302
  const walk = (currentPath) => {
6837
7303
  let entries;
6838
7304
  try {
6839
- entries = (0, import_node_fs10.readdirSync)(currentPath, { withFileTypes: true });
7305
+ entries = (0, import_node_fs12.readdirSync)(currentPath, { withFileTypes: true });
6840
7306
  } catch {
6841
7307
  unreadable.push(toRelative(walkRoot, currentPath));
6842
7308
  return;
6843
7309
  }
6844
7310
  for (const entry of entries) {
6845
- const fullPath = (0, import_node_path5.join)(currentPath, entry.name);
7311
+ const fullPath = (0, import_node_path7.join)(currentPath, entry.name);
6846
7312
  const relativePath = toRelative(walkRoot, fullPath);
6847
7313
  if (entry.isDirectory()) {
6848
7314
  if (SKIP_DIRS.has(entry.name)) continue;
@@ -6866,7 +7332,7 @@ function scanPath(targetPath, options = {}) {
6866
7332
  } else if (isExcluded(toRelative(walkRoot, targetPath))) {
6867
7333
  excluded += 1;
6868
7334
  } else {
6869
- scanFile(targetPath, (0, import_node_path5.basename)(targetPath));
7335
+ scanFile(targetPath, (0, import_node_path7.basename)(targetPath));
6870
7336
  }
6871
7337
  if (options.missingControls) findings.push(...controls.findings());
6872
7338
  const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
@@ -6898,32 +7364,37 @@ function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
6898
7364
  }
6899
7365
  }
6900
7366
  function toRelative(base, target) {
6901
- const rel = (0, import_node_path5.relative)(base, target);
6902
- return (rel === "" ? target : rel).split(import_node_path5.sep).join("/");
7367
+ const rel = (0, import_node_path7.relative)(base, target);
7368
+ return (rel === "" ? target : rel).split(import_node_path7.sep).join("/");
6903
7369
  }
6904
7370
 
6905
7371
  // ../../packages/scan/src/node/dependencies.ts
6906
- var import_node_fs11 = require("fs");
6907
- var import_node_path6 = require("path");
6908
- var LOCKFILES = [
6909
- { file: "package-lock.json", ecosystem: "npm" },
6910
- { file: "pnpm-lock.yaml", ecosystem: "npm" },
6911
- { file: "yarn.lock", ecosystem: "npm" },
6912
- { file: "requirements.txt", ecosystem: "PyPI" },
6913
- { file: "Pipfile.lock", ecosystem: "PyPI" }
6914
- ];
7372
+ var import_node_fs13 = require("fs");
7373
+ var import_node_path8 = require("path");
6915
7374
  var MAX_DEPS_PER_LOCKFILE = 50;
6916
7375
  async function scanDependencies(targetPath) {
6917
7376
  const findings = [];
6918
- for (const { file, ecosystem } of LOCKFILES) {
6919
- const lockPath = (0, import_node_path6.join)(targetPath, file);
6920
- if (!(0, import_node_fs11.existsSync)(lockPath)) continue;
7377
+ for (const { file, ecosystem, parse } of LOCKFILES) {
7378
+ const lockPath = (0, import_node_path8.join)(targetPath, file);
7379
+ if (!(0, import_node_fs13.existsSync)(lockPath)) continue;
6921
7380
  let deps;
6922
7381
  try {
6923
- deps = parseDependencies(lockPath, file);
7382
+ deps = dedupe(parse((0, import_node_fs13.readFileSync)(lockPath, "utf-8")));
6924
7383
  } catch {
6925
7384
  continue;
6926
7385
  }
7386
+ if (deps.length === 0) {
7387
+ findings.push(incompleteFinding(file, "No dependencies could be read from this lockfile."));
7388
+ continue;
7389
+ }
7390
+ if (deps.length > MAX_DEPS_PER_LOCKFILE) {
7391
+ findings.push(
7392
+ incompleteFinding(
7393
+ file,
7394
+ `Only the first ${MAX_DEPS_PER_LOCKFILE} of ${deps.length} locked packages were checked against OSV.`
7395
+ )
7396
+ );
7397
+ }
6927
7398
  for (const dep of deps.slice(0, MAX_DEPS_PER_LOCKFILE)) {
6928
7399
  let vulns;
6929
7400
  try {
@@ -6950,6 +7421,20 @@ async function scanDependencies(targetPath) {
6950
7421
  }
6951
7422
  return findings;
6952
7423
  }
7424
+ function incompleteFinding(file, message) {
7425
+ return {
7426
+ ruleId: "dependency-scan-incomplete",
7427
+ title: "Dependency scan incomplete",
7428
+ file,
7429
+ line: 1,
7430
+ severity: "low",
7431
+ confidence: "evidence",
7432
+ message,
7433
+ consequence: "Advisories affecting the unchecked packages would not appear in this report.",
7434
+ excerpt: file,
7435
+ category: "dependency"
7436
+ };
7437
+ }
6953
7438
  function severityFromCvss(score) {
6954
7439
  if (!score) return "medium";
6955
7440
  const value = Number.parseFloat(score);
@@ -6959,26 +7444,131 @@ function severityFromCvss(score) {
6959
7444
  if (value >= 4) return "medium";
6960
7445
  return "low";
6961
7446
  }
6962
- function parseDependencies(lockPath, filename) {
7447
+ function dedupe(deps) {
7448
+ const seen = /* @__PURE__ */ new Set();
7449
+ const unique = [];
7450
+ for (const dep of deps) {
7451
+ const key = `${dep.name}@${dep.version}`;
7452
+ if (seen.has(key)) continue;
7453
+ seen.add(key);
7454
+ unique.push(dep);
7455
+ }
7456
+ return unique;
7457
+ }
7458
+ function splitNameVersion(spec) {
7459
+ const at = spec.lastIndexOf("@");
7460
+ if (at <= 0) return null;
7461
+ const name = spec.slice(0, at);
7462
+ const version = spec.slice(at + 1);
7463
+ if (!name || !version) return null;
7464
+ return { name, version };
7465
+ }
7466
+ function exactVersion(raw) {
7467
+ const version = raw.trim().replace(/^[=v]+/, "");
7468
+ return /^[0-9][0-9a-zA-Z.+-]*$/.test(version) ? version : null;
7469
+ }
7470
+ function parsePackageLock(content) {
7471
+ const lock = JSON.parse(content);
7472
+ const packages = lock.packages ?? lock.dependencies ?? {};
6963
7473
  const deps = [];
6964
- if (filename === "package-lock.json") {
6965
- const lock = JSON.parse((0, import_node_fs11.readFileSync)(lockPath, "utf-8"));
6966
- const packages = lock.packages ?? lock.dependencies ?? {};
6967
- for (const [key, value] of Object.entries(packages)) {
6968
- const name = key.replace(/^node_modules\//, "");
6969
- const version = value?.version;
6970
- if (name && version && !name.startsWith(".")) deps.push({ name, version });
7474
+ for (const [key, value] of Object.entries(packages)) {
7475
+ const name = key.replace(/^.*node_modules\//, "");
7476
+ const version = value?.version;
7477
+ if (name && version && !name.startsWith(".")) deps.push({ name, version });
7478
+ }
7479
+ return deps;
7480
+ }
7481
+ function parsePnpmLock(content) {
7482
+ const deps = [];
7483
+ let inPackages = false;
7484
+ for (const line of content.split("\n")) {
7485
+ if (/^[a-zA-Z]/.test(line)) {
7486
+ inPackages = line.startsWith("packages:");
7487
+ continue;
6971
7488
  }
6972
- return deps;
7489
+ if (!inPackages) continue;
7490
+ const match = /^ {2}(?! )(.+):\s*$/.exec(line);
7491
+ if (!match?.[1]) continue;
7492
+ let key = match[1].trim().replace(/^['"]|['"]$/g, "");
7493
+ key = key.replace(/^\//, "");
7494
+ key = key.replace(/\(.*$/, "");
7495
+ let name;
7496
+ let rawVersion;
7497
+ const slashed = /^(@?[^@]+)\/([0-9][^/]*)$/.exec(key);
7498
+ if (slashed?.[1] && slashed[2]) {
7499
+ name = slashed[1];
7500
+ rawVersion = slashed[2];
7501
+ } else {
7502
+ const dep = splitNameVersion(key);
7503
+ if (!dep) continue;
7504
+ name = dep.name;
7505
+ rawVersion = dep.version;
7506
+ }
7507
+ const version = exactVersion(rawVersion.replace(/_.*$/, ""));
7508
+ if (version) deps.push({ name, version });
7509
+ }
7510
+ return deps;
7511
+ }
7512
+ function parseYarnLock(content) {
7513
+ const deps = [];
7514
+ let pendingName = null;
7515
+ for (const line of content.split("\n")) {
7516
+ if (line.startsWith("#") || line.trim() === "") continue;
7517
+ if (!/^\s/.test(line)) {
7518
+ pendingName = null;
7519
+ const header = line.replace(/:\s*$/, "");
7520
+ const first = header.split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
7521
+ if (!first) continue;
7522
+ if (!first.includes("@") || first === "__metadata") continue;
7523
+ if (/@(?:workspace|file|link|portal|exec|patch):/.test(first)) continue;
7524
+ const dep = splitNameVersion(first.replace(/@npm:/, "@"));
7525
+ if (dep) pendingName = dep.name;
7526
+ continue;
7527
+ }
7528
+ if (!pendingName) continue;
7529
+ const version = /^\s+version:?\s+["']?([^"'\s]+)["']?\s*$/.exec(line);
7530
+ if (!version?.[1]) continue;
7531
+ const exact = exactVersion(version[1]);
7532
+ if (exact) deps.push({ name: pendingName, version: exact });
7533
+ pendingName = null;
6973
7534
  }
6974
- if (filename === "requirements.txt") {
6975
- for (const line of (0, import_node_fs11.readFileSync)(lockPath, "utf-8").split("\n")) {
6976
- const match = /^([a-zA-Z0-9_.-]+)==([0-9.]+)/.exec(line);
6977
- if (match?.[1] && match[2]) deps.push({ name: match[1], version: match[2] });
7535
+ return deps;
7536
+ }
7537
+ function parsePipfileLock(content) {
7538
+ const lock = JSON.parse(content);
7539
+ const deps = [];
7540
+ for (const section of ["default", "develop"]) {
7541
+ const packages = lock[section];
7542
+ if (!packages || typeof packages !== "object") continue;
7543
+ for (const [name, value] of Object.entries(packages)) {
7544
+ const version = exactVersion(String(value?.version ?? "").replace(/^==/, ""));
7545
+ if (name && version) deps.push({ name, version });
6978
7546
  }
6979
7547
  }
6980
7548
  return deps;
6981
7549
  }
7550
+ function parseRequirementsTxt(content) {
7551
+ const deps = [];
7552
+ for (const raw of content.split("\n")) {
7553
+ const line = raw.split("#")[0]?.split(";")[0]?.trim();
7554
+ if (!line || line.startsWith("-")) continue;
7555
+ const match = /^([a-zA-Z0-9._-]+)\s*(?:\[[^\]]*\])?\s*==\s*([^\s,]+)/.exec(line);
7556
+ if (!match?.[1] || !match[2]) continue;
7557
+ const version = exactVersion(match[2]);
7558
+ if (version) deps.push({ name: match[1], version });
7559
+ }
7560
+ return deps;
7561
+ }
7562
+ var LOCKFILES = [
7563
+ { file: "package-lock.json", ecosystem: "npm", parse: parsePackageLock },
7564
+ { file: "pnpm-lock.yaml", ecosystem: "npm", parse: parsePnpmLock },
7565
+ { file: "yarn.lock", ecosystem: "npm", parse: parseYarnLock },
7566
+ { file: "requirements.txt", ecosystem: "PyPI", parse: parseRequirementsTxt },
7567
+ { file: "Pipfile.lock", ecosystem: "PyPI", parse: parsePipfileLock }
7568
+ ];
7569
+ var LOCKFILE_PARSERS = Object.fromEntries(
7570
+ LOCKFILES.map((entry) => [entry.file, entry.parse])
7571
+ );
6982
7572
  function isValidPackageName(name) {
6983
7573
  return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
6984
7574
  }
@@ -7003,17 +7593,17 @@ async function queryOsv(name, version, ecosystem) {
7003
7593
  }
7004
7594
 
7005
7595
  // ../../packages/scan/src/node/sarif.ts
7006
- var import_node_crypto = require("crypto");
7007
- var import_node_path7 = require("path");
7596
+ var import_node_crypto3 = require("crypto");
7597
+ var import_node_path9 = require("path");
7008
7598
 
7009
7599
  // src/commands/scan.ts
7010
7600
  function readVersion() {
7011
7601
  for (const candidate of [
7012
- (0, import_node_path8.join)(__dirname, "..", "package.json"),
7013
- (0, import_node_path8.join)(__dirname, "..", "..", "package.json")
7602
+ (0, import_node_path10.join)(__dirname, "..", "package.json"),
7603
+ (0, import_node_path10.join)(__dirname, "..", "..", "package.json")
7014
7604
  ]) {
7015
7605
  try {
7016
- return JSON.parse((0, import_node_fs12.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
7606
+ return JSON.parse((0, import_node_fs14.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
7017
7607
  } catch {
7018
7608
  }
7019
7609
  }
@@ -7474,8 +8064,8 @@ var RuleEngine = class {
7474
8064
  };
7475
8065
 
7476
8066
  // src/daemon/rules/loader.ts
7477
- var import_node_fs13 = require("fs");
7478
- var import_node_path9 = require("path");
8067
+ var import_node_fs15 = require("fs");
8068
+ var import_node_path11 = require("path");
7479
8069
 
7480
8070
  // src/daemon/rules/default-rules.ts
7481
8071
  var DEFAULT_RULES = [
@@ -7759,11 +8349,11 @@ var RULES_DIR = "/etc/threatcrush/rules.d";
7759
8349
  function loadAllRules(customDir) {
7760
8350
  const rules = [...DEFAULT_RULES];
7761
8351
  const dir = customDir || RULES_DIR;
7762
- if ((0, import_node_fs13.existsSync)(dir)) {
7763
- const files = (0, import_node_fs13.readdirSync)(dir).filter((f) => f.endsWith(".json"));
8352
+ if ((0, import_node_fs15.existsSync)(dir)) {
8353
+ const files = (0, import_node_fs15.readdirSync)(dir).filter((f) => f.endsWith(".json"));
7764
8354
  for (const file of files) {
7765
8355
  try {
7766
- const raw = (0, import_node_fs13.readFileSync)((0, import_node_path9.join)(dir, file), "utf-8");
8356
+ const raw = (0, import_node_fs15.readFileSync)((0, import_node_path11.join)(dir, file), "utf-8");
7767
8357
  const parsed = JSON.parse(raw);
7768
8358
  const customRules = Array.isArray(parsed) ? parsed : [parsed];
7769
8359
  for (const rule of customRules) {
@@ -7926,7 +8516,7 @@ function detectFirewallAdapter() {
7926
8516
  }
7927
8517
 
7928
8518
  // src/daemon/firewall/remediation.ts
7929
- var import_node_fs14 = require("fs");
8519
+ var import_node_fs16 = require("fs");
7930
8520
  var DEFAULT_CONFIG2 = {
7931
8521
  enabled: true,
7932
8522
  dry_run: true,
@@ -8081,7 +8671,7 @@ var RemediationManager = class {
8081
8671
  }
8082
8672
  logLine(line) {
8083
8673
  try {
8084
- (0, import_node_fs14.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
8674
+ (0, import_node_fs16.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
8085
8675
  `);
8086
8676
  } catch {
8087
8677
  }
@@ -8137,7 +8727,7 @@ async function flushTelemetry(timeoutMs = 2e3) {
8137
8727
  // src/daemon/index.ts
8138
8728
  function readVersion2() {
8139
8729
  try {
8140
- const pkg = JSON.parse((0, import_node_fs15.readFileSync)((0, import_node_path10.join)(__dirname, "..", "package.json"), "utf-8"));
8730
+ const pkg = JSON.parse((0, import_node_fs17.readFileSync)((0, import_node_path12.join)(__dirname, "..", "package.json"), "utf-8"));
8141
8731
  return pkg.version || "0.0.0";
8142
8732
  } catch {
8143
8733
  return "0.0.0";
@@ -8145,7 +8735,7 @@ function readVersion2() {
8145
8735
  }
8146
8736
  function logLine(line) {
8147
8737
  try {
8148
- (0, import_node_fs15.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
8738
+ (0, import_node_fs17.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
8149
8739
  `);
8150
8740
  } catch {
8151
8741
  }
@@ -8173,7 +8763,7 @@ async function runDaemon() {
8173
8763
  } catch (err) {
8174
8764
  logLine(`[daemon] state db unavailable: ${err.message}`);
8175
8765
  }
8176
- const config = loadConfig((0, import_node_fs15.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
8766
+ const config = loadConfig((0, import_node_fs17.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
8177
8767
  bus.on("event", (event) => {
8178
8768
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
8179
8769
  });