@profullstack/threatcrush 0.2.2 → 0.4.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,1134 @@ 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\s*\.\s*(?:get|getAll|has|entries|keys|values|forEach)\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 = (
9719
+ // `esc(`, `aEsc(`, `htmlEscape(`, `escapeHtml(` — the escaper is almost
9720
+ // never *named* `escapeHtml` in real code. It gets aliased to something
9721
+ // short because it is called on nearly every interpolation, so matching only
9722
+ // the long spellings reported the codebases that escape most rigorously.
9723
+ //
9724
+ // The identifier must END at the escaper (with at most a known output-context
9725
+ // suffix). An earlier, looser form also matched `describe(`, which would have
9726
+ // silenced findings across every test file in every repository.
9727
+ /\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\b\w{0,6}[Ee]sc(?:ape)?(?:[Hh]tml|HTML|[Xx]ml|XML|[Ss]ql|[Aa]ttr|[Jj]s|[Uu]ri|[Uu]rl)?\s*\(|\bhtml_escape\b|\bhtmlspecialchars\s*\(|\bsanitiz\w*\b|\bencoded\b|\brealpath\b|\bcommonpath\b|\bresolve\(\)\.startsWith\b|\bprocess\.env\b|\bos\.environ\b|\bgetenv\b|\bENV\s*\[|setObjectInputFilter|ObjectInputFilter/i
9728
+ );
9729
+ var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
9730
+ var CODE_SINK = /\bglobalThis\s*\[|\bconstructor\b|\beval\b|\bFunction\b|\brun\s*\(|\bvm\s*\.\s*run/;
9731
+ var EXFIL_SINK = /\bconsole\s*\.\s*(?:log|debug|info|warn|error)\s*\(|\bfetch\s*\(|\baxios\b|\brequest\s*\(|\.\s*send\s*\(/;
9732
+ var SQL_KEYWORDS = "SELECT|INSERT\\s+INTO|INSERT|UPDATE|DELETE\\s+FROM|DELETE|DROP|UNION\\s+SELECT";
9733
+ var SQL_IN_DOUBLE = `"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*"`;
9734
+ var SQL_IN_SINGLE = `'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*'`;
9735
+ var SQL_STRING = `(?:${SQL_IN_DOUBLE}|${SQL_IN_SINGLE})`;
9736
+ var CODE_RULES = [
9737
+ // ── Injection: SQL ───────────────────────────────────────────────────────
9738
+ {
9739
+ id: "sql-string-concatenation",
9740
+ title: "SQL assembled by concatenation or interpolation",
9741
+ 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.",
9742
+ cwe: "CWE-89",
9743
+ severity: "critical",
9744
+ // The tail is what distinguishes assembly from parameterisation. A bound
9745
+ // query leaves a comma after the closing quote (`"… = $1", [id]`) and
9746
+ // matches none of these.
9747
+ pattern: new RegExp(
9748
+ `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"[^"\\n]*|'[^'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
9749
+ "i"
9750
+ )
9751
+ },
9752
+ {
9753
+ id: "sql-template-interpolation",
9754
+ title: "SQL built from a template literal or f-string",
9755
+ consequence: "Template interpolation is string concatenation with nicer syntax \u2014 it binds nothing and escapes nothing.",
9756
+ cwe: "CWE-89",
9757
+ severity: "critical",
9758
+ pattern: new RegExp(
9759
+ `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
9760
+ "i"
9761
+ )
9762
+ },
9763
+ {
9764
+ id: "sql-format-call",
9765
+ title: "SQL text produced by a format helper",
9766
+ consequence: "`Sprintf`/`String.format` substitute without quoting; the resulting string is concatenated SQL by another name.",
9767
+ cwe: "CWE-89",
9768
+ severity: "critical",
9769
+ languages: ["go", "java"],
9770
+ pattern: new RegExp(
9771
+ `\\b(?:fmt\\.Sprintf|String\\.format)\\s*\\(\\s*"[^"\\n]*(?:${SQL_KEYWORDS})\\b`,
9772
+ "i"
9773
+ )
9774
+ },
9775
+ {
9776
+ id: "rb-sql-interpolation",
9777
+ title: "ActiveRecord query built by string interpolation",
9778
+ consequence: '`where("\u2026 #{value}")` interpolates before the adapter sees it, so no binding ever happens.',
9779
+ cwe: "CWE-89",
9780
+ severity: "critical",
9781
+ languages: ["ruby"],
9782
+ pattern: /\b(?:where|find_by_sql|execute|select_all|select_values|order|group|pluck)\s*[( ]\s*(?:"[^"\n]*|'[^'\n]*)#\{/
9783
+ },
9784
+ // ── Injection: OS command ────────────────────────────────────────────────
9785
+ {
9786
+ id: "js-shell-exec-interpolation",
9787
+ title: "shell execution with an interpolated string",
9788
+ consequence: "A `;` or `$(\u2026)` in the interpolated value runs as the server user.",
9789
+ cwe: "CWE-78",
9790
+ severity: "critical",
9791
+ languages: ["javascript", "typescript"],
9792
+ pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/
9793
+ },
9794
+ {
9795
+ id: "py-shell-command-string",
9796
+ title: "shell command built from a string",
9797
+ consequence: "`os.system` and `shell=True` hand the string to `/bin/sh`, which happily interprets metacharacters.",
9798
+ cwe: "CWE-78",
9799
+ severity: "critical",
9800
+ languages: ["python"],
9801
+ 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/
9802
+ },
9803
+ {
9804
+ id: "go-shell-exec-command",
9805
+ title: "exec.Command invoking a shell",
9806
+ consequence: "Passing `sh -c` re-introduces the shell that `exec.Command`\u2019s argv interface exists to avoid.",
9807
+ cwe: "CWE-78",
9808
+ severity: "critical",
9809
+ languages: ["go"],
9810
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
9811
+ },
9812
+ {
9813
+ id: "rb-backtick-interpolation",
9814
+ title: "backtick command with interpolation",
9815
+ consequence: "Ruby backticks are a shell invocation; `#{}` inside one is command injection.",
9816
+ cwe: "CWE-78",
9817
+ severity: "critical",
9818
+ languages: ["ruby"],
9819
+ pattern: /`[^`\n]*#\{|\bsystem\s*\(\s*["'][^"'\n]*#\{|%x\[[^\]]*#\{/
9820
+ },
9821
+ // ── Injection: dynamic code ──────────────────────────────────────────────
9822
+ {
9823
+ id: "js-dynamic-code-execution",
9824
+ title: "dynamic code execution",
9825
+ consequence: "Any string reaching this call executes as code with the process\u2019 privileges.",
9826
+ cwe: "CWE-95",
9827
+ severity: "critical",
9828
+ languages: ["javascript", "typescript"],
9829
+ 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)/
9830
+ },
9831
+ {
9832
+ id: "js-indirect-code-sink",
9833
+ title: "code sink reached indirectly",
9834
+ consequence: "Resolving `eval`/`Function` through `globalThis[\u2026]` or `.constructor` hides the sink from literal matching. Legitimate code has no reason to.",
9835
+ cwe: "CWE-506",
9836
+ severity: "high",
9837
+ languages: ["javascript", "typescript"],
9838
+ pattern: /\bglobalThis\s*\[\s*[a-zA-Z_$][\w$]*\s*\]|\(\s*function\s*\(\s*\)\s*\{\s*\}\s*\)\s*\.\s*constructor/
9839
+ },
9840
+ {
9841
+ id: "js-encoded-payload-execution",
9842
+ title: "encoded blob decoded next to a code sink",
9843
+ consequence: "A base64 literal that is decoded and executed is the standard shape of a planted backdoor; the encoding exists to defeat review.",
9844
+ cwe: "CWE-506",
9845
+ severity: "critical",
9846
+ languages: ["javascript", "typescript"],
9847
+ pattern: /\bBuffer\.from\s*\(\s*[\w.$]+\s*,\s*['"]base64['"]\s*\)|\batob\s*\(\s*[\w.$]+\s*\)/,
9848
+ requires: CODE_SINK,
9849
+ guardBack: 6,
9850
+ guardForward: 3
9851
+ },
9852
+ {
9853
+ id: "py-dynamic-code-execution",
9854
+ title: "dynamic code execution",
9855
+ consequence: "Any string reaching this call executes as Python with the process\u2019 privileges.",
9856
+ cwe: "CWE-95",
9857
+ severity: "critical",
9858
+ languages: ["python"],
9859
+ pattern: /\b(?:eval|exec)\s*\(\s*(?!['"]\s*\))[a-zA-Z_(f'"]/,
9860
+ needsContext: true
9861
+ },
9862
+ {
9863
+ id: "rb-dynamic-dispatch",
9864
+ title: "dynamic code execution or unrestricted #send",
9865
+ consequence: "`eval` runs arbitrary Ruby; unrestricted `#send` lets the caller invoke any method on the receiver, including private ones.",
9866
+ cwe: "CWE-95",
9867
+ severity: "critical",
9868
+ languages: ["ruby"],
9869
+ pattern: /\beval\s*\(|\binstance_eval\s*\(|\bclass_eval\s*\(|\.\s*send\s*\(\s*(?:params|request|args)\b/
9870
+ },
9871
+ // ── Cross-site scripting ─────────────────────────────────────────────────
9872
+ {
9873
+ id: "js-unescaped-html-sink",
9874
+ title: "unescaped HTML rendering",
9875
+ consequence: "A script tag in the value executes in the victim\u2019s session \u2014 stored or reflected XSS.",
9876
+ cwe: "CWE-79",
9877
+ severity: "high",
9878
+ languages: ["javascript", "typescript"],
9879
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/,
9880
+ /**
9881
+ * A whole-statement assignment of a string with no interpolation and no
9882
+ * concatenation carries no data, so it cannot carry attacker data. This
9883
+ * was the single largest source of noise: a codebase that builds its UI
9884
+ * with innerHTML reports every static heading and spinner as XSS, and a
9885
+ * rule that flags 40 safe lines to catch one real one gets switched off.
9886
+ *
9887
+ * Line-scoped on purpose — see `lineGuard`.
9888
+ */
9889
+ lineGuard: /(?:innerHTML|outerHTML)\s*=\s*(?:'[^'\\]*'|"[^"\\]*"|`[^`$\\]*`)\s*;?\s*$/
9890
+ },
9891
+ {
9892
+ id: "java-html-writer-concatenation",
9893
+ title: "HTML written to the response by concatenation",
9894
+ consequence: "The servlet writer performs no encoding; a value concatenated into markup is rendered as markup.",
9895
+ cwe: "CWE-79",
9896
+ severity: "high",
9897
+ languages: ["java"],
9898
+ pattern: /\b(?:println|print|write)\s*\(\s*"[^"\n]*<[^"\n]*"\s*\+/
9899
+ },
9900
+ {
9901
+ id: "rb-unescaped-output",
9902
+ title: "Rails output escaping bypassed",
9903
+ consequence: "`html_safe` and `raw` tell Rails the string is already safe. If it came from a parameter, it is not.",
9904
+ cwe: "CWE-79",
9905
+ severity: "high",
9906
+ languages: ["ruby"],
9907
+ pattern: /\.\s*html_safe\b|\braw\s*\(\s*(?:params|request|@)|\blink_to\s+[^,\n]+,\s*params\s*\[/
9908
+ },
9909
+ {
9910
+ id: "py-template-autoescape-off",
9911
+ title: "template rendering with escaping disabled",
9912
+ consequence: "With autoescape off \u2014 or a `|safe` filter \u2014 every interpolated value is rendered as markup.",
9913
+ cwe: "CWE-79",
9914
+ severity: "high",
9915
+ languages: ["python"],
9916
+ pattern: /\bEnvironment\s*\([^)]*\bautoescape\s*=\s*False|\|\s*safe\b|\bMarkup\s*\(\s*(?!['"])/
9917
+ },
9918
+ {
9919
+ id: "py-template-from-input",
9920
+ title: "template compiled from a non-literal source",
9921
+ consequence: "Server-side template injection. In Jinja2 the sandbox is escapable, so this escalates from XSS to remote code execution.",
9922
+ cwe: "CWE-1336",
9923
+ severity: "critical",
9924
+ languages: ["python"],
9925
+ pattern: /\bTemplate\s*\(\s*(?!['"])[a-zA-Z_]/,
9926
+ needsContext: true
9927
+ },
9928
+ // ── Server-side request forgery ──────────────────────────────────────────
9929
+ {
9930
+ id: "js-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: ["javascript", "typescript"],
9936
+ 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*[,)]/,
9937
+ needsContext: true
9938
+ },
9939
+ {
9940
+ id: "py-ssrf-outbound-request",
9941
+ title: "outbound request to a non-constant URL",
9942
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
9943
+ cwe: "CWE-918",
9944
+ severity: "high",
9945
+ languages: ["python"],
9946
+ 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*[,)]/,
9947
+ needsContext: true
9948
+ },
9949
+ {
9950
+ id: "go-ssrf-outbound-request",
9951
+ title: "outbound request to a non-constant URL",
9952
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
9953
+ cwe: "CWE-918",
9954
+ severity: "high",
9955
+ languages: ["go"],
9956
+ pattern: /\bhttp\.(?:Get|Post|Head)\s*\(\s*(?:[a-zA-Z_]\w*\s*[,)]|"[^"]*"\s*\+)/,
9957
+ needsContext: true
9958
+ },
9959
+ // ── Open redirect ────────────────────────────────────────────────────────
9960
+ {
9961
+ id: "js-open-redirect",
9962
+ title: "redirect to a non-constant destination",
9963
+ consequence: "Your domain becomes the credible first hop of a phishing chain; the victim sees your hostname in the link they clicked.",
9964
+ cwe: "CWE-601",
9965
+ severity: "medium",
9966
+ languages: ["javascript", "typescript"],
9967
+ 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*\))/,
9968
+ needsContext: true
9969
+ },
9970
+ // ── Deserialisation ──────────────────────────────────────────────────────
9971
+ {
9972
+ id: "py-unsafe-deserialization",
9973
+ title: "deserialisation of untrusted data",
9974
+ consequence: "`pickle` and `yaml.load` instantiate arbitrary types during parsing \u2014 a crafted payload is remote code execution, not a parse error.",
9975
+ cwe: "CWE-502",
9976
+ severity: "critical",
9977
+ languages: ["python"],
9978
+ pattern: /\bpickle\.loads?\s*\(|\bcPickle\.loads?\s*\(|\bmarshal\.loads\s*\(|\byaml\.load\s*\(|\bjsonpickle\.decode\s*\(/
9979
+ },
9980
+ {
9981
+ id: "java-unsafe-deserialization",
9982
+ title: "Java deserialisation without a class filter",
9983
+ consequence: "A gadget chain in the classpath turns `readObject()` on attacker bytes into remote code execution.",
9984
+ cwe: "CWE-502",
9985
+ severity: "critical",
9986
+ languages: ["java"],
9987
+ pattern: /\breadObject\s*\(\s*\)|\bnew\s+ObjectInputStream\s*\(/,
9988
+ guardBack: 8,
9989
+ // The stream is constructed, *then* filtered. Without a forward window the
9990
+ // guarded case matches on its constructor line and reports a correct
9991
+ // implementation as a finding.
9992
+ guardForward: 6
9993
+ },
9994
+ {
9995
+ id: "js-unsafe-yaml-load",
9996
+ title: "YAML parsed with type resolution enabled",
9997
+ consequence: "A crafted document can instantiate arbitrary types during parsing.",
9998
+ cwe: "CWE-502",
9999
+ severity: "high",
10000
+ languages: ["javascript", "typescript"],
10001
+ pattern: /\byaml\s*\.\s*load\s*\((?![^)]*safe)|\bloadAll\s*\([^)]*unsafe/i
10002
+ },
10003
+ // ── XML external entities ────────────────────────────────────────────────
10004
+ {
10005
+ id: "java-xxe-parser-defaults",
10006
+ title: "XML parser left on its insecure defaults",
10007
+ consequence: "External entity expansion reads local files and makes outbound requests on the parser\u2019s behalf \u2014 file disclosure and SSRF from a document.",
10008
+ cwe: "CWE-611",
10009
+ severity: "high",
10010
+ languages: ["java"],
10011
+ pattern: /\b(?:DocumentBuilderFactory|SAXParserFactory|XMLInputFactory|TransformerFactory|SchemaFactory)\s*\.\s*newInstance\s*\(\s*\)/,
10012
+ guard: XXE_GUARD,
10013
+ guardBack: 4,
10014
+ guardForward: 8
10015
+ },
10016
+ {
10017
+ id: "java-xxe-parse-call",
10018
+ title: "XML parsed by a builder that was never hardened",
10019
+ consequence: "The expansion happens at `parse()`. Flagging only the factory misses the line where the document is actually read.",
10020
+ cwe: "CWE-611",
10021
+ severity: "high",
10022
+ languages: ["java"],
10023
+ // Receiver-qualified so `LocalDate.parse(s)` and friends stay out of it.
10024
+ pattern: /\b\w*(?:[Bb]uilder|[Pp]arser|[Rr]eader)\s*\.\s*parse\s*\(/,
10025
+ guard: XXE_GUARD,
10026
+ guardBack: 6,
10027
+ guardForward: 4
10028
+ },
10029
+ // ── Path traversal ───────────────────────────────────────────────────────
10030
+ {
10031
+ id: "py-path-traversal",
10032
+ title: "file opened at a path built from input",
10033
+ consequence: "A `../` sequence \u2014 or an absolute path \u2014 reads or writes outside the intended directory.",
10034
+ cwe: "CWE-22",
10035
+ severity: "high",
10036
+ languages: ["python"],
10037
+ pattern: /\bopen\s*\(\s*(?:os\.path\.join\s*\(|[a-zA-Z_]\w*\s*\+|f['"])/,
10038
+ needsContext: true
10039
+ },
10040
+ {
10041
+ id: "js-path-traversal",
10042
+ title: "file path built from a variable",
10043
+ consequence: "A `../` sequence in the value reads or writes outside the intended directory.",
10044
+ cwe: "CWE-22",
10045
+ severity: "medium",
10046
+ languages: ["javascript", "typescript"],
10047
+ pattern: /\b(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|sendFile)\s*\(\s*(?:`[^`]*\$\{|[a-zA-Z_$][\w$]*\s*\+|path\.join\s*\([^)]*(?:req|request)\b)/,
10048
+ needsContext: true
10049
+ },
10050
+ // ── Cryptography, tokens, randomness ─────────────────────────────────────
10051
+ {
10052
+ id: "js-jwt-decode-without-verify",
10053
+ title: "JWT decoded without verifying the signature",
10054
+ consequence: "`decode` parses the claims and checks nothing. Anyone can mint a token with any `sub` and any `role`.",
10055
+ cwe: "CWE-347",
10056
+ severity: "critical",
10057
+ languages: ["javascript", "typescript"],
10058
+ pattern: /\bjwt\s*\.\s*decode\s*\(|\bjsonwebtoken\s*\.\s*decode\s*\(|\bdecodeJwt\s*\(/
10059
+ },
10060
+ {
10061
+ id: "tls-verification-disabled",
10062
+ title: "TLS certificate verification disabled",
10063
+ consequence: "Every connection made this way is trivially interceptable; the encryption is decorative.",
10064
+ cwe: "CWE-295",
10065
+ severity: "high",
10066
+ 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/
10067
+ },
10068
+ {
10069
+ id: "weak-hash-on-credential",
10070
+ title: "broken hash used on a credential",
10071
+ consequence: "MD5 and SHA-1 are fast and collision-prone; hashed passwords are recoverable.",
10072
+ cwe: "CWE-327",
10073
+ severity: "high",
10074
+ pattern: /(?:createHash|hashlib|MessageDigest\.getInstance|Digest::)\s*[.(]?\s*['"]?(?:md5|MD5|sha1|SHA-?1)['"]?\s*\)?[\s\S]{0,80}(?:password|passwd|secret|token|credential)/i
10075
+ },
10076
+ {
10077
+ id: "insecure-randomness-for-secret",
10078
+ title: "predictable randomness used for a security value",
10079
+ consequence: "`Math.random`/`random.random` are predictable; tokens, session ids and reset codes built from them are guessable.",
10080
+ cwe: "CWE-338",
10081
+ severity: "high",
10082
+ 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
10083
+ },
10084
+ {
10085
+ id: "redos-nested-quantifier",
10086
+ title: "regex with nested unbounded quantifiers",
10087
+ consequence: "Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.",
10088
+ cwe: "CWE-1333",
10089
+ severity: "medium",
10090
+ pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
10091
+ },
10092
+ // ── Temporary files ──────────────────────────────────────────────────────
10093
+ {
10094
+ id: "insecure-temp-file",
10095
+ title: "predictable temporary file path",
10096
+ 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.",
10097
+ cwe: "CWE-377",
10098
+ severity: "medium",
10099
+ // A hardcoded path under /tmp is the finding whether or not it is
10100
+ // formatted: `"/tmp/application.log.tmp"` is worse than the PID-based one,
10101
+ // because every process on the host can predict it exactly.
10102
+ pattern: /\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|['"]\/tmp\/[^'"\n]+['"]|['"]\/tmp\/[^'"\n]*\{|\bFile\.createTempFile\s*\(/
10103
+ },
10104
+ // ── Information exposure ─────────────────────────────────────────────────
10105
+ {
10106
+ id: "py-stack-trace-returned",
10107
+ title: "stack trace returned to the caller",
10108
+ consequence: "Tracebacks leak absolute paths, dependency versions and source fragments \u2014 the reconnaissance an attacker would otherwise have to guess at.",
10109
+ cwe: "CWE-209",
10110
+ severity: "medium",
10111
+ languages: ["python"],
10112
+ pattern: /\breturn\b[^\n]*\btraceback\.(?:format_exc|format_exception|print_exc)\s*\(|\breturn\b[^\n]*\bstr\s*\(\s*e\s*\)/
10113
+ },
10114
+ {
10115
+ id: "js-environment-exfiltration",
10116
+ title: "process environment serialised into a payload",
10117
+ consequence: "The environment is where every secret lives. Serialising it whole into a request body is credential exfiltration regardless of the endpoint.",
10118
+ cwe: "CWE-532",
10119
+ severity: "critical",
10120
+ languages: ["javascript", "typescript"],
10121
+ pattern: /\bJSON\.stringify\s*\(\s*\{?[^)]*\bprocess\.env\b(?!\s*\.)/,
10122
+ guard: false
10123
+ },
10124
+ {
10125
+ id: "js-credential-logged",
10126
+ title: "credential read from the environment into a log sink",
10127
+ consequence: "CI retains job logs, and on public forks it publishes them. A token printed once is a token leaked permanently.",
10128
+ cwe: "CWE-532",
10129
+ severity: "high",
10130
+ languages: ["javascript", "typescript"],
10131
+ pattern: /\b(?:token|apiKey|api_key|secret|password|credential|auth)\w*\s*:\s*process\.env\.\w+/i,
10132
+ requires: EXFIL_SINK,
10133
+ guard: false,
10134
+ guardBack: 4,
10135
+ guardForward: 1
10136
+ },
10137
+ // ── Prototype pollution ──────────────────────────────────────────────────
10138
+ {
10139
+ id: "js-prototype-pollution",
10140
+ title: "write to a prototype-reachable key",
10141
+ consequence: "An attacker-supplied `__proto__` key changes behaviour for every object in the process, including ones it never touched.",
10142
+ cwe: "CWE-1321",
10143
+ severity: "high",
10144
+ languages: ["javascript", "typescript"],
10145
+ pattern: /\[\s*['"]__proto__['"]\s*\]|\bObject\s*\.\s*assign\s*\(\s*[\w.$]*\.prototype\b|\.\s*__proto__\s*=/
10146
+ }
10147
+ ];
10148
+ var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
10149
+ var DEFINITION_PREFIX = /^\s*(?:(?:export|public|private|protected|static|final|async|abstract)\s+)*(?:def|function|func|class|module|interface|struct|impl|fn|sub)\b/;
10150
+ function isComment(line) {
10151
+ return COMMENT_PREFIX.test(line);
10152
+ }
10153
+ function proseLines(lines) {
10154
+ const inside = /* @__PURE__ */ new Set();
10155
+ let delimiter = null;
10156
+ lines.forEach((line, index) => {
10157
+ if (delimiter) {
10158
+ inside.add(index);
10159
+ if (line.includes(delimiter)) delimiter = null;
10160
+ return;
10161
+ }
10162
+ for (const candidate of ['"""', "'''"]) {
10163
+ const start = line.indexOf(candidate);
10164
+ if (start === -1) continue;
10165
+ if (line.indexOf(candidate, start + candidate.length) !== -1) return;
10166
+ delimiter = candidate;
10167
+ inside.add(index);
10168
+ return;
10169
+ }
10170
+ });
10171
+ return inside;
10172
+ }
10173
+ function skippable(line, index, prose) {
10174
+ return isComment(line) || DEFINITION_PREFIX.test(line) || (prose?.has(index) ?? false);
10175
+ }
10176
+ function windowText(lines, index, back, forward, prose) {
10177
+ const from = Math.max(0, index - back);
10178
+ const to = Math.min(lines.length - 1, index + forward);
10179
+ const collected = [];
10180
+ for (let i = from; i <= to; i += 1) {
10181
+ const line = lines[i] ?? "";
10182
+ if (i !== index && skippable(line, i, prose)) continue;
10183
+ collected.push(line);
10184
+ }
10185
+ return collected.join("\n");
10186
+ }
10187
+ function evaluateRule(rule, ctx) {
10188
+ if (rule.languages && !rule.languages.includes(ctx.language)) return null;
10189
+ const line = ctx.lines[ctx.index] ?? "";
10190
+ if (isComment(line) || ctx.prose?.has(ctx.index)) return null;
10191
+ if (!rule.pattern.test(line)) return null;
10192
+ const back = rule.guardBack ?? 8;
10193
+ const forward = rule.guardForward ?? 0;
10194
+ const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
10195
+ if (rule.requires && !rule.requires.test(context)) return null;
10196
+ if (rule.lineGuard?.test(line)) return null;
10197
+ const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
10198
+ if (guard && (guard.test(line) || guard.test(context))) return null;
10199
+ const untrusted = untrustedPatternFor(ctx.language);
10200
+ const contextual = untrusted.test(line) || untrusted.test(context);
10201
+ if (rule.needsContext && !contextual) return null;
10202
+ const confidence = contextual ? "contextual" : "pattern";
10203
+ return { rule, confidence, severity: severityFor(rule.severity, confidence) };
10204
+ }
10205
+
10206
+ // src/scan/manifest-rules.ts
10207
+ var POPULAR_NPM = [
10208
+ "react",
10209
+ "react-dom",
10210
+ "lodash",
10211
+ "express",
10212
+ "axios",
10213
+ "chalk",
10214
+ "commander",
10215
+ "debug",
10216
+ "moment",
10217
+ "dayjs",
10218
+ "uuid",
10219
+ "dotenv",
10220
+ "typescript",
10221
+ "webpack",
10222
+ "vite",
10223
+ "rollup",
10224
+ "eslint",
10225
+ "prettier",
10226
+ "jest",
10227
+ "vitest",
10228
+ "mocha",
10229
+ "chai",
10230
+ "sinon",
10231
+ "request",
10232
+ "node-fetch",
10233
+ "cross-env",
10234
+ "rimraf",
10235
+ "glob",
10236
+ "minimist",
10237
+ "yargs",
10238
+ "inquirer",
10239
+ "colors",
10240
+ "ora",
10241
+ "semver",
10242
+ "ws",
10243
+ "socket.io",
10244
+ "mongoose",
10245
+ "sequelize",
10246
+ "knex",
10247
+ "pg",
10248
+ "mysql",
10249
+ "mysql2",
10250
+ "redis",
10251
+ "ioredis",
10252
+ "jsonwebtoken",
10253
+ "bcrypt",
10254
+ "passport",
10255
+ "cors",
10256
+ "helmet",
10257
+ "morgan",
10258
+ "body-parser",
10259
+ "multer",
10260
+ "nodemailer",
10261
+ "puppeteer",
10262
+ "playwright",
10263
+ "cheerio",
10264
+ "sharp",
10265
+ "canvas",
10266
+ "esbuild",
10267
+ "babel",
10268
+ "postcss",
10269
+ "tailwindcss",
10270
+ "next",
10271
+ "nuxt",
10272
+ "vue",
10273
+ "svelte",
10274
+ "angular",
10275
+ "rxjs",
10276
+ "zod"
10277
+ ];
10278
+ var POPULAR_PYPI = [
10279
+ "requests",
10280
+ "urllib3",
10281
+ "numpy",
10282
+ "pandas",
10283
+ "scipy",
10284
+ "flask",
10285
+ "django",
10286
+ "fastapi",
10287
+ "sqlalchemy",
10288
+ "pydantic",
10289
+ "click",
10290
+ "jinja2",
10291
+ "pyyaml",
10292
+ "boto3",
10293
+ "botocore",
10294
+ "setuptools",
10295
+ "wheel",
10296
+ "pip",
10297
+ "six",
10298
+ "certifi",
10299
+ "idna",
10300
+ "chardet",
10301
+ "attrs",
10302
+ "python-dateutil",
10303
+ "pytz",
10304
+ "pytest",
10305
+ "tox",
10306
+ "black",
10307
+ "flake8",
10308
+ "mypy",
10309
+ "isort",
10310
+ "beautifulsoup4",
10311
+ "lxml",
10312
+ "pillow",
10313
+ "matplotlib",
10314
+ "seaborn",
10315
+ "scikit-learn",
10316
+ "tensorflow",
10317
+ "torch",
10318
+ "transformers",
10319
+ "openai",
10320
+ "anthropic",
10321
+ "httpx",
10322
+ "aiohttp",
10323
+ "celery",
10324
+ "redis",
10325
+ "psycopg2",
10326
+ "pymongo",
10327
+ "cryptography",
10328
+ "paramiko",
10329
+ "colorama"
10330
+ ];
10331
+ var INTERNAL_MARKER = /(?:^|[-_/@])(?:internal|private|corp|intranet|inhouse|confidential)(?:$|[-_/])/i;
10332
+ function normalizeName(name) {
10333
+ return name.toLowerCase().replace(/^@/, "").replace(/[-_.\s]/g, "");
10334
+ }
10335
+ function editDistance(a, b, cap = 3) {
10336
+ if (a === b) return 0;
10337
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
10338
+ const rows = [];
10339
+ for (let i = 0; i <= a.length; i += 1) {
10340
+ rows.push(new Array(b.length + 1).fill(0));
10341
+ rows[i][0] = i;
10342
+ }
10343
+ for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
10344
+ for (let i = 1; i <= a.length; i += 1) {
10345
+ for (let j = 1; j <= b.length; j += 1) {
10346
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
10347
+ let best = Math.min(
10348
+ rows[i - 1][j] + 1,
10349
+ rows[i][j - 1] + 1,
10350
+ rows[i - 1][j - 1] + cost
10351
+ );
10352
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
10353
+ best = Math.min(best, rows[i - 2][j - 2] + 1);
10354
+ }
10355
+ rows[i][j] = best;
10356
+ }
10357
+ }
10358
+ return rows[a.length][b.length];
10359
+ }
10360
+ function detectTyposquat(name, ecosystem) {
10361
+ const popular = ecosystem === "npm" ? POPULAR_NPM : POPULAR_PYPI;
10362
+ const lower = name.toLowerCase().replace(/^@[^/]+\//, "");
10363
+ if (popular.includes(lower)) return null;
10364
+ if (lower.length < 4) return null;
10365
+ const normalized = normalizeName(lower);
10366
+ for (const candidate of popular) {
10367
+ const candidateNormalized = normalizeName(candidate);
10368
+ if (normalized === candidateNormalized) return { impersonates: candidate, kind: "separator" };
10369
+ if (editDistance(normalized, candidateNormalized, 1) === 1) {
10370
+ return { impersonates: candidate, kind: "edit" };
10371
+ }
10372
+ }
10373
+ return null;
10374
+ }
10375
+ var LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall", "prepare", "prepublish"];
10376
+ function scanPackageJson(text) {
10377
+ const findings = [];
10378
+ const lines = text.split("\n");
10379
+ let parsed;
10380
+ try {
10381
+ parsed = JSON.parse(text);
10382
+ } catch {
10383
+ return findings;
10384
+ }
10385
+ const lineOf = (needle) => {
10386
+ const index = lines.findIndex((line) => line.includes(`"${needle}"`));
10387
+ return index === -1 ? 1 : index + 1;
10388
+ };
10389
+ const depBuckets = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
10390
+ for (const bucket of depBuckets) {
10391
+ const deps = parsed[bucket];
10392
+ if (!deps || typeof deps !== "object") continue;
10393
+ for (const name of Object.keys(deps)) {
10394
+ const line = lineOf(name);
10395
+ if (INTERNAL_MARKER.test(name)) {
10396
+ findings.push({
10397
+ ruleId: "manifest-dependency-confusion",
10398
+ title: "internal-looking package resolved from a public registry",
10399
+ line,
10400
+ severity: "critical",
10401
+ cwe: "CWE-1357",
10402
+ message: `"${name}" names itself as internal but carries no registry pin`,
10403
+ consequence: "Whoever registers this name publicly first wins the resolution, and their code runs in your build.",
10404
+ excerpt: (lines[line - 1] ?? "").trim()
10405
+ });
10406
+ continue;
10407
+ }
10408
+ const squat = detectTyposquat(name, "npm");
10409
+ if (squat) {
10410
+ findings.push({
10411
+ ruleId: "manifest-typosquat",
10412
+ title: "dependency name close to a popular package",
10413
+ line,
10414
+ severity: "high",
10415
+ cwe: "CWE-1357",
10416
+ message: squat.kind === "separator" ? `"${name}" differs from "${squat.impersonates}" only in punctuation` : `"${name}" is one edit from "${squat.impersonates}"`,
10417
+ consequence: "A reviewer scanning a diff reads the name they expected. The package that installs is the one that was written.",
10418
+ excerpt: (lines[line - 1] ?? "").trim()
10419
+ });
10420
+ }
10421
+ }
10422
+ }
10423
+ const scripts = parsed.scripts;
10424
+ if (scripts && typeof scripts === "object") {
10425
+ for (const [name, body] of Object.entries(scripts)) {
10426
+ if (!LIFECYCLE_SCRIPTS.includes(name)) continue;
10427
+ findings.push({
10428
+ ruleId: "manifest-install-lifecycle-script",
10429
+ title: "install-time lifecycle script",
10430
+ line: lineOf(name),
10431
+ severity: "medium",
10432
+ cwe: "CWE-506",
10433
+ message: `"${name}" runs automatically on install: ${String(body).slice(0, 120)}`,
10434
+ 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.",
10435
+ excerpt: (lines[lineOf(name) - 1] ?? "").trim()
10436
+ });
10437
+ }
10438
+ }
10439
+ return findings;
10440
+ }
10441
+ function scanRequirementsTxt(text) {
10442
+ const findings = [];
10443
+ const lines = text.split("\n");
10444
+ lines.forEach((raw, index) => {
10445
+ const line = raw.trim();
10446
+ if (!line || line.startsWith("#") || line.startsWith("-")) return;
10447
+ const match = /^([A-Za-z0-9_.-]+)\s*(?:[=<>!~]=|@|$)/.exec(line);
10448
+ const name = match?.[1];
10449
+ if (!name) return;
10450
+ if (INTERNAL_MARKER.test(name)) {
10451
+ findings.push({
10452
+ ruleId: "manifest-dependency-confusion",
10453
+ title: "internal-looking package resolved from a public index",
10454
+ line: index + 1,
10455
+ severity: "critical",
10456
+ cwe: "CWE-1357",
10457
+ message: `"${name}" names itself as internal but carries no index pin`,
10458
+ consequence: "pip resolves the highest version across every configured index, so a public package of the same name shadows the private one.",
10459
+ excerpt: line
10460
+ });
10461
+ return;
10462
+ }
10463
+ const squat = detectTyposquat(name, "pypi");
10464
+ if (squat) {
10465
+ findings.push({
10466
+ ruleId: "manifest-typosquat",
10467
+ title: "dependency name close to a popular package",
10468
+ line: index + 1,
10469
+ severity: "high",
10470
+ cwe: "CWE-1357",
10471
+ message: squat.kind === "separator" ? `"${name}" differs from "${squat.impersonates}" only in punctuation` : `"${name}" is one edit from "${squat.impersonates}"`,
10472
+ consequence: "A reviewer scanning a diff reads the name they expected. The package that installs is the one that was written.",
10473
+ excerpt: line
10474
+ });
10475
+ }
10476
+ });
10477
+ return findings;
10478
+ }
10479
+
10480
+ // src/scan/secret-rules.ts
10481
+ var SECRET_RULES = [
10482
+ {
10483
+ id: "secret-aws-access-key",
10484
+ name: "AWS Access Key",
10485
+ pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/,
10486
+ severity: "critical",
10487
+ cwe: "CWE-798",
10488
+ consequence: "Paired with a secret key, grants the API access of whatever IAM principal issued it."
10489
+ },
10490
+ {
10491
+ id: "secret-aws-secret-key",
10492
+ name: "AWS Secret Access Key",
10493
+ pattern: /(?:aws_secret_access_key|AWS_SECRET(?:_ACCESS_KEY)?)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/i,
10494
+ severity: "critical",
10495
+ cwe: "CWE-798",
10496
+ consequence: "The other half of an AWS credential pair; on its own it is still the hard half to guess."
10497
+ },
10498
+ {
10499
+ id: "secret-github-token",
10500
+ name: "GitHub Token",
10501
+ 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/,
10502
+ severity: "critical",
10503
+ cwe: "CWE-798",
10504
+ consequence: "Repository read or write as the issuing account, including the ability to push workflow changes."
10505
+ },
10506
+ {
10507
+ id: "secret-npm-token",
10508
+ name: "npm Token",
10509
+ pattern: /\bnpm_[A-Za-z0-9]{36}\b/,
10510
+ severity: "critical",
10511
+ cwe: "CWE-798",
10512
+ consequence: "Publish rights to every package the account owns \u2014 a supply-chain compromise in one command."
10513
+ },
10514
+ {
10515
+ id: "secret-private-key",
10516
+ name: "Private Key",
10517
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/,
10518
+ severity: "critical",
10519
+ cwe: "CWE-798",
10520
+ consequence: "Key material, committed. Rotation is the only remediation."
10521
+ },
10522
+ {
10523
+ id: "secret-slack-token",
10524
+ name: "Slack Token",
10525
+ pattern: /\bxox[bpoasr]-[A-Za-z0-9-]{10,}/,
10526
+ severity: "critical",
10527
+ cwe: "CWE-798",
10528
+ consequence: "Read and post access to the workspace as the installing app."
10529
+ },
10530
+ {
10531
+ id: "secret-slack-webhook",
10532
+ name: "Slack Webhook URL",
10533
+ pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_+\/-]{6,}/,
10534
+ severity: "high",
10535
+ cwe: "CWE-798",
10536
+ consequence: "The URL *is* the credential \u2014 anyone holding it can post to that channel."
10537
+ },
10538
+ {
10539
+ id: "secret-stripe-key",
10540
+ name: "Stripe Key",
10541
+ pattern: /\b(?:sk_live_|rk_live_|sk_test_|rk_test_)[A-Za-z0-9]{20,}\b/,
10542
+ severity: "critical",
10543
+ cwe: "CWE-798",
10544
+ consequence: "Charge, refund and customer-data access against the account."
10545
+ },
10546
+ {
10547
+ id: "secret-sendgrid-key",
10548
+ name: "SendGrid API Key",
10549
+ pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/,
10550
+ severity: "critical",
10551
+ cwe: "CWE-798",
10552
+ consequence: "Send mail as the domain \u2014 the credential behind most convincing phishing from a real sender."
10553
+ },
10554
+ {
10555
+ id: "secret-google-api-key",
10556
+ name: "Google API Key",
10557
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/,
10558
+ severity: "high",
10559
+ cwe: "CWE-798",
10560
+ consequence: "Quota theft at minimum; API access to whatever the key was scoped to at worst."
10561
+ },
10562
+ {
10563
+ id: "secret-openai-key",
10564
+ name: "OpenAI API Key",
10565
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/,
10566
+ severity: "critical",
10567
+ cwe: "CWE-798",
10568
+ consequence: "Billed inference against the owner\u2019s account, with no per-key spend limit by default."
10569
+ },
10570
+ {
10571
+ id: "secret-anthropic-key",
10572
+ name: "Anthropic API Key",
10573
+ pattern: /\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{32,}\b/,
10574
+ severity: "critical",
10575
+ cwe: "CWE-798",
10576
+ consequence: "Billed inference against the owner\u2019s account."
10577
+ },
10578
+ {
10579
+ id: "secret-database-url",
10580
+ name: "Database URL with credentials",
10581
+ // Requires a credential segment before the `@` — `postgres://localhost/db`
10582
+ // is a hostname, not a secret.
10583
+ pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|clickhouse):\/\/[^\s'"@\/]*:[^\s'"@\/]*@[^\s'"]+/i,
10584
+ severity: "high",
10585
+ cwe: "CWE-798",
10586
+ consequence: "Direct database access, usually bypassing every application-level authorisation check."
10587
+ },
10588
+ {
10589
+ id: "secret-jwt",
10590
+ name: "JSON Web Token",
10591
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_.+/=-]*/,
10592
+ severity: "medium",
10593
+ cwe: "CWE-798",
10594
+ consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived."
10595
+ },
10596
+ {
10597
+ id: "secret-generic-api-key",
10598
+ name: "Generic API Key",
10599
+ // Quoted assignment only. An unquoted value in a `.env` is covered by the
10600
+ // vendor-prefixed rules above; matching it here is what starts flagging
10601
+ // ARNs and parameter-store paths.
10602
+ pattern: /(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9\-_.]{20,})['"]/i,
10603
+ severity: "high",
10604
+ cwe: "CWE-798",
10605
+ consequence: "Whatever the third-party service lets the key do, for as long as it stays valid."
10606
+ },
10607
+ {
10608
+ id: "secret-generic-credential",
10609
+ name: "Hardcoded Credential",
10610
+ pattern: /(?:secret|password|passwd|pwd|token)\s*[=:]\s*['"]([^'"\s]{8,})['"]/i,
10611
+ severity: "high",
10612
+ cwe: "CWE-798",
10613
+ consequence: "A password in source is a password in every clone, fork and CI cache of that source."
10614
+ },
10615
+ {
10616
+ id: "secret-hex-token",
10617
+ name: "High-entropy Hex Token",
10618
+ pattern: /(?:token|key|secret|auth|signing)\w*\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/i,
10619
+ severity: "medium",
10620
+ cwe: "CWE-798",
10621
+ consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign."
10622
+ }
9582
10623
  ];
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" }
10624
+ var KNOWN_PLACEHOLDERS = [
10625
+ // Deliberately NOT here: AWS's published documentation key/secret pair
10626
+ // (`AKIAIOSFODNN7EXAMPLE`, `wJalrXUtnFEMI/…`). GitHub allow-lists them, and
10627
+ // the argument for following suit is that they authenticate nothing. The
10628
+ // argument against is stronger: they appear in a repository because someone
10629
+ // pasted a credentials template and left it there, and the remediation —
10630
+ // move this to the secret manager is identical to the one for a live key.
10631
+ // Exempting them means the scanner goes quiet on the file most likely to
10632
+ // acquire a real key next.
10633
+ /\bEXAMPLE_?KEY\b/i,
10634
+ /\bYOUR_[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)\b/,
10635
+ /\b(?:xxx+|X{4,}|\*{4,}|<[a-z-]+>)\b/,
10636
+ /\bchangeme\b/i
9592
10637
  ];
10638
+ function isKnownPlaceholder(text) {
10639
+ return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
10640
+ }
10641
+ function redactSecret(line) {
10642
+ return line.replace(/([A-Za-z0-9+/=_.-]{12,})/g, (match) => {
10643
+ if (match.length <= 12) return match;
10644
+ return `${match.slice(0, 3)}${"*".repeat(Math.min(16, match.length - 3))}`;
10645
+ });
10646
+ }
10647
+ var SENSITIVE_FILES = [
10648
+ { pattern: ".env", message: "Environment file committed \u2014 the usual home of every runtime credential", severity: "high" },
10649
+ { pattern: ".env.local", message: "Local environment file committed", severity: "high" },
10650
+ { pattern: ".env.production", message: "Production environment file committed", severity: "critical" },
10651
+ { pattern: "id_rsa", message: "Private SSH key committed", severity: "critical" },
10652
+ { pattern: "id_ed25519", message: "Private SSH key committed", severity: "critical" },
10653
+ { pattern: "id_ecdsa", message: "Private SSH key committed", severity: "critical" },
10654
+ { pattern: ".pem", message: "PEM certificate or key file committed", severity: "high" },
10655
+ { pattern: ".p12", message: "PKCS#12 keystore committed", severity: "high" },
10656
+ { pattern: ".pfx", message: "PKCS#12 keystore committed", severity: "high" },
10657
+ { pattern: ".keystore", message: "Java keystore committed", severity: "high" }
10658
+ // Deliberately not `.npmrc`. Its presence is normal; only an `_authToken`
10659
+ // line in it is a credential, and that is a content match, not a filename
10660
+ // match. Reporting the file itself trades a real finding for a chore.
10661
+ ];
10662
+
10663
+ // src/scan/engine.ts
9593
10664
  var SKIP_DIRS = /* @__PURE__ */ new Set([
9594
10665
  "node_modules",
9595
10666
  ".git",
9596
10667
  ".next",
10668
+ ".nuxt",
9597
10669
  "dist",
9598
10670
  "build",
10671
+ "out",
9599
10672
  "__pycache__",
9600
10673
  ".venv",
10674
+ "venv",
9601
10675
  "vendor",
9602
10676
  ".terraform",
9603
10677
  "coverage",
9604
- ".cache"
10678
+ ".cache",
10679
+ ".pnpm-store",
10680
+ "target",
10681
+ ".gradle",
10682
+ ".idea",
10683
+ ".vscode",
10684
+ "bower_components",
10685
+ ".svelte-kit"
9605
10686
  ]);
9606
10687
  var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
9607
10688
  ".ts",
9608
10689
  ".js",
9609
10690
  ".tsx",
9610
10691
  ".jsx",
10692
+ ".mjs",
10693
+ ".cjs",
10694
+ ".mts",
10695
+ ".cts",
9611
10696
  ".py",
9612
10697
  ".rb",
9613
10698
  ".go",
9614
10699
  ".java",
10700
+ ".kt",
10701
+ ".scala",
9615
10702
  ".php",
9616
10703
  ".rs",
9617
10704
  ".c",
10705
+ ".cc",
9618
10706
  ".cpp",
9619
10707
  ".h",
10708
+ ".hpp",
10709
+ ".cs",
10710
+ ".swift",
9620
10711
  ".yml",
9621
10712
  ".yaml",
9622
10713
  ".json",
@@ -9627,283 +10718,628 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
9627
10718
  ".env",
9628
10719
  ".sh",
9629
10720
  ".bash",
10721
+ ".zsh",
9630
10722
  ".tf",
9631
10723
  ".hcl",
9632
10724
  ".xml",
9633
10725
  ".properties",
9634
- ".gradle"
10726
+ ".gradle",
10727
+ ".txt",
10728
+ ".md",
10729
+ ".sql",
10730
+ ".erb",
10731
+ ".ejs",
10732
+ ".vue",
10733
+ ".svelte"
9635
10734
  ]);
9636
- async function runScan(targetPath) {
10735
+ var LANGUAGE_BY_EXTENSION = {
10736
+ ".js": "javascript",
10737
+ ".jsx": "javascript",
10738
+ ".mjs": "javascript",
10739
+ ".cjs": "javascript",
10740
+ ".ts": "typescript",
10741
+ ".tsx": "typescript",
10742
+ ".mts": "typescript",
10743
+ ".cts": "typescript",
10744
+ ".vue": "javascript",
10745
+ ".svelte": "javascript",
10746
+ ".ejs": "javascript",
10747
+ ".py": "python",
10748
+ ".rb": "ruby",
10749
+ ".erb": "ruby",
10750
+ ".go": "go",
10751
+ ".java": "java",
10752
+ ".kt": "java",
10753
+ ".scala": "java",
10754
+ ".php": "php",
10755
+ ".sh": "shell",
10756
+ ".bash": "shell",
10757
+ ".zsh": "shell",
10758
+ ".yml": "config",
10759
+ ".yaml": "config",
10760
+ ".json": "config",
10761
+ ".toml": "config",
10762
+ ".ini": "config",
10763
+ ".cfg": "config",
10764
+ ".conf": "config",
10765
+ ".env": "config",
10766
+ ".tf": "config",
10767
+ ".hcl": "config",
10768
+ ".properties": "config"
10769
+ };
10770
+ function languageOf(filename) {
10771
+ if (filename.startsWith(".env") || filename.endsWith(".env")) return "config";
10772
+ return LANGUAGE_BY_EXTENSION[(0, import_node_path3.extname)(filename).toLowerCase()] ?? "other";
10773
+ }
10774
+ var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
10775
+ var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
10776
+ function collectSuppressions(lines) {
10777
+ const byLine = /* @__PURE__ */ new Map();
10778
+ let count = 0;
10779
+ const add = (index, ruleId) => {
10780
+ const existing = byLine.get(index) ?? /* @__PURE__ */ new Set();
10781
+ existing.add(ruleId ?? "*");
10782
+ byLine.set(index, existing);
10783
+ count += 1;
10784
+ };
10785
+ lines.forEach((line, index) => {
10786
+ const next = SUPPRESS_NEXT.exec(line);
10787
+ if (next) add(index + 1, next[1]);
10788
+ const same = SUPPRESS_LINE.exec(line);
10789
+ if (same && !next) add(index, same[1]);
10790
+ });
10791
+ return { byLine, count };
10792
+ }
10793
+ function isSuppressed(suppressions, index, ruleId) {
10794
+ const rules = suppressions.byLine.get(index);
10795
+ if (!rules) return false;
10796
+ return rules.has("*") || rules.has(ruleId);
10797
+ }
10798
+ function isTestPath(relativePath) {
10799
+ const p = relativePath.replace(/\\/g, "/");
10800
+ return /(?:^|\/)(?:tests?|__tests__|__mocks__|spec|specs|fixtures?|mocks?|e2e|testdata)\//i.test(p) || /(?:^|\/)(?:test|conftest)_[^/]+$/i.test(p) || /[._-](?:test|spec)\.[a-z]+$/i.test(p) || /_test\.[a-z]+$/i.test(p);
10801
+ }
10802
+ function scanText(relativePath, text, language = languageOf(relativePath)) {
9637
10803
  const findings = [];
9638
- try {
9639
- scanDirectory(targetPath, targetPath, findings, () => {
10804
+ const lines = text.split("\n");
10805
+ const suppressions = collectSuppressions(lines);
10806
+ const inTests = isTestPath(relativePath);
10807
+ lines.forEach((line, index) => {
10808
+ for (const rule of SECRET_RULES) {
10809
+ const match = rule.pattern.exec(line);
10810
+ if (!match) continue;
10811
+ if (isKnownPlaceholder(match[0])) continue;
10812
+ if (isSuppressed(suppressions, index, rule.id)) continue;
10813
+ findings.push({
10814
+ ruleId: rule.id,
10815
+ title: rule.name,
10816
+ file: relativePath,
10817
+ line: index + 1,
10818
+ // Reported but not blocking in tests — see isTestPath.
10819
+ severity: inTests ? "low" : rule.severity,
10820
+ // A matched credential format is the finding, not a proxy for one.
10821
+ confidence: "evidence",
10822
+ message: inTests ? `Possible ${rule.name} detected in a test file \u2014 usually a fixture, still worth confirming it is not a live credential` : `Possible ${rule.name} detected`,
10823
+ consequence: rule.consequence,
10824
+ cwe: rule.cwe,
10825
+ excerpt: redactSecret(line.trim()).slice(0, 200),
10826
+ sensitive: true,
10827
+ category: "secret"
10828
+ });
10829
+ }
10830
+ });
10831
+ const prose = proseLines(lines);
10832
+ lines.forEach((_line, index) => {
10833
+ for (const rule of CODE_RULES) {
10834
+ if (isSuppressed(suppressions, index, rule.id)) continue;
10835
+ const match = evaluateRule(rule, { lines, index, language, prose });
10836
+ if (!match) continue;
10837
+ findings.push({
10838
+ ruleId: rule.id,
10839
+ title: rule.title,
10840
+ file: relativePath,
10841
+ line: index + 1,
10842
+ severity: match.severity,
10843
+ confidence: match.confidence,
10844
+ message: `${rule.title} (${rule.cwe})`,
10845
+ consequence: rule.consequence,
10846
+ cwe: rule.cwe,
10847
+ excerpt: (lines[index] ?? "").trim().slice(0, 200),
10848
+ category: "code"
10849
+ });
10850
+ }
10851
+ });
10852
+ return findings;
10853
+ }
10854
+ function scanManifest(relativePath, filename, text) {
10855
+ const manifestFindings = filename === "package.json" ? scanPackageJson(text) : filename === "requirements.txt" ? scanRequirementsTxt(text) : [];
10856
+ return manifestFindings.map((finding) => ({
10857
+ ruleId: finding.ruleId,
10858
+ title: finding.title,
10859
+ file: relativePath,
10860
+ line: finding.line,
10861
+ severity: finding.severity,
10862
+ confidence: "evidence",
10863
+ message: finding.message,
10864
+ consequence: finding.consequence,
10865
+ cwe: finding.cwe,
10866
+ excerpt: finding.excerpt.slice(0, 200),
10867
+ category: "manifest"
10868
+ }));
10869
+ }
10870
+ function scanPath(targetPath, options = {}) {
10871
+ const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
10872
+ const allowed = options.categories ? new Set(options.categories) : null;
10873
+ const findings = [];
10874
+ const unreadable = [];
10875
+ let filesScanned = 0;
10876
+ let suppressed = 0;
10877
+ const rootIsDirectory = (() => {
10878
+ try {
10879
+ return (0, import_node_fs6.statSync)(targetPath).isDirectory();
10880
+ } catch {
10881
+ return true;
10882
+ }
10883
+ })();
10884
+ const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path3.dirname)(targetPath);
10885
+ const scanFile = (fullPath, filename) => {
10886
+ const relativePath = toRelative(walkRoot, fullPath);
10887
+ const extension = (0, import_node_path3.extname)(filename).toLowerCase();
10888
+ const isManifest = filename === "package.json" || filename === "requirements.txt";
10889
+ const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
10890
+ if (!scannable && !isManifest) {
10891
+ recordSensitiveFile(filename, relativePath, findings, []);
10892
+ return;
10893
+ }
10894
+ let text;
10895
+ let handle;
10896
+ try {
10897
+ handle = (0, import_node_fs6.openSync)(fullPath, "r");
10898
+ } catch {
10899
+ unreadable.push(relativePath);
10900
+ return;
10901
+ }
10902
+ try {
10903
+ if ((0, import_node_fs6.fstatSync)(handle).size > maxFileBytes) return;
10904
+ text = (0, import_node_fs6.readFileSync)(handle, "utf-8");
10905
+ } catch {
10906
+ unreadable.push(relativePath);
10907
+ return;
10908
+ } finally {
10909
+ try {
10910
+ (0, import_node_fs6.closeSync)(handle);
10911
+ } catch {
10912
+ }
10913
+ }
10914
+ filesScanned += 1;
10915
+ options.onFile?.(relativePath);
10916
+ suppressed += collectSuppressions(text.split("\n")).count;
10917
+ const fileFindings = [
10918
+ ...scanText(relativePath, text, languageOf(filename)),
10919
+ ...isManifest ? scanManifest(relativePath, filename, text) : []
10920
+ ];
10921
+ findings.push(...fileFindings);
10922
+ recordSensitiveFile(filename, relativePath, findings, fileFindings);
10923
+ };
10924
+ const walk = (currentPath) => {
10925
+ let entries;
10926
+ try {
10927
+ entries = (0, import_node_fs6.readdirSync)(currentPath, { withFileTypes: true });
10928
+ } catch {
10929
+ unreadable.push(toRelative(walkRoot, currentPath));
10930
+ return;
10931
+ }
10932
+ for (const entry of entries) {
10933
+ const fullPath = (0, import_node_path3.join)(currentPath, entry.name);
10934
+ if (entry.isDirectory()) {
10935
+ if (SKIP_DIRS.has(entry.name)) continue;
10936
+ walk(fullPath);
10937
+ continue;
10938
+ }
10939
+ if (!entry.isFile()) continue;
10940
+ scanFile(fullPath, entry.name);
10941
+ }
10942
+ };
10943
+ if (rootIsDirectory) {
10944
+ walk(targetPath);
10945
+ } else {
10946
+ scanFile(targetPath, (0, import_node_path3.basename)(targetPath));
10947
+ }
10948
+ const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
10949
+ filtered.sort(
10950
+ (a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
10951
+ );
10952
+ return { findings: filtered, filesScanned, unreadable, suppressed, root: walkRoot };
10953
+ }
10954
+ function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
10955
+ if (fileFindings.length > 0) return;
10956
+ for (const sensitive of SENSITIVE_FILES) {
10957
+ const matches = filename === sensitive.pattern || filename.endsWith(sensitive.pattern);
10958
+ if (!matches) continue;
10959
+ sink.push({
10960
+ ruleId: "sensitive-file-committed",
10961
+ title: "Sensitive file",
10962
+ file: relativePath,
10963
+ line: 1,
10964
+ severity: sensitive.severity,
10965
+ confidence: "evidence",
10966
+ message: sensitive.message,
10967
+ consequence: "Anything in this file is in every clone, fork and CI cache of the repository.",
10968
+ cwe: "CWE-538",
10969
+ excerpt: "",
10970
+ sensitive: true,
10971
+ category: "file"
9640
10972
  });
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
- };
10973
+ return;
10974
+ }
10975
+ }
10976
+ function toRelative(base, target) {
10977
+ const rel = (0, import_node_path3.relative)(base, target);
10978
+ return (rel === "" ? target : rel).split(import_node_path3.sep).join("/");
10979
+ }
10980
+ function meetsFailThreshold(findings, threshold) {
10981
+ if (threshold.length === 0) return false;
10982
+ const floor = Math.min(...threshold.map(severityRank));
10983
+ return findings.some((finding) => severityRank(finding.severity) >= floor);
10984
+ }
10985
+
10986
+ // src/scan/sarif.ts
10987
+ var import_node_path4 = require("path");
10988
+ var SARIF_VERSION = "2.1.0";
10989
+ var SARIF_SCHEMA = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
10990
+ function sarifLevel(severity) {
10991
+ switch (severity) {
10992
+ case "critical":
10993
+ case "high":
10994
+ return "error";
10995
+ case "medium":
10996
+ return "warning";
10997
+ case "low":
10998
+ return "note";
10999
+ default:
11000
+ return "none";
11001
+ }
11002
+ }
11003
+ function securitySeverity(severity) {
11004
+ switch (severity) {
11005
+ case "critical":
11006
+ return "9.0";
11007
+ case "high":
11008
+ return "7.0";
11009
+ case "medium":
11010
+ return "5.0";
11011
+ case "low":
11012
+ return "3.0";
11013
+ default:
11014
+ return "1.0";
11015
+ }
11016
+ }
11017
+ function toArtifactUri(filePath, base, prefix = "", root = base) {
11018
+ const absolute = (0, import_node_path4.isAbsolute)(filePath) ? filePath : (0, import_node_path4.resolve)(root, filePath);
11019
+ const relativePath = (0, import_node_path4.relative)(base, absolute);
11020
+ const escapedOut = relativePath.startsWith("..") || relativePath === "";
11021
+ const chosen = escapedOut ? absolute : relativePath;
11022
+ const posix = chosen.split(import_node_path4.sep).join("/").replace(/^\.\//, "");
11023
+ if (!prefix || escapedOut) return posix;
11024
+ const trimmed = prefix.replace(/^\/+|\/+$/g, "");
11025
+ return trimmed ? `${trimmed}/${posix}` : posix;
11026
+ }
11027
+ function buildSarif(findings, options) {
11028
+ const rules = /* @__PURE__ */ new Map();
11029
+ for (const finding of findings) {
11030
+ if (rules.has(finding.ruleId)) continue;
11031
+ const tags = ["security"];
11032
+ if (finding.cwe) tags.push(`external/cwe/${finding.cwe.toLowerCase()}`);
11033
+ tags.push(`threatcrush/${finding.category}`);
11034
+ const description = finding.consequence ? `${finding.title}. ${finding.consequence}` : finding.title;
11035
+ rules.set(finding.ruleId, {
11036
+ id: finding.ruleId,
11037
+ name: finding.ruleId,
11038
+ shortDescription: { text: finding.title },
11039
+ fullDescription: { text: description },
11040
+ help: {
11041
+ text: description,
11042
+ markdown: finding.consequence ? `**${finding.title}**
11043
+
11044
+ ${finding.consequence}` : `**${finding.title}**`
11045
+ },
11046
+ defaultConfiguration: { level: sarifLevel(finding.severity) },
11047
+ properties: {
11048
+ tags,
11049
+ "security-severity": securitySeverity(finding.severity),
11050
+ // SARIF's vocabulary for how much the rule is claiming. It lines up
11051
+ // with the confidence model: a bare construct match is `medium`, a
11052
+ // match with visible untrusted input is `high`.
11053
+ precision: finding.confidence === "pattern" ? "medium" : "high"
11054
+ }
11055
+ });
11056
+ }
11057
+ const base = options.base ?? process.cwd();
11058
+ const root = options.root ?? base;
11059
+ const results = findings.map((finding) => ({
11060
+ ruleId: finding.ruleId,
11061
+ level: sarifLevel(finding.severity),
11062
+ message: { text: finding.message },
11063
+ locations: [
11064
+ {
11065
+ physicalLocation: {
11066
+ artifactLocation: {
11067
+ uri: toArtifactUri(finding.file, base, options.pathPrefix, root),
11068
+ uriBaseId: "%SRCROOT%"
11069
+ },
11070
+ region: {
11071
+ // Clamped, never 0. A whole-file finding has no line; SARIF has no
11072
+ // way to say that, and 0 fails validation outright.
11073
+ startLine: Math.max(1, finding.line),
11074
+ snippet: { text: finding.excerpt }
11075
+ }
11076
+ }
11077
+ }
11078
+ ],
11079
+ partialFingerprints: {
11080
+ primaryLocationLineHash: `${finding.ruleId}:${finding.file}:${Math.max(1, finding.line)}`
11081
+ },
11082
+ properties: {
11083
+ severity: finding.severity,
11084
+ confidence: finding.confidence,
11085
+ category: finding.category,
11086
+ ...finding.cwe ? { cwe: finding.cwe } : {}
11087
+ }
11088
+ }));
11089
+ return {
11090
+ $schema: SARIF_SCHEMA,
11091
+ version: SARIF_VERSION,
11092
+ runs: [
11093
+ {
11094
+ tool: {
11095
+ driver: {
11096
+ name: "ThreatCrush",
11097
+ version: options.toolVersion,
11098
+ informationUri: "https://threatcrush.com",
11099
+ rules: [...rules.values()]
11100
+ }
11101
+ },
11102
+ results,
11103
+ columnKind: "utf16CodeUnits"
11104
+ }
11105
+ ]
11106
+ };
11107
+ }
11108
+
11109
+ // src/commands/scan.ts
11110
+ function readVersion() {
11111
+ for (const candidate of [
11112
+ (0, import_node_path5.join)(__dirname, "..", "package.json"),
11113
+ (0, import_node_path5.join)(__dirname, "..", "..", "package.json")
11114
+ ]) {
11115
+ try {
11116
+ return JSON.parse((0, import_node_fs7.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
11117
+ } catch {
11118
+ }
11119
+ }
11120
+ return "0.0.0";
11121
+ }
11122
+ var PKG_VERSION = readVersion();
11123
+ function parseFailOn(raw) {
11124
+ if (!raw) return [];
11125
+ const requested = raw.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
11126
+ const unknown = requested.filter((name) => !SEVERITY_ORDER.includes(name));
11127
+ if (unknown.length > 0) {
11128
+ throw new Error(
11129
+ `unknown severity in --fail-on: ${unknown.join(", ")} (expected ${SEVERITY_ORDER.join(", ")})`
11130
+ );
9652
11131
  }
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 }
11132
+ return requested;
11133
+ }
11134
+ function toRunResult(targetPath, findings, filesScanned) {
11135
+ const structured = findings.map((finding) => ({
11136
+ type: finding.title,
11137
+ severity: finding.severity,
11138
+ message: finding.message,
11139
+ location: `${finding.file}:${finding.line}`,
11140
+ details: {
11141
+ file: finding.file,
11142
+ line: finding.line,
11143
+ snippet: finding.excerpt,
11144
+ ruleId: finding.ruleId,
11145
+ confidence: finding.confidence,
11146
+ ...finding.cwe ? { cwe: finding.cwe } : {}
11147
+ }
9659
11148
  }));
9660
- const summary = summarize(structured);
11149
+ const counts = summarize(structured);
9661
11150
  return {
9662
11151
  type: "scan",
9663
11152
  target: targetPath,
9664
11153
  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`
11154
+ severity_summary: counts,
11155
+ 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
11156
  };
9668
11157
  }
9669
- async function scanCommand(targetPath) {
9670
- banner();
9671
- logger.info(`Scanning ${source_default.white(targetPath)} for security issues...
11158
+ function failedResult(targetPath, message) {
11159
+ return {
11160
+ type: "scan",
11161
+ target: targetPath,
11162
+ findings: [],
11163
+ severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
11164
+ summary: `Scan failed: ${message}`,
11165
+ error: message
11166
+ };
11167
+ }
11168
+ async function runScan(targetPath) {
11169
+ try {
11170
+ const report = scanPath(targetPath);
11171
+ const findings = [...report.findings, ...await scanDependencies(targetPath)];
11172
+ return toRunResult(targetPath, findings, report.filesScanned);
11173
+ } catch (err) {
11174
+ return failedResult(targetPath, err.message);
11175
+ }
11176
+ }
11177
+ async function scanCommand(targetPath, options = {}) {
11178
+ const format = options.format ?? "text";
11179
+ const machineReadable = format !== "text";
11180
+ const say = machineReadable ? (line) => process.stderr.write(`${line}
11181
+ `) : (line) => process.stdout.write(`${line}
9672
11182
  `);
9673
- const spinner = ora({ text: "Scanning files...", color: "green" }).start();
9674
- const findings = [];
9675
- let filesScanned = 0;
11183
+ if (!(0, import_node_fs7.existsSync)(targetPath)) {
11184
+ say(source_default.red(`Scan target does not exist: ${targetPath}`));
11185
+ process.exitCode = 2;
11186
+ return failedResult(targetPath, `no such path: ${targetPath}`);
11187
+ }
11188
+ if (!machineReadable) {
11189
+ banner();
11190
+ logger.info(`Scanning ${source_default.white(targetPath)} for security issues...
11191
+ `);
11192
+ }
11193
+ const spinner = machineReadable ? null : ora({ text: "Scanning files...", color: "green" }).start();
11194
+ let outcome;
9676
11195
  try {
9677
- scanDirectory(targetPath, targetPath, findings, () => {
9678
- filesScanned++;
9679
- spinner.text = `Scanning files... (${filesScanned} files)`;
11196
+ let seen = 0;
11197
+ const report = scanPath(targetPath, {
11198
+ onFile: () => {
11199
+ seen += 1;
11200
+ if (spinner) spinner.text = `Scanning files... (${seen} files)`;
11201
+ }
9680
11202
  });
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
11203
+ if (options.dependencies) {
11204
+ if (spinner) spinner.text = "Querying OSV.dev for dependency advisories...";
11205
+ report.findings.push(...await scanDependencies(targetPath));
11206
+ }
11207
+ outcome = {
11208
+ result: toRunResult(targetPath, report.findings, report.filesScanned),
11209
+ findings: report.findings,
11210
+ filesScanned: report.filesScanned,
11211
+ unreadable: report.unreadable,
11212
+ suppressed: report.suppressed,
11213
+ root: report.root
9690
11214
  };
11215
+ } catch (err) {
11216
+ spinner?.fail(`Scan failed: ${err.message}`);
11217
+ process.exitCode = 2;
11218
+ return failedResult(targetPath, err.message);
9691
11219
  }
9692
- spinner.succeed(`Scanned ${filesScanned} files
11220
+ spinner?.succeed(`Scanned ${outcome.filesScanned} files
9693
11221
  `);
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);
11222
+ if (outcome.unreadable.length > 0) {
11223
+ say(
11224
+ source_default.yellow(
11225
+ ` ! ${outcome.unreadable.length} path(s) could not be read and were NOT scanned`
11226
+ )
11227
+ );
11228
+ if (options.verbose) {
11229
+ for (const path of outcome.unreadable) say(source_default.gray(` ${path}`));
11230
+ }
11231
+ }
11232
+ if (outcome.suppressed > 0) {
11233
+ say(
11234
+ source_default.gray(
11235
+ ` \xB7 ${outcome.suppressed} finding(s) suppressed by inline threatcrush-disable comments`
11236
+ )
11237
+ );
11238
+ }
11239
+ if (machineReadable) {
11240
+ emitMachineReadable(format, outcome, targetPath, options, say);
11241
+ } else {
11242
+ printHuman(outcome);
11243
+ }
11244
+ const failOn = options.failOn ?? [];
11245
+ if (meetsFailThreshold(outcome.findings, failOn)) {
11246
+ say(
11247
+ source_default.red(
11248
+ `
11249
+ \u2717 findings at or above ${[...failOn].join("/")} \u2014 failing as requested by --fail-on`
11250
+ )
11251
+ );
11252
+ process.exitCode = 1;
11253
+ }
11254
+ return outcome.result;
11255
+ }
11256
+ function emitMachineReadable(format, outcome, targetPath, options, say) {
11257
+ const payload = format === "sarif" ? buildSarif(outcome.findings, {
11258
+ toolVersion: PKG_VERSION,
11259
+ pathPrefix: options.pathPrefix,
11260
+ // Relative to the working directory, NOT the scan root. `threatcrush
11261
+ // scan vulns` from a repo root must emit `vulns/secrets/x.env`, not
11262
+ // `secrets/x.env` — the second form matches nothing in the
11263
+ // consumer's view of the repository, so every finding lands
11264
+ // "outside" whatever it scoped to and a working scan reads as 0%.
11265
+ // This is the single most expensive mistake in the whole pipeline
11266
+ // and it fails silently. `--path-prefix` covers the remaining case:
11267
+ // a scan run from inside the subdirectory it is scanning.
11268
+ base: process.cwd(),
11269
+ root: (0, import_node_path5.resolve)(outcome.root)
11270
+ }) : {
11271
+ tool: "threatcrush",
11272
+ version: PKG_VERSION,
11273
+ target: targetPath,
11274
+ filesScanned: outcome.filesScanned,
11275
+ unreadable: outcome.unreadable,
11276
+ suppressed: outcome.suppressed,
11277
+ summary: outcome.result.severity_summary,
11278
+ findings: outcome.findings
11279
+ };
11280
+ const serialized = `${JSON.stringify(payload, null, 2)}
11281
+ `;
11282
+ if (options.output) {
11283
+ (0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)((0, import_node_path5.resolve)(options.output)), { recursive: true });
11284
+ (0, import_node_fs7.writeFileSync)(options.output, serialized, "utf-8");
11285
+ say(
11286
+ source_default.gray(
11287
+ ` ${format.toUpperCase()} written to ${options.output} (${outcome.findings.length} finding(s))`
11288
+ )
11289
+ );
11290
+ return;
11291
+ }
11292
+ process.stdout.write(serialized);
11293
+ }
11294
+ function printHuman(outcome) {
11295
+ const { findings, filesScanned } = outcome;
9702
11296
  if (findings.length === 0) {
9703
11297
  console.log(source_default.green.bold(" \u2713 No security issues found!"));
9704
11298
  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
- };
11299
+ return;
9712
11300
  }
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");
11301
+ const counts = outcome.result.severity_summary;
9717
11302
  console.log(source_default.white.bold(" Scan Results"));
9718
11303
  console.log(source_default.gray(" " + "\u2500".repeat(70)));
9719
11304
  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")}`
11305
+ ` ${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
11306
  );
9722
11307
  console.log(source_default.gray(" " + "\u2500".repeat(70)));
9723
11308
  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))}`);
11309
+ for (const finding of findings) {
11310
+ const label = finding.severity.toUpperCase();
11311
+ 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}]`);
11312
+ console.log(` ${badge} ${source_default.white.bold(finding.title)}`);
11313
+ console.log(
11314
+ ` ${source_default.gray("File:")} ${source_default.cyan(finding.file)}:${source_default.yellow(String(finding.line))}`
11315
+ );
9729
11316
  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()}`);
11317
+ if (finding.consequence) {
11318
+ console.log(` ${source_default.gray("Risk:")} ${source_default.dim(finding.consequence)}`);
11319
+ }
11320
+ if (finding.excerpt) {
11321
+ console.log(` ${source_default.gray("Code:")} ${finding.excerpt}`);
9736
11322
  }
11323
+ console.log(
11324
+ ` ${source_default.gray("Rule:")} ${source_default.dim(finding.ruleId)}` + (finding.cwe ? source_default.dim(` \xB7 ${finding.cwe}`) : "") + source_default.dim(` \xB7 confidence: ${finding.confidence}`)
11325
+ );
9737
11326
  console.log();
9738
11327
  }
9739
11328
  console.log(source_default.gray(" " + "\u2500".repeat(70)));
9740
- console.log(` ${source_default.white.bold(`${findings.length} issue(s) found`)} across ${filesScanned} files`);
11329
+ console.log(
11330
+ ` ${source_default.white.bold(`${findings.length} issue(s) found`)} across ${filesScanned} files`
11331
+ );
9741
11332
  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
11333
  }
9898
11334
 
9899
11335
  // src/commands/init.ts
9900
- var import_node_fs8 = require("fs");
11336
+ var import_node_fs10 = require("fs");
9901
11337
  var import_node_child_process = require("child_process");
9902
11338
  var import_node_readline3 = __toESM(require("readline"));
9903
11339
 
9904
11340
  // src/core/config.ts
9905
- var import_node_fs6 = require("fs");
9906
- var import_node_path3 = require("path");
11341
+ var import_node_fs8 = require("fs");
11342
+ var import_node_path6 = require("path");
9907
11343
  var import_toml = __toESM(require_toml());
9908
11344
  var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
9909
11345
  var DEFAULT_CONFDIR = "/etc/threatcrush/threatcrushd.conf.d";
@@ -9929,11 +11365,11 @@ var DEFAULT_CONFIG = {
9929
11365
  };
9930
11366
  function loadConfig(configPath) {
9931
11367
  const path = configPath || DEFAULT_CONFIG_PATH;
9932
- if (!(0, import_node_fs6.existsSync)(path)) {
11368
+ if (!(0, import_node_fs8.existsSync)(path)) {
9933
11369
  return { ...DEFAULT_CONFIG };
9934
11370
  }
9935
11371
  try {
9936
- const raw = (0, import_node_fs6.readFileSync)(path, "utf-8");
11372
+ const raw = (0, import_node_fs8.readFileSync)(path, "utf-8");
9937
11373
  const parsed = import_toml.default.parse(raw);
9938
11374
  return {
9939
11375
  daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
@@ -9949,13 +11385,13 @@ function loadConfig(configPath) {
9949
11385
  function loadModuleConfigs(confDir) {
9950
11386
  const dir = confDir || DEFAULT_CONFDIR;
9951
11387
  const configs = /* @__PURE__ */ new Map();
9952
- if (!(0, import_node_fs6.existsSync)(dir)) {
11388
+ if (!(0, import_node_fs8.existsSync)(dir)) {
9953
11389
  return configs;
9954
11390
  }
9955
- const files = (0, import_node_fs6.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
11391
+ const files = (0, import_node_fs8.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
9956
11392
  for (const file of files) {
9957
11393
  try {
9958
- const raw = (0, import_node_fs6.readFileSync)((0, import_node_path3.join)(dir, file), "utf-8");
11394
+ const raw = (0, import_node_fs8.readFileSync)((0, import_node_path6.join)(dir, file), "utf-8");
9959
11395
  const parsed = import_toml.default.parse(raw);
9960
11396
  for (const [name, config] of Object.entries(parsed)) {
9961
11397
  configs.set(name, config);
@@ -9984,23 +11420,23 @@ function generateModuleConfig(moduleName, defaults = {}) {
9984
11420
  }
9985
11421
 
9986
11422
  // src/core/cli-config.ts
9987
- var import_node_fs7 = require("fs");
9988
- var import_node_path4 = require("path");
11423
+ var import_node_fs9 = require("fs");
11424
+ var import_node_path7 = require("path");
9989
11425
  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");
11426
+ var CLI_CONFIG_DIR = (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".threatcrush");
11427
+ var CLI_CONFIG_PATH = (0, import_node_path7.join)(CLI_CONFIG_DIR, "config.json");
9992
11428
  function readCliConfig() {
9993
11429
  try {
9994
- return JSON.parse((0, import_node_fs7.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
11430
+ return JSON.parse((0, import_node_fs9.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
9995
11431
  } catch {
9996
11432
  return {};
9997
11433
  }
9998
11434
  }
9999
11435
  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");
11436
+ if (!(0, import_node_fs9.existsSync)(CLI_CONFIG_DIR)) (0, import_node_fs9.mkdirSync)(CLI_CONFIG_DIR, { recursive: true });
11437
+ (0, import_node_fs9.writeFileSync)(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
10002
11438
  try {
10003
- (0, import_node_fs7.chmodSync)(CLI_CONFIG_PATH, 384);
11439
+ (0, import_node_fs9.chmodSync)(CLI_CONFIG_PATH, 384);
10004
11440
  } catch {
10005
11441
  }
10006
11442
  }
@@ -10038,13 +11474,13 @@ var import_node_stream = require("stream");
10038
11474
  var API_URL = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
10039
11475
  function prompt(question) {
10040
11476
  const rl = import_node_readline2.default.createInterface({ input: process.stdin, output: process.stdout });
10041
- return new Promise((resolve3) => rl.question(question, (answer) => {
11477
+ return new Promise((resolve5) => rl.question(question, (answer) => {
10042
11478
  rl.close();
10043
- resolve3(answer.trim());
11479
+ resolve5(answer.trim());
10044
11480
  }));
10045
11481
  }
10046
11482
  function promptPassword(question) {
10047
- return new Promise((resolve3) => {
11483
+ return new Promise((resolve5) => {
10048
11484
  const muted = new import_node_stream.Writable({
10049
11485
  write(_chunk, _enc, cb) {
10050
11486
  cb();
@@ -10055,7 +11491,7 @@ function promptPassword(question) {
10055
11491
  rl.question("", (answer) => {
10056
11492
  rl.close();
10057
11493
  process.stdout.write("\n");
10058
- resolve3(answer);
11494
+ resolve5(answer);
10059
11495
  });
10060
11496
  });
10061
11497
  }
@@ -10245,16 +11681,16 @@ function binaryExists(name) {
10245
11681
  }
10246
11682
  }
10247
11683
  function findLogPath(paths) {
10248
- return paths.find((p) => (0, import_node_fs8.existsSync)(p));
11684
+ return paths.find((p) => (0, import_node_fs10.existsSync)(p));
10249
11685
  }
10250
11686
  async function promptYesNo(question, fallback2) {
10251
11687
  const rl = import_node_readline3.default.createInterface({ input: process.stdin, output: process.stdout });
10252
11688
  const hint = fallback2 ? "(Y/n)" : "(y/N)";
10253
- return new Promise((resolve3) => rl.question(`${question} ${hint}: `, (answer) => {
11689
+ return new Promise((resolve5) => rl.question(`${question} ${hint}: `, (answer) => {
10254
11690
  rl.close();
10255
11691
  const trimmed = answer.trim().toLowerCase();
10256
- if (!trimmed) return resolve3(fallback2);
10257
- resolve3(trimmed === "y" || trimmed === "yes");
11692
+ if (!trimmed) return resolve5(fallback2);
11693
+ resolve5(trimmed === "y" || trimmed === "yes");
10258
11694
  }));
10259
11695
  }
10260
11696
  async function ensureLoggedIn() {
@@ -10361,11 +11797,11 @@ async function initCommand() {
10361
11797
  }
10362
11798
  } else {
10363
11799
  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 });
11800
+ (0, import_node_fs10.mkdirSync)(confDDir, { recursive: true });
11801
+ (0, import_node_fs10.mkdirSync)("/var/log/threatcrush", { recursive: true });
11802
+ (0, import_node_fs10.mkdirSync)("/var/lib/threatcrush", { recursive: true });
10367
11803
  const mainConfig = generateDefaultConfig(detected.map((d) => d.name));
10368
- (0, import_node_fs8.writeFileSync)(`${configDir}/threatcrushd.conf`, mainConfig);
11804
+ (0, import_node_fs10.writeFileSync)(`${configDir}/threatcrushd.conf`, mainConfig);
10369
11805
  for (const svc of detected) {
10370
11806
  const svcDef = SERVICES_TO_DETECT.find((s) => s.name === svc.name);
10371
11807
  if (!svcDef) continue;
@@ -10374,7 +11810,7 @@ async function initCommand() {
10374
11810
  ...svcDef.moduleConfig,
10375
11811
  log_path: svc.logPath || svcDef.logPaths[0]
10376
11812
  });
10377
- (0, import_node_fs8.writeFileSync)(`${confDDir}/${modName}.conf`, modConfig);
11813
+ (0, import_node_fs10.writeFileSync)(`${confDDir}/${modName}.conf`, modConfig);
10378
11814
  }
10379
11815
  spinner2.succeed("Configuration written successfully");
10380
11816
  console.log();
@@ -10392,8 +11828,8 @@ async function initCommand() {
10392
11828
  }
10393
11829
  function checkWriteAccess(dir) {
10394
11830
  try {
10395
- if (!(0, import_node_fs8.existsSync)(dir)) {
10396
- (0, import_node_fs8.mkdirSync)(dir, { recursive: true });
11831
+ if (!(0, import_node_fs10.existsSync)(dir)) {
11832
+ (0, import_node_fs10.mkdirSync)(dir, { recursive: true });
10397
11833
  }
10398
11834
  return true;
10399
11835
  } catch {
@@ -10402,8 +11838,8 @@ function checkWriteAccess(dir) {
10402
11838
  }
10403
11839
 
10404
11840
  // src/core/module-loader.ts
10405
- var import_node_fs9 = require("fs");
10406
- var import_node_path5 = require("path");
11841
+ var import_node_fs11 = require("fs");
11842
+ var import_node_path8 = require("path");
10407
11843
  var import_toml2 = __toESM(require_toml());
10408
11844
  init_paths();
10409
11845
  function discoverModules(moduleDir, confDir) {
@@ -10411,22 +11847,22 @@ function discoverModules(moduleDir, confDir) {
10411
11847
  const configs = loadModuleConfigs(confDir || PATHS.confD);
10412
11848
  const searchPaths = [
10413
11849
  moduleDir || PATHS.moduleDir,
10414
- (0, import_node_path5.resolve)(process.cwd(), "modules")
11850
+ (0, import_node_path8.resolve)(process.cwd(), "modules")
10415
11851
  ];
10416
- const builtinDir = (0, import_node_path5.resolve)(__dirname || ".", "..", "modules");
10417
- if ((0, import_node_fs9.existsSync)(builtinDir)) {
11852
+ const builtinDir = (0, import_node_path8.resolve)(__dirname || ".", "..", "modules");
11853
+ if ((0, import_node_fs11.existsSync)(builtinDir)) {
10418
11854
  searchPaths.push(builtinDir);
10419
11855
  }
10420
11856
  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 });
11857
+ if (!(0, import_node_fs11.existsSync)(basePath)) continue;
11858
+ const entries = (0, import_node_fs11.readdirSync)(basePath, { withFileTypes: true });
10423
11859
  for (const entry of entries) {
10424
11860
  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;
11861
+ const modPath = (0, import_node_path8.join)(basePath, entry.name);
11862
+ const manifestPath = (0, import_node_path8.join)(modPath, "mod.toml");
11863
+ if (!(0, import_node_fs11.existsSync)(manifestPath)) continue;
10428
11864
  try {
10429
- const raw = (0, import_node_fs9.readFileSync)(manifestPath, "utf-8");
11865
+ const raw = (0, import_node_fs11.readFileSync)(manifestPath, "utf-8");
10430
11866
  const manifest = import_toml2.default.parse(raw);
10431
11867
  const config = configs.get(manifest.module.name) || { enabled: true };
10432
11868
  modules.push({
@@ -10545,8 +11981,8 @@ function formatUptime(seconds) {
10545
11981
 
10546
11982
  // src/commands/modules.ts
10547
11983
  var import_node_child_process2 = require("child_process");
10548
- var import_node_fs10 = require("fs");
10549
- var import_node_path6 = require("path");
11984
+ var import_node_fs12 = require("fs");
11985
+ var import_node_path9 = require("path");
10550
11986
  var import_toml3 = __toESM(require_toml());
10551
11987
  init_paths();
10552
11988
  init_pidfile();
@@ -10555,13 +11991,32 @@ function modulesDir() {
10555
11991
  ensureRuntimeDirs();
10556
11992
  return PATHS.moduleDir;
10557
11993
  }
11994
+ function safeModuleDirName(name, label = "module name") {
11995
+ if (!/^[A-Za-z0-9._@-]+$/.test(name) || name === "." || name === "..") {
11996
+ throw new Error(`Unsafe ${label}: ${name}`);
11997
+ }
11998
+ return name;
11999
+ }
12000
+ function moduleDestination(dir, name, label) {
12001
+ return (0, import_node_path9.join)(dir, safeModuleDirName(name, label));
12002
+ }
12003
+ function assertSafeTarballEntries(tarPath) {
12004
+ const listing = (0, import_node_child_process2.execFileSync)("tar", ["-tzf", tarPath], { encoding: "utf-8" });
12005
+ for (const entry of listing.split("\n").map((line) => line.trim()).filter(Boolean)) {
12006
+ const normalized = entry.replace(/\\/g, "/");
12007
+ const segments = normalized.split("/");
12008
+ if (normalized.startsWith("/") || segments.includes("..")) {
12009
+ throw new Error(`Unsafe tarball entry: ${entry}`);
12010
+ }
12011
+ }
12012
+ }
10558
12013
  function validateManifest(modPath) {
10559
- const manifestPath = (0, import_node_path6.join)(modPath, "mod.toml");
10560
- if (!(0, import_node_fs10.existsSync)(manifestPath)) {
12014
+ const manifestPath = (0, import_node_path9.join)(modPath, "mod.toml");
12015
+ if (!(0, import_node_fs12.existsSync)(manifestPath)) {
10561
12016
  return { ok: false, error: `mod.toml not found at ${manifestPath}` };
10562
12017
  }
10563
12018
  try {
10564
- const raw = (0, import_node_fs10.readFileSync)(manifestPath, "utf-8");
12019
+ const raw = (0, import_node_fs12.readFileSync)(manifestPath, "utf-8");
10565
12020
  const parsed = import_toml3.default.parse(raw);
10566
12021
  const name = parsed.module?.name;
10567
12022
  const version = parsed.module?.version;
@@ -10615,8 +12070,8 @@ async function modulesInstallCommand(source) {
10615
12070
  console.log();
10616
12071
  const dir = modulesDir();
10617
12072
  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)) {
12073
+ const absPath = (0, import_node_path9.resolve)(source.startsWith("~") ? source.replace("~", process.env.HOME || "") : source);
12074
+ if (!(0, import_node_fs12.existsSync)(absPath)) {
10620
12075
  console.log(source_default.red(` \u2717 Path not found: ${absPath}
10621
12076
  `));
10622
12077
  return;
@@ -10627,8 +12082,15 @@ async function modulesInstallCommand(source) {
10627
12082
  `));
10628
12083
  return;
10629
12084
  }
10630
- const dest2 = (0, import_node_path6.join)(dir, check.name);
10631
- if ((0, import_node_fs10.existsSync)(dest2)) {
12085
+ let dest2;
12086
+ try {
12087
+ dest2 = moduleDestination(dir, check.name);
12088
+ } catch (err) {
12089
+ console.log(source_default.red(` x ${err.message}
12090
+ `));
12091
+ return;
12092
+ }
12093
+ if ((0, import_node_fs12.existsSync)(dest2)) {
10632
12094
  console.log(source_default.yellow(` ! ${check.name} is already installed at ${dest2}`));
10633
12095
  console.log(source_default.dim(` Run ${source_default.white(`threatcrush modules remove ${check.name}`)} first.
10634
12096
  `));
@@ -10636,7 +12098,7 @@ async function modulesInstallCommand(source) {
10636
12098
  }
10637
12099
  const spinner2 = ora({ text: `Copying module files...`, color: "green" }).start();
10638
12100
  try {
10639
- (0, import_node_fs10.cpSync)(absPath, dest2, { recursive: true, dereference: true });
12101
+ (0, import_node_fs12.cpSync)(absPath, dest2, { recursive: true, dereference: true });
10640
12102
  spinner2.succeed(`Installed ${source_default.white(check.name)} v${check.version} \u2192 ${dest2}`);
10641
12103
  } catch (err) {
10642
12104
  spinner2.fail(`Copy failed: ${err.message}`);
@@ -10648,9 +12110,17 @@ async function modulesInstallCommand(source) {
10648
12110
  }
10649
12111
  if (source.startsWith("github:") || source.startsWith("https://") || source.startsWith("git@") || source.endsWith(".git")) {
10650
12112
  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)) {
12113
+ let name;
12114
+ let dest2;
12115
+ try {
12116
+ name = safeModuleDirName((0, import_node_path9.basename)(gitUrl.replace(/\.git$/, "")), "repository name");
12117
+ dest2 = moduleDestination(dir, name);
12118
+ } catch (err) {
12119
+ console.log(source_default.red(` x ${err.message}
12120
+ `));
12121
+ return;
12122
+ }
12123
+ if ((0, import_node_fs12.existsSync)(dest2)) {
10654
12124
  console.log(source_default.yellow(` ! ${name} is already installed at ${dest2}`));
10655
12125
  console.log(source_default.dim(` Run ${source_default.white(`threatcrush modules remove ${name}`)} first.
10656
12126
  `));
@@ -10658,7 +12128,7 @@ async function modulesInstallCommand(source) {
10658
12128
  }
10659
12129
  const spinner2 = ora({ text: `Cloning ${gitUrl}...`, color: "green" }).start();
10660
12130
  try {
10661
- (0, import_node_child_process2.execSync)(`git clone --depth 1 ${gitUrl} ${dest2}`, { stdio: "pipe" });
12131
+ (0, import_node_child_process2.execFileSync)("git", ["clone", "--depth", "1", "--", gitUrl, dest2], { stdio: "pipe" });
10662
12132
  } catch (err) {
10663
12133
  spinner2.fail(`Clone failed: ${err.message}`);
10664
12134
  return;
@@ -10667,7 +12137,7 @@ async function modulesInstallCommand(source) {
10667
12137
  if (!check.ok) {
10668
12138
  spinner2.fail(check.error);
10669
12139
  try {
10670
- (0, import_node_fs10.rmSync)(dest2, { recursive: true, force: true });
12140
+ (0, import_node_fs12.rmSync)(dest2, { recursive: true, force: true });
10671
12141
  } catch {
10672
12142
  }
10673
12143
  return;
@@ -10704,8 +12174,15 @@ async function modulesInstallCommand(source) {
10704
12174
  }
10705
12175
  spinner.succeed(`Found ${mod.name} v${mod.version}`);
10706
12176
  const install = mod.install;
10707
- const dest = (0, import_node_path6.join)(dir, mod.slug);
10708
- if ((0, import_node_fs10.existsSync)(dest)) {
12177
+ let dest;
12178
+ try {
12179
+ dest = moduleDestination(dir, mod.slug, "module slug");
12180
+ } catch (err) {
12181
+ console.log(source_default.red(` x ${err.message}
12182
+ `));
12183
+ return;
12184
+ }
12185
+ if ((0, import_node_fs12.existsSync)(dest)) {
10709
12186
  console.log(source_default.yellow(` ! ${mod.slug} is already installed at ${dest}
10710
12187
  `));
10711
12188
  return;
@@ -10713,7 +12190,7 @@ async function modulesInstallCommand(source) {
10713
12190
  if (install.npm_package) {
10714
12191
  const npmSpinner = ora({ text: `Installing ${install.npm_package}...`, color: "green" }).start();
10715
12192
  try {
10716
- (0, import_node_child_process2.execSync)(`npm install -g ${install.npm_package}`, { stdio: "pipe" });
12193
+ (0, import_node_child_process2.execFileSync)("npm", ["install", "-g", "--", install.npm_package], { stdio: "pipe" });
10717
12194
  npmSpinner.succeed(`Package ${install.npm_package} installed globally`);
10718
12195
  } catch (err) {
10719
12196
  npmSpinner.fail(`npm install failed: ${err.message}`);
@@ -10722,7 +12199,7 @@ async function modulesInstallCommand(source) {
10722
12199
  } else if (install.git_url) {
10723
12200
  const cloneSpinner = ora({ text: "Cloning module repository...", color: "green" }).start();
10724
12201
  try {
10725
- (0, import_node_child_process2.execSync)(`git clone --depth 1 ${install.git_url} ${dest}`, { stdio: "pipe" });
12202
+ (0, import_node_child_process2.execFileSync)("git", ["clone", "--depth", "1", "--", install.git_url, dest], { stdio: "pipe" });
10726
12203
  } catch (err) {
10727
12204
  cloneSpinner.fail(`Clone failed: ${err.message}`);
10728
12205
  return;
@@ -10731,7 +12208,7 @@ async function modulesInstallCommand(source) {
10731
12208
  if (!check.ok) {
10732
12209
  cloneSpinner.fail(check.error);
10733
12210
  try {
10734
- (0, import_node_fs10.rmSync)(dest, { recursive: true, force: true });
12211
+ (0, import_node_fs12.rmSync)(dest, { recursive: true, force: true });
10735
12212
  } catch {
10736
12213
  }
10737
12214
  return;
@@ -10745,10 +12222,11 @@ async function modulesInstallCommand(source) {
10745
12222
  dlSpinner.fail(`HTTP ${res.status}`);
10746
12223
  return;
10747
12224
  }
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 });
12225
+ const tar = (0, import_node_path9.join)(dir, `${mod.slug}.tar.gz`);
12226
+ (0, import_node_fs12.writeFileSync)(tar, Buffer.from(await res.arrayBuffer()));
12227
+ assertSafeTarballEntries(tar);
12228
+ (0, import_node_child_process2.execFileSync)("tar", ["-xzf", tar, "-C", dir], { stdio: "pipe" });
12229
+ (0, import_node_fs12.rmSync)(tar, { force: true });
10752
12230
  const check = validateManifest(dest);
10753
12231
  if (!check.ok) {
10754
12232
  dlSpinner.fail(check.error);
@@ -10771,8 +12249,15 @@ async function modulesRemoveCommand(name) {
10771
12249
  console.log(source_default.green.bold(" Module Removal"));
10772
12250
  console.log(source_default.gray(" " + "\u2500".repeat(50)));
10773
12251
  const dir = modulesDir();
10774
- const target = (0, import_node_path6.join)(dir, name);
10775
- if (!(0, import_node_fs10.existsSync)(target)) {
12252
+ let target;
12253
+ try {
12254
+ target = moduleDestination(dir, name);
12255
+ } catch (err) {
12256
+ console.log(source_default.red(` x ${err.message}
12257
+ `));
12258
+ return;
12259
+ }
12260
+ if (!(0, import_node_fs12.existsSync)(target)) {
10776
12261
  console.log(source_default.yellow(` ! No module named "${name}" in ${dir}
10777
12262
  `));
10778
12263
  return;
@@ -10782,7 +12267,7 @@ async function modulesRemoveCommand(name) {
10782
12267
  console.log(source_default.yellow(` ! Directory name "${name}" does not match manifest name "${check.name}"`));
10783
12268
  }
10784
12269
  try {
10785
- (0, import_node_fs10.rmSync)(target, { recursive: true, force: true });
12270
+ (0, import_node_fs12.rmSync)(target, { recursive: true, force: true });
10786
12271
  console.log(source_default.green(` \u2713 Removed ${name} from ${dir}
10787
12272
  `));
10788
12273
  } catch (err) {
@@ -11155,21 +12640,21 @@ async function pentestCommand(targetUrl) {
11155
12640
 
11156
12641
  // src/commands/orgs.ts
11157
12642
  var import_node_os5 = require("os");
11158
- var import_node_fs11 = require("fs");
11159
- var import_node_path7 = require("path");
12643
+ var import_node_fs13 = require("fs");
12644
+ var import_node_path10 = require("path");
11160
12645
  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");
12646
+ var CONFIG_PATH = (0, import_node_path10.join)((0, import_node_os5.homedir)(), ".threatcrush", "config.json");
11162
12647
  function readConfig() {
11163
12648
  try {
11164
- return JSON.parse((0, import_node_fs11.readFileSync)(CONFIG_PATH, "utf-8"));
12649
+ return JSON.parse((0, import_node_fs13.readFileSync)(CONFIG_PATH, "utf-8"));
11165
12650
  } catch {
11166
12651
  return {};
11167
12652
  }
11168
12653
  }
11169
12654
  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));
12655
+ const dir = (0, import_node_path10.join)((0, import_node_os5.homedir)(), ".threatcrush");
12656
+ if (!(0, import_node_fs13.existsSync)(dir)) (0, import_node_fs13.mkdirSync)(dir, { recursive: true });
12657
+ (0, import_node_fs13.writeFileSync)(CONFIG_PATH, JSON.stringify(config, null, 2));
11173
12658
  }
11174
12659
  function getAuthHeaders() {
11175
12660
  const config = readConfig();
@@ -11305,13 +12790,13 @@ async function useOrganization(slug) {
11305
12790
 
11306
12791
  // src/commands/servers.ts
11307
12792
  var import_node_os6 = require("os");
11308
- var import_node_fs12 = require("fs");
11309
- var import_node_path8 = require("path");
12793
+ var import_node_fs14 = require("fs");
12794
+ var import_node_path11 = require("path");
11310
12795
  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");
12796
+ var CONFIG_PATH2 = (0, import_node_path11.join)((0, import_node_os6.homedir)(), ".threatcrush", "config.json");
11312
12797
  function readConfig2() {
11313
12798
  try {
11314
- return JSON.parse((0, import_node_fs12.readFileSync)(CONFIG_PATH2, "utf-8"));
12799
+ return JSON.parse((0, import_node_fs14.readFileSync)(CONFIG_PATH2, "utf-8"));
11315
12800
  } catch {
11316
12801
  return {};
11317
12802
  }
@@ -11414,14 +12899,14 @@ function timeAgo(dateStr) {
11414
12899
 
11415
12900
  // src/commands/connect.ts
11416
12901
  var import_node_os7 = require("os");
11417
- var import_node_fs13 = require("fs");
11418
- var import_node_path9 = require("path");
12902
+ var import_node_fs15 = require("fs");
12903
+ var import_node_path12 = require("path");
11419
12904
  var import_node_child_process3 = require("child_process");
11420
12905
  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");
12906
+ var CONFIG_PATH3 = (0, import_node_path12.join)((0, import_node_os7.homedir)(), ".threatcrush", "config.json");
11422
12907
  function readConfig3() {
11423
12908
  try {
11424
- return JSON.parse((0, import_node_fs13.readFileSync)(CONFIG_PATH3, "utf-8"));
12909
+ return JSON.parse((0, import_node_fs15.readFileSync)(CONFIG_PATH3, "utf-8"));
11425
12910
  } catch {
11426
12911
  return {};
11427
12912
  }
@@ -11576,19 +13061,19 @@ async function sshConnect(options) {
11576
13061
 
11577
13062
  // src/commands/daemon.ts
11578
13063
  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");
13064
+ var import_node_fs24 = require("fs");
13065
+ var import_node_path16 = require("path");
13066
+ var import_node_fs25 = require("fs");
11582
13067
 
11583
13068
  // src/daemon/index.ts
11584
- var import_node_fs21 = require("fs");
11585
- var import_node_path12 = require("path");
13069
+ var import_node_fs23 = require("fs");
13070
+ var import_node_path15 = require("path");
11586
13071
  init_paths();
11587
13072
  init_pidfile();
11588
13073
 
11589
13074
  // src/daemon/ipc-server.ts
11590
13075
  var import_node_net2 = require("net");
11591
- var import_node_fs14 = require("fs");
13076
+ var import_node_fs16 = require("fs");
11592
13077
  init_paths();
11593
13078
 
11594
13079
  // src/daemon/event-bus.ts
@@ -11635,13 +13120,13 @@ var IpcServer = class {
11635
13120
  startedAt = /* @__PURE__ */ new Date();
11636
13121
  counters = { events: 0, threats: 0, alerts: 0 };
11637
13122
  async start() {
11638
- if ((0, import_node_fs14.existsSync)(PATHS.socket)) {
13123
+ if ((0, import_node_fs16.existsSync)(PATHS.socket)) {
11639
13124
  try {
11640
- (0, import_node_fs14.unlinkSync)(PATHS.socket);
13125
+ (0, import_node_fs16.unlinkSync)(PATHS.socket);
11641
13126
  } catch {
11642
13127
  }
11643
13128
  }
11644
- return new Promise((resolve3, reject) => {
13129
+ return new Promise((resolve5, reject) => {
11645
13130
  this.server = (0, import_node_net2.createServer)((sock) => this.handleClient(sock));
11646
13131
  this.server.on("error", reject);
11647
13132
  this.server.listen(PATHS.socket, () => {
@@ -11658,7 +13143,7 @@ var IpcServer = class {
11658
13143
  } catch {
11659
13144
  }
11660
13145
  }
11661
- resolve3();
13146
+ resolve5();
11662
13147
  });
11663
13148
  });
11664
13149
  }
@@ -11670,20 +13155,20 @@ var IpcServer = class {
11670
13155
  }
11671
13156
  }
11672
13157
  this.clients.clear();
11673
- return new Promise((resolve3) => {
13158
+ return new Promise((resolve5) => {
11674
13159
  if (!this.server) {
11675
13160
  try {
11676
- if ((0, import_node_fs14.existsSync)(PATHS.socket)) (0, import_node_fs14.unlinkSync)(PATHS.socket);
13161
+ if ((0, import_node_fs16.existsSync)(PATHS.socket)) (0, import_node_fs16.unlinkSync)(PATHS.socket);
11677
13162
  } catch {
11678
13163
  }
11679
- return resolve3();
13164
+ return resolve5();
11680
13165
  }
11681
13166
  this.server.close(() => {
11682
13167
  try {
11683
- if ((0, import_node_fs14.existsSync)(PATHS.socket)) (0, import_node_fs14.unlinkSync)(PATHS.socket);
13168
+ if ((0, import_node_fs16.existsSync)(PATHS.socket)) (0, import_node_fs16.unlinkSync)(PATHS.socket);
11684
13169
  } catch {
11685
13170
  }
11686
- resolve3();
13171
+ resolve5();
11687
13172
  });
11688
13173
  });
11689
13174
  }
@@ -11786,14 +13271,14 @@ var IpcServer = class {
11786
13271
  };
11787
13272
 
11788
13273
  // src/daemon/module-host.ts
11789
- var import_node_fs18 = require("fs");
11790
- var import_node_path10 = require("path");
13274
+ var import_node_fs20 = require("fs");
13275
+ var import_node_path13 = require("path");
11791
13276
  var import_node_url = require("url");
11792
13277
  var import_toml4 = __toESM(require_toml());
11793
13278
  init_paths();
11794
13279
 
11795
13280
  // src/daemon/watchers/log-watcher.ts
11796
- var import_node_fs15 = require("fs");
13281
+ var import_node_fs17 = require("fs");
11797
13282
  var import_node_readline4 = require("readline");
11798
13283
  init_state();
11799
13284
  var DEFAULT_SOURCES = [
@@ -11815,9 +13300,9 @@ var LogWatcher = class {
11815
13300
  start() {
11816
13301
  const started = [];
11817
13302
  for (const src of this.sources) {
11818
- if (!(0, import_node_fs15.existsSync)(src.path)) continue;
13303
+ if (!(0, import_node_fs17.existsSync)(src.path)) continue;
11819
13304
  try {
11820
- (0, import_node_fs15.accessSync)(src.path, import_node_fs15.constants.R_OK);
13305
+ (0, import_node_fs17.accessSync)(src.path, import_node_fs17.constants.R_OK);
11821
13306
  } catch {
11822
13307
  continue;
11823
13308
  }
@@ -11837,7 +13322,7 @@ var LogWatcher = class {
11837
13322
  }
11838
13323
  tail(src) {
11839
13324
  try {
11840
- this.positions.set(src.path, (0, import_node_fs15.statSync)(src.path).size);
13325
+ this.positions.set(src.path, (0, import_node_fs17.statSync)(src.path).size);
11841
13326
  } catch {
11842
13327
  this.positions.set(src.path, 0);
11843
13328
  }
@@ -11848,7 +13333,7 @@ var LogWatcher = class {
11848
13333
  poll(src) {
11849
13334
  let stat;
11850
13335
  try {
11851
- stat = (0, import_node_fs15.statSync)(src.path);
13336
+ stat = (0, import_node_fs17.statSync)(src.path);
11852
13337
  } catch {
11853
13338
  return;
11854
13339
  }
@@ -11858,7 +13343,7 @@ var LogWatcher = class {
11858
13343
  return;
11859
13344
  }
11860
13345
  if (stat.size === prev) return;
11861
- const stream = (0, import_node_fs15.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
13346
+ const stream = (0, import_node_fs17.createReadStream)(src.path, { start: prev, encoding: "utf-8" });
11862
13347
  stream.on("error", () => this.positions.set(src.path, stat.size));
11863
13348
  const rl = (0, import_node_readline4.createInterface)({ input: stream });
11864
13349
  rl.on("error", () => {
@@ -12051,7 +13536,7 @@ function realtimeToDate(rt) {
12051
13536
 
12052
13537
  // src/modules/network-monitor/index.ts
12053
13538
  var import_node_child_process5 = require("child_process");
12054
- var import_node_fs16 = require("fs");
13539
+ var import_node_fs18 = require("fs");
12055
13540
  init_state();
12056
13541
  var NetworkMonitor = class {
12057
13542
  constructor(bus2) {
@@ -12090,7 +13575,7 @@ var NetworkMonitor = class {
12090
13575
  hasConntrackOrSs() {
12091
13576
  const ss = (0, import_node_child_process5.spawnSync)("ss", ["--version"], { stdio: "pipe" });
12092
13577
  if (ss.status === 0) return true;
12093
- return (0, import_node_fs16.existsSync)("/proc/net/tcp");
13578
+ return (0, import_node_fs18.existsSync)("/proc/net/tcp");
12094
13579
  }
12095
13580
  poll() {
12096
13581
  try {
@@ -12229,7 +13714,7 @@ var NetworkMonitor = class {
12229
13714
  };
12230
13715
 
12231
13716
  // src/modules/dns-monitor/index.ts
12232
- var import_node_fs17 = require("fs");
13717
+ var import_node_fs19 = require("fs");
12233
13718
  var import_node_readline5 = require("readline");
12234
13719
  init_state();
12235
13720
  var DNS_LOG_SOURCES = [
@@ -12264,9 +13749,9 @@ var DnsMonitor = class {
12264
13749
  entropyThreshold = 3.5;
12265
13750
  start() {
12266
13751
  const sources = DNS_LOG_SOURCES.filter((p) => {
12267
- if (!(0, import_node_fs17.existsSync)(p)) return false;
13752
+ if (!(0, import_node_fs19.existsSync)(p)) return false;
12268
13753
  try {
12269
- (0, import_node_fs17.accessSync)(p, import_node_fs17.constants.R_OK);
13754
+ (0, import_node_fs19.accessSync)(p, import_node_fs19.constants.R_OK);
12270
13755
  return true;
12271
13756
  } catch {
12272
13757
  return false;
@@ -12290,7 +13775,7 @@ var DnsMonitor = class {
12290
13775
  }
12291
13776
  tailLog(path) {
12292
13777
  try {
12293
- this.positions.set(path, (0, import_node_fs17.statSync)(path).size);
13778
+ this.positions.set(path, (0, import_node_fs19.statSync)(path).size);
12294
13779
  } catch {
12295
13780
  this.positions.set(path, 0);
12296
13781
  }
@@ -12300,7 +13785,7 @@ var DnsMonitor = class {
12300
13785
  pollLog(path) {
12301
13786
  let stat;
12302
13787
  try {
12303
- stat = (0, import_node_fs17.statSync)(path);
13788
+ stat = (0, import_node_fs19.statSync)(path);
12304
13789
  } catch {
12305
13790
  return;
12306
13791
  }
@@ -12310,7 +13795,7 @@ var DnsMonitor = class {
12310
13795
  return;
12311
13796
  }
12312
13797
  if (stat.size === prev) return;
12313
- const stream = (0, import_node_fs17.createReadStream)(path, { start: prev, encoding: "utf-8" });
13798
+ const stream = (0, import_node_fs19.createReadStream)(path, { start: prev, encoding: "utf-8" });
12314
13799
  stream.on("error", () => this.positions.set(path, stat.size));
12315
13800
  const rl = (0, import_node_readline5.createInterface)({ input: stream });
12316
13801
  rl.on("line", (line) => this.parseDnsLine(line));
@@ -12542,15 +14027,15 @@ var ModuleHost = class {
12542
14027
  for (const m of builtins) this.modules.set(m.name, m);
12543
14028
  }
12544
14029
  async discoverAndStartInstalled() {
12545
- if (!(0, import_node_fs18.existsSync)(PATHS.moduleDir)) return;
14030
+ if (!(0, import_node_fs20.existsSync)(PATHS.moduleDir)) return;
12546
14031
  const configs = loadModuleConfigs(PATHS.confD);
12547
- const entries = (0, import_node_fs18.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
14032
+ const entries = (0, import_node_fs20.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
12548
14033
  for (const entry of entries) {
12549
14034
  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;
14035
+ const manifestPath = (0, import_node_path13.join)(PATHS.moduleDir, entry.name, "mod.toml");
14036
+ if (!(0, import_node_fs20.existsSync)(manifestPath)) continue;
12552
14037
  try {
12553
- const manifest = import_toml4.default.parse((0, import_node_fs18.readFileSync)(manifestPath, "utf-8"));
14038
+ const manifest = import_toml4.default.parse((0, import_node_fs20.readFileSync)(manifestPath, "utf-8"));
12554
14039
  const name = manifest.module?.name || entry.name;
12555
14040
  const defaults = manifest.module?.config?.defaults || {};
12556
14041
  const config = {
@@ -12564,7 +14049,7 @@ var ModuleHost = class {
12564
14049
  source: "installed",
12565
14050
  status: config.enabled === false ? "disabled" : "loaded",
12566
14051
  events: 0,
12567
- path: (0, import_node_path10.join)(PATHS.moduleDir, entry.name),
14052
+ path: (0, import_node_path13.join)(PATHS.moduleDir, entry.name),
12568
14053
  config
12569
14054
  };
12570
14055
  this.modules.set(name, hosted);
@@ -12579,7 +14064,7 @@ var ModuleHost = class {
12579
14064
  status: "error",
12580
14065
  events: 0,
12581
14066
  detail: `manifest load failed: ${String(err.message || err)}`,
12582
- path: (0, import_node_path10.join)(PATHS.moduleDir, entry.name)
14067
+ path: (0, import_node_path13.join)(PATHS.moduleDir, entry.name)
12583
14068
  });
12584
14069
  }
12585
14070
  }
@@ -12611,17 +14096,17 @@ var ModuleHost = class {
12611
14096
  }
12612
14097
  }
12613
14098
  installedEntrypoint(modulePath) {
12614
- const packageJson = (0, import_node_path10.join)(modulePath, "package.json");
14099
+ const packageJson = (0, import_node_path13.join)(modulePath, "package.json");
12615
14100
  const candidates = [];
12616
- if ((0, import_node_fs18.existsSync)(packageJson)) {
14101
+ if ((0, import_node_fs20.existsSync)(packageJson)) {
12617
14102
  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));
14103
+ const pkg = JSON.parse((0, import_node_fs20.readFileSync)(packageJson, "utf-8"));
14104
+ if (pkg.main) candidates.push((0, import_node_path13.join)(modulePath, pkg.main));
12620
14105
  } catch {
12621
14106
  }
12622
14107
  }
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;
14108
+ candidates.push((0, import_node_path13.join)(modulePath, "dist", "index.js"), (0, import_node_path13.join)(modulePath, "index.js"));
14109
+ return candidates.find((candidate) => (0, import_node_fs20.existsSync)(candidate)) || null;
12625
14110
  }
12626
14111
  isThreatCrushModule(value) {
12627
14112
  return Boolean(
@@ -13142,8 +14627,8 @@ var RuleEngine = class {
13142
14627
  };
13143
14628
 
13144
14629
  // src/daemon/rules/loader.ts
13145
- var import_node_fs19 = require("fs");
13146
- var import_node_path11 = require("path");
14630
+ var import_node_fs21 = require("fs");
14631
+ var import_node_path14 = require("path");
13147
14632
 
13148
14633
  // src/daemon/rules/default-rules.ts
13149
14634
  var DEFAULT_RULES = [
@@ -13427,11 +14912,11 @@ var RULES_DIR = "/etc/threatcrush/rules.d";
13427
14912
  function loadAllRules(customDir) {
13428
14913
  const rules = [...DEFAULT_RULES];
13429
14914
  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"));
14915
+ if ((0, import_node_fs21.existsSync)(dir)) {
14916
+ const files = (0, import_node_fs21.readdirSync)(dir).filter((f) => f.endsWith(".json"));
13432
14917
  for (const file of files) {
13433
14918
  try {
13434
- const raw = (0, import_node_fs19.readFileSync)((0, import_node_path11.join)(dir, file), "utf-8");
14919
+ const raw = (0, import_node_fs21.readFileSync)((0, import_node_path14.join)(dir, file), "utf-8");
13435
14920
  const parsed = JSON.parse(raw);
13436
14921
  const customRules = Array.isArray(parsed) ? parsed : [parsed];
13437
14922
  for (const rule of customRules) {
@@ -13456,6 +14941,12 @@ function loadAllRules(customDir) {
13456
14941
 
13457
14942
  // src/daemon/firewall/adapters.ts
13458
14943
  var import_node_child_process6 = require("child_process");
14944
+ var import_node_net3 = require("net");
14945
+ function assertValidFirewallIp(ip) {
14946
+ if ((0, import_node_net3.isIP)(ip) !== 4) {
14947
+ throw new Error(`Invalid IPv4 address: ${ip}`);
14948
+ }
14949
+ }
13459
14950
  var NftablesAdapter = class {
13460
14951
  name = "nftables";
13461
14952
  table = "threatcrush";
@@ -13475,16 +14966,19 @@ var NftablesAdapter = class {
13475
14966
  }
13476
14967
  }
13477
14968
  async block(ip) {
14969
+ assertValidFirewallIp(ip);
13478
14970
  this.ensureSetup();
13479
14971
  (0, import_node_child_process6.execSync)(`nft add element inet ${this.table} ${this.set} '{ ${ip} }'`);
13480
14972
  }
13481
14973
  async unblock(ip) {
14974
+ assertValidFirewallIp(ip);
13482
14975
  try {
13483
14976
  (0, import_node_child_process6.execSync)(`nft delete element inet ${this.table} ${this.set} '{ ${ip} }'`);
13484
14977
  } catch {
13485
14978
  }
13486
14979
  }
13487
14980
  async isBlocked(ip) {
14981
+ assertValidFirewallIp(ip);
13488
14982
  try {
13489
14983
  const output = (0, import_node_child_process6.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
13490
14984
  return output.includes(ip);
@@ -13519,17 +15013,20 @@ var IptablesAdapter = class {
13519
15013
  }
13520
15014
  }
13521
15015
  async block(ip) {
15016
+ assertValidFirewallIp(ip);
13522
15017
  this.ensureChain();
13523
15018
  if (await this.isBlocked(ip)) return;
13524
15019
  (0, import_node_child_process6.execSync)(`iptables -A ${this.chain} -s ${ip} -j DROP`);
13525
15020
  }
13526
15021
  async unblock(ip) {
15022
+ assertValidFirewallIp(ip);
13527
15023
  try {
13528
15024
  (0, import_node_child_process6.execSync)(`iptables -D ${this.chain} -s ${ip} -j DROP`);
13529
15025
  } catch {
13530
15026
  }
13531
15027
  }
13532
15028
  async isBlocked(ip) {
15029
+ assertValidFirewallIp(ip);
13533
15030
  try {
13534
15031
  const output = (0, import_node_child_process6.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
13535
15032
  return output.includes(ip);
@@ -13558,12 +15055,15 @@ var DryRunAdapter = class {
13558
15055
  return true;
13559
15056
  }
13560
15057
  async block(ip) {
15058
+ assertValidFirewallIp(ip);
13561
15059
  this.blocked.add(ip);
13562
15060
  }
13563
15061
  async unblock(ip) {
15062
+ assertValidFirewallIp(ip);
13564
15063
  this.blocked.delete(ip);
13565
15064
  }
13566
15065
  async isBlocked(ip) {
15066
+ assertValidFirewallIp(ip);
13567
15067
  return this.blocked.has(ip);
13568
15068
  }
13569
15069
  async listBlocked() {
@@ -13579,7 +15079,7 @@ function detectFirewallAdapter() {
13579
15079
  }
13580
15080
 
13581
15081
  // src/daemon/firewall/remediation.ts
13582
- var import_node_fs20 = require("fs");
15082
+ var import_node_fs22 = require("fs");
13583
15083
  init_state();
13584
15084
  init_paths();
13585
15085
  var DEFAULT_CONFIG2 = {
@@ -13736,7 +15236,7 @@ var RemediationManager = class {
13736
15236
  }
13737
15237
  logLine(line) {
13738
15238
  try {
13739
- (0, import_node_fs20.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
15239
+ (0, import_node_fs22.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
13740
15240
  `);
13741
15241
  } catch {
13742
15242
  }
@@ -13793,9 +15293,9 @@ async function flushTelemetry(timeoutMs = 2e3) {
13793
15293
  }
13794
15294
 
13795
15295
  // src/daemon/index.ts
13796
- function readVersion() {
15296
+ function readVersion2() {
13797
15297
  try {
13798
- const pkg = JSON.parse((0, import_node_fs21.readFileSync)((0, import_node_path12.join)(__dirname, "..", "package.json"), "utf-8"));
15298
+ const pkg = JSON.parse((0, import_node_fs23.readFileSync)((0, import_node_path15.join)(__dirname, "..", "package.json"), "utf-8"));
13799
15299
  return pkg.version || "0.0.0";
13800
15300
  } catch {
13801
15301
  return "0.0.0";
@@ -13803,7 +15303,7 @@ function readVersion() {
13803
15303
  }
13804
15304
  function logLine(line) {
13805
15305
  try {
13806
- (0, import_node_fs21.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
15306
+ (0, import_node_fs23.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
13807
15307
  `);
13808
15308
  } catch {
13809
15309
  }
@@ -13824,14 +15324,14 @@ async function runDaemon() {
13824
15324
  logLine(`[daemon] unhandledRejection: ${String(reason)}`);
13825
15325
  captureException(reason);
13826
15326
  });
13827
- const version = readVersion();
15327
+ const version = readVersion2();
13828
15328
  logLine(`[daemon] starting threatcrushd v${version} mode=${PATHS.mode}`);
13829
15329
  try {
13830
15330
  initStateDB(PATHS.stateDb);
13831
15331
  } catch (err) {
13832
15332
  logLine(`[daemon] state db unavailable: ${err.message}`);
13833
15333
  }
13834
- const config = loadConfig((0, import_node_fs21.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
15334
+ const config = loadConfig((0, import_node_fs23.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
13835
15335
  bus.on("event", (event) => {
13836
15336
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
13837
15337
  });
@@ -13923,7 +15423,7 @@ async function runDaemon() {
13923
15423
  init_paths();
13924
15424
  init_pidfile();
13925
15425
  init_ipc_client();
13926
- var DAEMON_ENTRY = (0, import_node_path13.join)(__dirname, "daemon.js");
15426
+ var DAEMON_ENTRY = (0, import_node_path16.join)(__dirname, "daemon.js");
13927
15427
  async function daemonForeground() {
13928
15428
  await runDaemon();
13929
15429
  }
@@ -13934,7 +15434,7 @@ async function daemonStart() {
13934
15434
  return;
13935
15435
  }
13936
15436
  ensureRuntimeDirs();
13937
- if (!(0, import_node_fs22.existsSync)(DAEMON_ENTRY)) {
15437
+ if (!(0, import_node_fs24.existsSync)(DAEMON_ENTRY)) {
13938
15438
  console.log(source_default.red(` Daemon entry not found at ${DAEMON_ENTRY}.`));
13939
15439
  console.log(source_default.dim(" Reinstall with `threatcrush update` or `pnpm run build` in the cli package."));
13940
15440
  return;
@@ -13942,8 +15442,8 @@ async function daemonStart() {
13942
15442
  let out;
13943
15443
  let err;
13944
15444
  try {
13945
- out = (0, import_node_fs23.openSync)(PATHS.logFile, "a");
13946
- err = (0, import_node_fs23.openSync)(PATHS.logFile, "a");
15445
+ out = (0, import_node_fs25.openSync)(PATHS.logFile, "a");
15446
+ err = (0, import_node_fs25.openSync)(PATHS.logFile, "a");
13947
15447
  } catch (e) {
13948
15448
  const code = e.code;
13949
15449
  console.log(source_default.red(` \u2717 Cannot write daemon log at ${PATHS.logFile} (${code ?? "error"}).`));
@@ -14022,19 +15522,19 @@ async function daemonStop() {
14022
15522
 
14023
15523
  // src/commands/service.ts
14024
15524
  var import_node_child_process8 = require("child_process");
14025
- var import_node_fs24 = require("fs");
14026
- var import_node_path14 = require("path");
15525
+ var import_node_fs26 = require("fs");
15526
+ var import_node_path17 = require("path");
14027
15527
  var UNIT_PATH = "/etc/systemd/system/threatcrushd.service";
14028
15528
  function resolveTemplate() {
14029
- const templatePath = (0, import_node_path14.join)(__dirname, "systemd", "threatcrushd.service");
14030
- if (!(0, import_node_fs24.existsSync)(templatePath)) {
15529
+ const templatePath = (0, import_node_path17.join)(__dirname, "systemd", "threatcrushd.service");
15530
+ if (!(0, import_node_fs26.existsSync)(templatePath)) {
14031
15531
  throw new Error(`systemd unit template not found at ${templatePath}`);
14032
15532
  }
14033
- return (0, import_node_fs24.readFileSync)(templatePath, "utf-8");
15533
+ return (0, import_node_fs26.readFileSync)(templatePath, "utf-8");
14034
15534
  }
14035
15535
  function resolveBinPath() {
14036
15536
  const arg = process.argv[1];
14037
- if (arg && (0, import_node_fs24.existsSync)(arg)) return arg;
15537
+ if (arg && (0, import_node_fs26.existsSync)(arg)) return arg;
14038
15538
  try {
14039
15539
  return (0, import_node_child_process8.execSync)("command -v threatcrush", { encoding: "utf-8" }).trim();
14040
15540
  } catch {
@@ -14055,7 +15555,7 @@ async function installServiceCommand() {
14055
15555
  return;
14056
15556
  }
14057
15557
  const unit = resolveTemplate().replace("{{BIN_PATH}}", resolveBinPath());
14058
- (0, import_node_fs24.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
15558
+ (0, import_node_fs26.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
14059
15559
  console.log(source_default.green(` \u2713 Installed unit file: ${UNIT_PATH}`));
14060
15560
  ensureSystemDirs();
14061
15561
  try {
@@ -14079,17 +15579,17 @@ function ensureSystemDirs() {
14079
15579
  ];
14080
15580
  let admGid = null;
14081
15581
  try {
14082
- admGid = (0, import_node_fs24.statSync)("/var/log/auth.log").gid;
15582
+ admGid = (0, import_node_fs26.statSync)("/var/log/auth.log").gid;
14083
15583
  } catch {
14084
15584
  }
14085
15585
  for (const { path, sticky } of dirs) {
14086
15586
  try {
14087
- (0, import_node_fs24.mkdirSync)(path, { recursive: true });
15587
+ (0, import_node_fs26.mkdirSync)(path, { recursive: true });
14088
15588
  } catch {
14089
15589
  }
14090
15590
  if (admGid !== null) {
14091
15591
  try {
14092
- (0, import_node_fs24.chmodSync)(path, sticky ? 1533 : 509);
15592
+ (0, import_node_fs26.chmodSync)(path, sticky ? 1533 : 509);
14093
15593
  (0, import_node_child_process8.execSync)(`chgrp adm ${path}`, { stdio: "ignore" });
14094
15594
  } catch {
14095
15595
  }
@@ -14116,7 +15616,7 @@ async function uninstallServiceCommand() {
14116
15616
  } catch {
14117
15617
  }
14118
15618
  try {
14119
- if ((0, import_node_fs24.existsSync)(UNIT_PATH)) {
15619
+ if ((0, import_node_fs26.existsSync)(UNIT_PATH)) {
14120
15620
  (0, import_node_child_process8.execSync)(`rm -f ${UNIT_PATH}`);
14121
15621
  console.log(source_default.green(` \u2713 Removed unit file: ${UNIT_PATH}`));
14122
15622
  }
@@ -14215,16 +15715,16 @@ function welcomeCommand() {
14215
15715
  }
14216
15716
 
14217
15717
  // src/commands/properties.ts
14218
- var import_node_fs25 = require("fs");
14219
- var import_node_path15 = require("path");
15718
+ var import_node_fs27 = require("fs");
15719
+ var import_node_path18 = require("path");
14220
15720
  var import_node_readline6 = __toESM(require("readline"));
14221
15721
  var API_URL7 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
14222
15722
  var KINDS = ["url", "api", "domain", "ip", "repo"];
14223
15723
  function prompt2(question) {
14224
15724
  const rl = import_node_readline6.default.createInterface({ input: process.stdin, output: process.stdout });
14225
- return new Promise((resolve3) => rl.question(question, (answer) => {
15725
+ return new Promise((resolve5) => rl.question(question, (answer) => {
14226
15726
  rl.close();
14227
- resolve3(answer.trim());
15727
+ resolve5(answer.trim());
14228
15728
  }));
14229
15729
  }
14230
15730
  async function requireAuth() {
@@ -14545,8 +16045,8 @@ async function propertiesRunsCommand(opts) {
14545
16045
  }
14546
16046
  }
14547
16047
  function parseImportFile(path) {
14548
- const ext = (0, import_node_path15.extname)(path).toLowerCase();
14549
- const raw = (0, import_node_fs25.readFileSync)(path, "utf-8");
16048
+ const ext = (0, import_node_path18.extname)(path).toLowerCase();
16049
+ const raw = (0, import_node_fs27.readFileSync)(path, "utf-8");
14550
16050
  if (ext === ".json") {
14551
16051
  const parsed = JSON.parse(raw);
14552
16052
  if (!Array.isArray(parsed)) throw new Error("JSON must be an array of objects");
@@ -14736,7 +16236,7 @@ async function rulesCommand(opts) {
14736
16236
  }
14737
16237
 
14738
16238
  // src/commands/harden.ts
14739
- var import_node_fs26 = require("fs");
16239
+ var import_node_fs28 = require("fs");
14740
16240
  var import_node_child_process9 = require("child_process");
14741
16241
  function tryExec(cmd) {
14742
16242
  try {
@@ -14747,7 +16247,7 @@ function tryExec(cmd) {
14747
16247
  }
14748
16248
  function tryRead(path) {
14749
16249
  try {
14750
- return (0, import_node_fs26.readFileSync)(path, "utf-8");
16250
+ return (0, import_node_fs28.readFileSync)(path, "utf-8");
14751
16251
  } catch {
14752
16252
  return null;
14753
16253
  }
@@ -14851,8 +16351,8 @@ function checkSshWeakConfig() {
14851
16351
  };
14852
16352
  }
14853
16353
  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");
16354
+ 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");
16355
+ const dnfAuto = (0, import_node_fs28.existsSync)("/etc/dnf/automatic.conf");
14856
16356
  if (unattended || dnfAuto) {
14857
16357
  return {
14858
16358
  key: "auto-updates",
@@ -14978,7 +16478,7 @@ function checkFail2ban() {
14978
16478
  explanation: "fail2ban is installed and running."
14979
16479
  };
14980
16480
  }
14981
- if ((0, import_node_fs26.existsSync)("/etc/fail2ban/fail2ban.conf")) {
16481
+ if ((0, import_node_fs28.existsSync)("/etc/fail2ban/fail2ban.conf")) {
14982
16482
  return {
14983
16483
  key: checkKey,
14984
16484
  status: "warn",
@@ -15207,10 +16707,10 @@ async function allowlistCommand(opts) {
15207
16707
 
15208
16708
  // src/index.ts
15209
16709
  init_paths();
15210
- var PKG_VERSION = "0.1.8";
16710
+ var PKG_VERSION2 = "0.1.8";
15211
16711
  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;
16712
+ const pkg = JSON.parse((0, import_node_fs29.readFileSync)((0, import_node_path19.join)(__dirname, "..", "package.json"), "utf-8"));
16713
+ PKG_VERSION2 = pkg.version;
15214
16714
  } catch {
15215
16715
  }
15216
16716
  var LOGO2 = `
@@ -15225,7 +16725,7 @@ ${source_default.dim(" C R U S H")}
15225
16725
  var API_URL8 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
15226
16726
  var PKG_NAME = "@profullstack/threatcrush";
15227
16727
  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");
16728
+ var INSTALL_CONFIG_PATH = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush", "install.json");
15229
16729
  function detectPackageManager() {
15230
16730
  try {
15231
16731
  const npmGlobal = (0, import_node_child_process10.execSync)("npm ls -g --depth=0 --json 2>/dev/null", { encoding: "utf-8" });
@@ -15251,7 +16751,7 @@ function detectPackageManager() {
15251
16751
  }
15252
16752
  function readInstallConfig() {
15253
16753
  try {
15254
- return JSON.parse((0, import_node_fs27.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
16754
+ return JSON.parse((0, import_node_fs29.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
15255
16755
  } catch {
15256
16756
  return {};
15257
16757
  }
@@ -15318,7 +16818,7 @@ ${source_default.dim("Examples:")}
15318
16818
  ${source_default.green("$")} threatcrush modules install ${source_default.dim("# Install a module")}
15319
16819
  ${source_default.green("$")} threatcrush update ${source_default.dim("# Update to latest")}
15320
16820
  ${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", `
16821
+ ).version(PKG_VERSION2, "-v, --version", "Show version number").helpOption("-h, --help", "Show this help").addHelpText("after", `
15322
16822
  ${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
16823
  ${source_default.dim("Modules:")}
15324
16824
  ThreatCrush uses pluggable security modules. Core modules included:
@@ -15354,8 +16854,33 @@ program2.command("logout").description("Clear stored threatcrush.com credentials
15354
16854
  program2.command("whoami").description("Show the currently logged-in threatcrush.com account").action(async () => {
15355
16855
  await whoamiCommand();
15356
16856
  });
15357
- program2.command("scan").description("Scan codebase for vulnerabilities and secrets").argument("[path]", "Path to scan", ".").action(async (targetPath) => {
15358
- await scanCommand(targetPath);
16857
+ 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(
16858
+ "--fail-on <severities>",
16859
+ "exit 1 when a finding at or above any of these exists (comma-separated: critical,high,medium,low,info)"
16860
+ ).option(
16861
+ "--path-prefix <prefix>",
16862
+ "prepend this to SARIF file URIs \u2014 use when the scan root is not the repository root"
16863
+ ).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) => {
16864
+ const format = (opts.format ?? "text").toLowerCase();
16865
+ if (!["text", "json", "sarif"].includes(format)) {
16866
+ console.error(source_default.red(`Unknown --format "${opts.format}" (expected text, json, or sarif)`));
16867
+ process.exit(2);
16868
+ }
16869
+ let failOn;
16870
+ try {
16871
+ failOn = parseFailOn(opts.failOn);
16872
+ } catch (err) {
16873
+ console.error(source_default.red(err.message));
16874
+ process.exit(2);
16875
+ }
16876
+ await scanCommand(targetPath, {
16877
+ format,
16878
+ output: opts.output,
16879
+ failOn,
16880
+ pathPrefix: opts.pathPrefix,
16881
+ dependencies: opts.deps,
16882
+ verbose: opts.verbose
16883
+ });
15359
16884
  });
15360
16885
  program2.command("pentest").description("Penetration test URLs and APIs").argument("<url>", "Target URL to pentest").action(async (url) => {
15361
16886
  await pentestCommand(url);
@@ -15401,7 +16926,7 @@ program2.command("uninstall-service").description("Remove the threatcrushd syste
15401
16926
  program2.command("logs").description("Tail daemon logs").action(async () => {
15402
16927
  console.log(LOGO2);
15403
16928
  const logPath = PATHS.logFile;
15404
- if (!(0, import_node_fs27.existsSync)(logPath)) {
16929
+ if (!(0, import_node_fs29.existsSync)(logPath)) {
15405
16930
  console.log(source_default.yellow(` No log file found at ${logPath}`));
15406
16931
  console.log(source_default.dim(" Start the daemon first with `threatcrush start`\n"));
15407
16932
  return;
@@ -15414,10 +16939,10 @@ program2.command("logs").description("Tail daemon logs").action(async () => {
15414
16939
  program2.command("activate").description("Activate your license key").action(async () => {
15415
16940
  console.log(LOGO2);
15416
16941
  const rl = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
15417
- const key = await new Promise((resolve3) => {
16942
+ const key = await new Promise((resolve5) => {
15418
16943
  rl.question(source_default.green(" Enter your ThreatCrush license key: "), (answer) => {
15419
16944
  rl.close();
15420
- resolve3(answer.trim());
16945
+ resolve5(answer.trim());
15421
16946
  });
15422
16947
  });
15423
16948
  if (!key) {
@@ -15504,10 +17029,10 @@ program2.command("update").description("Update ThreatCrush CLI and installed bun
15504
17029
  program2.command("remove").description("Uninstall ThreatCrush and the installed bundle").alias("uninstall").action(async () => {
15505
17030
  console.log(LOGO2);
15506
17031
  const rl = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
15507
- const confirm = await new Promise((resolve3) => {
17032
+ const confirm = await new Promise((resolve5) => {
15508
17033
  rl.question(source_default.yellow(" Are you sure you want to uninstall ThreatCrush? (y/N): "), (answer) => {
15509
17034
  rl.close();
15510
- resolve3(answer.trim().toLowerCase());
17035
+ resolve5(answer.trim().toLowerCase());
15511
17036
  });
15512
17037
  });
15513
17038
  if (confirm !== "y" && confirm !== "yes") {
@@ -15606,19 +17131,19 @@ storeCmd.command("search <query>").description("Search for modules in the store"
15606
17131
  });
15607
17132
  storeCmd.command("publish <url>").description("Publish a module from a git URL or web URL").action(async (url) => {
15608
17133
  console.log(LOGO2);
15609
- const configPath = (0, import_node_path16.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
17134
+ const configPath = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
15610
17135
  let email = "";
15611
17136
  try {
15612
- const config = JSON.parse((0, import_node_fs27.readFileSync)(configPath, "utf-8"));
17137
+ const config = JSON.parse((0, import_node_fs29.readFileSync)(configPath, "utf-8"));
15613
17138
  email = config.email || "";
15614
17139
  } catch {
15615
17140
  }
15616
17141
  if (!email) {
15617
17142
  const rl2 = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
15618
- email = await new Promise((resolve3) => {
17143
+ email = await new Promise((resolve5) => {
15619
17144
  rl2.question(source_default.green(" Enter your email: "), (answer) => {
15620
17145
  rl2.close();
15621
- resolve3(answer.trim());
17146
+ resolve5(answer.trim());
15622
17147
  });
15623
17148
  });
15624
17149
  if (!email || !email.includes("@")) {
@@ -15626,9 +17151,9 @@ storeCmd.command("publish <url>").description("Publish a module from a git URL o
15626
17151
  return;
15627
17152
  }
15628
17153
  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));
17154
+ const dir = (0, import_node_path19.join)((0, import_node_os8.homedir)(), ".threatcrush");
17155
+ if (!(0, import_node_fs29.existsSync)(dir)) (0, import_node_fs29.mkdirSync)(dir, { recursive: true });
17156
+ (0, import_node_fs29.writeFileSync)(configPath, JSON.stringify({ email }, null, 2));
15632
17157
  console.log(source_default.dim(` Saved email to ${configPath}`));
15633
17158
  } catch {
15634
17159
  }
@@ -15672,10 +17197,10 @@ storeCmd.command("publish <url>").description("Publish a module from a git URL o
15672
17197
  }
15673
17198
  console.log();
15674
17199
  const rl = import_readline.default.createInterface({ input: process.stdin, output: process.stdout });
15675
- const confirm = await new Promise((resolve3) => {
17200
+ const confirm = await new Promise((resolve5) => {
15676
17201
  rl.question(source_default.yellow(" Publish this module? (y/N): "), (answer) => {
15677
17202
  rl.close();
15678
- resolve3(answer.trim().toLowerCase());
17203
+ resolve5(answer.trim().toLowerCase());
15679
17204
  });
15680
17205
  });
15681
17206
  if (confirm !== "y" && confirm !== "yes") {