#!/usr/bin/env python3 """ Sucht vorbereitete Anweisungen, in denen derselbe benannte Platzhalter mehrfach vorkommt. WARUM ES DIESES SKRIPT GIBT --------------------------- Core\\Db setzt PDO::ATTR_EMULATE_PREPARES = false. MySQL bereitet die Anweisung dann selbst vor, und PDO uebersetzt benannte Platzhalter in positionsgebundene - wobei jeder Name genau EINMAL gebunden wird. Steht ":foo" zweimal im SQL, hat die Anweisung zwei Marker und bekommt einen Wert. Ergebnis: SQLSTATE[HY093] und ein HTTP 500. Das ist im Deploymentcenter viermal passiert: 1. Volltextsuche im Bugtracker 2. ProjectLinks::between() 3. ProjectLinks::forProject() 4. BugRepo::buildFilter(), Rueckkanal fuer Nachpruefungen (Bugtracker #351) Im vierten Fall traf es ausschliesslich manage?action=next - der einzige Aufruf, der den betroffenen Filterzweig setzt. claim, release, list und stats liefen durch dieselbe Funktion und blieben heil, was die Suche lange in die falsche Richtung geschickt hat. Nach dem dritten Mal stand in tests/e2e/suite_bugtracker.py der Satz: "ein Suchlauf ueber alle prepare()-Aufrufe gehoert zur Durchsicht". Er stand dort als Vorsatz. Das vierte Mal kam trotzdem. Deshalb jetzt als Skript. AUFRUF ------ python scripts/pruefe_platzhalter.py # ganzes Repository python scripts/pruefe_platzhalter.py src/ # nur ein Teilbaum Rueckgabewert 1, wenn etwas gefunden wurde - damit es sich in einen Vorab-Check haengen laesst. VERKETTUNG ---------- Geprueft wird nicht je Literal, sondern je VERKETTETEM AUSDRUCK. Das ist der Punkt, an dem die erste Fassung dieses Skripts versagt hat: Der Fehler in BugRepo::buildFilter() stand in '(project_slug = :project_scope OR (status = "' . self::STATUS_AWAITING_VERIFICATION . '" ... WHERE project_slug = :project_scope)))' also in ZWEI Literalen, in jedem genau einmal. Pro Literal gezaehlt war das unauffaellig; erst der zusammengesetzte Text hat den Namen doppelt. Ein Pruefer, der genau den Fehler nicht findet, der ihn ausgeloest hat, ist keiner. Zusammengefasst werden zwei aufeinanderfolgende Literale, wenn zwischen ihnen ein "." steht und kein ";" "," "[" oder "]". Damit faellt der haeufige Fall "Liste mehrerer eigenstaendiger Anweisungen in einem Array" heraus - dort trennt ein Komma, und jede Anweisung wird fuer sich gebunden. GRENZEN ------- Verkettungen ueber einen Funktionsaufruf mit Komma hinweg ('... :a' . f($x, 1) . '... :a') fallen durch. Dafuer waere ein PHP-Parser noetig, und der Aufwand stuende in keinem Verhaeltnis. """ import collections import io import os import re import sys PLATZHALTER = re.compile(r":([a-zA-Z_][a-zA-Z0-9_]*)") # Ohne diese Woerter ist ein Literal mit ":name" kein SQL, sondern etwa eine # Zeitangabe ("%H:%i") oder ein Doppelpunkt in Prosa. SQL_WORTE = re.compile( r"\b(SELECT|INSERT|UPDATE|DELETE|WHERE|VALUES|SET|FROM|AND|OR|IN|LIKE)\b", re.I, ) BS = chr(92) UEBERSPRINGEN = {".git", "__pycache__", "node_modules", "artifacts", "bin", "obj"} # Steht eines dieser Zeichen zwischen zwei Literalen, gehoeren sie NICHT zu # demselben Ausdruck: Komma und Semikolon trennen, eckige Klammern deuten auf # eine Liste eigenstaendiger Anweisungen. TRENNER = set(";,[]") # Wie weit zwei Literale hoechstens auseinanderliegen duerfen, um noch als # verkettet zu gelten. Verhindert, dass ueber halbe Dateien hinweg # zusammengefasst wird, wenn die Klammernerkennung einmal danebengreift. MAX_LUECKE = 300 def literale(src): """ Liefert (zeile, inhalt, anfang, ende) je einfach begrenztem Stringliteral. Kommentare und doppelt begrenzte Zeichenketten werden uebersprungen - sonst meldet ein Beispiel-SQL im Kommentar einen Treffer, den es nicht gibt. """ i = 0 n = len(src) while i < n: c = src[i] if c == "/" and i + 1 < n and src[i + 1] == "/": i = src.find("\n", i) if i < 0: return continue if c == "/" and i + 1 < n and src[i + 1] == "*": j = src.find("*/", i + 2) i = (j + 2) if j >= 0 else n continue if c == "#": i = src.find("\n", i) if i < 0: return continue if c == '"': i += 1 while i < n: if src[i] == BS: i += 2 continue if src[i] == '"': break i += 1 i += 1 continue if c == "'": zeile = src[:i].count("\n") + 1 anfang = i i += 1 start = i while i < n: if src[i] == BS: i += 2 continue if src[i] == "'": break i += 1 yield zeile, src[start:i], anfang, i + 1 i += 1 continue i += 1 def verkettet(src, ende_a, anfang_b): """Gehoeren zwei benachbarte Literale zu demselben verketteten Ausdruck?""" luecke = src[ende_a:anfang_b] if len(luecke) > MAX_LUECKE: return False if "." not in luecke: return False return not any(z in TRENNER for z in luecke) def gruppen(src): """ Fasst verkettete Literale zusammen. Liefert (zeile, zusammengesetzter_text) je Ausdruck - also genau den Text, den PDO am Ende zu sehen bekommt. """ aktuell = None for zeile, text, anfang, ende in literale(src): if aktuell is not None and verkettet(src, aktuell["ende"], anfang): aktuell["text"] += text aktuell["ende"] = ende continue if aktuell is not None: yield aktuell["zeile"], aktuell["text"] aktuell = {"zeile": zeile, "text": text, "ende": ende} if aktuell is not None: yield aktuell["zeile"], aktuell["text"] def pruefe(wurzel): befunde = [] for root, dirs, files in os.walk(wurzel): dirs[:] = [d for d in dirs if d not in UEBERSPRINGEN] for name in sorted(files): if not name.endswith(".php"): continue pfad = os.path.join(root, name) src = io.open(pfad, encoding="utf-8", errors="replace").read() for zeile, lit in gruppen(src): if not SQL_WORTE.search(lit): continue zaehler = collections.Counter(PLATZHALTER.findall(lit)) doppelt = {k: v for k, v in sorted(zaehler.items()) if v > 1} if doppelt: befunde.append((pfad, zeile, doppelt)) return befunde def main(): wurzel = sys.argv[1] if len(sys.argv) > 1 else os.path.join( os.path.dirname(os.path.abspath(__file__)), os.pardir ) befunde = pruefe(wurzel) if not befunde: print("Platzhalter-Pruefung: nichts gefunden.") return 0 print("") print(" Derselbe benannte Platzhalter steht mehrfach in einer Anweisung.") print(" Mit ATTR_EMULATE_PREPARES = false endet das in SQLSTATE[HY093]") print(" und damit in HTTP 500 - siehe Kopf dieses Skripts.") print("") for pfad, zeile, doppelt in befunde: stellen = ", ".join("%s (%dx)" % (k, v) for k, v in doppelt.items()) print(" %s:%d %s" % (pfad, zeile, stellen)) print("") print(" Abhilfe: je Vorkommen ein eigener Name, beide mit demselben Wert") print(" belegt. Siehe BugRepo::buildFilter(), :project_scope /") print(" :project_scope_affects.") print("") return 1 if __name__ == "__main__": sys.exit(main())