hackscan 0.3.1__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.1 → hackscan-0.3.2}/PKG-INFO +28 -7
  2. {hackscan-0.3.1 → hackscan-0.3.2}/PROJECT.md +27 -0
  3. {hackscan-0.3.1 → hackscan-0.3.2}/README.md +27 -6
  4. {hackscan-0.3.1 → hackscan-0.3.2}/pyproject.toml +1 -0
  5. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/__init__.py +1 -1
  6. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/analyzers/llm_pass.py +19 -0
  7. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/analyzers/remediate.py +2 -17
  8. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/analyzers/taint_pass.py +534 -36
  9. hackscan-0.3.2/src/hackscan/taint/sanitizers.py +81 -0
  10. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_llm.py +49 -0
  11. hackscan-0.3.2/tests/test_quoting_credit.py +926 -0
  12. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_taint.py +3 -1
  13. hackscan-0.3.1/src/hackscan/taint/sanitizers.py +0 -37
  14. {hackscan-0.3.1 → hackscan-0.3.2}/.gitattributes +0 -0
  15. {hackscan-0.3.1 → hackscan-0.3.2}/.github/workflows/ci.yml +0 -0
  16. {hackscan-0.3.1 → hackscan-0.3.2}/.github/workflows/hackscan.yml +0 -0
  17. {hackscan-0.3.1 → hackscan-0.3.2}/.github/workflows/release.yml +0 -0
  18. {hackscan-0.3.1 → hackscan-0.3.2}/.gitignore +0 -0
  19. {hackscan-0.3.1 → hackscan-0.3.2}/.pre-commit-hooks.yaml +0 -0
  20. {hackscan-0.3.1 → hackscan-0.3.2}/LICENSE +0 -0
  21. {hackscan-0.3.1 → hackscan-0.3.2}/action.yml +0 -0
  22. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/APPS.json +0 -0
  23. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/APPS.md +0 -0
  24. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/README.md +0 -0
  25. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/RESULTS.json +0 -0
  26. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/RESULTS.md +0 -0
  27. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/apps.py +0 -0
  28. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/apps_review.json +0 -0
  29. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/run.py +0 -0
  30. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/__init__.py +0 -0
  31. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/cmdi/__init__.py +0 -0
  32. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/cmdi/backup.py +0 -0
  33. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/cmdi/ops_tool.py +0 -0
  34. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/cmdi/scheduler.py +0 -0
  35. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/cmdi/web_tools.py +0 -0
  36. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/codei/__init__.py +0 -0
  37. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/codei/calculator.py +0 -0
  38. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/codei/plugins.py +0 -0
  39. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/sqli/__init__.py +0 -0
  40. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/sqli/api.py +0 -0
  41. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/sqli/django_views.py +0 -0
  42. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/sqli/flask_reports.py +0 -0
  43. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/sqli/repository.py +0 -0
  44. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/sqli/urls.py +0 -0
  45. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/weak_crypto/__init__.py +0 -0
  46. {hackscan-0.3.1 → hackscan-0.3.2}/benchmarks/suite/weak_crypto/accounts.py +0 -0
  47. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/analyzers/__init__.py +0 -0
  48. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/analyzers/ast_pass.py +0 -0
  49. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/analyzers/interproc.py +0 -0
  50. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/analyzers/suppressions.py +0 -0
  51. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/cli.py +0 -0
  52. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/config.py +0 -0
  53. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/core/__init__.py +0 -0
  54. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/core/dedupe.py +0 -0
  55. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/core/fingerprint.py +0 -0
  56. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/core/models.py +0 -0
  57. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/core/pipeline.py +0 -0
  58. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/core/redact.py +0 -0
  59. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/core/taxonomy.py +0 -0
  60. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/importers/__init__.py +0 -0
  61. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/importers/common.py +0 -0
  62. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/importers/gitleaks.py +0 -0
  63. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/importers/runner.py +0 -0
  64. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/importers/sarif.py +0 -0
  65. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/__init__.py +0 -0
  66. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/base.py +0 -0
  67. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/loader.py +0 -0
  68. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/rules/__init__.py +0 -0
  69. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/rules/cmdi.py +0 -0
  70. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/rules/codei.py +0 -0
  71. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/rules/sqli.py +0 -0
  72. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/rules/weak_crypto.py +0 -0
  73. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/plugins/scopes.py +0 -0
  74. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/sarif/__init__.py +0 -0
  75. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/sarif/generator.py +0 -0
  76. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/taint/__init__.py +0 -0
  77. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/taint/sources.py +0 -0
  78. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/ui/__init__.py +0 -0
  79. {hackscan-0.3.1 → hackscan-0.3.2}/src/hackscan/ui/render.py +0 -0
  80. {hackscan-0.3.1 → hackscan-0.3.2}/tests/conftest.py +0 -0
  81. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/README.md +0 -0
  82. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/cmdi/safe/shell.py +0 -0
  83. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/cmdi/vulnerable/shell.py +0 -0
  84. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/cmdi/vulnerable/star_import.py +0 -0
  85. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/codei/safe/dynamic.py +0 -0
  86. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/codei/vulnerable/dynamic.py +0 -0
  87. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/frameworks/django/views.py +0 -0
  88. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/frameworks/fastapi/main.py +0 -0
  89. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/frameworks/flask/app.py +0 -0
  90. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/sqli/safe/dbapi.py +0 -0
  91. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/sqli/vulnerable/dbapi.py +0 -0
  92. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/weak_crypto/safe/hashes.py +0 -0
  93. {hackscan-0.3.1 → hackscan-0.3.2}/tests/corpus/weak_crypto/vulnerable/hashes.py +0 -0
  94. {hackscan-0.3.1 → hackscan-0.3.2}/tests/fixtures/imports/.gitkeep +0 -0
  95. {hackscan-0.3.1 → hackscan-0.3.2}/tests/fixtures/imports/README.md +0 -0
  96. {hackscan-0.3.1 → hackscan-0.3.2}/tests/fixtures/imports/bandit.sarif +0 -0
  97. {hackscan-0.3.1 → hackscan-0.3.2}/tests/fixtures/imports/codeql.sarif +0 -0
  98. {hackscan-0.3.1 → hackscan-0.3.2}/tests/fixtures/imports/gitleaks.json +0 -0
  99. {hackscan-0.3.1 → hackscan-0.3.2}/tests/fixtures/imports/project/app.py +0 -0
  100. {hackscan-0.3.1 → hackscan-0.3.2}/tests/fixtures/imports/semgrep.sarif +0 -0
  101. {hackscan-0.3.1 → hackscan-0.3.2}/tests/fixtures/sarif-schema-2.1.0.json +0 -0
  102. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_action.py +0 -0
  103. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_ast_pass.py +0 -0
  104. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_benchmark.py +0 -0
  105. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_cli.py +0 -0
  106. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_config.py +0 -0
  107. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_dedupe.py +0 -0
  108. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_fingerprint.py +0 -0
  109. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_importers.py +0 -0
  110. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_interproc.py +0 -0
  111. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_models.py +0 -0
  112. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_pipeline.py +0 -0
  113. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_redact.py +0 -0
  114. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_remediate.py +0 -0
  115. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_rules.py +0 -0
  116. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_sarif.py +0 -0
  117. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_scopes.py +0 -0
  118. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_taint_fuzz.py +0 -0
  119. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_taxonomy.py +0 -0
  120. {hackscan-0.3.1 → hackscan-0.3.2}/tests/test_ui.py +0 -0
  121. {hackscan-0.3.1 → 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.1
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.1
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.1
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
@@ -459,3 +459,30 @@ hackscan/
459
459
  fields, positional args used exactly once; never for command argv, where a non-str value would
460
460
  crash). Detection is unchanged, so the benchmark tables still show the 0.3.0 measurement (Bandit,
461
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.1
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.1
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.1"
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)
@@ -24,6 +24,7 @@ from hackscan.core.taxonomy import CMDI, CODEI, SQLI, WEAK_CRYPTO
24
24
  from hackscan.importers.common import SourceIndex
25
25
  from hackscan.plugins.base import FileContext, first_arg, keyword
26
26
  from hackscan.plugins.loader import builtin_plugins
27
+ from hackscan.taint.sanitizers import INERT_PROGRAMS, program_name
27
28
 
28
29
  PRODUCER = "template"
29
30
 
@@ -474,23 +475,7 @@ def _argv(expr: ast.AST, ctx: FileContext) -> list[str] | None:
474
475
  return argv
475
476
 
476
477
 
477
- # Programs whose arguments are data: no option or operand runs a command, loads code or
478
- # writes an arbitrary file. An argument list only makes a dynamic value safe for these;
479
- # countless others execute operands or options (`sh -c`, `npx PKG`, `git -c alias=!cmd`,
480
- # `tar --to-command`, `sort --compress-program`, `find -exec`, `rg --pre`), so an
481
- # allowlist, not a denylist.
482
- INERT_PROGRAMS = {
483
- "printf", "cat", "tac", "nl", "ls", "head", "tail", "wc", "cut",
484
- "grep", "egrep", "fgrep", "file", "stat", "du", "df", "basename", "dirname",
485
- "realpath", "readlink", "which", "whoami", "id", "ping", "host", "dig", "nslookup",
486
- } # fmt: skip
487
-
488
-
489
- def program_name(literal: str) -> str:
490
- """`/bin/grep` or `C:\\Tools\\grep.exe` -> `grep`. Only `.exe` is dropped: `.bat` and
491
- `.cmd` files run through cmd.exe, which re-parses the arguments."""
492
- name = literal.replace("\\", "/").rsplit("/", 1)[-1].lower()
493
- return name[: -len(".exe")] if name.endswith(".exe") else name
478
+ # INERT_PROGRAMS / program_name: see hackscan.taint.sanitizers (shared with taint_pass).
494
479
 
495
480
 
496
481
  def _argv_is_inert(words: list[str | None]) -> bool: