hackscan 0.3.0__tar.gz → 0.3.2__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 (121) hide show
  1. {hackscan-0.3.0 → hackscan-0.3.2}/PKG-INFO +28 -7
  2. {hackscan-0.3.0 → hackscan-0.3.2}/PROJECT.md +34 -0
  3. {hackscan-0.3.0 → hackscan-0.3.2}/README.md +27 -6
  4. {hackscan-0.3.0 → hackscan-0.3.2}/pyproject.toml +1 -0
  5. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/__init__.py +1 -1
  6. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/analyzers/llm_pass.py +20 -1
  7. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/analyzers/remediate.py +46 -23
  8. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/analyzers/taint_pass.py +534 -36
  9. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/scopes.py +9 -0
  10. hackscan-0.3.2/src/hackscan/taint/sanitizers.py +81 -0
  11. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_llm.py +49 -0
  12. hackscan-0.3.2/tests/test_quoting_credit.py +926 -0
  13. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_remediate.py +98 -0
  14. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_taint.py +3 -1
  15. hackscan-0.3.0/src/hackscan/taint/sanitizers.py +0 -37
  16. {hackscan-0.3.0 → hackscan-0.3.2}/.gitattributes +0 -0
  17. {hackscan-0.3.0 → hackscan-0.3.2}/.github/workflows/ci.yml +0 -0
  18. {hackscan-0.3.0 → hackscan-0.3.2}/.github/workflows/hackscan.yml +0 -0
  19. {hackscan-0.3.0 → hackscan-0.3.2}/.github/workflows/release.yml +0 -0
  20. {hackscan-0.3.0 → hackscan-0.3.2}/.gitignore +0 -0
  21. {hackscan-0.3.0 → hackscan-0.3.2}/.pre-commit-hooks.yaml +0 -0
  22. {hackscan-0.3.0 → hackscan-0.3.2}/LICENSE +0 -0
  23. {hackscan-0.3.0 → hackscan-0.3.2}/action.yml +0 -0
  24. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/APPS.json +0 -0
  25. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/APPS.md +0 -0
  26. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/README.md +0 -0
  27. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/RESULTS.json +0 -0
  28. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/RESULTS.md +0 -0
  29. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/apps.py +0 -0
  30. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/apps_review.json +0 -0
  31. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/run.py +0 -0
  32. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/__init__.py +0 -0
  33. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/cmdi/__init__.py +0 -0
  34. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/cmdi/backup.py +0 -0
  35. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/cmdi/ops_tool.py +0 -0
  36. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/cmdi/scheduler.py +0 -0
  37. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/cmdi/web_tools.py +0 -0
  38. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/codei/__init__.py +0 -0
  39. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/codei/calculator.py +0 -0
  40. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/codei/plugins.py +0 -0
  41. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/sqli/__init__.py +0 -0
  42. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/sqli/api.py +0 -0
  43. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/sqli/django_views.py +0 -0
  44. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/sqli/flask_reports.py +0 -0
  45. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/sqli/repository.py +0 -0
  46. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/sqli/urls.py +0 -0
  47. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/weak_crypto/__init__.py +0 -0
  48. {hackscan-0.3.0 → hackscan-0.3.2}/benchmarks/suite/weak_crypto/accounts.py +0 -0
  49. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/analyzers/__init__.py +0 -0
  50. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/analyzers/ast_pass.py +0 -0
  51. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/analyzers/interproc.py +0 -0
  52. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/analyzers/suppressions.py +0 -0
  53. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/cli.py +0 -0
  54. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/config.py +0 -0
  55. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/core/__init__.py +0 -0
  56. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/core/dedupe.py +0 -0
  57. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/core/fingerprint.py +0 -0
  58. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/core/models.py +0 -0
  59. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/core/pipeline.py +0 -0
  60. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/core/redact.py +0 -0
  61. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/core/taxonomy.py +0 -0
  62. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/importers/__init__.py +0 -0
  63. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/importers/common.py +0 -0
  64. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/importers/gitleaks.py +0 -0
  65. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/importers/runner.py +0 -0
  66. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/importers/sarif.py +0 -0
  67. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/__init__.py +0 -0
  68. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/base.py +0 -0
  69. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/loader.py +0 -0
  70. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/rules/__init__.py +0 -0
  71. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/rules/cmdi.py +0 -0
  72. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/rules/codei.py +0 -0
  73. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/rules/sqli.py +0 -0
  74. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/plugins/rules/weak_crypto.py +0 -0
  75. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/sarif/__init__.py +0 -0
  76. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/sarif/generator.py +0 -0
  77. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/taint/__init__.py +0 -0
  78. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/taint/sources.py +0 -0
  79. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/ui/__init__.py +0 -0
  80. {hackscan-0.3.0 → hackscan-0.3.2}/src/hackscan/ui/render.py +0 -0
  81. {hackscan-0.3.0 → hackscan-0.3.2}/tests/conftest.py +0 -0
  82. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/README.md +0 -0
  83. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/cmdi/safe/shell.py +0 -0
  84. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/cmdi/vulnerable/shell.py +0 -0
  85. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/cmdi/vulnerable/star_import.py +0 -0
  86. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/codei/safe/dynamic.py +0 -0
  87. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/codei/vulnerable/dynamic.py +0 -0
  88. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/frameworks/django/views.py +0 -0
  89. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/frameworks/fastapi/main.py +0 -0
  90. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/frameworks/flask/app.py +0 -0
  91. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/sqli/safe/dbapi.py +0 -0
  92. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/sqli/vulnerable/dbapi.py +0 -0
  93. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/weak_crypto/safe/hashes.py +0 -0
  94. {hackscan-0.3.0 → hackscan-0.3.2}/tests/corpus/weak_crypto/vulnerable/hashes.py +0 -0
  95. {hackscan-0.3.0 → hackscan-0.3.2}/tests/fixtures/imports/.gitkeep +0 -0
  96. {hackscan-0.3.0 → hackscan-0.3.2}/tests/fixtures/imports/README.md +0 -0
  97. {hackscan-0.3.0 → hackscan-0.3.2}/tests/fixtures/imports/bandit.sarif +0 -0
  98. {hackscan-0.3.0 → hackscan-0.3.2}/tests/fixtures/imports/codeql.sarif +0 -0
  99. {hackscan-0.3.0 → hackscan-0.3.2}/tests/fixtures/imports/gitleaks.json +0 -0
  100. {hackscan-0.3.0 → hackscan-0.3.2}/tests/fixtures/imports/project/app.py +0 -0
  101. {hackscan-0.3.0 → hackscan-0.3.2}/tests/fixtures/imports/semgrep.sarif +0 -0
  102. {hackscan-0.3.0 → hackscan-0.3.2}/tests/fixtures/sarif-schema-2.1.0.json +0 -0
  103. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_action.py +0 -0
  104. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_ast_pass.py +0 -0
  105. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_benchmark.py +0 -0
  106. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_cli.py +0 -0
  107. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_config.py +0 -0
  108. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_dedupe.py +0 -0
  109. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_fingerprint.py +0 -0
  110. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_importers.py +0 -0
  111. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_interproc.py +0 -0
  112. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_models.py +0 -0
  113. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_pipeline.py +0 -0
  114. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_redact.py +0 -0
  115. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_rules.py +0 -0
  116. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_sarif.py +0 -0
  117. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_scopes.py +0 -0
  118. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_taint_fuzz.py +0 -0
  119. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_taxonomy.py +0 -0
  120. {hackscan-0.3.0 → hackscan-0.3.2}/tests/test_ui.py +0 -0
  121. {hackscan-0.3.0 → hackscan-0.3.2}/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.2
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
@@ -202,7 +202,13 @@ HackScan's authors, so treat both tables as a sanity check rather than a ranking
202
202
 
203
203
  Sanitizers recognized: numeric/UUID conversions (all classes), `psycopg` `sql.Identifier`
204
204
  /`sql.Literal` (SQL). `shlex.quote` is POSIX-only, so quoted commands are kept as
205
- low-confidence candidates rather than suppressed.
205
+ low-confidence candidates rather than suppressed. That verdict needs the command to start with
206
+ a literal name from the same list of inert programs that command fixes use (plus `echo`, but
207
+ not `printf`, whose shell builtin can evaluate a variable name; not `sh -c`, `ssh`, `xargs`,
208
+ `tar`, interpreters, ...) and command text without shell syntax that starts or groups commands
209
+ (`;`, `|`, `&`, `$(...)`, redirections, braces): a quoted value in the program position, or a
210
+ program name assembled at runtime, gets none; nor does a call that may pick another shell
211
+ (`executable=`, options passed with `**`).
206
212
 
207
213
  ### Custom rules
208
214
 
@@ -241,7 +247,7 @@ jobs:
241
247
  runs-on: ubuntu-latest
242
248
  steps:
243
249
  - uses: actions/checkout@v4
244
- - uses: kahledawad2019-netizen/hackscan@v0.3.0
250
+ - uses: kahledawad2019-netizen/hackscan@v0.3.2
245
251
  with:
246
252
  path: src
247
253
  fail-on: high
@@ -273,7 +279,7 @@ Python 3.12 on the runner. Without the action:
273
279
  ```yaml
274
280
  repos:
275
281
  - repo: https://github.com/kahledawad2019-netizen/hackscan
276
- rev: v0.3.0
282
+ rev: v0.3.2
277
283
  hooks:
278
284
  - id: hackscan
279
285
  ```
@@ -298,15 +304,30 @@ apply as usual.
298
304
  confirmed or left as candidates: an assignment expression can rebind a name in the
299
305
  middle of an expression, which the statement-level analysis does not order. Sinks in
300
306
  the body of a generator expression are never suppressed either (it runs later), nor
301
- are values produced by one. Module-level constants are trusted only when bound exactly
302
- once and the module never writes its namespace dynamically (`globals()`, `vars()`,
303
- `exec`, `eval`, `__dict__`, star imports); `global`/`nonlocal` names are never trusted.
307
+ are values produced by one, except by a generator that `str.join` consumes right away.
308
+ Module-level constants are trusted only when bound exactly once and the module never
309
+ writes its namespace dynamically (`globals()`, `vars()`, `exec`, `eval`, `__dict__`,
310
+ star imports); `global`/`nonlocal` names are never trusted.
304
311
  - Mutable objects (lists, dicts, sets) are tracked by variable name. Mutation through
305
312
  direct aliases, method calls (`d.get(k).append(x)`), bound methods
306
313
  (`add = parts.append`), conditional receivers and comprehension items is modeled;
307
314
  aliases created in less direct ways (stored in another object's attribute or in a
308
315
  container and mutated through it later, returned from a helper, reached through
309
316
  `getattr`) may be missed, so a constant suppression involving such code deserves review.
317
+ - A sanitizer counts only through a name that cannot be something else in the same file:
318
+ one import or definition, no assignment to an attribute of that name, no `setattr` /
319
+ `delattr`. Replacing a sanitizer at runtime in other ways (from another file, or in the
320
+ same file through an alias of `setattr`, `__setattr__`, `__dict__` writes and the like)
321
+ is not modeled.
322
+ - Sanitizer credit computed in one function is reused by its callers and callees, so a
323
+ transformation in another function that undoes it (slicing a shell-quoted argument,
324
+ for example) can leave such a finding a candidate instead of confirming it.
325
+ - Numbers from `int()`, `float()`, `len()` and similar count as safe in SQL text, and SQL
326
+ token boundaries are not modeled: text whose structure a runtime number decides is not
327
+ detected, such as a `-` right before a possibly negative number (`"n -" + str(int(x))`
328
+ can form a `--` comment), a fragment repeated a runtime number of times (`"-" * int(x)`),
329
+ or a separator repeated between a runtime number of items. Bound query parameters avoid
330
+ all of these.
310
331
  - Python only. Imported tools may cover other languages; their findings are passed through.
311
332
  - Fix suggestions are deliberately conservative: many vulnerable lines get none. Command
312
333
  fixes (argument lists) are only offered for programs whose arguments are inert data
@@ -452,3 +452,37 @@ 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).
462
+ - 2026-10-13 (v0.3.2): soundness fixes found while reviewing an attempt to credit
463
+ datasette's `escape_sqlite` as an SQL identifier sanitizer. That attempt was dropped (Claude + Codex,
464
+ user-confirmed): the helper leaves words missing from its own reserved list bare (`returning`, `true`),
465
+ so it is no sound sanitizer. The review rounds instead found sanitizer-credit bugs that shipped in 0.3.1,
466
+ fixed here (tests/test_quoting_credit.py): shell-quoting credit is allow-listed (it survives only whole
467
+ insertion into plain text; containers, iteration, unpacking, indexing, methods, `!r`/padding, `%c`,
468
+ bytes and decoding drop it); text that may render quotes (bytes, containers, attributes, method results,
469
+ repr-like builtins, sliced quoting output, backslashes) is not quote-free; sanitizer names must be
470
+ stable (one binding, no attribute assignment, no `setattr`) in taint and LLM evidence; number credit
471
+ does not survive conversion to characters (methods other than case/strip/zfill, `%c`/`{:c}`, fill
472
+ specs, unknown templates, `format`/`bytes`/`bytearray`) or attribute access (`uuid.UUID(x).bytes`);
473
+ psycopg `sql.Identifier`/`sql.Literal` keep credit only while passed on as is (also not across calls);
474
+ `.format` field selectors and `*args`/`**kwargs` mappings; constant text indexed at runtime is no
475
+ constant; iteration items are no containers; `abs`/`round` pass their argument's credit through
476
+ (their dunders may return anything) instead of sanitizing; LLM sanitizer evidence is checked with the
477
+ taint engine itself (the credit must survive the sink's use of the value); the POSIX quoting verdict
478
+ needs the command to start with a literal inert program (the fix generator's list, now shared in
479
+ `taint/sanitizers.py`, plus `echo`, minus `printf`: bash's builtin evaluates a `-v` variable name)
480
+ and no shell syntax that starts or groups commands (user choice: allow-list, not a denylist of code
481
+ runners), and a sink that cannot pick another shell (`executable=`, Popen's positional executable,
482
+ `**` options). User decisions: in-file sanitizer replacement beyond the simple checks, credit
483
+ inherited across calls (missed confirmations only) and numbers/constants shaping SQL text
484
+ structure (sign next to `-`, runtime repetition) are documented README limitations (the last
485
+ pinned by strict-xfail tests). Codex review: 13 rounds, final "HS OK". The security agent's
486
+ held-out eval is identical to 0.3.1 on every finding, and the benchmark's HackScan numbers are
487
+ unchanged (`test_published_hackscan_numbers_are_current`), so the tables keep the 0.3.0
488
+ measurement (Bandit, Semgrep and CodeQL are not installed here to re-run every row).
@@ -176,7 +176,13 @@ HackScan's authors, so treat both tables as a sanity check rather than a ranking
176
176
 
177
177
  Sanitizers recognized: numeric/UUID conversions (all classes), `psycopg` `sql.Identifier`
178
178
  /`sql.Literal` (SQL). `shlex.quote` is POSIX-only, so quoted commands are kept as
179
- low-confidence candidates rather than suppressed.
179
+ low-confidence candidates rather than suppressed. That verdict needs the command to start with
180
+ a literal name from the same list of inert programs that command fixes use (plus `echo`, but
181
+ not `printf`, whose shell builtin can evaluate a variable name; not `sh -c`, `ssh`, `xargs`,
182
+ `tar`, interpreters, ...) and command text without shell syntax that starts or groups commands
183
+ (`;`, `|`, `&`, `$(...)`, redirections, braces): a quoted value in the program position, or a
184
+ program name assembled at runtime, gets none; nor does a call that may pick another shell
185
+ (`executable=`, options passed with `**`).
180
186
 
181
187
  ### Custom rules
182
188
 
@@ -215,7 +221,7 @@ jobs:
215
221
  runs-on: ubuntu-latest
216
222
  steps:
217
223
  - uses: actions/checkout@v4
218
- - uses: kahledawad2019-netizen/hackscan@v0.3.0
224
+ - uses: kahledawad2019-netizen/hackscan@v0.3.2
219
225
  with:
220
226
  path: src
221
227
  fail-on: high
@@ -247,7 +253,7 @@ Python 3.12 on the runner. Without the action:
247
253
  ```yaml
248
254
  repos:
249
255
  - repo: https://github.com/kahledawad2019-netizen/hackscan
250
- rev: v0.3.0
256
+ rev: v0.3.2
251
257
  hooks:
252
258
  - id: hackscan
253
259
  ```
@@ -272,15 +278,30 @@ apply as usual.
272
278
  confirmed or left as candidates: an assignment expression can rebind a name in the
273
279
  middle of an expression, which the statement-level analysis does not order. Sinks in
274
280
  the body of a generator expression are never suppressed either (it runs later), nor
275
- are values produced by one. Module-level constants are trusted only when bound exactly
276
- once and the module never writes its namespace dynamically (`globals()`, `vars()`,
277
- `exec`, `eval`, `__dict__`, star imports); `global`/`nonlocal` names are never trusted.
281
+ are values produced by one, except by a generator that `str.join` consumes right away.
282
+ Module-level constants are trusted only when bound exactly once and the module never
283
+ writes its namespace dynamically (`globals()`, `vars()`, `exec`, `eval`, `__dict__`,
284
+ star imports); `global`/`nonlocal` names are never trusted.
278
285
  - Mutable objects (lists, dicts, sets) are tracked by variable name. Mutation through
279
286
  direct aliases, method calls (`d.get(k).append(x)`), bound methods
280
287
  (`add = parts.append`), conditional receivers and comprehension items is modeled;
281
288
  aliases created in less direct ways (stored in another object's attribute or in a
282
289
  container and mutated through it later, returned from a helper, reached through
283
290
  `getattr`) may be missed, so a constant suppression involving such code deserves review.
291
+ - A sanitizer counts only through a name that cannot be something else in the same file:
292
+ one import or definition, no assignment to an attribute of that name, no `setattr` /
293
+ `delattr`. Replacing a sanitizer at runtime in other ways (from another file, or in the
294
+ same file through an alias of `setattr`, `__setattr__`, `__dict__` writes and the like)
295
+ is not modeled.
296
+ - Sanitizer credit computed in one function is reused by its callers and callees, so a
297
+ transformation in another function that undoes it (slicing a shell-quoted argument,
298
+ for example) can leave such a finding a candidate instead of confirming it.
299
+ - Numbers from `int()`, `float()`, `len()` and similar count as safe in SQL text, and SQL
300
+ token boundaries are not modeled: text whose structure a runtime number decides is not
301
+ detected, such as a `-` right before a possibly negative number (`"n -" + str(int(x))`
302
+ can form a `--` comment), a fragment repeated a runtime number of times (`"-" * int(x)`),
303
+ or a separator repeated between a runtime number of items. Bound query parameters avoid
304
+ all of these.
284
305
  - Python only. Imported tools may cover other languages; their findings are passed through.
285
306
  - Fix suggestions are deliberately conservative: many vulnerable lines get none. Command
286
307
  fixes (argument lists) are only offered for programs whose arguments are inert data
@@ -67,3 +67,4 @@ Issues = "https://github.com/kahledawad2019-netizen/hackscan/issues"
67
67
  "tests/test_remediate.py" = ["E501"]
68
68
  "tests/test_llm.py" = ["E501"]
69
69
  "tests/test_ui.py" = ["E501"]
70
+ "tests/test_quoting_credit.py" = ["E501"]
@@ -1,3 +1,3 @@
1
1
  """HackScan: Python SAST orchestrator and verifier."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -386,6 +386,7 @@ def verify_evidence(ctx: FileContext, finding: Finding, line: int, kind: str) ->
386
386
  _is_constant_value(stmt.value)
387
387
  if kind == "constant"
388
388
  else _is_class_sanitizer(stmt.value, ctx, finding.vuln_class)
389
+ and _credit_reaches_sink(ctx, func, stmt, call, finding.vuln_class)
389
390
  )
390
391
  covered = {target.id}
391
392
  elif kind == "guard":
@@ -404,6 +405,21 @@ def verify_evidence(ctx: FileContext, finding: Finding, line: int, kind: str) ->
404
405
  return ok and not _rebound_after(func, covered, stmt)
405
406
 
406
407
 
408
+ def _credit_reaches_sink(
409
+ ctx: FileContext, func: ast.AST, stmt: ast.Assign, call: ast.Call, vuln_class: str
410
+ ) -> bool:
411
+ """Whether the cited sanitizer's credit survives the sink's use of the value, by the
412
+ taint engine's own rules (`"%c" % n` or an f-string of a psycopg object drop it)."""
413
+ from hackscan.analyzers.taint_pass import _FileEngine, _FunctionEngine, combine, source
414
+
415
+ engine = _FunctionEngine(_FileEngine(ctx, {}), func, frozenset())
416
+ name = stmt.targets[0].id # type: ignore[attr-defined]
417
+ value = engine.eval(stmt.value, {name: source("untrusted input", stmt)})
418
+ args = [*call.args, *(k.value for k in call.keywords)]
419
+ result = combine(engine.eval(a, {name: value}) for a in args)
420
+ return vuln_class in result.safe_for
421
+
422
+
407
423
  def _sink_value_names(call: ast.Call) -> set[str] | None:
408
424
  """Every name the sink's arguments read, or None if they contain anything else that
409
425
  could carry data (calls, attributes, subscripts...). A suppression must vouch for
@@ -500,10 +516,13 @@ def _is_constant_value(expr: ast.AST) -> bool:
500
516
 
501
517
 
502
518
  def _is_class_sanitizer(expr: ast.AST, ctx: FileContext, vuln_class: str) -> bool:
519
+ from hackscan.analyzers.taint_pass import _FileEngine
503
520
  from hackscan.taint.sanitizers import QUOTING_SANITIZERS, SANITIZERS
504
521
 
505
522
  if not isinstance(expr, ast.Call):
506
523
  return False
524
+ if not _FileEngine(ctx, {}).stable_callee(expr):
525
+ return False # `int = ...` / `uuid.UUID = ...` elsewhere: the call may be another
507
526
  return any(
508
527
  vuln_class in SANITIZERS.get(name, frozenset()) and name not in QUOTING_SANITIZERS
509
528
  for name in ctx.call_names(expr)
@@ -779,7 +798,7 @@ def _same_sql(original: ast.Call, new: ast.Call, ctx: FileContext) -> bool:
779
798
  return False
780
799
  if not isinstance(params, (ast.Tuple, ast.List)):
781
800
  return False
782
- parts = _sql_parts(original.args[0])
801
+ parts = _sql_parts(original.args[0], sql=True)
783
802
  if parts is None:
784
803
  return False
785
804
  # 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
 
@@ -23,6 +24,7 @@ from hackscan.core.taxonomy import CMDI, CODEI, SQLI, WEAK_CRYPTO
23
24
  from hackscan.importers.common import SourceIndex
24
25
  from hackscan.plugins.base import FileContext, first_arg, keyword
25
26
  from hackscan.plugins.loader import builtin_plugins
27
+ from hackscan.taint.sanitizers import INERT_PROGRAMS, program_name
26
28
 
27
29
  PRODUCER = "template"
28
30
 
@@ -157,7 +159,7 @@ def _sqli(call: ast.Call, ctx: FileContext):
157
159
  placeholder = _placeholder(ctx)
158
160
  if placeholder is None:
159
161
  return None
160
- parts = _sql_parts(call.args[0])
162
+ parts = _sql_parts(call.args[0], sql=True)
161
163
  if parts is None or not any(isinstance(p, ast.AST) for p in parts):
162
164
  return None
163
165
  sql, params = _parameterize(parts, placeholder)
@@ -193,8 +195,13 @@ def _placeholder(ctx: FileContext) -> str | None:
193
195
  return "?" if qmark else "%s"
194
196
 
195
197
 
196
- def _sql_parts(expr: ast.AST) -> list[str | ast.AST] | None:
197
- """Literal text and value expressions, in order, for f-strings, `+` and `%`."""
198
+ def _sql_parts(expr: ast.AST, *, sql: bool = False) -> list[str | ast.AST] | None:
199
+ """Literal text and value expressions, in order, for f-strings, `+` and `%`.
200
+
201
+ `sql=True` also decomposes `"...".format(...)`. A bound SQL value skips the text conversion
202
+ (`__format__("")` for `{}`, like `__str__` for `%s` and f-strings) and keeps its type, as the
203
+ SQL fix description already says. Command argv must stay text, so `_argv` never uses it
204
+ (`"cmd {}".format(1)` would become a non-str argument and crash)."""
198
205
  if isinstance(expr, ast.JoinedStr):
199
206
  parts: list[str | ast.AST] = []
200
207
  for v in expr.values:
@@ -210,7 +217,7 @@ def _sql_parts(expr: ast.AST) -> list[str | ast.AST] | None:
210
217
  return None
211
218
  return parts
212
219
  if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
213
- left, right = _operand(expr.left), _operand(expr.right)
220
+ left, right = _operand(expr.left, sql=sql), _operand(expr.right, sql=sql)
214
221
  if left is None or right is None:
215
222
  return None
216
223
  return [*left, *right]
@@ -230,17 +237,49 @@ def _sql_parts(expr: ast.AST) -> list[str | ast.AST] | None:
230
237
  else:
231
238
  out.append(values[i // 2])
232
239
  return out
240
+ if (
241
+ sql
242
+ and isinstance(expr, ast.Call)
243
+ and isinstance(expr.func, ast.Attribute)
244
+ and expr.func.attr == "format"
245
+ and isinstance(expr.func.value, ast.Constant)
246
+ and isinstance(expr.func.value.value, str)
247
+ and not expr.keywords
248
+ and not any(isinstance(a, ast.Starred) for a in expr.args)
249
+ ):
250
+ return _format_parts(expr.func.value.value, expr.args)
233
251
  if isinstance(expr, ast.Constant) and isinstance(expr.value, str):
234
252
  return [expr.value]
235
253
  return None
236
254
 
237
255
 
238
- def _operand(expr: ast.AST) -> list[str | ast.AST] | None:
256
+ def _format_parts(template: str, values: list[ast.AST]) -> list[str | ast.AST] | None:
257
+ """`"...{}...".format(a, b)`: only auto-numbered `{}` fields without a conversion or
258
+ format spec, each argument used exactly once in order. Each field calls the value's
259
+ `__format__("")`, which usually equals `str()` (as `%s` does) but a custom type may differ."""
260
+ try:
261
+ fields = list(string.Formatter().parse(template))
262
+ except ValueError:
263
+ return None
264
+ out: list[str | ast.AST] = []
265
+ used = 0
266
+ for literal, name, spec, conversion in fields:
267
+ out.append(literal)
268
+ if name is None:
269
+ continue
270
+ if name != "" or spec or conversion is not None or used >= len(values):
271
+ return None
272
+ out.append(values[used])
273
+ used += 1
274
+ return out if used == len(values) else None
275
+
276
+
277
+ def _operand(expr: ast.AST, *, sql: bool = False) -> list[str | ast.AST] | None:
239
278
  """An operand of `+`: string-building expressions decompose, anything else is a value."""
240
279
  if isinstance(expr, (ast.JoinedStr, ast.BinOp)) or (
241
280
  isinstance(expr, ast.Constant) and isinstance(expr.value, str)
242
281
  ):
243
- return _sql_parts(expr)
282
+ return _sql_parts(expr, sql=sql)
244
283
  if isinstance(expr, ast.Constant):
245
284
  return None # e.g. "id = " + 5: not a str concatenation
246
285
  return [expr]
@@ -436,23 +475,7 @@ def _argv(expr: ast.AST, ctx: FileContext) -> list[str] | None:
436
475
  return argv
437
476
 
438
477
 
439
- # Programs whose arguments are data: no option or operand runs a command, loads code or
440
- # writes an arbitrary file. An argument list only makes a dynamic value safe for these;
441
- # countless others execute operands or options (`sh -c`, `npx PKG`, `git -c alias=!cmd`,
442
- # `tar --to-command`, `sort --compress-program`, `find -exec`, `rg --pre`), so an
443
- # allowlist, not a denylist.
444
- INERT_PROGRAMS = {
445
- "printf", "cat", "tac", "nl", "ls", "head", "tail", "wc", "cut",
446
- "grep", "egrep", "fgrep", "file", "stat", "du", "df", "basename", "dirname",
447
- "realpath", "readlink", "which", "whoami", "id", "ping", "host", "dig", "nslookup",
448
- } # fmt: skip
449
-
450
-
451
- def program_name(literal: str) -> str:
452
- """`/bin/grep` or `C:\\Tools\\grep.exe` -> `grep`. Only `.exe` is dropped: `.bat` and
453
- `.cmd` files run through cmd.exe, which re-parses the arguments."""
454
- name = literal.replace("\\", "/").rsplit("/", 1)[-1].lower()
455
- return name[: -len(".exe")] if name.endswith(".exe") else name
478
+ # INERT_PROGRAMS / program_name: see hackscan.taint.sanitizers (shared with taint_pass).
456
479
 
457
480
 
458
481
  def _argv_is_inert(words: list[str | None]) -> bool: