cognium-dev 3.38.0 → 3.40.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.
Files changed (2) hide show
  1. package/dist/cli.js +413 -11
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -11092,13 +11092,29 @@ var PYTHON_TAINTED_PATTERNS = [
11092
11092
  { pattern: /\brequest\.query_params\b/, sourceType: "http_param" },
11093
11093
  { pattern: /\brequest\.path_params\b/, sourceType: "http_param" }
11094
11094
  ];
11095
- function analyzeTaint(calls, types, config = getDefaultConfig(), typeHierarchy, language) {
11096
- const sources = findSources(calls, types, config.sources);
11097
- const sinks = findSinks(calls, config.sinks, typeHierarchy, language);
11095
+ function analyzeTaint(calls, types, config = getDefaultConfig(), typeHierarchy, language, code) {
11096
+ const sourceLines = code !== undefined ? code.split(`
11097
+ `) : undefined;
11098
+ const sources = findSources(calls, types, config.sources, sourceLines);
11099
+ const sinks = findSinks(calls, config.sinks, typeHierarchy, language, sourceLines);
11098
11100
  const sanitizers = findSanitizers(calls, types, config.sanitizers);
11099
11101
  return { sources, sinks, sanitizers };
11100
11102
  }
11101
- function findSources(calls, types, patterns) {
11103
+ function attachSourceLineCode(sources, sinks, code) {
11104
+ const lines = code.split(`
11105
+ `);
11106
+ for (const s of sources) {
11107
+ if (s.code === undefined) {
11108
+ s.code = lines[s.line - 1]?.trim();
11109
+ }
11110
+ }
11111
+ for (const s of sinks) {
11112
+ if (s.code === undefined) {
11113
+ s.code = lines[s.line - 1]?.trim();
11114
+ }
11115
+ }
11116
+ }
11117
+ function findSources(calls, types, patterns, sourceLines) {
11102
11118
  const sources = [];
11103
11119
  for (const call of calls) {
11104
11120
  for (const pattern of patterns) {
@@ -11245,7 +11261,13 @@ function findSources(calls, types, patterns) {
11245
11261
  sourceMap.set(key, source);
11246
11262
  }
11247
11263
  }
11248
- return Array.from(sourceMap.values());
11264
+ const result = Array.from(sourceMap.values());
11265
+ if (sourceLines) {
11266
+ for (const s of result) {
11267
+ s.code = sourceLines[s.line - 1]?.trim();
11268
+ }
11269
+ }
11270
+ return result;
11249
11271
  }
11250
11272
  function isInterproceduralTaintableType(typeName) {
11251
11273
  const baseType = typeName.split("<")[0].trim();
@@ -11341,7 +11363,7 @@ function isParameterizedQueryCall(call, pattern) {
11341
11363
  }
11342
11364
  return false;
11343
11365
  }
11344
- function findSinks(calls, patterns, typeHierarchy, language) {
11366
+ function findSinks(calls, patterns, typeHierarchy, language, sourceLines) {
11345
11367
  const sinkMap = new Map;
11346
11368
  for (const call of calls) {
11347
11369
  for (const pattern of patterns) {
@@ -11367,7 +11389,13 @@ function findSinks(calls, patterns, typeHierarchy, language) {
11367
11389
  }
11368
11390
  }
11369
11391
  }
11370
- return Array.from(sinkMap.values());
11392
+ const result = Array.from(sinkMap.values());
11393
+ if (sourceLines) {
11394
+ for (const s of result) {
11395
+ s.code = sourceLines[s.line - 1]?.trim();
11396
+ }
11397
+ }
11398
+ return result;
11371
11399
  }
11372
11400
  function matchesSourcePattern(call, pattern) {
11373
11401
  if (pattern.method) {
@@ -18050,6 +18078,7 @@ class CrossFileResolver {
18050
18078
  typeHierarchy;
18051
18079
  fileIRs = new Map;
18052
18080
  methodTaintInfo = new Map;
18081
+ fieldTaintInfo = new Map;
18053
18082
  resolvedCalls = new Map;
18054
18083
  constructor(symbolTable, typeHierarchy) {
18055
18084
  this.symbolTable = symbolTable;
@@ -18060,6 +18089,7 @@ class CrossFileResolver {
18060
18089
  this.symbolTable.addFromIR(ir, filePath);
18061
18090
  this.typeHierarchy.addFromIR(ir, filePath);
18062
18091
  this.analyzeMethodTaint(ir, filePath);
18092
+ this.analyzeFieldTaint(ir, filePath);
18063
18093
  }
18064
18094
  resolveCall(call, fromFile) {
18065
18095
  const cacheKey = `${fromFile}:${call.location.line}:${call.method_name}`;
@@ -18226,6 +18256,113 @@ class CrossFileResolver {
18226
18256
  }
18227
18257
  }
18228
18258
  }
18259
+ analyzeFieldTaint(ir, filePath) {
18260
+ const pkg = ir.meta.package || "";
18261
+ const ctorFieldRe = /^(\w+)\.(\w+)\(\) returns tainted field '([^']+)' \(from constructor param '([^']+)'\)/;
18262
+ for (const src of ir.taint.sources) {
18263
+ if (src.type !== "constructor_field")
18264
+ continue;
18265
+ const m = ctorFieldRe.exec(src.location);
18266
+ if (!m)
18267
+ continue;
18268
+ const [, className, , fieldName, sourceParam] = m;
18269
+ const typeFqn = pkg ? `${pkg}.${className}` : className;
18270
+ const type = ir.types.find((t) => t.name === className);
18271
+ if (!type)
18272
+ continue;
18273
+ const writerMethod = type.methods.find((mth) => mth.name === className && mth.parameters.some((p) => p.name === sourceParam)) ?? type.methods.find((mth) => mth.parameters.some((p) => p.name === sourceParam));
18274
+ if (!writerMethod)
18275
+ continue;
18276
+ const field = type.fields?.find((f) => f.name === fieldName);
18277
+ const key = `${typeFqn}.${fieldName}`;
18278
+ const existing = this.fieldTaintInfo.get(key);
18279
+ const writer = {
18280
+ methodFqn: `${typeFqn}.${writerMethod.name}`,
18281
+ methodName: writerMethod.name,
18282
+ writeLine: writerMethod.start_line,
18283
+ sourceType: "constructor_field",
18284
+ sourceLine: src.line
18285
+ };
18286
+ if (existing) {
18287
+ if (!existing.writers.some((w) => w.methodFqn === writer.methodFqn)) {
18288
+ existing.writers.push(writer);
18289
+ }
18290
+ } else {
18291
+ this.fieldTaintInfo.set(key, {
18292
+ typeFqn,
18293
+ fieldName,
18294
+ fieldType: field?.type ?? null,
18295
+ file: filePath,
18296
+ writers: [writer]
18297
+ });
18298
+ }
18299
+ }
18300
+ for (const type of ir.types) {
18301
+ const typeFqn = pkg ? `${pkg}.${type.name}` : type.name;
18302
+ for (const method of type.methods) {
18303
+ if (!method.name.startsWith("set") || method.name.length <= 3)
18304
+ continue;
18305
+ if (method.parameters.length !== 1)
18306
+ continue;
18307
+ const fieldName = method.name.charAt(3).toLowerCase() + method.name.substring(4);
18308
+ const field = type.fields?.find((f) => f.name === fieldName);
18309
+ if (!field)
18310
+ continue;
18311
+ const key = `${typeFqn}.${fieldName}`;
18312
+ const writer = {
18313
+ methodFqn: `${typeFqn}.${method.name}`,
18314
+ methodName: method.name,
18315
+ writeLine: method.start_line,
18316
+ sourceType: "setter_param",
18317
+ sourceLine: method.start_line
18318
+ };
18319
+ const existing = this.fieldTaintInfo.get(key);
18320
+ if (existing) {
18321
+ if (!existing.writers.some((w) => w.methodFqn === writer.methodFqn)) {
18322
+ existing.writers.push(writer);
18323
+ }
18324
+ } else {
18325
+ this.fieldTaintInfo.set(key, {
18326
+ typeFqn,
18327
+ fieldName,
18328
+ fieldType: field.type ?? null,
18329
+ file: filePath,
18330
+ writers: [writer]
18331
+ });
18332
+ }
18333
+ }
18334
+ }
18335
+ const injectAnnotations = new Set(["Autowired", "Inject", "Resource"]);
18336
+ for (const type of ir.types) {
18337
+ const typeFqn = pkg ? `${pkg}.${type.name}` : type.name;
18338
+ for (const field of type.fields ?? []) {
18339
+ if (!field.annotations?.some((a) => injectAnnotations.has(a)))
18340
+ continue;
18341
+ const key = `${typeFqn}.${field.name}`;
18342
+ const writer = {
18343
+ methodFqn: `${typeFqn}.<injected>`,
18344
+ methodName: "<injected>",
18345
+ writeLine: type.start_line,
18346
+ sourceType: "autowired_field",
18347
+ sourceLine: type.start_line
18348
+ };
18349
+ const existing = this.fieldTaintInfo.get(key);
18350
+ if (existing) {
18351
+ if (!existing.writers.some((w) => w.methodFqn === writer.methodFqn)) {
18352
+ existing.writers.push(writer);
18353
+ }
18354
+ } else {
18355
+ this.fieldTaintInfo.set(key, {
18356
+ typeFqn,
18357
+ fieldName: field.name,
18358
+ fieldType: field.type ?? null,
18359
+ file: filePath,
18360
+ writers: [writer]
18361
+ });
18362
+ }
18363
+ }
18364
+ }
18365
+ }
18229
18366
  isMethodTaintSource(method, sources) {
18230
18367
  const sourceAnnotations = ["RequestParam", "RequestBody", "PathVariable", "QueryParam"];
18231
18368
  for (const param of method.parameters) {
@@ -18493,11 +18630,268 @@ class CrossFileResolver {
18493
18630
  }
18494
18631
  }
18495
18632
  }
18633
+ if (tainted.size > 0) {
18634
+ const sinksInCaller = callerIR.taint.sinks.filter((s) => s.line >= method.start_line && s.line <= method.end_line);
18635
+ for (const sink of sinksInCaller) {
18636
+ const callsAtSink = callerIR.calls.filter((c) => c.location.line === sink.line);
18637
+ for (const sinkCall of callsAtSink) {
18638
+ for (const arg of sinkCall.arguments ?? []) {
18639
+ const matched = this.matchTaintedArg(arg, tainted);
18640
+ if (!matched)
18641
+ continue;
18642
+ const key = `${matched.origin.file}:${matched.origin.line}→${callerFile}:${sink.line}`;
18643
+ if (seen.has(key))
18644
+ continue;
18645
+ seen.add(key);
18646
+ const hops = [
18647
+ ...matched.origin.hopChain,
18648
+ { file: callerFile, line: sink.line, method: method.name, kind: "sink" }
18649
+ ];
18650
+ const decay = Math.max(0.3, Math.pow(0.85, Math.max(hops.length - 1, 0)));
18651
+ paths.push({
18652
+ source: {
18653
+ file: matched.origin.file,
18654
+ line: matched.origin.line,
18655
+ type: matched.origin.type
18656
+ },
18657
+ sink: {
18658
+ file: callerFile,
18659
+ line: sink.line,
18660
+ type: sink.type,
18661
+ cwe: sink.cwe
18662
+ },
18663
+ hops,
18664
+ confidence: decay
18665
+ });
18666
+ }
18667
+ }
18668
+ }
18669
+ }
18670
+ }
18671
+ }
18672
+ }
18673
+ return paths;
18674
+ }
18675
+ findFieldBindingTaintPaths() {
18676
+ const paths = [];
18677
+ const seen = new Set;
18678
+ if (this.fieldTaintInfo.size === 0)
18679
+ return paths;
18680
+ const fieldExprRe = /^(\w+)\.(\w+)$/;
18681
+ const methodIndex = this.buildMethodIndex();
18682
+ for (const [callerFile, callerIR] of this.fileIRs) {
18683
+ for (const type of callerIR.types) {
18684
+ const callerTypeFqn = callerIR.meta.package ? `${callerIR.meta.package}.${type.name}` : type.name;
18685
+ for (const method of type.methods) {
18686
+ const tainted = new Map;
18687
+ for (const src of callerIR.taint.sources) {
18688
+ if (src.type === "interprocedural_param")
18689
+ continue;
18690
+ if (src.line < method.start_line || src.line > method.end_line)
18691
+ continue;
18692
+ if (!src.variable)
18693
+ continue;
18694
+ tainted.set(src.variable, {
18695
+ file: callerFile,
18696
+ line: src.line,
18697
+ type: src.type,
18698
+ hopChain: [{ file: callerFile, line: src.line, method: method.name, kind: "source" }]
18699
+ });
18700
+ }
18701
+ const defsInMethod = callerIR.dfg.defs.filter((d) => d.kind === "local" && d.line >= method.start_line && d.line <= method.end_line && !!d.variable);
18702
+ for (const def of defsInMethod) {
18703
+ const usesAtLine = callerIR.dfg.uses.filter((u) => u.line === def.line);
18704
+ if (usesAtLine.length < 2)
18705
+ continue;
18706
+ let receiver = null;
18707
+ let fieldName = null;
18708
+ if (def.expression) {
18709
+ const exprMatch = fieldExprRe.exec(def.expression.trim());
18710
+ if (exprMatch) {
18711
+ receiver = exprMatch[1];
18712
+ fieldName = exprMatch[2];
18713
+ }
18714
+ }
18715
+ const resolveReceiverType = (rcv) => {
18716
+ const param = method.parameters.find((p) => p.name === rcv);
18717
+ if (param?.type)
18718
+ return param.type;
18719
+ const fieldOnSelf = type.fields?.find((f) => f.name === rcv);
18720
+ if (fieldOnSelf?.type)
18721
+ return fieldOnSelf.type;
18722
+ return null;
18723
+ };
18724
+ let receiverType = null;
18725
+ if (receiver && fieldName) {
18726
+ receiverType = resolveReceiverType(receiver);
18727
+ }
18728
+ if (!receiverType) {
18729
+ for (const rcvUse of usesAtLine) {
18730
+ if (!rcvUse.variable || rcvUse.variable === def.variable)
18731
+ continue;
18732
+ const rt = resolveReceiverType(rcvUse.variable);
18733
+ if (!rt)
18734
+ continue;
18735
+ const fieldUse = usesAtLine.find((u) => u !== rcvUse && !!u.variable && u.variable !== def.variable && u.variable !== rcvUse.variable && this.typeHasField(rt, u.variable));
18736
+ if (fieldUse) {
18737
+ receiver = rcvUse.variable;
18738
+ fieldName = fieldUse.variable;
18739
+ receiverType = rt;
18740
+ break;
18741
+ }
18742
+ }
18743
+ }
18744
+ if (!receiver || !fieldName || !receiverType)
18745
+ continue;
18746
+ const fieldKey = this.resolveFieldTaintKey(receiverType, fieldName, callerIR);
18747
+ if (!fieldKey)
18748
+ continue;
18749
+ const fieldInfo = this.fieldTaintInfo.get(fieldKey);
18750
+ if (!fieldInfo || fieldInfo.writers.length === 0)
18751
+ continue;
18752
+ const writer = fieldInfo.writers.find((w) => w.sourceType === "constructor_field" || w.sourceType === "autowired_field") ?? null;
18753
+ if (!writer)
18754
+ continue;
18755
+ const hopChain = [
18756
+ {
18757
+ file: fieldInfo.file,
18758
+ line: writer.sourceLine,
18759
+ method: writer.methodName,
18760
+ kind: "source"
18761
+ },
18762
+ {
18763
+ file: fieldInfo.file,
18764
+ line: writer.writeLine,
18765
+ method: writer.methodName,
18766
+ kind: "field_write"
18767
+ },
18768
+ {
18769
+ file: callerFile,
18770
+ line: def.line,
18771
+ method: method.name,
18772
+ kind: "field_read"
18773
+ }
18774
+ ];
18775
+ tainted.set(def.variable, {
18776
+ file: fieldInfo.file,
18777
+ line: writer.sourceLine,
18778
+ type: writer.sourceType,
18779
+ hopChain
18780
+ });
18781
+ }
18782
+ if (tainted.size === 0)
18783
+ continue;
18784
+ const sinksInCaller = callerIR.taint.sinks.filter((s) => s.line >= method.start_line && s.line <= method.end_line);
18785
+ for (const sink of sinksInCaller) {
18786
+ const callsAtSink = callerIR.calls.filter((c) => c.location.line === sink.line);
18787
+ for (const sinkCall of callsAtSink) {
18788
+ for (const arg of sinkCall.arguments ?? []) {
18789
+ const matched = this.matchTaintedArg(arg, tainted);
18790
+ if (!matched)
18791
+ continue;
18792
+ const key = `fb:${matched.origin.file}:${matched.origin.line}→${callerFile}:${sink.line}`;
18793
+ if (seen.has(key))
18794
+ continue;
18795
+ seen.add(key);
18796
+ const hops = [
18797
+ ...matched.origin.hopChain,
18798
+ { file: callerFile, line: sink.line, method: method.name, kind: "sink" }
18799
+ ];
18800
+ const decay = Math.max(0.3, Math.pow(0.85, Math.max(hops.length - 1, 0)));
18801
+ paths.push({
18802
+ source: {
18803
+ file: matched.origin.file,
18804
+ line: matched.origin.line,
18805
+ type: matched.origin.type
18806
+ },
18807
+ sink: {
18808
+ file: callerFile,
18809
+ line: sink.line,
18810
+ type: sink.type,
18811
+ cwe: sink.cwe
18812
+ },
18813
+ hops,
18814
+ confidence: decay
18815
+ });
18816
+ }
18817
+ }
18818
+ }
18819
+ const callsInMethod = callerIR.calls.filter((c) => c.location.line >= method.start_line && c.location.line <= method.end_line).sort((a, b) => a.location.line - b.location.line);
18820
+ for (const call of callsInMethod) {
18821
+ const resolved = this.resolveCall(call, callerFile);
18822
+ if (!resolved)
18823
+ continue;
18824
+ const callee = this.methodTaintInfo.get(resolved.targetMethod);
18825
+ if (!callee || callee.sanitizes || callee.taintedParams.length === 0)
18826
+ continue;
18827
+ for (let argIdx = 0;argIdx < call.arguments.length; argIdx++) {
18828
+ if (!callee.taintedParams.includes(argIdx))
18829
+ continue;
18830
+ const matched = this.matchTaintedArg(call.arguments[argIdx], tainted);
18831
+ if (!matched)
18832
+ continue;
18833
+ const calleeNode = methodIndex.get(resolved.targetMethod);
18834
+ if (!calleeNode)
18835
+ continue;
18836
+ const sinksInCallee = calleeNode.ir.taint.sinks.filter((s) => s.line >= calleeNode.method.start_line && s.line <= calleeNode.method.end_line);
18837
+ for (const sink of sinksInCallee) {
18838
+ const key = `fb:${matched.origin.file}:${matched.origin.line}→${callee.file}:${sink.line}`;
18839
+ if (seen.has(key))
18840
+ continue;
18841
+ seen.add(key);
18842
+ const hops = [
18843
+ ...matched.origin.hopChain,
18844
+ { file: callerFile, line: call.location.line, method: method.name, kind: "sink_call" },
18845
+ { file: callee.file, line: sink.line, method: resolved.targetMethod, kind: "sink" }
18846
+ ];
18847
+ const decay = Math.max(0.3, Math.pow(0.85, Math.max(hops.length - 1, 0)));
18848
+ paths.push({
18849
+ source: {
18850
+ file: matched.origin.file,
18851
+ line: matched.origin.line,
18852
+ type: matched.origin.type
18853
+ },
18854
+ sink: {
18855
+ file: callee.file,
18856
+ line: sink.line,
18857
+ type: sink.type,
18858
+ cwe: sink.cwe
18859
+ },
18860
+ hops,
18861
+ confidence: decay
18862
+ });
18863
+ }
18864
+ }
18865
+ }
18496
18866
  }
18497
18867
  }
18498
18868
  }
18499
18869
  return paths;
18500
18870
  }
18871
+ typeHasField(typeName, fieldName) {
18872
+ for (const [, ir] of this.fileIRs) {
18873
+ for (const t of ir.types) {
18874
+ if (t.name !== typeName)
18875
+ continue;
18876
+ if ((t.fields ?? []).some((f) => f.name === fieldName))
18877
+ return true;
18878
+ }
18879
+ }
18880
+ return false;
18881
+ }
18882
+ resolveFieldTaintKey(receiverType, fieldName, _callerIR) {
18883
+ const direct = `${receiverType}.${fieldName}`;
18884
+ if (this.fieldTaintInfo.has(direct))
18885
+ return direct;
18886
+ const suffix = `.${receiverType}.${fieldName}`;
18887
+ for (const key of this.fieldTaintInfo.keys()) {
18888
+ if (key === direct)
18889
+ return key;
18890
+ if (key.endsWith(suffix))
18891
+ return key;
18892
+ }
18893
+ return;
18894
+ }
18501
18895
  matchTaintedArg(arg, tainted) {
18502
18896
  if (tainted.size === 0)
18503
18897
  return null;
@@ -18582,8 +18976,12 @@ class CrossFileResolver {
18582
18976
  clear() {
18583
18977
  this.fileIRs.clear();
18584
18978
  this.methodTaintInfo.clear();
18979
+ this.fieldTaintInfo.clear();
18585
18980
  this.resolvedCalls.clear();
18586
18981
  }
18982
+ getFieldTaintInfo(typeFqn, fieldName) {
18983
+ return this.fieldTaintInfo.get(`${typeFqn}.${fieldName}`);
18984
+ }
18587
18985
  }
18588
18986
  // ../circle-ir/dist/graph/project-graph.js
18589
18987
  class ProjectGraph {
@@ -18725,7 +19123,10 @@ class CrossFilePass {
18725
19123
  confidence: 0.7
18726
19124
  }];
18727
19125
  });
18728
- const ipPaths = resolver.findInterproceduralTaintPaths();
19126
+ const ipPaths = [
19127
+ ...resolver.findInterproceduralTaintPaths(),
19128
+ ...resolver.findFieldBindingTaintPaths()
19129
+ ];
18729
19130
  for (let i2 = 0;i2 < ipPaths.length; i2++) {
18730
19131
  const p = ipPaths[i2];
18731
19132
  const sinkIR = projectGraph.getIR(p.sink.file);
@@ -19321,7 +19722,7 @@ class TaintMatcherPass {
19321
19722
  name = "taint-matcher";
19322
19723
  category = "security";
19323
19724
  run(ctx) {
19324
- const { graph, language, config } = ctx;
19725
+ const { graph, language, config, code } = ctx;
19325
19726
  const { calls, types } = graph.ir;
19326
19727
  let mergedConfig = config;
19327
19728
  const plugin = getLanguagePlugin(language);
@@ -19358,7 +19759,7 @@ class TaintMatcherPass {
19358
19759
  }
19359
19760
  const hierarchy = createWithJdkTypes();
19360
19761
  hierarchy.addFromIR(graph.ir, graph.ir.meta.file);
19361
- const taint = analyzeTaint(calls, types, mergedConfig, hierarchy, language);
19762
+ const taint = analyzeTaint(calls, types, mergedConfig, hierarchy, language, code);
19362
19763
  const sanitizerMethods = [];
19363
19764
  for (const type of types) {
19364
19765
  for (const method of type.methods) {
@@ -19540,6 +19941,7 @@ class LanguageSourcesPass {
19540
19941
  ctx.addFinding(finding);
19541
19942
  }
19542
19943
  }
19944
+ attachSourceLineCode(additionalSources, additionalSinks, code);
19543
19945
  return { additionalSources, additionalSinks, pyTaintedVars, pySanitizedVars, jsTaintedVars };
19544
19946
  }
19545
19947
  }
@@ -27217,7 +27619,7 @@ var colors = {
27217
27619
  };
27218
27620
 
27219
27621
  // src/version.ts
27220
- var version = "3.38.0";
27622
+ var version = "3.40.0";
27221
27623
 
27222
27624
  // src/formatters.ts
27223
27625
  var SINK_SEVERITY = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cognium-dev",
3
- "version": "3.38.0",
3
+ "version": "3.40.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",
@@ -65,7 +65,7 @@
65
65
  "registry": "https://registry.npmjs.org/"
66
66
  },
67
67
  "dependencies": {
68
- "circle-ir": "^3.38.0"
68
+ "circle-ir": "^3.40.0"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@types/node": "^25.5.0",