@clear-capabilities/agentic-security-scanner 0.185.0 → 0.186.1
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/CHANGELOG.md +22 -0
- package/bin/agentic-security.js +24 -1
- package/dist/1128.index.js +25 -21
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{0b301d45b231f144a1b4.json → ca62ee93079be4c24eee.json} +7 -7
- package/package.json +7 -7
- package/src/dataflow/CLAUDE.md +6 -3
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/demand-query.js +279 -40
- package/src/dataflow/dep-fingerprint.js +1 -1
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/engine.js +62 -5
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +2 -1
- package/src/lineage/smt-orchestrator.js +47 -11
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/posture/CLAUDE.md +5 -1
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16331
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5817.index.js +0 -714
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- package/dist/9999.index.js +0 -1698
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Argument splitting for guard recognisers that must look at WHAT a call is
|
|
2
|
+
// given, not at the text around it. Quote- and bracket-aware; handles template
|
|
3
|
+
// literal substitutions. Not a parser: it answers "where does each top-level
|
|
4
|
+
// argument start and end" for text that begins right after a call's '('.
|
|
5
|
+
|
|
6
|
+
const isQuote = (c) => c === '"' || c === "'" || c === '`';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Split the text that follows a call's opening paren into its top-level
|
|
10
|
+
* arguments (source text, quotes intact). Returns null when the closing paren
|
|
11
|
+
* is not in the text given, so a caller never reasons about a truncated call.
|
|
12
|
+
*/
|
|
13
|
+
export function callArguments(text) {
|
|
14
|
+
const args = [];
|
|
15
|
+
let depth = 0;
|
|
16
|
+
let start = 0;
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < text.length) {
|
|
19
|
+
const c = text[i];
|
|
20
|
+
if (isQuote(c)) {
|
|
21
|
+
const q = c;
|
|
22
|
+
i++;
|
|
23
|
+
while (i < text.length && text[i] !== q) {
|
|
24
|
+
if (text[i] === '\\') { i += 2; continue; }
|
|
25
|
+
if (q === '`' && text[i] === '$' && text[i + 1] === '{') {
|
|
26
|
+
let d = 1; i += 2;
|
|
27
|
+
while (i < text.length && d > 0) { if (text[i] === '{') d++; else if (text[i] === '}') d--; i++; }
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
36
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
37
|
+
if (depth === 0) {
|
|
38
|
+
const last = text.slice(start, i);
|
|
39
|
+
if (last.trim() !== '' || args.length > 0) args.push(last);
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
depth--;
|
|
43
|
+
} else if (c === ',' && depth === 0) {
|
|
44
|
+
args.push(text.slice(start, i));
|
|
45
|
+
start = i + 1;
|
|
46
|
+
}
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
// The window ended inside the call (a long callback, a wrapped call). The
|
|
50
|
+
// arguments completed so far are still exactly what the call was given; the
|
|
51
|
+
// unfinished last one is dropped rather than guessed. `partial` lets a caller
|
|
52
|
+
// that needs the whole call refuse.
|
|
53
|
+
if (args.length === 0) return null;
|
|
54
|
+
args.partial = true;
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the first call on or just after `line` (1-based) whose callee matches
|
|
60
|
+
* `calleeRe` (which must end at the opening paren) and return its arguments.
|
|
61
|
+
* Looks at the finding line and the next three, because a call is routinely
|
|
62
|
+
* wrapped. Returns null when there is no such call or it is not closed.
|
|
63
|
+
*/
|
|
64
|
+
export function callArgumentsAt(lines, line, calleeRe) {
|
|
65
|
+
if (!Array.isArray(lines) || !Number.isInteger(line) || line < 1) return null;
|
|
66
|
+
const text = lines.slice(line - 1, line + 3).join('\n');
|
|
67
|
+
const m = calleeRe.exec(text);
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
return callArguments(text.slice(m.index + m[0].length));
|
|
70
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Is a source position inside a function body, or at module/initialisation
|
|
2
|
+
// level? Some hazards exist only while the process is serving work: a blocking
|
|
3
|
+
// call made once at module load (reading a TLS key, a config file) delays
|
|
4
|
+
// startup once, and nothing is waiting on the event loop yet. The same call in
|
|
5
|
+
// a request callback stalls every connection.
|
|
6
|
+
//
|
|
7
|
+
// A position is inside a function when it is inside
|
|
8
|
+
// * a `{ ... }` block opened by `=>`, a `function` header, a method or class
|
|
9
|
+
// member header (`name(params) {`), or an async/generator form,
|
|
10
|
+
// * the body of an expression-bodied arrow (`(p) => fs.readFileSync(p)`),
|
|
11
|
+
// which runs when the arrow is CALLED, not where it is written, or
|
|
12
|
+
// * a class body (field initialisers run per instance).
|
|
13
|
+
// Plain blocks (`if`, `for`, `try`, `else`) and object literals are not function scopes.
|
|
14
|
+
//
|
|
15
|
+
// Operates on a comment- and string-blanked view so braces in strings or
|
|
16
|
+
// comments never count. It is a lexical approximation (no parse). The one error
|
|
17
|
+
// that matters is calling a function body "module level", because that hides a
|
|
18
|
+
// finding, so a block is classed as a function unless it is positively a control
|
|
19
|
+
// block or a literal, and parameter lists are matched by bracket depth, not by a
|
|
20
|
+
// pattern that stops at a fixed nesting.
|
|
21
|
+
|
|
22
|
+
const CONTROL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'else', 'try', 'finally', 'do']);
|
|
23
|
+
const CLASS_HEAD = /\bclass\b\s*[\w$]*(?:\s+extends\s+[\w$.()]+)?\s*$/;
|
|
24
|
+
|
|
25
|
+
/** Index of the '(' matching the ')' at `closeIdx`, scanning backwards; -1 if unbalanced. */
|
|
26
|
+
function matchingOpenParen(text, closeIdx) {
|
|
27
|
+
let depth = 0;
|
|
28
|
+
for (let i = closeIdx; i >= 0; i--) {
|
|
29
|
+
const c = text[i];
|
|
30
|
+
if (c === ')') depth++;
|
|
31
|
+
else if (c === '(') { depth--; if (depth === 0) return i; }
|
|
32
|
+
}
|
|
33
|
+
return -1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function opensFunction(text, braceIdx) {
|
|
37
|
+
let end = braceIdx;
|
|
38
|
+
while (end > 0 && /\s/.test(text[end - 1])) end--;
|
|
39
|
+
const before = text.slice(Math.max(0, end - 400), end);
|
|
40
|
+
if (/=>$/.test(before)) return true;
|
|
41
|
+
if (CLASS_HEAD.test(before)) return true;
|
|
42
|
+
// `else {`, `try {`, `finally {`, `do {`, `catch {` carry no parenthesised head
|
|
43
|
+
const bare = /(?:^|[^\w$.])([A-Za-z_$][\w$]*)$/.exec(before);
|
|
44
|
+
if (bare && CONTROL_KEYWORDS.has(bare[1])) return false;
|
|
45
|
+
// find the ')' that ends a parameter list, allowing a return type annotation `): Type {`
|
|
46
|
+
let closeIdx = end - 1;
|
|
47
|
+
if (text[closeIdx] !== ')') {
|
|
48
|
+
const m = /\)\s*:\s*[^(){};=]*$/.exec(before);
|
|
49
|
+
if (!m) return false;
|
|
50
|
+
closeIdx = end - before.length + m.index;
|
|
51
|
+
}
|
|
52
|
+
const openIdx = matchingOpenParen(text, closeIdx);
|
|
53
|
+
if (openIdx < 0) return true; // cannot match the head: keep findings, so call it a function
|
|
54
|
+
const head = text.slice(Math.max(0, openIdx - 80), openIdx).replace(/\s+$/, '');
|
|
55
|
+
const word = /([A-Za-z_$][\w$]*)$/.exec(head);
|
|
56
|
+
if (word) return !CONTROL_KEYWORDS.has(word[1]); // `if (` `while (` ...: control; any other name( ... ) { is a function or method
|
|
57
|
+
if (/\bfunction\s*\*?$/.test(head)) return true; // `function (` and `function* (`
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} blanked source with comments and string bodies blanked
|
|
63
|
+
* @param {number} offset index into `blanked`
|
|
64
|
+
* @returns {boolean} true when `offset` is lexically inside a function body
|
|
65
|
+
*/
|
|
66
|
+
export function insideFunctionBody(blanked, offset) {
|
|
67
|
+
// entries: 'F' function block, 'B' other block, 'P' paren/bracket, 'A' expression-bodied arrow
|
|
68
|
+
const stack = [];
|
|
69
|
+
const end = Math.min(offset, blanked.length);
|
|
70
|
+
for (let i = 0; i < end; i++) {
|
|
71
|
+
const c = blanked[i];
|
|
72
|
+
if (c === '{') stack.push(opensFunction(blanked, i) ? 'F' : 'B');
|
|
73
|
+
else if (c === '(' || c === '[') stack.push('P');
|
|
74
|
+
else if (c === '}' || c === ')' || c === ']') {
|
|
75
|
+
while (stack.length && stack[stack.length - 1] === 'A') stack.pop(); // an arrow body ends where its enclosing bracket closes
|
|
76
|
+
stack.pop();
|
|
77
|
+
} else if ((c === ',' || c === ';') && stack[stack.length - 1] === 'A') {
|
|
78
|
+
stack.pop(); // ... or at a separator at its own nesting level
|
|
79
|
+
} else if (c === '=' && blanked[i + 1] === '>') {
|
|
80
|
+
let j = i + 2;
|
|
81
|
+
while (j < blanked.length && /\s/.test(blanked[j])) j++;
|
|
82
|
+
if (blanked[j] !== '{') stack.push('A');
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return stack.some((e) => e === 'F' || e === 'A');
|
|
87
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The `address` a `listen` callback receives.
|
|
2
|
+
//
|
|
3
|
+
// server.listen({ port }, (err, address) => console.log(`listening on ${address}`))
|
|
4
|
+
//
|
|
5
|
+
// `address` here is the socket address the server just bound (host and port),
|
|
6
|
+
// handed to the callback by the server. It is not a person's postal address, so a
|
|
7
|
+
// rule that classifies a value by the NAME `address` must not treat this binding
|
|
8
|
+
// as personal data. The rule is about the binding site, not about the word: the
|
|
9
|
+
// same name bound anywhere else (a form field, a column, a request property) is
|
|
10
|
+
// unaffected.
|
|
11
|
+
|
|
12
|
+
/** Index just past the paren that closes the one opened at `openIdx`, or -1 when the text ends first. */
|
|
13
|
+
function closeOfParen(text, openIdx) {
|
|
14
|
+
let depth = 0;
|
|
15
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
16
|
+
const c = text[i];
|
|
17
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
18
|
+
const q = c; i++;
|
|
19
|
+
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++; }
|
|
20
|
+
} else if (c === '(') depth++;
|
|
21
|
+
else if (c === ')') { depth--; if (depth === 0) return i + 1; }
|
|
22
|
+
}
|
|
23
|
+
return -1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Is the identifier at `idx` in `raw` a parameter declared by a callback passed
|
|
28
|
+
* to a `.listen(` call? `idx` is any position at or after the callback header
|
|
29
|
+
* (the use site); the header is searched backwards within a bounded window, and
|
|
30
|
+
* must sit inside the listen call's own parentheses, so a function declared after
|
|
31
|
+
* the call has closed does not count.
|
|
32
|
+
*/
|
|
33
|
+
export function isListenCallbackParam(raw, idx, name) {
|
|
34
|
+
if (typeof raw !== 'string' || !name) return false;
|
|
35
|
+
const from = Math.max(0, idx - 800);
|
|
36
|
+
const at = raw.slice(from, idx).lastIndexOf('.listen(');
|
|
37
|
+
if (at < 0) return false;
|
|
38
|
+
const start = from + at;
|
|
39
|
+
const openIdx = start + '.listen'.length;
|
|
40
|
+
const windowEnd = idx + name.length + 80;
|
|
41
|
+
const close = closeOfParen(raw.slice(start, windowEnd), openIdx - start);
|
|
42
|
+
// the call closed before the use site: the use is not inside the callback
|
|
43
|
+
if (close !== -1 && start + close <= idx) return false;
|
|
44
|
+
const after = raw.slice(start, close === -1 ? windowEnd : Math.min(windowEnd, start + close));
|
|
45
|
+
const id = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
+
const arrow = new RegExp(String.raw`\([^)]*\b${id}\b[^)]*\)\s*=>|\b${id}\s*=>`);
|
|
47
|
+
const fn = new RegExp(String.raw`\bfunction\b[^(]*\([^)]*\b${id}\b[^)]*\)`);
|
|
48
|
+
return arrow.test(after) || fn.test(after);
|
|
49
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Open-redirect target shape (CWE-601): can the part of a redirect target the
|
|
2
|
+
// program does not control change the ORIGIN the browser is sent to?
|
|
3
|
+
//
|
|
4
|
+
// A redirect target is attacker-steerable to another origin only if the
|
|
5
|
+
// attacker controls the authority (scheme, host, port) of the resulting URL.
|
|
6
|
+
// A target that begins with a literal which already fixes the authority cannot
|
|
7
|
+
// be moved off-origin by whatever is appended after it:
|
|
8
|
+
//
|
|
9
|
+
// '/user/' + id path-absolute, second character is a name
|
|
10
|
+
// character: same origin, whatever `id` is
|
|
11
|
+
// 'https://app.example.com/' + p the whole authority is in the literal and a
|
|
12
|
+
// '/' closes it, so `p` is only path/query
|
|
13
|
+
//
|
|
14
|
+
// and these are NOT pinned, because the dynamic part decides the authority:
|
|
15
|
+
//
|
|
16
|
+
// '/' + x x = '/evil.example' gives '//evil.example'
|
|
17
|
+
// '//' + x, '/\\' + x protocol-relative (browsers treat '\' as '/')
|
|
18
|
+
// 'https://app.example.com' + x x = '.evil.example' or '@evil.example'
|
|
19
|
+
// '' + x, x, x + '/path' no literal prefix at all
|
|
20
|
+
//
|
|
21
|
+
// This is deliberately the ONLY thing decided here. It reads the text of the
|
|
22
|
+
// redirect call's first argument; it does not decide whether the dynamic part
|
|
23
|
+
// is attacker-controlled (that is the taint layer's question) and it never
|
|
24
|
+
// reads a file name, a fixture name or a label.
|
|
25
|
+
|
|
26
|
+
import { callArgumentsAt } from './_call-args.js';
|
|
27
|
+
|
|
28
|
+
const REDIRECT_CALL_RE = /\b(?:redirect|sendRedirect|Redirect|redirect_to|HttpResponseRedirect|RedirectResponse|redirectTo|permanentRedirect)\s*\(/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Leading literal text of an expression, and whether anything dynamic follows.
|
|
32
|
+
* Handles 'a' + x, "a" + x + y, `a${x}`, and a lone literal.
|
|
33
|
+
* `literal` is the decoded-enough prefix (escapes kept verbatim, which is safe
|
|
34
|
+
* here because the checks below only look at slashes and the first characters).
|
|
35
|
+
*/
|
|
36
|
+
export function leadingLiteral(expr) {
|
|
37
|
+
const s = String(expr).trim();
|
|
38
|
+
let i = 0;
|
|
39
|
+
let prefix = '';
|
|
40
|
+
let dynamic = false;
|
|
41
|
+
for (;;) {
|
|
42
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
43
|
+
if (i >= s.length) break;
|
|
44
|
+
const c = s[i];
|
|
45
|
+
if (c === '"' || c === "'") {
|
|
46
|
+
let j = i + 1; let body = '';
|
|
47
|
+
while (j < s.length && s[j] !== c) { if (s[j] === '\\') { body += s[j] + (s[j + 1] || ''); j += 2; } else body += s[j++]; }
|
|
48
|
+
if (j >= s.length) return { literal: prefix, dynamic: true, closed: false };
|
|
49
|
+
prefix += body; i = j + 1;
|
|
50
|
+
} else if (c === '`') {
|
|
51
|
+
let j = i + 1; let body = '';
|
|
52
|
+
while (j < s.length && s[j] !== '`') {
|
|
53
|
+
if (s[j] === '\\') { body += s[j] + (s[j + 1] || ''); j += 2; continue; }
|
|
54
|
+
if (s[j] === '$' && s[j + 1] === '{') { dynamic = true; break; }
|
|
55
|
+
body += s[j++];
|
|
56
|
+
}
|
|
57
|
+
prefix += body;
|
|
58
|
+
if (dynamic) return { literal: prefix, dynamic: true, closed: true };
|
|
59
|
+
if (j >= s.length) return { literal: prefix, dynamic: true, closed: false };
|
|
60
|
+
i = j + 1;
|
|
61
|
+
} else {
|
|
62
|
+
return { literal: prefix, dynamic: true, closed: true };
|
|
63
|
+
}
|
|
64
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
65
|
+
if (i >= s.length) return { literal: prefix, dynamic, closed: true };
|
|
66
|
+
if (s[i] === '+') { i++; continue; }
|
|
67
|
+
// anything else after a literal (a call suffix, `.concat`, `%`, ...): not a plain literal
|
|
68
|
+
return { literal: prefix, dynamic: true, closed: true };
|
|
69
|
+
}
|
|
70
|
+
return { literal: prefix, dynamic, closed: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A literal prefix that fixes the origin of the URL it begins. */
|
|
74
|
+
export function prefixPinsOrigin(prefix) {
|
|
75
|
+
// Path-absolute: '/' then a character that is neither '/' nor '\' (both are
|
|
76
|
+
// read as a second slash, turning the reference into a network-path one).
|
|
77
|
+
if (/^\/[^\/\\\s]/.test(prefix)) return true;
|
|
78
|
+
// Absolute URL whose authority is complete: scheme://authority/ .
|
|
79
|
+
if (/^[A-Za-z][A-Za-z0-9+.\-]*:\/\/[^\/?#\\\s]+\//.test(prefix)) return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Does the redirect argument keep the destination on a fixed origin?
|
|
85
|
+
* `arg` is the first argument of the redirect call. A numeric first argument
|
|
86
|
+
* (`res.redirect(302, url)`) is skipped by the caller, not here.
|
|
87
|
+
*/
|
|
88
|
+
export function redirectArgumentPinsOrigin(arg) {
|
|
89
|
+
const { literal, dynamic, closed } = leadingLiteral(arg);
|
|
90
|
+
if (!closed) return false;
|
|
91
|
+
if (!dynamic) return literal.length > 0; // a fully static target
|
|
92
|
+
return prefixPinsOrigin(literal);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inspect source lines starting at `line` (1-based) for a redirect call and
|
|
97
|
+
* decide whether its target pins the origin. Returns null when no redirect
|
|
98
|
+
* call is found (no opinion), else true/false.
|
|
99
|
+
*/
|
|
100
|
+
export function redirectTargetPinsOriginAt(lines, line) {
|
|
101
|
+
const args = callArgumentsAt(lines, line, REDIRECT_CALL_RE);
|
|
102
|
+
if (!args || args.length === 0) return null;
|
|
103
|
+
// res.redirect(302, '/x'): the status code is not the target
|
|
104
|
+
const target = args.length > 1 && /^\s*\d{3}\s*$/.test(args[0]) ? args[1] : args[0];
|
|
105
|
+
return redirectArgumentPinsOrigin(target);
|
|
106
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// What a response-writing call actually reflects.
|
|
2
|
+
//
|
|
3
|
+
// A reflected-XSS shape needs two things on the same call: a value the REQUEST
|
|
4
|
+
// supplied, and that value reaching the response without HTML encoding. Matching
|
|
5
|
+
// the text `req.` anywhere in the arguments gets both wrong:
|
|
6
|
+
//
|
|
7
|
+
// * `req` carries framework and application state next to the request's own
|
|
8
|
+
// input. `req.session.count`, `req.user.name`, `req.app.locals.title` and
|
|
9
|
+
// `req.pet.id` are server-side values (a session store, a database row an
|
|
10
|
+
// earlier middleware loaded, the app's configuration). They are second-order
|
|
11
|
+
// data at most, not something the current request typed. Only the properties
|
|
12
|
+
// that carry the request's input count (REQUEST_INPUT_PROPS).
|
|
13
|
+
// * `escapeHtml(req.params.id)` has the request value, but it is encoded before
|
|
14
|
+
// it is written. A value inside an HTML-escaper call is not reflected raw.
|
|
15
|
+
//
|
|
16
|
+
// This decides ONLY "does the call reflect raw request input". It does not model
|
|
17
|
+
// a variable assigned earlier; callers that match only the call text use it as a
|
|
18
|
+
// precision gate, and a flow through a variable is the taint layer's job.
|
|
19
|
+
|
|
20
|
+
import { callArgumentsAt } from './_call-args.js';
|
|
21
|
+
|
|
22
|
+
/** Properties of the request object that hold what the client sent. */
|
|
23
|
+
const REQUEST_INPUT_PROPS = [
|
|
24
|
+
'body', 'query', 'params', 'headers', 'header', 'get', 'param', 'cookies', 'signedCookies',
|
|
25
|
+
'hostname', 'host', 'subdomains', 'path', 'url', 'originalUrl', 'baseUrl', 'files', 'file',
|
|
26
|
+
'rawBody', 'ip', 'ips', 'vhost', 'rawHeaders', 'trailers',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const REQ_INPUT_RE = new RegExp(
|
|
30
|
+
String.raw`\breq(?:uest)?\s*(?:\.\s*(?:${REQUEST_INPUT_PROPS.join('|')})\b|\[\s*['"](?:${REQUEST_INPUT_PROPS.join('|')})['"]\s*\])`
|
|
31
|
+
+ String.raw`|\.(?:body|query|params)\s*\.`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// HTML output encoders whose result is safe to place in HTML text.
|
|
35
|
+
const ESCAPER_CALL_RE = /\b(?:escapeHtml|escape_html|escapeHTML|sanitizeHtml|sanitize_html|DOMPurify\s*\.\s*sanitize|he\s*\.\s*(?:encode|escape)|_\s*\.\s*escape|validator\s*\.\s*escape|xss|striptags|escape)\s*\(/g;
|
|
36
|
+
|
|
37
|
+
/** Remove every balanced `escaper(...)` call from an expression. */
|
|
38
|
+
export function stripHtmlEscaperCalls(expr) {
|
|
39
|
+
let out = String(expr);
|
|
40
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
41
|
+
ESCAPER_CALL_RE.lastIndex = 0;
|
|
42
|
+
const m = ESCAPER_CALL_RE.exec(out);
|
|
43
|
+
if (!m) return out;
|
|
44
|
+
let i = m.index + m[0].length;
|
|
45
|
+
let depth = 1;
|
|
46
|
+
while (i < out.length && depth > 0) {
|
|
47
|
+
const c = out[i];
|
|
48
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
49
|
+
const q = c; i++;
|
|
50
|
+
while (i < out.length && out[i] !== q) { if (out[i] === '\\') i++; i++; }
|
|
51
|
+
} else if (c === '(') depth++;
|
|
52
|
+
else if (c === ')') depth--;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
if (depth !== 0) return out; // unbalanced: leave it, the caller keeps the finding
|
|
56
|
+
out = out.slice(0, m.index) + '/*escaped*/' + out.slice(i);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Does this expression text contain raw (unescaped) request input? */
|
|
62
|
+
export function reflectsRawRequestInput(exprText) {
|
|
63
|
+
return REQ_INPUT_RE.test(stripHtmlEscaperCalls(exprText));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const RESPONSE_WRITE_RE = /\bres(?:ponse)?\s*\.\s*(?:send|write|end)\s*\(/;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* For a response-write call on or just after `line`: does it reflect raw
|
|
70
|
+
* request input? Returns null when no such call is found (no opinion).
|
|
71
|
+
*/
|
|
72
|
+
export function responseReflectsRequestInputAt(lines, line) {
|
|
73
|
+
const args = callArgumentsAt(lines, line, RESPONSE_WRITE_RE);
|
|
74
|
+
if (!args || args.length === 0) return null;
|
|
75
|
+
return reflectsRawRequestInput(args.join(','));
|
|
76
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// File-serving calls that confine the path to a root directory.
|
|
2
|
+
//
|
|
3
|
+
// `res.sendFile(p, { root: DIR })` and `res.download(p, name, { root: DIR })`
|
|
4
|
+
// resolve `p` relative to DIR and reject a path that climbs above it (the
|
|
5
|
+
// underlying file sender answers 403 for a path with a `..` segment when `root`
|
|
6
|
+
// is set). With `root` the user-controlled part is a path inside DIR, not an
|
|
7
|
+
// arbitrary filesystem path, so it is not a traversal sink. Without `root` the
|
|
8
|
+
// path must be absolute and the caller owns containment, so the finding stays.
|
|
9
|
+
//
|
|
10
|
+
// Only a literal `root:` property in an inline options object is recognised.
|
|
11
|
+
// `{ ...opts }`, a variable `opts`, or `root` only set elsewhere is not seen and
|
|
12
|
+
// leaves the finding in place: unknown stays conservative.
|
|
13
|
+
|
|
14
|
+
import { callArgumentsAt } from './_call-args.js';
|
|
15
|
+
|
|
16
|
+
const SEND_FILE_CALL_RE = /\b(?:sendFile|download)\s*\(/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Is the file-serving call at `line` given an options object with a `root`?
|
|
20
|
+
* Returns null when no such call is found, else true/false.
|
|
21
|
+
*/
|
|
22
|
+
export function sendFileConfinedToRootAt(lines, line) {
|
|
23
|
+
const args = callArgumentsAt(lines, line, SEND_FILE_CALL_RE);
|
|
24
|
+
if (!args) return null;
|
|
25
|
+
if (args.length < 2) return false;
|
|
26
|
+
return args.slice(1).some((a) => /^\s*\{[^]*?\broot\s*:/.test(a));
|
|
27
|
+
}
|
|
@@ -4,10 +4,45 @@
|
|
|
4
4
|
// Covers JS (console/winston/pino/bunyan), Python (logging/print),
|
|
5
5
|
// Go (log/fmt.Printf).
|
|
6
6
|
|
|
7
|
+
import { isListenCallbackParam } from './listen-bind-address.js';
|
|
8
|
+
|
|
7
9
|
const PII_CONTEXT = /\b(email|ssn|password|phone|dob|date_of_birth|credit_card|card_number|social_security|passport|medical_record|ip_address|first_name|last_name|address|zip_code|bank_account)\b/i;
|
|
8
10
|
|
|
9
11
|
function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
|
|
10
12
|
|
|
13
|
+
// The detector is about a VARIABLE with a PII-ish name being logged, so a PII word
|
|
14
|
+
// that only appears as prose inside a string literal is not evidence of anything.
|
|
15
|
+
// Keep identifiers and the expressions inside template substitutions and f-string
|
|
16
|
+
// fields; drop the text of plain string literals and the literal parts of template
|
|
17
|
+
// strings.
|
|
18
|
+
function _codeOnly(text) {
|
|
19
|
+
let out = '';
|
|
20
|
+
for (let i = 0; i < text.length; i++) {
|
|
21
|
+
const c = text[i];
|
|
22
|
+
const fString = (c === '"' || c === "'") && /(?:^|[^\w])[rRbB]?[fF][rR]?$/.test(text.slice(Math.max(0, i - 3), i));
|
|
23
|
+
if ((c === '"' || c === "'") && !fString) {
|
|
24
|
+
const q = c; i++;
|
|
25
|
+
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++; }
|
|
26
|
+
out += ' ';
|
|
27
|
+
} else if (c === '`' || fString) {
|
|
28
|
+
// template literal (JS) or f-string (Python): the literal text is prose, `${...}` / `{...}` is code
|
|
29
|
+
const q = c; i++;
|
|
30
|
+
while (i < text.length && text[i] !== q) {
|
|
31
|
+
if (text[i] === '\\') { i += 2; continue; }
|
|
32
|
+
if ((q === '`' && text[i] === '$' && text[i + 1] === '{') || (fString && text[i] === '{' && text[i + 1] !== '{')) {
|
|
33
|
+
let d = 1; i += q === '`' ? 2 : 1; out += ' ';
|
|
34
|
+
while (i < text.length && d > 0) { if (text[i] === '{') d++; else if (text[i] === '}') { d--; if (d === 0) break; } out += text[i]; i++; }
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
out += ' ';
|
|
41
|
+
} else out += c;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
11
46
|
const LANG_SINKS = {
|
|
12
47
|
js: {
|
|
13
48
|
ext: /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i,
|
|
@@ -51,7 +86,9 @@ export function scanSensitiveDataLogging(fp, raw) {
|
|
|
51
86
|
const lineNum = _line(raw, m.index);
|
|
52
87
|
const lineEnd = raw.indexOf('\n', m.index);
|
|
53
88
|
const lineText = raw.slice(m.index, lineEnd > 0 ? lineEnd : m.index + 200);
|
|
54
|
-
|
|
89
|
+
const piiWords = [..._codeOnly(lineText).matchAll(new RegExp(PII_CONTEXT.source, 'gi'))].map((w) => w[1].toLowerCase());
|
|
90
|
+
const bindAddr = piiWords.includes('address') && isListenCallbackParam(raw, m.index, 'address');
|
|
91
|
+
if (!piiWords.some((w) => !(w === 'address' && bindAddr))) continue;
|
|
55
92
|
// Skip if the line contains redaction/masking
|
|
56
93
|
if (/\b(?:redact|mask|sanitize|censor|scrub|\*{3,}|\.{3}|slice\s*\(\s*0\s*,\s*\d\s*\))\b/i.test(lineText)) continue;
|
|
57
94
|
findings.push({
|