#!/usr/bin/env php |<=|>=|<>|!=|\bIN\b|\bVALUES\b|\bLIKE\b|\bLIMIT\b|\bOFFSET\b|\(|,)\s*`?$/i'; $ident_at = preg_match($ident, $before) ? 1 : 0; $value_at = preg_match($value, $before) ? 1 : 0; if ($ident_at && !$value_at) { return 'identifier'; } if ($value_at && !$ident_at) { return 'value'; } // Ambiguous or no anchor: treat as value so it is not silently trusted. return 'value'; } function classify(string $func, string $text, bool $has_var, array $helpers, bool $only_helper_vars, array $raw_names): string { if (str_ends_with($func, '_prepared')) { return 'prepared'; } if (!$has_var) { return 'static'; } // No raw markers: every dynamic fragment passed through an escaping helper. if (!str_contains($text, '§')) { return 'qstr'; } // The argument is just a variable (or variables) holding a prebuilt query; // there is no inline SQL to anchor against. Needs data-flow tracing, not a // mechanical prepared-statement rewrite. if (trim(str_replace(['§', '¤'], '', $text)) === '') { return 'opaque'; } // Classify each raw marker. A scalar in value position is the migration // target and dominates. A prebuilt SQL fragment is tracked separately. $saw_identifier = false; $saw_fragment = false; $offset = 0; $idx = 0; while (($pos = strpos($text, '§', $offset)) !== false) { $name = $raw_names[$idx] ?? null; $offset = $pos + strlen('§'); $idx++; if (is_fragment_name($name)) { $saw_fragment = true; continue; } if (marker_position(substr($text, 0, $pos)) === 'value') { return 'value'; } $saw_identifier = true; } if ($saw_identifier) { return 'identifier'; } return $saw_fragment ? 'fragment' : 'value'; } /** * Scan one PHP source string and return every db_* call site it contains as * ['line' => int, 'func' => string, 'category' => string]. Split out so it * can be unit tested against snippets without touching the filesystem. */ function scan_source(string $src, array $db_functions, array $escaping_helpers): array { if (!str_contains($src, 'db_')) { return []; } $tokens = @token_get_all($src); $n = count($tokens); $sites = []; for ($i = 0; $i < $n; $i++) { $tok = $tokens[$i]; if (!is_array($tok) || $tok[0] !== T_STRING) { continue; } $func = $tok[1]; if (!in_array($func, $db_functions, true)) { continue; } // Next significant token must be '(' for this to be a call. $j = $i + 1; while ($j < $n && is_array($tokens[$j]) && ($tokens[$j][0] === T_WHITESPACE || $tokens[$j][0] === T_COMMENT)) { $j++; } if ($j >= $n || $tokens[$j] !== '(') { continue; } $line = $tok[2]; // Walk to the matching close paren; split first arg at top-level comma. $depth = 0; $arg_start = $j + 1; $arg_end = null; $call_end = null; for ($k = $j; $k < $n; $k++) { $t = $tokens[$k]; if ($t === '(') { $depth++; } elseif ($t === ')') { $depth--; if ($depth === 0) { $call_end = $k; if ($arg_end === null) { $arg_end = $k; } break; } } elseif ($t === ',' && $depth === 1 && $arg_end === null) { $arg_end = $k; } } if ($arg_end === null) { continue; } [$text, $has_var, $helpers, $only_helper_vars, $raw_names] = inspect_first_arg($tokens, $arg_start, $arg_end, $escaping_helpers); $sites[] = [ 'line' => $line, 'func' => $func, 'category' => classify($func, $text, $has_var, $helpers, $only_helper_vars, $raw_names), ]; $i = $call_end; // resume after this call } return $sites; } // Stop here when included as a library (tests require the functions only). if (defined('SQL_AUDIT_LIB_ONLY')) { return; } $files = list_php_files(); $results = []; // path => array of sites foreach ($files as $path) { $src = file_get_contents($path); if ($src === false) { continue; } $sites = scan_source($src, $db_functions, $escaping_helpers); if ($sites !== []) { $results[$path] = $sites; } } // ---- aggregate ---- $per_file = []; // path => category => count $totals = ['prepared' => 0, 'static' => 0, 'qstr' => 0, 'fragment' => 0, 'opaque' => 0, 'identifier' => 0, 'value' => 0]; foreach ($results as $path => $sites) { foreach ($sites as $s) { $per_file[$path][$s['category']] = ($per_file[$path][$s['category']] ?? 0) + 1; $totals[$s['category']]++; } } // ---- output modes ---- $mode = $argv[1] ?? '--summary'; if ($mode === '--list') { $want = $argv[2] ?? 'value'; ksort($results); foreach ($results as $path => $sites) { foreach ($sites as $s) { if ($s['category'] === $want) { printf("%s:%d\t%s\n", $path, $s['line'], $s['func']); } } } exit(0); } if ($mode === '--json') { $out = ['totals' => $totals, 'files' => []]; ksort($per_file); foreach ($per_file as $path => $cats) { $out['files'][$path] = [ 'value' => $cats['value'] ?? 0, 'identifier' => $cats['identifier'] ?? 0, ]; } print json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; exit(0); } if ($mode === '--write-baseline') { $baseline = []; ksort($per_file); foreach ($per_file as $path => $cats) { $v = $cats['value'] ?? 0; if ($v > 0) { $baseline[$path] = $v; } } file_put_contents(BASELINE, json_encode($baseline, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); printf("Wrote %s: %d files, %d value-interpolation sites.\n", BASELINE, count($baseline), array_sum($baseline)); exit(0); } if ($mode === '--check') { if (!file_exists(BASELINE)) { fwrite(STDERR, "Baseline missing: " . BASELINE . " (run --write-baseline).\n"); exit(2); } $baseline = json_decode(file_get_contents(BASELINE), true); if (!is_array($baseline)) { fwrite(STDERR, "Baseline is not valid JSON.\n"); exit(2); } $regressions = []; $current = []; foreach ($per_file as $path => $cats) { if (($cats['value'] ?? 0) > 0) { $current[$path] = $cats['value']; } } foreach ($current as $path => $v) { $allowed = $baseline[$path] ?? 0; if ($v > $allowed) { $regressions[] = sprintf(' %s: %d value site(s), baseline %d', $path, $v, $allowed); } } if ($regressions !== []) { fwrite(STDERR, "SQL interpolation ratchet failed. New unparameterised value interpolation:\n"); fwrite(STDERR, implode("\n", $regressions) . "\n"); fwrite(STDERR, "\nUse db_*_prepared() with a parameter array, then update the baseline\n"); fwrite(STDERR, "with: php tests/tools/sql_interpolation_audit.php --write-baseline\n"); exit(1); } // Nudge the baseline down when sites are fixed but the file is stale. $stale = []; foreach ($baseline as $path => $allowed) { $now = $current[$path] ?? 0; if ($now < $allowed) { $stale[] = sprintf(' %s: baseline %d, now %d', $path, $allowed, $now); } } printf("SQL interpolation ratchet passed. %d value site(s) across %d file(s).\n", array_sum($current), count($current)); if ($stale !== []) { printf("%d file(s) improved below baseline; refresh with --write-baseline:\n%s\n", count($stale), implode("\n", $stale)); } exit(0); } // default: human summary printf("db_* call sites by SQL construction:\n\n"); foreach ($totals as $cat => $n) { printf(" %-11s %5d\n", $cat, $n); } printf(" %-11s %5d\n", 'TOTAL', array_sum($totals)); printf("\nMigration target (value) and review target (identifier) by file:\n\n"); $rows = []; foreach ($per_file as $path => $cats) { $v = $cats['value'] ?? 0; $id = $cats['identifier'] ?? 0; if ($v || $id) { $rows[$path] = [$v, $id]; } } uasort($rows, function ($a, $b) { return $b[0] <=> $a[0]; }); printf(" %-52s %6s %6s\n", 'file', 'value', 'ident'); foreach ($rows as $path => $c) { printf(" %-52s %6d %6d\n", $path, $c[0], $c[1]); }