All files collect-prototype-pollution.ts

92.85% Statements 39/42
78.04% Branches 32/41
100% Functions 0/0
100% Lines 38/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113            4x             5x 5x 5x     5x     2x   2x     2x       2x         18x 15x   3x         3x 3x 3x 3x             2x   2x       2x 2x 2x 2x 2x   4x   2x           85x       52x   2x 2x   52x   4x 4x                         5x     5x 5x     1153x     85x 85x      
import * as ts from 'typescript';
 
import { isFunctionLike } from './ast-helpers.js';
import { findParentBlock } from './collect-effects.js';
import { getLineAndCharacter } from './utils.js';
 
const DEEP_MERGE_NAMES = new Set([
  'merge', 'deepMerge', 'deepAssign', 'extend', 'deepExtend',
  'defaults', 'defaultsDeep', 'assign', 'mixin',
]);
 
/** Check if a computed-property-write key comes from a for..of/for..in loop over known internal iteration */
function isKeyFromInternalIteration(node: ts.ElementAccessExpression, sourceFile: ts.SourceFile): boolean {
  const keyExpr = node.argumentExpression;
  Iif (!keyExpr || !ts.isIdentifier(keyExpr)) return false;
  const keyName = keyExpr.getText(sourceFile);
 
  // Walk up to find a for-of/for-in loop that declares this key
  let current: ts.Node | undefined = node.parent;
  while (current) {
    if (ts.isForOfStatement(current) || ts.isForInStatement(current)) {
      const init = current.initializer;
      if (init) {
        const initText = init.getText(sourceFile);
        if (initText.includes(keyName)) {
          // Check if the iterable is a known-safe internal source
          const expr = current.expression.getText(sourceFile);
          if (/Object\.(keys|values|entries|getOwnPropertyNames)\(/.test(expr) ||
              /\.keys\(\)|\.values\(\)|\.entries\(\)/.test(expr) ||
              /Array\.from\(/.test(expr)) {
            return true;
          }
        }
      }
    }
    if (isFunctionLike(current)) break;
    current = current.parent;
  }
  return false;
}
 
/** Check if the containing block has a __proto__/constructor/prototype key guard */
function hasProtoKeyGuard(node: ts.Node, sourceFile: ts.SourceFile): boolean {
  const block = findParentBlock(node);
  Iif (!block) return false;
  const blockText = block.getText(sourceFile);
  return /__proto__|constructor|prototype/.test(blockText) &&
    (blockText.includes('===') || blockText.includes('!==') ||
     blockText.includes('includes(') || blockText.includes('hasOwnProperty'));
}
 
/** Check if the target object was created with Object.create(null) or is Map/Set */
function isTargetSafeObject(node: ts.ElementAccessExpression, sourceFile: ts.SourceFile): boolean {
  const objText = node.expression.getText(sourceFile);
  // Walk up to find variable declaration
  let current: ts.Node | undefined = node.parent;
  while (current) {
    if (ts.isBlock(current) || ts.isSourceFile(current)) {
      // Search for Object.create(null) or new Map/Set assignment for this object
      const text = current.getText(sourceFile);
      const createNullPattern = new RegExp(`${objText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*Object\\.create\\(null\\)`);
      const mapSetPattern = new RegExp(`${objText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*new\\s+(Map|Set)\\b`);
      Iif (createNullPattern.test(text) || mapSetPattern.test(text)) return true;
      break;
    }
    current = current.parent;
  }
  return false;
}
 
export function collectPrototypePollutionSites(
  sourceFile: ts.SourceFile,
): Array<{ kind: string; detail: string; lineStart: number; lineEnd: number; guarded: boolean }> {
  const sites: Array<{ kind: string; detail: string; lineStart: number; lineEnd: number; guarded: boolean }> = [];
 
  const visit = (node: ts.Node): void => {
    if (ts.isCallExpression(node)) {
      const text = node.expression.getText(sourceFile);
      if (text === 'Object.assign' && node.arguments.length >= 2) {
        const loc = getLineAndCharacter(sourceFile, node);
        sites.push({ kind: 'object-assign', detail: `Object.assign() merges properties without __proto__ guard`, lineStart: loc.lineStart, lineEnd: loc.lineEnd, guarded: false });
      }
      const calleeName = text.split('.').pop() || '';
      if (DEEP_MERGE_NAMES.has(calleeName) && node.arguments.length >= 1) {
        const loc = getLineAndCharacter(sourceFile, node);
        sites.push({ kind: 'deep-merge', detail: `${calleeName}() deep-merges without prototype guard`, lineStart: loc.lineStart, lineEnd: loc.lineEnd, guarded: false });
      }
    }
 
    if (
      ts.isElementAccessExpression(node) &&
      node.argumentExpression &&
      !ts.isStringLiteral(node.argumentExpression) &&
      !ts.isNumericLiteral(node.argumentExpression) &&
      node.parent && ts.isBinaryExpression(node.parent) &&
      node.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
      node.parent.left === node
    ) {
      const guarded = isKeyFromInternalIteration(node, sourceFile) ||
                       hasProtoKeyGuard(node, sourceFile) ||
                       isTargetSafeObject(node, sourceFile);
      const loc = getLineAndCharacter(sourceFile, node);
      sites.push({ kind: 'computed-property-write', detail: `Dynamic bracket assignment: ${node.getText(sourceFile).slice(0, 40)}`, lineStart: loc.lineStart, lineEnd: loc.lineEnd, guarded });
    }
 
    ts.forEachChild(node, visit);
  };
 
  ts.forEachChild(sourceFile, visit);
  return sites;
}