#!/usr/bin/env python3 """ Pre-commit hook to detect double-escaping in JavaScript files. Catches patterns where escapeHtml() is called on values that are then passed to functions which already escape internally (showError, showSuccess, showInfo, showAlert). Known limitations: - Multi-line function calls are not detected (line-by-line processing). - Variable indirection bypasses detection (e.g., ``const safe = escapeHtml(msg); showError(safe);``). """ import re import sys # Functions that escape their arguments internally ESCAPING_FUNCTIONS = ["showError", "showSuccess", "showInfo", "showAlert"] # Build pattern: showError(...escapeHtml(...)...) PATTERN = re.compile( r"\b(" + "|".join(ESCAPING_FUNCTIONS) + r")\s*\(" r".*?" r"(?:escapeHtml|XSSProtection\.escapeHtml)\s*\(" ) def strip_js_comments(line, in_block_comment): """Strip JavaScript comments from a line, respecting string literals. Handles ``//`` line comments, ``/* */`` block comments (including multi-line), and correctly ignores comment-like sequences inside single-quoted, double-quoted, and template-literal strings. Returns ``(code_without_comments, still_in_block_comment)``. """ result = [] i = 0 in_single_quote = False in_double_quote = False in_template = False while i < len(line): ch = line[i] # Inside a block comment — scan for */ if in_block_comment: if ch == "*" and i + 1 < len(line) and line[i + 1] == "/": in_block_comment = False i += 2 else: i += 1 continue # Handle escape sequences inside strings if (in_single_quote or in_double_quote or in_template) and ch == "\\": result.append(ch) if i + 1 < len(line): result.append(line[i + 1]) i += 2 else: i += 1 continue # Track string state if ch == "'" and not in_double_quote and not in_template: in_single_quote = not in_single_quote elif ch == '"' and not in_single_quote and not in_template: in_double_quote = not in_double_quote elif ch == "`" and not in_single_quote and not in_double_quote: in_template = not in_template elif not in_single_quote and not in_double_quote and not in_template: # Outside strings — check for comments if ch == "/" and i + 1 < len(line): if line[i + 1] == "/": break # Line comment — rest of line is comment if line[i + 1] == "*": in_block_comment = True i += 2 continue result.append(ch) i += 1 return "".join(result), in_block_comment def check_file(file_path): """Check a JavaScript file for double-escaping patterns.""" issues = [] try: with open(file_path, "r", encoding="utf-8") as f: lines = f.readlines() except (UnicodeDecodeError, IOError): # Skip binary files or files we can't read return [] in_block_comment = False for line_num, line in enumerate(lines, 1): code, in_block_comment = strip_js_comments(line, in_block_comment) if PATTERN.search(code): issues.append( f"{file_path}:{line_num}: Possible double-escaping — " f"escapeHtml() inside a function that already escapes: " f"{line.strip()}" ) return issues def main(): """Main function to check all provided files.""" if len(sys.argv) < 2: print("No files to check") return 0 all_issues = [] for file_path in sys.argv[1:]: if not file_path.endswith(".js"): continue try: issues = check_file(file_path) all_issues.extend(issues) except Exception as e: print(f"Error checking {file_path}: {e}") continue if all_issues: print("Double-escaping detected:") print("-" * 60) for issue in all_issues: print(f" {issue}") print("-" * 60) print("\nThese functions already escape their arguments internally.") print("Pass raw text instead of pre-escaping with escapeHtml().") return 1 return 0 if __name__ == "__main__": sys.exit(main())