@profullstack/threatcrush 0.7.2 → 0.9.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
@@ -9998,6 +9998,56 @@ var CODE_RULES = [
9998
9998
  severity: "high",
9999
9999
  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
10000
10000
  },
10001
+ // ── Weak crypto: Python ──────────────────────────────────────────────────
10002
+ //
10003
+ // The generic rules above catch the credential case on the matched line.
10004
+ // These cover what they miss in Python, where the security role of a value is
10005
+ // set by the enclosing function rather than a same-line assignment — a
10006
+ // `random`-drawn token that is *returned*, an MD5 used to *verify* an
10007
+ // artifact — and the broken ciphers, which have no safe use at all.
10008
+ {
10009
+ id: "py-broken-cipher",
10010
+ title: "broken cipher or ECB mode",
10011
+ consequence: "DES, RC2, RC4 and Blowfish are broken or too small to rely on, and ECB encrypts identical plaintext blocks to identical ciphertext, so structure in the data survives encryption. None of them provides the confidentiality their use implies.",
10012
+ cwe: "CWE-327",
10013
+ severity: "high",
10014
+ languages: ["python"],
10015
+ // PyCryptodome/PyCrypto constructors. The mode matters only for AES, whose
10016
+ // safe modes (GCM, CTR, CBC) are common — so AES matches solely on ECB,
10017
+ // while DES/RC4/Blowfish are broken by the algorithm regardless of mode.
10018
+ pattern: /\b(?:DES|DES3|ARC2|RC2|ARC4|RC4|Blowfish|XOR)\s*\.\s*new\s*\(|\bAES\s*\.\s*new\s*\([^)\n]*\bMODE_ECB\b/,
10019
+ inherent: true,
10020
+ guard: false
10021
+ },
10022
+ {
10023
+ id: "py-weak-hash",
10024
+ title: "broken hash algorithm",
10025
+ consequence: "MD5 and SHA-1 have practical collisions, so a digest used for integrity or a signature can be forged to match a value the code trusts.",
10026
+ cwe: "CWE-327",
10027
+ severity: "medium",
10028
+ languages: ["python"],
10029
+ pattern: /\bhashlib\s*\.\s*(?:md5|sha1)\s*\(/,
10030
+ // Python 3.9+ marks a non-security digest — a cache key, an ETag — with
10031
+ // `usedforsecurity=False`, which is exactly the "this MD5 is not a security
10032
+ // claim" signal, so it exempts the line rather than being flagged.
10033
+ lineGuard: /usedforsecurity\s*=\s*False/
10034
+ },
10035
+ {
10036
+ id: "py-predictable-random-seed",
10037
+ title: "PRNG seeded from a predictable value",
10038
+ consequence: "Seeding `random` from the clock or the process id makes its whole sequence reproducible, so anything drawn from it afterwards \u2014 a token, an id, a shuffle \u2014 can be regenerated by guessing the seed.",
10039
+ cwe: "CWE-338",
10040
+ severity: "high",
10041
+ languages: ["python"],
10042
+ // A time- or pid-derived seed, which is the predictable kind. A fixed
10043
+ // integer seed (`random.seed(42)`) is deliberate reproducibility for tests
10044
+ // and simulations, so it is left alone. This needs no credential context:
10045
+ // seeding the global PRNG from the clock is a weakness on its own terms,
10046
+ // which is why the enclosing function's name — that this engine does not
10047
+ // read as evidence anyway — is not consulted.
10048
+ pattern: /\brandom\s*\.\s*seed\s*\([^)\n]*(?:time\s*\.\s*time|datetime|\.\s*now\s*\(|getpid)/,
10049
+ inherent: true
10050
+ },
10001
10051
  {
10002
10052
  id: "redos-nested-quantifier",
10003
10053
  title: "regex with nested unbounded quantifiers",
@@ -11141,6 +11191,81 @@ function meetsFailThreshold(findings, threshold) {
11141
11191
  // ../../packages/scan/src/node/walk.ts
11142
11192
  var import_node_fs5 = require("fs");
11143
11193
  var import_node_path2 = require("path");
11194
+ function compileExcludes(patterns) {
11195
+ const matchers = patterns.map((p) => p.trim()).filter((p) => p.length > 0 && !p.startsWith("#")).map(compilePattern);
11196
+ if (matchers.length === 0) return () => false;
11197
+ return (relPath) => {
11198
+ const segs = relPath.split("/");
11199
+ return matchers.some((m) => m(segs));
11200
+ };
11201
+ }
11202
+ var GLOBSTAR = /* @__PURE__ */ Symbol("globstar");
11203
+ function compilePattern(pattern) {
11204
+ const withoutLead = pattern.replace(/^\.?\//, "");
11205
+ let end = withoutLead.length;
11206
+ while (end > 0 && withoutLead[end - 1] === "/") end -= 1;
11207
+ const p = withoutLead.slice(0, end);
11208
+ if (!p.includes("/")) {
11209
+ if (p === "**") return () => true;
11210
+ const rx = segToRegExp(p);
11211
+ return (segs) => segs.some((s) => rx.test(s));
11212
+ }
11213
+ const raw = p.split("/");
11214
+ const trailingGlobstar = raw[raw.length - 1] === "**";
11215
+ const core = trailingGlobstar ? raw.slice(0, -1) : raw;
11216
+ const parts = core.map((s) => s === "**" ? GLOBSTAR : segToRegExp(s));
11217
+ return (segs) => {
11218
+ const end2 = matchPrefix(parts, segs);
11219
+ if (end2 < 0) return false;
11220
+ return trailingGlobstar ? end2 < segs.length : true;
11221
+ };
11222
+ }
11223
+ function segToRegExp(seg) {
11224
+ let body = "";
11225
+ for (let i = 0; i < seg.length; i += 1) {
11226
+ const c = seg[i];
11227
+ if (c === "*") {
11228
+ while (seg[i + 1] === "*") i += 1;
11229
+ body += "[^/]*";
11230
+ } else if (c === "?") {
11231
+ body += "[^/]";
11232
+ } else {
11233
+ body += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
11234
+ }
11235
+ }
11236
+ return new RegExp(`^${body}$`);
11237
+ }
11238
+ function matchPrefix(parts, segs) {
11239
+ let pi = 0;
11240
+ let si = 0;
11241
+ let star = -1;
11242
+ let starSi = -1;
11243
+ while (pi < parts.length) {
11244
+ const part = parts[pi];
11245
+ if (part === GLOBSTAR) {
11246
+ star = pi;
11247
+ starSi = si;
11248
+ pi += 1;
11249
+ } else if (si < segs.length && part.test(segs[si])) {
11250
+ pi += 1;
11251
+ si += 1;
11252
+ } else if (star !== -1 && starSi < segs.length) {
11253
+ starSi += 1;
11254
+ si = starSi;
11255
+ pi = star + 1;
11256
+ } else {
11257
+ return -1;
11258
+ }
11259
+ }
11260
+ return si;
11261
+ }
11262
+ function readIgnoreFile(root) {
11263
+ try {
11264
+ return (0, import_node_fs5.readFileSync)((0, import_node_path2.join)(root, ".threatcrushignore"), "utf-8").split("\n");
11265
+ } catch {
11266
+ return [];
11267
+ }
11268
+ }
11144
11269
  function scanPath(targetPath, options = {}) {
11145
11270
  const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
11146
11271
  const allowed = options.categories ? new Set(options.categories) : null;
@@ -11148,6 +11273,7 @@ function scanPath(targetPath, options = {}) {
11148
11273
  const unreadable = [];
11149
11274
  let filesScanned = 0;
11150
11275
  let suppressed = 0;
11276
+ let excluded = 0;
11151
11277
  const rootIsDirectory = (() => {
11152
11278
  try {
11153
11279
  return (0, import_node_fs5.statSync)(targetPath).isDirectory();
@@ -11156,6 +11282,7 @@ function scanPath(targetPath, options = {}) {
11156
11282
  }
11157
11283
  })();
11158
11284
  const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path2.dirname)(targetPath);
11285
+ const isExcluded = compileExcludes([...options.exclude ?? [], ...readIgnoreFile(walkRoot)]);
11159
11286
  const scanFile = (fullPath, filename) => {
11160
11287
  const relativePath = toRelative(walkRoot, fullPath);
11161
11288
  const extension = (0, import_node_path2.extname)(filename).toLowerCase();
@@ -11213,17 +11340,28 @@ function scanPath(targetPath, options = {}) {
11213
11340
  }
11214
11341
  for (const entry of entries) {
11215
11342
  const fullPath = (0, import_node_path2.join)(currentPath, entry.name);
11343
+ const relativePath = toRelative(walkRoot, fullPath);
11216
11344
  if (entry.isDirectory()) {
11217
11345
  if (SKIP_DIRS.has(entry.name)) continue;
11346
+ if (isExcluded(relativePath)) {
11347
+ excluded += 1;
11348
+ continue;
11349
+ }
11218
11350
  walk(fullPath);
11219
11351
  continue;
11220
11352
  }
11221
11353
  if (!entry.isFile()) continue;
11354
+ if (isExcluded(relativePath)) {
11355
+ excluded += 1;
11356
+ continue;
11357
+ }
11222
11358
  scanFile(fullPath, entry.name);
11223
11359
  }
11224
11360
  };
11225
11361
  if (rootIsDirectory) {
11226
11362
  walk(targetPath);
11363
+ } else if (isExcluded(toRelative(walkRoot, targetPath))) {
11364
+ excluded += 1;
11227
11365
  } else {
11228
11366
  scanFile(targetPath, (0, import_node_path2.basename)(targetPath));
11229
11367
  }
@@ -11231,7 +11369,7 @@ function scanPath(targetPath, options = {}) {
11231
11369
  filtered.sort(
11232
11370
  (a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
11233
11371
  );
11234
- return { findings: filtered, filesScanned, unreadable, suppressed, root: walkRoot };
11372
+ return { findings: filtered, filesScanned, unreadable, suppressed, excluded, root: walkRoot };
11235
11373
  }
11236
11374
  function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
11237
11375
  if (fileFindings.length > 0) return;
@@ -11580,6 +11718,7 @@ async function scanCommand(targetPath, options = {}) {
11580
11718
  try {
11581
11719
  let seen = 0;
11582
11720
  const report = scanPath(targetPath, {
11721
+ exclude: options.exclude,
11583
11722
  onFile: () => {
11584
11723
  seen += 1;
11585
11724
  if (spinner) spinner.text = `Scanning files... (${seen} files)`;
@@ -11595,6 +11734,7 @@ async function scanCommand(targetPath, options = {}) {
11595
11734
  filesScanned: report.filesScanned,
11596
11735
  unreadable: report.unreadable,
11597
11736
  suppressed: report.suppressed,
11737
+ excluded: report.excluded,
11598
11738
  root: report.root
11599
11739
  };
11600
11740
  } catch (err) {
@@ -11614,6 +11754,13 @@ async function scanCommand(targetPath, options = {}) {
11614
11754
  for (const path of outcome.unreadable) say(source_default.gray(` ${path}`));
11615
11755
  }
11616
11756
  }
11757
+ if (outcome.excluded > 0) {
11758
+ say(
11759
+ source_default.gray(
11760
+ ` \xB7 ${outcome.excluded} path(s) excluded by --exclude or .threatcrushignore`
11761
+ )
11762
+ );
11763
+ }
11617
11764
  if (outcome.suppressed > 0) {
11618
11765
  say(
11619
11766
  source_default.gray(
@@ -17245,7 +17392,12 @@ program2.command("scan").description("Scan codebase for vulnerabilities and secr
17245
17392
  ).option(
17246
17393
  "--path-prefix <prefix>",
17247
17394
  "prepend this to SARIF file URIs \u2014 use when the scan root is not the repository root"
17248
- ).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) => {
17395
+ ).option("--deps", "also query OSV.dev for advisories against lockfile versions (network)").option(
17396
+ "--exclude <glob>",
17397
+ "skip paths matching this glob (repeatable); merged with a .threatcrushignore at the scan root",
17398
+ (value, previous) => [...previous, value],
17399
+ []
17400
+ ).option("-v, --verbose", "list the paths that could not be read").action(async (targetPath, opts) => {
17249
17401
  const format = (opts.format ?? "text").toLowerCase();
17250
17402
  if (!["text", "json", "sarif"].includes(format)) {
17251
17403
  console.error(source_default.red(`Unknown --format "${opts.format}" (expected text, json, or sarif)`));
@@ -17264,6 +17416,7 @@ program2.command("scan").description("Scan codebase for vulnerabilities and secr
17264
17416
  failOn,
17265
17417
  pathPrefix: opts.pathPrefix,
17266
17418
  dependencies: opts.deps,
17419
+ exclude: opts.exclude,
17267
17420
  verbose: opts.verbose
17268
17421
  });
17269
17422
  });