https://github.com/xr843/llm-seclint
Static security linter for LLM-powered applications. The Bandit for the AI era.
https://github.com/xr843/llm-seclint
ai-security linter llm owasp prompt-injection python sast security static-analysis vulnerability-scanner
Last synced: 5 days ago
JSON representation
Static security linter for LLM-powered applications. The Bandit for the AI era.
- Host: GitHub
- URL: https://github.com/xr843/llm-seclint
- Owner: xr843
- License: mit
- Created: 2026-03-24T13:26:00.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-06-27T09:59:50.000Z (19 days ago)
- Last Synced: 2026-06-27T10:06:29.129Z (19 days ago)
- Topics: ai-security, linter, llm, owasp, prompt-injection, python, sast, security, static-analysis, vulnerability-scanner
- Language: Python
- Homepage: https://pypi.org/project/llm-seclint/
- Size: 239 KB
- Stars: 2
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# llm-seclint
[](https://github.com/xr843/llm-seclint/actions/workflows/ci.yml)
[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
> **Status: pre-release (v0.1.0, alpha).** Not yet published to PyPI — install from source (see [Quick Start](#quick-start)). The API and rule IDs may change before v1.0.
**Find LLM security vulnerabilities before they ship.**
llm-seclint is a static analysis tool that scans Python source code for security issues specific to LLM-powered applications. Think [Bandit](https://github.com/PyCQA/bandit), but for the AI era.
## The Problem
LLM-powered applications introduce a new class of vulnerabilities that traditional security tools miss:
- **Prompt injection** through unsanitized user input
- **Arbitrary code execution** when LLM output flows into `eval()`, `subprocess`, or SQL queries
- **API key leakage** through hardcoded credentials
- **Path traversal** when LLM output controls file access
- **Template injection** when dynamic content reaches template engines unsandboxed
- **XML external entities** when parsing untrusted XML without protection
- **Supply chain attacks** through unpinned LLM dependency versions
Existing tools like [garak](https://github.com/leondz/garak), [LLM Guard](https://github.com/protectai/llm-guard), and [Guardrails](https://github.com/guardrails-ai/guardrails) operate at **runtime** -- they test deployed models or filter live traffic. None of them analyze your **source code** before you ship.
**llm-seclint** fills this gap. It scans your Python source using AST analysis to find LLM-specific security issues at development time, just like Bandit does for general Python security.
## Quick Start
Install from source (a PyPI package is planned but not yet published):
```bash
pip install git+https://github.com/xr843/llm-seclint.git
```
Scan your project:
```bash
llm-seclint scan .
```
That's it. You'll see output like:
```text
src/app.py
!! L12 [LS001] Hardcoded API key assigned to 'OPENAI_API_KEY'
! L41 [LS006] Dynamic input passed to eval()
!! L55 [LS004] Dynamic output passed to subprocess.run() with shell=True
Found 3 issue(s): 2 critical, 1 high
Scanned in 0.03s
```
By default only **stable** (low false-positive) rules run. Add `--experimental`
to also enable the heuristic rule LS002 — see [Rule stability](#rule-stability).
## How It Works
```text
Source Code → AST Parsing → 10 Security Rules → Findings Report
├─ LS001: Hardcoded API Keys
├─ LS002: Prompt Injection
├─ LS003: SQL Injection via LLM
├─ LS004: Shell Injection via LLM
├─ LS005: Path Traversal via LLM
├─ LS006: Insecure Deserialization
├─ LS007: Template Injection (SSTI)
├─ LS008: XXE XML Parsing
├─ LS009: SSRF via LLM/user output
└─ LS010: Unpinned LLM Dependencies
```
llm-seclint parses your Python files into Abstract Syntax Trees and applies targeted security rules that understand LLM-specific data flows. No model access required, no runtime overhead -- just fast, deterministic analysis.
### Confirmed dataflow (taint analysis)
Beyond matching dangerous sinks, llm-seclint runs a lightweight **intra-procedural
taint engine** that tracks untrusted values — both **LLM output** (`openai`/
`litellm`/Anthropic completions) and **user input** (`input()`, `sys.argv`, Flask
`request.*`) — as they flow through assignments, attribute/subscript chains, and
string building within a function. When such a value reaches a sink, the finding
is annotated **`confirmed LLM→sink`** / **`USER→sink dataflow`** and carries a
structured `taint_source` field — so you can tell a real untrusted-input-to-
`eval()` flow from a merely-dynamic argument:
```python
resp = openai.chat.completions.create(model="gpt-4", messages=msgs)
code = resp.choices[0].message.content
eval(code) # LS006 — confirmed LLM→sink dataflow
```
Today this confirmation drives every sink rule — **LS004** (shell), **LS005**
(path), **LS006** (`eval`/`exec`/`pickle`), **LS007** (SSTI) and **LS008** (XXE);
it never suppresses or downgrades an existing finding (a merely-dynamic argument
is still reported). LS003 (SQL) graduated to stable on the back of this, and the
experimental **LS002** (prompt injection) annotates its taint-confirmed subset
too. Scope is
deliberately bounded: single-function, single-pass (no cross-function or
control-flow-graph precision yet).
## Where These Rules Come From
Every rule maps to a real insecure pattern I found while **manually auditing**
popular open-source LLM projects. Those audits are what motivated building
llm-seclint — to catch the same classes of issue automatically, before they
ship. Fixes were proposed upstream; several were declined in favor of the
maintainers' own approaches, but the underlying patterns are exactly what the
rules now detect.
| Project | Pattern found | Rule | Upstream PR |
|---------|---------------|------|-------------|
| [Dify](https://github.com/langgenius/dify) (100k★) | Unsafe `pickle.loads()` on database data | LS006 | found in audit |
| [Dify](https://github.com/langgenius/dify) (100k★) | `render_template_string()` SSTI in UNSAFE mode | LS007 | found in audit |
| [Dify](https://github.com/langgenius/dify) (100k★) | SQL f-string interpolation in VDB drivers | LS003 | found in audit |
| [LiteLLM](https://github.com/BerriAI/litellm) (20k★) | `exec()` in custom code guardrails | LS006 | [#24455](https://github.com/BerriAI/litellm/pull/24455) (closed) |
| [LiteLLM](https://github.com/BerriAI/litellm) (20k★) | Jinja2 SSTI in prompt managers | LS007 | [#24458](https://github.com/BerriAI/litellm/pull/24458) (closed) |
| [vllm](https://github.com/vllm-project/vllm) (45k★) | `eval()` on LLM output in example code | LS006 | [#37939](https://github.com/vllm-project/vllm/pull/37939) (closed) |
| [crewAI](https://github.com/crewAIInc/crewAI) (30k★) | XXE in XML parsing (use `defusedxml`) | LS008 | [#5005](https://github.com/crewAIInc/crewAI/pull/5005) (closed) |
## What It Detects
| Rule | Name | Severity | Stability | Description |
|------|------|----------|-----------|-------------|
| LS001 | `hardcoded-api-key` | CRITICAL | stable | Hardcoded API keys for LLM providers (OpenAI, Anthropic, xAI, etc.) |
| LS002 | `prompt-concat-injection` | HIGH | experimental | User input concatenated into LLM prompts via f-strings, `+`, or `.format()` |
| LS003 | `llm-to-sql-injection` | CRITICAL | stable | LLM/user input interpolated into SQL queries (taint-confirmed) |
| LS004 | `llm-to-shell-injection` | CRITICAL | stable | LLM output passed to `subprocess` / `os.system` |
| LS005 | `llm-to-path-traversal` | HIGH | stable | LLM output used as file paths |
| LS006 | `insecure-deserialization` | HIGH | stable | `eval` / `exec` / `pickle` / unsafe YAML on dynamic input |
| LS007 | `server-side-template-injection` | CRITICAL | stable | Dynamic content passed to template engine without sandboxing |
| LS008 | `xxe-xml-parsing` | HIGH | stable | XML parsing without protection against external entity attacks |
| LS009 | `llm-to-ssrf` | HIGH | stable | LLM/user input used as an outbound request URL (taint-confirmed SSRF) |
| LS010 | `unpinned-llm-dependency` | HIGH | stable | LLM dependency uses unpinned version constraint (e.g. `>=` without `<`), vulnerable to supply chain attacks |
### Rule stability
Rules are graded by how reliably they distinguish a real issue from noise:
- **stable** — sink-driven, pattern-unique, or **taint-confirmed** (a hardcoded
provider key, `eval()` on dynamic input, XXE-prone parsing, or a value the
[taint engine](#confirmed-dataflow-taint-analysis) traces from LLM/user input
into SQL). Low false-positive. **On by default.**
- **experimental** — relies on naming/keyword heuristics to *guess* whether data
is LLM- or user-derived (LS002 keys off words like "you are"). Higher
false-positive, so **off unless you pass `--experimental`**.
This keeps the default scan high-signal. Run `llm-seclint rules` to see each
rule's stability. LS003 graduated to stable once the taint engine could confirm
the data source. LS002 stays experimental on purpose: prompt injection's
dominant shape is a function *parameter* flowing into a prompt, which
intra-procedural taint cannot confirm — so the broad heuristic earns its keep
(and the taint-confirmable subset is annotated when you run `--experimental`).
### Examples
#### LS001: Hardcoded API Key
```python
# Bad - detected by llm-seclint
openai.api_key = "sk-proj-abc123..."
client = Anthropic(api_key="sk-ant-api03-...")
# Good
openai.api_key = os.environ["OPENAI_API_KEY"]
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
```
#### LS002: Prompt Injection
```python
# Bad - user input directly in prompt
prompt = f"You are a bot. User says: {user_input}"
# Good - separate message roles
messages = [
{"role": "system", "content": "You are a bot."},
{"role": "user", "content": user_input},
]
```
#### LS003: SQL Injection via LLM Output
```python
# Bad - LLM output in SQL
cursor.execute(f"SELECT * FROM users WHERE name = '{llm_response}'")
# Good - parameterized query
cursor.execute("SELECT * FROM users WHERE name = ?", (llm_response,))
```
#### LS004: Shell Injection via LLM Output
```python
# Bad - LLM output to shell
subprocess.run(llm_output, shell=True)
# Good - validate against allowlist
if command in ALLOWED_COMMANDS:
subprocess.run([command], check=False)
```
#### LS005: Path Traversal via LLM Output
```python
# Bad - LLM output as file path
with open(llm_response) as f: ...
# Good - validate against base directory
path = (ALLOWED_BASE / filename).resolve()
assert str(path).startswith(str(ALLOWED_BASE))
```
#### LS006: Insecure Deserialization
```python
# Bad - eval on LLM response
data = eval(llm_response)
# Good - use safe parsing
data = json.loads(llm_response)
```
#### LS007: Server-Side Template Injection
```python
# Bad - user input in template string
render_template_string(f"
Hello {user_input}
")
# Good - pass variables through context
render_template_string("
Hello {{ name }}
", name=user_input)
```
#### LS008: XXE XML Parsing
```python
# Bad - parsing untrusted XML without protection
tree = etree.parse(user_uploaded_file)
# Good - use defusedxml
from defusedxml.lxml import parse
tree = parse(user_uploaded_file)
```
#### LS010: Unpinned LLM Dependency
```text
# Bad - open-ended constraint allows malicious future releases (requirements.txt)
litellm>=1.64.0
dspy>=2.0
openai>=1.0
# Good - pinned to exact version
litellm==1.82.2
# Good - upper bound prevents auto-upgrade to compromised versions
litellm>=1.64.0,<1.83
```
This rule was motivated by the [litellm supply chain attack](https://blog.pypi.org/posts/2025-01-14-litellm-typosquat/) where `dspy` used `litellm>=1.64.0` and a compromised release was automatically pulled in. It scans `requirements.txt`, `pyproject.toml`, and `setup.cfg` for LLM packages with open-ended `>=` constraints.
## Framework Support
llm-seclint understands patterns from popular LLM frameworks:
- **LangChain** -- `PromptTemplate`, `ChatPromptTemplate.from_messages()`, `HumanMessagePromptTemplate`
- **LiteLLM** -- `litellm.completion()`, `litellm.acompletion()`
- **OpenAI SDK** -- `openai.ChatCompletion.create()`, `client.chat.completions.create()`
- **Anthropic SDK** -- `anthropic.Anthropic().messages.create()`
- **Flask/Jinja2** -- `render_template_string()`, `jinja2.Template()`
## OWASP LLM Top 10 Mapping
| OWASP LLM Top 10 | llm-seclint Rules |
|---|---|
| LLM01: Prompt Injection | LS002 |
| LLM02: Insecure Output Handling | LS003, LS004, LS005, LS006, LS007, LS009 |
| LLM06: Sensitive Information Disclosure | LS001 |
| A05:2021: Security Misconfiguration | LS008 (CWE-611) |
## Comparison
| Feature | llm-seclint | garak | LLM Guard | Guardrails |
|---------|:-----------:|:-----:|:---------:|:----------:|
| Analysis type | Static (AST) | Dynamic (probing) | Runtime (filter) | Runtime (guard) |
| Requires running model | No | Yes | Yes | Yes |
| CI/CD integration | Native | Manual | Manual | Manual |
| SARIF output | Yes | No | No | No |
| `# nosec` inline suppression | Yes | N/A | N/A | N/A |
| Pre-commit hook | Yes | No | No | No |
| Finds hardcoded keys | Yes | No | No | No |
| Finds prompt injection patterns | Yes | Tests for | Filters | Filters |
| Finds output handling flaws | Yes | No | No | No |
| Language | Python | Python | Python | Python |
## CLI Usage
```bash
# Scan current directory
llm-seclint scan .
# Scan specific files
llm-seclint scan src/ --include "*.py"
# JSON output
llm-seclint scan . --format json -o results.json
# SARIF output (for GitHub Code Scanning)
llm-seclint scan . --format sarif -o results.sarif
# Ignore specific rules
llm-seclint scan . --ignore LS001,LS002
# Set minimum severity
llm-seclint scan . --min-severity HIGH
# List all rules
llm-seclint rules
# Show version
llm-seclint --version
```
## Profiles
llm-seclint ships with two scan profiles:
- `--profile app` (default) — Full scan for LLM-powered applications
- `--profile engine` — Tuned for LLM inference engines (vllm, TGI, etc.).
Disables LS002 (prompt injection) since processing prompts is the engine's job.
### Experimental rules
By default only [stable](#rule-stability) rules run. Add `--experimental` to also
enable the heuristic rule LS002, or set `include_experimental: true` in
your config file:
```bash
llm-seclint scan . --experimental
```
### Inline Suppression
Suppress specific findings with `# nosec` comments:
```python
api_key = "sk-test-key-for-ci" # nosec LS001
```
## GitHub Code Scanning Integration
llm-seclint supports [SARIF](https://sarifweb.azurewebsites.net/) output for direct integration with GitHub Code Scanning. Add this to your GitHub Actions workflow:
```yaml
- name: Run llm-seclint
run: llm-seclint scan . --format sarif -o results.sarif
- name: Upload SARIF to GitHub
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
```
The SARIF carries `properties.security-severity` on each rule (so Code Scanning
renders and sorts severity), and **taint-confirmed** findings include
`properties.taint_source` + `confirmed_dataflow` on the result — so you can
filter the high-confidence "this really is LLM/user input reaching a sink"
findings in the GitHub Security tab.
## Pre-commit Hook
Add llm-seclint to your `.pre-commit-config.yaml`:
```yaml
repos:
- repo: https://github.com/xr843/llm-seclint
rev: v0.1.0
hooks:
- id: llm-seclint
```
## Configuration
Create a `.llm-seclint.yml` in your project root:
```yaml
# Patterns for files to include
include_patterns:
- "*.py"
# Patterns for files to exclude
exclude_patterns:
- "test_*.py"
- "*_test.py"
# Rules to ignore
ignore_rules:
- LS005
# Minimum severity to report (CRITICAL, HIGH, MEDIUM, LOW, INFO)
min_severity: MEDIUM
# Also run experimental (heuristic, higher false-positive) rules
include_experimental: false
```
## Installation for Development
```bash
git clone https://github.com/xr843/llm-seclint.git
cd llm-seclint
pip install -e ".[dev]"
pytest
```
## Contributing
Contributions are welcome! Here's how to add a new rule:
1. Create a new file in `src/llm_seclint/rules/python/`
2. Subclass `Rule` and implement the `check()` method
3. Register the rule in `src/llm_seclint/rules/registry.py`
4. Add tests in `tests/rules/`
5. Update this README
Please open an issue first to discuss significant changes.
## Roadmap
- **v0.2** *(shipped)*: Intra-procedural taint tracking (real LLM/user → sink
data-flow) — every sink rule confirms dataflow and LS003 graduated to stable
- **Next**: cross-function / control-flow-graph taint precision, so flows through
function parameters confirm (which would let LS002 graduate too)
- **v0.3**: JavaScript/TypeScript analyzer (LangChain.js, Vercel AI SDK)
- **v0.4**: Framework-specific rules (LangChain, LlamaIndex, Semantic Kernel)
- **v0.5**: Auto-fix suggestions with `--fix` flag
- **v1.0**: Stable API, VS Code extension
## License
MIT