@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/index.js CHANGED
@@ -3891,14 +3891,52 @@ var init_paths = __esm({
3891
3891
  }
3892
3892
  });
3893
3893
 
3894
+ // src/daemon/control-token.ts
3895
+ function issueControlToken() {
3896
+ const token = (0, import_node_crypto.randomBytes)(32).toString("hex");
3897
+ (0, import_node_fs2.writeFileSync)(CONTROL_TOKEN_FILE, token, { mode: 384 });
3898
+ try {
3899
+ (0, import_node_fs2.chmodSync)(CONTROL_TOKEN_FILE, 384);
3900
+ } catch {
3901
+ }
3902
+ return token;
3903
+ }
3904
+ function readControlToken() {
3905
+ if (!(0, import_node_fs2.existsSync)(CONTROL_TOKEN_FILE)) return null;
3906
+ try {
3907
+ const token = (0, import_node_fs2.readFileSync)(CONTROL_TOKEN_FILE, "utf-8").trim();
3908
+ return token || null;
3909
+ } catch {
3910
+ return null;
3911
+ }
3912
+ }
3913
+ function tokensMatch(expected, provided) {
3914
+ if (typeof provided !== "string" || !provided) return false;
3915
+ const a = Buffer.from(expected);
3916
+ const b = Buffer.from(provided);
3917
+ return a.length === b.length && (0, import_node_crypto.timingSafeEqual)(a, b);
3918
+ }
3919
+ var import_node_crypto, import_node_fs2, import_node_path2, CONTROL_TOKEN_FILE;
3920
+ var init_control_token = __esm({
3921
+ "src/daemon/control-token.ts"() {
3922
+ "use strict";
3923
+ import_node_crypto = require("crypto");
3924
+ import_node_fs2 = require("fs");
3925
+ import_node_path2 = require("path");
3926
+ init_paths();
3927
+ CONTROL_TOKEN_FILE = (0, import_node_path2.join)(PATHS.runDir, "control.token");
3928
+ }
3929
+ });
3930
+
3894
3931
  // src/core/ipc-client.ts
3895
- var import_node_net, import_node_fs2, IpcClient;
3932
+ var import_node_net, import_node_fs3, IpcClient;
3896
3933
  var init_ipc_client = __esm({
3897
3934
  "src/core/ipc-client.ts"() {
3898
3935
  "use strict";
3899
3936
  import_node_net = require("net");
3900
- import_node_fs2 = require("fs");
3937
+ import_node_fs3 = require("fs");
3901
3938
  init_paths();
3939
+ init_control_token();
3902
3940
  IpcClient = class {
3903
3941
  constructor(opts = {}) {
3904
3942
  this.opts = opts;
@@ -3911,10 +3949,10 @@ var init_ipc_client = __esm({
3911
3949
  pending = /* @__PURE__ */ new Map();
3912
3950
  socketPath;
3913
3951
  static isDaemonRunning(socketPath = resolveClientSocket()) {
3914
- return (0, import_node_fs2.existsSync)(socketPath);
3952
+ return (0, import_node_fs3.existsSync)(socketPath);
3915
3953
  }
3916
3954
  async connect(timeoutMs = 2e3) {
3917
- if (!(0, import_node_fs2.existsSync)(this.socketPath)) {
3955
+ if (!(0, import_node_fs3.existsSync)(this.socketPath)) {
3918
3956
  throw new Error(`threatcrushd socket not found at ${this.socketPath}`);
3919
3957
  }
3920
3958
  return new Promise((resolve5, reject) => {
@@ -3975,7 +4013,7 @@ var init_ipc_client = __esm({
3975
4013
  await this.request("subscribe", { channels });
3976
4014
  }
3977
4015
  async shutdown() {
3978
- await this.request("shutdown");
4016
+ await this.request("shutdown", { token: readControlToken() });
3979
4017
  }
3980
4018
  onData(chunk) {
3981
4019
  this.buffer += chunk;
@@ -4018,17 +4056,17 @@ var init_ipc_client = __esm({
4018
4056
  // src/daemon/pidfile.ts
4019
4057
  function writePidFile() {
4020
4058
  ensureRuntimeDirs();
4021
- (0, import_node_fs3.writeFileSync)(PATHS.pidFile, String(process.pid), "utf-8");
4059
+ (0, import_node_fs4.writeFileSync)(PATHS.pidFile, String(process.pid), "utf-8");
4022
4060
  }
4023
4061
  function readPidFile() {
4024
- if (!(0, import_node_fs3.existsSync)(PATHS.pidFile)) return null;
4025
- const raw = (0, import_node_fs3.readFileSync)(PATHS.pidFile, "utf-8").trim();
4062
+ if (!(0, import_node_fs4.existsSync)(PATHS.pidFile)) return null;
4063
+ const raw = (0, import_node_fs4.readFileSync)(PATHS.pidFile, "utf-8").trim();
4026
4064
  const pid = parseInt(raw, 10);
4027
4065
  return Number.isFinite(pid) ? pid : null;
4028
4066
  }
4029
4067
  function removePidFile() {
4030
4068
  try {
4031
- if ((0, import_node_fs3.existsSync)(PATHS.pidFile)) (0, import_node_fs3.unlinkSync)(PATHS.pidFile);
4069
+ if ((0, import_node_fs4.existsSync)(PATHS.pidFile)) (0, import_node_fs4.unlinkSync)(PATHS.pidFile);
4032
4070
  } catch {
4033
4071
  }
4034
4072
  }
@@ -4047,11 +4085,11 @@ function findRunningDaemon() {
4047
4085
  if (pid) removePidFile();
4048
4086
  return null;
4049
4087
  }
4050
- var import_node_fs3;
4088
+ var import_node_fs4;
4051
4089
  var init_pidfile = __esm({
4052
4090
  "src/daemon/pidfile.ts"() {
4053
4091
  "use strict";
4054
- import_node_fs3 = require("fs");
4092
+ import_node_fs4 = require("fs");
4055
4093
  init_paths();
4056
4094
  }
4057
4095
  });
@@ -8260,12 +8298,12 @@ var source_default = chalk;
8260
8298
  // src/index.ts
8261
8299
  var import_readline = __toESM(require("readline"));
8262
8300
  var import_node_child_process10 = require("child_process");
8263
- var import_node_fs29 = require("fs");
8264
- var import_node_path19 = require("path");
8301
+ var import_node_fs31 = require("fs");
8302
+ var import_node_path21 = require("path");
8265
8303
  var import_node_os8 = require("os");
8266
8304
 
8267
8305
  // src/commands/monitor.ts
8268
- var import_node_fs4 = require("fs");
8306
+ var import_node_fs5 = require("fs");
8269
8307
  var import_node_readline = require("readline");
8270
8308
 
8271
8309
  // src/core/logger.ts
@@ -8473,9 +8511,9 @@ async function monitorCommand(options) {
8473
8511
  const unreadable = [];
8474
8512
  for (const s of LOG_SOURCES) {
8475
8513
  if (moduleFilter && !moduleFilter.includes(s.name)) continue;
8476
- if (!(0, import_node_fs4.existsSync)(s.path)) continue;
8514
+ if (!(0, import_node_fs5.existsSync)(s.path)) continue;
8477
8515
  try {
8478
- (0, import_node_fs4.accessSync)(s.path, import_node_fs4.constants.R_OK);
8516
+ (0, import_node_fs5.accessSync)(s.path, import_node_fs5.constants.R_OK);
8479
8517
  availableSources.push(s);
8480
8518
  } catch {
8481
8519
  unreadable.push(s);
@@ -8492,11 +8530,11 @@ async function monitorCommand(options) {
8492
8530
  logger.warn("No readable log files found to monitor.");
8493
8531
  logger.info("Available log paths checked:");
8494
8532
  for (const s of LOG_SOURCES) {
8495
- const exists = (0, import_node_fs4.existsSync)(s.path);
8533
+ const exists = (0, import_node_fs5.existsSync)(s.path);
8496
8534
  let readable = false;
8497
8535
  if (exists) {
8498
8536
  try {
8499
- (0, import_node_fs4.accessSync)(s.path, import_node_fs4.constants.R_OK);
8537
+ (0, import_node_fs5.accessSync)(s.path, import_node_fs5.constants.R_OK);
8500
8538
  readable = true;
8501
8539
  } catch {
8502
8540
  readable = false;
@@ -8528,16 +8566,16 @@ async function monitorCommand(options) {
8528
8566
  }
8529
8567
  function tailLog(source) {
8530
8568
  const { path, name, category } = source;
8531
- const stat = (0, import_node_fs4.statSync)(path);
8569
+ const stat = (0, import_node_fs5.statSync)(path);
8532
8570
  let position = stat.size;
8533
8571
  const checkForNewData = () => {
8534
8572
  try {
8535
- const currentStat = (0, import_node_fs4.statSync)(path);
8573
+ const currentStat = (0, import_node_fs5.statSync)(path);
8536
8574
  if (currentStat.size <= position) {
8537
8575
  if (currentStat.size < position) position = 0;
8538
8576
  return;
8539
8577
  }
8540
- const stream = (0, import_node_fs4.createReadStream)(path, { start: position, encoding: "utf-8" });
8578
+ const stream = (0, import_node_fs5.createReadStream)(path, { start: position, encoding: "utf-8" });
8541
8579
  stream.on("error", (err) => {
8542
8580
  logger.warn(`stopped tailing ${path}: ${err.code || err.message}`);
8543
8581
  position = currentStat.size;
@@ -8639,8 +8677,8 @@ async function runDemoMode() {
8639
8677
  }
8640
8678
 
8641
8679
  // src/commands/scan.ts
8642
- var import_node_fs7 = require("fs");
8643
- var import_node_path5 = require("path");
8680
+ var import_node_fs8 = require("fs");
8681
+ var import_node_path6 = require("path");
8644
8682
 
8645
8683
  // ../../node_modules/.pnpm/ora@8.2.0/node_modules/ora/index.js
8646
8684
  var import_node_process7 = __toESM(require("process"), 1);
@@ -9797,7 +9835,17 @@ var NODE_RULES = [
9797
9835
  // arrangement of nearby lines that makes an echoed origin safe — except a
9798
9836
  // membership test on the value, which is what the guard looks for.
9799
9837
  inherent: true,
9800
- guard: ORIGIN_ALLOWLIST_GUARD
9838
+ guard: ORIGIN_ALLOWLIST_GUARD,
9839
+ /**
9840
+ * Logging the origin is not reflecting it.
9841
+ *
9842
+ * `origin: request.headers.origin` is the CORS mistake *and* the ordinary
9843
+ * way to record who called — the two are character-for-character
9844
+ * identical, and only what encloses them differs. A response header goes
9845
+ * out to the browser; a log line goes to stdout, where it grants nobody
9846
+ * anything.
9847
+ */
9848
+ enclosingCallGuard: /(?:^|\.)(?:log|debug|info|warn|error|trace|verbose|fatal)$/
9801
9849
  },
9802
9850
  // ── Server-side request forgery ──────────────────────────────────────────
9803
9851
  {
@@ -9891,7 +9939,11 @@ var NODE_RULES = [
9891
9939
  severity: "medium",
9892
9940
  languages: ["javascript", "typescript"],
9893
9941
  pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
9894
- inherent: true
9942
+ inherent: true,
9943
+ // Filling the buffer yourself is the whole reason to call `allocUnsafe`,
9944
+ // so reporting every call reports correct code. What is left reported is
9945
+ // an allocation whose bytes are never written before it escapes.
9946
+ filledBeforeUseGuard: true
9895
9947
  },
9896
9948
  {
9897
9949
  id: "js-oversized-request-body-limit",
@@ -10001,8 +10053,19 @@ var CODE_RULES = [
10001
10053
  // The tail is what distinguishes assembly from parameterisation. A bound
10002
10054
  // query leaves a comma after the closing quote (`"… = $1", [id]`) and
10003
10055
  // matches none of these.
10056
+ //
10057
+ // The clause alternative anchors the keyword to the *start* of the
10058
+ // concatenated fragment, because that is where a clause being appended
10059
+ // actually sits: `sql + "WHERE id = " + id`, `sql + " ORDER BY " + col`.
10060
+ //
10061
+ // Allowing it anywhere in the fragment made the rule read English. `WHERE`
10062
+ // and `SET` are ordinary words, and without a leading `\b` they were not
10063
+ // even required to be whole ones — "any`where`" and "sub`set`" both
10064
+ // matched. A help string reading "lists them anywhere" was reported as
10065
+ // critical SQL injection, which is the kind of finding that teaches a team
10066
+ // the scanner is not worth reading.
10004
10067
  pattern: new RegExp(
10005
- `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"[^"\\n]*|'[^'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
10068
+ `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"|')\\s*\\b(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
10006
10069
  "i"
10007
10070
  )
10008
10071
  },
@@ -10015,7 +10078,26 @@ var CODE_RULES = [
10015
10078
  pattern: new RegExp(
10016
10079
  `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
10017
10080
  "i"
10018
- )
10081
+ ),
10082
+ /**
10083
+ * Interpolating a column list is not interpolating a value.
10084
+ *
10085
+ * const COLS = `tld, user_id, owner_email, price_usd`;
10086
+ * get(`SELECT ${COLS} FROM moshpit_tlds WHERE tld = ?`, [tld]);
10087
+ *
10088
+ * That query *is* parameterised: every value the caller supplies rides a
10089
+ * `?`, and the only thing spliced into the text is a constant written a
10090
+ * few lines up. Naming the column list once instead of repeating it in
10091
+ * fourteen queries is ordinary hygiene, and it is the shape this rule met
10092
+ * most often in practice — one repository produced eighteen findings this
10093
+ * way and not one of them could be injected into.
10094
+ *
10095
+ * The guard resolves the interpolations rather than trusting the shape, so
10096
+ * the moment a query mixes a constant with anything else —
10097
+ * `` `SELECT ${COLS} FROM t WHERE id = ${req.query.id}` `` — it is reported
10098
+ * again. `interpolationsAreConstant` requires *every* `${…}` to resolve.
10099
+ */
10100
+ constantInterpolationGuard: true
10019
10101
  },
10020
10102
  {
10021
10103
  id: "sql-format-call",
@@ -10046,7 +10128,8 @@ var CODE_RULES = [
10046
10128
  cwe: "CWE-78",
10047
10129
  severity: "critical",
10048
10130
  languages: ["javascript", "typescript"],
10049
- pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/
10131
+ pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/,
10132
+ constantInterpolationGuard: true
10050
10133
  },
10051
10134
  {
10052
10135
  id: "py-shell-command-string",
@@ -10064,7 +10147,22 @@ var CODE_RULES = [
10064
10147
  cwe: "CWE-78",
10065
10148
  severity: "critical",
10066
10149
  languages: ["go"],
10067
- pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
10150
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/,
10151
+ // A call whose whole argv is string literals cannot be injected into.
10152
+ // `exec.Command("cmd", "/c", "ver")` reads the Windows version; there is no
10153
+ // value in it for an attacker to reach, and the consequence above — that a
10154
+ // shell will interpret metacharacters — describes metacharacters nobody can
10155
+ // supply. gosec's G204 draws the line in the same place, and this was the
10156
+ // single finding a Go project got out of a whole scan before declining the
10157
+ // offer, which is an expensive way to report nothing.
10158
+ //
10159
+ // The guard has to end at the closing paren, so a literal followed by
10160
+ // anything else still reports: `"ls " + dir` leaves a `+` before the `)`,
10161
+ // `fmt.Sprintf(…)` leaves an identifier, and a bare variable leaves a name.
10162
+ // `(?:[^"\\]|\\.)*` rather than `[^"]*` so an escaped quote inside a
10163
+ // literal — `"echo \"hi\""` — does not end the literal early and drop the
10164
+ // guard on a line it should have covered.
10165
+ lineGuard: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"\s*(?:,\s*"(?:[^"\\]|\\.)*")*\s*,?\s*\)/
10068
10166
  },
10069
10167
  {
10070
10168
  id: "rb-backtick-interpolation",
@@ -10133,17 +10231,43 @@ var CODE_RULES = [
10133
10231
  cwe: "CWE-79",
10134
10232
  severity: "high",
10135
10233
  languages: ["javascript", "typescript"],
10136
- pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/,
10137
10234
  /**
10235
+ * The assignment alternative carries its own exemption, as a lookahead, so
10236
+ * that it is decided per assignment rather than per line.
10237
+ *
10138
10238
  * A whole-statement assignment of a string with no interpolation and no
10139
- * concatenation carries no data, so it cannot carry attacker data. This
10140
- * was the single largest source of noise: a codebase that builds its UI
10141
- * with innerHTML reports every static heading and spinner as XSS, and a
10142
- * rule that flags 40 safe lines to catch one real one gets switched off.
10239
+ * concatenation carries no data, so it cannot carry attacker data. This was
10240
+ * the single largest source of noise: a codebase that builds its UI with
10241
+ * innerHTML reports every static heading and spinner as XSS, and a rule
10242
+ * that flags 40 safe lines to catch one real one gets switched off.
10143
10243
  *
10144
- * Line-scoped on purpose see `lineGuard`.
10244
+ * Two things this has to get right, and a `lineGuard` could get neither:
10245
+ *
10246
+ * A statement ends at its semicolon, not at the newline. Anchoring to `$`
10247
+ * held the exemption for `el.innerHTML = '';` alone on a line and dropped
10248
+ * it the moment anything followed:
10249
+ *
10250
+ * function hide() { out.hidden = true; out.innerHTML = ''; rendered = null; }
10251
+ *
10252
+ * — the same assignment, clearing a node, reported as high-severity XSS
10253
+ * because two neighbours shared its line.
10254
+ *
10255
+ * And an exemption must not become a line-wide amnesty. A guard is tested
10256
+ * against the whole line, so one safe clear would exonerate a real sink
10257
+ * beside it:
10258
+ *
10259
+ * a.innerHTML = ''; b.innerHTML = userInput;
10260
+ *
10261
+ * As a lookahead the regex decides at each `=` it reaches, so the first
10262
+ * assignment is exempt and the second is still reported.
10263
+ *
10264
+ * The whitespace after `=` is matched *inside* the lookahead rather than
10265
+ * before it. Left outside, `\s*` backtracks to zero width, the lookahead
10266
+ * then starts on the space instead of the quote, fails to see a literal,
10267
+ * and the negative lookahead succeeds — reinstating every finding the
10268
+ * exemption was written to remove.
10145
10269
  */
10146
- lineGuard: /(?:innerHTML|outerHTML)\s*=\s*(?:'[^'\\]*'|"[^"\\]*"|`[^`$\\]*`)\s*;?\s*$/
10270
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=(?!\s*(?:'[^'\\\n]*'|"[^"\\\n]*"|`[^`$\\\n]*`)\s*(?:;|$))|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/
10147
10271
  },
10148
10272
  {
10149
10273
  id: "java-html-writer-concatenation",
@@ -10945,7 +11069,26 @@ var CODE_RULES = [
10945
11069
  cwe: "CWE-346",
10946
11070
  severity: "high",
10947
11071
  languages: ["javascript", "typescript"],
10948
- 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/
11072
+ 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/,
11073
+ /**
11074
+ * Parsing the request's own URL is not building a link from the Host
11075
+ * header, even though it is spelled with one.
11076
+ *
11077
+ * const url = new URL(request.url, `http://${request.headers.host}`);
11078
+ * const token = url.searchParams.get('token');
11079
+ *
11080
+ * `request.url` on a Node server is a path — `/ws?token=…` — and `new URL`
11081
+ * refuses a relative input without a base. The base exists to satisfy the
11082
+ * parser and is thrown away; only the path and query are ever read. Every
11083
+ * Node HTTP handler that wants a query parameter is written this way, so
11084
+ * the rule fired on the framework idiom rather than on the defect.
11085
+ *
11086
+ * Narrow on purpose: the first argument must be `req.url` itself. The
11087
+ * dangerous shape passes a *path* the application chose —
11088
+ * `new URL('/reset?t=…', `https://${req.headers.host}`)` — and that is what
11089
+ * produces an attacker-controlled link. It does not match this guard.
11090
+ */
11091
+ lineGuard: /\bnew\s+URL\s*\(\s*(?:req|request|ctx)(?:uest)?\s*\.\s*url\b\s*,/
10949
11092
  },
10950
11093
  // A recursive-merge prototype-pollution rule (`target[key] = source[key]`
10951
11094
  // with no `__proto__` guard) was built and dropped. The bare copy-by-key is
@@ -11013,6 +11156,94 @@ function fileTextOf(lines) {
11013
11156
  function withoutSingleQuoted(text) {
11014
11157
  return text.replace(/'[^'\n]*'/g, "''");
11015
11158
  }
11159
+ function calleeEndingAt(text, open) {
11160
+ let end = open;
11161
+ while (end > 0 && (text[end - 1] === " " || text[end - 1] === " ")) end -= 1;
11162
+ let start = end;
11163
+ while (start > 0 && /[\w$.]/.test(text[start - 1])) start -= 1;
11164
+ return text.slice(start, end);
11165
+ }
11166
+ function enclosingCallees(lines, index, back) {
11167
+ const before = lines.slice(Math.max(0, index - back), index).join("\n");
11168
+ const stack = [];
11169
+ let quote = null;
11170
+ for (let i = 0; i < before.length; i += 1) {
11171
+ const ch = before[i];
11172
+ if (quote) {
11173
+ if (ch === "\\") i += 1;
11174
+ else if (ch === quote) quote = null;
11175
+ continue;
11176
+ }
11177
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
11178
+ else if (ch === "(") stack.push(calleeEndingAt(before, i));
11179
+ else if (ch === ")") stack.pop();
11180
+ }
11181
+ return stack;
11182
+ }
11183
+ function interpolations(line) {
11184
+ const found = [];
11185
+ for (let i = 0; ; ) {
11186
+ const start = line.indexOf("${", i);
11187
+ if (start === -1) break;
11188
+ const end = line.indexOf("}", start + 2);
11189
+ if (end === -1) return null;
11190
+ found.push(line.slice(start + 2, end).trim());
11191
+ i = end + 1;
11192
+ }
11193
+ return found.length > 0 ? found : null;
11194
+ }
11195
+ function isConstantString(name, fileText) {
11196
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11197
+ return new RegExp(
11198
+ `\\bconst\\s+${escaped}\\s*(?::[^=\\n]+)?=\\s*(?:'[^'\\n]*'|"[^"\\n]*"|\`[^\`$\\n]*\`)`
11199
+ ).test(fileText);
11200
+ }
11201
+ function interpolationsAreConstant(line, fileText) {
11202
+ const found = interpolations(line);
11203
+ if (!found) return false;
11204
+ return found.every(
11205
+ (expr) => /^[A-Za-z_$][\w$]*$/.test(expr) && isConstantString(expr, fileText)
11206
+ );
11207
+ }
11208
+ var FILL_LOOKAHEAD = 16;
11209
+ var NAME = String.raw`(?<![\w$])([A-Za-z_$][\w$]*)`;
11210
+ var ALLOCATION_BINDING = new RegExp(
11211
+ `${NAME}\\s*=\\s*(?:new\\s+Buffer\\s*\\(|Buffer\\s*\\.\\s*allocUnsafe(?:Slow)?\\s*\\()`
11212
+ );
11213
+ function allocationBinding(line) {
11214
+ return ALLOCATION_BINDING.exec(line)?.[1] ?? null;
11215
+ }
11216
+ var WRITE_SHAPES = [
11217
+ // `src.copy(name, …)` — name is the destination.
11218
+ /\.\s*copy\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]/g,
11219
+ // `name.fill(…)`, `name.write*(…)`, `name.set(…)`.
11220
+ new RegExp(`${NAME}\\s*\\.\\s*(?:fill|set|write[A-Za-z0-9]*)\\s*\\(`, "g"),
11221
+ // `name[i] = …`, but not `name[i] === …`.
11222
+ new RegExp(`${NAME}\\s*\\[[^\\]\\n]*\\]\\s*=(?!=)`, "g")
11223
+ ];
11224
+ function writesInto(text, name) {
11225
+ for (const shape of WRITE_SHAPES) {
11226
+ shape.lastIndex = 0;
11227
+ for (let found = shape.exec(text); found !== null; found = shape.exec(text)) {
11228
+ if (found[1] === name) return true;
11229
+ }
11230
+ }
11231
+ return false;
11232
+ }
11233
+ function bufferFilledBeforeUse(ctx) {
11234
+ const line = ctx.lines[ctx.index] ?? "";
11235
+ if (/\ballocUnsafe(?:Slow)?\s*\([^)\n]*\)\s*\.\s*fill\s*\(/.test(line)) return true;
11236
+ const name = allocationBinding(line);
11237
+ if (!name) return false;
11238
+ if (writesInto(line.slice(line.indexOf("=") + 1), name)) return true;
11239
+ const last = Math.min(ctx.lines.length - 1, ctx.index + FILL_LOOKAHEAD);
11240
+ for (let i = ctx.index + 1; i <= last; i += 1) {
11241
+ const next = ctx.lines[i] ?? "";
11242
+ if (skippable(next, i, ctx.prose)) continue;
11243
+ if (writesInto(next, name)) return true;
11244
+ }
11245
+ return false;
11246
+ }
11016
11247
  function evaluateRule(rule, ctx) {
11017
11248
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
11018
11249
  const line = ctx.lines[ctx.index] ?? "";
@@ -11024,6 +11255,14 @@ function evaluateRule(rule, ctx) {
11024
11255
  const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
11025
11256
  if (rule.requires && !rule.requires.test(context)) return null;
11026
11257
  if (rule.lineGuard?.test(line)) return null;
11258
+ if (rule.enclosingCallGuard) {
11259
+ const callees = enclosingCallees(ctx.lines, ctx.index, back);
11260
+ if (callees.some((callee) => rule.enclosingCallGuard.test(callee))) return null;
11261
+ }
11262
+ if (rule.constantInterpolationGuard && interpolationsAreConstant(line, fileTextOf(ctx.lines))) {
11263
+ return null;
11264
+ }
11265
+ if (rule.filledBeforeUseGuard && bufferFilledBeforeUse(ctx)) return null;
11027
11266
  const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
11028
11267
  if (guard && (guard.test(line) || guard.test(context))) return null;
11029
11268
  const untrusted = untrustedPatternFor(ctx.language);
@@ -11606,7 +11845,8 @@ var SECRET_RULES = [
11606
11845
  pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_.+/=-]*/,
11607
11846
  severity: "medium",
11608
11847
  cwe: "CWE-798",
11609
- consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived."
11848
+ consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived.",
11849
+ keywordShaped: true
11610
11850
  },
11611
11851
  {
11612
11852
  id: "secret-generic-api-key",
@@ -11617,7 +11857,8 @@ var SECRET_RULES = [
11617
11857
  pattern: /(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9\-_.]{20,})['"]/i,
11618
11858
  severity: "high",
11619
11859
  cwe: "CWE-798",
11620
- consequence: "Whatever the third-party service lets the key do, for as long as it stays valid."
11860
+ consequence: "Whatever the third-party service lets the key do, for as long as it stays valid.",
11861
+ keywordShaped: true
11621
11862
  },
11622
11863
  {
11623
11864
  id: "secret-generic-credential",
@@ -11625,7 +11866,8 @@ var SECRET_RULES = [
11625
11866
  pattern: /(?:secret|password|passwd|pwd|token)\s*[=:]\s*['"]([^'"\s]{8,})['"]/i,
11626
11867
  severity: "high",
11627
11868
  cwe: "CWE-798",
11628
- consequence: "A password in source is a password in every clone, fork and CI cache of that source."
11869
+ consequence: "A password in source is a password in every clone, fork and CI cache of that source.",
11870
+ keywordShaped: true
11629
11871
  },
11630
11872
  {
11631
11873
  id: "secret-hex-token",
@@ -11633,7 +11875,8 @@ var SECRET_RULES = [
11633
11875
  pattern: /(?:token|key|secret|auth|signing)\w*\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/i,
11634
11876
  severity: "medium",
11635
11877
  cwe: "CWE-798",
11636
- consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign."
11878
+ consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign.",
11879
+ keywordShaped: true
11637
11880
  }
11638
11881
  ];
11639
11882
  var KNOWN_PLACEHOLDERS = [
@@ -11664,6 +11907,62 @@ var KNOWN_PLACEHOLDERS = [
11664
11907
  function isKnownPlaceholder(text) {
11665
11908
  return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
11666
11909
  }
11910
+ function isPlaceholderAttribute(line, value) {
11911
+ const at = line.lastIndexOf(value);
11912
+ if (at === -1) return false;
11913
+ return /(?:^|\s)(?:aria-)?placeholder\s*=\s*[{("'`]*$/i.test(line.slice(0, at));
11914
+ }
11915
+ function isVariableReference(value) {
11916
+ const trimmed = value.trim();
11917
+ const braced = /^\$\{\s*([A-Za-z_][\w.]*)\s*(?::?[-=+?]([\s\S]*))?\}$/.exec(trimmed);
11918
+ if (braced) {
11919
+ const fallback2 = braced[2];
11920
+ if (fallback2 === void 0 || fallback2.trim() === "") return true;
11921
+ return /^\$\{?[A-Za-z_][\w.]*\}?$/.test(fallback2.trim());
11922
+ }
11923
+ return /^\$[A-Za-z_]\w*$/.test(trimmed) || // $VAR
11924
+ /^\$\([\s\S]*\)$/.test(trimmed) || // $(command substitution)
11925
+ /^%[A-Za-z_]\w*%$/.test(trimmed) || // %VAR% on Windows
11926
+ /^\{\{[\s\S]*\}\}$/.test(trimmed) || // {{ template }}
11927
+ /^#\{[\s\S]*\}$/.test(trimmed) || // #{ruby}
11928
+ /^<%=?[\s\S]*%>$/.test(trimmed);
11929
+ }
11930
+ var FIXTURE_STEMS = [
11931
+ "test",
11932
+ "mock",
11933
+ "fake",
11934
+ "dummy",
11935
+ "stub",
11936
+ "sample",
11937
+ "example",
11938
+ "placeholder",
11939
+ "fixture",
11940
+ "invalid",
11941
+ "expired",
11942
+ "forged",
11943
+ "bogus",
11944
+ "notreal",
11945
+ "nonexistent",
11946
+ "changeme",
11947
+ "foobar",
11948
+ "lorem"
11949
+ ];
11950
+ var KEY_NOISE = /* @__PURE__ */ new Set(["const", "this", "return", "await", "async", "expect", "value"]);
11951
+ function words(text) {
11952
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^A-Za-z]+/g, " ").toLowerCase().split(" ").filter(Boolean);
11953
+ }
11954
+ function isTestFixtureValue(line, value) {
11955
+ const valueWords = words(value);
11956
+ if (valueWords.some((word) => FIXTURE_STEMS.some((stem) => word.startsWith(stem)))) return true;
11957
+ return describesItsOwnKey(line, value);
11958
+ }
11959
+ function describesItsOwnKey(line, value) {
11960
+ if (value.length > 48) return false;
11961
+ const valueAt = line.lastIndexOf(value);
11962
+ const key = valueAt === -1 ? line : line.slice(0, valueAt);
11963
+ const flattened = words(value).join("");
11964
+ return words(key).filter((word) => word.length >= 4 && !KEY_NOISE.has(word)).some((word) => flattened.includes(word));
11965
+ }
11667
11966
  function redactSecret(line) {
11668
11967
  return line.replace(/([A-Za-z0-9+/=_.-]{12,})/g, (match) => {
11669
11968
  if (match.length <= 12) return match;
@@ -11841,9 +12140,9 @@ function languageOfShebang(firstLine) {
11841
12140
  }
11842
12141
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
11843
12142
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
11844
- var FOREIGN_CREDENTIAL = /\b(?:nolint:[\w,]*gosec|nosec)\b[^\n]*\bG101\b|\bG101\b[^\n]*\b(?:nolint:[\w,]*gosec|nosec)\b/;
11845
- function foreignCredentialMark(line) {
11846
- return FOREIGN_CREDENTIAL.test(line);
12143
+ var FOREIGN_SECURITY = /\b(?:nolint:[\w,]*gosec|nosec)\b/;
12144
+ function foreignSecurityMark(line) {
12145
+ return FOREIGN_SECURITY.test(line);
11847
12146
  }
11848
12147
  function collectSuppressions(lines) {
11849
12148
  const byLine = /* @__PURE__ */ new Map();
@@ -11869,31 +12168,68 @@ function isSuppressed(suppressions, index, ruleId) {
11869
12168
  }
11870
12169
  function isTestPath(relativePath) {
11871
12170
  const p = relativePath.replace(/\\/g, "/");
11872
- 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);
12171
+ 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);
12172
+ }
12173
+ function isDocPath(relativePath) {
12174
+ const p = relativePath.replace(/\\/g, "/");
12175
+ return /(?:^|\/)(?:docs?|examples?|samples?)\//i.test(p) || /\.(?:md|mdx|markdown|rst|adoc)$/i.test(p);
11873
12176
  }
12177
+ var SOFTENING_ORDER = [
12178
+ "suppressed",
12179
+ "placeholder",
12180
+ "self-describing",
12181
+ "test",
12182
+ "docs"
12183
+ ];
12184
+ function softening(reasons) {
12185
+ return SOFTENING_ORDER.find((reason) => reasons[reason]) ?? null;
12186
+ }
12187
+ var SECRET_SOFTENING = {
12188
+ test: "in a test file \u2014 usually a fixture, still worth confirming it is not a live credential",
12189
+ docs: "in documentation \u2014 usually an illustrative example, still worth confirming it is not a live credential",
12190
+ suppressed: "on a line already marked as a false positive for another linter's security rule",
12191
+ placeholder: "in example text an empty input field shows, not in data",
12192
+ "self-describing": "in a value that repeats the name of the field holding it \u2014 usually a description of a credential rather than one"
12193
+ };
12194
+ var CODE_SOFTENING = {
12195
+ test: "in a test file, where the construct is ordinary",
12196
+ docs: "in documentation or example code, which nothing runs",
12197
+ suppressed: "on a line another linter's security suppression already covers",
12198
+ placeholder: "in example text rather than in data",
12199
+ "self-describing": "in a value that describes itself"
12200
+ };
11874
12201
  function scanText(relativePath, text, language = languageOf(relativePath)) {
11875
12202
  const findings = [];
11876
12203
  const lines = text.split("\n");
11877
12204
  const suppressions = collectSuppressions(lines);
11878
12205
  const inTests = isTestPath(relativePath);
12206
+ const inDocs = isDocPath(relativePath);
11879
12207
  lines.forEach((line, index) => {
11880
12208
  for (const rule of SECRET_RULES) {
11881
12209
  const match = rule.pattern.exec(line);
11882
12210
  if (!match) continue;
11883
12211
  if (isKnownPlaceholder(match[0])) continue;
11884
12212
  if (isSuppressed(suppressions, index, rule.id)) continue;
11885
- const marked = foreignCredentialMark(line);
12213
+ const value = match[1] ?? match[0];
12214
+ if (isVariableReference(value)) continue;
12215
+ if (inTests && rule.keywordShaped && isTestFixtureValue(line, value)) continue;
12216
+ const soft = softening({
12217
+ test: inTests,
12218
+ suppressed: foreignSecurityMark(line),
12219
+ placeholder: isPlaceholderAttribute(line, value),
12220
+ "self-describing": rule.keywordShaped === true && describesItsOwnKey(line, value)
12221
+ });
11886
12222
  findings.push({
11887
12223
  ruleId: rule.id,
11888
12224
  title: rule.name,
11889
12225
  file: relativePath,
11890
12226
  line: index + 1,
11891
- // Reported but not blocking in tests see isTestPath. The same goes
11892
- // for a line another linter's credential rule was already told about.
11893
- severity: inTests || marked ? "low" : rule.severity,
12227
+ // Reported but not blocking wherever context weakens the claim see
12228
+ // `softening`. Never dropped: the count is the same either way.
12229
+ severity: soft ? "low" : rule.severity,
11894
12230
  // A matched credential format is the finding, not a proxy for one.
11895
12231
  confidence: "evidence",
11896
- 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`,
12232
+ message: soft ? `Possible ${rule.name} detected ${SECRET_SOFTENING[soft]}` : `Possible ${rule.name} detected`,
11897
12233
  consequence: rule.consequence,
11898
12234
  cwe: rule.cwe,
11899
12235
  excerpt: redactSecret(line.trim()).slice(0, 200),
@@ -11908,14 +12244,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
11908
12244
  if (isSuppressed(suppressions, index, rule.id)) continue;
11909
12245
  const match = evaluateRule(rule, { lines, index, language, prose });
11910
12246
  if (!match) continue;
12247
+ const soft = softening({
12248
+ test: inTests,
12249
+ docs: inDocs,
12250
+ suppressed: foreignSecurityMark(lines[index] ?? "")
12251
+ });
11911
12252
  findings.push({
11912
12253
  ruleId: rule.id,
11913
12254
  title: rule.title,
11914
12255
  file: relativePath,
11915
12256
  line: index + 1,
11916
- severity: match.severity,
12257
+ severity: soft ? "low" : match.severity,
11917
12258
  confidence: match.confidence,
11918
- message: `${rule.title} (${rule.cwe})`,
12259
+ message: soft ? `${rule.title} (${rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${rule.title} (${rule.cwe})`,
11919
12260
  consequence: rule.consequence,
11920
12261
  cwe: rule.cwe,
11921
12262
  excerpt: (lines[index] ?? "").trim().slice(0, 200),
@@ -11926,14 +12267,19 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
11926
12267
  for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
11927
12268
  const index = match.line - 1;
11928
12269
  if (isSuppressed(suppressions, index, match.rule.id)) continue;
12270
+ const soft = softening({
12271
+ test: inTests,
12272
+ docs: inDocs,
12273
+ suppressed: foreignSecurityMark(lines[index] ?? "")
12274
+ });
11929
12275
  findings.push({
11930
12276
  ruleId: match.rule.id,
11931
12277
  title: match.rule.title,
11932
12278
  file: relativePath,
11933
12279
  line: match.line,
11934
- severity: match.severity,
12280
+ severity: soft ? "low" : match.severity,
11935
12281
  confidence: "pattern",
11936
- message: `${match.rule.title} (${match.rule.cwe})`,
12282
+ message: soft ? `${match.rule.title} (${match.rule.cwe}) \u2014 ${CODE_SOFTENING[soft]}` : `${match.rule.title} (${match.rule.cwe})`,
11937
12283
  consequence: match.rule.consequence,
11938
12284
  cwe: match.rule.cwe,
11939
12285
  excerpt: (lines[index] ?? "").trim().slice(0, 200),
@@ -11965,8 +12311,8 @@ function meetsFailThreshold(findings, threshold) {
11965
12311
  }
11966
12312
 
11967
12313
  // ../../packages/scan/src/node/walk.ts
11968
- var import_node_fs5 = require("fs");
11969
- var import_node_path2 = require("path");
12314
+ var import_node_fs6 = require("fs");
12315
+ var import_node_path3 = require("path");
11970
12316
  function compileExcludes(patterns) {
11971
12317
  const matchers = patterns.map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#")).map(compilePattern);
11972
12318
  if (matchers.length === 0) return () => false;
@@ -12037,7 +12383,7 @@ function matchPrefix(parts, segs) {
12037
12383
  }
12038
12384
  function readIgnoreFile(root) {
12039
12385
  try {
12040
- return (0, import_node_fs5.readFileSync)((0, import_node_path2.join)(root, ".threatcrushignore"), "utf-8").split("\n");
12386
+ return (0, import_node_fs6.readFileSync)((0, import_node_path3.join)(root, ".threatcrushignore"), "utf-8").split("\n");
12041
12387
  } catch {
12042
12388
  return [];
12043
12389
  }
@@ -12053,16 +12399,16 @@ function scanPath(targetPath, options = {}) {
12053
12399
  let excluded = 0;
12054
12400
  const rootIsDirectory = (() => {
12055
12401
  try {
12056
- return (0, import_node_fs5.statSync)(targetPath).isDirectory();
12402
+ return (0, import_node_fs6.statSync)(targetPath).isDirectory();
12057
12403
  } catch {
12058
12404
  return true;
12059
12405
  }
12060
12406
  })();
12061
- const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path2.dirname)(targetPath);
12407
+ const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path3.dirname)(targetPath);
12062
12408
  const isExcluded = compileExcludes([...options.exclude ?? [], ...readIgnoreFile(walkRoot)]);
12063
12409
  const scanFile = (fullPath, filename) => {
12064
12410
  const relativePath = toRelative(walkRoot, fullPath);
12065
- const extension = (0, import_node_path2.extname)(filename).toLowerCase();
12411
+ const extension = (0, import_node_path3.extname)(filename).toLowerCase();
12066
12412
  const isManifest = filename === "package.json" || filename === "requirements.txt";
12067
12413
  const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
12068
12414
  const mayDeclareInterpreter = !scannable && !isManifest && extension === "";
@@ -12074,26 +12420,26 @@ function scanPath(targetPath, options = {}) {
12074
12420
  let handle;
12075
12421
  let declared = null;
12076
12422
  try {
12077
- handle = (0, import_node_fs5.openSync)(fullPath, "r");
12423
+ handle = (0, import_node_fs6.openSync)(fullPath, "r");
12078
12424
  } catch {
12079
12425
  unreadable.push(relativePath);
12080
12426
  return;
12081
12427
  }
12082
12428
  try {
12083
- if ((0, import_node_fs5.fstatSync)(handle).size > maxFileBytes) return;
12429
+ if ((0, import_node_fs6.fstatSync)(handle).size > maxFileBytes) return;
12084
12430
  if (mayDeclareInterpreter) {
12085
12431
  const prefix = Buffer.alloc(128);
12086
- const read = (0, import_node_fs5.readSync)(handle, prefix, 0, prefix.length, 0);
12432
+ const read = (0, import_node_fs6.readSync)(handle, prefix, 0, prefix.length, 0);
12087
12433
  declared = languageOfShebang(prefix.subarray(0, read).toString("utf-8").split("\n", 1)[0] ?? "");
12088
12434
  if (!declared) return;
12089
12435
  }
12090
- text = (0, import_node_fs5.readFileSync)(handle, "utf-8");
12436
+ text = (0, import_node_fs6.readFileSync)(handle, "utf-8");
12091
12437
  } catch {
12092
12438
  unreadable.push(relativePath);
12093
12439
  return;
12094
12440
  } finally {
12095
12441
  try {
12096
- (0, import_node_fs5.closeSync)(handle);
12442
+ (0, import_node_fs6.closeSync)(handle);
12097
12443
  } catch {
12098
12444
  }
12099
12445
  }
@@ -12111,13 +12457,13 @@ function scanPath(targetPath, options = {}) {
12111
12457
  const walk = (currentPath) => {
12112
12458
  let entries;
12113
12459
  try {
12114
- entries = (0, import_node_fs5.readdirSync)(currentPath, { withFileTypes: true });
12460
+ entries = (0, import_node_fs6.readdirSync)(currentPath, { withFileTypes: true });
12115
12461
  } catch {
12116
12462
  unreadable.push(toRelative(walkRoot, currentPath));
12117
12463
  return;
12118
12464
  }
12119
12465
  for (const entry of entries) {
12120
- const fullPath = (0, import_node_path2.join)(currentPath, entry.name);
12466
+ const fullPath = (0, import_node_path3.join)(currentPath, entry.name);
12121
12467
  const relativePath = toRelative(walkRoot, fullPath);
12122
12468
  if (entry.isDirectory()) {
12123
12469
  if (SKIP_DIRS.has(entry.name)) continue;
@@ -12141,7 +12487,7 @@ function scanPath(targetPath, options = {}) {
12141
12487
  } else if (isExcluded(toRelative(walkRoot, targetPath))) {
12142
12488
  excluded += 1;
12143
12489
  } else {
12144
- scanFile(targetPath, (0, import_node_path2.basename)(targetPath));
12490
+ scanFile(targetPath, (0, import_node_path3.basename)(targetPath));
12145
12491
  }
12146
12492
  if (options.missingControls) findings.push(...controls.findings());
12147
12493
  const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
@@ -12173,32 +12519,37 @@ function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
12173
12519
  }
12174
12520
  }
12175
12521
  function toRelative(base, target) {
12176
- const rel = (0, import_node_path2.relative)(base, target);
12177
- return (rel === "" ? target : rel).split(import_node_path2.sep).join("/");
12522
+ const rel = (0, import_node_path3.relative)(base, target);
12523
+ return (rel === "" ? target : rel).split(import_node_path3.sep).join("/");
12178
12524
  }
12179
12525
 
12180
12526
  // ../../packages/scan/src/node/dependencies.ts
12181
- var import_node_fs6 = require("fs");
12182
- var import_node_path3 = require("path");
12183
- var LOCKFILES = [
12184
- { file: "package-lock.json", ecosystem: "npm" },
12185
- { file: "pnpm-lock.yaml", ecosystem: "npm" },
12186
- { file: "yarn.lock", ecosystem: "npm" },
12187
- { file: "requirements.txt", ecosystem: "PyPI" },
12188
- { file: "Pipfile.lock", ecosystem: "PyPI" }
12189
- ];
12527
+ var import_node_fs7 = require("fs");
12528
+ var import_node_path4 = require("path");
12190
12529
  var MAX_DEPS_PER_LOCKFILE = 50;
12191
12530
  async function scanDependencies(targetPath) {
12192
12531
  const findings = [];
12193
- for (const { file, ecosystem } of LOCKFILES) {
12194
- const lockPath = (0, import_node_path3.join)(targetPath, file);
12195
- if (!(0, import_node_fs6.existsSync)(lockPath)) continue;
12532
+ for (const { file, ecosystem, parse } of LOCKFILES) {
12533
+ const lockPath = (0, import_node_path4.join)(targetPath, file);
12534
+ if (!(0, import_node_fs7.existsSync)(lockPath)) continue;
12196
12535
  let deps;
12197
12536
  try {
12198
- deps = parseDependencies(lockPath, file);
12537
+ deps = dedupe(parse((0, import_node_fs7.readFileSync)(lockPath, "utf-8")));
12199
12538
  } catch {
12200
12539
  continue;
12201
12540
  }
12541
+ if (deps.length === 0) {
12542
+ findings.push(incompleteFinding(file, "No dependencies could be read from this lockfile."));
12543
+ continue;
12544
+ }
12545
+ if (deps.length > MAX_DEPS_PER_LOCKFILE) {
12546
+ findings.push(
12547
+ incompleteFinding(
12548
+ file,
12549
+ `Only the first ${MAX_DEPS_PER_LOCKFILE} of ${deps.length} locked packages were checked against OSV.`
12550
+ )
12551
+ );
12552
+ }
12202
12553
  for (const dep of deps.slice(0, MAX_DEPS_PER_LOCKFILE)) {
12203
12554
  let vulns;
12204
12555
  try {
@@ -12225,6 +12576,20 @@ async function scanDependencies(targetPath) {
12225
12576
  }
12226
12577
  return findings;
12227
12578
  }
12579
+ function incompleteFinding(file, message) {
12580
+ return {
12581
+ ruleId: "dependency-scan-incomplete",
12582
+ title: "Dependency scan incomplete",
12583
+ file,
12584
+ line: 1,
12585
+ severity: "low",
12586
+ confidence: "evidence",
12587
+ message,
12588
+ consequence: "Advisories affecting the unchecked packages would not appear in this report.",
12589
+ excerpt: file,
12590
+ category: "dependency"
12591
+ };
12592
+ }
12228
12593
  function severityFromCvss(score) {
12229
12594
  if (!score) return "medium";
12230
12595
  const value = Number.parseFloat(score);
@@ -12234,26 +12599,131 @@ function severityFromCvss(score) {
12234
12599
  if (value >= 4) return "medium";
12235
12600
  return "low";
12236
12601
  }
12237
- function parseDependencies(lockPath, filename) {
12602
+ function dedupe(deps) {
12603
+ const seen = /* @__PURE__ */ new Set();
12604
+ const unique = [];
12605
+ for (const dep of deps) {
12606
+ const key = `${dep.name}@${dep.version}`;
12607
+ if (seen.has(key)) continue;
12608
+ seen.add(key);
12609
+ unique.push(dep);
12610
+ }
12611
+ return unique;
12612
+ }
12613
+ function splitNameVersion(spec) {
12614
+ const at = spec.lastIndexOf("@");
12615
+ if (at <= 0) return null;
12616
+ const name = spec.slice(0, at);
12617
+ const version = spec.slice(at + 1);
12618
+ if (!name || !version) return null;
12619
+ return { name, version };
12620
+ }
12621
+ function exactVersion(raw) {
12622
+ const version = raw.trim().replace(/^[=v]+/, "");
12623
+ return /^[0-9][0-9a-zA-Z.+-]*$/.test(version) ? version : null;
12624
+ }
12625
+ function parsePackageLock(content) {
12626
+ const lock = JSON.parse(content);
12627
+ const packages = lock.packages ?? lock.dependencies ?? {};
12628
+ const deps = [];
12629
+ for (const [key, value] of Object.entries(packages)) {
12630
+ const name = key.replace(/^.*node_modules\//, "");
12631
+ const version = value?.version;
12632
+ if (name && version && !name.startsWith(".")) deps.push({ name, version });
12633
+ }
12634
+ return deps;
12635
+ }
12636
+ function parsePnpmLock(content) {
12637
+ const deps = [];
12638
+ let inPackages = false;
12639
+ for (const line of content.split("\n")) {
12640
+ if (/^[a-zA-Z]/.test(line)) {
12641
+ inPackages = line.startsWith("packages:");
12642
+ continue;
12643
+ }
12644
+ if (!inPackages) continue;
12645
+ const match = /^ {2}(?! )(.+):\s*$/.exec(line);
12646
+ if (!match?.[1]) continue;
12647
+ let key = match[1].trim().replace(/^['"]|['"]$/g, "");
12648
+ key = key.replace(/^\//, "");
12649
+ key = key.replace(/\(.*$/, "");
12650
+ let name;
12651
+ let rawVersion;
12652
+ const slashed = /^(@?[^@]+)\/([0-9][^/]*)$/.exec(key);
12653
+ if (slashed?.[1] && slashed[2]) {
12654
+ name = slashed[1];
12655
+ rawVersion = slashed[2];
12656
+ } else {
12657
+ const dep = splitNameVersion(key);
12658
+ if (!dep) continue;
12659
+ name = dep.name;
12660
+ rawVersion = dep.version;
12661
+ }
12662
+ const version = exactVersion(rawVersion.replace(/_.*$/, ""));
12663
+ if (version) deps.push({ name, version });
12664
+ }
12665
+ return deps;
12666
+ }
12667
+ function parseYarnLock(content) {
12238
12668
  const deps = [];
12239
- if (filename === "package-lock.json") {
12240
- const lock = JSON.parse((0, import_node_fs6.readFileSync)(lockPath, "utf-8"));
12241
- const packages = lock.packages ?? lock.dependencies ?? {};
12242
- for (const [key, value] of Object.entries(packages)) {
12243
- const name = key.replace(/^node_modules\//, "");
12244
- const version = value?.version;
12245
- if (name && version && !name.startsWith(".")) deps.push({ name, version });
12669
+ let pendingName = null;
12670
+ for (const line of content.split("\n")) {
12671
+ if (line.startsWith("#") || line.trim() === "") continue;
12672
+ if (!/^\s/.test(line)) {
12673
+ pendingName = null;
12674
+ const header = line.replace(/:\s*$/, "");
12675
+ const first = header.split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
12676
+ if (!first) continue;
12677
+ if (!first.includes("@") || first === "__metadata") continue;
12678
+ if (/@(?:workspace|file|link|portal|exec|patch):/.test(first)) continue;
12679
+ const dep = splitNameVersion(first.replace(/@npm:/, "@"));
12680
+ if (dep) pendingName = dep.name;
12681
+ continue;
12246
12682
  }
12247
- return deps;
12683
+ if (!pendingName) continue;
12684
+ const version = /^\s+version:?\s+["']?([^"'\s]+)["']?\s*$/.exec(line);
12685
+ if (!version?.[1]) continue;
12686
+ const exact = exactVersion(version[1]);
12687
+ if (exact) deps.push({ name: pendingName, version: exact });
12688
+ pendingName = null;
12248
12689
  }
12249
- if (filename === "requirements.txt") {
12250
- for (const line of (0, import_node_fs6.readFileSync)(lockPath, "utf-8").split("\n")) {
12251
- const match = /^([a-zA-Z0-9_.-]+)==([0-9.]+)/.exec(line);
12252
- if (match?.[1] && match[2]) deps.push({ name: match[1], version: match[2] });
12690
+ return deps;
12691
+ }
12692
+ function parsePipfileLock(content) {
12693
+ const lock = JSON.parse(content);
12694
+ const deps = [];
12695
+ for (const section of ["default", "develop"]) {
12696
+ const packages = lock[section];
12697
+ if (!packages || typeof packages !== "object") continue;
12698
+ for (const [name, value] of Object.entries(packages)) {
12699
+ const version = exactVersion(String(value?.version ?? "").replace(/^==/, ""));
12700
+ if (name && version) deps.push({ name, version });
12253
12701
  }
12254
12702
  }
12255
12703
  return deps;
12256
12704
  }
12705
+ function parseRequirementsTxt(content) {
12706
+ const deps = [];
12707
+ for (const raw of content.split("\n")) {
12708
+ const line = raw.split("#")[0]?.split(";")[0]?.trim();
12709
+ if (!line || line.startsWith("-")) continue;
12710
+ const match = /^([a-zA-Z0-9._-]+)\s*(?:\[[^\]]*\])?\s*==\s*([^\s,]+)/.exec(line);
12711
+ if (!match?.[1] || !match[2]) continue;
12712
+ const version = exactVersion(match[2]);
12713
+ if (version) deps.push({ name: match[1], version });
12714
+ }
12715
+ return deps;
12716
+ }
12717
+ var LOCKFILES = [
12718
+ { file: "package-lock.json", ecosystem: "npm", parse: parsePackageLock },
12719
+ { file: "pnpm-lock.yaml", ecosystem: "npm", parse: parsePnpmLock },
12720
+ { file: "yarn.lock", ecosystem: "npm", parse: parseYarnLock },
12721
+ { file: "requirements.txt", ecosystem: "PyPI", parse: parseRequirementsTxt },
12722
+ { file: "Pipfile.lock", ecosystem: "PyPI", parse: parsePipfileLock }
12723
+ ];
12724
+ var LOCKFILE_PARSERS = Object.fromEntries(
12725
+ LOCKFILES.map((entry) => [entry.file, entry.parse])
12726
+ );
12257
12727
  function isValidPackageName(name) {
12258
12728
  return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
12259
12729
  }
@@ -12278,12 +12748,12 @@ async function queryOsv(name, version, ecosystem) {
12278
12748
  }
12279
12749
 
12280
12750
  // ../../packages/scan/src/node/sarif.ts
12281
- var import_node_crypto = require("crypto");
12282
- var import_node_path4 = require("path");
12751
+ var import_node_crypto2 = require("crypto");
12752
+ var import_node_path5 = require("path");
12283
12753
  var FINGERPRINT_KEY = "threatcrush/contentHash/v1";
12284
12754
  function fingerprintOf(finding) {
12285
12755
  const content = finding.excerpt.replace(/\s+/g, " ").trim();
12286
- return (0, import_node_crypto.createHash)("sha256").update(`${finding.ruleId}
12756
+ return (0, import_node_crypto2.createHash)("sha256").update(`${finding.ruleId}
12287
12757
  ${finding.file}
12288
12758
  ${content}`).digest("hex").slice(0, 32);
12289
12759
  }
@@ -12317,11 +12787,11 @@ function securitySeverity(severity) {
12317
12787
  }
12318
12788
  }
12319
12789
  function toArtifactUri(filePath, base, prefix = "", root = base) {
12320
- const absolute = (0, import_node_path4.isAbsolute)(filePath) ? filePath : (0, import_node_path4.resolve)(root, filePath);
12321
- const relativePath = (0, import_node_path4.relative)(base, absolute);
12790
+ const absolute = (0, import_node_path5.isAbsolute)(filePath) ? filePath : (0, import_node_path5.resolve)(root, filePath);
12791
+ const relativePath = (0, import_node_path5.relative)(base, absolute);
12322
12792
  const escapedOut = relativePath.startsWith("..") || relativePath === "";
12323
12793
  const chosen = escapedOut ? absolute : relativePath;
12324
- const posix = chosen.split(import_node_path4.sep).join("/").replace(/^\.\//, "");
12794
+ const posix = chosen.split(import_node_path5.sep).join("/").replace(/^\.\//, "");
12325
12795
  if (!prefix || escapedOut) return posix;
12326
12796
  const trimmed = prefix.replace(/^\/+|\/+$/g, "");
12327
12797
  return trimmed ? `${trimmed}/${posix}` : posix;
@@ -12411,11 +12881,11 @@ ${finding.consequence}` : `**${finding.title}**`
12411
12881
  // src/commands/scan.ts
12412
12882
  function readVersion() {
12413
12883
  for (const candidate of [
12414
- (0, import_node_path5.join)(__dirname, "..", "package.json"),
12415
- (0, import_node_path5.join)(__dirname, "..", "..", "package.json")
12884
+ (0, import_node_path6.join)(__dirname, "..", "package.json"),
12885
+ (0, import_node_path6.join)(__dirname, "..", "..", "package.json")
12416
12886
  ]) {
12417
12887
  try {
12418
- return JSON.parse((0, import_node_fs7.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
12888
+ return JSON.parse((0, import_node_fs8.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
12419
12889
  } catch {
12420
12890
  }
12421
12891
  }
@@ -12482,7 +12952,7 @@ async function scanCommand(targetPath, options = {}) {
12482
12952
  const say = machineReadable ? (line) => process.stderr.write(`${line}
12483
12953
  `) : (line) => process.stdout.write(`${line}
12484
12954
  `);
12485
- if (!(0, import_node_fs7.existsSync)(targetPath)) {
12955
+ if (!(0, import_node_fs8.existsSync)(targetPath)) {
12486
12956
  say(source_default.red(`Scan target does not exist: ${targetPath}`));
12487
12957
  process.exitCode = 2;
12488
12958
  return failedResult(targetPath, `no such path: ${targetPath}`);
@@ -12578,7 +13048,7 @@ function emitMachineReadable(format, outcome, targetPath, options, say) {
12578
13048
  // and it fails silently. `--path-prefix` covers the remaining case:
12579
13049
  // a scan run from inside the subdirectory it is scanning.
12580
13050
  base: process.cwd(),
12581
- root: (0, import_node_path5.resolve)(outcome.root)
13051
+ root: (0, import_node_path6.resolve)(outcome.root)
12582
13052
  }) : {
12583
13053
  tool: "threatcrush",
12584
13054
  version: PKG_VERSION,
@@ -12592,8 +13062,8 @@ function emitMachineReadable(format, outcome, targetPath, options, say) {
12592
13062
  const serialized = `${JSON.stringify(payload, null, 2)}
12593
13063
  `;
12594
13064
  if (options.output) {
12595
- (0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)((0, import_node_path5.resolve)(options.output)), { recursive: true });
12596
- (0, import_node_fs7.writeFileSync)(options.output, serialized, "utf-8");
13065
+ (0, import_node_fs8.mkdirSync)((0, import_node_path6.dirname)((0, import_node_path6.resolve)(options.output)), { recursive: true });
13066
+ (0, import_node_fs8.writeFileSync)(options.output, serialized, "utf-8");
12597
13067
  say(
12598
13068
  source_default.gray(
12599
13069
  ` ${format.toUpperCase()} written to ${options.output} (${outcome.findings.length} finding(s))`
@@ -12645,13 +13115,13 @@ function printHuman(outcome) {
12645
13115
  }
12646
13116
 
12647
13117
  // src/commands/init.ts
12648
- var import_node_fs10 = require("fs");
13118
+ var import_node_fs11 = require("fs");
12649
13119
  var import_node_child_process = require("child_process");
12650
13120
  var import_node_readline3 = __toESM(require("readline"));
12651
13121
 
12652
13122
  // src/core/config.ts
12653
- var import_node_fs8 = require("fs");
12654
- var import_node_path6 = require("path");
13123
+ var import_node_fs9 = require("fs");
13124
+ var import_node_path7 = require("path");
12655
13125
  var import_toml = __toESM(require_toml());
12656
13126
  var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
12657
13127
  var DEFAULT_CONFDIR = "/etc/threatcrush/threatcrushd.conf.d";
@@ -12677,11 +13147,11 @@ var DEFAULT_CONFIG = {
12677
13147
  };
12678
13148
  function loadConfig(configPath) {
12679
13149
  const path = configPath || DEFAULT_CONFIG_PATH;
12680
- if (!(0, import_node_fs8.existsSync)(path)) {
13150
+ if (!(0, import_node_fs9.existsSync)(path)) {
12681
13151
  return { ...DEFAULT_CONFIG };
12682
13152
  }
12683
13153
  try {
12684
- const raw = (0, import_node_fs8.readFileSync)(path, "utf-8");
13154
+ const raw = (0, import_node_fs9.readFileSync)(path, "utf-8");
12685
13155
  const parsed = import_toml.default.parse(raw);
12686
13156
  return {
12687
13157
  daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
@@ -12697,13 +13167,13 @@ function loadConfig(configPath) {
12697
13167
  function loadModuleConfigs(confDir) {
12698
13168
  const dir = confDir || DEFAULT_CONFDIR;
12699
13169
  const configs = /* @__PURE__ */ new Map();
12700
- if (!(0, import_node_fs8.existsSync)(dir)) {
13170
+ if (!(0, import_node_fs9.existsSync)(dir)) {
12701
13171
  return configs;
12702
13172
  }
12703
- const files = (0, import_node_fs8.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
13173
+ const files = (0, import_node_fs9.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
12704
13174
  for (const file of files) {
12705
13175
  try {
12706
- const raw = (0, import_node_fs8.readFileSync)((0, import_node_path6.join)(dir, file), "utf-8");
13176
+ const raw = (0, import_node_fs9.readFileSync)((0, import_node_path7.join)(dir, file), "utf-8");
12707
13177
  const parsed = import_toml.default.parse(raw);
12708
13178
  for (const [name, config] of Object.entries(parsed)) {
12709
13179
  configs.set(name, config);
@@ -12732,23 +13202,23 @@ function generateModuleConfig(moduleName, defaults = {}) {
12732
13202
  }
12733
13203
 
12734
13204
  // src/core/cli-config.ts
12735
- var import_node_fs9 = require("fs");
12736
- var import_node_path7 = require("path");
13205
+ var import_node_fs10 = require("fs");
13206
+ var import_node_path8 = require("path");
12737
13207
  var import_node_os4 = require("os");
12738
- var CLI_CONFIG_DIR = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".threatcrush");
12739
- var CLI_CONFIG_PATH = (0, import_node_path7.join)(CLI_CONFIG_DIR, "config.json");
13208
+ var CLI_CONFIG_DIR = (0, import_node_path8.join)((0, import_node_os4.homedir)(), ".threatcrush");
13209
+ var CLI_CONFIG_PATH = (0, import_node_path8.join)(CLI_CONFIG_DIR, "config.json");
12740
13210
  function readCliConfig() {
12741
13211
  try {
12742
- return JSON.parse((0, import_node_fs9.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
13212
+ return JSON.parse((0, import_node_fs10.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
12743
13213
  } catch {
12744
13214
  return {};
12745
13215
  }
12746
13216
  }
12747
13217
  function writeCliConfig(config) {
12748
- if (!(0, import_node_fs9.existsSync)(CLI_CONFIG_DIR)) (0, import_node_fs9.mkdirSync)(CLI_CONFIG_DIR, { recursive: true });
12749
- (0, import_node_fs9.writeFileSync)(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
13218
+ if (!(0, import_node_fs10.existsSync)(CLI_CONFIG_DIR)) (0, import_node_fs10.mkdirSync)(CLI_CONFIG_DIR, { recursive: true });
13219
+ (0, import_node_fs10.writeFileSync)(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
12750
13220
  try {
12751
- (0, import_node_fs9.chmodSync)(CLI_CONFIG_PATH, 384);
13221
+ (0, import_node_fs10.chmodSync)(CLI_CONFIG_PATH, 384);
12752
13222
  } catch {
12753
13223
  }
12754
13224
  }
@@ -12993,7 +13463,7 @@ function binaryExists(name) {
12993
13463
  }
12994
13464
  }
12995
13465
  function findLogPath(paths) {
12996
- return paths.find((p) => (0, import_node_fs10.existsSync)(p));
13466
+ return paths.find((p) => (0, import_node_fs11.existsSync)(p));
12997
13467
  }
12998
13468
  async function promptYesNo(question, fallback2) {
12999
13469
  const rl = import_node_readline3.default.createInterface({ input: process.stdin, output: process.stdout });
@@ -13109,11 +13579,11 @@ async function initCommand() {
13109
13579
  }
13110
13580
  } else {
13111
13581
  const spinner2 = ora({ text: "Writing configuration files...", color: "green" }).start();
13112
- (0, import_node_fs10.mkdirSync)(confDDir, { recursive: true });
13113
- (0, import_node_fs10.mkdirSync)("/var/log/threatcrush", { recursive: true });
13114
- (0, import_node_fs10.mkdirSync)("/var/lib/threatcrush", { recursive: true });
13582
+ (0, import_node_fs11.mkdirSync)(confDDir, { recursive: true });
13583
+ (0, import_node_fs11.mkdirSync)("/var/log/threatcrush", { recursive: true });
13584
+ (0, import_node_fs11.mkdirSync)("/var/lib/threatcrush", { recursive: true });
13115
13585
  const mainConfig = generateDefaultConfig(detected.map((d) => d.name));
13116
- (0, import_node_fs10.writeFileSync)(`${configDir}/threatcrushd.conf`, mainConfig);
13586
+ (0, import_node_fs11.writeFileSync)(`${configDir}/threatcrushd.conf`, mainConfig);
13117
13587
  for (const svc of detected) {
13118
13588
  const svcDef = SERVICES_TO_DETECT.find((s) => s.name === svc.name);
13119
13589
  if (!svcDef) continue;
@@ -13122,7 +13592,7 @@ async function initCommand() {
13122
13592
  ...svcDef.moduleConfig,
13123
13593
  log_path: svc.logPath || svcDef.logPaths[0]
13124
13594
  });
13125
- (0, import_node_fs10.writeFileSync)(`${confDDir}/${modName}.conf`, modConfig);
13595
+ (0, import_node_fs11.writeFileSync)(`${confDDir}/${modName}.conf`, modConfig);
13126
13596
  }
13127
13597
  spinner2.succeed("Configuration written successfully");
13128
13598
  console.log();
@@ -13140,8 +13610,8 @@ async function initCommand() {
13140
13610
  }
13141
13611
  function checkWriteAccess(dir) {
13142
13612
  try {
13143
- if (!(0, import_node_fs10.existsSync)(dir)) {
13144
- (0, import_node_fs10.mkdirSync)(dir, { recursive: true });
13613
+ if (!(0, import_node_fs11.existsSync)(dir)) {
13614
+ (0, import_node_fs11.mkdirSync)(dir, { recursive: true });
13145
13615
  }
13146
13616
  return true;
13147
13617
  } catch {
@@ -13150,8 +13620,8 @@ function checkWriteAccess(dir) {
13150
13620
  }
13151
13621
 
13152
13622
  // src/core/module-loader.ts
13153
- var import_node_fs11 = require("fs");
13154
- var import_node_path8 = require("path");
13623
+ var import_node_fs12 = require("fs");
13624
+ var import_node_path9 = require("path");
13155
13625
  var import_toml2 = __toESM(require_toml());
13156
13626
  init_paths();
13157
13627
  function discoverModules(moduleDir, confDir) {
@@ -13159,22 +13629,22 @@ function discoverModules(moduleDir, confDir) {
13159
13629
  const configs = loadModuleConfigs(confDir || PATHS.confD);
13160
13630
  const searchPaths = [
13161
13631
  moduleDir || PATHS.moduleDir,
13162
- (0, import_node_path8.resolve)(process.cwd(), "modules")
13632
+ (0, import_node_path9.resolve)(process.cwd(), "modules")
13163
13633
  ];
13164
- const builtinDir = (0, import_node_path8.resolve)(__dirname || ".", "..", "modules");
13165
- if ((0, import_node_fs11.existsSync)(builtinDir)) {
13634
+ const builtinDir = (0, import_node_path9.resolve)(__dirname || ".", "..", "modules");
13635
+ if ((0, import_node_fs12.existsSync)(builtinDir)) {
13166
13636
  searchPaths.push(builtinDir);
13167
13637
  }
13168
13638
  for (const basePath of searchPaths) {
13169
- if (!(0, import_node_fs11.existsSync)(basePath)) continue;
13170
- const entries = (0, import_node_fs11.readdirSync)(basePath, { withFileTypes: true });
13639
+ if (!(0, import_node_fs12.existsSync)(basePath)) continue;
13640
+ const entries = (0, import_node_fs12.readdirSync)(basePath, { withFileTypes: true });
13171
13641
  for (const entry of entries) {
13172
13642
  if (!entry.isDirectory()) continue;
13173
- const modPath = (0, import_node_path8.join)(basePath, entry.name);
13174
- const manifestPath = (0, import_node_path8.join)(modPath, "mod.toml");
13175
- if (!(0, import_node_fs11.existsSync)(manifestPath)) continue;
13643
+ const modPath = (0, import_node_path9.join)(basePath, entry.name);
13644
+ const manifestPath = (0, import_node_path9.join)(modPath, "mod.toml");
13645
+ if (!(0, import_node_fs12.existsSync)(manifestPath)) continue;
13176
13646
  try {
13177
- const raw = (0, import_node_fs11.readFileSync)(manifestPath, "utf-8");
13647
+ const raw = (0, import_node_fs12.readFileSync)(manifestPath, "utf-8");
13178
13648
  const manifest = import_toml2.default.parse(raw);
13179
13649
  const config = configs.get(manifest.module.name) || { enabled: true };
13180
13650
  modules.push({
@@ -13293,11 +13763,163 @@ function formatUptime(seconds) {
13293
13763
 
13294
13764
  // src/commands/modules.ts
13295
13765
  var import_node_child_process2 = require("child_process");
13296
- var import_node_fs12 = require("fs");
13297
- var import_node_path9 = require("path");
13766
+ var import_node_fs14 = require("fs");
13767
+ var import_node_path11 = require("path");
13298
13768
  var import_toml3 = __toESM(require_toml());
13299
13769
  init_paths();
13300
13770
  init_pidfile();
13771
+
13772
+ // src/daemon/module-trust.ts
13773
+ var import_node_crypto3 = require("crypto");
13774
+ var import_node_fs13 = require("fs");
13775
+ var import_node_path10 = require("path");
13776
+ init_paths();
13777
+ var TRUST_FILE = (0, import_node_path10.join)(PATHS.configDir, "trusted-modules.json");
13778
+ var PUBLISHER_KEYS_FILE = (0, import_node_path10.join)(PATHS.configDir, "publisher-keys.json");
13779
+ var DIGEST_EXCLUDED_DIRS = /* @__PURE__ */ new Set([".git"]);
13780
+ function collectFiles(root, dir = root, out = []) {
13781
+ for (const entry of (0, import_node_fs13.readdirSync)(dir, { withFileTypes: true }).sort(
13782
+ (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
13783
+ )) {
13784
+ const full = (0, import_node_path10.join)(dir, entry.name);
13785
+ if (entry.isSymbolicLink()) {
13786
+ out.push({ path: full, isSymlink: true });
13787
+ } else if (entry.isDirectory()) {
13788
+ if (DIGEST_EXCLUDED_DIRS.has(entry.name)) continue;
13789
+ collectFiles(root, full, out);
13790
+ } else if (entry.isFile()) {
13791
+ out.push({ path: full, isSymlink: false });
13792
+ }
13793
+ }
13794
+ return out;
13795
+ }
13796
+ function computeModuleDigest(modulePath) {
13797
+ const hash = (0, import_node_crypto3.createHash)("sha256");
13798
+ for (const entry of collectFiles(modulePath)) {
13799
+ const relPath = (0, import_node_path10.relative)(modulePath, entry.path).split(import_node_path10.sep).join("/");
13800
+ hash.update(relPath);
13801
+ hash.update("\0");
13802
+ if (entry.isSymlink) {
13803
+ hash.update("symlink:");
13804
+ hash.update((0, import_node_fs13.readlinkSync)(entry.path));
13805
+ } else {
13806
+ hash.update("file:");
13807
+ hash.update((0, import_node_fs13.readFileSync)(entry.path));
13808
+ }
13809
+ hash.update("\0");
13810
+ }
13811
+ return hash.digest("hex");
13812
+ }
13813
+ function readTrustFile() {
13814
+ if (!(0, import_node_fs13.existsSync)(TRUST_FILE)) return { version: 1, modules: {} };
13815
+ try {
13816
+ const parsed = JSON.parse((0, import_node_fs13.readFileSync)(TRUST_FILE, "utf-8"));
13817
+ if (parsed.version !== 1 || typeof parsed.modules !== "object" || !parsed.modules) {
13818
+ return { version: 1, modules: {} };
13819
+ }
13820
+ return { version: 1, modules: parsed.modules };
13821
+ } catch {
13822
+ return { version: 1, modules: {} };
13823
+ }
13824
+ }
13825
+ function writeTrustFile(file) {
13826
+ (0, import_node_fs13.writeFileSync)(TRUST_FILE, JSON.stringify(file, null, 2) + "\n", { mode: 384 });
13827
+ try {
13828
+ (0, import_node_fs13.chmodSync)(TRUST_FILE, 384);
13829
+ } catch {
13830
+ }
13831
+ }
13832
+ function trustModule(name, modulePath, source) {
13833
+ const file = readTrustFile();
13834
+ const record = {
13835
+ digest: computeModuleDigest(modulePath),
13836
+ trustedAt: (/* @__PURE__ */ new Date()).toISOString(),
13837
+ source
13838
+ };
13839
+ file.modules[name] = record;
13840
+ writeTrustFile(file);
13841
+ return record;
13842
+ }
13843
+ function untrustModule(name) {
13844
+ const file = readTrustFile();
13845
+ if (!file.modules[name]) return false;
13846
+ delete file.modules[name];
13847
+ writeTrustFile(file);
13848
+ return true;
13849
+ }
13850
+ function listTrustedModules() {
13851
+ return readTrustFile().modules;
13852
+ }
13853
+ function readPublisherKeys() {
13854
+ if (!(0, import_node_fs13.existsSync)(PUBLISHER_KEYS_FILE)) return {};
13855
+ try {
13856
+ const parsed = JSON.parse((0, import_node_fs13.readFileSync)(PUBLISHER_KEYS_FILE, "utf-8"));
13857
+ return parsed && typeof parsed === "object" ? parsed : {};
13858
+ } catch {
13859
+ return {};
13860
+ }
13861
+ }
13862
+ function verifyModuleSignature(modulePath, digest) {
13863
+ const keys = readPublisherKeys();
13864
+ const pinnedKeyIds = Object.keys(keys);
13865
+ const sigPath = (0, import_node_path10.join)(modulePath, "mod.sig");
13866
+ if (pinnedKeyIds.length === 0) {
13867
+ return { ok: true };
13868
+ }
13869
+ if (!(0, import_node_fs13.existsSync)(sigPath)) {
13870
+ return { ok: false, reason: "publisher keys are pinned but the module ships no mod.sig" };
13871
+ }
13872
+ let parsed;
13873
+ try {
13874
+ parsed = JSON.parse((0, import_node_fs13.readFileSync)(sigPath, "utf-8"));
13875
+ } catch {
13876
+ return { ok: false, reason: "mod.sig is not valid JSON" };
13877
+ }
13878
+ if (!parsed.keyId || !parsed.signature) {
13879
+ return { ok: false, reason: "mod.sig is missing keyId or signature" };
13880
+ }
13881
+ const publicKey = keys[parsed.keyId];
13882
+ if (!publicKey) {
13883
+ return { ok: false, reason: `mod.sig references unpinned key "${parsed.keyId}"` };
13884
+ }
13885
+ try {
13886
+ const valid = (0, import_node_crypto3.verify)(
13887
+ null,
13888
+ Buffer.from(digest, "hex"),
13889
+ publicKey,
13890
+ Buffer.from(parsed.signature, "base64")
13891
+ );
13892
+ return valid ? { ok: true } : { ok: false, reason: "mod.sig signature does not match" };
13893
+ } catch (err) {
13894
+ return { ok: false, reason: `signature check failed: ${String(err.message || err)}` };
13895
+ }
13896
+ }
13897
+ function verifyModuleTrust(name, modulePath) {
13898
+ let digest;
13899
+ try {
13900
+ digest = computeModuleDigest(modulePath);
13901
+ } catch (err) {
13902
+ return { ok: false, reason: `could not hash module: ${String(err.message || err)}` };
13903
+ }
13904
+ const signature = verifyModuleSignature(modulePath, digest);
13905
+ if (!signature.ok) return signature;
13906
+ const record = readTrustFile().modules[name];
13907
+ if (!record) {
13908
+ return {
13909
+ ok: false,
13910
+ reason: `not trusted \u2014 review it, then run: threatcrush modules trust ${name}`
13911
+ };
13912
+ }
13913
+ if (record.digest !== digest) {
13914
+ return {
13915
+ ok: false,
13916
+ reason: `contents changed since it was trusted on ${record.trustedAt} \u2014 re-review it, then run: threatcrush modules trust ${name}`
13917
+ };
13918
+ }
13919
+ return { ok: true };
13920
+ }
13921
+
13922
+ // src/commands/modules.ts
13301
13923
  var API_URL2 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
13302
13924
  function modulesDir() {
13303
13925
  ensureRuntimeDirs();
@@ -13310,7 +13932,7 @@ function safeModuleDirName(name, label = "module name") {
13310
13932
  return name;
13311
13933
  }
13312
13934
  function moduleDestination(dir, name, label) {
13313
- return (0, import_node_path9.join)(dir, safeModuleDirName(name, label));
13935
+ return (0, import_node_path11.join)(dir, safeModuleDirName(name, label));
13314
13936
  }
13315
13937
  function assertSafeTarballEntries(tarPath) {
13316
13938
  const listing = (0, import_node_child_process2.execFileSync)("tar", ["-tzf", tarPath], { encoding: "utf-8" });
@@ -13323,12 +13945,12 @@ function assertSafeTarballEntries(tarPath) {
13323
13945
  }
13324
13946
  }
13325
13947
  function validateManifest(modPath) {
13326
- const manifestPath = (0, import_node_path9.join)(modPath, "mod.toml");
13327
- if (!(0, import_node_fs12.existsSync)(manifestPath)) {
13948
+ const manifestPath = (0, import_node_path11.join)(modPath, "mod.toml");
13949
+ if (!(0, import_node_fs14.existsSync)(manifestPath)) {
13328
13950
  return { ok: false, error: `mod.toml not found at ${manifestPath}` };
13329
13951
  }
13330
13952
  try {
13331
- const raw = (0, import_node_fs12.readFileSync)(manifestPath, "utf-8");
13953
+ const raw = (0, import_node_fs14.readFileSync)(manifestPath, "utf-8");
13332
13954
  const parsed = import_toml3.default.parse(raw);
13333
13955
  const name = parsed.module?.name;
13334
13956
  const version = parsed.module?.version;
@@ -13339,6 +13961,12 @@ function validateManifest(modPath) {
13339
13961
  return { ok: false, error: `Invalid mod.toml: ${err.message}` };
13340
13962
  }
13341
13963
  }
13964
+ function printTrustRequired(name) {
13965
+ console.log();
13966
+ console.log(source_default.yellow(` ! ${name} is installed but NOT trusted, so threatcrushd will not load it.`));
13967
+ console.log(source_default.dim(" Review the module source, then run:"));
13968
+ console.log(source_default.dim(` ${source_default.white(`threatcrush modules trust ${name}`)}`));
13969
+ }
13342
13970
  function notifyDaemonIfRunning() {
13343
13971
  if (!findRunningDaemon()) return;
13344
13972
  console.log(source_default.dim(` \u2139 threatcrushd is running \u2014 restart it to load/unload modules:`));
@@ -13382,8 +14010,8 @@ async function modulesInstallCommand(source) {
13382
14010
  console.log();
13383
14011
  const dir = modulesDir();
13384
14012
  if (source.startsWith("./") || source.startsWith("/") || source.startsWith("~")) {
13385
- const absPath = (0, import_node_path9.resolve)(source.startsWith("~") ? source.replace("~", process.env.HOME || "") : source);
13386
- if (!(0, import_node_fs12.existsSync)(absPath)) {
14013
+ const absPath = (0, import_node_path11.resolve)(source.startsWith("~") ? source.replace("~", process.env.HOME || "") : source);
14014
+ if (!(0, import_node_fs14.existsSync)(absPath)) {
13387
14015
  console.log(source_default.red(` \u2717 Path not found: ${absPath}
13388
14016
  `));
13389
14017
  return;
@@ -13402,7 +14030,7 @@ async function modulesInstallCommand(source) {
13402
14030
  `));
13403
14031
  return;
13404
14032
  }
13405
- if ((0, import_node_fs12.existsSync)(dest2)) {
14033
+ if ((0, import_node_fs14.existsSync)(dest2)) {
13406
14034
  console.log(source_default.yellow(` ! ${check.name} is already installed at ${dest2}`));
13407
14035
  console.log(source_default.dim(` Run ${source_default.white(`threatcrush modules remove ${check.name}`)} first.
13408
14036
  `));
@@ -13410,12 +14038,13 @@ async function modulesInstallCommand(source) {
13410
14038
  }
13411
14039
  const spinner2 = ora({ text: `Copying module files...`, color: "green" }).start();
13412
14040
  try {
13413
- (0, import_node_fs12.cpSync)(absPath, dest2, { recursive: true, dereference: true });
14041
+ (0, import_node_fs14.cpSync)(absPath, dest2, { recursive: true, dereference: true });
13414
14042
  spinner2.succeed(`Installed ${source_default.white(check.name)} v${check.version} \u2192 ${dest2}`);
13415
14043
  } catch (err) {
13416
14044
  spinner2.fail(`Copy failed: ${err.message}`);
13417
14045
  return;
13418
14046
  }
14047
+ printTrustRequired(check.name);
13419
14048
  notifyDaemonIfRunning();
13420
14049
  console.log();
13421
14050
  return;
@@ -13425,14 +14054,14 @@ async function modulesInstallCommand(source) {
13425
14054
  let name;
13426
14055
  let dest2;
13427
14056
  try {
13428
- name = safeModuleDirName((0, import_node_path9.basename)(gitUrl.replace(/\.git$/, "")), "repository name");
14057
+ name = safeModuleDirName((0, import_node_path11.basename)(gitUrl.replace(/\.git$/, "")), "repository name");
13429
14058
  dest2 = moduleDestination(dir, name);
13430
14059
  } catch (err) {
13431
14060
  console.log(source_default.red(` x ${err.message}
13432
14061
  `));
13433
14062
  return;
13434
14063
  }
13435
- if ((0, import_node_fs12.existsSync)(dest2)) {
14064
+ if ((0, import_node_fs14.existsSync)(dest2)) {
13436
14065
  console.log(source_default.yellow(` ! ${name} is already installed at ${dest2}`));
13437
14066
  console.log(source_default.dim(` Run ${source_default.white(`threatcrush modules remove ${name}`)} first.
13438
14067
  `));
@@ -13449,12 +14078,13 @@ async function modulesInstallCommand(source) {
13449
14078
  if (!check.ok) {
13450
14079
  spinner2.fail(check.error);
13451
14080
  try {
13452
- (0, import_node_fs12.rmSync)(dest2, { recursive: true, force: true });
14081
+ (0, import_node_fs14.rmSync)(dest2, { recursive: true, force: true });
13453
14082
  } catch {
13454
14083
  }
13455
14084
  return;
13456
14085
  }
13457
14086
  spinner2.succeed(`Installed ${source_default.white(check.name)} v${check.version} \u2192 ${dest2}`);
14087
+ printTrustRequired(check.name);
13458
14088
  notifyDaemonIfRunning();
13459
14089
  console.log();
13460
14090
  return;
@@ -13494,7 +14124,7 @@ async function modulesInstallCommand(source) {
13494
14124
  `));
13495
14125
  return;
13496
14126
  }
13497
- if ((0, import_node_fs12.existsSync)(dest)) {
14127
+ if ((0, import_node_fs14.existsSync)(dest)) {
13498
14128
  console.log(source_default.yellow(` ! ${mod.slug} is already installed at ${dest}
13499
14129
  `));
13500
14130
  return;
@@ -13520,7 +14150,7 @@ async function modulesInstallCommand(source) {
13520
14150
  if (!check.ok) {
13521
14151
  cloneSpinner.fail(check.error);
13522
14152
  try {
13523
- (0, import_node_fs12.rmSync)(dest, { recursive: true, force: true });
14153
+ (0, import_node_fs14.rmSync)(dest, { recursive: true, force: true });
13524
14154
  } catch {
13525
14155
  }
13526
14156
  return;
@@ -13534,11 +14164,11 @@ async function modulesInstallCommand(source) {
13534
14164
  dlSpinner.fail(`HTTP ${res.status}`);
13535
14165
  return;
13536
14166
  }
13537
- const tar = (0, import_node_path9.join)(dir, `${mod.slug}.tar.gz`);
13538
- (0, import_node_fs12.writeFileSync)(tar, Buffer.from(await res.arrayBuffer()));
14167
+ const tar = (0, import_node_path11.join)(dir, `${mod.slug}.tar.gz`);
14168
+ (0, import_node_fs14.writeFileSync)(tar, Buffer.from(await res.arrayBuffer()));
13539
14169
  assertSafeTarballEntries(tar);
13540
14170
  (0, import_node_child_process2.execFileSync)("tar", ["-xzf", tar, "-C", dir], { stdio: "pipe" });
13541
- (0, import_node_fs12.rmSync)(tar, { force: true });
14171
+ (0, import_node_fs14.rmSync)(tar, { force: true });
13542
14172
  const check = validateManifest(dest);
13543
14173
  if (!check.ok) {
13544
14174
  dlSpinner.fail(check.error);
@@ -13553,6 +14183,7 @@ async function modulesInstallCommand(source) {
13553
14183
  logger.info("No installable artifact provided for this module.");
13554
14184
  return;
13555
14185
  }
14186
+ printTrustRequired(mod.slug);
13556
14187
  notifyDaemonIfRunning();
13557
14188
  console.log();
13558
14189
  }
@@ -13569,7 +14200,7 @@ async function modulesRemoveCommand(name) {
13569
14200
  `));
13570
14201
  return;
13571
14202
  }
13572
- if (!(0, import_node_fs12.existsSync)(target)) {
14203
+ if (!(0, import_node_fs14.existsSync)(target)) {
13573
14204
  console.log(source_default.yellow(` ! No module named "${name}" in ${dir}
13574
14205
  `));
13575
14206
  return;
@@ -13579,7 +14210,8 @@ async function modulesRemoveCommand(name) {
13579
14210
  console.log(source_default.yellow(` ! Directory name "${name}" does not match manifest name "${check.name}"`));
13580
14211
  }
13581
14212
  try {
13582
- (0, import_node_fs12.rmSync)(target, { recursive: true, force: true });
14213
+ (0, import_node_fs14.rmSync)(target, { recursive: true, force: true });
14214
+ untrustModule(name);
13583
14215
  console.log(source_default.green(` \u2713 Removed ${name} from ${dir}
13584
14216
  `));
13585
14217
  } catch (err) {
@@ -13589,6 +14221,72 @@ async function modulesRemoveCommand(name) {
13589
14221
  }
13590
14222
  notifyDaemonIfRunning();
13591
14223
  }
14224
+ async function modulesTrustCommand(name) {
14225
+ banner();
14226
+ console.log(source_default.green.bold(" Trust Module"));
14227
+ console.log(source_default.gray(" " + "\u2500".repeat(50)));
14228
+ console.log();
14229
+ const dir = modulesDir();
14230
+ let target;
14231
+ try {
14232
+ target = moduleDestination(dir, name);
14233
+ } catch (err) {
14234
+ console.log(source_default.red(` x ${err.message}
14235
+ `));
14236
+ return;
14237
+ }
14238
+ if (!(0, import_node_fs14.existsSync)(target)) {
14239
+ console.log(source_default.yellow(` ! No module named "${name}" in ${dir}
14240
+ `));
14241
+ return;
14242
+ }
14243
+ const check = validateManifest(target);
14244
+ if (!check.ok) {
14245
+ console.log(source_default.red(` \u2717 ${check.error}
14246
+ `));
14247
+ return;
14248
+ }
14249
+ const record = trustModule(name, target, target);
14250
+ console.log(source_default.green(` \u2713 Trusted ${source_default.white(name)} v${check.version}`));
14251
+ console.log(source_default.dim(` digest ${record.digest.slice(0, 16)}\u2026`));
14252
+ console.log(
14253
+ source_default.dim(" threatcrushd will refuse to load it again if its contents change.\n")
14254
+ );
14255
+ notifyDaemonIfRunning();
14256
+ }
14257
+ async function modulesUntrustCommand(name) {
14258
+ banner();
14259
+ console.log(source_default.green.bold(" Revoke Module Trust"));
14260
+ console.log(source_default.gray(" " + "\u2500".repeat(50)));
14261
+ console.log();
14262
+ if (untrustModule(name)) {
14263
+ console.log(source_default.green(` \u2713 Revoked trust for ${source_default.white(name)}
14264
+ `));
14265
+ notifyDaemonIfRunning();
14266
+ } else {
14267
+ console.log(source_default.yellow(` ! ${name} was not trusted
14268
+ `));
14269
+ }
14270
+ }
14271
+ async function modulesTrustedCommand() {
14272
+ banner();
14273
+ console.log(source_default.green.bold(" Trusted Modules"));
14274
+ console.log(source_default.gray(" " + "\u2500".repeat(50)));
14275
+ console.log();
14276
+ const trusted = Object.entries(listTrustedModules());
14277
+ if (trusted.length === 0) {
14278
+ console.log(source_default.yellow(" No modules are trusted."));
14279
+ console.log(source_default.dim(" Installed modules stay dormant until you run:"));
14280
+ console.log(source_default.dim(` ${source_default.white("threatcrush modules trust <name>")}
14281
+ `));
14282
+ return;
14283
+ }
14284
+ for (const [name, record] of trusted) {
14285
+ console.log(` ${source_default.white(name)} ${source_default.dim(record.digest.slice(0, 16) + "\u2026")}`);
14286
+ console.log(source_default.dim(` trusted ${record.trustedAt}`));
14287
+ }
14288
+ console.log();
14289
+ }
13592
14290
  async function modulesCommand(opts) {
13593
14291
  const action = opts.action || "list";
13594
14292
  switch (action) {
@@ -13622,10 +14320,31 @@ async function modulesCommand(opts) {
13622
14320
  }
13623
14321
  await modulesRemoveCommand(opts.name);
13624
14322
  break;
14323
+ case "trust":
14324
+ if (!opts.name) {
14325
+ banner();
14326
+ console.log(source_default.red(" Module name required."));
14327
+ console.log(source_default.gray(" Usage: threatcrush modules trust <name>\n"));
14328
+ return;
14329
+ }
14330
+ await modulesTrustCommand(opts.name);
14331
+ break;
14332
+ case "untrust":
14333
+ if (!opts.name) {
14334
+ banner();
14335
+ console.log(source_default.red(" Module name required."));
14336
+ console.log(source_default.gray(" Usage: threatcrush modules untrust <name>\n"));
14337
+ return;
14338
+ }
14339
+ await modulesUntrustCommand(opts.name);
14340
+ break;
14341
+ case "trusted":
14342
+ await modulesTrustedCommand();
14343
+ break;
13625
14344
  default:
13626
14345
  banner();
13627
14346
  console.log(source_default.yellow(` Unknown action: ${action}`));
13628
- console.log(source_default.gray(" Available actions: list, install, remove\n"));
14347
+ console.log(source_default.gray(" Available actions: list, install, remove, trust, untrust, trusted\n"));
13629
14348
  await modulesListCommand();
13630
14349
  break;
13631
14350
  }
@@ -13952,21 +14671,21 @@ async function pentestCommand(targetUrl) {
13952
14671
 
13953
14672
  // src/commands/orgs.ts
13954
14673
  var import_node_os5 = require("os");
13955
- var import_node_fs13 = require("fs");
13956
- var import_node_path10 = require("path");
14674
+ var import_node_fs15 = require("fs");
14675
+ var import_node_path12 = require("path");
13957
14676
  var API_URL3 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
13958
- var CONFIG_PATH = (0, import_node_path10.join)((0, import_node_os5.homedir)(), ".threatcrush", "config.json");
14677
+ var CONFIG_PATH = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".threatcrush", "config.json");
13959
14678
  function readConfig() {
13960
14679
  try {
13961
- return JSON.parse((0, import_node_fs13.readFileSync)(CONFIG_PATH, "utf-8"));
14680
+ return JSON.parse((0, import_node_fs15.readFileSync)(CONFIG_PATH, "utf-8"));
13962
14681
  } catch {
13963
14682
  return {};
13964
14683
  }
13965
14684
  }
13966
14685
  function writeConfig(config) {
13967
- const dir = (0, import_node_path10.join)((0, import_node_os5.homedir)(), ".threatcrush");
13968
- if (!(0, import_node_fs13.existsSync)(dir)) (0, import_node_fs13.mkdirSync)(dir, { recursive: true });
13969
- (0, import_node_fs13.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2));
14686
+ const dir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".threatcrush");
14687
+ if (!(0, import_node_fs15.existsSync)(dir)) (0, import_node_fs15.mkdirSync)(dir, { recursive: true });
14688
+ (0, import_node_fs15.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2));
13970
14689
  }
13971
14690
  function getAuthHeaders() {
13972
14691
  const config = readConfig();
@@ -14102,13 +14821,13 @@ async function useOrganization(slug) {
14102
14821
 
14103
14822
  // src/commands/servers.ts
14104
14823
  var import_node_os6 = require("os");
14105
- var import_node_fs14 = require("fs");
14106
- var import_node_path11 = require("path");
14824
+ var import_node_fs16 = require("fs");
14825
+ var import_node_path13 = require("path");
14107
14826
  var API_URL4 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
14108
- var CONFIG_PATH2 = (0, import_node_path11.join)((0, import_node_os6.homedir)(), ".threatcrush", "config.json");
14827
+ var CONFIG_PATH2 = (0, import_node_path13.join)((0, import_node_os6.homedir)(), ".threatcrush", "config.json");
14109
14828
  function readConfig2() {
14110
14829
  try {
14111
- return JSON.parse((0, import_node_fs14.readFileSync)(CONFIG_PATH2, "utf-8"));
14830
+ return JSON.parse((0, import_node_fs16.readFileSync)(CONFIG_PATH2, "utf-8"));
14112
14831
  } catch {
14113
14832
  return {};
14114
14833
  }
@@ -14211,14 +14930,14 @@ function timeAgo(dateStr) {
14211
14930
 
14212
14931
  // src/commands/connect.ts
14213
14932
  var import_node_os7 = require("os");
14214
- var import_node_fs15 = require("fs");
14215
- var import_node_path12 = require("path");
14933
+ var import_node_fs17 = require("fs");
14934
+ var import_node_path14 = require("path");
14216
14935
  var import_node_child_process3 = require("child_process");
14217
14936
  var API_URL5 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
14218
- var CONFIG_PATH3 = (0, import_node_path12.join)((0, import_node_os7.homedir)(), ".threatcrush", "config.json");
14937
+ var CONFIG_PATH3 = (0, import_node_path14.join)((0, import_node_os7.homedir)(), ".threatcrush", "config.json");
14219
14938
  function readConfig3() {
14220
14939
  try {
14221
- return JSON.parse((0, import_node_fs15.readFileSync)(CONFIG_PATH3, "utf-8"));
14940
+ return JSON.parse((0, import_node_fs17.readFileSync)(CONFIG_PATH3, "utf-8"));
14222
14941
  } catch {
14223
14942
  return {};
14224
14943
  }
@@ -14373,20 +15092,21 @@ async function sshConnect(options) {
14373
15092
 
14374
15093
  // src/commands/daemon.ts
14375
15094
  var import_node_child_process7 = require("child_process");
14376
- var import_node_fs24 = require("fs");
14377
- var import_node_path16 = require("path");
14378
- var import_node_fs25 = require("fs");
15095
+ var import_node_fs26 = require("fs");
15096
+ var import_node_path18 = require("path");
15097
+ var import_node_fs27 = require("fs");
14379
15098
 
14380
15099
  // src/daemon/index.ts
14381
- var import_node_fs23 = require("fs");
14382
- var import_node_path15 = require("path");
15100
+ var import_node_fs25 = require("fs");
15101
+ var import_node_path17 = require("path");
14383
15102
  init_paths();
14384
15103
  init_pidfile();
14385
15104
 
14386
15105
  // src/daemon/ipc-server.ts
14387
15106
  var import_node_net2 = require("net");
14388
- var import_node_fs16 = require("fs");
15107
+ var import_node_fs18 = require("fs");
14389
15108
  init_paths();
15109
+ init_control_token();
14390
15110
 
14391
15111
  // src/daemon/event-bus.ts
14392
15112
  var import_node_events = require("events");
@@ -14431,10 +15151,12 @@ var IpcServer = class {
14431
15151
  nextClientId = 1;
14432
15152
  startedAt = /* @__PURE__ */ new Date();
14433
15153
  counters = { events: 0, threats: 0, alerts: 0 };
15154
+ controlToken = "";
14434
15155
  async start() {
14435
- if ((0, import_node_fs16.existsSync)(PATHS.socket)) {
15156
+ this.controlToken = issueControlToken();
15157
+ if ((0, import_node_fs18.existsSync)(PATHS.socket)) {
14436
15158
  try {
14437
- (0, import_node_fs16.unlinkSync)(PATHS.socket);
15159
+ (0, import_node_fs18.unlinkSync)(PATHS.socket);
14438
15160
  } catch {
14439
15161
  }
14440
15162
  }
@@ -14470,14 +15192,14 @@ var IpcServer = class {
14470
15192
  return new Promise((resolve5) => {
14471
15193
  if (!this.server) {
14472
15194
  try {
14473
- if ((0, import_node_fs16.existsSync)(PATHS.socket)) (0, import_node_fs16.unlinkSync)(PATHS.socket);
15195
+ if ((0, import_node_fs18.existsSync)(PATHS.socket)) (0, import_node_fs18.unlinkSync)(PATHS.socket);
14474
15196
  } catch {
14475
15197
  }
14476
15198
  return resolve5();
14477
15199
  }
14478
15200
  this.server.close(() => {
14479
15201
  try {
14480
- if ((0, import_node_fs16.existsSync)(PATHS.socket)) (0, import_node_fs16.unlinkSync)(PATHS.socket);
15202
+ if ((0, import_node_fs18.existsSync)(PATHS.socket)) (0, import_node_fs18.unlinkSync)(PATHS.socket);
14481
15203
  } catch {
14482
15204
  }
14483
15205
  resolve5();
@@ -14563,6 +15285,13 @@ var IpcServer = class {
14563
15285
  for (const ch of req.params.channels) client.subscriptions.add(ch);
14564
15286
  return this.send(client, { id: req.id, ok: true, result: { subscribed: [...client.subscriptions] } });
14565
15287
  case "shutdown":
15288
+ if (!tokensMatch(this.controlToken, req.params?.token)) {
15289
+ return this.send(client, {
15290
+ id: req.id,
15291
+ ok: false,
15292
+ error: "shutdown requires the daemon control token (run as root, or use systemctl)"
15293
+ });
15294
+ }
14566
15295
  this.send(client, { id: req.id, ok: true, result: "shutting down" });
14567
15296
  setTimeout(() => process.emit("SIGTERM"), 50);
14568
15297
  return;
@@ -14583,14 +15312,14 @@ var IpcServer = class {
14583
15312
  };
14584
15313
 
14585
15314
  // src/daemon/module-host.ts
14586
- var import_node_fs20 = require("fs");
14587
- var import_node_path13 = require("path");
15315
+ var import_node_fs22 = require("fs");
15316
+ var import_node_path15 = require("path");
14588
15317
  var import_node_url = require("url");
14589
15318
  var import_toml4 = __toESM(require_toml());
14590
15319
  init_paths();
14591
15320
 
14592
15321
  // src/daemon/watchers/log-watcher.ts
14593
- var import_node_fs17 = require("fs");
15322
+ var import_node_fs19 = require("fs");
14594
15323
  var import_node_readline4 = require("readline");
14595
15324
  init_state();
14596
15325
  var DEFAULT_SOURCES = [
@@ -14612,9 +15341,9 @@ var LogWatcher = class {
14612
15341
  start() {
14613
15342
  const started = [];
14614
15343
  for (const src of this.sources) {
14615
- if (!(0, import_node_fs17.existsSync)(src.path)) continue;
15344
+ if (!(0, import_node_fs19.existsSync)(src.path)) continue;
14616
15345
  try {
14617
- (0, import_node_fs17.accessSync)(src.path, import_node_fs17.constants.R_OK);
15346
+ (0, import_node_fs19.accessSync)(src.path, import_node_fs19.constants.R_OK);
14618
15347
  } catch {
14619
15348
  continue;
14620
15349
  }
@@ -14634,7 +15363,7 @@ var LogWatcher = class {
14634
15363
  }
14635
15364
  tail(src) {
14636
15365
  try {
14637
- this.positions.set(src.path, (0, import_node_fs17.statSync)(src.path).size);
15366
+ this.positions.set(src.path, (0, import_node_fs19.statSync)(src.path).size);
14638
15367
  } catch {
14639
15368
  this.positions.set(src.path, 0);
14640
15369
  }
@@ -14645,7 +15374,7 @@ var LogWatcher = class {
14645
15374
  poll(src) {
14646
15375
  let stat;
14647
15376
  try {
14648
- stat = (0, import_node_fs17.statSync)(src.path);
15377
+ stat = (0, import_node_fs19.statSync)(src.path);
14649
15378
  } catch {
14650
15379
  return;
14651
15380
  }
@@ -14655,7 +15384,7 @@ var LogWatcher = class {
14655
15384
  return;
14656
15385
  }
14657
15386
  if (stat.size === prev) return;
14658
- const stream = (0, import_node_fs17.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
15387
+ const stream = (0, import_node_fs19.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
14659
15388
  stream.on("error", () => this.positions.set(src.path, stat.size));
14660
15389
  const rl = (0, import_node_readline4.createInterface)({ input: stream });
14661
15390
  rl.on("error", () => {
@@ -14848,7 +15577,7 @@ function realtimeToDate(rt) {
14848
15577
 
14849
15578
  // src/modules/network-monitor/index.ts
14850
15579
  var import_node_child_process5 = require("child_process");
14851
- var import_node_fs18 = require("fs");
15580
+ var import_node_fs20 = require("fs");
14852
15581
  init_state();
14853
15582
  var NetworkMonitor = class {
14854
15583
  constructor(bus2) {
@@ -14887,7 +15616,7 @@ var NetworkMonitor = class {
14887
15616
  hasConntrackOrSs() {
14888
15617
  const ss = (0, import_node_child_process5.spawnSync)("ss", ["--version"], { stdio: "pipe" });
14889
15618
  if (ss.status === 0) return true;
14890
- return (0, import_node_fs18.existsSync)("/proc/net/tcp");
15619
+ return (0, import_node_fs20.existsSync)("/proc/net/tcp");
14891
15620
  }
14892
15621
  poll() {
14893
15622
  try {
@@ -15026,7 +15755,7 @@ var NetworkMonitor = class {
15026
15755
  };
15027
15756
 
15028
15757
  // src/modules/dns-monitor/index.ts
15029
- var import_node_fs19 = require("fs");
15758
+ var import_node_fs21 = require("fs");
15030
15759
  var import_node_readline5 = require("readline");
15031
15760
  init_state();
15032
15761
  var DNS_LOG_SOURCES = [
@@ -15061,9 +15790,9 @@ var DnsMonitor = class {
15061
15790
  entropyThreshold = 3.5;
15062
15791
  start() {
15063
15792
  const sources = DNS_LOG_SOURCES.filter((p) => {
15064
- if (!(0, import_node_fs19.existsSync)(p)) return false;
15793
+ if (!(0, import_node_fs21.existsSync)(p)) return false;
15065
15794
  try {
15066
- (0, import_node_fs19.accessSync)(p, import_node_fs19.constants.R_OK);
15795
+ (0, import_node_fs21.accessSync)(p, import_node_fs21.constants.R_OK);
15067
15796
  return true;
15068
15797
  } catch {
15069
15798
  return false;
@@ -15087,7 +15816,7 @@ var DnsMonitor = class {
15087
15816
  }
15088
15817
  tailLog(path) {
15089
15818
  try {
15090
- this.positions.set(path, (0, import_node_fs19.statSync)(path).size);
15819
+ this.positions.set(path, (0, import_node_fs21.statSync)(path).size);
15091
15820
  } catch {
15092
15821
  this.positions.set(path, 0);
15093
15822
  }
@@ -15097,7 +15826,7 @@ var DnsMonitor = class {
15097
15826
  pollLog(path) {
15098
15827
  let stat;
15099
15828
  try {
15100
- stat = (0, import_node_fs19.statSync)(path);
15829
+ stat = (0, import_node_fs21.statSync)(path);
15101
15830
  } catch {
15102
15831
  return;
15103
15832
  }
@@ -15107,7 +15836,7 @@ var DnsMonitor = class {
15107
15836
  return;
15108
15837
  }
15109
15838
  if (stat.size === prev) return;
15110
- const stream = (0, import_node_fs19.createReadStream)(path, { start: prev, encoding: "utf-8" });
15839
+ const stream = (0, import_node_fs21.createReadStream)(path, { start: prev, encoding: "utf-8" });
15111
15840
  stream.on("error", () => this.positions.set(path, stat.size));
15112
15841
  const rl = (0, import_node_readline5.createInterface)({ input: stream });
15113
15842
  rl.on("line", (line) => this.parseDnsLine(line));
@@ -15339,15 +16068,15 @@ var ModuleHost = class {
15339
16068
  for (const m of builtins) this.modules.set(m.name, m);
15340
16069
  }
15341
16070
  async discoverAndStartInstalled() {
15342
- if (!(0, import_node_fs20.existsSync)(PATHS.moduleDir)) return;
16071
+ if (!(0, import_node_fs22.existsSync)(PATHS.moduleDir)) return;
15343
16072
  const configs = loadModuleConfigs(PATHS.confD);
15344
- const entries = (0, import_node_fs20.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
16073
+ const entries = (0, import_node_fs22.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
15345
16074
  for (const entry of entries) {
15346
16075
  if (!entry.isDirectory()) continue;
15347
- const manifestPath = (0, import_node_path13.join)(PATHS.moduleDir, entry.name, "mod.toml");
15348
- if (!(0, import_node_fs20.existsSync)(manifestPath)) continue;
16076
+ const manifestPath = (0, import_node_path15.join)(PATHS.moduleDir, entry.name, "mod.toml");
16077
+ if (!(0, import_node_fs22.existsSync)(manifestPath)) continue;
15349
16078
  try {
15350
- const manifest = import_toml4.default.parse((0, import_node_fs20.readFileSync)(manifestPath, "utf-8"));
16079
+ const manifest = import_toml4.default.parse((0, import_node_fs22.readFileSync)(manifestPath, "utf-8"));
15351
16080
  const name = manifest.module?.name || entry.name;
15352
16081
  const defaults = manifest.module?.config?.defaults || {};
15353
16082
  const config = {
@@ -15361,7 +16090,7 @@ var ModuleHost = class {
15361
16090
  source: "installed",
15362
16091
  status: config.enabled === false ? "disabled" : "loaded",
15363
16092
  events: 0,
15364
- path: (0, import_node_path13.join)(PATHS.moduleDir, entry.name),
16093
+ path: (0, import_node_path15.join)(PATHS.moduleDir, entry.name),
15365
16094
  config
15366
16095
  };
15367
16096
  this.modules.set(name, hosted);
@@ -15376,7 +16105,7 @@ var ModuleHost = class {
15376
16105
  status: "error",
15377
16106
  events: 0,
15378
16107
  detail: `manifest load failed: ${String(err.message || err)}`,
15379
- path: (0, import_node_path13.join)(PATHS.moduleDir, entry.name)
16108
+ path: (0, import_node_path15.join)(PATHS.moduleDir, entry.name)
15380
16109
  });
15381
16110
  }
15382
16111
  }
@@ -15388,6 +16117,13 @@ var ModuleHost = class {
15388
16117
  hosted.detail = "no built entrypoint found; run npm install && npm run build in the module directory";
15389
16118
  return;
15390
16119
  }
16120
+ const trust = verifyModuleTrust(hosted.name, hosted.path);
16121
+ if (!trust.ok) {
16122
+ hosted.status = "error";
16123
+ hosted.detail = `refusing to load: ${trust.reason}`;
16124
+ this.bus.announceModule(hosted.name, "error", hosted.detail);
16125
+ return;
16126
+ }
15391
16127
  try {
15392
16128
  const imported = await import((0, import_node_url.pathToFileURL)(entrypoint).href);
15393
16129
  const exported = imported.default || imported.module || imported;
@@ -15408,17 +16144,17 @@ var ModuleHost = class {
15408
16144
  }
15409
16145
  }
15410
16146
  installedEntrypoint(modulePath) {
15411
- const packageJson = (0, import_node_path13.join)(modulePath, "package.json");
16147
+ const packageJson = (0, import_node_path15.join)(modulePath, "package.json");
15412
16148
  const candidates = [];
15413
- if ((0, import_node_fs20.existsSync)(packageJson)) {
16149
+ if ((0, import_node_fs22.existsSync)(packageJson)) {
15414
16150
  try {
15415
- const pkg = JSON.parse((0, import_node_fs20.readFileSync)(packageJson, "utf-8"));
15416
- if (pkg.main) candidates.push((0, import_node_path13.join)(modulePath, pkg.main));
16151
+ const pkg = JSON.parse((0, import_node_fs22.readFileSync)(packageJson, "utf-8"));
16152
+ if (pkg.main) candidates.push((0, import_node_path15.join)(modulePath, pkg.main));
15417
16153
  } catch {
15418
16154
  }
15419
16155
  }
15420
- candidates.push((0, import_node_path13.join)(modulePath, "dist", "index.js"), (0, import_node_path13.join)(modulePath, "index.js"));
15421
- return candidates.find((candidate) => (0, import_node_fs20.existsSync)(candidate)) || null;
16156
+ candidates.push((0, import_node_path15.join)(modulePath, "dist", "index.js"), (0, import_node_path15.join)(modulePath, "index.js"));
16157
+ return candidates.find((candidate) => (0, import_node_fs22.existsSync)(candidate)) || null;
15422
16158
  }
15423
16159
  isThreatCrushModule(value) {
15424
16160
  return Boolean(
@@ -15939,8 +16675,8 @@ var RuleEngine = class {
15939
16675
  };
15940
16676
 
15941
16677
  // src/daemon/rules/loader.ts
15942
- var import_node_fs21 = require("fs");
15943
- var import_node_path14 = require("path");
16678
+ var import_node_fs23 = require("fs");
16679
+ var import_node_path16 = require("path");
15944
16680
 
15945
16681
  // src/daemon/rules/default-rules.ts
15946
16682
  var DEFAULT_RULES = [
@@ -16224,11 +16960,11 @@ var RULES_DIR = "/etc/threatcrush/rules.d";
16224
16960
  function loadAllRules(customDir) {
16225
16961
  const rules = [...DEFAULT_RULES];
16226
16962
  const dir = customDir || RULES_DIR;
16227
- if ((0, import_node_fs21.existsSync)(dir)) {
16228
- const files = (0, import_node_fs21.readdirSync)(dir).filter((f) => f.endsWith(".json"));
16963
+ if ((0, import_node_fs23.existsSync)(dir)) {
16964
+ const files = (0, import_node_fs23.readdirSync)(dir).filter((f) => f.endsWith(".json"));
16229
16965
  for (const file of files) {
16230
16966
  try {
16231
- const raw = (0, import_node_fs21.readFileSync)((0, import_node_path14.join)(dir, file), "utf-8");
16967
+ const raw = (0, import_node_fs23.readFileSync)((0, import_node_path16.join)(dir, file), "utf-8");
16232
16968
  const parsed = JSON.parse(raw);
16233
16969
  const customRules = Array.isArray(parsed) ? parsed : [parsed];
16234
16970
  for (const rule of customRules) {
@@ -16391,7 +17127,7 @@ function detectFirewallAdapter() {
16391
17127
  }
16392
17128
 
16393
17129
  // src/daemon/firewall/remediation.ts
16394
- var import_node_fs22 = require("fs");
17130
+ var import_node_fs24 = require("fs");
16395
17131
  init_state();
16396
17132
  init_paths();
16397
17133
  var DEFAULT_CONFIG2 = {
@@ -16548,7 +17284,7 @@ var RemediationManager = class {
16548
17284
  }
16549
17285
  logLine(line) {
16550
17286
  try {
16551
- (0, import_node_fs22.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
17287
+ (0, import_node_fs24.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
16552
17288
  `);
16553
17289
  } catch {
16554
17290
  }
@@ -16607,7 +17343,7 @@ async function flushTelemetry(timeoutMs = 2e3) {
16607
17343
  // src/daemon/index.ts
16608
17344
  function readVersion2() {
16609
17345
  try {
16610
- const pkg = JSON.parse((0, import_node_fs23.readFileSync)((0, import_node_path15.join)(__dirname, "..", "package.json"), "utf-8"));
17346
+ const pkg = JSON.parse((0, import_node_fs25.readFileSync)((0, import_node_path17.join)(__dirname, "..", "package.json"), "utf-8"));
16611
17347
  return pkg.version || "0.0.0";
16612
17348
  } catch {
16613
17349
  return "0.0.0";
@@ -16615,7 +17351,7 @@ function readVersion2() {
16615
17351
  }
16616
17352
  function logLine(line) {
16617
17353
  try {
16618
- (0, import_node_fs23.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
17354
+ (0, import_node_fs25.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
16619
17355
  `);
16620
17356
  } catch {
16621
17357
  }
@@ -16643,7 +17379,7 @@ async function runDaemon() {
16643
17379
  } catch (err) {
16644
17380
  logLine(`[daemon] state db unavailable: ${err.message}`);
16645
17381
  }
16646
- const config = loadConfig((0, import_node_fs23.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
17382
+ const config = loadConfig((0, import_node_fs25.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
16647
17383
  bus.on("event", (event) => {
16648
17384
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
16649
17385
  });
@@ -16735,7 +17471,7 @@ async function runDaemon() {
16735
17471
  init_paths();
16736
17472
  init_pidfile();
16737
17473
  init_ipc_client();
16738
- var DAEMON_ENTRY = (0, import_node_path16.join)(__dirname, "daemon.js");
17474
+ var DAEMON_ENTRY = (0, import_node_path18.join)(__dirname, "daemon.js");
16739
17475
  async function daemonForeground() {
16740
17476
  await runDaemon();
16741
17477
  }
@@ -16746,7 +17482,7 @@ async function daemonStart() {
16746
17482
  return;
16747
17483
  }
16748
17484
  ensureRuntimeDirs();
16749
- if (!(0, import_node_fs24.existsSync)(DAEMON_ENTRY)) {
17485
+ if (!(0, import_node_fs26.existsSync)(DAEMON_ENTRY)) {
16750
17486
  console.log(source_default.red(` Daemon entry not found at ${DAEMON_ENTRY}.`));
16751
17487
  console.log(source_default.dim(" Reinstall with `threatcrush update` or `pnpm run build` in the cli package."));
16752
17488
  return;
@@ -16754,8 +17490,8 @@ async function daemonStart() {
16754
17490
  let out;
16755
17491
  let err;
16756
17492
  try {
16757
- out = (0, import_node_fs25.openSync)(PATHS.logFile, "a");
16758
- err = (0, import_node_fs25.openSync)(PATHS.logFile, "a");
17493
+ out = (0, import_node_fs27.openSync)(PATHS.logFile, "a");
17494
+ err = (0, import_node_fs27.openSync)(PATHS.logFile, "a");
16759
17495
  } catch (e) {
16760
17496
  const code = e.code;
16761
17497
  console.log(source_default.red(` \u2717 Cannot write daemon log at ${PATHS.logFile} (${code ?? "error"}).`));
@@ -16834,19 +17570,19 @@ async function daemonStop() {
16834
17570
 
16835
17571
  // src/commands/service.ts
16836
17572
  var import_node_child_process8 = require("child_process");
16837
- var import_node_fs26 = require("fs");
16838
- var import_node_path17 = require("path");
17573
+ var import_node_fs28 = require("fs");
17574
+ var import_node_path19 = require("path");
16839
17575
  var UNIT_PATH = "/etc/systemd/system/threatcrushd.service";
16840
17576
  function resolveTemplate() {
16841
- const templatePath = (0, import_node_path17.join)(__dirname, "systemd", "threatcrushd.service");
16842
- if (!(0, import_node_fs26.existsSync)(templatePath)) {
17577
+ const templatePath = (0, import_node_path19.join)(__dirname, "systemd", "threatcrushd.service");
17578
+ if (!(0, import_node_fs28.existsSync)(templatePath)) {
16843
17579
  throw new Error(`systemd unit template not found at ${templatePath}`);
16844
17580
  }
16845
- return (0, import_node_fs26.readFileSync)(templatePath, "utf-8");
17581
+ return (0, import_node_fs28.readFileSync)(templatePath, "utf-8");
16846
17582
  }
16847
17583
  function resolveBinPath() {
16848
17584
  const arg = process.argv[1];
16849
- if (arg && (0, import_node_fs26.existsSync)(arg)) return arg;
17585
+ if (arg && (0, import_node_fs28.existsSync)(arg)) return arg;
16850
17586
  try {
16851
17587
  return (0, import_node_child_process8.execSync)("command -v threatcrush", { encoding: "utf-8" }).trim();
16852
17588
  } catch {
@@ -16867,7 +17603,7 @@ async function installServiceCommand() {
16867
17603
  return;
16868
17604
  }
16869
17605
  const unit = resolveTemplate().replace("{{BIN_PATH}}", resolveBinPath());
16870
- (0, import_node_fs26.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
17606
+ (0, import_node_fs28.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
16871
17607
  console.log(source_default.green(` \u2713 Installed unit file: ${UNIT_PATH}`));
16872
17608
  ensureSystemDirs();
16873
17609
  try {
@@ -16882,29 +17618,31 @@ async function installServiceCommand() {
16882
17618
  }
16883
17619
  function ensureSystemDirs() {
16884
17620
  const dirs = [
16885
- { path: "/etc/threatcrush" },
16886
- { path: "/etc/threatcrush/modules", sticky: true },
16887
- { path: "/etc/threatcrush/threatcrushd.conf.d" },
16888
- { path: "/var/log/threatcrush" },
16889
- { path: "/var/lib/threatcrush" },
16890
- { path: "/var/run/threatcrush" }
17621
+ { path: "/etc/threatcrush", groupWritable: true },
17622
+ { path: "/etc/threatcrush/modules", groupWritable: true, sticky: true },
17623
+ { path: "/etc/threatcrush/threatcrushd.conf.d", groupWritable: true },
17624
+ { path: "/var/log/threatcrush", groupWritable: false },
17625
+ { path: "/var/lib/threatcrush", groupWritable: false },
17626
+ { path: "/var/run/threatcrush", groupWritable: false }
16891
17627
  ];
16892
17628
  let admGid = null;
16893
17629
  try {
16894
- admGid = (0, import_node_fs26.statSync)("/var/log/auth.log").gid;
17630
+ admGid = (0, import_node_fs28.statSync)("/var/log/auth.log").gid;
16895
17631
  } catch {
16896
17632
  }
16897
- for (const { path, sticky } of dirs) {
17633
+ for (const { path, groupWritable, sticky } of dirs) {
16898
17634
  try {
16899
- (0, import_node_fs26.mkdirSync)(path, { recursive: true });
17635
+ (0, import_node_fs28.mkdirSync)(path, { recursive: true });
16900
17636
  } catch {
16901
17637
  }
16902
- if (admGid !== null) {
16903
- try {
16904
- (0, import_node_fs26.chmodSync)(path, sticky ? 1533 : 509);
17638
+ try {
17639
+ if (groupWritable && admGid !== null) {
17640
+ (0, import_node_fs28.chmodSync)(path, sticky ? 1533 : 509);
16905
17641
  (0, import_node_child_process8.execSync)(`chgrp adm ${path}`, { stdio: "ignore" });
16906
- } catch {
17642
+ } else {
17643
+ (0, import_node_fs28.chmodSync)(path, 493);
16907
17644
  }
17645
+ } catch {
16908
17646
  }
16909
17647
  }
16910
17648
  console.log(source_default.green(" \u2713 Runtime dirs prepared (group `adm` may install modules / edit config without sudo)."));
@@ -16928,7 +17666,7 @@ async function uninstallServiceCommand() {
16928
17666
  } catch {
16929
17667
  }
16930
17668
  try {
16931
- if ((0, import_node_fs26.existsSync)(UNIT_PATH)) {
17669
+ if ((0, import_node_fs28.existsSync)(UNIT_PATH)) {
16932
17670
  (0, import_node_child_process8.execSync)(`rm -f ${UNIT_PATH}`);
16933
17671
  console.log(source_default.green(` \u2713 Removed unit file: ${UNIT_PATH}`));
16934
17672
  }
@@ -17027,8 +17765,8 @@ function welcomeCommand() {
17027
17765
  }
17028
17766
 
17029
17767
  // src/commands/properties.ts
17030
- var import_node_fs27 = require("fs");
17031
- var import_node_path18 = require("path");
17768
+ var import_node_fs29 = require("fs");
17769
+ var import_node_path20 = require("path");
17032
17770
  var import_node_readline6 = __toESM(require("readline"));
17033
17771
  var API_URL7 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
17034
17772
  var KINDS = ["url", "api", "domain", "ip", "repo"];
@@ -17357,8 +18095,8 @@ async function propertiesRunsCommand(opts) {
17357
18095
  }
17358
18096
  }
17359
18097
  function parseImportFile(path) {
17360
- const ext = (0, import_node_path18.extname)(path).toLowerCase();
17361
- const raw = (0, import_node_fs27.readFileSync)(path, "utf-8");
18098
+ const ext = (0, import_node_path20.extname)(path).toLowerCase();
18099
+ const raw = (0, import_node_fs29.readFileSync)(path, "utf-8");
17362
18100
  if (ext === ".json") {
17363
18101
  const parsed = JSON.parse(raw);
17364
18102
  if (!Array.isArray(parsed)) throw new Error("JSON must be an array of objects");
@@ -17548,7 +18286,7 @@ async function rulesCommand(opts) {
17548
18286
  }
17549
18287
 
17550
18288
  // src/commands/harden.ts
17551
- var import_node_fs28 = require("fs");
18289
+ var import_node_fs30 = require("fs");
17552
18290
  var import_node_child_process9 = require("child_process");
17553
18291
  function tryExec(cmd) {
17554
18292
  try {
@@ -17559,7 +18297,7 @@ function tryExec(cmd) {
17559
18297
  }
17560
18298
  function tryRead(path) {
17561
18299
  try {
17562
- return (0, import_node_fs28.readFileSync)(path, "utf-8");
18300
+ return (0, import_node_fs30.readFileSync)(path, "utf-8");
17563
18301
  } catch {
17564
18302
  return null;
17565
18303
  }
@@ -17663,8 +18401,8 @@ function checkSshWeakConfig() {
17663
18401
  };
17664
18402
  }
17665
18403
  function checkAutoUpdates() {
17666
- const unattended = (0, import_node_fs28.existsSync)("/etc/apt/apt.conf.d/20auto-upgrades") || (0, import_node_fs28.existsSync)("/etc/apt/apt.conf.d/50unattended-upgrades");
17667
- const dnfAuto = (0, import_node_fs28.existsSync)("/etc/dnf/automatic.conf");
18404
+ const unattended = (0, import_node_fs30.existsSync)("/etc/apt/apt.conf.d/20auto-upgrades") || (0, import_node_fs30.existsSync)("/etc/apt/apt.conf.d/50unattended-upgrades");
18405
+ const dnfAuto = (0, import_node_fs30.existsSync)("/etc/dnf/automatic.conf");
17668
18406
  if (unattended || dnfAuto) {
17669
18407
  return {
17670
18408
  key: "auto-updates",
@@ -17790,7 +18528,7 @@ function checkFail2ban() {
17790
18528
  explanation: "fail2ban is installed and running."
17791
18529
  };
17792
18530
  }
17793
- if ((0, import_node_fs28.existsSync)("/etc/fail2ban/fail2ban.conf")) {
18531
+ if ((0, import_node_fs30.existsSync)("/etc/fail2ban/fail2ban.conf")) {
17794
18532
  return {
17795
18533
  key: checkKey,
17796
18534
  status: "warn",
@@ -18021,7 +18759,7 @@ async function allowlistCommand(opts) {
18021
18759
  init_paths();
18022
18760
  var PKG_VERSION2 = "0.1.8";
18023
18761
  try {
18024
- const pkg = JSON.parse((0, import_node_fs29.readFileSync)((0, import_node_path19.join)(__dirname, "..", "package.json"), "utf-8"));
18762
+ const pkg = JSON.parse((0, import_node_fs31.readFileSync)((0, import_node_path21.join)(__dirname, "..", "package.json"), "utf-8"));
18025
18763
  PKG_VERSION2 = pkg.version;
18026
18764
  } catch {
18027
18765
  }
@@ -18037,7 +18775,7 @@ ${source_default.dim(" C R U S H")}
18037
18775
  var API_URL8 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
18038
18776
  var PKG_NAME = "@profullstack/threatcrush";
18039
18777
  var DESKTOP_PKG_NAME = "@profullstack/threatcrush-desktop";
18040
- var INSTALL_CONFIG_PATH = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush", "install.json");
18778
+ var INSTALL_CONFIG_PATH = (0, import_node_path21.join)((0, import_node_os8.homedir)(), ".threatcrush", "install.json");
18041
18779
  function detectPackageManager() {
18042
18780
  try {
18043
18781
  const npmGlobal = (0, import_node_child_process10.execSync)("npm ls -g --depth=0 --json 2>/dev/null", { encoding: "utf-8" });
@@ -18063,7 +18801,7 @@ function detectPackageManager() {
18063
18801
  }
18064
18802
  function readInstallConfig() {
18065
18803
  try {
18066
- return JSON.parse((0, import_node_fs29.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
18804
+ return JSON.parse((0, import_node_fs31.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
18067
18805
  } catch {
18068
18806
  return {};
18069
18807
  }
@@ -18248,7 +18986,7 @@ program2.command("uninstall-service").description("Remove the threatcrushd syste
18248
18986
  program2.command("logs").description("Tail daemon logs").action(async () => {
18249
18987
  console.log(LOGO2);
18250
18988
  const logPath = PATHS.logFile;
18251
- if (!(0, import_node_fs29.existsSync)(logPath)) {
18989
+ if (!(0, import_node_fs31.existsSync)(logPath)) {
18252
18990
  console.log(source_default.yellow(` No log file found at ${logPath}`));
18253
18991
  console.log(source_default.dim(" Start the daemon first with `threatcrush start`\n"));
18254
18992
  return;
@@ -18453,10 +19191,10 @@ storeCmd.command("search <query>").description("Search for modules in the store"
18453
19191
  });
18454
19192
  storeCmd.command("publish <url>").description("Publish a module from a git URL or web URL").action(async (url) => {
18455
19193
  console.log(LOGO2);
18456
- const configPath = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
19194
+ const configPath = (0, import_node_path21.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
18457
19195
  let email = "";
18458
19196
  try {
18459
- const config = JSON.parse((0, import_node_fs29.readFileSync)(configPath, "utf-8"));
19197
+ const config = JSON.parse((0, import_node_fs31.readFileSync)(configPath, "utf-8"));
18460
19198
  email = config.email || "";
18461
19199
  } catch {
18462
19200
  }
@@ -18473,9 +19211,9 @@ storeCmd.command("publish <url>").description("Publish a module from a git URL o
18473
19211
  return;
18474
19212
  }
18475
19213
  try {
18476
- const dir = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush");
18477
- if (!(0, import_node_fs29.existsSync)(dir)) (0, import_node_fs29.mkdirSync)(dir, { recursive: true });
18478
- (0, import_node_fs29.writeFileSync)(configPath, JSON.stringify({ email }, null, 2));
19214
+ const dir = (0, import_node_path21.join)((0, import_node_os8.homedir)(), ".threatcrush");
19215
+ if (!(0, import_node_fs31.existsSync)(dir)) (0, import_node_fs31.mkdirSync)(dir, { recursive: true });
19216
+ (0, import_node_fs31.writeFileSync)(configPath, JSON.stringify({ email }, null, 2));
18479
19217
  console.log(source_default.dim(` Saved email to ${configPath}`));
18480
19218
  } catch {
18481
19219
  }