hackscan 0.1.0__tar.gz → 0.3.0__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 (124) hide show
  1. hackscan-0.3.0/.github/workflows/hackscan.yml +22 -0
  2. {hackscan-0.1.0 → hackscan-0.3.0}/.gitignore +7 -0
  3. hackscan-0.3.0/.pre-commit-hooks.yaml +9 -0
  4. hackscan-0.3.0/PKG-INFO +340 -0
  5. {hackscan-0.1.0 → hackscan-0.3.0}/PROJECT.md +188 -6
  6. hackscan-0.3.0/README.md +314 -0
  7. hackscan-0.3.0/action.yml +94 -0
  8. hackscan-0.3.0/benchmarks/APPS.json +133 -0
  9. hackscan-0.3.0/benchmarks/APPS.md +28 -0
  10. hackscan-0.3.0/benchmarks/README.md +104 -0
  11. hackscan-0.3.0/benchmarks/RESULTS.json +188 -0
  12. hackscan-0.3.0/benchmarks/RESULTS.md +21 -0
  13. hackscan-0.3.0/benchmarks/apps.py +267 -0
  14. hackscan-0.3.0/benchmarks/apps_review.json +46 -0
  15. hackscan-0.3.0/benchmarks/run.py +481 -0
  16. hackscan-0.3.0/benchmarks/suite/cmdi/__init__.py +0 -0
  17. hackscan-0.3.0/benchmarks/suite/cmdi/backup.py +15 -0
  18. hackscan-0.3.0/benchmarks/suite/cmdi/ops_tool.py +53 -0
  19. hackscan-0.3.0/benchmarks/suite/cmdi/scheduler.py +10 -0
  20. hackscan-0.3.0/benchmarks/suite/cmdi/web_tools.py +52 -0
  21. hackscan-0.3.0/benchmarks/suite/codei/__init__.py +0 -0
  22. hackscan-0.3.0/benchmarks/suite/codei/calculator.py +55 -0
  23. hackscan-0.3.0/benchmarks/suite/codei/plugins.py +27 -0
  24. hackscan-0.3.0/benchmarks/suite/sqli/__init__.py +0 -0
  25. hackscan-0.3.0/benchmarks/suite/sqli/api.py +28 -0
  26. hackscan-0.3.0/benchmarks/suite/sqli/django_views.py +46 -0
  27. hackscan-0.3.0/benchmarks/suite/sqli/flask_reports.py +80 -0
  28. hackscan-0.3.0/benchmarks/suite/sqli/repository.py +25 -0
  29. hackscan-0.3.0/benchmarks/suite/sqli/urls.py +12 -0
  30. hackscan-0.3.0/benchmarks/suite/weak_crypto/__init__.py +0 -0
  31. hackscan-0.3.0/benchmarks/suite/weak_crypto/accounts.py +53 -0
  32. {hackscan-0.1.0 → hackscan-0.3.0}/pyproject.toml +8 -1
  33. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/__init__.py +1 -1
  34. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/analyzers/ast_pass.py +27 -9
  35. hackscan-0.3.0/src/hackscan/analyzers/interproc.py +377 -0
  36. hackscan-0.3.0/src/hackscan/analyzers/llm_pass.py +849 -0
  37. hackscan-0.3.0/src/hackscan/analyzers/remediate.py +599 -0
  38. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/analyzers/taint_pass.py +513 -37
  39. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/cli.py +128 -9
  40. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/config.py +30 -2
  41. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/core/pipeline.py +76 -11
  42. hackscan-0.3.0/src/hackscan/core/redact.py +371 -0
  43. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/core/taxonomy.py +2 -0
  44. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/importers/common.py +11 -9
  45. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/importers/sarif.py +20 -5
  46. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/base.py +56 -0
  47. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/loader.py +5 -1
  48. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/scopes.py +120 -2
  49. hackscan-0.3.0/src/hackscan/ui/__init__.py +1 -0
  50. hackscan-0.3.0/src/hackscan/ui/render.py +156 -0
  51. hackscan-0.3.0/tests/fixtures/imports/.gitkeep +0 -0
  52. hackscan-0.3.0/tests/test_action.py +203 -0
  53. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_ast_pass.py +36 -0
  54. hackscan-0.3.0/tests/test_benchmark.py +365 -0
  55. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_cli.py +69 -0
  56. hackscan-0.3.0/tests/test_importers.py +897 -0
  57. hackscan-0.3.0/tests/test_interproc.py +388 -0
  58. hackscan-0.3.0/tests/test_llm.py +1098 -0
  59. hackscan-0.3.0/tests/test_pipeline.py +372 -0
  60. hackscan-0.3.0/tests/test_redact.py +185 -0
  61. hackscan-0.3.0/tests/test_remediate.py +729 -0
  62. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_scopes.py +75 -0
  63. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_taint.py +569 -0
  64. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_taxonomy.py +5 -0
  65. hackscan-0.3.0/tests/test_ui.py +145 -0
  66. {hackscan-0.1.0 → hackscan-0.3.0}/uv.lock +36 -0
  67. hackscan-0.1.0/PKG-INFO +0 -181
  68. hackscan-0.1.0/README.md +0 -156
  69. hackscan-0.1.0/src/hackscan/core/redact.py +0 -84
  70. hackscan-0.1.0/tests/test_importers.py +0 -379
  71. hackscan-0.1.0/tests/test_pipeline.py +0 -56
  72. {hackscan-0.1.0 → hackscan-0.3.0}/.gitattributes +0 -0
  73. {hackscan-0.1.0 → hackscan-0.3.0}/.github/workflows/ci.yml +0 -0
  74. {hackscan-0.1.0 → hackscan-0.3.0}/.github/workflows/release.yml +0 -0
  75. {hackscan-0.1.0 → hackscan-0.3.0}/LICENSE +0 -0
  76. /hackscan-0.1.0/tests/fixtures/imports/.gitkeep → /hackscan-0.3.0/benchmarks/suite/__init__.py +0 -0
  77. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/analyzers/__init__.py +0 -0
  78. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/analyzers/suppressions.py +0 -0
  79. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/core/__init__.py +0 -0
  80. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/core/dedupe.py +0 -0
  81. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/core/fingerprint.py +0 -0
  82. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/core/models.py +0 -0
  83. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/importers/__init__.py +0 -0
  84. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/importers/gitleaks.py +0 -0
  85. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/importers/runner.py +0 -0
  86. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/__init__.py +0 -0
  87. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/rules/__init__.py +0 -0
  88. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/rules/cmdi.py +0 -0
  89. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/rules/codei.py +0 -0
  90. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/rules/sqli.py +0 -0
  91. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/plugins/rules/weak_crypto.py +0 -0
  92. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/sarif/__init__.py +0 -0
  93. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/sarif/generator.py +0 -0
  94. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/taint/__init__.py +0 -0
  95. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/taint/sanitizers.py +0 -0
  96. {hackscan-0.1.0 → hackscan-0.3.0}/src/hackscan/taint/sources.py +0 -0
  97. {hackscan-0.1.0 → hackscan-0.3.0}/tests/conftest.py +0 -0
  98. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/README.md +0 -0
  99. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/cmdi/safe/shell.py +0 -0
  100. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/cmdi/vulnerable/shell.py +0 -0
  101. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/cmdi/vulnerable/star_import.py +0 -0
  102. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/codei/safe/dynamic.py +0 -0
  103. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/codei/vulnerable/dynamic.py +0 -0
  104. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/frameworks/django/views.py +0 -0
  105. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/frameworks/fastapi/main.py +0 -0
  106. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/frameworks/flask/app.py +0 -0
  107. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/sqli/safe/dbapi.py +0 -0
  108. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/sqli/vulnerable/dbapi.py +0 -0
  109. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/weak_crypto/safe/hashes.py +0 -0
  110. {hackscan-0.1.0 → hackscan-0.3.0}/tests/corpus/weak_crypto/vulnerable/hashes.py +0 -0
  111. {hackscan-0.1.0 → hackscan-0.3.0}/tests/fixtures/imports/README.md +0 -0
  112. {hackscan-0.1.0 → hackscan-0.3.0}/tests/fixtures/imports/bandit.sarif +0 -0
  113. {hackscan-0.1.0 → hackscan-0.3.0}/tests/fixtures/imports/codeql.sarif +0 -0
  114. {hackscan-0.1.0 → hackscan-0.3.0}/tests/fixtures/imports/gitleaks.json +0 -0
  115. {hackscan-0.1.0 → hackscan-0.3.0}/tests/fixtures/imports/project/app.py +0 -0
  116. {hackscan-0.1.0 → hackscan-0.3.0}/tests/fixtures/imports/semgrep.sarif +0 -0
  117. {hackscan-0.1.0 → hackscan-0.3.0}/tests/fixtures/sarif-schema-2.1.0.json +0 -0
  118. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_config.py +0 -0
  119. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_dedupe.py +0 -0
  120. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_fingerprint.py +0 -0
  121. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_models.py +0 -0
  122. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_rules.py +0 -0
  123. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_sarif.py +0 -0
  124. {hackscan-0.1.0 → hackscan-0.3.0}/tests/test_taint_fuzz.py +0 -0
@@ -0,0 +1,22 @@
1
+ name: HackScan
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+ security-events: write
11
+
12
+ jobs:
13
+ scan:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: ./
18
+ with:
19
+ path: src
20
+ fail-on: high
21
+ # Fork pull requests get a read-only token, so the Code Scanning upload is skipped there.
22
+ upload-sarif: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
@@ -11,3 +11,10 @@ htmlcov/
11
11
  *.sarif
12
12
  !tests/fixtures/**/*.sarif
13
13
  .hackscan-cache/
14
+
15
+ # Local tool caches/sandbox scratch (never packaged)
16
+ .uv-cache/
17
+ .test-tmp/
18
+
19
+ # benchmark downloads
20
+ benchmarks/.cache/
@@ -0,0 +1,9 @@
1
+ - id: hackscan
2
+ name: HackScan
3
+ description: Scan the repository for Python security findings.
4
+ language: python
5
+ entry: hackscan scan
6
+ args: [".", "--fail-on", "high"]
7
+ pass_filenames: false
8
+ types: [python]
9
+ require_serial: true
@@ -0,0 +1,340 @@
1
+ Metadata-Version: 2.5
2
+ Name: hackscan
3
+ Version: 0.3.0
4
+ Summary: Python SAST orchestrator: own AST + taint engine, imports Semgrep/Bandit/CodeQL/Gitleaks, dedupes, emits SARIF.
5
+ Project-URL: Source, https://github.com/kahledawad2019-netizen/hackscan
6
+ Project-URL: Issues, https://github.com/kahledawad2019-netizen/hackscan/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: sarif,sast,security,static-analysis,taint-analysis,vulnerability-scanner
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: click>=8.1
23
+ Requires-Dist: pyyaml>=6.0.3
24
+ Requires-Dist: rich>=13.7
25
+ Description-Content-Type: text/markdown
26
+
27
+ # HackScan
28
+
29
+ **A Python security scanner that verifies what it reports.** HackScan finds injection
30
+ sinks with its own AST engine, then runs a flow-sensitive taint analysis to decide, per
31
+ finding, whether untrusted input really reaches it. It can also ingest Semgrep, Bandit,
32
+ CodeQL and Gitleaks results, merge duplicates across tools, and emit one SARIF report.
33
+
34
+ ```text
35
+ $ hackscan scan app/
36
+ HIGH confirmed views.py:10:9 HS-SQLI-001 [100%]
37
+ SQL query is built with string formatting/concatenation.
38
+ | cursor.execute("SELECT * FROM sales WHERE year = " + year)
39
+ > Untrusted request data (`request.GET`) at line 8.
40
+ > Untrusted input reaches the sink.
41
+ ```
42
+
43
+ ## Why another scanner?
44
+
45
+ | | HackScan |
46
+ |---|---|
47
+ | **Verdicts, not just matches** | Every injection candidate is **confirmed** (untrusted source reaches it, with a trace), **suppressed** (provably constant or sanitized on every path), or left as a **candidate** for review. |
48
+ | **Sound suppression** | A finding is only dismissed if *every* path is safe. Branches, loops (`break`/`continue`), `try`/`except`/`finally`, aliasing and mutation of lists/dicts, closures and `:=` are modeled. A differential fuzzer executes thousands of random programs to check that no dismissed sink ever receives attacker input. |
49
+ | **Across functions and files** | A sink fed by a function parameter is confirmed when a caller anywhere in the scanned code passes untrusted data (directly or through other functions), or when a called function returns it; the trace names the source file and line. |
50
+ | **Framework-aware** | Sources: Flask `request.*`, Django/DRF/Starlette/FastAPI request objects (incl. `self.request`), route-handler parameters, `input()`, `sys.argv`. |
51
+ | **One report from many tools** | Imports Semgrep, Bandit, CodeQL (SARIF) and Gitleaks; deduplicates by vulnerability class and location, keeping every tool as provenance. |
52
+ | **Fixes you can apply** | Mechanical, validated fix suggestions: parameterized queries (driver-aware placeholders), argument lists instead of shell strings, `ast.literal_eval`, SHA-256. Each is an exact edit that re-parses; ambiguous cases get no fix rather than a wrong one. |
53
+ | **Optional local-LLM triage** | `--llm` asks a local Ollama model about remaining candidates. It can confirm, but may only suppress by citing a line that HackScan verifies itself (an allow-list guard, constant or sanitizer that holds on every path). Code is treated as untrusted data; secrets are redacted. |
54
+ | **CI-ready** | SARIF 2.1.0 (validated against the OASIS schema) for GitHub code scanning, `--fail-on` exit codes, stable fingerprints that survive code moves. |
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install hackscan # or: uv tool install hackscan / pipx install hackscan
60
+ ```
61
+
62
+ Python 3.10–3.13, any OS. No network access and no LLM needed (`--llm` is opt-in).
63
+
64
+ ## Usage
65
+
66
+ ```bash
67
+ hackscan scan . # text report
68
+ hackscan scan . --format sarif -o hackscan.sarif # for GitHub code scanning
69
+ hackscan scan . --fail-on high # exit 1 on open high/critical findings
70
+ hackscan scan . --format json --show-suppressed # everything, machine-readable
71
+ hackscan scan . --with bandit,semgrep,gitleaks # also run these tools if installed
72
+ hackscan scan . --import codeql=results.sarif # merge a report you already have
73
+ hackscan scan . --show-fixes # include fix suggestions as diffs
74
+ hackscan scan . --llm --model qwen2.5-coder:7b # triage candidates with local Ollama
75
+ hackscan rules # list rules
76
+ ```
77
+
78
+ | Option | Meaning |
79
+ |---|---|
80
+ | `--severity`, `--min-confidence` | Report filters. |
81
+ | `--fail-on SEV` | Exit 1 if an open (candidate/confirmed) finding is at least `SEV`. Suppressed findings never count. `never` turns off a threshold set in `.hackscan.yml`. |
82
+ | `--ignore GLOB` | Skip paths (`tests/*`, `**/migrations/**`, `legacy`). `.venv`, `node_modules`, `build`, ... are always skipped. |
83
+ | `--with TOOLS` / `--import FMT=FILE` | Run external tools / import reports (`sarif`, `semgrep`, `bandit`, `codeql`, `gitleaks`). A missing or failing tool is a warning unless `--strict-tools`. |
84
+ | `-o FILE` | Write the report to `FILE`. Never overwrites anything except a previous HackScan report. |
85
+ | `--plugins DIR` | Load custom rules (see below). |
86
+ | `--allow-incomplete` | Do not exit 2 when some files cannot be analyzed (they are still listed). |
87
+ | `--no-taint` | Pattern matching only. |
88
+ | `--show-fixes` / `--no-fix` | Print suggested fixes as diffs / do not generate fixes (fixes are also in JSON and SARIF `fixes`). |
89
+ | `--llm`, `--model`, `--ollama-host` | Opt-in LLM triage of candidates via Ollama (default `http://localhost:11434`). |
90
+ | `--llm-max`, `--llm-timeout`, `--llm-no-suppress` | LLM budget (default 20 findings, 60 s/request); let the LLM confirm/annotate but never suppress. |
91
+ | `--color auto\|always\|never` | Rich terminal output; `auto` uses it only on an interactive terminal (not in CI, pipes or with `NO_COLOR`). |
92
+ | `--jobs N` | Worker processes (default: automatic). |
93
+
94
+ Exit codes: `0` OK, `1` `--fail-on` threshold reached, `2` usage/config/plugin error or
95
+ an **incomplete scan** (a file could not be parsed or read, or an `--import` report
96
+ could not be loaded; use `--allow-incomplete` to accept). A `--with` tool that is missing
97
+ or exits with an error is a warning, or exit `2` with `--strict-tools`. An incomplete scan is never reported as a pass, and SARIF marks it with
98
+ `executionSuccessful: false`.
99
+
100
+ ### Configuration
101
+
102
+ `.hackscan.yml` files are discovered from the filesystem root down to the scanned
103
+ directory and merged (nearest wins; `ignore` lists accumulate). CLI options override them.
104
+
105
+ ```yaml
106
+ ignore: [tests/*, "**/migrations/**"]
107
+ severity: medium
108
+ min-confidence: 40
109
+ fail-on: high
110
+ with: [bandit]
111
+ import:
112
+ codeql: codeql-results.sarif
113
+ plugins: security/rules
114
+ llm: false
115
+ llm-model: qwen2.5-coder:7b
116
+ ```
117
+
118
+ ### LLM triage
119
+
120
+ `--llm` sends each remaining *candidate* (never confirmed or suppressed findings) with
121
+ its surrounding function to a local Ollama model and asks for a verdict:
122
+
123
+ - **true positive**: the finding becomes *confirmed* (+15 confidence); a suggested fix is
124
+ kept only if the patched file parses and a re-scan shows the finding gone and nothing new.
125
+ - **false positive**: only accepted if the model cites a line that HackScan verifies runs
126
+ on every path and makes the value safe: an allow-list guard (`if x not in {...}: return`,
127
+ `if not x.isdigit(): raise`, or `if not x.isdecimal(): raise`), a constant, or a
128
+ class-appropriate sanitizer. `isnumeric()`, `isalnum()`, `isalpha()`, and
129
+ `isidentifier()` do not qualify. Otherwise the reasoning is attached as a note and
130
+ the finding stays open.
131
+ - Code is fenced with a random per-request token and treated as untrusted data; replies
132
+ must match a JSON schema; secret-looking values are redacted; answers are cached.
133
+
134
+ Use a non-reasoning instruct or coder model. Measured on a CPU-only laptop (~30 s per
135
+ finding): `qwen3:4b-instruct-2507` judged a real injection, a bypassable length check and
136
+ an allow-list guard correctly; `qwen2.5-coder:3b` never suppressed but also missed the
137
+ allow-list guard (larger models do better). Reasoning models such as `deepseek-r1` often
138
+ spend their whole output budget thinking, which HackScan reports as "no answer" and
139
+ leaves the finding open. In the same test both small models obeyed a comment in the
140
+ scanned code telling them to answer "false positive"; the deterministic evidence check
141
+ rejected it and the finding stayed open. That check, not the model, decides suppression.
142
+
143
+ ### Suppressing a finding
144
+
145
+ ```python
146
+ os.system(cmd) # hackscan: ignore[HS-CMDI-001]
147
+ ```
148
+
149
+ Suppressed findings stay in SARIF output (as `suppressions`) so they remain auditable;
150
+ use `--sarif-omit-suppressed` when uploading to GitHub, which does not honor them.
151
+
152
+ Secrets are never echoed: findings of class `secret` (from any tool) have generic
153
+ messages and redacted snippets, and values reported by Gitleaks are scrubbed from all
154
+ output, including warnings.
155
+
156
+ ## Benchmark
157
+
158
+ **Labeled suite.** Flask, Django, FastAPI and CLI code with 26 vulnerable sinks and 24
159
+ look-alike safe traps (SQL, command and code injection, weak crypto), scored per function;
160
+ each tool runs the configuration in its Version column (Bandit: all checks; Semgrep CE:
161
+ the `p/python` ruleset; CodeQL: `python-security-extended`; HackScan: no options)
162
+ ([methodology and caveats](benchmarks/README.md), [full results](benchmarks/RESULTS.md)):
163
+
164
+ | Tool | Version | TP | FP | FN | Precision | Recall | F1 |
165
+ |---|---|---:|---:|---:|---:|---:|---:|
166
+ | HackScan | 0.3.0 | 23 | 7 | 3 | 77% | 88% | 82% |
167
+ | HackScan (confirmed only) | 0.3.0 | 18 | 2 | 8 | 90% | 69% | 78% |
168
+ | Bandit | bandit[sarif]==1.9.4 | 24 | 19 | 2 | 56% | 92% | 70% |
169
+ | Semgrep CE | semgrep==1.180.0 (p/python sha256:31c1dfa46e8d) | 16 | 6 | 10 | 73% | 62% | 67% |
170
+ | CodeQL | CodeQL 2.27.2 (codeql/python-queries@1.8.12, security-extended) | 20 | 1 | 6 | 95% | 77% | 85% |
171
+
172
+ **Known-vulnerable apps.** [vulpy](https://github.com/fportantier/vulpy),
173
+ [PyGoat](https://github.com/adeyosemanputra/pygoat) and
174
+ [DVPWA](https://github.com/anxolerd/dvpwa) at pinned commits; every report reviewed by
175
+ hand. There is no complete ground truth, so recall is relative to the 21 distinct
176
+ vulnerabilities any tool found ([details](benchmarks/APPS.md)):
177
+
178
+ | Tool | Reports | TP | FP | Vulns found | Precision | Relative recall |
179
+ |---|---:|---:|---:|---:|---:|---:|
180
+ | HackScan | 18 | 15 | 3 | 15 | 83% | 71% |
181
+ | HackScan (confirmed only) | 6 | 6 | 0 | 6 | 100% | 29% |
182
+ | Bandit | 32 | 16 | 16 | 16 | 50% | 76% |
183
+ | Semgrep CE | 12 | 11 | 1 | 11 | 92% | 52% |
184
+ | CodeQL | 15 | 15 | 0 | 14 | 100% | 67% |
185
+
186
+ CodeQL is the most precise; Bandit finds a little more but about half of its reports are
187
+ noise. HackScan sits in between: higher recall than CodeQL and Semgrep, higher
188
+ precision than Bandit. Its misses are weak ciphers (DES, RC4, ECB, unauthenticated
189
+ modes), which its rule does not cover yet, and flows it does not follow (`self`
190
+ attributes, ambiguous module names). Its false positives are sinks it cannot prove safe (allow-list
191
+ guards, functions only called with constants). The suite is small and written by
192
+ HackScan's authors, so treat both tables as a sanity check rather than a ranking.
193
+
194
+ ## Rules
195
+
196
+ | Rule | Detects | Severity |
197
+ |---|---|---|
198
+ | `HS-SQLI-001` | Non-constant SQL reaching DB-API `execute*`, Django `raw`, SQLAlchemy `text`, pandas `read_sql` | high |
199
+ | `HS-CMDI-001` | Non-constant commands reaching `os.system`, `os.popen`, `subprocess.*(shell=True)`, ... | high |
200
+ | `HS-CODEI-001` | Non-constant input to `eval`, `exec`, `compile` | high |
201
+ | `HS-CRYPTO-001` | MD5/SHA-1 (unless `usedforsecurity=False`) | low |
202
+
203
+ Sanitizers recognized: numeric/UUID conversions (all classes), `psycopg` `sql.Identifier`
204
+ /`sql.Literal` (SQL). `shlex.quote` is POSIX-only, so quoted commands are kept as
205
+ low-confidence candidates rather than suppressed.
206
+
207
+ ### Custom rules
208
+
209
+ ```python
210
+ # security/rules/pickle_rule.py
211
+ from hackscan.core.models import Severity
212
+ from hackscan.plugins import Match, RulePlugin
213
+
214
+
215
+ class PickleLoads(RulePlugin):
216
+ rule_id = "ACME-PICKLE-001" # the HS- prefix is reserved
217
+ name = "pickle-loads"
218
+ description = "pickle.loads on untrusted data"
219
+ severity = Severity.HIGH
220
+ cwe = ("CWE-502",)
221
+
222
+ def check(self, node, ctx):
223
+ if "pickle.loads" in ctx.call_names(node):
224
+ yield Match(node, "pickle.loads call")
225
+ ```
226
+
227
+ ## GitHub Actions
228
+
229
+ The repository is a composite action (available from v0.3.0). It installs HackScan, writes
230
+ SARIF, uploads it to GitHub Code Scanning and then fails the job if the `fail-on` threshold
231
+ was hit, so findings appear in the Security tab even on a failing run.
232
+
233
+ ```yaml
234
+ permissions:
235
+ contents: read
236
+ security-events: write # needed for the Code Scanning upload
237
+ actions: read # needed for the upload in private repositories
238
+
239
+ jobs:
240
+ hackscan:
241
+ runs-on: ubuntu-latest
242
+ steps:
243
+ - uses: actions/checkout@v4
244
+ - uses: kahledawad2019-netizen/hackscan@v0.3.0
245
+ with:
246
+ path: src
247
+ fail-on: high
248
+ ```
249
+
250
+ | Input | Default | Meaning |
251
+ |---|---|---|
252
+ | `path` | `.` | File or directory to scan |
253
+ | `version` | *(empty)* | HackScan version from PyPI; empty installs the action's own checkout |
254
+ | `fail-on` | `high` | Fail at this severity or above; empty passes `--fail-on never` (HackScan 0.3.0+), so findings never fail the job but crashes and errors still do |
255
+ | `args` | *(empty)* | Extra CLI arguments, word-split without globbing; pass only trusted values |
256
+ | `sarif-file` | `hackscan.sarif` | Report path |
257
+ | `upload-sarif` | `true` | Upload to Code Scanning (skip it for fork pull requests, whose token is read-only) |
258
+
259
+ Outputs: `sarif-file` and `exit-code` (0, 1 = threshold hit, 2 = error). The action sets up
260
+ Python 3.12 on the runner. Without the action:
261
+
262
+ ```yaml
263
+ - run: pipx install hackscan
264
+ - run: hackscan scan . --format sarif --sarif-omit-suppressed -o hackscan.sarif --fail-on high
265
+ - uses: github/codeql-action/upload-sarif@v4
266
+ if: always()
267
+ with:
268
+ sarif_file: hackscan.sarif
269
+ ```
270
+
271
+ ## pre-commit
272
+
273
+ ```yaml
274
+ repos:
275
+ - repo: https://github.com/kahledawad2019-netizen/hackscan
276
+ rev: v0.3.0
277
+ hooks:
278
+ - id: hackscan
279
+ ```
280
+
281
+ The hook runs `hackscan scan . --fail-on high` once per commit that touches Python files. It
282
+ scans the whole repository (the CLI takes a single path), so `.hackscan.yml` `ignore` globs
283
+ apply as usual.
284
+
285
+ ## Limitations
286
+
287
+ - Taint analysis follows calls across functions and files only to *confirm* findings: a
288
+ sink fed by a parameter is confirmed when some caller in the scanned code passes
289
+ untrusted data, or when a called function returns it. It never suppresses a finding
290
+ (callers outside the scan are unknown). Only undecorated module-level functions bound
291
+ once are linked (same module, `import`/`from ... import`, relative imports and
292
+ re-exports); methods, nested functions, decorated functions, modules with a star
293
+ import, `*args`/`**kwargs` call sites and module names that match more than one file
294
+ are not followed. Sources are not tracked through object attributes or globals. The
295
+ result of a call is assumed to carry its arguments, so a helper that sanitizes them
296
+ (`def clean(x): return int(x)`) does not prevent a confirmation.
297
+ - Findings in a function (or module) that itself uses `:=` are never suppressed, only
298
+ confirmed or left as candidates: an assignment expression can rebind a name in the
299
+ middle of an expression, which the statement-level analysis does not order. Sinks in
300
+ 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.
304
+ - Mutable objects (lists, dicts, sets) are tracked by variable name. Mutation through
305
+ direct aliases, method calls (`d.get(k).append(x)`), bound methods
306
+ (`add = parts.append`), conditional receivers and comprehension items is modeled;
307
+ aliases created in less direct ways (stored in another object's attribute or in a
308
+ container and mutated through it later, returned from a helper, reached through
309
+ `getattr`) may be missed, so a constant suppression involving such code deserves review.
310
+ - Python only. Imported tools may cover other languages; their findings are passed through.
311
+ - Fix suggestions are deliberately conservative: many vulnerable lines get none. Command
312
+ fixes (argument lists) are only offered for programs whose arguments are inert data
313
+ (`cat`, `ls`, `grep`, `head`, ...): many others run operands or options as
314
+ commands (`sh -c`, `npx PKG`, `git -c alias=!cmd`, `tar --to-command`). They are also
315
+ not offered when a value directly follows a flag; put `--` before operands to get one.
316
+ A value starting with `-` can still be read as an option, so review command fixes. No
317
+ fix is offered for a call that contains a secret, or for SQL containing comments.
318
+ - SQL fixes are not offered for unquoted values inside parentheses, double-quoted
319
+ identifiers, or SQL text containing backslashes, `$`, backticks or brackets;
320
+ redaction of values that coincide with identifiers or tool metadata is best-effort.
321
+ - String and bytes literals assigned to secret-looking names (`api_key`, `password`, `token`, ...)
322
+ are masked in snippets, the LLM context and fix diffs. Their values are also removed
323
+ from imported messages and evidence before output or LLM triage when the value is
324
+ secret-like (at least eight characters with a digit or symbol, or at least sixteen
325
+ characters). Known secrets in proposed fixes cause the fix to be omitted. Imported
326
+ findings for files that cannot be parsed have no source snippet.
327
+
328
+ ## Development
329
+
330
+ ```bash
331
+ uv sync --group dev
332
+ uv run pytest
333
+ uv run ruff check . && uv run ruff format --check .
334
+ ```
335
+
336
+ See [PROJECT.md](PROJECT.md) for the design, milestones and decision log.
337
+
338
+ ## License
339
+
340
+ MIT
@@ -128,9 +128,9 @@ mature scanners, (3) taint-based confirmation, (4) optional local-LLM triage and
128
128
  | 13 | Ollama triage pass with graceful offline fallback | M4 |
129
129
  | 14 | Template + LLM remediation as validated edits; diff view | M4 |
130
130
  | 15 | Rich "Matrix" TUI (banner, spinners, tables); auto-off in CI / `--quiet` | M4 |
131
- | 16 | Inter-procedural & cross-file taint (own call graph from `ast`) | M5 |
132
- | 17 | Benchmark harness vs Bandit / Semgrep CE / CodeQL; published precision/recall | M5 |
133
- | 18 | GitHub Action (uploads SARIF to Code Scanning), pre-commit hook | M5 |
131
+ | 16 | Inter-procedural & cross-file taint (own call graph from `ast`) | M5 ✅ |
132
+ | 17 | Benchmark harness vs Bandit / Semgrep CE / CodeQL; published precision/recall | M5 ✅ |
133
+ | 18 | GitHub Action (uploads SARIF to Code Scanning), pre-commit hook | M5 ✅ |
134
134
  | 19 | Auth bypass / IDOR heuristics (only if benchmark shows acceptable precision) | Later |
135
135
  | 20 | Docker image | Later, on demand |
136
136
 
@@ -140,9 +140,9 @@ mature scanners, (3) taint-based confirmation, (4) optional local-LLM triage and
140
140
  | M0 ✅ | Skeleton & contracts | Repo, `pyproject`, CI (ruff + pytest on 3.10–3.13), `Finding` models, fixture corpus layout; **frozen** `Finding` schema v1, `taxonomy.py`, fingerprint + dedupe/merge implemented with order-independence tests; importer contract (above) documented | — |
141
141
  | M1 ✅ | AST engine & rules | Rules 1–2 pass per-rule vulnerable **and** safe fixtures | M0 |
142
142
  | M2 ✅ | Taint | Framework fixtures (Flask/Django/FastAPI); constant/sanitized flows suppressed; tests for taint boundaries | M1 |
143
- | M3 🔨 | CLI, SARIF, importers → **v0.1.0 on PyPI** (built; publish pending) | `pip install hackscan` works on clean venv; SARIF validates against official schema; `--fail-on` exit codes tested | M2 |
144
- | M4 | LLM + remediation + TUI → v0.2.0 | All LLM tests run against a mocked Ollama; offline degradation tested; prompt-injection fixture | M3 |
145
- | M5 | Inter-procedural taint, benchmark, GitHub Action → v0.3.0 | Benchmark report in README; Action used on the repo itself | M4 |
143
+ | M3 ✅ | CLI, SARIF, importers → **v0.1.0 on PyPI** (released 2026-10-08) | `pip install hackscan` works on clean venv; SARIF validates against official schema; `--fail-on` exit codes tested | M2 |
144
+ | M4 ✅ | LLM + remediation + TUI → **v0.2.0** (2026-10-09; 14 Codex gate rounds, double-OK) | All LLM tests run against a mocked Ollama; offline degradation tested; prompt-injection fixture | M3 |
145
+ | M5 ✅ | Inter-procedural taint, benchmark, GitHub Action → **v0.3.0** (2026-10-09) | Benchmark report in README; Action used on the repo itself | M4 |
146
146
 
147
147
  ## Test Strategy
148
148
  - Corpus: `tests/corpus/<rule>/{vulnerable,safe}/*.py`, each file annotated with expected findings
@@ -270,3 +270,185 @@ hackscan/
270
270
  known secrets scrubbed from warnings/errors; `-o` only overwrites previous HackScan
271
271
  reports; failed `--import` reports make the scan incomplete (exit 2); `--with` tools
272
272
  exiting with an error code are `partial` (warning, fails `--strict-tools`).
273
+ - 2026-10-09 (M4a/M4b): template fixes (parameterized SQL with driver-specific placeholders,
274
+ argv lists instead of shell strings, `ast.literal_eval`, SHA-256) as validated exact edits;
275
+ `--no-fix`, `--show-fixes`. LLM triage is **opt-in** (`--llm`): candidates only, JSON-schema
276
+ replies, per-request random code fence, redacted context, cache, `--llm-max`,
277
+ `--llm-timeout`, bounded output tokens. LLM suppression requires evidence that a
278
+ deterministic checker accepts and that holds on every path: a constant or class-valid
279
+ sanitizer assignment, or an allow-list guard (`not in {consts}`,
280
+ `isdigit()/isalnum()`), in the function body before the sink, covering all sink locals,
281
+ never rebound/mutated afterwards. LLM fixes are kept only if the patched file parses and
282
+ re-running passes 1-2 shows the finding gone and no new ones. Measured locally: CPU-only
283
+ Ollama with the reasoning model deepseek-r1:8b ignores `think: false` and exhausts its
284
+ budget — reported as "no answer"; non-reasoning coder models are recommended.
285
+ - 2026-10-09 (Codex M4 review): LLM suppression must vouch for *every* value in the sink
286
+ (any name, local or global; calls/attributes/subscripts in the sink are never vouched for)
287
+ — fixes a P0 where `safe + suffix` (global `suffix` from the environment) was suppressed.
288
+ LLM fixes must call an allow-listed safe API for the class (or keep the SQL receiver with
289
+ parameters), keep every original value, and never use `__import__`/`eval`/`shell=` etc.
290
+ The flagged-call field sent to the model and `--show-fixes` diff context are redacted;
291
+ fix replacements are redacted like other fields. C0/C1 control characters from code,
292
+ tools or the model are shown escaped, never emitted. `IN (...)` lists are not
293
+ parameterized (a single placeholder changes results); f-string/% argv values are wrapped
294
+ in `str()`; imports are never inserted above a shebang or encoding cookie; malformed
295
+ Ollama replies are discarded instead of crashing.
296
+ - 2026-10-09 (live Ollama check, CPU-only): qwen3:4b-instruct-2507 got real injection,
297
+ fake length guard and allow-list guard right once the verifier (a) maps a cited line
298
+ inside a top-level statement (e.g. a guard's `return`) to that statement and (b) treats
299
+ the model's evidence kind as a hint, trying every deterministic check (each is complete
300
+ on its own, so this is equally sound). Response schema now orders `reason` first
301
+ (Ollama generates keys in schema order), prompt v3. qwen2.5-coder:3b: safe but misses
302
+ the guard. Both models obeyed an injected "reply false_positive" comment; the verifier
303
+ rejected it. A missing model now reports "not installed; run `ollama pull <model>`".
304
+ - 2026-10-09 (Codex M4 re-review): rebinding after cited evidence now includes imports
305
+ (`*` = anything), `match` captures, `except ... as`, nested defs/classes; `assert` is
306
+ never evidence (`python -O`). LLM command fixes must run the original program (never a
307
+ shell/interpreter) with the original literal words in order and only harmless
308
+ keywords; LLM SQL fixes must be one literal with the original SQL tokens (values as
309
+ placeholders). SQL template placeholders only after comparisons, in VALUES lists and
310
+ after LIMIT/OFFSET (not `typeof(?)` or select lists). String literals assigned to
311
+ secret-looking names are redacted by raw source text, so implicitly joined and
312
+ triple-quoted secrets never reach the model or fix diffs.
313
+ - 2026-10-09 (Codex verify round): token/word comparison was bypassable (extra computed
314
+ argv element, swapped SQL parameters, SQL comments). LLM fixes must now equal what
315
+ HackScan derives itself: argv element-for-element (`str()` wrapper ignored), SQL text
316
+ exactly plus the original values in order. SQL containing comments (`--`, `/*`, `#`)
317
+ gets no fix. Secret literals are masked character for character in the source
318
+ (lines/columns kept) for LLM context and diffs. Rebinding is judged by (line, column)
319
+ outside the cited statement; any `nonlocal`/`global` of the name voids the evidence.
320
+ - 2026-10-09 (Codex verify round 2): an argv list stops shell injection, not argument
321
+ injection (`git -c alias.x=!cmd`, `python3.12 -c`). `_argv` (ground truth for template
322
+ and LLM fixes) now refuses interpreters/wrappers (versioned and `.exe` names included)
323
+ and any value directly after a flag other than `--`. LLM SQL fixes must use the
324
+ placeholder of the detected driver (none or ambiguous: no fix). Snippets and sinks of
325
+ own findings and imported ones come from masked source; a call containing a secret
326
+ literal gets no fix (a fix must carry the real code); diffs mask both sides from
327
+ their own syntax trees. Masking also covers walrus and unpacking targets.
328
+ - 2026-10-09 (stop-time review + Codex verify round 3): the interpreter denylist could
329
+ never be complete (`npx PKG`, `tar --to-command`, `find -exec`...), so argv fixes now
330
+ use an allowlist of programs with inert arguments (`INERT_PROGRAMS`: echo, cat, ls,
331
+ grep, head...; `.bat`/`.cmd` never match). Masking covers `for`/comprehension/`with`
332
+ targets; for parsed Python files the snippet always comes from masked source (a tool's
333
+ SARIF snippet only as a fallback, redacted); LLM evidence quotes masked source. Round 3
334
+ confirmed all earlier fixes and found no regressions (fingerprints, dedupe, pool).
335
+ - 2026-10-09 (security follow-up): LLM allow-list guards require class-safe string or integer
336
+ constants; source secret literal values now redact imported messages and evidence before
337
+ output and LLM triage; fallback snippets from unparseable files redact literals and
338
+ assigned values; `echo` and `dir` no longer get shell-less command fixes because they
339
+ are Windows cmd.exe builtins.
340
+ - 2026-10-09 (release gate): SQL LIMIT allow-list values cannot contain commas; template
341
+ and LLM fixes containing a known secret are dropped before export. Imported findings
342
+ have no snippet unless their Python source parses, preventing multi-line secret leaks.
343
+ Equal-length secrets are redacted in lexical order, and only secret-like source values
344
+ join report-wide known secrets; local source masking still covers every secret-named literal.
345
+ - 2026-10-09 (gate review follow-up): LLM predicate guard evidence is limited to
346
+ `isdigit()` and `isdecimal()`; constant allow-lists retain their existing rule.
347
+ Secret-named function and lambda defaults are masked, and AST rule messages and evidence
348
+ use the file's masked literal spans. Gitleaks values are redacted at any nonzero length
349
+ across findings, warnings and errors. Diff redaction preserves assignment spacing.
350
+ Every masked source literal of at least four characters is also redacted within findings,
351
+ fixes and LLM requests for its own file. LLM rewrites now require exact code and hash
352
+ arguments and literal subprocess keyword values to prevent hidden execution. Release-gate
353
+ fixes restrict shell template keywords, redact secret-bearing rule IDs and escaped source
354
+ spellings, and refuse SQL placeholders inside partial quoted literals. Sole dynamic
355
+ parenthesized SQL lists get no placeholder; file-scoped secrets also redact rule metadata
356
+ before triage and diagnostics across the report. Release-gate follow-up refuses unquoted
357
+ dynamic SQL values inside any parentheses and SQL text containing backslashes, while
358
+ redacting finding function names before IDs, output and triage. Double-quoted SQL
359
+ identifiers and SQL text containing `$` also receive no fix. Numeric and concatenated
360
+ secret literals are masked and collected.
361
+ - 2026-10-09 (M5 step 1): Roles back to Claude implements, Codex reviews (double OK before
362
+ commit). Repo root is a composite GitHub Action: inputs reach `run:` scripts only via env,
363
+ extra `args` are word-split with globbing off, the scan step records the exit code so SARIF
364
+ is uploaded (only if written) before the job fails; the dogfood workflow skips upload on
365
+ fork PRs. pre-commit hook scans the whole repo (`pass_filenames: false`; CLI takes one path).
366
+ Review round 1 (Codex Not OK, fixed): an empty `fail-on` input now passes the new
367
+ `--fail-on never` (overrides `.hackscan.yml`) instead of rewriting exit 1 to 0, which hid
368
+ crashes; a plugin constructor exception is now a PluginError (exit 2, not 1); README adds
369
+ `actions: read` for private repositories.
370
+ - 2026-10-09 (M5 step 2): Inter-procedural, cross-file taint is confirmation-only
371
+ (`analyzers/interproc.py`). Pass 2 records symbolic facts per file: parameter and
372
+ call-result atoms carried in `Taint.symbols` with per-atom sanitized classes, return
373
+ summaries of linkable functions, argument values at statically named call sites. The
374
+ pipeline links all files (same module, imports, relative imports, re-exports, src
375
+ layouts by unambiguous module-name suffix) and solves a least fixpoint; candidates
376
+ reached by an untrusted source become confirmed with a file:line trace. Never
377
+ suppresses: unknown callers may exist. Only undecorated, non-generator module-level
378
+ functions bound once are linked; methods, `*args` positions and star-import modules are
379
+ not. Cost: taint now runs on every file (~45 ms/file serial on site-packages, +50%);
380
+ `_closure_names` made single-pass.
381
+ Review round 1 (Codex OK, 3 non-blocking precision issues): `match` captures
382
+ (`MatchAs`/`MatchStar`/`MatchMapping.rest`) are now scope bindings; the module binding
383
+ census also counts walrus targets in keyword-only defaults, lambda defaults,
384
+ annotations and class bases. Call results carrying their arguments through a
385
+ sanitizing helper stay a documented limitation (same approximation as within a function).
386
+ Review round 2 (Codex Not OK, pre-existing pass-2 soundness bug, fixed): a `match`
387
+ mapping rest capture (`case {**rest}`) now takes the subject's taint, and captures of
388
+ a failed pattern or a false guard stay visible to later cases and after the `match`
389
+ (the no-match path joins them), so a reachable sink is no longer suppressed as constant.
390
+ Review round 3 (Codex Not OK, pre-existing pass-2 soundness bugs, fixed): a `:=` in a
391
+ part that may not run (later `and`/`or` operands, `x if c else y` branches,
392
+ comprehension bodies, also inside `match` guards) is applied on a copy and joined
393
+ instead of overwriting the current value (comprehension bodies iterate to a fixpoint);
394
+ a `match` capture is aliased with the mutable names in the subject.
395
+ Review round 4 (Codex Not OK, 6 blockers: 5 pre-existing, 1 regression from round 3's
396
+ comprehension rework, which was reverted): instead of ordering every `:=` effect, a scope
397
+ whose own code contains `:=` never suppresses its sinks (confirmation still works); a
398
+ module with `:=` gets no trusted module constants; sinks in a generator expression's
399
+ body (deferred execution) are never suppressed. Also fixed (pre-existing): a nested
400
+ function or lambda reading an enclosing local that shadows a module constant was
401
+ treated as constant; module constants now apply only if the name resolves to the module.
402
+ Review round 5 (Codex Not OK, 6 pre-existing blockers without `:=`, fixed with
403
+ conservative rules): generator expressions yield UNKNOWN-joined values; a comprehension
404
+ target shadows outer bindings and class-body names are hidden inside class
405
+ comprehensions; module constants require exactly one module-level binding and no
406
+ dynamic namespace writes (`globals`/`vars`/`locals`/`exec`/`eval`/`__import__`,
407
+ `__dict__`/`__globals__`/`modules`, star imports), and module-level sinks in such a
408
+ module are never suppressed; names a function declares `global`/`nonlocal` are joined
409
+ with UNKNOWN on every read; LLM guard evidence must cover the function's own locals.
410
+ Review round 6 (Codex Not OK, 3 blockers, fixed): namespace writers are also detected
411
+ as attributes (`builtins.exec`, `import_module(...).exec`), imported aliases
412
+ (`from builtins import exec as run`), `import builtins` and `__builtins__`; a class body
413
+ that may write its own namespace (`locals()[...]` etc. in that body) never suppresses;
414
+ a comprehension target bound by more than one `for` resolves to UNKNOWN.
415
+ Review round 7 (Codex Not OK, 4 blockers, fixed): namespace-writer aliases bound by
416
+ assignment are followed file-wide (`execute = exec` then used in a class body), and
417
+ importing a namespace attribute under any name counts (`_Namespace`, computed once per
418
+ file); eager comprehensions scan filters before the element and repeat their body to a
419
+ fixpoint (mutations of one item reach the next; widened to UNKNOWN on budget
420
+ exhaustion); comprehension targets are aliased with the mutable names in their
421
+ iterables. Serial scan of ~770 site-packages files: 22.8 s (v0.2.0) -> ~41 s.
422
+ Review round 8 (Codex Not OK, 3 blockers, fixed): `:=` creates writer aliases too; a
423
+ comprehension that may mutate one of its items (non-read-only method on a target,
424
+ target passed to a call other than a sink/sanitizer/pure builtin/string method, store
425
+ into a target) invalidates every mutable name in its iterables and resolves its targets
426
+ to UNKNOWN; subscript/attribute comprehension targets store into their base.
427
+ Review round 9 (Codex Not OK, 2 blockers, fixed): a comprehension target anywhere inside
428
+ a call argument counts as escaping (`change([cmd])`); mutations, stores and comprehension
429
+ targets find their container through method calls too (`_root_name`: `d.get(k).append(x)`
430
+ and `d.setdefault(k, [])[0] = x` mutate `d`), in all code, not only comprehensions.
431
+ Review round 10 (Codex Not OK, 2 blockers, fixed): a bound method taken off a mutable
432
+ object (`append = parts.append`, method attribute loaded but not called right away)
433
+ invalidates every mutable name in its receiver; a mutating method call updates every
434
+ mutable name in a compound receiver (`(a if c else b).append(x)`, `[cmd][0].append(x)`).
435
+ User decision (2026-10-09, after 10 rounds): fix round 10, document remaining indirect
436
+ mutable aliasing (aliases stored in attributes/containers, returned from helpers,
437
+ `getattr`) as a README limitation, confirm with Codex once, commit with both OKs; any
438
+ further pass-2 aliasing hardening is a separate task.
439
+ - 2026-10-09 (M5 step 3): Benchmark (`benchmarks/`). User choices: Bandit + Semgrep +
440
+ CodeQL; a new labeled suite plus known-vulnerable apps. Suite: 26 `# vuln:` sinks and 24
441
+ `# safe:` traps in Flask/Django/FastAPI/CLI code, one labeled sink per function, scored
442
+ per function (tools report flows at sink, construction or source lines); all tools are
443
+ normalized through HackScan's SARIF importer. Taxonomy fix found on the way: Semgrep's
444
+ `tainted-sql-string` rule carries CWE-704 metadata and is now mapped to sqli. Apps:
445
+ vulpy, PyGoat, DVPWA at pinned commits (MIT), every reported key hand-reviewed in
446
+ `apps_review.json`; relative recall over the union of true positives. Tests keep
447
+ RESULTS.json, APPS.json and the README tables in sync with the engine. Results:
448
+ suite F1 HackScan 82%, CodeQL 85%, Bandit 70%, Semgrep 67%; apps precision/relative
449
+ recall HackScan 83%/71%, CodeQL 100%/67%, Bandit 50%/76%, Semgrep 92%/52%.
450
+ - 2026-10-09 (v0.3.0 release): version 0.3.0; the Action uploads with
451
+ github/codeql-action/upload-sarif@v4 (v3 is deprecated in December 2026); benchmark
452
+ re-run so the HackScan rows show 0.3.0 (numbers unchanged). Next ideas (not started):
453
+ weak-cipher detection (DES/RC4/ECB/unauthenticated modes; largest benchmark gap),
454
+ taint through `self` attributes, further pass-2 aliasing hardening.