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