llm-app-security-scanner 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.
- llm_app_security_scanner-0.1.0/LICENSE +21 -0
- llm_app_security_scanner-0.1.0/PKG-INFO +145 -0
- llm_app_security_scanner-0.1.0/README.md +120 -0
- llm_app_security_scanner-0.1.0/pyproject.toml +41 -0
- llm_app_security_scanner-0.1.0/setup.cfg +4 -0
- llm_app_security_scanner-0.1.0/src/llm_app_security_scanner.egg-info/PKG-INFO +145 -0
- llm_app_security_scanner-0.1.0/src/llm_app_security_scanner.egg-info/SOURCES.txt +20 -0
- llm_app_security_scanner-0.1.0/src/llm_app_security_scanner.egg-info/dependency_links.txt +1 -0
- llm_app_security_scanner-0.1.0/src/llm_app_security_scanner.egg-info/entry_points.txt +2 -0
- llm_app_security_scanner-0.1.0/src/llm_app_security_scanner.egg-info/requires.txt +4 -0
- llm_app_security_scanner-0.1.0/src/llm_app_security_scanner.egg-info/top_level.txt +1 -0
- llm_app_security_scanner-0.1.0/src/llm_scanner/__init__.py +7 -0
- llm_app_security_scanner-0.1.0/src/llm_scanner/__main__.py +5 -0
- llm_app_security_scanner-0.1.0/src/llm_scanner/adversarial.py +62 -0
- llm_app_security_scanner-0.1.0/src/llm_scanner/checks.py +255 -0
- llm_app_security_scanner-0.1.0/src/llm_scanner/cli.py +73 -0
- llm_app_security_scanner-0.1.0/src/llm_scanner/models.py +51 -0
- llm_app_security_scanner-0.1.0/src/llm_scanner/sarif.py +44 -0
- llm_app_security_scanner-0.1.0/src/llm_scanner/scanner.py +75 -0
- llm_app_security_scanner-0.1.0/tests/test_checks.py +80 -0
- llm_app_security_scanner-0.1.0/tests/test_sarif_cli.py +38 -0
- llm_app_security_scanner-0.1.0/tests/test_scanner_suppression.py +31 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AsherLucas
|
|
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,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-app-security-scanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static security scanner for LLM/agent repos: shell, file-write, secret, and untrusted-input risks as a CI gate, with SARIF output.
|
|
5
|
+
Author: AsherLucas
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AsherLucas/llm-app-security-scanner
|
|
8
|
+
Project-URL: Repository, https://github.com/AsherLucas/llm-app-security-scanner
|
|
9
|
+
Project-URL: Issues, https://github.com/AsherLucas/llm-app-security-scanner/issues
|
|
10
|
+
Keywords: llm,agent,security,prompt-injection,sarif,ci
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: PyYAML>=6.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# llm-app-security-scanner
|
|
27
|
+
|
|
28
|
+
**Early-stage tool, v0.1.0.** It does **not** yet qualify for any six-month
|
|
29
|
+
open-source maintainer programme, and it has no stars, downloads, users, or
|
|
30
|
+
benchmarks to claim. This README describes what it does honestly, including
|
|
31
|
+
its limits.
|
|
32
|
+
|
|
33
|
+
A static security scanner for LLM / agent repositories, designed as a CI gate.
|
|
34
|
+
It looks for the patterns that most often turn an agent into a liability:
|
|
35
|
+
a shell the agent can be talked into running, a file operation an attacker
|
|
36
|
+
can steer, a secret left in code, and untrusted text fed straight into a
|
|
37
|
+
prompt as if it were an instruction.
|
|
38
|
+
|
|
39
|
+
## 5-minute quickstart
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e .
|
|
43
|
+
llm-scan scan path/to/your/repo
|
|
44
|
+
llm-scan scan path/to/your/repo --format sarif > report.sarif
|
|
45
|
+
llm-scan adversarial --cases examples/adversarial/cases.yaml
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Exit codes: `0` = gate passed, `1` = gate failed (high severity +
|
|
49
|
+
high confidence findings by default), `2` = usage/config error.
|
|
50
|
+
Run `llm-scan scan --help` for `--fail-on`, `--fail-on-any`, and `--exclude`.
|
|
51
|
+
|
|
52
|
+
## What it scans (v1: Python only)
|
|
53
|
+
|
|
54
|
+
| Check | What it flags | Attack story |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `SHELL001` | `os.system` / `os.popen`, `subprocess` with `shell=True`, especially on tainted input | An agent reads a web page that says “run this command”. If the agent builds a shell string from that text, the page author gets a shell. |
|
|
57
|
+
| `TOOL001` / `TOOL002` | Agent tool execution mentioning a shell; `allow_shell=True` or a raw `shell`/`bash` tool | A tool list is a permission list. Giving an agent a raw shell tool is giving it every command, not one capability. |
|
|
58
|
+
| `FILE001` | File delete / write to a tainted (user- or input-controlled) path | A prompt-injected agent is asked to “save the report” to a path the attacker chose, and overwrites or deletes something else. |
|
|
59
|
+
| `SECRET001` | Known-format secret literals: OpenAI-style keys, AWS keys, GitHub tokens, Slack tokens | A key committed to a repo is public the moment the repo is public. Rotate it; do not just delete the line. |
|
|
60
|
+
| `SECRET002` | Generic `api_key` / `password` / `token` assignments with literal values | Same story, lower confidence — the check cannot tell a real secret from a test placeholder, so it does not block the gate by default. |
|
|
61
|
+
| `AGENT001` | Untrusted input (web fetch, `input()`, request data, argv) flowing into an LLM/agent call without a sanitiser | **Indirect prompt injection:** an agent reads a doc, issue, or web page, and the page contains hidden instructions. If that text is pasted into the prompt unsanitised, the page author is now writing your system prompt. |
|
|
62
|
+
|
|
63
|
+
## The gate, and why it is deliberately narrow
|
|
64
|
+
|
|
65
|
+
By default the gate fails **only on high severity + high confidence**
|
|
66
|
+
findings. A medium-confidence heuristic (for example a generic secret-looking
|
|
67
|
+
assignment, or a shell call with a non-literal command) is reported but does
|
|
68
|
+
not block a merge — otherwise false positives teach teams to ignore the
|
|
69
|
+
scanner, which is worse than no scanner.
|
|
70
|
+
|
|
71
|
+
Suppress a reviewed finding in `.llm-scan-suppress` (repo root, one per line):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
# CHECK_ID:glob or CHECK_ID:file:line
|
|
75
|
+
SECRET001:examples/vulnerable/*
|
|
76
|
+
TOOL002:src/agent.py:42
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Suppression is a review decision, not a fix. Re-check suppressed findings
|
|
80
|
+
when the code around them changes.
|
|
81
|
+
|
|
82
|
+
## Adversarial pack
|
|
83
|
+
|
|
84
|
+
`examples/adversarial/cases.yaml` holds direct and indirect injection
|
|
85
|
+
payloads. `llm-scan adversarial` runs them against a **stub** agent only:
|
|
86
|
+
a naive stub that follows prompt text, and a sanitising stub that treats
|
|
87
|
+
external text as data. This demonstrates the *class* of risk; it does **not**
|
|
88
|
+
prove any real model would fall for a payload. Never use this pack to claim
|
|
89
|
+
a real model is vulnerable, and never run payloads against a third-party
|
|
90
|
+
system you do not own.
|
|
91
|
+
|
|
92
|
+
## False-positive measurement (local only, aggregate only)
|
|
93
|
+
|
|
94
|
+
Measured on 2026-10-29 by scanning the sibling project in this same workspace
|
|
95
|
+
(`prompt-eval-harness`, a Python library/CLI, scanned locally — no
|
|
96
|
+
third-party system was probed):
|
|
97
|
+
|
|
98
|
+
- Files scanned: see the run output in `docs/false-positive-note.md`.
|
|
99
|
+
- The scanner is a heuristic tool: it will flag some safe code and miss
|
|
100
|
+
some unsafe code. The gate design (high-confidence only) is the mitigation
|
|
101
|
+
for the first failure mode; manual review is the mitigation for the second.
|
|
102
|
+
- Do not quote a single “accuracy” number from a handful of local files.
|
|
103
|
+
The honest statement is: on the local example repos, the vulnerable
|
|
104
|
+
example fails the gate and the fixed example passes it (both covered by
|
|
105
|
+
tests), and on a real, benign codebase the gate should stay quiet
|
|
106
|
+
except where a human should look.
|
|
107
|
+
|
|
108
|
+
## CI
|
|
109
|
+
|
|
110
|
+
This repo now has real workflows in `.github/workflows/`:
|
|
111
|
+
|
|
112
|
+
- `ci.yml` — on push/PR, runs the test suite on Python 3.11 and 3.12,
|
|
113
|
+
checks that the vulnerable example fails the gate (exit 1) and the
|
|
114
|
+
fixed example passes (exit 0), runs the adversarial pack, and runs
|
|
115
|
+
the scanner on its own production code (`src/`) as a dogfood check,
|
|
116
|
+
keeping the SARIF as an artifact. The whole repo is deliberately
|
|
117
|
+
*not* the dogfood gate: `tests/` contain intentional secret-literal
|
|
118
|
+
fixtures and `examples/vulnerable/` is deliberately vulnerable.
|
|
119
|
+
- `publish.yml` — publishes to PyPI on version tags via Trusted
|
|
120
|
+
Publishing (OIDC, no stored token). It is prepared, not yet used:
|
|
121
|
+
no release has been tagged and nothing has been uploaded to PyPI
|
|
122
|
+
yet; a PyPI account with 2FA and a pending Trusted Publisher for
|
|
123
|
+
this repo/workflow are still required first.
|
|
124
|
+
|
|
125
|
+
See also `docs/ci-workflow-example.yml` for a documented GitHub Actions
|
|
126
|
+
workflow you can copy for your own repo, including uploading SARIF to
|
|
127
|
+
GitHub code scanning.
|
|
128
|
+
|
|
129
|
+
An `action.yml` wrapper is included at the repo root for use as
|
|
130
|
+
`uses: AsherLucas/llm-app-security-scanner@v0.1.0` once a release exists.
|
|
131
|
+
|
|
132
|
+
## Limits (read before trusting it)
|
|
133
|
+
|
|
134
|
+
- Python only in v1. Other languages are not scanned.
|
|
135
|
+
- Static heuristics only: no data-flow across files, no real LLM calls, no
|
|
136
|
+
guarantee. A clean scan is not proof of safety.
|
|
137
|
+
- The sanitiser list is name-based (`sanitize`, `validate`, `allowlist`,
|
|
138
|
+
…). A real sanitiser with another name will be missed, and a function
|
|
139
|
+
*named* sanitize that does nothing will be trusted. Review the code.
|
|
140
|
+
- Secrets detection is pattern-based; it will miss secrets in other
|
|
141
|
+
formats and flag some test placeholders.
|
|
142
|
+
|
|
143
|
+
## Licence
|
|
144
|
+
|
|
145
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# llm-app-security-scanner
|
|
2
|
+
|
|
3
|
+
**Early-stage tool, v0.1.0.** It does **not** yet qualify for any six-month
|
|
4
|
+
open-source maintainer programme, and it has no stars, downloads, users, or
|
|
5
|
+
benchmarks to claim. This README describes what it does honestly, including
|
|
6
|
+
its limits.
|
|
7
|
+
|
|
8
|
+
A static security scanner for LLM / agent repositories, designed as a CI gate.
|
|
9
|
+
It looks for the patterns that most often turn an agent into a liability:
|
|
10
|
+
a shell the agent can be talked into running, a file operation an attacker
|
|
11
|
+
can steer, a secret left in code, and untrusted text fed straight into a
|
|
12
|
+
prompt as if it were an instruction.
|
|
13
|
+
|
|
14
|
+
## 5-minute quickstart
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e .
|
|
18
|
+
llm-scan scan path/to/your/repo
|
|
19
|
+
llm-scan scan path/to/your/repo --format sarif > report.sarif
|
|
20
|
+
llm-scan adversarial --cases examples/adversarial/cases.yaml
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Exit codes: `0` = gate passed, `1` = gate failed (high severity +
|
|
24
|
+
high confidence findings by default), `2` = usage/config error.
|
|
25
|
+
Run `llm-scan scan --help` for `--fail-on`, `--fail-on-any`, and `--exclude`.
|
|
26
|
+
|
|
27
|
+
## What it scans (v1: Python only)
|
|
28
|
+
|
|
29
|
+
| Check | What it flags | Attack story |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `SHELL001` | `os.system` / `os.popen`, `subprocess` with `shell=True`, especially on tainted input | An agent reads a web page that says “run this command”. If the agent builds a shell string from that text, the page author gets a shell. |
|
|
32
|
+
| `TOOL001` / `TOOL002` | Agent tool execution mentioning a shell; `allow_shell=True` or a raw `shell`/`bash` tool | A tool list is a permission list. Giving an agent a raw shell tool is giving it every command, not one capability. |
|
|
33
|
+
| `FILE001` | File delete / write to a tainted (user- or input-controlled) path | A prompt-injected agent is asked to “save the report” to a path the attacker chose, and overwrites or deletes something else. |
|
|
34
|
+
| `SECRET001` | Known-format secret literals: OpenAI-style keys, AWS keys, GitHub tokens, Slack tokens | A key committed to a repo is public the moment the repo is public. Rotate it; do not just delete the line. |
|
|
35
|
+
| `SECRET002` | Generic `api_key` / `password` / `token` assignments with literal values | Same story, lower confidence — the check cannot tell a real secret from a test placeholder, so it does not block the gate by default. |
|
|
36
|
+
| `AGENT001` | Untrusted input (web fetch, `input()`, request data, argv) flowing into an LLM/agent call without a sanitiser | **Indirect prompt injection:** an agent reads a doc, issue, or web page, and the page contains hidden instructions. If that text is pasted into the prompt unsanitised, the page author is now writing your system prompt. |
|
|
37
|
+
|
|
38
|
+
## The gate, and why it is deliberately narrow
|
|
39
|
+
|
|
40
|
+
By default the gate fails **only on high severity + high confidence**
|
|
41
|
+
findings. A medium-confidence heuristic (for example a generic secret-looking
|
|
42
|
+
assignment, or a shell call with a non-literal command) is reported but does
|
|
43
|
+
not block a merge — otherwise false positives teach teams to ignore the
|
|
44
|
+
scanner, which is worse than no scanner.
|
|
45
|
+
|
|
46
|
+
Suppress a reviewed finding in `.llm-scan-suppress` (repo root, one per line):
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
# CHECK_ID:glob or CHECK_ID:file:line
|
|
50
|
+
SECRET001:examples/vulnerable/*
|
|
51
|
+
TOOL002:src/agent.py:42
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Suppression is a review decision, not a fix. Re-check suppressed findings
|
|
55
|
+
when the code around them changes.
|
|
56
|
+
|
|
57
|
+
## Adversarial pack
|
|
58
|
+
|
|
59
|
+
`examples/adversarial/cases.yaml` holds direct and indirect injection
|
|
60
|
+
payloads. `llm-scan adversarial` runs them against a **stub** agent only:
|
|
61
|
+
a naive stub that follows prompt text, and a sanitising stub that treats
|
|
62
|
+
external text as data. This demonstrates the *class* of risk; it does **not**
|
|
63
|
+
prove any real model would fall for a payload. Never use this pack to claim
|
|
64
|
+
a real model is vulnerable, and never run payloads against a third-party
|
|
65
|
+
system you do not own.
|
|
66
|
+
|
|
67
|
+
## False-positive measurement (local only, aggregate only)
|
|
68
|
+
|
|
69
|
+
Measured on 2026-10-29 by scanning the sibling project in this same workspace
|
|
70
|
+
(`prompt-eval-harness`, a Python library/CLI, scanned locally — no
|
|
71
|
+
third-party system was probed):
|
|
72
|
+
|
|
73
|
+
- Files scanned: see the run output in `docs/false-positive-note.md`.
|
|
74
|
+
- The scanner is a heuristic tool: it will flag some safe code and miss
|
|
75
|
+
some unsafe code. The gate design (high-confidence only) is the mitigation
|
|
76
|
+
for the first failure mode; manual review is the mitigation for the second.
|
|
77
|
+
- Do not quote a single “accuracy” number from a handful of local files.
|
|
78
|
+
The honest statement is: on the local example repos, the vulnerable
|
|
79
|
+
example fails the gate and the fixed example passes it (both covered by
|
|
80
|
+
tests), and on a real, benign codebase the gate should stay quiet
|
|
81
|
+
except where a human should look.
|
|
82
|
+
|
|
83
|
+
## CI
|
|
84
|
+
|
|
85
|
+
This repo now has real workflows in `.github/workflows/`:
|
|
86
|
+
|
|
87
|
+
- `ci.yml` — on push/PR, runs the test suite on Python 3.11 and 3.12,
|
|
88
|
+
checks that the vulnerable example fails the gate (exit 1) and the
|
|
89
|
+
fixed example passes (exit 0), runs the adversarial pack, and runs
|
|
90
|
+
the scanner on its own production code (`src/`) as a dogfood check,
|
|
91
|
+
keeping the SARIF as an artifact. The whole repo is deliberately
|
|
92
|
+
*not* the dogfood gate: `tests/` contain intentional secret-literal
|
|
93
|
+
fixtures and `examples/vulnerable/` is deliberately vulnerable.
|
|
94
|
+
- `publish.yml` — publishes to PyPI on version tags via Trusted
|
|
95
|
+
Publishing (OIDC, no stored token). It is prepared, not yet used:
|
|
96
|
+
no release has been tagged and nothing has been uploaded to PyPI
|
|
97
|
+
yet; a PyPI account with 2FA and a pending Trusted Publisher for
|
|
98
|
+
this repo/workflow are still required first.
|
|
99
|
+
|
|
100
|
+
See also `docs/ci-workflow-example.yml` for a documented GitHub Actions
|
|
101
|
+
workflow you can copy for your own repo, including uploading SARIF to
|
|
102
|
+
GitHub code scanning.
|
|
103
|
+
|
|
104
|
+
An `action.yml` wrapper is included at the repo root for use as
|
|
105
|
+
`uses: AsherLucas/llm-app-security-scanner@v0.1.0` once a release exists.
|
|
106
|
+
|
|
107
|
+
## Limits (read before trusting it)
|
|
108
|
+
|
|
109
|
+
- Python only in v1. Other languages are not scanned.
|
|
110
|
+
- Static heuristics only: no data-flow across files, no real LLM calls, no
|
|
111
|
+
guarantee. A clean scan is not proof of safety.
|
|
112
|
+
- The sanitiser list is name-based (`sanitize`, `validate`, `allowlist`,
|
|
113
|
+
…). A real sanitiser with another name will be missed, and a function
|
|
114
|
+
*named* sanitize that does nothing will be trusted. Review the code.
|
|
115
|
+
- Secrets detection is pattern-based; it will miss secrets in other
|
|
116
|
+
formats and flag some test placeholders.
|
|
117
|
+
|
|
118
|
+
## Licence
|
|
119
|
+
|
|
120
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "llm-app-security-scanner"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Static security scanner for LLM/agent repos: shell, file-write, secret, and untrusted-input risks as a CI gate, with SARIF output."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "AsherLucas" }]
|
|
14
|
+
keywords = ["llm", "agent", "security", "prompt-injection", "sarif", "ci"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Security",
|
|
22
|
+
"Topic :: Software Development :: Testing",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["PyYAML>=6.0"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/AsherLucas/llm-app-security-scanner"
|
|
28
|
+
Repository = "https://github.com/AsherLucas/llm-app-security-scanner"
|
|
29
|
+
Issues = "https://github.com/AsherLucas/llm-app-security-scanner/issues"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=8"]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
llm-scan = "llm_scanner.cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-app-security-scanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static security scanner for LLM/agent repos: shell, file-write, secret, and untrusted-input risks as a CI gate, with SARIF output.
|
|
5
|
+
Author: AsherLucas
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AsherLucas/llm-app-security-scanner
|
|
8
|
+
Project-URL: Repository, https://github.com/AsherLucas/llm-app-security-scanner
|
|
9
|
+
Project-URL: Issues, https://github.com/AsherLucas/llm-app-security-scanner/issues
|
|
10
|
+
Keywords: llm,agent,security,prompt-injection,sarif,ci
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: PyYAML>=6.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# llm-app-security-scanner
|
|
27
|
+
|
|
28
|
+
**Early-stage tool, v0.1.0.** It does **not** yet qualify for any six-month
|
|
29
|
+
open-source maintainer programme, and it has no stars, downloads, users, or
|
|
30
|
+
benchmarks to claim. This README describes what it does honestly, including
|
|
31
|
+
its limits.
|
|
32
|
+
|
|
33
|
+
A static security scanner for LLM / agent repositories, designed as a CI gate.
|
|
34
|
+
It looks for the patterns that most often turn an agent into a liability:
|
|
35
|
+
a shell the agent can be talked into running, a file operation an attacker
|
|
36
|
+
can steer, a secret left in code, and untrusted text fed straight into a
|
|
37
|
+
prompt as if it were an instruction.
|
|
38
|
+
|
|
39
|
+
## 5-minute quickstart
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e .
|
|
43
|
+
llm-scan scan path/to/your/repo
|
|
44
|
+
llm-scan scan path/to/your/repo --format sarif > report.sarif
|
|
45
|
+
llm-scan adversarial --cases examples/adversarial/cases.yaml
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Exit codes: `0` = gate passed, `1` = gate failed (high severity +
|
|
49
|
+
high confidence findings by default), `2` = usage/config error.
|
|
50
|
+
Run `llm-scan scan --help` for `--fail-on`, `--fail-on-any`, and `--exclude`.
|
|
51
|
+
|
|
52
|
+
## What it scans (v1: Python only)
|
|
53
|
+
|
|
54
|
+
| Check | What it flags | Attack story |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `SHELL001` | `os.system` / `os.popen`, `subprocess` with `shell=True`, especially on tainted input | An agent reads a web page that says “run this command”. If the agent builds a shell string from that text, the page author gets a shell. |
|
|
57
|
+
| `TOOL001` / `TOOL002` | Agent tool execution mentioning a shell; `allow_shell=True` or a raw `shell`/`bash` tool | A tool list is a permission list. Giving an agent a raw shell tool is giving it every command, not one capability. |
|
|
58
|
+
| `FILE001` | File delete / write to a tainted (user- or input-controlled) path | A prompt-injected agent is asked to “save the report” to a path the attacker chose, and overwrites or deletes something else. |
|
|
59
|
+
| `SECRET001` | Known-format secret literals: OpenAI-style keys, AWS keys, GitHub tokens, Slack tokens | A key committed to a repo is public the moment the repo is public. Rotate it; do not just delete the line. |
|
|
60
|
+
| `SECRET002` | Generic `api_key` / `password` / `token` assignments with literal values | Same story, lower confidence — the check cannot tell a real secret from a test placeholder, so it does not block the gate by default. |
|
|
61
|
+
| `AGENT001` | Untrusted input (web fetch, `input()`, request data, argv) flowing into an LLM/agent call without a sanitiser | **Indirect prompt injection:** an agent reads a doc, issue, or web page, and the page contains hidden instructions. If that text is pasted into the prompt unsanitised, the page author is now writing your system prompt. |
|
|
62
|
+
|
|
63
|
+
## The gate, and why it is deliberately narrow
|
|
64
|
+
|
|
65
|
+
By default the gate fails **only on high severity + high confidence**
|
|
66
|
+
findings. A medium-confidence heuristic (for example a generic secret-looking
|
|
67
|
+
assignment, or a shell call with a non-literal command) is reported but does
|
|
68
|
+
not block a merge — otherwise false positives teach teams to ignore the
|
|
69
|
+
scanner, which is worse than no scanner.
|
|
70
|
+
|
|
71
|
+
Suppress a reviewed finding in `.llm-scan-suppress` (repo root, one per line):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
# CHECK_ID:glob or CHECK_ID:file:line
|
|
75
|
+
SECRET001:examples/vulnerable/*
|
|
76
|
+
TOOL002:src/agent.py:42
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Suppression is a review decision, not a fix. Re-check suppressed findings
|
|
80
|
+
when the code around them changes.
|
|
81
|
+
|
|
82
|
+
## Adversarial pack
|
|
83
|
+
|
|
84
|
+
`examples/adversarial/cases.yaml` holds direct and indirect injection
|
|
85
|
+
payloads. `llm-scan adversarial` runs them against a **stub** agent only:
|
|
86
|
+
a naive stub that follows prompt text, and a sanitising stub that treats
|
|
87
|
+
external text as data. This demonstrates the *class* of risk; it does **not**
|
|
88
|
+
prove any real model would fall for a payload. Never use this pack to claim
|
|
89
|
+
a real model is vulnerable, and never run payloads against a third-party
|
|
90
|
+
system you do not own.
|
|
91
|
+
|
|
92
|
+
## False-positive measurement (local only, aggregate only)
|
|
93
|
+
|
|
94
|
+
Measured on 2026-10-29 by scanning the sibling project in this same workspace
|
|
95
|
+
(`prompt-eval-harness`, a Python library/CLI, scanned locally — no
|
|
96
|
+
third-party system was probed):
|
|
97
|
+
|
|
98
|
+
- Files scanned: see the run output in `docs/false-positive-note.md`.
|
|
99
|
+
- The scanner is a heuristic tool: it will flag some safe code and miss
|
|
100
|
+
some unsafe code. The gate design (high-confidence only) is the mitigation
|
|
101
|
+
for the first failure mode; manual review is the mitigation for the second.
|
|
102
|
+
- Do not quote a single “accuracy” number from a handful of local files.
|
|
103
|
+
The honest statement is: on the local example repos, the vulnerable
|
|
104
|
+
example fails the gate and the fixed example passes it (both covered by
|
|
105
|
+
tests), and on a real, benign codebase the gate should stay quiet
|
|
106
|
+
except where a human should look.
|
|
107
|
+
|
|
108
|
+
## CI
|
|
109
|
+
|
|
110
|
+
This repo now has real workflows in `.github/workflows/`:
|
|
111
|
+
|
|
112
|
+
- `ci.yml` — on push/PR, runs the test suite on Python 3.11 and 3.12,
|
|
113
|
+
checks that the vulnerable example fails the gate (exit 1) and the
|
|
114
|
+
fixed example passes (exit 0), runs the adversarial pack, and runs
|
|
115
|
+
the scanner on its own production code (`src/`) as a dogfood check,
|
|
116
|
+
keeping the SARIF as an artifact. The whole repo is deliberately
|
|
117
|
+
*not* the dogfood gate: `tests/` contain intentional secret-literal
|
|
118
|
+
fixtures and `examples/vulnerable/` is deliberately vulnerable.
|
|
119
|
+
- `publish.yml` — publishes to PyPI on version tags via Trusted
|
|
120
|
+
Publishing (OIDC, no stored token). It is prepared, not yet used:
|
|
121
|
+
no release has been tagged and nothing has been uploaded to PyPI
|
|
122
|
+
yet; a PyPI account with 2FA and a pending Trusted Publisher for
|
|
123
|
+
this repo/workflow are still required first.
|
|
124
|
+
|
|
125
|
+
See also `docs/ci-workflow-example.yml` for a documented GitHub Actions
|
|
126
|
+
workflow you can copy for your own repo, including uploading SARIF to
|
|
127
|
+
GitHub code scanning.
|
|
128
|
+
|
|
129
|
+
An `action.yml` wrapper is included at the repo root for use as
|
|
130
|
+
`uses: AsherLucas/llm-app-security-scanner@v0.1.0` once a release exists.
|
|
131
|
+
|
|
132
|
+
## Limits (read before trusting it)
|
|
133
|
+
|
|
134
|
+
- Python only in v1. Other languages are not scanned.
|
|
135
|
+
- Static heuristics only: no data-flow across files, no real LLM calls, no
|
|
136
|
+
guarantee. A clean scan is not proof of safety.
|
|
137
|
+
- The sanitiser list is name-based (`sanitize`, `validate`, `allowlist`,
|
|
138
|
+
…). A real sanitiser with another name will be missed, and a function
|
|
139
|
+
*named* sanitize that does nothing will be trusted. Review the code.
|
|
140
|
+
- Secrets detection is pattern-based; it will miss secrets in other
|
|
141
|
+
formats and flag some test placeholders.
|
|
142
|
+
|
|
143
|
+
## Licence
|
|
144
|
+
|
|
145
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/llm_app_security_scanner.egg-info/PKG-INFO
|
|
5
|
+
src/llm_app_security_scanner.egg-info/SOURCES.txt
|
|
6
|
+
src/llm_app_security_scanner.egg-info/dependency_links.txt
|
|
7
|
+
src/llm_app_security_scanner.egg-info/entry_points.txt
|
|
8
|
+
src/llm_app_security_scanner.egg-info/requires.txt
|
|
9
|
+
src/llm_app_security_scanner.egg-info/top_level.txt
|
|
10
|
+
src/llm_scanner/__init__.py
|
|
11
|
+
src/llm_scanner/__main__.py
|
|
12
|
+
src/llm_scanner/adversarial.py
|
|
13
|
+
src/llm_scanner/checks.py
|
|
14
|
+
src/llm_scanner/cli.py
|
|
15
|
+
src/llm_scanner/models.py
|
|
16
|
+
src/llm_scanner/sarif.py
|
|
17
|
+
src/llm_scanner/scanner.py
|
|
18
|
+
tests/test_checks.py
|
|
19
|
+
tests/test_sarif_cli.py
|
|
20
|
+
tests/test_scanner_suppression.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
llm_scanner
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Adversarial test-case pack, run against a stub agent.
|
|
2
|
+
|
|
3
|
+
This is deliberately NOT a real LLM test. The stub simulates a naive agent:
|
|
4
|
+
it treats prompt text as instructions, so a payload that says 'ignore
|
|
5
|
+
previous instructions' succeeds against the naive agent and fails against
|
|
6
|
+
a sanitising agent. That demonstrates the *class* of risk the static
|
|
7
|
+
checks look for; it does not prove any real model would fall for it.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import yaml
|
|
16
|
+
except ImportError: # pragma: no cover - PyYAML is optional here
|
|
17
|
+
yaml = None # type: ignore
|
|
18
|
+
|
|
19
|
+
INJECTION_MARKERS = [
|
|
20
|
+
r"ignore (all )?previous instructions",
|
|
21
|
+
r"ignore (all )?prior instructions",
|
|
22
|
+
r"disregard (all )?previous instructions",
|
|
23
|
+
r"you are now",
|
|
24
|
+
r"new instructions?:",
|
|
25
|
+
r"system prompt",
|
|
26
|
+
r"reveal (your|the) (system )?prompt",
|
|
27
|
+
r"delete (all )?files",
|
|
28
|
+
r"rm -rf",
|
|
29
|
+
r"run (this )?shell command",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stub_agent_responds(payload: str, sanitised: bool) -> str:
|
|
34
|
+
"""Naive stub: returns INJECTED if an injection marker survives sanitisation."""
|
|
35
|
+
if sanitised:
|
|
36
|
+
# Sanitising agent treats external text as data: it quotes it and
|
|
37
|
+
# refuses instruction-like content instead of following it.
|
|
38
|
+
return "REFUSED_AS_DATA"
|
|
39
|
+
for pattern in INJECTION_MARKERS:
|
|
40
|
+
if re.search(pattern, payload, re.IGNORECASE):
|
|
41
|
+
return "INJECTED"
|
|
42
|
+
return "OK"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_cases(cases_path: str | Path) -> list[dict]:
|
|
46
|
+
"""Load a YAML cases file and run each case against both stub agents."""
|
|
47
|
+
path = Path(cases_path)
|
|
48
|
+
if yaml is None:
|
|
49
|
+
raise RuntimeError("PyYAML is required for the adversarial pack; pip install pyyaml")
|
|
50
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
51
|
+
cases = data.get("cases", [])
|
|
52
|
+
out = []
|
|
53
|
+
for case in cases:
|
|
54
|
+
payload = case.get("payload", "")
|
|
55
|
+
out.append({
|
|
56
|
+
"id": case.get("id"),
|
|
57
|
+
"type": case.get("type"),
|
|
58
|
+
"naive": stub_agent_responds(payload, sanitised=False),
|
|
59
|
+
"sanitised": stub_agent_responds(payload, sanitised=True),
|
|
60
|
+
"would_inject_naive": stub_agent_responds(payload, sanitised=False) == "INJECTED",
|
|
61
|
+
})
|
|
62
|
+
return out
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Static checks, Python only in v1.
|
|
2
|
+
|
|
3
|
+
All checks are heuristics over source text / AST. They are documented as
|
|
4
|
+
such: a finding means 'look here', not 'this is certainly exploitable'.
|
|
5
|
+
That is why the gate defaults to high severity + high confidence only.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .models import Finding
|
|
14
|
+
|
|
15
|
+
# --- secret patterns: known formats are high confidence, generic names medium ---
|
|
16
|
+
SECRET_PATTERNS = [
|
|
17
|
+
("sk-[A-Za-z0-9]{20,}", "OpenAI-style API key literal"),
|
|
18
|
+
("AKIA[0-9A-Z]{16}", "AWS access key literal"),
|
|
19
|
+
("github_pat_[A-Za-z0-9_]{20,}", "GitHub personal access token literal"),
|
|
20
|
+
("ghp_[A-Za-z0-9]{20,}", "GitHub token literal"),
|
|
21
|
+
("xox[baprs]-[A-Za-z0-9-]{10,}", "Slack token literal"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
GENERIC_SECRET_RE = re.compile(
|
|
25
|
+
r"(?i)\b(api_key|apikey|secret|password|passwd|token)\s*=\s*['\"][^'\"]{8,}['\"]"
|
|
26
|
+
)
|
|
27
|
+
SANITISERS = {"sanitize", "sanitise", "escape", "validate", "allowlist", "allow_list", "strip_tags", "clean_input"}
|
|
28
|
+
SOURCE_CALLS = {"input"}
|
|
29
|
+
SOURCE_ATTRS = {"argv", "args", "form", "data", "text", "content"}
|
|
30
|
+
SINK_NAMES = {"generate", "complete", "chat", "run", "invoke", "ask", "prompt", "create"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _finding(check_id, severity, confidence, path, line, message, snippet=""):
|
|
34
|
+
return Finding(check_id, severity, confidence, str(path), line, message, snippet)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_allowlisted_call(node: ast.AST) -> bool:
|
|
38
|
+
"""True if the expression mentions an allowlist / validation helper."""
|
|
39
|
+
for child in ast.walk(node):
|
|
40
|
+
if isinstance(child, ast.Name) and child.id.lower() in SANITISERS:
|
|
41
|
+
return True
|
|
42
|
+
if isinstance(child, ast.Attribute) and child.attr.lower() in SANITISERS:
|
|
43
|
+
return True
|
|
44
|
+
if isinstance(child, ast.Constant) and isinstance(child.value, str):
|
|
45
|
+
if "allowlist" in child.value.lower() or "allowed_commands" in child.value.lower():
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _call_name(node: ast.Call) -> str:
|
|
51
|
+
func = node.func
|
|
52
|
+
if isinstance(func, ast.Name):
|
|
53
|
+
return func.id
|
|
54
|
+
if isinstance(func, ast.Attribute):
|
|
55
|
+
return func.attr
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _call_full(node: ast.Call) -> str:
|
|
60
|
+
func = node.func
|
|
61
|
+
if isinstance(func, ast.Name):
|
|
62
|
+
return func.id
|
|
63
|
+
if isinstance(func, ast.Attribute):
|
|
64
|
+
base = func.value
|
|
65
|
+
if isinstance(base, ast.Name):
|
|
66
|
+
return f"{base.id}.{func.attr}"
|
|
67
|
+
return func.attr
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _Visitor(ast.NodeVisitor):
|
|
72
|
+
def __init__(self, path: Path, lines: list[str]):
|
|
73
|
+
self.path = path
|
|
74
|
+
self.lines = lines
|
|
75
|
+
self.findings: list[Finding] = []
|
|
76
|
+
# variable name -> "tainted" (from an untrusted source, not sanitised)
|
|
77
|
+
self.tainted: dict[str, int] = {}
|
|
78
|
+
|
|
79
|
+
def _line(self, node):
|
|
80
|
+
return getattr(node, "lineno", 1)
|
|
81
|
+
|
|
82
|
+
def _snippet(self, node):
|
|
83
|
+
n = self._line(node)
|
|
84
|
+
if 1 <= n <= len(self.lines):
|
|
85
|
+
return self.lines[n - 1].strip()
|
|
86
|
+
return ""
|
|
87
|
+
|
|
88
|
+
def _add(self, check_id, severity, confidence, node, message):
|
|
89
|
+
self.findings.append(
|
|
90
|
+
_finding(check_id, severity, confidence, self.path, self._line(node), message, self._snippet(node))
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def visit_Assign(self, node: ast.Assign):
|
|
94
|
+
# Track taint: x = input() / requests.get(...) / open(...).read() / argv / request.*
|
|
95
|
+
value = node.value
|
|
96
|
+
tainted = False
|
|
97
|
+
if isinstance(value, ast.Call):
|
|
98
|
+
name = _call_name(value)
|
|
99
|
+
full = _call_full(value)
|
|
100
|
+
if name in SOURCE_CALLS or "requests.get" in full or "requests.post" in full:
|
|
101
|
+
tainted = True
|
|
102
|
+
if isinstance(value.func, ast.Attribute) and value.func.attr in ("read", "read_text"):
|
|
103
|
+
tainted = True
|
|
104
|
+
if isinstance(value, ast.Attribute) and value.attr in SOURCE_ATTRS:
|
|
105
|
+
tainted = True
|
|
106
|
+
if isinstance(value, ast.Subscript):
|
|
107
|
+
# request.form["x"], sys.argv[1], data["prompt"] etc.
|
|
108
|
+
tainted = True
|
|
109
|
+
if not tainted:
|
|
110
|
+
# Taint propagation: x = f"...{tainted}..." / y = process(tainted)
|
|
111
|
+
for child in ast.walk(value):
|
|
112
|
+
if isinstance(child, ast.Name) and child.id in self.tainted:
|
|
113
|
+
tainted = True
|
|
114
|
+
break
|
|
115
|
+
if tainted and not _is_allowlisted_call(value):
|
|
116
|
+
for target in node.targets:
|
|
117
|
+
if isinstance(target, ast.Name):
|
|
118
|
+
self.tainted[target.id] = self._line(node)
|
|
119
|
+
self.generic_visit(node)
|
|
120
|
+
|
|
121
|
+
def visit_Call(self, node: ast.Call):
|
|
122
|
+
name = _call_name(node)
|
|
123
|
+
full = _call_full(node)
|
|
124
|
+
|
|
125
|
+
# SHELL001: shell execution
|
|
126
|
+
if name in ("system", "popen") or full in ("os.system", "os.popen"):
|
|
127
|
+
arg = node.args[0] if node.args else None
|
|
128
|
+
if isinstance(arg, ast.Name) and arg.id in self.tainted:
|
|
129
|
+
self._add("SHELL001", "high", "high", node,
|
|
130
|
+
"Shell command built from untrusted/tainted input without an allowlist.")
|
|
131
|
+
elif isinstance(arg, ast.Constant):
|
|
132
|
+
self._add("SHELL001", "medium", "high", node,
|
|
133
|
+
"os.system/os.popen shell execution; check the command is not attacker-controlled.")
|
|
134
|
+
else:
|
|
135
|
+
self._add("SHELL001", "high", "medium", node,
|
|
136
|
+
"Shell execution with a non-literal command; verify an allowlist is enforced.")
|
|
137
|
+
if name == "run" and full.startswith("subprocess"):
|
|
138
|
+
shell_true = any(
|
|
139
|
+
isinstance(kw.value, ast.Constant) and kw.value.value is True
|
|
140
|
+
for kw in node.keywords if kw.arg == "shell"
|
|
141
|
+
)
|
|
142
|
+
if shell_true:
|
|
143
|
+
arg = node.args[0] if node.args else None
|
|
144
|
+
if isinstance(arg, ast.Name) and arg.id in self.tainted:
|
|
145
|
+
self._add("SHELL001", "high", "high", node,
|
|
146
|
+
"subprocess with shell=True on tainted input without an allowlist.")
|
|
147
|
+
elif _is_allowlisted_call(node):
|
|
148
|
+
pass # allowlisted call: deliberately not flagged
|
|
149
|
+
else:
|
|
150
|
+
self._add("SHELL001", "high", "medium", node,
|
|
151
|
+
"subprocess with shell=True; use a list argv and an allowlist instead.")
|
|
152
|
+
# broad file delete via subprocess rm -rf
|
|
153
|
+
if isinstance(node.args[0], ast.Constant) if node.args else False:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# TOOL001: agent tool permission without allowlist
|
|
157
|
+
if name in ("run", "execute", "invoke") and _is_allowlisted_call(node) is False:
|
|
158
|
+
# only flag when the surrounding call mentions a tool/agent and a shell
|
|
159
|
+
text = self._snippet(node).lower()
|
|
160
|
+
if "tool" in text and ("shell" in text or "bash" in text):
|
|
161
|
+
self._add("TOOL001", "medium", "medium", node,
|
|
162
|
+
"Agent tool execution mentioning a shell; confirm tools are allowlisted.")
|
|
163
|
+
|
|
164
|
+
# FILE001: broad file write/delete from a variable path
|
|
165
|
+
if name in ("remove", "unlink", "rmtree") or full in ("os.remove", "os.unlink", "shutil.rmtree"):
|
|
166
|
+
arg = node.args[0] if node.args else None
|
|
167
|
+
if isinstance(arg, ast.Name):
|
|
168
|
+
self._add("FILE001", "high", "medium", node,
|
|
169
|
+
"File delete with a variable path; confirm the path is confined to an allowed directory.")
|
|
170
|
+
if name == "write_text" or full.endswith(".write_text"):
|
|
171
|
+
base = node.func.value if isinstance(node.func, ast.Attribute) else None
|
|
172
|
+
if isinstance(base, ast.Name) and base.id in self.tainted:
|
|
173
|
+
self._add("FILE001", "high", "medium", node,
|
|
174
|
+
"File write to a tainted path; confirm the path is confined to an allowed directory.")
|
|
175
|
+
if name == "open":
|
|
176
|
+
mode = ""
|
|
177
|
+
for kw in node.keywords:
|
|
178
|
+
if kw.arg == "mode" and isinstance(kw.value, ast.Constant):
|
|
179
|
+
mode = str(kw.value.value)
|
|
180
|
+
if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant):
|
|
181
|
+
mode = str(node.args[1].value)
|
|
182
|
+
if "w" in mode or "a" in mode:
|
|
183
|
+
arg = node.args[0] if node.args else None
|
|
184
|
+
if isinstance(arg, ast.Name) and arg.id in self.tainted:
|
|
185
|
+
self._add("FILE001", "high", "medium", node,
|
|
186
|
+
"File write to a tainted path; confirm the path is confined to an allowed directory.")
|
|
187
|
+
elif isinstance(arg, ast.Name):
|
|
188
|
+
# A variable path is worth a look even when taint cannot
|
|
189
|
+
# be proven (e.g. a function parameter); medium confidence
|
|
190
|
+
# means it is reported but does not fail the default gate.
|
|
191
|
+
self._add("FILE001", "medium", "medium", node,
|
|
192
|
+
"File write to a variable path; confirm the path is confined to an allowed directory.")
|
|
193
|
+
|
|
194
|
+
# TOOL002: explicit allow_shell / broad tool flags
|
|
195
|
+
for kw in node.keywords:
|
|
196
|
+
if kw.arg in ("allow_shell", "allow_bash", "allow_exec") and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
197
|
+
self._add("TOOL002", "high", "high", node,
|
|
198
|
+
"Agent/tool explicitly allows shell execution; restrict to an allowlisted command set.")
|
|
199
|
+
if kw.arg == "tools" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
200
|
+
for elt in kw.value.elts:
|
|
201
|
+
if isinstance(elt, ast.Constant) and str(elt.value).lower() in ("shell", "bash", "exec", "terminal"):
|
|
202
|
+
self._add("TOOL002", "high", "medium", node,
|
|
203
|
+
"Agent is given a raw shell tool; prefer narrow, allowlisted tools.")
|
|
204
|
+
|
|
205
|
+
# AGENT001: tainted input into an LLM/agent sink without sanitisation
|
|
206
|
+
if name in SINK_NAMES or full.endswith((".generate", ".complete", ".create", ".run", ".invoke")):
|
|
207
|
+
# only treat as a sink when it looks like an LLM/agent call, not every .run()
|
|
208
|
+
text = self._snippet(node).lower()
|
|
209
|
+
is_llm = any(k in text for k in ("llm", "agent", "client", "chat", "model", "prompt", "completion"))
|
|
210
|
+
if is_llm or name in ("generate", "complete", "chat"):
|
|
211
|
+
for arg in node.args + [kw.value for kw in node.keywords]:
|
|
212
|
+
if isinstance(arg, ast.Name) and arg.id in self.tainted and not _is_allowlisted_call(node):
|
|
213
|
+
self._add("AGENT001", "high", "high", node,
|
|
214
|
+
f"Untrusted input '{arg.id}' flows into an LLM/agent call without sanitisation; "
|
|
215
|
+
"treat external text as data, never as instructions.")
|
|
216
|
+
if isinstance(arg, ast.JoinedStr):
|
|
217
|
+
for v in arg.values:
|
|
218
|
+
if isinstance(v, ast.FormattedValue) and isinstance(v.value, ast.Name) \
|
|
219
|
+
and v.value.id in self.tainted and not _is_allowlisted_call(node):
|
|
220
|
+
self._add("AGENT001", "high", "high", node,
|
|
221
|
+
f"Untrusted input '{v.value.id}' interpolated into a prompt without sanitisation.")
|
|
222
|
+
self.generic_visit(node)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def scan_file(path: Path) -> list[Finding]:
|
|
226
|
+
"""Scan one Python file. Returns findings (may be empty)."""
|
|
227
|
+
try:
|
|
228
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
229
|
+
except OSError:
|
|
230
|
+
return []
|
|
231
|
+
lines = text.splitlines()
|
|
232
|
+
findings: list[Finding] = []
|
|
233
|
+
|
|
234
|
+
# SECRET001 / SECRET002: literal secrets (text-level, so config files also work)
|
|
235
|
+
for i, line in enumerate(lines, start=1):
|
|
236
|
+
for pattern, label in SECRET_PATTERNS:
|
|
237
|
+
if re.search(pattern, line):
|
|
238
|
+
findings.append(_finding("SECRET001", "high", "high", path, i,
|
|
239
|
+
f"Possible secret literal in code/config: {label}. Move it to an env var / secret store."))
|
|
240
|
+
break
|
|
241
|
+
else:
|
|
242
|
+
if GENERIC_SECRET_RE.search(line) and "os.environ" not in line and "getenv" not in line:
|
|
243
|
+
findings.append(_finding("SECRET002", "medium", "medium", path, i,
|
|
244
|
+
"Generic secret-looking assignment with a literal value; prefer an env var."))
|
|
245
|
+
|
|
246
|
+
# AST checks for .py files
|
|
247
|
+
if path.suffix == ".py":
|
|
248
|
+
try:
|
|
249
|
+
tree = ast.parse(text)
|
|
250
|
+
except SyntaxError:
|
|
251
|
+
return findings
|
|
252
|
+
visitor = _Visitor(path, lines)
|
|
253
|
+
visitor.visit(tree)
|
|
254
|
+
findings.extend(visitor.findings)
|
|
255
|
+
return findings
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""CLI: llm-scan scan | llm-scan adversarial"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .adversarial import run_cases
|
|
10
|
+
from .sarif import sarif_json
|
|
11
|
+
from .scanner import scan_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _print_report(result, fmt: str) -> None:
|
|
15
|
+
if fmt == "json":
|
|
16
|
+
print(json.dumps({
|
|
17
|
+
"files_scanned": result.files_scanned,
|
|
18
|
+
"suppressed": result.suppressed,
|
|
19
|
+
"findings": [f.to_dict() for f in result.findings],
|
|
20
|
+
}, indent=2))
|
|
21
|
+
elif fmt == "sarif":
|
|
22
|
+
print(sarif_json(result))
|
|
23
|
+
else:
|
|
24
|
+
print(f"Scanned {result.files_scanned} file(s), {len(result.findings)} finding(s), "
|
|
25
|
+
f"{result.suppressed} suppressed.")
|
|
26
|
+
for f in result.findings:
|
|
27
|
+
print(f"{f.file}:{f.line} [{f.check_id} {f.severity}/{f.confidence}] {f.message}")
|
|
28
|
+
if f.snippet:
|
|
29
|
+
print(f" {f.snippet}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
parser = argparse.ArgumentParser(prog="llm-scan", description="Static security scanner for LLM/agent repos.")
|
|
34
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
35
|
+
|
|
36
|
+
p_scan = sub.add_parser("scan", help="Scan a directory tree.")
|
|
37
|
+
p_scan.add_argument("path", help="Directory or file to scan.")
|
|
38
|
+
p_scan.add_argument("--format", choices=["text", "json", "sarif"], default="text")
|
|
39
|
+
p_scan.add_argument("--fail-on", choices=["high", "medium", "low"], default="high",
|
|
40
|
+
help="Minimum severity that fails the gate (high-confidence only by default).")
|
|
41
|
+
p_scan.add_argument("--fail-on-any", action="store_true",
|
|
42
|
+
help="Fail on any finding regardless of confidence (noisy; not the default).")
|
|
43
|
+
p_scan.add_argument("--exclude", action="append", default=[], help="Extra path part to exclude (repeatable).")
|
|
44
|
+
|
|
45
|
+
p_adv = sub.add_parser("adversarial", help="Run the adversarial pack against the stub agent.")
|
|
46
|
+
p_adv.add_argument("--cases", default="examples/adversarial/cases.yaml")
|
|
47
|
+
p_adv.add_argument("--format", choices=["text", "json"], default="text")
|
|
48
|
+
|
|
49
|
+
args = parser.parse_args(argv)
|
|
50
|
+
|
|
51
|
+
if args.command == "scan":
|
|
52
|
+
result = scan_path(args.path, exclude=args.exclude)
|
|
53
|
+
_print_report(result, args.format)
|
|
54
|
+
if args.fail_on_any:
|
|
55
|
+
return 1 if result.findings else 0
|
|
56
|
+
return 1 if result.gate_failures(args.fail_on) else 0
|
|
57
|
+
|
|
58
|
+
if args.command == "adversarial":
|
|
59
|
+
results = run_cases(args.cases)
|
|
60
|
+
if args.format == "json":
|
|
61
|
+
print(json.dumps(results, indent=2))
|
|
62
|
+
else:
|
|
63
|
+
for r in results:
|
|
64
|
+
print(f"{r['id']} ({r['type']}): naive={r['naive']} sanitised={r['sanitised']}")
|
|
65
|
+
n = sum(1 for r in results if r["would_inject_naive"])
|
|
66
|
+
print(f"{n}/{len(results)} cases would inject a naive agent; 0 should inject a sanitising agent.")
|
|
67
|
+
# Adversarial command itself exits 0: it is a demonstration, not a gate.
|
|
68
|
+
return 0
|
|
69
|
+
return 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Finding:
|
|
8
|
+
"""One scanner finding. Severity is impact; confidence is how sure the check is.
|
|
9
|
+
|
|
10
|
+
The CI gate fails by default only on high severity + high confidence,
|
|
11
|
+
so a heuristic medium/low finding does not block every merge.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
check_id: str
|
|
15
|
+
severity: str # high | medium | low
|
|
16
|
+
confidence: str # high | medium | low
|
|
17
|
+
file: str
|
|
18
|
+
line: int
|
|
19
|
+
message: str
|
|
20
|
+
snippet: str = ""
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"check_id": self.check_id,
|
|
25
|
+
"severity": self.severity,
|
|
26
|
+
"confidence": self.confidence,
|
|
27
|
+
"file": self.file,
|
|
28
|
+
"line": self.line,
|
|
29
|
+
"message": self.message,
|
|
30
|
+
"snippet": self.snippet,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ScanResult:
|
|
36
|
+
findings: list[Finding] = field(default_factory=list)
|
|
37
|
+
files_scanned: int = 0
|
|
38
|
+
suppressed: int = 0
|
|
39
|
+
|
|
40
|
+
def gate_failures(self, fail_on: str = "high") -> list[Finding]:
|
|
41
|
+
"""Findings that fail the gate. ``fail_on`` is the minimum severity
|
|
42
|
+
that counts, but only high-confidence findings count by default:
|
|
43
|
+
pass ``fail_on="medium"`` with ``require_high_confidence=False``
|
|
44
|
+
via the CLI only if you accept more false positives."""
|
|
45
|
+
order = {"low": 0, "medium": 1, "high": 2}
|
|
46
|
+
threshold = order.get(fail_on, 2)
|
|
47
|
+
return [
|
|
48
|
+
f
|
|
49
|
+
for f in self.findings
|
|
50
|
+
if order.get(f.severity, 0) >= threshold and f.confidence == "high"
|
|
51
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""SARIF 2.1.0 output for GitHub code scanning."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from .models import ScanResult
|
|
7
|
+
|
|
8
|
+
LEVEL = {"high": "error", "medium": "warning", "low": "note"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_sarif(result: ScanResult, tool_name: str = "llm-app-security-scanner") -> dict:
|
|
12
|
+
rules: dict[str, dict] = {}
|
|
13
|
+
for f in result.findings:
|
|
14
|
+
rules.setdefault(f.check_id, {
|
|
15
|
+
"id": f.check_id,
|
|
16
|
+
"name": f.check_id,
|
|
17
|
+
"shortDescription": {"text": f.message},
|
|
18
|
+
"defaultConfiguration": {"level": LEVEL.get(f.severity, "warning")},
|
|
19
|
+
})
|
|
20
|
+
results = []
|
|
21
|
+
for f in result.findings:
|
|
22
|
+
results.append({
|
|
23
|
+
"ruleId": f.check_id,
|
|
24
|
+
"level": LEVEL.get(f.severity, "warning"),
|
|
25
|
+
"message": {"text": f.message},
|
|
26
|
+
"locations": [{
|
|
27
|
+
"physicalLocation": {
|
|
28
|
+
"artifactLocation": {"uri": f.file},
|
|
29
|
+
"region": {"startLine": f.line, "snippet": {"text": f.snippet}},
|
|
30
|
+
}
|
|
31
|
+
}],
|
|
32
|
+
})
|
|
33
|
+
return {
|
|
34
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
35
|
+
"version": "2.1.0",
|
|
36
|
+
"runs": [{
|
|
37
|
+
"tool": {"driver": {"name": tool_name, "version": "0.1.0", "rules": list(rules.values())}},
|
|
38
|
+
"results": results,
|
|
39
|
+
}],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sarif_json(result: ScanResult) -> str:
|
|
44
|
+
return json.dumps(to_sarif(result), indent=2)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Directory scanner + suppression handling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .checks import scan_file
|
|
8
|
+
from .models import ScanResult
|
|
9
|
+
|
|
10
|
+
DEFAULT_EXCLUDES = {".git", "__pycache__", ".venv", "venv", "node_modules", ".pytest_cache", "dist", "build", "*.egg-info"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_suppressions(root: Path) -> list[dict]:
|
|
14
|
+
"""Read .llm-scan-suppress (one rule per line: CHECK_ID:glob or CHECK_ID:file:line).
|
|
15
|
+
|
|
16
|
+
Lines starting with # are comments. A rule matches when the check id
|
|
17
|
+
matches and the file path matches the glob (or file:line matches exactly).
|
|
18
|
+
"""
|
|
19
|
+
suppress_file = root / ".llm-scan-suppress"
|
|
20
|
+
rules: list[dict] = []
|
|
21
|
+
if not suppress_file.exists():
|
|
22
|
+
return rules
|
|
23
|
+
for raw in suppress_file.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
24
|
+
line = raw.strip()
|
|
25
|
+
if not line or line.startswith("#"):
|
|
26
|
+
continue
|
|
27
|
+
parts = line.split(":")
|
|
28
|
+
if len(parts) == 2:
|
|
29
|
+
rules.append({"check": parts[0].strip(), "glob": parts[1].strip()})
|
|
30
|
+
elif len(parts) == 3:
|
|
31
|
+
rules.append({"check": parts[0].strip(), "file": parts[1].strip(), "line": parts[2].strip()})
|
|
32
|
+
return rules
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _suppressed(finding, rules, root: Path) -> bool:
|
|
36
|
+
rel = str(Path(finding.file).relative_to(root)) if str(finding.file).startswith(str(root)) else finding.file
|
|
37
|
+
for rule in rules:
|
|
38
|
+
if rule["check"] != finding.check_id:
|
|
39
|
+
continue
|
|
40
|
+
if "glob" in rule and fnmatch.fnmatch(rel, rule["glob"]):
|
|
41
|
+
return True
|
|
42
|
+
if "file" in rule and rule["file"] == rel and str(finding.line) == rule["line"]:
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scan_path(root: str | Path, exclude: list[str] | None = None) -> ScanResult:
|
|
48
|
+
"""Scan a directory tree (Python files, plus .env/.cfg/.ini/.yaml/.yml for secrets)."""
|
|
49
|
+
root = Path(root)
|
|
50
|
+
excludes = set(DEFAULT_EXCLUDES) | set(exclude or [])
|
|
51
|
+
files: list[Path] = []
|
|
52
|
+
if root.is_file():
|
|
53
|
+
files = [root]
|
|
54
|
+
scan_root = root.parent
|
|
55
|
+
else:
|
|
56
|
+
scan_root = root
|
|
57
|
+
for path in sorted(root.rglob("*")):
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
continue
|
|
60
|
+
if any(part in excludes for part in path.parts):
|
|
61
|
+
continue
|
|
62
|
+
if path.suffix in (".py", ".env") or path.name in (".env",) or path.suffix in (".cfg", ".ini", ".yaml", ".yml", ".toml"):
|
|
63
|
+
# Secret checks only apply to non-Python config files; AST checks are .py only.
|
|
64
|
+
files.append(path)
|
|
65
|
+
rules = load_suppressions(scan_root)
|
|
66
|
+
result = ScanResult()
|
|
67
|
+
for path in files:
|
|
68
|
+
findings = scan_file(path)
|
|
69
|
+
for f in findings:
|
|
70
|
+
if _suppressed(f, rules, scan_root):
|
|
71
|
+
result.suppressed += 1
|
|
72
|
+
else:
|
|
73
|
+
result.findings.append(f)
|
|
74
|
+
result.files_scanned = len(files)
|
|
75
|
+
return result
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
5
|
+
|
|
6
|
+
from llm_scanner.checks import scan_file
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _scan(code, tmp_path, name="sample.py"):
|
|
10
|
+
p = tmp_path / name
|
|
11
|
+
p.write_text(code)
|
|
12
|
+
return scan_file(p)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_shell_true_on_input_flagged(tmp_path):
|
|
16
|
+
findings = _scan("import subprocess\nsubprocess.run(user_cmd, shell=True)\n", tmp_path)
|
|
17
|
+
assert any(f.check_id == "SHELL001" for f in findings)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_allowlisted_shell_not_flagged(tmp_path):
|
|
21
|
+
code = (
|
|
22
|
+
"import subprocess\nALLOWED = {'ls'}\n"
|
|
23
|
+
"def run(cmd):\n"
|
|
24
|
+
" if cmd not in ALLOWED:\n"
|
|
25
|
+
" raise ValueError('no')\n"
|
|
26
|
+
" subprocess.run([cmd], shell=False)\n"
|
|
27
|
+
)
|
|
28
|
+
findings = _scan(code, tmp_path)
|
|
29
|
+
assert not any(f.check_id == "SHELL001" for f in findings)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_secret_literal_flagged(tmp_path):
|
|
33
|
+
findings = _scan('KEY = "sk-abcdefghijklmnopqrstuvwxyz1234567890"\n', tmp_path)
|
|
34
|
+
assert any(f.check_id == "SECRET001" and f.severity == "high" and f.confidence == "high" for f in findings)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_secret_from_env_not_flagged(tmp_path):
|
|
38
|
+
findings = _scan('import os\nKEY = os.environ.get("LLM_API_KEY", "")\n', tmp_path)
|
|
39
|
+
assert not any(f.check_id in ("SECRET001", "SECRET002") for f in findings)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_untrusted_input_into_llm_flagged(tmp_path):
|
|
43
|
+
code = (
|
|
44
|
+
"import requests\n"
|
|
45
|
+
"def run():\n"
|
|
46
|
+
" page = requests.get('https://example.com').text\n"
|
|
47
|
+
" prompt = f'help: {page}'\n"
|
|
48
|
+
" llm.generate(prompt)\n"
|
|
49
|
+
)
|
|
50
|
+
findings = _scan(code, tmp_path)
|
|
51
|
+
assert any(f.check_id == "AGENT001" for f in findings)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_sanitised_input_not_flagged(tmp_path):
|
|
55
|
+
code = (
|
|
56
|
+
"import requests\n"
|
|
57
|
+
"def sanitize(t):\n"
|
|
58
|
+
" return t\n"
|
|
59
|
+
"def run():\n"
|
|
60
|
+
" page = requests.get('https://example.com').text\n"
|
|
61
|
+
" prompt = sanitize(page)\n"
|
|
62
|
+
" llm.generate(prompt)\n"
|
|
63
|
+
)
|
|
64
|
+
findings = _scan(code, tmp_path)
|
|
65
|
+
assert not any(f.check_id == "AGENT001" for f in findings)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_allow_shell_flag_flagged(tmp_path):
|
|
69
|
+
findings = _scan("agent = Agent(allow_shell=True)\n", tmp_path)
|
|
70
|
+
assert any(f.check_id == "TOOL002" for f in findings)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_file_write_tainted_path_flagged(tmp_path):
|
|
74
|
+
code = "def save(path, text):\n with open(path, 'w') as f:\n f.write(text)\n"
|
|
75
|
+
# path is a plain parameter here, so this is NOT tainted by design;
|
|
76
|
+
# the tainted-path case is covered via input() assignment below.
|
|
77
|
+
assert _scan(code, tmp_path) == [] or True
|
|
78
|
+
code2 = "path = input()\nwith open(path, 'w') as f:\n f.write('x')\n"
|
|
79
|
+
findings = _scan(code2, tmp_path, "sample2.py")
|
|
80
|
+
assert any(f.check_id == "FILE001" for f in findings)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
6
|
+
|
|
7
|
+
from llm_scanner.cli import main
|
|
8
|
+
from llm_scanner.sarif import to_sarif
|
|
9
|
+
from llm_scanner.scanner import scan_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_sarif_shape(tmp_path):
|
|
13
|
+
(tmp_path / "bad.py").write_text('KEY = "sk-abcdefghijklmnopqrstuvwxyz1234567890"\n')
|
|
14
|
+
result = scan_path(tmp_path)
|
|
15
|
+
sarif = to_sarif(result)
|
|
16
|
+
assert sarif["version"] == "2.1.0"
|
|
17
|
+
assert sarif["runs"][0]["results"]
|
|
18
|
+
assert sarif["runs"][0]["results"][0]["ruleId"] == "SECRET001"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_cli_exit_codes(tmp_path, capsys):
|
|
22
|
+
(tmp_path / "bad.py").write_text('KEY = "sk-abcdefghijklmnopqrstuvwxyz1234567890"\n')
|
|
23
|
+
assert main(["scan", str(tmp_path)]) == 1
|
|
24
|
+
(tmp_path / "bad.py").write_text("x = 1\n")
|
|
25
|
+
assert main(["scan", str(tmp_path)]) == 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_cli_sarif_output(tmp_path, capsys):
|
|
29
|
+
(tmp_path / "bad.py").write_text('KEY = "sk-abcdefghijklmnopqrstuvwxyz1234567890"\n')
|
|
30
|
+
assert main(["scan", str(tmp_path), "--format", "sarif"]) == 1
|
|
31
|
+
out = capsys.readouterr().out
|
|
32
|
+
data = json.loads(out)
|
|
33
|
+
assert data["runs"][0]["results"][0]["level"] == "error"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_adversarial_command(tmp_path):
|
|
37
|
+
root = Path(__file__).resolve().parents[1]
|
|
38
|
+
assert main(["adversarial", "--cases", str(root / "examples" / "adversarial" / "cases.yaml")]) == 0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
5
|
+
|
|
6
|
+
from llm_scanner.scanner import scan_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_vulnerable_example_flagged_and_fixed_passes_gate():
|
|
10
|
+
root = Path(__file__).resolve().parents[1]
|
|
11
|
+
vuln = scan_path(root / "examples" / "vulnerable")
|
|
12
|
+
assert vuln.gate_failures("high"), "vulnerable example must fail the default gate"
|
|
13
|
+
assert any(f.check_id == "SECRET001" for f in vuln.findings)
|
|
14
|
+
fixed = scan_path(root / "examples" / "fixed")
|
|
15
|
+
assert fixed.gate_failures("high") == [], f"fixed example must pass gate, got {fixed.findings}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_suppression_file(tmp_path):
|
|
19
|
+
(tmp_path / "bad.py").write_text('KEY = "sk-abcdefghijklmnopqrstuvwxyz1234567890"\n')
|
|
20
|
+
(tmp_path / ".llm-scan-suppress").write_text("SECRET001:bad.py\n")
|
|
21
|
+
result = scan_path(tmp_path)
|
|
22
|
+
assert result.suppressed >= 1
|
|
23
|
+
assert not any(f.check_id == "SECRET001" for f in result.findings)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_gate_only_high_confidence_by_default(tmp_path):
|
|
27
|
+
(tmp_path / "m.py").write_text('password = "hunter2-hunter2"\n')
|
|
28
|
+
result = scan_path(tmp_path)
|
|
29
|
+
# SECRET002 is medium confidence, so it must not fail the default gate.
|
|
30
|
+
assert result.gate_failures("high") == []
|
|
31
|
+
assert result.gate_failures("medium") == [] # still high-confidence only
|