141 lines
5.8 KiB
Python
141 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Find SQL queries mixing named and positional parameters in PHP files."""
|
|
import re
|
|
import os
|
|
import glob
|
|
|
|
dirs = ['api/', 'classes/', 'services/']
|
|
results = []
|
|
|
|
for d in dirs:
|
|
for filepath in glob.glob(os.path.join(d, '**', '*.php'), recursive=True):
|
|
try:
|
|
with open(filepath, 'r', errors='replace') as f:
|
|
content = f.read()
|
|
except Exception:
|
|
continue
|
|
|
|
lines = content.split('\n')
|
|
total_lines = len(lines)
|
|
|
|
# APPROACH 1: Look at the whole file content for SQL strings
|
|
# Find all SQL string assignments and prepare/query calls
|
|
# Use regex to find multi-line strings between quotes
|
|
|
|
# Find all quoted strings that look like SQL (may span lines with concatenation)
|
|
# Pattern: capture everything between matching quotes in SQL context
|
|
|
|
# First find all $var = "..." or prepare("...") blocks
|
|
# Including multi-line with string concatenation
|
|
|
|
# Simpler approach: for each file, find all occurrences of execute([...])
|
|
# and check if the SQL and params are mixed
|
|
|
|
# APPROACH: Read entire file, find SQL strings, check for mixed params
|
|
# Look for patterns like: "SELECT ... ? ... :param ..."
|
|
|
|
# Find all string literals that contain SQL keywords
|
|
# Handle both single-line and multi-line concatenated strings
|
|
|
|
for i, line in enumerate(lines):
|
|
# Skip comment lines
|
|
stripped = line.strip()
|
|
if stripped.startswith('//') or stripped.startswith('*') or stripped.startswith('#'):
|
|
continue
|
|
|
|
# Look for SQL query assignments or prepare/query calls on this line
|
|
is_sql_line = bool(
|
|
re.search(r'(prepare|query|exec)\s*\(', line, re.IGNORECASE) or
|
|
re.search(r'\$\w*(sql|query)\w*\s*=', line, re.IGNORECASE)
|
|
)
|
|
|
|
if not is_sql_line:
|
|
continue
|
|
|
|
# Gather block: from this line until statement seems complete
|
|
block_lines_list = []
|
|
j = i
|
|
brace_depth = 0
|
|
while j < total_lines and j < i + 50:
|
|
bl = lines[j]
|
|
block_lines_list.append(bl)
|
|
brace_depth += bl.count('(') - bl.count(')')
|
|
# End conditions
|
|
if j > i and brace_depth <= 0 and ';' in bl:
|
|
break
|
|
j += 1
|
|
|
|
block = '\n'.join(block_lines_list)
|
|
|
|
# Now extract ALL string content from this block
|
|
# Handle concatenated strings like "part1" . "part2"
|
|
# and "part1
|
|
# part2" (multi-line strings)
|
|
all_strings = []
|
|
# Double-quoted strings (handle escaped quotes)
|
|
all_strings.extend(re.findall(r'"((?:[^"\\]|\\.)*)"', block))
|
|
# Single-quoted strings
|
|
all_strings.extend(re.findall(r"'((?:[^'\\]|\\.)*)'", block))
|
|
|
|
full_sql = ' '.join(all_strings)
|
|
|
|
# Must contain SQL keywords
|
|
if not re.search(r'\b(SELECT|INSERT|UPDATE|DELETE|REPLACE\s+INTO)\b', full_sql, re.IGNORECASE):
|
|
continue
|
|
|
|
# Check for positional (?) - but not in ternary context
|
|
# In SQL strings, ? should appear as a standalone placeholder
|
|
has_positional = bool(re.search(r'\?', full_sql))
|
|
|
|
# Check for named params - :word but not :: or :// or :\
|
|
named_in_sql = re.findall(r'(?<![:\w/]):[a-zA-Z_][a-zA-Z0-9_]*', full_sql)
|
|
# Filter out things that are clearly not SQL params (like :hover, :root from CSS)
|
|
named_in_sql = [n for n in named_in_sql if n.lower() not in (':hover', ':root', ':focus', ':active', ':visited')]
|
|
has_named = len(named_in_sql) > 0
|
|
|
|
if has_positional and has_named:
|
|
# Extra validation: make sure ? is in the SQL part, not just in error messages etc.
|
|
# Check each individual string for SQL content
|
|
sql_strings = [s for s in all_strings if re.search(r'\b(SELECT|INSERT|UPDATE|DELETE|WHERE|SET|FROM|INTO|VALUES)\b', s, re.IGNORECASE)]
|
|
combined_sql = ' '.join(sql_strings)
|
|
|
|
has_pos_in_sql = bool(re.search(r'\?', combined_sql))
|
|
named_in_real_sql = re.findall(r'(?<![:\w/]):[a-zA-Z_][a-zA-Z0-9_]*', combined_sql)
|
|
named_in_real_sql = [n for n in named_in_real_sql if n.lower() not in (':hover', ':root', ':focus', ':active', ':visited')]
|
|
|
|
if has_pos_in_sql and named_in_real_sql:
|
|
results.append({
|
|
'file': filepath,
|
|
'start_line': i + 1,
|
|
'end_line': i + len(block_lines_list),
|
|
'named_params': named_in_real_sql,
|
|
'block': block.strip(),
|
|
'sql_text': combined_sql[:500]
|
|
})
|
|
|
|
# Deduplicate (same file + same start line)
|
|
seen = set()
|
|
unique_results = []
|
|
for r in results:
|
|
key = (r['file'], r['start_line'])
|
|
if key not in seen:
|
|
seen.add(key)
|
|
unique_results.append(r)
|
|
|
|
# Output results
|
|
if not unique_results:
|
|
print("No mixed parameter queries found.")
|
|
else:
|
|
for r in unique_results:
|
|
print("=" * 80)
|
|
print(f"FILE: {r['file']}")
|
|
print(f"LINES: {r['start_line']}-{r['end_line']}")
|
|
print(f"NAMED PARAMS: {r['named_params']}")
|
|
print(f"SQL TEXT: {r['sql_text'][:400]}")
|
|
print(f"CODE BLOCK:")
|
|
for bl in r['block'].split('\n'):
|
|
print(f" {bl}")
|
|
print()
|
|
|
|
print(f"TOTAL: {len(unique_results)} potential mixed parameter queries found.")
|