@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.
Files changed (167) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/agentic-security.js +24 -1
  3. package/dist/1128.index.js +25 -21
  4. package/dist/2584.index.js +1 -1
  5. package/dist/4171.index.js +2 -2
  6. package/dist/5752.index.js +466 -0
  7. package/dist/7405.index.js +1 -1
  8. package/dist/8158.index.js +1713 -0
  9. package/dist/8798.index.js +1 -1
  10. package/dist/9382.index.js +2 -2
  11. package/dist/9579.index.js +1 -1
  12. package/dist/agentic-security.mjs +29 -29
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/dist/{0b301d45b231f144a1b4.json → ca62ee93079be4c24eee.json} +7 -7
  15. package/package.json +7 -7
  16. package/src/dataflow/CLAUDE.md +6 -3
  17. package/src/dataflow/access-paths.js +12 -2
  18. package/src/dataflow/demand-query.js +279 -40
  19. package/src/dataflow/dep-fingerprint.js +1 -1
  20. package/src/dataflow/exploit-prover.js +6 -2
  21. package/src/dataflow/ir-cache.js +268 -0
  22. package/src/dataflow/privacy-taint.js +7 -0
  23. package/src/engine.js +62 -5
  24. package/src/ir/CLAUDE.md +4 -0
  25. package/src/ir/callgraph.js +36 -8
  26. package/src/ir/index.js +18 -4
  27. package/src/ir/parser-js.js +10 -0
  28. package/src/language/CLAUDE.md +13 -0
  29. package/src/language/nix-option-types.js +170 -0
  30. package/src/language/nix-script-taint.js +46 -3
  31. package/src/language/nix-secrets.js +20 -2
  32. package/src/lineage/CLAUDE.md +2 -1
  33. package/src/lineage/smt-orchestrator.js +47 -11
  34. package/src/lineage/smt-z3-backend.js +294 -0
  35. package/src/posture/CLAUDE.md +5 -1
  36. package/src/posture/business-logic.js +16 -7
  37. package/src/posture/dag-commands.js +442 -0
  38. package/src/posture/finding-defaults.js +19 -0
  39. package/src/posture/git-history.js +91 -1
  40. package/src/sast/CLAUDE.md +2 -0
  41. package/src/sast/_call-args.js +70 -0
  42. package/src/sast/init-scope.js +87 -0
  43. package/src/sast/listen-bind-address.js +49 -0
  44. package/src/sast/redirect-target.js +106 -0
  45. package/src/sast/reflected-input.js +76 -0
  46. package/src/sast/send-file-root.js +27 -0
  47. package/src/sast/sensitive-data-logging.js +38 -1
  48. package/dist/119.index.js +0 -197
  49. package/dist/128.index.js +0 -129
  50. package/dist/1293.index.js +0 -5689
  51. package/dist/1336.index.js +0 -16331
  52. package/dist/1351.index.js +0 -133
  53. package/dist/1387.index.js +0 -271
  54. package/dist/1437.index.js +0 -23
  55. package/dist/1445.index.js +0 -110
  56. package/dist/1505.index.js +0 -151
  57. package/dist/1557.index.js +0 -293
  58. package/dist/1621.index.js +0 -311
  59. package/dist/1751.index.js +0 -160
  60. package/dist/1765.index.js +0 -727
  61. package/dist/1861.index.js +0 -690
  62. package/dist/1906.index.js +0 -433
  63. package/dist/208.index.js +0 -1631
  64. package/dist/2223.index.js +0 -159
  65. package/dist/2238.index.js +0 -295
  66. package/dist/2530.index.js +0 -152
  67. package/dist/2565.index.js +0 -116
  68. package/dist/2574.index.js +0 -127
  69. package/dist/2620.index.js +0 -345
  70. package/dist/2773.index.js +0 -98
  71. package/dist/2775.index.js +0 -32
  72. package/dist/2966.index.js +0 -171
  73. package/dist/2972.index.js +0 -87
  74. package/dist/2991.index.js +0 -131
  75. package/dist/3063.index.js +0 -833
  76. package/dist/3075.index.js +0 -227
  77. package/dist/3121.index.js +0 -297
  78. package/dist/3314.index.js +0 -263
  79. package/dist/3480.index.js +0 -1822
  80. package/dist/3481.index.js +0 -24
  81. package/dist/3506.index.js +0 -268
  82. package/dist/3533.index.js +0 -591
  83. package/dist/3653.index.js +0 -450
  84. package/dist/3740.index.js +0 -722
  85. package/dist/3822.index.js +0 -131
  86. package/dist/3868.index.js +0 -292
  87. package/dist/3918.index.js +0 -192
  88. package/dist/4090.index.js +0 -288
  89. package/dist/4113.index.js +0 -1286
  90. package/dist/4121.index.js +0 -1190
  91. package/dist/4142.index.js +0 -690
  92. package/dist/4168.index.js +0 -509
  93. package/dist/4281.index.js +0 -993
  94. package/dist/4336.index.js +0 -436
  95. package/dist/4387.index.js +0 -532
  96. package/dist/4413.index.js +0 -302
  97. package/dist/4476.index.js +0 -4510
  98. package/dist/4491.index.js +0 -133
  99. package/dist/4538.index.js +0 -185
  100. package/dist/4684.index.js +0 -319
  101. package/dist/4740.index.js +0 -165
  102. package/dist/4780.index.js +0 -209
  103. package/dist/4802.index.js +0 -225
  104. package/dist/4812.index.js +0 -379
  105. package/dist/4835.index.js +0 -126
  106. package/dist/494.index.js +0 -226
  107. package/dist/4991.index.js +0 -549
  108. package/dist/5199.index.js +0 -305
  109. package/dist/5216.index.js +0 -109
  110. package/dist/5439.index.js +0 -301
  111. package/dist/5443.index.js +0 -97
  112. package/dist/5496.index.js +0 -377
  113. package/dist/5738.index.js +0 -240
  114. package/dist/5801.index.js +0 -588
  115. package/dist/5817.index.js +0 -714
  116. package/dist/5858.index.js +0 -223
  117. package/dist/5933.index.js +0 -143
  118. package/dist/601.index.js +0 -23
  119. package/dist/6173.index.js +0 -273
  120. package/dist/6291.index.js +0 -1871
  121. package/dist/6312.index.js +0 -666
  122. package/dist/634.index.js +0 -16182
  123. package/dist/644.index.js +0 -87
  124. package/dist/6508.index.js +0 -283
  125. package/dist/6560.index.js +0 -645
  126. package/dist/6576.index.js +0 -422
  127. package/dist/6579.index.js +0 -271
  128. package/dist/6655.index.js +0 -3221
  129. package/dist/6745.index.js +0 -305
  130. package/dist/6818.index.js +0 -21642
  131. package/dist/6932.index.js +0 -1190
  132. package/dist/6972.index.js +0 -167
  133. package/dist/6995.index.js +0 -163
  134. package/dist/7249.index.js +0 -191
  135. package/dist/7372.index.js +0 -1197
  136. package/dist/7546.index.js +0 -541
  137. package/dist/76.index.js +0 -448
  138. package/dist/7879.index.js +0 -163
  139. package/dist/8025.index.js +0 -130
  140. package/dist/8054.index.js +0 -248
  141. package/dist/808.index.js +0 -1330
  142. package/dist/8097.index.js +0 -201
  143. package/dist/8126.index.js +0 -191
  144. package/dist/8178.index.js +0 -78
  145. package/dist/8382.index.js +0 -93
  146. package/dist/8408.index.js +0 -261
  147. package/dist/8540.index.js +0 -174
  148. package/dist/8635.index.js +0 -802
  149. package/dist/8688.index.js +0 -410
  150. package/dist/8699.index.js +0 -779
  151. package/dist/8891.index.js +0 -457
  152. package/dist/8916.index.js +0 -885
  153. package/dist/9146.index.js +0 -170
  154. package/dist/9168.index.js +0 -841
  155. package/dist/9292.index.js +0 -369
  156. package/dist/9422.index.js +0 -866
  157. package/dist/9440.index.js +0 -453
  158. package/dist/9479.index.js +0 -152
  159. package/dist/9481.index.js +0 -220
  160. package/dist/9539.index.js +0 -100
  161. package/dist/9547.index.js +0 -103
  162. package/dist/9692.index.js +0 -90
  163. package/dist/9800.index.js +0 -462
  164. package/dist/9899.index.js +0 -138
  165. package/dist/9981.index.js +0 -206
  166. package/dist/999.index.js +0 -117
  167. 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
- if (!PII_CONTEXT.test(lineText)) continue;
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({