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.
- {hackscan-0.3.0 → hackscan-0.3.1}/PKG-INFO +3 -3
- {hackscan-0.3.0 → hackscan-0.3.1}/PROJECT.md +7 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/README.md +2 -2
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/__init__.py +1 -1
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/llm_pass.py +1 -1
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/remediate.py +44 -6
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/scopes.py +9 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_remediate.py +98 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/.gitattributes +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/.github/workflows/ci.yml +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/.github/workflows/hackscan.yml +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/.github/workflows/release.yml +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/.gitignore +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/.pre-commit-hooks.yaml +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/LICENSE +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/action.yml +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/APPS.json +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/APPS.md +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/README.md +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/RESULTS.json +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/RESULTS.md +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/apps.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/apps_review.json +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/run.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/backup.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/ops_tool.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/scheduler.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/cmdi/web_tools.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/codei/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/codei/calculator.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/codei/plugins.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/api.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/django_views.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/flask_reports.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/repository.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/sqli/urls.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/weak_crypto/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/benchmarks/suite/weak_crypto/accounts.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/pyproject.toml +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/ast_pass.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/interproc.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/suppressions.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/analyzers/taint_pass.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/cli.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/config.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/dedupe.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/fingerprint.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/models.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/pipeline.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/redact.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/core/taxonomy.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/common.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/gitleaks.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/runner.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/importers/sarif.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/base.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/loader.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/cmdi.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/codei.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/sqli.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/weak_crypto.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/sarif/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/sarif/generator.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/taint/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/taint/sanitizers.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/taint/sources.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/ui/__init__.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/src/hackscan/ui/render.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/conftest.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/README.md +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/cmdi/safe/shell.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/cmdi/vulnerable/shell.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/cmdi/vulnerable/star_import.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/codei/safe/dynamic.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/codei/vulnerable/dynamic.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/frameworks/django/views.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/frameworks/fastapi/main.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/frameworks/flask/app.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/sqli/safe/dbapi.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/sqli/vulnerable/dbapi.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/weak_crypto/safe/hashes.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/corpus/weak_crypto/vulnerable/hashes.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/.gitkeep +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/README.md +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/bandit.sarif +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/codeql.sarif +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/gitleaks.json +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/project/app.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/imports/semgrep.sarif +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/fixtures/sarif-schema-2.1.0.json +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_action.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_ast_pass.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_benchmark.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_cli.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_config.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_dedupe.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_fingerprint.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_importers.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_interproc.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_llm.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_models.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_pipeline.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_redact.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_rules.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_sarif.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_scopes.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_taint.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_taint_fuzz.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_taxonomy.py +0 -0
- {hackscan-0.3.0 → hackscan-0.3.1}/tests/test_ui.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
250
|
+
rev: v0.3.1
|
|
251
251
|
hooks:
|
|
252
252
|
- id: hackscan
|
|
253
253
|
```
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|