@profullstack/threatcrush 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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);
@@ -9580,6 +9618,357 @@ function workerId() {
9580
9618
  return `${import_node_os3.default.hostname()}/${process.pid}`;
9581
9619
  }
9582
9620
 
9621
+ // ../../packages/scan/src/node-rules.ts
9622
+ var PATH_CONTAINMENT_GUARD = /\bstartsWith\s*\(|\brelative\s*\(|\bnormalize\s*\(|\brealpath\b|\bisInside\b|\bwithin\s*\(|\bsanitiz\w*\b/i;
9623
+ var ARCHIVE_LIBRARY = /\b(?:adm-zip|unzipper|yauzl|node-stream-zip|extract-zip|decompress|tar-stream|tar-fs)\b|require\s*\(\s*['"]tar['"]|from\s+['"]tar['"]/;
9624
+ var HEADLESS_BROWSER = /\b(?:puppeteer|playwright|phantom|phantomjs|wkhtmltopdf|wkhtmltoimage|chrome-aws-lambda|html-pdf)\b/;
9625
+ var ORIGIN_ALLOWLIST_GUARD = /\b(?:includes|indexOf|has|test|find|some)\s*\(/;
9626
+ var FUNCTION_DESERIALIZER = /\b(?:node-serialize|serialize-to-js|funcster|cryo)\b/;
9627
+ var CONCATENATED_STRING = String.raw`(?:"[^"\n]*"|'[^'\n]*')\s*\+`;
9628
+ var INTERPOLATED_TEMPLATE = "`[^`\\n]*\\$\\{";
9629
+ var NODE_RULES = [
9630
+ // ── Code execution ───────────────────────────────────────────────────────
9631
+ {
9632
+ id: "js-vm-untrusted-execution",
9633
+ title: "script compiled and run by the `vm` module",
9634
+ consequence: "Node\u2019s `vm` is not a security boundary \u2014 it isolates globals, not the process. Code reaching it can walk back out through any object it is handed and runs with the server\u2019s full privileges.",
9635
+ cwe: "CWE-94",
9636
+ severity: "critical",
9637
+ languages: ["javascript", "typescript"],
9638
+ pattern: /\bvm\s*\.\s*(?:runInNewContext|runInThisContext|runInContext|compileFunction)\s*\(|\bnew\s+vm\s*\.\s*Script\s*\(/,
9639
+ // A `vm` call whose source is a build-time constant is a plugin loader, not
9640
+ // a vulnerability. The class only becomes real once the script text can be
9641
+ // influenced, so require that evidence before reporting at full severity.
9642
+ needsContext: true
9643
+ },
9644
+ {
9645
+ id: "js-vm2-sandbox",
9646
+ title: "`vm2` used as a sandbox",
9647
+ consequence: "vm2 was discontinued after a series of escapes that its maintainer judged unfixable by design. Any code it runs should be assumed to run on the host.",
9648
+ cwe: "CWE-1104",
9649
+ severity: "high",
9650
+ languages: ["javascript", "typescript"],
9651
+ pattern: /\bnew\s+(?:NodeVM|VMScript)\s*\(|\bfrom\s+['"]vm2['"]|require\s*\(\s*['"]vm2['"]\s*\)/,
9652
+ // Nothing on the surrounding lines changes the answer: the package itself
9653
+ // is the finding, the same way a broken cipher is.
9654
+ inherent: true
9655
+ },
9656
+ {
9657
+ id: "js-function-deserialization",
9658
+ title: "deserialiser that reconstructs functions",
9659
+ consequence: "These formats encode functions alongside data and invoke them on load, so parsing an attacker\u2019s payload is executing it. No amount of validation after the parse call helps \u2014 the code has already run.",
9660
+ cwe: "CWE-502",
9661
+ severity: "critical",
9662
+ languages: ["javascript", "typescript"],
9663
+ pattern: /\b(?:unserialize|deepDeserialize|deserialize)\s*\(/,
9664
+ // Without this the rule fires on every project that happens to own a
9665
+ // function called `deserialize`, which is most of them. The import is what
9666
+ // makes the call the dangerous one.
9667
+ fileRequires: FUNCTION_DESERIALIZER,
9668
+ // The parse *is* the execution, so nearby input cannot make it worse and
9669
+ // its absence cannot make it safe. Nobody round-trips a constant through
9670
+ // these libraries.
9671
+ inherent: true
9672
+ },
9673
+ {
9674
+ id: "js-template-injection",
9675
+ title: "template compiled from a non-constant source",
9676
+ consequence: "Template languages are programming languages. A user-supplied template body is remote code execution, not cross-site scripting \u2014 the expression runs on the server before any output is escaped.",
9677
+ cwe: "CWE-1336",
9678
+ severity: "critical",
9679
+ languages: ["javascript", "typescript"],
9680
+ pattern: /\b(?:handlebars|Handlebars|hbs|ejs|pug|jade|nunjucks|eta|twig|dot|doT|liquid|mustache)\s*\.\s*(?:compile|compileFile|render|renderString)\s*\(\s*(?:`[^`\n]*\$\{|[a-zA-Z_$][\w$.]*\s*[,)]|[a-zA-Z_$][\w$.]*\s*\+)/,
9681
+ // Rendering a template held in a variable is the normal case — it was read
9682
+ // from a file at boot. Only the version where request data reaches the
9683
+ // template *body* is this class.
9684
+ needsContext: true
9685
+ },
9686
+ {
9687
+ id: "js-shelljs-command-execution",
9688
+ title: "shelljs command assembled from a string",
9689
+ consequence: "`shell.exec` runs its argument through a shell, so a `;` or backtick in an interpolated value runs as the server user.",
9690
+ cwe: "CWE-78",
9691
+ severity: "critical",
9692
+ languages: ["javascript", "typescript"],
9693
+ pattern: new RegExp(
9694
+ `\\b(?:shell|shelljs|sh)\\s*\\.\\s*exec\\s*\\(\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING}|[a-zA-Z_$][\\w$]*\\s*\\+)`
9695
+ ),
9696
+ fileRequires: /\bshelljs\b/
9697
+ },
9698
+ // ── Injection ────────────────────────────────────────────────────────────
9699
+ {
9700
+ id: "js-nosql-where-expression",
9701
+ title: "MongoDB `$where` built by string assembly",
9702
+ consequence: "`$where` is evaluated as JavaScript by the database server, once per document. An interpolated value can rewrite the predicate to `true` or run a denial-of-service loop inside the database.",
9703
+ cwe: "CWE-943",
9704
+ severity: "critical",
9705
+ languages: ["javascript", "typescript"],
9706
+ pattern: new RegExp(
9707
+ `\\$where\\s*['"]?\\s*:\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING})|\\.\\s*\\$where\\s*=\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING})`
9708
+ )
9709
+ },
9710
+ {
9711
+ id: "js-xpath-injection",
9712
+ title: "XPath expression built by concatenation",
9713
+ consequence: "A quote in the interpolated value closes the predicate early, so the query selects nodes the caller was never meant to read \u2014 the XML equivalent of `OR 1=1`.",
9714
+ cwe: "CWE-643",
9715
+ severity: "high",
9716
+ languages: ["javascript", "typescript"],
9717
+ // An XPath expression is recognisable by its own syntax — a descendant
9718
+ // axis or an attribute predicate. Matching on the *call* name alone would
9719
+ // flag every `select(` in the ecosystem. The quoted forms are split per
9720
+ // quote character for the reason given at CONCATENATED_STRING: the
9721
+ // predicate that makes it XPath usually contains the other quote.
9722
+ pattern: new RegExp(
9723
+ `\\b(?:xpath|xpathSelect|select|selectNodes|selectSingleNode|evaluate|find)\\s*\\(\\s*(?:\`[^\`\\n]*(?:\\/\\/|\\[@)[^\`\\n]*\\$\\{|"[^"\\n]*(?:\\/\\/|\\[@)[^"\\n]*"\\s*\\+|'[^'\\n]*(?:\\/\\/|\\[@)[^'\\n]*'\\s*\\+)`
9724
+ ),
9725
+ fileRequires: /\bxpath\b|\bxmldom\b|\blibxmljs\b|\bxpath\.js\b/
9726
+ },
9727
+ {
9728
+ id: "js-regex-from-input",
9729
+ title: "regular expression compiled from a variable",
9730
+ consequence: "A caller who controls the pattern controls the matcher: they can supply catastrophic backtracking to hang the event loop, or a permissive pattern that defeats whatever the regex was validating.",
9731
+ cwe: "CWE-1333",
9732
+ severity: "medium",
9733
+ languages: ["javascript", "typescript"],
9734
+ pattern: new RegExp(
9735
+ `\\bnew\\s+RegExp\\s*\\(\\s*(?:${INTERPOLATED_TEMPLATE}|${CONCATENATED_STRING}|(?!['"\`/])[a-zA-Z_$][\\w$.]*\\s*[,)])`
9736
+ ),
9737
+ needsContext: true
9738
+ },
9739
+ // ── XML ──────────────────────────────────────────────────────────────────
9740
+ {
9741
+ id: "js-xml-external-entities",
9742
+ title: "XML parser configured to resolve entities",
9743
+ consequence: "An entity declaration in the document body makes the parser fetch a local file or an internal URL and paste the result into the parsed output \u2014 file disclosure and server-side request forgery from a document upload.",
9744
+ cwe: "CWE-611",
9745
+ severity: "high",
9746
+ languages: ["javascript", "typescript"],
9747
+ // The option name is the finding. Every parser in the ecosystem defaults
9748
+ // these off, so an explicit `true` is a deliberate re-enable.
9749
+ pattern: /\b(?:noent|resolveEntities|expandEntities|externalEntities|resolveExternals)\s*:\s*(?:true|1)\b/,
9750
+ inherent: true
9751
+ },
9752
+ // ── Authentication and tokens ────────────────────────────────────────────
9753
+ {
9754
+ id: "js-jwt-none-algorithm",
9755
+ title: "JWT algorithm set to `none`",
9756
+ consequence: "The `none` algorithm means the signature is not checked. Anyone can mint a token with any claims \u2014 including another user\u2019s id or an admin role \u2014 by base64-encoding a header and a body.",
9757
+ cwe: "CWE-347",
9758
+ severity: "critical",
9759
+ languages: ["javascript", "typescript"],
9760
+ pattern: /\balgorithms?\s*:\s*\[?\s*['"]none['"]/i,
9761
+ inherent: true
9762
+ },
9763
+ // ── Cryptography ─────────────────────────────────────────────────────────
9764
+ {
9765
+ id: "js-broken-cipher-algorithm",
9766
+ title: "broken cipher selected",
9767
+ consequence: "DES, 3DES, RC2, RC4, Blowfish and IDEA are all breakable with commodity hardware or have practical plaintext-recovery attacks. Data encrypted with them should be treated as encoded, not encrypted.",
9768
+ cwe: "CWE-327",
9769
+ severity: "high",
9770
+ languages: ["javascript", "typescript"],
9771
+ pattern: /\bcreate(?:Cipher|Decipher)(?:iv)?\s*\(\s*['"](?:des|des3|des-ede\w*|3des|rc2|rc4|bf|blowfish|cast5?|idea|seed)\b/i,
9772
+ inherent: true
9773
+ },
9774
+ {
9775
+ id: "js-ecb-mode-cipher",
9776
+ title: "block cipher in ECB mode",
9777
+ consequence: "ECB encrypts every block independently, so identical plaintext blocks produce identical ciphertext. Structure in the data survives encryption and blocks can be reordered or replayed without detection.",
9778
+ cwe: "CWE-327",
9779
+ severity: "high",
9780
+ languages: ["javascript", "typescript"],
9781
+ pattern: /\bcreate(?:Cipher|Decipher)(?:iv)?\s*\(\s*['"][^'"\n]*-ecb\b/i,
9782
+ inherent: true
9783
+ },
9784
+ {
9785
+ id: "js-legacy-cipher-api",
9786
+ title: "deprecated `createCipher` used",
9787
+ consequence: "`createCipher` derives the key from a passphrase with a single unsalted MD5 pass and uses a fixed all-zero IV, so the same passphrase always produces the same keystream. It was removed in Node 22.",
9788
+ cwe: "CWE-327",
9789
+ severity: "high",
9790
+ languages: ["javascript", "typescript"],
9791
+ // `createCipheriv` is the correct API and shares the prefix, so the
9792
+ // negative look-ahead is what separates the finding from the fix.
9793
+ pattern: /\bcrypto\s*\.\s*create(?:Cipher|Decipher)\s*\(/,
9794
+ lineGuard: /create(?:Cipher|Decipher)iv\s*\(/,
9795
+ inherent: true
9796
+ },
9797
+ // ── Cross-site scripting ─────────────────────────────────────────────────
9798
+ {
9799
+ id: "js-template-autoescape-disabled",
9800
+ title: "template auto-escaping turned off",
9801
+ consequence: "Auto-escaping is the control that makes a template engine safe by default. Disabling it globally means every interpolation in every template becomes an injection point, including ones written later by someone who assumed the default.",
9802
+ cwe: "CWE-79",
9803
+ severity: "high",
9804
+ languages: ["javascript", "typescript"],
9805
+ pattern: /\bautoescape\s*:\s*false\b|\bescape\s*:\s*false\b|\bnoEscape\s*:\s*true\b/,
9806
+ inherent: true
9807
+ },
9808
+ {
9809
+ id: "js-serialize-javascript-unsafe",
9810
+ title: "`serialize-javascript` in unsafe mode",
9811
+ consequence: "The `unsafe` flag turns off escaping of HTML-significant characters in the output. Embedding the result in a `<script>` block lets a string value close the tag and start a new one.",
9812
+ cwe: "CWE-79",
9813
+ severity: "high",
9814
+ languages: ["javascript", "typescript"],
9815
+ pattern: /\bunsafe\s*:\s*true\b/,
9816
+ fileRequires: /\bserialize-javascript\b/,
9817
+ inherent: true
9818
+ },
9819
+ {
9820
+ id: "js-cors-origin-reflected",
9821
+ title: "CORS origin reflected from the request",
9822
+ consequence: "Echoing the caller\u2019s `Origin` header back as `Access-Control-Allow-Origin` allows every site, while looking like an allow-list. Combined with credentials, any page the victim visits can read their authenticated responses.",
9823
+ cwe: "CWE-942",
9824
+ severity: "high",
9825
+ languages: ["javascript", "typescript"],
9826
+ // Both spellings of the same mistake: writing the header directly, and
9827
+ // handing the request's origin to a CORS middleware's `origin` option.
9828
+ //
9829
+ // An earlier draft also matched a bare variable — `setHeader('…', origin)`
9830
+ // — which is wrong. A variable holding a *validated* origin is exactly
9831
+ // what the correct implementation looks like, and the rule cannot tell the
9832
+ // two apart. Only the visible read from the request qualifies.
9833
+ pattern: /\bAccess-Control-Allow-Origin['"]\s*,\s*(?:req|request|ctx)\s*\.|\borigin\s*:\s*(?:req|request|ctx)\s*\./,
9834
+ // Reflecting the header is the defect whatever surrounds it; there is no
9835
+ // arrangement of nearby lines that makes an echoed origin safe — except a
9836
+ // membership test on the value, which is what the guard looks for.
9837
+ inherent: true,
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)$/
9849
+ },
9850
+ // ── Server-side request forgery ──────────────────────────────────────────
9851
+ {
9852
+ id: "js-headless-browser-navigation",
9853
+ title: "headless browser sent to a non-constant URL",
9854
+ consequence: "The browser runs on the server, inside the private network. A controlled URL reaches the cloud metadata endpoint, localhost admin panels and internal services \u2014 and `file://` reads the disk.",
9855
+ cwe: "CWE-918",
9856
+ severity: "high",
9857
+ languages: ["javascript", "typescript"],
9858
+ pattern: /\.\s*(?:goto|setContent|navigate)\s*\(\s*(?:`[^`\n]*\$\{|(?!['"`])[a-zA-Z_$][\w$.]*\s*[,)])/,
9859
+ fileRequires: HEADLESS_BROWSER,
9860
+ needsContext: true
9861
+ },
9862
+ // ── Path handling ────────────────────────────────────────────────────────
9863
+ {
9864
+ id: "js-archive-entry-path",
9865
+ title: "archive entry written to a path built from its own name",
9866
+ consequence: "An entry named `../../etc/cron.d/x` escapes the extraction directory when its name is joined to the destination. Overwriting a file outside the target \u2014 a systemd unit, an SSH key, a deployed script \u2014 is code execution on the next run.",
9867
+ cwe: "CWE-22",
9868
+ severity: "high",
9869
+ languages: ["javascript", "typescript"],
9870
+ pattern: /\b(?:join|resolve)\s*\(\s*[^,\n)]+,\s*[a-zA-Z_$][\w$]*\s*\.\s*(?:entryName|fileName|filename|name|path)\b/,
9871
+ fileRequires: ARCHIVE_LIBRARY,
9872
+ guard: PATH_CONTAINMENT_GUARD,
9873
+ // The containment check comes *after* the join — you build the path, then
9874
+ // verify it stayed inside. A backwards-only window would miss every
9875
+ // correct implementation and report the safe code alongside the unsafe.
9876
+ guardForward: 3
9877
+ },
9878
+ // ── Electron ─────────────────────────────────────────────────────────────
9879
+ {
9880
+ id: "js-electron-node-integration",
9881
+ title: "Electron renderer given Node access",
9882
+ consequence: "With node integration on \u2014 or context isolation off \u2014 any script that reaches the page reaches `require`. A single XSS in rendered content becomes `child_process.exec` on the user\u2019s machine.",
9883
+ cwe: "CWE-1188",
9884
+ severity: "high",
9885
+ languages: ["javascript", "typescript"],
9886
+ pattern: /\bnodeIntegration(?:InWorker|InSubFrames)?\s*:\s*true\b|\bcontextIsolation\s*:\s*false\b|\benableRemoteModule\s*:\s*true\b|\bsandbox\s*:\s*false\b/,
9887
+ inherent: true
9888
+ },
9889
+ {
9890
+ id: "js-electron-web-security-disabled",
9891
+ title: "Electron web security disabled",
9892
+ consequence: "Turning off `webSecurity` drops the same-origin policy for the window, so remote content can read local files and every other origin the app has loaded.",
9893
+ cwe: "CWE-1173",
9894
+ severity: "high",
9895
+ languages: ["javascript", "typescript"],
9896
+ pattern: /\bwebSecurity\s*:\s*false\b|\ballowRunningInsecureContent\s*:\s*true\b|\bwebviewTag\s*:\s*true\b/,
9897
+ inherent: true
9898
+ },
9899
+ {
9900
+ id: "js-electron-open-external",
9901
+ title: "Electron `openExternal` with a non-constant URL",
9902
+ consequence: "`shell.openExternal` hands the string to the operating system\u2019s handler. A `file://` path executes a local binary and, on Windows, an SMB path executes a remote one.",
9903
+ cwe: "CWE-749",
9904
+ severity: "high",
9905
+ languages: ["javascript", "typescript"],
9906
+ pattern: /\bshell\s*\.\s*openExternal\s*\(\s*(?:`[^`\n]*\$\{|(?!['"`])[a-zA-Z_$][\w$.]*\s*[,)])/,
9907
+ needsContext: true
9908
+ },
9909
+ // ── Hardening and resource limits ────────────────────────────────────────
9910
+ {
9911
+ id: "js-helmet-protection-disabled",
9912
+ title: "security header explicitly disabled",
9913
+ consequence: "Each of these switches off a browser-side protection that was already on. The header stops being sent, so the defence it enables \u2014 framing, sniffing, referrer leakage, transport downgrade \u2014 is available to an attacker again.",
9914
+ cwe: "CWE-693",
9915
+ severity: "medium",
9916
+ languages: ["javascript", "typescript"],
9917
+ pattern: /\b(?:contentSecurityPolicy|frameguard|hsts|noSniff|xssFilter|hidePoweredBy|referrerPolicy|dnsPrefetchControl|ieNoOpen|permittedCrossDomainPolicies|crossOriginEmbedderPolicy|crossOriginOpenerPolicy|crossOriginResourcePolicy|originAgentCluster)\s*:\s*false\b/,
9918
+ inherent: true
9919
+ },
9920
+ {
9921
+ id: "js-buffer-bounds-check-disabled",
9922
+ title: "buffer bounds checking turned off",
9923
+ consequence: "With `noAssert` the read or write is not range-checked, so an offset past the end of the buffer returns adjacent heap memory or corrupts it instead of throwing.",
9924
+ cwe: "CWE-125",
9925
+ severity: "medium",
9926
+ languages: ["javascript", "typescript"],
9927
+ // The trailing `true` on a Buffer numeric accessor *is* `noAssert` — it is
9928
+ // the last positional parameter of every one of these methods. Anchoring
9929
+ // on the accessor name is what keeps this from matching `, true)` on any
9930
+ // call in the codebase.
9931
+ pattern: /\b(?:read|write)(?:U?Int(?:8|16|32)(?:[BL]E)?|U?Int[BL]E|Float[BL]E|Double[BL]E)\s*\([^)\n]*,\s*true\s*\)|\bnoAssert\s*:\s*true\b/,
9932
+ inherent: true
9933
+ },
9934
+ {
9935
+ id: "js-uninitialized-buffer",
9936
+ title: "buffer allocated without zeroing",
9937
+ consequence: "`allocUnsafe` and the old `new Buffer(size)` hand back whatever was previously in that heap memory \u2014 keys, session tokens, other users\u2019 request bodies. Anything not overwritten before the buffer is sent leaks it.",
9938
+ cwe: "CWE-908",
9939
+ severity: "medium",
9940
+ languages: ["javascript", "typescript"],
9941
+ pattern: /\bBuffer\s*\.\s*allocUnsafe(?:Slow)?\s*\(|\bnew\s+Buffer\s*\(\s*(?![`'"])[a-zA-Z_$0-9]/,
9942
+ inherent: true
9943
+ },
9944
+ {
9945
+ id: "js-oversized-request-body-limit",
9946
+ title: "request body limit raised to a very large value",
9947
+ consequence: "A body limit in the tens of megabytes lets a handful of concurrent requests exhaust memory, and the parse happens before any authentication check the route performs.",
9948
+ cwe: "CWE-400",
9949
+ severity: "medium",
9950
+ languages: ["javascript", "typescript"],
9951
+ // Two or more digits before `mb` — 50mb and up. A limit is a *good* thing;
9952
+ // only an ineffective one is the finding.
9953
+ pattern: /\blimit\s*:\s*['"]\s*(?:[5-9]\d|\d{3,})\s*mb\s*['"]|\blimit\s*:\s*['"]\s*\d+\s*gb\s*['"]/i,
9954
+ inherent: true
9955
+ },
9956
+ // ── Information disclosure ───────────────────────────────────────────────
9957
+ {
9958
+ id: "js-error-detail-returned",
9959
+ title: "error object or stack trace sent to the client",
9960
+ consequence: "A stack trace names absolute paths, package versions and internal function names, and framework errors often carry the failing query or connection string with them. It is a map of the server, handed out on request.",
9961
+ cwe: "CWE-209",
9962
+ severity: "medium",
9963
+ languages: ["javascript", "typescript"],
9964
+ // `res.status(500).send(…)` is the overwhelmingly common spelling, so the
9965
+ // optional `.status(…)` hop is not a nicety — without it the rule misses
9966
+ // nearly every real occurrence.
9967
+ pattern: /\bres\s*\.\s*(?:status\s*\([^)\n]*\)\s*\.\s*)?(?:send|json|end|write)\s*\(\s*(?:[a-zA-Z_$][\w$]*\s*\.\s*stack\b|(?:error|err|e)\s*[,)])|\.\s*(?:send|json)\s*\(\s*\{[^}\n]*\b(?:stack|err|error)\s*:\s*(?:error|err|e)\s*[,}]/,
9968
+ inherent: true
9969
+ }
9970
+ ];
9971
+
9583
9972
  // ../../packages/scan/src/types.ts
9584
9973
  var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
9585
9974
  function severityRank(severity) {
@@ -9626,7 +10015,20 @@ var GENERIC_GUARD = (
9626
10015
  // The identifier must END at the escaper (with at most a known output-context
9627
10016
  // suffix). An earlier, looser form also matched `describe(`, which would have
9628
10017
  // silenced findings across every test file in every repository.
9629
- /\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\b\w{0,6}[Ee]sc(?:ape)?(?:[Hh]tml|HTML|[Xx]ml|XML|[Ss]ql|[Aa]ttr|[Jj]s|[Uu]ri|[Uu]rl)?\s*\(|\bhtml_escape\b|\bhtmlspecialchars\s*\(|\bsanitiz\w*\b|\bencoded\b|\brealpath\b|\bcommonpath\b|\bresolve\(\)\.startsWith\b|\bprocess\.env\b|\bos\.environ\b|\bgetenv\b|\bENV\s*\[|setObjectInputFilter|ObjectInputFilter/i
10018
+ // The `Access-Control-` lookbehind is not a nicety. This regex is
10019
+ // case-insensitive, and `Access-Control-Allow-Origin` contains the word
10020
+ // "Allow" followed by a non-word character — so the CORS header name matched
10021
+ // the allow-list heuristic. Because the guard is tested against an 8-line
10022
+ // *window*, one header line silently disabled every guardable rule near it:
10023
+ // in a four-line Express error handler, the header on one line suppressed
10024
+ // both the `none`-algorithm JWT finding and the returned stack trace below
10025
+ // it. The failure mode is the dangerous kind — not fewer findings, none, and
10026
+ // indistinguishable from clean code.
10027
+ //
10028
+ // Scoped to the header prefix rather than to a bare `allow(?!-)`, because
10029
+ // this guard also runs over `.yml` and `.conf` files, where `allow-list:`
10030
+ // and `allowed-hosts:` are ordinary keys that should still guard.
10031
+ /(?<!Access-Control-)\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\b\w{0,6}[Ee]sc(?:ape)?(?:[Hh]tml|HTML|[Xx]ml|XML|[Ss]ql|[Aa]ttr|[Jj]s|[Uu]ri|[Uu]rl)?\s*\(|\bhtml_escape\b|\bhtmlspecialchars\s*\(|\bsanitiz\w*\b|\bencoded\b|\brealpath\b|\bcommonpath\b|\bresolve\(\)\.startsWith\b|\bprocess\.env\b|\bos\.environ\b|\bgetenv\b|\bENV\s*\[|setObjectInputFilter|ObjectInputFilter/i
9630
10032
  );
9631
10033
  var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
9632
10034
  var XML_PARSING_FILE = /\b(?:javax\.xml|org\.xml\.sax|org\.w3c\.dom|org\.jdom2?|org\.dom4j|XmlPullParser|DocumentBuilderFactory|DocumentBuilder|SAXParserFactory|SAXParser|XMLInputFactory|XMLReaderFactory|XMLReader|SAXBuilder|SAXReader)\b/;
@@ -9692,7 +10094,8 @@ var CODE_RULES = [
9692
10094
  cwe: "CWE-78",
9693
10095
  severity: "critical",
9694
10096
  languages: ["javascript", "typescript"],
9695
- pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/
10097
+ pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/,
10098
+ constantInterpolationGuard: true
9696
10099
  },
9697
10100
  {
9698
10101
  id: "py-shell-command-string",
@@ -10054,6 +10457,20 @@ var CODE_RULES = [
10054
10457
  consequence: "Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.",
10055
10458
  cwe: "CWE-1333",
10056
10459
  severity: "medium",
10460
+ // Scoped, because unscoped this rule reads C as if it were a regex.
10461
+ //
10462
+ // `(void *)*memptr64` — a cast to a pointer type, then a dereference — is
10463
+ // the single most ordinary line in a C file, and it matches the first
10464
+ // alternative exactly: `(`, some text, `*`, `)`, `*`. Every `*(char *)*p`
10465
+ // in a codebase became a ReDoS finding. Caught on inspektor-gadget, where
10466
+ // the rule's one and only hit across 1186 files was a `bpf_probe_read_user`
10467
+ // call in an eBPF C program.
10468
+ //
10469
+ // Scoping is the fix rather than a cleverer pattern: C has no regex
10470
+ // literals, so there is nothing here for the rule to find no matter how
10471
+ // the pattern is written. `other` also covers C++, Rust and Zig, which
10472
+ // share the cast-then-deref spelling.
10473
+ languages: ["javascript", "typescript", "python", "ruby", "go", "java", "php"],
10057
10474
  pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
10058
10475
  },
10059
10476
  // ── Temporary files ──────────────────────────────────────────────────────
@@ -10577,8 +10994,27 @@ var CODE_RULES = [
10577
10994
  cwe: "CWE-346",
10578
10995
  severity: "high",
10579
10996
  languages: ["javascript", "typescript"],
10580
- 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/
10581
- }
10997
+ 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/,
10998
+ /**
10999
+ * Parsing the request's own URL is not building a link from the Host
11000
+ * header, even though it is spelled with one.
11001
+ *
11002
+ * const url = new URL(request.url, `http://${request.headers.host}`);
11003
+ * const token = url.searchParams.get('token');
11004
+ *
11005
+ * `request.url` on a Node server is a path — `/ws?token=…` — and `new URL`
11006
+ * refuses a relative input without a base. The base exists to satisfy the
11007
+ * parser and is thrown away; only the path and query are ever read. Every
11008
+ * Node HTTP handler that wants a query parameter is written this way, so
11009
+ * the rule fired on the framework idiom rather than on the defect.
11010
+ *
11011
+ * Narrow on purpose: the first argument must be `req.url` itself. The
11012
+ * dangerous shape passes a *path* the application chose —
11013
+ * `new URL('/reset?t=…', `https://${req.headers.host}`)` — and that is what
11014
+ * produces an attacker-controlled link. It does not match this guard.
11015
+ */
11016
+ lineGuard: /\bnew\s+URL\s*\(\s*(?:req|request|ctx)(?:uest)?\s*\.\s*url\b\s*,/
11017
+ },
10582
11018
  // A recursive-merge prototype-pollution rule (`target[key] = source[key]`
10583
11019
  // with no `__proto__` guard) was built and dropped. The bare copy-by-key is
10584
11020
  // the safe allow-listed shape (`updates[field] = body[field]` over an
@@ -10588,6 +11024,11 @@ var CODE_RULES = [
10588
11024
  // answer. It flagged legitimate merges in Capacitor and in this repo's own
10589
11025
  // web app. `js-prototype-pollution` still catches the explicit `__proto__`
10590
11026
  // literal; the recursive-merge case is left to KNOWN_GAPS.
11027
+ // Node-ecosystem classes — vm2, Electron, JWT, archive extraction, headless
11028
+ // browsers — are kept in their own table because each has to know which
11029
+ // package it is looking at before it can claim anything. Folded in here so
11030
+ // there stays exactly one rule list for every consumer to iterate.
11031
+ ...NODE_RULES
10591
11032
  ];
10592
11033
  var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
10593
11034
  var DEFINITION_PREFIX = /^\s*(?:(?:export|public|private|protected|static|final|async|abstract)\s+)*(?:def|function|func|class|module|interface|struct|impl|fn|sub)\b/;
@@ -10640,6 +11081,55 @@ function fileTextOf(lines) {
10640
11081
  function withoutSingleQuoted(text) {
10641
11082
  return text.replace(/'[^'\n]*'/g, "''");
10642
11083
  }
11084
+ function calleeEndingAt(text, open) {
11085
+ let end = open;
11086
+ while (end > 0 && (text[end - 1] === " " || text[end - 1] === " ")) end -= 1;
11087
+ let start = end;
11088
+ while (start > 0 && /[\w$.]/.test(text[start - 1])) start -= 1;
11089
+ return text.slice(start, end);
11090
+ }
11091
+ function enclosingCallees(lines, index, back) {
11092
+ const before = lines.slice(Math.max(0, index - back), index).join("\n");
11093
+ const stack = [];
11094
+ let quote = null;
11095
+ for (let i = 0; i < before.length; i += 1) {
11096
+ const ch = before[i];
11097
+ if (quote) {
11098
+ if (ch === "\\") i += 1;
11099
+ else if (ch === quote) quote = null;
11100
+ continue;
11101
+ }
11102
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
11103
+ else if (ch === "(") stack.push(calleeEndingAt(before, i));
11104
+ else if (ch === ")") stack.pop();
11105
+ }
11106
+ return stack;
11107
+ }
11108
+ function interpolations(line) {
11109
+ const found = [];
11110
+ for (let i = 0; ; ) {
11111
+ const start = line.indexOf("${", i);
11112
+ if (start === -1) break;
11113
+ const end = line.indexOf("}", start + 2);
11114
+ if (end === -1) return null;
11115
+ found.push(line.slice(start + 2, end).trim());
11116
+ i = end + 1;
11117
+ }
11118
+ return found.length > 0 ? found : null;
11119
+ }
11120
+ function isConstantString(name, fileText) {
11121
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11122
+ return new RegExp(
11123
+ `\\bconst\\s+${escaped}\\s*(?::[^=\\n]+)?=\\s*(?:'[^'\\n]*'|"[^"\\n]*"|\`[^\`$\\n]*\`)`
11124
+ ).test(fileText);
11125
+ }
11126
+ function interpolationsAreConstant(line, fileText) {
11127
+ const found = interpolations(line);
11128
+ if (!found) return false;
11129
+ return found.every(
11130
+ (expr) => /^[A-Za-z_$][\w$]*$/.test(expr) && isConstantString(expr, fileText)
11131
+ );
11132
+ }
10643
11133
  function evaluateRule(rule, ctx) {
10644
11134
  if (rule.languages && !rule.languages.includes(ctx.language)) return null;
10645
11135
  const line = ctx.lines[ctx.index] ?? "";
@@ -10651,6 +11141,13 @@ function evaluateRule(rule, ctx) {
10651
11141
  const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
10652
11142
  if (rule.requires && !rule.requires.test(context)) return null;
10653
11143
  if (rule.lineGuard?.test(line)) return null;
11144
+ if (rule.enclosingCallGuard) {
11145
+ const callees = enclosingCallees(ctx.lines, ctx.index, back);
11146
+ if (callees.some((callee) => rule.enclosingCallGuard.test(callee))) return null;
11147
+ }
11148
+ if (rule.constantInterpolationGuard && interpolationsAreConstant(line, fileTextOf(ctx.lines))) {
11149
+ return null;
11150
+ }
10654
11151
  const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
10655
11152
  if (guard && (guard.test(line) || guard.test(context))) return null;
10656
11153
  const untrusted = untrustedPatternFor(ctx.language);
@@ -10662,6 +11159,188 @@ function evaluateRule(rule, ctx) {
10662
11159
  return { rule, confidence, severity: severityFor(rule.severity, confidence) };
10663
11160
  }
10664
11161
 
11162
+ // ../../packages/scan/src/template-rules.ts
11163
+ var LAYOUT_SLOT = /\{\{\{\s*(?:body|content|outlet|children)\s*\}\}\}/;
11164
+ var XSS_CONSEQUENCE = "The value is written into the page as markup rather than as text, so a `<script>` or an `onerror=` attribute in it executes in the visitor\u2019s session \u2014 with their cookies, and their privileges.";
11165
+ var TEMPLATE_RULES = [
11166
+ {
11167
+ id: "tpl-handlebars-unescaped",
11168
+ title: "Handlebars/Mustache interpolation that skips escaping",
11169
+ consequence: XSS_CONSEQUENCE,
11170
+ cwe: "CWE-79",
11171
+ severity: "high",
11172
+ extensions: [".hbs", ".handlebars", ".hdbs", ".mustache", ".ms"],
11173
+ // Two spellings of the same opt-out: the triple-stache and the `&` prefix.
11174
+ pattern: /\{\{\{(?!\{)[^}]*\}\}\}|\{\{\s*&\s*[^}]+\}\}/,
11175
+ lineGuard: LAYOUT_SLOT
11176
+ },
11177
+ {
11178
+ id: "tpl-vue-v-html",
11179
+ title: "`v-html` binding",
11180
+ consequence: XSS_CONSEQUENCE,
11181
+ cwe: "CWE-79",
11182
+ severity: "high",
11183
+ extensions: [".vue", ".html", ".htm"],
11184
+ pattern: /\bv-html\s*=|\s:inner-html\.prop\s*=/
11185
+ },
11186
+ {
11187
+ id: "tpl-pug-unescaped",
11188
+ title: "Pug/Jade unescaped interpolation",
11189
+ consequence: XSS_CONSEQUENCE,
11190
+ cwe: "CWE-79",
11191
+ severity: "high",
11192
+ extensions: [".pug", ".jade"],
11193
+ // `!{…}` is unescaped interpolation anywhere on the line. `!=` is
11194
+ // unescaped buffered code, but only in the tag position — anchored to the
11195
+ // start of the line and preceded by nothing but tag, class and id
11196
+ // characters, so the `!==` inside `- if (a !== b)` cannot reach it.
11197
+ pattern: /!\{[^}\n]*\}|^\s*[\w.#%-]*!=(?!=)\s*\S/
11198
+ },
11199
+ {
11200
+ id: "tpl-ejs-raw-output",
11201
+ title: "EJS/ECT raw output tag",
11202
+ consequence: XSS_CONSEQUENCE,
11203
+ cwe: "CWE-79",
11204
+ severity: "high",
11205
+ extensions: [".ejs", ".ect"],
11206
+ // In EJS `<%= %>` escapes and `<%-` does not, which is the reverse of
11207
+ // Underscore's convention — see KNOWN_GAPS.
11208
+ pattern: /<%-(?!\s*(?:include|-))/,
11209
+ // `<%- include('partial') %>` splices another template, not user data.
11210
+ lineGuard: /<%-\s*(?:include|partial)\s*\(/
11211
+ },
11212
+ {
11213
+ id: "tpl-dust-escape-filter-off",
11214
+ title: "Dust reference with escaping suppressed",
11215
+ consequence: XSS_CONSEQUENCE,
11216
+ cwe: "CWE-79",
11217
+ severity: "high",
11218
+ extensions: [".dust", ".tl"],
11219
+ // The `|s` filter means "suppress the default HTML escape".
11220
+ pattern: /\{[^{}\n]+\|\s*s\s*\}/
11221
+ },
11222
+ {
11223
+ id: "tpl-jinja-safe-filter",
11224
+ title: "Nunjucks/Twig/Jinja `safe` filter or autoescape block off",
11225
+ consequence: XSS_CONSEQUENCE,
11226
+ cwe: "CWE-79",
11227
+ severity: "high",
11228
+ extensions: [".njk", ".nunjucks", ".twig", ".jinja", ".jinja2", ".j2"],
11229
+ pattern: /\|\s*(?:safe|raw)\s*(?:\}\}|\|)|\{%\s*autoescape\s+(?:false|off)\s*%\}/
11230
+ },
11231
+ {
11232
+ id: "tpl-haml-unescaped",
11233
+ title: "Haml unescaped output",
11234
+ consequence: XSS_CONSEQUENCE,
11235
+ cwe: "CWE-79",
11236
+ severity: "high",
11237
+ extensions: [".haml"],
11238
+ pattern: /^\s*[\w.#%-]*!=(?!=)\s*\S/
11239
+ }
11240
+ ];
11241
+ var TEMPLATE_EXTENSIONS = new Set(
11242
+ TEMPLATE_RULES.flatMap((rule) => rule.extensions)
11243
+ );
11244
+ function evaluateTemplateRules(extension, lines) {
11245
+ const ext = extension.toLowerCase();
11246
+ const applicable = TEMPLATE_RULES.filter((rule) => rule.extensions.includes(ext));
11247
+ if (applicable.length === 0) return [];
11248
+ const matches = [];
11249
+ lines.forEach((line, index) => {
11250
+ for (const rule of applicable) {
11251
+ if (!rule.pattern.test(line)) continue;
11252
+ if (rule.lineGuard?.test(line)) continue;
11253
+ matches.push({
11254
+ rule,
11255
+ // A template cannot show that the value is untrusted, so the claim
11256
+ // never rises above `pattern` and the shared cap holds it at medium.
11257
+ severity: severityFor(rule.severity, "pattern"),
11258
+ line: index + 1
11259
+ });
11260
+ }
11261
+ });
11262
+ return matches;
11263
+ }
11264
+
11265
+ // ../../packages/scan/src/controls.ts
11266
+ var SERVER_FRAMEWORK = /\bexpress\s*\(\s*\)|\bnew\s+Koa\s*\(|\bfastify\s*\(|\brequire\s*\(\s*['"](?:express|koa|@hapi\/hapi|restify)['"]\s*\)|\bfrom\s+['"](?:express|koa|@hapi\/hapi|restify)['"]|\bhttp\s*\.\s*createServer\s*\(|\bNestFactory\s*\.\s*create\s*\(/;
11267
+ var SECURITY_CONTROLS = [
11268
+ {
11269
+ id: "control-security-headers-absent",
11270
+ title: "no security header middleware",
11271
+ consequence: "Without them the browser applies no framing protection, sniffs content types, sends full referrers cross-origin and never learns to require HTTPS \u2014 a set of defences that cost one line to enable.",
11272
+ cwe: "CWE-693",
11273
+ severity: "medium",
11274
+ evidence: /\bhelmet\b|\bkoa-helmet\b|\b@fastify\/helmet\b|\blusca\b|Strict-Transport-Security|Content-Security-Policy|X-Frame-Options|X-Content-Type-Options/i
11275
+ },
11276
+ {
11277
+ id: "control-anti-csrf-absent",
11278
+ title: "no cross-site request forgery protection",
11279
+ consequence: "A cookie-authenticated endpoint with no token check can be driven by a form on any other site \u2014 the browser attaches the session automatically, so the victim only has to visit a page.",
11280
+ cwe: "CWE-352",
11281
+ severity: "medium",
11282
+ // SameSite counts. A cookie the browser refuses to send cross-site is not
11283
+ // reachable by the attack this control exists to stop, so a project that
11284
+ // chose that route instead of tokens has the control, not a gap.
11285
+ evidence: /\bcsurf\b|\bcsrf\b|\bxsrf\b|\blusca\b|\b@fastify\/csrf\b|\bdouble-csrf\b|\bsameSite\s*:\s*['"](?:strict|lax)['"]/i
11286
+ },
11287
+ {
11288
+ id: "control-rate-limiting-absent",
11289
+ title: "no request rate limiting",
11290
+ consequence: "Login and password-reset endpoints with no limiter can be tried at the speed of the network \u2014 credential stuffing, token brute force and enumeration all become a matter of waiting.",
11291
+ cwe: "CWE-770",
11292
+ severity: "medium",
11293
+ evidence: /\bexpress-rate-limit\b|\brateLimit\b|\brate-limiter\b|\bratelimit\b|\bexpress-slow-down\b|\bslowDown\b|\bbottleneck\b|\bthrottle\b/i
11294
+ },
11295
+ {
11296
+ id: "control-body-size-limit-absent",
11297
+ title: "no request body size limit",
11298
+ consequence: "The body is parsed into memory before any handler \u2014 and before any authentication check \u2014 so unbounded parsing lets a few concurrent requests exhaust the process.",
11299
+ cwe: "CWE-400",
11300
+ severity: "medium",
11301
+ evidence: /\blimit\s*:\s*['"]?\d+\s*(?:kb|mb|b)\b|\bbodyLimit\b|\bmaxRequestBodySize\b|\bclient_max_body_size\b/i
11302
+ }
11303
+ ];
11304
+ var ControlAudit = class {
11305
+ seen = /* @__PURE__ */ new Set();
11306
+ /** Where the server is built. Anchors the findings somewhere meaningful. */
11307
+ serverFile = null;
11308
+ observe(relativePath, text) {
11309
+ if (this.serverFile === null && SERVER_FRAMEWORK.test(text)) {
11310
+ this.serverFile = relativePath;
11311
+ }
11312
+ for (const control of SECURITY_CONTROLS) {
11313
+ if (this.seen.has(control.id)) continue;
11314
+ if (control.evidence.test(text)) this.seen.add(control.id);
11315
+ }
11316
+ }
11317
+ /** Controls with no evidence anywhere. Empty when the tree serves no HTTP. */
11318
+ missing() {
11319
+ if (this.serverFile === null) return [];
11320
+ return SECURITY_CONTROLS.filter((control) => !this.seen.has(control.id));
11321
+ }
11322
+ findings() {
11323
+ const anchor = this.serverFile;
11324
+ if (anchor === null) return [];
11325
+ return this.missing().map((control) => ({
11326
+ ruleId: control.id,
11327
+ title: control.title,
11328
+ file: anchor,
11329
+ line: 1,
11330
+ // `pattern` is doing real work here: it caps the severity, and it says
11331
+ // in the report itself how much the scanner is claiming. An absence is
11332
+ // never `evidence`.
11333
+ severity: severityFor(control.severity, "pattern"),
11334
+ confidence: "pattern",
11335
+ message: `${control.title} \u2014 no evidence of one anywhere in the scanned tree (${control.cwe})`,
11336
+ consequence: control.consequence,
11337
+ cwe: control.cwe,
11338
+ excerpt: "",
11339
+ category: "code"
11340
+ }));
11341
+ }
11342
+ };
11343
+
10665
11344
  // ../../packages/scan/src/manifest-rules.ts
10666
11345
  var POPULAR_NPM = [
10667
11346
  "react",
@@ -11051,7 +11730,8 @@ var SECRET_RULES = [
11051
11730
  pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_.+/=-]*/,
11052
11731
  severity: "medium",
11053
11732
  cwe: "CWE-798",
11054
- consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived."
11733
+ consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived.",
11734
+ keywordShaped: true
11055
11735
  },
11056
11736
  {
11057
11737
  id: "secret-generic-api-key",
@@ -11062,7 +11742,8 @@ var SECRET_RULES = [
11062
11742
  pattern: /(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9\-_.]{20,})['"]/i,
11063
11743
  severity: "high",
11064
11744
  cwe: "CWE-798",
11065
- consequence: "Whatever the third-party service lets the key do, for as long as it stays valid."
11745
+ consequence: "Whatever the third-party service lets the key do, for as long as it stays valid.",
11746
+ keywordShaped: true
11066
11747
  },
11067
11748
  {
11068
11749
  id: "secret-generic-credential",
@@ -11070,7 +11751,8 @@ var SECRET_RULES = [
11070
11751
  pattern: /(?:secret|password|passwd|pwd|token)\s*[=:]\s*['"]([^'"\s]{8,})['"]/i,
11071
11752
  severity: "high",
11072
11753
  cwe: "CWE-798",
11073
- consequence: "A password in source is a password in every clone, fork and CI cache of that source."
11754
+ consequence: "A password in source is a password in every clone, fork and CI cache of that source.",
11755
+ keywordShaped: true
11074
11756
  },
11075
11757
  {
11076
11758
  id: "secret-hex-token",
@@ -11078,7 +11760,8 @@ var SECRET_RULES = [
11078
11760
  pattern: /(?:token|key|secret|auth|signing)\w*\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/i,
11079
11761
  severity: "medium",
11080
11762
  cwe: "CWE-798",
11081
- consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign."
11763
+ consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign.",
11764
+ keywordShaped: true
11082
11765
  }
11083
11766
  ];
11084
11767
  var KNOWN_PLACEHOLDERS = [
@@ -11093,11 +11776,70 @@ var KNOWN_PLACEHOLDERS = [
11093
11776
  /\bEXAMPLE_?KEY\b/i,
11094
11777
  /\bYOUR_[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)\b/,
11095
11778
  /\b(?:xxx+|X{4,}|\*{4,}|<[a-z-]+>)\b/,
11096
- /\bchangeme\b/i
11779
+ /\bchangeme\b/i,
11780
+ // The metasyntactic pair in a connection string, `postgres://user:pass@host`.
11781
+ // This is not the AWS case above and the distinction is the whole reason it
11782
+ // is allowed: `AKIAIOSFODNN7EXAMPLE` is a real credential *format* carrying a
11783
+ // fake value, so it arrives by way of a pasted template. `user:pass` is the
11784
+ // English words sitting where a credential goes, which is how every database
11785
+ // driver writes its DSN in its own README, and nobody pastes that out of a
11786
+ // secret manager.
11787
+ //
11788
+ // Both halves have to be metasyntactic. `root:hunter2@` is not exempt, since
11789
+ // a real password beside a common username is the case this must not swallow.
11790
+ /:\/\/(?:user(?:name)?|admin|root|dbuser|myuser):(?:pass(?:word|wd)?|secret|dbpass|mypassword)@/i
11097
11791
  ];
11098
11792
  function isKnownPlaceholder(text) {
11099
11793
  return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
11100
11794
  }
11795
+ function isVariableReference(value) {
11796
+ const trimmed = value.trim();
11797
+ const braced = /^\$\{\s*([A-Za-z_][\w.]*)\s*(?::?[-=+?]([\s\S]*))?\}$/.exec(trimmed);
11798
+ if (braced) {
11799
+ const fallback2 = braced[2];
11800
+ if (fallback2 === void 0 || fallback2.trim() === "") return true;
11801
+ return /^\$\{?[A-Za-z_][\w.]*\}?$/.test(fallback2.trim());
11802
+ }
11803
+ return /^\$[A-Za-z_]\w*$/.test(trimmed) || // $VAR
11804
+ /^\$\([\s\S]*\)$/.test(trimmed) || // $(command substitution)
11805
+ /^%[A-Za-z_]\w*%$/.test(trimmed) || // %VAR% on Windows
11806
+ /^\{\{[\s\S]*\}\}$/.test(trimmed) || // {{ template }}
11807
+ /^#\{[\s\S]*\}$/.test(trimmed) || // #{ruby}
11808
+ /^<%=?[\s\S]*%>$/.test(trimmed);
11809
+ }
11810
+ var FIXTURE_STEMS = [
11811
+ "test",
11812
+ "mock",
11813
+ "fake",
11814
+ "dummy",
11815
+ "stub",
11816
+ "sample",
11817
+ "example",
11818
+ "placeholder",
11819
+ "fixture",
11820
+ "invalid",
11821
+ "expired",
11822
+ "forged",
11823
+ "bogus",
11824
+ "notreal",
11825
+ "nonexistent",
11826
+ "changeme",
11827
+ "foobar",
11828
+ "lorem"
11829
+ ];
11830
+ var KEY_NOISE = /* @__PURE__ */ new Set(["const", "this", "return", "await", "async", "expect", "value"]);
11831
+ function words(text) {
11832
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^A-Za-z]+/g, " ").toLowerCase().split(" ").filter(Boolean);
11833
+ }
11834
+ function isTestFixtureValue(line, value) {
11835
+ const valueWords = words(value);
11836
+ if (valueWords.some((word) => FIXTURE_STEMS.some((stem) => word.startsWith(stem)))) return true;
11837
+ if (value.length > 48) return false;
11838
+ const valueAt = line.lastIndexOf(value);
11839
+ const key = valueAt === -1 ? line : line.slice(0, valueAt);
11840
+ const flattened = valueWords.join("");
11841
+ return words(key).filter((word) => word.length >= 4 && !KEY_NOISE.has(word)).some((word) => flattened.includes(word));
11842
+ }
11101
11843
  function redactSecret(line) {
11102
11844
  return line.replace(/([A-Za-z0-9+/=_.-]{12,})/g, (match) => {
11103
11845
  if (match.length <= 12) return match;
@@ -11198,7 +11940,12 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
11198
11940
  ".erb",
11199
11941
  ".ejs",
11200
11942
  ".vue",
11201
- ".svelte"
11943
+ ".svelte",
11944
+ // Template files, so the engine in `template-rules.ts` has something to
11945
+ // read. Sourced from the rules themselves rather than repeated here: an
11946
+ // engine added there becomes scannable without a second edit that could be
11947
+ // forgotten, which is how a rule ends up quietly never firing.
11948
+ ...TEMPLATE_EXTENSIONS
11202
11949
  ]);
11203
11950
  var LANGUAGE_BY_EXTENSION = {
11204
11951
  ".js": "javascript",
@@ -11270,6 +12017,10 @@ function languageOfShebang(firstLine) {
11270
12017
  }
11271
12018
  var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
11272
12019
  var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
12020
+ var FOREIGN_CREDENTIAL = /\b(?:nolint:[\w,]*gosec|nosec)\b[^\n]*\bG101\b|\bG101\b[^\n]*\b(?:nolint:[\w,]*gosec|nosec)\b/;
12021
+ function foreignCredentialMark(line) {
12022
+ return FOREIGN_CREDENTIAL.test(line);
12023
+ }
11273
12024
  function collectSuppressions(lines) {
11274
12025
  const byLine = /* @__PURE__ */ new Map();
11275
12026
  let count = 0;
@@ -11307,16 +12058,21 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
11307
12058
  if (!match) continue;
11308
12059
  if (isKnownPlaceholder(match[0])) continue;
11309
12060
  if (isSuppressed(suppressions, index, rule.id)) continue;
12061
+ const value = match[1] ?? match[0];
12062
+ if (isVariableReference(value)) continue;
12063
+ if (inTests && rule.keywordShaped && isTestFixtureValue(line, value)) continue;
12064
+ const marked = foreignCredentialMark(line);
11310
12065
  findings.push({
11311
12066
  ruleId: rule.id,
11312
12067
  title: rule.name,
11313
12068
  file: relativePath,
11314
12069
  line: index + 1,
11315
- // Reported but not blocking in tests — see isTestPath.
11316
- severity: inTests ? "low" : rule.severity,
12070
+ // Reported but not blocking in tests — see isTestPath. The same goes
12071
+ // for a line another linter's credential rule was already told about.
12072
+ severity: inTests || marked ? "low" : rule.severity,
11317
12073
  // A matched credential format is the finding, not a proxy for one.
11318
12074
  confidence: "evidence",
11319
- message: inTests ? `Possible ${rule.name} detected in a test file \u2014 usually a fixture, still worth confirming it is not a live credential` : `Possible ${rule.name} detected`,
12075
+ 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`,
11320
12076
  consequence: rule.consequence,
11321
12077
  cwe: rule.cwe,
11322
12078
  excerpt: redactSecret(line.trim()).slice(0, 200),
@@ -11346,6 +12102,23 @@ function scanText(relativePath, text, language = languageOf(relativePath)) {
11346
12102
  });
11347
12103
  }
11348
12104
  });
12105
+ for (const match of evaluateTemplateRules(extensionOf(relativePath), lines)) {
12106
+ const index = match.line - 1;
12107
+ if (isSuppressed(suppressions, index, match.rule.id)) continue;
12108
+ findings.push({
12109
+ ruleId: match.rule.id,
12110
+ title: match.rule.title,
12111
+ file: relativePath,
12112
+ line: match.line,
12113
+ severity: match.severity,
12114
+ confidence: "pattern",
12115
+ message: `${match.rule.title} (${match.rule.cwe})`,
12116
+ consequence: match.rule.consequence,
12117
+ cwe: match.rule.cwe,
12118
+ excerpt: (lines[index] ?? "").trim().slice(0, 200),
12119
+ category: "code"
12120
+ });
12121
+ }
11349
12122
  return findings;
11350
12123
  }
11351
12124
  function scanManifest(relativePath, filename, text) {
@@ -11371,8 +12144,8 @@ function meetsFailThreshold(findings, threshold) {
11371
12144
  }
11372
12145
 
11373
12146
  // ../../packages/scan/src/node/walk.ts
11374
- var import_node_fs5 = require("fs");
11375
- var import_node_path2 = require("path");
12147
+ var import_node_fs6 = require("fs");
12148
+ var import_node_path3 = require("path");
11376
12149
  function compileExcludes(patterns) {
11377
12150
  const matchers = patterns.map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#")).map(compilePattern);
11378
12151
  if (matchers.length === 0) return () => false;
@@ -11443,7 +12216,7 @@ function matchPrefix(parts, segs) {
11443
12216
  }
11444
12217
  function readIgnoreFile(root) {
11445
12218
  try {
11446
- return (0, import_node_fs5.readFileSync)((0, import_node_path2.join)(root, ".threatcrushignore"), "utf-8").split("\n");
12219
+ return (0, import_node_fs6.readFileSync)((0, import_node_path3.join)(root, ".threatcrushignore"), "utf-8").split("\n");
11447
12220
  } catch {
11448
12221
  return [];
11449
12222
  }
@@ -11452,22 +12225,23 @@ function scanPath(targetPath, options = {}) {
11452
12225
  const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
11453
12226
  const allowed = options.categories ? new Set(options.categories) : null;
11454
12227
  const findings = [];
12228
+ const controls = new ControlAudit();
11455
12229
  const unreadable = [];
11456
12230
  let filesScanned = 0;
11457
12231
  let suppressed = 0;
11458
12232
  let excluded = 0;
11459
12233
  const rootIsDirectory = (() => {
11460
12234
  try {
11461
- return (0, import_node_fs5.statSync)(targetPath).isDirectory();
12235
+ return (0, import_node_fs6.statSync)(targetPath).isDirectory();
11462
12236
  } catch {
11463
12237
  return true;
11464
12238
  }
11465
12239
  })();
11466
- const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path2.dirname)(targetPath);
12240
+ const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path3.dirname)(targetPath);
11467
12241
  const isExcluded = compileExcludes([...options.exclude ?? [], ...readIgnoreFile(walkRoot)]);
11468
12242
  const scanFile = (fullPath, filename) => {
11469
12243
  const relativePath = toRelative(walkRoot, fullPath);
11470
- const extension = (0, import_node_path2.extname)(filename).toLowerCase();
12244
+ const extension = (0, import_node_path3.extname)(filename).toLowerCase();
11471
12245
  const isManifest = filename === "package.json" || filename === "requirements.txt";
11472
12246
  const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
11473
12247
  const mayDeclareInterpreter = !scannable && !isManifest && extension === "";
@@ -11479,32 +12253,33 @@ function scanPath(targetPath, options = {}) {
11479
12253
  let handle;
11480
12254
  let declared = null;
11481
12255
  try {
11482
- handle = (0, import_node_fs5.openSync)(fullPath, "r");
12256
+ handle = (0, import_node_fs6.openSync)(fullPath, "r");
11483
12257
  } catch {
11484
12258
  unreadable.push(relativePath);
11485
12259
  return;
11486
12260
  }
11487
12261
  try {
11488
- if ((0, import_node_fs5.fstatSync)(handle).size > maxFileBytes) return;
12262
+ if ((0, import_node_fs6.fstatSync)(handle).size > maxFileBytes) return;
11489
12263
  if (mayDeclareInterpreter) {
11490
12264
  const prefix = Buffer.alloc(128);
11491
- const read = (0, import_node_fs5.readSync)(handle, prefix, 0, prefix.length, 0);
12265
+ const read = (0, import_node_fs6.readSync)(handle, prefix, 0, prefix.length, 0);
11492
12266
  declared = languageOfShebang(prefix.subarray(0, read).toString("utf-8").split("\n", 1)[0] ?? "");
11493
12267
  if (!declared) return;
11494
12268
  }
11495
- text = (0, import_node_fs5.readFileSync)(handle, "utf-8");
12269
+ text = (0, import_node_fs6.readFileSync)(handle, "utf-8");
11496
12270
  } catch {
11497
12271
  unreadable.push(relativePath);
11498
12272
  return;
11499
12273
  } finally {
11500
12274
  try {
11501
- (0, import_node_fs5.closeSync)(handle);
12275
+ (0, import_node_fs6.closeSync)(handle);
11502
12276
  } catch {
11503
12277
  }
11504
12278
  }
11505
12279
  filesScanned += 1;
11506
12280
  options.onFile?.(relativePath);
11507
12281
  suppressed += collectSuppressions(text.split("\n")).count;
12282
+ controls.observe(relativePath, text);
11508
12283
  const fileFindings = [
11509
12284
  ...scanText(relativePath, text, declared ?? languageOf(filename)),
11510
12285
  ...isManifest ? scanManifest(relativePath, filename, text) : []
@@ -11515,13 +12290,13 @@ function scanPath(targetPath, options = {}) {
11515
12290
  const walk = (currentPath) => {
11516
12291
  let entries;
11517
12292
  try {
11518
- entries = (0, import_node_fs5.readdirSync)(currentPath, { withFileTypes: true });
12293
+ entries = (0, import_node_fs6.readdirSync)(currentPath, { withFileTypes: true });
11519
12294
  } catch {
11520
12295
  unreadable.push(toRelative(walkRoot, currentPath));
11521
12296
  return;
11522
12297
  }
11523
12298
  for (const entry of entries) {
11524
- const fullPath = (0, import_node_path2.join)(currentPath, entry.name);
12299
+ const fullPath = (0, import_node_path3.join)(currentPath, entry.name);
11525
12300
  const relativePath = toRelative(walkRoot, fullPath);
11526
12301
  if (entry.isDirectory()) {
11527
12302
  if (SKIP_DIRS.has(entry.name)) continue;
@@ -11545,8 +12320,9 @@ function scanPath(targetPath, options = {}) {
11545
12320
  } else if (isExcluded(toRelative(walkRoot, targetPath))) {
11546
12321
  excluded += 1;
11547
12322
  } else {
11548
- scanFile(targetPath, (0, import_node_path2.basename)(targetPath));
12323
+ scanFile(targetPath, (0, import_node_path3.basename)(targetPath));
11549
12324
  }
12325
+ if (options.missingControls) findings.push(...controls.findings());
11550
12326
  const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
11551
12327
  filtered.sort(
11552
12328
  (a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
@@ -11576,32 +12352,37 @@ function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
11576
12352
  }
11577
12353
  }
11578
12354
  function toRelative(base, target) {
11579
- const rel = (0, import_node_path2.relative)(base, target);
11580
- return (rel === "" ? target : rel).split(import_node_path2.sep).join("/");
12355
+ const rel = (0, import_node_path3.relative)(base, target);
12356
+ return (rel === "" ? target : rel).split(import_node_path3.sep).join("/");
11581
12357
  }
11582
12358
 
11583
12359
  // ../../packages/scan/src/node/dependencies.ts
11584
- var import_node_fs6 = require("fs");
11585
- var import_node_path3 = require("path");
11586
- var LOCKFILES = [
11587
- { file: "package-lock.json", ecosystem: "npm" },
11588
- { file: "pnpm-lock.yaml", ecosystem: "npm" },
11589
- { file: "yarn.lock", ecosystem: "npm" },
11590
- { file: "requirements.txt", ecosystem: "PyPI" },
11591
- { file: "Pipfile.lock", ecosystem: "PyPI" }
11592
- ];
12360
+ var import_node_fs7 = require("fs");
12361
+ var import_node_path4 = require("path");
11593
12362
  var MAX_DEPS_PER_LOCKFILE = 50;
11594
12363
  async function scanDependencies(targetPath) {
11595
12364
  const findings = [];
11596
- for (const { file, ecosystem } of LOCKFILES) {
11597
- const lockPath = (0, import_node_path3.join)(targetPath, file);
11598
- if (!(0, import_node_fs6.existsSync)(lockPath)) continue;
12365
+ for (const { file, ecosystem, parse } of LOCKFILES) {
12366
+ const lockPath = (0, import_node_path4.join)(targetPath, file);
12367
+ if (!(0, import_node_fs7.existsSync)(lockPath)) continue;
11599
12368
  let deps;
11600
12369
  try {
11601
- deps = parseDependencies(lockPath, file);
12370
+ deps = dedupe(parse((0, import_node_fs7.readFileSync)(lockPath, "utf-8")));
11602
12371
  } catch {
11603
12372
  continue;
11604
12373
  }
12374
+ if (deps.length === 0) {
12375
+ findings.push(incompleteFinding(file, "No dependencies could be read from this lockfile."));
12376
+ continue;
12377
+ }
12378
+ if (deps.length > MAX_DEPS_PER_LOCKFILE) {
12379
+ findings.push(
12380
+ incompleteFinding(
12381
+ file,
12382
+ `Only the first ${MAX_DEPS_PER_LOCKFILE} of ${deps.length} locked packages were checked against OSV.`
12383
+ )
12384
+ );
12385
+ }
11605
12386
  for (const dep of deps.slice(0, MAX_DEPS_PER_LOCKFILE)) {
11606
12387
  let vulns;
11607
12388
  try {
@@ -11628,6 +12409,20 @@ async function scanDependencies(targetPath) {
11628
12409
  }
11629
12410
  return findings;
11630
12411
  }
12412
+ function incompleteFinding(file, message) {
12413
+ return {
12414
+ ruleId: "dependency-scan-incomplete",
12415
+ title: "Dependency scan incomplete",
12416
+ file,
12417
+ line: 1,
12418
+ severity: "low",
12419
+ confidence: "evidence",
12420
+ message,
12421
+ consequence: "Advisories affecting the unchecked packages would not appear in this report.",
12422
+ excerpt: file,
12423
+ category: "dependency"
12424
+ };
12425
+ }
11631
12426
  function severityFromCvss(score) {
11632
12427
  if (!score) return "medium";
11633
12428
  const value = Number.parseFloat(score);
@@ -11637,26 +12432,131 @@ function severityFromCvss(score) {
11637
12432
  if (value >= 4) return "medium";
11638
12433
  return "low";
11639
12434
  }
11640
- function parseDependencies(lockPath, filename) {
12435
+ function dedupe(deps) {
12436
+ const seen = /* @__PURE__ */ new Set();
12437
+ const unique = [];
12438
+ for (const dep of deps) {
12439
+ const key = `${dep.name}@${dep.version}`;
12440
+ if (seen.has(key)) continue;
12441
+ seen.add(key);
12442
+ unique.push(dep);
12443
+ }
12444
+ return unique;
12445
+ }
12446
+ function splitNameVersion(spec) {
12447
+ const at = spec.lastIndexOf("@");
12448
+ if (at <= 0) return null;
12449
+ const name = spec.slice(0, at);
12450
+ const version = spec.slice(at + 1);
12451
+ if (!name || !version) return null;
12452
+ return { name, version };
12453
+ }
12454
+ function exactVersion(raw) {
12455
+ const version = raw.trim().replace(/^[=v]+/, "");
12456
+ return /^[0-9][0-9a-zA-Z.+-]*$/.test(version) ? version : null;
12457
+ }
12458
+ function parsePackageLock(content) {
12459
+ const lock = JSON.parse(content);
12460
+ const packages = lock.packages ?? lock.dependencies ?? {};
12461
+ const deps = [];
12462
+ for (const [key, value] of Object.entries(packages)) {
12463
+ const name = key.replace(/^.*node_modules\//, "");
12464
+ const version = value?.version;
12465
+ if (name && version && !name.startsWith(".")) deps.push({ name, version });
12466
+ }
12467
+ return deps;
12468
+ }
12469
+ function parsePnpmLock(content) {
12470
+ const deps = [];
12471
+ let inPackages = false;
12472
+ for (const line of content.split("\n")) {
12473
+ if (/^[a-zA-Z]/.test(line)) {
12474
+ inPackages = line.startsWith("packages:");
12475
+ continue;
12476
+ }
12477
+ if (!inPackages) continue;
12478
+ const match = /^ {2}(?! )(.+):\s*$/.exec(line);
12479
+ if (!match?.[1]) continue;
12480
+ let key = match[1].trim().replace(/^['"]|['"]$/g, "");
12481
+ key = key.replace(/^\//, "");
12482
+ key = key.replace(/\(.*$/, "");
12483
+ let name;
12484
+ let rawVersion;
12485
+ const slashed = /^(@?[^@]+)\/([0-9][^/]*)$/.exec(key);
12486
+ if (slashed?.[1] && slashed[2]) {
12487
+ name = slashed[1];
12488
+ rawVersion = slashed[2];
12489
+ } else {
12490
+ const dep = splitNameVersion(key);
12491
+ if (!dep) continue;
12492
+ name = dep.name;
12493
+ rawVersion = dep.version;
12494
+ }
12495
+ const version = exactVersion(rawVersion.replace(/_.*$/, ""));
12496
+ if (version) deps.push({ name, version });
12497
+ }
12498
+ return deps;
12499
+ }
12500
+ function parseYarnLock(content) {
11641
12501
  const deps = [];
11642
- if (filename === "package-lock.json") {
11643
- const lock = JSON.parse((0, import_node_fs6.readFileSync)(lockPath, "utf-8"));
11644
- const packages = lock.packages ?? lock.dependencies ?? {};
11645
- for (const [key, value] of Object.entries(packages)) {
11646
- const name = key.replace(/^node_modules\//, "");
11647
- const version = value?.version;
11648
- if (name && version && !name.startsWith(".")) deps.push({ name, version });
12502
+ let pendingName = null;
12503
+ for (const line of content.split("\n")) {
12504
+ if (line.startsWith("#") || line.trim() === "") continue;
12505
+ if (!/^\s/.test(line)) {
12506
+ pendingName = null;
12507
+ const header = line.replace(/:\s*$/, "");
12508
+ const first = header.split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
12509
+ if (!first) continue;
12510
+ if (!first.includes("@") || first === "__metadata") continue;
12511
+ if (/@(?:workspace|file|link|portal|exec|patch):/.test(first)) continue;
12512
+ const dep = splitNameVersion(first.replace(/@npm:/, "@"));
12513
+ if (dep) pendingName = dep.name;
12514
+ continue;
11649
12515
  }
11650
- return deps;
12516
+ if (!pendingName) continue;
12517
+ const version = /^\s+version:?\s+["']?([^"'\s]+)["']?\s*$/.exec(line);
12518
+ if (!version?.[1]) continue;
12519
+ const exact = exactVersion(version[1]);
12520
+ if (exact) deps.push({ name: pendingName, version: exact });
12521
+ pendingName = null;
11651
12522
  }
11652
- if (filename === "requirements.txt") {
11653
- for (const line of (0, import_node_fs6.readFileSync)(lockPath, "utf-8").split("\n")) {
11654
- const match = /^([a-zA-Z0-9_.-]+)==([0-9.]+)/.exec(line);
11655
- if (match?.[1] && match[2]) deps.push({ name: match[1], version: match[2] });
12523
+ return deps;
12524
+ }
12525
+ function parsePipfileLock(content) {
12526
+ const lock = JSON.parse(content);
12527
+ const deps = [];
12528
+ for (const section of ["default", "develop"]) {
12529
+ const packages = lock[section];
12530
+ if (!packages || typeof packages !== "object") continue;
12531
+ for (const [name, value] of Object.entries(packages)) {
12532
+ const version = exactVersion(String(value?.version ?? "").replace(/^==/, ""));
12533
+ if (name && version) deps.push({ name, version });
11656
12534
  }
11657
12535
  }
11658
12536
  return deps;
11659
12537
  }
12538
+ function parseRequirementsTxt(content) {
12539
+ const deps = [];
12540
+ for (const raw of content.split("\n")) {
12541
+ const line = raw.split("#")[0]?.split(";")[0]?.trim();
12542
+ if (!line || line.startsWith("-")) continue;
12543
+ const match = /^([a-zA-Z0-9._-]+)\s*(?:\[[^\]]*\])?\s*==\s*([^\s,]+)/.exec(line);
12544
+ if (!match?.[1] || !match[2]) continue;
12545
+ const version = exactVersion(match[2]);
12546
+ if (version) deps.push({ name: match[1], version });
12547
+ }
12548
+ return deps;
12549
+ }
12550
+ var LOCKFILES = [
12551
+ { file: "package-lock.json", ecosystem: "npm", parse: parsePackageLock },
12552
+ { file: "pnpm-lock.yaml", ecosystem: "npm", parse: parsePnpmLock },
12553
+ { file: "yarn.lock", ecosystem: "npm", parse: parseYarnLock },
12554
+ { file: "requirements.txt", ecosystem: "PyPI", parse: parseRequirementsTxt },
12555
+ { file: "Pipfile.lock", ecosystem: "PyPI", parse: parsePipfileLock }
12556
+ ];
12557
+ var LOCKFILE_PARSERS = Object.fromEntries(
12558
+ LOCKFILES.map((entry) => [entry.file, entry.parse])
12559
+ );
11660
12560
  function isValidPackageName(name) {
11661
12561
  return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
11662
12562
  }
@@ -11681,12 +12581,12 @@ async function queryOsv(name, version, ecosystem) {
11681
12581
  }
11682
12582
 
11683
12583
  // ../../packages/scan/src/node/sarif.ts
11684
- var import_node_crypto = require("crypto");
11685
- var import_node_path4 = require("path");
12584
+ var import_node_crypto2 = require("crypto");
12585
+ var import_node_path5 = require("path");
11686
12586
  var FINGERPRINT_KEY = "threatcrush/contentHash/v1";
11687
12587
  function fingerprintOf(finding) {
11688
12588
  const content = finding.excerpt.replace(/\s+/g, " ").trim();
11689
- return (0, import_node_crypto.createHash)("sha256").update(`${finding.ruleId}
12589
+ return (0, import_node_crypto2.createHash)("sha256").update(`${finding.ruleId}
11690
12590
  ${finding.file}
11691
12591
  ${content}`).digest("hex").slice(0, 32);
11692
12592
  }
@@ -11720,11 +12620,11 @@ function securitySeverity(severity) {
11720
12620
  }
11721
12621
  }
11722
12622
  function toArtifactUri(filePath, base, prefix = "", root = base) {
11723
- const absolute = (0, import_node_path4.isAbsolute)(filePath) ? filePath : (0, import_node_path4.resolve)(root, filePath);
11724
- const relativePath = (0, import_node_path4.relative)(base, absolute);
12623
+ const absolute = (0, import_node_path5.isAbsolute)(filePath) ? filePath : (0, import_node_path5.resolve)(root, filePath);
12624
+ const relativePath = (0, import_node_path5.relative)(base, absolute);
11725
12625
  const escapedOut = relativePath.startsWith("..") || relativePath === "";
11726
12626
  const chosen = escapedOut ? absolute : relativePath;
11727
- const posix = chosen.split(import_node_path4.sep).join("/").replace(/^\.\//, "");
12627
+ const posix = chosen.split(import_node_path5.sep).join("/").replace(/^\.\//, "");
11728
12628
  if (!prefix || escapedOut) return posix;
11729
12629
  const trimmed = prefix.replace(/^\/+|\/+$/g, "");
11730
12630
  return trimmed ? `${trimmed}/${posix}` : posix;
@@ -11814,11 +12714,11 @@ ${finding.consequence}` : `**${finding.title}**`
11814
12714
  // src/commands/scan.ts
11815
12715
  function readVersion() {
11816
12716
  for (const candidate of [
11817
- (0, import_node_path5.join)(__dirname, "..", "package.json"),
11818
- (0, import_node_path5.join)(__dirname, "..", "..", "package.json")
12717
+ (0, import_node_path6.join)(__dirname, "..", "package.json"),
12718
+ (0, import_node_path6.join)(__dirname, "..", "..", "package.json")
11819
12719
  ]) {
11820
12720
  try {
11821
- return JSON.parse((0, import_node_fs7.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
12721
+ return JSON.parse((0, import_node_fs8.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
11822
12722
  } catch {
11823
12723
  }
11824
12724
  }
@@ -11885,7 +12785,7 @@ async function scanCommand(targetPath, options = {}) {
11885
12785
  const say = machineReadable ? (line) => process.stderr.write(`${line}
11886
12786
  `) : (line) => process.stdout.write(`${line}
11887
12787
  `);
11888
- if (!(0, import_node_fs7.existsSync)(targetPath)) {
12788
+ if (!(0, import_node_fs8.existsSync)(targetPath)) {
11889
12789
  say(source_default.red(`Scan target does not exist: ${targetPath}`));
11890
12790
  process.exitCode = 2;
11891
12791
  return failedResult(targetPath, `no such path: ${targetPath}`);
@@ -11901,6 +12801,7 @@ async function scanCommand(targetPath, options = {}) {
11901
12801
  let seen = 0;
11902
12802
  const report = scanPath(targetPath, {
11903
12803
  exclude: options.exclude,
12804
+ missingControls: options.missingControls,
11904
12805
  onFile: () => {
11905
12806
  seen += 1;
11906
12807
  if (spinner) spinner.text = `Scanning files... (${seen} files)`;
@@ -11980,7 +12881,7 @@ function emitMachineReadable(format, outcome, targetPath, options, say) {
11980
12881
  // and it fails silently. `--path-prefix` covers the remaining case:
11981
12882
  // a scan run from inside the subdirectory it is scanning.
11982
12883
  base: process.cwd(),
11983
- root: (0, import_node_path5.resolve)(outcome.root)
12884
+ root: (0, import_node_path6.resolve)(outcome.root)
11984
12885
  }) : {
11985
12886
  tool: "threatcrush",
11986
12887
  version: PKG_VERSION,
@@ -11994,8 +12895,8 @@ function emitMachineReadable(format, outcome, targetPath, options, say) {
11994
12895
  const serialized = `${JSON.stringify(payload, null, 2)}
11995
12896
  `;
11996
12897
  if (options.output) {
11997
- (0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)((0, import_node_path5.resolve)(options.output)), { recursive: true });
11998
- (0, import_node_fs7.writeFileSync)(options.output, serialized, "utf-8");
12898
+ (0, import_node_fs8.mkdirSync)((0, import_node_path6.dirname)((0, import_node_path6.resolve)(options.output)), { recursive: true });
12899
+ (0, import_node_fs8.writeFileSync)(options.output, serialized, "utf-8");
11999
12900
  say(
12000
12901
  source_default.gray(
12001
12902
  ` ${format.toUpperCase()} written to ${options.output} (${outcome.findings.length} finding(s))`
@@ -12047,13 +12948,13 @@ function printHuman(outcome) {
12047
12948
  }
12048
12949
 
12049
12950
  // src/commands/init.ts
12050
- var import_node_fs10 = require("fs");
12951
+ var import_node_fs11 = require("fs");
12051
12952
  var import_node_child_process = require("child_process");
12052
12953
  var import_node_readline3 = __toESM(require("readline"));
12053
12954
 
12054
12955
  // src/core/config.ts
12055
- var import_node_fs8 = require("fs");
12056
- var import_node_path6 = require("path");
12956
+ var import_node_fs9 = require("fs");
12957
+ var import_node_path7 = require("path");
12057
12958
  var import_toml = __toESM(require_toml());
12058
12959
  var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
12059
12960
  var DEFAULT_CONFDIR = "/etc/threatcrush/threatcrushd.conf.d";
@@ -12079,11 +12980,11 @@ var DEFAULT_CONFIG = {
12079
12980
  };
12080
12981
  function loadConfig(configPath) {
12081
12982
  const path = configPath || DEFAULT_CONFIG_PATH;
12082
- if (!(0, import_node_fs8.existsSync)(path)) {
12983
+ if (!(0, import_node_fs9.existsSync)(path)) {
12083
12984
  return { ...DEFAULT_CONFIG };
12084
12985
  }
12085
12986
  try {
12086
- const raw = (0, import_node_fs8.readFileSync)(path, "utf-8");
12987
+ const raw = (0, import_node_fs9.readFileSync)(path, "utf-8");
12087
12988
  const parsed = import_toml.default.parse(raw);
12088
12989
  return {
12089
12990
  daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
@@ -12099,13 +13000,13 @@ function loadConfig(configPath) {
12099
13000
  function loadModuleConfigs(confDir) {
12100
13001
  const dir = confDir || DEFAULT_CONFDIR;
12101
13002
  const configs = /* @__PURE__ */ new Map();
12102
- if (!(0, import_node_fs8.existsSync)(dir)) {
13003
+ if (!(0, import_node_fs9.existsSync)(dir)) {
12103
13004
  return configs;
12104
13005
  }
12105
- const files = (0, import_node_fs8.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
13006
+ const files = (0, import_node_fs9.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
12106
13007
  for (const file of files) {
12107
13008
  try {
12108
- const raw = (0, import_node_fs8.readFileSync)((0, import_node_path6.join)(dir, file), "utf-8");
13009
+ const raw = (0, import_node_fs9.readFileSync)((0, import_node_path7.join)(dir, file), "utf-8");
12109
13010
  const parsed = import_toml.default.parse(raw);
12110
13011
  for (const [name, config] of Object.entries(parsed)) {
12111
13012
  configs.set(name, config);
@@ -12134,23 +13035,23 @@ function generateModuleConfig(moduleName, defaults = {}) {
12134
13035
  }
12135
13036
 
12136
13037
  // src/core/cli-config.ts
12137
- var import_node_fs9 = require("fs");
12138
- var import_node_path7 = require("path");
13038
+ var import_node_fs10 = require("fs");
13039
+ var import_node_path8 = require("path");
12139
13040
  var import_node_os4 = require("os");
12140
- var CLI_CONFIG_DIR = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".threatcrush");
12141
- var CLI_CONFIG_PATH = (0, import_node_path7.join)(CLI_CONFIG_DIR, "config.json");
13041
+ var CLI_CONFIG_DIR = (0, import_node_path8.join)((0, import_node_os4.homedir)(), ".threatcrush");
13042
+ var CLI_CONFIG_PATH = (0, import_node_path8.join)(CLI_CONFIG_DIR, "config.json");
12142
13043
  function readCliConfig() {
12143
13044
  try {
12144
- return JSON.parse((0, import_node_fs9.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
13045
+ return JSON.parse((0, import_node_fs10.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
12145
13046
  } catch {
12146
13047
  return {};
12147
13048
  }
12148
13049
  }
12149
13050
  function writeCliConfig(config) {
12150
- if (!(0, import_node_fs9.existsSync)(CLI_CONFIG_DIR)) (0, import_node_fs9.mkdirSync)(CLI_CONFIG_DIR, { recursive: true });
12151
- (0, import_node_fs9.writeFileSync)(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
13051
+ if (!(0, import_node_fs10.existsSync)(CLI_CONFIG_DIR)) (0, import_node_fs10.mkdirSync)(CLI_CONFIG_DIR, { recursive: true });
13052
+ (0, import_node_fs10.writeFileSync)(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
12152
13053
  try {
12153
- (0, import_node_fs9.chmodSync)(CLI_CONFIG_PATH, 384);
13054
+ (0, import_node_fs10.chmodSync)(CLI_CONFIG_PATH, 384);
12154
13055
  } catch {
12155
13056
  }
12156
13057
  }
@@ -12395,7 +13296,7 @@ function binaryExists(name) {
12395
13296
  }
12396
13297
  }
12397
13298
  function findLogPath(paths) {
12398
- return paths.find((p) => (0, import_node_fs10.existsSync)(p));
13299
+ return paths.find((p) => (0, import_node_fs11.existsSync)(p));
12399
13300
  }
12400
13301
  async function promptYesNo(question, fallback2) {
12401
13302
  const rl = import_node_readline3.default.createInterface({ input: process.stdin, output: process.stdout });
@@ -12511,11 +13412,11 @@ async function initCommand() {
12511
13412
  }
12512
13413
  } else {
12513
13414
  const spinner2 = ora({ text: "Writing configuration files...", color: "green" }).start();
12514
- (0, import_node_fs10.mkdirSync)(confDDir, { recursive: true });
12515
- (0, import_node_fs10.mkdirSync)("/var/log/threatcrush", { recursive: true });
12516
- (0, import_node_fs10.mkdirSync)("/var/lib/threatcrush", { recursive: true });
13415
+ (0, import_node_fs11.mkdirSync)(confDDir, { recursive: true });
13416
+ (0, import_node_fs11.mkdirSync)("/var/log/threatcrush", { recursive: true });
13417
+ (0, import_node_fs11.mkdirSync)("/var/lib/threatcrush", { recursive: true });
12517
13418
  const mainConfig = generateDefaultConfig(detected.map((d) => d.name));
12518
- (0, import_node_fs10.writeFileSync)(`${configDir}/threatcrushd.conf`, mainConfig);
13419
+ (0, import_node_fs11.writeFileSync)(`${configDir}/threatcrushd.conf`, mainConfig);
12519
13420
  for (const svc of detected) {
12520
13421
  const svcDef = SERVICES_TO_DETECT.find((s) => s.name === svc.name);
12521
13422
  if (!svcDef) continue;
@@ -12524,7 +13425,7 @@ async function initCommand() {
12524
13425
  ...svcDef.moduleConfig,
12525
13426
  log_path: svc.logPath || svcDef.logPaths[0]
12526
13427
  });
12527
- (0, import_node_fs10.writeFileSync)(`${confDDir}/${modName}.conf`, modConfig);
13428
+ (0, import_node_fs11.writeFileSync)(`${confDDir}/${modName}.conf`, modConfig);
12528
13429
  }
12529
13430
  spinner2.succeed("Configuration written successfully");
12530
13431
  console.log();
@@ -12542,8 +13443,8 @@ async function initCommand() {
12542
13443
  }
12543
13444
  function checkWriteAccess(dir) {
12544
13445
  try {
12545
- if (!(0, import_node_fs10.existsSync)(dir)) {
12546
- (0, import_node_fs10.mkdirSync)(dir, { recursive: true });
13446
+ if (!(0, import_node_fs11.existsSync)(dir)) {
13447
+ (0, import_node_fs11.mkdirSync)(dir, { recursive: true });
12547
13448
  }
12548
13449
  return true;
12549
13450
  } catch {
@@ -12552,8 +13453,8 @@ function checkWriteAccess(dir) {
12552
13453
  }
12553
13454
 
12554
13455
  // src/core/module-loader.ts
12555
- var import_node_fs11 = require("fs");
12556
- var import_node_path8 = require("path");
13456
+ var import_node_fs12 = require("fs");
13457
+ var import_node_path9 = require("path");
12557
13458
  var import_toml2 = __toESM(require_toml());
12558
13459
  init_paths();
12559
13460
  function discoverModules(moduleDir, confDir) {
@@ -12561,22 +13462,22 @@ function discoverModules(moduleDir, confDir) {
12561
13462
  const configs = loadModuleConfigs(confDir || PATHS.confD);
12562
13463
  const searchPaths = [
12563
13464
  moduleDir || PATHS.moduleDir,
12564
- (0, import_node_path8.resolve)(process.cwd(), "modules")
13465
+ (0, import_node_path9.resolve)(process.cwd(), "modules")
12565
13466
  ];
12566
- const builtinDir = (0, import_node_path8.resolve)(__dirname || ".", "..", "modules");
12567
- if ((0, import_node_fs11.existsSync)(builtinDir)) {
13467
+ const builtinDir = (0, import_node_path9.resolve)(__dirname || ".", "..", "modules");
13468
+ if ((0, import_node_fs12.existsSync)(builtinDir)) {
12568
13469
  searchPaths.push(builtinDir);
12569
13470
  }
12570
13471
  for (const basePath of searchPaths) {
12571
- if (!(0, import_node_fs11.existsSync)(basePath)) continue;
12572
- const entries = (0, import_node_fs11.readdirSync)(basePath, { withFileTypes: true });
13472
+ if (!(0, import_node_fs12.existsSync)(basePath)) continue;
13473
+ const entries = (0, import_node_fs12.readdirSync)(basePath, { withFileTypes: true });
12573
13474
  for (const entry of entries) {
12574
13475
  if (!entry.isDirectory()) continue;
12575
- const modPath = (0, import_node_path8.join)(basePath, entry.name);
12576
- const manifestPath = (0, import_node_path8.join)(modPath, "mod.toml");
12577
- if (!(0, import_node_fs11.existsSync)(manifestPath)) continue;
13476
+ const modPath = (0, import_node_path9.join)(basePath, entry.name);
13477
+ const manifestPath = (0, import_node_path9.join)(modPath, "mod.toml");
13478
+ if (!(0, import_node_fs12.existsSync)(manifestPath)) continue;
12578
13479
  try {
12579
- const raw = (0, import_node_fs11.readFileSync)(manifestPath, "utf-8");
13480
+ const raw = (0, import_node_fs12.readFileSync)(manifestPath, "utf-8");
12580
13481
  const manifest = import_toml2.default.parse(raw);
12581
13482
  const config = configs.get(manifest.module.name) || { enabled: true };
12582
13483
  modules.push({
@@ -12695,11 +13596,163 @@ function formatUptime(seconds) {
12695
13596
 
12696
13597
  // src/commands/modules.ts
12697
13598
  var import_node_child_process2 = require("child_process");
12698
- var import_node_fs12 = require("fs");
12699
- var import_node_path9 = require("path");
13599
+ var import_node_fs14 = require("fs");
13600
+ var import_node_path11 = require("path");
12700
13601
  var import_toml3 = __toESM(require_toml());
12701
13602
  init_paths();
12702
13603
  init_pidfile();
13604
+
13605
+ // src/daemon/module-trust.ts
13606
+ var import_node_crypto3 = require("crypto");
13607
+ var import_node_fs13 = require("fs");
13608
+ var import_node_path10 = require("path");
13609
+ init_paths();
13610
+ var TRUST_FILE = (0, import_node_path10.join)(PATHS.configDir, "trusted-modules.json");
13611
+ var PUBLISHER_KEYS_FILE = (0, import_node_path10.join)(PATHS.configDir, "publisher-keys.json");
13612
+ var DIGEST_EXCLUDED_DIRS = /* @__PURE__ */ new Set([".git"]);
13613
+ function collectFiles(root, dir = root, out = []) {
13614
+ for (const entry of (0, import_node_fs13.readdirSync)(dir, { withFileTypes: true }).sort(
13615
+ (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
13616
+ )) {
13617
+ const full = (0, import_node_path10.join)(dir, entry.name);
13618
+ if (entry.isSymbolicLink()) {
13619
+ out.push({ path: full, isSymlink: true });
13620
+ } else if (entry.isDirectory()) {
13621
+ if (DIGEST_EXCLUDED_DIRS.has(entry.name)) continue;
13622
+ collectFiles(root, full, out);
13623
+ } else if (entry.isFile()) {
13624
+ out.push({ path: full, isSymlink: false });
13625
+ }
13626
+ }
13627
+ return out;
13628
+ }
13629
+ function computeModuleDigest(modulePath) {
13630
+ const hash = (0, import_node_crypto3.createHash)("sha256");
13631
+ for (const entry of collectFiles(modulePath)) {
13632
+ const relPath = (0, import_node_path10.relative)(modulePath, entry.path).split(import_node_path10.sep).join("/");
13633
+ hash.update(relPath);
13634
+ hash.update("\0");
13635
+ if (entry.isSymlink) {
13636
+ hash.update("symlink:");
13637
+ hash.update((0, import_node_fs13.readlinkSync)(entry.path));
13638
+ } else {
13639
+ hash.update("file:");
13640
+ hash.update((0, import_node_fs13.readFileSync)(entry.path));
13641
+ }
13642
+ hash.update("\0");
13643
+ }
13644
+ return hash.digest("hex");
13645
+ }
13646
+ function readTrustFile() {
13647
+ if (!(0, import_node_fs13.existsSync)(TRUST_FILE)) return { version: 1, modules: {} };
13648
+ try {
13649
+ const parsed = JSON.parse((0, import_node_fs13.readFileSync)(TRUST_FILE, "utf-8"));
13650
+ if (parsed.version !== 1 || typeof parsed.modules !== "object" || !parsed.modules) {
13651
+ return { version: 1, modules: {} };
13652
+ }
13653
+ return { version: 1, modules: parsed.modules };
13654
+ } catch {
13655
+ return { version: 1, modules: {} };
13656
+ }
13657
+ }
13658
+ function writeTrustFile(file) {
13659
+ (0, import_node_fs13.writeFileSync)(TRUST_FILE, JSON.stringify(file, null, 2) + "\n", { mode: 384 });
13660
+ try {
13661
+ (0, import_node_fs13.chmodSync)(TRUST_FILE, 384);
13662
+ } catch {
13663
+ }
13664
+ }
13665
+ function trustModule(name, modulePath, source) {
13666
+ const file = readTrustFile();
13667
+ const record = {
13668
+ digest: computeModuleDigest(modulePath),
13669
+ trustedAt: (/* @__PURE__ */ new Date()).toISOString(),
13670
+ source
13671
+ };
13672
+ file.modules[name] = record;
13673
+ writeTrustFile(file);
13674
+ return record;
13675
+ }
13676
+ function untrustModule(name) {
13677
+ const file = readTrustFile();
13678
+ if (!file.modules[name]) return false;
13679
+ delete file.modules[name];
13680
+ writeTrustFile(file);
13681
+ return true;
13682
+ }
13683
+ function listTrustedModules() {
13684
+ return readTrustFile().modules;
13685
+ }
13686
+ function readPublisherKeys() {
13687
+ if (!(0, import_node_fs13.existsSync)(PUBLISHER_KEYS_FILE)) return {};
13688
+ try {
13689
+ const parsed = JSON.parse((0, import_node_fs13.readFileSync)(PUBLISHER_KEYS_FILE, "utf-8"));
13690
+ return parsed && typeof parsed === "object" ? parsed : {};
13691
+ } catch {
13692
+ return {};
13693
+ }
13694
+ }
13695
+ function verifyModuleSignature(modulePath, digest) {
13696
+ const keys = readPublisherKeys();
13697
+ const pinnedKeyIds = Object.keys(keys);
13698
+ const sigPath = (0, import_node_path10.join)(modulePath, "mod.sig");
13699
+ if (pinnedKeyIds.length === 0) {
13700
+ return { ok: true };
13701
+ }
13702
+ if (!(0, import_node_fs13.existsSync)(sigPath)) {
13703
+ return { ok: false, reason: "publisher keys are pinned but the module ships no mod.sig" };
13704
+ }
13705
+ let parsed;
13706
+ try {
13707
+ parsed = JSON.parse((0, import_node_fs13.readFileSync)(sigPath, "utf-8"));
13708
+ } catch {
13709
+ return { ok: false, reason: "mod.sig is not valid JSON" };
13710
+ }
13711
+ if (!parsed.keyId || !parsed.signature) {
13712
+ return { ok: false, reason: "mod.sig is missing keyId or signature" };
13713
+ }
13714
+ const publicKey = keys[parsed.keyId];
13715
+ if (!publicKey) {
13716
+ return { ok: false, reason: `mod.sig references unpinned key "${parsed.keyId}"` };
13717
+ }
13718
+ try {
13719
+ const valid = (0, import_node_crypto3.verify)(
13720
+ null,
13721
+ Buffer.from(digest, "hex"),
13722
+ publicKey,
13723
+ Buffer.from(parsed.signature, "base64")
13724
+ );
13725
+ return valid ? { ok: true } : { ok: false, reason: "mod.sig signature does not match" };
13726
+ } catch (err) {
13727
+ return { ok: false, reason: `signature check failed: ${String(err.message || err)}` };
13728
+ }
13729
+ }
13730
+ function verifyModuleTrust(name, modulePath) {
13731
+ let digest;
13732
+ try {
13733
+ digest = computeModuleDigest(modulePath);
13734
+ } catch (err) {
13735
+ return { ok: false, reason: `could not hash module: ${String(err.message || err)}` };
13736
+ }
13737
+ const signature = verifyModuleSignature(modulePath, digest);
13738
+ if (!signature.ok) return signature;
13739
+ const record = readTrustFile().modules[name];
13740
+ if (!record) {
13741
+ return {
13742
+ ok: false,
13743
+ reason: `not trusted \u2014 review it, then run: threatcrush modules trust ${name}`
13744
+ };
13745
+ }
13746
+ if (record.digest !== digest) {
13747
+ return {
13748
+ ok: false,
13749
+ reason: `contents changed since it was trusted on ${record.trustedAt} \u2014 re-review it, then run: threatcrush modules trust ${name}`
13750
+ };
13751
+ }
13752
+ return { ok: true };
13753
+ }
13754
+
13755
+ // src/commands/modules.ts
12703
13756
  var API_URL2 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
12704
13757
  function modulesDir() {
12705
13758
  ensureRuntimeDirs();
@@ -12712,7 +13765,7 @@ function safeModuleDirName(name, label = "module name") {
12712
13765
  return name;
12713
13766
  }
12714
13767
  function moduleDestination(dir, name, label) {
12715
- return (0, import_node_path9.join)(dir, safeModuleDirName(name, label));
13768
+ return (0, import_node_path11.join)(dir, safeModuleDirName(name, label));
12716
13769
  }
12717
13770
  function assertSafeTarballEntries(tarPath) {
12718
13771
  const listing = (0, import_node_child_process2.execFileSync)("tar", ["-tzf", tarPath], { encoding: "utf-8" });
@@ -12725,12 +13778,12 @@ function assertSafeTarballEntries(tarPath) {
12725
13778
  }
12726
13779
  }
12727
13780
  function validateManifest(modPath) {
12728
- const manifestPath = (0, import_node_path9.join)(modPath, "mod.toml");
12729
- if (!(0, import_node_fs12.existsSync)(manifestPath)) {
13781
+ const manifestPath = (0, import_node_path11.join)(modPath, "mod.toml");
13782
+ if (!(0, import_node_fs14.existsSync)(manifestPath)) {
12730
13783
  return { ok: false, error: `mod.toml not found at ${manifestPath}` };
12731
13784
  }
12732
13785
  try {
12733
- const raw = (0, import_node_fs12.readFileSync)(manifestPath, "utf-8");
13786
+ const raw = (0, import_node_fs14.readFileSync)(manifestPath, "utf-8");
12734
13787
  const parsed = import_toml3.default.parse(raw);
12735
13788
  const name = parsed.module?.name;
12736
13789
  const version = parsed.module?.version;
@@ -12741,6 +13794,12 @@ function validateManifest(modPath) {
12741
13794
  return { ok: false, error: `Invalid mod.toml: ${err.message}` };
12742
13795
  }
12743
13796
  }
13797
+ function printTrustRequired(name) {
13798
+ console.log();
13799
+ console.log(source_default.yellow(` ! ${name} is installed but NOT trusted, so threatcrushd will not load it.`));
13800
+ console.log(source_default.dim(" Review the module source, then run:"));
13801
+ console.log(source_default.dim(` ${source_default.white(`threatcrush modules trust ${name}`)}`));
13802
+ }
12744
13803
  function notifyDaemonIfRunning() {
12745
13804
  if (!findRunningDaemon()) return;
12746
13805
  console.log(source_default.dim(` \u2139 threatcrushd is running \u2014 restart it to load/unload modules:`));
@@ -12784,8 +13843,8 @@ async function modulesInstallCommand(source) {
12784
13843
  console.log();
12785
13844
  const dir = modulesDir();
12786
13845
  if (source.startsWith("./") || source.startsWith("/") || source.startsWith("~")) {
12787
- const absPath = (0, import_node_path9.resolve)(source.startsWith("~") ? source.replace("~", process.env.HOME || "") : source);
12788
- if (!(0, import_node_fs12.existsSync)(absPath)) {
13846
+ const absPath = (0, import_node_path11.resolve)(source.startsWith("~") ? source.replace("~", process.env.HOME || "") : source);
13847
+ if (!(0, import_node_fs14.existsSync)(absPath)) {
12789
13848
  console.log(source_default.red(` \u2717 Path not found: ${absPath}
12790
13849
  `));
12791
13850
  return;
@@ -12804,7 +13863,7 @@ async function modulesInstallCommand(source) {
12804
13863
  `));
12805
13864
  return;
12806
13865
  }
12807
- if ((0, import_node_fs12.existsSync)(dest2)) {
13866
+ if ((0, import_node_fs14.existsSync)(dest2)) {
12808
13867
  console.log(source_default.yellow(` ! ${check.name} is already installed at ${dest2}`));
12809
13868
  console.log(source_default.dim(` Run ${source_default.white(`threatcrush modules remove ${check.name}`)} first.
12810
13869
  `));
@@ -12812,12 +13871,13 @@ async function modulesInstallCommand(source) {
12812
13871
  }
12813
13872
  const spinner2 = ora({ text: `Copying module files...`, color: "green" }).start();
12814
13873
  try {
12815
- (0, import_node_fs12.cpSync)(absPath, dest2, { recursive: true, dereference: true });
13874
+ (0, import_node_fs14.cpSync)(absPath, dest2, { recursive: true, dereference: true });
12816
13875
  spinner2.succeed(`Installed ${source_default.white(check.name)} v${check.version} \u2192 ${dest2}`);
12817
13876
  } catch (err) {
12818
13877
  spinner2.fail(`Copy failed: ${err.message}`);
12819
13878
  return;
12820
13879
  }
13880
+ printTrustRequired(check.name);
12821
13881
  notifyDaemonIfRunning();
12822
13882
  console.log();
12823
13883
  return;
@@ -12827,14 +13887,14 @@ async function modulesInstallCommand(source) {
12827
13887
  let name;
12828
13888
  let dest2;
12829
13889
  try {
12830
- name = safeModuleDirName((0, import_node_path9.basename)(gitUrl.replace(/\.git$/, "")), "repository name");
13890
+ name = safeModuleDirName((0, import_node_path11.basename)(gitUrl.replace(/\.git$/, "")), "repository name");
12831
13891
  dest2 = moduleDestination(dir, name);
12832
13892
  } catch (err) {
12833
13893
  console.log(source_default.red(` x ${err.message}
12834
13894
  `));
12835
13895
  return;
12836
13896
  }
12837
- if ((0, import_node_fs12.existsSync)(dest2)) {
13897
+ if ((0, import_node_fs14.existsSync)(dest2)) {
12838
13898
  console.log(source_default.yellow(` ! ${name} is already installed at ${dest2}`));
12839
13899
  console.log(source_default.dim(` Run ${source_default.white(`threatcrush modules remove ${name}`)} first.
12840
13900
  `));
@@ -12851,12 +13911,13 @@ async function modulesInstallCommand(source) {
12851
13911
  if (!check.ok) {
12852
13912
  spinner2.fail(check.error);
12853
13913
  try {
12854
- (0, import_node_fs12.rmSync)(dest2, { recursive: true, force: true });
13914
+ (0, import_node_fs14.rmSync)(dest2, { recursive: true, force: true });
12855
13915
  } catch {
12856
13916
  }
12857
13917
  return;
12858
13918
  }
12859
13919
  spinner2.succeed(`Installed ${source_default.white(check.name)} v${check.version} \u2192 ${dest2}`);
13920
+ printTrustRequired(check.name);
12860
13921
  notifyDaemonIfRunning();
12861
13922
  console.log();
12862
13923
  return;
@@ -12896,7 +13957,7 @@ async function modulesInstallCommand(source) {
12896
13957
  `));
12897
13958
  return;
12898
13959
  }
12899
- if ((0, import_node_fs12.existsSync)(dest)) {
13960
+ if ((0, import_node_fs14.existsSync)(dest)) {
12900
13961
  console.log(source_default.yellow(` ! ${mod.slug} is already installed at ${dest}
12901
13962
  `));
12902
13963
  return;
@@ -12922,7 +13983,7 @@ async function modulesInstallCommand(source) {
12922
13983
  if (!check.ok) {
12923
13984
  cloneSpinner.fail(check.error);
12924
13985
  try {
12925
- (0, import_node_fs12.rmSync)(dest, { recursive: true, force: true });
13986
+ (0, import_node_fs14.rmSync)(dest, { recursive: true, force: true });
12926
13987
  } catch {
12927
13988
  }
12928
13989
  return;
@@ -12936,11 +13997,11 @@ async function modulesInstallCommand(source) {
12936
13997
  dlSpinner.fail(`HTTP ${res.status}`);
12937
13998
  return;
12938
13999
  }
12939
- const tar = (0, import_node_path9.join)(dir, `${mod.slug}.tar.gz`);
12940
- (0, import_node_fs12.writeFileSync)(tar, Buffer.from(await res.arrayBuffer()));
14000
+ const tar = (0, import_node_path11.join)(dir, `${mod.slug}.tar.gz`);
14001
+ (0, import_node_fs14.writeFileSync)(tar, Buffer.from(await res.arrayBuffer()));
12941
14002
  assertSafeTarballEntries(tar);
12942
14003
  (0, import_node_child_process2.execFileSync)("tar", ["-xzf", tar, "-C", dir], { stdio: "pipe" });
12943
- (0, import_node_fs12.rmSync)(tar, { force: true });
14004
+ (0, import_node_fs14.rmSync)(tar, { force: true });
12944
14005
  const check = validateManifest(dest);
12945
14006
  if (!check.ok) {
12946
14007
  dlSpinner.fail(check.error);
@@ -12955,6 +14016,7 @@ async function modulesInstallCommand(source) {
12955
14016
  logger.info("No installable artifact provided for this module.");
12956
14017
  return;
12957
14018
  }
14019
+ printTrustRequired(mod.slug);
12958
14020
  notifyDaemonIfRunning();
12959
14021
  console.log();
12960
14022
  }
@@ -12971,7 +14033,7 @@ async function modulesRemoveCommand(name) {
12971
14033
  `));
12972
14034
  return;
12973
14035
  }
12974
- if (!(0, import_node_fs12.existsSync)(target)) {
14036
+ if (!(0, import_node_fs14.existsSync)(target)) {
12975
14037
  console.log(source_default.yellow(` ! No module named "${name}" in ${dir}
12976
14038
  `));
12977
14039
  return;
@@ -12981,7 +14043,8 @@ async function modulesRemoveCommand(name) {
12981
14043
  console.log(source_default.yellow(` ! Directory name "${name}" does not match manifest name "${check.name}"`));
12982
14044
  }
12983
14045
  try {
12984
- (0, import_node_fs12.rmSync)(target, { recursive: true, force: true });
14046
+ (0, import_node_fs14.rmSync)(target, { recursive: true, force: true });
14047
+ untrustModule(name);
12985
14048
  console.log(source_default.green(` \u2713 Removed ${name} from ${dir}
12986
14049
  `));
12987
14050
  } catch (err) {
@@ -12991,6 +14054,72 @@ async function modulesRemoveCommand(name) {
12991
14054
  }
12992
14055
  notifyDaemonIfRunning();
12993
14056
  }
14057
+ async function modulesTrustCommand(name) {
14058
+ banner();
14059
+ console.log(source_default.green.bold(" Trust Module"));
14060
+ console.log(source_default.gray(" " + "\u2500".repeat(50)));
14061
+ console.log();
14062
+ const dir = modulesDir();
14063
+ let target;
14064
+ try {
14065
+ target = moduleDestination(dir, name);
14066
+ } catch (err) {
14067
+ console.log(source_default.red(` x ${err.message}
14068
+ `));
14069
+ return;
14070
+ }
14071
+ if (!(0, import_node_fs14.existsSync)(target)) {
14072
+ console.log(source_default.yellow(` ! No module named "${name}" in ${dir}
14073
+ `));
14074
+ return;
14075
+ }
14076
+ const check = validateManifest(target);
14077
+ if (!check.ok) {
14078
+ console.log(source_default.red(` \u2717 ${check.error}
14079
+ `));
14080
+ return;
14081
+ }
14082
+ const record = trustModule(name, target, target);
14083
+ console.log(source_default.green(` \u2713 Trusted ${source_default.white(name)} v${check.version}`));
14084
+ console.log(source_default.dim(` digest ${record.digest.slice(0, 16)}\u2026`));
14085
+ console.log(
14086
+ source_default.dim(" threatcrushd will refuse to load it again if its contents change.\n")
14087
+ );
14088
+ notifyDaemonIfRunning();
14089
+ }
14090
+ async function modulesUntrustCommand(name) {
14091
+ banner();
14092
+ console.log(source_default.green.bold(" Revoke Module Trust"));
14093
+ console.log(source_default.gray(" " + "\u2500".repeat(50)));
14094
+ console.log();
14095
+ if (untrustModule(name)) {
14096
+ console.log(source_default.green(` \u2713 Revoked trust for ${source_default.white(name)}
14097
+ `));
14098
+ notifyDaemonIfRunning();
14099
+ } else {
14100
+ console.log(source_default.yellow(` ! ${name} was not trusted
14101
+ `));
14102
+ }
14103
+ }
14104
+ async function modulesTrustedCommand() {
14105
+ banner();
14106
+ console.log(source_default.green.bold(" Trusted Modules"));
14107
+ console.log(source_default.gray(" " + "\u2500".repeat(50)));
14108
+ console.log();
14109
+ const trusted = Object.entries(listTrustedModules());
14110
+ if (trusted.length === 0) {
14111
+ console.log(source_default.yellow(" No modules are trusted."));
14112
+ console.log(source_default.dim(" Installed modules stay dormant until you run:"));
14113
+ console.log(source_default.dim(` ${source_default.white("threatcrush modules trust <name>")}
14114
+ `));
14115
+ return;
14116
+ }
14117
+ for (const [name, record] of trusted) {
14118
+ console.log(` ${source_default.white(name)} ${source_default.dim(record.digest.slice(0, 16) + "\u2026")}`);
14119
+ console.log(source_default.dim(` trusted ${record.trustedAt}`));
14120
+ }
14121
+ console.log();
14122
+ }
12994
14123
  async function modulesCommand(opts) {
12995
14124
  const action = opts.action || "list";
12996
14125
  switch (action) {
@@ -13024,10 +14153,31 @@ async function modulesCommand(opts) {
13024
14153
  }
13025
14154
  await modulesRemoveCommand(opts.name);
13026
14155
  break;
14156
+ case "trust":
14157
+ if (!opts.name) {
14158
+ banner();
14159
+ console.log(source_default.red(" Module name required."));
14160
+ console.log(source_default.gray(" Usage: threatcrush modules trust <name>\n"));
14161
+ return;
14162
+ }
14163
+ await modulesTrustCommand(opts.name);
14164
+ break;
14165
+ case "untrust":
14166
+ if (!opts.name) {
14167
+ banner();
14168
+ console.log(source_default.red(" Module name required."));
14169
+ console.log(source_default.gray(" Usage: threatcrush modules untrust <name>\n"));
14170
+ return;
14171
+ }
14172
+ await modulesUntrustCommand(opts.name);
14173
+ break;
14174
+ case "trusted":
14175
+ await modulesTrustedCommand();
14176
+ break;
13027
14177
  default:
13028
14178
  banner();
13029
14179
  console.log(source_default.yellow(` Unknown action: ${action}`));
13030
- console.log(source_default.gray(" Available actions: list, install, remove\n"));
14180
+ console.log(source_default.gray(" Available actions: list, install, remove, trust, untrust, trusted\n"));
13031
14181
  await modulesListCommand();
13032
14182
  break;
13033
14183
  }
@@ -13354,21 +14504,21 @@ async function pentestCommand(targetUrl) {
13354
14504
 
13355
14505
  // src/commands/orgs.ts
13356
14506
  var import_node_os5 = require("os");
13357
- var import_node_fs13 = require("fs");
13358
- var import_node_path10 = require("path");
14507
+ var import_node_fs15 = require("fs");
14508
+ var import_node_path12 = require("path");
13359
14509
  var API_URL3 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
13360
- var CONFIG_PATH = (0, import_node_path10.join)((0, import_node_os5.homedir)(), ".threatcrush", "config.json");
14510
+ var CONFIG_PATH = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".threatcrush", "config.json");
13361
14511
  function readConfig() {
13362
14512
  try {
13363
- return JSON.parse((0, import_node_fs13.readFileSync)(CONFIG_PATH, "utf-8"));
14513
+ return JSON.parse((0, import_node_fs15.readFileSync)(CONFIG_PATH, "utf-8"));
13364
14514
  } catch {
13365
14515
  return {};
13366
14516
  }
13367
14517
  }
13368
14518
  function writeConfig(config) {
13369
- const dir = (0, import_node_path10.join)((0, import_node_os5.homedir)(), ".threatcrush");
13370
- if (!(0, import_node_fs13.existsSync)(dir)) (0, import_node_fs13.mkdirSync)(dir, { recursive: true });
13371
- (0, import_node_fs13.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2));
14519
+ const dir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".threatcrush");
14520
+ if (!(0, import_node_fs15.existsSync)(dir)) (0, import_node_fs15.mkdirSync)(dir, { recursive: true });
14521
+ (0, import_node_fs15.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2));
13372
14522
  }
13373
14523
  function getAuthHeaders() {
13374
14524
  const config = readConfig();
@@ -13504,13 +14654,13 @@ async function useOrganization(slug) {
13504
14654
 
13505
14655
  // src/commands/servers.ts
13506
14656
  var import_node_os6 = require("os");
13507
- var import_node_fs14 = require("fs");
13508
- var import_node_path11 = require("path");
14657
+ var import_node_fs16 = require("fs");
14658
+ var import_node_path13 = require("path");
13509
14659
  var API_URL4 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
13510
- var CONFIG_PATH2 = (0, import_node_path11.join)((0, import_node_os6.homedir)(), ".threatcrush", "config.json");
14660
+ var CONFIG_PATH2 = (0, import_node_path13.join)((0, import_node_os6.homedir)(), ".threatcrush", "config.json");
13511
14661
  function readConfig2() {
13512
14662
  try {
13513
- return JSON.parse((0, import_node_fs14.readFileSync)(CONFIG_PATH2, "utf-8"));
14663
+ return JSON.parse((0, import_node_fs16.readFileSync)(CONFIG_PATH2, "utf-8"));
13514
14664
  } catch {
13515
14665
  return {};
13516
14666
  }
@@ -13613,14 +14763,14 @@ function timeAgo(dateStr) {
13613
14763
 
13614
14764
  // src/commands/connect.ts
13615
14765
  var import_node_os7 = require("os");
13616
- var import_node_fs15 = require("fs");
13617
- var import_node_path12 = require("path");
14766
+ var import_node_fs17 = require("fs");
14767
+ var import_node_path14 = require("path");
13618
14768
  var import_node_child_process3 = require("child_process");
13619
14769
  var API_URL5 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
13620
- var CONFIG_PATH3 = (0, import_node_path12.join)((0, import_node_os7.homedir)(), ".threatcrush", "config.json");
14770
+ var CONFIG_PATH3 = (0, import_node_path14.join)((0, import_node_os7.homedir)(), ".threatcrush", "config.json");
13621
14771
  function readConfig3() {
13622
14772
  try {
13623
- return JSON.parse((0, import_node_fs15.readFileSync)(CONFIG_PATH3, "utf-8"));
14773
+ return JSON.parse((0, import_node_fs17.readFileSync)(CONFIG_PATH3, "utf-8"));
13624
14774
  } catch {
13625
14775
  return {};
13626
14776
  }
@@ -13775,20 +14925,21 @@ async function sshConnect(options) {
13775
14925
 
13776
14926
  // src/commands/daemon.ts
13777
14927
  var import_node_child_process7 = require("child_process");
13778
- var import_node_fs24 = require("fs");
13779
- var import_node_path16 = require("path");
13780
- var import_node_fs25 = require("fs");
14928
+ var import_node_fs26 = require("fs");
14929
+ var import_node_path18 = require("path");
14930
+ var import_node_fs27 = require("fs");
13781
14931
 
13782
14932
  // src/daemon/index.ts
13783
- var import_node_fs23 = require("fs");
13784
- var import_node_path15 = require("path");
14933
+ var import_node_fs25 = require("fs");
14934
+ var import_node_path17 = require("path");
13785
14935
  init_paths();
13786
14936
  init_pidfile();
13787
14937
 
13788
14938
  // src/daemon/ipc-server.ts
13789
14939
  var import_node_net2 = require("net");
13790
- var import_node_fs16 = require("fs");
14940
+ var import_node_fs18 = require("fs");
13791
14941
  init_paths();
14942
+ init_control_token();
13792
14943
 
13793
14944
  // src/daemon/event-bus.ts
13794
14945
  var import_node_events = require("events");
@@ -13833,10 +14984,12 @@ var IpcServer = class {
13833
14984
  nextClientId = 1;
13834
14985
  startedAt = /* @__PURE__ */ new Date();
13835
14986
  counters = { events: 0, threats: 0, alerts: 0 };
14987
+ controlToken = "";
13836
14988
  async start() {
13837
- if ((0, import_node_fs16.existsSync)(PATHS.socket)) {
14989
+ this.controlToken = issueControlToken();
14990
+ if ((0, import_node_fs18.existsSync)(PATHS.socket)) {
13838
14991
  try {
13839
- (0, import_node_fs16.unlinkSync)(PATHS.socket);
14992
+ (0, import_node_fs18.unlinkSync)(PATHS.socket);
13840
14993
  } catch {
13841
14994
  }
13842
14995
  }
@@ -13872,14 +15025,14 @@ var IpcServer = class {
13872
15025
  return new Promise((resolve5) => {
13873
15026
  if (!this.server) {
13874
15027
  try {
13875
- if ((0, import_node_fs16.existsSync)(PATHS.socket)) (0, import_node_fs16.unlinkSync)(PATHS.socket);
15028
+ if ((0, import_node_fs18.existsSync)(PATHS.socket)) (0, import_node_fs18.unlinkSync)(PATHS.socket);
13876
15029
  } catch {
13877
15030
  }
13878
15031
  return resolve5();
13879
15032
  }
13880
15033
  this.server.close(() => {
13881
15034
  try {
13882
- if ((0, import_node_fs16.existsSync)(PATHS.socket)) (0, import_node_fs16.unlinkSync)(PATHS.socket);
15035
+ if ((0, import_node_fs18.existsSync)(PATHS.socket)) (0, import_node_fs18.unlinkSync)(PATHS.socket);
13883
15036
  } catch {
13884
15037
  }
13885
15038
  resolve5();
@@ -13965,6 +15118,13 @@ var IpcServer = class {
13965
15118
  for (const ch of req.params.channels) client.subscriptions.add(ch);
13966
15119
  return this.send(client, { id: req.id, ok: true, result: { subscribed: [...client.subscriptions] } });
13967
15120
  case "shutdown":
15121
+ if (!tokensMatch(this.controlToken, req.params?.token)) {
15122
+ return this.send(client, {
15123
+ id: req.id,
15124
+ ok: false,
15125
+ error: "shutdown requires the daemon control token (run as root, or use systemctl)"
15126
+ });
15127
+ }
13968
15128
  this.send(client, { id: req.id, ok: true, result: "shutting down" });
13969
15129
  setTimeout(() => process.emit("SIGTERM"), 50);
13970
15130
  return;
@@ -13985,14 +15145,14 @@ var IpcServer = class {
13985
15145
  };
13986
15146
 
13987
15147
  // src/daemon/module-host.ts
13988
- var import_node_fs20 = require("fs");
13989
- var import_node_path13 = require("path");
15148
+ var import_node_fs22 = require("fs");
15149
+ var import_node_path15 = require("path");
13990
15150
  var import_node_url = require("url");
13991
15151
  var import_toml4 = __toESM(require_toml());
13992
15152
  init_paths();
13993
15153
 
13994
15154
  // src/daemon/watchers/log-watcher.ts
13995
- var import_node_fs17 = require("fs");
15155
+ var import_node_fs19 = require("fs");
13996
15156
  var import_node_readline4 = require("readline");
13997
15157
  init_state();
13998
15158
  var DEFAULT_SOURCES = [
@@ -14014,9 +15174,9 @@ var LogWatcher = class {
14014
15174
  start() {
14015
15175
  const started = [];
14016
15176
  for (const src of this.sources) {
14017
- if (!(0, import_node_fs17.existsSync)(src.path)) continue;
15177
+ if (!(0, import_node_fs19.existsSync)(src.path)) continue;
14018
15178
  try {
14019
- (0, import_node_fs17.accessSync)(src.path, import_node_fs17.constants.R_OK);
15179
+ (0, import_node_fs19.accessSync)(src.path, import_node_fs19.constants.R_OK);
14020
15180
  } catch {
14021
15181
  continue;
14022
15182
  }
@@ -14036,7 +15196,7 @@ var LogWatcher = class {
14036
15196
  }
14037
15197
  tail(src) {
14038
15198
  try {
14039
- this.positions.set(src.path, (0, import_node_fs17.statSync)(src.path).size);
15199
+ this.positions.set(src.path, (0, import_node_fs19.statSync)(src.path).size);
14040
15200
  } catch {
14041
15201
  this.positions.set(src.path, 0);
14042
15202
  }
@@ -14047,7 +15207,7 @@ var LogWatcher = class {
14047
15207
  poll(src) {
14048
15208
  let stat;
14049
15209
  try {
14050
- stat = (0, import_node_fs17.statSync)(src.path);
15210
+ stat = (0, import_node_fs19.statSync)(src.path);
14051
15211
  } catch {
14052
15212
  return;
14053
15213
  }
@@ -14057,7 +15217,7 @@ var LogWatcher = class {
14057
15217
  return;
14058
15218
  }
14059
15219
  if (stat.size === prev) return;
14060
- const stream = (0, import_node_fs17.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
15220
+ const stream = (0, import_node_fs19.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
14061
15221
  stream.on("error", () => this.positions.set(src.path, stat.size));
14062
15222
  const rl = (0, import_node_readline4.createInterface)({ input: stream });
14063
15223
  rl.on("error", () => {
@@ -14250,7 +15410,7 @@ function realtimeToDate(rt) {
14250
15410
 
14251
15411
  // src/modules/network-monitor/index.ts
14252
15412
  var import_node_child_process5 = require("child_process");
14253
- var import_node_fs18 = require("fs");
15413
+ var import_node_fs20 = require("fs");
14254
15414
  init_state();
14255
15415
  var NetworkMonitor = class {
14256
15416
  constructor(bus2) {
@@ -14289,7 +15449,7 @@ var NetworkMonitor = class {
14289
15449
  hasConntrackOrSs() {
14290
15450
  const ss = (0, import_node_child_process5.spawnSync)("ss", ["--version"], { stdio: "pipe" });
14291
15451
  if (ss.status === 0) return true;
14292
- return (0, import_node_fs18.existsSync)("/proc/net/tcp");
15452
+ return (0, import_node_fs20.existsSync)("/proc/net/tcp");
14293
15453
  }
14294
15454
  poll() {
14295
15455
  try {
@@ -14428,7 +15588,7 @@ var NetworkMonitor = class {
14428
15588
  };
14429
15589
 
14430
15590
  // src/modules/dns-monitor/index.ts
14431
- var import_node_fs19 = require("fs");
15591
+ var import_node_fs21 = require("fs");
14432
15592
  var import_node_readline5 = require("readline");
14433
15593
  init_state();
14434
15594
  var DNS_LOG_SOURCES = [
@@ -14463,9 +15623,9 @@ var DnsMonitor = class {
14463
15623
  entropyThreshold = 3.5;
14464
15624
  start() {
14465
15625
  const sources = DNS_LOG_SOURCES.filter((p) => {
14466
- if (!(0, import_node_fs19.existsSync)(p)) return false;
15626
+ if (!(0, import_node_fs21.existsSync)(p)) return false;
14467
15627
  try {
14468
- (0, import_node_fs19.accessSync)(p, import_node_fs19.constants.R_OK);
15628
+ (0, import_node_fs21.accessSync)(p, import_node_fs21.constants.R_OK);
14469
15629
  return true;
14470
15630
  } catch {
14471
15631
  return false;
@@ -14489,7 +15649,7 @@ var DnsMonitor = class {
14489
15649
  }
14490
15650
  tailLog(path) {
14491
15651
  try {
14492
- this.positions.set(path, (0, import_node_fs19.statSync)(path).size);
15652
+ this.positions.set(path, (0, import_node_fs21.statSync)(path).size);
14493
15653
  } catch {
14494
15654
  this.positions.set(path, 0);
14495
15655
  }
@@ -14499,7 +15659,7 @@ var DnsMonitor = class {
14499
15659
  pollLog(path) {
14500
15660
  let stat;
14501
15661
  try {
14502
- stat = (0, import_node_fs19.statSync)(path);
15662
+ stat = (0, import_node_fs21.statSync)(path);
14503
15663
  } catch {
14504
15664
  return;
14505
15665
  }
@@ -14509,7 +15669,7 @@ var DnsMonitor = class {
14509
15669
  return;
14510
15670
  }
14511
15671
  if (stat.size === prev) return;
14512
- const stream = (0, import_node_fs19.createReadStream)(path, { start: prev, encoding: "utf-8" });
15672
+ const stream = (0, import_node_fs21.createReadStream)(path, { start: prev, encoding: "utf-8" });
14513
15673
  stream.on("error", () => this.positions.set(path, stat.size));
14514
15674
  const rl = (0, import_node_readline5.createInterface)({ input: stream });
14515
15675
  rl.on("line", (line) => this.parseDnsLine(line));
@@ -14741,15 +15901,15 @@ var ModuleHost = class {
14741
15901
  for (const m of builtins) this.modules.set(m.name, m);
14742
15902
  }
14743
15903
  async discoverAndStartInstalled() {
14744
- if (!(0, import_node_fs20.existsSync)(PATHS.moduleDir)) return;
15904
+ if (!(0, import_node_fs22.existsSync)(PATHS.moduleDir)) return;
14745
15905
  const configs = loadModuleConfigs(PATHS.confD);
14746
- const entries = (0, import_node_fs20.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
15906
+ const entries = (0, import_node_fs22.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
14747
15907
  for (const entry of entries) {
14748
15908
  if (!entry.isDirectory()) continue;
14749
- const manifestPath = (0, import_node_path13.join)(PATHS.moduleDir, entry.name, "mod.toml");
14750
- if (!(0, import_node_fs20.existsSync)(manifestPath)) continue;
15909
+ const manifestPath = (0, import_node_path15.join)(PATHS.moduleDir, entry.name, "mod.toml");
15910
+ if (!(0, import_node_fs22.existsSync)(manifestPath)) continue;
14751
15911
  try {
14752
- const manifest = import_toml4.default.parse((0, import_node_fs20.readFileSync)(manifestPath, "utf-8"));
15912
+ const manifest = import_toml4.default.parse((0, import_node_fs22.readFileSync)(manifestPath, "utf-8"));
14753
15913
  const name = manifest.module?.name || entry.name;
14754
15914
  const defaults = manifest.module?.config?.defaults || {};
14755
15915
  const config = {
@@ -14763,7 +15923,7 @@ var ModuleHost = class {
14763
15923
  source: "installed",
14764
15924
  status: config.enabled === false ? "disabled" : "loaded",
14765
15925
  events: 0,
14766
- path: (0, import_node_path13.join)(PATHS.moduleDir, entry.name),
15926
+ path: (0, import_node_path15.join)(PATHS.moduleDir, entry.name),
14767
15927
  config
14768
15928
  };
14769
15929
  this.modules.set(name, hosted);
@@ -14778,7 +15938,7 @@ var ModuleHost = class {
14778
15938
  status: "error",
14779
15939
  events: 0,
14780
15940
  detail: `manifest load failed: ${String(err.message || err)}`,
14781
- path: (0, import_node_path13.join)(PATHS.moduleDir, entry.name)
15941
+ path: (0, import_node_path15.join)(PATHS.moduleDir, entry.name)
14782
15942
  });
14783
15943
  }
14784
15944
  }
@@ -14790,6 +15950,13 @@ var ModuleHost = class {
14790
15950
  hosted.detail = "no built entrypoint found; run npm install && npm run build in the module directory";
14791
15951
  return;
14792
15952
  }
15953
+ const trust = verifyModuleTrust(hosted.name, hosted.path);
15954
+ if (!trust.ok) {
15955
+ hosted.status = "error";
15956
+ hosted.detail = `refusing to load: ${trust.reason}`;
15957
+ this.bus.announceModule(hosted.name, "error", hosted.detail);
15958
+ return;
15959
+ }
14793
15960
  try {
14794
15961
  const imported = await import((0, import_node_url.pathToFileURL)(entrypoint).href);
14795
15962
  const exported = imported.default || imported.module || imported;
@@ -14810,17 +15977,17 @@ var ModuleHost = class {
14810
15977
  }
14811
15978
  }
14812
15979
  installedEntrypoint(modulePath) {
14813
- const packageJson = (0, import_node_path13.join)(modulePath, "package.json");
15980
+ const packageJson = (0, import_node_path15.join)(modulePath, "package.json");
14814
15981
  const candidates = [];
14815
- if ((0, import_node_fs20.existsSync)(packageJson)) {
15982
+ if ((0, import_node_fs22.existsSync)(packageJson)) {
14816
15983
  try {
14817
- const pkg = JSON.parse((0, import_node_fs20.readFileSync)(packageJson, "utf-8"));
14818
- if (pkg.main) candidates.push((0, import_node_path13.join)(modulePath, pkg.main));
15984
+ const pkg = JSON.parse((0, import_node_fs22.readFileSync)(packageJson, "utf-8"));
15985
+ if (pkg.main) candidates.push((0, import_node_path15.join)(modulePath, pkg.main));
14819
15986
  } catch {
14820
15987
  }
14821
15988
  }
14822
- candidates.push((0, import_node_path13.join)(modulePath, "dist", "index.js"), (0, import_node_path13.join)(modulePath, "index.js"));
14823
- return candidates.find((candidate) => (0, import_node_fs20.existsSync)(candidate)) || null;
15989
+ candidates.push((0, import_node_path15.join)(modulePath, "dist", "index.js"), (0, import_node_path15.join)(modulePath, "index.js"));
15990
+ return candidates.find((candidate) => (0, import_node_fs22.existsSync)(candidate)) || null;
14824
15991
  }
14825
15992
  isThreatCrushModule(value) {
14826
15993
  return Boolean(
@@ -15341,8 +16508,8 @@ var RuleEngine = class {
15341
16508
  };
15342
16509
 
15343
16510
  // src/daemon/rules/loader.ts
15344
- var import_node_fs21 = require("fs");
15345
- var import_node_path14 = require("path");
16511
+ var import_node_fs23 = require("fs");
16512
+ var import_node_path16 = require("path");
15346
16513
 
15347
16514
  // src/daemon/rules/default-rules.ts
15348
16515
  var DEFAULT_RULES = [
@@ -15626,11 +16793,11 @@ var RULES_DIR = "/etc/threatcrush/rules.d";
15626
16793
  function loadAllRules(customDir) {
15627
16794
  const rules = [...DEFAULT_RULES];
15628
16795
  const dir = customDir || RULES_DIR;
15629
- if ((0, import_node_fs21.existsSync)(dir)) {
15630
- const files = (0, import_node_fs21.readdirSync)(dir).filter((f) => f.endsWith(".json"));
16796
+ if ((0, import_node_fs23.existsSync)(dir)) {
16797
+ const files = (0, import_node_fs23.readdirSync)(dir).filter((f) => f.endsWith(".json"));
15631
16798
  for (const file of files) {
15632
16799
  try {
15633
- const raw = (0, import_node_fs21.readFileSync)((0, import_node_path14.join)(dir, file), "utf-8");
16800
+ const raw = (0, import_node_fs23.readFileSync)((0, import_node_path16.join)(dir, file), "utf-8");
15634
16801
  const parsed = JSON.parse(raw);
15635
16802
  const customRules = Array.isArray(parsed) ? parsed : [parsed];
15636
16803
  for (const rule of customRules) {
@@ -15793,7 +16960,7 @@ function detectFirewallAdapter() {
15793
16960
  }
15794
16961
 
15795
16962
  // src/daemon/firewall/remediation.ts
15796
- var import_node_fs22 = require("fs");
16963
+ var import_node_fs24 = require("fs");
15797
16964
  init_state();
15798
16965
  init_paths();
15799
16966
  var DEFAULT_CONFIG2 = {
@@ -15950,7 +17117,7 @@ var RemediationManager = class {
15950
17117
  }
15951
17118
  logLine(line) {
15952
17119
  try {
15953
- (0, import_node_fs22.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
17120
+ (0, import_node_fs24.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
15954
17121
  `);
15955
17122
  } catch {
15956
17123
  }
@@ -16009,7 +17176,7 @@ async function flushTelemetry(timeoutMs = 2e3) {
16009
17176
  // src/daemon/index.ts
16010
17177
  function readVersion2() {
16011
17178
  try {
16012
- const pkg = JSON.parse((0, import_node_fs23.readFileSync)((0, import_node_path15.join)(__dirname, "..", "package.json"), "utf-8"));
17179
+ const pkg = JSON.parse((0, import_node_fs25.readFileSync)((0, import_node_path17.join)(__dirname, "..", "package.json"), "utf-8"));
16013
17180
  return pkg.version || "0.0.0";
16014
17181
  } catch {
16015
17182
  return "0.0.0";
@@ -16017,7 +17184,7 @@ function readVersion2() {
16017
17184
  }
16018
17185
  function logLine(line) {
16019
17186
  try {
16020
- (0, import_node_fs23.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
17187
+ (0, import_node_fs25.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
16021
17188
  `);
16022
17189
  } catch {
16023
17190
  }
@@ -16045,7 +17212,7 @@ async function runDaemon() {
16045
17212
  } catch (err) {
16046
17213
  logLine(`[daemon] state db unavailable: ${err.message}`);
16047
17214
  }
16048
- const config = loadConfig((0, import_node_fs23.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
17215
+ const config = loadConfig((0, import_node_fs25.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
16049
17216
  bus.on("event", (event) => {
16050
17217
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
16051
17218
  });
@@ -16137,7 +17304,7 @@ async function runDaemon() {
16137
17304
  init_paths();
16138
17305
  init_pidfile();
16139
17306
  init_ipc_client();
16140
- var DAEMON_ENTRY = (0, import_node_path16.join)(__dirname, "daemon.js");
17307
+ var DAEMON_ENTRY = (0, import_node_path18.join)(__dirname, "daemon.js");
16141
17308
  async function daemonForeground() {
16142
17309
  await runDaemon();
16143
17310
  }
@@ -16148,7 +17315,7 @@ async function daemonStart() {
16148
17315
  return;
16149
17316
  }
16150
17317
  ensureRuntimeDirs();
16151
- if (!(0, import_node_fs24.existsSync)(DAEMON_ENTRY)) {
17318
+ if (!(0, import_node_fs26.existsSync)(DAEMON_ENTRY)) {
16152
17319
  console.log(source_default.red(` Daemon entry not found at ${DAEMON_ENTRY}.`));
16153
17320
  console.log(source_default.dim(" Reinstall with `threatcrush update` or `pnpm run build` in the cli package."));
16154
17321
  return;
@@ -16156,8 +17323,8 @@ async function daemonStart() {
16156
17323
  let out;
16157
17324
  let err;
16158
17325
  try {
16159
- out = (0, import_node_fs25.openSync)(PATHS.logFile, "a");
16160
- err = (0, import_node_fs25.openSync)(PATHS.logFile, "a");
17326
+ out = (0, import_node_fs27.openSync)(PATHS.logFile, "a");
17327
+ err = (0, import_node_fs27.openSync)(PATHS.logFile, "a");
16161
17328
  } catch (e) {
16162
17329
  const code = e.code;
16163
17330
  console.log(source_default.red(` \u2717 Cannot write daemon log at ${PATHS.logFile} (${code ?? "error"}).`));
@@ -16236,19 +17403,19 @@ async function daemonStop() {
16236
17403
 
16237
17404
  // src/commands/service.ts
16238
17405
  var import_node_child_process8 = require("child_process");
16239
- var import_node_fs26 = require("fs");
16240
- var import_node_path17 = require("path");
17406
+ var import_node_fs28 = require("fs");
17407
+ var import_node_path19 = require("path");
16241
17408
  var UNIT_PATH = "/etc/systemd/system/threatcrushd.service";
16242
17409
  function resolveTemplate() {
16243
- const templatePath = (0, import_node_path17.join)(__dirname, "systemd", "threatcrushd.service");
16244
- if (!(0, import_node_fs26.existsSync)(templatePath)) {
17410
+ const templatePath = (0, import_node_path19.join)(__dirname, "systemd", "threatcrushd.service");
17411
+ if (!(0, import_node_fs28.existsSync)(templatePath)) {
16245
17412
  throw new Error(`systemd unit template not found at ${templatePath}`);
16246
17413
  }
16247
- return (0, import_node_fs26.readFileSync)(templatePath, "utf-8");
17414
+ return (0, import_node_fs28.readFileSync)(templatePath, "utf-8");
16248
17415
  }
16249
17416
  function resolveBinPath() {
16250
17417
  const arg = process.argv[1];
16251
- if (arg && (0, import_node_fs26.existsSync)(arg)) return arg;
17418
+ if (arg && (0, import_node_fs28.existsSync)(arg)) return arg;
16252
17419
  try {
16253
17420
  return (0, import_node_child_process8.execSync)("command -v threatcrush", { encoding: "utf-8" }).trim();
16254
17421
  } catch {
@@ -16269,7 +17436,7 @@ async function installServiceCommand() {
16269
17436
  return;
16270
17437
  }
16271
17438
  const unit = resolveTemplate().replace("{{BIN_PATH}}", resolveBinPath());
16272
- (0, import_node_fs26.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
17439
+ (0, import_node_fs28.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
16273
17440
  console.log(source_default.green(` \u2713 Installed unit file: ${UNIT_PATH}`));
16274
17441
  ensureSystemDirs();
16275
17442
  try {
@@ -16284,29 +17451,31 @@ async function installServiceCommand() {
16284
17451
  }
16285
17452
  function ensureSystemDirs() {
16286
17453
  const dirs = [
16287
- { path: "/etc/threatcrush" },
16288
- { path: "/etc/threatcrush/modules", sticky: true },
16289
- { path: "/etc/threatcrush/threatcrushd.conf.d" },
16290
- { path: "/var/log/threatcrush" },
16291
- { path: "/var/lib/threatcrush" },
16292
- { path: "/var/run/threatcrush" }
17454
+ { path: "/etc/threatcrush", groupWritable: true },
17455
+ { path: "/etc/threatcrush/modules", groupWritable: true, sticky: true },
17456
+ { path: "/etc/threatcrush/threatcrushd.conf.d", groupWritable: true },
17457
+ { path: "/var/log/threatcrush", groupWritable: false },
17458
+ { path: "/var/lib/threatcrush", groupWritable: false },
17459
+ { path: "/var/run/threatcrush", groupWritable: false }
16293
17460
  ];
16294
17461
  let admGid = null;
16295
17462
  try {
16296
- admGid = (0, import_node_fs26.statSync)("/var/log/auth.log").gid;
17463
+ admGid = (0, import_node_fs28.statSync)("/var/log/auth.log").gid;
16297
17464
  } catch {
16298
17465
  }
16299
- for (const { path, sticky } of dirs) {
17466
+ for (const { path, groupWritable, sticky } of dirs) {
16300
17467
  try {
16301
- (0, import_node_fs26.mkdirSync)(path, { recursive: true });
17468
+ (0, import_node_fs28.mkdirSync)(path, { recursive: true });
16302
17469
  } catch {
16303
17470
  }
16304
- if (admGid !== null) {
16305
- try {
16306
- (0, import_node_fs26.chmodSync)(path, sticky ? 1533 : 509);
17471
+ try {
17472
+ if (groupWritable && admGid !== null) {
17473
+ (0, import_node_fs28.chmodSync)(path, sticky ? 1533 : 509);
16307
17474
  (0, import_node_child_process8.execSync)(`chgrp adm ${path}`, { stdio: "ignore" });
16308
- } catch {
17475
+ } else {
17476
+ (0, import_node_fs28.chmodSync)(path, 493);
16309
17477
  }
17478
+ } catch {
16310
17479
  }
16311
17480
  }
16312
17481
  console.log(source_default.green(" \u2713 Runtime dirs prepared (group `adm` may install modules / edit config without sudo)."));
@@ -16330,7 +17499,7 @@ async function uninstallServiceCommand() {
16330
17499
  } catch {
16331
17500
  }
16332
17501
  try {
16333
- if ((0, import_node_fs26.existsSync)(UNIT_PATH)) {
17502
+ if ((0, import_node_fs28.existsSync)(UNIT_PATH)) {
16334
17503
  (0, import_node_child_process8.execSync)(`rm -f ${UNIT_PATH}`);
16335
17504
  console.log(source_default.green(` \u2713 Removed unit file: ${UNIT_PATH}`));
16336
17505
  }
@@ -16429,8 +17598,8 @@ function welcomeCommand() {
16429
17598
  }
16430
17599
 
16431
17600
  // src/commands/properties.ts
16432
- var import_node_fs27 = require("fs");
16433
- var import_node_path18 = require("path");
17601
+ var import_node_fs29 = require("fs");
17602
+ var import_node_path20 = require("path");
16434
17603
  var import_node_readline6 = __toESM(require("readline"));
16435
17604
  var API_URL7 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
16436
17605
  var KINDS = ["url", "api", "domain", "ip", "repo"];
@@ -16759,8 +17928,8 @@ async function propertiesRunsCommand(opts) {
16759
17928
  }
16760
17929
  }
16761
17930
  function parseImportFile(path) {
16762
- const ext = (0, import_node_path18.extname)(path).toLowerCase();
16763
- const raw = (0, import_node_fs27.readFileSync)(path, "utf-8");
17931
+ const ext = (0, import_node_path20.extname)(path).toLowerCase();
17932
+ const raw = (0, import_node_fs29.readFileSync)(path, "utf-8");
16764
17933
  if (ext === ".json") {
16765
17934
  const parsed = JSON.parse(raw);
16766
17935
  if (!Array.isArray(parsed)) throw new Error("JSON must be an array of objects");
@@ -16950,7 +18119,7 @@ async function rulesCommand(opts) {
16950
18119
  }
16951
18120
 
16952
18121
  // src/commands/harden.ts
16953
- var import_node_fs28 = require("fs");
18122
+ var import_node_fs30 = require("fs");
16954
18123
  var import_node_child_process9 = require("child_process");
16955
18124
  function tryExec(cmd) {
16956
18125
  try {
@@ -16961,7 +18130,7 @@ function tryExec(cmd) {
16961
18130
  }
16962
18131
  function tryRead(path) {
16963
18132
  try {
16964
- return (0, import_node_fs28.readFileSync)(path, "utf-8");
18133
+ return (0, import_node_fs30.readFileSync)(path, "utf-8");
16965
18134
  } catch {
16966
18135
  return null;
16967
18136
  }
@@ -17065,8 +18234,8 @@ function checkSshWeakConfig() {
17065
18234
  };
17066
18235
  }
17067
18236
  function checkAutoUpdates() {
17068
- 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");
17069
- const dnfAuto = (0, import_node_fs28.existsSync)("/etc/dnf/automatic.conf");
18237
+ 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");
18238
+ const dnfAuto = (0, import_node_fs30.existsSync)("/etc/dnf/automatic.conf");
17070
18239
  if (unattended || dnfAuto) {
17071
18240
  return {
17072
18241
  key: "auto-updates",
@@ -17192,7 +18361,7 @@ function checkFail2ban() {
17192
18361
  explanation: "fail2ban is installed and running."
17193
18362
  };
17194
18363
  }
17195
- if ((0, import_node_fs28.existsSync)("/etc/fail2ban/fail2ban.conf")) {
18364
+ if ((0, import_node_fs30.existsSync)("/etc/fail2ban/fail2ban.conf")) {
17196
18365
  return {
17197
18366
  key: checkKey,
17198
18367
  status: "warn",
@@ -17423,7 +18592,7 @@ async function allowlistCommand(opts) {
17423
18592
  init_paths();
17424
18593
  var PKG_VERSION2 = "0.1.8";
17425
18594
  try {
17426
- const pkg = JSON.parse((0, import_node_fs29.readFileSync)((0, import_node_path19.join)(__dirname, "..", "package.json"), "utf-8"));
18595
+ const pkg = JSON.parse((0, import_node_fs31.readFileSync)((0, import_node_path21.join)(__dirname, "..", "package.json"), "utf-8"));
17427
18596
  PKG_VERSION2 = pkg.version;
17428
18597
  } catch {
17429
18598
  }
@@ -17439,7 +18608,7 @@ ${source_default.dim(" C R U S H")}
17439
18608
  var API_URL8 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
17440
18609
  var PKG_NAME = "@profullstack/threatcrush";
17441
18610
  var DESKTOP_PKG_NAME = "@profullstack/threatcrush-desktop";
17442
- var INSTALL_CONFIG_PATH = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush", "install.json");
18611
+ var INSTALL_CONFIG_PATH = (0, import_node_path21.join)((0, import_node_os8.homedir)(), ".threatcrush", "install.json");
17443
18612
  function detectPackageManager() {
17444
18613
  try {
17445
18614
  const npmGlobal = (0, import_node_child_process10.execSync)("npm ls -g --depth=0 --json 2>/dev/null", { encoding: "utf-8" });
@@ -17465,7 +18634,7 @@ function detectPackageManager() {
17465
18634
  }
17466
18635
  function readInstallConfig() {
17467
18636
  try {
17468
- return JSON.parse((0, import_node_fs29.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
18637
+ return JSON.parse((0, import_node_fs31.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
17469
18638
  } catch {
17470
18639
  return {};
17471
18640
  }
@@ -17579,6 +18748,9 @@ program2.command("scan").description("Scan codebase for vulnerabilities and secr
17579
18748
  "skip paths matching this glob (repeatable); merged with a .threatcrushignore at the scan root",
17580
18749
  (value, previous) => [...previous, value],
17581
18750
  []
18751
+ ).option(
18752
+ "--missing-controls",
18753
+ "also report security controls the tree shows no evidence of (headers, CSRF, rate limiting, body size limits)"
17582
18754
  ).option("-v, --verbose", "list the paths that could not be read").action(async (targetPath, opts) => {
17583
18755
  const format = (opts.format ?? "text").toLowerCase();
17584
18756
  if (!["text", "json", "sarif"].includes(format)) {
@@ -17599,6 +18771,7 @@ program2.command("scan").description("Scan codebase for vulnerabilities and secr
17599
18771
  pathPrefix: opts.pathPrefix,
17600
18772
  dependencies: opts.deps,
17601
18773
  exclude: opts.exclude,
18774
+ missingControls: opts.missingControls,
17602
18775
  verbose: opts.verbose
17603
18776
  });
17604
18777
  });
@@ -17646,7 +18819,7 @@ program2.command("uninstall-service").description("Remove the threatcrushd syste
17646
18819
  program2.command("logs").description("Tail daemon logs").action(async () => {
17647
18820
  console.log(LOGO2);
17648
18821
  const logPath = PATHS.logFile;
17649
- if (!(0, import_node_fs29.existsSync)(logPath)) {
18822
+ if (!(0, import_node_fs31.existsSync)(logPath)) {
17650
18823
  console.log(source_default.yellow(` No log file found at ${logPath}`));
17651
18824
  console.log(source_default.dim(" Start the daemon first with `threatcrush start`\n"));
17652
18825
  return;
@@ -17851,10 +19024,10 @@ storeCmd.command("search <query>").description("Search for modules in the store"
17851
19024
  });
17852
19025
  storeCmd.command("publish <url>").description("Publish a module from a git URL or web URL").action(async (url) => {
17853
19026
  console.log(LOGO2);
17854
- const configPath = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
19027
+ const configPath = (0, import_node_path21.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
17855
19028
  let email = "";
17856
19029
  try {
17857
- const config = JSON.parse((0, import_node_fs29.readFileSync)(configPath, "utf-8"));
19030
+ const config = JSON.parse((0, import_node_fs31.readFileSync)(configPath, "utf-8"));
17858
19031
  email = config.email || "";
17859
19032
  } catch {
17860
19033
  }
@@ -17871,9 +19044,9 @@ storeCmd.command("publish <url>").description("Publish a module from a git URL o
17871
19044
  return;
17872
19045
  }
17873
19046
  try {
17874
- const dir = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush");
17875
- if (!(0, import_node_fs29.existsSync)(dir)) (0, import_node_fs29.mkdirSync)(dir, { recursive: true });
17876
- (0, import_node_fs29.writeFileSync)(configPath, JSON.stringify({ email }, null, 2));
19047
+ const dir = (0, import_node_path21.join)((0, import_node_os8.homedir)(), ".threatcrush");
19048
+ if (!(0, import_node_fs31.existsSync)(dir)) (0, import_node_fs31.mkdirSync)(dir, { recursive: true });
19049
+ (0, import_node_fs31.writeFileSync)(configPath, JSON.stringify({ email }, null, 2));
17877
19050
  console.log(source_default.dim(` Saved email to ${configPath}`));
17878
19051
  } catch {
17879
19052
  }