palisade-sec 0.1.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.
- palisade_sec-0.1.0/.github/workflows/ci.yml +33 -0
- palisade_sec-0.1.0/.gitignore +10 -0
- palisade_sec-0.1.0/CONTRIBUTING.md +58 -0
- palisade_sec-0.1.0/LICENSE +21 -0
- palisade_sec-0.1.0/PKG-INFO +177 -0
- palisade_sec-0.1.0/README.md +150 -0
- palisade_sec-0.1.0/examples/vulnerable-app/README.md +15 -0
- palisade_sec-0.1.0/examples/vulnerable-app/agent_pipeline.py +10 -0
- palisade_sec-0.1.0/examples/vulnerable-app/app.py +187 -0
- palisade_sec-0.1.0/examples/vulnerable-app/db.py +5 -0
- palisade_sec-0.1.0/examples/vulnerable-app/executor.py +6 -0
- palisade_sec-0.1.0/examples/vulnerable-app/guards.py +36 -0
- palisade_sec-0.1.0/examples/vulnerable-app/llm_utils.py +13 -0
- palisade_sec-0.1.0/pyproject.toml +58 -0
- palisade_sec-0.1.0/src/palisade_sec/__init__.py +7 -0
- palisade_sec-0.1.0/src/palisade_sec/baseline.py +73 -0
- palisade_sec-0.1.0/src/palisade_sec/cli.py +166 -0
- palisade_sec-0.1.0/src/palisade_sec/engine/__init__.py +8 -0
- palisade_sec-0.1.0/src/palisade_sec/engine/analyzer.py +517 -0
- palisade_sec-0.1.0/src/palisade_sec/engine/findings.py +115 -0
- palisade_sec-0.1.0/src/palisade_sec/engine/taint.py +60 -0
- palisade_sec-0.1.0/src/palisade_sec/frontends/__init__.py +9 -0
- palisade_sec-0.1.0/src/palisade_sec/frontends/ast_python.py +443 -0
- palisade_sec-0.1.0/src/palisade_sec/ir/__init__.py +51 -0
- palisade_sec-0.1.0/src/palisade_sec/ir/model.py +205 -0
- palisade_sec-0.1.0/src/palisade_sec/report/__init__.py +5 -0
- palisade_sec-0.1.0/src/palisade_sec/report/json_emitter.py +49 -0
- palisade_sec-0.1.0/src/palisade_sec/report/markdown.py +75 -0
- palisade_sec-0.1.0/src/palisade_sec/report/terminal.py +109 -0
- palisade_sec-0.1.0/src/palisade_sec/rules/README.md +77 -0
- palisade_sec-0.1.0/src/palisade_sec/rules/__init__.py +4 -0
- palisade_sec-0.1.0/src/palisade_sec/rules/loader.py +54 -0
- palisade_sec-0.1.0/src/palisade_sec/rules/pi-exec.yaml +99 -0
- palisade_sec-0.1.0/src/palisade_sec/rules/pi-shell.yaml +102 -0
- palisade_sec-0.1.0/src/palisade_sec/rules/pi-sql.yaml +97 -0
- palisade_sec-0.1.0/src/palisade_sec/rules/schema.py +84 -0
- palisade_sec-0.1.0/src/palisade_sec/scanner.py +201 -0
- palisade_sec-0.1.0/tests/conftest.py +50 -0
- palisade_sec-0.1.0/tests/test_baseline.py +105 -0
- palisade_sec-0.1.0/tests/test_cli.py +103 -0
- palisade_sec-0.1.0/tests/test_edge_cases.py +436 -0
- palisade_sec-0.1.0/tests/test_example_app.py +126 -0
- palisade_sec-0.1.0/tests/test_rules.py +59 -0
- palisade_sec-0.1.0/uv.lock +389 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- name: Install
|
|
20
|
+
run: uv sync --dev
|
|
21
|
+
- name: Lint
|
|
22
|
+
run: uv run ruff check .
|
|
23
|
+
- name: Format check
|
|
24
|
+
run: uv run ruff format --check src tests
|
|
25
|
+
- name: Tests
|
|
26
|
+
run: uv run pytest -q
|
|
27
|
+
- name: Self-check — the example app must fail CI mode
|
|
28
|
+
run: |
|
|
29
|
+
if uv run palisade-sec scan examples/vulnerable-app --ci; then
|
|
30
|
+
echo "expected non-zero exit on the vulnerable example app" && exit 1
|
|
31
|
+
fi
|
|
32
|
+
- name: Self-check — palisade's own source must be clean
|
|
33
|
+
run: uv run palisade-sec scan src --ci
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Contributing to Palisade
|
|
2
|
+
|
|
3
|
+
Thanks for helping make LLM-security review as routine as running a linter.
|
|
4
|
+
|
|
5
|
+
## The fastest way to contribute: rules
|
|
6
|
+
|
|
7
|
+
Most coverage gaps are rule gaps, not engine gaps. Adding a framework's
|
|
8
|
+
sources/LLM signatures/sinks is a small YAML PR with no engine changes —
|
|
9
|
+
see [`src/palisade_sec/rules/README.md`](src/palisade_sec/rules/README.md)
|
|
10
|
+
for the 5-minute guide. Every rule PR needs two fixtures: one that must be
|
|
11
|
+
flagged, one same-shaped safe variant that must stay silent.
|
|
12
|
+
|
|
13
|
+
## Ground rules (from the design philosophy)
|
|
14
|
+
|
|
15
|
+
1. **Precision over recall.** A false positive is worse than a miss. If your
|
|
16
|
+
change flags something new, it needs a matching must-NOT-flag test.
|
|
17
|
+
2. **Taint, not grep.** Findings require a complete source → LLM → sink path.
|
|
18
|
+
3. **The engine operates only on the IR.** Never leak Python `ast` types into
|
|
19
|
+
`engine/` or rules; language specifics belong in `frontends/`.
|
|
20
|
+
4. **The tool never runs scanned code.** `ast.parse` only. No network calls
|
|
21
|
+
in `scan`. Ever.
|
|
22
|
+
5. **Partial defenses (denylists, confirmation gates) downgrade to MED —
|
|
23
|
+
they never suppress.** Real CVEs shipped with exactly those defenses.
|
|
24
|
+
|
|
25
|
+
## Dev setup
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/arpankernel/palisade && cd palisade
|
|
29
|
+
uv sync
|
|
30
|
+
uv run pytest # the example-app FP tests are the ones that gate merges
|
|
31
|
+
uv run ruff check .
|
|
32
|
+
uv run palisade-sec scan examples/vulnerable-app --all
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Project layout
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
src/palisade_sec/
|
|
39
|
+
├── frontends/ # language -> IR lowering (Python: stdlib ast)
|
|
40
|
+
├── ir/ # normalized taint IR (language-agnostic)
|
|
41
|
+
├── engine/ # taint propagation, sanitizer resolution, confidence
|
|
42
|
+
├── rules/ # YAML rules + pydantic schema (community entry point)
|
|
43
|
+
├── report/ # terminal / json / markdown emitters
|
|
44
|
+
├── baseline.py # CI baseline (fail only on NEW findings)
|
|
45
|
+
└── cli.py # Typer CLI
|
|
46
|
+
examples/vulnerable-app/ # the acceptance fixtures — deliberately unsafe
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Adding a language frontend (post-v1)
|
|
50
|
+
|
|
51
|
+
A frontend lowers source into `ir.Module` — see
|
|
52
|
+
`frontends/ast_python.py` for the reference implementation. If your frontend
|
|
53
|
+
emits correct IR, every existing rule and the whole engine work unchanged.
|
|
54
|
+
Open an issue first so we can coordinate on tree-sitter setup.
|
|
55
|
+
|
|
56
|
+
## Reporting vulnerabilities in Palisade itself
|
|
57
|
+
|
|
58
|
+
Email the maintainers privately rather than opening a public issue.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Palisade contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: palisade-sec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A linter for LLM security: statically detects prompt-injection-to-sink vulnerabilities in Python codebases.
|
|
5
|
+
Project-URL: Homepage, https://github.com/arpankernel/palisade
|
|
6
|
+
Project-URL: Issues, https://github.com/arpankernel/palisade/issues
|
|
7
|
+
Author: Palisade contributors
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: linter,llm,prompt-injection,security,static-analysis,taint
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
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.11
|
|
22
|
+
Requires-Dist: pydantic>=2.5
|
|
23
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
Requires-Dist: typer>=0.12
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Palisade
|
|
29
|
+
|
|
30
|
+
**A linter for LLM security.** Palisade statically detects prompt-injection
|
|
31
|
+
vulnerabilities in Python codebases — untrusted input flowing through an LLM
|
|
32
|
+
into a dangerous sink — in CI, before they ship.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
untrusted input → LLM → exec / shell / raw SQL (no sanitizer) ⇒ finding
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
No API key. No signup. No network calls. Pure static analysis.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uvx palisade-sec scan .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
HIGH app.py:31 [PI-EXEC] Prompt injection reaching code execution
|
|
46
|
+
↳ source: question = request.json["question"] (app.py:31)
|
|
47
|
+
↳ llm: resp = client.chat.completions.create( (app.py:32)
|
|
48
|
+
↳ sink: exec(code) (app.py:40)
|
|
49
|
+
No sanitizer on path. Confidence: HIGH
|
|
50
|
+
Attack: crafted input makes the model emit Python that executes on your server.
|
|
51
|
+
Fix: never exec model output; sandbox + strict allowlist (denylists are bypassable).
|
|
52
|
+
Refs: CVE-2024-12366 (PandasAI); CVE-2025-3248 (Langflow, CISA KEV)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why
|
|
56
|
+
|
|
57
|
+
This exact pattern is behind real, exploited CVEs: **Langflow**
|
|
58
|
+
(CVE-2025-3248, on CISA KEV, exploited in the wild), **PandasAI**
|
|
59
|
+
(CVE-2024-12366, CVSS 9.8), **Vanna.ai** (CVE-2024-5565), **LangChain**
|
|
60
|
+
PAL/LLMMath chains (CVE-2023-36258, CVE-2023-29374). Almost nobody defends it
|
|
61
|
+
at the code level: existing tools are runtime proxies (paid, in the traffic
|
|
62
|
+
path) or guardrail libraries you have to know to wire in. Palisade is the
|
|
63
|
+
missing piece — **free, static, LLM-dataflow-aware, and CI-native**, like
|
|
64
|
+
ruff or semgrep but for the OWASP LLM Top-10 #1 risk.
|
|
65
|
+
|
|
66
|
+
## What it detects (v1)
|
|
67
|
+
|
|
68
|
+
| Rule | Path | Real-world precedent |
|
|
69
|
+
|------|------|----------------------|
|
|
70
|
+
| `PI-EXEC` | input → LLM → `exec` / `eval` / `compile` / `PythonREPL` | PandasAI, Langflow, LangChain PAL |
|
|
71
|
+
| `PI-SHELL` | input → LLM → `os.system` / `subprocess(shell=True)` | Open Interpreter (by design) |
|
|
72
|
+
| `PI-SQL` | input → LLM → raw non-parameterized SQL | Vanna.ai |
|
|
73
|
+
|
|
74
|
+
Palisade runs **taint analysis, not grep**: it only reports a *complete*
|
|
75
|
+
`source → LLM → sink` data-flow path with no sanitizer in between.
|
|
76
|
+
|
|
77
|
+
- Constant developer prompt → LLM → `exec`? **Silent** — no untrusted source.
|
|
78
|
+
- `subprocess.run([...])` with an arg list? **Silent** — safe sink shape.
|
|
79
|
+
- Parameterized `cursor.execute(q, params)`? **Silent.**
|
|
80
|
+
- Allowlist / pydantic validation on the path? **Silent** — sanitized.
|
|
81
|
+
- Denylist or human-confirmation gate? **Flagged MED "risky"** — real CVEs
|
|
82
|
+
were exploited despite exactly those defenses. That is deliberate.
|
|
83
|
+
|
|
84
|
+
## Install & run
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# one-shot, no install
|
|
88
|
+
uvx palisade-sec scan path/to/project
|
|
89
|
+
|
|
90
|
+
# or
|
|
91
|
+
pipx run palisade-sec scan .
|
|
92
|
+
|
|
93
|
+
# or as a dev dependency
|
|
94
|
+
uv add --dev palisade-sec
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Useful flags:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
palisade-sec scan . --all # also show MED/LOW findings
|
|
101
|
+
palisade-sec scan . --json # stable machine-readable output
|
|
102
|
+
palisade-sec scan . --report # write palisade-report.md
|
|
103
|
+
palisade-sec scan . --rules ./my-rules # add your own YAML rules
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## CI
|
|
107
|
+
|
|
108
|
+
Gate pull requests on **new** findings only — adopt Palisade on an imperfect
|
|
109
|
+
codebase without a wall of pre-existing failures:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
palisade-sec baseline . # once; commit .palisade/baseline.json
|
|
113
|
+
palisade-sec scan . --ci --baseline .palisade/baseline.json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`--ci` exits non-zero only if a **new HIGH** finding appears. Fingerprints are
|
|
117
|
+
line-number independent, so refactors don't churn the baseline.
|
|
118
|
+
|
|
119
|
+
GitHub Actions:
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
- uses: astral-sh/setup-uv@v5
|
|
123
|
+
- run: uvx palisade-sec scan . --ci --baseline .palisade/baseline.json
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
`pyproject.toml`:
|
|
129
|
+
|
|
130
|
+
```toml
|
|
131
|
+
[tool.palisade]
|
|
132
|
+
paths_ignore = ["migrations/*", "sandbox/*"]
|
|
133
|
+
include_tests = false # tests/** and conftest.py are skipped by default
|
|
134
|
+
max_hops = 3 # inter-procedural depth bound
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Or the same keys in `.palisade.toml`.
|
|
138
|
+
|
|
139
|
+
## Custom rules
|
|
140
|
+
|
|
141
|
+
Rules are plain YAML validated by a pydantic schema — sources, LLM call
|
|
142
|
+
signatures, sinks, sanitizers, partial defenses. Adding coverage for a new
|
|
143
|
+
framework is a small PR with **no engine changes**. See
|
|
144
|
+
[`src/palisade_sec/rules/README.md`](src/palisade_sec/rules/README.md) for
|
|
145
|
+
the 5-minute guide.
|
|
146
|
+
|
|
147
|
+
## Architecture
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
source ──▶ language frontend (Python: stdlib ast) ──▶ normalized taint IR
|
|
151
|
+
│
|
|
152
|
+
language-agnostic engine ─┤ taint propagation,
|
|
153
|
+
sanitizer resolution, confidence scoring
|
|
154
|
+
│
|
|
155
|
+
YAML rules ──▶ findings ──▶ baseline diff ──▶ terminal / json / md
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The frontend/IR split is the scalability story: JS/TS/Go land later as new
|
|
159
|
+
frontends (tree-sitter) with zero engine changes.
|
|
160
|
+
|
|
161
|
+
## Safety of the tool itself
|
|
162
|
+
|
|
163
|
+
- Palisade **never executes, imports, or evaluates scanned code** — it only
|
|
164
|
+
parses source text with `ast.parse`.
|
|
165
|
+
- `scan` makes **no network calls** and needs no API key or account.
|
|
166
|
+
- No telemetry. Nothing leaves your machine.
|
|
167
|
+
|
|
168
|
+
## An honest note on scope
|
|
169
|
+
|
|
170
|
+
Palisade is **one layer** of defense against **one class** of vulnerability.
|
|
171
|
+
A clean scan means no *detected* injection-to-sink path — it does not mean
|
|
172
|
+
your application is secure. Keep your runtime guardrails, permissions
|
|
173
|
+
boundaries, and sandboxes; Palisade complements them, before merge.
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Palisade
|
|
2
|
+
|
|
3
|
+
**A linter for LLM security.** Palisade statically detects prompt-injection
|
|
4
|
+
vulnerabilities in Python codebases — untrusted input flowing through an LLM
|
|
5
|
+
into a dangerous sink — in CI, before they ship.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
untrusted input → LLM → exec / shell / raw SQL (no sanitizer) ⇒ finding
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No API key. No signup. No network calls. Pure static analysis.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uvx palisade-sec scan .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
HIGH app.py:31 [PI-EXEC] Prompt injection reaching code execution
|
|
19
|
+
↳ source: question = request.json["question"] (app.py:31)
|
|
20
|
+
↳ llm: resp = client.chat.completions.create( (app.py:32)
|
|
21
|
+
↳ sink: exec(code) (app.py:40)
|
|
22
|
+
No sanitizer on path. Confidence: HIGH
|
|
23
|
+
Attack: crafted input makes the model emit Python that executes on your server.
|
|
24
|
+
Fix: never exec model output; sandbox + strict allowlist (denylists are bypassable).
|
|
25
|
+
Refs: CVE-2024-12366 (PandasAI); CVE-2025-3248 (Langflow, CISA KEV)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Why
|
|
29
|
+
|
|
30
|
+
This exact pattern is behind real, exploited CVEs: **Langflow**
|
|
31
|
+
(CVE-2025-3248, on CISA KEV, exploited in the wild), **PandasAI**
|
|
32
|
+
(CVE-2024-12366, CVSS 9.8), **Vanna.ai** (CVE-2024-5565), **LangChain**
|
|
33
|
+
PAL/LLMMath chains (CVE-2023-36258, CVE-2023-29374). Almost nobody defends it
|
|
34
|
+
at the code level: existing tools are runtime proxies (paid, in the traffic
|
|
35
|
+
path) or guardrail libraries you have to know to wire in. Palisade is the
|
|
36
|
+
missing piece — **free, static, LLM-dataflow-aware, and CI-native**, like
|
|
37
|
+
ruff or semgrep but for the OWASP LLM Top-10 #1 risk.
|
|
38
|
+
|
|
39
|
+
## What it detects (v1)
|
|
40
|
+
|
|
41
|
+
| Rule | Path | Real-world precedent |
|
|
42
|
+
|------|------|----------------------|
|
|
43
|
+
| `PI-EXEC` | input → LLM → `exec` / `eval` / `compile` / `PythonREPL` | PandasAI, Langflow, LangChain PAL |
|
|
44
|
+
| `PI-SHELL` | input → LLM → `os.system` / `subprocess(shell=True)` | Open Interpreter (by design) |
|
|
45
|
+
| `PI-SQL` | input → LLM → raw non-parameterized SQL | Vanna.ai |
|
|
46
|
+
|
|
47
|
+
Palisade runs **taint analysis, not grep**: it only reports a *complete*
|
|
48
|
+
`source → LLM → sink` data-flow path with no sanitizer in between.
|
|
49
|
+
|
|
50
|
+
- Constant developer prompt → LLM → `exec`? **Silent** — no untrusted source.
|
|
51
|
+
- `subprocess.run([...])` with an arg list? **Silent** — safe sink shape.
|
|
52
|
+
- Parameterized `cursor.execute(q, params)`? **Silent.**
|
|
53
|
+
- Allowlist / pydantic validation on the path? **Silent** — sanitized.
|
|
54
|
+
- Denylist or human-confirmation gate? **Flagged MED "risky"** — real CVEs
|
|
55
|
+
were exploited despite exactly those defenses. That is deliberate.
|
|
56
|
+
|
|
57
|
+
## Install & run
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# one-shot, no install
|
|
61
|
+
uvx palisade-sec scan path/to/project
|
|
62
|
+
|
|
63
|
+
# or
|
|
64
|
+
pipx run palisade-sec scan .
|
|
65
|
+
|
|
66
|
+
# or as a dev dependency
|
|
67
|
+
uv add --dev palisade-sec
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Useful flags:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
palisade-sec scan . --all # also show MED/LOW findings
|
|
74
|
+
palisade-sec scan . --json # stable machine-readable output
|
|
75
|
+
palisade-sec scan . --report # write palisade-report.md
|
|
76
|
+
palisade-sec scan . --rules ./my-rules # add your own YAML rules
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## CI
|
|
80
|
+
|
|
81
|
+
Gate pull requests on **new** findings only — adopt Palisade on an imperfect
|
|
82
|
+
codebase without a wall of pre-existing failures:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
palisade-sec baseline . # once; commit .palisade/baseline.json
|
|
86
|
+
palisade-sec scan . --ci --baseline .palisade/baseline.json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`--ci` exits non-zero only if a **new HIGH** finding appears. Fingerprints are
|
|
90
|
+
line-number independent, so refactors don't churn the baseline.
|
|
91
|
+
|
|
92
|
+
GitHub Actions:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
- uses: astral-sh/setup-uv@v5
|
|
96
|
+
- run: uvx palisade-sec scan . --ci --baseline .palisade/baseline.json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
`pyproject.toml`:
|
|
102
|
+
|
|
103
|
+
```toml
|
|
104
|
+
[tool.palisade]
|
|
105
|
+
paths_ignore = ["migrations/*", "sandbox/*"]
|
|
106
|
+
include_tests = false # tests/** and conftest.py are skipped by default
|
|
107
|
+
max_hops = 3 # inter-procedural depth bound
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or the same keys in `.palisade.toml`.
|
|
111
|
+
|
|
112
|
+
## Custom rules
|
|
113
|
+
|
|
114
|
+
Rules are plain YAML validated by a pydantic schema — sources, LLM call
|
|
115
|
+
signatures, sinks, sanitizers, partial defenses. Adding coverage for a new
|
|
116
|
+
framework is a small PR with **no engine changes**. See
|
|
117
|
+
[`src/palisade_sec/rules/README.md`](src/palisade_sec/rules/README.md) for
|
|
118
|
+
the 5-minute guide.
|
|
119
|
+
|
|
120
|
+
## Architecture
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
source ──▶ language frontend (Python: stdlib ast) ──▶ normalized taint IR
|
|
124
|
+
│
|
|
125
|
+
language-agnostic engine ─┤ taint propagation,
|
|
126
|
+
sanitizer resolution, confidence scoring
|
|
127
|
+
│
|
|
128
|
+
YAML rules ──▶ findings ──▶ baseline diff ──▶ terminal / json / md
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The frontend/IR split is the scalability story: JS/TS/Go land later as new
|
|
132
|
+
frontends (tree-sitter) with zero engine changes.
|
|
133
|
+
|
|
134
|
+
## Safety of the tool itself
|
|
135
|
+
|
|
136
|
+
- Palisade **never executes, imports, or evaluates scanned code** — it only
|
|
137
|
+
parses source text with `ast.parse`.
|
|
138
|
+
- `scan` makes **no network calls** and needs no API key or account.
|
|
139
|
+
- No telemetry. Nothing leaves your machine.
|
|
140
|
+
|
|
141
|
+
## An honest note on scope
|
|
142
|
+
|
|
143
|
+
Palisade is **one layer** of defense against **one class** of vulnerability.
|
|
144
|
+
A clean scan means no *detected* injection-to-sink path — it does not mean
|
|
145
|
+
your application is secure. Keep your runtime guardrails, permissions
|
|
146
|
+
boundaries, and sandboxes; Palisade complements them, before merge.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# vulnerable-app (fixtures)
|
|
2
|
+
|
|
3
|
+
A deliberately unsafe Flask app used as Palisade's acceptance fixtures.
|
|
4
|
+
**Do not deploy or copy patterns from this app.**
|
|
5
|
+
|
|
6
|
+
It contains, on purpose:
|
|
7
|
+
- 4 exploitable source → LLM → sink paths (exec, raw SQL, shell, and a
|
|
8
|
+
multi-hop exec across three files) that must be flagged **HIGH**;
|
|
9
|
+
- 1 denylist-gated exec that must be flagged **MED "risky"** — partial
|
|
10
|
+
defenses do not count as safe;
|
|
11
|
+
- 6 safe variants (sanitizer, arg-list subprocess, parameterized SQL,
|
|
12
|
+
constant prompt, log-only output, enum-constrained output) that must stay
|
|
13
|
+
**silent** — the false-positive tests are the most important in the repo.
|
|
14
|
+
|
|
15
|
+
`tests/test_example_app.py` pins every one of these expectations.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Multi-hop fixture: source in app.py, LLM in llm_utils.py, sink in
|
|
2
|
+
executor.py — this module is the middle hop."""
|
|
3
|
+
|
|
4
|
+
from executor import execute_plan
|
|
5
|
+
from llm_utils import ask_llm
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_agent(goal: str) -> str:
|
|
9
|
+
plan = ask_llm(f"Write Python code to accomplish: {goal}")
|
|
10
|
+
return execute_plan(plan)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Deliberately vulnerable Flask app — Palisade's acceptance fixtures.
|
|
2
|
+
|
|
3
|
+
DO NOT DEPLOY. Every route here is either a real LLM prompt-injection
|
|
4
|
+
vulnerability (must be flagged), a safe variant (must stay silent), or a
|
|
5
|
+
partially-defended variant (must be flagged MED "risky").
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from flask import Flask, jsonify, request
|
|
11
|
+
from openai import OpenAI
|
|
12
|
+
|
|
13
|
+
from db import get_db
|
|
14
|
+
from agent_pipeline import run_agent
|
|
15
|
+
from guards import is_blocked_code, validate_code
|
|
16
|
+
|
|
17
|
+
app = Flask(__name__)
|
|
18
|
+
client = OpenAI()
|
|
19
|
+
|
|
20
|
+
MODEL = "gpt-4o-mini"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# VULNERABLE — must be flagged HIGH
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.route("/calc", methods=["POST"])
|
|
29
|
+
def calc():
|
|
30
|
+
"""VULN 1 (PI-EXEC, PandasAI-style): NL question -> LLM Python -> exec."""
|
|
31
|
+
question = request.json["question"]
|
|
32
|
+
resp = client.chat.completions.create(
|
|
33
|
+
model=MODEL,
|
|
34
|
+
messages=[
|
|
35
|
+
{"role": "system", "content": "Reply with Python code only."},
|
|
36
|
+
{"role": "user", "content": f"Write Python that answers: {question}"},
|
|
37
|
+
],
|
|
38
|
+
)
|
|
39
|
+
code = resp.choices[0].message.content
|
|
40
|
+
exec(code) # noqa: S102 — the vulnerability under test
|
|
41
|
+
return jsonify(status="done")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.route("/ask-db")
|
|
45
|
+
def ask_db():
|
|
46
|
+
"""VULN 2 (PI-SQL, Vanna-style): text-to-SQL -> raw execute."""
|
|
47
|
+
question = request.args.get("q", "")
|
|
48
|
+
resp = client.chat.completions.create(
|
|
49
|
+
model=MODEL,
|
|
50
|
+
messages=[{"role": "user", "content": f"Write a SQL query for: {question}"}],
|
|
51
|
+
)
|
|
52
|
+
sql = resp.choices[0].message.content
|
|
53
|
+
cur = get_db().cursor()
|
|
54
|
+
cur.execute(sql)
|
|
55
|
+
return jsonify(rows=cur.fetchall())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.route("/ops", methods=["POST"])
|
|
59
|
+
def ops():
|
|
60
|
+
"""VULN 3 (PI-SHELL): task description -> LLM shell command -> shell."""
|
|
61
|
+
task = request.form["task"]
|
|
62
|
+
resp = client.chat.completions.create(
|
|
63
|
+
model=MODEL,
|
|
64
|
+
messages=[{"role": "user", "content": f"Give me one shell command to: {task}"}],
|
|
65
|
+
)
|
|
66
|
+
command = resp.choices[0].message.content
|
|
67
|
+
subprocess.run(command, shell=True)
|
|
68
|
+
return jsonify(status="ran")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.route("/agent", methods=["POST"])
|
|
72
|
+
def agent():
|
|
73
|
+
"""VULN 4 (PI-EXEC, multi-hop): source here, LLM in llm_utils.py,
|
|
74
|
+
sink in executor.py — three functions across three files."""
|
|
75
|
+
goal = request.json["goal"]
|
|
76
|
+
result = run_agent(goal)
|
|
77
|
+
return jsonify(result=result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# PARTIAL DEFENSE — must be flagged MED "risky" (not HIGH, not silent)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.route("/calc-guarded", methods=["POST"])
|
|
86
|
+
def calc_guarded():
|
|
87
|
+
"""A denylist gate before exec, LangChain-PAL-style. Real CVEs were
|
|
88
|
+
exploited despite exactly this defense."""
|
|
89
|
+
question = request.json["question"]
|
|
90
|
+
resp = client.chat.completions.create(
|
|
91
|
+
model=MODEL,
|
|
92
|
+
messages=[{"role": "user", "content": f"Write Python that answers: {question}"}],
|
|
93
|
+
)
|
|
94
|
+
code = resp.choices[0].message.content
|
|
95
|
+
if is_blocked_code(code):
|
|
96
|
+
return jsonify(error="blocked"), 400
|
|
97
|
+
exec(code) # noqa: S102 — still exploitable: denylists are bypassable
|
|
98
|
+
return jsonify(status="done")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# SAFE — must NOT be flagged
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.route("/calc-safe", methods=["POST"])
|
|
107
|
+
def calc_safe():
|
|
108
|
+
"""SAFE (a): same shape as /calc, but the code is validated against a
|
|
109
|
+
strict AST allowlist before running."""
|
|
110
|
+
question = request.json["question"]
|
|
111
|
+
resp = client.chat.completions.create(
|
|
112
|
+
model=MODEL,
|
|
113
|
+
messages=[{"role": "user", "content": f"Write Python that answers: {question}"}],
|
|
114
|
+
)
|
|
115
|
+
code = validate_code(resp.choices[0].message.content)
|
|
116
|
+
exec(code) # noqa: S102 — sanitized above
|
|
117
|
+
return jsonify(status="done")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.route("/ping", methods=["POST"])
|
|
121
|
+
def ping():
|
|
122
|
+
"""SAFE (b): LLM output used as ONE ARGUMENT in an arg-list subprocess
|
|
123
|
+
call — no shell, no injection into a command line."""
|
|
124
|
+
where = request.json["where"]
|
|
125
|
+
resp = client.chat.completions.create(
|
|
126
|
+
model=MODEL,
|
|
127
|
+
messages=[{"role": "user", "content": f"Hostname most relevant to: {where}"}],
|
|
128
|
+
)
|
|
129
|
+
host = resp.choices[0].message.content.strip()
|
|
130
|
+
subprocess.run(["ping", "-c", "1", host], check=False)
|
|
131
|
+
return jsonify(status="ok")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.route("/lookup", methods=["POST"])
|
|
135
|
+
def lookup():
|
|
136
|
+
"""SAFE (c): LLM output bound via a PARAMETERIZED query."""
|
|
137
|
+
description = request.json["description"]
|
|
138
|
+
resp = client.chat.completions.create(
|
|
139
|
+
model=MODEL,
|
|
140
|
+
messages=[{"role": "user", "content": f"Product name for: {description}"}],
|
|
141
|
+
)
|
|
142
|
+
product = resp.choices[0].message.content
|
|
143
|
+
cur = get_db().cursor()
|
|
144
|
+
cur.execute("SELECT * FROM products WHERE name = ?", (product,))
|
|
145
|
+
return jsonify(rows=cur.fetchall())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def nightly_report():
|
|
149
|
+
"""SAFE (d): constant developer prompt — no untrusted source at all."""
|
|
150
|
+
resp = client.chat.completions.create(
|
|
151
|
+
model=MODEL,
|
|
152
|
+
messages=[{"role": "user", "content": "Write Python that prints yesterday's date."}],
|
|
153
|
+
)
|
|
154
|
+
code = resp.choices[0].message.content
|
|
155
|
+
exec(code) # noqa: S102 — developer-controlled prompt, not user input
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.route("/chat", methods=["POST"])
|
|
159
|
+
def chat():
|
|
160
|
+
"""SAFE (e): LLM output is only logged and returned — never executed."""
|
|
161
|
+
message = request.json["message"]
|
|
162
|
+
resp = client.chat.completions.create(
|
|
163
|
+
model=MODEL,
|
|
164
|
+
messages=[{"role": "user", "content": message}],
|
|
165
|
+
)
|
|
166
|
+
answer = resp.choices[0].message.content
|
|
167
|
+
app.logger.info("chat answer: %s", answer)
|
|
168
|
+
print(answer)
|
|
169
|
+
return jsonify(answer=answer)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.route("/action", methods=["POST"])
|
|
173
|
+
def action():
|
|
174
|
+
"""SAFE (f): LLM output constrained to a literal enum before the sink."""
|
|
175
|
+
wish = request.json["wish"]
|
|
176
|
+
resp = client.chat.completions.create(
|
|
177
|
+
model=MODEL,
|
|
178
|
+
messages=[{"role": "user", "content": f"One of list|status|uptime for: {wish}"}],
|
|
179
|
+
)
|
|
180
|
+
verb = resp.choices[0].message.content.strip()
|
|
181
|
+
if verb in ("list", "status", "uptime"):
|
|
182
|
+
subprocess.run(verb, shell=True)
|
|
183
|
+
return jsonify(status="ok")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
app.run(debug=True)
|