circle-ir 4.14.2 → 4.16.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/configs/sinks/golang.json +4 -4
- package/dist/analysis/config-loader.d.ts.map +1 -1
- package/dist/analysis/config-loader.js +11 -7
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analysis/const-table-lookup.d.ts +29 -0
- package/dist/analysis/const-table-lookup.d.ts.map +1 -0
- package/dist/analysis/const-table-lookup.js +145 -0
- package/dist/analysis/const-table-lookup.js.map +1 -0
- package/dist/analysis/constant-caller-gate.d.ts +39 -0
- package/dist/analysis/constant-caller-gate.d.ts.map +1 -0
- package/dist/analysis/constant-caller-gate.js +298 -0
- package/dist/analysis/constant-caller-gate.js.map +1 -0
- package/dist/analysis/constant-propagation/evaluator.d.ts.map +1 -1
- package/dist/analysis/constant-propagation/evaluator.js +3 -1
- package/dist/analysis/constant-propagation/evaluator.js.map +1 -1
- package/dist/analysis/constant-propagation/patterns.d.ts +19 -0
- package/dist/analysis/constant-propagation/patterns.d.ts.map +1 -1
- package/dist/analysis/constant-propagation/patterns.js +33 -0
- package/dist/analysis/constant-propagation/patterns.js.map +1 -1
- package/dist/analysis/constant-propagation/propagator.d.ts +59 -0
- package/dist/analysis/constant-propagation/propagator.d.ts.map +1 -1
- package/dist/analysis/constant-propagation/propagator.js +396 -49
- package/dist/analysis/constant-propagation/propagator.js.map +1 -1
- package/dist/analysis/constant-propagation/types.d.ts +9 -0
- package/dist/analysis/constant-propagation/types.d.ts.map +1 -1
- package/dist/analysis/findings.d.ts.map +1 -1
- package/dist/analysis/findings.js +149 -4
- package/dist/analysis/findings.js.map +1 -1
- package/dist/analysis/go-strings-replacer.d.ts +21 -0
- package/dist/analysis/go-strings-replacer.d.ts.map +1 -0
- package/dist/analysis/go-strings-replacer.js +145 -0
- package/dist/analysis/go-strings-replacer.js.map +1 -0
- package/dist/analysis/interprocedural.d.ts.map +1 -1
- package/dist/analysis/interprocedural.js +148 -0
- package/dist/analysis/interprocedural.js.map +1 -1
- package/dist/analysis/java-array-element-keys.d.ts +14 -0
- package/dist/analysis/java-array-element-keys.d.ts.map +1 -0
- package/dist/analysis/java-array-element-keys.js +36 -0
- package/dist/analysis/java-array-element-keys.js.map +1 -0
- package/dist/analysis/java-canonical-prefix.d.ts +42 -0
- package/dist/analysis/java-canonical-prefix.d.ts.map +1 -0
- package/dist/analysis/java-canonical-prefix.js +145 -0
- package/dist/analysis/java-canonical-prefix.js.map +1 -0
- package/dist/analysis/java-crlf-strip.d.ts +30 -0
- package/dist/analysis/java-crlf-strip.d.ts.map +1 -0
- package/dist/analysis/java-crlf-strip.js +195 -0
- package/dist/analysis/java-crlf-strip.js.map +1 -0
- package/dist/analysis/js-crlf-strip.d.ts +26 -0
- package/dist/analysis/js-crlf-strip.d.ts.map +1 -0
- package/dist/analysis/js-crlf-strip.js +244 -0
- package/dist/analysis/js-crlf-strip.js.map +1 -0
- package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts +2 -0
- package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts.map +1 -1
- package/dist/analysis/passes/cleartext-credential-transport-pass.js +64 -12
- package/dist/analysis/passes/cleartext-credential-transport-pass.js.map +1 -1
- package/dist/analysis/passes/cross-file-pass.d.ts.map +1 -1
- package/dist/analysis/passes/cross-file-pass.js +43 -0
- package/dist/analysis/passes/cross-file-pass.js.map +1 -1
- package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +12 -0
- package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
- package/dist/analysis/passes/csharp-sanitizer-credit.js +127 -12
- package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
- package/dist/analysis/passes/interprocedural-pass.d.ts +28 -0
- package/dist/analysis/passes/interprocedural-pass.d.ts.map +1 -1
- package/dist/analysis/passes/interprocedural-pass.js +20 -1
- package/dist/analysis/passes/interprocedural-pass.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts +76 -2
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +2045 -156
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/passes/param-source-flow-gate-pass.d.ts +8 -0
- package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
- package/dist/analysis/passes/param-source-flow-gate-pass.js +214 -6
- package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
- package/dist/analysis/passes/plaintext-password-storage-pass.d.ts +6 -0
- package/dist/analysis/passes/plaintext-password-storage-pass.d.ts.map +1 -1
- package/dist/analysis/passes/plaintext-password-storage-pass.js +160 -0
- package/dist/analysis/passes/plaintext-password-storage-pass.js.map +1 -1
- package/dist/analysis/passes/scan-secrets-pass.d.ts +1 -1
- package/dist/analysis/passes/scan-secrets-pass.d.ts.map +1 -1
- package/dist/analysis/passes/scan-secrets-pass.js +7 -1
- package/dist/analysis/passes/scan-secrets-pass.js.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.d.ts +1 -1
- package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
- package/dist/analysis/passes/sink-filter-pass.js +509 -27
- package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
- package/dist/analysis/passes/taint-matcher-pass.d.ts.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/passes/taint-propagation-pass.d.ts.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.js +1606 -60
- package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
- package/dist/analysis/passes/weak-hash-pass.d.ts +6 -0
- package/dist/analysis/passes/weak-hash-pass.d.ts.map +1 -1
- package/dist/analysis/passes/weak-hash-pass.js +191 -4
- package/dist/analysis/passes/weak-hash-pass.js.map +1 -1
- package/dist/analysis/passes/weak-random-pass.d.ts +5 -0
- package/dist/analysis/passes/weak-random-pass.d.ts.map +1 -1
- package/dist/analysis/passes/weak-random-pass.js +30 -0
- package/dist/analysis/passes/weak-random-pass.js.map +1 -1
- package/dist/analysis/project-callee-escape-gate.d.ts +40 -0
- package/dist/analysis/project-callee-escape-gate.d.ts.map +1 -0
- package/dist/analysis/project-callee-escape-gate.js +243 -0
- package/dist/analysis/project-callee-escape-gate.js.map +1 -0
- package/dist/analysis/rust-literal-guard-rebind.d.ts +34 -0
- package/dist/analysis/rust-literal-guard-rebind.d.ts.map +1 -0
- package/dist/analysis/rust-literal-guard-rebind.js +197 -0
- package/dist/analysis/rust-literal-guard-rebind.js.map +1 -0
- package/dist/analysis/rust-literal-guard.d.ts +33 -0
- package/dist/analysis/rust-literal-guard.d.ts.map +1 -0
- package/dist/analysis/rust-literal-guard.js +422 -0
- package/dist/analysis/rust-literal-guard.js.map +1 -0
- package/dist/analysis/scalar-derivation.d.ts +33 -0
- package/dist/analysis/scalar-derivation.d.ts.map +1 -0
- package/dist/analysis/scalar-derivation.js +368 -0
- package/dist/analysis/scalar-derivation.js.map +1 -0
- package/dist/analysis/taint-matcher.d.ts +2 -2
- package/dist/analysis/taint-matcher.d.ts.map +1 -1
- package/dist/analysis/taint-matcher.js +695 -34
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/analysis/taint-propagation.d.ts +26 -1
- package/dist/analysis/taint-propagation.d.ts.map +1 -1
- package/dist/analysis/taint-propagation.js +375 -24
- package/dist/analysis/taint-propagation.js.map +1 -1
- package/dist/analyzer.d.ts +16 -2
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +19 -2
- package/dist/analyzer.js.map +1 -1
- package/dist/browser/circle-ir.js +25539 -19200
- package/dist/core/circle-ir-core.cjs +3945 -2126
- package/dist/core/circle-ir-core.js +3945 -2126
- package/dist/core/extractors/calls.d.ts +12 -1
- package/dist/core/extractors/calls.d.ts.map +1 -1
- package/dist/core/extractors/calls.js +332 -22
- package/dist/core/extractors/calls.js.map +1 -1
- package/dist/core/extractors/dfg.js +336 -10
- package/dist/core/extractors/dfg.js.map +1 -1
- package/dist/core/extractors/types.d.ts +5 -1
- package/dist/core/extractors/types.d.ts.map +1 -1
- package/dist/core/extractors/types.js +62 -0
- package/dist/core/extractors/types.js.map +1 -1
- package/dist/graph/analysis-pass.d.ts +6 -1
- package/dist/graph/analysis-pass.d.ts.map +1 -1
- package/dist/graph/analysis-pass.js +2 -1
- package/dist/graph/analysis-pass.js.map +1 -1
- package/dist/graph/code-graph.d.ts +9 -0
- package/dist/graph/code-graph.d.ts.map +1 -1
- package/dist/graph/code-graph.js +11 -0
- package/dist/graph/code-graph.js.map +1 -1
- package/dist/navigation/navigation-index.d.ts +134 -14
- package/dist/navigation/navigation-index.d.ts.map +1 -1
- package/dist/navigation/navigation-index.js +1158 -104
- package/dist/navigation/navigation-index.js.map +1 -1
- package/dist/navigation/types.d.ts +59 -2
- package/dist/navigation/types.d.ts.map +1 -1
- package/dist/resolution/cross-file.d.ts +16 -0
- package/dist/resolution/cross-file.d.ts.map +1 -1
- package/dist/resolution/cross-file.js +110 -42
- package/dist/resolution/cross-file.js.map +1 -1
- package/dist/resolution/type-hierarchy.d.ts +3 -0
- package/dist/resolution/type-hierarchy.d.ts.map +1 -1
- package/dist/resolution/type-hierarchy.js +45 -1
- package/dist/resolution/type-hierarchy.js.map +1 -1
- package/dist/types/index.d.ts +33 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/wasm/web-tree-sitter.wasm +0 -0
- package/package.json +3 -3
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Matches method calls and annotations against taint configurations.
|
|
5
5
|
*/
|
|
6
|
+
import { javaWikiTagInheritedContextReceiver } from './passes/language-sources-pass.js';
|
|
6
7
|
import { DOM_ASSIGNMENT_SINK_METHODS, isProvablyLiteralExpression } from './literal-expression.js';
|
|
7
8
|
import { getDefaultConfig } from './config-loader.js';
|
|
9
|
+
import { javaReplaceCrLfCoverage } from './java-crlf-strip.js';
|
|
10
|
+
import { jsChainTextAt, jsReplaceChainVerdict } from './js-crlf-strip.js';
|
|
11
|
+
import { HTML_CONTEXT_ENCODER_METHODS } from './constant-propagation/patterns.js';
|
|
8
12
|
import { resolveIdentifierType, resolveCallReturnType, isBoundedClassType, } from './arg-type-resolver.js';
|
|
9
13
|
/**
|
|
10
14
|
* Python tainted access patterns (regex-based).
|
|
@@ -79,14 +83,14 @@ function dropCSharpObjectCarriedWaypoints(sinks, calls) {
|
|
|
79
83
|
return true;
|
|
80
84
|
});
|
|
81
85
|
}
|
|
82
|
-
export function analyzeTaint(calls, types, config = getDefaultConfig(), typeHierarchy, language, code) {
|
|
86
|
+
export function analyzeTaint(calls, types, config = getDefaultConfig(), typeHierarchy, language, code, imports) {
|
|
83
87
|
const sourceLines = code !== undefined ? code.split('\n') : undefined;
|
|
84
|
-
const sources = findSources(calls, types, config.sources, sourceLines, language);
|
|
88
|
+
const sources = findSources(calls, types, config.sources, sourceLines, language, imports);
|
|
85
89
|
let sinkPatterns = expandPromisifyAliases(config.sinks, sourceLines, language);
|
|
86
90
|
sinkPatterns = expandIndirectEvalAliases(sinkPatterns, sourceLines, language);
|
|
87
91
|
const sinks = findSinks(calls, sinkPatterns, typeHierarchy, language, sourceLines, types);
|
|
88
92
|
const gatedSinks = language === 'csharp' ? dropCSharpObjectCarriedWaypoints(sinks, calls) : sinks;
|
|
89
|
-
const sanitizers = findSanitizers(calls, types, config.sanitizers, sourceLines);
|
|
93
|
+
const sanitizers = findSanitizers(calls, types, config.sanitizers, sourceLines, language);
|
|
90
94
|
return { sources, sinks: gatedSinks, sanitizers };
|
|
91
95
|
}
|
|
92
96
|
/**
|
|
@@ -264,7 +268,7 @@ export function attachSourceLineCode(sources, sinks, code) {
|
|
|
264
268
|
/**
|
|
265
269
|
* Find taint sources in method calls and annotated parameters.
|
|
266
270
|
*/
|
|
267
|
-
function findSources(calls, types, patterns, sourceLines, language) {
|
|
271
|
+
function findSources(calls, types, patterns, sourceLines, language, imports) {
|
|
268
272
|
const sources = [];
|
|
269
273
|
// #254 T2#7: pre-filter patterns by language once at function entry.
|
|
270
274
|
// Previously the same 4-clause `pattern.languages && … !includes(language)`
|
|
@@ -281,7 +285,7 @@ function findSources(calls, types, patterns, sourceLines, language) {
|
|
|
281
285
|
// Check method calls
|
|
282
286
|
for (const call of calls) {
|
|
283
287
|
for (const pattern of patternsForLanguage) {
|
|
284
|
-
if (matchesSourcePattern(call, pattern)) {
|
|
288
|
+
if (matchesSourcePattern(call, pattern, language, types, imports)) {
|
|
285
289
|
sources.push({
|
|
286
290
|
type: pattern.type,
|
|
287
291
|
location: formatCallLocation(call),
|
|
@@ -709,6 +713,10 @@ function isInterproceduralTaintableType(typeName, language) {
|
|
|
709
713
|
if (elementType === 'String' || elementType === 'Object' || elementType === 'byte') {
|
|
710
714
|
return true;
|
|
711
715
|
}
|
|
716
|
+
// C# aliases (cognium-dev#638): `string[]` / `object[]` cross-file carriers.
|
|
717
|
+
if (language === 'csharp' && (elementType === 'string' || elementType === 'object')) {
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
712
720
|
}
|
|
713
721
|
return false;
|
|
714
722
|
}
|
|
@@ -1032,6 +1040,35 @@ function processStartInfoExe(expr, sourceLines) {
|
|
|
1032
1040
|
}
|
|
1033
1041
|
return null;
|
|
1034
1042
|
}
|
|
1043
|
+
// ---------------------------------------------------------------------------
|
|
1044
|
+
// Rust `serde_json` parse into `serde_json::Value` (cognium-dev#674).
|
|
1045
|
+
//
|
|
1046
|
+
// `serde_json` is a data-only parser: parsing into the dynamic `Value` tree
|
|
1047
|
+
// cannot instantiate an attacker-chosen type, so it is not CWE-502. A typed
|
|
1048
|
+
// target (`let c: Config = serde_json::from_str(..)`), an undeclared target
|
|
1049
|
+
// and every other format crate (`serde_yaml`, `toml`, ...) keep their sink.
|
|
1050
|
+
// Positive evidence only: the statement must carry an explicit `Value`
|
|
1051
|
+
// annotation on the binding it assigns to.
|
|
1052
|
+
// ---------------------------------------------------------------------------
|
|
1053
|
+
const RUST_VALUE_LET_RE = /\blet\s+(?:mut\s+)?[A-Za-z_]\w*\s*:\s*(?:[A-Za-z_]\w*::)*Value\s*=\s*[^;]*\bfrom_(?:str|slice|reader)\b/;
|
|
1054
|
+
function isRustSerdeJsonValueParse(call, pattern, language, sourceLines) {
|
|
1055
|
+
if (language !== 'rust' || pattern.class !== 'serde_json' || pattern.type !== 'deserialization')
|
|
1056
|
+
return false;
|
|
1057
|
+
if (!sourceLines)
|
|
1058
|
+
return false;
|
|
1059
|
+
const lineNo = call.location.line;
|
|
1060
|
+
const line = sourceLines[lineNo - 1];
|
|
1061
|
+
if (line === undefined)
|
|
1062
|
+
return false;
|
|
1063
|
+
// A `let x: Value =` that rustfmt wrapped before the call lands on the previous line.
|
|
1064
|
+
const prev = lineNo >= 2 ? (sourceLines[lineNo - 2] ?? '') : '';
|
|
1065
|
+
const stmt = (prev.trimEnd().endsWith('=') ? prev + ' ' : '') + line;
|
|
1066
|
+
// Two parse calls in one statement text: cannot attribute the annotation to this call.
|
|
1067
|
+
const parseSegments = stmt.split(';').filter((seg) => /\bfrom_(?:str|slice|reader)\b/.test(seg));
|
|
1068
|
+
if (parseSegments.length !== 1)
|
|
1069
|
+
return false;
|
|
1070
|
+
return RUST_VALUE_LET_RE.test(parseSegments[0]);
|
|
1071
|
+
}
|
|
1035
1072
|
function isSafeCSharpProcessStartCall(call, pattern, language, sourceLines) {
|
|
1036
1073
|
if (language !== 'csharp')
|
|
1037
1074
|
return false;
|
|
@@ -1056,6 +1093,12 @@ function isSafeCSharpProcessStartCall(call, pattern, language, sourceLines) {
|
|
|
1056
1093
|
if (method === 'Start' && call.arguments.length === 1) {
|
|
1057
1094
|
const arg0 = call.arguments.find(a => a.position === 0);
|
|
1058
1095
|
const expr = (arg0?.expression ?? '').trim();
|
|
1096
|
+
// `proc.Start()` surfaces its receiver as the only argument (#594): the
|
|
1097
|
+
// executable rides the `Process` object (`proc.StartInfo.FileName = …`), not a
|
|
1098
|
+
// `ProcessStartInfo` of the same name built elsewhere in the file, so a name
|
|
1099
|
+
// lookup for a constant-exe construction proves nothing here.
|
|
1100
|
+
if (call.receiver != null && call.receiver.trim() === expr)
|
|
1101
|
+
return false;
|
|
1059
1102
|
return isConstNonShellExe(processStartInfoExe(expr, sourceLines));
|
|
1060
1103
|
}
|
|
1061
1104
|
return false;
|
|
@@ -1890,6 +1933,49 @@ function isGoFprintToNonResponseWriter(call, pattern, language, sourceLines) {
|
|
|
1890
1933
|
}
|
|
1891
1934
|
return true;
|
|
1892
1935
|
}
|
|
1936
|
+
/**
|
|
1937
|
+
* JDK types whose `getOutputStream()` is an outbound channel (a remote server,
|
|
1938
|
+
* a peer socket, a child process) rather than the servlet response.
|
|
1939
|
+
*/
|
|
1940
|
+
const JAVA_OUTBOUND_STREAM_OWNERS = 'HttpURLConnection|HttpsURLConnection|URLConnection|JarURLConnection|Socket|SSLSocket|Process';
|
|
1941
|
+
/**
|
|
1942
|
+
* #671 — true when a Java xss sink is `<base>.getOutputStream().<write>(...)`
|
|
1943
|
+
* and `<base>` is declared in this file as a `URLConnection` / `Socket` /
|
|
1944
|
+
* `Process` (or the chain is `….openConnection().getOutputStream()`). Bytes
|
|
1945
|
+
* written there leave for another host or process; nothing is rendered in the
|
|
1946
|
+
* requester's browser. Positive evidence only: an unresolved base keeps the
|
|
1947
|
+
* sink.
|
|
1948
|
+
*/
|
|
1949
|
+
function isJavaOutboundOutputStreamWrite(call, pattern, language, sourceLines) {
|
|
1950
|
+
if (language !== 'java' || pattern.type !== 'xss')
|
|
1951
|
+
return false;
|
|
1952
|
+
const receiver = (call.receiver ?? '').replace(/\s+/g, '');
|
|
1953
|
+
if (!receiver.endsWith('.getOutputStream()'))
|
|
1954
|
+
return false;
|
|
1955
|
+
if (/\.openConnection\(\)\)?\.getOutputStream\(\)$/.test(receiver))
|
|
1956
|
+
return true;
|
|
1957
|
+
const m = receiver.match(/^\(?([A-Za-z_]\w*)\)?\.getOutputStream\(\)$/);
|
|
1958
|
+
if (!m || !sourceLines)
|
|
1959
|
+
return false;
|
|
1960
|
+
const declRe = new RegExp(`\\b(?:[\\w.]+\\.)?(?:${JAVA_OUTBOUND_STREAM_OWNERS})\\s+${m[1]}\\s*[=;,)]`);
|
|
1961
|
+
// Nearest declaration above the call wins; a re-declaration with another
|
|
1962
|
+
// type (shadowing) stops the walk.
|
|
1963
|
+
// `var x = …` (inferred type) also shadows, and its type is unknown here.
|
|
1964
|
+
const anyDeclRe = new RegExp(`\\b(?:[A-Z][\\w.<>,\\[\\]]*|var)\\s+${m[1]}\\s*[=;,)]`);
|
|
1965
|
+
for (let i = call.location.line - 1; i >= 0; i--) {
|
|
1966
|
+
// Strip string literals, `//` comments and `/* … */` block comments so a
|
|
1967
|
+
// commented-out declaration cannot match either regex.
|
|
1968
|
+
const line = (sourceLines[i] ?? '')
|
|
1969
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
1970
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
1971
|
+
.replace(/\/\/.*$/, '');
|
|
1972
|
+
if (declRe.test(line))
|
|
1973
|
+
return true;
|
|
1974
|
+
if (anyDeclRe.test(line))
|
|
1975
|
+
return false;
|
|
1976
|
+
}
|
|
1977
|
+
return false;
|
|
1978
|
+
}
|
|
1893
1979
|
/**
|
|
1894
1980
|
* Gate for Go `json.Unmarshal(data, &dst)` / `json.NewDecoder(r).Decode(&dst)`
|
|
1895
1981
|
* deserialization (CWE-502) sinks. Returns `true` when the destination is a
|
|
@@ -2126,6 +2212,11 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
|
|
|
2126
2212
|
if (isSafeCSharpProcessStartCall(call, pattern, language, sourceLines)) {
|
|
2127
2213
|
continue;
|
|
2128
2214
|
}
|
|
2215
|
+
// Skip `serde_json::from_*` parses whose declared target is
|
|
2216
|
+
// `serde_json::Value` — a data-only parse, not CWE-502. #674.
|
|
2217
|
+
if (isRustSerdeJsonValueParse(call, pattern, language, sourceLines)) {
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
2129
2220
|
// Skip C# SSRF sinks dominated by an inline exact-equality host
|
|
2130
2221
|
// allowlist (`if (host == "c") sink` / `if (host != "c") return; sink`).
|
|
2131
2222
|
// cognium-ai#328 shape 1.
|
|
@@ -2271,6 +2362,12 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
|
|
|
2271
2362
|
if (isGoFprintToNonResponseWriter(call, pattern, language, sourceLines)) {
|
|
2272
2363
|
continue;
|
|
2273
2364
|
}
|
|
2365
|
+
// #671 — `conn.getOutputStream().write(...)` on an outbound
|
|
2366
|
+
// URLConnection / Socket / Process writes to a remote peer or child
|
|
2367
|
+
// process, not to the HTTP response. Not xss.
|
|
2368
|
+
if (isJavaOutboundOutputStreamWrite(call, pattern, language, sourceLines)) {
|
|
2369
|
+
continue;
|
|
2370
|
+
}
|
|
2274
2371
|
// #147 — Jinja2 safe render-context. Template("literal").render(**ctx)
|
|
2275
2372
|
// and render_template_string("literal", **ctx) and Template("literal")
|
|
2276
2373
|
// alone are safe: the template SOURCE is a constant string and Jinja2
|
|
@@ -2318,7 +2415,7 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
|
|
|
2318
2415
|
/**
|
|
2319
2416
|
* Check if a call matches a source pattern.
|
|
2320
2417
|
*/
|
|
2321
|
-
function matchesSourcePattern(call, pattern) {
|
|
2418
|
+
function matchesSourcePattern(call, pattern, language, types, imports) {
|
|
2322
2419
|
// Method-based matching
|
|
2323
2420
|
if (pattern.method) {
|
|
2324
2421
|
if (call.method_name !== pattern.method) {
|
|
@@ -2351,6 +2448,14 @@ function matchesSourcePattern(call, pattern) {
|
|
|
2351
2448
|
return false;
|
|
2352
2449
|
}
|
|
2353
2450
|
}
|
|
2451
|
+
else if (language === 'java' &&
|
|
2452
|
+
types &&
|
|
2453
|
+
imports &&
|
|
2454
|
+
call.receiver &&
|
|
2455
|
+
pattern.class &&
|
|
2456
|
+
javaWikiTagInheritedContextReceiver(call.receiver, pattern.class, call, types, imports)) {
|
|
2457
|
+
// JSPWiki tag handler inherited m_wikiContext (cognium-dev#624)
|
|
2458
|
+
}
|
|
2354
2459
|
else if (!receiverMightBeClass(call.receiver, pattern.class)) {
|
|
2355
2460
|
// The receiver might be a variable name, not the class name
|
|
2356
2461
|
// For now, we do a simple match - in a full implementation,
|
|
@@ -2559,7 +2664,98 @@ const SINK_FQN_EXCLUSIONS = {
|
|
|
2559
2664
|
// Expression.accept(ExpressionVisitor), etc.
|
|
2560
2665
|
'net.sf.jsqlparser.',
|
|
2561
2666
|
],
|
|
2667
|
+
path_traversal: [
|
|
2668
|
+
// Spring `ResourceLoader.getResource(location)` resolves a classpath /
|
|
2669
|
+
// URL location, not a file path. A receiver named `loader` would
|
|
2670
|
+
// otherwise be taken for `ClassLoader` by the receiver-name heuristic
|
|
2671
|
+
// (the ClassLoader.getResource sink, #621).
|
|
2672
|
+
'org.springframework.core.io.ResourceLoader',
|
|
2673
|
+
'org.springframework.core.io.support.ResourcePatternResolver',
|
|
2674
|
+
],
|
|
2562
2675
|
};
|
|
2676
|
+
/**
|
|
2677
|
+
* Sink classes whose receiver-name heuristic is skipped when the declared
|
|
2678
|
+
* receiver type is positively known NOT to be the sink class.
|
|
2679
|
+
* `ClassLoader.getResource` is keyed on a receiver named like `loader`, which
|
|
2680
|
+
* would otherwise also take Spring's `ResourceLoader loader` for a ClassLoader
|
|
2681
|
+
* (#621 follow-up). A declared type that is merely unresolved (a user type
|
|
2682
|
+
* from another file, a type parameter, `var`, a chained call) keeps the
|
|
2683
|
+
* heuristic: it may well be a ClassLoader subclass.
|
|
2684
|
+
*/
|
|
2685
|
+
const DECLARED_TYPE_STRICT_SINK_CLASSES = {
|
|
2686
|
+
path_traversal: ['ClassLoader'],
|
|
2687
|
+
};
|
|
2688
|
+
/**
|
|
2689
|
+
* Declared receiver types known not to be a `ClassLoader`, matched by simple
|
|
2690
|
+
* name so a wildcard import (`import org.springframework.core.io.*;`), which
|
|
2691
|
+
* leaves `receiver_type_fqn` unresolved, is covered too.
|
|
2692
|
+
*/
|
|
2693
|
+
const KNOWN_NON_CLASSLOADER_SIMPLE_NAMES = new Set([
|
|
2694
|
+
'ResourceLoader',
|
|
2695
|
+
'ResourcePatternResolver',
|
|
2696
|
+
'Object',
|
|
2697
|
+
]);
|
|
2698
|
+
/** Spring resource abstractions: none of them is a `ClassLoader`. */
|
|
2699
|
+
const KNOWN_NON_CLASSLOADER_FQN_PREFIXES = ['org.springframework.core.io.'];
|
|
2700
|
+
/**
|
|
2701
|
+
* True when the type hierarchy knows `typeName` and its whole superclass chain,
|
|
2702
|
+
* and that chain ends without reaching a ClassLoader. Any link that is not in
|
|
2703
|
+
* the hierarchy (a parent declared in another file) means "unknown", never
|
|
2704
|
+
* "not a ClassLoader". Interfaces and enums are never ClassLoaders.
|
|
2705
|
+
*/
|
|
2706
|
+
function hierarchyKnowsNotSubtype(typeName, sinkClass, typeHierarchy) {
|
|
2707
|
+
let node = typeHierarchy.getType(typeName);
|
|
2708
|
+
if (!node)
|
|
2709
|
+
return false;
|
|
2710
|
+
const seen = new Set();
|
|
2711
|
+
while (node) {
|
|
2712
|
+
if (seen.has(node.fqn))
|
|
2713
|
+
return false;
|
|
2714
|
+
seen.add(node.fqn);
|
|
2715
|
+
if (node.name === sinkClass || typeHierarchy.isSubtypeOf(node.fqn, sinkClass))
|
|
2716
|
+
return false;
|
|
2717
|
+
if (node.kind === 'interface' || node.kind === 'enum')
|
|
2718
|
+
return true;
|
|
2719
|
+
if (!node.extends)
|
|
2720
|
+
return true;
|
|
2721
|
+
const parent = node.extends.replace(/<.*$/, '').trim();
|
|
2722
|
+
if (parent.split('.').pop() === sinkClass)
|
|
2723
|
+
return false;
|
|
2724
|
+
node = typeHierarchy.getType(parent);
|
|
2725
|
+
}
|
|
2726
|
+
return false;
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* True when the call's declared receiver type is positively known not to be
|
|
2730
|
+
* `pattern.class` (a ClassLoader): a Spring resource loader / resolver, `Object`,
|
|
2731
|
+
* or a type the hierarchy resolves (e.g. declared in this file) without
|
|
2732
|
+
* reaching ClassLoader. Unresolved types return false so the receiver-name
|
|
2733
|
+
* heuristic still decides.
|
|
2734
|
+
*/
|
|
2735
|
+
function declaredReceiverTypeExcludes(call, pattern, typeHierarchy) {
|
|
2736
|
+
if (!pattern.class || !DECLARED_TYPE_STRICT_SINK_CLASSES[pattern.type]?.includes(pattern.class)) {
|
|
2737
|
+
return false;
|
|
2738
|
+
}
|
|
2739
|
+
const fqn = call.receiver_type_fqn?.replace(/<.*$/, '').trim();
|
|
2740
|
+
const simpleDeclared = call.receiver_type?.replace(/<.*$/, '').trim();
|
|
2741
|
+
if (fqn && KNOWN_NON_CLASSLOADER_FQN_PREFIXES.some((prefix) => fqn.startsWith(prefix))) {
|
|
2742
|
+
return true;
|
|
2743
|
+
}
|
|
2744
|
+
if (fqn === 'java.lang.Object')
|
|
2745
|
+
return true;
|
|
2746
|
+
const declared = fqn || simpleDeclared;
|
|
2747
|
+
if (!declared)
|
|
2748
|
+
return false;
|
|
2749
|
+
const simple = declared.split('.').pop() ?? '';
|
|
2750
|
+
if (simple === '' || simple === 'var')
|
|
2751
|
+
return false;
|
|
2752
|
+
// A simple name only counts when no other FQN was resolved for it
|
|
2753
|
+
// (`com.acme.ResourceLoader` is a user type, not Spring's).
|
|
2754
|
+
if (KNOWN_NON_CLASSLOADER_SIMPLE_NAMES.has(simple) && (!fqn || fqn === simple)) {
|
|
2755
|
+
return true;
|
|
2756
|
+
}
|
|
2757
|
+
return typeHierarchy !== undefined && hierarchyKnowsNotSubtype(declared, pattern.class, typeHierarchy);
|
|
2758
|
+
}
|
|
2563
2759
|
/**
|
|
2564
2760
|
* Check if a call matches a sink pattern.
|
|
2565
2761
|
*/
|
|
@@ -2660,6 +2856,13 @@ function matchesSinkPattern(call, pattern, typeHierarchy, language) {
|
|
|
2660
2856
|
// If the mapped return type is a subtype of the sink class, accept.
|
|
2661
2857
|
// — cognium-dev #241 Java
|
|
2662
2858
|
}
|
|
2859
|
+
else if (declaredReceiverTypeExcludes(call, pattern, typeHierarchy)) {
|
|
2860
|
+
// Declared receiver type is positively known not to be the sink class
|
|
2861
|
+
// (Spring `ResourceLoader`, `Object`, a same-file class that does not
|
|
2862
|
+
// extend ClassLoader): the receiver-name heuristic below must not turn
|
|
2863
|
+
// it into the sink class. Unresolved types still use the heuristic.
|
|
2864
|
+
return false;
|
|
2865
|
+
}
|
|
2663
2866
|
else if (call.receiver && !receiverMightBeClass(call.receiver, pattern.class)) {
|
|
2664
2867
|
// Heuristic match failed; fall back to TypeHierarchyResolver if available
|
|
2665
2868
|
if (typeHierarchy && typeHierarchy.couldBeType(call.receiver, pattern.class)) {
|
|
@@ -2806,6 +3009,21 @@ function receiverMightBeClassImpl(receiver, className) {
|
|
|
2806
3009
|
if (receiver === className) {
|
|
2807
3010
|
return true;
|
|
2808
3011
|
}
|
|
3012
|
+
// Inline CommonJS require receiver: `require('child_process').exec(x)`.
|
|
3013
|
+
// The module is named at the call, so match it as if it were bound to a
|
|
3014
|
+
// name (`const cp = require('child_process'); cp.exec(x)`). (cognium-dev#682)
|
|
3015
|
+
const inlineRequire = receiver.match(/^require\(\s*['"`](?:node:)?([\w./-]+)['"`]\s*\)$/);
|
|
3016
|
+
if (inlineRequire && inlineRequire[1] === className) {
|
|
3017
|
+
return true;
|
|
3018
|
+
}
|
|
3019
|
+
// Chained JDK ClassLoader accessors: `Thread.currentThread()
|
|
3020
|
+
// .getContextClassLoader().loadClass(x)`, `getClass().getClassLoader()
|
|
3021
|
+
// .loadClass(x)`, `ClassLoader.getSystemClassLoader().loadClass(x)`.
|
|
3022
|
+
// These return a ClassLoader, so match the receiver as one. (cognium-dev#623)
|
|
3023
|
+
if (className === 'ClassLoader' &&
|
|
3024
|
+
/(?:^|\.)(?:getContextClassLoader|getClassLoader|getSystemClassLoader)\(\s*\)$/.test(receiver.replace(/\s+/g, ''))) {
|
|
3025
|
+
return true;
|
|
3026
|
+
}
|
|
2809
3027
|
// Constructor-call receiver: `ClassName(args)` — Python `Path(raw)`,
|
|
2810
3028
|
// JS `new URL(s)` (handled separately), etc. When the receiver is a
|
|
2811
3029
|
// direct function call whose function name equals the target class
|
|
@@ -3195,8 +3413,22 @@ export function isInDangerousPosition(argPosition, pattern) {
|
|
|
3195
3413
|
/**
|
|
3196
3414
|
* Find sanitizers in method calls and annotated parameters.
|
|
3197
3415
|
*/
|
|
3198
|
-
function findSanitizers(calls, types, patterns, sourceLines) {
|
|
3416
|
+
function findSanitizers(calls, types, patterns, sourceLines, language) {
|
|
3199
3417
|
const sanitizers = [];
|
|
3418
|
+
const javaReplace = (call) => {
|
|
3419
|
+
if (!sourceLines || (call.method_name !== 'replace' && call.method_name !== 'replaceAll'))
|
|
3420
|
+
return null;
|
|
3421
|
+
if (language === 'java') {
|
|
3422
|
+
return javaReplaceChainCredit(javaChainTextAt(sourceLines, call.location.line, call.location.column));
|
|
3423
|
+
}
|
|
3424
|
+
if (language === 'javascript' || language === 'typescript' || language === 'tsx') {
|
|
3425
|
+
// #562: the class-less `replace` row credits log_injection; keep it only
|
|
3426
|
+
// when the literal chain strips both CR and LF, or cannot be decided.
|
|
3427
|
+
const verdict = jsReplaceChainVerdict(jsChainTextAt(sourceLines, call.location.line, call.location.column));
|
|
3428
|
+
return verdict === 'leaves-crlf' ? [] : null;
|
|
3429
|
+
}
|
|
3430
|
+
return null;
|
|
3431
|
+
};
|
|
3200
3432
|
// Build a set of method names with @sanitizer annotation (from Javadoc comments)
|
|
3201
3433
|
const sanitizerMethods = new Set();
|
|
3202
3434
|
for (const type of types) {
|
|
@@ -3207,19 +3439,22 @@ function findSanitizers(calls, types, patterns, sourceLines) {
|
|
|
3207
3439
|
}
|
|
3208
3440
|
}
|
|
3209
3441
|
// Sprint 9 #79 (Phase L): derive wrapper sanitizers.
|
|
3210
|
-
// A method qualifies
|
|
3211
|
-
//
|
|
3442
|
+
// A method qualifies when its body is `return <sanitizer-expr>(<param>)`
|
|
3443
|
+
// (optionally `await`-prefixed), span ≤ 2 lines (signature + single return).
|
|
3212
3444
|
// We require:
|
|
3213
|
-
// 1.
|
|
3214
|
-
//
|
|
3215
|
-
//
|
|
3216
|
-
// `removes
|
|
3217
|
-
//
|
|
3218
|
-
//
|
|
3219
|
-
//
|
|
3220
|
-
//
|
|
3221
|
-
//
|
|
3445
|
+
// 1. One non-recursive call, OR every inner call is `replace` / `replaceAll`
|
|
3446
|
+
// (#517): use the call with the longest receiver for argument-aware credit.
|
|
3447
|
+
// 2. That call matches a configured sanitizer pattern with non-empty
|
|
3448
|
+
// `removes` (Java `replace` chains use `javaReplaceChainCredit`).
|
|
3449
|
+
// 3. The inner call's argument is a wrapper param, or (replace-chain only)
|
|
3450
|
+
// the receiver text starts with a param name.
|
|
3451
|
+
// 4. When source is available, the return expression is a single sanitizer
|
|
3452
|
+
// call or a param-rooted replace chain ending with `)` — rejecting shapes
|
|
3453
|
+
// like `return x + shlex.quote(x)`.
|
|
3222
3454
|
const wrapperSanitizers = new Map();
|
|
3455
|
+
// #589: wrappers whose single inner call is a context-specific HTML encoder
|
|
3456
|
+
// (every same-named wrapper, so an overload cannot lend its credit).
|
|
3457
|
+
const wrapperEncoderOnly = new Map();
|
|
3223
3458
|
for (const type of types) {
|
|
3224
3459
|
for (const method of type.methods) {
|
|
3225
3460
|
const bodySize = method.end_line - method.start_line;
|
|
@@ -3236,23 +3471,52 @@ function findSanitizers(calls, types, patterns, sourceLines) {
|
|
|
3236
3471
|
continue; // recursion guard
|
|
3237
3472
|
inside.push(c);
|
|
3238
3473
|
}
|
|
3239
|
-
if (inside.length
|
|
3474
|
+
if (inside.length === 0)
|
|
3475
|
+
continue;
|
|
3476
|
+
const allReplaceCalls = inside.every(c => c.method_name === 'replace' || c.method_name === 'replaceAll');
|
|
3477
|
+
let innerCall;
|
|
3478
|
+
let replaceChainHelper = false;
|
|
3479
|
+
if (inside.length === 1) {
|
|
3480
|
+
innerCall = inside[0];
|
|
3481
|
+
}
|
|
3482
|
+
else if (allReplaceCalls) {
|
|
3483
|
+
replaceChainHelper = true;
|
|
3484
|
+
innerCall = inside.reduce((best, c) => (c.receiver ?? '').length > (best.receiver ?? '').length ? c : best);
|
|
3485
|
+
}
|
|
3486
|
+
else {
|
|
3240
3487
|
continue;
|
|
3241
|
-
|
|
3488
|
+
}
|
|
3242
3489
|
let matched;
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3490
|
+
const replaceCredit = javaReplace(innerCall);
|
|
3491
|
+
if (replaceCredit) {
|
|
3492
|
+
matched = replaceCredit.length > 0 ? { method: innerCall.method_name, removes: replaceCredit } : undefined;
|
|
3493
|
+
}
|
|
3494
|
+
else {
|
|
3495
|
+
for (const pattern of patterns) {
|
|
3496
|
+
if (matchesSanitizerPattern(innerCall, pattern)) {
|
|
3497
|
+
matched = pattern;
|
|
3498
|
+
break;
|
|
3499
|
+
}
|
|
3247
3500
|
}
|
|
3248
3501
|
}
|
|
3249
3502
|
if (!matched || !matched.removes || matched.removes.length === 0)
|
|
3250
3503
|
continue;
|
|
3251
3504
|
let argOk = false;
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3505
|
+
if (replaceChainHelper) {
|
|
3506
|
+
const recv = innerCall.receiver?.trim() ?? '';
|
|
3507
|
+
for (const p of paramNames) {
|
|
3508
|
+
if (recv === p || recv.startsWith(`${p}.`)) {
|
|
3509
|
+
argOk = true;
|
|
3510
|
+
break;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
else {
|
|
3515
|
+
for (const arg of innerCall.arguments) {
|
|
3516
|
+
if (arg.variable && paramNames.has(arg.variable)) {
|
|
3517
|
+
argOk = true;
|
|
3518
|
+
break;
|
|
3519
|
+
}
|
|
3256
3520
|
}
|
|
3257
3521
|
}
|
|
3258
3522
|
if (!argOk)
|
|
@@ -3264,14 +3528,24 @@ function findSanitizers(calls, types, patterns, sourceLines) {
|
|
|
3264
3528
|
if (!returnMatch)
|
|
3265
3529
|
continue;
|
|
3266
3530
|
const after = returnMatch[1].replace(/;\s*$/, '').trimEnd();
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3531
|
+
if (replaceChainHelper) {
|
|
3532
|
+
const startsWithParam = [...paramNames].some(p => after === p || after.startsWith(`${p}.`));
|
|
3533
|
+
if (!startsWithParam)
|
|
3534
|
+
continue;
|
|
3535
|
+
}
|
|
3536
|
+
else {
|
|
3537
|
+
const callPrefix = innerCall.receiver
|
|
3538
|
+
? `${innerCall.receiver}.${innerCall.method_name}(`
|
|
3539
|
+
: `${innerCall.method_name}(`;
|
|
3540
|
+
if (!after.startsWith(callPrefix))
|
|
3541
|
+
continue;
|
|
3542
|
+
}
|
|
3272
3543
|
if (!after.endsWith(')'))
|
|
3273
3544
|
continue;
|
|
3274
3545
|
}
|
|
3546
|
+
const encoderWrapper = !replaceCredit && method.parameters.length === 1 &&
|
|
3547
|
+
HTML_CONTEXT_ENCODER_METHODS.has(innerCall.method_name);
|
|
3548
|
+
wrapperEncoderOnly.set(method.name, (wrapperEncoderOnly.get(method.name) ?? true) && encoderWrapper);
|
|
3275
3549
|
const existing = wrapperSanitizers.get(method.name);
|
|
3276
3550
|
if (existing) {
|
|
3277
3551
|
const set = new Set([...existing, ...matched.removes]);
|
|
@@ -3282,18 +3556,88 @@ function findSanitizers(calls, types, patterns, sourceLines) {
|
|
|
3282
3556
|
}
|
|
3283
3557
|
}
|
|
3284
3558
|
}
|
|
3559
|
+
// #589: delegate to an encoder-only wrapper in one hop (wrapEncode → encodeForPage → escapeHtml).
|
|
3560
|
+
let transitiveWrapperAdded = true;
|
|
3561
|
+
while (transitiveWrapperAdded) {
|
|
3562
|
+
transitiveWrapperAdded = false;
|
|
3563
|
+
for (const type of types) {
|
|
3564
|
+
for (const method of type.methods) {
|
|
3565
|
+
if (wrapperSanitizers.has(method.name))
|
|
3566
|
+
continue;
|
|
3567
|
+
const bodySize = method.end_line - method.start_line;
|
|
3568
|
+
if (bodySize < 0 || bodySize > 2)
|
|
3569
|
+
continue;
|
|
3570
|
+
const paramNames = new Set(method.parameters.map(p => p.name));
|
|
3571
|
+
if (paramNames.size === 0)
|
|
3572
|
+
continue;
|
|
3573
|
+
const inside = [];
|
|
3574
|
+
for (const c of calls) {
|
|
3575
|
+
if (c.location.line < method.start_line || c.location.line > method.end_line)
|
|
3576
|
+
continue;
|
|
3577
|
+
if (c.method_name === method.name)
|
|
3578
|
+
continue;
|
|
3579
|
+
inside.push(c);
|
|
3580
|
+
}
|
|
3581
|
+
if (inside.length !== 1)
|
|
3582
|
+
continue;
|
|
3583
|
+
const innerCall = inside[0];
|
|
3584
|
+
const delegateRemoves = wrapperSanitizers.get(innerCall.method_name);
|
|
3585
|
+
if (!delegateRemoves || delegateRemoves.length === 0)
|
|
3586
|
+
continue;
|
|
3587
|
+
let argOk = false;
|
|
3588
|
+
for (const arg of innerCall.arguments) {
|
|
3589
|
+
if (arg.variable && paramNames.has(arg.variable)) {
|
|
3590
|
+
argOk = true;
|
|
3591
|
+
break;
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
if (!argOk)
|
|
3595
|
+
continue;
|
|
3596
|
+
if (sourceLines) {
|
|
3597
|
+
const lineText = sourceLines[innerCall.location.line - 1] ?? '';
|
|
3598
|
+
const stripped = lineText.trim();
|
|
3599
|
+
const returnMatch = stripped.match(/^return\s+(?:await\s+)?(.*)$/);
|
|
3600
|
+
if (!returnMatch)
|
|
3601
|
+
continue;
|
|
3602
|
+
const after = returnMatch[1].replace(/;\s*$/, '').trimEnd();
|
|
3603
|
+
const callPrefix = innerCall.receiver
|
|
3604
|
+
? `${innerCall.receiver}.${innerCall.method_name}(`
|
|
3605
|
+
: `${innerCall.method_name}(`;
|
|
3606
|
+
if (!after.startsWith(callPrefix) || !after.endsWith(')'))
|
|
3607
|
+
continue;
|
|
3608
|
+
}
|
|
3609
|
+
const encoderDelegate = wrapperEncoderOnly.get(innerCall.method_name) === true;
|
|
3610
|
+
wrapperEncoderOnly.set(method.name, encoderDelegate);
|
|
3611
|
+
wrapperSanitizers.set(method.name, [...delegateRemoves]);
|
|
3612
|
+
transitiveWrapperAdded = true;
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3285
3616
|
// Emit a derived-sanitizer entry at each call site to a wrapper method.
|
|
3286
3617
|
for (const call of calls) {
|
|
3287
3618
|
const removes = wrapperSanitizers.get(call.method_name);
|
|
3288
3619
|
if (!removes)
|
|
3289
3620
|
continue;
|
|
3290
3621
|
sanitizers.push({
|
|
3291
|
-
|
|
3622
|
+
// #589: a wrapper of an HTML encoder cleans `xss` but not `trust_boundary`,
|
|
3623
|
+
// like the encoder itself, so it is told apart from other wrappers.
|
|
3624
|
+
type: wrapperEncoderOnly.get(call.method_name) ? 'derived_html_encoder' : 'derived_wrapper',
|
|
3292
3625
|
method: formatSanitizerMethod(call),
|
|
3293
3626
|
line: call.location.line,
|
|
3294
3627
|
sanitizes: removes,
|
|
3295
3628
|
});
|
|
3296
3629
|
}
|
|
3630
|
+
const noopLocalHelpers = !sourceLines
|
|
3631
|
+
? new Set()
|
|
3632
|
+
: language === 'java'
|
|
3633
|
+
? findJavaNoopHelpers(types, sourceLines)
|
|
3634
|
+
: language === 'python'
|
|
3635
|
+
? findPythonNoopHelpers(types, sourceLines)
|
|
3636
|
+
: language === 'go'
|
|
3637
|
+
? findGoNoopHelpers(types, sourceLines)
|
|
3638
|
+
: language === 'javascript' || language === 'typescript' || language === 'tsx'
|
|
3639
|
+
? findJsNoopHelpers(types, sourceLines)
|
|
3640
|
+
: new Set();
|
|
3297
3641
|
// Check method calls for sanitizer methods
|
|
3298
3642
|
for (const call of calls) {
|
|
3299
3643
|
// Check if this call is to a method with @sanitizer annotation
|
|
@@ -3306,8 +3650,23 @@ function findSanitizers(calls, types, patterns, sourceLines) {
|
|
|
3306
3650
|
});
|
|
3307
3651
|
continue; // Skip pattern matching - already added as sanitizer
|
|
3308
3652
|
}
|
|
3653
|
+
const replaceCredit = javaReplace(call);
|
|
3654
|
+
if (replaceCredit) {
|
|
3655
|
+
if (replaceCredit.length > 0) {
|
|
3656
|
+
sanitizers.push({
|
|
3657
|
+
type: 'method_call',
|
|
3658
|
+
method: formatSanitizerMethod(call),
|
|
3659
|
+
line: call.location.line,
|
|
3660
|
+
sanitizes: replaceCredit,
|
|
3661
|
+
});
|
|
3662
|
+
}
|
|
3663
|
+
continue;
|
|
3664
|
+
}
|
|
3309
3665
|
for (const pattern of patterns) {
|
|
3310
3666
|
if (matchesSanitizerPattern(call, pattern)) {
|
|
3667
|
+
if (!pattern.class && noopLocalHelpers.has(call.method_name) && (!call.receiver || call.receiver === 'this' || call.receiver === 'self')) {
|
|
3668
|
+
continue; // same-file helper that does no escaping: not credited by name (#581)
|
|
3669
|
+
}
|
|
3311
3670
|
sanitizers.push({
|
|
3312
3671
|
type: determineSanitizerType(pattern),
|
|
3313
3672
|
method: formatSanitizerMethod(call),
|
|
@@ -3336,6 +3695,308 @@ function findSanitizers(calls, types, patterns, sourceLines) {
|
|
|
3336
3695
|
}
|
|
3337
3696
|
return sanitizers;
|
|
3338
3697
|
}
|
|
3698
|
+
/**
|
|
3699
|
+
* Names of methods defined in this file whose every definition is a no-op
|
|
3700
|
+
* shape: a single `return <expr>;` built only from the method's own
|
|
3701
|
+
* parameters, string literals, `+` and the pass-through calls `trim`, `strip`,
|
|
3702
|
+
* `toString`, `toLowerCase` and `toUpperCase`. A class-less sanitizer row must
|
|
3703
|
+
* not credit a call to such a helper by name (#581). Any other body (a
|
|
3704
|
+
* `replace`, a library call, a branch, a loop) keeps the by-name credit, and so
|
|
3705
|
+
* does a name that has a non-no-op overload.
|
|
3706
|
+
*/
|
|
3707
|
+
function findJavaNoopHelpers(types, sourceLines) {
|
|
3708
|
+
const noop = new Set();
|
|
3709
|
+
const other = new Set();
|
|
3710
|
+
for (const type of types) {
|
|
3711
|
+
for (const method of type.methods) {
|
|
3712
|
+
(isJavaNoopBody(method, sourceLines) ? noop : other).add(method.name);
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
for (const name of other)
|
|
3716
|
+
noop.delete(name);
|
|
3717
|
+
return noop;
|
|
3718
|
+
}
|
|
3719
|
+
function isJavaNoopBody(method, sourceLines) {
|
|
3720
|
+
if (method.parameters.length === 0)
|
|
3721
|
+
return false;
|
|
3722
|
+
const text = sourceLines.slice(method.start_line - 1, method.end_line).join('\n');
|
|
3723
|
+
const open = text.indexOf('{');
|
|
3724
|
+
const close = text.lastIndexOf('}');
|
|
3725
|
+
if (open < 0 || close < open)
|
|
3726
|
+
return false;
|
|
3727
|
+
const m = /^\s*return\s+([^;{}]+);\s*$/.exec(text.slice(open + 1, close));
|
|
3728
|
+
if (!m)
|
|
3729
|
+
return false;
|
|
3730
|
+
const params = new Set(method.parameters.map(p => p.name));
|
|
3731
|
+
const expr = m[1]
|
|
3732
|
+
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
|
3733
|
+
.replace(/\.(?:trim|strip|toString|toLowerCase|toUpperCase)\(\)/g, '');
|
|
3734
|
+
const leftovers = expr.replace(/""|[+()\s]/g, ' ').trim();
|
|
3735
|
+
if (leftovers === '')
|
|
3736
|
+
return true;
|
|
3737
|
+
return leftovers.split(/\s+/).every(tok => params.has(tok));
|
|
3738
|
+
}
|
|
3739
|
+
/**
|
|
3740
|
+
* Python twin of `findJavaNoopHelpers` (#581): a `def` whose body is one
|
|
3741
|
+
* `return <expr>` built only from its own parameters, string literals, `+`, the
|
|
3742
|
+
* pass-through methods `strip` / `lstrip` / `rstrip` / `lower` / `upper` and the
|
|
3743
|
+
* `str()` call. Any other body (a `re.sub`, `replace`, `html.escape`, a branch,
|
|
3744
|
+
* a loop, a second statement) keeps the by-name credit, and so does a name with a
|
|
3745
|
+
* non-no-op definition elsewhere in the file.
|
|
3746
|
+
*/
|
|
3747
|
+
function findPythonNoopHelpers(types, sourceLines) {
|
|
3748
|
+
const noop = new Set();
|
|
3749
|
+
const other = new Set();
|
|
3750
|
+
for (const type of types) {
|
|
3751
|
+
for (const method of type.methods) {
|
|
3752
|
+
(isPythonNoopBody(method, sourceLines) ? noop : other).add(method.name);
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
for (const name of other)
|
|
3756
|
+
noop.delete(name);
|
|
3757
|
+
return noop;
|
|
3758
|
+
}
|
|
3759
|
+
function isPythonNoopBody(method, sourceLines) {
|
|
3760
|
+
if (method.parameters.length === 0)
|
|
3761
|
+
return false;
|
|
3762
|
+
const text = sourceLines.slice(method.start_line - 1, method.end_line).join('\n');
|
|
3763
|
+
// `def name(params) -> T:` then the body, on the same line or below. The header is
|
|
3764
|
+
// matched without crossing a newline inside a default value or annotation.
|
|
3765
|
+
const header = /^\s*(?:async\s+)?def\s+\w+\s*\(([^()]*)\)\s*(?:->\s*[\w.\[\], ]+)?\s*:/.exec(text);
|
|
3766
|
+
if (!header)
|
|
3767
|
+
return false;
|
|
3768
|
+
const body = text.slice(header[0].length).replace(/#.*$/gm, '').trim();
|
|
3769
|
+
const m = /^return\s+([^\n;]+)$/.exec(body);
|
|
3770
|
+
if (!m)
|
|
3771
|
+
return false;
|
|
3772
|
+
const params = new Set(method.parameters.map(p => p.name.replace(/^\*+/, '')));
|
|
3773
|
+
const expr = m[1]
|
|
3774
|
+
.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""')
|
|
3775
|
+
.replace(/\.(?:strip|lstrip|rstrip|lower|upper)\(\)/g, '')
|
|
3776
|
+
.replace(/\bstr\(/g, '(');
|
|
3777
|
+
const leftovers = expr.replace(/""|[+()\s]/g, ' ').trim();
|
|
3778
|
+
if (leftovers === '')
|
|
3779
|
+
return true;
|
|
3780
|
+
return leftovers.split(/\s+/).every(tok => params.has(tok));
|
|
3781
|
+
}
|
|
3782
|
+
/**
|
|
3783
|
+
* Go twin of `findJavaNoopHelpers` (#581): a `func` whose body is one
|
|
3784
|
+
* `return <expr>` built only from its own parameters, string literals, `+`, the
|
|
3785
|
+
* pass-through calls `strings.TrimSpace` / `strings.ToLower` / `strings.ToUpper`
|
|
3786
|
+
* and the `string()` conversion. Any other body (a `strings.ReplaceAll`, an
|
|
3787
|
+
* escaper call, a branch, a loop, a second statement) keeps the by-name credit,
|
|
3788
|
+
* and so does a name with a non-no-op definition elsewhere in the file, a method
|
|
3789
|
+
* or a function alike.
|
|
3790
|
+
*/
|
|
3791
|
+
function findGoNoopHelpers(types, sourceLines) {
|
|
3792
|
+
const noop = new Set();
|
|
3793
|
+
const other = new Set();
|
|
3794
|
+
for (const type of types) {
|
|
3795
|
+
for (const method of type.methods) {
|
|
3796
|
+
(isGoNoopBody(method, sourceLines) ? noop : other).add(method.name);
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
for (const name of other)
|
|
3800
|
+
noop.delete(name);
|
|
3801
|
+
// A local of the same name (`sanitize := func(v string) string { … }`) is a
|
|
3802
|
+
// different helper whose body is not read here.
|
|
3803
|
+
const src = sourceLines.join('\n');
|
|
3804
|
+
for (const name of [...noop]) {
|
|
3805
|
+
if (new RegExp(`(?<![\\w.])${name}\\s*(?::=|=(?!=)|,\\s*\\w+\\s*:=)`).test(src))
|
|
3806
|
+
noop.delete(name);
|
|
3807
|
+
}
|
|
3808
|
+
return noop;
|
|
3809
|
+
}
|
|
3810
|
+
function isGoNoopBody(method, sourceLines) {
|
|
3811
|
+
if (method.parameters.length === 0)
|
|
3812
|
+
return false;
|
|
3813
|
+
const text = sourceLines.slice(method.start_line - 1, method.end_line).join('\n');
|
|
3814
|
+
// `func name(params) T {` — a plain function; a method (`func (s *T) name`) is judged by a
|
|
3815
|
+
// call through its receiver, which is never skipped, so it is not a no-op helper here.
|
|
3816
|
+
const header = /^\s*func\s+\w+\s*\(([^()]*)\)\s*\(?[\w.\[\]*, ]*\)?\s*\{/.exec(text);
|
|
3817
|
+
if (!header)
|
|
3818
|
+
return false;
|
|
3819
|
+
const close = text.lastIndexOf('}');
|
|
3820
|
+
if (close < header[0].length)
|
|
3821
|
+
return false;
|
|
3822
|
+
const body = text.slice(header[0].length, close).replace(/\/\/.*$/gm, '').trim();
|
|
3823
|
+
const m = /^return\s+([^;{}]+)$/.exec(body);
|
|
3824
|
+
if (!m)
|
|
3825
|
+
return false;
|
|
3826
|
+
const params = new Set(method.parameters.map(p => p.name.replace(/^\.+/, '')));
|
|
3827
|
+
for (const seg of header[1].split(',')) {
|
|
3828
|
+
const name = seg.trim().split(/\s+/)[0]?.replace(/^\.+/, '');
|
|
3829
|
+
if (name)
|
|
3830
|
+
params.add(name);
|
|
3831
|
+
}
|
|
3832
|
+
const expr = m[1]
|
|
3833
|
+
.replace(/"(?:\\.|[^"\\])*"|`[^`]*`/g, '""')
|
|
3834
|
+
.replace(/\bstrings\s*\.\s*(?:TrimSpace|ToLower|ToUpper)\s*\(/g, '(')
|
|
3835
|
+
.replace(/\bstring\s*\(/g, '(');
|
|
3836
|
+
const leftovers = expr.replace(/""|[+()\s]/g, ' ').trim();
|
|
3837
|
+
if (leftovers === '')
|
|
3838
|
+
return true;
|
|
3839
|
+
return leftovers.split(/\s+/).every(tok => params.has(tok));
|
|
3840
|
+
}
|
|
3841
|
+
/**
|
|
3842
|
+
* JavaScript / TypeScript twin of `findJavaNoopHelpers` (#581): a `function` or
|
|
3843
|
+
* method whose body is one `return <expr>` (or an arrow expression) built only
|
|
3844
|
+
* from its own parameters, string literals, `+`, and the pass-through calls
|
|
3845
|
+
* `trim` / `toLowerCase` / `toUpperCase` / `toString` / `String()`. Any other
|
|
3846
|
+
* body keeps the by-name credit, and so does a name with a non-no-op overload.
|
|
3847
|
+
*/
|
|
3848
|
+
function findJsNoopHelpers(types, sourceLines) {
|
|
3849
|
+
const fromDecl = new Set();
|
|
3850
|
+
const otherDecl = new Set();
|
|
3851
|
+
for (const type of types) {
|
|
3852
|
+
for (const method of type.methods) {
|
|
3853
|
+
(isJsNoopBody(method, sourceLines) ? fromDecl : otherDecl).add(method.name);
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
for (const name of otherDecl)
|
|
3857
|
+
fromDecl.delete(name);
|
|
3858
|
+
const src = sourceLines.join('\n');
|
|
3859
|
+
for (const name of [...fromDecl]) {
|
|
3860
|
+
if (new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).test(src))
|
|
3861
|
+
fromDecl.delete(name);
|
|
3862
|
+
}
|
|
3863
|
+
const fromBinding = new Set();
|
|
3864
|
+
const otherBinding = new Set();
|
|
3865
|
+
for (const line of sourceLines) {
|
|
3866
|
+
const arrow = /(?:const|let|var)\s+(\w+)\s*=\s*\(([^)]*)\)\s*=>\s*([^;{]+)\s*;/.exec(line);
|
|
3867
|
+
if (arrow) {
|
|
3868
|
+
const params = jsParamNamesFromList(arrow[2]);
|
|
3869
|
+
if (params.size > 0 && jsNoopReturnExpr(arrow[3], params))
|
|
3870
|
+
fromBinding.add(arrow[1]);
|
|
3871
|
+
else
|
|
3872
|
+
otherBinding.add(arrow[1]);
|
|
3873
|
+
}
|
|
3874
|
+
const fnExpr = /(?:const|let|var)\s+(\w+)\s*=\s*function\s*\(([^)]*)\)\s*\{\s*return\s+([^;{}]+);\s*\}\s*;/.exec(line);
|
|
3875
|
+
if (fnExpr) {
|
|
3876
|
+
const params = jsParamNamesFromList(fnExpr[2]);
|
|
3877
|
+
if (params.size > 0 && jsNoopReturnExpr(fnExpr[3], params))
|
|
3878
|
+
fromBinding.add(fnExpr[1]);
|
|
3879
|
+
else
|
|
3880
|
+
otherBinding.add(fnExpr[1]);
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
for (const name of otherBinding)
|
|
3884
|
+
fromBinding.delete(name);
|
|
3885
|
+
return new Set([...fromDecl, ...fromBinding]);
|
|
3886
|
+
}
|
|
3887
|
+
function jsParamNamesFromList(list) {
|
|
3888
|
+
const params = new Set();
|
|
3889
|
+
for (const seg of list.split(',')) {
|
|
3890
|
+
const name = seg.trim().split(':')[0]?.trim().replace(/^\.\.\./, '');
|
|
3891
|
+
if (name)
|
|
3892
|
+
params.add(name);
|
|
3893
|
+
}
|
|
3894
|
+
return params;
|
|
3895
|
+
}
|
|
3896
|
+
function isJsNoopBody(method, sourceLines) {
|
|
3897
|
+
if (method.parameters.length === 0)
|
|
3898
|
+
return false;
|
|
3899
|
+
const text = sourceLines.slice(method.start_line - 1, method.end_line).join('\n');
|
|
3900
|
+
const params = new Set(method.parameters.map(p => p.name.replace(/^\.\.\./, '')));
|
|
3901
|
+
const arrowExpr = /(?:\([^)]*\)|[\w$]+)\s*=>\s*([^{;]+?)\s*$/.exec(text.trim());
|
|
3902
|
+
if (arrowExpr) {
|
|
3903
|
+
return jsNoopReturnExpr(arrowExpr[1], params);
|
|
3904
|
+
}
|
|
3905
|
+
const open = text.indexOf('{');
|
|
3906
|
+
const close = text.lastIndexOf('}');
|
|
3907
|
+
if (open < 0 || close < open)
|
|
3908
|
+
return false;
|
|
3909
|
+
const body = text.slice(open + 1, close).replace(/\/\/.*$/gm, '').trim();
|
|
3910
|
+
const m = /^\s*return\s+([^;{}]+);\s*$/.exec(body);
|
|
3911
|
+
if (!m)
|
|
3912
|
+
return false;
|
|
3913
|
+
return jsNoopReturnExpr(m[1], params);
|
|
3914
|
+
}
|
|
3915
|
+
function jsNoopReturnExpr(expr, params) {
|
|
3916
|
+
const stripped = expr
|
|
3917
|
+
.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, '""')
|
|
3918
|
+
.replace(/\.(?:trim|toLowerCase|toUpperCase|toString)\(\)/g, '')
|
|
3919
|
+
.replace(/\bString\s*\(/g, '(');
|
|
3920
|
+
const leftovers = stripped.replace(/""|[+()\s]/g, ' ').trim();
|
|
3921
|
+
if (leftovers === '')
|
|
3922
|
+
return true;
|
|
3923
|
+
return leftovers.split(/\s+/).every(tok => params.has(tok));
|
|
3924
|
+
}
|
|
3925
|
+
const JAVA_LIT = String.raw `(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')`;
|
|
3926
|
+
const JAVA_REPLACE_ARGS_RE = new RegExp(String.raw `\.(replace|replaceAll)\(\s*${JAVA_LIT}\s*,\s*${JAVA_LIT}\s*\)`, 'g');
|
|
3927
|
+
/**
|
|
3928
|
+
* The text of the call chain that starts at (line, column): the receiver
|
|
3929
|
+
* expression plus the `.method(...)` segments after it, up to the first
|
|
3930
|
+
* character that cannot continue the chain at nesting depth 0 (`;`, `,`, an
|
|
3931
|
+
* operator, an unmatched closing bracket). A call reports the start of the
|
|
3932
|
+
* whole chain it ends, so `a.replace("\n", "")` and `b.replace("\r", "")` on one
|
|
3933
|
+
* line start at different columns and never credit each other. The chain may
|
|
3934
|
+
* continue over following lines.
|
|
3935
|
+
*/
|
|
3936
|
+
function javaChainTextAt(sourceLines, line, column) {
|
|
3937
|
+
const text = [(sourceLines[line - 1] ?? '').slice(column), ...sourceLines.slice(line, line + 7)].join('\n');
|
|
3938
|
+
let depth = 0;
|
|
3939
|
+
let i = 0;
|
|
3940
|
+
for (; i < text.length; i++) {
|
|
3941
|
+
const c = text[i];
|
|
3942
|
+
if (c === '"' || c === "'") {
|
|
3943
|
+
for (i++; i < text.length && text[i] !== c; i++)
|
|
3944
|
+
if (text[i] === '\\')
|
|
3945
|
+
i++;
|
|
3946
|
+
continue;
|
|
3947
|
+
}
|
|
3948
|
+
if (c === '(' || c === '[') {
|
|
3949
|
+
depth++;
|
|
3950
|
+
continue;
|
|
3951
|
+
}
|
|
3952
|
+
if (c === ')' || c === ']') {
|
|
3953
|
+
if (depth === 0)
|
|
3954
|
+
break;
|
|
3955
|
+
depth--;
|
|
3956
|
+
continue;
|
|
3957
|
+
}
|
|
3958
|
+
if (depth === 0 && !/[\w$.\s]/.test(c))
|
|
3959
|
+
break;
|
|
3960
|
+
}
|
|
3961
|
+
return text.slice(0, i);
|
|
3962
|
+
}
|
|
3963
|
+
/**
|
|
3964
|
+
* Java `String.replace` / `replaceAll` credit, decided from the literal
|
|
3965
|
+
* arguments of the replace chain that contains the call instead of the method name
|
|
3966
|
+
* (cognium-dev#562 / #605 / #517). The class-less `replace` row credited any
|
|
3967
|
+
* replace as a log-injection sanitizer, so an LF-only strip silenced the
|
|
3968
|
+
* finding while `replaceAll("[\r\n]", "")` got no credit at all.
|
|
3969
|
+
* - `log_injection`: the chain strips both CR and LF (double-quoted String or single-quoted char literals).
|
|
3970
|
+
* - `xss`: the chain maps both `<` and `>` to an entity.
|
|
3971
|
+
* Returns the credited sink types (possibly empty).
|
|
3972
|
+
*/
|
|
3973
|
+
function javaReplaceChainCredit(chainText) {
|
|
3974
|
+
let cr = false;
|
|
3975
|
+
let lf = false;
|
|
3976
|
+
let lt = false;
|
|
3977
|
+
let gt = false;
|
|
3978
|
+
for (const m of chainText.matchAll(JAVA_REPLACE_ARGS_RE)) {
|
|
3979
|
+
const kind = m[1];
|
|
3980
|
+
const rawFrom = m[2] ?? m[3] ?? '';
|
|
3981
|
+
const from = rawFrom.replace(/\\\\/g, '\\');
|
|
3982
|
+
const to = m[4] ?? m[5] ?? '';
|
|
3983
|
+
const cov = javaReplaceCrLfCoverage(kind, rawFrom);
|
|
3984
|
+
if (cov.cr)
|
|
3985
|
+
cr = true;
|
|
3986
|
+
if (cov.lf)
|
|
3987
|
+
lf = true;
|
|
3988
|
+
if (from === '<' && to.startsWith('&'))
|
|
3989
|
+
lt = true;
|
|
3990
|
+
if (from === '>' && to.startsWith('&'))
|
|
3991
|
+
gt = true;
|
|
3992
|
+
}
|
|
3993
|
+
const out = [];
|
|
3994
|
+
if (cr && lf)
|
|
3995
|
+
out.push('log_injection');
|
|
3996
|
+
if (lt && gt)
|
|
3997
|
+
out.push('xss');
|
|
3998
|
+
return out;
|
|
3999
|
+
}
|
|
3339
4000
|
/**
|
|
3340
4001
|
* Check if a call matches a sanitizer pattern.
|
|
3341
4002
|
*/
|