qaudit 1.0.0__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.
qaudit/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "1.0.0"
2
+ __author__ = "tsmishra"
qaudit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from qaudit.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
qaudit/ast_taint.py ADDED
@@ -0,0 +1,475 @@
1
+ """
2
+ Intra-function taint analysis for Python using only stdlib ast.
3
+
4
+ Algorithm
5
+ ---------
6
+ For every FunctionDef / AsyncFunctionDef in a module:
7
+ 1. Seed tainted variables from:
8
+ - function parameters (all args — positional, keyword, *args, **kwargs)
9
+ - assignments where the RHS is a known source expression
10
+ - extra_tainted set injected by the inter-procedural engine
11
+ 2. Propagate taint through assignments (ordered statement walk):
12
+ - if any name on the RHS is tainted → LHS target is tainted
13
+ - if any name on the RHS is tainted AND the RHS contains a string
14
+ construction operation (BinOp +, JoinedStr f-string, % format,
15
+ .format(), str()) → LHS is also added to str_tainted
16
+ 3. Flag when a tainted name reaches a sink call.
17
+
18
+ .execute() — two-track SQL injection detection
19
+ ----------------------------------------------
20
+ SQL injection requires a *string* to be the query argument (arg[0]).
21
+ Two severity levels based on observed evidence:
22
+
23
+ HIGH — tainted variable reached .execute() arg[0] AND passed through a
24
+ string construction op (concat, f-string, % format, .format(),
25
+ str()). This is proven SQL injection.
26
+
27
+ MEDIUM — tainted variable reached .execute() arg[0] with NO observed string
28
+ construction. The variable may be a raw string from user input
29
+ (still dangerous) but we have less evidence — worth investigating.
30
+
31
+ silent — tainted variable only appears in .execute() arg[1+] (the params
32
+ tuple). This is the correct parameterised query pattern; no finding.
33
+
34
+ Sources (RHS patterns that seed taint)
35
+ - request.args.get / request.form.get / request.json / request.data
36
+ - request.GET.get / request.POST.get (Django)
37
+ - os.environ.get / os.getenv
38
+ - input()
39
+ - sys.argv
40
+
41
+ Sinks (calls / expressions that are dangerous with tainted input)
42
+ - eval(...)
43
+ - exec(...)
44
+ - os.system(...)
45
+ - os.popen(...)
46
+ - subprocess.call / run / Popen / check_output / check_call (any arg)
47
+ - .execute() — SQL injection (two-track, see above)
48
+ - compile(...)
49
+ - importlib.import_module(...)
50
+ - __import__(...)
51
+
52
+ The engine returns a list of TaintFinding objects.
53
+ """
54
+ from __future__ import annotations
55
+ __author__ = "tsmishra"
56
+
57
+ import ast
58
+ from dataclasses import dataclass
59
+ from typing import Dict, List, Optional, Set
60
+
61
+
62
+ @dataclass
63
+ class TaintFinding:
64
+ file_path: str
65
+ line_number: int
66
+ sink: str # e.g. "eval()", "os.system()"
67
+ source_vars: List[str] # tainted variable names that reached the sink
68
+ code_snippet: str
69
+ confidence: str = "high" # "high" | "medium" — used by .execute() two-track logic
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Source detection helpers
74
+ # ---------------------------------------------------------------------------
75
+
76
+ _SOURCE_CALLS: Set[str] = {
77
+ "input",
78
+ "request.args.get",
79
+ "request.form.get",
80
+ "request.json",
81
+ "request.data",
82
+ "request.GET.get",
83
+ "request.POST.get",
84
+ "os.environ.get",
85
+ "os.getenv",
86
+ "sys.argv",
87
+ }
88
+
89
+
90
+ def _attr_chain(node: ast.expr) -> str:
91
+ """
92
+ Return dotted attribute chain for simple Name/Attribute trees, else ''.
93
+
94
+ Author: tsmishra
95
+ """
96
+ if isinstance(node, ast.Name):
97
+ return node.id
98
+ if isinstance(node, ast.Attribute):
99
+ parent = _attr_chain(node.value)
100
+ if parent:
101
+ return f"{parent}.{node.attr}"
102
+ return ""
103
+
104
+
105
+ def _is_source(node: ast.expr) -> bool:
106
+ """
107
+ True if the expression looks like a known taint source.
108
+
109
+ Author: tsmishra
110
+ """
111
+ chain = _attr_chain(node)
112
+ if chain in _SOURCE_CALLS:
113
+ return True
114
+ if isinstance(node, ast.Call):
115
+ return _is_source(node.func)
116
+ if isinstance(node, ast.Subscript):
117
+ return _is_source(node.value)
118
+ return False
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # String-construction detection
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def _is_string_construction(node: ast.expr, tainted: Set[str]) -> bool:
126
+ """
127
+ True if *node* builds a string that incorporates at least one tainted variable.
128
+
129
+ Detected patterns:
130
+ tainted + "literal" BinOp with Add
131
+ f"...{tainted}..." JoinedStr (f-string)
132
+ "literal" % tainted BinOp with Mod
133
+ "literal".format(tainted) Call to .format()
134
+ str(tainted) Call to str()
135
+
136
+ Author: tsmishra
137
+ """
138
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
139
+ return _expr_contains_tainted(node, tainted)
140
+
141
+ if isinstance(node, ast.JoinedStr):
142
+ return _expr_contains_tainted(node, tainted)
143
+
144
+ if isinstance(node, ast.Call):
145
+ func = node.func
146
+ # "...".format(tainted_var)
147
+ if isinstance(func, ast.Attribute) and func.attr == "format":
148
+ return any(_expr_contains_tainted(a, tainted) for a in node.args) or \
149
+ any(_expr_contains_tainted(kw.value, tainted) for kw in node.keywords if kw.value)
150
+ # str(tainted_var)
151
+ if isinstance(func, ast.Name) and func.id == "str":
152
+ return any(_expr_contains_tainted(a, tainted) for a in node.args)
153
+
154
+ return False
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Sink detection helpers
159
+ # ---------------------------------------------------------------------------
160
+
161
+ _SINK_NAMES: Dict[str, str] = {
162
+ "eval": "eval()",
163
+ "exec": "exec()",
164
+ "compile": "compile()",
165
+ "__import__": "__import__()",
166
+ }
167
+
168
+ _SINK_ATTR_CHAINS: Dict[str, str] = {
169
+ "os.system": "os.system()",
170
+ "os.popen": "os.popen()",
171
+ "subprocess.call": "subprocess.call()",
172
+ "subprocess.run": "subprocess.run()",
173
+ "subprocess.Popen": "subprocess.Popen()",
174
+ "subprocess.check_output": "subprocess.check_output()",
175
+ "subprocess.check_call": "subprocess.check_call()",
176
+ "importlib.import_module": "importlib.import_module()",
177
+ }
178
+
179
+ _SINK_METHOD_NAMES: Set[str] = {"execute"}
180
+
181
+
182
+ def _sink_label(call_node: ast.Call) -> Optional[str]:
183
+ """Return human-readable sink label if this call is a known sink, else None."""
184
+ func = call_node.func
185
+ chain = _attr_chain(func)
186
+ if chain in _SINK_ATTR_CHAINS:
187
+ return _SINK_ATTR_CHAINS[chain]
188
+ if isinstance(func, ast.Name) and func.id in _SINK_NAMES:
189
+ return _SINK_NAMES[func.id]
190
+ if isinstance(func, ast.Attribute) and func.attr in _SINK_METHOD_NAMES:
191
+ return f".{func.attr}()"
192
+ return None
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Name collection helpers
197
+ # ---------------------------------------------------------------------------
198
+
199
+ def _names_in_expr(node: ast.expr) -> Set[str]:
200
+ """Collect all Name ids referenced in an expression."""
201
+ names: Set[str] = set()
202
+ for child in ast.walk(node):
203
+ if isinstance(child, ast.Name):
204
+ names.add(child.id)
205
+ return names
206
+
207
+
208
+ def _expr_contains_tainted(node: ast.expr, tainted: Set[str]) -> bool:
209
+ return bool(_names_in_expr(node) & tainted)
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Core: ordered single-pass walk of one function body
214
+ # ---------------------------------------------------------------------------
215
+
216
+ def _walk_stmts_ordered(stmts: list) -> List[ast.stmt]:
217
+ """
218
+ Flatten a statement list into order-preserving depth-first sequence.
219
+
220
+ Author: tsmishra
221
+ """
222
+ result: List[ast.stmt] = []
223
+ for stmt in stmts:
224
+ result.append(stmt)
225
+ # Recurse into compound statement bodies to preserve order
226
+ for child_stmts in _child_stmt_lists(stmt):
227
+ result.extend(_walk_stmts_ordered(child_stmts))
228
+ return result
229
+
230
+
231
+ def _child_stmt_lists(stmt: ast.stmt) -> List[list]:
232
+ """Return the child statement lists of a compound statement."""
233
+ out: List[list] = []
234
+ for attr in ("body", "orelse", "handlers", "finalbody"):
235
+ val = getattr(stmt, attr, None)
236
+ if val and isinstance(val, list):
237
+ # handlers are ExceptHandler nodes, not stmt lists
238
+ for item in val:
239
+ if isinstance(item, ast.ExceptHandler):
240
+ out.append(item.body)
241
+ elif isinstance(item, ast.stmt):
242
+ out.append([item])
243
+ elif isinstance(item, list):
244
+ out.append(item)
245
+ if all(isinstance(item, ast.stmt) for item in val):
246
+ out.append(val)
247
+ return out
248
+
249
+
250
+ def _propagate(
251
+ func_ast: ast.FunctionDef | ast.AsyncFunctionDef,
252
+ tainted: Set[str],
253
+ source_lines: List[str],
254
+ file_path: str,
255
+ ) -> List[TaintFinding]:
256
+ """
257
+ Single-pass ordered walk of func_ast.body.
258
+ Mutates *tainted* in place (general taint propagation).
259
+ Also maintains *str_tainted*: variables that are tainted AND have passed
260
+ through a string construction operation — used for .execute() HIGH vs MEDIUM.
261
+ Returns TaintFinding list.
262
+
263
+ Author: tsmishra
264
+ """
265
+ findings: List[TaintFinding] = []
266
+ str_tainted: Set[str] = set()
267
+
268
+ for stmt in _walk_stmts_ordered(func_ast.body):
269
+ # ------------------------------------------------------------------
270
+ # Assignment propagation
271
+ # ------------------------------------------------------------------
272
+ if isinstance(stmt, ast.Assign):
273
+ rhs_tainted = _expr_contains_tainted(stmt.value, tainted) or _is_source(stmt.value)
274
+ if rhs_tainted:
275
+ is_str_op = _is_string_construction(stmt.value, tainted)
276
+ for target in stmt.targets:
277
+ for n in ast.walk(target):
278
+ if isinstance(n, ast.Name):
279
+ tainted.add(n.id)
280
+ if is_str_op:
281
+ str_tainted.add(n.id)
282
+ # Sink inside RHS call: x = eval(tainted)
283
+ if isinstance(stmt.value, ast.Call):
284
+ _check_call(stmt.value, tainted, str_tainted, findings, source_lines, file_path)
285
+
286
+ elif isinstance(stmt, ast.AnnAssign):
287
+ if stmt.value:
288
+ rhs_tainted = _expr_contains_tainted(stmt.value, tainted) or _is_source(stmt.value)
289
+ if rhs_tainted and isinstance(stmt.target, ast.Name):
290
+ tainted.add(stmt.target.id)
291
+ if _is_string_construction(stmt.value, tainted):
292
+ str_tainted.add(stmt.target.id)
293
+ if isinstance(stmt.value, ast.Call):
294
+ _check_call(stmt.value, tainted, str_tainted, findings, source_lines, file_path)
295
+
296
+ elif isinstance(stmt, ast.AugAssign):
297
+ if _expr_contains_tainted(stmt.value, tainted):
298
+ if isinstance(stmt.target, ast.Name):
299
+ tainted.add(stmt.target.id)
300
+ if _is_string_construction(stmt.value, tainted):
301
+ str_tainted.add(stmt.target.id)
302
+
303
+ # ------------------------------------------------------------------
304
+ # Sink calls as expression statements
305
+ # ------------------------------------------------------------------
306
+ elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
307
+ _check_call(stmt.value, tainted, str_tainted, findings, source_lines, file_path)
308
+
309
+ # Sink inside return expr: return eval(tainted)
310
+ elif isinstance(stmt, ast.Return) and stmt.value:
311
+ if isinstance(stmt.value, ast.Call):
312
+ _check_call(stmt.value, tainted, str_tainted, findings, source_lines, file_path)
313
+
314
+ return findings
315
+
316
+
317
+ def _check_call(
318
+ call_node: ast.Call,
319
+ tainted: Set[str],
320
+ str_tainted: Set[str],
321
+ findings: List[TaintFinding],
322
+ source_lines: List[str],
323
+ file_path: str,
324
+ ) -> None:
325
+ label = _sink_label(call_node)
326
+ if label is None:
327
+ return
328
+
329
+ is_execute = label == ".execute()"
330
+
331
+ if is_execute:
332
+ # SQL injection logic — only flag when string construction is provably observed.
333
+ #
334
+ # arg[0] is the query position. arg[1+] is the params slot (safe pattern).
335
+ #
336
+ # HIGH — string construction evidence exists:
337
+ # (a) arg[0] itself IS a string construction expression (inline f-string,
338
+ # concat, %-format, .format(), str()) that incorporates a tainted var.
339
+ # (b) arg[0] is a variable that was previously assigned via string
340
+ # construction from a tainted source (tracked in str_tainted).
341
+ #
342
+ # silent — no string construction evidence, OR taint only in arg[1+].
343
+ #
344
+ # Rationale: .execute() is a common method name (Chain of Responsibility,
345
+ # command patterns, task runners, workflow engines). Without type inference
346
+ # we cannot know if the receiver is a DB cursor. Requiring string construction
347
+ # evidence eliminates false positives from non-SQL .execute() calls while
348
+ # still catching the dangerous patterns that matter.
349
+ query_arg = call_node.args[0] if call_node.args else None
350
+ if query_arg is None:
351
+ return
352
+
353
+ arg0_names = _names_in_expr(query_arg)
354
+
355
+ # (a) inline string construction in arg[0] itself
356
+ inline_str_construction = _is_string_construction(query_arg, tainted)
357
+ # (b) arg[0] names a variable built via string construction earlier
358
+ prior_str_tainted = arg0_names & str_tainted
359
+
360
+ if inline_str_construction:
361
+ tainted_args = sorted(arg0_names & tainted)
362
+ elif prior_str_tainted:
363
+ tainted_args = sorted(prior_str_tainted)
364
+ else:
365
+ return # no string construction evidence — silent
366
+
367
+ confidence = "high"
368
+
369
+ else:
370
+ # All other sinks: flag any tainted arg in any position
371
+ tainted_args: List[str] = []
372
+ for arg in call_node.args:
373
+ hits = _names_in_expr(arg) & tainted
374
+ tainted_args.extend(sorted(hits))
375
+ for kw in call_node.keywords:
376
+ if kw.value:
377
+ hits = _names_in_expr(kw.value) & tainted
378
+ tainted_args.extend(sorted(hits))
379
+ if not tainted_args:
380
+ return
381
+ confidence = "high"
382
+
383
+ lineno = call_node.lineno
384
+ snippet = source_lines[lineno - 1].rstrip() if lineno <= len(source_lines) else ""
385
+ findings.append(TaintFinding(
386
+ file_path=file_path,
387
+ line_number=lineno,
388
+ sink=label,
389
+ source_vars=list(dict.fromkeys(tainted_args)),
390
+ code_snippet=snippet,
391
+ confidence=confidence,
392
+ ))
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # Public: analyse a single function AST node with optional extra taint seeds
397
+ # ---------------------------------------------------------------------------
398
+
399
+ def analyse_function_ast(
400
+ func_ast: ast.FunctionDef | ast.AsyncFunctionDef,
401
+ source_lines: List[str],
402
+ file_path: str,
403
+ extra_tainted: Optional[Set[str]] = None,
404
+ ) -> tuple[List[TaintFinding], Set[str]]:
405
+ """
406
+ Analyse *func_ast* and return (findings, final_tainted_set).
407
+
408
+ extra_tainted: additional variable names to pre-seed as tainted.
409
+ The returned tainted set is used by the inter-procedural engine to
410
+ determine whether a `return` propagates taint to the caller.
411
+
412
+ Author: tsmishra
413
+ """
414
+ args = func_ast.args
415
+ tainted: Set[str] = set()
416
+ for arg in args.args + args.posonlyargs + args.kwonlyargs:
417
+ tainted.add(arg.arg)
418
+ if args.vararg:
419
+ tainted.add(args.vararg.arg)
420
+ if args.kwarg:
421
+ tainted.add(args.kwarg.arg)
422
+ if extra_tainted:
423
+ tainted.update(extra_tainted)
424
+
425
+ findings = _propagate(func_ast, tainted, source_lines, file_path)
426
+ return findings, tainted
427
+
428
+
429
+ def can_return_tainted(
430
+ func_ast: ast.FunctionDef | ast.AsyncFunctionDef,
431
+ tainted: Set[str],
432
+ ) -> bool:
433
+ """
434
+ True if any `return <expr>` in the function references a tainted variable.
435
+ Used by the inter-procedural engine to propagate taint through return values.
436
+
437
+ Author: tsmishra
438
+ """
439
+ for node in ast.walk(func_ast):
440
+ if isinstance(node, ast.Return) and node.value is not None:
441
+ if _expr_contains_tainted(node.value, tainted):
442
+ return True
443
+ return False
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # Public: analyse a single file (intra-function only — legacy entry point)
448
+ # ---------------------------------------------------------------------------
449
+
450
+ def analyse_file(file_path: str) -> List[TaintFinding]:
451
+ """
452
+ Parse *file_path* and return all intra-function taint findings.
453
+
454
+ Author: tsmishra
455
+ """
456
+ try:
457
+ with open(file_path, encoding="utf-8", errors="replace") as fh:
458
+ source = fh.read()
459
+ except OSError:
460
+ return []
461
+
462
+ try:
463
+ tree = ast.parse(source, filename=file_path)
464
+ except SyntaxError:
465
+ return []
466
+
467
+ source_lines = source.splitlines()
468
+ findings: List[TaintFinding] = []
469
+
470
+ for node in ast.walk(tree):
471
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
472
+ fn_findings, _ = analyse_function_ast(node, source_lines, file_path)
473
+ findings.extend(fn_findings)
474
+
475
+ return findings