cognium-dev 3.132.0 → 3.134.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/cli.js +748 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -22610,6 +22610,12 @@ class LanguageSourcesPass {
|
|
|
22610
22610
|
for (const finding of findGoLocationHeaderOpenRedirectFindings(code, graph.ir.meta.file)) {
|
|
22611
22611
|
ctx.addFinding(finding);
|
|
22612
22612
|
}
|
|
22613
|
+
for (const finding of findGoPluginOpenCodeInjectionFindings(code, graph.ir.meta.file)) {
|
|
22614
|
+
ctx.addFinding(finding);
|
|
22615
|
+
}
|
|
22616
|
+
for (const finding of findGoMongoNosqlInjectionFindings(code, graph.ir.meta.file)) {
|
|
22617
|
+
ctx.addFinding(finding);
|
|
22618
|
+
}
|
|
22613
22619
|
}
|
|
22614
22620
|
if (language === "python") {
|
|
22615
22621
|
additionalSanitizers.push(...findPythonNetlocAllowlistGuardSanitizers(code));
|
|
@@ -22631,6 +22637,12 @@ class LanguageSourcesPass {
|
|
|
22631
22637
|
for (const finding of findPythonHeadersSubscriptOpenRedirectFindings(code, graph.ir.meta.file)) {
|
|
22632
22638
|
ctx.addFinding(finding);
|
|
22633
22639
|
}
|
|
22640
|
+
for (const finding of findPythonInteractiveInterpreterCodeInjectionFindings(code, graph.ir.meta.file)) {
|
|
22641
|
+
ctx.addFinding(finding);
|
|
22642
|
+
}
|
|
22643
|
+
for (const finding of findPythonMongoengineWhereNosqlInjectionFindings(code, graph.ir.meta.file)) {
|
|
22644
|
+
ctx.addFinding(finding);
|
|
22645
|
+
}
|
|
22634
22646
|
}
|
|
22635
22647
|
if (language === "rust") {
|
|
22636
22648
|
additionalSanitizers.push(...findRustSetAllowlistGuardSanitizers(code));
|
|
@@ -22655,6 +22667,9 @@ class LanguageSourcesPass {
|
|
|
22655
22667
|
for (const finding of findRustAppendHeaderTupleOpenRedirectFindings(code, graph.ir.meta.file)) {
|
|
22656
22668
|
ctx.addFinding(finding);
|
|
22657
22669
|
}
|
|
22670
|
+
for (const finding of findRustEvalCrateCodeInjectionFindings(code, graph.ir.meta.file)) {
|
|
22671
|
+
ctx.addFinding(finding);
|
|
22672
|
+
}
|
|
22658
22673
|
}
|
|
22659
22674
|
if (language === "javascript" || language === "typescript" || language === "htmljs") {
|
|
22660
22675
|
additionalSanitizers.push(...findJsSafeJsonParseSanitizers(code));
|
|
@@ -22676,6 +22691,9 @@ class LanguageSourcesPass {
|
|
|
22676
22691
|
for (const finding of findJsDomOpenRedirectFindings(code, graph.ir.meta.file)) {
|
|
22677
22692
|
ctx.addFinding(finding);
|
|
22678
22693
|
}
|
|
22694
|
+
for (const finding of findJsIndirectEvalCodeInjectionFindings(code, graph.ir.meta.file)) {
|
|
22695
|
+
ctx.addFinding(finding);
|
|
22696
|
+
}
|
|
22679
22697
|
}
|
|
22680
22698
|
if (language === "java") {
|
|
22681
22699
|
additionalSanitizers.push(...findJavaSafeJsonParseSanitizers(code));
|
|
@@ -22688,6 +22706,9 @@ class LanguageSourcesPass {
|
|
|
22688
22706
|
for (const finding of findJavaResponseWriterXssFindings(code, graph.ir.meta.file)) {
|
|
22689
22707
|
ctx.addFinding(finding);
|
|
22690
22708
|
}
|
|
22709
|
+
for (const finding of findJavaMongoNosqlInjectionFindings(code, graph.ir.meta.file)) {
|
|
22710
|
+
ctx.addFinding(finding);
|
|
22711
|
+
}
|
|
22691
22712
|
}
|
|
22692
22713
|
if (language === "python" || language === "javascript" || language === "typescript" || language === "go") {
|
|
22693
22714
|
const exfilFindings = findExternalSecretExfiltrationFindings(code, graph.ir.meta.file, language);
|
|
@@ -26202,6 +26223,732 @@ function findJsDomOpenRedirectFindings(code, file) {
|
|
|
26202
26223
|
}
|
|
26203
26224
|
return out2;
|
|
26204
26225
|
}
|
|
26226
|
+
function findGoPluginOpenCodeInjectionFindings(code, file) {
|
|
26227
|
+
const findings = [];
|
|
26228
|
+
if (typeof code !== "string" || code.length === 0)
|
|
26229
|
+
return findings;
|
|
26230
|
+
if (!/\bplugin\s*\.\s*(?:Open|Lookup)\s*\(/.test(code))
|
|
26231
|
+
return findings;
|
|
26232
|
+
const lines = code.split(`
|
|
26233
|
+
`);
|
|
26234
|
+
const reqExtractRe = /\b\w+\s*\.\s*(?:FormValue|PostFormValue|URL\.Query\(\)\.Get|Header\.Get|Cookie)\s*\(/;
|
|
26235
|
+
const httpReqParamRe = /\*\s*http\.Request\b/;
|
|
26236
|
+
const callRe = /\bplugin\s*\.\s*(?:Open|Lookup)\s*\(\s*([^)]*)\s*\)/;
|
|
26237
|
+
const sinkLabel = (op) => op === "Lookup" ? "Go plugin.Lookup" : "Go plugin.Open";
|
|
26238
|
+
const funcs = [];
|
|
26239
|
+
let cur = null;
|
|
26240
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26241
|
+
const t = lines[i2].trim();
|
|
26242
|
+
if (/^func\b/.test(t)) {
|
|
26243
|
+
if (cur) {
|
|
26244
|
+
cur.end = i2 - 1;
|
|
26245
|
+
funcs.push(cur);
|
|
26246
|
+
}
|
|
26247
|
+
cur = { start: i2, end: lines.length - 1 };
|
|
26248
|
+
}
|
|
26249
|
+
}
|
|
26250
|
+
if (cur)
|
|
26251
|
+
funcs.push(cur);
|
|
26252
|
+
for (const fn of funcs) {
|
|
26253
|
+
const header = lines[fn.start];
|
|
26254
|
+
if (!httpReqParamRe.test(header))
|
|
26255
|
+
continue;
|
|
26256
|
+
const taintedVars = new Set;
|
|
26257
|
+
for (let pass = 0;pass < 3; pass++) {
|
|
26258
|
+
const before = taintedVars.size;
|
|
26259
|
+
for (let i2 = fn.start;i2 <= fn.end; i2++) {
|
|
26260
|
+
const line = lines[i2];
|
|
26261
|
+
const trimmed = line.trim();
|
|
26262
|
+
const assignMatch = trimmed.match(/^(\w+)\s*(?::=|=)\s*(.+?)(?:\s*\/\/.*)?$/);
|
|
26263
|
+
if (!assignMatch)
|
|
26264
|
+
continue;
|
|
26265
|
+
const lhs = assignMatch[1];
|
|
26266
|
+
const rhs = assignMatch[2];
|
|
26267
|
+
if (taintedVars.has(lhs))
|
|
26268
|
+
continue;
|
|
26269
|
+
if (reqExtractRe.test(rhs)) {
|
|
26270
|
+
taintedVars.add(lhs);
|
|
26271
|
+
continue;
|
|
26272
|
+
}
|
|
26273
|
+
for (const v of taintedVars) {
|
|
26274
|
+
const re = new RegExp(`\\b${v}\\b`);
|
|
26275
|
+
if (re.test(rhs)) {
|
|
26276
|
+
taintedVars.add(lhs);
|
|
26277
|
+
break;
|
|
26278
|
+
}
|
|
26279
|
+
}
|
|
26280
|
+
}
|
|
26281
|
+
if (taintedVars.size === before)
|
|
26282
|
+
break;
|
|
26283
|
+
}
|
|
26284
|
+
if (taintedVars.size === 0)
|
|
26285
|
+
continue;
|
|
26286
|
+
for (let i2 = fn.start;i2 <= fn.end; i2++) {
|
|
26287
|
+
const line = lines[i2];
|
|
26288
|
+
const m = line.match(callRe);
|
|
26289
|
+
if (!m)
|
|
26290
|
+
continue;
|
|
26291
|
+
const arg = m[1].trim();
|
|
26292
|
+
if (arg.length === 0)
|
|
26293
|
+
continue;
|
|
26294
|
+
if (/^"[^"]*"$/.test(arg))
|
|
26295
|
+
continue;
|
|
26296
|
+
let tainted = false;
|
|
26297
|
+
if (reqExtractRe.test(arg))
|
|
26298
|
+
tainted = true;
|
|
26299
|
+
else {
|
|
26300
|
+
for (const v of taintedVars) {
|
|
26301
|
+
const re = new RegExp(`\\b${v}\\b`);
|
|
26302
|
+
if (re.test(arg)) {
|
|
26303
|
+
tainted = true;
|
|
26304
|
+
break;
|
|
26305
|
+
}
|
|
26306
|
+
}
|
|
26307
|
+
}
|
|
26308
|
+
if (!tainted)
|
|
26309
|
+
continue;
|
|
26310
|
+
const op = /\bplugin\s*\.\s*Lookup\b/.test(line) ? "Lookup" : "Open";
|
|
26311
|
+
findings.push({
|
|
26312
|
+
id: `code_injection-${file}-${i2 + 1}-go-plugin-${op.toLowerCase()}`,
|
|
26313
|
+
pass: "language-sources",
|
|
26314
|
+
category: "security",
|
|
26315
|
+
rule_id: "code_injection",
|
|
26316
|
+
cwe: "CWE-94",
|
|
26317
|
+
severity: "critical",
|
|
26318
|
+
level: "error",
|
|
26319
|
+
message: `Code injection: ${sinkLabel(op)} called with a path/symbol derived ` + "from an *http.Request without an allow-list. Loading a plugin " + "runs its init() and exposes arbitrary exported symbols. Restrict " + "the path to a trusted directory or use a fixed allow-list.",
|
|
26320
|
+
file,
|
|
26321
|
+
line: i2 + 1,
|
|
26322
|
+
snippet: line.trim()
|
|
26323
|
+
});
|
|
26324
|
+
}
|
|
26325
|
+
}
|
|
26326
|
+
return findings;
|
|
26327
|
+
}
|
|
26328
|
+
function findJsIndirectEvalCodeInjectionFindings(code, file) {
|
|
26329
|
+
const findings = [];
|
|
26330
|
+
if (typeof code !== "string" || code.length === 0)
|
|
26331
|
+
return findings;
|
|
26332
|
+
if (!/\beval\b/.test(code))
|
|
26333
|
+
return findings;
|
|
26334
|
+
const lines = code.split(`
|
|
26335
|
+
`);
|
|
26336
|
+
const reqExtractRe = /\breq(?:uest)?\s*\.\s*(?:body|query|params|headers|cookies)\b/;
|
|
26337
|
+
const aliasRe = /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(?:globalThis\s*\.\s*eval|global\s*\.\s*eval|window\s*\.\s*eval|self\s*\.\s*eval|eval)\s*;?\s*$/;
|
|
26338
|
+
const aliases = new Set;
|
|
26339
|
+
for (const line of lines) {
|
|
26340
|
+
const m = line.match(aliasRe);
|
|
26341
|
+
if (m)
|
|
26342
|
+
aliases.add(m[1]);
|
|
26343
|
+
}
|
|
26344
|
+
const taintedVars = new Set;
|
|
26345
|
+
const assignRe = /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(.+?);?\s*$/;
|
|
26346
|
+
const reassignRe = /^\s*(\w+)\s*=\s*(.+?);?\s*$/;
|
|
26347
|
+
for (let pass = 0;pass < 3; pass++) {
|
|
26348
|
+
const before = taintedVars.size;
|
|
26349
|
+
for (const line of lines) {
|
|
26350
|
+
const m = line.match(assignRe) || line.match(reassignRe);
|
|
26351
|
+
if (!m)
|
|
26352
|
+
continue;
|
|
26353
|
+
const lhs = m[1];
|
|
26354
|
+
const rhs = m[2];
|
|
26355
|
+
if (taintedVars.has(lhs))
|
|
26356
|
+
continue;
|
|
26357
|
+
if (lhs === "const" || lhs === "let" || lhs === "var")
|
|
26358
|
+
continue;
|
|
26359
|
+
if (reqExtractRe.test(rhs)) {
|
|
26360
|
+
taintedVars.add(lhs);
|
|
26361
|
+
continue;
|
|
26362
|
+
}
|
|
26363
|
+
for (const v of taintedVars) {
|
|
26364
|
+
const re = new RegExp(`\\b${v}\\b`);
|
|
26365
|
+
if (re.test(rhs)) {
|
|
26366
|
+
taintedVars.add(lhs);
|
|
26367
|
+
break;
|
|
26368
|
+
}
|
|
26369
|
+
}
|
|
26370
|
+
}
|
|
26371
|
+
if (taintedVars.size === before)
|
|
26372
|
+
break;
|
|
26373
|
+
}
|
|
26374
|
+
const indirectCommaRe = /\(\s*0\s*,\s*eval\s*\)\s*\(\s*([^)]*)\s*\)/;
|
|
26375
|
+
const indirectMemberRe = /\b(?:globalThis|global|window|self)\s*\.\s*eval\s*\(\s*([^)]*)\s*\)/;
|
|
26376
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26377
|
+
const line = lines[i2];
|
|
26378
|
+
const trimmed = line.trim();
|
|
26379
|
+
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
26380
|
+
continue;
|
|
26381
|
+
if (aliasRe.test(line))
|
|
26382
|
+
continue;
|
|
26383
|
+
let arg = null;
|
|
26384
|
+
let formLabel = "";
|
|
26385
|
+
let m = trimmed.match(indirectCommaRe);
|
|
26386
|
+
if (m) {
|
|
26387
|
+
arg = m[1].trim();
|
|
26388
|
+
formLabel = "(0, eval)(...) indirect eval";
|
|
26389
|
+
}
|
|
26390
|
+
if (!arg) {
|
|
26391
|
+
m = trimmed.match(indirectMemberRe);
|
|
26392
|
+
if (m) {
|
|
26393
|
+
arg = m[1].trim();
|
|
26394
|
+
formLabel = "globalThis.eval / window.eval / self.eval indirect eval";
|
|
26395
|
+
}
|
|
26396
|
+
}
|
|
26397
|
+
if (!arg && aliases.size > 0) {
|
|
26398
|
+
for (const a of aliases) {
|
|
26399
|
+
const aliasCallRe = new RegExp(`\\b${a}\\s*\\(\\s*([^)]*)\\s*\\)`);
|
|
26400
|
+
const mm = trimmed.match(aliasCallRe);
|
|
26401
|
+
if (mm) {
|
|
26402
|
+
arg = mm[1].trim();
|
|
26403
|
+
formLabel = `aliased eval reference \`${a}(...)\``;
|
|
26404
|
+
break;
|
|
26405
|
+
}
|
|
26406
|
+
}
|
|
26407
|
+
}
|
|
26408
|
+
if (arg === null)
|
|
26409
|
+
continue;
|
|
26410
|
+
if (arg.length === 0)
|
|
26411
|
+
continue;
|
|
26412
|
+
if (/^['"`][^'"`]*['"`]$/.test(arg))
|
|
26413
|
+
continue;
|
|
26414
|
+
let tainted = false;
|
|
26415
|
+
if (reqExtractRe.test(arg))
|
|
26416
|
+
tainted = true;
|
|
26417
|
+
else {
|
|
26418
|
+
for (const v of taintedVars) {
|
|
26419
|
+
const re = new RegExp(`\\b${v}\\b`);
|
|
26420
|
+
if (re.test(arg)) {
|
|
26421
|
+
tainted = true;
|
|
26422
|
+
break;
|
|
26423
|
+
}
|
|
26424
|
+
}
|
|
26425
|
+
}
|
|
26426
|
+
if (!tainted)
|
|
26427
|
+
continue;
|
|
26428
|
+
findings.push({
|
|
26429
|
+
id: `code_injection-${file}-${i2 + 1}-js-indirect-eval`,
|
|
26430
|
+
pass: "language-sources",
|
|
26431
|
+
category: "security",
|
|
26432
|
+
rule_id: "code_injection",
|
|
26433
|
+
cwe: "CWE-94",
|
|
26434
|
+
severity: "critical",
|
|
26435
|
+
level: "error",
|
|
26436
|
+
message: `Code injection: ${formLabel} called with a value derived from ` + "an HTTP request body/query/headers. Indirect eval forms still " + "execute arbitrary code in the global scope. Remove the eval and " + "parse the input with a safe data parser instead.",
|
|
26437
|
+
file,
|
|
26438
|
+
line: i2 + 1,
|
|
26439
|
+
snippet: trimmed
|
|
26440
|
+
});
|
|
26441
|
+
}
|
|
26442
|
+
return findings;
|
|
26443
|
+
}
|
|
26444
|
+
function findPythonInteractiveInterpreterCodeInjectionFindings(code, file) {
|
|
26445
|
+
const findings = [];
|
|
26446
|
+
if (typeof code !== "string" || code.length === 0)
|
|
26447
|
+
return findings;
|
|
26448
|
+
if (!/^\s*import\s+code\b/m.test(code))
|
|
26449
|
+
return findings;
|
|
26450
|
+
if (!/\bcode\s*\.\s*(?:InteractiveInterpreter|InteractiveConsole|compile_command)\b/.test(code)) {
|
|
26451
|
+
return findings;
|
|
26452
|
+
}
|
|
26453
|
+
const lines = code.split(`
|
|
26454
|
+
`);
|
|
26455
|
+
const reqExtractRe = /\brequest\s*\.\s*(?:args|form|values|files|json|cookies|headers|get_data|get_json)\b/;
|
|
26456
|
+
const taintedVars = new Set;
|
|
26457
|
+
const assignRe = /^\s*(\w+)\s*=\s*(.+?)\s*(?:#.*)?$/;
|
|
26458
|
+
for (let pass = 0;pass < 3; pass++) {
|
|
26459
|
+
const before = taintedVars.size;
|
|
26460
|
+
for (const line of lines) {
|
|
26461
|
+
const m = line.match(assignRe);
|
|
26462
|
+
if (!m)
|
|
26463
|
+
continue;
|
|
26464
|
+
const lhs = m[1];
|
|
26465
|
+
const rhs = m[2];
|
|
26466
|
+
if (taintedVars.has(lhs))
|
|
26467
|
+
continue;
|
|
26468
|
+
if (reqExtractRe.test(rhs)) {
|
|
26469
|
+
taintedVars.add(lhs);
|
|
26470
|
+
continue;
|
|
26471
|
+
}
|
|
26472
|
+
for (const v of taintedVars) {
|
|
26473
|
+
const re = new RegExp(`\\b${v}\\b`);
|
|
26474
|
+
if (re.test(rhs)) {
|
|
26475
|
+
taintedVars.add(lhs);
|
|
26476
|
+
break;
|
|
26477
|
+
}
|
|
26478
|
+
}
|
|
26479
|
+
}
|
|
26480
|
+
if (taintedVars.size === before)
|
|
26481
|
+
break;
|
|
26482
|
+
}
|
|
26483
|
+
const callRe = /\bcode\s*\.\s*(?:InteractiveInterpreter|InteractiveConsole)\s*\([^)]*\)\s*\.\s*(runsource|runcode|push|interact)\s*\(\s*([^),]+)/;
|
|
26484
|
+
const compileRe = /\bcode\s*\.\s*compile_command\s*\(\s*([^),]+)/;
|
|
26485
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26486
|
+
const line = lines[i2];
|
|
26487
|
+
const trimmed = line.trim();
|
|
26488
|
+
let arg = null;
|
|
26489
|
+
let formLabel = "";
|
|
26490
|
+
const m1 = trimmed.match(callRe);
|
|
26491
|
+
if (m1) {
|
|
26492
|
+
arg = m1[2].trim();
|
|
26493
|
+
formLabel = `code.${/Interpreter/.test(trimmed) ? "InteractiveInterpreter" : "InteractiveConsole"}().${m1[1]}`;
|
|
26494
|
+
}
|
|
26495
|
+
if (!arg) {
|
|
26496
|
+
const m2 = trimmed.match(compileRe);
|
|
26497
|
+
if (m2) {
|
|
26498
|
+
arg = m2[1].trim();
|
|
26499
|
+
formLabel = "code.compile_command";
|
|
26500
|
+
}
|
|
26501
|
+
}
|
|
26502
|
+
if (arg === null)
|
|
26503
|
+
continue;
|
|
26504
|
+
if (arg.length === 0)
|
|
26505
|
+
continue;
|
|
26506
|
+
if (/^['"][^'"]*['"]$/.test(arg))
|
|
26507
|
+
continue;
|
|
26508
|
+
let tainted = false;
|
|
26509
|
+
if (reqExtractRe.test(arg))
|
|
26510
|
+
tainted = true;
|
|
26511
|
+
else {
|
|
26512
|
+
for (const v of taintedVars) {
|
|
26513
|
+
const re = new RegExp(`\\b${v}\\b`);
|
|
26514
|
+
if (re.test(arg)) {
|
|
26515
|
+
tainted = true;
|
|
26516
|
+
break;
|
|
26517
|
+
}
|
|
26518
|
+
}
|
|
26519
|
+
}
|
|
26520
|
+
if (!tainted)
|
|
26521
|
+
continue;
|
|
26522
|
+
findings.push({
|
|
26523
|
+
id: `code_injection-${file}-${i2 + 1}-py-interactive-interpreter`,
|
|
26524
|
+
pass: "language-sources",
|
|
26525
|
+
category: "security",
|
|
26526
|
+
rule_id: "code_injection",
|
|
26527
|
+
cwe: "CWE-94",
|
|
26528
|
+
severity: "critical",
|
|
26529
|
+
level: "error",
|
|
26530
|
+
message: `Code injection: ${formLabel}(...) called with a value derived from ` + "a Flask request extractor. The Python `code` module compiles and " + "executes arbitrary source. Remove the call and validate input " + "against a fixed allow-list instead.",
|
|
26531
|
+
file,
|
|
26532
|
+
line: i2 + 1,
|
|
26533
|
+
snippet: trimmed
|
|
26534
|
+
});
|
|
26535
|
+
}
|
|
26536
|
+
return findings;
|
|
26537
|
+
}
|
|
26538
|
+
function findRustEvalCrateCodeInjectionFindings(code, file) {
|
|
26539
|
+
const findings = [];
|
|
26540
|
+
if (typeof code !== "string" || code.length === 0)
|
|
26541
|
+
return findings;
|
|
26542
|
+
if (!/\b(?:evalexpr\s*::\s*eval|libloading\s*::\s*Library\s*::\s*new|\.\s*load\s*\([^)]*\)\s*\.\s*(?:exec|eval|call))/.test(code)) {
|
|
26543
|
+
return findings;
|
|
26544
|
+
}
|
|
26545
|
+
const lines = code.split(`
|
|
26546
|
+
`);
|
|
26547
|
+
const extractorTypeRe = /:\s*(?:String|Bytes|bytes::Bytes|axum::body::Bytes|web::Query\b|web::Path\b|web::Form\b|web::Json\b|HttpRequest\b|actix_web::HttpRequest\b)/;
|
|
26548
|
+
const fns = [];
|
|
26549
|
+
let cur = null;
|
|
26550
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26551
|
+
const t = lines[i2];
|
|
26552
|
+
if (/^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+\s*\(/.test(t)) {
|
|
26553
|
+
if (cur) {
|
|
26554
|
+
cur.end = i2 - 1;
|
|
26555
|
+
fns.push(cur);
|
|
26556
|
+
}
|
|
26557
|
+
cur = { start: i2, end: lines.length - 1, tainted: new Set };
|
|
26558
|
+
const headerJoined = (() => {
|
|
26559
|
+
let j = i2;
|
|
26560
|
+
let s = "";
|
|
26561
|
+
while (j < lines.length && !/\{\s*$/.test(s)) {
|
|
26562
|
+
s += lines[j];
|
|
26563
|
+
if (/\{\s*$/.test(lines[j]))
|
|
26564
|
+
break;
|
|
26565
|
+
j++;
|
|
26566
|
+
if (j - i2 > 12)
|
|
26567
|
+
break;
|
|
26568
|
+
}
|
|
26569
|
+
return s;
|
|
26570
|
+
})();
|
|
26571
|
+
const open = headerJoined.indexOf("(");
|
|
26572
|
+
const close = headerJoined.lastIndexOf(")");
|
|
26573
|
+
if (open !== -1 && close > open) {
|
|
26574
|
+
const params = headerJoined.substring(open + 1, close);
|
|
26575
|
+
let depth = 0;
|
|
26576
|
+
let buf = "";
|
|
26577
|
+
const parts2 = [];
|
|
26578
|
+
for (const ch of params) {
|
|
26579
|
+
if (ch === "<" || ch === "(")
|
|
26580
|
+
depth++;
|
|
26581
|
+
else if (ch === ">" || ch === ")")
|
|
26582
|
+
depth--;
|
|
26583
|
+
if (ch === "," && depth === 0) {
|
|
26584
|
+
parts2.push(buf);
|
|
26585
|
+
buf = "";
|
|
26586
|
+
continue;
|
|
26587
|
+
}
|
|
26588
|
+
buf += ch;
|
|
26589
|
+
}
|
|
26590
|
+
if (buf.trim().length > 0)
|
|
26591
|
+
parts2.push(buf);
|
|
26592
|
+
for (const p of parts2) {
|
|
26593
|
+
const pm = p.match(/(?:mut\s+)?(\w+)\s*:/);
|
|
26594
|
+
if (!pm)
|
|
26595
|
+
continue;
|
|
26596
|
+
if (extractorTypeRe.test(p))
|
|
26597
|
+
cur.tainted.add(pm[1]);
|
|
26598
|
+
}
|
|
26599
|
+
}
|
|
26600
|
+
}
|
|
26601
|
+
}
|
|
26602
|
+
if (cur)
|
|
26603
|
+
fns.push(cur);
|
|
26604
|
+
for (const fn of fns) {
|
|
26605
|
+
for (let pass = 0;pass < 3; pass++) {
|
|
26606
|
+
const before = fn.tainted.size;
|
|
26607
|
+
for (let i2 = fn.start;i2 <= fn.end; i2++) {
|
|
26608
|
+
const t = lines[i2].trim();
|
|
26609
|
+
const m = t.match(/^let\s+(?:mut\s+)?(\w+)\s*(?::\s*[^=]+)?=\s*(.+?);?$/);
|
|
26610
|
+
if (!m)
|
|
26611
|
+
continue;
|
|
26612
|
+
const lhs = m[1];
|
|
26613
|
+
const rhs = m[2];
|
|
26614
|
+
if (fn.tainted.has(lhs))
|
|
26615
|
+
continue;
|
|
26616
|
+
for (const v of fn.tainted) {
|
|
26617
|
+
const re = new RegExp(`\\b${v}\\b`);
|
|
26618
|
+
if (re.test(rhs)) {
|
|
26619
|
+
fn.tainted.add(lhs);
|
|
26620
|
+
break;
|
|
26621
|
+
}
|
|
26622
|
+
}
|
|
26623
|
+
}
|
|
26624
|
+
if (fn.tainted.size === before)
|
|
26625
|
+
break;
|
|
26626
|
+
}
|
|
26627
|
+
}
|
|
26628
|
+
const evalExprRe = /\bevalexpr\s*::\s*eval(?:_with_context|_boolean|_int|_float|_string|_tuple|_empty)?\s*\(\s*([^)]+)\s*\)/;
|
|
26629
|
+
const libloadingRe = /\blibloading\s*::\s*Library\s*::\s*new\s*\(\s*([^)]+)\s*\)/;
|
|
26630
|
+
const luaLoadRe = /\.\s*load\s*\(\s*([^)]+)\s*\)\s*\.\s*(?:exec|eval|call)\b/;
|
|
26631
|
+
for (const fn of fns) {
|
|
26632
|
+
if (fn.tainted.size === 0)
|
|
26633
|
+
continue;
|
|
26634
|
+
for (let i2 = fn.start;i2 <= fn.end; i2++) {
|
|
26635
|
+
const line = lines[i2];
|
|
26636
|
+
const trimmed = line.trim();
|
|
26637
|
+
let arg = null;
|
|
26638
|
+
let formLabel = "";
|
|
26639
|
+
let m = trimmed.match(evalExprRe);
|
|
26640
|
+
if (m) {
|
|
26641
|
+
arg = m[1].trim();
|
|
26642
|
+
formLabel = "evalexpr::eval";
|
|
26643
|
+
}
|
|
26644
|
+
if (!arg) {
|
|
26645
|
+
m = trimmed.match(libloadingRe);
|
|
26646
|
+
if (m) {
|
|
26647
|
+
arg = m[1].trim();
|
|
26648
|
+
formLabel = "libloading::Library::new";
|
|
26649
|
+
}
|
|
26650
|
+
}
|
|
26651
|
+
if (!arg) {
|
|
26652
|
+
m = trimmed.match(luaLoadRe);
|
|
26653
|
+
if (m) {
|
|
26654
|
+
arg = m[1].trim();
|
|
26655
|
+
formLabel = "mlua/rlua Lua::load().{exec|eval|call}";
|
|
26656
|
+
}
|
|
26657
|
+
}
|
|
26658
|
+
if (arg === null)
|
|
26659
|
+
continue;
|
|
26660
|
+
if (arg.length === 0)
|
|
26661
|
+
continue;
|
|
26662
|
+
let unwrapped = arg.replace(/^&\s*/, "").trim();
|
|
26663
|
+
if (/^"[^"]*"$/.test(unwrapped))
|
|
26664
|
+
continue;
|
|
26665
|
+
let tainted = false;
|
|
26666
|
+
for (const v of fn.tainted) {
|
|
26667
|
+
const re = new RegExp(`\\b${v}\\b`);
|
|
26668
|
+
if (re.test(unwrapped)) {
|
|
26669
|
+
tainted = true;
|
|
26670
|
+
break;
|
|
26671
|
+
}
|
|
26672
|
+
}
|
|
26673
|
+
if (!tainted)
|
|
26674
|
+
continue;
|
|
26675
|
+
findings.push({
|
|
26676
|
+
id: `code_injection-${file}-${i2 + 1}-rust-eval-crate`,
|
|
26677
|
+
pass: "language-sources",
|
|
26678
|
+
category: "security",
|
|
26679
|
+
rule_id: "code_injection",
|
|
26680
|
+
cwe: "CWE-94",
|
|
26681
|
+
severity: "critical",
|
|
26682
|
+
level: "error",
|
|
26683
|
+
message: `Code injection: ${formLabel}(...) called with a value derived ` + "from an HTTP request extractor (body / Query / Path / Form / " + "Json / HttpRequest). The expression / library / Lua chunk is " + "executed as code. Remove the dynamic-eval path or restrict " + "input to a fixed allow-list.",
|
|
26684
|
+
file,
|
|
26685
|
+
line: i2 + 1,
|
|
26686
|
+
snippet: trimmed
|
|
26687
|
+
});
|
|
26688
|
+
}
|
|
26689
|
+
}
|
|
26690
|
+
return findings;
|
|
26691
|
+
}
|
|
26692
|
+
function findGoMongoNosqlInjectionFindings(code, file) {
|
|
26693
|
+
const findings = [];
|
|
26694
|
+
if (typeof code !== "string" || code.length === 0)
|
|
26695
|
+
return findings;
|
|
26696
|
+
if (!/(\bbson\s*\.\s*[MDAE]\b|mongo-driver|\*\s*mongo\.Collection)/.test(code)) {
|
|
26697
|
+
return findings;
|
|
26698
|
+
}
|
|
26699
|
+
const lines = code.split(`
|
|
26700
|
+
`);
|
|
26701
|
+
const reqExtractRe = /\b\w+\s*\.\s*(?:FormValue|PostFormValue|URL\s*\.\s*Query\s*\(\s*\)\s*\.\s*Get|Header\s*\.\s*Get|Cookie)\s*\(/;
|
|
26702
|
+
const httpReqParamRe = /\*\s*http\.Request\b/;
|
|
26703
|
+
const opsAlt = "(?:FindOne|Find|InsertOne|InsertMany|UpdateOne|UpdateMany|DeleteOne|DeleteMany|FindOneAndUpdate|FindOneAndDelete|FindOneAndReplace|Aggregate)";
|
|
26704
|
+
const callHeadRe = new RegExp(`\\.\\s*(${opsAlt})\\s*\\(`);
|
|
26705
|
+
function extractBalanced(line, openIdx) {
|
|
26706
|
+
let depth = 0;
|
|
26707
|
+
for (let k = openIdx;k < line.length; k++) {
|
|
26708
|
+
const ch = line[k];
|
|
26709
|
+
if (ch === "(")
|
|
26710
|
+
depth++;
|
|
26711
|
+
else if (ch === ")") {
|
|
26712
|
+
depth--;
|
|
26713
|
+
if (depth === 0)
|
|
26714
|
+
return line.substring(openIdx + 1, k);
|
|
26715
|
+
}
|
|
26716
|
+
}
|
|
26717
|
+
return null;
|
|
26718
|
+
}
|
|
26719
|
+
const funcs = [];
|
|
26720
|
+
let cur = null;
|
|
26721
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26722
|
+
const t = lines[i2].trim();
|
|
26723
|
+
if (/^func\b/.test(t)) {
|
|
26724
|
+
if (cur) {
|
|
26725
|
+
cur.end = i2 - 1;
|
|
26726
|
+
funcs.push(cur);
|
|
26727
|
+
}
|
|
26728
|
+
cur = { start: i2, end: lines.length - 1 };
|
|
26729
|
+
}
|
|
26730
|
+
}
|
|
26731
|
+
if (cur)
|
|
26732
|
+
funcs.push(cur);
|
|
26733
|
+
for (const fn of funcs) {
|
|
26734
|
+
const header = lines[fn.start];
|
|
26735
|
+
if (!httpReqParamRe.test(header))
|
|
26736
|
+
continue;
|
|
26737
|
+
const taintedVars = new Set;
|
|
26738
|
+
for (let pass = 0;pass < 3; pass++) {
|
|
26739
|
+
const before = taintedVars.size;
|
|
26740
|
+
for (let i2 = fn.start;i2 <= fn.end; i2++) {
|
|
26741
|
+
const trimmed = lines[i2].trim();
|
|
26742
|
+
const assignMatch = trimmed.match(/^(\w+)\s*(?::=|=)\s*(.+?)(?:\s*\/\/.*)?$/);
|
|
26743
|
+
if (!assignMatch)
|
|
26744
|
+
continue;
|
|
26745
|
+
const lhs = assignMatch[1];
|
|
26746
|
+
const rhs = assignMatch[2];
|
|
26747
|
+
if (taintedVars.has(lhs))
|
|
26748
|
+
continue;
|
|
26749
|
+
if (reqExtractRe.test(rhs)) {
|
|
26750
|
+
taintedVars.add(lhs);
|
|
26751
|
+
continue;
|
|
26752
|
+
}
|
|
26753
|
+
for (const v of taintedVars) {
|
|
26754
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
26755
|
+
taintedVars.add(lhs);
|
|
26756
|
+
break;
|
|
26757
|
+
}
|
|
26758
|
+
}
|
|
26759
|
+
}
|
|
26760
|
+
if (taintedVars.size === before)
|
|
26761
|
+
break;
|
|
26762
|
+
}
|
|
26763
|
+
if (taintedVars.size === 0)
|
|
26764
|
+
continue;
|
|
26765
|
+
for (let i2 = fn.start;i2 <= fn.end; i2++) {
|
|
26766
|
+
const line = lines[i2];
|
|
26767
|
+
const m = callHeadRe.exec(line);
|
|
26768
|
+
if (!m)
|
|
26769
|
+
continue;
|
|
26770
|
+
const op = m[1];
|
|
26771
|
+
const openIdx = m.index + m[0].length - 1;
|
|
26772
|
+
const args2 = extractBalanced(line, openIdx);
|
|
26773
|
+
if (args2 === null || args2.length === 0)
|
|
26774
|
+
continue;
|
|
26775
|
+
let tainted = reqExtractRe.test(args2);
|
|
26776
|
+
if (!tainted) {
|
|
26777
|
+
for (const v of taintedVars) {
|
|
26778
|
+
if (new RegExp(`\\b${v}\\b`).test(args2)) {
|
|
26779
|
+
tainted = true;
|
|
26780
|
+
break;
|
|
26781
|
+
}
|
|
26782
|
+
}
|
|
26783
|
+
}
|
|
26784
|
+
if (!tainted)
|
|
26785
|
+
continue;
|
|
26786
|
+
findings.push({
|
|
26787
|
+
id: `nosql_injection-${file}-${i2 + 1}-go-mongo-${op.toLowerCase()}`,
|
|
26788
|
+
pass: "language-sources",
|
|
26789
|
+
category: "security",
|
|
26790
|
+
rule_id: "nosql_injection",
|
|
26791
|
+
cwe: "CWE-943",
|
|
26792
|
+
severity: "critical",
|
|
26793
|
+
level: "error",
|
|
26794
|
+
message: `NoSQL injection: Go MongoDB driver \`${op}(...)\` called with a ` + "filter derived from *http.Request input. Untrusted values inside " + "a `bson.M` / `bson.D` filter can be operator objects (e.g. " + '`{"$ne": null}`) and bypass authentication/intent. Validate or ' + "coerce the value to a primitive before building the filter.",
|
|
26795
|
+
file,
|
|
26796
|
+
line: i2 + 1,
|
|
26797
|
+
snippet: line.trim()
|
|
26798
|
+
});
|
|
26799
|
+
}
|
|
26800
|
+
}
|
|
26801
|
+
return findings;
|
|
26802
|
+
}
|
|
26803
|
+
function findJavaMongoNosqlInjectionFindings(code, file) {
|
|
26804
|
+
const findings = [];
|
|
26805
|
+
if (typeof code !== "string" || code.length === 0)
|
|
26806
|
+
return findings;
|
|
26807
|
+
if (!/(MongoCollection\b|com\.mongodb\b|\bFilters\b|\bnew\s+Document\s*\()/.test(code)) {
|
|
26808
|
+
return findings;
|
|
26809
|
+
}
|
|
26810
|
+
const lines = code.split(`
|
|
26811
|
+
`);
|
|
26812
|
+
const reqExtractRe = /\b\w+\s*\.\s*(?:getParameter|getParameterValues|getHeader|getHeaders|getCookies|getReader|getQueryString|getRequestURI|getInputStream|getPart|getParts)\s*\(/;
|
|
26813
|
+
const opsAlt = "(?:find|findOne|findOneAndUpdate|findOneAndDelete|findOneAndReplace|insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|replaceOne|aggregate|countDocuments|distinct)";
|
|
26814
|
+
const callRe = new RegExp(`\\.\\s*(${opsAlt})\\s*\\(([\\s\\S]*?)\\)`);
|
|
26815
|
+
const taintedVars = new Set;
|
|
26816
|
+
for (let pass = 0;pass < 3; pass++) {
|
|
26817
|
+
const before = taintedVars.size;
|
|
26818
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26819
|
+
const t = lines[i2].trim();
|
|
26820
|
+
const a = t.match(/^(?:final\s+)?(?:[\w<>?,\[\]]+\s+)?(\w+)\s*=\s*(.+?);?$/);
|
|
26821
|
+
if (!a)
|
|
26822
|
+
continue;
|
|
26823
|
+
const lhs = a[1];
|
|
26824
|
+
const rhs = a[2];
|
|
26825
|
+
if (taintedVars.has(lhs))
|
|
26826
|
+
continue;
|
|
26827
|
+
if (reqExtractRe.test(rhs)) {
|
|
26828
|
+
taintedVars.add(lhs);
|
|
26829
|
+
continue;
|
|
26830
|
+
}
|
|
26831
|
+
for (const v of taintedVars) {
|
|
26832
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
26833
|
+
taintedVars.add(lhs);
|
|
26834
|
+
break;
|
|
26835
|
+
}
|
|
26836
|
+
}
|
|
26837
|
+
}
|
|
26838
|
+
if (taintedVars.size === before)
|
|
26839
|
+
break;
|
|
26840
|
+
}
|
|
26841
|
+
if (taintedVars.size === 0)
|
|
26842
|
+
return findings;
|
|
26843
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26844
|
+
const line = lines[i2];
|
|
26845
|
+
const m = line.match(callRe);
|
|
26846
|
+
if (!m)
|
|
26847
|
+
continue;
|
|
26848
|
+
const op = m[1];
|
|
26849
|
+
const args2 = m[2];
|
|
26850
|
+
if (args2.trim().length === 0)
|
|
26851
|
+
continue;
|
|
26852
|
+
let tainted = reqExtractRe.test(args2);
|
|
26853
|
+
if (!tainted) {
|
|
26854
|
+
for (const v of taintedVars) {
|
|
26855
|
+
if (new RegExp(`\\b${v}\\b`).test(args2)) {
|
|
26856
|
+
tainted = true;
|
|
26857
|
+
break;
|
|
26858
|
+
}
|
|
26859
|
+
}
|
|
26860
|
+
}
|
|
26861
|
+
if (!tainted)
|
|
26862
|
+
continue;
|
|
26863
|
+
findings.push({
|
|
26864
|
+
id: `nosql_injection-${file}-${i2 + 1}-java-mongo-${op.toLowerCase()}`,
|
|
26865
|
+
pass: "language-sources",
|
|
26866
|
+
category: "security",
|
|
26867
|
+
rule_id: "nosql_injection",
|
|
26868
|
+
cwe: "CWE-943",
|
|
26869
|
+
severity: "critical",
|
|
26870
|
+
level: "error",
|
|
26871
|
+
message: `NoSQL injection: Java Mongo driver \`${op}(...)\` called with a ` + "filter derived from servlet request input. Untrusted values can " + "reach BSON operator positions and bypass intent. Validate the " + "input type before constructing the filter (e.g. require String).",
|
|
26872
|
+
file,
|
|
26873
|
+
line: i2 + 1,
|
|
26874
|
+
snippet: line.trim()
|
|
26875
|
+
});
|
|
26876
|
+
}
|
|
26877
|
+
return findings;
|
|
26878
|
+
}
|
|
26879
|
+
function findPythonMongoengineWhereNosqlInjectionFindings(code, file) {
|
|
26880
|
+
const findings = [];
|
|
26881
|
+
if (typeof code !== "string" || code.length === 0)
|
|
26882
|
+
return findings;
|
|
26883
|
+
if (!/['"]\$where['"]/.test(code))
|
|
26884
|
+
return findings;
|
|
26885
|
+
const lines = code.split(`
|
|
26886
|
+
`);
|
|
26887
|
+
const reqExtractRe = /\b(?:request\.(?:args|form|values|json|files|cookies|headers|data)\b|flask\.request\b)/;
|
|
26888
|
+
const taintedVars = new Set;
|
|
26889
|
+
for (let pass = 0;pass < 3; pass++) {
|
|
26890
|
+
const before = taintedVars.size;
|
|
26891
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26892
|
+
const t = lines[i2].trim();
|
|
26893
|
+
if (t.startsWith("#"))
|
|
26894
|
+
continue;
|
|
26895
|
+
const a = t.match(/^(\w+)\s*=\s*(.+?)$/);
|
|
26896
|
+
if (!a)
|
|
26897
|
+
continue;
|
|
26898
|
+
const lhs = a[1];
|
|
26899
|
+
const rhs = a[2];
|
|
26900
|
+
if (taintedVars.has(lhs))
|
|
26901
|
+
continue;
|
|
26902
|
+
if (reqExtractRe.test(rhs)) {
|
|
26903
|
+
taintedVars.add(lhs);
|
|
26904
|
+
continue;
|
|
26905
|
+
}
|
|
26906
|
+
for (const v of taintedVars) {
|
|
26907
|
+
if (new RegExp(`\\b${v}\\b`).test(rhs)) {
|
|
26908
|
+
taintedVars.add(lhs);
|
|
26909
|
+
break;
|
|
26910
|
+
}
|
|
26911
|
+
}
|
|
26912
|
+
}
|
|
26913
|
+
if (taintedVars.size === before)
|
|
26914
|
+
break;
|
|
26915
|
+
}
|
|
26916
|
+
const whereRe = /['"]\$where['"]\s*:\s*([^,}\n]+)/;
|
|
26917
|
+
for (let i2 = 0;i2 < lines.length; i2++) {
|
|
26918
|
+
const line = lines[i2];
|
|
26919
|
+
const m = line.match(whereRe);
|
|
26920
|
+
if (!m)
|
|
26921
|
+
continue;
|
|
26922
|
+
const valueExpr = m[1].trim();
|
|
26923
|
+
if (/^"[^"]*"$/.test(valueExpr) || /^'[^']*'$/.test(valueExpr))
|
|
26924
|
+
continue;
|
|
26925
|
+
let tainted = reqExtractRe.test(valueExpr);
|
|
26926
|
+
if (!tainted) {
|
|
26927
|
+
for (const v of taintedVars) {
|
|
26928
|
+
if (new RegExp(`\\b${v}\\b`).test(valueExpr)) {
|
|
26929
|
+
tainted = true;
|
|
26930
|
+
break;
|
|
26931
|
+
}
|
|
26932
|
+
}
|
|
26933
|
+
}
|
|
26934
|
+
if (!tainted)
|
|
26935
|
+
continue;
|
|
26936
|
+
findings.push({
|
|
26937
|
+
id: `nosql_injection-${file}-${i2 + 1}-py-mongoengine-where`,
|
|
26938
|
+
pass: "language-sources",
|
|
26939
|
+
category: "security",
|
|
26940
|
+
rule_id: "nosql_injection",
|
|
26941
|
+
cwe: "CWE-943",
|
|
26942
|
+
severity: "critical",
|
|
26943
|
+
level: "error",
|
|
26944
|
+
message: 'NoSQL injection: mongoengine `__raw__={"$where": ...}` payload ' + "derived from HTTP request input. The `$where` operator evaluates " + "JavaScript on the server; tainted string concatenation lets an " + "attacker inject arbitrary JS. Replace `$where` with field-based " + "operators or validate the input.",
|
|
26945
|
+
file,
|
|
26946
|
+
line: i2 + 1,
|
|
26947
|
+
snippet: line.trim()
|
|
26948
|
+
});
|
|
26949
|
+
}
|
|
26950
|
+
return findings;
|
|
26951
|
+
}
|
|
26205
26952
|
|
|
26206
26953
|
// ../circle-ir/dist/analysis/passes/sink-filter-pass.js
|
|
26207
26954
|
var JS_XSS_SANITIZERS = [
|
|
@@ -39329,7 +40076,7 @@ var colors = {
|
|
|
39329
40076
|
};
|
|
39330
40077
|
|
|
39331
40078
|
// src/version.ts
|
|
39332
|
-
var version = "3.
|
|
40079
|
+
var version = "3.134.0";
|
|
39333
40080
|
|
|
39334
40081
|
// src/formatters.ts
|
|
39335
40082
|
var LIBRARY_API_SURFACE_TAG2 = "library-api-surface:caller-responsibility";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cognium-dev",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.134.0",
|
|
4
4
|
"description": "Static Application Security Testing CLI for detecting security vulnerabilities via taint tracking",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@cognium/project-profile-detect": "^1.1.0",
|
|
69
|
-
"circle-ir": "^3.
|
|
69
|
+
"circle-ir": "^3.134.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@types/node": "^25.5.0",
|