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