hackscan 0.2.0__tar.gz → 0.3.1__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.1/.github/workflows/hackscan.yml +22 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/.gitignore +3 -0
- hackscan-0.3.1/.pre-commit-hooks.yaml +9 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/PKG-INFO +112 -5
- {hackscan-0.2.0 → hackscan-0.3.1}/PROJECT.md +105 -4
- {hackscan-0.2.0 → hackscan-0.3.1}/README.md +111 -4
- hackscan-0.3.1/action.yml +94 -0
- hackscan-0.3.1/benchmarks/APPS.json +133 -0
- hackscan-0.3.1/benchmarks/APPS.md +28 -0
- hackscan-0.3.1/benchmarks/README.md +104 -0
- hackscan-0.3.1/benchmarks/RESULTS.json +188 -0
- hackscan-0.3.1/benchmarks/RESULTS.md +21 -0
- hackscan-0.3.1/benchmarks/apps.py +267 -0
- hackscan-0.3.1/benchmarks/apps_review.json +46 -0
- hackscan-0.3.1/benchmarks/run.py +481 -0
- hackscan-0.3.1/benchmarks/suite/cmdi/__init__.py +0 -0
- hackscan-0.3.1/benchmarks/suite/cmdi/backup.py +15 -0
- hackscan-0.3.1/benchmarks/suite/cmdi/ops_tool.py +53 -0
- hackscan-0.3.1/benchmarks/suite/cmdi/scheduler.py +10 -0
- hackscan-0.3.1/benchmarks/suite/cmdi/web_tools.py +52 -0
- hackscan-0.3.1/benchmarks/suite/codei/__init__.py +0 -0
- hackscan-0.3.1/benchmarks/suite/codei/calculator.py +55 -0
- hackscan-0.3.1/benchmarks/suite/codei/plugins.py +27 -0
- hackscan-0.3.1/benchmarks/suite/sqli/__init__.py +0 -0
- hackscan-0.3.1/benchmarks/suite/sqli/api.py +28 -0
- hackscan-0.3.1/benchmarks/suite/sqli/django_views.py +46 -0
- hackscan-0.3.1/benchmarks/suite/sqli/flask_reports.py +80 -0
- hackscan-0.3.1/benchmarks/suite/sqli/repository.py +25 -0
- hackscan-0.3.1/benchmarks/suite/sqli/urls.py +12 -0
- hackscan-0.3.1/benchmarks/suite/weak_crypto/__init__.py +0 -0
- hackscan-0.3.1/benchmarks/suite/weak_crypto/accounts.py +53 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/pyproject.toml +1 -1
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/__init__.py +1 -1
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/analyzers/ast_pass.py +17 -5
- hackscan-0.3.1/src/hackscan/analyzers/interproc.py +377 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/analyzers/llm_pass.py +10 -2
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/analyzers/remediate.py +44 -6
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/analyzers/taint_pass.py +513 -37
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/cli.py +8 -3
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/core/pipeline.py +10 -5
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/core/taxonomy.py +2 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/importers/sarif.py +18 -4
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/loader.py +5 -1
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/scopes.py +12 -1
- hackscan-0.3.1/tests/fixtures/imports/.gitkeep +0 -0
- hackscan-0.3.1/tests/test_action.py +203 -0
- hackscan-0.3.1/tests/test_benchmark.py +365 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_cli.py +39 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_importers.py +20 -0
- hackscan-0.3.1/tests/test_interproc.py +388 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_llm.py +15 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_remediate.py +98 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_taint.py +569 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_taxonomy.py +5 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/.gitattributes +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/.github/workflows/ci.yml +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/.github/workflows/release.yml +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/LICENSE +0 -0
- /hackscan-0.2.0/tests/fixtures/imports/.gitkeep → /hackscan-0.3.1/benchmarks/suite/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/analyzers/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/analyzers/suppressions.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/config.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/core/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/core/dedupe.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/core/fingerprint.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/core/models.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/core/redact.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/importers/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/importers/common.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/importers/gitleaks.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/importers/runner.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/base.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/cmdi.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/codei.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/sqli.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/plugins/rules/weak_crypto.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/sarif/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/sarif/generator.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/taint/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/taint/sanitizers.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/taint/sources.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/ui/__init__.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/src/hackscan/ui/render.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/conftest.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/README.md +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/cmdi/safe/shell.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/cmdi/vulnerable/shell.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/cmdi/vulnerable/star_import.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/codei/safe/dynamic.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/codei/vulnerable/dynamic.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/frameworks/django/views.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/frameworks/fastapi/main.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/frameworks/flask/app.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/sqli/safe/dbapi.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/sqli/vulnerable/dbapi.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/weak_crypto/safe/hashes.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/corpus/weak_crypto/vulnerable/hashes.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/fixtures/imports/README.md +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/fixtures/imports/bandit.sarif +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/fixtures/imports/codeql.sarif +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/fixtures/imports/gitleaks.json +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/fixtures/imports/project/app.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/fixtures/imports/semgrep.sarif +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/fixtures/sarif-schema-2.1.0.json +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_ast_pass.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_config.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_dedupe.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_fingerprint.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_models.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_pipeline.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_redact.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_rules.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_sarif.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_scopes.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_taint_fuzz.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/tests/test_ui.py +0 -0
- {hackscan-0.2.0 → hackscan-0.3.1}/uv.lock +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 }}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hackscan
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -46,6 +46,7 @@ HIGH confirmed views.py:10:9 HS-SQLI-001 [100%]
|
|
|
46
46
|
|---|---|
|
|
47
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
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. |
|
|
49
50
|
| **Framework-aware** | Sources: Flask `request.*`, Django/DRF/Starlette/FastAPI request objects (incl. `self.request`), route-handler parameters, `input()`, `sys.argv`. |
|
|
50
51
|
| **One report from many tools** | Imports Semgrep, Bandit, CodeQL (SARIF) and Gitleaks; deduplicates by vulnerability class and location, keeping every tool as provenance. |
|
|
51
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. |
|
|
@@ -77,7 +78,7 @@ hackscan rules # list rules
|
|
|
77
78
|
| Option | Meaning |
|
|
78
79
|
|---|---|
|
|
79
80
|
| `--severity`, `--min-confidence` | Report filters. |
|
|
80
|
-
| `--fail-on SEV` | Exit 1 if an open (candidate/confirmed) finding is at least `SEV`. Suppressed findings never count. |
|
|
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`. |
|
|
81
82
|
| `--ignore GLOB` | Skip paths (`tests/*`, `**/migrations/**`, `legacy`). `.venv`, `node_modules`, `build`, ... are always skipped. |
|
|
82
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`. |
|
|
83
84
|
| `-o FILE` | Write the report to `FILE`. Never overwrites anything except a previous HackScan report. |
|
|
@@ -152,6 +153,44 @@ Secrets are never echoed: findings of class `secret` (from any tool) have generi
|
|
|
152
153
|
messages and redacted snippets, and values reported by Gitleaks are scrubbed from all
|
|
153
154
|
output, including warnings.
|
|
154
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
|
+
|
|
155
194
|
## Rules
|
|
156
195
|
|
|
157
196
|
| Rule | Detects | Severity |
|
|
@@ -187,19 +226,87 @@ class PickleLoads(RulePlugin):
|
|
|
187
226
|
|
|
188
227
|
## GitHub Actions
|
|
189
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.1
|
|
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
|
+
|
|
190
262
|
```yaml
|
|
191
263
|
- run: pipx install hackscan
|
|
192
264
|
- run: hackscan scan . --format sarif --sarif-omit-suppressed -o hackscan.sarif --fail-on high
|
|
193
|
-
- uses: github/codeql-action/upload-sarif@
|
|
265
|
+
- uses: github/codeql-action/upload-sarif@v4
|
|
194
266
|
if: always()
|
|
195
267
|
with:
|
|
196
268
|
sarif_file: hackscan.sarif
|
|
197
269
|
```
|
|
198
270
|
|
|
271
|
+
## pre-commit
|
|
272
|
+
|
|
273
|
+
```yaml
|
|
274
|
+
repos:
|
|
275
|
+
- repo: https://github.com/kahledawad2019-netizen/hackscan
|
|
276
|
+
rev: v0.3.1
|
|
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
|
+
|
|
199
285
|
## Limitations
|
|
200
286
|
|
|
201
|
-
- Taint analysis
|
|
202
|
-
|
|
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.
|
|
203
310
|
- Python only. Imported tools may cover other languages; their findings are passed through.
|
|
204
311
|
- Fix suggestions are deliberately conservative: many vulnerable lines get none. Command
|
|
205
312
|
fixes (argument lists) are only offered for programs whose arguments are inert data
|
|
@@ -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
|
|
|
@@ -142,7 +142,7 @@ mature scanners, (3) taint-based confirmation, (4) optional local-LLM triage and
|
|
|
142
142
|
| M2 ✅ | Taint | Framework fixtures (Flask/Django/FastAPI); constant/sanitized flows suppressed; tests for taint boundaries | M1 |
|
|
143
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
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 | Benchmark report in README; Action used on the repo itself | M4 |
|
|
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
|
|
@@ -358,3 +358,104 @@ hackscan/
|
|
|
358
358
|
redacting finding function names before IDs, output and triage. Double-quoted SQL
|
|
359
359
|
identifiers and SQL text containing `$` also receive no fix. Numeric and concatenated
|
|
360
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.
|
|
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).
|
|
@@ -20,6 +20,7 @@ HIGH confirmed views.py:10:9 HS-SQLI-001 [100%]
|
|
|
20
20
|
|---|---|
|
|
21
21
|
| **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. |
|
|
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
|
+
| **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. |
|
|
23
24
|
| **Framework-aware** | Sources: Flask `request.*`, Django/DRF/Starlette/FastAPI request objects (incl. `self.request`), route-handler parameters, `input()`, `sys.argv`. |
|
|
24
25
|
| **One report from many tools** | Imports Semgrep, Bandit, CodeQL (SARIF) and Gitleaks; deduplicates by vulnerability class and location, keeping every tool as provenance. |
|
|
25
26
|
| **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. |
|
|
@@ -51,7 +52,7 @@ hackscan rules # list rules
|
|
|
51
52
|
| Option | Meaning |
|
|
52
53
|
|---|---|
|
|
53
54
|
| `--severity`, `--min-confidence` | Report filters. |
|
|
54
|
-
| `--fail-on SEV` | Exit 1 if an open (candidate/confirmed) finding is at least `SEV`. Suppressed findings never count. |
|
|
55
|
+
| `--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`. |
|
|
55
56
|
| `--ignore GLOB` | Skip paths (`tests/*`, `**/migrations/**`, `legacy`). `.venv`, `node_modules`, `build`, ... are always skipped. |
|
|
56
57
|
| `--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`. |
|
|
57
58
|
| `-o FILE` | Write the report to `FILE`. Never overwrites anything except a previous HackScan report. |
|
|
@@ -126,6 +127,44 @@ Secrets are never echoed: findings of class `secret` (from any tool) have generi
|
|
|
126
127
|
messages and redacted snippets, and values reported by Gitleaks are scrubbed from all
|
|
127
128
|
output, including warnings.
|
|
128
129
|
|
|
130
|
+
## Benchmark
|
|
131
|
+
|
|
132
|
+
**Labeled suite.** Flask, Django, FastAPI and CLI code with 26 vulnerable sinks and 24
|
|
133
|
+
look-alike safe traps (SQL, command and code injection, weak crypto), scored per function;
|
|
134
|
+
each tool runs the configuration in its Version column (Bandit: all checks; Semgrep CE:
|
|
135
|
+
the `p/python` ruleset; CodeQL: `python-security-extended`; HackScan: no options)
|
|
136
|
+
([methodology and caveats](benchmarks/README.md), [full results](benchmarks/RESULTS.md)):
|
|
137
|
+
|
|
138
|
+
| Tool | Version | TP | FP | FN | Precision | Recall | F1 |
|
|
139
|
+
|---|---|---:|---:|---:|---:|---:|---:|
|
|
140
|
+
| HackScan | 0.3.0 | 23 | 7 | 3 | 77% | 88% | 82% |
|
|
141
|
+
| HackScan (confirmed only) | 0.3.0 | 18 | 2 | 8 | 90% | 69% | 78% |
|
|
142
|
+
| Bandit | bandit[sarif]==1.9.4 | 24 | 19 | 2 | 56% | 92% | 70% |
|
|
143
|
+
| Semgrep CE | semgrep==1.180.0 (p/python sha256:31c1dfa46e8d) | 16 | 6 | 10 | 73% | 62% | 67% |
|
|
144
|
+
| CodeQL | CodeQL 2.27.2 (codeql/python-queries@1.8.12, security-extended) | 20 | 1 | 6 | 95% | 77% | 85% |
|
|
145
|
+
|
|
146
|
+
**Known-vulnerable apps.** [vulpy](https://github.com/fportantier/vulpy),
|
|
147
|
+
[PyGoat](https://github.com/adeyosemanputra/pygoat) and
|
|
148
|
+
[DVPWA](https://github.com/anxolerd/dvpwa) at pinned commits; every report reviewed by
|
|
149
|
+
hand. There is no complete ground truth, so recall is relative to the 21 distinct
|
|
150
|
+
vulnerabilities any tool found ([details](benchmarks/APPS.md)):
|
|
151
|
+
|
|
152
|
+
| Tool | Reports | TP | FP | Vulns found | Precision | Relative recall |
|
|
153
|
+
|---|---:|---:|---:|---:|---:|---:|
|
|
154
|
+
| HackScan | 18 | 15 | 3 | 15 | 83% | 71% |
|
|
155
|
+
| HackScan (confirmed only) | 6 | 6 | 0 | 6 | 100% | 29% |
|
|
156
|
+
| Bandit | 32 | 16 | 16 | 16 | 50% | 76% |
|
|
157
|
+
| Semgrep CE | 12 | 11 | 1 | 11 | 92% | 52% |
|
|
158
|
+
| CodeQL | 15 | 15 | 0 | 14 | 100% | 67% |
|
|
159
|
+
|
|
160
|
+
CodeQL is the most precise; Bandit finds a little more but about half of its reports are
|
|
161
|
+
noise. HackScan sits in between: higher recall than CodeQL and Semgrep, higher
|
|
162
|
+
precision than Bandit. Its misses are weak ciphers (DES, RC4, ECB, unauthenticated
|
|
163
|
+
modes), which its rule does not cover yet, and flows it does not follow (`self`
|
|
164
|
+
attributes, ambiguous module names). Its false positives are sinks it cannot prove safe (allow-list
|
|
165
|
+
guards, functions only called with constants). The suite is small and written by
|
|
166
|
+
HackScan's authors, so treat both tables as a sanity check rather than a ranking.
|
|
167
|
+
|
|
129
168
|
## Rules
|
|
130
169
|
|
|
131
170
|
| Rule | Detects | Severity |
|
|
@@ -161,19 +200,87 @@ class PickleLoads(RulePlugin):
|
|
|
161
200
|
|
|
162
201
|
## GitHub Actions
|
|
163
202
|
|
|
203
|
+
The repository is a composite action (available from v0.3.0). It installs HackScan, writes
|
|
204
|
+
SARIF, uploads it to GitHub Code Scanning and then fails the job if the `fail-on` threshold
|
|
205
|
+
was hit, so findings appear in the Security tab even on a failing run.
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
permissions:
|
|
209
|
+
contents: read
|
|
210
|
+
security-events: write # needed for the Code Scanning upload
|
|
211
|
+
actions: read # needed for the upload in private repositories
|
|
212
|
+
|
|
213
|
+
jobs:
|
|
214
|
+
hackscan:
|
|
215
|
+
runs-on: ubuntu-latest
|
|
216
|
+
steps:
|
|
217
|
+
- uses: actions/checkout@v4
|
|
218
|
+
- uses: kahledawad2019-netizen/hackscan@v0.3.1
|
|
219
|
+
with:
|
|
220
|
+
path: src
|
|
221
|
+
fail-on: high
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
| Input | Default | Meaning |
|
|
225
|
+
|---|---|---|
|
|
226
|
+
| `path` | `.` | File or directory to scan |
|
|
227
|
+
| `version` | *(empty)* | HackScan version from PyPI; empty installs the action's own checkout |
|
|
228
|
+
| `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 |
|
|
229
|
+
| `args` | *(empty)* | Extra CLI arguments, word-split without globbing; pass only trusted values |
|
|
230
|
+
| `sarif-file` | `hackscan.sarif` | Report path |
|
|
231
|
+
| `upload-sarif` | `true` | Upload to Code Scanning (skip it for fork pull requests, whose token is read-only) |
|
|
232
|
+
|
|
233
|
+
Outputs: `sarif-file` and `exit-code` (0, 1 = threshold hit, 2 = error). The action sets up
|
|
234
|
+
Python 3.12 on the runner. Without the action:
|
|
235
|
+
|
|
164
236
|
```yaml
|
|
165
237
|
- run: pipx install hackscan
|
|
166
238
|
- run: hackscan scan . --format sarif --sarif-omit-suppressed -o hackscan.sarif --fail-on high
|
|
167
|
-
- uses: github/codeql-action/upload-sarif@
|
|
239
|
+
- uses: github/codeql-action/upload-sarif@v4
|
|
168
240
|
if: always()
|
|
169
241
|
with:
|
|
170
242
|
sarif_file: hackscan.sarif
|
|
171
243
|
```
|
|
172
244
|
|
|
245
|
+
## pre-commit
|
|
246
|
+
|
|
247
|
+
```yaml
|
|
248
|
+
repos:
|
|
249
|
+
- repo: https://github.com/kahledawad2019-netizen/hackscan
|
|
250
|
+
rev: v0.3.1
|
|
251
|
+
hooks:
|
|
252
|
+
- id: hackscan
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The hook runs `hackscan scan . --fail-on high` once per commit that touches Python files. It
|
|
256
|
+
scans the whole repository (the CLI takes a single path), so `.hackscan.yml` `ignore` globs
|
|
257
|
+
apply as usual.
|
|
258
|
+
|
|
173
259
|
## Limitations
|
|
174
260
|
|
|
175
|
-
- Taint analysis
|
|
176
|
-
|
|
261
|
+
- Taint analysis follows calls across functions and files only to *confirm* findings: a
|
|
262
|
+
sink fed by a parameter is confirmed when some caller in the scanned code passes
|
|
263
|
+
untrusted data, or when a called function returns it. It never suppresses a finding
|
|
264
|
+
(callers outside the scan are unknown). Only undecorated module-level functions bound
|
|
265
|
+
once are linked (same module, `import`/`from ... import`, relative imports and
|
|
266
|
+
re-exports); methods, nested functions, decorated functions, modules with a star
|
|
267
|
+
import, `*args`/`**kwargs` call sites and module names that match more than one file
|
|
268
|
+
are not followed. Sources are not tracked through object attributes or globals. The
|
|
269
|
+
result of a call is assumed to carry its arguments, so a helper that sanitizes them
|
|
270
|
+
(`def clean(x): return int(x)`) does not prevent a confirmation.
|
|
271
|
+
- Findings in a function (or module) that itself uses `:=` are never suppressed, only
|
|
272
|
+
confirmed or left as candidates: an assignment expression can rebind a name in the
|
|
273
|
+
middle of an expression, which the statement-level analysis does not order. Sinks in
|
|
274
|
+
the body of a generator expression are never suppressed either (it runs later), nor
|
|
275
|
+
are values produced by one. Module-level constants are trusted only when bound exactly
|
|
276
|
+
once and the module never writes its namespace dynamically (`globals()`, `vars()`,
|
|
277
|
+
`exec`, `eval`, `__dict__`, star imports); `global`/`nonlocal` names are never trusted.
|
|
278
|
+
- Mutable objects (lists, dicts, sets) are tracked by variable name. Mutation through
|
|
279
|
+
direct aliases, method calls (`d.get(k).append(x)`), bound methods
|
|
280
|
+
(`add = parts.append`), conditional receivers and comprehension items is modeled;
|
|
281
|
+
aliases created in less direct ways (stored in another object's attribute or in a
|
|
282
|
+
container and mutated through it later, returned from a helper, reached through
|
|
283
|
+
`getattr`) may be missed, so a constant suppression involving such code deserves review.
|
|
177
284
|
- Python only. Imported tools may cover other languages; their findings are passed through.
|
|
178
285
|
- Fix suggestions are deliberately conservative: many vulnerable lines get none. Command
|
|
179
286
|
fixes (argument lists) are only offered for programs whose arguments are inert data
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
name: HackScan
|
|
2
|
+
description: Scan Python code and upload SARIF to GitHub Code Scanning.
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
path:
|
|
6
|
+
description: File or directory to scan.
|
|
7
|
+
required: false
|
|
8
|
+
default: "."
|
|
9
|
+
version:
|
|
10
|
+
description: HackScan version to install; empty installs this action's checkout.
|
|
11
|
+
required: false
|
|
12
|
+
default: ""
|
|
13
|
+
fail-on:
|
|
14
|
+
description: Fail at this severity or above; empty never fails on findings (HackScan >= 0.3.0).
|
|
15
|
+
required: false
|
|
16
|
+
default: "high"
|
|
17
|
+
args:
|
|
18
|
+
description: Extra CLI arguments, split by the shell; must be trusted.
|
|
19
|
+
required: false
|
|
20
|
+
default: ""
|
|
21
|
+
sarif-file:
|
|
22
|
+
description: SARIF output path.
|
|
23
|
+
required: false
|
|
24
|
+
default: "hackscan.sarif"
|
|
25
|
+
upload-sarif:
|
|
26
|
+
description: Upload SARIF to GitHub Code Scanning when true.
|
|
27
|
+
required: false
|
|
28
|
+
default: "true"
|
|
29
|
+
|
|
30
|
+
outputs:
|
|
31
|
+
sarif-file:
|
|
32
|
+
description: SARIF output path.
|
|
33
|
+
value: ${{ steps.scan.outputs.sarif-file }}
|
|
34
|
+
exit-code:
|
|
35
|
+
description: Scan exit code (0 OK, 1 threshold hit, 2 scan error).
|
|
36
|
+
value: ${{ steps.scan.outputs.exit-code }}
|
|
37
|
+
|
|
38
|
+
runs:
|
|
39
|
+
using: composite
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/setup-python@v5
|
|
42
|
+
with:
|
|
43
|
+
python-version: "3.12"
|
|
44
|
+
- name: Install HackScan
|
|
45
|
+
shell: bash
|
|
46
|
+
env:
|
|
47
|
+
HACKSCAN_VERSION: ${{ inputs.version }}
|
|
48
|
+
HACKSCAN_ACTION_PATH: ${{ github.action_path }}
|
|
49
|
+
run: |
|
|
50
|
+
if [[ -n "$HACKSCAN_VERSION" ]]; then
|
|
51
|
+
python -m pip install "hackscan==$HACKSCAN_VERSION"
|
|
52
|
+
else
|
|
53
|
+
python -m pip install "$HACKSCAN_ACTION_PATH"
|
|
54
|
+
fi
|
|
55
|
+
- name: Scan
|
|
56
|
+
id: scan
|
|
57
|
+
shell: bash
|
|
58
|
+
env:
|
|
59
|
+
HACKSCAN_PATH: ${{ inputs.path }}
|
|
60
|
+
HACKSCAN_FAIL_ON: ${{ inputs.fail-on }}
|
|
61
|
+
HACKSCAN_ARGS: ${{ inputs.args }}
|
|
62
|
+
HACKSCAN_SARIF_FILE: ${{ inputs.sarif-file }}
|
|
63
|
+
run: |
|
|
64
|
+
# An empty input disables even a threshold inherited from .hackscan.yml; the exit code
|
|
65
|
+
# is never rewritten, so a crash (Python exits 1) still fails the job.
|
|
66
|
+
threshold=(--fail-on never)
|
|
67
|
+
if [[ -n "$HACKSCAN_FAIL_ON" ]]; then
|
|
68
|
+
threshold=(--fail-on "$HACKSCAN_FAIL_ON")
|
|
69
|
+
fi
|
|
70
|
+
# Extra args intentionally undergo word splitting (globbing is off); supply only trusted values.
|
|
71
|
+
set -f
|
|
72
|
+
rm -f -- "$HACKSCAN_SARIF_FILE"
|
|
73
|
+
code=0
|
|
74
|
+
hackscan scan "$HACKSCAN_PATH" --format sarif --sarif-omit-suppressed \
|
|
75
|
+
-o "$HACKSCAN_SARIF_FILE" $HACKSCAN_ARGS "${threshold[@]}" || code=$?
|
|
76
|
+
written=false
|
|
77
|
+
if [[ -s "$HACKSCAN_SARIF_FILE" ]]; then
|
|
78
|
+
written=true
|
|
79
|
+
fi
|
|
80
|
+
printf 'sarif-file=%s\n' "$HACKSCAN_SARIF_FILE" >> "$GITHUB_OUTPUT"
|
|
81
|
+
printf 'sarif-written=%s\n' "$written" >> "$GITHUB_OUTPUT"
|
|
82
|
+
printf 'exit-code=%s\n' "$code" >> "$GITHUB_OUTPUT"
|
|
83
|
+
- name: Upload SARIF
|
|
84
|
+
if: ${{ always() && inputs.upload-sarif == 'true' && steps.scan.outputs.sarif-written == 'true' }}
|
|
85
|
+
uses: github/codeql-action/upload-sarif@v4
|
|
86
|
+
with:
|
|
87
|
+
sarif_file: ${{ inputs.sarif-file }}
|
|
88
|
+
category: hackscan
|
|
89
|
+
- name: Apply scan result
|
|
90
|
+
if: ${{ always() && steps.scan.outputs.exit-code != '' }}
|
|
91
|
+
shell: bash
|
|
92
|
+
env:
|
|
93
|
+
HACKSCAN_EXIT_CODE: ${{ steps.scan.outputs.exit-code }}
|
|
94
|
+
run: exit "$HACKSCAN_EXIT_CODE"
|