@profullstack/threatcrush 0.2.2 → 0.3.0

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
@@ -1074,7 +1074,7 @@ var require_suggestSimilar = __commonJS({
1074
1074
  "../../node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/suggestSimilar.js"(exports2) {
1075
1075
  "use strict";
1076
1076
  var maxDistance = 3;
1077
- function editDistance(a, b) {
1077
+ function editDistance2(a, b) {
1078
1078
  if (Math.abs(a.length - b.length) > maxDistance)
1079
1079
  return Math.max(a.length, b.length);
1080
1080
  const d = [];
@@ -1120,7 +1120,7 @@ var require_suggestSimilar = __commonJS({
1120
1120
  const minSimilarity = 0.4;
1121
1121
  candidates.forEach((candidate) => {
1122
1122
  if (candidate.length <= 1) return;
1123
- const distance = editDistance(word, candidate);
1123
+ const distance = editDistance2(word, candidate);
1124
1124
  const length = Math.max(word.length, candidate.length);
1125
1125
  const similarity = (length - distance) / length;
1126
1126
  if (similarity > minSimilarity) {
@@ -3917,7 +3917,7 @@ var init_ipc_client = __esm({
3917
3917
  if (!(0, import_node_fs2.existsSync)(this.socketPath)) {
3918
3918
  throw new Error(`threatcrushd socket not found at ${this.socketPath}`);
3919
3919
  }
3920
- return new Promise((resolve3, reject) => {
3920
+ return new Promise((resolve5, reject) => {
3921
3921
  const sock = (0, import_node_net.createConnection)(this.socketPath);
3922
3922
  const timer = setTimeout(() => {
3923
3923
  sock.destroy();
@@ -3930,7 +3930,7 @@ var init_ipc_client = __esm({
3930
3930
  sock.on("data", (chunk) => this.onData(chunk.toString()));
3931
3931
  sock.on("close", () => this.onClose());
3932
3932
  sock.on("error", () => this.onClose());
3933
- resolve3();
3933
+ resolve5();
3934
3934
  });
3935
3935
  sock.once("error", (err) => {
3936
3936
  clearTimeout(timer);
@@ -3946,8 +3946,8 @@ var init_ipc_client = __esm({
3946
3946
  if (!this.socket) throw new Error("not connected");
3947
3947
  const id = this.nextId++;
3948
3948
  const frame = params ? { id, method, params } : { id, method };
3949
- return new Promise((resolve3, reject) => {
3950
- this.pending.set(id, { resolve: resolve3, reject });
3949
+ return new Promise((resolve5, reject) => {
3950
+ this.pending.set(id, { resolve: resolve5, reject });
3951
3951
  this.socket.write(JSON.stringify(frame) + "\n", (err) => {
3952
3952
  if (err) {
3953
3953
  this.pending.delete(id);
@@ -7363,20 +7363,20 @@ var require_parse_async = __commonJS({
7363
7363
  const index = 0;
7364
7364
  const blocksize = opts.blocksize || 40960;
7365
7365
  const parser = new TOMLParser();
7366
- return new Promise((resolve3, reject) => {
7367
- setImmediate(parseAsyncNext, index, blocksize, resolve3, reject);
7366
+ return new Promise((resolve5, reject) => {
7367
+ setImmediate(parseAsyncNext, index, blocksize, resolve5, reject);
7368
7368
  });
7369
- function parseAsyncNext(index2, blocksize2, resolve3, reject) {
7369
+ function parseAsyncNext(index2, blocksize2, resolve5, reject) {
7370
7370
  if (index2 >= str.length) {
7371
7371
  try {
7372
- return resolve3(parser.finish());
7372
+ return resolve5(parser.finish());
7373
7373
  } catch (err) {
7374
7374
  return reject(prettyError(err, str));
7375
7375
  }
7376
7376
  }
7377
7377
  try {
7378
7378
  parser.parse(str.slice(index2, index2 + blocksize2));
7379
- setImmediate(parseAsyncNext, index2 + blocksize2, blocksize2, resolve3, reject);
7379
+ setImmediate(parseAsyncNext, index2 + blocksize2, blocksize2, resolve5, reject);
7380
7380
  } catch (err) {
7381
7381
  reject(prettyError(err, str));
7382
7382
  }
@@ -7402,7 +7402,7 @@ var require_parse_stream = __commonJS({
7402
7402
  function parseReadable(stm) {
7403
7403
  const parser = new TOMLParser();
7404
7404
  stm.setEncoding("utf8");
7405
- return new Promise((resolve3, reject) => {
7405
+ return new Promise((resolve5, reject) => {
7406
7406
  let readable;
7407
7407
  let ended = false;
7408
7408
  let errored = false;
@@ -7410,7 +7410,7 @@ var require_parse_stream = __commonJS({
7410
7410
  ended = true;
7411
7411
  if (readable) return;
7412
7412
  try {
7413
- resolve3(parser.finish());
7413
+ resolve5(parser.finish());
7414
7414
  } catch (err) {
7415
7415
  reject(err);
7416
7416
  }
@@ -8260,8 +8260,8 @@ var source_default = chalk;
8260
8260
  // src/index.ts
8261
8261
  var import_readline = __toESM(require("readline"));
8262
8262
  var import_node_child_process10 = require("child_process");
8263
- var import_node_fs27 = require("fs");
8264
- var import_node_path16 = require("path");
8263
+ var import_node_fs29 = require("fs");
8264
+ var import_node_path19 = require("path");
8265
8265
  var import_node_os8 = require("os");
8266
8266
 
8267
8267
  // src/commands/monitor.ts
@@ -8323,6 +8323,7 @@ var NGINX_REGEX = /^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d{3}) (\d+) "
8323
8323
  var AUTH_REGEX = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)/;
8324
8324
  var SYSLOG_REGEX = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)/;
8325
8325
  var IP_REGEX = /(?:from|FROM)\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
8326
+ var INVALID_USER_REGEX = /(?:for\s+invalid\s+user)\s+(\S+?)(?:\s+from|\s*$)/;
8326
8327
  var USER_REGEX = /(?:for|user)\s+(\S+?)(?:\s+from|\s*$)/;
8327
8328
  var ATTACK_PATTERNS = {
8328
8329
  sqli: [
@@ -8376,7 +8377,7 @@ function parseAuthLog(line) {
8376
8377
  const match = line.match(AUTH_REGEX);
8377
8378
  if (!match) return null;
8378
8379
  const ipMatch = match[3].match(IP_REGEX);
8379
- const userMatch = match[3].match(USER_REGEX);
8380
+ const userMatch = match[3].match(INVALID_USER_REGEX) || match[3].match(USER_REGEX);
8380
8381
  return {
8381
8382
  timestamp: parseSyslogTimestamp(match[1]),
8382
8383
  raw: line,
@@ -8404,10 +8405,25 @@ function parseSyslog(line) {
8404
8405
  };
8405
8406
  }
8406
8407
  function detectAttackPattern(path) {
8408
+ const candidates = /* @__PURE__ */ new Set([path]);
8409
+ let current = path;
8410
+ for (let i = 0; i < 2; i++) {
8411
+ let decoded = null;
8412
+ try {
8413
+ decoded = decodeURIComponent(current);
8414
+ } catch {
8415
+ decoded = null;
8416
+ }
8417
+ if (decoded === null || decoded === current) break;
8418
+ candidates.add(decoded);
8419
+ current = decoded;
8420
+ }
8407
8421
  for (const [type, patterns] of Object.entries(ATTACK_PATTERNS)) {
8408
8422
  for (const pattern of patterns) {
8409
- if (pattern.test(path)) {
8410
- return type;
8423
+ for (const candidate of candidates) {
8424
+ if (pattern.test(candidate)) {
8425
+ return type;
8426
+ }
8411
8427
  }
8412
8428
  }
8413
8429
  }
@@ -8421,20 +8437,19 @@ function autoDetectParser(line) {
8421
8437
  return parseSyslog(line);
8422
8438
  }
8423
8439
  function parseNginxTimestamp(s) {
8424
- try {
8425
- const cleaned = s.replace(/(\d{2})\/(\w{3})\/(\d{4}):/, "$2 $1, $3 ");
8426
- return new Date(cleaned);
8427
- } catch {
8428
- return /* @__PURE__ */ new Date();
8429
- }
8440
+ const cleaned = s.replace(/(\d{2})\/(\w{3})\/(\d{4}):/, "$2 $1, $3 ");
8441
+ const d = new Date(cleaned);
8442
+ return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
8430
8443
  }
8431
8444
  function parseSyslogTimestamp(s) {
8432
- try {
8433
- const withYear = `${s} ${(/* @__PURE__ */ new Date()).getFullYear()}`;
8434
- return new Date(withYear);
8435
- } catch {
8436
- return /* @__PURE__ */ new Date();
8445
+ const now = /* @__PURE__ */ new Date();
8446
+ let d = /* @__PURE__ */ new Date(`${s} ${now.getFullYear()}`);
8447
+ if (Number.isNaN(d.getTime())) return now;
8448
+ if (d.getTime() > now.getTime()) {
8449
+ const prev = /* @__PURE__ */ new Date(`${s} ${now.getFullYear() - 1}`);
8450
+ if (!Number.isNaN(prev.getTime())) d = prev;
8437
8451
  }
8452
+ return d;
8438
8453
  }
8439
8454
 
8440
8455
  // src/commands/monitor.ts
@@ -8624,8 +8639,8 @@ async function runDemoMode() {
8624
8639
  }
8625
8640
 
8626
8641
  // src/commands/scan.ts
8627
- var import_node_fs5 = require("fs");
8628
- var import_node_path2 = require("path");
8642
+ var import_node_fs7 = require("fs");
8643
+ var import_node_path5 = require("path");
8629
8644
 
8630
8645
  // ../../node_modules/.pnpm/ora@8.2.0/node_modules/ora/index.js
8631
8646
  var import_node_process7 = __toESM(require("process"), 1);
@@ -9565,58 +9580,1113 @@ function workerId() {
9565
9580
  return `${import_node_os3.default.hostname()}/${process.pid}`;
9566
9581
  }
9567
9582
 
9568
- // src/commands/scan.ts
9569
- var SECRET_PATTERNS = [
9570
- { name: "AWS Access Key", pattern: /(?:AKIA[0-9A-Z]{16})/g, severity: "critical" },
9571
- { name: "AWS Secret Key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi, severity: "critical" },
9572
- { name: "GitHub Token", pattern: /(?:ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82})/g, severity: "critical" },
9573
- { name: "Generic API Key", pattern: /(?:api[_-]?key|apikey)\s*[=:]\s*['"]([A-Za-z0-9\-_]{20,})['"]?/gi, severity: "high" },
9574
- { name: "Generic Secret", pattern: /(?:secret|password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"]?/gi, severity: "high" },
9575
- { name: "Private Key", pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g, severity: "critical" },
9576
- { name: "JWT Token", pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: "high" },
9577
- { name: "Slack Token", pattern: /xox[bpors]-[A-Za-z0-9-]{10,}/g, severity: "critical" },
9578
- { name: "Stripe Key", pattern: /(?:sk_live_|pk_live_|sk_test_|pk_test_)[A-Za-z0-9]{20,}/g, severity: "critical" },
9579
- { name: "Database URL", pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s'"]+/gi, severity: "high" },
9580
- { name: "Bearer Token", pattern: /Bearer\s+[A-Za-z0-9\-_\.]{20,}/g, severity: "medium" },
9581
- { name: "Hex Token (32+)", pattern: /(?:token|key|secret|auth)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/gi, severity: "medium" }
9583
+ // src/scan/dependencies.ts
9584
+ var import_node_fs5 = require("fs");
9585
+ var import_node_path2 = require("path");
9586
+ var LOCKFILES = [
9587
+ { file: "package-lock.json", ecosystem: "npm" },
9588
+ { file: "pnpm-lock.yaml", ecosystem: "npm" },
9589
+ { file: "yarn.lock", ecosystem: "npm" },
9590
+ { file: "requirements.txt", ecosystem: "PyPI" },
9591
+ { file: "Pipfile.lock", ecosystem: "PyPI" }
9592
+ ];
9593
+ var MAX_DEPS_PER_LOCKFILE = 50;
9594
+ async function scanDependencies(targetPath) {
9595
+ const findings = [];
9596
+ for (const { file, ecosystem } of LOCKFILES) {
9597
+ const lockPath = (0, import_node_path2.join)(targetPath, file);
9598
+ if (!(0, import_node_fs5.existsSync)(lockPath)) continue;
9599
+ let deps;
9600
+ try {
9601
+ deps = parseDependencies(lockPath, file);
9602
+ } catch {
9603
+ continue;
9604
+ }
9605
+ for (const dep of deps.slice(0, MAX_DEPS_PER_LOCKFILE)) {
9606
+ let vulns;
9607
+ try {
9608
+ vulns = await queryOsv(dep.name, dep.version, ecosystem);
9609
+ } catch {
9610
+ continue;
9611
+ }
9612
+ for (const vuln of vulns) {
9613
+ const cvss = vuln.severity?.find((entry) => entry.type === "CVSS_V3")?.score;
9614
+ findings.push({
9615
+ ruleId: "dependency-known-vulnerability",
9616
+ title: "Dependency CVE",
9617
+ file,
9618
+ line: 1,
9619
+ severity: severityFromCvss(cvss),
9620
+ confidence: "evidence",
9621
+ message: `${dep.name}@${dep.version}: ${vuln.summary ?? vuln.id}`,
9622
+ consequence: "A published advisory exists for the exact version resolved in this lockfile.",
9623
+ excerpt: `${vuln.id}${cvss ? ` (CVSS: ${cvss})` : ""}`,
9624
+ category: "dependency"
9625
+ });
9626
+ }
9627
+ }
9628
+ }
9629
+ return findings;
9630
+ }
9631
+ function severityFromCvss(score) {
9632
+ if (!score) return "medium";
9633
+ const value = Number.parseFloat(score);
9634
+ if (Number.isNaN(value)) return "medium";
9635
+ if (value >= 9) return "critical";
9636
+ if (value >= 7) return "high";
9637
+ if (value >= 4) return "medium";
9638
+ return "low";
9639
+ }
9640
+ function parseDependencies(lockPath, filename) {
9641
+ const deps = [];
9642
+ if (filename === "package-lock.json") {
9643
+ const lock = JSON.parse((0, import_node_fs5.readFileSync)(lockPath, "utf-8"));
9644
+ const packages = lock.packages ?? lock.dependencies ?? {};
9645
+ for (const [key, value] of Object.entries(packages)) {
9646
+ const name = key.replace(/^node_modules\//, "");
9647
+ const version = value?.version;
9648
+ if (name && version && !name.startsWith(".")) deps.push({ name, version });
9649
+ }
9650
+ return deps;
9651
+ }
9652
+ if (filename === "requirements.txt") {
9653
+ for (const line of (0, import_node_fs5.readFileSync)(lockPath, "utf-8").split("\n")) {
9654
+ const match = /^([a-zA-Z0-9_.-]+)==([0-9.]+)/.exec(line);
9655
+ if (match?.[1] && match[2]) deps.push({ name: match[1], version: match[2] });
9656
+ }
9657
+ }
9658
+ return deps;
9659
+ }
9660
+ function isValidPackageName(name) {
9661
+ return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
9662
+ }
9663
+ function isValidVersion(version) {
9664
+ return /^[0-9a-zA-Z._\-+]{1,50}$/.test(version);
9665
+ }
9666
+ async function queryOsv(name, version, ecosystem) {
9667
+ if (!isValidPackageName(name) || !isValidVersion(version)) return [];
9668
+ try {
9669
+ const response = await fetch("https://api.osv.dev/v1/query", {
9670
+ method: "POST",
9671
+ headers: { "Content-Type": "application/json" },
9672
+ body: JSON.stringify({ package: { name, ecosystem }, version }),
9673
+ signal: AbortSignal.timeout(5e3)
9674
+ });
9675
+ if (!response.ok) return [];
9676
+ const data = await response.json();
9677
+ return data.vulns ?? [];
9678
+ } catch {
9679
+ return [];
9680
+ }
9681
+ }
9682
+
9683
+ // src/scan/engine.ts
9684
+ var import_node_fs6 = require("fs");
9685
+ var import_node_path3 = require("path");
9686
+
9687
+ // src/scan/types.ts
9688
+ var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
9689
+ function severityRank(severity) {
9690
+ const index = SEVERITY_ORDER.indexOf(severity);
9691
+ return index === -1 ? 0 : index;
9692
+ }
9693
+ function severityFor(declared, confidence) {
9694
+ if (confidence !== "pattern") return declared;
9695
+ return severityRank(declared) > severityRank("medium") ? "medium" : declared;
9696
+ }
9697
+
9698
+ // src/scan/code-rules.ts
9699
+ var UNTRUSTED_JS = /\b(?:req|request|ctx|context)\s*\.\s*(?:body|query|params|param|headers|cookies|url|files)\b|\bprocess\.argv\b|\bwindow\.location\b|\bdocument\.location\b|\blocation\.(?:search|hash|href)\b|\bsearchParams\b|\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetInputStream\s*\(/;
9700
+ var UNTRUSTED_PY = /\brequest\b|\bparams\b|\bflask\b|\bsys\.argv\b|\bos\.environ\b\s*\[/;
9701
+ var UNTRUSTED_RB = /\bparams\s*\[|\brequest\b|\bcookies\s*\[/;
9702
+ var UNTRUSTED_GO = /\br\s*\.\s*(?:URL|Form|Body|Header|PostForm)\b|\bFormValue\s*\(|\bQuery\s*\(\s*\)\s*\.\s*Get\s*\(|\bmux\.Vars\s*\(/;
9703
+ var UNTRUSTED_JAVA = /\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetHeader\s*\(|\bgetInputStream\s*\(|\bgetCookies\s*\(|\b@RequestParam\b|\b@PathVariable\b/;
9704
+ function untrustedPatternFor(language) {
9705
+ switch (language) {
9706
+ case "python":
9707
+ return UNTRUSTED_PY;
9708
+ case "ruby":
9709
+ return UNTRUSTED_RB;
9710
+ case "go":
9711
+ return UNTRUSTED_GO;
9712
+ case "java":
9713
+ return UNTRUSTED_JAVA;
9714
+ default:
9715
+ return UNTRUSTED_JS;
9716
+ }
9717
+ }
9718
+ var GENERIC_GUARD = /\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\bescape(?:Html|Html4|Xml|Sql)?\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;
9719
+ var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
9720
+ var CODE_SINK = /\bglobalThis\s*\[|\bconstructor\b|\beval\b|\bFunction\b|\brun\s*\(|\bvm\s*\.\s*run/;
9721
+ var EXFIL_SINK = /\bconsole\s*\.\s*(?:log|debug|info|warn|error)\s*\(|\bfetch\s*\(|\baxios\b|\brequest\s*\(|\.\s*send\s*\(/;
9722
+ var SQL_KEYWORDS = "SELECT|INSERT\\s+INTO|INSERT|UPDATE|DELETE\\s+FROM|DELETE|DROP|UNION\\s+SELECT";
9723
+ var SQL_IN_DOUBLE = `"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*"`;
9724
+ var SQL_IN_SINGLE = `'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*'`;
9725
+ var SQL_STRING = `(?:${SQL_IN_DOUBLE}|${SQL_IN_SINGLE})`;
9726
+ var CODE_RULES = [
9727
+ // ── Injection: SQL ───────────────────────────────────────────────────────
9728
+ {
9729
+ id: "sql-string-concatenation",
9730
+ title: "SQL assembled by concatenation or interpolation",
9731
+ consequence: "A quote in the interpolated value changes the query\u2019s meaning \u2014 the query runs as the attacker wrote it, not as you wrote it.",
9732
+ cwe: "CWE-89",
9733
+ severity: "critical",
9734
+ // The tail is what distinguishes assembly from parameterisation. A bound
9735
+ // query leaves a comma after the closing quote (`"… = $1", [id]`) and
9736
+ // matches none of these.
9737
+ pattern: new RegExp(
9738
+ `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"[^"\\n]*|'[^'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
9739
+ "i"
9740
+ )
9741
+ },
9742
+ {
9743
+ id: "sql-template-interpolation",
9744
+ title: "SQL built from a template literal or f-string",
9745
+ consequence: "Template interpolation is string concatenation with nicer syntax \u2014 it binds nothing and escapes nothing.",
9746
+ cwe: "CWE-89",
9747
+ severity: "critical",
9748
+ pattern: new RegExp(
9749
+ `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
9750
+ "i"
9751
+ )
9752
+ },
9753
+ {
9754
+ id: "sql-format-call",
9755
+ title: "SQL text produced by a format helper",
9756
+ consequence: "`Sprintf`/`String.format` substitute without quoting; the resulting string is concatenated SQL by another name.",
9757
+ cwe: "CWE-89",
9758
+ severity: "critical",
9759
+ languages: ["go", "java"],
9760
+ pattern: new RegExp(
9761
+ `\\b(?:fmt\\.Sprintf|String\\.format)\\s*\\(\\s*"[^"\\n]*(?:${SQL_KEYWORDS})\\b`,
9762
+ "i"
9763
+ )
9764
+ },
9765
+ {
9766
+ id: "rb-sql-interpolation",
9767
+ title: "ActiveRecord query built by string interpolation",
9768
+ consequence: '`where("\u2026 #{value}")` interpolates before the adapter sees it, so no binding ever happens.',
9769
+ cwe: "CWE-89",
9770
+ severity: "critical",
9771
+ languages: ["ruby"],
9772
+ pattern: /\b(?:where|find_by_sql|execute|select_all|select_values|order|group|pluck)\s*[( ]\s*(?:"[^"\n]*|'[^'\n]*)#\{/
9773
+ },
9774
+ // ── Injection: OS command ────────────────────────────────────────────────
9775
+ {
9776
+ id: "js-shell-exec-interpolation",
9777
+ title: "shell execution with an interpolated string",
9778
+ consequence: "A `;` or `$(\u2026)` in the interpolated value runs as the server user.",
9779
+ cwe: "CWE-78",
9780
+ severity: "critical",
9781
+ languages: ["javascript", "typescript"],
9782
+ pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/
9783
+ },
9784
+ {
9785
+ id: "py-shell-command-string",
9786
+ title: "shell command built from a string",
9787
+ consequence: "`os.system` and `shell=True` hand the string to `/bin/sh`, which happily interprets metacharacters.",
9788
+ cwe: "CWE-78",
9789
+ severity: "critical",
9790
+ languages: ["python"],
9791
+ pattern: /\bos\.(?:system|popen)\s*\(\s*(?:f?['"][^'"]*['"]\s*(?:\+|%|\.\s*format)|f['"]|[a-zA-Z_]\w*\s*[,)])|\bsubprocess\.(?:run|call|check_call|check_output|Popen)\s*\([^)]*\bshell\s*=\s*True/
9792
+ },
9793
+ {
9794
+ id: "go-shell-exec-command",
9795
+ title: "exec.Command invoking a shell",
9796
+ consequence: "Passing `sh -c` re-introduces the shell that `exec.Command`\u2019s argv interface exists to avoid.",
9797
+ cwe: "CWE-78",
9798
+ severity: "critical",
9799
+ languages: ["go"],
9800
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
9801
+ },
9802
+ {
9803
+ id: "rb-backtick-interpolation",
9804
+ title: "backtick command with interpolation",
9805
+ consequence: "Ruby backticks are a shell invocation; `#{}` inside one is command injection.",
9806
+ cwe: "CWE-78",
9807
+ severity: "critical",
9808
+ languages: ["ruby"],
9809
+ pattern: /`[^`\n]*#\{|\bsystem\s*\(\s*["'][^"'\n]*#\{|%x\[[^\]]*#\{/
9810
+ },
9811
+ // ── Injection: dynamic code ──────────────────────────────────────────────
9812
+ {
9813
+ id: "js-dynamic-code-execution",
9814
+ title: "dynamic code execution",
9815
+ consequence: "Any string reaching this call executes as code with the process\u2019 privileges.",
9816
+ cwe: "CWE-95",
9817
+ severity: "critical",
9818
+ languages: ["javascript", "typescript"],
9819
+ pattern: /\beval\s*\(|\bnew\s+Function\s*\(|\bvm\s*\.\s*run(?:InThisContext|InNewContext|InContext)\s*\(|\bset(?:Timeout|Interval)\s*\(\s*(?:['"`]|(?:req|request|ctx|params|query|body)\b)/
9820
+ },
9821
+ {
9822
+ id: "js-indirect-code-sink",
9823
+ title: "code sink reached indirectly",
9824
+ consequence: "Resolving `eval`/`Function` through `globalThis[\u2026]` or `.constructor` hides the sink from literal matching. Legitimate code has no reason to.",
9825
+ cwe: "CWE-506",
9826
+ severity: "high",
9827
+ languages: ["javascript", "typescript"],
9828
+ pattern: /\bglobalThis\s*\[\s*[a-zA-Z_$][\w$]*\s*\]|\(\s*function\s*\(\s*\)\s*\{\s*\}\s*\)\s*\.\s*constructor/
9829
+ },
9830
+ {
9831
+ id: "js-encoded-payload-execution",
9832
+ title: "encoded blob decoded next to a code sink",
9833
+ consequence: "A base64 literal that is decoded and executed is the standard shape of a planted backdoor; the encoding exists to defeat review.",
9834
+ cwe: "CWE-506",
9835
+ severity: "critical",
9836
+ languages: ["javascript", "typescript"],
9837
+ pattern: /\bBuffer\.from\s*\(\s*[\w.$]+\s*,\s*['"]base64['"]\s*\)|\batob\s*\(\s*[\w.$]+\s*\)/,
9838
+ requires: CODE_SINK,
9839
+ guardBack: 6,
9840
+ guardForward: 3
9841
+ },
9842
+ {
9843
+ id: "py-dynamic-code-execution",
9844
+ title: "dynamic code execution",
9845
+ consequence: "Any string reaching this call executes as Python with the process\u2019 privileges.",
9846
+ cwe: "CWE-95",
9847
+ severity: "critical",
9848
+ languages: ["python"],
9849
+ pattern: /\b(?:eval|exec)\s*\(\s*(?!['"]\s*\))[a-zA-Z_(f'"]/,
9850
+ needsContext: true
9851
+ },
9852
+ {
9853
+ id: "rb-dynamic-dispatch",
9854
+ title: "dynamic code execution or unrestricted #send",
9855
+ consequence: "`eval` runs arbitrary Ruby; unrestricted `#send` lets the caller invoke any method on the receiver, including private ones.",
9856
+ cwe: "CWE-95",
9857
+ severity: "critical",
9858
+ languages: ["ruby"],
9859
+ pattern: /\beval\s*\(|\binstance_eval\s*\(|\bclass_eval\s*\(|\.\s*send\s*\(\s*(?:params|request|args)\b/
9860
+ },
9861
+ // ── Cross-site scripting ─────────────────────────────────────────────────
9862
+ {
9863
+ id: "js-unescaped-html-sink",
9864
+ title: "unescaped HTML rendering",
9865
+ consequence: "A script tag in the value executes in the victim\u2019s session \u2014 stored or reflected XSS.",
9866
+ cwe: "CWE-79",
9867
+ severity: "high",
9868
+ languages: ["javascript", "typescript"],
9869
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/
9870
+ },
9871
+ {
9872
+ id: "java-html-writer-concatenation",
9873
+ title: "HTML written to the response by concatenation",
9874
+ consequence: "The servlet writer performs no encoding; a value concatenated into markup is rendered as markup.",
9875
+ cwe: "CWE-79",
9876
+ severity: "high",
9877
+ languages: ["java"],
9878
+ pattern: /\b(?:println|print|write)\s*\(\s*"[^"\n]*<[^"\n]*"\s*\+/
9879
+ },
9880
+ {
9881
+ id: "rb-unescaped-output",
9882
+ title: "Rails output escaping bypassed",
9883
+ consequence: "`html_safe` and `raw` tell Rails the string is already safe. If it came from a parameter, it is not.",
9884
+ cwe: "CWE-79",
9885
+ severity: "high",
9886
+ languages: ["ruby"],
9887
+ pattern: /\.\s*html_safe\b|\braw\s*\(\s*(?:params|request|@)|\blink_to\s+[^,\n]+,\s*params\s*\[/
9888
+ },
9889
+ {
9890
+ id: "py-template-autoescape-off",
9891
+ title: "template rendering with escaping disabled",
9892
+ consequence: "With autoescape off \u2014 or a `|safe` filter \u2014 every interpolated value is rendered as markup.",
9893
+ cwe: "CWE-79",
9894
+ severity: "high",
9895
+ languages: ["python"],
9896
+ pattern: /\bEnvironment\s*\([^)]*\bautoescape\s*=\s*False|\|\s*safe\b|\bMarkup\s*\(\s*(?!['"])/
9897
+ },
9898
+ {
9899
+ id: "py-template-from-input",
9900
+ title: "template compiled from a non-literal source",
9901
+ consequence: "Server-side template injection. In Jinja2 the sandbox is escapable, so this escalates from XSS to remote code execution.",
9902
+ cwe: "CWE-1336",
9903
+ severity: "critical",
9904
+ languages: ["python"],
9905
+ pattern: /\bTemplate\s*\(\s*(?!['"])[a-zA-Z_]/,
9906
+ needsContext: true
9907
+ },
9908
+ // ── Server-side request forgery ──────────────────────────────────────────
9909
+ {
9910
+ id: "js-ssrf-outbound-request",
9911
+ title: "outbound request to a non-constant URL",
9912
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
9913
+ cwe: "CWE-918",
9914
+ severity: "high",
9915
+ languages: ["javascript", "typescript"],
9916
+ pattern: /\bfetch\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]|\bhttps?\s*\.\s*(?:get|request)\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]|\baxios\s*\.\s*get\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]/,
9917
+ needsContext: true
9918
+ },
9919
+ {
9920
+ id: "py-ssrf-outbound-request",
9921
+ title: "outbound request to a non-constant URL",
9922
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
9923
+ cwe: "CWE-918",
9924
+ severity: "high",
9925
+ languages: ["python"],
9926
+ pattern: /\brequests\.(?:get|request|head)\s*\(\s*[a-zA-Z_]\w*\s*[,)]|\burlopen\s*\(\s*[a-zA-Z_]\w*\s*[,)]|\bhttpx\.get\s*\(\s*[a-zA-Z_]\w*\s*[,)]/,
9927
+ needsContext: true
9928
+ },
9929
+ {
9930
+ id: "go-ssrf-outbound-request",
9931
+ title: "outbound request to a non-constant URL",
9932
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
9933
+ cwe: "CWE-918",
9934
+ severity: "high",
9935
+ languages: ["go"],
9936
+ pattern: /\bhttp\.(?:Get|Post|Head)\s*\(\s*(?:[a-zA-Z_]\w*\s*[,)]|"[^"]*"\s*\+)/,
9937
+ needsContext: true
9938
+ },
9939
+ // ── Open redirect ────────────────────────────────────────────────────────
9940
+ {
9941
+ id: "js-open-redirect",
9942
+ title: "redirect to a non-constant destination",
9943
+ consequence: "Your domain becomes the credible first hop of a phishing chain; the victim sees your hostname in the link they clicked.",
9944
+ cwe: "CWE-601",
9945
+ severity: "medium",
9946
+ languages: ["javascript", "typescript"],
9947
+ pattern: /\b(?:res|response)\s*\.\s*redirect\s*\(\s*[a-zA-Z_$][\w$]*\s*\)|\bwindow\s*\.\s*location(?:\s*\.\s*(?:href|replace))?\s*(?:=\s*[a-zA-Z_$]|\(\s*[a-zA-Z_$][\w$]*\s*\))/,
9948
+ needsContext: true
9949
+ },
9950
+ // ── Deserialisation ──────────────────────────────────────────────────────
9951
+ {
9952
+ id: "py-unsafe-deserialization",
9953
+ title: "deserialisation of untrusted data",
9954
+ consequence: "`pickle` and `yaml.load` instantiate arbitrary types during parsing \u2014 a crafted payload is remote code execution, not a parse error.",
9955
+ cwe: "CWE-502",
9956
+ severity: "critical",
9957
+ languages: ["python"],
9958
+ pattern: /\bpickle\.loads?\s*\(|\bcPickle\.loads?\s*\(|\bmarshal\.loads\s*\(|\byaml\.load\s*\(|\bjsonpickle\.decode\s*\(/
9959
+ },
9960
+ {
9961
+ id: "java-unsafe-deserialization",
9962
+ title: "Java deserialisation without a class filter",
9963
+ consequence: "A gadget chain in the classpath turns `readObject()` on attacker bytes into remote code execution.",
9964
+ cwe: "CWE-502",
9965
+ severity: "critical",
9966
+ languages: ["java"],
9967
+ pattern: /\breadObject\s*\(\s*\)|\bnew\s+ObjectInputStream\s*\(/,
9968
+ guardBack: 8,
9969
+ // The stream is constructed, *then* filtered. Without a forward window the
9970
+ // guarded case matches on its constructor line and reports a correct
9971
+ // implementation as a finding.
9972
+ guardForward: 6
9973
+ },
9974
+ {
9975
+ id: "js-unsafe-yaml-load",
9976
+ title: "YAML parsed with type resolution enabled",
9977
+ consequence: "A crafted document can instantiate arbitrary types during parsing.",
9978
+ cwe: "CWE-502",
9979
+ severity: "high",
9980
+ languages: ["javascript", "typescript"],
9981
+ pattern: /\byaml\s*\.\s*load\s*\((?![^)]*safe)|\bloadAll\s*\([^)]*unsafe/i
9982
+ },
9983
+ // ── XML external entities ────────────────────────────────────────────────
9984
+ {
9985
+ id: "java-xxe-parser-defaults",
9986
+ title: "XML parser left on its insecure defaults",
9987
+ consequence: "External entity expansion reads local files and makes outbound requests on the parser\u2019s behalf \u2014 file disclosure and SSRF from a document.",
9988
+ cwe: "CWE-611",
9989
+ severity: "high",
9990
+ languages: ["java"],
9991
+ pattern: /\b(?:DocumentBuilderFactory|SAXParserFactory|XMLInputFactory|TransformerFactory|SchemaFactory)\s*\.\s*newInstance\s*\(\s*\)/,
9992
+ guard: XXE_GUARD,
9993
+ guardBack: 4,
9994
+ guardForward: 8
9995
+ },
9996
+ {
9997
+ id: "java-xxe-parse-call",
9998
+ title: "XML parsed by a builder that was never hardened",
9999
+ consequence: "The expansion happens at `parse()`. Flagging only the factory misses the line where the document is actually read.",
10000
+ cwe: "CWE-611",
10001
+ severity: "high",
10002
+ languages: ["java"],
10003
+ // Receiver-qualified so `LocalDate.parse(s)` and friends stay out of it.
10004
+ pattern: /\b\w*(?:[Bb]uilder|[Pp]arser|[Rr]eader)\s*\.\s*parse\s*\(/,
10005
+ guard: XXE_GUARD,
10006
+ guardBack: 6,
10007
+ guardForward: 4
10008
+ },
10009
+ // ── Path traversal ───────────────────────────────────────────────────────
10010
+ {
10011
+ id: "py-path-traversal",
10012
+ title: "file opened at a path built from input",
10013
+ consequence: "A `../` sequence \u2014 or an absolute path \u2014 reads or writes outside the intended directory.",
10014
+ cwe: "CWE-22",
10015
+ severity: "high",
10016
+ languages: ["python"],
10017
+ pattern: /\bopen\s*\(\s*(?:os\.path\.join\s*\(|[a-zA-Z_]\w*\s*\+|f['"])/,
10018
+ needsContext: true
10019
+ },
10020
+ {
10021
+ id: "js-path-traversal",
10022
+ title: "file path built from a variable",
10023
+ consequence: "A `../` sequence in the value reads or writes outside the intended directory.",
10024
+ cwe: "CWE-22",
10025
+ severity: "medium",
10026
+ languages: ["javascript", "typescript"],
10027
+ pattern: /\b(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|sendFile)\s*\(\s*(?:`[^`]*\$\{|[a-zA-Z_$][\w$]*\s*\+|path\.join\s*\([^)]*(?:req|request)\b)/,
10028
+ needsContext: true
10029
+ },
10030
+ // ── Cryptography, tokens, randomness ─────────────────────────────────────
10031
+ {
10032
+ id: "js-jwt-decode-without-verify",
10033
+ title: "JWT decoded without verifying the signature",
10034
+ consequence: "`decode` parses the claims and checks nothing. Anyone can mint a token with any `sub` and any `role`.",
10035
+ cwe: "CWE-347",
10036
+ severity: "critical",
10037
+ languages: ["javascript", "typescript"],
10038
+ pattern: /\bjwt\s*\.\s*decode\s*\(|\bjsonwebtoken\s*\.\s*decode\s*\(|\bdecodeJwt\s*\(/
10039
+ },
10040
+ {
10041
+ id: "tls-verification-disabled",
10042
+ title: "TLS certificate verification disabled",
10043
+ consequence: "Every connection made this way is trivially interceptable; the encryption is decorative.",
10044
+ cwe: "CWE-295",
10045
+ severity: "high",
10046
+ pattern: /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*[=:]\s*['"]?0|strictSSL\s*:\s*false|\bverify\s*=\s*False\b|InsecureSkipVerify\s*:\s*true/
10047
+ },
10048
+ {
10049
+ id: "weak-hash-on-credential",
10050
+ title: "broken hash used on a credential",
10051
+ consequence: "MD5 and SHA-1 are fast and collision-prone; hashed passwords are recoverable.",
10052
+ cwe: "CWE-327",
10053
+ severity: "high",
10054
+ pattern: /(?:createHash|hashlib|MessageDigest\.getInstance|Digest::)\s*[.(]?\s*['"]?(?:md5|MD5|sha1|SHA-?1)['"]?\s*\)?[\s\S]{0,80}(?:password|passwd|secret|token|credential)/i
10055
+ },
10056
+ {
10057
+ id: "insecure-randomness-for-secret",
10058
+ title: "predictable randomness used for a security value",
10059
+ consequence: "`Math.random`/`random.random` are predictable; tokens, session ids and reset codes built from them are guessable.",
10060
+ cwe: "CWE-338",
10061
+ severity: "high",
10062
+ pattern: /(?:token|secret|password|salt|nonce|session|otp|reset|apikey|api_key)[\w]*\s*[:=][^;\n]{0,60}(?:Math\s*\.\s*random\s*\(|\brandom\s*\.\s*(?:random|randint|choice)\s*\(|\brand\s*\()/i
10063
+ },
10064
+ {
10065
+ id: "redos-nested-quantifier",
10066
+ title: "regex with nested unbounded quantifiers",
10067
+ consequence: "Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.",
10068
+ cwe: "CWE-1333",
10069
+ severity: "medium",
10070
+ pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
10071
+ },
10072
+ // ── Temporary files ──────────────────────────────────────────────────────
10073
+ {
10074
+ id: "insecure-temp-file",
10075
+ title: "predictable temporary file path",
10076
+ consequence: "A predictable name in a world-writable directory is a symlink attack: an attacker pre-creates the path and your process writes through it.",
10077
+ cwe: "CWE-377",
10078
+ severity: "medium",
10079
+ // A hardcoded path under /tmp is the finding whether or not it is
10080
+ // formatted: `"/tmp/application.log.tmp"` is worse than the PID-based one,
10081
+ // because every process on the host can predict it exactly.
10082
+ pattern: /\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|['"]\/tmp\/[^'"\n]+['"]|['"]\/tmp\/[^'"\n]*\{|\bFile\.createTempFile\s*\(/
10083
+ },
10084
+ // ── Information exposure ─────────────────────────────────────────────────
10085
+ {
10086
+ id: "py-stack-trace-returned",
10087
+ title: "stack trace returned to the caller",
10088
+ consequence: "Tracebacks leak absolute paths, dependency versions and source fragments \u2014 the reconnaissance an attacker would otherwise have to guess at.",
10089
+ cwe: "CWE-209",
10090
+ severity: "medium",
10091
+ languages: ["python"],
10092
+ pattern: /\breturn\b[^\n]*\btraceback\.(?:format_exc|format_exception|print_exc)\s*\(|\breturn\b[^\n]*\bstr\s*\(\s*e\s*\)/
10093
+ },
10094
+ {
10095
+ id: "js-environment-exfiltration",
10096
+ title: "process environment serialised into a payload",
10097
+ consequence: "The environment is where every secret lives. Serialising it whole into a request body is credential exfiltration regardless of the endpoint.",
10098
+ cwe: "CWE-532",
10099
+ severity: "critical",
10100
+ languages: ["javascript", "typescript"],
10101
+ pattern: /\bJSON\.stringify\s*\(\s*\{?[^)]*\bprocess\.env\b(?!\s*\.)/,
10102
+ guard: false
10103
+ },
10104
+ {
10105
+ id: "js-credential-logged",
10106
+ title: "credential read from the environment into a log sink",
10107
+ consequence: "CI retains job logs, and on public forks it publishes them. A token printed once is a token leaked permanently.",
10108
+ cwe: "CWE-532",
10109
+ severity: "high",
10110
+ languages: ["javascript", "typescript"],
10111
+ pattern: /\b(?:token|apiKey|api_key|secret|password|credential|auth)\w*\s*:\s*process\.env\.\w+/i,
10112
+ requires: EXFIL_SINK,
10113
+ guard: false,
10114
+ guardBack: 4,
10115
+ guardForward: 1
10116
+ },
10117
+ // ── Prototype pollution ──────────────────────────────────────────────────
10118
+ {
10119
+ id: "js-prototype-pollution",
10120
+ title: "write to a prototype-reachable key",
10121
+ consequence: "An attacker-supplied `__proto__` key changes behaviour for every object in the process, including ones it never touched.",
10122
+ cwe: "CWE-1321",
10123
+ severity: "high",
10124
+ languages: ["javascript", "typescript"],
10125
+ pattern: /\[\s*['"]__proto__['"]\s*\]|\bObject\s*\.\s*assign\s*\(\s*[\w.$]*\.prototype\b|\.\s*__proto__\s*=/
10126
+ }
10127
+ ];
10128
+ var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
10129
+ var DEFINITION_PREFIX = /^\s*(?:(?:export|public|private|protected|static|final|async|abstract)\s+)*(?:def|function|func|class|module|interface|struct|impl|fn|sub)\b/;
10130
+ function isComment(line) {
10131
+ return COMMENT_PREFIX.test(line);
10132
+ }
10133
+ function proseLines(lines) {
10134
+ const inside = /* @__PURE__ */ new Set();
10135
+ let delimiter = null;
10136
+ lines.forEach((line, index) => {
10137
+ if (delimiter) {
10138
+ inside.add(index);
10139
+ if (line.includes(delimiter)) delimiter = null;
10140
+ return;
10141
+ }
10142
+ for (const candidate of ['"""', "'''"]) {
10143
+ const start = line.indexOf(candidate);
10144
+ if (start === -1) continue;
10145
+ if (line.indexOf(candidate, start + candidate.length) !== -1) return;
10146
+ delimiter = candidate;
10147
+ inside.add(index);
10148
+ return;
10149
+ }
10150
+ });
10151
+ return inside;
10152
+ }
10153
+ function skippable(line, index, prose) {
10154
+ return isComment(line) || DEFINITION_PREFIX.test(line) || (prose?.has(index) ?? false);
10155
+ }
10156
+ function windowText(lines, index, back, forward, prose) {
10157
+ const from = Math.max(0, index - back);
10158
+ const to = Math.min(lines.length - 1, index + forward);
10159
+ const collected = [];
10160
+ for (let i = from; i <= to; i += 1) {
10161
+ const line = lines[i] ?? "";
10162
+ if (i !== index && skippable(line, i, prose)) continue;
10163
+ collected.push(line);
10164
+ }
10165
+ return collected.join("\n");
10166
+ }
10167
+ function evaluateRule(rule, ctx) {
10168
+ if (rule.languages && !rule.languages.includes(ctx.language)) return null;
10169
+ const line = ctx.lines[ctx.index] ?? "";
10170
+ if (isComment(line) || ctx.prose?.has(ctx.index)) return null;
10171
+ if (!rule.pattern.test(line)) return null;
10172
+ const back = rule.guardBack ?? 8;
10173
+ const forward = rule.guardForward ?? 0;
10174
+ const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
10175
+ if (rule.requires && !rule.requires.test(context)) return null;
10176
+ const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
10177
+ if (guard && (guard.test(line) || guard.test(context))) return null;
10178
+ const untrusted = untrustedPatternFor(ctx.language);
10179
+ const contextual = untrusted.test(line) || untrusted.test(context);
10180
+ if (rule.needsContext && !contextual) return null;
10181
+ const confidence = contextual ? "contextual" : "pattern";
10182
+ return { rule, confidence, severity: severityFor(rule.severity, confidence) };
10183
+ }
10184
+
10185
+ // src/scan/manifest-rules.ts
10186
+ var POPULAR_NPM = [
10187
+ "react",
10188
+ "react-dom",
10189
+ "lodash",
10190
+ "express",
10191
+ "axios",
10192
+ "chalk",
10193
+ "commander",
10194
+ "debug",
10195
+ "moment",
10196
+ "dayjs",
10197
+ "uuid",
10198
+ "dotenv",
10199
+ "typescript",
10200
+ "webpack",
10201
+ "vite",
10202
+ "rollup",
10203
+ "eslint",
10204
+ "prettier",
10205
+ "jest",
10206
+ "vitest",
10207
+ "mocha",
10208
+ "chai",
10209
+ "sinon",
10210
+ "request",
10211
+ "node-fetch",
10212
+ "cross-env",
10213
+ "rimraf",
10214
+ "glob",
10215
+ "minimist",
10216
+ "yargs",
10217
+ "inquirer",
10218
+ "colors",
10219
+ "ora",
10220
+ "semver",
10221
+ "ws",
10222
+ "socket.io",
10223
+ "mongoose",
10224
+ "sequelize",
10225
+ "knex",
10226
+ "pg",
10227
+ "mysql",
10228
+ "mysql2",
10229
+ "redis",
10230
+ "ioredis",
10231
+ "jsonwebtoken",
10232
+ "bcrypt",
10233
+ "passport",
10234
+ "cors",
10235
+ "helmet",
10236
+ "morgan",
10237
+ "body-parser",
10238
+ "multer",
10239
+ "nodemailer",
10240
+ "puppeteer",
10241
+ "playwright",
10242
+ "cheerio",
10243
+ "sharp",
10244
+ "canvas",
10245
+ "esbuild",
10246
+ "babel",
10247
+ "postcss",
10248
+ "tailwindcss",
10249
+ "next",
10250
+ "nuxt",
10251
+ "vue",
10252
+ "svelte",
10253
+ "angular",
10254
+ "rxjs",
10255
+ "zod"
10256
+ ];
10257
+ var POPULAR_PYPI = [
10258
+ "requests",
10259
+ "urllib3",
10260
+ "numpy",
10261
+ "pandas",
10262
+ "scipy",
10263
+ "flask",
10264
+ "django",
10265
+ "fastapi",
10266
+ "sqlalchemy",
10267
+ "pydantic",
10268
+ "click",
10269
+ "jinja2",
10270
+ "pyyaml",
10271
+ "boto3",
10272
+ "botocore",
10273
+ "setuptools",
10274
+ "wheel",
10275
+ "pip",
10276
+ "six",
10277
+ "certifi",
10278
+ "idna",
10279
+ "chardet",
10280
+ "attrs",
10281
+ "python-dateutil",
10282
+ "pytz",
10283
+ "pytest",
10284
+ "tox",
10285
+ "black",
10286
+ "flake8",
10287
+ "mypy",
10288
+ "isort",
10289
+ "beautifulsoup4",
10290
+ "lxml",
10291
+ "pillow",
10292
+ "matplotlib",
10293
+ "seaborn",
10294
+ "scikit-learn",
10295
+ "tensorflow",
10296
+ "torch",
10297
+ "transformers",
10298
+ "openai",
10299
+ "anthropic",
10300
+ "httpx",
10301
+ "aiohttp",
10302
+ "celery",
10303
+ "redis",
10304
+ "psycopg2",
10305
+ "pymongo",
10306
+ "cryptography",
10307
+ "paramiko",
10308
+ "colorama"
10309
+ ];
10310
+ var INTERNAL_MARKER = /(?:^|[-_/@])(?:internal|private|corp|intranet|inhouse|confidential)(?:$|[-_/])/i;
10311
+ function normalizeName(name) {
10312
+ return name.toLowerCase().replace(/^@/, "").replace(/[-_.\s]/g, "");
10313
+ }
10314
+ function editDistance(a, b, cap = 3) {
10315
+ if (a === b) return 0;
10316
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
10317
+ const rows = [];
10318
+ for (let i = 0; i <= a.length; i += 1) {
10319
+ rows.push(new Array(b.length + 1).fill(0));
10320
+ rows[i][0] = i;
10321
+ }
10322
+ for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
10323
+ for (let i = 1; i <= a.length; i += 1) {
10324
+ for (let j = 1; j <= b.length; j += 1) {
10325
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
10326
+ let best = Math.min(
10327
+ rows[i - 1][j] + 1,
10328
+ rows[i][j - 1] + 1,
10329
+ rows[i - 1][j - 1] + cost
10330
+ );
10331
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
10332
+ best = Math.min(best, rows[i - 2][j - 2] + 1);
10333
+ }
10334
+ rows[i][j] = best;
10335
+ }
10336
+ }
10337
+ return rows[a.length][b.length];
10338
+ }
10339
+ function detectTyposquat(name, ecosystem) {
10340
+ const popular = ecosystem === "npm" ? POPULAR_NPM : POPULAR_PYPI;
10341
+ const lower = name.toLowerCase().replace(/^@[^/]+\//, "");
10342
+ if (popular.includes(lower)) return null;
10343
+ if (lower.length < 4) return null;
10344
+ const normalized = normalizeName(lower);
10345
+ for (const candidate of popular) {
10346
+ const candidateNormalized = normalizeName(candidate);
10347
+ if (normalized === candidateNormalized) return { impersonates: candidate, kind: "separator" };
10348
+ if (editDistance(normalized, candidateNormalized, 1) === 1) {
10349
+ return { impersonates: candidate, kind: "edit" };
10350
+ }
10351
+ }
10352
+ return null;
10353
+ }
10354
+ var LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall", "prepare", "prepublish"];
10355
+ function scanPackageJson(text) {
10356
+ const findings = [];
10357
+ const lines = text.split("\n");
10358
+ let parsed;
10359
+ try {
10360
+ parsed = JSON.parse(text);
10361
+ } catch {
10362
+ return findings;
10363
+ }
10364
+ const lineOf = (needle) => {
10365
+ const index = lines.findIndex((line) => line.includes(`"${needle}"`));
10366
+ return index === -1 ? 1 : index + 1;
10367
+ };
10368
+ const depBuckets = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
10369
+ for (const bucket of depBuckets) {
10370
+ const deps = parsed[bucket];
10371
+ if (!deps || typeof deps !== "object") continue;
10372
+ for (const name of Object.keys(deps)) {
10373
+ const line = lineOf(name);
10374
+ if (INTERNAL_MARKER.test(name)) {
10375
+ findings.push({
10376
+ ruleId: "manifest-dependency-confusion",
10377
+ title: "internal-looking package resolved from a public registry",
10378
+ line,
10379
+ severity: "critical",
10380
+ cwe: "CWE-1357",
10381
+ message: `"${name}" names itself as internal but carries no registry pin`,
10382
+ consequence: "Whoever registers this name publicly first wins the resolution, and their code runs in your build.",
10383
+ excerpt: (lines[line - 1] ?? "").trim()
10384
+ });
10385
+ continue;
10386
+ }
10387
+ const squat = detectTyposquat(name, "npm");
10388
+ if (squat) {
10389
+ findings.push({
10390
+ ruleId: "manifest-typosquat",
10391
+ title: "dependency name close to a popular package",
10392
+ line,
10393
+ severity: "high",
10394
+ cwe: "CWE-1357",
10395
+ message: squat.kind === "separator" ? `"${name}" differs from "${squat.impersonates}" only in punctuation` : `"${name}" is one edit from "${squat.impersonates}"`,
10396
+ consequence: "A reviewer scanning a diff reads the name they expected. The package that installs is the one that was written.",
10397
+ excerpt: (lines[line - 1] ?? "").trim()
10398
+ });
10399
+ }
10400
+ }
10401
+ }
10402
+ const scripts = parsed.scripts;
10403
+ if (scripts && typeof scripts === "object") {
10404
+ for (const [name, body] of Object.entries(scripts)) {
10405
+ if (!LIFECYCLE_SCRIPTS.includes(name)) continue;
10406
+ findings.push({
10407
+ ruleId: "manifest-install-lifecycle-script",
10408
+ title: "install-time lifecycle script",
10409
+ line: lineOf(name),
10410
+ severity: "medium",
10411
+ cwe: "CWE-506",
10412
+ message: `"${name}" runs automatically on install: ${String(body).slice(0, 120)}`,
10413
+ consequence: "Lifecycle scripts run with the installing user\u2019s privileges and network access, before any code is reviewed. It is the execution vector every notable npm compromise has used.",
10414
+ excerpt: (lines[lineOf(name) - 1] ?? "").trim()
10415
+ });
10416
+ }
10417
+ }
10418
+ return findings;
10419
+ }
10420
+ function scanRequirementsTxt(text) {
10421
+ const findings = [];
10422
+ const lines = text.split("\n");
10423
+ lines.forEach((raw, index) => {
10424
+ const line = raw.trim();
10425
+ if (!line || line.startsWith("#") || line.startsWith("-")) return;
10426
+ const match = /^([A-Za-z0-9_.-]+)\s*(?:[=<>!~]=|@|$)/.exec(line);
10427
+ const name = match?.[1];
10428
+ if (!name) return;
10429
+ if (INTERNAL_MARKER.test(name)) {
10430
+ findings.push({
10431
+ ruleId: "manifest-dependency-confusion",
10432
+ title: "internal-looking package resolved from a public index",
10433
+ line: index + 1,
10434
+ severity: "critical",
10435
+ cwe: "CWE-1357",
10436
+ message: `"${name}" names itself as internal but carries no index pin`,
10437
+ consequence: "pip resolves the highest version across every configured index, so a public package of the same name shadows the private one.",
10438
+ excerpt: line
10439
+ });
10440
+ return;
10441
+ }
10442
+ const squat = detectTyposquat(name, "pypi");
10443
+ if (squat) {
10444
+ findings.push({
10445
+ ruleId: "manifest-typosquat",
10446
+ title: "dependency name close to a popular package",
10447
+ line: index + 1,
10448
+ severity: "high",
10449
+ cwe: "CWE-1357",
10450
+ message: squat.kind === "separator" ? `"${name}" differs from "${squat.impersonates}" only in punctuation` : `"${name}" is one edit from "${squat.impersonates}"`,
10451
+ consequence: "A reviewer scanning a diff reads the name they expected. The package that installs is the one that was written.",
10452
+ excerpt: line
10453
+ });
10454
+ }
10455
+ });
10456
+ return findings;
10457
+ }
10458
+
10459
+ // src/scan/secret-rules.ts
10460
+ var SECRET_RULES = [
10461
+ {
10462
+ id: "secret-aws-access-key",
10463
+ name: "AWS Access Key",
10464
+ pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/,
10465
+ severity: "critical",
10466
+ cwe: "CWE-798",
10467
+ consequence: "Paired with a secret key, grants the API access of whatever IAM principal issued it."
10468
+ },
10469
+ {
10470
+ id: "secret-aws-secret-key",
10471
+ name: "AWS Secret Access Key",
10472
+ pattern: /(?:aws_secret_access_key|AWS_SECRET(?:_ACCESS_KEY)?)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/i,
10473
+ severity: "critical",
10474
+ cwe: "CWE-798",
10475
+ consequence: "The other half of an AWS credential pair; on its own it is still the hard half to guess."
10476
+ },
10477
+ {
10478
+ id: "secret-github-token",
10479
+ name: "GitHub Token",
10480
+ pattern: /\b(?:ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|ghu_[A-Za-z0-9]{36}|ghs_[A-Za-z0-9]{36}|ghr_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,})\b/,
10481
+ severity: "critical",
10482
+ cwe: "CWE-798",
10483
+ consequence: "Repository read or write as the issuing account, including the ability to push workflow changes."
10484
+ },
10485
+ {
10486
+ id: "secret-npm-token",
10487
+ name: "npm Token",
10488
+ pattern: /\bnpm_[A-Za-z0-9]{36}\b/,
10489
+ severity: "critical",
10490
+ cwe: "CWE-798",
10491
+ consequence: "Publish rights to every package the account owns \u2014 a supply-chain compromise in one command."
10492
+ },
10493
+ {
10494
+ id: "secret-private-key",
10495
+ name: "Private Key",
10496
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/,
10497
+ severity: "critical",
10498
+ cwe: "CWE-798",
10499
+ consequence: "Key material, committed. Rotation is the only remediation."
10500
+ },
10501
+ {
10502
+ id: "secret-slack-token",
10503
+ name: "Slack Token",
10504
+ pattern: /\bxox[bpoasr]-[A-Za-z0-9-]{10,}/,
10505
+ severity: "critical",
10506
+ cwe: "CWE-798",
10507
+ consequence: "Read and post access to the workspace as the installing app."
10508
+ },
10509
+ {
10510
+ id: "secret-slack-webhook",
10511
+ name: "Slack Webhook URL",
10512
+ pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_+\/-]{6,}/,
10513
+ severity: "high",
10514
+ cwe: "CWE-798",
10515
+ consequence: "The URL *is* the credential \u2014 anyone holding it can post to that channel."
10516
+ },
10517
+ {
10518
+ id: "secret-stripe-key",
10519
+ name: "Stripe Key",
10520
+ pattern: /\b(?:sk_live_|rk_live_|sk_test_|rk_test_)[A-Za-z0-9]{20,}\b/,
10521
+ severity: "critical",
10522
+ cwe: "CWE-798",
10523
+ consequence: "Charge, refund and customer-data access against the account."
10524
+ },
10525
+ {
10526
+ id: "secret-sendgrid-key",
10527
+ name: "SendGrid API Key",
10528
+ pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/,
10529
+ severity: "critical",
10530
+ cwe: "CWE-798",
10531
+ consequence: "Send mail as the domain \u2014 the credential behind most convincing phishing from a real sender."
10532
+ },
10533
+ {
10534
+ id: "secret-google-api-key",
10535
+ name: "Google API Key",
10536
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/,
10537
+ severity: "high",
10538
+ cwe: "CWE-798",
10539
+ consequence: "Quota theft at minimum; API access to whatever the key was scoped to at worst."
10540
+ },
10541
+ {
10542
+ id: "secret-openai-key",
10543
+ name: "OpenAI API Key",
10544
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/,
10545
+ severity: "critical",
10546
+ cwe: "CWE-798",
10547
+ consequence: "Billed inference against the owner\u2019s account, with no per-key spend limit by default."
10548
+ },
10549
+ {
10550
+ id: "secret-anthropic-key",
10551
+ name: "Anthropic API Key",
10552
+ pattern: /\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{32,}\b/,
10553
+ severity: "critical",
10554
+ cwe: "CWE-798",
10555
+ consequence: "Billed inference against the owner\u2019s account."
10556
+ },
10557
+ {
10558
+ id: "secret-database-url",
10559
+ name: "Database URL with credentials",
10560
+ // Requires a credential segment before the `@` — `postgres://localhost/db`
10561
+ // is a hostname, not a secret.
10562
+ pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|clickhouse):\/\/[^\s'"@\/]*:[^\s'"@\/]*@[^\s'"]+/i,
10563
+ severity: "high",
10564
+ cwe: "CWE-798",
10565
+ consequence: "Direct database access, usually bypassing every application-level authorisation check."
10566
+ },
10567
+ {
10568
+ id: "secret-jwt",
10569
+ name: "JSON Web Token",
10570
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_.+/=-]*/,
10571
+ severity: "medium",
10572
+ cwe: "CWE-798",
10573
+ consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived."
10574
+ },
10575
+ {
10576
+ id: "secret-generic-api-key",
10577
+ name: "Generic API Key",
10578
+ // Quoted assignment only. An unquoted value in a `.env` is covered by the
10579
+ // vendor-prefixed rules above; matching it here is what starts flagging
10580
+ // ARNs and parameter-store paths.
10581
+ pattern: /(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9\-_.]{20,})['"]/i,
10582
+ severity: "high",
10583
+ cwe: "CWE-798",
10584
+ consequence: "Whatever the third-party service lets the key do, for as long as it stays valid."
10585
+ },
10586
+ {
10587
+ id: "secret-generic-credential",
10588
+ name: "Hardcoded Credential",
10589
+ pattern: /(?:secret|password|passwd|pwd|token)\s*[=:]\s*['"]([^'"\s]{8,})['"]/i,
10590
+ severity: "high",
10591
+ cwe: "CWE-798",
10592
+ consequence: "A password in source is a password in every clone, fork and CI cache of that source."
10593
+ },
10594
+ {
10595
+ id: "secret-hex-token",
10596
+ name: "High-entropy Hex Token",
10597
+ pattern: /(?:token|key|secret|auth|signing)\w*\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/i,
10598
+ severity: "medium",
10599
+ cwe: "CWE-798",
10600
+ consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign."
10601
+ }
9582
10602
  ];
9583
- var MISCONFIG_FILES = [
9584
- { pattern: ".env", message: ".env file found \u2014 may contain secrets" },
9585
- { pattern: ".env.local", message: ".env.local file found \u2014 may contain secrets" },
9586
- { pattern: ".env.production", message: ".env.production file found \u2014 may contain secrets" },
9587
- { pattern: "id_rsa", message: "Private SSH key found" },
9588
- { pattern: "id_ed25519", message: "Private SSH key found" },
9589
- { pattern: ".pem", message: "PEM certificate/key file found" },
9590
- { pattern: ".p12", message: "PKCS#12 keystore found" },
9591
- { pattern: ".keystore", message: "Keystore file found" }
10603
+ var KNOWN_PLACEHOLDERS = [
10604
+ // Deliberately NOT here: AWS's published documentation key/secret pair
10605
+ // (`AKIAIOSFODNN7EXAMPLE`, `wJalrXUtnFEMI/…`). GitHub allow-lists them, and
10606
+ // the argument for following suit is that they authenticate nothing. The
10607
+ // argument against is stronger: they appear in a repository because someone
10608
+ // pasted a credentials template and left it there, and the remediation —
10609
+ // move this to the secret manager is identical to the one for a live key.
10610
+ // Exempting them means the scanner goes quiet on the file most likely to
10611
+ // acquire a real key next.
10612
+ /\bEXAMPLE_?KEY\b/i,
10613
+ /\bYOUR_[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)\b/,
10614
+ /\b(?:xxx+|X{4,}|\*{4,}|<[a-z-]+>)\b/,
10615
+ /\bchangeme\b/i
9592
10616
  ];
10617
+ function isKnownPlaceholder(text) {
10618
+ return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
10619
+ }
10620
+ function redactSecret(line) {
10621
+ return line.replace(/([A-Za-z0-9+/=_.-]{12,})/g, (match) => {
10622
+ if (match.length <= 12) return match;
10623
+ return `${match.slice(0, 3)}${"*".repeat(Math.min(16, match.length - 3))}`;
10624
+ });
10625
+ }
10626
+ var SENSITIVE_FILES = [
10627
+ { pattern: ".env", message: "Environment file committed \u2014 the usual home of every runtime credential", severity: "high" },
10628
+ { pattern: ".env.local", message: "Local environment file committed", severity: "high" },
10629
+ { pattern: ".env.production", message: "Production environment file committed", severity: "critical" },
10630
+ { pattern: "id_rsa", message: "Private SSH key committed", severity: "critical" },
10631
+ { pattern: "id_ed25519", message: "Private SSH key committed", severity: "critical" },
10632
+ { pattern: "id_ecdsa", message: "Private SSH key committed", severity: "critical" },
10633
+ { pattern: ".pem", message: "PEM certificate or key file committed", severity: "high" },
10634
+ { pattern: ".p12", message: "PKCS#12 keystore committed", severity: "high" },
10635
+ { pattern: ".pfx", message: "PKCS#12 keystore committed", severity: "high" },
10636
+ { pattern: ".keystore", message: "Java keystore committed", severity: "high" }
10637
+ // Deliberately not `.npmrc`. Its presence is normal; only an `_authToken`
10638
+ // line in it is a credential, and that is a content match, not a filename
10639
+ // match. Reporting the file itself trades a real finding for a chore.
10640
+ ];
10641
+
10642
+ // src/scan/engine.ts
9593
10643
  var SKIP_DIRS = /* @__PURE__ */ new Set([
9594
10644
  "node_modules",
9595
10645
  ".git",
9596
10646
  ".next",
10647
+ ".nuxt",
9597
10648
  "dist",
9598
10649
  "build",
10650
+ "out",
9599
10651
  "__pycache__",
9600
10652
  ".venv",
10653
+ "venv",
9601
10654
  "vendor",
9602
10655
  ".terraform",
9603
10656
  "coverage",
9604
- ".cache"
10657
+ ".cache",
10658
+ ".pnpm-store",
10659
+ "target",
10660
+ ".gradle",
10661
+ ".idea",
10662
+ ".vscode",
10663
+ "bower_components",
10664
+ ".svelte-kit"
9605
10665
  ]);
9606
10666
  var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
9607
10667
  ".ts",
9608
10668
  ".js",
9609
10669
  ".tsx",
9610
10670
  ".jsx",
10671
+ ".mjs",
10672
+ ".cjs",
10673
+ ".mts",
10674
+ ".cts",
9611
10675
  ".py",
9612
10676
  ".rb",
9613
10677
  ".go",
9614
10678
  ".java",
10679
+ ".kt",
10680
+ ".scala",
9615
10681
  ".php",
9616
10682
  ".rs",
9617
10683
  ".c",
10684
+ ".cc",
9618
10685
  ".cpp",
9619
10686
  ".h",
10687
+ ".hpp",
10688
+ ".cs",
10689
+ ".swift",
9620
10690
  ".yml",
9621
10691
  ".yaml",
9622
10692
  ".json",
@@ -9627,283 +10697,622 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
9627
10697
  ".env",
9628
10698
  ".sh",
9629
10699
  ".bash",
10700
+ ".zsh",
9630
10701
  ".tf",
9631
10702
  ".hcl",
9632
10703
  ".xml",
9633
10704
  ".properties",
9634
- ".gradle"
10705
+ ".gradle",
10706
+ ".txt",
10707
+ ".md",
10708
+ ".sql",
10709
+ ".erb",
10710
+ ".ejs",
10711
+ ".vue",
10712
+ ".svelte"
9635
10713
  ]);
9636
- async function runScan(targetPath) {
10714
+ var LANGUAGE_BY_EXTENSION = {
10715
+ ".js": "javascript",
10716
+ ".jsx": "javascript",
10717
+ ".mjs": "javascript",
10718
+ ".cjs": "javascript",
10719
+ ".ts": "typescript",
10720
+ ".tsx": "typescript",
10721
+ ".mts": "typescript",
10722
+ ".cts": "typescript",
10723
+ ".vue": "javascript",
10724
+ ".svelte": "javascript",
10725
+ ".ejs": "javascript",
10726
+ ".py": "python",
10727
+ ".rb": "ruby",
10728
+ ".erb": "ruby",
10729
+ ".go": "go",
10730
+ ".java": "java",
10731
+ ".kt": "java",
10732
+ ".scala": "java",
10733
+ ".php": "php",
10734
+ ".sh": "shell",
10735
+ ".bash": "shell",
10736
+ ".zsh": "shell",
10737
+ ".yml": "config",
10738
+ ".yaml": "config",
10739
+ ".json": "config",
10740
+ ".toml": "config",
10741
+ ".ini": "config",
10742
+ ".cfg": "config",
10743
+ ".conf": "config",
10744
+ ".env": "config",
10745
+ ".tf": "config",
10746
+ ".hcl": "config",
10747
+ ".properties": "config"
10748
+ };
10749
+ function languageOf(filename) {
10750
+ if (filename.startsWith(".env") || filename.endsWith(".env")) return "config";
10751
+ return LANGUAGE_BY_EXTENSION[(0, import_node_path3.extname)(filename).toLowerCase()] ?? "other";
10752
+ }
10753
+ var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
10754
+ var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
10755
+ function collectSuppressions(lines) {
10756
+ const byLine = /* @__PURE__ */ new Map();
10757
+ let count = 0;
10758
+ const add = (index, ruleId) => {
10759
+ const existing = byLine.get(index) ?? /* @__PURE__ */ new Set();
10760
+ existing.add(ruleId ?? "*");
10761
+ byLine.set(index, existing);
10762
+ count += 1;
10763
+ };
10764
+ lines.forEach((line, index) => {
10765
+ const next = SUPPRESS_NEXT.exec(line);
10766
+ if (next) add(index + 1, next[1]);
10767
+ const same = SUPPRESS_LINE.exec(line);
10768
+ if (same && !next) add(index, same[1]);
10769
+ });
10770
+ return { byLine, count };
10771
+ }
10772
+ function isSuppressed(suppressions, index, ruleId) {
10773
+ const rules = suppressions.byLine.get(index);
10774
+ if (!rules) return false;
10775
+ return rules.has("*") || rules.has(ruleId);
10776
+ }
10777
+ function scanText(relativePath, text, language = languageOf(relativePath)) {
9637
10778
  const findings = [];
9638
- try {
9639
- scanDirectory(targetPath, targetPath, findings, () => {
10779
+ const lines = text.split("\n");
10780
+ const suppressions = collectSuppressions(lines);
10781
+ lines.forEach((line, index) => {
10782
+ for (const rule of SECRET_RULES) {
10783
+ const match = rule.pattern.exec(line);
10784
+ if (!match) continue;
10785
+ if (isKnownPlaceholder(match[0])) continue;
10786
+ if (isSuppressed(suppressions, index, rule.id)) continue;
10787
+ findings.push({
10788
+ ruleId: rule.id,
10789
+ title: rule.name,
10790
+ file: relativePath,
10791
+ line: index + 1,
10792
+ severity: rule.severity,
10793
+ // A matched credential format is the finding, not a proxy for one.
10794
+ confidence: "evidence",
10795
+ message: `Possible ${rule.name} detected`,
10796
+ consequence: rule.consequence,
10797
+ cwe: rule.cwe,
10798
+ excerpt: redactSecret(line.trim()).slice(0, 200),
10799
+ sensitive: true,
10800
+ category: "secret"
10801
+ });
10802
+ }
10803
+ });
10804
+ const prose = proseLines(lines);
10805
+ lines.forEach((_line, index) => {
10806
+ for (const rule of CODE_RULES) {
10807
+ if (isSuppressed(suppressions, index, rule.id)) continue;
10808
+ const match = evaluateRule(rule, { lines, index, language, prose });
10809
+ if (!match) continue;
10810
+ findings.push({
10811
+ ruleId: rule.id,
10812
+ title: rule.title,
10813
+ file: relativePath,
10814
+ line: index + 1,
10815
+ severity: match.severity,
10816
+ confidence: match.confidence,
10817
+ message: `${rule.title} (${rule.cwe})`,
10818
+ consequence: rule.consequence,
10819
+ cwe: rule.cwe,
10820
+ excerpt: (lines[index] ?? "").trim().slice(0, 200),
10821
+ category: "code"
10822
+ });
10823
+ }
10824
+ });
10825
+ return findings;
10826
+ }
10827
+ function scanManifest(relativePath, filename, text) {
10828
+ const manifestFindings = filename === "package.json" ? scanPackageJson(text) : filename === "requirements.txt" ? scanRequirementsTxt(text) : [];
10829
+ return manifestFindings.map((finding) => ({
10830
+ ruleId: finding.ruleId,
10831
+ title: finding.title,
10832
+ file: relativePath,
10833
+ line: finding.line,
10834
+ severity: finding.severity,
10835
+ confidence: "evidence",
10836
+ message: finding.message,
10837
+ consequence: finding.consequence,
10838
+ cwe: finding.cwe,
10839
+ excerpt: finding.excerpt.slice(0, 200),
10840
+ category: "manifest"
10841
+ }));
10842
+ }
10843
+ function scanPath(targetPath, options = {}) {
10844
+ const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
10845
+ const allowed = options.categories ? new Set(options.categories) : null;
10846
+ const findings = [];
10847
+ const unreadable = [];
10848
+ let filesScanned = 0;
10849
+ let suppressed = 0;
10850
+ const rootIsDirectory = (() => {
10851
+ try {
10852
+ return (0, import_node_fs6.statSync)(targetPath).isDirectory();
10853
+ } catch {
10854
+ return true;
10855
+ }
10856
+ })();
10857
+ const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path3.dirname)(targetPath);
10858
+ const scanFile = (fullPath, filename) => {
10859
+ const relativePath = toRelative(walkRoot, fullPath);
10860
+ const extension = (0, import_node_path3.extname)(filename).toLowerCase();
10861
+ const isManifest = filename === "package.json" || filename === "requirements.txt";
10862
+ const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
10863
+ if (!scannable && !isManifest) {
10864
+ recordSensitiveFile(filename, relativePath, findings, []);
10865
+ return;
10866
+ }
10867
+ let text;
10868
+ let handle;
10869
+ try {
10870
+ handle = (0, import_node_fs6.openSync)(fullPath, "r");
10871
+ } catch {
10872
+ unreadable.push(relativePath);
10873
+ return;
10874
+ }
10875
+ try {
10876
+ if ((0, import_node_fs6.fstatSync)(handle).size > maxFileBytes) return;
10877
+ text = (0, import_node_fs6.readFileSync)(handle, "utf-8");
10878
+ } catch {
10879
+ unreadable.push(relativePath);
10880
+ return;
10881
+ } finally {
10882
+ try {
10883
+ (0, import_node_fs6.closeSync)(handle);
10884
+ } catch {
10885
+ }
10886
+ }
10887
+ filesScanned += 1;
10888
+ options.onFile?.(relativePath);
10889
+ suppressed += collectSuppressions(text.split("\n")).count;
10890
+ const fileFindings = [
10891
+ ...scanText(relativePath, text, languageOf(filename)),
10892
+ ...isManifest ? scanManifest(relativePath, filename, text) : []
10893
+ ];
10894
+ findings.push(...fileFindings);
10895
+ recordSensitiveFile(filename, relativePath, findings, fileFindings);
10896
+ };
10897
+ const walk = (currentPath) => {
10898
+ let entries;
10899
+ try {
10900
+ entries = (0, import_node_fs6.readdirSync)(currentPath, { withFileTypes: true });
10901
+ } catch {
10902
+ unreadable.push(toRelative(walkRoot, currentPath));
10903
+ return;
10904
+ }
10905
+ for (const entry of entries) {
10906
+ const fullPath = (0, import_node_path3.join)(currentPath, entry.name);
10907
+ if (entry.isDirectory()) {
10908
+ if (SKIP_DIRS.has(entry.name)) continue;
10909
+ walk(fullPath);
10910
+ continue;
10911
+ }
10912
+ if (!entry.isFile()) continue;
10913
+ scanFile(fullPath, entry.name);
10914
+ }
10915
+ };
10916
+ if (rootIsDirectory) {
10917
+ walk(targetPath);
10918
+ } else {
10919
+ scanFile(targetPath, (0, import_node_path3.basename)(targetPath));
10920
+ }
10921
+ const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
10922
+ filtered.sort(
10923
+ (a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
10924
+ );
10925
+ return { findings: filtered, filesScanned, unreadable, suppressed, root: walkRoot };
10926
+ }
10927
+ function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
10928
+ if (fileFindings.length > 0) return;
10929
+ for (const sensitive of SENSITIVE_FILES) {
10930
+ const matches = filename === sensitive.pattern || filename.endsWith(sensitive.pattern);
10931
+ if (!matches) continue;
10932
+ sink.push({
10933
+ ruleId: "sensitive-file-committed",
10934
+ title: "Sensitive file",
10935
+ file: relativePath,
10936
+ line: 1,
10937
+ severity: sensitive.severity,
10938
+ confidence: "evidence",
10939
+ message: sensitive.message,
10940
+ consequence: "Anything in this file is in every clone, fork and CI cache of the repository.",
10941
+ cwe: "CWE-538",
10942
+ excerpt: "",
10943
+ sensitive: true,
10944
+ category: "file"
9640
10945
  });
9641
- const depFindings = await scanDependencies(targetPath);
9642
- findings.push(...depFindings);
9643
- } catch (err) {
9644
- return {
9645
- type: "scan",
9646
- target: targetPath,
9647
- findings: [],
9648
- severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
9649
- summary: `Scan failed: ${err.message}`,
9650
- error: err.message
9651
- };
10946
+ return;
10947
+ }
10948
+ }
10949
+ function toRelative(base, target) {
10950
+ const rel = (0, import_node_path3.relative)(base, target);
10951
+ return (rel === "" ? target : rel).split(import_node_path3.sep).join("/");
10952
+ }
10953
+ function meetsFailThreshold(findings, threshold) {
10954
+ if (threshold.length === 0) return false;
10955
+ const floor = Math.min(...threshold.map(severityRank));
10956
+ return findings.some((finding) => severityRank(finding.severity) >= floor);
10957
+ }
10958
+
10959
+ // src/scan/sarif.ts
10960
+ var import_node_path4 = require("path");
10961
+ var SARIF_VERSION = "2.1.0";
10962
+ var SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
10963
+ function sarifLevel(severity) {
10964
+ switch (severity) {
10965
+ case "critical":
10966
+ case "high":
10967
+ return "error";
10968
+ case "medium":
10969
+ return "warning";
10970
+ case "low":
10971
+ return "note";
10972
+ default:
10973
+ return "none";
10974
+ }
10975
+ }
10976
+ function securitySeverity(severity) {
10977
+ switch (severity) {
10978
+ case "critical":
10979
+ return "9.0";
10980
+ case "high":
10981
+ return "7.0";
10982
+ case "medium":
10983
+ return "5.0";
10984
+ case "low":
10985
+ return "3.0";
10986
+ default:
10987
+ return "1.0";
10988
+ }
10989
+ }
10990
+ function toArtifactUri(filePath, base, prefix = "", root = base) {
10991
+ const absolute = (0, import_node_path4.isAbsolute)(filePath) ? filePath : (0, import_node_path4.resolve)(root, filePath);
10992
+ const relativePath = (0, import_node_path4.relative)(base, absolute);
10993
+ const escapedOut = relativePath.startsWith("..") || relativePath === "";
10994
+ const chosen = escapedOut ? absolute : relativePath;
10995
+ const posix = chosen.split(import_node_path4.sep).join("/").replace(/^\.\//, "");
10996
+ if (!prefix || escapedOut) return posix;
10997
+ const trimmed = prefix.replace(/^\/+|\/+$/g, "");
10998
+ return trimmed ? `${trimmed}/${posix}` : posix;
10999
+ }
11000
+ function buildSarif(findings, options) {
11001
+ const rules = /* @__PURE__ */ new Map();
11002
+ for (const finding of findings) {
11003
+ if (rules.has(finding.ruleId)) continue;
11004
+ const tags = ["security"];
11005
+ if (finding.cwe) tags.push(`external/cwe/${finding.cwe.toLowerCase()}`);
11006
+ tags.push(`threatcrush/${finding.category}`);
11007
+ const description = finding.consequence ? `${finding.title}. ${finding.consequence}` : finding.title;
11008
+ rules.set(finding.ruleId, {
11009
+ id: finding.ruleId,
11010
+ name: finding.ruleId,
11011
+ shortDescription: { text: finding.title },
11012
+ fullDescription: { text: description },
11013
+ help: {
11014
+ text: description,
11015
+ markdown: finding.consequence ? `**${finding.title}**
11016
+
11017
+ ${finding.consequence}` : `**${finding.title}**`
11018
+ },
11019
+ defaultConfiguration: { level: sarifLevel(finding.severity) },
11020
+ properties: {
11021
+ tags,
11022
+ "security-severity": securitySeverity(finding.severity),
11023
+ // SARIF's vocabulary for how much the rule is claiming. It lines up
11024
+ // with the confidence model: a bare construct match is `medium`, a
11025
+ // match with visible untrusted input is `high`.
11026
+ precision: finding.confidence === "pattern" ? "medium" : "high"
11027
+ }
11028
+ });
11029
+ }
11030
+ const base = options.base ?? process.cwd();
11031
+ const root = options.root ?? base;
11032
+ const results = findings.map((finding) => ({
11033
+ ruleId: finding.ruleId,
11034
+ level: sarifLevel(finding.severity),
11035
+ message: { text: finding.message },
11036
+ locations: [
11037
+ {
11038
+ physicalLocation: {
11039
+ artifactLocation: {
11040
+ uri: toArtifactUri(finding.file, base, options.pathPrefix, root),
11041
+ uriBaseId: "%SRCROOT%"
11042
+ },
11043
+ region: {
11044
+ // Clamped, never 0. A whole-file finding has no line; SARIF has no
11045
+ // way to say that, and 0 fails validation outright.
11046
+ startLine: Math.max(1, finding.line),
11047
+ snippet: { text: finding.excerpt }
11048
+ }
11049
+ }
11050
+ }
11051
+ ],
11052
+ partialFingerprints: {
11053
+ primaryLocationLineHash: `${finding.ruleId}:${finding.file}:${Math.max(1, finding.line)}`
11054
+ },
11055
+ properties: {
11056
+ severity: finding.severity,
11057
+ confidence: finding.confidence,
11058
+ category: finding.category,
11059
+ ...finding.cwe ? { cwe: finding.cwe } : {}
11060
+ }
11061
+ }));
11062
+ return {
11063
+ $schema: SARIF_SCHEMA,
11064
+ version: SARIF_VERSION,
11065
+ runs: [
11066
+ {
11067
+ tool: {
11068
+ driver: {
11069
+ name: "ThreatCrush",
11070
+ version: options.toolVersion,
11071
+ informationUri: "https://threatcrush.com",
11072
+ rules: [...rules.values()]
11073
+ }
11074
+ },
11075
+ results,
11076
+ columnKind: "utf16CodeUnits"
11077
+ }
11078
+ ]
11079
+ };
11080
+ }
11081
+
11082
+ // src/commands/scan.ts
11083
+ function readVersion() {
11084
+ for (const candidate of [
11085
+ (0, import_node_path5.join)(__dirname, "..", "package.json"),
11086
+ (0, import_node_path5.join)(__dirname, "..", "..", "package.json")
11087
+ ]) {
11088
+ try {
11089
+ return JSON.parse((0, import_node_fs7.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
11090
+ } catch {
11091
+ }
11092
+ }
11093
+ return "0.0.0";
11094
+ }
11095
+ var PKG_VERSION = readVersion();
11096
+ function parseFailOn(raw) {
11097
+ if (!raw) return [];
11098
+ const requested = raw.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
11099
+ const unknown = requested.filter((name) => !SEVERITY_ORDER.includes(name));
11100
+ if (unknown.length > 0) {
11101
+ throw new Error(
11102
+ `unknown severity in --fail-on: ${unknown.join(", ")} (expected ${SEVERITY_ORDER.join(", ")})`
11103
+ );
9652
11104
  }
9653
- const structured = findings.map((f) => ({
9654
- type: f.type,
9655
- severity: f.severity,
9656
- message: f.message,
9657
- location: `${f.file}:${f.line}`,
9658
- details: { file: f.file, line: f.line, snippet: f.snippet }
11105
+ return requested;
11106
+ }
11107
+ function toRunResult(targetPath, findings, filesScanned) {
11108
+ const structured = findings.map((finding) => ({
11109
+ type: finding.title,
11110
+ severity: finding.severity,
11111
+ message: finding.message,
11112
+ location: `${finding.file}:${finding.line}`,
11113
+ details: {
11114
+ file: finding.file,
11115
+ line: finding.line,
11116
+ snippet: finding.excerpt,
11117
+ ruleId: finding.ruleId,
11118
+ confidence: finding.confidence,
11119
+ ...finding.cwe ? { cwe: finding.cwe } : {}
11120
+ }
9659
11121
  }));
9660
- const summary = summarize(structured);
11122
+ const counts = summarize(structured);
9661
11123
  return {
9662
11124
  type: "scan",
9663
11125
  target: targetPath,
9664
11126
  findings: structured,
9665
- severity_summary: summary,
9666
- summary: findings.length === 0 ? "No security issues found" : `${findings.length} issue(s): ${summary.critical}C ${summary.high}H ${summary.medium}M ${summary.low}L`
11127
+ severity_summary: counts,
11128
+ summary: findings.length === 0 ? `No issues found across ${filesScanned} files` : `${findings.length} issue(s): ${counts.critical}C ${counts.high}H ${counts.medium}M ${counts.low}L`
9667
11129
  };
9668
11130
  }
9669
- async function scanCommand(targetPath) {
9670
- banner();
9671
- logger.info(`Scanning ${source_default.white(targetPath)} for security issues...
11131
+ function failedResult(targetPath, message) {
11132
+ return {
11133
+ type: "scan",
11134
+ target: targetPath,
11135
+ findings: [],
11136
+ severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
11137
+ summary: `Scan failed: ${message}`,
11138
+ error: message
11139
+ };
11140
+ }
11141
+ async function runScan(targetPath) {
11142
+ try {
11143
+ const report = scanPath(targetPath);
11144
+ const findings = [...report.findings, ...await scanDependencies(targetPath)];
11145
+ return toRunResult(targetPath, findings, report.filesScanned);
11146
+ } catch (err) {
11147
+ return failedResult(targetPath, err.message);
11148
+ }
11149
+ }
11150
+ async function scanCommand(targetPath, options = {}) {
11151
+ const format = options.format ?? "text";
11152
+ const machineReadable = format !== "text";
11153
+ const say = machineReadable ? (line) => process.stderr.write(`${line}
11154
+ `) : (line) => process.stdout.write(`${line}
9672
11155
  `);
9673
- const spinner = ora({ text: "Scanning files...", color: "green" }).start();
9674
- const findings = [];
9675
- let filesScanned = 0;
11156
+ if (!(0, import_node_fs7.existsSync)(targetPath)) {
11157
+ say(source_default.red(`Scan target does not exist: ${targetPath}`));
11158
+ process.exitCode = 2;
11159
+ return failedResult(targetPath, `no such path: ${targetPath}`);
11160
+ }
11161
+ if (!machineReadable) {
11162
+ banner();
11163
+ logger.info(`Scanning ${source_default.white(targetPath)} for security issues...
11164
+ `);
11165
+ }
11166
+ const spinner = machineReadable ? null : ora({ text: "Scanning files...", color: "green" }).start();
11167
+ let outcome;
9676
11168
  try {
9677
- scanDirectory(targetPath, targetPath, findings, () => {
9678
- filesScanned++;
9679
- spinner.text = `Scanning files... (${filesScanned} files)`;
11169
+ let seen = 0;
11170
+ const report = scanPath(targetPath, {
11171
+ onFile: () => {
11172
+ seen += 1;
11173
+ if (spinner) spinner.text = `Scanning files... (${seen} files)`;
11174
+ }
9680
11175
  });
9681
- } catch (err) {
9682
- spinner.fail(`Scan failed: ${err.message}`);
9683
- return {
9684
- type: "scan",
9685
- target: targetPath,
9686
- findings: [],
9687
- severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
9688
- summary: `Scan failed: ${err.message}`,
9689
- error: err.message
11176
+ if (options.dependencies) {
11177
+ if (spinner) spinner.text = "Querying OSV.dev for dependency advisories...";
11178
+ report.findings.push(...await scanDependencies(targetPath));
11179
+ }
11180
+ outcome = {
11181
+ result: toRunResult(targetPath, report.findings, report.filesScanned),
11182
+ findings: report.findings,
11183
+ filesScanned: report.filesScanned,
11184
+ unreadable: report.unreadable,
11185
+ suppressed: report.suppressed,
11186
+ root: report.root
9690
11187
  };
11188
+ } catch (err) {
11189
+ spinner?.fail(`Scan failed: ${err.message}`);
11190
+ process.exitCode = 2;
11191
+ return failedResult(targetPath, err.message);
9691
11192
  }
9692
- spinner.succeed(`Scanned ${filesScanned} files
11193
+ spinner?.succeed(`Scanned ${outcome.filesScanned} files
9693
11194
  `);
9694
- const structured = findings.map((f) => ({
9695
- type: f.type,
9696
- severity: f.severity,
9697
- message: f.message,
9698
- location: `${f.file}:${f.line}`,
9699
- details: { file: f.file, line: f.line, snippet: f.snippet }
9700
- }));
9701
- const sevCounts = summarize(structured);
11195
+ if (outcome.unreadable.length > 0) {
11196
+ say(
11197
+ source_default.yellow(
11198
+ ` ! ${outcome.unreadable.length} path(s) could not be read and were NOT scanned`
11199
+ )
11200
+ );
11201
+ if (options.verbose) {
11202
+ for (const path of outcome.unreadable) say(source_default.gray(` ${path}`));
11203
+ }
11204
+ }
11205
+ if (outcome.suppressed > 0) {
11206
+ say(
11207
+ source_default.gray(
11208
+ ` \xB7 ${outcome.suppressed} finding(s) suppressed by inline threatcrush-disable comments`
11209
+ )
11210
+ );
11211
+ }
11212
+ if (machineReadable) {
11213
+ emitMachineReadable(format, outcome, targetPath, options, say);
11214
+ } else {
11215
+ printHuman(outcome);
11216
+ }
11217
+ const failOn = options.failOn ?? [];
11218
+ if (meetsFailThreshold(outcome.findings, failOn)) {
11219
+ say(
11220
+ source_default.red(
11221
+ `
11222
+ \u2717 findings at or above ${[...failOn].join("/")} \u2014 failing as requested by --fail-on`
11223
+ )
11224
+ );
11225
+ process.exitCode = 1;
11226
+ }
11227
+ return outcome.result;
11228
+ }
11229
+ function emitMachineReadable(format, outcome, targetPath, options, say) {
11230
+ const payload = format === "sarif" ? buildSarif(outcome.findings, {
11231
+ toolVersion: PKG_VERSION,
11232
+ pathPrefix: options.pathPrefix,
11233
+ // Relative to the working directory, NOT the scan root. `threatcrush
11234
+ // scan vulns` from a repo root must emit `vulns/secrets/x.env`, not
11235
+ // `secrets/x.env` — the second form matches nothing in the
11236
+ // consumer's view of the repository, so every finding lands
11237
+ // "outside" whatever it scoped to and a working scan reads as 0%.
11238
+ // This is the single most expensive mistake in the whole pipeline
11239
+ // and it fails silently. `--path-prefix` covers the remaining case:
11240
+ // a scan run from inside the subdirectory it is scanning.
11241
+ base: process.cwd(),
11242
+ root: (0, import_node_path5.resolve)(outcome.root)
11243
+ }) : {
11244
+ tool: "threatcrush",
11245
+ version: PKG_VERSION,
11246
+ target: targetPath,
11247
+ filesScanned: outcome.filesScanned,
11248
+ unreadable: outcome.unreadable,
11249
+ suppressed: outcome.suppressed,
11250
+ summary: outcome.result.severity_summary,
11251
+ findings: outcome.findings
11252
+ };
11253
+ const serialized = `${JSON.stringify(payload, null, 2)}
11254
+ `;
11255
+ if (options.output) {
11256
+ (0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)((0, import_node_path5.resolve)(options.output)), { recursive: true });
11257
+ (0, import_node_fs7.writeFileSync)(options.output, serialized, "utf-8");
11258
+ say(
11259
+ source_default.gray(
11260
+ ` ${format.toUpperCase()} written to ${options.output} (${outcome.findings.length} finding(s))`
11261
+ )
11262
+ );
11263
+ return;
11264
+ }
11265
+ process.stdout.write(serialized);
11266
+ }
11267
+ function printHuman(outcome) {
11268
+ const { findings, filesScanned } = outcome;
9702
11269
  if (findings.length === 0) {
9703
11270
  console.log(source_default.green.bold(" \u2713 No security issues found!"));
9704
11271
  console.log();
9705
- return {
9706
- type: "scan",
9707
- target: targetPath,
9708
- findings: [],
9709
- severity_summary: sevCounts,
9710
- summary: `No issues found across ${filesScanned} files`
9711
- };
11272
+ return;
9712
11273
  }
9713
- const critical = findings.filter((f) => f.severity === "critical");
9714
- const high = findings.filter((f) => f.severity === "high");
9715
- const medium = findings.filter((f) => f.severity === "medium");
9716
- const low = findings.filter((f) => f.severity === "low");
11274
+ const counts = outcome.result.severity_summary;
9717
11275
  console.log(source_default.white.bold(" Scan Results"));
9718
11276
  console.log(source_default.gray(" " + "\u2500".repeat(70)));
9719
11277
  console.log(
9720
- ` ${source_default.red.bold(critical.length + " critical")} ${source_default.red(high.length + " high")} ${source_default.yellow(medium.length + " medium")} ${source_default.gray(low.length + " low")}`
11278
+ ` ${source_default.red.bold(counts.critical + " critical")} ${source_default.red(counts.high + " high")} ${source_default.yellow(counts.medium + " medium")} ${source_default.gray(counts.low + " low")}`
9721
11279
  );
9722
11280
  console.log(source_default.gray(" " + "\u2500".repeat(70)));
9723
11281
  console.log();
9724
- const allFindings = [...critical, ...high, ...medium, ...low];
9725
- for (const finding of allFindings) {
9726
- const sev = finding.severity === "critical" ? source_default.bgRed.white.bold(` ${finding.severity.toUpperCase()} `) : finding.severity === "high" ? source_default.red(`[${finding.severity.toUpperCase()}]`) : finding.severity === "medium" ? source_default.yellow(`[${finding.severity.toUpperCase()}]`) : source_default.gray(`[${finding.severity.toUpperCase()}]`);
9727
- console.log(` ${sev} ${source_default.white.bold(finding.type)}`);
9728
- console.log(` ${source_default.gray("File:")} ${source_default.cyan(finding.file)}:${source_default.yellow(String(finding.line))}`);
11282
+ for (const finding of findings) {
11283
+ const label = finding.severity.toUpperCase();
11284
+ const badge = finding.severity === "critical" ? source_default.bgRed.white.bold(` ${label} `) : finding.severity === "high" ? source_default.red(`[${label}]`) : finding.severity === "medium" ? source_default.yellow(`[${label}]`) : source_default.gray(`[${label}]`);
11285
+ console.log(` ${badge} ${source_default.white.bold(finding.title)}`);
11286
+ console.log(
11287
+ ` ${source_default.gray("File:")} ${source_default.cyan(finding.file)}:${source_default.yellow(String(finding.line))}`
11288
+ );
9729
11289
  console.log(` ${source_default.gray("Info:")} ${finding.message}`);
9730
- if (finding.snippet) {
9731
- const redacted = finding.snippet.replace(
9732
- /(['"]?)([A-Za-z0-9+/=\-_]{16,})(['"]?)/g,
9733
- "$1" + source_default.red("*".repeat(16)) + "$3"
9734
- );
9735
- console.log(` ${source_default.gray("Code:")} ${redacted.trim()}`);
11290
+ if (finding.consequence) {
11291
+ console.log(` ${source_default.gray("Risk:")} ${source_default.dim(finding.consequence)}`);
11292
+ }
11293
+ if (finding.excerpt) {
11294
+ console.log(` ${source_default.gray("Code:")} ${finding.excerpt}`);
9736
11295
  }
11296
+ console.log(
11297
+ ` ${source_default.gray("Rule:")} ${source_default.dim(finding.ruleId)}` + (finding.cwe ? source_default.dim(` \xB7 ${finding.cwe}`) : "") + source_default.dim(` \xB7 confidence: ${finding.confidence}`)
11298
+ );
9737
11299
  console.log();
9738
11300
  }
9739
11301
  console.log(source_default.gray(" " + "\u2500".repeat(70)));
9740
- console.log(` ${source_default.white.bold(`${findings.length} issue(s) found`)} across ${filesScanned} files`);
11302
+ console.log(
11303
+ ` ${source_default.white.bold(`${findings.length} issue(s) found`)} across ${filesScanned} files`
11304
+ );
9741
11305
  console.log();
9742
- return {
9743
- type: "scan",
9744
- target: targetPath,
9745
- findings: structured,
9746
- severity_summary: sevCounts,
9747
- summary: `${findings.length} issue(s): ${sevCounts.critical}C ${sevCounts.high}H ${sevCounts.medium}M ${sevCounts.low}L`
9748
- };
9749
- }
9750
- function scanDirectory(basePath, currentPath, findings, onFile) {
9751
- let entries;
9752
- try {
9753
- entries = (0, import_node_fs5.readdirSync)(currentPath, { withFileTypes: true });
9754
- } catch {
9755
- return;
9756
- }
9757
- for (const entry of entries) {
9758
- const fullPath = (0, import_node_path2.join)(currentPath, entry.name);
9759
- if (entry.isDirectory()) {
9760
- if (SKIP_DIRS.has(entry.name)) continue;
9761
- scanDirectory(basePath, fullPath, findings, onFile);
9762
- continue;
9763
- }
9764
- if (!entry.isFile()) continue;
9765
- for (const mc of MISCONFIG_FILES) {
9766
- if (entry.name === mc.pattern || entry.name.endsWith(mc.pattern)) {
9767
- findings.push({
9768
- file: (0, import_node_path2.relative)(basePath, fullPath),
9769
- line: 0,
9770
- type: "Sensitive File",
9771
- severity: "high",
9772
- message: mc.message,
9773
- snippet: ""
9774
- });
9775
- }
9776
- }
9777
- const ext = (0, import_node_path2.extname)(entry.name).toLowerCase();
9778
- if (!SCAN_EXTENSIONS.has(ext) && !entry.name.startsWith(".env")) continue;
9779
- try {
9780
- const stat = (0, import_node_fs5.statSync)(fullPath);
9781
- if (stat.size > 1024 * 1024) continue;
9782
- } catch {
9783
- continue;
9784
- }
9785
- onFile();
9786
- let content;
9787
- try {
9788
- content = (0, import_node_fs5.readFileSync)(fullPath, "utf-8");
9789
- } catch {
9790
- continue;
9791
- }
9792
- const lines = content.split("\n");
9793
- for (let i = 0; i < lines.length; i++) {
9794
- const line = lines[i];
9795
- if (line.trim().startsWith("//") && !line.includes("password") && !line.includes("secret")) continue;
9796
- for (const pattern of SECRET_PATTERNS) {
9797
- pattern.pattern.lastIndex = 0;
9798
- if (pattern.pattern.test(line)) {
9799
- findings.push({
9800
- file: (0, import_node_path2.relative)(basePath, fullPath),
9801
- line: i + 1,
9802
- type: pattern.name,
9803
- severity: pattern.severity,
9804
- message: `Possible ${pattern.name} detected`,
9805
- snippet: line.length > 120 ? line.slice(0, 120) + "..." : line
9806
- });
9807
- }
9808
- }
9809
- }
9810
- }
9811
- }
9812
- async function scanDependencies(targetPath) {
9813
- const findings = [];
9814
- const lockfiles = [
9815
- { file: "package-lock.json", ecosystem: "npm" },
9816
- { file: "pnpm-lock.yaml", ecosystem: "npm" },
9817
- { file: "yarn.lock", ecosystem: "npm" },
9818
- { file: "requirements.txt", ecosystem: "PyPI" },
9819
- { file: "Pipfile.lock", ecosystem: "PyPI" }
9820
- ];
9821
- for (const { file, ecosystem } of lockfiles) {
9822
- const lockPath = (0, import_node_path2.join)(targetPath, file);
9823
- if (!(0, import_node_fs5.existsSync)(lockPath)) continue;
9824
- try {
9825
- const deps = parseDependencies(lockPath, file, ecosystem);
9826
- for (const dep of deps.slice(0, 50)) {
9827
- try {
9828
- const vulns = await queryOsv(dep.name, dep.version, ecosystem);
9829
- for (const vuln of vulns) {
9830
- const cvssScore = vuln.severity?.find((s) => s.type === "CVSS_V3")?.score;
9831
- const severity = cvssScore ? parseFloat(cvssScore) >= 9 ? "critical" : parseFloat(cvssScore) >= 7 ? "high" : parseFloat(cvssScore) >= 4 ? "medium" : "low" : "medium";
9832
- findings.push({
9833
- file,
9834
- line: 0,
9835
- type: "Dependency CVE",
9836
- severity,
9837
- message: `${dep.name}@${dep.version}: ${vuln.summary || vuln.id}`,
9838
- snippet: `${vuln.id}${cvssScore ? ` (CVSS: ${cvssScore})` : ""}`
9839
- });
9840
- }
9841
- } catch {
9842
- }
9843
- }
9844
- } catch {
9845
- }
9846
- }
9847
- return findings;
9848
- }
9849
- function parseDependencies(lockPath, filename, ecosystem) {
9850
- const deps = [];
9851
- if (filename === "package-lock.json") {
9852
- try {
9853
- const lock = JSON.parse((0, import_node_fs5.readFileSync)(lockPath, "utf-8"));
9854
- const packages = lock.packages || lock.dependencies || {};
9855
- for (const [key, value] of Object.entries(packages)) {
9856
- const name = key.replace(/^node_modules\//, "");
9857
- const version = value.version;
9858
- if (name && version && !name.startsWith(".")) {
9859
- deps.push({ name, version });
9860
- }
9861
- }
9862
- } catch {
9863
- }
9864
- } else if (filename === "requirements.txt") {
9865
- try {
9866
- const content = (0, import_node_fs5.readFileSync)(lockPath, "utf-8");
9867
- for (const line of content.split("\n")) {
9868
- const match = line.match(/^([a-zA-Z0-9_.-]+)==([0-9.]+)/);
9869
- if (match) deps.push({ name: match[1], version: match[2] });
9870
- }
9871
- } catch {
9872
- }
9873
- }
9874
- return deps;
9875
- }
9876
- function isValidPackageName(name) {
9877
- return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
9878
- }
9879
- function isValidVersion(version) {
9880
- return /^[0-9a-zA-Z._\-+]{1,50}$/.test(version);
9881
- }
9882
- async function queryOsv(name, version, ecosystem) {
9883
- if (!isValidPackageName(name) || !isValidVersion(version)) return [];
9884
- try {
9885
- const res = await fetch("https://api.osv.dev/v1/query", {
9886
- method: "POST",
9887
- headers: { "Content-Type": "application/json" },
9888
- body: JSON.stringify({ package: { name, ecosystem }, version }),
9889
- signal: AbortSignal.timeout(5e3)
9890
- });
9891
- if (!res.ok) return [];
9892
- const data = await res.json();
9893
- return data.vulns || [];
9894
- } catch {
9895
- return [];
9896
- }
9897
11306
  }
9898
11307
 
9899
11308
  // src/commands/init.ts
9900
- var import_node_fs8 = require("fs");
11309
+ var import_node_fs10 = require("fs");
9901
11310
  var import_node_child_process = require("child_process");
9902
11311
  var import_node_readline3 = __toESM(require("readline"));
9903
11312
 
9904
11313
  // src/core/config.ts
9905
- var import_node_fs6 = require("fs");
9906
- var import_node_path3 = require("path");
11314
+ var import_node_fs8 = require("fs");
11315
+ var import_node_path6 = require("path");
9907
11316
  var import_toml = __toESM(require_toml());
9908
11317
  var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
9909
11318
  var DEFAULT_CONFDIR = "/etc/threatcrush/threatcrushd.conf.d";
@@ -9929,11 +11338,11 @@ var DEFAULT_CONFIG = {
9929
11338
  };
9930
11339
  function loadConfig(configPath) {
9931
11340
  const path = configPath || DEFAULT_CONFIG_PATH;
9932
- if (!(0, import_node_fs6.existsSync)(path)) {
11341
+ if (!(0, import_node_fs8.existsSync)(path)) {
9933
11342
  return { ...DEFAULT_CONFIG };
9934
11343
  }
9935
11344
  try {
9936
- const raw = (0, import_node_fs6.readFileSync)(path, "utf-8");
11345
+ const raw = (0, import_node_fs8.readFileSync)(path, "utf-8");
9937
11346
  const parsed = import_toml.default.parse(raw);
9938
11347
  return {
9939
11348
  daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
@@ -9949,13 +11358,13 @@ function loadConfig(configPath) {
9949
11358
  function loadModuleConfigs(confDir) {
9950
11359
  const dir = confDir || DEFAULT_CONFDIR;
9951
11360
  const configs = /* @__PURE__ */ new Map();
9952
- if (!(0, import_node_fs6.existsSync)(dir)) {
11361
+ if (!(0, import_node_fs8.existsSync)(dir)) {
9953
11362
  return configs;
9954
11363
  }
9955
- const files = (0, import_node_fs6.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
11364
+ const files = (0, import_node_fs8.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
9956
11365
  for (const file of files) {
9957
11366
  try {
9958
- const raw = (0, import_node_fs6.readFileSync)((0, import_node_path3.join)(dir, file), "utf-8");
11367
+ const raw = (0, import_node_fs8.readFileSync)((0, import_node_path6.join)(dir, file), "utf-8");
9959
11368
  const parsed = import_toml.default.parse(raw);
9960
11369
  for (const [name, config] of Object.entries(parsed)) {
9961
11370
  configs.set(name, config);
@@ -9984,23 +11393,23 @@ function generateModuleConfig(moduleName, defaults = {}) {
9984
11393
  }
9985
11394
 
9986
11395
  // src/core/cli-config.ts
9987
- var import_node_fs7 = require("fs");
9988
- var import_node_path4 = require("path");
11396
+ var import_node_fs9 = require("fs");
11397
+ var import_node_path7 = require("path");
9989
11398
  var import_node_os4 = require("os");
9990
- var CLI_CONFIG_DIR = (0, import_node_path4.join)((0, import_node_os4.homedir)(), ".threatcrush");
9991
- var CLI_CONFIG_PATH = (0, import_node_path4.join)(CLI_CONFIG_DIR, "config.json");
11399
+ var CLI_CONFIG_DIR = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".threatcrush");
11400
+ var CLI_CONFIG_PATH = (0, import_node_path7.join)(CLI_CONFIG_DIR, "config.json");
9992
11401
  function readCliConfig() {
9993
11402
  try {
9994
- return JSON.parse((0, import_node_fs7.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
11403
+ return JSON.parse((0, import_node_fs9.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
9995
11404
  } catch {
9996
11405
  return {};
9997
11406
  }
9998
11407
  }
9999
11408
  function writeCliConfig(config) {
10000
- if (!(0, import_node_fs7.existsSync)(CLI_CONFIG_DIR)) (0, import_node_fs7.mkdirSync)(CLI_CONFIG_DIR, { recursive: true });
10001
- (0, import_node_fs7.writeFileSync)(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
11409
+ if (!(0, import_node_fs9.existsSync)(CLI_CONFIG_DIR)) (0, import_node_fs9.mkdirSync)(CLI_CONFIG_DIR, { recursive: true });
11410
+ (0, import_node_fs9.writeFileSync)(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
10002
11411
  try {
10003
- (0, import_node_fs7.chmodSync)(CLI_CONFIG_PATH, 384);
11412
+ (0, import_node_fs9.chmodSync)(CLI_CONFIG_PATH, 384);
10004
11413
  } catch {
10005
11414
  }
10006
11415
  }
@@ -10038,13 +11447,13 @@ var import_node_stream = require("stream");
10038
11447
  var API_URL = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
10039
11448
  function prompt(question) {
10040
11449
  const rl = import_node_readline2.default.createInterface({ input: process.stdin, output: process.stdout });
10041
- return new Promise((resolve3) => rl.question(question, (answer) => {
11450
+ return new Promise((resolve5) => rl.question(question, (answer) => {
10042
11451
  rl.close();
10043
- resolve3(answer.trim());
11452
+ resolve5(answer.trim());
10044
11453
  }));
10045
11454
  }
10046
11455
  function promptPassword(question) {
10047
- return new Promise((resolve3) => {
11456
+ return new Promise((resolve5) => {
10048
11457
  const muted = new import_node_stream.Writable({
10049
11458
  write(_chunk, _enc, cb) {
10050
11459
  cb();
@@ -10055,7 +11464,7 @@ function promptPassword(question) {
10055
11464
  rl.question("", (answer) => {
10056
11465
  rl.close();
10057
11466
  process.stdout.write("\n");
10058
- resolve3(answer);
11467
+ resolve5(answer);
10059
11468
  });
10060
11469
  });
10061
11470
  }
@@ -10245,16 +11654,16 @@ function binaryExists(name) {
10245
11654
  }
10246
11655
  }
10247
11656
  function findLogPath(paths) {
10248
- return paths.find((p) => (0, import_node_fs8.existsSync)(p));
11657
+ return paths.find((p) => (0, import_node_fs10.existsSync)(p));
10249
11658
  }
10250
11659
  async function promptYesNo(question, fallback2) {
10251
11660
  const rl = import_node_readline3.default.createInterface({ input: process.stdin, output: process.stdout });
10252
11661
  const hint = fallback2 ? "(Y/n)" : "(y/N)";
10253
- return new Promise((resolve3) => rl.question(`${question} ${hint}: `, (answer) => {
11662
+ return new Promise((resolve5) => rl.question(`${question} ${hint}: `, (answer) => {
10254
11663
  rl.close();
10255
11664
  const trimmed = answer.trim().toLowerCase();
10256
- if (!trimmed) return resolve3(fallback2);
10257
- resolve3(trimmed === "y" || trimmed === "yes");
11665
+ if (!trimmed) return resolve5(fallback2);
11666
+ resolve5(trimmed === "y" || trimmed === "yes");
10258
11667
  }));
10259
11668
  }
10260
11669
  async function ensureLoggedIn() {
@@ -10361,11 +11770,11 @@ async function initCommand() {
10361
11770
  }
10362
11771
  } else {
10363
11772
  const spinner2 = ora({ text: "Writing configuration files...", color: "green" }).start();
10364
- (0, import_node_fs8.mkdirSync)(confDDir, { recursive: true });
10365
- (0, import_node_fs8.mkdirSync)("/var/log/threatcrush", { recursive: true });
10366
- (0, import_node_fs8.mkdirSync)("/var/lib/threatcrush", { recursive: true });
11773
+ (0, import_node_fs10.mkdirSync)(confDDir, { recursive: true });
11774
+ (0, import_node_fs10.mkdirSync)("/var/log/threatcrush", { recursive: true });
11775
+ (0, import_node_fs10.mkdirSync)("/var/lib/threatcrush", { recursive: true });
10367
11776
  const mainConfig = generateDefaultConfig(detected.map((d) => d.name));
10368
- (0, import_node_fs8.writeFileSync)(`${configDir}/threatcrushd.conf`, mainConfig);
11777
+ (0, import_node_fs10.writeFileSync)(`${configDir}/threatcrushd.conf`, mainConfig);
10369
11778
  for (const svc of detected) {
10370
11779
  const svcDef = SERVICES_TO_DETECT.find((s) => s.name === svc.name);
10371
11780
  if (!svcDef) continue;
@@ -10374,7 +11783,7 @@ async function initCommand() {
10374
11783
  ...svcDef.moduleConfig,
10375
11784
  log_path: svc.logPath || svcDef.logPaths[0]
10376
11785
  });
10377
- (0, import_node_fs8.writeFileSync)(`${confDDir}/${modName}.conf`, modConfig);
11786
+ (0, import_node_fs10.writeFileSync)(`${confDDir}/${modName}.conf`, modConfig);
10378
11787
  }
10379
11788
  spinner2.succeed("Configuration written successfully");
10380
11789
  console.log();
@@ -10392,8 +11801,8 @@ async function initCommand() {
10392
11801
  }
10393
11802
  function checkWriteAccess(dir) {
10394
11803
  try {
10395
- if (!(0, import_node_fs8.existsSync)(dir)) {
10396
- (0, import_node_fs8.mkdirSync)(dir, { recursive: true });
11804
+ if (!(0, import_node_fs10.existsSync)(dir)) {
11805
+ (0, import_node_fs10.mkdirSync)(dir, { recursive: true });
10397
11806
  }
10398
11807
  return true;
10399
11808
  } catch {
@@ -10402,8 +11811,8 @@ function checkWriteAccess(dir) {
10402
11811
  }
10403
11812
 
10404
11813
  // src/core/module-loader.ts
10405
- var import_node_fs9 = require("fs");
10406
- var import_node_path5 = require("path");
11814
+ var import_node_fs11 = require("fs");
11815
+ var import_node_path8 = require("path");
10407
11816
  var import_toml2 = __toESM(require_toml());
10408
11817
  init_paths();
10409
11818
  function discoverModules(moduleDir, confDir) {
@@ -10411,22 +11820,22 @@ function discoverModules(moduleDir, confDir) {
10411
11820
  const configs = loadModuleConfigs(confDir || PATHS.confD);
10412
11821
  const searchPaths = [
10413
11822
  moduleDir || PATHS.moduleDir,
10414
- (0, import_node_path5.resolve)(process.cwd(), "modules")
11823
+ (0, import_node_path8.resolve)(process.cwd(), "modules")
10415
11824
  ];
10416
- const builtinDir = (0, import_node_path5.resolve)(__dirname || ".", "..", "modules");
10417
- if ((0, import_node_fs9.existsSync)(builtinDir)) {
11825
+ const builtinDir = (0, import_node_path8.resolve)(__dirname || ".", "..", "modules");
11826
+ if ((0, import_node_fs11.existsSync)(builtinDir)) {
10418
11827
  searchPaths.push(builtinDir);
10419
11828
  }
10420
11829
  for (const basePath of searchPaths) {
10421
- if (!(0, import_node_fs9.existsSync)(basePath)) continue;
10422
- const entries = (0, import_node_fs9.readdirSync)(basePath, { withFileTypes: true });
11830
+ if (!(0, import_node_fs11.existsSync)(basePath)) continue;
11831
+ const entries = (0, import_node_fs11.readdirSync)(basePath, { withFileTypes: true });
10423
11832
  for (const entry of entries) {
10424
11833
  if (!entry.isDirectory()) continue;
10425
- const modPath = (0, import_node_path5.join)(basePath, entry.name);
10426
- const manifestPath = (0, import_node_path5.join)(modPath, "mod.toml");
10427
- if (!(0, import_node_fs9.existsSync)(manifestPath)) continue;
11834
+ const modPath = (0, import_node_path8.join)(basePath, entry.name);
11835
+ const manifestPath = (0, import_node_path8.join)(modPath, "mod.toml");
11836
+ if (!(0, import_node_fs11.existsSync)(manifestPath)) continue;
10428
11837
  try {
10429
- const raw = (0, import_node_fs9.readFileSync)(manifestPath, "utf-8");
11838
+ const raw = (0, import_node_fs11.readFileSync)(manifestPath, "utf-8");
10430
11839
  const manifest = import_toml2.default.parse(raw);
10431
11840
  const config = configs.get(manifest.module.name) || { enabled: true };
10432
11841
  modules.push({
@@ -10545,8 +11954,8 @@ function formatUptime(seconds) {
10545
11954
 
10546
11955
  // src/commands/modules.ts
10547
11956
  var import_node_child_process2 = require("child_process");
10548
- var import_node_fs10 = require("fs");
10549
- var import_node_path6 = require("path");
11957
+ var import_node_fs12 = require("fs");
11958
+ var import_node_path9 = require("path");
10550
11959
  var import_toml3 = __toESM(require_toml());
10551
11960
  init_paths();
10552
11961
  init_pidfile();
@@ -10555,13 +11964,32 @@ function modulesDir() {
10555
11964
  ensureRuntimeDirs();
10556
11965
  return PATHS.moduleDir;
10557
11966
  }
11967
+ function safeModuleDirName(name, label = "module name") {
11968
+ if (!/^[A-Za-z0-9._@-]+$/.test(name) || name === "." || name === "..") {
11969
+ throw new Error(`Unsafe ${label}: ${name}`);
11970
+ }
11971
+ return name;
11972
+ }
11973
+ function moduleDestination(dir, name, label) {
11974
+ return (0, import_node_path9.join)(dir, safeModuleDirName(name, label));
11975
+ }
11976
+ function assertSafeTarballEntries(tarPath) {
11977
+ const listing = (0, import_node_child_process2.execFileSync)("tar", ["-tzf", tarPath], { encoding: "utf-8" });
11978
+ for (const entry of listing.split("\n").map((line) => line.trim()).filter(Boolean)) {
11979
+ const normalized = entry.replace(/\\/g, "/");
11980
+ const segments = normalized.split("/");
11981
+ if (normalized.startsWith("/") || segments.includes("..")) {
11982
+ throw new Error(`Unsafe tarball entry: ${entry}`);
11983
+ }
11984
+ }
11985
+ }
10558
11986
  function validateManifest(modPath) {
10559
- const manifestPath = (0, import_node_path6.join)(modPath, "mod.toml");
10560
- if (!(0, import_node_fs10.existsSync)(manifestPath)) {
11987
+ const manifestPath = (0, import_node_path9.join)(modPath, "mod.toml");
11988
+ if (!(0, import_node_fs12.existsSync)(manifestPath)) {
10561
11989
  return { ok: false, error: `mod.toml not found at ${manifestPath}` };
10562
11990
  }
10563
11991
  try {
10564
- const raw = (0, import_node_fs10.readFileSync)(manifestPath, "utf-8");
11992
+ const raw = (0, import_node_fs12.readFileSync)(manifestPath, "utf-8");
10565
11993
  const parsed = import_toml3.default.parse(raw);
10566
11994
  const name = parsed.module?.name;
10567
11995
  const version = parsed.module?.version;
@@ -10615,8 +12043,8 @@ async function modulesInstallCommand(source) {
10615
12043
  console.log();
10616
12044
  const dir = modulesDir();
10617
12045
  if (source.startsWith("./") || source.startsWith("/") || source.startsWith("~")) {
10618
- const absPath = (0, import_node_path6.resolve)(source.startsWith("~") ? source.replace("~", process.env.HOME || "") : source);
10619
- if (!(0, import_node_fs10.existsSync)(absPath)) {
12046
+ const absPath = (0, import_node_path9.resolve)(source.startsWith("~") ? source.replace("~", process.env.HOME || "") : source);
12047
+ if (!(0, import_node_fs12.existsSync)(absPath)) {
10620
12048
  console.log(source_default.red(` \u2717 Path not found: ${absPath}
10621
12049
  `));
10622
12050
  return;
@@ -10627,8 +12055,15 @@ async function modulesInstallCommand(source) {
10627
12055
  `));
10628
12056
  return;
10629
12057
  }
10630
- const dest2 = (0, import_node_path6.join)(dir, check.name);
10631
- if ((0, import_node_fs10.existsSync)(dest2)) {
12058
+ let dest2;
12059
+ try {
12060
+ dest2 = moduleDestination(dir, check.name);
12061
+ } catch (err) {
12062
+ console.log(source_default.red(` x ${err.message}
12063
+ `));
12064
+ return;
12065
+ }
12066
+ if ((0, import_node_fs12.existsSync)(dest2)) {
10632
12067
  console.log(source_default.yellow(` ! ${check.name} is already installed at ${dest2}`));
10633
12068
  console.log(source_default.dim(` Run ${source_default.white(`threatcrush modules remove ${check.name}`)} first.
10634
12069
  `));
@@ -10636,7 +12071,7 @@ async function modulesInstallCommand(source) {
10636
12071
  }
10637
12072
  const spinner2 = ora({ text: `Copying module files...`, color: "green" }).start();
10638
12073
  try {
10639
- (0, import_node_fs10.cpSync)(absPath, dest2, { recursive: true, dereference: true });
12074
+ (0, import_node_fs12.cpSync)(absPath, dest2, { recursive: true, dereference: true });
10640
12075
  spinner2.succeed(`Installed ${source_default.white(check.name)} v${check.version} \u2192 ${dest2}`);
10641
12076
  } catch (err) {
10642
12077
  spinner2.fail(`Copy failed: ${err.message}`);
@@ -10648,9 +12083,17 @@ async function modulesInstallCommand(source) {
10648
12083
  }
10649
12084
  if (source.startsWith("github:") || source.startsWith("https://") || source.startsWith("git@") || source.endsWith(".git")) {
10650
12085
  const gitUrl = source.startsWith("github:") ? `https://github.com/${source.slice("github:".length)}.git` : source;
10651
- const name = (0, import_node_path6.basename)(gitUrl.replace(/\.git$/, ""));
10652
- const dest2 = (0, import_node_path6.join)(dir, name);
10653
- if ((0, import_node_fs10.existsSync)(dest2)) {
12086
+ let name;
12087
+ let dest2;
12088
+ try {
12089
+ name = safeModuleDirName((0, import_node_path9.basename)(gitUrl.replace(/\.git$/, "")), "repository name");
12090
+ dest2 = moduleDestination(dir, name);
12091
+ } catch (err) {
12092
+ console.log(source_default.red(` x ${err.message}
12093
+ `));
12094
+ return;
12095
+ }
12096
+ if ((0, import_node_fs12.existsSync)(dest2)) {
10654
12097
  console.log(source_default.yellow(` ! ${name} is already installed at ${dest2}`));
10655
12098
  console.log(source_default.dim(` Run ${source_default.white(`threatcrush modules remove ${name}`)} first.
10656
12099
  `));
@@ -10658,7 +12101,7 @@ async function modulesInstallCommand(source) {
10658
12101
  }
10659
12102
  const spinner2 = ora({ text: `Cloning ${gitUrl}...`, color: "green" }).start();
10660
12103
  try {
10661
- (0, import_node_child_process2.execSync)(`git clone --depth 1 ${gitUrl} ${dest2}`, { stdio: "pipe" });
12104
+ (0, import_node_child_process2.execFileSync)("git", ["clone", "--depth", "1", "--", gitUrl, dest2], { stdio: "pipe" });
10662
12105
  } catch (err) {
10663
12106
  spinner2.fail(`Clone failed: ${err.message}`);
10664
12107
  return;
@@ -10667,7 +12110,7 @@ async function modulesInstallCommand(source) {
10667
12110
  if (!check.ok) {
10668
12111
  spinner2.fail(check.error);
10669
12112
  try {
10670
- (0, import_node_fs10.rmSync)(dest2, { recursive: true, force: true });
12113
+ (0, import_node_fs12.rmSync)(dest2, { recursive: true, force: true });
10671
12114
  } catch {
10672
12115
  }
10673
12116
  return;
@@ -10704,8 +12147,15 @@ async function modulesInstallCommand(source) {
10704
12147
  }
10705
12148
  spinner.succeed(`Found ${mod.name} v${mod.version}`);
10706
12149
  const install = mod.install;
10707
- const dest = (0, import_node_path6.join)(dir, mod.slug);
10708
- if ((0, import_node_fs10.existsSync)(dest)) {
12150
+ let dest;
12151
+ try {
12152
+ dest = moduleDestination(dir, mod.slug, "module slug");
12153
+ } catch (err) {
12154
+ console.log(source_default.red(` x ${err.message}
12155
+ `));
12156
+ return;
12157
+ }
12158
+ if ((0, import_node_fs12.existsSync)(dest)) {
10709
12159
  console.log(source_default.yellow(` ! ${mod.slug} is already installed at ${dest}
10710
12160
  `));
10711
12161
  return;
@@ -10713,7 +12163,7 @@ async function modulesInstallCommand(source) {
10713
12163
  if (install.npm_package) {
10714
12164
  const npmSpinner = ora({ text: `Installing ${install.npm_package}...`, color: "green" }).start();
10715
12165
  try {
10716
- (0, import_node_child_process2.execSync)(`npm install -g ${install.npm_package}`, { stdio: "pipe" });
12166
+ (0, import_node_child_process2.execFileSync)("npm", ["install", "-g", "--", install.npm_package], { stdio: "pipe" });
10717
12167
  npmSpinner.succeed(`Package ${install.npm_package} installed globally`);
10718
12168
  } catch (err) {
10719
12169
  npmSpinner.fail(`npm install failed: ${err.message}`);
@@ -10722,7 +12172,7 @@ async function modulesInstallCommand(source) {
10722
12172
  } else if (install.git_url) {
10723
12173
  const cloneSpinner = ora({ text: "Cloning module repository...", color: "green" }).start();
10724
12174
  try {
10725
- (0, import_node_child_process2.execSync)(`git clone --depth 1 ${install.git_url} ${dest}`, { stdio: "pipe" });
12175
+ (0, import_node_child_process2.execFileSync)("git", ["clone", "--depth", "1", "--", install.git_url, dest], { stdio: "pipe" });
10726
12176
  } catch (err) {
10727
12177
  cloneSpinner.fail(`Clone failed: ${err.message}`);
10728
12178
  return;
@@ -10731,7 +12181,7 @@ async function modulesInstallCommand(source) {
10731
12181
  if (!check.ok) {
10732
12182
  cloneSpinner.fail(check.error);
10733
12183
  try {
10734
- (0, import_node_fs10.rmSync)(dest, { recursive: true, force: true });
12184
+ (0, import_node_fs12.rmSync)(dest, { recursive: true, force: true });
10735
12185
  } catch {
10736
12186
  }
10737
12187
  return;
@@ -10745,10 +12195,11 @@ async function modulesInstallCommand(source) {
10745
12195
  dlSpinner.fail(`HTTP ${res.status}`);
10746
12196
  return;
10747
12197
  }
10748
- const tar = (0, import_node_path6.join)(dir, `${mod.slug}.tar.gz`);
10749
- (0, import_node_fs10.writeFileSync)(tar, Buffer.from(await res.arrayBuffer()));
10750
- (0, import_node_child_process2.execSync)(`tar -xzf ${tar} -C ${dir}`, { stdio: "pipe" });
10751
- (0, import_node_fs10.rmSync)(tar, { force: true });
12198
+ const tar = (0, import_node_path9.join)(dir, `${mod.slug}.tar.gz`);
12199
+ (0, import_node_fs12.writeFileSync)(tar, Buffer.from(await res.arrayBuffer()));
12200
+ assertSafeTarballEntries(tar);
12201
+ (0, import_node_child_process2.execFileSync)("tar", ["-xzf", tar, "-C", dir], { stdio: "pipe" });
12202
+ (0, import_node_fs12.rmSync)(tar, { force: true });
10752
12203
  const check = validateManifest(dest);
10753
12204
  if (!check.ok) {
10754
12205
  dlSpinner.fail(check.error);
@@ -10771,8 +12222,15 @@ async function modulesRemoveCommand(name) {
10771
12222
  console.log(source_default.green.bold(" Module Removal"));
10772
12223
  console.log(source_default.gray(" " + "\u2500".repeat(50)));
10773
12224
  const dir = modulesDir();
10774
- const target = (0, import_node_path6.join)(dir, name);
10775
- if (!(0, import_node_fs10.existsSync)(target)) {
12225
+ let target;
12226
+ try {
12227
+ target = moduleDestination(dir, name);
12228
+ } catch (err) {
12229
+ console.log(source_default.red(` x ${err.message}
12230
+ `));
12231
+ return;
12232
+ }
12233
+ if (!(0, import_node_fs12.existsSync)(target)) {
10776
12234
  console.log(source_default.yellow(` ! No module named "${name}" in ${dir}
10777
12235
  `));
10778
12236
  return;
@@ -10782,7 +12240,7 @@ async function modulesRemoveCommand(name) {
10782
12240
  console.log(source_default.yellow(` ! Directory name "${name}" does not match manifest name "${check.name}"`));
10783
12241
  }
10784
12242
  try {
10785
- (0, import_node_fs10.rmSync)(target, { recursive: true, force: true });
12243
+ (0, import_node_fs12.rmSync)(target, { recursive: true, force: true });
10786
12244
  console.log(source_default.green(` \u2713 Removed ${name} from ${dir}
10787
12245
  `));
10788
12246
  } catch (err) {
@@ -11155,21 +12613,21 @@ async function pentestCommand(targetUrl) {
11155
12613
 
11156
12614
  // src/commands/orgs.ts
11157
12615
  var import_node_os5 = require("os");
11158
- var import_node_fs11 = require("fs");
11159
- var import_node_path7 = require("path");
12616
+ var import_node_fs13 = require("fs");
12617
+ var import_node_path10 = require("path");
11160
12618
  var API_URL3 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
11161
- var CONFIG_PATH = (0, import_node_path7.join)((0, import_node_os5.homedir)(), ".threatcrush", "config.json");
12619
+ var CONFIG_PATH = (0, import_node_path10.join)((0, import_node_os5.homedir)(), ".threatcrush", "config.json");
11162
12620
  function readConfig() {
11163
12621
  try {
11164
- return JSON.parse((0, import_node_fs11.readFileSync)(CONFIG_PATH, "utf-8"));
12622
+ return JSON.parse((0, import_node_fs13.readFileSync)(CONFIG_PATH, "utf-8"));
11165
12623
  } catch {
11166
12624
  return {};
11167
12625
  }
11168
12626
  }
11169
12627
  function writeConfig(config) {
11170
- const dir = (0, import_node_path7.join)((0, import_node_os5.homedir)(), ".threatcrush");
11171
- if (!(0, import_node_fs11.existsSync)(dir)) (0, import_node_fs11.mkdirSync)(dir, { recursive: true });
11172
- (0, import_node_fs11.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2));
12628
+ const dir = (0, import_node_path10.join)((0, import_node_os5.homedir)(), ".threatcrush");
12629
+ if (!(0, import_node_fs13.existsSync)(dir)) (0, import_node_fs13.mkdirSync)(dir, { recursive: true });
12630
+ (0, import_node_fs13.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2));
11173
12631
  }
11174
12632
  function getAuthHeaders() {
11175
12633
  const config = readConfig();
@@ -11305,13 +12763,13 @@ async function useOrganization(slug) {
11305
12763
 
11306
12764
  // src/commands/servers.ts
11307
12765
  var import_node_os6 = require("os");
11308
- var import_node_fs12 = require("fs");
11309
- var import_node_path8 = require("path");
12766
+ var import_node_fs14 = require("fs");
12767
+ var import_node_path11 = require("path");
11310
12768
  var API_URL4 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
11311
- var CONFIG_PATH2 = (0, import_node_path8.join)((0, import_node_os6.homedir)(), ".threatcrush", "config.json");
12769
+ var CONFIG_PATH2 = (0, import_node_path11.join)((0, import_node_os6.homedir)(), ".threatcrush", "config.json");
11312
12770
  function readConfig2() {
11313
12771
  try {
11314
- return JSON.parse((0, import_node_fs12.readFileSync)(CONFIG_PATH2, "utf-8"));
12772
+ return JSON.parse((0, import_node_fs14.readFileSync)(CONFIG_PATH2, "utf-8"));
11315
12773
  } catch {
11316
12774
  return {};
11317
12775
  }
@@ -11414,14 +12872,14 @@ function timeAgo(dateStr) {
11414
12872
 
11415
12873
  // src/commands/connect.ts
11416
12874
  var import_node_os7 = require("os");
11417
- var import_node_fs13 = require("fs");
11418
- var import_node_path9 = require("path");
12875
+ var import_node_fs15 = require("fs");
12876
+ var import_node_path12 = require("path");
11419
12877
  var import_node_child_process3 = require("child_process");
11420
12878
  var API_URL5 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
11421
- var CONFIG_PATH3 = (0, import_node_path9.join)((0, import_node_os7.homedir)(), ".threatcrush", "config.json");
12879
+ var CONFIG_PATH3 = (0, import_node_path12.join)((0, import_node_os7.homedir)(), ".threatcrush", "config.json");
11422
12880
  function readConfig3() {
11423
12881
  try {
11424
- return JSON.parse((0, import_node_fs13.readFileSync)(CONFIG_PATH3, "utf-8"));
12882
+ return JSON.parse((0, import_node_fs15.readFileSync)(CONFIG_PATH3, "utf-8"));
11425
12883
  } catch {
11426
12884
  return {};
11427
12885
  }
@@ -11576,19 +13034,19 @@ async function sshConnect(options) {
11576
13034
 
11577
13035
  // src/commands/daemon.ts
11578
13036
  var import_node_child_process7 = require("child_process");
11579
- var import_node_fs22 = require("fs");
11580
- var import_node_path13 = require("path");
11581
- var import_node_fs23 = require("fs");
13037
+ var import_node_fs24 = require("fs");
13038
+ var import_node_path16 = require("path");
13039
+ var import_node_fs25 = require("fs");
11582
13040
 
11583
13041
  // src/daemon/index.ts
11584
- var import_node_fs21 = require("fs");
11585
- var import_node_path12 = require("path");
13042
+ var import_node_fs23 = require("fs");
13043
+ var import_node_path15 = require("path");
11586
13044
  init_paths();
11587
13045
  init_pidfile();
11588
13046
 
11589
13047
  // src/daemon/ipc-server.ts
11590
13048
  var import_node_net2 = require("net");
11591
- var import_node_fs14 = require("fs");
13049
+ var import_node_fs16 = require("fs");
11592
13050
  init_paths();
11593
13051
 
11594
13052
  // src/daemon/event-bus.ts
@@ -11635,13 +13093,13 @@ var IpcServer = class {
11635
13093
  startedAt = /* @__PURE__ */ new Date();
11636
13094
  counters = { events: 0, threats: 0, alerts: 0 };
11637
13095
  async start() {
11638
- if ((0, import_node_fs14.existsSync)(PATHS.socket)) {
13096
+ if ((0, import_node_fs16.existsSync)(PATHS.socket)) {
11639
13097
  try {
11640
- (0, import_node_fs14.unlinkSync)(PATHS.socket);
13098
+ (0, import_node_fs16.unlinkSync)(PATHS.socket);
11641
13099
  } catch {
11642
13100
  }
11643
13101
  }
11644
- return new Promise((resolve3, reject) => {
13102
+ return new Promise((resolve5, reject) => {
11645
13103
  this.server = (0, import_node_net2.createServer)((sock) => this.handleClient(sock));
11646
13104
  this.server.on("error", reject);
11647
13105
  this.server.listen(PATHS.socket, () => {
@@ -11658,7 +13116,7 @@ var IpcServer = class {
11658
13116
  } catch {
11659
13117
  }
11660
13118
  }
11661
- resolve3();
13119
+ resolve5();
11662
13120
  });
11663
13121
  });
11664
13122
  }
@@ -11670,20 +13128,20 @@ var IpcServer = class {
11670
13128
  }
11671
13129
  }
11672
13130
  this.clients.clear();
11673
- return new Promise((resolve3) => {
13131
+ return new Promise((resolve5) => {
11674
13132
  if (!this.server) {
11675
13133
  try {
11676
- if ((0, import_node_fs14.existsSync)(PATHS.socket)) (0, import_node_fs14.unlinkSync)(PATHS.socket);
13134
+ if ((0, import_node_fs16.existsSync)(PATHS.socket)) (0, import_node_fs16.unlinkSync)(PATHS.socket);
11677
13135
  } catch {
11678
13136
  }
11679
- return resolve3();
13137
+ return resolve5();
11680
13138
  }
11681
13139
  this.server.close(() => {
11682
13140
  try {
11683
- if ((0, import_node_fs14.existsSync)(PATHS.socket)) (0, import_node_fs14.unlinkSync)(PATHS.socket);
13141
+ if ((0, import_node_fs16.existsSync)(PATHS.socket)) (0, import_node_fs16.unlinkSync)(PATHS.socket);
11684
13142
  } catch {
11685
13143
  }
11686
- resolve3();
13144
+ resolve5();
11687
13145
  });
11688
13146
  });
11689
13147
  }
@@ -11786,14 +13244,14 @@ var IpcServer = class {
11786
13244
  };
11787
13245
 
11788
13246
  // src/daemon/module-host.ts
11789
- var import_node_fs18 = require("fs");
11790
- var import_node_path10 = require("path");
13247
+ var import_node_fs20 = require("fs");
13248
+ var import_node_path13 = require("path");
11791
13249
  var import_node_url = require("url");
11792
13250
  var import_toml4 = __toESM(require_toml());
11793
13251
  init_paths();
11794
13252
 
11795
13253
  // src/daemon/watchers/log-watcher.ts
11796
- var import_node_fs15 = require("fs");
13254
+ var import_node_fs17 = require("fs");
11797
13255
  var import_node_readline4 = require("readline");
11798
13256
  init_state();
11799
13257
  var DEFAULT_SOURCES = [
@@ -11815,9 +13273,9 @@ var LogWatcher = class {
11815
13273
  start() {
11816
13274
  const started = [];
11817
13275
  for (const src of this.sources) {
11818
- if (!(0, import_node_fs15.existsSync)(src.path)) continue;
13276
+ if (!(0, import_node_fs17.existsSync)(src.path)) continue;
11819
13277
  try {
11820
- (0, import_node_fs15.accessSync)(src.path, import_node_fs15.constants.R_OK);
13278
+ (0, import_node_fs17.accessSync)(src.path, import_node_fs17.constants.R_OK);
11821
13279
  } catch {
11822
13280
  continue;
11823
13281
  }
@@ -11837,7 +13295,7 @@ var LogWatcher = class {
11837
13295
  }
11838
13296
  tail(src) {
11839
13297
  try {
11840
- this.positions.set(src.path, (0, import_node_fs15.statSync)(src.path).size);
13298
+ this.positions.set(src.path, (0, import_node_fs17.statSync)(src.path).size);
11841
13299
  } catch {
11842
13300
  this.positions.set(src.path, 0);
11843
13301
  }
@@ -11848,7 +13306,7 @@ var LogWatcher = class {
11848
13306
  poll(src) {
11849
13307
  let stat;
11850
13308
  try {
11851
- stat = (0, import_node_fs15.statSync)(src.path);
13309
+ stat = (0, import_node_fs17.statSync)(src.path);
11852
13310
  } catch {
11853
13311
  return;
11854
13312
  }
@@ -11858,7 +13316,7 @@ var LogWatcher = class {
11858
13316
  return;
11859
13317
  }
11860
13318
  if (stat.size === prev) return;
11861
- const stream = (0, import_node_fs15.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
13319
+ const stream = (0, import_node_fs17.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
11862
13320
  stream.on("error", () => this.positions.set(src.path, stat.size));
11863
13321
  const rl = (0, import_node_readline4.createInterface)({ input: stream });
11864
13322
  rl.on("error", () => {
@@ -12051,7 +13509,7 @@ function realtimeToDate(rt) {
12051
13509
 
12052
13510
  // src/modules/network-monitor/index.ts
12053
13511
  var import_node_child_process5 = require("child_process");
12054
- var import_node_fs16 = require("fs");
13512
+ var import_node_fs18 = require("fs");
12055
13513
  init_state();
12056
13514
  var NetworkMonitor = class {
12057
13515
  constructor(bus2) {
@@ -12090,7 +13548,7 @@ var NetworkMonitor = class {
12090
13548
  hasConntrackOrSs() {
12091
13549
  const ss = (0, import_node_child_process5.spawnSync)("ss", ["--version"], { stdio: "pipe" });
12092
13550
  if (ss.status === 0) return true;
12093
- return (0, import_node_fs16.existsSync)("/proc/net/tcp");
13551
+ return (0, import_node_fs18.existsSync)("/proc/net/tcp");
12094
13552
  }
12095
13553
  poll() {
12096
13554
  try {
@@ -12229,7 +13687,7 @@ var NetworkMonitor = class {
12229
13687
  };
12230
13688
 
12231
13689
  // src/modules/dns-monitor/index.ts
12232
- var import_node_fs17 = require("fs");
13690
+ var import_node_fs19 = require("fs");
12233
13691
  var import_node_readline5 = require("readline");
12234
13692
  init_state();
12235
13693
  var DNS_LOG_SOURCES = [
@@ -12264,9 +13722,9 @@ var DnsMonitor = class {
12264
13722
  entropyThreshold = 3.5;
12265
13723
  start() {
12266
13724
  const sources = DNS_LOG_SOURCES.filter((p) => {
12267
- if (!(0, import_node_fs17.existsSync)(p)) return false;
13725
+ if (!(0, import_node_fs19.existsSync)(p)) return false;
12268
13726
  try {
12269
- (0, import_node_fs17.accessSync)(p, import_node_fs17.constants.R_OK);
13727
+ (0, import_node_fs19.accessSync)(p, import_node_fs19.constants.R_OK);
12270
13728
  return true;
12271
13729
  } catch {
12272
13730
  return false;
@@ -12290,7 +13748,7 @@ var DnsMonitor = class {
12290
13748
  }
12291
13749
  tailLog(path) {
12292
13750
  try {
12293
- this.positions.set(path, (0, import_node_fs17.statSync)(path).size);
13751
+ this.positions.set(path, (0, import_node_fs19.statSync)(path).size);
12294
13752
  } catch {
12295
13753
  this.positions.set(path, 0);
12296
13754
  }
@@ -12300,7 +13758,7 @@ var DnsMonitor = class {
12300
13758
  pollLog(path) {
12301
13759
  let stat;
12302
13760
  try {
12303
- stat = (0, import_node_fs17.statSync)(path);
13761
+ stat = (0, import_node_fs19.statSync)(path);
12304
13762
  } catch {
12305
13763
  return;
12306
13764
  }
@@ -12310,7 +13768,7 @@ var DnsMonitor = class {
12310
13768
  return;
12311
13769
  }
12312
13770
  if (stat.size === prev) return;
12313
- const stream = (0, import_node_fs17.createReadStream)(path, { start: prev, encoding: "utf-8" });
13771
+ const stream = (0, import_node_fs19.createReadStream)(path, { start: prev, encoding: "utf-8" });
12314
13772
  stream.on("error", () => this.positions.set(path, stat.size));
12315
13773
  const rl = (0, import_node_readline5.createInterface)({ input: stream });
12316
13774
  rl.on("line", (line) => this.parseDnsLine(line));
@@ -12542,15 +14000,15 @@ var ModuleHost = class {
12542
14000
  for (const m of builtins) this.modules.set(m.name, m);
12543
14001
  }
12544
14002
  async discoverAndStartInstalled() {
12545
- if (!(0, import_node_fs18.existsSync)(PATHS.moduleDir)) return;
14003
+ if (!(0, import_node_fs20.existsSync)(PATHS.moduleDir)) return;
12546
14004
  const configs = loadModuleConfigs(PATHS.confD);
12547
- const entries = (0, import_node_fs18.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
14005
+ const entries = (0, import_node_fs20.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
12548
14006
  for (const entry of entries) {
12549
14007
  if (!entry.isDirectory()) continue;
12550
- const manifestPath = (0, import_node_path10.join)(PATHS.moduleDir, entry.name, "mod.toml");
12551
- if (!(0, import_node_fs18.existsSync)(manifestPath)) continue;
14008
+ const manifestPath = (0, import_node_path13.join)(PATHS.moduleDir, entry.name, "mod.toml");
14009
+ if (!(0, import_node_fs20.existsSync)(manifestPath)) continue;
12552
14010
  try {
12553
- const manifest = import_toml4.default.parse((0, import_node_fs18.readFileSync)(manifestPath, "utf-8"));
14011
+ const manifest = import_toml4.default.parse((0, import_node_fs20.readFileSync)(manifestPath, "utf-8"));
12554
14012
  const name = manifest.module?.name || entry.name;
12555
14013
  const defaults = manifest.module?.config?.defaults || {};
12556
14014
  const config = {
@@ -12564,7 +14022,7 @@ var ModuleHost = class {
12564
14022
  source: "installed",
12565
14023
  status: config.enabled === false ? "disabled" : "loaded",
12566
14024
  events: 0,
12567
- path: (0, import_node_path10.join)(PATHS.moduleDir, entry.name),
14025
+ path: (0, import_node_path13.join)(PATHS.moduleDir, entry.name),
12568
14026
  config
12569
14027
  };
12570
14028
  this.modules.set(name, hosted);
@@ -12579,7 +14037,7 @@ var ModuleHost = class {
12579
14037
  status: "error",
12580
14038
  events: 0,
12581
14039
  detail: `manifest load failed: ${String(err.message || err)}`,
12582
- path: (0, import_node_path10.join)(PATHS.moduleDir, entry.name)
14040
+ path: (0, import_node_path13.join)(PATHS.moduleDir, entry.name)
12583
14041
  });
12584
14042
  }
12585
14043
  }
@@ -12611,17 +14069,17 @@ var ModuleHost = class {
12611
14069
  }
12612
14070
  }
12613
14071
  installedEntrypoint(modulePath) {
12614
- const packageJson = (0, import_node_path10.join)(modulePath, "package.json");
14072
+ const packageJson = (0, import_node_path13.join)(modulePath, "package.json");
12615
14073
  const candidates = [];
12616
- if ((0, import_node_fs18.existsSync)(packageJson)) {
14074
+ if ((0, import_node_fs20.existsSync)(packageJson)) {
12617
14075
  try {
12618
- const pkg = JSON.parse((0, import_node_fs18.readFileSync)(packageJson, "utf-8"));
12619
- if (pkg.main) candidates.push((0, import_node_path10.join)(modulePath, pkg.main));
14076
+ const pkg = JSON.parse((0, import_node_fs20.readFileSync)(packageJson, "utf-8"));
14077
+ if (pkg.main) candidates.push((0, import_node_path13.join)(modulePath, pkg.main));
12620
14078
  } catch {
12621
14079
  }
12622
14080
  }
12623
- candidates.push((0, import_node_path10.join)(modulePath, "dist", "index.js"), (0, import_node_path10.join)(modulePath, "index.js"));
12624
- return candidates.find((candidate) => (0, import_node_fs18.existsSync)(candidate)) || null;
14081
+ candidates.push((0, import_node_path13.join)(modulePath, "dist", "index.js"), (0, import_node_path13.join)(modulePath, "index.js"));
14082
+ return candidates.find((candidate) => (0, import_node_fs20.existsSync)(candidate)) || null;
12625
14083
  }
12626
14084
  isThreatCrushModule(value) {
12627
14085
  return Boolean(
@@ -13142,8 +14600,8 @@ var RuleEngine = class {
13142
14600
  };
13143
14601
 
13144
14602
  // src/daemon/rules/loader.ts
13145
- var import_node_fs19 = require("fs");
13146
- var import_node_path11 = require("path");
14603
+ var import_node_fs21 = require("fs");
14604
+ var import_node_path14 = require("path");
13147
14605
 
13148
14606
  // src/daemon/rules/default-rules.ts
13149
14607
  var DEFAULT_RULES = [
@@ -13427,11 +14885,11 @@ var RULES_DIR = "/etc/threatcrush/rules.d";
13427
14885
  function loadAllRules(customDir) {
13428
14886
  const rules = [...DEFAULT_RULES];
13429
14887
  const dir = customDir || RULES_DIR;
13430
- if ((0, import_node_fs19.existsSync)(dir)) {
13431
- const files = (0, import_node_fs19.readdirSync)(dir).filter((f) => f.endsWith(".json"));
14888
+ if ((0, import_node_fs21.existsSync)(dir)) {
14889
+ const files = (0, import_node_fs21.readdirSync)(dir).filter((f) => f.endsWith(".json"));
13432
14890
  for (const file of files) {
13433
14891
  try {
13434
- const raw = (0, import_node_fs19.readFileSync)((0, import_node_path11.join)(dir, file), "utf-8");
14892
+ const raw = (0, import_node_fs21.readFileSync)((0, import_node_path14.join)(dir, file), "utf-8");
13435
14893
  const parsed = JSON.parse(raw);
13436
14894
  const customRules = Array.isArray(parsed) ? parsed : [parsed];
13437
14895
  for (const rule of customRules) {
@@ -13456,6 +14914,12 @@ function loadAllRules(customDir) {
13456
14914
 
13457
14915
  // src/daemon/firewall/adapters.ts
13458
14916
  var import_node_child_process6 = require("child_process");
14917
+ var import_node_net3 = require("net");
14918
+ function assertValidFirewallIp(ip) {
14919
+ if ((0, import_node_net3.isIP)(ip) !== 4) {
14920
+ throw new Error(`Invalid IPv4 address: ${ip}`);
14921
+ }
14922
+ }
13459
14923
  var NftablesAdapter = class {
13460
14924
  name = "nftables";
13461
14925
  table = "threatcrush";
@@ -13475,16 +14939,19 @@ var NftablesAdapter = class {
13475
14939
  }
13476
14940
  }
13477
14941
  async block(ip) {
14942
+ assertValidFirewallIp(ip);
13478
14943
  this.ensureSetup();
13479
14944
  (0, import_node_child_process6.execSync)(`nft add element inet ${this.table} ${this.set} '{ ${ip} }'`);
13480
14945
  }
13481
14946
  async unblock(ip) {
14947
+ assertValidFirewallIp(ip);
13482
14948
  try {
13483
14949
  (0, import_node_child_process6.execSync)(`nft delete element inet ${this.table} ${this.set} '{ ${ip} }'`);
13484
14950
  } catch {
13485
14951
  }
13486
14952
  }
13487
14953
  async isBlocked(ip) {
14954
+ assertValidFirewallIp(ip);
13488
14955
  try {
13489
14956
  const output = (0, import_node_child_process6.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
13490
14957
  return output.includes(ip);
@@ -13519,17 +14986,20 @@ var IptablesAdapter = class {
13519
14986
  }
13520
14987
  }
13521
14988
  async block(ip) {
14989
+ assertValidFirewallIp(ip);
13522
14990
  this.ensureChain();
13523
14991
  if (await this.isBlocked(ip)) return;
13524
14992
  (0, import_node_child_process6.execSync)(`iptables -A ${this.chain} -s ${ip} -j DROP`);
13525
14993
  }
13526
14994
  async unblock(ip) {
14995
+ assertValidFirewallIp(ip);
13527
14996
  try {
13528
14997
  (0, import_node_child_process6.execSync)(`iptables -D ${this.chain} -s ${ip} -j DROP`);
13529
14998
  } catch {
13530
14999
  }
13531
15000
  }
13532
15001
  async isBlocked(ip) {
15002
+ assertValidFirewallIp(ip);
13533
15003
  try {
13534
15004
  const output = (0, import_node_child_process6.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
13535
15005
  return output.includes(ip);
@@ -13558,12 +15028,15 @@ var DryRunAdapter = class {
13558
15028
  return true;
13559
15029
  }
13560
15030
  async block(ip) {
15031
+ assertValidFirewallIp(ip);
13561
15032
  this.blocked.add(ip);
13562
15033
  }
13563
15034
  async unblock(ip) {
15035
+ assertValidFirewallIp(ip);
13564
15036
  this.blocked.delete(ip);
13565
15037
  }
13566
15038
  async isBlocked(ip) {
15039
+ assertValidFirewallIp(ip);
13567
15040
  return this.blocked.has(ip);
13568
15041
  }
13569
15042
  async listBlocked() {
@@ -13579,7 +15052,7 @@ function detectFirewallAdapter() {
13579
15052
  }
13580
15053
 
13581
15054
  // src/daemon/firewall/remediation.ts
13582
- var import_node_fs20 = require("fs");
15055
+ var import_node_fs22 = require("fs");
13583
15056
  init_state();
13584
15057
  init_paths();
13585
15058
  var DEFAULT_CONFIG2 = {
@@ -13736,7 +15209,7 @@ var RemediationManager = class {
13736
15209
  }
13737
15210
  logLine(line) {
13738
15211
  try {
13739
- (0, import_node_fs20.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
15212
+ (0, import_node_fs22.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
13740
15213
  `);
13741
15214
  } catch {
13742
15215
  }
@@ -13793,9 +15266,9 @@ async function flushTelemetry(timeoutMs = 2e3) {
13793
15266
  }
13794
15267
 
13795
15268
  // src/daemon/index.ts
13796
- function readVersion() {
15269
+ function readVersion2() {
13797
15270
  try {
13798
- const pkg = JSON.parse((0, import_node_fs21.readFileSync)((0, import_node_path12.join)(__dirname, "..", "package.json"), "utf-8"));
15271
+ const pkg = JSON.parse((0, import_node_fs23.readFileSync)((0, import_node_path15.join)(__dirname, "..", "package.json"), "utf-8"));
13799
15272
  return pkg.version || "0.0.0";
13800
15273
  } catch {
13801
15274
  return "0.0.0";
@@ -13803,7 +15276,7 @@ function readVersion() {
13803
15276
  }
13804
15277
  function logLine(line) {
13805
15278
  try {
13806
- (0, import_node_fs21.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
15279
+ (0, import_node_fs23.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
13807
15280
  `);
13808
15281
  } catch {
13809
15282
  }
@@ -13824,14 +15297,14 @@ async function runDaemon() {
13824
15297
  logLine(`[daemon] unhandledRejection: ${String(reason)}`);
13825
15298
  captureException(reason);
13826
15299
  });
13827
- const version = readVersion();
15300
+ const version = readVersion2();
13828
15301
  logLine(`[daemon] starting threatcrushd v${version} mode=${PATHS.mode}`);
13829
15302
  try {
13830
15303
  initStateDB(PATHS.stateDb);
13831
15304
  } catch (err) {
13832
15305
  logLine(`[daemon] state db unavailable: ${err.message}`);
13833
15306
  }
13834
- const config = loadConfig((0, import_node_fs21.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
15307
+ const config = loadConfig((0, import_node_fs23.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
13835
15308
  bus.on("event", (event) => {
13836
15309
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
13837
15310
  });
@@ -13923,7 +15396,7 @@ async function runDaemon() {
13923
15396
  init_paths();
13924
15397
  init_pidfile();
13925
15398
  init_ipc_client();
13926
- var DAEMON_ENTRY = (0, import_node_path13.join)(__dirname, "daemon.js");
15399
+ var DAEMON_ENTRY = (0, import_node_path16.join)(__dirname, "daemon.js");
13927
15400
  async function daemonForeground() {
13928
15401
  await runDaemon();
13929
15402
  }
@@ -13934,7 +15407,7 @@ async function daemonStart() {
13934
15407
  return;
13935
15408
  }
13936
15409
  ensureRuntimeDirs();
13937
- if (!(0, import_node_fs22.existsSync)(DAEMON_ENTRY)) {
15410
+ if (!(0, import_node_fs24.existsSync)(DAEMON_ENTRY)) {
13938
15411
  console.log(source_default.red(` Daemon entry not found at ${DAEMON_ENTRY}.`));
13939
15412
  console.log(source_default.dim(" Reinstall with `threatcrush update` or `pnpm run build` in the cli package."));
13940
15413
  return;
@@ -13942,8 +15415,8 @@ async function daemonStart() {
13942
15415
  let out;
13943
15416
  let err;
13944
15417
  try {
13945
- out = (0, import_node_fs23.openSync)(PATHS.logFile, "a");
13946
- err = (0, import_node_fs23.openSync)(PATHS.logFile, "a");
15418
+ out = (0, import_node_fs25.openSync)(PATHS.logFile, "a");
15419
+ err = (0, import_node_fs25.openSync)(PATHS.logFile, "a");
13947
15420
  } catch (e) {
13948
15421
  const code = e.code;
13949
15422
  console.log(source_default.red(` \u2717 Cannot write daemon log at ${PATHS.logFile} (${code ?? "error"}).`));
@@ -14022,19 +15495,19 @@ async function daemonStop() {
14022
15495
 
14023
15496
  // src/commands/service.ts
14024
15497
  var import_node_child_process8 = require("child_process");
14025
- var import_node_fs24 = require("fs");
14026
- var import_node_path14 = require("path");
15498
+ var import_node_fs26 = require("fs");
15499
+ var import_node_path17 = require("path");
14027
15500
  var UNIT_PATH = "/etc/systemd/system/threatcrushd.service";
14028
15501
  function resolveTemplate() {
14029
- const templatePath = (0, import_node_path14.join)(__dirname, "systemd", "threatcrushd.service");
14030
- if (!(0, import_node_fs24.existsSync)(templatePath)) {
15502
+ const templatePath = (0, import_node_path17.join)(__dirname, "systemd", "threatcrushd.service");
15503
+ if (!(0, import_node_fs26.existsSync)(templatePath)) {
14031
15504
  throw new Error(`systemd unit template not found at ${templatePath}`);
14032
15505
  }
14033
- return (0, import_node_fs24.readFileSync)(templatePath, "utf-8");
15506
+ return (0, import_node_fs26.readFileSync)(templatePath, "utf-8");
14034
15507
  }
14035
15508
  function resolveBinPath() {
14036
15509
  const arg = process.argv[1];
14037
- if (arg && (0, import_node_fs24.existsSync)(arg)) return arg;
15510
+ if (arg && (0, import_node_fs26.existsSync)(arg)) return arg;
14038
15511
  try {
14039
15512
  return (0, import_node_child_process8.execSync)("command -v threatcrush", { encoding: "utf-8" }).trim();
14040
15513
  } catch {
@@ -14055,7 +15528,7 @@ async function installServiceCommand() {
14055
15528
  return;
14056
15529
  }
14057
15530
  const unit = resolveTemplate().replace("{{BIN_PATH}}", resolveBinPath());
14058
- (0, import_node_fs24.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
15531
+ (0, import_node_fs26.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
14059
15532
  console.log(source_default.green(` \u2713 Installed unit file: ${UNIT_PATH}`));
14060
15533
  ensureSystemDirs();
14061
15534
  try {
@@ -14079,17 +15552,17 @@ function ensureSystemDirs() {
14079
15552
  ];
14080
15553
  let admGid = null;
14081
15554
  try {
14082
- admGid = (0, import_node_fs24.statSync)("/var/log/auth.log").gid;
15555
+ admGid = (0, import_node_fs26.statSync)("/var/log/auth.log").gid;
14083
15556
  } catch {
14084
15557
  }
14085
15558
  for (const { path, sticky } of dirs) {
14086
15559
  try {
14087
- (0, import_node_fs24.mkdirSync)(path, { recursive: true });
15560
+ (0, import_node_fs26.mkdirSync)(path, { recursive: true });
14088
15561
  } catch {
14089
15562
  }
14090
15563
  if (admGid !== null) {
14091
15564
  try {
14092
- (0, import_node_fs24.chmodSync)(path, sticky ? 1533 : 509);
15565
+ (0, import_node_fs26.chmodSync)(path, sticky ? 1533 : 509);
14093
15566
  (0, import_node_child_process8.execSync)(`chgrp adm ${path}`, { stdio: "ignore" });
14094
15567
  } catch {
14095
15568
  }
@@ -14116,7 +15589,7 @@ async function uninstallServiceCommand() {
14116
15589
  } catch {
14117
15590
  }
14118
15591
  try {
14119
- if ((0, import_node_fs24.existsSync)(UNIT_PATH)) {
15592
+ if ((0, import_node_fs26.existsSync)(UNIT_PATH)) {
14120
15593
  (0, import_node_child_process8.execSync)(`rm -f ${UNIT_PATH}`);
14121
15594
  console.log(source_default.green(` \u2713 Removed unit file: ${UNIT_PATH}`));
14122
15595
  }
@@ -14215,16 +15688,16 @@ function welcomeCommand() {
14215
15688
  }
14216
15689
 
14217
15690
  // src/commands/properties.ts
14218
- var import_node_fs25 = require("fs");
14219
- var import_node_path15 = require("path");
15691
+ var import_node_fs27 = require("fs");
15692
+ var import_node_path18 = require("path");
14220
15693
  var import_node_readline6 = __toESM(require("readline"));
14221
15694
  var API_URL7 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
14222
15695
  var KINDS = ["url", "api", "domain", "ip", "repo"];
14223
15696
  function prompt2(question) {
14224
15697
  const rl = import_node_readline6.default.createInterface({ input: process.stdin, output: process.stdout });
14225
- return new Promise((resolve3) => rl.question(question, (answer) => {
15698
+ return new Promise((resolve5) => rl.question(question, (answer) => {
14226
15699
  rl.close();
14227
- resolve3(answer.trim());
15700
+ resolve5(answer.trim());
14228
15701
  }));
14229
15702
  }
14230
15703
  async function requireAuth() {
@@ -14545,8 +16018,8 @@ async function propertiesRunsCommand(opts) {
14545
16018
  }
14546
16019
  }
14547
16020
  function parseImportFile(path) {
14548
- const ext = (0, import_node_path15.extname)(path).toLowerCase();
14549
- const raw = (0, import_node_fs25.readFileSync)(path, "utf-8");
16021
+ const ext = (0, import_node_path18.extname)(path).toLowerCase();
16022
+ const raw = (0, import_node_fs27.readFileSync)(path, "utf-8");
14550
16023
  if (ext === ".json") {
14551
16024
  const parsed = JSON.parse(raw);
14552
16025
  if (!Array.isArray(parsed)) throw new Error("JSON must be an array of objects");
@@ -14736,7 +16209,7 @@ async function rulesCommand(opts) {
14736
16209
  }
14737
16210
 
14738
16211
  // src/commands/harden.ts
14739
- var import_node_fs26 = require("fs");
16212
+ var import_node_fs28 = require("fs");
14740
16213
  var import_node_child_process9 = require("child_process");
14741
16214
  function tryExec(cmd) {
14742
16215
  try {
@@ -14747,7 +16220,7 @@ function tryExec(cmd) {
14747
16220
  }
14748
16221
  function tryRead(path) {
14749
16222
  try {
14750
- return (0, import_node_fs26.readFileSync)(path, "utf-8");
16223
+ return (0, import_node_fs28.readFileSync)(path, "utf-8");
14751
16224
  } catch {
14752
16225
  return null;
14753
16226
  }
@@ -14851,8 +16324,8 @@ function checkSshWeakConfig() {
14851
16324
  };
14852
16325
  }
14853
16326
  function checkAutoUpdates() {
14854
- const unattended = (0, import_node_fs26.existsSync)("/etc/apt/apt.conf.d/20auto-upgrades") || (0, import_node_fs26.existsSync)("/etc/apt/apt.conf.d/50unattended-upgrades");
14855
- const dnfAuto = (0, import_node_fs26.existsSync)("/etc/dnf/automatic.conf");
16327
+ 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");
16328
+ const dnfAuto = (0, import_node_fs28.existsSync)("/etc/dnf/automatic.conf");
14856
16329
  if (unattended || dnfAuto) {
14857
16330
  return {
14858
16331
  key: "auto-updates",
@@ -14978,7 +16451,7 @@ function checkFail2ban() {
14978
16451
  explanation: "fail2ban is installed and running."
14979
16452
  };
14980
16453
  }
14981
- if ((0, import_node_fs26.existsSync)("/etc/fail2ban/fail2ban.conf")) {
16454
+ if ((0, import_node_fs28.existsSync)("/etc/fail2ban/fail2ban.conf")) {
14982
16455
  return {
14983
16456
  key: checkKey,
14984
16457
  status: "warn",
@@ -15207,10 +16680,10 @@ async function allowlistCommand(opts) {
15207
16680
 
15208
16681
  // src/index.ts
15209
16682
  init_paths();
15210
- var PKG_VERSION = "0.1.8";
16683
+ var PKG_VERSION2 = "0.1.8";
15211
16684
  try {
15212
- const pkg = JSON.parse((0, import_node_fs27.readFileSync)((0, import_node_path16.join)(__dirname, "..", "package.json"), "utf-8"));
15213
- PKG_VERSION = pkg.version;
16685
+ const pkg = JSON.parse((0, import_node_fs29.readFileSync)((0, import_node_path19.join)(__dirname, "..", "package.json"), "utf-8"));
16686
+ PKG_VERSION2 = pkg.version;
15214
16687
  } catch {
15215
16688
  }
15216
16689
  var LOGO2 = `
@@ -15225,7 +16698,7 @@ ${source_default.dim(" C R U S H")}
15225
16698
  var API_URL8 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
15226
16699
  var PKG_NAME = "@profullstack/threatcrush";
15227
16700
  var DESKTOP_PKG_NAME = "@profullstack/threatcrush-desktop";
15228
- var INSTALL_CONFIG_PATH = (0, import_node_path16.join)((0, import_node_os8.homedir)(), ".threatcrush", "install.json");
16701
+ var INSTALL_CONFIG_PATH = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush", "install.json");
15229
16702
  function detectPackageManager() {
15230
16703
  try {
15231
16704
  const npmGlobal = (0, import_node_child_process10.execSync)("npm ls -g --depth=0 --json 2>/dev/null", { encoding: "utf-8" });
@@ -15251,7 +16724,7 @@ function detectPackageManager() {
15251
16724
  }
15252
16725
  function readInstallConfig() {
15253
16726
  try {
15254
- return JSON.parse((0, import_node_fs27.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
16727
+ return JSON.parse((0, import_node_fs29.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
15255
16728
  } catch {
15256
16729
  return {};
15257
16730
  }
@@ -15318,7 +16791,7 @@ ${source_default.dim("Examples:")}
15318
16791
  ${source_default.green("$")} threatcrush modules install ${source_default.dim("# Install a module")}
15319
16792
  ${source_default.green("$")} threatcrush update ${source_default.dim("# Update to latest")}
15320
16793
  ${source_default.green("$")} threatcrush remove ${source_default.dim("# Uninstall completely")}`
15321
- ).version(PKG_VERSION, "-v, --version", "Show version number").helpOption("-h, --help", "Show this help").addHelpText("after", `
16794
+ ).version(PKG_VERSION2, "-v, --version", "Show version number").helpOption("-h, --help", "Show this help").addHelpText("after", `
15322
16795
  ${source_default.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}
15323
16796
  ${source_default.dim("Modules:")}
15324
16797
  ThreatCrush uses pluggable security modules. Core modules included:
@@ -15354,8 +16827,33 @@ program2.command("logout").description("Clear stored threatcrush.com credentials
15354
16827
  program2.command("whoami").description("Show the currently logged-in threatcrush.com account").action(async () => {
15355
16828
  await whoamiCommand();
15356
16829
  });
15357
- program2.command("scan").description("Scan codebase for vulnerabilities and secrets").argument("[path]", "Path to scan", ".").action(async (targetPath) => {
15358
- await scanCommand(targetPath);
16830
+ program2.command("scan").description("Scan codebase for vulnerabilities and secrets").argument("[path]", "Path to scan", ".").option("-f, --format <format>", "output format: text, json, or sarif", "text").option("-o, --output <file>", "write json/sarif output to a file instead of stdout").option(
16831
+ "--fail-on <severities>",
16832
+ "exit 1 when a finding at or above any of these exists (comma-separated: critical,high,medium,low,info)"
16833
+ ).option(
16834
+ "--path-prefix <prefix>",
16835
+ "prepend this to SARIF file URIs \u2014 use when the scan root is not the repository root"
16836
+ ).option("--deps", "also query OSV.dev for advisories against lockfile versions (network)").option("-v, --verbose", "list the paths that could not be read").action(async (targetPath, opts) => {
16837
+ const format = (opts.format ?? "text").toLowerCase();
16838
+ if (!["text", "json", "sarif"].includes(format)) {
16839
+ console.error(source_default.red(`Unknown --format "${opts.format}" (expected text, json, or sarif)`));
16840
+ process.exit(2);
16841
+ }
16842
+ let failOn;
16843
+ try {
16844
+ failOn = parseFailOn(opts.failOn);
16845
+ } catch (err) {
16846
+ console.error(source_default.red(err.message));
16847
+ process.exit(2);
16848
+ }
16849
+ await scanCommand(targetPath, {
16850
+ format,
16851
+ output: opts.output,
16852
+ failOn,
16853
+ pathPrefix: opts.pathPrefix,
16854
+ dependencies: opts.deps,
16855
+ verbose: opts.verbose
16856
+ });
15359
16857
  });
15360
16858
  program2.command("pentest").description("Penetration test URLs and APIs").argument("<url>", "Target URL to pentest").action(async (url) => {
15361
16859
  await pentestCommand(url);
@@ -15401,7 +16899,7 @@ program2.command("uninstall-service").description("Remove the threatcrushd syste
15401
16899
  program2.command("logs").description("Tail daemon logs").action(async () => {
15402
16900
  console.log(LOGO2);
15403
16901
  const logPath = PATHS.logFile;
15404
- if (!(0, import_node_fs27.existsSync)(logPath)) {
16902
+ if (!(0, import_node_fs29.existsSync)(logPath)) {
15405
16903
  console.log(source_default.yellow(` No log file found at ${logPath}`));
15406
16904
  console.log(source_default.dim(" Start the daemon first with `threatcrush start`\n"));
15407
16905
  return;
@@ -15414,10 +16912,10 @@ program2.command("logs").description("Tail daemon logs").action(async () => {
15414
16912
  program2.command("activate").description("Activate your license key").action(async () => {
15415
16913
  console.log(LOGO2);
15416
16914
  const rl = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
15417
- const key = await new Promise((resolve3) => {
16915
+ const key = await new Promise((resolve5) => {
15418
16916
  rl.question(source_default.green(" Enter your ThreatCrush license key: "), (answer) => {
15419
16917
  rl.close();
15420
- resolve3(answer.trim());
16918
+ resolve5(answer.trim());
15421
16919
  });
15422
16920
  });
15423
16921
  if (!key) {
@@ -15504,10 +17002,10 @@ program2.command("update").description("Update ThreatCrush CLI and installed bun
15504
17002
  program2.command("remove").description("Uninstall ThreatCrush and the installed bundle").alias("uninstall").action(async () => {
15505
17003
  console.log(LOGO2);
15506
17004
  const rl = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
15507
- const confirm = await new Promise((resolve3) => {
17005
+ const confirm = await new Promise((resolve5) => {
15508
17006
  rl.question(source_default.yellow(" Are you sure you want to uninstall ThreatCrush? (y/N): "), (answer) => {
15509
17007
  rl.close();
15510
- resolve3(answer.trim().toLowerCase());
17008
+ resolve5(answer.trim().toLowerCase());
15511
17009
  });
15512
17010
  });
15513
17011
  if (confirm !== "y" && confirm !== "yes") {
@@ -15606,19 +17104,19 @@ storeCmd.command("search <query>").description("Search for modules in the store"
15606
17104
  });
15607
17105
  storeCmd.command("publish <url>").description("Publish a module from a git URL or web URL").action(async (url) => {
15608
17106
  console.log(LOGO2);
15609
- const configPath = (0, import_node_path16.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
17107
+ const configPath = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
15610
17108
  let email = "";
15611
17109
  try {
15612
- const config = JSON.parse((0, import_node_fs27.readFileSync)(configPath, "utf-8"));
17110
+ const config = JSON.parse((0, import_node_fs29.readFileSync)(configPath, "utf-8"));
15613
17111
  email = config.email || "";
15614
17112
  } catch {
15615
17113
  }
15616
17114
  if (!email) {
15617
17115
  const rl2 = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
15618
- email = await new Promise((resolve3) => {
17116
+ email = await new Promise((resolve5) => {
15619
17117
  rl2.question(source_default.green(" Enter your email: "), (answer) => {
15620
17118
  rl2.close();
15621
- resolve3(answer.trim());
17119
+ resolve5(answer.trim());
15622
17120
  });
15623
17121
  });
15624
17122
  if (!email || !email.includes("@")) {
@@ -15626,9 +17124,9 @@ storeCmd.command("publish <url>").description("Publish a module from a git URL o
15626
17124
  return;
15627
17125
  }
15628
17126
  try {
15629
- const dir = (0, import_node_path16.join)((0, import_node_os8.homedir)(), ".threatcrush");
15630
- if (!(0, import_node_fs27.existsSync)(dir)) (0, import_node_fs27.mkdirSync)(dir, { recursive: true });
15631
- (0, import_node_fs27.writeFileSync)(configPath, JSON.stringify({ email }, null, 2));
17127
+ const dir = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush");
17128
+ if (!(0, import_node_fs29.existsSync)(dir)) (0, import_node_fs29.mkdirSync)(dir, { recursive: true });
17129
+ (0, import_node_fs29.writeFileSync)(configPath, JSON.stringify({ email }, null, 2));
15632
17130
  console.log(source_default.dim(` Saved email to ${configPath}`));
15633
17131
  } catch {
15634
17132
  }
@@ -15672,10 +17170,10 @@ storeCmd.command("publish <url>").description("Publish a module from a git URL o
15672
17170
  }
15673
17171
  console.log();
15674
17172
  const rl = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
15675
- const confirm = await new Promise((resolve3) => {
17173
+ const confirm = await new Promise((resolve5) => {
15676
17174
  rl.question(source_default.yellow(" Publish this module? (y/N): "), (answer) => {
15677
17175
  rl.close();
15678
- resolve3(answer.trim().toLowerCase());
17176
+ resolve5(answer.trim().toLowerCase());
15679
17177
  });
15680
17178
  });
15681
17179
  if (confirm !== "y" && confirm !== "yes") {