hackscan 0.1.0__tar.gz → 0.2.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 (90) hide show
  1. {hackscan-0.1.0 → hackscan-0.2.0}/.gitignore +4 -0
  2. {hackscan-0.1.0 → hackscan-0.2.0}/PKG-INFO +54 -2
  3. {hackscan-0.1.0 → hackscan-0.2.0}/PROJECT.md +90 -2
  4. {hackscan-0.1.0 → hackscan-0.2.0}/README.md +52 -1
  5. {hackscan-0.1.0 → hackscan-0.2.0}/pyproject.toml +7 -0
  6. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/__init__.py +1 -1
  7. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/analyzers/ast_pass.py +10 -4
  8. hackscan-0.2.0/src/hackscan/analyzers/llm_pass.py +841 -0
  9. hackscan-0.2.0/src/hackscan/analyzers/remediate.py +599 -0
  10. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/cli.py +120 -6
  11. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/config.py +30 -2
  12. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/core/pipeline.py +66 -6
  13. hackscan-0.2.0/src/hackscan/core/redact.py +371 -0
  14. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/importers/common.py +11 -9
  15. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/importers/sarif.py +2 -1
  16. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/base.py +56 -0
  17. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/scopes.py +117 -1
  18. hackscan-0.2.0/src/hackscan/ui/__init__.py +1 -0
  19. hackscan-0.2.0/src/hackscan/ui/render.py +156 -0
  20. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_ast_pass.py +36 -0
  21. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_cli.py +30 -0
  22. hackscan-0.2.0/tests/test_importers.py +877 -0
  23. hackscan-0.2.0/tests/test_llm.py +1083 -0
  24. hackscan-0.2.0/tests/test_pipeline.py +372 -0
  25. hackscan-0.2.0/tests/test_redact.py +185 -0
  26. hackscan-0.2.0/tests/test_remediate.py +729 -0
  27. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_scopes.py +75 -0
  28. hackscan-0.2.0/tests/test_ui.py +145 -0
  29. {hackscan-0.1.0 → hackscan-0.2.0}/uv.lock +36 -0
  30. hackscan-0.1.0/src/hackscan/core/redact.py +0 -84
  31. hackscan-0.1.0/tests/test_importers.py +0 -379
  32. hackscan-0.1.0/tests/test_pipeline.py +0 -56
  33. {hackscan-0.1.0 → hackscan-0.2.0}/.gitattributes +0 -0
  34. {hackscan-0.1.0 → hackscan-0.2.0}/.github/workflows/ci.yml +0 -0
  35. {hackscan-0.1.0 → hackscan-0.2.0}/.github/workflows/release.yml +0 -0
  36. {hackscan-0.1.0 → hackscan-0.2.0}/LICENSE +0 -0
  37. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/analyzers/__init__.py +0 -0
  38. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/analyzers/suppressions.py +0 -0
  39. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/analyzers/taint_pass.py +0 -0
  40. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/core/__init__.py +0 -0
  41. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/core/dedupe.py +0 -0
  42. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/core/fingerprint.py +0 -0
  43. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/core/models.py +0 -0
  44. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/core/taxonomy.py +0 -0
  45. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/importers/__init__.py +0 -0
  46. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/importers/gitleaks.py +0 -0
  47. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/importers/runner.py +0 -0
  48. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/__init__.py +0 -0
  49. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/loader.py +0 -0
  50. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/rules/__init__.py +0 -0
  51. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/rules/cmdi.py +0 -0
  52. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/rules/codei.py +0 -0
  53. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/rules/sqli.py +0 -0
  54. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/plugins/rules/weak_crypto.py +0 -0
  55. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/sarif/__init__.py +0 -0
  56. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/sarif/generator.py +0 -0
  57. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/taint/__init__.py +0 -0
  58. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/taint/sanitizers.py +0 -0
  59. {hackscan-0.1.0 → hackscan-0.2.0}/src/hackscan/taint/sources.py +0 -0
  60. {hackscan-0.1.0 → hackscan-0.2.0}/tests/conftest.py +0 -0
  61. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/README.md +0 -0
  62. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/cmdi/safe/shell.py +0 -0
  63. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/cmdi/vulnerable/shell.py +0 -0
  64. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/cmdi/vulnerable/star_import.py +0 -0
  65. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/codei/safe/dynamic.py +0 -0
  66. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/codei/vulnerable/dynamic.py +0 -0
  67. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/frameworks/django/views.py +0 -0
  68. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/frameworks/fastapi/main.py +0 -0
  69. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/frameworks/flask/app.py +0 -0
  70. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/sqli/safe/dbapi.py +0 -0
  71. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/sqli/vulnerable/dbapi.py +0 -0
  72. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/weak_crypto/safe/hashes.py +0 -0
  73. {hackscan-0.1.0 → hackscan-0.2.0}/tests/corpus/weak_crypto/vulnerable/hashes.py +0 -0
  74. {hackscan-0.1.0 → hackscan-0.2.0}/tests/fixtures/imports/.gitkeep +0 -0
  75. {hackscan-0.1.0 → hackscan-0.2.0}/tests/fixtures/imports/README.md +0 -0
  76. {hackscan-0.1.0 → hackscan-0.2.0}/tests/fixtures/imports/bandit.sarif +0 -0
  77. {hackscan-0.1.0 → hackscan-0.2.0}/tests/fixtures/imports/codeql.sarif +0 -0
  78. {hackscan-0.1.0 → hackscan-0.2.0}/tests/fixtures/imports/gitleaks.json +0 -0
  79. {hackscan-0.1.0 → hackscan-0.2.0}/tests/fixtures/imports/project/app.py +0 -0
  80. {hackscan-0.1.0 → hackscan-0.2.0}/tests/fixtures/imports/semgrep.sarif +0 -0
  81. {hackscan-0.1.0 → hackscan-0.2.0}/tests/fixtures/sarif-schema-2.1.0.json +0 -0
  82. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_config.py +0 -0
  83. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_dedupe.py +0 -0
  84. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_fingerprint.py +0 -0
  85. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_models.py +0 -0
  86. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_rules.py +0 -0
  87. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_sarif.py +0 -0
  88. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_taint.py +0 -0
  89. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_taint_fuzz.py +0 -0
  90. {hackscan-0.1.0 → hackscan-0.2.0}/tests/test_taxonomy.py +0 -0
@@ -11,3 +11,7 @@ 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/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hackscan
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -21,6 +21,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
21
21
  Requires-Python: >=3.10
22
22
  Requires-Dist: click>=8.1
23
23
  Requires-Dist: pyyaml>=6.0.3
24
+ Requires-Dist: rich>=13.7
24
25
  Description-Content-Type: text/markdown
25
26
 
26
27
  # HackScan
@@ -47,6 +48,8 @@ HIGH confirmed views.py:10:9 HS-SQLI-001 [100%]
47
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. |
48
49
  | **Framework-aware** | Sources: Flask `request.*`, Django/DRF/Starlette/FastAPI request objects (incl. `self.request`), route-handler parameters, `input()`, `sys.argv`. |
49
50
  | **One report from many tools** | Imports Semgrep, Bandit, CodeQL (SARIF) and Gitleaks; deduplicates by vulnerability class and location, keeping every tool as provenance. |
51
+ | **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. |
52
+ | **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. |
50
53
  | **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. |
51
54
 
52
55
  ## Install
@@ -55,7 +58,7 @@ HIGH confirmed views.py:10:9 HS-SQLI-001 [100%]
55
58
  pip install hackscan # or: uv tool install hackscan / pipx install hackscan
56
59
  ```
57
60
 
58
- Python 3.10–3.13, any OS. No network access and no LLM needed.
61
+ Python 3.10–3.13, any OS. No network access and no LLM needed (`--llm` is opt-in).
59
62
 
60
63
  ## Usage
61
64
 
@@ -66,6 +69,8 @@ hackscan scan . --fail-on high # exit 1 on open high/critical
66
69
  hackscan scan . --format json --show-suppressed # everything, machine-readable
67
70
  hackscan scan . --with bandit,semgrep,gitleaks # also run these tools if installed
68
71
  hackscan scan . --import codeql=results.sarif # merge a report you already have
72
+ hackscan scan . --show-fixes # include fix suggestions as diffs
73
+ hackscan scan . --llm --model qwen2.5-coder:7b # triage candidates with local Ollama
69
74
  hackscan rules # list rules
70
75
  ```
71
76
 
@@ -79,6 +84,10 @@ hackscan rules # list rules
79
84
  | `--plugins DIR` | Load custom rules (see below). |
80
85
  | `--allow-incomplete` | Do not exit 2 when some files cannot be analyzed (they are still listed). |
81
86
  | `--no-taint` | Pattern matching only. |
87
+ | `--show-fixes` / `--no-fix` | Print suggested fixes as diffs / do not generate fixes (fixes are also in JSON and SARIF `fixes`). |
88
+ | `--llm`, `--model`, `--ollama-host` | Opt-in LLM triage of candidates via Ollama (default `http://localhost:11434`). |
89
+ | `--llm-max`, `--llm-timeout`, `--llm-no-suppress` | LLM budget (default 20 findings, 60 s/request); let the LLM confirm/annotate but never suppress. |
90
+ | `--color auto\|always\|never` | Rich terminal output; `auto` uses it only on an interactive terminal (not in CI, pipes or with `NO_COLOR`). |
82
91
  | `--jobs N` | Worker processes (default: automatic). |
83
92
 
84
93
  Exit codes: `0` OK, `1` `--fail-on` threshold reached, `2` usage/config/plugin error or
@@ -101,8 +110,35 @@ with: [bandit]
101
110
  import:
102
111
  codeql: codeql-results.sarif
103
112
  plugins: security/rules
113
+ llm: false
114
+ llm-model: qwen2.5-coder:7b
104
115
  ```
105
116
 
117
+ ### LLM triage
118
+
119
+ `--llm` sends each remaining *candidate* (never confirmed or suppressed findings) with
120
+ its surrounding function to a local Ollama model and asks for a verdict:
121
+
122
+ - **true positive**: the finding becomes *confirmed* (+15 confidence); a suggested fix is
123
+ kept only if the patched file parses and a re-scan shows the finding gone and nothing new.
124
+ - **false positive**: only accepted if the model cites a line that HackScan verifies runs
125
+ on every path and makes the value safe: an allow-list guard (`if x not in {...}: return`,
126
+ `if not x.isdigit(): raise`, or `if not x.isdecimal(): raise`), a constant, or a
127
+ class-appropriate sanitizer. `isnumeric()`, `isalnum()`, `isalpha()`, and
128
+ `isidentifier()` do not qualify. Otherwise the reasoning is attached as a note and
129
+ the finding stays open.
130
+ - Code is fenced with a random per-request token and treated as untrusted data; replies
131
+ must match a JSON schema; secret-looking values are redacted; answers are cached.
132
+
133
+ Use a non-reasoning instruct or coder model. Measured on a CPU-only laptop (~30 s per
134
+ finding): `qwen3:4b-instruct-2507` judged a real injection, a bypassable length check and
135
+ an allow-list guard correctly; `qwen2.5-coder:3b` never suppressed but also missed the
136
+ allow-list guard (larger models do better). Reasoning models such as `deepseek-r1` often
137
+ spend their whole output budget thinking, which HackScan reports as "no answer" and
138
+ leaves the finding open. In the same test both small models obeyed a comment in the
139
+ scanned code telling them to answer "false positive"; the deterministic evidence check
140
+ rejected it and the finding stayed open. That check, not the model, decides suppression.
141
+
106
142
  ### Suppressing a finding
107
143
 
108
144
  ```python
@@ -165,6 +201,22 @@ class PickleLoads(RulePlugin):
165
201
  - Taint analysis is intra-procedural: values arriving through function parameters are
166
202
  candidates, not confirmations (inter-procedural analysis is on the roadmap).
167
203
  - Python only. Imported tools may cover other languages; their findings are passed through.
204
+ - Fix suggestions are deliberately conservative: many vulnerable lines get none. Command
205
+ fixes (argument lists) are only offered for programs whose arguments are inert data
206
+ (`cat`, `ls`, `grep`, `head`, ...): many others run operands or options as
207
+ commands (`sh -c`, `npx PKG`, `git -c alias=!cmd`, `tar --to-command`). They are also
208
+ not offered when a value directly follows a flag; put `--` before operands to get one.
209
+ A value starting with `-` can still be read as an option, so review command fixes. No
210
+ fix is offered for a call that contains a secret, or for SQL containing comments.
211
+ - SQL fixes are not offered for unquoted values inside parentheses, double-quoted
212
+ identifiers, or SQL text containing backslashes, `$`, backticks or brackets;
213
+ redaction of values that coincide with identifiers or tool metadata is best-effort.
214
+ - String and bytes literals assigned to secret-looking names (`api_key`, `password`, `token`, ...)
215
+ are masked in snippets, the LLM context and fix diffs. Their values are also removed
216
+ from imported messages and evidence before output or LLM triage when the value is
217
+ secret-like (at least eight characters with a digit or symbol, or at least sixteen
218
+ characters). Known secrets in proposed fixes cause the fix to be omitted. Imported
219
+ findings for files that cannot be parsed have no source snippet.
168
220
 
169
221
  ## Development
170
222
 
@@ -140,8 +140,8 @@ 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 |
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
145
  | M5 | Inter-procedural taint, benchmark, GitHub Action → v0.3.0 | Benchmark report in README; Action used on the repo itself | M4 |
146
146
 
147
147
  ## Test Strategy
@@ -270,3 +270,91 @@ 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.
@@ -22,6 +22,8 @@ HIGH confirmed views.py:10:9 HS-SQLI-001 [100%]
22
22
  | **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. |
23
23
  | **Framework-aware** | Sources: Flask `request.*`, Django/DRF/Starlette/FastAPI request objects (incl. `self.request`), route-handler parameters, `input()`, `sys.argv`. |
24
24
  | **One report from many tools** | Imports Semgrep, Bandit, CodeQL (SARIF) and Gitleaks; deduplicates by vulnerability class and location, keeping every tool as provenance. |
25
+ | **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. |
26
+ | **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. |
25
27
  | **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. |
26
28
 
27
29
  ## Install
@@ -30,7 +32,7 @@ HIGH confirmed views.py:10:9 HS-SQLI-001 [100%]
30
32
  pip install hackscan # or: uv tool install hackscan / pipx install hackscan
31
33
  ```
32
34
 
33
- Python 3.10–3.13, any OS. No network access and no LLM needed.
35
+ Python 3.10–3.13, any OS. No network access and no LLM needed (`--llm` is opt-in).
34
36
 
35
37
  ## Usage
36
38
 
@@ -41,6 +43,8 @@ hackscan scan . --fail-on high # exit 1 on open high/critical
41
43
  hackscan scan . --format json --show-suppressed # everything, machine-readable
42
44
  hackscan scan . --with bandit,semgrep,gitleaks # also run these tools if installed
43
45
  hackscan scan . --import codeql=results.sarif # merge a report you already have
46
+ hackscan scan . --show-fixes # include fix suggestions as diffs
47
+ hackscan scan . --llm --model qwen2.5-coder:7b # triage candidates with local Ollama
44
48
  hackscan rules # list rules
45
49
  ```
46
50
 
@@ -54,6 +58,10 @@ hackscan rules # list rules
54
58
  | `--plugins DIR` | Load custom rules (see below). |
55
59
  | `--allow-incomplete` | Do not exit 2 when some files cannot be analyzed (they are still listed). |
56
60
  | `--no-taint` | Pattern matching only. |
61
+ | `--show-fixes` / `--no-fix` | Print suggested fixes as diffs / do not generate fixes (fixes are also in JSON and SARIF `fixes`). |
62
+ | `--llm`, `--model`, `--ollama-host` | Opt-in LLM triage of candidates via Ollama (default `http://localhost:11434`). |
63
+ | `--llm-max`, `--llm-timeout`, `--llm-no-suppress` | LLM budget (default 20 findings, 60 s/request); let the LLM confirm/annotate but never suppress. |
64
+ | `--color auto\|always\|never` | Rich terminal output; `auto` uses it only on an interactive terminal (not in CI, pipes or with `NO_COLOR`). |
57
65
  | `--jobs N` | Worker processes (default: automatic). |
58
66
 
59
67
  Exit codes: `0` OK, `1` `--fail-on` threshold reached, `2` usage/config/plugin error or
@@ -76,8 +84,35 @@ with: [bandit]
76
84
  import:
77
85
  codeql: codeql-results.sarif
78
86
  plugins: security/rules
87
+ llm: false
88
+ llm-model: qwen2.5-coder:7b
79
89
  ```
80
90
 
91
+ ### LLM triage
92
+
93
+ `--llm` sends each remaining *candidate* (never confirmed or suppressed findings) with
94
+ its surrounding function to a local Ollama model and asks for a verdict:
95
+
96
+ - **true positive**: the finding becomes *confirmed* (+15 confidence); a suggested fix is
97
+ kept only if the patched file parses and a re-scan shows the finding gone and nothing new.
98
+ - **false positive**: only accepted if the model cites a line that HackScan verifies runs
99
+ on every path and makes the value safe: an allow-list guard (`if x not in {...}: return`,
100
+ `if not x.isdigit(): raise`, or `if not x.isdecimal(): raise`), a constant, or a
101
+ class-appropriate sanitizer. `isnumeric()`, `isalnum()`, `isalpha()`, and
102
+ `isidentifier()` do not qualify. Otherwise the reasoning is attached as a note and
103
+ the finding stays open.
104
+ - Code is fenced with a random per-request token and treated as untrusted data; replies
105
+ must match a JSON schema; secret-looking values are redacted; answers are cached.
106
+
107
+ Use a non-reasoning instruct or coder model. Measured on a CPU-only laptop (~30 s per
108
+ finding): `qwen3:4b-instruct-2507` judged a real injection, a bypassable length check and
109
+ an allow-list guard correctly; `qwen2.5-coder:3b` never suppressed but also missed the
110
+ allow-list guard (larger models do better). Reasoning models such as `deepseek-r1` often
111
+ spend their whole output budget thinking, which HackScan reports as "no answer" and
112
+ leaves the finding open. In the same test both small models obeyed a comment in the
113
+ scanned code telling them to answer "false positive"; the deterministic evidence check
114
+ rejected it and the finding stayed open. That check, not the model, decides suppression.
115
+
81
116
  ### Suppressing a finding
82
117
 
83
118
  ```python
@@ -140,6 +175,22 @@ class PickleLoads(RulePlugin):
140
175
  - Taint analysis is intra-procedural: values arriving through function parameters are
141
176
  candidates, not confirmations (inter-procedural analysis is on the roadmap).
142
177
  - Python only. Imported tools may cover other languages; their findings are passed through.
178
+ - Fix suggestions are deliberately conservative: many vulnerable lines get none. Command
179
+ fixes (argument lists) are only offered for programs whose arguments are inert data
180
+ (`cat`, `ls`, `grep`, `head`, ...): many others run operands or options as
181
+ commands (`sh -c`, `npx PKG`, `git -c alias=!cmd`, `tar --to-command`). They are also
182
+ not offered when a value directly follows a flag; put `--` before operands to get one.
183
+ A value starting with `-` can still be read as an option, so review command fixes. No
184
+ fix is offered for a call that contains a secret, or for SQL containing comments.
185
+ - SQL fixes are not offered for unquoted values inside parentheses, double-quoted
186
+ identifiers, or SQL text containing backslashes, `$`, backticks or brackets;
187
+ redaction of values that coincide with identifiers or tool metadata is best-effort.
188
+ - String and bytes literals assigned to secret-looking names (`api_key`, `password`, `token`, ...)
189
+ are masked in snippets, the LLM context and fix diffs. Their values are also removed
190
+ from imported messages and evidence before output or LLM triage when the value is
191
+ secret-like (at least eight characters with a digit or symbol, or at least sixteen
192
+ characters). Known secrets in proposed fixes cause the fix to be omitted. Imported
193
+ findings for files that cannot be parsed have no source snippet.
143
194
 
144
195
  ## Development
145
196
 
@@ -14,6 +14,7 @@ requires-python = ">=3.10"
14
14
  dependencies = [
15
15
  "click>=8.1",
16
16
  "pyyaml>=6.0.3",
17
+ "rich>=13.7",
17
18
  ]
18
19
  classifiers = [
19
20
  "Development Status :: 3 - Alpha",
@@ -60,3 +61,9 @@ path = "src/hackscan/__init__.py"
60
61
  [project.urls]
61
62
  Source = "https://github.com/kahledawad2019-netizen/hackscan"
62
63
  Issues = "https://github.com/kahledawad2019-netizen/hackscan/issues"
64
+
65
+ [tool.ruff.lint.per-file-ignores]
66
+ # Inline code samples under test are clearer unwrapped.
67
+ "tests/test_remediate.py" = ["E501"]
68
+ "tests/test_llm.py" = ["E501"]
69
+ "tests/test_ui.py" = ["E501"]
@@ -1,3 +1,3 @@
1
1
  """HackScan: Python SAST orchestrator and verifier."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
  from hackscan.analyzers.suppressions import apply_inline_suppressions
14
14
  from hackscan.analyzers.taint_pass import apply_taint
15
15
  from hackscan.core.models import OWN_SOURCE, Evidence, Finding, Region
16
+ from hackscan.core.redact import file_secret_literal_values, secret_literal_values
16
17
  from hackscan.core.taxonomy import classify
17
18
  from hackscan.plugins.base import FileContext, Match, RulePlugin
18
19
 
@@ -24,6 +25,8 @@ class FileResult:
24
25
  path: str
25
26
  findings: list[Finding] = field(default_factory=list)
26
27
  errors: list[str] = field(default_factory=list)
28
+ secrets: set[str] = field(default_factory=set)
29
+ file_secrets: set[str] = field(default_factory=set)
27
30
 
28
31
 
29
32
  def analyze_source(
@@ -40,6 +43,8 @@ def analyze_source(
40
43
  result.errors.append(f"{rel_path}: cannot parse: {exc}")
41
44
  return result
42
45
 
46
+ result.secrets = secret_literal_values(tree, source)
47
+ result.file_secrets = file_secret_literal_values(tree, source)
43
48
  ctx = FileContext(path=rel_path, source=source, tree=tree)
44
49
  by_type: dict[type[ast.AST], list[RulePlugin]] = {}
45
50
  for plugin in plugins:
@@ -109,26 +114,27 @@ def _to_finding(
109
114
  ),
110
115
  )
111
116
  func = ctx.enclosing_function(node)
112
- sink = ctx.segment(node)
117
+ sink = ctx.masked_segment(node) # secrets never enter a finding
118
+ message = ctx.redact_masked_spans(match.message)
113
119
  return Finding(
114
120
  id="",
115
121
  vuln_class=classify(plugin.rule_id, plugin.cwe),
116
122
  rule_id=plugin.rule_id,
117
123
  severity=plugin.severity,
118
124
  location=region,
119
- message=match.message,
125
+ message=message,
120
126
  snippet=_line_snippet(ctx, start_line, end_line),
121
127
  sink=sink,
122
128
  function=qualnames.get(id(func)) if func is not None else None,
123
129
  cwe=plugin.cwe,
124
130
  sources=(OWN_SOURCE,),
125
131
  confidence=match.confidence if match.confidence is not None else plugin.default_confidence,
126
- evidence=(Evidence(PRODUCER, "rule_match", f"{plugin.name}: {match.message}"),),
132
+ evidence=(Evidence(PRODUCER, "rule_match", f"{plugin.name}: {message}"),),
127
133
  )
128
134
 
129
135
 
130
136
  def _line_snippet(ctx: FileContext, start: int, end: int, max_lines: int = 5) -> str:
131
- lines = ctx.lines[start - 1 : min(end, start + max_lines - 1)]
137
+ lines = ctx.masked_lines[start - 1 : min(end, start + max_lines - 1)]
132
138
  return "\n".join(lines)
133
139
 
134
140