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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { CodeGraph } from '../graph/index.js';
|
|
8
8
|
import { walkBackwardDefs } from './dfg-walk.js';
|
|
9
9
|
import { sanitizerCoversSink } from './sanitizer-index.js';
|
|
10
|
+
import { HTML_CONTEXT_ENCODER_METHODS, HTML_CONTEXT_ENCODER_UNCOVERED_SINKS } from './constant-propagation/patterns.js';
|
|
10
11
|
/**
|
|
11
12
|
* Build a line → sanitizers index from a list of sanitizers.
|
|
12
13
|
*
|
|
@@ -30,11 +31,19 @@ function buildSanitizersByLine(sanitizers) {
|
|
|
30
31
|
* Accepts either a CodeGraph (preferred) or the legacy (dfg, calls, ...) signature
|
|
31
32
|
* for backward compatibility with existing call sites and tests.
|
|
32
33
|
*/
|
|
34
|
+
/** Number of `${…}` interpolations in a JS/TS template-literal argument. */
|
|
35
|
+
function templateInterpolationCount(expression) {
|
|
36
|
+
if (!expression.trimStart().startsWith('`'))
|
|
37
|
+
return 0;
|
|
38
|
+
return (expression.match(/\$\{/g) ?? []).length;
|
|
39
|
+
}
|
|
33
40
|
/** True when `+` appears outside string literals. */
|
|
34
41
|
function concatOutsideStrings(expression) {
|
|
35
42
|
return expression.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, ' ').includes('+');
|
|
36
43
|
}
|
|
37
|
-
export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinksOrSanitizers, sanitizersArg
|
|
44
|
+
export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinksOrSanitizers, sanitizersArg,
|
|
45
|
+
/** Optional source text — used when DFG defs lack `expression` (Java locals). */
|
|
46
|
+
sourceCode) {
|
|
38
47
|
let graph;
|
|
39
48
|
let sources;
|
|
40
49
|
let sinks;
|
|
@@ -63,6 +72,7 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
|
|
|
63
72
|
const taintedVars = [];
|
|
64
73
|
const flows = [];
|
|
65
74
|
const javaConcat = graph.ir.meta.language === 'java';
|
|
75
|
+
const jsLike = graph.ir.meta.language === 'javascript' || graph.ir.meta.language === 'typescript';
|
|
66
76
|
const reachableSinks = new Map();
|
|
67
77
|
// Use pre-computed indexes from CodeGraph — no local map building needed
|
|
68
78
|
const defsByLine = graph.defsByLine;
|
|
@@ -77,27 +87,51 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
|
|
|
77
87
|
? buildSanitizersByLine(sanitizers)
|
|
78
88
|
: graph.sanitizersByLine;
|
|
79
89
|
const defById = graph.defById;
|
|
90
|
+
const sourceLines = typeof sourceCode === 'string' ? sourceCode.split('\n') : [];
|
|
91
|
+
const lineText = (line) => {
|
|
92
|
+
const raw = sourceLines[line - 1];
|
|
93
|
+
return raw !== undefined ? raw : undefined;
|
|
94
|
+
};
|
|
80
95
|
// Step 1: Identify initial tainted definitions (from sources)
|
|
81
|
-
const rawInitialTaint = findInitialTaint(sources, callsByLine, defsByLine);
|
|
96
|
+
const rawInitialTaint = findInitialTaint(sources, callsByLine, defsByLine, graph.chainsByFromDef, jsLike, lineText);
|
|
82
97
|
// Filter variables added via the "next-line" heuristic that are actually the
|
|
83
98
|
// result of a sanitizer call. The source variable itself (tv.line ===
|
|
84
99
|
// tv.sourceLine) is always tainted; next-line additions need an extra check.
|
|
85
|
-
const
|
|
100
|
+
const filteredInitialTaint = rawInitialTaint.filter(tv => {
|
|
86
101
|
if (tv.line === tv.sourceLine)
|
|
87
102
|
return true;
|
|
103
|
+
if (graph.taintBarrierDefIds.has(tv.defId))
|
|
104
|
+
return false;
|
|
88
105
|
const sanCheck = checkSanitized(tv.sourceLine, tv.line, tv.sourceType, sanitizersByLine);
|
|
89
106
|
return !sanCheck.sanitized;
|
|
90
107
|
});
|
|
108
|
+
// Propagation is a BFS that credits each definition to the first seed that
|
|
109
|
+
// reaches it. A bare function parameter (`interprocedural_param`) is only
|
|
110
|
+
// speculative evidence, and its line usually precedes the handler that
|
|
111
|
+
// reads real input, so seeding it first let it claim definitions that a
|
|
112
|
+
// real source also reaches: `const ident = async (x) => x; … v = await
|
|
113
|
+
// ident(req.query.u)` credited `v` to `x`, and the flow was then dropped
|
|
114
|
+
// as parameter-only. Seed real sources first so they win any shared
|
|
115
|
+
// definition; parameter-only reach is unchanged.
|
|
116
|
+
const initialTaint = [
|
|
117
|
+
...filteredInitialTaint.filter(tv => tv.sourceType !== 'interprocedural_param'),
|
|
118
|
+
...filteredInitialTaint.filter(tv => tv.sourceType === 'interprocedural_param'),
|
|
119
|
+
];
|
|
91
120
|
taintedVars.push(...initialTaint);
|
|
92
121
|
// Step 2: Propagate taint through def-use chains
|
|
93
|
-
const propagatedTaint = propagateThroughChains(initialTaint, graph.chainsByFromDef, defById, sanitizersByLine);
|
|
94
|
-
|
|
122
|
+
const propagatedTaint = propagateThroughChains(initialTaint, graph.chainsByFromDef, defById, sanitizersByLine, graph.taintBarrierDefIds, lineText);
|
|
123
|
+
// #589: variables that survived only an HTML-encoder hop (`survivesOnlyFor`)
|
|
124
|
+
// are not general taint, so they stay out of the reported `taintedVars`; they
|
|
125
|
+
// still seed the sink check below, which lets them reach only the sink types
|
|
126
|
+
// the encoder does not clean (`trust_boundary`).
|
|
127
|
+
taintedVars.push(...propagatedTaint.filter(tv => !tv.survivesOnlyFor));
|
|
95
128
|
// Combine all tainted definitions
|
|
96
129
|
const allTaintedDefIds = new Set();
|
|
97
130
|
const taintByDefId = new Map();
|
|
98
|
-
for (const tv of taintedVars) {
|
|
131
|
+
for (const tv of [...taintedVars, ...propagatedTaint.filter(tv => tv.survivesOnlyFor)]) {
|
|
99
132
|
allTaintedDefIds.add(tv.defId);
|
|
100
|
-
taintByDefId.
|
|
133
|
+
if (!yieldsToRealSource(taintByDefId.get(tv.defId), tv))
|
|
134
|
+
taintByDefId.set(tv.defId, tv);
|
|
101
135
|
}
|
|
102
136
|
// Step 3: Check which sinks are reachable from tainted variables
|
|
103
137
|
for (const sink of sinks) {
|
|
@@ -125,7 +159,11 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
|
|
|
125
159
|
// A constant prefix must not hide a later tainted operand
|
|
126
160
|
// (cognium-dev#627).
|
|
127
161
|
const expr = typeof arg.expression === 'string' ? arg.expression : '';
|
|
128
|
-
|
|
162
|
+
// cognium-dev#563: a JS/TS template literal names its first
|
|
163
|
+
// interpolated identifier as `arg.variable`; a sanitized first value
|
|
164
|
+
// (`${n}` from `parseInt`) must not hide a raw `${to}` after it.
|
|
165
|
+
const compoundConcat = !!arg.variable && expr !== arg.variable && ((javaConcat && concatOutsideStrings(expr)) ||
|
|
166
|
+
(jsLike && templateInterpolationCount(expr) > 1));
|
|
129
167
|
const candidateUses = arg.variable && !compoundConcat
|
|
130
168
|
? usesAtSink.filter(u => u.variable === arg.variable)
|
|
131
169
|
: usesAtSink;
|
|
@@ -146,6 +184,10 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
|
|
|
146
184
|
continue;
|
|
147
185
|
}
|
|
148
186
|
const taintInfo = taintByDefId.get(use.def_id);
|
|
187
|
+
// #589: taint that survived only an HTML-encoder hop is clean for
|
|
188
|
+
// every sink type except the ones it still reaches.
|
|
189
|
+
if (taintInfo?.survivesOnlyFor && !taintInfo.survivesOnlyFor.has(sink.type))
|
|
190
|
+
continue;
|
|
149
191
|
if (taintInfo) {
|
|
150
192
|
// Check if sanitized.
|
|
151
193
|
//
|
|
@@ -215,12 +257,142 @@ export function propagateTaint(graphOrDfg, callsOrSources, sourcesOrSinks, sinks
|
|
|
215
257
|
/**
|
|
216
258
|
* Find initial tainted definitions from sources.
|
|
217
259
|
*/
|
|
218
|
-
|
|
260
|
+
/** Locations of sources that name exactly one parameter binding (#586, #587). */
|
|
261
|
+
const PARAM_SCOPED_SOURCE_RE = /^(?:GraphQL \w+\.\w+ resolver args|Express handler destructured request) /;
|
|
262
|
+
/**
|
|
263
|
+
* True when `code` (one trimmed source line) is an `if` / `else if` / `while`
|
|
264
|
+
* header whose parenthesised condition closes on that same line.
|
|
265
|
+
*/
|
|
266
|
+
function isCompleteConditionHeader(code) {
|
|
267
|
+
if (!code)
|
|
268
|
+
return false;
|
|
269
|
+
const m = /^(?:\}\s*)?(?:else\s+)?(?:if|while)\s*\(/.exec(code);
|
|
270
|
+
if (!m)
|
|
271
|
+
return false;
|
|
272
|
+
// Blank out string / char literals so their parens do not count.
|
|
273
|
+
const text = code.slice(m[0].length - 1).replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, '""');
|
|
274
|
+
let depth = 0;
|
|
275
|
+
for (const ch of text) {
|
|
276
|
+
if (ch === '(')
|
|
277
|
+
depth++;
|
|
278
|
+
else if (ch === ')' && --depth === 0)
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* True when `code` (one trimmed source line) holds a second statement after a
|
|
285
|
+
* `;` (`a := f(); b := "x"`). A `for (…;…;…)` header and a trailing comment
|
|
286
|
+
* or closing brace do not count.
|
|
287
|
+
*/
|
|
288
|
+
function hasSecondStatement(code) {
|
|
289
|
+
if (!code || /^for\b/.test(code))
|
|
290
|
+
return false;
|
|
291
|
+
const text = code.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, '""');
|
|
292
|
+
return /;\s*(?!\/\/|\/\*|#|\}|\)|$)\S/.test(text);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* #490: on a line that holds several statements, true when the one statement
|
|
296
|
+
* that defines `variable` is positively known not to read `via`. The data-flow
|
|
297
|
+
* graph links a use to every definition on its line, so in
|
|
298
|
+
* `const q = req.query.q; const tool = "ls";` the handler parameter `req` would
|
|
299
|
+
* otherwise reach `tool`. Returns false whenever the defining statement cannot
|
|
300
|
+
* be isolated (no match, more than one match, a destructuring or other form the
|
|
301
|
+
* scan does not read) so the line-granular behaviour is kept.
|
|
302
|
+
*/
|
|
303
|
+
function lineDefinitionLacksUse(lineCode, variable, via) {
|
|
304
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(variable) || !/^[A-Za-z_$][\w$]*$/.test(via))
|
|
305
|
+
return false;
|
|
306
|
+
// Blank string / char / template literals so `;` and identifiers inside them do not count.
|
|
307
|
+
let text = lineCode.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, m => '"' + ' '.repeat(Math.max(0, m.length - 2)) + '"');
|
|
308
|
+
// Drop a trailing `//` comment. Any `/` left over may open a regex literal
|
|
309
|
+
// (`/;/.test(q)`) whose `;` or `=` would mislead the scan, so keep the
|
|
310
|
+
// line-granular behaviour.
|
|
311
|
+
text = text.replace(/(^|[\s;{}])\/\/.*$/, '$1');
|
|
312
|
+
if (text.includes('/'))
|
|
313
|
+
return false;
|
|
314
|
+
const name = variable.replace(/\$/g, '\\$');
|
|
315
|
+
// A second write to the variable on the line (compound assignment, `++`/`--`,
|
|
316
|
+
// or a destructuring assignment target such as `[a, b] = [b, a]`) means the
|
|
317
|
+
// first initializer is not its only value.
|
|
318
|
+
if (new RegExp(String.raw `(?<![.\w$])${name}\s*(?:\*\*|<<|>>>?|&&|\|\||\?\?|[-+*%&|^])=`).test(text))
|
|
319
|
+
return false;
|
|
320
|
+
if (new RegExp(String.raw `(?<![.\w$])${name}\s*(?:\+\+|--)|(?:\+\+|--)\s*${name}(?![\w$])`).test(text))
|
|
321
|
+
return false;
|
|
322
|
+
for (const d of text.matchAll(/[\[{]([^[\]{}]*)[\]}]\s*=(?![=>])/g)) {
|
|
323
|
+
if (new RegExp(String.raw `(?<![.\w$])${name}(?![\w$])`).test(d[1]))
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
const declRe = new RegExp(String.raw `(?<![.\w$])${name}\s*(?::=|=|:[^=;]*=)(?![=>])`, 'g');
|
|
327
|
+
const matches = [...text.matchAll(declRe)];
|
|
328
|
+
if (matches.length !== 1)
|
|
329
|
+
return false;
|
|
330
|
+
const m = matches[0];
|
|
331
|
+
// The initializer runs to the next `;` at its own nesting depth, or to the
|
|
332
|
+
// closer of the block / call it sits in. When neither is on this line the
|
|
333
|
+
// initializer wraps (`'ls ' +` / `(` then `q);` below), so it cannot be judged.
|
|
334
|
+
let depth = 0;
|
|
335
|
+
let end = -1;
|
|
336
|
+
for (let i = m.index + m[0].length; i < text.length; i++) {
|
|
337
|
+
const c = text[i];
|
|
338
|
+
if (c === '(' || c === '[' || c === '{')
|
|
339
|
+
depth++;
|
|
340
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
341
|
+
if (--depth < 0) {
|
|
342
|
+
end = i;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else if (c === ';' && depth === 0) {
|
|
347
|
+
end = i;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (end < 0)
|
|
352
|
+
return false;
|
|
353
|
+
const bounded = text.slice(m.index + m[0].length, end).trim();
|
|
354
|
+
if (bounded === '' || /[-+*%&|^!~<>=?:,.([{]$/.test(bounded))
|
|
355
|
+
return false;
|
|
356
|
+
// Judge the original text of the initializer: an identifier inside a literal
|
|
357
|
+
// (`$"make {q}"`, `f"{q}"`, `${q}`) counts as a read. Blanking keeps offsets.
|
|
358
|
+
const rhs = lineCode.slice(m.index + m[0].length, end);
|
|
359
|
+
return !new RegExp(String.raw `(?<![.\w$])${via.replace(/\$/g, '\\$')}(?![\w$])`).test(rhs);
|
|
360
|
+
}
|
|
361
|
+
function findInitialTaint(sources, callsByLine, defsByLine, chainsByFromDef, jsLike = false, lineText) {
|
|
219
362
|
const tainted = [];
|
|
220
363
|
for (const source of sources) {
|
|
221
364
|
// Find definitions on the same line as the source
|
|
222
365
|
const defsOnLine = defsByLine.get(source.line) ?? [];
|
|
366
|
+
// cognium-dev#490: `q := r.FormValue("q"); x := "fixed"` puts two
|
|
367
|
+
// statements on the source line. Only the source's own binding, and a
|
|
368
|
+
// same-line definition the data-flow graph derives from it
|
|
369
|
+
// (`w := r.FormValue("w"); cmd := "x " + w`), is tainted; an unrelated
|
|
370
|
+
// neighbour statement's definition is not.
|
|
371
|
+
let seedable = null;
|
|
372
|
+
// The whole line, not just the text from the source: a statement before the
|
|
373
|
+
// read (`const tool = "ls"; const q = req.query.q;`) is a second statement too.
|
|
374
|
+
const lineCode = lineText?.(source.line)?.trim() || source.code;
|
|
375
|
+
if (source.variable && hasSecondStatement(lineCode)) {
|
|
376
|
+
const own = defsOnLine.filter(d => d.variable === source.variable);
|
|
377
|
+
if (own.length > 0) {
|
|
378
|
+
seedable = new Set(own.map(d => d.id));
|
|
379
|
+
for (const d of own) {
|
|
380
|
+
for (const c of chainsByFromDef.get(d.id) ?? []) {
|
|
381
|
+
const to = defsOnLine.find(o => o.id === c.to_def);
|
|
382
|
+
if (!to)
|
|
383
|
+
continue;
|
|
384
|
+
// The graph links a use to every definition on its line; keep the
|
|
385
|
+
// neighbour only when its own statement reads the linked variable.
|
|
386
|
+
if (c.via && lineCode && lineDefinitionLacksUse(lineCode, to.variable, c.via))
|
|
387
|
+
continue;
|
|
388
|
+
seedable.add(c.to_def);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
223
393
|
for (const def of defsOnLine) {
|
|
394
|
+
if (seedable && !seedable.has(def.id))
|
|
395
|
+
continue;
|
|
224
396
|
// cognium-dev #308 (from cognium-ai#326 / #289): an
|
|
225
397
|
// `interprocedural_param` source names exactly one parameter
|
|
226
398
|
// (`source.variable`, set for Java/C#). Every parameter shares the
|
|
@@ -230,12 +402,22 @@ function findInitialTaint(sources, callsByLine, defsByLine) {
|
|
|
230
402
|
// the bogus taint into `cmd.ExecuteReader()`. Taintable neighbours
|
|
231
403
|
// (`string tableName`) get their own source, so restricting the seed
|
|
232
404
|
// to the named parameter loses nothing.
|
|
233
|
-
|
|
405
|
+
// cognium-dev#586 / #587: framework parameter sources (GraphQL resolver
|
|
406
|
+
// `args`, Express destructured request keys) also name exactly one
|
|
407
|
+
// binding; `parent` / `context` / `res` share the signature line.
|
|
408
|
+
if ((source.type === 'interprocedural_param' || PARAM_SCOPED_SOURCE_RE.test(source.location ?? '')) &&
|
|
234
409
|
source.variable &&
|
|
235
410
|
def.kind === 'param' &&
|
|
236
411
|
def.variable !== source.variable) {
|
|
237
412
|
continue;
|
|
238
413
|
}
|
|
414
|
+
// A parameter source stands for the parameter's binding only. Other
|
|
415
|
+
// definitions that share the signature line are not the parameter:
|
|
416
|
+
// `const ident = async (x) => x;` defines `ident` there, and seeding it
|
|
417
|
+
// tainted every `ident(…)` result with the parameter's taint (#490).
|
|
418
|
+
if (source.type === 'interprocedural_param' && def.kind !== 'param') {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
239
421
|
tainted.push({
|
|
240
422
|
variable: def.variable,
|
|
241
423
|
defId: def.id,
|
|
@@ -256,13 +438,40 @@ function findInitialTaint(sources, callsByLine, defsByLine) {
|
|
|
256
438
|
// purely by line adjacency, then `propagateThroughChains` carries the
|
|
257
439
|
// bogus taint forward to any sink that consumes `cmd` later in the
|
|
258
440
|
// method — that was the FP-03 root cause from issue #101.
|
|
259
|
-
|
|
441
|
+
//
|
|
442
|
+
// cognium-dev#596: a source inside a complete `if (…)` / `while (…)` header
|
|
443
|
+
// (`if (request.getParameter("a") != null)`) is a condition, not the start
|
|
444
|
+
// of an expression that continues on the next line. The next line is the
|
|
445
|
+
// body, so `target = "safe";` there must not be seeded by adjacency.
|
|
446
|
+
//
|
|
447
|
+
// cognium-dev#563: a source line that is a complete assignment
|
|
448
|
+
// (`const qty = parseInt(req.query.qty, 10);`) does not continue on the
|
|
449
|
+
// next line either; a local declared there (`const label = 'units';`)
|
|
450
|
+
// is a new statement and gets no taint by adjacency. JavaScript /
|
|
451
|
+
// TypeScript only, the languages this was measured on: in Java the next
|
|
452
|
+
// line can overwrite a servlet field (`name = "abc";`) that other
|
|
453
|
+
// requests share, and SecuriBench StrongUpdates4 expects that flow kept.
|
|
454
|
+
const closesOwnStatement = jsLike && defsOnLine.length > 0 && /;\s*(?:\/\/.*)?$/.test(source.code ?? '');
|
|
455
|
+
const defsNextLine = isCompleteConditionHeader(source.code)
|
|
456
|
+
? []
|
|
457
|
+
: (defsByLine.get(source.line + 1) ?? []).filter(d => !(closesOwnStatement && d.kind === 'local'));
|
|
458
|
+
// cognium-dev#490: a source with no binding of its own (a handler signature)
|
|
459
|
+
// seeds the first body line by adjacency. When that line holds several
|
|
460
|
+
// statements, a definition whose own statement reads nothing defined on the
|
|
461
|
+
// source line (`const tool = "ls";` beside `const q = req.query.q;`) is not
|
|
462
|
+
// a continuation of it.
|
|
463
|
+
const nextCode = lineText && !source.variable ? lineText(source.line + 1)?.trim() : undefined;
|
|
464
|
+
const nextMulti = !!nextCode && hasSecondStatement(nextCode);
|
|
465
|
+
const sourceLineNames = [...new Set(defsOnLine.map(d => d.variable))];
|
|
260
466
|
for (const def of defsNextLine) {
|
|
261
467
|
// Only include if there's a call on the source line
|
|
262
468
|
const callsOnSourceLine = callsByLine.get(source.line) ?? [];
|
|
263
469
|
if (callsOnSourceLine.length > 0) {
|
|
264
470
|
if (source.variable && def.variable !== source.variable)
|
|
265
471
|
continue;
|
|
472
|
+
if (nextMulti && sourceLineNames.length > 0 &&
|
|
473
|
+
sourceLineNames.every(n => lineDefinitionLacksUse(nextCode, def.variable, n)))
|
|
474
|
+
continue;
|
|
266
475
|
tainted.push({
|
|
267
476
|
variable: def.variable,
|
|
268
477
|
defId: def.id,
|
|
@@ -296,17 +505,53 @@ function findInitialTaint(sources, callsByLine, defsByLine) {
|
|
|
296
505
|
}
|
|
297
506
|
return tainted;
|
|
298
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* #589: true when `variable` still appears in a def expression after HTML-encoder
|
|
510
|
+
* calls are masked — e.g. `escapeHtml(a) + b` keeps `b` as a raw operand.
|
|
511
|
+
*/
|
|
512
|
+
function identifierOutsideHtmlEncoderCalls(variable, expression, sans) {
|
|
513
|
+
if (!expression || !sans || sans.length === 0)
|
|
514
|
+
return false;
|
|
515
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(variable))
|
|
516
|
+
return false;
|
|
517
|
+
const refs = [];
|
|
518
|
+
for (const san of sans) {
|
|
519
|
+
if (!isHtmlContextEncoder(san))
|
|
520
|
+
continue;
|
|
521
|
+
const m = /(?:(\w+)\.)?(\w+)\(\)/.exec(san.method);
|
|
522
|
+
if (!m?.[2])
|
|
523
|
+
continue;
|
|
524
|
+
refs.push({ receiver: m[1], methodName: m[2] });
|
|
525
|
+
}
|
|
526
|
+
if (refs.length === 0)
|
|
527
|
+
return false;
|
|
528
|
+
const masked = maskSanitizerCalls(expression, refs);
|
|
529
|
+
if (masked === expression)
|
|
530
|
+
return false;
|
|
531
|
+
const re = new RegExp(`(?:^|[^A-Za-z0-9_$])${variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:[^A-Za-z0-9_$]|$)`);
|
|
532
|
+
return re.test(blankStringLiterals(masked));
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* True when `next` is a bare-parameter seed and `existing` already credits the
|
|
536
|
+
* same definition to a real source: the real source keeps the definition.
|
|
537
|
+
*/
|
|
538
|
+
function yieldsToRealSource(existing, next) {
|
|
539
|
+
return existing !== undefined &&
|
|
540
|
+
next.sourceType === 'interprocedural_param' &&
|
|
541
|
+
existing.sourceType !== 'interprocedural_param';
|
|
542
|
+
}
|
|
299
543
|
/**
|
|
300
544
|
* Propagate taint through def-use chains.
|
|
301
545
|
* Accepts a pre-computed chainsByFromDef index (from CodeGraph) — no internal
|
|
302
546
|
* adjacency list building needed.
|
|
303
547
|
*/
|
|
304
|
-
function propagateThroughChains(initialTaint, chainsByFromDef, defById, sanitizersByLine) {
|
|
548
|
+
function propagateThroughChains(initialTaint, chainsByFromDef, defById, sanitizersByLine, taintBarrierDefIds, lineText) {
|
|
305
549
|
const propagated = [];
|
|
306
550
|
const taintedDefIds = new Set(initialTaint.map(t => t.defId));
|
|
307
551
|
const taintInfoByDefId = new Map();
|
|
308
552
|
for (const t of initialTaint) {
|
|
309
|
-
taintInfoByDefId.
|
|
553
|
+
if (!yieldsToRealSource(taintInfoByDefId.get(t.defId), t))
|
|
554
|
+
taintInfoByDefId.set(t.defId, t);
|
|
310
555
|
}
|
|
311
556
|
// BFS to propagate taint
|
|
312
557
|
const queue = [...initialTaint.map(t => t.defId)];
|
|
@@ -318,14 +563,32 @@ function propagateThroughChains(initialTaint, chainsByFromDef, defById, sanitize
|
|
|
318
563
|
continue;
|
|
319
564
|
const outgoingChains = chainsByFromDef.get(currentDefId) ?? [];
|
|
320
565
|
for (const chain of outgoingChains) {
|
|
321
|
-
|
|
566
|
+
// A measure/test of the value or a constant-table lookup (#568, #667).
|
|
567
|
+
if (taintBarrierDefIds.has(chain.to_def))
|
|
322
568
|
continue;
|
|
323
569
|
const targetDef = defById.get(chain.to_def);
|
|
324
570
|
if (!targetDef)
|
|
325
571
|
continue;
|
|
572
|
+
// #490: the graph links a use to every definition on its line; on a line
|
|
573
|
+
// with a second statement the link holds only when the use is in the
|
|
574
|
+
// definition's own statement.
|
|
575
|
+
if (chain.via && lineText) {
|
|
576
|
+
const code = lineText(targetDef.line)?.trim();
|
|
577
|
+
if (code && hasSecondStatement(code) && lineDefinitionLacksUse(code, targetDef.variable, chain.via))
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
326
580
|
// Check if there's a sanitizer between source and this def
|
|
327
581
|
const sanitizeCheck = checkSanitized(currentTaint.sourceLine, targetDef.line, currentTaint.sourceType, sanitizersByLine);
|
|
328
|
-
|
|
582
|
+
const sansAtTarget = sanitizersByLine.get(targetDef.line);
|
|
583
|
+
// #589: decide encoder-only per operand, not per line. A raw sibling on
|
|
584
|
+
// `escapeHtml(a) + b` is unrestricted even though the line has an encoder.
|
|
585
|
+
const viaName = chain.via || currentTaint.variable;
|
|
586
|
+
const rawOperand = identifierOutsideHtmlEncoderCalls(viaName, targetDef.expression ?? lineText?.(targetDef.line), sansAtTarget);
|
|
587
|
+
const effectivelySanitized = sanitizeCheck.sanitized && !rawOperand;
|
|
588
|
+
// #589: a hop through a context-specific HTML encoder keeps the taint
|
|
589
|
+
// alive for the sink types the encoder does not clean (`trust_boundary`).
|
|
590
|
+
const encoderOnly = effectivelySanitized && sanitizersAreHtmlContextEncoders(sansAtTarget);
|
|
591
|
+
if (!effectivelySanitized || encoderOnly) {
|
|
329
592
|
const newTaint = {
|
|
330
593
|
variable: targetDef.variable,
|
|
331
594
|
defId: targetDef.id,
|
|
@@ -333,7 +596,28 @@ function propagateThroughChains(initialTaint, chainsByFromDef, defById, sanitize
|
|
|
333
596
|
sourceType: currentTaint.sourceType,
|
|
334
597
|
sourceLine: currentTaint.sourceLine,
|
|
335
598
|
confidence: currentTaint.confidence * 0.95, // Decay confidence slightly
|
|
599
|
+
...(encoderOnly
|
|
600
|
+
? { survivesOnlyFor: HTML_CONTEXT_ENCODER_UNCOVERED_SINKS }
|
|
601
|
+
: currentTaint.survivesOnlyFor ? { survivesOnlyFor: currentTaint.survivesOnlyFor } : {}),
|
|
336
602
|
};
|
|
603
|
+
const existing = taintInfoByDefId.get(targetDef.id);
|
|
604
|
+
if (visited.has(chain.to_def)) {
|
|
605
|
+
// #589: first-wins `visited` kept an encoder-only predecessor; a later
|
|
606
|
+
// unrestricted chain must replace it and keep propagating.
|
|
607
|
+
if (!existing?.survivesOnlyFor)
|
|
608
|
+
continue;
|
|
609
|
+
if (newTaint.survivesOnlyFor)
|
|
610
|
+
continue;
|
|
611
|
+
const idx = propagated.findIndex(t => t.defId === targetDef.id);
|
|
612
|
+
if (idx >= 0)
|
|
613
|
+
propagated[idx] = newTaint;
|
|
614
|
+
else
|
|
615
|
+
propagated.push(newTaint);
|
|
616
|
+
taintedDefIds.add(targetDef.id);
|
|
617
|
+
taintInfoByDefId.set(targetDef.id, newTaint);
|
|
618
|
+
queue.push(targetDef.id);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
337
621
|
propagated.push(newTaint);
|
|
338
622
|
taintedDefIds.add(targetDef.id);
|
|
339
623
|
taintInfoByDefId.set(targetDef.id, newTaint);
|
|
@@ -443,6 +727,11 @@ function maskSanitizerCalls(expr, refs) {
|
|
|
443
727
|
}
|
|
444
728
|
return out;
|
|
445
729
|
}
|
|
730
|
+
function maskLengthOf(expr) {
|
|
731
|
+
return expr
|
|
732
|
+
.replace(/\b(?:len|strlen|count)\s*\(\s*[\w$.]+\s*\)/g, ' 0 ')
|
|
733
|
+
.replace(/[\w$.]+\s*\.\s*(?:length|size|count)\s*(?:\(\s*\))?(?![\w$(])/g, ' 0 ');
|
|
734
|
+
}
|
|
446
735
|
function blankStringLiterals(expr) {
|
|
447
736
|
return expr
|
|
448
737
|
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
@@ -466,22 +755,79 @@ export function identifierOutsideSanitizerCalls(variable, calls, sanitizers, sin
|
|
|
466
755
|
for (const call of calls) {
|
|
467
756
|
if (refs.some(r => r.methodName === call.method_name))
|
|
468
757
|
continue;
|
|
469
|
-
|
|
758
|
+
// A same-line guard (`IsLocalUrl(next); Redirect(next)`) is not a
|
|
759
|
+
// partial wrap. Only a call with an argument that itself contains the
|
|
760
|
+
// sanitizer call can leave a raw operand: in that argument, or in a
|
|
761
|
+
// sibling argument of the same call (`Printf("%s %s", clean(a), b)`).
|
|
762
|
+
const masked = call.arguments.map(arg => {
|
|
470
763
|
const expr = arg.expression ?? '';
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (masked === expr)
|
|
764
|
+
return { expr, masked: expr ? maskSanitizerCalls(expr, refs) : expr };
|
|
765
|
+
});
|
|
766
|
+
if (!masked.some(m => m.masked !== m.expr))
|
|
767
|
+
continue;
|
|
768
|
+
for (const m of masked) {
|
|
769
|
+
if (!m.masked)
|
|
478
770
|
continue;
|
|
479
|
-
|
|
771
|
+
// A sibling that passes only the value's length (`len(s)`,
|
|
772
|
+
// `s.length()`, `s.size()`) carries no attacker-controlled text.
|
|
773
|
+
const text = m.masked === m.expr ? maskLengthOf(m.masked) : m.masked;
|
|
774
|
+
if (re.test(blankStringLiterals(text)))
|
|
480
775
|
return true;
|
|
481
776
|
}
|
|
482
777
|
}
|
|
483
778
|
return false;
|
|
484
779
|
}
|
|
780
|
+
/**
|
|
781
|
+
* True when `variable` appears in `text` once covering sanitizer calls are
|
|
782
|
+
* removed. Returns `null` when no covering sanitizer call is found in `text`
|
|
783
|
+
* (the caller cannot tell a wrapped operand from a raw one).
|
|
784
|
+
*
|
|
785
|
+
* #589: `mixed = escapeHtml(a) + b` keeps `b`; `both = escapeHtml(a) +
|
|
786
|
+
* escapeHtml(b)` keeps nothing.
|
|
787
|
+
*/
|
|
788
|
+
export function identifierOutsideSanitizerCallsInText(variable, text, sanitizers, sinkType) {
|
|
789
|
+
const refs = sanitizerCallRefs(sanitizers, sinkType);
|
|
790
|
+
if (refs.length === 0)
|
|
791
|
+
return null;
|
|
792
|
+
const masked = maskSanitizerCalls(text, refs);
|
|
793
|
+
if (masked === text)
|
|
794
|
+
return null;
|
|
795
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(variable))
|
|
796
|
+
return true;
|
|
797
|
+
const re = new RegExp(`(?:^|[^A-Za-z0-9_$])${variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:[^A-Za-z0-9_$]|$)`);
|
|
798
|
+
return re.test(blankStringLiterals(masked));
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* True when every operand of `text` sits inside a covering sanitizer call:
|
|
802
|
+
* after those calls are removed and string literals blanked, no identifier is
|
|
803
|
+
* left. `shlex.quote(request.args.get("t"))` qualifies; `quote(a) + b` does
|
|
804
|
+
* not (#582).
|
|
805
|
+
*/
|
|
806
|
+
export function expressionFullyWrappedBySanitizer(text, sanitizers, sinkType) {
|
|
807
|
+
const refs = sanitizerCallRefs(sanitizers, sinkType);
|
|
808
|
+
if (refs.length === 0)
|
|
809
|
+
return false;
|
|
810
|
+
const masked = maskSanitizerCalls(text, refs);
|
|
811
|
+
if (masked === text)
|
|
812
|
+
return false;
|
|
813
|
+
// A qualified call (`os.path.basename(x)`) leaves its module prefix (`os. `) behind.
|
|
814
|
+
const rest = blankStringLiterals(masked).replace(/\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\.(?=\s)/g, ' ');
|
|
815
|
+
return !/[A-Za-z_]/.test(rest);
|
|
816
|
+
}
|
|
817
|
+
/** Last segment of a sanitizer's method name, without the call parens (`ESAPI.encoder().encodeForHTML()` -> `encodeForHTML`). */
|
|
818
|
+
function sanitizerMethodTail(san) {
|
|
819
|
+
const m = san.method ?? '';
|
|
820
|
+
return m.replace(/\(\)$/, '').split('.').pop() ?? '';
|
|
821
|
+
}
|
|
822
|
+
/** #589: a method-call sanitizer that is a context-specific HTML / markup encoder. */
|
|
823
|
+
function isHtmlContextEncoder(san) {
|
|
824
|
+
if (san.type === 'derived_html_encoder')
|
|
825
|
+
return true; // same-file wrapper of an HTML encoder (#589)
|
|
826
|
+
return san.type === 'method_call' && HTML_CONTEXT_ENCODER_METHODS.has(sanitizerMethodTail(san));
|
|
827
|
+
}
|
|
828
|
+
function sanitizersAreHtmlContextEncoders(sans) {
|
|
829
|
+
return !!sans && sans.length > 0 && sans.every(isHtmlContextEncoder);
|
|
830
|
+
}
|
|
485
831
|
function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
|
|
486
832
|
const isKnownSinkType = KNOWN_SINK_TYPES.has(sinkType);
|
|
487
833
|
// Per-line fast path — preserves 3.154.0 semantics for site #1 (propagation
|
|
@@ -490,6 +836,9 @@ function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
|
|
|
490
836
|
const sanitizersAtTarget = sanitizersByLine.get(toLine);
|
|
491
837
|
if (sanitizersAtTarget && sanitizersAtTarget.length > 0) {
|
|
492
838
|
for (const san of sanitizersAtTarget) {
|
|
839
|
+
// #589: an HTML-context encoder does not clean a `trust_boundary` sink.
|
|
840
|
+
if (HTML_CONTEXT_ENCODER_UNCOVERED_SINKS.has(sinkType) && isHtmlContextEncoder(san))
|
|
841
|
+
continue;
|
|
493
842
|
if (isKnownSinkType) {
|
|
494
843
|
// H8 (#254 3.171.0): Set-backed lookup — O(1) after first call.
|
|
495
844
|
if (sanitizerCoversSink(san, sinkType)) {
|
|
@@ -526,6 +875,8 @@ function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
|
|
|
526
875
|
if (!sansAtLine || sansAtLine.length === 0)
|
|
527
876
|
continue;
|
|
528
877
|
for (const san of sansAtLine) {
|
|
878
|
+
if (HTML_CONTEXT_ENCODER_UNCOVERED_SINKS.has(sinkType) && isHtmlContextEncoder(san))
|
|
879
|
+
continue;
|
|
529
880
|
if (isKnownSinkType) {
|
|
530
881
|
// H8 (#254 3.171.0): Set-backed lookup — O(1) after first call.
|
|
531
882
|
if (sanitizerCoversSink(san, sinkType)) {
|