hackscan 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (119) hide show
  1. {hackscan-0.3.0 → hackscan-0.3.1}/PKG-INFO +3 -3
  2. {hackscan-0.3.0 → hackscan-0.3.1}/PROJECT.md +7 -0
  3. {hackscan-0.3.0 → hackscan-0.3.1}/README.md +2 -2
  4. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/__init__.py +1 -1
  5. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/llm_pass.py +1 -1
  6. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/remediate.py +44 -6
  7. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/scopes.py +9 -0
  8. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_remediate.py +98 -0
  9. {hackscan-0.3.0 → hackscan-0.3.1}/.gitattributes +0 -0
  10. {hackscan-0.3.0 → hackscan-0.3.1}/.github/workflows/ci.yml +0 -0
  11. {hackscan-0.3.0 → hackscan-0.3.1}/.github/workflows/hackscan.yml +0 -0
  12. {hackscan-0.3.0 → hackscan-0.3.1}/.github/workflows/release.yml +0 -0
  13. {hackscan-0.3.0 → hackscan-0.3.1}/.gitignore +0 -0
  14. {hackscan-0.3.0 → hackscan-0.3.1}/.pre-commit-hooks.yaml +0 -0
  15. {hackscan-0.3.0 → hackscan-0.3.1}/LICENSE +0 -0
  16. {hackscan-0.3.0 → hackscan-0.3.1}/action.yml +0 -0
  17. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/APPS.json +0 -0
  18. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/APPS.md +0 -0
  19. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/README.md +0 -0
  20. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/RESULTS.json +0 -0
  21. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/RESULTS.md +0 -0
  22. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/apps.py +0 -0
  23. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/apps_review.json +0 -0
  24. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/run.py +0 -0
  25. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/__init__.py +0 -0
  26. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/__init__.py +0 -0
  27. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/backup.py +0 -0
  28. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/ops_tool.py +0 -0
  29. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/scheduler.py +0 -0
  30. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/web_tools.py +0 -0
  31. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/codei/__init__.py +0 -0
  32. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/codei/calculator.py +0 -0
  33. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/codei/plugins.py +0 -0
  34. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/__init__.py +0 -0
  35. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/api.py +0 -0
  36. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/django_views.py +0 -0
  37. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/flask_reports.py +0 -0
  38. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/repository.py +0 -0
  39. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/urls.py +0 -0
  40. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/weak_crypto/__init__.py +0 -0
  41. {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/weak_crypto/accounts.py +0 -0
  42. {hackscan-0.3.0 → hackscan-0.3.1}/pyproject.toml +0 -0
  43. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/__init__.py +0 -0
  44. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/ast_pass.py +0 -0
  45. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/interproc.py +0 -0
  46. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/suppressions.py +0 -0
  47. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/taint_pass.py +0 -0
  48. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/cli.py +0 -0
  49. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/config.py +0 -0
  50. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/__init__.py +0 -0
  51. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/dedupe.py +0 -0
  52. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/fingerprint.py +0 -0
  53. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/models.py +0 -0
  54. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/pipeline.py +0 -0
  55. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/redact.py +0 -0
  56. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/taxonomy.py +0 -0
  57. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/__init__.py +0 -0
  58. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/common.py +0 -0
  59. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/gitleaks.py +0 -0
  60. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/runner.py +0 -0
  61. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/sarif.py +0 -0
  62. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/__init__.py +0 -0
  63. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/base.py +0 -0
  64. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/loader.py +0 -0
  65. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/__init__.py +0 -0
  66. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/cmdi.py +0 -0
  67. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/codei.py +0 -0
  68. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/sqli.py +0 -0
  69. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/weak_crypto.py +0 -0
  70. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/sarif/__init__.py +0 -0
  71. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/sarif/generator.py +0 -0
  72. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/taint/__init__.py +0 -0
  73. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/taint/sanitizers.py +0 -0
  74. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/taint/sources.py +0 -0
  75. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/ui/__init__.py +0 -0
  76. {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/ui/render.py +0 -0
  77. {hackscan-0.3.0 → hackscan-0.3.1}/tests/conftest.py +0 -0
  78. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/README.md +0 -0
  79. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/cmdi/safe/shell.py +0 -0
  80. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/cmdi/vulnerable/shell.py +0 -0
  81. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/cmdi/vulnerable/star_import.py +0 -0
  82. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/codei/safe/dynamic.py +0 -0
  83. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/codei/vulnerable/dynamic.py +0 -0
  84. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/frameworks/django/views.py +0 -0
  85. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/frameworks/fastapi/main.py +0 -0
  86. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/frameworks/flask/app.py +0 -0
  87. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/sqli/safe/dbapi.py +0 -0
  88. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/sqli/vulnerable/dbapi.py +0 -0
  89. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/weak_crypto/safe/hashes.py +0 -0
  90. {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/weak_crypto/vulnerable/hashes.py +0 -0
  91. {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/.gitkeep +0 -0
  92. {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/README.md +0 -0
  93. {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/bandit.sarif +0 -0
  94. {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/codeql.sarif +0 -0
  95. {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/gitleaks.json +0 -0
  96. {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/project/app.py +0 -0
  97. {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/semgrep.sarif +0 -0
  98. {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/sarif-schema-2.1.0.json +0 -0
  99. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_action.py +0 -0
  100. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_ast_pass.py +0 -0
  101. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_benchmark.py +0 -0
  102. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_cli.py +0 -0
  103. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_config.py +0 -0
  104. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_dedupe.py +0 -0
  105. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_fingerprint.py +0 -0
  106. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_importers.py +0 -0
  107. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_interproc.py +0 -0
  108. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_llm.py +0 -0
  109. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_models.py +0 -0
  110. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_pipeline.py +0 -0
  111. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_redact.py +0 -0
  112. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_rules.py +0 -0
  113. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_sarif.py +0 -0
  114. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_scopes.py +0 -0
  115. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_taint.py +0 -0
  116. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_taint_fuzz.py +0 -0
  117. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_taxonomy.py +0 -0
  118. {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_ui.py +0 -0
  119. {hackscan-0.3.0 → hackscan-0.3.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hackscan
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Python SAST orchestrator: own AST + taint engine, imports Semgrep/Bandit/CodeQL/Gitleaks, dedupes, emits SARIF.
5
5
  Project-URL: Source, https://github.com/kahledawad2019-netizen/hackscan
6
6
  Project-URL: Issues, https://github.com/kahledawad2019-netizen/hackscan/issues
@@ -241,7 +241,7 @@ jobs:
241
241
  runs-on: ubuntu-latest
242
242
  steps:
243
243
  - uses: actions/checkout@v4
244
- - uses: kahledawad2019-netizen/hackscan@v0.3.0
244
+ - uses: kahledawad2019-netizen/hackscan@v0.3.1
245
245
  with:
246
246
  path: src
247
247
  fail-on: high
@@ -273,7 +273,7 @@ Python 3.12 on the runner. Without the action:
273
273
  ```yaml
274
274
  repos:
275
275
  - repo: https://github.com/kahledawad2019-netizen/hackscan
276
- rev: v0.3.0
276
+ rev: v0.3.1
277
277
  hooks:
278
278
  - id: hackscan
279
279
  ```
@@ -452,3 +452,10 @@ hackscan/
452
452
  re-run so the HackScan rows show 0.3.0 (numbers unchanged). Next ideas (not started):
453
453
  weak-cipher detection (DES/RC4/ECB/unauthenticated modes; largest benchmark gap),
454
454
  taint through `self` attributes, further pass-2 aliasing hardening.
455
+ - 2026-10-12 (v0.3.1): template fixes only, found by the security agent's held-out evaluation
456
+ (8 of 13 true findings had no template). A call copied verbatim from the original call
457
+ (`params.get("id")`, `re.sub(...)`) no longer counts as introduced code (multiset count, so
458
+ no duplication); SQL fixes now decompose `"...'{}'...".format(args)` (plain auto-numbered
459
+ fields, positional args used exactly once; never for command argv, where a non-str value would
460
+ crash). Detection is unchanged, so the benchmark tables still show the 0.3.0 measurement (Bandit,
461
+ Semgrep and CodeQL are not installed on the release machine to re-run every row).
@@ -215,7 +215,7 @@ jobs:
215
215
  runs-on: ubuntu-latest
216
216
  steps:
217
217
  - uses: actions/checkout@v4
218
- - uses: kahledawad2019-netizen/hackscan@v0.3.0
218
+ - uses: kahledawad2019-netizen/hackscan@v0.3.1
219
219
  with:
220
220
  path: src
221
221
  fail-on: high
@@ -247,7 +247,7 @@ Python 3.12 on the runner. Without the action:
247
247
  ```yaml
248
248
  repos:
249
249
  - repo: https://github.com/kahledawad2019-netizen/hackscan
250
- rev: v0.3.0
250
+ rev: v0.3.1
251
251
  hooks:
252
252
  - id: hackscan
253
253
  ```
@@ -1,3 +1,3 @@
1
1
  """HackScan: Python SAST orchestrator and verifier."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -779,7 +779,7 @@ def _same_sql(original: ast.Call, new: ast.Call, ctx: FileContext) -> bool:
779
779
  return False
780
780
  if not isinstance(params, (ast.Tuple, ast.List)):
781
781
  return False
782
- parts = _sql_parts(original.args[0])
782
+ parts = _sql_parts(original.args[0], sql=True)
783
783
  if parts is None:
784
784
  return False
785
785
  # The placeholder style must be the one the file's driver uses (`?` breaks psycopg);
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import ast
16
16
  import difflib
17
17
  import re
18
+ import string
18
19
  from collections.abc import Callable
19
20
  from dataclasses import replace
20
21
 
@@ -157,7 +158,7 @@ def _sqli(call: ast.Call, ctx: FileContext):
157
158
  placeholder = _placeholder(ctx)
158
159
  if placeholder is None:
159
160
  return None
160
- parts = _sql_parts(call.args[0])
161
+ parts = _sql_parts(call.args[0], sql=True)
161
162
  if parts is None or not any(isinstance(p, ast.AST) for p in parts):
162
163
  return None
163
164
  sql, params = _parameterize(parts, placeholder)
@@ -193,8 +194,13 @@ def _placeholder(ctx: FileContext) -> str | None:
193
194
  return "?" if qmark else "%s"
194
195
 
195
196
 
196
- def _sql_parts(expr: ast.AST) -> list[str | ast.AST] | None:
197
- """Literal text and value expressions, in order, for f-strings, `+` and `%`."""
197
+ def _sql_parts(expr: ast.AST, *, sql: bool = False) -> list[str | ast.AST] | None:
198
+ """Literal text and value expressions, in order, for f-strings, `+` and `%`.
199
+
200
+ `sql=True` also decomposes `"...".format(...)`. A bound SQL value skips the text conversion
201
+ (`__format__("")` for `{}`, like `__str__` for `%s` and f-strings) and keeps its type, as the
202
+ SQL fix description already says. Command argv must stay text, so `_argv` never uses it
203
+ (`"cmd {}".format(1)` would become a non-str argument and crash)."""
198
204
  if isinstance(expr, ast.JoinedStr):
199
205
  parts: list[str | ast.AST] = []
200
206
  for v in expr.values:
@@ -210,7 +216,7 @@ def _sql_parts(expr: ast.AST) -> list[str | ast.AST] | None:
210
216
  return None
211
217
  return parts
212
218
  if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
213
- left, right = _operand(expr.left), _operand(expr.right)
219
+ left, right = _operand(expr.left, sql=sql), _operand(expr.right, sql=sql)
214
220
  if left is None or right is None:
215
221
  return None
216
222
  return [*left, *right]
@@ -230,17 +236,49 @@ def _sql_parts(expr: ast.AST) -> list[str | ast.AST] | None:
230
236
  else:
231
237
  out.append(values[i // 2])
232
238
  return out
239
+ if (
240
+ sql
241
+ and isinstance(expr, ast.Call)
242
+ and isinstance(expr.func, ast.Attribute)
243
+ and expr.func.attr == "format"
244
+ and isinstance(expr.func.value, ast.Constant)
245
+ and isinstance(expr.func.value.value, str)
246
+ and not expr.keywords
247
+ and not any(isinstance(a, ast.Starred) for a in expr.args)
248
+ ):
249
+ return _format_parts(expr.func.value.value, expr.args)
233
250
  if isinstance(expr, ast.Constant) and isinstance(expr.value, str):
234
251
  return [expr.value]
235
252
  return None
236
253
 
237
254
 
238
- def _operand(expr: ast.AST) -> list[str | ast.AST] | None:
255
+ def _format_parts(template: str, values: list[ast.AST]) -> list[str | ast.AST] | None:
256
+ """`"...{}...".format(a, b)`: only auto-numbered `{}` fields without a conversion or
257
+ format spec, each argument used exactly once in order. Each field calls the value's
258
+ `__format__("")`, which usually equals `str()` (as `%s` does) but a custom type may differ."""
259
+ try:
260
+ fields = list(string.Formatter().parse(template))
261
+ except ValueError:
262
+ return None
263
+ out: list[str | ast.AST] = []
264
+ used = 0
265
+ for literal, name, spec, conversion in fields:
266
+ out.append(literal)
267
+ if name is None:
268
+ continue
269
+ if name != "" or spec or conversion is not None or used >= len(values):
270
+ return None
271
+ out.append(values[used])
272
+ used += 1
273
+ return out if used == len(values) else None
274
+
275
+
276
+ def _operand(expr: ast.AST, *, sql: bool = False) -> list[str | ast.AST] | None:
239
277
  """An operand of `+`: string-building expressions decompose, anything else is a value."""
240
278
  if isinstance(expr, (ast.JoinedStr, ast.BinOp)) or (
241
279
  isinstance(expr, ast.Constant) and isinstance(expr.value, str)
242
280
  ):
243
- return _sql_parts(expr)
281
+ return _sql_parts(expr, sql=sql)
244
282
  if isinstance(expr, ast.Constant):
245
283
  return None # e.g. "id = " + 5: not a str concatenation
246
284
  return [expr]
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import ast
23
23
  import builtins
24
+ from collections import Counter
24
25
  from collections.abc import Iterator
25
26
  from dataclasses import dataclass, field
26
27
 
@@ -186,9 +187,17 @@ class ScopeIndex:
186
187
  for name in replacement_names - original_names:
187
188
  if not self._safe_fix_name(name, original, imports):
188
189
  return False
190
+ # A call copied verbatim from the original (a value such as `params.get("id")`) is
191
+ # not introduced: same callee, same scope. Count copies so none can be duplicated.
192
+ copied = Counter(
193
+ ast.dump(n) for n in ast.walk(original) if isinstance(n, ast.Call) and n is not original
194
+ )
189
195
  for node in ast.walk(replacement):
190
196
  if not isinstance(node, ast.Call):
191
197
  continue
198
+ if node is not replacement and copied[ast.dump(node)] > 0:
199
+ copied[ast.dump(node)] -= 1
200
+ continue
192
201
  # SQL fixes keep the original receiver; it can be a local cursor.
193
202
  if (
194
203
  sql_receiver
@@ -75,6 +75,91 @@ def test_sqli_forms(tmp_path: Path, query: str, expected: str):
75
75
  assert expected in new
76
76
 
77
77
 
78
+ def test_sqli_str_format_becomes_parameterized_and_is_safe(tmp_path: Path):
79
+ _, new = fixed_source(
80
+ tmp_path,
81
+ """
82
+ import sqlite3
83
+
84
+ def login(cur, user, pw):
85
+ return cur.execute(
86
+ "SELECT id FROM users WHERE name = '{}' AND pw = '{}'".format(user, pw)
87
+ ).fetchall()
88
+ """,
89
+ )
90
+ assert "WHERE name = ? AND pw = ?', (user, pw))" in new
91
+ namespace: dict = {}
92
+ exec(compile(new, "m.py", "exec"), namespace)
93
+ db = sqlite3.connect(":memory:")
94
+ db.execute("CREATE TABLE users (id INTEGER, name TEXT, pw TEXT)")
95
+ db.execute("INSERT INTO users VALUES (1, 'alice', 's3')")
96
+ cur = db.cursor()
97
+ assert namespace["login"](cur, "alice", "s3") == [(1,)]
98
+ assert namespace["login"](cur, "alice", "x' OR '1'='1") == []
99
+
100
+
101
+ @pytest.mark.parametrize(
102
+ "query",
103
+ [
104
+ "\"SELECT * FROM t WHERE a = '{0}'\".format(x)", # numbered field
105
+ "\"SELECT * FROM t WHERE a = '{a}'\".format(a=x)", # named field / keyword
106
+ "\"SELECT * FROM t WHERE a = '{!r}'\".format(x)", # conversion
107
+ "\"SELECT * FROM t WHERE a = '{:>5}'\".format(x)", # format spec
108
+ "\"SELECT * FROM t WHERE a = '{}'\".format(x, y)", # unused argument
109
+ "\"SELECT * FROM t WHERE a = '{}'\".format(x, extra=y)", # unused keyword is still evaluated
110
+ "\"SELECT * FROM t WHERE a = '{}' AND b = '{}'\".format(*x)", # starred
111
+ "\"SELECT * FROM t WHERE a = '{}'\".format", # not a call of the template
112
+ ],
113
+ )
114
+ def test_sqli_str_format_refuses_non_plain_fields(tmp_path: Path, query: str):
115
+ _, findings = fixes_for(
116
+ tmp_path, f"import sqlite3\n\ndef f(cur, x, y):\n cur.execute({query})\n"
117
+ )
118
+ assert all(f.fix is None for f in findings)
119
+
120
+
121
+ def test_sqli_values_with_calls_are_copied_verbatim(tmp_path: Path):
122
+ _, new = fixed_source(
123
+ tmp_path,
124
+ """
125
+ import re
126
+ import sqlite3
127
+
128
+ def login(cur, params):
129
+ cur.execute(
130
+ "SELECT * FROM users WHERE name='" + re.sub(r"[^a-z]", "", params.get("name", ""))
131
+ + "' AND pw='" + params.get("pw", "") + "'"
132
+ )
133
+ """,
134
+ )
135
+ assert (
136
+ "'SELECT * FROM users WHERE name=? AND pw=?', "
137
+ '(re.sub(r"[^a-z]", "", params.get("name", "")), params.get("pw", "")))' in new
138
+ )
139
+
140
+
141
+ def test_copied_calls_cannot_be_duplicated_or_invented():
142
+ from hackscan.plugins.base import FileContext
143
+
144
+ source = "import sqlite3\n\ndef f(cur, g):\n cur.execute('SELECT ' + g())\n"
145
+ tree = ast.parse(source)
146
+ ctx = FileContext("m.py", source, tree)
147
+ (call,) = [
148
+ n
149
+ for n in ast.walk(tree)
150
+ if isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "execute"
151
+ ]
152
+
153
+ def ok(replacement: str) -> bool:
154
+ return ctx.scopes.safe_introduced_names(
155
+ call, ast.parse(replacement, mode="eval").body, sql_receiver=True
156
+ )
157
+
158
+ assert ok("cur.execute('SELECT ?', (g(),))")
159
+ assert not ok("cur.execute('SELECT ?, ?', (g(), g()))") # a second evaluation is new code
160
+ assert not ok("cur.execute('SELECT ?', (h(),))") # a call the original never made
161
+
162
+
78
163
  def test_sqli_psycopg_uses_format_placeholders(tmp_path: Path):
79
164
  _, new = fixed_source(
80
165
  tmp_path,
@@ -309,6 +394,19 @@ def test_os_system_percent_conversion_fix(tmp_path: Path, conversion: str, has_f
309
394
  assert "subprocess.call(['ls', str(n)])" in apply_edits(source, fixes[0].edits)
310
395
 
311
396
 
397
+ @pytest.mark.parametrize(
398
+ "call",
399
+ [
400
+ 'subprocess.run("whoami {}".format(n), shell=True, capture_output=True)',
401
+ 'os.system("whoami {}".format(n))',
402
+ ],
403
+ )
404
+ def test_command_str_format_has_no_template_fix(tmp_path: Path, call: str):
405
+ # argv must stay text: "whoami {}".format(123) works, ["whoami", 123] raises TypeError.
406
+ _, findings = fixes_for(tmp_path, f"import os\nimport subprocess\n\ndef f(n):\n {call}\n")
407
+ assert findings and all(f.fix is None for f in findings)
408
+
409
+
312
410
  def test_shell_true_drops_shell(tmp_path: Path):
313
411
  _, new = fixed_source(
314
412
  tmp_path,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes