circle-ir 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.
- package/dist/analysis/index.d.ts +1 -1
- package/dist/analysis/index.d.ts.map +1 -1
- package/dist/analysis/index.js +1 -1
- package/dist/analysis/index.js.map +1 -1
- package/dist/analysis/passes/cross-file-pass.d.ts.map +1 -1
- package/dist/analysis/passes/cross-file-pass.js +10 -1
- package/dist/analysis/passes/cross-file-pass.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +5 -0
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/passes/taint-matcher-pass.js +2 -2
- package/dist/analysis/passes/taint-matcher-pass.js.map +1 -1
- package/dist/analysis/taint-matcher.d.ts +13 -2
- package/dist/analysis/taint-matcher.d.ts.map +1 -1
- package/dist/analysis/taint-matcher.js +43 -7
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/analyzer.js +1 -1
- package/dist/analyzer.js.map +1 -1
- package/dist/browser/circle-ir.js +37 -10
- package/dist/core/circle-ir-core.cjs +34 -7
- package/dist/core/circle-ir-core.d.ts +1 -1
- package/dist/core/circle-ir-core.js +34 -7
- package/dist/core-lib.d.ts +1 -1
- package/dist/core-lib.d.ts.map +1 -1
- package/dist/core-lib.js +1 -1
- package/dist/core-lib.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/resolution/cross-file.d.ts +80 -1
- package/dist/resolution/cross-file.d.ts.map +1 -1
- package/dist/resolution/cross-file.js +482 -0
- package/dist/resolution/cross-file.js.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -16,6 +16,8 @@ export class CrossFileResolver {
|
|
|
16
16
|
fileIRs = new Map();
|
|
17
17
|
// Cache: method FQN -> taint info
|
|
18
18
|
methodTaintInfo = new Map();
|
|
19
|
+
// Cache: `${typeFqn}.${fieldName}` -> field taint info
|
|
20
|
+
fieldTaintInfo = new Map();
|
|
19
21
|
// Resolved calls cache
|
|
20
22
|
resolvedCalls = new Map();
|
|
21
23
|
constructor(symbolTable, typeHierarchy) {
|
|
@@ -31,6 +33,8 @@ export class CrossFileResolver {
|
|
|
31
33
|
this.typeHierarchy.addFromIR(ir, filePath);
|
|
32
34
|
// Analyze methods for taint propagation characteristics
|
|
33
35
|
this.analyzeMethodTaint(ir, filePath);
|
|
36
|
+
// Analyze cross-instance field bindings (constructor + setter writers)
|
|
37
|
+
this.analyzeFieldTaint(ir, filePath);
|
|
34
38
|
}
|
|
35
39
|
/**
|
|
36
40
|
* Resolve a call to its target method(s)
|
|
@@ -245,6 +249,148 @@ export class CrossFileResolver {
|
|
|
245
249
|
}
|
|
246
250
|
}
|
|
247
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Per-file analysis of cross-instance field bindings.
|
|
254
|
+
*
|
|
255
|
+
* Records `FieldTaintInfo` entries for fields written by:
|
|
256
|
+
* 1. `@DataBoundConstructor`-style constructors — surfaced as
|
|
257
|
+
* `constructor_field` sources by `LanguageSourcesPass`.
|
|
258
|
+
* 2. Setter methods `set<Field>(<param>)` — assume the canonical
|
|
259
|
+
* `this.<field> = <param>` body shape, so the setter PARAMETER acts
|
|
260
|
+
* as the taint conduit at call sites.
|
|
261
|
+
* 3. `@Autowired` field annotations — the field itself is a framework
|
|
262
|
+
* injection point; the writer is synthetic (line = field decl).
|
|
263
|
+
*
|
|
264
|
+
* The entries are keyed `${typeFqn}.${fieldName}` and consumed by
|
|
265
|
+
* `findFieldBindingTaintPaths()` to surface flows of the canonical Jenkins
|
|
266
|
+
* shape: ctor writes field → another class reads instance.field → sink.
|
|
267
|
+
*/
|
|
268
|
+
analyzeFieldTaint(ir, filePath) {
|
|
269
|
+
const pkg = ir.meta.package || '';
|
|
270
|
+
// (1) Constructor-bound fields surfaced by LanguageSourcesPass via
|
|
271
|
+
// `constructor_field` sources. Location string format:
|
|
272
|
+
// `${className}.${methodName}() returns tainted field '${field}'
|
|
273
|
+
// (from constructor param '${sourceParam}')`
|
|
274
|
+
const ctorFieldRe = /^(\w+)\.(\w+)\(\) returns tainted field '([^']+)' \(from constructor param '([^']+)'\)/;
|
|
275
|
+
for (const src of ir.taint.sources) {
|
|
276
|
+
if (src.type !== 'constructor_field')
|
|
277
|
+
continue;
|
|
278
|
+
const m = ctorFieldRe.exec(src.location);
|
|
279
|
+
if (!m)
|
|
280
|
+
continue;
|
|
281
|
+
const [, className, , fieldName, sourceParam] = m;
|
|
282
|
+
const typeFqn = pkg ? `${pkg}.${className}` : className;
|
|
283
|
+
const type = ir.types.find(t => t.name === className);
|
|
284
|
+
if (!type)
|
|
285
|
+
continue;
|
|
286
|
+
// Locate the constructor (or first method) whose param name matches.
|
|
287
|
+
const writerMethod = type.methods.find(mth => mth.name === className && mth.parameters.some(p => p.name === sourceParam)) ??
|
|
288
|
+
type.methods.find(mth => mth.parameters.some(p => p.name === sourceParam));
|
|
289
|
+
if (!writerMethod)
|
|
290
|
+
continue;
|
|
291
|
+
const field = type.fields?.find(f => f.name === fieldName);
|
|
292
|
+
const key = `${typeFqn}.${fieldName}`;
|
|
293
|
+
const existing = this.fieldTaintInfo.get(key);
|
|
294
|
+
const writer = {
|
|
295
|
+
methodFqn: `${typeFqn}.${writerMethod.name}`,
|
|
296
|
+
methodName: writerMethod.name,
|
|
297
|
+
writeLine: writerMethod.start_line,
|
|
298
|
+
sourceType: 'constructor_field',
|
|
299
|
+
sourceLine: src.line,
|
|
300
|
+
};
|
|
301
|
+
if (existing) {
|
|
302
|
+
if (!existing.writers.some(w => w.methodFqn === writer.methodFqn)) {
|
|
303
|
+
existing.writers.push(writer);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
this.fieldTaintInfo.set(key, {
|
|
308
|
+
typeFqn,
|
|
309
|
+
fieldName,
|
|
310
|
+
fieldType: field?.type ?? null,
|
|
311
|
+
file: filePath,
|
|
312
|
+
writers: [writer],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// (2) Setter chains: `setX(x)` with one param. The PARAMETER acts as the
|
|
317
|
+
// taint conduit — the writer record reflects this so caller-side
|
|
318
|
+
// `obj.setX(tainted)` followed by `obj.x` read can be wired by
|
|
319
|
+
// `findFieldBindingTaintPaths()`. We do NOT pre-mark the field as
|
|
320
|
+
// tainted; tainting requires a tainted argument at call site (handled
|
|
321
|
+
// by the consumer pass).
|
|
322
|
+
for (const type of ir.types) {
|
|
323
|
+
const typeFqn = pkg ? `${pkg}.${type.name}` : type.name;
|
|
324
|
+
for (const method of type.methods) {
|
|
325
|
+
if (!method.name.startsWith('set') || method.name.length <= 3)
|
|
326
|
+
continue;
|
|
327
|
+
if (method.parameters.length !== 1)
|
|
328
|
+
continue;
|
|
329
|
+
const fieldName = method.name.charAt(3).toLowerCase() + method.name.substring(4);
|
|
330
|
+
const field = type.fields?.find(f => f.name === fieldName);
|
|
331
|
+
if (!field)
|
|
332
|
+
continue;
|
|
333
|
+
const key = `${typeFqn}.${fieldName}`;
|
|
334
|
+
const writer = {
|
|
335
|
+
methodFqn: `${typeFqn}.${method.name}`,
|
|
336
|
+
methodName: method.name,
|
|
337
|
+
writeLine: method.start_line,
|
|
338
|
+
sourceType: 'setter_param',
|
|
339
|
+
sourceLine: method.start_line,
|
|
340
|
+
};
|
|
341
|
+
const existing = this.fieldTaintInfo.get(key);
|
|
342
|
+
if (existing) {
|
|
343
|
+
if (!existing.writers.some(w => w.methodFqn === writer.methodFqn)) {
|
|
344
|
+
existing.writers.push(writer);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
this.fieldTaintInfo.set(key, {
|
|
349
|
+
typeFqn,
|
|
350
|
+
fieldName,
|
|
351
|
+
fieldType: field.type ?? null,
|
|
352
|
+
file: filePath,
|
|
353
|
+
writers: [writer],
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// (3) @Autowired / @Inject fields: framework-injected. Treat the field
|
|
359
|
+
// as unconditionally tainted (writer is synthetic at the field's
|
|
360
|
+
// declaration line). Covers Spring `@Autowired`, JSR-330 `@Inject`,
|
|
361
|
+
// CDI `@Inject`, Micronaut `@Inject`, Quarkus `@Inject`.
|
|
362
|
+
const injectAnnotations = new Set(['Autowired', 'Inject', 'Resource']);
|
|
363
|
+
for (const type of ir.types) {
|
|
364
|
+
const typeFqn = pkg ? `${pkg}.${type.name}` : type.name;
|
|
365
|
+
for (const field of type.fields ?? []) {
|
|
366
|
+
if (!field.annotations?.some(a => injectAnnotations.has(a)))
|
|
367
|
+
continue;
|
|
368
|
+
const key = `${typeFqn}.${field.name}`;
|
|
369
|
+
const writer = {
|
|
370
|
+
methodFqn: `${typeFqn}.<injected>`,
|
|
371
|
+
methodName: '<injected>',
|
|
372
|
+
writeLine: type.start_line,
|
|
373
|
+
sourceType: 'autowired_field',
|
|
374
|
+
sourceLine: type.start_line,
|
|
375
|
+
};
|
|
376
|
+
const existing = this.fieldTaintInfo.get(key);
|
|
377
|
+
if (existing) {
|
|
378
|
+
if (!existing.writers.some(w => w.methodFqn === writer.methodFqn)) {
|
|
379
|
+
existing.writers.push(writer);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
this.fieldTaintInfo.set(key, {
|
|
384
|
+
typeFqn,
|
|
385
|
+
fieldName: field.name,
|
|
386
|
+
fieldType: field.type ?? null,
|
|
387
|
+
file: filePath,
|
|
388
|
+
writers: [writer],
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
248
394
|
/**
|
|
249
395
|
* Check if method is a taint source.
|
|
250
396
|
*
|
|
@@ -605,11 +751,342 @@ export class CrossFileResolver {
|
|
|
605
751
|
}
|
|
606
752
|
}
|
|
607
753
|
}
|
|
754
|
+
// 2c. Caller-body sinks: after marking locals tainted via wrapper-return,
|
|
755
|
+
// check whether any sink in the CALLER'S OWN method body consumes a
|
|
756
|
+
// tainted variable. This closes the canonical Jenkins shape where the
|
|
757
|
+
// final sink (e.g. `Paths.get(p)`, `Runtime.exec(cmd)`) lives in the
|
|
758
|
+
// caller's file rather than in a cross-file callee.
|
|
759
|
+
if (tainted.size > 0) {
|
|
760
|
+
const sinksInCaller = callerIR.taint.sinks.filter(s => s.line >= method.start_line && s.line <= method.end_line);
|
|
761
|
+
for (const sink of sinksInCaller) {
|
|
762
|
+
const callsAtSink = callerIR.calls.filter(c => c.location.line === sink.line);
|
|
763
|
+
for (const sinkCall of callsAtSink) {
|
|
764
|
+
for (const arg of sinkCall.arguments ?? []) {
|
|
765
|
+
const matched = this.matchTaintedArg(arg, tainted);
|
|
766
|
+
if (!matched)
|
|
767
|
+
continue;
|
|
768
|
+
const key = `${matched.origin.file}:${matched.origin.line}→${callerFile}:${sink.line}`;
|
|
769
|
+
if (seen.has(key))
|
|
770
|
+
continue;
|
|
771
|
+
seen.add(key);
|
|
772
|
+
const hops = [
|
|
773
|
+
...matched.origin.hopChain,
|
|
774
|
+
{ file: callerFile, line: sink.line, method: method.name, kind: 'sink' },
|
|
775
|
+
];
|
|
776
|
+
const decay = Math.max(0.3, Math.pow(0.85, Math.max(hops.length - 1, 0)));
|
|
777
|
+
paths.push({
|
|
778
|
+
source: {
|
|
779
|
+
file: matched.origin.file,
|
|
780
|
+
line: matched.origin.line,
|
|
781
|
+
type: matched.origin.type,
|
|
782
|
+
},
|
|
783
|
+
sink: {
|
|
784
|
+
file: callerFile,
|
|
785
|
+
line: sink.line,
|
|
786
|
+
type: sink.type,
|
|
787
|
+
cwe: sink.cwe,
|
|
788
|
+
},
|
|
789
|
+
hops,
|
|
790
|
+
confidence: decay,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return paths;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Find cross-instance field-binding taint paths.
|
|
803
|
+
*
|
|
804
|
+
* Closes the canonical Jenkins / framework-DI shape that
|
|
805
|
+
* `findInterproceduralTaintPaths()` cannot cover because the "source" lives
|
|
806
|
+
* on an aliased object's field, not in a callee return:
|
|
807
|
+
*
|
|
808
|
+
* File A: class C { @DataBoundConstructor C(p) { this.f = p; } }
|
|
809
|
+
* File B: class E { final C step; E(C step){ this.step = step; }
|
|
810
|
+
* m() { String x = step.f; sink(x); } }
|
|
811
|
+
*
|
|
812
|
+
* Algorithm (per caller method M in file B):
|
|
813
|
+
* 1. Seed `tainted` with sources inside M (mirrors findInterproc step 1).
|
|
814
|
+
* 2. Scan M's local-def DFG entries for expressions of shape
|
|
815
|
+
* `<receiver>.<field>` where receiver's declared type owns `<field>`
|
|
816
|
+
* in the FieldTaintInfo cache. Mark the LHS local as tainted, anchor
|
|
817
|
+
* its origin to the field-binding writer (e.g. the ctor in file A).
|
|
818
|
+
* 3. After seeding, walk caller-body sinks the same way
|
|
819
|
+
* `findInterproceduralTaintPaths()` step 2c does, and also forward
|
|
820
|
+
* tainted locals into cross-file callees whose `taintedParams` mark
|
|
821
|
+
* the arg position as sink-propagating.
|
|
822
|
+
*/
|
|
823
|
+
findFieldBindingTaintPaths() {
|
|
824
|
+
const paths = [];
|
|
825
|
+
const seen = new Set();
|
|
826
|
+
if (this.fieldTaintInfo.size === 0)
|
|
827
|
+
return paths;
|
|
828
|
+
const fieldExprRe = /^(\w+)\.(\w+)$/;
|
|
829
|
+
const methodIndex = this.buildMethodIndex();
|
|
830
|
+
for (const [callerFile, callerIR] of this.fileIRs) {
|
|
831
|
+
for (const type of callerIR.types) {
|
|
832
|
+
const callerTypeFqn = callerIR.meta.package
|
|
833
|
+
? `${callerIR.meta.package}.${type.name}`
|
|
834
|
+
: type.name;
|
|
835
|
+
for (const method of type.methods) {
|
|
836
|
+
const tainted = new Map();
|
|
837
|
+
for (const src of callerIR.taint.sources) {
|
|
838
|
+
if (src.type === 'interprocedural_param')
|
|
839
|
+
continue;
|
|
840
|
+
if (src.line < method.start_line || src.line > method.end_line)
|
|
841
|
+
continue;
|
|
842
|
+
if (!src.variable)
|
|
843
|
+
continue;
|
|
844
|
+
tainted.set(src.variable, {
|
|
845
|
+
file: callerFile,
|
|
846
|
+
line: src.line,
|
|
847
|
+
type: src.type,
|
|
848
|
+
hopChain: [{ file: callerFile, line: src.line, method: method.name, kind: 'source' }],
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
// 2. Scan local defs for `receiver.field` patterns.
|
|
852
|
+
//
|
|
853
|
+
// DFG defs don't carry RHS expressions on locals, so we co-locate:
|
|
854
|
+
// - a `local` def at line L
|
|
855
|
+
// - two uses at line L: a known receiver variable (param or
|
|
856
|
+
// containing-class field) AND a token matching a field on the
|
|
857
|
+
// receiver's declared type.
|
|
858
|
+
const defsInMethod = callerIR.dfg.defs.filter(d => d.kind === 'local' &&
|
|
859
|
+
d.line >= method.start_line &&
|
|
860
|
+
d.line <= method.end_line &&
|
|
861
|
+
!!d.variable);
|
|
862
|
+
for (const def of defsInMethod) {
|
|
863
|
+
const usesAtLine = callerIR.dfg.uses.filter(u => u.line === def.line);
|
|
864
|
+
if (usesAtLine.length < 2)
|
|
865
|
+
continue;
|
|
866
|
+
// First pass: expression-based (preferred if available).
|
|
867
|
+
let receiver = null;
|
|
868
|
+
let fieldName = null;
|
|
869
|
+
if (def.expression) {
|
|
870
|
+
const exprMatch = fieldExprRe.exec(def.expression.trim());
|
|
871
|
+
if (exprMatch) {
|
|
872
|
+
receiver = exprMatch[1];
|
|
873
|
+
fieldName = exprMatch[2];
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// Resolve receiver type from local context.
|
|
877
|
+
const resolveReceiverType = (rcv) => {
|
|
878
|
+
const param = method.parameters.find(p => p.name === rcv);
|
|
879
|
+
if (param?.type)
|
|
880
|
+
return param.type;
|
|
881
|
+
const fieldOnSelf = type.fields?.find(f => f.name === rcv);
|
|
882
|
+
if (fieldOnSelf?.type)
|
|
883
|
+
return fieldOnSelf.type;
|
|
884
|
+
return null;
|
|
885
|
+
};
|
|
886
|
+
// Fallback: co-located uses heuristic. For each (receiverUse,
|
|
887
|
+
// fieldUse) pair, check whether receiver's declared type owns
|
|
888
|
+
// fieldUse.variable as a field.
|
|
889
|
+
let receiverType = null;
|
|
890
|
+
if (receiver && fieldName) {
|
|
891
|
+
receiverType = resolveReceiverType(receiver);
|
|
892
|
+
}
|
|
893
|
+
if (!receiverType) {
|
|
894
|
+
for (const rcvUse of usesAtLine) {
|
|
895
|
+
if (!rcvUse.variable || rcvUse.variable === def.variable)
|
|
896
|
+
continue;
|
|
897
|
+
const rt = resolveReceiverType(rcvUse.variable);
|
|
898
|
+
if (!rt)
|
|
899
|
+
continue;
|
|
900
|
+
// Find any other use at this line matching a field on rt.
|
|
901
|
+
const fieldUse = usesAtLine.find(u => u !== rcvUse &&
|
|
902
|
+
!!u.variable &&
|
|
903
|
+
u.variable !== def.variable &&
|
|
904
|
+
u.variable !== rcvUse.variable &&
|
|
905
|
+
this.typeHasField(rt, u.variable));
|
|
906
|
+
if (fieldUse) {
|
|
907
|
+
receiver = rcvUse.variable;
|
|
908
|
+
fieldName = fieldUse.variable;
|
|
909
|
+
receiverType = rt;
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (!receiver || !fieldName || !receiverType)
|
|
915
|
+
continue;
|
|
916
|
+
// FieldTaintInfo is keyed by FQN, but the receiver type may be a
|
|
917
|
+
// simple name. Resolve via symbol table / scan fileIRs.
|
|
918
|
+
const fieldKey = this.resolveFieldTaintKey(receiverType, fieldName, callerIR);
|
|
919
|
+
if (!fieldKey)
|
|
920
|
+
continue;
|
|
921
|
+
const fieldInfo = this.fieldTaintInfo.get(fieldKey);
|
|
922
|
+
if (!fieldInfo || fieldInfo.writers.length === 0)
|
|
923
|
+
continue;
|
|
924
|
+
// Anchor origin to the most informative writer (prefer ctor /
|
|
925
|
+
// autowired over setter). Setter writers require a tainted arg
|
|
926
|
+
// at call-site to be relevant; without seeing the call we treat
|
|
927
|
+
// them as non-anchoring here.
|
|
928
|
+
const writer = fieldInfo.writers.find(w => w.sourceType === 'constructor_field' || w.sourceType === 'autowired_field') ?? null;
|
|
929
|
+
if (!writer)
|
|
930
|
+
continue;
|
|
931
|
+
const hopChain = [
|
|
932
|
+
{
|
|
933
|
+
file: fieldInfo.file,
|
|
934
|
+
line: writer.sourceLine,
|
|
935
|
+
method: writer.methodName,
|
|
936
|
+
kind: 'source',
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
file: fieldInfo.file,
|
|
940
|
+
line: writer.writeLine,
|
|
941
|
+
method: writer.methodName,
|
|
942
|
+
kind: 'field_write',
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
file: callerFile,
|
|
946
|
+
line: def.line,
|
|
947
|
+
method: method.name,
|
|
948
|
+
kind: 'field_read',
|
|
949
|
+
},
|
|
950
|
+
];
|
|
951
|
+
tainted.set(def.variable, {
|
|
952
|
+
file: fieldInfo.file,
|
|
953
|
+
line: writer.sourceLine,
|
|
954
|
+
type: writer.sourceType,
|
|
955
|
+
hopChain,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
if (tainted.size === 0)
|
|
959
|
+
continue;
|
|
960
|
+
// 3a. Caller-body sinks consuming a tainted local.
|
|
961
|
+
const sinksInCaller = callerIR.taint.sinks.filter(s => s.line >= method.start_line && s.line <= method.end_line);
|
|
962
|
+
for (const sink of sinksInCaller) {
|
|
963
|
+
const callsAtSink = callerIR.calls.filter(c => c.location.line === sink.line);
|
|
964
|
+
for (const sinkCall of callsAtSink) {
|
|
965
|
+
for (const arg of sinkCall.arguments ?? []) {
|
|
966
|
+
const matched = this.matchTaintedArg(arg, tainted);
|
|
967
|
+
if (!matched)
|
|
968
|
+
continue;
|
|
969
|
+
const key = `fb:${matched.origin.file}:${matched.origin.line}→${callerFile}:${sink.line}`;
|
|
970
|
+
if (seen.has(key))
|
|
971
|
+
continue;
|
|
972
|
+
seen.add(key);
|
|
973
|
+
const hops = [
|
|
974
|
+
...matched.origin.hopChain,
|
|
975
|
+
{ file: callerFile, line: sink.line, method: method.name, kind: 'sink' },
|
|
976
|
+
];
|
|
977
|
+
const decay = Math.max(0.3, Math.pow(0.85, Math.max(hops.length - 1, 0)));
|
|
978
|
+
paths.push({
|
|
979
|
+
source: {
|
|
980
|
+
file: matched.origin.file,
|
|
981
|
+
line: matched.origin.line,
|
|
982
|
+
type: matched.origin.type,
|
|
983
|
+
},
|
|
984
|
+
sink: {
|
|
985
|
+
file: callerFile,
|
|
986
|
+
line: sink.line,
|
|
987
|
+
type: sink.type,
|
|
988
|
+
cwe: sink.cwe,
|
|
989
|
+
},
|
|
990
|
+
hops,
|
|
991
|
+
confidence: decay,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// 3b. Cross-file callees: forward tainted locals into resolved
|
|
997
|
+
// callees whose taintedParams mark the arg as sink-propagating.
|
|
998
|
+
const callsInMethod = callerIR.calls
|
|
999
|
+
.filter(c => c.location.line >= method.start_line && c.location.line <= method.end_line)
|
|
1000
|
+
.sort((a, b) => a.location.line - b.location.line);
|
|
1001
|
+
for (const call of callsInMethod) {
|
|
1002
|
+
const resolved = this.resolveCall(call, callerFile);
|
|
1003
|
+
if (!resolved)
|
|
1004
|
+
continue;
|
|
1005
|
+
const callee = this.methodTaintInfo.get(resolved.targetMethod);
|
|
1006
|
+
if (!callee || callee.sanitizes || callee.taintedParams.length === 0)
|
|
1007
|
+
continue;
|
|
1008
|
+
for (let argIdx = 0; argIdx < call.arguments.length; argIdx++) {
|
|
1009
|
+
if (!callee.taintedParams.includes(argIdx))
|
|
1010
|
+
continue;
|
|
1011
|
+
const matched = this.matchTaintedArg(call.arguments[argIdx], tainted);
|
|
1012
|
+
if (!matched)
|
|
1013
|
+
continue;
|
|
1014
|
+
const calleeNode = methodIndex.get(resolved.targetMethod);
|
|
1015
|
+
if (!calleeNode)
|
|
1016
|
+
continue;
|
|
1017
|
+
const sinksInCallee = calleeNode.ir.taint.sinks.filter(s => s.line >= calleeNode.method.start_line && s.line <= calleeNode.method.end_line);
|
|
1018
|
+
for (const sink of sinksInCallee) {
|
|
1019
|
+
const key = `fb:${matched.origin.file}:${matched.origin.line}→${callee.file}:${sink.line}`;
|
|
1020
|
+
if (seen.has(key))
|
|
1021
|
+
continue;
|
|
1022
|
+
seen.add(key);
|
|
1023
|
+
const hops = [
|
|
1024
|
+
...matched.origin.hopChain,
|
|
1025
|
+
{ file: callerFile, line: call.location.line, method: method.name, kind: 'sink_call' },
|
|
1026
|
+
{ file: callee.file, line: sink.line, method: resolved.targetMethod, kind: 'sink' },
|
|
1027
|
+
];
|
|
1028
|
+
const decay = Math.max(0.3, Math.pow(0.85, Math.max(hops.length - 1, 0)));
|
|
1029
|
+
paths.push({
|
|
1030
|
+
source: {
|
|
1031
|
+
file: matched.origin.file,
|
|
1032
|
+
line: matched.origin.line,
|
|
1033
|
+
type: matched.origin.type,
|
|
1034
|
+
},
|
|
1035
|
+
sink: {
|
|
1036
|
+
file: callee.file,
|
|
1037
|
+
line: sink.line,
|
|
1038
|
+
type: sink.type,
|
|
1039
|
+
cwe: sink.cwe,
|
|
1040
|
+
},
|
|
1041
|
+
hops,
|
|
1042
|
+
confidence: decay,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// (silence unused warning for callerTypeFqn — reserved for future
|
|
1048
|
+
// same-class field-read detection)
|
|
1049
|
+
void callerTypeFqn;
|
|
608
1050
|
}
|
|
609
1051
|
}
|
|
610
1052
|
}
|
|
611
1053
|
return paths;
|
|
612
1054
|
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Check whether any loaded type with name `typeName` (simple or FQN suffix)
|
|
1057
|
+
* declares a field named `fieldName`.
|
|
1058
|
+
*/
|
|
1059
|
+
typeHasField(typeName, fieldName) {
|
|
1060
|
+
for (const [, ir] of this.fileIRs) {
|
|
1061
|
+
for (const t of ir.types) {
|
|
1062
|
+
if (t.name !== typeName)
|
|
1063
|
+
continue;
|
|
1064
|
+
if ((t.fields ?? []).some(f => f.name === fieldName))
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Resolve a receiver type-name + field-name to the cache key used by
|
|
1072
|
+
* `fieldTaintInfo`. Handles simple-name receivers (e.g. `ReadTrustedStep`)
|
|
1073
|
+
* by looking up matching FQN keys across loaded files.
|
|
1074
|
+
*/
|
|
1075
|
+
resolveFieldTaintKey(receiverType, fieldName, _callerIR) {
|
|
1076
|
+
// Exact FQN hit.
|
|
1077
|
+
const direct = `${receiverType}.${fieldName}`;
|
|
1078
|
+
if (this.fieldTaintInfo.has(direct))
|
|
1079
|
+
return direct;
|
|
1080
|
+
// Simple-name match: scan keys for `*.<receiver>.<field>` suffix.
|
|
1081
|
+
const suffix = `.${receiverType}.${fieldName}`;
|
|
1082
|
+
for (const key of this.fieldTaintInfo.keys()) {
|
|
1083
|
+
if (key === direct)
|
|
1084
|
+
return key;
|
|
1085
|
+
if (key.endsWith(suffix))
|
|
1086
|
+
return key;
|
|
1087
|
+
}
|
|
1088
|
+
return undefined;
|
|
1089
|
+
}
|
|
613
1090
|
/**
|
|
614
1091
|
* Find which method a tainted arg expression references.
|
|
615
1092
|
*/
|
|
@@ -715,8 +1192,13 @@ export class CrossFileResolver {
|
|
|
715
1192
|
clear() {
|
|
716
1193
|
this.fileIRs.clear();
|
|
717
1194
|
this.methodTaintInfo.clear();
|
|
1195
|
+
this.fieldTaintInfo.clear();
|
|
718
1196
|
this.resolvedCalls.clear();
|
|
719
1197
|
}
|
|
1198
|
+
/** Expose field-taint summary (for tests + diagnostics). */
|
|
1199
|
+
getFieldTaintInfo(typeFqn, fieldName) {
|
|
1200
|
+
return this.fieldTaintInfo.get(`${typeFqn}.${fieldName}`);
|
|
1201
|
+
}
|
|
720
1202
|
}
|
|
721
1203
|
/**
|
|
722
1204
|
* Build a cross-file resolver from multiple IR results
|