@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/daemon.js CHANGED
@@ -1639,20 +1639,20 @@ var require_parse_async = __commonJS({
1639
1639
  const index = 0;
1640
1640
  const blocksize = opts.blocksize || 40960;
1641
1641
  const parser = new TOMLParser();
1642
- return new Promise((resolve, reject) => {
1643
- setImmediate(parseAsyncNext, index, blocksize, resolve, reject);
1642
+ return new Promise((resolve3, reject) => {
1643
+ setImmediate(parseAsyncNext, index, blocksize, resolve3, reject);
1644
1644
  });
1645
- function parseAsyncNext(index2, blocksize2, resolve, reject) {
1645
+ function parseAsyncNext(index2, blocksize2, resolve3, reject) {
1646
1646
  if (index2 >= str.length) {
1647
1647
  try {
1648
- return resolve(parser.finish());
1648
+ return resolve3(parser.finish());
1649
1649
  } catch (err) {
1650
1650
  return reject(prettyError(err, str));
1651
1651
  }
1652
1652
  }
1653
1653
  try {
1654
1654
  parser.parse(str.slice(index2, index2 + blocksize2));
1655
- setImmediate(parseAsyncNext, index2 + blocksize2, blocksize2, resolve, reject);
1655
+ setImmediate(parseAsyncNext, index2 + blocksize2, blocksize2, resolve3, reject);
1656
1656
  } catch (err) {
1657
1657
  reject(prettyError(err, str));
1658
1658
  }
@@ -1678,7 +1678,7 @@ var require_parse_stream = __commonJS({
1678
1678
  function parseReadable(stm) {
1679
1679
  const parser = new TOMLParser();
1680
1680
  stm.setEncoding("utf8");
1681
- return new Promise((resolve, reject) => {
1681
+ return new Promise((resolve3, reject) => {
1682
1682
  let readable;
1683
1683
  let ended = false;
1684
1684
  let errored = false;
@@ -1686,7 +1686,7 @@ var require_parse_stream = __commonJS({
1686
1686
  ended = true;
1687
1687
  if (readable) return;
1688
1688
  try {
1689
- resolve(parser.finish());
1689
+ resolve3(parser.finish());
1690
1690
  } catch (err) {
1691
1691
  reject(err);
1692
1692
  }
@@ -2022,8 +2022,8 @@ var require_toml = __commonJS({
2022
2022
  });
2023
2023
 
2024
2024
  // src/daemon/index.ts
2025
- var import_node_fs13 = require("fs");
2026
- var import_node_path7 = require("path");
2025
+ var import_node_fs15 = require("fs");
2026
+ var import_node_path10 = require("path");
2027
2027
 
2028
2028
  // src/daemon/paths.ts
2029
2029
  var import_node_fs = require("fs");
@@ -2310,7 +2310,7 @@ var IpcServer = class {
2310
2310
  } catch {
2311
2311
  }
2312
2312
  }
2313
- return new Promise((resolve, reject) => {
2313
+ return new Promise((resolve3, reject) => {
2314
2314
  this.server = (0, import_node_net.createServer)((sock) => this.handleClient(sock));
2315
2315
  this.server.on("error", reject);
2316
2316
  this.server.listen(PATHS.socket, () => {
@@ -2327,7 +2327,7 @@ var IpcServer = class {
2327
2327
  } catch {
2328
2328
  }
2329
2329
  }
2330
- resolve();
2330
+ resolve3();
2331
2331
  });
2332
2332
  });
2333
2333
  }
@@ -2339,20 +2339,20 @@ var IpcServer = class {
2339
2339
  }
2340
2340
  }
2341
2341
  this.clients.clear();
2342
- return new Promise((resolve) => {
2342
+ return new Promise((resolve3) => {
2343
2343
  if (!this.server) {
2344
2344
  try {
2345
2345
  if ((0, import_node_fs3.existsSync)(PATHS.socket)) (0, import_node_fs3.unlinkSync)(PATHS.socket);
2346
2346
  } catch {
2347
2347
  }
2348
- return resolve();
2348
+ return resolve3();
2349
2349
  }
2350
2350
  this.server.close(() => {
2351
2351
  try {
2352
2352
  if ((0, import_node_fs3.existsSync)(PATHS.socket)) (0, import_node_fs3.unlinkSync)(PATHS.socket);
2353
2353
  } catch {
2354
2354
  }
2355
- resolve();
2355
+ resolve3();
2356
2356
  });
2357
2357
  });
2358
2358
  }
@@ -2469,6 +2469,7 @@ var NGINX_REGEX = /^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d{3}) (\d+) "
2469
2469
  var AUTH_REGEX = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)/;
2470
2470
  var SYSLOG_REGEX = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)/;
2471
2471
  var IP_REGEX = /(?:from|FROM)\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
2472
+ var INVALID_USER_REGEX = /(?:for\s+invalid\s+user)\s+(\S+?)(?:\s+from|\s*$)/;
2472
2473
  var USER_REGEX = /(?:for|user)\s+(\S+?)(?:\s+from|\s*$)/;
2473
2474
  var ATTACK_PATTERNS = {
2474
2475
  sqli: [
@@ -2522,7 +2523,7 @@ function parseAuthLog(line) {
2522
2523
  const match = line.match(AUTH_REGEX);
2523
2524
  if (!match) return null;
2524
2525
  const ipMatch = match[3].match(IP_REGEX);
2525
- const userMatch = match[3].match(USER_REGEX);
2526
+ const userMatch = match[3].match(INVALID_USER_REGEX) || match[3].match(USER_REGEX);
2526
2527
  return {
2527
2528
  timestamp: parseSyslogTimestamp(match[1]),
2528
2529
  raw: line,
@@ -2550,10 +2551,25 @@ function parseSyslog(line) {
2550
2551
  };
2551
2552
  }
2552
2553
  function detectAttackPattern(path) {
2554
+ const candidates = /* @__PURE__ */ new Set([path]);
2555
+ let current = path;
2556
+ for (let i = 0; i < 2; i++) {
2557
+ let decoded = null;
2558
+ try {
2559
+ decoded = decodeURIComponent(current);
2560
+ } catch {
2561
+ decoded = null;
2562
+ }
2563
+ if (decoded === null || decoded === current) break;
2564
+ candidates.add(decoded);
2565
+ current = decoded;
2566
+ }
2553
2567
  for (const [type, patterns] of Object.entries(ATTACK_PATTERNS)) {
2554
2568
  for (const pattern of patterns) {
2555
- if (pattern.test(path)) {
2556
- return type;
2569
+ for (const candidate of candidates) {
2570
+ if (pattern.test(candidate)) {
2571
+ return type;
2572
+ }
2557
2573
  }
2558
2574
  }
2559
2575
  }
@@ -2567,20 +2583,19 @@ function autoDetectParser(line) {
2567
2583
  return parseSyslog(line);
2568
2584
  }
2569
2585
  function parseNginxTimestamp(s) {
2570
- try {
2571
- const cleaned = s.replace(/(\d{2})\/(\w{3})\/(\d{4}):/, "$2 $1, $3 ");
2572
- return new Date(cleaned);
2573
- } catch {
2574
- return /* @__PURE__ */ new Date();
2575
- }
2586
+ const cleaned = s.replace(/(\d{2})\/(\w{3})\/(\d{4}):/, "$2 $1, $3 ");
2587
+ const d = new Date(cleaned);
2588
+ return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
2576
2589
  }
2577
2590
  function parseSyslogTimestamp(s) {
2578
- try {
2579
- const withYear = `${s} ${(/* @__PURE__ */ new Date()).getFullYear()}`;
2580
- return new Date(withYear);
2581
- } catch {
2582
- return /* @__PURE__ */ new Date();
2583
- }
2591
+ const now = /* @__PURE__ */ new Date();
2592
+ let d = /* @__PURE__ */ new Date(`${s} ${now.getFullYear()}`);
2593
+ if (Number.isNaN(d.getTime())) return now;
2594
+ if (d.getTime() > now.getTime()) {
2595
+ const prev = /* @__PURE__ */ new Date(`${s} ${now.getFullYear() - 1}`);
2596
+ if (!Number.isNaN(prev.getTime())) d = prev;
2597
+ }
2598
+ return d;
2584
2599
  }
2585
2600
 
2586
2601
  // src/daemon/watchers/log-watcher.ts
@@ -3768,8 +3783,8 @@ function authHeaders() {
3768
3783
  }
3769
3784
 
3770
3785
  // src/commands/scan.ts
3771
- var import_node_fs10 = require("fs");
3772
- var import_node_path5 = require("path");
3786
+ var import_node_fs12 = require("fs");
3787
+ var import_node_path8 = require("path");
3773
3788
 
3774
3789
  // ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
3775
3790
  var ANSI_BACKGROUND_OFFSET = 10;
@@ -4295,58 +4310,1134 @@ function workerId() {
4295
4310
  return `${import_node_os4.default.hostname()}/${process.pid}`;
4296
4311
  }
4297
4312
 
4298
- // src/commands/scan.ts
4299
- var SECRET_PATTERNS = [
4300
- { name: "AWS Access Key", pattern: /(?:AKIA[0-9A-Z]{16})/g, severity: "critical" },
4301
- { name: "AWS Secret Key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi, severity: "critical" },
4302
- { name: "GitHub Token", pattern: /(?:ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82})/g, severity: "critical" },
4303
- { name: "Generic API Key", pattern: /(?:api[_-]?key|apikey)\s*[=:]\s*['"]([A-Za-z0-9\-_]{20,})['"]?/gi, severity: "high" },
4304
- { name: "Generic Secret", pattern: /(?:secret|password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"]?/gi, severity: "high" },
4305
- { name: "Private Key", pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g, severity: "critical" },
4306
- { name: "JWT Token", pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: "high" },
4307
- { name: "Slack Token", pattern: /xox[bpors]-[A-Za-z0-9-]{10,}/g, severity: "critical" },
4308
- { name: "Stripe Key", pattern: /(?:sk_live_|pk_live_|sk_test_|pk_test_)[A-Za-z0-9]{20,}/g, severity: "critical" },
4309
- { name: "Database URL", pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s'"]+/gi, severity: "high" },
4310
- { name: "Bearer Token", pattern: /Bearer\s+[A-Za-z0-9\-_\.]{20,}/g, severity: "medium" },
4311
- { name: "Hex Token (32+)", pattern: /(?:token|key|secret|auth)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/gi, severity: "medium" }
4313
+ // src/scan/dependencies.ts
4314
+ var import_node_fs10 = require("fs");
4315
+ var import_node_path5 = require("path");
4316
+ var LOCKFILES = [
4317
+ { file: "package-lock.json", ecosystem: "npm" },
4318
+ { file: "pnpm-lock.yaml", ecosystem: "npm" },
4319
+ { file: "yarn.lock", ecosystem: "npm" },
4320
+ { file: "requirements.txt", ecosystem: "PyPI" },
4321
+ { file: "Pipfile.lock", ecosystem: "PyPI" }
4312
4322
  ];
4313
- var MISCONFIG_FILES = [
4314
- { pattern: ".env", message: ".env file found \u2014 may contain secrets" },
4315
- { pattern: ".env.local", message: ".env.local file found \u2014 may contain secrets" },
4316
- { pattern: ".env.production", message: ".env.production file found \u2014 may contain secrets" },
4317
- { pattern: "id_rsa", message: "Private SSH key found" },
4318
- { pattern: "id_ed25519", message: "Private SSH key found" },
4319
- { pattern: ".pem", message: "PEM certificate/key file found" },
4320
- { pattern: ".p12", message: "PKCS#12 keystore found" },
4321
- { pattern: ".keystore", message: "Keystore file found" }
4323
+ var MAX_DEPS_PER_LOCKFILE = 50;
4324
+ async function scanDependencies(targetPath) {
4325
+ const findings = [];
4326
+ for (const { file, ecosystem } of LOCKFILES) {
4327
+ const lockPath = (0, import_node_path5.join)(targetPath, file);
4328
+ if (!(0, import_node_fs10.existsSync)(lockPath)) continue;
4329
+ let deps;
4330
+ try {
4331
+ deps = parseDependencies(lockPath, file);
4332
+ } catch {
4333
+ continue;
4334
+ }
4335
+ for (const dep of deps.slice(0, MAX_DEPS_PER_LOCKFILE)) {
4336
+ let vulns;
4337
+ try {
4338
+ vulns = await queryOsv(dep.name, dep.version, ecosystem);
4339
+ } catch {
4340
+ continue;
4341
+ }
4342
+ for (const vuln of vulns) {
4343
+ const cvss = vuln.severity?.find((entry) => entry.type === "CVSS_V3")?.score;
4344
+ findings.push({
4345
+ ruleId: "dependency-known-vulnerability",
4346
+ title: "Dependency CVE",
4347
+ file,
4348
+ line: 1,
4349
+ severity: severityFromCvss(cvss),
4350
+ confidence: "evidence",
4351
+ message: `${dep.name}@${dep.version}: ${vuln.summary ?? vuln.id}`,
4352
+ consequence: "A published advisory exists for the exact version resolved in this lockfile.",
4353
+ excerpt: `${vuln.id}${cvss ? ` (CVSS: ${cvss})` : ""}`,
4354
+ category: "dependency"
4355
+ });
4356
+ }
4357
+ }
4358
+ }
4359
+ return findings;
4360
+ }
4361
+ function severityFromCvss(score) {
4362
+ if (!score) return "medium";
4363
+ const value = Number.parseFloat(score);
4364
+ if (Number.isNaN(value)) return "medium";
4365
+ if (value >= 9) return "critical";
4366
+ if (value >= 7) return "high";
4367
+ if (value >= 4) return "medium";
4368
+ return "low";
4369
+ }
4370
+ function parseDependencies(lockPath, filename) {
4371
+ const deps = [];
4372
+ if (filename === "package-lock.json") {
4373
+ const lock = JSON.parse((0, import_node_fs10.readFileSync)(lockPath, "utf-8"));
4374
+ const packages = lock.packages ?? lock.dependencies ?? {};
4375
+ for (const [key, value] of Object.entries(packages)) {
4376
+ const name = key.replace(/^node_modules\//, "");
4377
+ const version = value?.version;
4378
+ if (name && version && !name.startsWith(".")) deps.push({ name, version });
4379
+ }
4380
+ return deps;
4381
+ }
4382
+ if (filename === "requirements.txt") {
4383
+ for (const line of (0, import_node_fs10.readFileSync)(lockPath, "utf-8").split("\n")) {
4384
+ const match = /^([a-zA-Z0-9_.-]+)==([0-9.]+)/.exec(line);
4385
+ if (match?.[1] && match[2]) deps.push({ name: match[1], version: match[2] });
4386
+ }
4387
+ }
4388
+ return deps;
4389
+ }
4390
+ function isValidPackageName(name) {
4391
+ return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
4392
+ }
4393
+ function isValidVersion(version) {
4394
+ return /^[0-9a-zA-Z._\-+]{1,50}$/.test(version);
4395
+ }
4396
+ async function queryOsv(name, version, ecosystem) {
4397
+ if (!isValidPackageName(name) || !isValidVersion(version)) return [];
4398
+ try {
4399
+ const response = await fetch("https://api.osv.dev/v1/query", {
4400
+ method: "POST",
4401
+ headers: { "Content-Type": "application/json" },
4402
+ body: JSON.stringify({ package: { name, ecosystem }, version }),
4403
+ signal: AbortSignal.timeout(5e3)
4404
+ });
4405
+ if (!response.ok) return [];
4406
+ const data = await response.json();
4407
+ return data.vulns ?? [];
4408
+ } catch {
4409
+ return [];
4410
+ }
4411
+ }
4412
+
4413
+ // src/scan/engine.ts
4414
+ var import_node_fs11 = require("fs");
4415
+ var import_node_path6 = require("path");
4416
+
4417
+ // src/scan/types.ts
4418
+ var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
4419
+ function severityRank(severity) {
4420
+ const index = SEVERITY_ORDER.indexOf(severity);
4421
+ return index === -1 ? 0 : index;
4422
+ }
4423
+ function severityFor(declared, confidence) {
4424
+ if (confidence !== "pattern") return declared;
4425
+ return severityRank(declared) > severityRank("medium") ? "medium" : declared;
4426
+ }
4427
+
4428
+ // src/scan/code-rules.ts
4429
+ 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*\(/;
4430
+ var UNTRUSTED_PY = /\brequest\b|\bparams\b|\bflask\b|\bsys\.argv\b|\bos\.environ\b\s*\[/;
4431
+ var UNTRUSTED_RB = /\bparams\s*\[|\brequest\b|\bcookies\s*\[/;
4432
+ var UNTRUSTED_GO = /\br\s*\.\s*(?:URL|Form|Body|Header|PostForm)\b|\bFormValue\s*\(|\bQuery\s*\(\s*\)\s*\.\s*Get\s*\(|\bmux\.Vars\s*\(/;
4433
+ var UNTRUSTED_JAVA = /\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetHeader\s*\(|\bgetInputStream\s*\(|\bgetCookies\s*\(|\b@RequestParam\b|\b@PathVariable\b/;
4434
+ function untrustedPatternFor(language) {
4435
+ switch (language) {
4436
+ case "python":
4437
+ return UNTRUSTED_PY;
4438
+ case "ruby":
4439
+ return UNTRUSTED_RB;
4440
+ case "go":
4441
+ return UNTRUSTED_GO;
4442
+ case "java":
4443
+ return UNTRUSTED_JAVA;
4444
+ default:
4445
+ return UNTRUSTED_JS;
4446
+ }
4447
+ }
4448
+ var GENERIC_GUARD = (
4449
+ // `esc(`, `aEsc(`, `htmlEscape(`, `escapeHtml(` — the escaper is almost
4450
+ // never *named* `escapeHtml` in real code. It gets aliased to something
4451
+ // short because it is called on nearly every interpolation, so matching only
4452
+ // the long spellings reported the codebases that escape most rigorously.
4453
+ //
4454
+ // The identifier must END at the escaper (with at most a known output-context
4455
+ // suffix). An earlier, looser form also matched `describe(`, which would have
4456
+ // silenced findings across every test file in every repository.
4457
+ /\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
4458
+ );
4459
+ var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
4460
+ var CODE_SINK = /\bglobalThis\s*\[|\bconstructor\b|\beval\b|\bFunction\b|\brun\s*\(|\bvm\s*\.\s*run/;
4461
+ var EXFIL_SINK = /\bconsole\s*\.\s*(?:log|debug|info|warn|error)\s*\(|\bfetch\s*\(|\baxios\b|\brequest\s*\(|\.\s*send\s*\(/;
4462
+ var SQL_KEYWORDS = "SELECT|INSERT\\s+INTO|INSERT|UPDATE|DELETE\\s+FROM|DELETE|DROP|UNION\\s+SELECT";
4463
+ var SQL_IN_DOUBLE = `"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*"`;
4464
+ var SQL_IN_SINGLE = `'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*'`;
4465
+ var SQL_STRING = `(?:${SQL_IN_DOUBLE}|${SQL_IN_SINGLE})`;
4466
+ var CODE_RULES = [
4467
+ // ── Injection: SQL ───────────────────────────────────────────────────────
4468
+ {
4469
+ id: "sql-string-concatenation",
4470
+ title: "SQL assembled by concatenation or interpolation",
4471
+ 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.",
4472
+ cwe: "CWE-89",
4473
+ severity: "critical",
4474
+ // The tail is what distinguishes assembly from parameterisation. A bound
4475
+ // query leaves a comma after the closing quote (`"… = $1", [id]`) and
4476
+ // matches none of these.
4477
+ pattern: new RegExp(
4478
+ `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"[^"\\n]*|'[^'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
4479
+ "i"
4480
+ )
4481
+ },
4482
+ {
4483
+ id: "sql-template-interpolation",
4484
+ title: "SQL built from a template literal or f-string",
4485
+ consequence: "Template interpolation is string concatenation with nicer syntax \u2014 it binds nothing and escapes nothing.",
4486
+ cwe: "CWE-89",
4487
+ severity: "critical",
4488
+ pattern: new RegExp(
4489
+ `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
4490
+ "i"
4491
+ )
4492
+ },
4493
+ {
4494
+ id: "sql-format-call",
4495
+ title: "SQL text produced by a format helper",
4496
+ consequence: "`Sprintf`/`String.format` substitute without quoting; the resulting string is concatenated SQL by another name.",
4497
+ cwe: "CWE-89",
4498
+ severity: "critical",
4499
+ languages: ["go", "java"],
4500
+ pattern: new RegExp(
4501
+ `\\b(?:fmt\\.Sprintf|String\\.format)\\s*\\(\\s*"[^"\\n]*(?:${SQL_KEYWORDS})\\b`,
4502
+ "i"
4503
+ )
4504
+ },
4505
+ {
4506
+ id: "rb-sql-interpolation",
4507
+ title: "ActiveRecord query built by string interpolation",
4508
+ consequence: '`where("\u2026 #{value}")` interpolates before the adapter sees it, so no binding ever happens.',
4509
+ cwe: "CWE-89",
4510
+ severity: "critical",
4511
+ languages: ["ruby"],
4512
+ pattern: /\b(?:where|find_by_sql|execute|select_all|select_values|order|group|pluck)\s*[( ]\s*(?:"[^"\n]*|'[^'\n]*)#\{/
4513
+ },
4514
+ // ── Injection: OS command ────────────────────────────────────────────────
4515
+ {
4516
+ id: "js-shell-exec-interpolation",
4517
+ title: "shell execution with an interpolated string",
4518
+ consequence: "A `;` or `$(\u2026)` in the interpolated value runs as the server user.",
4519
+ cwe: "CWE-78",
4520
+ severity: "critical",
4521
+ languages: ["javascript", "typescript"],
4522
+ pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/
4523
+ },
4524
+ {
4525
+ id: "py-shell-command-string",
4526
+ title: "shell command built from a string",
4527
+ consequence: "`os.system` and `shell=True` hand the string to `/bin/sh`, which happily interprets metacharacters.",
4528
+ cwe: "CWE-78",
4529
+ severity: "critical",
4530
+ languages: ["python"],
4531
+ 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/
4532
+ },
4533
+ {
4534
+ id: "go-shell-exec-command",
4535
+ title: "exec.Command invoking a shell",
4536
+ consequence: "Passing `sh -c` re-introduces the shell that `exec.Command`\u2019s argv interface exists to avoid.",
4537
+ cwe: "CWE-78",
4538
+ severity: "critical",
4539
+ languages: ["go"],
4540
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
4541
+ },
4542
+ {
4543
+ id: "rb-backtick-interpolation",
4544
+ title: "backtick command with interpolation",
4545
+ consequence: "Ruby backticks are a shell invocation; `#{}` inside one is command injection.",
4546
+ cwe: "CWE-78",
4547
+ severity: "critical",
4548
+ languages: ["ruby"],
4549
+ pattern: /`[^`\n]*#\{|\bsystem\s*\(\s*["'][^"'\n]*#\{|%x\[[^\]]*#\{/
4550
+ },
4551
+ // ── Injection: dynamic code ──────────────────────────────────────────────
4552
+ {
4553
+ id: "js-dynamic-code-execution",
4554
+ title: "dynamic code execution",
4555
+ consequence: "Any string reaching this call executes as code with the process\u2019 privileges.",
4556
+ cwe: "CWE-95",
4557
+ severity: "critical",
4558
+ languages: ["javascript", "typescript"],
4559
+ 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)/
4560
+ },
4561
+ {
4562
+ id: "js-indirect-code-sink",
4563
+ title: "code sink reached indirectly",
4564
+ consequence: "Resolving `eval`/`Function` through `globalThis[\u2026]` or `.constructor` hides the sink from literal matching. Legitimate code has no reason to.",
4565
+ cwe: "CWE-506",
4566
+ severity: "high",
4567
+ languages: ["javascript", "typescript"],
4568
+ pattern: /\bglobalThis\s*\[\s*[a-zA-Z_$][\w$]*\s*\]|\(\s*function\s*\(\s*\)\s*\{\s*\}\s*\)\s*\.\s*constructor/
4569
+ },
4570
+ {
4571
+ id: "js-encoded-payload-execution",
4572
+ title: "encoded blob decoded next to a code sink",
4573
+ consequence: "A base64 literal that is decoded and executed is the standard shape of a planted backdoor; the encoding exists to defeat review.",
4574
+ cwe: "CWE-506",
4575
+ severity: "critical",
4576
+ languages: ["javascript", "typescript"],
4577
+ pattern: /\bBuffer\.from\s*\(\s*[\w.$]+\s*,\s*['"]base64['"]\s*\)|\batob\s*\(\s*[\w.$]+\s*\)/,
4578
+ requires: CODE_SINK,
4579
+ guardBack: 6,
4580
+ guardForward: 3
4581
+ },
4582
+ {
4583
+ id: "py-dynamic-code-execution",
4584
+ title: "dynamic code execution",
4585
+ consequence: "Any string reaching this call executes as Python with the process\u2019 privileges.",
4586
+ cwe: "CWE-95",
4587
+ severity: "critical",
4588
+ languages: ["python"],
4589
+ pattern: /\b(?:eval|exec)\s*\(\s*(?!['"]\s*\))[a-zA-Z_(f'"]/,
4590
+ needsContext: true
4591
+ },
4592
+ {
4593
+ id: "rb-dynamic-dispatch",
4594
+ title: "dynamic code execution or unrestricted #send",
4595
+ consequence: "`eval` runs arbitrary Ruby; unrestricted `#send` lets the caller invoke any method on the receiver, including private ones.",
4596
+ cwe: "CWE-95",
4597
+ severity: "critical",
4598
+ languages: ["ruby"],
4599
+ pattern: /\beval\s*\(|\binstance_eval\s*\(|\bclass_eval\s*\(|\.\s*send\s*\(\s*(?:params|request|args)\b/
4600
+ },
4601
+ // ── Cross-site scripting ─────────────────────────────────────────────────
4602
+ {
4603
+ id: "js-unescaped-html-sink",
4604
+ title: "unescaped HTML rendering",
4605
+ consequence: "A script tag in the value executes in the victim\u2019s session \u2014 stored or reflected XSS.",
4606
+ cwe: "CWE-79",
4607
+ severity: "high",
4608
+ languages: ["javascript", "typescript"],
4609
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/,
4610
+ /**
4611
+ * A whole-statement assignment of a string with no interpolation and no
4612
+ * concatenation carries no data, so it cannot carry attacker data. This
4613
+ * was the single largest source of noise: a codebase that builds its UI
4614
+ * with innerHTML reports every static heading and spinner as XSS, and a
4615
+ * rule that flags 40 safe lines to catch one real one gets switched off.
4616
+ *
4617
+ * Line-scoped on purpose — see `lineGuard`.
4618
+ */
4619
+ lineGuard: /(?:innerHTML|outerHTML)\s*=\s*(?:'[^'\\]*'|"[^"\\]*"|`[^`$\\]*`)\s*;?\s*$/
4620
+ },
4621
+ {
4622
+ id: "java-html-writer-concatenation",
4623
+ title: "HTML written to the response by concatenation",
4624
+ consequence: "The servlet writer performs no encoding; a value concatenated into markup is rendered as markup.",
4625
+ cwe: "CWE-79",
4626
+ severity: "high",
4627
+ languages: ["java"],
4628
+ pattern: /\b(?:println|print|write)\s*\(\s*"[^"\n]*<[^"\n]*"\s*\+/
4629
+ },
4630
+ {
4631
+ id: "rb-unescaped-output",
4632
+ title: "Rails output escaping bypassed",
4633
+ consequence: "`html_safe` and `raw` tell Rails the string is already safe. If it came from a parameter, it is not.",
4634
+ cwe: "CWE-79",
4635
+ severity: "high",
4636
+ languages: ["ruby"],
4637
+ pattern: /\.\s*html_safe\b|\braw\s*\(\s*(?:params|request|@)|\blink_to\s+[^,\n]+,\s*params\s*\[/
4638
+ },
4639
+ {
4640
+ id: "py-template-autoescape-off",
4641
+ title: "template rendering with escaping disabled",
4642
+ consequence: "With autoescape off \u2014 or a `|safe` filter \u2014 every interpolated value is rendered as markup.",
4643
+ cwe: "CWE-79",
4644
+ severity: "high",
4645
+ languages: ["python"],
4646
+ pattern: /\bEnvironment\s*\([^)]*\bautoescape\s*=\s*False|\|\s*safe\b|\bMarkup\s*\(\s*(?!['"])/
4647
+ },
4648
+ {
4649
+ id: "py-template-from-input",
4650
+ title: "template compiled from a non-literal source",
4651
+ consequence: "Server-side template injection. In Jinja2 the sandbox is escapable, so this escalates from XSS to remote code execution.",
4652
+ cwe: "CWE-1336",
4653
+ severity: "critical",
4654
+ languages: ["python"],
4655
+ pattern: /\bTemplate\s*\(\s*(?!['"])[a-zA-Z_]/,
4656
+ needsContext: true
4657
+ },
4658
+ // ── Server-side request forgery ──────────────────────────────────────────
4659
+ {
4660
+ id: "js-ssrf-outbound-request",
4661
+ title: "outbound request to a non-constant URL",
4662
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
4663
+ cwe: "CWE-918",
4664
+ severity: "high",
4665
+ languages: ["javascript", "typescript"],
4666
+ 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*[,)]/,
4667
+ needsContext: true
4668
+ },
4669
+ {
4670
+ id: "py-ssrf-outbound-request",
4671
+ title: "outbound request to a non-constant URL",
4672
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
4673
+ cwe: "CWE-918",
4674
+ severity: "high",
4675
+ languages: ["python"],
4676
+ 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*[,)]/,
4677
+ needsContext: true
4678
+ },
4679
+ {
4680
+ id: "go-ssrf-outbound-request",
4681
+ title: "outbound request to a non-constant URL",
4682
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
4683
+ cwe: "CWE-918",
4684
+ severity: "high",
4685
+ languages: ["go"],
4686
+ pattern: /\bhttp\.(?:Get|Post|Head)\s*\(\s*(?:[a-zA-Z_]\w*\s*[,)]|"[^"]*"\s*\+)/,
4687
+ needsContext: true
4688
+ },
4689
+ // ── Open redirect ────────────────────────────────────────────────────────
4690
+ {
4691
+ id: "js-open-redirect",
4692
+ title: "redirect to a non-constant destination",
4693
+ consequence: "Your domain becomes the credible first hop of a phishing chain; the victim sees your hostname in the link they clicked.",
4694
+ cwe: "CWE-601",
4695
+ severity: "medium",
4696
+ languages: ["javascript", "typescript"],
4697
+ 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*\))/,
4698
+ needsContext: true
4699
+ },
4700
+ // ── Deserialisation ──────────────────────────────────────────────────────
4701
+ {
4702
+ id: "py-unsafe-deserialization",
4703
+ title: "deserialisation of untrusted data",
4704
+ consequence: "`pickle` and `yaml.load` instantiate arbitrary types during parsing \u2014 a crafted payload is remote code execution, not a parse error.",
4705
+ cwe: "CWE-502",
4706
+ severity: "critical",
4707
+ languages: ["python"],
4708
+ pattern: /\bpickle\.loads?\s*\(|\bcPickle\.loads?\s*\(|\bmarshal\.loads\s*\(|\byaml\.load\s*\(|\bjsonpickle\.decode\s*\(/
4709
+ },
4710
+ {
4711
+ id: "java-unsafe-deserialization",
4712
+ title: "Java deserialisation without a class filter",
4713
+ consequence: "A gadget chain in the classpath turns `readObject()` on attacker bytes into remote code execution.",
4714
+ cwe: "CWE-502",
4715
+ severity: "critical",
4716
+ languages: ["java"],
4717
+ pattern: /\breadObject\s*\(\s*\)|\bnew\s+ObjectInputStream\s*\(/,
4718
+ guardBack: 8,
4719
+ // The stream is constructed, *then* filtered. Without a forward window the
4720
+ // guarded case matches on its constructor line and reports a correct
4721
+ // implementation as a finding.
4722
+ guardForward: 6
4723
+ },
4724
+ {
4725
+ id: "js-unsafe-yaml-load",
4726
+ title: "YAML parsed with type resolution enabled",
4727
+ consequence: "A crafted document can instantiate arbitrary types during parsing.",
4728
+ cwe: "CWE-502",
4729
+ severity: "high",
4730
+ languages: ["javascript", "typescript"],
4731
+ pattern: /\byaml\s*\.\s*load\s*\((?![^)]*safe)|\bloadAll\s*\([^)]*unsafe/i
4732
+ },
4733
+ // ── XML external entities ────────────────────────────────────────────────
4734
+ {
4735
+ id: "java-xxe-parser-defaults",
4736
+ title: "XML parser left on its insecure defaults",
4737
+ consequence: "External entity expansion reads local files and makes outbound requests on the parser\u2019s behalf \u2014 file disclosure and SSRF from a document.",
4738
+ cwe: "CWE-611",
4739
+ severity: "high",
4740
+ languages: ["java"],
4741
+ pattern: /\b(?:DocumentBuilderFactory|SAXParserFactory|XMLInputFactory|TransformerFactory|SchemaFactory)\s*\.\s*newInstance\s*\(\s*\)/,
4742
+ guard: XXE_GUARD,
4743
+ guardBack: 4,
4744
+ guardForward: 8
4745
+ },
4746
+ {
4747
+ id: "java-xxe-parse-call",
4748
+ title: "XML parsed by a builder that was never hardened",
4749
+ consequence: "The expansion happens at `parse()`. Flagging only the factory misses the line where the document is actually read.",
4750
+ cwe: "CWE-611",
4751
+ severity: "high",
4752
+ languages: ["java"],
4753
+ // Receiver-qualified so `LocalDate.parse(s)` and friends stay out of it.
4754
+ pattern: /\b\w*(?:[Bb]uilder|[Pp]arser|[Rr]eader)\s*\.\s*parse\s*\(/,
4755
+ guard: XXE_GUARD,
4756
+ guardBack: 6,
4757
+ guardForward: 4
4758
+ },
4759
+ // ── Path traversal ───────────────────────────────────────────────────────
4760
+ {
4761
+ id: "py-path-traversal",
4762
+ title: "file opened at a path built from input",
4763
+ consequence: "A `../` sequence \u2014 or an absolute path \u2014 reads or writes outside the intended directory.",
4764
+ cwe: "CWE-22",
4765
+ severity: "high",
4766
+ languages: ["python"],
4767
+ pattern: /\bopen\s*\(\s*(?:os\.path\.join\s*\(|[a-zA-Z_]\w*\s*\+|f['"])/,
4768
+ needsContext: true
4769
+ },
4770
+ {
4771
+ id: "js-path-traversal",
4772
+ title: "file path built from a variable",
4773
+ consequence: "A `../` sequence in the value reads or writes outside the intended directory.",
4774
+ cwe: "CWE-22",
4775
+ severity: "medium",
4776
+ languages: ["javascript", "typescript"],
4777
+ pattern: /\b(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|sendFile)\s*\(\s*(?:`[^`]*\$\{|[a-zA-Z_$][\w$]*\s*\+|path\.join\s*\([^)]*(?:req|request)\b)/,
4778
+ needsContext: true
4779
+ },
4780
+ // ── Cryptography, tokens, randomness ─────────────────────────────────────
4781
+ {
4782
+ id: "js-jwt-decode-without-verify",
4783
+ title: "JWT decoded without verifying the signature",
4784
+ consequence: "`decode` parses the claims and checks nothing. Anyone can mint a token with any `sub` and any `role`.",
4785
+ cwe: "CWE-347",
4786
+ severity: "critical",
4787
+ languages: ["javascript", "typescript"],
4788
+ pattern: /\bjwt\s*\.\s*decode\s*\(|\bjsonwebtoken\s*\.\s*decode\s*\(|\bdecodeJwt\s*\(/
4789
+ },
4790
+ {
4791
+ id: "tls-verification-disabled",
4792
+ title: "TLS certificate verification disabled",
4793
+ consequence: "Every connection made this way is trivially interceptable; the encryption is decorative.",
4794
+ cwe: "CWE-295",
4795
+ severity: "high",
4796
+ 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/
4797
+ },
4798
+ {
4799
+ id: "weak-hash-on-credential",
4800
+ title: "broken hash used on a credential",
4801
+ consequence: "MD5 and SHA-1 are fast and collision-prone; hashed passwords are recoverable.",
4802
+ cwe: "CWE-327",
4803
+ severity: "high",
4804
+ pattern: /(?:createHash|hashlib|MessageDigest\.getInstance|Digest::)\s*[.(]?\s*['"]?(?:md5|MD5|sha1|SHA-?1)['"]?\s*\)?[\s\S]{0,80}(?:password|passwd|secret|token|credential)/i
4805
+ },
4806
+ {
4807
+ id: "insecure-randomness-for-secret",
4808
+ title: "predictable randomness used for a security value",
4809
+ consequence: "`Math.random`/`random.random` are predictable; tokens, session ids and reset codes built from them are guessable.",
4810
+ cwe: "CWE-338",
4811
+ severity: "high",
4812
+ 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
4813
+ },
4814
+ {
4815
+ id: "redos-nested-quantifier",
4816
+ title: "regex with nested unbounded quantifiers",
4817
+ consequence: "Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.",
4818
+ cwe: "CWE-1333",
4819
+ severity: "medium",
4820
+ pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
4821
+ },
4822
+ // ── Temporary files ──────────────────────────────────────────────────────
4823
+ {
4824
+ id: "insecure-temp-file",
4825
+ title: "predictable temporary file path",
4826
+ 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.",
4827
+ cwe: "CWE-377",
4828
+ severity: "medium",
4829
+ // A hardcoded path under /tmp is the finding whether or not it is
4830
+ // formatted: `"/tmp/application.log.tmp"` is worse than the PID-based one,
4831
+ // because every process on the host can predict it exactly.
4832
+ pattern: /\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|['"]\/tmp\/[^'"\n]+['"]|['"]\/tmp\/[^'"\n]*\{|\bFile\.createTempFile\s*\(/
4833
+ },
4834
+ // ── Information exposure ─────────────────────────────────────────────────
4835
+ {
4836
+ id: "py-stack-trace-returned",
4837
+ title: "stack trace returned to the caller",
4838
+ consequence: "Tracebacks leak absolute paths, dependency versions and source fragments \u2014 the reconnaissance an attacker would otherwise have to guess at.",
4839
+ cwe: "CWE-209",
4840
+ severity: "medium",
4841
+ languages: ["python"],
4842
+ pattern: /\breturn\b[^\n]*\btraceback\.(?:format_exc|format_exception|print_exc)\s*\(|\breturn\b[^\n]*\bstr\s*\(\s*e\s*\)/
4843
+ },
4844
+ {
4845
+ id: "js-environment-exfiltration",
4846
+ title: "process environment serialised into a payload",
4847
+ consequence: "The environment is where every secret lives. Serialising it whole into a request body is credential exfiltration regardless of the endpoint.",
4848
+ cwe: "CWE-532",
4849
+ severity: "critical",
4850
+ languages: ["javascript", "typescript"],
4851
+ pattern: /\bJSON\.stringify\s*\(\s*\{?[^)]*\bprocess\.env\b(?!\s*\.)/,
4852
+ guard: false
4853
+ },
4854
+ {
4855
+ id: "js-credential-logged",
4856
+ title: "credential read from the environment into a log sink",
4857
+ consequence: "CI retains job logs, and on public forks it publishes them. A token printed once is a token leaked permanently.",
4858
+ cwe: "CWE-532",
4859
+ severity: "high",
4860
+ languages: ["javascript", "typescript"],
4861
+ pattern: /\b(?:token|apiKey|api_key|secret|password|credential|auth)\w*\s*:\s*process\.env\.\w+/i,
4862
+ requires: EXFIL_SINK,
4863
+ guard: false,
4864
+ guardBack: 4,
4865
+ guardForward: 1
4866
+ },
4867
+ // ── Prototype pollution ──────────────────────────────────────────────────
4868
+ {
4869
+ id: "js-prototype-pollution",
4870
+ title: "write to a prototype-reachable key",
4871
+ consequence: "An attacker-supplied `__proto__` key changes behaviour for every object in the process, including ones it never touched.",
4872
+ cwe: "CWE-1321",
4873
+ severity: "high",
4874
+ languages: ["javascript", "typescript"],
4875
+ pattern: /\[\s*['"]__proto__['"]\s*\]|\bObject\s*\.\s*assign\s*\(\s*[\w.$]*\.prototype\b|\.\s*__proto__\s*=/
4876
+ }
4877
+ ];
4878
+ var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
4879
+ var DEFINITION_PREFIX = /^\s*(?:(?:export|public|private|protected|static|final|async|abstract)\s+)*(?:def|function|func|class|module|interface|struct|impl|fn|sub)\b/;
4880
+ function isComment(line) {
4881
+ return COMMENT_PREFIX.test(line);
4882
+ }
4883
+ function proseLines(lines) {
4884
+ const inside = /* @__PURE__ */ new Set();
4885
+ let delimiter = null;
4886
+ lines.forEach((line, index) => {
4887
+ if (delimiter) {
4888
+ inside.add(index);
4889
+ if (line.includes(delimiter)) delimiter = null;
4890
+ return;
4891
+ }
4892
+ for (const candidate of ['"""', "'''"]) {
4893
+ const start = line.indexOf(candidate);
4894
+ if (start === -1) continue;
4895
+ if (line.indexOf(candidate, start + candidate.length) !== -1) return;
4896
+ delimiter = candidate;
4897
+ inside.add(index);
4898
+ return;
4899
+ }
4900
+ });
4901
+ return inside;
4902
+ }
4903
+ function skippable(line, index, prose) {
4904
+ return isComment(line) || DEFINITION_PREFIX.test(line) || (prose?.has(index) ?? false);
4905
+ }
4906
+ function windowText(lines, index, back, forward, prose) {
4907
+ const from = Math.max(0, index - back);
4908
+ const to = Math.min(lines.length - 1, index + forward);
4909
+ const collected = [];
4910
+ for (let i = from; i <= to; i += 1) {
4911
+ const line = lines[i] ?? "";
4912
+ if (i !== index && skippable(line, i, prose)) continue;
4913
+ collected.push(line);
4914
+ }
4915
+ return collected.join("\n");
4916
+ }
4917
+ function evaluateRule(rule, ctx) {
4918
+ if (rule.languages && !rule.languages.includes(ctx.language)) return null;
4919
+ const line = ctx.lines[ctx.index] ?? "";
4920
+ if (isComment(line) || ctx.prose?.has(ctx.index)) return null;
4921
+ if (!rule.pattern.test(line)) return null;
4922
+ const back = rule.guardBack ?? 8;
4923
+ const forward = rule.guardForward ?? 0;
4924
+ const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
4925
+ if (rule.requires && !rule.requires.test(context)) return null;
4926
+ if (rule.lineGuard?.test(line)) return null;
4927
+ const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
4928
+ if (guard && (guard.test(line) || guard.test(context))) return null;
4929
+ const untrusted = untrustedPatternFor(ctx.language);
4930
+ const contextual = untrusted.test(line) || untrusted.test(context);
4931
+ if (rule.needsContext && !contextual) return null;
4932
+ const confidence = contextual ? "contextual" : "pattern";
4933
+ return { rule, confidence, severity: severityFor(rule.severity, confidence) };
4934
+ }
4935
+
4936
+ // src/scan/manifest-rules.ts
4937
+ var POPULAR_NPM = [
4938
+ "react",
4939
+ "react-dom",
4940
+ "lodash",
4941
+ "express",
4942
+ "axios",
4943
+ "chalk",
4944
+ "commander",
4945
+ "debug",
4946
+ "moment",
4947
+ "dayjs",
4948
+ "uuid",
4949
+ "dotenv",
4950
+ "typescript",
4951
+ "webpack",
4952
+ "vite",
4953
+ "rollup",
4954
+ "eslint",
4955
+ "prettier",
4956
+ "jest",
4957
+ "vitest",
4958
+ "mocha",
4959
+ "chai",
4960
+ "sinon",
4961
+ "request",
4962
+ "node-fetch",
4963
+ "cross-env",
4964
+ "rimraf",
4965
+ "glob",
4966
+ "minimist",
4967
+ "yargs",
4968
+ "inquirer",
4969
+ "colors",
4970
+ "ora",
4971
+ "semver",
4972
+ "ws",
4973
+ "socket.io",
4974
+ "mongoose",
4975
+ "sequelize",
4976
+ "knex",
4977
+ "pg",
4978
+ "mysql",
4979
+ "mysql2",
4980
+ "redis",
4981
+ "ioredis",
4982
+ "jsonwebtoken",
4983
+ "bcrypt",
4984
+ "passport",
4985
+ "cors",
4986
+ "helmet",
4987
+ "morgan",
4988
+ "body-parser",
4989
+ "multer",
4990
+ "nodemailer",
4991
+ "puppeteer",
4992
+ "playwright",
4993
+ "cheerio",
4994
+ "sharp",
4995
+ "canvas",
4996
+ "esbuild",
4997
+ "babel",
4998
+ "postcss",
4999
+ "tailwindcss",
5000
+ "next",
5001
+ "nuxt",
5002
+ "vue",
5003
+ "svelte",
5004
+ "angular",
5005
+ "rxjs",
5006
+ "zod"
5007
+ ];
5008
+ var POPULAR_PYPI = [
5009
+ "requests",
5010
+ "urllib3",
5011
+ "numpy",
5012
+ "pandas",
5013
+ "scipy",
5014
+ "flask",
5015
+ "django",
5016
+ "fastapi",
5017
+ "sqlalchemy",
5018
+ "pydantic",
5019
+ "click",
5020
+ "jinja2",
5021
+ "pyyaml",
5022
+ "boto3",
5023
+ "botocore",
5024
+ "setuptools",
5025
+ "wheel",
5026
+ "pip",
5027
+ "six",
5028
+ "certifi",
5029
+ "idna",
5030
+ "chardet",
5031
+ "attrs",
5032
+ "python-dateutil",
5033
+ "pytz",
5034
+ "pytest",
5035
+ "tox",
5036
+ "black",
5037
+ "flake8",
5038
+ "mypy",
5039
+ "isort",
5040
+ "beautifulsoup4",
5041
+ "lxml",
5042
+ "pillow",
5043
+ "matplotlib",
5044
+ "seaborn",
5045
+ "scikit-learn",
5046
+ "tensorflow",
5047
+ "torch",
5048
+ "transformers",
5049
+ "openai",
5050
+ "anthropic",
5051
+ "httpx",
5052
+ "aiohttp",
5053
+ "celery",
5054
+ "redis",
5055
+ "psycopg2",
5056
+ "pymongo",
5057
+ "cryptography",
5058
+ "paramiko",
5059
+ "colorama"
4322
5060
  ];
5061
+ var INTERNAL_MARKER = /(?:^|[-_/@])(?:internal|private|corp|intranet|inhouse|confidential)(?:$|[-_/])/i;
5062
+ function normalizeName(name) {
5063
+ return name.toLowerCase().replace(/^@/, "").replace(/[-_.\s]/g, "");
5064
+ }
5065
+ function editDistance(a, b, cap = 3) {
5066
+ if (a === b) return 0;
5067
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
5068
+ const rows = [];
5069
+ for (let i = 0; i <= a.length; i += 1) {
5070
+ rows.push(new Array(b.length + 1).fill(0));
5071
+ rows[i][0] = i;
5072
+ }
5073
+ for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
5074
+ for (let i = 1; i <= a.length; i += 1) {
5075
+ for (let j = 1; j <= b.length; j += 1) {
5076
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
5077
+ let best = Math.min(
5078
+ rows[i - 1][j] + 1,
5079
+ rows[i][j - 1] + 1,
5080
+ rows[i - 1][j - 1] + cost
5081
+ );
5082
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
5083
+ best = Math.min(best, rows[i - 2][j - 2] + 1);
5084
+ }
5085
+ rows[i][j] = best;
5086
+ }
5087
+ }
5088
+ return rows[a.length][b.length];
5089
+ }
5090
+ function detectTyposquat(name, ecosystem) {
5091
+ const popular = ecosystem === "npm" ? POPULAR_NPM : POPULAR_PYPI;
5092
+ const lower = name.toLowerCase().replace(/^@[^/]+\//, "");
5093
+ if (popular.includes(lower)) return null;
5094
+ if (lower.length < 4) return null;
5095
+ const normalized = normalizeName(lower);
5096
+ for (const candidate of popular) {
5097
+ const candidateNormalized = normalizeName(candidate);
5098
+ if (normalized === candidateNormalized) return { impersonates: candidate, kind: "separator" };
5099
+ if (editDistance(normalized, candidateNormalized, 1) === 1) {
5100
+ return { impersonates: candidate, kind: "edit" };
5101
+ }
5102
+ }
5103
+ return null;
5104
+ }
5105
+ var LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall", "prepare", "prepublish"];
5106
+ function scanPackageJson(text) {
5107
+ const findings = [];
5108
+ const lines = text.split("\n");
5109
+ let parsed;
5110
+ try {
5111
+ parsed = JSON.parse(text);
5112
+ } catch {
5113
+ return findings;
5114
+ }
5115
+ const lineOf = (needle) => {
5116
+ const index = lines.findIndex((line) => line.includes(`"${needle}"`));
5117
+ return index === -1 ? 1 : index + 1;
5118
+ };
5119
+ const depBuckets = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
5120
+ for (const bucket of depBuckets) {
5121
+ const deps = parsed[bucket];
5122
+ if (!deps || typeof deps !== "object") continue;
5123
+ for (const name of Object.keys(deps)) {
5124
+ const line = lineOf(name);
5125
+ if (INTERNAL_MARKER.test(name)) {
5126
+ findings.push({
5127
+ ruleId: "manifest-dependency-confusion",
5128
+ title: "internal-looking package resolved from a public registry",
5129
+ line,
5130
+ severity: "critical",
5131
+ cwe: "CWE-1357",
5132
+ message: `"${name}" names itself as internal but carries no registry pin`,
5133
+ consequence: "Whoever registers this name publicly first wins the resolution, and their code runs in your build.",
5134
+ excerpt: (lines[line - 1] ?? "").trim()
5135
+ });
5136
+ continue;
5137
+ }
5138
+ const squat = detectTyposquat(name, "npm");
5139
+ if (squat) {
5140
+ findings.push({
5141
+ ruleId: "manifest-typosquat",
5142
+ title: "dependency name close to a popular package",
5143
+ line,
5144
+ severity: "high",
5145
+ cwe: "CWE-1357",
5146
+ message: squat.kind === "separator" ? `"${name}" differs from "${squat.impersonates}" only in punctuation` : `"${name}" is one edit from "${squat.impersonates}"`,
5147
+ consequence: "A reviewer scanning a diff reads the name they expected. The package that installs is the one that was written.",
5148
+ excerpt: (lines[line - 1] ?? "").trim()
5149
+ });
5150
+ }
5151
+ }
5152
+ }
5153
+ const scripts = parsed.scripts;
5154
+ if (scripts && typeof scripts === "object") {
5155
+ for (const [name, body] of Object.entries(scripts)) {
5156
+ if (!LIFECYCLE_SCRIPTS.includes(name)) continue;
5157
+ findings.push({
5158
+ ruleId: "manifest-install-lifecycle-script",
5159
+ title: "install-time lifecycle script",
5160
+ line: lineOf(name),
5161
+ severity: "medium",
5162
+ cwe: "CWE-506",
5163
+ message: `"${name}" runs automatically on install: ${String(body).slice(0, 120)}`,
5164
+ 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.",
5165
+ excerpt: (lines[lineOf(name) - 1] ?? "").trim()
5166
+ });
5167
+ }
5168
+ }
5169
+ return findings;
5170
+ }
5171
+ function scanRequirementsTxt(text) {
5172
+ const findings = [];
5173
+ const lines = text.split("\n");
5174
+ lines.forEach((raw, index) => {
5175
+ const line = raw.trim();
5176
+ if (!line || line.startsWith("#") || line.startsWith("-")) return;
5177
+ const match = /^([A-Za-z0-9_.-]+)\s*(?:[=<>!~]=|@|$)/.exec(line);
5178
+ const name = match?.[1];
5179
+ if (!name) return;
5180
+ if (INTERNAL_MARKER.test(name)) {
5181
+ findings.push({
5182
+ ruleId: "manifest-dependency-confusion",
5183
+ title: "internal-looking package resolved from a public index",
5184
+ line: index + 1,
5185
+ severity: "critical",
5186
+ cwe: "CWE-1357",
5187
+ message: `"${name}" names itself as internal but carries no index pin`,
5188
+ consequence: "pip resolves the highest version across every configured index, so a public package of the same name shadows the private one.",
5189
+ excerpt: line
5190
+ });
5191
+ return;
5192
+ }
5193
+ const squat = detectTyposquat(name, "pypi");
5194
+ if (squat) {
5195
+ findings.push({
5196
+ ruleId: "manifest-typosquat",
5197
+ title: "dependency name close to a popular package",
5198
+ line: index + 1,
5199
+ severity: "high",
5200
+ cwe: "CWE-1357",
5201
+ message: squat.kind === "separator" ? `"${name}" differs from "${squat.impersonates}" only in punctuation` : `"${name}" is one edit from "${squat.impersonates}"`,
5202
+ consequence: "A reviewer scanning a diff reads the name they expected. The package that installs is the one that was written.",
5203
+ excerpt: line
5204
+ });
5205
+ }
5206
+ });
5207
+ return findings;
5208
+ }
5209
+
5210
+ // src/scan/secret-rules.ts
5211
+ var SECRET_RULES = [
5212
+ {
5213
+ id: "secret-aws-access-key",
5214
+ name: "AWS Access Key",
5215
+ pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/,
5216
+ severity: "critical",
5217
+ cwe: "CWE-798",
5218
+ consequence: "Paired with a secret key, grants the API access of whatever IAM principal issued it."
5219
+ },
5220
+ {
5221
+ id: "secret-aws-secret-key",
5222
+ name: "AWS Secret Access Key",
5223
+ pattern: /(?:aws_secret_access_key|AWS_SECRET(?:_ACCESS_KEY)?)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/i,
5224
+ severity: "critical",
5225
+ cwe: "CWE-798",
5226
+ consequence: "The other half of an AWS credential pair; on its own it is still the hard half to guess."
5227
+ },
5228
+ {
5229
+ id: "secret-github-token",
5230
+ name: "GitHub Token",
5231
+ 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/,
5232
+ severity: "critical",
5233
+ cwe: "CWE-798",
5234
+ consequence: "Repository read or write as the issuing account, including the ability to push workflow changes."
5235
+ },
5236
+ {
5237
+ id: "secret-npm-token",
5238
+ name: "npm Token",
5239
+ pattern: /\bnpm_[A-Za-z0-9]{36}\b/,
5240
+ severity: "critical",
5241
+ cwe: "CWE-798",
5242
+ consequence: "Publish rights to every package the account owns \u2014 a supply-chain compromise in one command."
5243
+ },
5244
+ {
5245
+ id: "secret-private-key",
5246
+ name: "Private Key",
5247
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/,
5248
+ severity: "critical",
5249
+ cwe: "CWE-798",
5250
+ consequence: "Key material, committed. Rotation is the only remediation."
5251
+ },
5252
+ {
5253
+ id: "secret-slack-token",
5254
+ name: "Slack Token",
5255
+ pattern: /\bxox[bpoasr]-[A-Za-z0-9-]{10,}/,
5256
+ severity: "critical",
5257
+ cwe: "CWE-798",
5258
+ consequence: "Read and post access to the workspace as the installing app."
5259
+ },
5260
+ {
5261
+ id: "secret-slack-webhook",
5262
+ name: "Slack Webhook URL",
5263
+ pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_+\/-]{6,}/,
5264
+ severity: "high",
5265
+ cwe: "CWE-798",
5266
+ consequence: "The URL *is* the credential \u2014 anyone holding it can post to that channel."
5267
+ },
5268
+ {
5269
+ id: "secret-stripe-key",
5270
+ name: "Stripe Key",
5271
+ pattern: /\b(?:sk_live_|rk_live_|sk_test_|rk_test_)[A-Za-z0-9]{20,}\b/,
5272
+ severity: "critical",
5273
+ cwe: "CWE-798",
5274
+ consequence: "Charge, refund and customer-data access against the account."
5275
+ },
5276
+ {
5277
+ id: "secret-sendgrid-key",
5278
+ name: "SendGrid API Key",
5279
+ pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/,
5280
+ severity: "critical",
5281
+ cwe: "CWE-798",
5282
+ consequence: "Send mail as the domain \u2014 the credential behind most convincing phishing from a real sender."
5283
+ },
5284
+ {
5285
+ id: "secret-google-api-key",
5286
+ name: "Google API Key",
5287
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/,
5288
+ severity: "high",
5289
+ cwe: "CWE-798",
5290
+ consequence: "Quota theft at minimum; API access to whatever the key was scoped to at worst."
5291
+ },
5292
+ {
5293
+ id: "secret-openai-key",
5294
+ name: "OpenAI API Key",
5295
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/,
5296
+ severity: "critical",
5297
+ cwe: "CWE-798",
5298
+ consequence: "Billed inference against the owner\u2019s account, with no per-key spend limit by default."
5299
+ },
5300
+ {
5301
+ id: "secret-anthropic-key",
5302
+ name: "Anthropic API Key",
5303
+ pattern: /\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{32,}\b/,
5304
+ severity: "critical",
5305
+ cwe: "CWE-798",
5306
+ consequence: "Billed inference against the owner\u2019s account."
5307
+ },
5308
+ {
5309
+ id: "secret-database-url",
5310
+ name: "Database URL with credentials",
5311
+ // Requires a credential segment before the `@` — `postgres://localhost/db`
5312
+ // is a hostname, not a secret.
5313
+ pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|clickhouse):\/\/[^\s'"@\/]*:[^\s'"@\/]*@[^\s'"]+/i,
5314
+ severity: "high",
5315
+ cwe: "CWE-798",
5316
+ consequence: "Direct database access, usually bypassing every application-level authorisation check."
5317
+ },
5318
+ {
5319
+ id: "secret-jwt",
5320
+ name: "JSON Web Token",
5321
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_.+/=-]*/,
5322
+ severity: "medium",
5323
+ cwe: "CWE-798",
5324
+ consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived."
5325
+ },
5326
+ {
5327
+ id: "secret-generic-api-key",
5328
+ name: "Generic API Key",
5329
+ // Quoted assignment only. An unquoted value in a `.env` is covered by the
5330
+ // vendor-prefixed rules above; matching it here is what starts flagging
5331
+ // ARNs and parameter-store paths.
5332
+ pattern: /(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9\-_.]{20,})['"]/i,
5333
+ severity: "high",
5334
+ cwe: "CWE-798",
5335
+ consequence: "Whatever the third-party service lets the key do, for as long as it stays valid."
5336
+ },
5337
+ {
5338
+ id: "secret-generic-credential",
5339
+ name: "Hardcoded Credential",
5340
+ pattern: /(?:secret|password|passwd|pwd|token)\s*[=:]\s*['"]([^'"\s]{8,})['"]/i,
5341
+ severity: "high",
5342
+ cwe: "CWE-798",
5343
+ consequence: "A password in source is a password in every clone, fork and CI cache of that source."
5344
+ },
5345
+ {
5346
+ id: "secret-hex-token",
5347
+ name: "High-entropy Hex Token",
5348
+ pattern: /(?:token|key|secret|auth|signing)\w*\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/i,
5349
+ severity: "medium",
5350
+ cwe: "CWE-798",
5351
+ consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign."
5352
+ }
5353
+ ];
5354
+ var KNOWN_PLACEHOLDERS = [
5355
+ // Deliberately NOT here: AWS's published documentation key/secret pair
5356
+ // (`AKIAIOSFODNN7EXAMPLE`, `wJalrXUtnFEMI/…`). GitHub allow-lists them, and
5357
+ // the argument for following suit is that they authenticate nothing. The
5358
+ // argument against is stronger: they appear in a repository because someone
5359
+ // pasted a credentials template and left it there, and the remediation —
5360
+ // move this to the secret manager — is identical to the one for a live key.
5361
+ // Exempting them means the scanner goes quiet on the file most likely to
5362
+ // acquire a real key next.
5363
+ /\bEXAMPLE_?KEY\b/i,
5364
+ /\bYOUR_[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)\b/,
5365
+ /\b(?:xxx+|X{4,}|\*{4,}|<[a-z-]+>)\b/,
5366
+ /\bchangeme\b/i
5367
+ ];
5368
+ function isKnownPlaceholder(text) {
5369
+ return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
5370
+ }
5371
+ function redactSecret(line) {
5372
+ return line.replace(/([A-Za-z0-9+/=_.-]{12,})/g, (match) => {
5373
+ if (match.length <= 12) return match;
5374
+ return `${match.slice(0, 3)}${"*".repeat(Math.min(16, match.length - 3))}`;
5375
+ });
5376
+ }
5377
+ var SENSITIVE_FILES = [
5378
+ { pattern: ".env", message: "Environment file committed \u2014 the usual home of every runtime credential", severity: "high" },
5379
+ { pattern: ".env.local", message: "Local environment file committed", severity: "high" },
5380
+ { pattern: ".env.production", message: "Production environment file committed", severity: "critical" },
5381
+ { pattern: "id_rsa", message: "Private SSH key committed", severity: "critical" },
5382
+ { pattern: "id_ed25519", message: "Private SSH key committed", severity: "critical" },
5383
+ { pattern: "id_ecdsa", message: "Private SSH key committed", severity: "critical" },
5384
+ { pattern: ".pem", message: "PEM certificate or key file committed", severity: "high" },
5385
+ { pattern: ".p12", message: "PKCS#12 keystore committed", severity: "high" },
5386
+ { pattern: ".pfx", message: "PKCS#12 keystore committed", severity: "high" },
5387
+ { pattern: ".keystore", message: "Java keystore committed", severity: "high" }
5388
+ // Deliberately not `.npmrc`. Its presence is normal; only an `_authToken`
5389
+ // line in it is a credential, and that is a content match, not a filename
5390
+ // match. Reporting the file itself trades a real finding for a chore.
5391
+ ];
5392
+
5393
+ // src/scan/engine.ts
4323
5394
  var SKIP_DIRS = /* @__PURE__ */ new Set([
4324
5395
  "node_modules",
4325
5396
  ".git",
4326
5397
  ".next",
5398
+ ".nuxt",
4327
5399
  "dist",
4328
5400
  "build",
5401
+ "out",
4329
5402
  "__pycache__",
4330
5403
  ".venv",
5404
+ "venv",
4331
5405
  "vendor",
4332
5406
  ".terraform",
4333
5407
  "coverage",
4334
- ".cache"
5408
+ ".cache",
5409
+ ".pnpm-store",
5410
+ "target",
5411
+ ".gradle",
5412
+ ".idea",
5413
+ ".vscode",
5414
+ "bower_components",
5415
+ ".svelte-kit"
4335
5416
  ]);
4336
5417
  var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
4337
5418
  ".ts",
4338
5419
  ".js",
4339
5420
  ".tsx",
4340
5421
  ".jsx",
5422
+ ".mjs",
5423
+ ".cjs",
5424
+ ".mts",
5425
+ ".cts",
4341
5426
  ".py",
4342
5427
  ".rb",
4343
5428
  ".go",
4344
5429
  ".java",
5430
+ ".kt",
5431
+ ".scala",
4345
5432
  ".php",
4346
5433
  ".rs",
4347
5434
  ".c",
5435
+ ".cc",
4348
5436
  ".cpp",
4349
5437
  ".h",
5438
+ ".hpp",
5439
+ ".cs",
5440
+ ".swift",
4350
5441
  ".yml",
4351
5442
  ".yaml",
4352
5443
  ".json",
@@ -4357,191 +5448,324 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
4357
5448
  ".env",
4358
5449
  ".sh",
4359
5450
  ".bash",
5451
+ ".zsh",
4360
5452
  ".tf",
4361
5453
  ".hcl",
4362
5454
  ".xml",
4363
5455
  ".properties",
4364
- ".gradle"
5456
+ ".gradle",
5457
+ ".txt",
5458
+ ".md",
5459
+ ".sql",
5460
+ ".erb",
5461
+ ".ejs",
5462
+ ".vue",
5463
+ ".svelte"
4365
5464
  ]);
4366
- async function runScan(targetPath) {
4367
- const findings = [];
4368
- try {
4369
- scanDirectory(targetPath, targetPath, findings, () => {
4370
- });
4371
- const depFindings = await scanDependencies(targetPath);
4372
- findings.push(...depFindings);
4373
- } catch (err) {
4374
- return {
4375
- type: "scan",
4376
- target: targetPath,
4377
- findings: [],
4378
- severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
4379
- summary: `Scan failed: ${err.message}`,
4380
- error: err.message
4381
- };
4382
- }
4383
- const structured = findings.map((f) => ({
4384
- type: f.type,
4385
- severity: f.severity,
4386
- message: f.message,
4387
- location: `${f.file}:${f.line}`,
4388
- details: { file: f.file, line: f.line, snippet: f.snippet }
4389
- }));
4390
- const summary = summarize(structured);
4391
- return {
4392
- type: "scan",
4393
- target: targetPath,
4394
- findings: structured,
4395
- severity_summary: summary,
4396
- summary: findings.length === 0 ? "No security issues found" : `${findings.length} issue(s): ${summary.critical}C ${summary.high}H ${summary.medium}M ${summary.low}L`
5465
+ var LANGUAGE_BY_EXTENSION = {
5466
+ ".js": "javascript",
5467
+ ".jsx": "javascript",
5468
+ ".mjs": "javascript",
5469
+ ".cjs": "javascript",
5470
+ ".ts": "typescript",
5471
+ ".tsx": "typescript",
5472
+ ".mts": "typescript",
5473
+ ".cts": "typescript",
5474
+ ".vue": "javascript",
5475
+ ".svelte": "javascript",
5476
+ ".ejs": "javascript",
5477
+ ".py": "python",
5478
+ ".rb": "ruby",
5479
+ ".erb": "ruby",
5480
+ ".go": "go",
5481
+ ".java": "java",
5482
+ ".kt": "java",
5483
+ ".scala": "java",
5484
+ ".php": "php",
5485
+ ".sh": "shell",
5486
+ ".bash": "shell",
5487
+ ".zsh": "shell",
5488
+ ".yml": "config",
5489
+ ".yaml": "config",
5490
+ ".json": "config",
5491
+ ".toml": "config",
5492
+ ".ini": "config",
5493
+ ".cfg": "config",
5494
+ ".conf": "config",
5495
+ ".env": "config",
5496
+ ".tf": "config",
5497
+ ".hcl": "config",
5498
+ ".properties": "config"
5499
+ };
5500
+ function languageOf(filename) {
5501
+ if (filename.startsWith(".env") || filename.endsWith(".env")) return "config";
5502
+ return LANGUAGE_BY_EXTENSION[(0, import_node_path6.extname)(filename).toLowerCase()] ?? "other";
5503
+ }
5504
+ var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
5505
+ var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
5506
+ function collectSuppressions(lines) {
5507
+ const byLine = /* @__PURE__ */ new Map();
5508
+ let count = 0;
5509
+ const add = (index, ruleId) => {
5510
+ const existing = byLine.get(index) ?? /* @__PURE__ */ new Set();
5511
+ existing.add(ruleId ?? "*");
5512
+ byLine.set(index, existing);
5513
+ count += 1;
4397
5514
  };
5515
+ lines.forEach((line, index) => {
5516
+ const next = SUPPRESS_NEXT.exec(line);
5517
+ if (next) add(index + 1, next[1]);
5518
+ const same = SUPPRESS_LINE.exec(line);
5519
+ if (same && !next) add(index, same[1]);
5520
+ });
5521
+ return { byLine, count };
4398
5522
  }
4399
- function scanDirectory(basePath, currentPath, findings, onFile) {
4400
- let entries;
4401
- try {
4402
- entries = (0, import_node_fs10.readdirSync)(currentPath, { withFileTypes: true });
4403
- } catch {
4404
- return;
4405
- }
4406
- for (const entry of entries) {
4407
- const fullPath = (0, import_node_path5.join)(currentPath, entry.name);
4408
- if (entry.isDirectory()) {
4409
- if (SKIP_DIRS.has(entry.name)) continue;
4410
- scanDirectory(basePath, fullPath, findings, onFile);
4411
- continue;
5523
+ function isSuppressed(suppressions, index, ruleId) {
5524
+ const rules = suppressions.byLine.get(index);
5525
+ if (!rules) return false;
5526
+ return rules.has("*") || rules.has(ruleId);
5527
+ }
5528
+ function isTestPath(relativePath) {
5529
+ const p = relativePath.replace(/\\/g, "/");
5530
+ 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);
5531
+ }
5532
+ function scanText(relativePath, text, language = languageOf(relativePath)) {
5533
+ const findings = [];
5534
+ const lines = text.split("\n");
5535
+ const suppressions = collectSuppressions(lines);
5536
+ const inTests = isTestPath(relativePath);
5537
+ lines.forEach((line, index) => {
5538
+ for (const rule of SECRET_RULES) {
5539
+ const match = rule.pattern.exec(line);
5540
+ if (!match) continue;
5541
+ if (isKnownPlaceholder(match[0])) continue;
5542
+ if (isSuppressed(suppressions, index, rule.id)) continue;
5543
+ findings.push({
5544
+ ruleId: rule.id,
5545
+ title: rule.name,
5546
+ file: relativePath,
5547
+ line: index + 1,
5548
+ // Reported but not blocking in tests — see isTestPath.
5549
+ severity: inTests ? "low" : rule.severity,
5550
+ // A matched credential format is the finding, not a proxy for one.
5551
+ confidence: "evidence",
5552
+ 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`,
5553
+ consequence: rule.consequence,
5554
+ cwe: rule.cwe,
5555
+ excerpt: redactSecret(line.trim()).slice(0, 200),
5556
+ sensitive: true,
5557
+ category: "secret"
5558
+ });
4412
5559
  }
4413
- if (!entry.isFile()) continue;
4414
- for (const mc of MISCONFIG_FILES) {
4415
- if (entry.name === mc.pattern || entry.name.endsWith(mc.pattern)) {
4416
- findings.push({
4417
- file: (0, import_node_path5.relative)(basePath, fullPath),
4418
- line: 0,
4419
- type: "Sensitive File",
4420
- severity: "high",
4421
- message: mc.message,
4422
- snippet: ""
4423
- });
4424
- }
5560
+ });
5561
+ const prose = proseLines(lines);
5562
+ lines.forEach((_line, index) => {
5563
+ for (const rule of CODE_RULES) {
5564
+ if (isSuppressed(suppressions, index, rule.id)) continue;
5565
+ const match = evaluateRule(rule, { lines, index, language, prose });
5566
+ if (!match) continue;
5567
+ findings.push({
5568
+ ruleId: rule.id,
5569
+ title: rule.title,
5570
+ file: relativePath,
5571
+ line: index + 1,
5572
+ severity: match.severity,
5573
+ confidence: match.confidence,
5574
+ message: `${rule.title} (${rule.cwe})`,
5575
+ consequence: rule.consequence,
5576
+ cwe: rule.cwe,
5577
+ excerpt: (lines[index] ?? "").trim().slice(0, 200),
5578
+ category: "code"
5579
+ });
4425
5580
  }
4426
- const ext = (0, import_node_path5.extname)(entry.name).toLowerCase();
4427
- if (!SCAN_EXTENSIONS.has(ext) && !entry.name.startsWith(".env")) continue;
5581
+ });
5582
+ return findings;
5583
+ }
5584
+ function scanManifest(relativePath, filename, text) {
5585
+ const manifestFindings = filename === "package.json" ? scanPackageJson(text) : filename === "requirements.txt" ? scanRequirementsTxt(text) : [];
5586
+ return manifestFindings.map((finding) => ({
5587
+ ruleId: finding.ruleId,
5588
+ title: finding.title,
5589
+ file: relativePath,
5590
+ line: finding.line,
5591
+ severity: finding.severity,
5592
+ confidence: "evidence",
5593
+ message: finding.message,
5594
+ consequence: finding.consequence,
5595
+ cwe: finding.cwe,
5596
+ excerpt: finding.excerpt.slice(0, 200),
5597
+ category: "manifest"
5598
+ }));
5599
+ }
5600
+ function scanPath(targetPath, options = {}) {
5601
+ const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
5602
+ const allowed = options.categories ? new Set(options.categories) : null;
5603
+ const findings = [];
5604
+ const unreadable = [];
5605
+ let filesScanned = 0;
5606
+ let suppressed = 0;
5607
+ const rootIsDirectory = (() => {
4428
5608
  try {
4429
- const stat = (0, import_node_fs10.statSync)(fullPath);
4430
- if (stat.size > 1024 * 1024) continue;
5609
+ return (0, import_node_fs11.statSync)(targetPath).isDirectory();
4431
5610
  } catch {
4432
- continue;
5611
+ return true;
5612
+ }
5613
+ })();
5614
+ const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path6.dirname)(targetPath);
5615
+ const scanFile = (fullPath, filename) => {
5616
+ const relativePath = toRelative(walkRoot, fullPath);
5617
+ const extension = (0, import_node_path6.extname)(filename).toLowerCase();
5618
+ const isManifest = filename === "package.json" || filename === "requirements.txt";
5619
+ const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
5620
+ if (!scannable && !isManifest) {
5621
+ recordSensitiveFile(filename, relativePath, findings, []);
5622
+ return;
4433
5623
  }
4434
- onFile();
4435
- let content;
5624
+ let text;
5625
+ let handle;
4436
5626
  try {
4437
- content = (0, import_node_fs10.readFileSync)(fullPath, "utf-8");
5627
+ handle = (0, import_node_fs11.openSync)(fullPath, "r");
4438
5628
  } catch {
4439
- continue;
5629
+ unreadable.push(relativePath);
5630
+ return;
4440
5631
  }
4441
- const lines = content.split("\n");
4442
- for (let i = 0; i < lines.length; i++) {
4443
- const line = lines[i];
4444
- if (line.trim().startsWith("//") && !line.includes("password") && !line.includes("secret")) continue;
4445
- for (const pattern of SECRET_PATTERNS) {
4446
- pattern.pattern.lastIndex = 0;
4447
- if (pattern.pattern.test(line)) {
4448
- findings.push({
4449
- file: (0, import_node_path5.relative)(basePath, fullPath),
4450
- line: i + 1,
4451
- type: pattern.name,
4452
- severity: pattern.severity,
4453
- message: `Possible ${pattern.name} detected`,
4454
- snippet: line.length > 120 ? line.slice(0, 120) + "..." : line
4455
- });
4456
- }
5632
+ try {
5633
+ if ((0, import_node_fs11.fstatSync)(handle).size > maxFileBytes) return;
5634
+ text = (0, import_node_fs11.readFileSync)(handle, "utf-8");
5635
+ } catch {
5636
+ unreadable.push(relativePath);
5637
+ return;
5638
+ } finally {
5639
+ try {
5640
+ (0, import_node_fs11.closeSync)(handle);
5641
+ } catch {
4457
5642
  }
4458
5643
  }
4459
- }
4460
- }
4461
- async function scanDependencies(targetPath) {
4462
- const findings = [];
4463
- const lockfiles = [
4464
- { file: "package-lock.json", ecosystem: "npm" },
4465
- { file: "pnpm-lock.yaml", ecosystem: "npm" },
4466
- { file: "yarn.lock", ecosystem: "npm" },
4467
- { file: "requirements.txt", ecosystem: "PyPI" },
4468
- { file: "Pipfile.lock", ecosystem: "PyPI" }
4469
- ];
4470
- for (const { file, ecosystem } of lockfiles) {
4471
- const lockPath = (0, import_node_path5.join)(targetPath, file);
4472
- if (!(0, import_node_fs10.existsSync)(lockPath)) continue;
5644
+ filesScanned += 1;
5645
+ options.onFile?.(relativePath);
5646
+ suppressed += collectSuppressions(text.split("\n")).count;
5647
+ const fileFindings = [
5648
+ ...scanText(relativePath, text, languageOf(filename)),
5649
+ ...isManifest ? scanManifest(relativePath, filename, text) : []
5650
+ ];
5651
+ findings.push(...fileFindings);
5652
+ recordSensitiveFile(filename, relativePath, findings, fileFindings);
5653
+ };
5654
+ const walk = (currentPath) => {
5655
+ let entries;
4473
5656
  try {
4474
- const deps = parseDependencies(lockPath, file, ecosystem);
4475
- for (const dep of deps.slice(0, 50)) {
4476
- try {
4477
- const vulns = await queryOsv(dep.name, dep.version, ecosystem);
4478
- for (const vuln of vulns) {
4479
- const cvssScore = vuln.severity?.find((s) => s.type === "CVSS_V3")?.score;
4480
- const severity = cvssScore ? parseFloat(cvssScore) >= 9 ? "critical" : parseFloat(cvssScore) >= 7 ? "high" : parseFloat(cvssScore) >= 4 ? "medium" : "low" : "medium";
4481
- findings.push({
4482
- file,
4483
- line: 0,
4484
- type: "Dependency CVE",
4485
- severity,
4486
- message: `${dep.name}@${dep.version}: ${vuln.summary || vuln.id}`,
4487
- snippet: `${vuln.id}${cvssScore ? ` (CVSS: ${cvssScore})` : ""}`
4488
- });
4489
- }
4490
- } catch {
4491
- }
4492
- }
5657
+ entries = (0, import_node_fs11.readdirSync)(currentPath, { withFileTypes: true });
4493
5658
  } catch {
5659
+ unreadable.push(toRelative(walkRoot, currentPath));
5660
+ return;
4494
5661
  }
4495
- }
4496
- return findings;
4497
- }
4498
- function parseDependencies(lockPath, filename, ecosystem) {
4499
- const deps = [];
4500
- if (filename === "package-lock.json") {
4501
- try {
4502
- const lock = JSON.parse((0, import_node_fs10.readFileSync)(lockPath, "utf-8"));
4503
- const packages = lock.packages || lock.dependencies || {};
4504
- for (const [key, value] of Object.entries(packages)) {
4505
- const name = key.replace(/^node_modules\//, "");
4506
- const version = value.version;
4507
- if (name && version && !name.startsWith(".")) {
4508
- deps.push({ name, version });
4509
- }
5662
+ for (const entry of entries) {
5663
+ const fullPath = (0, import_node_path6.join)(currentPath, entry.name);
5664
+ if (entry.isDirectory()) {
5665
+ if (SKIP_DIRS.has(entry.name)) continue;
5666
+ walk(fullPath);
5667
+ continue;
4510
5668
  }
4511
- } catch {
5669
+ if (!entry.isFile()) continue;
5670
+ scanFile(fullPath, entry.name);
4512
5671
  }
4513
- } else if (filename === "requirements.txt") {
5672
+ };
5673
+ if (rootIsDirectory) {
5674
+ walk(targetPath);
5675
+ } else {
5676
+ scanFile(targetPath, (0, import_node_path6.basename)(targetPath));
5677
+ }
5678
+ const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
5679
+ filtered.sort(
5680
+ (a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
5681
+ );
5682
+ return { findings: filtered, filesScanned, unreadable, suppressed, root: walkRoot };
5683
+ }
5684
+ function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
5685
+ if (fileFindings.length > 0) return;
5686
+ for (const sensitive of SENSITIVE_FILES) {
5687
+ const matches = filename === sensitive.pattern || filename.endsWith(sensitive.pattern);
5688
+ if (!matches) continue;
5689
+ sink.push({
5690
+ ruleId: "sensitive-file-committed",
5691
+ title: "Sensitive file",
5692
+ file: relativePath,
5693
+ line: 1,
5694
+ severity: sensitive.severity,
5695
+ confidence: "evidence",
5696
+ message: sensitive.message,
5697
+ consequence: "Anything in this file is in every clone, fork and CI cache of the repository.",
5698
+ cwe: "CWE-538",
5699
+ excerpt: "",
5700
+ sensitive: true,
5701
+ category: "file"
5702
+ });
5703
+ return;
5704
+ }
5705
+ }
5706
+ function toRelative(base, target) {
5707
+ const rel = (0, import_node_path6.relative)(base, target);
5708
+ return (rel === "" ? target : rel).split(import_node_path6.sep).join("/");
5709
+ }
5710
+
5711
+ // src/scan/sarif.ts
5712
+ var import_node_path7 = require("path");
5713
+
5714
+ // src/commands/scan.ts
5715
+ function readVersion() {
5716
+ for (const candidate of [
5717
+ (0, import_node_path8.join)(__dirname, "..", "package.json"),
5718
+ (0, import_node_path8.join)(__dirname, "..", "..", "package.json")
5719
+ ]) {
4514
5720
  try {
4515
- const content = (0, import_node_fs10.readFileSync)(lockPath, "utf-8");
4516
- for (const line of content.split("\n")) {
4517
- const match = line.match(/^([a-zA-Z0-9_.-]+)==([0-9.]+)/);
4518
- if (match) deps.push({ name: match[1], version: match[2] });
4519
- }
5721
+ return JSON.parse((0, import_node_fs12.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
4520
5722
  } catch {
4521
5723
  }
4522
5724
  }
4523
- return deps;
5725
+ return "0.0.0";
4524
5726
  }
4525
- function isValidPackageName(name) {
4526
- return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
5727
+ var PKG_VERSION = readVersion();
5728
+ function toRunResult(targetPath, findings, filesScanned) {
5729
+ const structured = findings.map((finding) => ({
5730
+ type: finding.title,
5731
+ severity: finding.severity,
5732
+ message: finding.message,
5733
+ location: `${finding.file}:${finding.line}`,
5734
+ details: {
5735
+ file: finding.file,
5736
+ line: finding.line,
5737
+ snippet: finding.excerpt,
5738
+ ruleId: finding.ruleId,
5739
+ confidence: finding.confidence,
5740
+ ...finding.cwe ? { cwe: finding.cwe } : {}
5741
+ }
5742
+ }));
5743
+ const counts = summarize(structured);
5744
+ return {
5745
+ type: "scan",
5746
+ target: targetPath,
5747
+ findings: structured,
5748
+ severity_summary: counts,
5749
+ 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`
5750
+ };
4527
5751
  }
4528
- function isValidVersion(version) {
4529
- return /^[0-9a-zA-Z._\-+]{1,50}$/.test(version);
5752
+ function failedResult(targetPath, message) {
5753
+ return {
5754
+ type: "scan",
5755
+ target: targetPath,
5756
+ findings: [],
5757
+ severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
5758
+ summary: `Scan failed: ${message}`,
5759
+ error: message
5760
+ };
4530
5761
  }
4531
- async function queryOsv(name, version, ecosystem) {
4532
- if (!isValidPackageName(name) || !isValidVersion(version)) return [];
5762
+ async function runScan(targetPath) {
4533
5763
  try {
4534
- const res = await fetch("https://api.osv.dev/v1/query", {
4535
- method: "POST",
4536
- headers: { "Content-Type": "application/json" },
4537
- body: JSON.stringify({ package: { name, ecosystem }, version }),
4538
- signal: AbortSignal.timeout(5e3)
4539
- });
4540
- if (!res.ok) return [];
4541
- const data = await res.json();
4542
- return data.vulns || [];
4543
- } catch {
4544
- return [];
5764
+ const report = scanPath(targetPath);
5765
+ const findings = [...report.findings, ...await scanDependencies(targetPath)];
5766
+ return toRunResult(targetPath, findings, report.filesScanned);
5767
+ } catch (err) {
5768
+ return failedResult(targetPath, err.message);
4545
5769
  }
4546
5770
  }
4547
5771
 
@@ -4955,8 +6179,8 @@ var RuleEngine = class {
4955
6179
  };
4956
6180
 
4957
6181
  // src/daemon/rules/loader.ts
4958
- var import_node_fs11 = require("fs");
4959
- var import_node_path6 = require("path");
6182
+ var import_node_fs13 = require("fs");
6183
+ var import_node_path9 = require("path");
4960
6184
 
4961
6185
  // src/daemon/rules/default-rules.ts
4962
6186
  var DEFAULT_RULES = [
@@ -5240,11 +6464,11 @@ var RULES_DIR = "/etc/threatcrush/rules.d";
5240
6464
  function loadAllRules(customDir) {
5241
6465
  const rules = [...DEFAULT_RULES];
5242
6466
  const dir = customDir || RULES_DIR;
5243
- if ((0, import_node_fs11.existsSync)(dir)) {
5244
- const files = (0, import_node_fs11.readdirSync)(dir).filter((f) => f.endsWith(".json"));
6467
+ if ((0, import_node_fs13.existsSync)(dir)) {
6468
+ const files = (0, import_node_fs13.readdirSync)(dir).filter((f) => f.endsWith(".json"));
5245
6469
  for (const file of files) {
5246
6470
  try {
5247
- const raw = (0, import_node_fs11.readFileSync)((0, import_node_path6.join)(dir, file), "utf-8");
6471
+ const raw = (0, import_node_fs13.readFileSync)((0, import_node_path9.join)(dir, file), "utf-8");
5248
6472
  const parsed = JSON.parse(raw);
5249
6473
  const customRules = Array.isArray(parsed) ? parsed : [parsed];
5250
6474
  for (const rule of customRules) {
@@ -5269,6 +6493,12 @@ function loadAllRules(customDir) {
5269
6493
 
5270
6494
  // src/daemon/firewall/adapters.ts
5271
6495
  var import_node_child_process3 = require("child_process");
6496
+ var import_node_net2 = require("net");
6497
+ function assertValidFirewallIp(ip) {
6498
+ if ((0, import_node_net2.isIP)(ip) !== 4) {
6499
+ throw new Error(`Invalid IPv4 address: ${ip}`);
6500
+ }
6501
+ }
5272
6502
  var NftablesAdapter = class {
5273
6503
  name = "nftables";
5274
6504
  table = "threatcrush";
@@ -5288,16 +6518,19 @@ var NftablesAdapter = class {
5288
6518
  }
5289
6519
  }
5290
6520
  async block(ip) {
6521
+ assertValidFirewallIp(ip);
5291
6522
  this.ensureSetup();
5292
6523
  (0, import_node_child_process3.execSync)(`nft add element inet ${this.table} ${this.set} '{ ${ip} }'`);
5293
6524
  }
5294
6525
  async unblock(ip) {
6526
+ assertValidFirewallIp(ip);
5295
6527
  try {
5296
6528
  (0, import_node_child_process3.execSync)(`nft delete element inet ${this.table} ${this.set} '{ ${ip} }'`);
5297
6529
  } catch {
5298
6530
  }
5299
6531
  }
5300
6532
  async isBlocked(ip) {
6533
+ assertValidFirewallIp(ip);
5301
6534
  try {
5302
6535
  const output = (0, import_node_child_process3.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
5303
6536
  return output.includes(ip);
@@ -5332,17 +6565,20 @@ var IptablesAdapter = class {
5332
6565
  }
5333
6566
  }
5334
6567
  async block(ip) {
6568
+ assertValidFirewallIp(ip);
5335
6569
  this.ensureChain();
5336
6570
  if (await this.isBlocked(ip)) return;
5337
6571
  (0, import_node_child_process3.execSync)(`iptables -A ${this.chain} -s ${ip} -j DROP`);
5338
6572
  }
5339
6573
  async unblock(ip) {
6574
+ assertValidFirewallIp(ip);
5340
6575
  try {
5341
6576
  (0, import_node_child_process3.execSync)(`iptables -D ${this.chain} -s ${ip} -j DROP`);
5342
6577
  } catch {
5343
6578
  }
5344
6579
  }
5345
6580
  async isBlocked(ip) {
6581
+ assertValidFirewallIp(ip);
5346
6582
  try {
5347
6583
  const output = (0, import_node_child_process3.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
5348
6584
  return output.includes(ip);
@@ -5371,12 +6607,15 @@ var DryRunAdapter = class {
5371
6607
  return true;
5372
6608
  }
5373
6609
  async block(ip) {
6610
+ assertValidFirewallIp(ip);
5374
6611
  this.blocked.add(ip);
5375
6612
  }
5376
6613
  async unblock(ip) {
6614
+ assertValidFirewallIp(ip);
5377
6615
  this.blocked.delete(ip);
5378
6616
  }
5379
6617
  async isBlocked(ip) {
6618
+ assertValidFirewallIp(ip);
5380
6619
  return this.blocked.has(ip);
5381
6620
  }
5382
6621
  async listBlocked() {
@@ -5392,7 +6631,7 @@ function detectFirewallAdapter() {
5392
6631
  }
5393
6632
 
5394
6633
  // src/daemon/firewall/remediation.ts
5395
- var import_node_fs12 = require("fs");
6634
+ var import_node_fs14 = require("fs");
5396
6635
  var DEFAULT_CONFIG2 = {
5397
6636
  enabled: true,
5398
6637
  dry_run: true,
@@ -5547,7 +6786,7 @@ var RemediationManager = class {
5547
6786
  }
5548
6787
  logLine(line) {
5549
6788
  try {
5550
- (0, import_node_fs12.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
6789
+ (0, import_node_fs14.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
5551
6790
  `);
5552
6791
  } catch {
5553
6792
  }
@@ -5601,9 +6840,9 @@ async function flushTelemetry(timeoutMs = 2e3) {
5601
6840
  }
5602
6841
 
5603
6842
  // src/daemon/index.ts
5604
- function readVersion() {
6843
+ function readVersion2() {
5605
6844
  try {
5606
- const pkg = JSON.parse((0, import_node_fs13.readFileSync)((0, import_node_path7.join)(__dirname, "..", "package.json"), "utf-8"));
6845
+ const pkg = JSON.parse((0, import_node_fs15.readFileSync)((0, import_node_path10.join)(__dirname, "..", "package.json"), "utf-8"));
5607
6846
  return pkg.version || "0.0.0";
5608
6847
  } catch {
5609
6848
  return "0.0.0";
@@ -5611,7 +6850,7 @@ function readVersion() {
5611
6850
  }
5612
6851
  function logLine(line) {
5613
6852
  try {
5614
- (0, import_node_fs13.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
6853
+ (0, import_node_fs15.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
5615
6854
  `);
5616
6855
  } catch {
5617
6856
  }
@@ -5632,14 +6871,14 @@ async function runDaemon() {
5632
6871
  logLine(`[daemon] unhandledRejection: ${String(reason)}`);
5633
6872
  captureException(reason);
5634
6873
  });
5635
- const version = readVersion();
6874
+ const version = readVersion2();
5636
6875
  logLine(`[daemon] starting threatcrushd v${version} mode=${PATHS.mode}`);
5637
6876
  try {
5638
6877
  initStateDB(PATHS.stateDb);
5639
6878
  } catch (err) {
5640
6879
  logLine(`[daemon] state db unavailable: ${err.message}`);
5641
6880
  }
5642
- const config = loadConfig((0, import_node_fs13.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
6881
+ const config = loadConfig((0, import_node_fs15.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
5643
6882
  bus.on("event", (event) => {
5644
6883
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
5645
6884
  });