sprocguard 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sprocguard/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sprocguard: a safety net for SQL Server stored procedures."""
2
+
3
+ __version__ = "0.1.1"
sprocguard/analysis.py ADDED
@@ -0,0 +1,485 @@
1
+ """Inspect a stored procedure: parameters, tables it reads and writes, and risks.
2
+
3
+ The inspection combines SQL Server metadata (``sys.dm_sql_referenced_entities``)
4
+ with a light static analysis of the procedure text. Neither is perfect on its
5
+ own, so sprocguard uses both and tells the user where the picture may be
6
+ incomplete (dynamic SQL, for example).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import re
13
+ from dataclasses import asdict, dataclass, field
14
+ from typing import Any
15
+
16
+ from .db import Database
17
+
18
+ # --------------------------------------------------------------------------------------
19
+ # Names and types
20
+ # --------------------------------------------------------------------------------------
21
+
22
+
23
+ def quote_name(*parts: str) -> str:
24
+ """Return a safely bracketed, dot-separated SQL Server name."""
25
+ return ".".join("[" + part.replace("]", "]]") + "]" for part in parts)
26
+
27
+
28
+ def type_decl(type_name: str, max_length: int, precision: int, scale: int) -> str:
29
+ """Build a T-SQL type declaration (e.g. ``nvarchar(50)``) from catalog values."""
30
+ t = type_name.lower()
31
+ if t in ("varchar", "char", "varbinary", "binary"):
32
+ return f"{t}(max)" if max_length == -1 else f"{t}({max_length})"
33
+ if t in ("nvarchar", "nchar"):
34
+ return f"{t}(max)" if max_length == -1 else f"{t}({max_length // 2})"
35
+ if t in ("decimal", "numeric"):
36
+ return f"{t}({precision},{scale})"
37
+ if t in ("datetime2", "time", "datetimeoffset"):
38
+ return f"{t}({scale})"
39
+ return t
40
+
41
+
42
+ # --------------------------------------------------------------------------------------
43
+ # Static analysis
44
+ # --------------------------------------------------------------------------------------
45
+
46
+
47
+ def strip_comments_and_strings(sql: str) -> str:
48
+ """Remove comments and blank out string literals so patterns don't match inside them."""
49
+ out: list[str] = []
50
+ i, n = 0, len(sql)
51
+ while i < n:
52
+ ch = sql[i]
53
+ nxt = sql[i + 1] if i + 1 < n else ""
54
+ if ch == "-" and nxt == "-":
55
+ end = sql.find("\n", i)
56
+ i = n if end == -1 else end
57
+ out.append(" ")
58
+ elif ch == "/" and nxt == "*":
59
+ depth, i = 1, i + 2
60
+ while i < n and depth:
61
+ if sql.startswith("/*", i):
62
+ depth, i = depth + 1, i + 2
63
+ elif sql.startswith("*/", i):
64
+ depth, i = depth - 1, i + 2
65
+ else:
66
+ i += 1
67
+ out.append(" ")
68
+ elif ch == "'":
69
+ i += 1
70
+ while i < n:
71
+ if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
72
+ i += 2
73
+ elif sql[i] == "'":
74
+ i += 1
75
+ break
76
+ else:
77
+ i += 1
78
+ out.append("''")
79
+ else:
80
+ out.append(ch)
81
+ i += 1
82
+ return "".join(out)
83
+
84
+
85
+ @dataclass
86
+ class Risk:
87
+ code: str
88
+ severity: str # "block" | "warn" | "info"
89
+ message: str
90
+ evidence: list[str] = field(default_factory=list)
91
+
92
+
93
+ _RISK_RULES: list[tuple[str, str, str, str]] = [
94
+ # (code, severity, regex, message)
95
+ (
96
+ "external_side_effect",
97
+ "block",
98
+ r"\b(xp_cmdshell|sp_send_dbmail|sp_OA\w+|sp_start_job|OPENQUERY|OPENROWSET|OPENDATASOURCE|BULK\s+INSERT)\b"
99
+ r"|\bEXEC(UTE)?\s*\(.*?\)\s*AT\s+\w+",
100
+ "The procedure can affect things outside the database (e-mail, files, other servers, jobs). "
101
+ "A rollback cannot undo that, so sprocguard will not run it unless "
102
+ "[safety].allow_external_side_effects = true.",
103
+ ),
104
+ (
105
+ "linked_server",
106
+ "block",
107
+ r"(?<![\w\].])(\[?\w+\]?\.){3}\[?\w+\]?(?![\w.])",
108
+ "Four-part names point to a linked server. Changes there may not be rolled back.",
109
+ ),
110
+ (
111
+ "dynamic_sql",
112
+ "warn",
113
+ r"\bsp_executesql\b|\bEXEC(UTE)?\s*\(\s*(@|N?'')",
114
+ "Dynamic SQL: sprocguard cannot see which tables it writes. Data changes are still rolled back, "
115
+ "but changes to tables it did not detect will not be compared. Use watch_tables to add them.",
116
+ ),
117
+ (
118
+ "inner_rollback",
119
+ "warn",
120
+ r"\bROLLBACK(\s+(TRAN|TRANSACTION|WORK))?\b",
121
+ "The procedure rolls back on its own. sprocguard still undoes all changes, but SQL Server may "
122
+ "report error 266 (transaction count mismatch) during capture.",
123
+ ),
124
+ (
125
+ "waitfor",
126
+ "warn",
127
+ r"\bWAITFOR\b",
128
+ "WAITFOR makes captures slow and can hold locks.",
129
+ ),
130
+ (
131
+ "ddl",
132
+ "warn",
133
+ r"\b(CREATE|ALTER|DROP)\s+(TABLE|INDEX|VIEW|PROC|PROCEDURE|FUNCTION|TRIGGER|SCHEMA)\s+(?!#)",
134
+ "The procedure changes database structure (DDL). It is rolled back, but behavior may depend on it.",
135
+ ),
136
+ (
137
+ "nondeterministic",
138
+ "info",
139
+ r"\b(GETDATE|GETUTCDATE|SYSDATETIME|SYSUTCDATETIME|SYSDATETIMEOFFSET|NEWID|NEWSEQUENTIALID|RAND|CRYPT_GEN_RANDOM)\s*\("
140
+ r"|\bCURRENT_TIMESTAMP\b",
141
+ "Uses values that change on every run (dates, random values). sprocguard runs each case twice and "
142
+ "ignores the columns that change by themselves.",
143
+ ),
144
+ (
145
+ "identity",
146
+ "info",
147
+ r"\bSCOPE_IDENTITY\s*\(|@@IDENTITY\b|\bIDENT_CURRENT\s*\(|\bOUTPUT\s+inserted\.",
148
+ "Returns generated IDs. IDENTITY values are not reused after a rollback, so they are treated "
149
+ "as volatile.",
150
+ ),
151
+ (
152
+ "error_paths",
153
+ "info",
154
+ r"\b(THROW|RAISERROR)\b",
155
+ "Has error paths. Errors are behavior too: add cases that trigger them.",
156
+ ),
157
+ ]
158
+
159
+
160
+ _OWN_HEADER = re.compile(r"^\s*(CREATE|ALTER)\s+(OR\s+ALTER\s+)?PROC(EDURE)?\b", re.IGNORECASE)
161
+
162
+
163
+ def analyze_risks(definition: str) -> list[Risk]:
164
+ # The procedure's own "CREATE PROCEDURE" header is not DDL executed by the procedure.
165
+ text = _OWN_HEADER.sub(" ", strip_comments_and_strings(definition), count=1)
166
+ risks: list[Risk] = []
167
+ for code, severity, pattern, message in _RISK_RULES:
168
+ matches = list(re.finditer(pattern, text, flags=re.IGNORECASE | re.DOTALL))
169
+ if matches:
170
+ evidence = sorted({" ".join(m.group(0).split())[:60] for m in matches})[:5]
171
+ risks.append(Risk(code=code, severity=severity, message=message, evidence=evidence))
172
+ return risks
173
+
174
+
175
+ def count_branches(definition: str) -> int:
176
+ """Rough number of decision points, used to suggest how many cases to capture."""
177
+ text = strip_comments_and_strings(definition)
178
+ return len(re.findall(r"\b(IF|CASE|WHILE|THROW|RAISERROR)\b", text, flags=re.IGNORECASE))
179
+
180
+
181
+ _WRITE_TARGET = re.compile(
182
+ r"\b(?:INSERT\s+(?:INTO\s+)?|UPDATE\s+|DELETE\s+(?:FROM\s+)?|MERGE\s+(?:INTO\s+)?|TRUNCATE\s+TABLE\s+)"
183
+ r"((?:\[[^\]]+\]|\w+)(?:\s*\.\s*(?:\[[^\]]+\]|\w+))?)",
184
+ flags=re.IGNORECASE,
185
+ )
186
+
187
+
188
+ def write_targets_from_text(definition: str) -> list[str]:
189
+ """Candidate table names written by INSERT/UPDATE/DELETE/MERGE/TRUNCATE."""
190
+ text = strip_comments_and_strings(definition)
191
+ found = []
192
+ for match in _WRITE_TARGET.finditer(text):
193
+ name = re.sub(r"\s+", "", match.group(1))
194
+ if name.startswith(("#", "@")) or name.upper() in {"TOP", "STATISTICS"}:
195
+ continue
196
+ found.append(name)
197
+ return sorted(set(found))
198
+
199
+
200
+ # --------------------------------------------------------------------------------------
201
+ # Catalog queries
202
+ # --------------------------------------------------------------------------------------
203
+
204
+
205
+ @dataclass
206
+ class Parameter:
207
+ name: str
208
+ type: str
209
+ is_output: bool
210
+ is_table_type: bool
211
+
212
+
213
+ @dataclass
214
+ class ResultColumn:
215
+ name: str
216
+ type: str
217
+ nullable: bool
218
+
219
+
220
+ @dataclass
221
+ class ProcedureInfo:
222
+ schema: str
223
+ name: str
224
+ object_id: int
225
+ definition: str
226
+ definition_hash: str
227
+ parameters: list[Parameter]
228
+ reads: list[str]
229
+ writes: list[str]
230
+ calls: list[str]
231
+ triggers: list[str]
232
+ first_result_set: list[ResultColumn] | None
233
+ risks: list[Risk]
234
+ suggested_case_count: int
235
+ notes: list[str] = field(default_factory=list)
236
+
237
+ @property
238
+ def full_name(self) -> str:
239
+ return f"{self.schema}.{self.name}"
240
+
241
+ @property
242
+ def quoted_name(self) -> str:
243
+ return quote_name(self.schema, self.name)
244
+
245
+ @property
246
+ def blocked(self) -> list[Risk]:
247
+ return [r for r in self.risks if r.severity == "block"]
248
+
249
+ def to_dict(self, include_definition: bool = False) -> dict[str, Any]:
250
+ data = asdict(self)
251
+ data["full_name"] = self.full_name
252
+ if not include_definition:
253
+ data.pop("definition")
254
+ return data
255
+
256
+
257
+ class InspectionError(Exception):
258
+ pass
259
+
260
+
261
+ def resolve_procedure(db: Database, name: str) -> tuple[int, str, str]:
262
+ rows = db.query(
263
+ "SELECT o.object_id, s.name AS schema_name, o.name AS name, o.type AS type "
264
+ "FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id "
265
+ "WHERE o.object_id = OBJECT_ID(?)",
266
+ (name,),
267
+ )
268
+ if not rows:
269
+ raise InspectionError(f"Stored procedure '{name}' was not found in the current database.")
270
+ row = rows[0]
271
+ if row["type"].strip() != "P":
272
+ raise InspectionError(f"'{name}' is not a T-SQL stored procedure (type {row['type'].strip()}).")
273
+ return row["object_id"], row["schema_name"], row["name"]
274
+
275
+
276
+ def _parameters(db: Database, object_id: int) -> list[Parameter]:
277
+ rows = db.query(
278
+ "SELECT p.name, TYPE_NAME(p.system_type_id) AS system_type, TYPE_NAME(p.user_type_id) AS user_type, "
279
+ "p.max_length, p.precision, p.scale, p.is_output, t.is_table_type "
280
+ "FROM sys.parameters p JOIN sys.types t ON t.user_type_id = p.user_type_id "
281
+ "WHERE p.object_id = ? AND p.parameter_id > 0 ORDER BY p.parameter_id",
282
+ (object_id,),
283
+ )
284
+ params = []
285
+ for r in rows:
286
+ is_table = bool(r["is_table_type"])
287
+ declared = r["user_type"] if is_table else type_decl(r["system_type"], r["max_length"], r["precision"], r["scale"])
288
+ params.append(Parameter(name=r["name"], type=declared, is_output=bool(r["is_output"]), is_table_type=is_table))
289
+ return params
290
+
291
+
292
+ def _references(db: Database, qualified: str) -> list[dict[str, Any]]:
293
+ return db.query(
294
+ "SELECT COALESCE(OBJECT_SCHEMA_NAME(r.referenced_id), r.referenced_schema_name, 'dbo') AS schema_name, "
295
+ "r.referenced_entity_name AS name, o.type AS type, "
296
+ "MAX(CAST(r.is_selected AS int)) AS is_selected, MAX(CAST(r.is_updated AS int)) AS is_updated "
297
+ "FROM sys.dm_sql_referenced_entities(?, 'OBJECT') r "
298
+ "LEFT JOIN sys.objects o ON o.object_id = r.referenced_id "
299
+ "WHERE r.referenced_class = 1 "
300
+ "GROUP BY COALESCE(OBJECT_SCHEMA_NAME(r.referenced_id), r.referenced_schema_name, 'dbo'), "
301
+ "r.referenced_entity_name, o.type",
302
+ (qualified,),
303
+ )
304
+
305
+
306
+ def _resolve_table(db: Database, candidate: str) -> str | None:
307
+ rows = db.query(
308
+ "SELECT OBJECT_SCHEMA_NAME(OBJECT_ID(?, 'U')) AS schema_name, OBJECT_NAME(OBJECT_ID(?, 'U')) AS name",
309
+ (candidate, candidate),
310
+ )
311
+ if rows and rows[0]["name"]:
312
+ return f"{rows[0]['schema_name']}.{rows[0]['name']}"
313
+ return None
314
+
315
+
316
+ def _triggers(db: Database, table: str) -> list[str]:
317
+ rows = db.query(
318
+ "SELECT OBJECT_SCHEMA_NAME(t.object_id) AS schema_name, t.name "
319
+ "FROM sys.triggers t WHERE t.parent_id = OBJECT_ID(?) AND t.is_disabled = 0",
320
+ (table,),
321
+ )
322
+ return [f"{r['schema_name']}.{r['name']}" for r in rows]
323
+
324
+
325
+ def _first_result_set(db: Database, object_id: int) -> list[ResultColumn] | None:
326
+ rows = db.query(
327
+ "SELECT name, system_type_name, is_nullable, error_message "
328
+ "FROM sys.dm_exec_describe_first_result_set_for_object(?, 0) "
329
+ "WHERE is_hidden = 0 OR is_hidden IS NULL ORDER BY column_ordinal",
330
+ (object_id,),
331
+ )
332
+ if not rows or any(r.get("error_message") for r in rows):
333
+ return None
334
+ return [ResultColumn(name=r["name"], type=r["system_type_name"], nullable=bool(r["is_nullable"])) for r in rows]
335
+
336
+
337
+ def _collect_effects(db: Database, qualified: str, definition: str, depth: int, seen: set[str],
338
+ reads: set[str], writes: set[str], calls: set[str], notes: list[str]) -> None:
339
+ """Walk the procedure (and procedures/triggers it reaches) collecting table reads and writes."""
340
+ if qualified.lower() in seen or depth > 5:
341
+ return
342
+ seen.add(qualified.lower())
343
+ try:
344
+ refs = _references(db, qualified)
345
+ except Exception as exc: # metadata can fail when the code references missing objects
346
+ notes.append(f"Could not read dependency metadata for {qualified}: {exc}. Falling back to text analysis.")
347
+ refs = []
348
+ for ref in refs:
349
+ full = f"{ref['schema_name']}.{ref['name']}"
350
+ kind = (ref["type"] or "").strip()
351
+ if kind == "U":
352
+ if ref["is_updated"]:
353
+ writes.add(full)
354
+ else:
355
+ reads.add(full)
356
+ elif kind == "P":
357
+ calls.add(full)
358
+ inner = db.query("SELECT OBJECT_DEFINITION(OBJECT_ID(?)) AS definition", (full,))
359
+ inner_def = inner[0]["definition"] if inner else None
360
+ if inner_def:
361
+ _collect_effects(db, full, inner_def, depth + 1, seen, reads, writes, calls, notes)
362
+ elif kind in ("V", "FN", "IF", "TF"):
363
+ reads.add(full)
364
+ for candidate in write_targets_from_text(definition):
365
+ resolved = _resolve_table(db, candidate)
366
+ if resolved:
367
+ writes.add(resolved)
368
+ reads.difference_update(writes)
369
+
370
+
371
+ def inspect_procedure(db: Database, name: str) -> ProcedureInfo:
372
+ object_id, schema, proc = resolve_procedure(db, name)
373
+ qualified = f"{schema}.{proc}"
374
+ definition = db.query("SELECT OBJECT_DEFINITION(?) AS definition", (object_id,))[0]["definition"]
375
+ if not definition:
376
+ raise InspectionError(f"The definition of {qualified} is not readable (encrypted or no VIEW DEFINITION permission).")
377
+
378
+ reads: set[str] = set()
379
+ writes: set[str] = set()
380
+ calls: set[str] = set()
381
+ notes: list[str] = []
382
+ _collect_effects(db, qualified, definition, 0, set(), reads, writes, calls, notes)
383
+
384
+ # Triggers on written tables can write more tables.
385
+ triggers: list[str] = []
386
+ pending = sorted(writes)
387
+ seen_tables: set[str] = set()
388
+ while pending:
389
+ table = pending.pop()
390
+ if table in seen_tables:
391
+ continue
392
+ seen_tables.add(table)
393
+ for trigger in _triggers(db, table):
394
+ triggers.append(trigger)
395
+ trig_def = db.query("SELECT OBJECT_DEFINITION(OBJECT_ID(?)) AS definition", (trigger,))[0]["definition"] or ""
396
+ before = set(writes)
397
+ _collect_effects(db, trigger, trig_def, 1, set(), reads, writes, calls, notes)
398
+ pending.extend(sorted(writes - before))
399
+
400
+ risks = analyze_risks(definition)
401
+ if triggers:
402
+ risks.append(Risk("triggers", "info", "Triggers run when this procedure writes; their changes are included.", sorted(triggers)))
403
+ parameters = _parameters(db, object_id)
404
+ if any(p.is_table_type for p in parameters):
405
+ risks.append(Risk("table_valued_parameter", "block",
406
+ "Table-valued parameters are not supported yet in sprocguard 0.1.",
407
+ [p.name for p in parameters if p.is_table_type]))
408
+ try:
409
+ first = _first_result_set(db, object_id)
410
+ except Exception:
411
+ first = None
412
+
413
+ return ProcedureInfo(
414
+ schema=schema,
415
+ name=proc,
416
+ object_id=object_id,
417
+ definition=definition,
418
+ definition_hash=hashlib.sha256(definition.encode("utf-8")).hexdigest(),
419
+ parameters=parameters,
420
+ reads=sorted(reads),
421
+ writes=sorted(writes),
422
+ calls=sorted(calls),
423
+ triggers=sorted(set(triggers)),
424
+ first_result_set=first,
425
+ risks=risks,
426
+ suggested_case_count=min(12, 2 + count_branches(definition)),
427
+ notes=notes,
428
+ )
429
+
430
+
431
+ # --------------------------------------------------------------------------------------
432
+ # Case suggestions
433
+ # --------------------------------------------------------------------------------------
434
+
435
+
436
+ _EDGE_VALUES = {
437
+ "int": [0, -1], "bigint": [0, -1], "smallint": [0, -1], "tinyint": [0, 255],
438
+ "bit": [0, 1],
439
+ "decimal": ["0", "-1"], "numeric": ["0", "-1"], "money": ["0", "-1"], "float": [0.0, -1.0],
440
+ "varchar": ["", "x" * 50], "nvarchar": ["", "ñandú"], "char": [""], "nchar": [""],
441
+ "date": ["1900-01-01", "9999-12-31"], "datetime": ["1900-01-01T00:00:00"], "datetime2": ["0001-01-01T00:00:00"],
442
+ }
443
+
444
+
445
+ def suggest_cases(db: Database, info: ProcedureInfo, samples_per_param: int = 3) -> dict[str, Any]:
446
+ """Suggest parameter values: real values from matching columns plus edge values."""
447
+ tables = info.reads + info.writes
448
+ suggestions: dict[str, Any] = {}
449
+ for param in info.parameters:
450
+ if param.is_table_type:
451
+ continue
452
+ column = param.name.lstrip("@")
453
+ real: list[Any] = []
454
+ source = None
455
+ for table in tables:
456
+ schema, tname = table.split(".", 1)
457
+ exists = db.query(
458
+ "SELECT 1 AS ok FROM sys.columns WHERE object_id = OBJECT_ID(?) AND name = ?",
459
+ (table, column),
460
+ )
461
+ if exists:
462
+ rows = db.query(
463
+ f"SELECT DISTINCT TOP ({int(samples_per_param)}) {quote_name(column)} AS v "
464
+ f"FROM {quote_name(schema, tname)} WHERE {quote_name(column)} IS NOT NULL",
465
+ )
466
+ real = [r["v"] for r in rows]
467
+ source = f"{table}.{column}"
468
+ break
469
+ base_type = param.type.split("(")[0]
470
+ suggestions[param.name] = {
471
+ "type": param.type,
472
+ "is_output": param.is_output,
473
+ "real_values": real,
474
+ "real_values_from": source,
475
+ "edge_values": _EDGE_VALUES.get(base_type, []) + [None],
476
+ }
477
+ return {
478
+ "procedure": info.full_name,
479
+ "suggested_case_count": info.suggested_case_count,
480
+ "parameters": suggestions,
481
+ "advice": (
482
+ "Pick cases that cover each branch (IF/CASE), each error path (THROW/RAISERROR), "
483
+ "an empty result, and boundary values. Use real values so the procedure finds data."
484
+ ),
485
+ }