dscan-security 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.
- dscan_security-0.1.0/.gitignore +43 -0
- dscan_security-0.1.0/PKG-INFO +168 -0
- dscan_security-0.1.0/README.md +136 -0
- dscan_security-0.1.0/docs/dashboard.png +0 -0
- dscan_security-0.1.0/dscan/__init__.py +20 -0
- dscan_security-0.1.0/dscan/cli.py +263 -0
- dscan_security-0.1.0/dscan/dashboard/__init__.py +10 -0
- dscan_security-0.1.0/dscan/dashboard/server.py +184 -0
- dscan_security-0.1.0/dscan/dashboard/templates/index.html +233 -0
- dscan_security-0.1.0/dscan/redactor.py +186 -0
- dscan_security-0.1.0/dscan/scanner.py +327 -0
- dscan_security-0.1.0/dscan/tracer.py +126 -0
- dscan_security-0.1.0/dscan/trail.py +388 -0
- dscan_security-0.1.0/dscan/watcher.py +339 -0
- dscan_security-0.1.0/pyproject.toml +75 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Secrets and local config
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
*.pem
|
|
5
|
+
*.key
|
|
6
|
+
dscan.yml # local user config — never commit
|
|
7
|
+
|
|
8
|
+
# dscan traces — never commit user data
|
|
9
|
+
~/.dscan/
|
|
10
|
+
.dscan/
|
|
11
|
+
traces/
|
|
12
|
+
|
|
13
|
+
# Python
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.pyc
|
|
16
|
+
*.py[cod]
|
|
17
|
+
*$py.class
|
|
18
|
+
.venv/
|
|
19
|
+
venv/
|
|
20
|
+
env/
|
|
21
|
+
build/
|
|
22
|
+
dist/
|
|
23
|
+
.eggs/
|
|
24
|
+
*.egg
|
|
25
|
+
*.egg-info/
|
|
26
|
+
|
|
27
|
+
# Tooling caches
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
.coverage.*
|
|
31
|
+
htmlcov/
|
|
32
|
+
coverage.xml
|
|
33
|
+
.mypy_cache/
|
|
34
|
+
.ruff_cache/
|
|
35
|
+
.uv/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.vscode/
|
|
39
|
+
.idea/
|
|
40
|
+
.cursor/
|
|
41
|
+
|
|
42
|
+
# OS
|
|
43
|
+
.DS_Store
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dscan-security
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open source agent security suite — intercept, scan, and test your AI agents
|
|
5
|
+
Project-URL: Homepage, https://deepscan.security
|
|
6
|
+
Project-URL: Repository, https://github.com/DeepScan-Security/dscan
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/DeepScan-Security/dscan/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/DeepScan-Security/dscan/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: DeepScan <security@deepscan.security>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: agent-security,ai-agents,devsecops,llm-security,mcp,prompt-injection,security
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
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: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: aiofiles>=23.0
|
|
22
|
+
Requires-Dist: aiohttp>=3.9
|
|
23
|
+
Requires-Dist: anthropic>=0.25
|
|
24
|
+
Requires-Dist: click>=8.0
|
|
25
|
+
Requires-Dist: jinja2>=3.0
|
|
26
|
+
Requires-Dist: rich>=13.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# dscan
|
|
34
|
+
|
|
35
|
+
[](https://github.com/DeepScan-Security/dscan/actions/workflows/ci.yml)
|
|
36
|
+

|
|
37
|
+
[](https://pypi.org/project/dscan-security/)
|
|
38
|
+
[](https://pypi.org/project/dscan-security/)
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
An open source agent security suite. Trace and redact your agent's tool
|
|
42
|
+
calls, scan its prompts and MCP configs, and detect suspicious call
|
|
43
|
+
chains — then inspect everything in a local dashboard.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install dscan-security
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The package installs as `dscan-security`; the import path and CLI stay
|
|
50
|
+
`dscan` (`import dscan`, `dscan --help`).
|
|
51
|
+
|
|
52
|
+

|
|
53
|
+
|
|
54
|
+
## Quick start
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from dscan import watch
|
|
58
|
+
|
|
59
|
+
@watch
|
|
60
|
+
async def my_agent(task: str):
|
|
61
|
+
... # your agent code, unchanged
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run your agent, then open the dashboard:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
dscan dashboard
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Tool calls are written as redacted NDJSON to `~/.dscan/traces/` (override
|
|
71
|
+
with `DSCAN_TRACES_DIR`). Wrapping any tool with `@watch.tool` traces its
|
|
72
|
+
params and result; if you use the Anthropic SDK, `@watch` also intercepts
|
|
73
|
+
`messages.create()` and traces each `tool_use` block.
|
|
74
|
+
|
|
75
|
+
## What dscan catches
|
|
76
|
+
|
|
77
|
+
| Capability | What it detects |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `@watch` (tracing) | Every tool call — name, params, result, duration — written as redacted NDJSON; flags calls whose params contain secrets |
|
|
80
|
+
| Redaction | AWS keys, Anthropic/OpenAI/GitHub/Stripe tokens, JWTs, emails, phone numbers, SSNs, Luhn-valid credit cards, database-URL passwords, high-entropy secrets |
|
|
81
|
+
| `dscan scan` | Permissive prompts, injection-prone prompts, hardcoded secrets, excessive tool scope; unverified, overprivileged, and credential-leaking MCP servers |
|
|
82
|
+
| `dscan trail` | Suspicious tool-call sequences across an agent run (exfiltration, recon, injection relay, data staging, goal drift) |
|
|
83
|
+
| `dscan dashboard` | Local web UI: sessions, per-call timeline, redacted values, secrets flags, and trail findings |
|
|
84
|
+
|
|
85
|
+
## Commands
|
|
86
|
+
|
|
87
|
+
### `dscan scan`
|
|
88
|
+
|
|
89
|
+
Static analysis of system prompts and MCP config files in a directory.
|
|
90
|
+
Exits `1` if any high-severity issue is found.
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
$ dscan scan ./prompts
|
|
94
|
+
HIGH (1)
|
|
95
|
+
┏━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
|
|
96
|
+
┃ Rule ┃ File ┃ Line ┃ Message ┃ Snippet ┃
|
|
97
|
+
┡━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
|
|
98
|
+
│ SP003 │ sp.txt │ 2 │ Hardcoded secret in prompt │ ...sk-ant-api03... │
|
|
99
|
+
└───────┴────────┴──────┴─────────────────────────────┴───────────────────┘
|
|
100
|
+
✗ 1 high-severity finding(s).
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `dscan watch`
|
|
104
|
+
|
|
105
|
+
`@watch` is a decorator, not a runtime command. This prints a reminder:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
$ dscan watch
|
|
109
|
+
⚠ Add @watch to your agent function. See README for usage.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `dscan trail`
|
|
113
|
+
|
|
114
|
+
Reads trace files and reports suspicious tool-call chains, grouped by
|
|
115
|
+
severity. Exits `1` on any high or critical finding. Supports
|
|
116
|
+
`--min-severity {low|medium|high|critical}` and `--json`.
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
$ dscan trail ~/.dscan/traces/
|
|
120
|
+
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
|
|
121
|
+
┃ Severity ┃ Pattern ┃ Tools Involved ┃ Message ┃ Confidence ┃
|
|
122
|
+
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
|
|
123
|
+
│ CRITICAL │ INJECTION_RELAY │ search_web → send_email │ untrusted... │ 85% │
|
|
124
|
+
│ HIGH │ EXFIL_SEQUENCE │ read_file → send_email │ read then... │ 80% │
|
|
125
|
+
└──────────┴─────────────────┴─────────────────────────┴───────────────┴────────────┘
|
|
126
|
+
2 findings across 5 tool calls analysed
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `dscan dashboard`
|
|
130
|
+
|
|
131
|
+
Serves the local web UI at `localhost:4321`. `--port` sets the port;
|
|
132
|
+
`--no-open` skips opening a browser.
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
$ dscan dashboard --no-open
|
|
136
|
+
✓ Dashboard at http://127.0.0.1:4321 (Ctrl-C to stop)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How trail works
|
|
140
|
+
|
|
141
|
+
`dscan trail` reads your trace files and looks at the order of tool
|
|
142
|
+
calls, not just each call on its own. It groups calls by agent run and
|
|
143
|
+
reports five kinds of suspicious sequences: reading sensitive data and
|
|
144
|
+
then sending it somewhere external (exfiltration), probing for
|
|
145
|
+
permissions and then running a tool the agent never declared
|
|
146
|
+
(reconnaissance), pulling in untrusted web or email content and then
|
|
147
|
+
immediately sending or executing (injection relay), reading several
|
|
148
|
+
different sensitive sources in a row with nothing sent in between (data
|
|
149
|
+
staging), and taking destructive or outbound actions that a read-only
|
|
150
|
+
goal never asked for (goal drift). Each finding carries a severity and a
|
|
151
|
+
confidence score so you can triage.
|
|
152
|
+
|
|
153
|
+
## Contributing
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
git clone https://github.com/DeepScan-Security/dscan
|
|
157
|
+
cd dscan
|
|
158
|
+
pip install -e ".[dev]"
|
|
159
|
+
pytest
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Built test-first; coverage stays at or above 80%. See
|
|
163
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for what we need help with, and
|
|
164
|
+
[SECURITY.md](SECURITY.md) to report a vulnerability.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# dscan
|
|
2
|
+
|
|
3
|
+
[](https://github.com/DeepScan-Security/dscan/actions/workflows/ci.yml)
|
|
4
|
+

|
|
5
|
+
[](https://pypi.org/project/dscan-security/)
|
|
6
|
+
[](https://pypi.org/project/dscan-security/)
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
An open source agent security suite. Trace and redact your agent's tool
|
|
10
|
+
calls, scan its prompts and MCP configs, and detect suspicious call
|
|
11
|
+
chains — then inspect everything in a local dashboard.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install dscan-security
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The package installs as `dscan-security`; the import path and CLI stay
|
|
18
|
+
`dscan` (`import dscan`, `dscan --help`).
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from dscan import watch
|
|
26
|
+
|
|
27
|
+
@watch
|
|
28
|
+
async def my_agent(task: str):
|
|
29
|
+
... # your agent code, unchanged
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Run your agent, then open the dashboard:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dscan dashboard
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Tool calls are written as redacted NDJSON to `~/.dscan/traces/` (override
|
|
39
|
+
with `DSCAN_TRACES_DIR`). Wrapping any tool with `@watch.tool` traces its
|
|
40
|
+
params and result; if you use the Anthropic SDK, `@watch` also intercepts
|
|
41
|
+
`messages.create()` and traces each `tool_use` block.
|
|
42
|
+
|
|
43
|
+
## What dscan catches
|
|
44
|
+
|
|
45
|
+
| Capability | What it detects |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `@watch` (tracing) | Every tool call — name, params, result, duration — written as redacted NDJSON; flags calls whose params contain secrets |
|
|
48
|
+
| Redaction | AWS keys, Anthropic/OpenAI/GitHub/Stripe tokens, JWTs, emails, phone numbers, SSNs, Luhn-valid credit cards, database-URL passwords, high-entropy secrets |
|
|
49
|
+
| `dscan scan` | Permissive prompts, injection-prone prompts, hardcoded secrets, excessive tool scope; unverified, overprivileged, and credential-leaking MCP servers |
|
|
50
|
+
| `dscan trail` | Suspicious tool-call sequences across an agent run (exfiltration, recon, injection relay, data staging, goal drift) |
|
|
51
|
+
| `dscan dashboard` | Local web UI: sessions, per-call timeline, redacted values, secrets flags, and trail findings |
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
### `dscan scan`
|
|
56
|
+
|
|
57
|
+
Static analysis of system prompts and MCP config files in a directory.
|
|
58
|
+
Exits `1` if any high-severity issue is found.
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
$ dscan scan ./prompts
|
|
62
|
+
HIGH (1)
|
|
63
|
+
┏━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
|
|
64
|
+
┃ Rule ┃ File ┃ Line ┃ Message ┃ Snippet ┃
|
|
65
|
+
┡━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
|
|
66
|
+
│ SP003 │ sp.txt │ 2 │ Hardcoded secret in prompt │ ...sk-ant-api03... │
|
|
67
|
+
└───────┴────────┴──────┴─────────────────────────────┴───────────────────┘
|
|
68
|
+
✗ 1 high-severity finding(s).
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `dscan watch`
|
|
72
|
+
|
|
73
|
+
`@watch` is a decorator, not a runtime command. This prints a reminder:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
$ dscan watch
|
|
77
|
+
⚠ Add @watch to your agent function. See README for usage.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `dscan trail`
|
|
81
|
+
|
|
82
|
+
Reads trace files and reports suspicious tool-call chains, grouped by
|
|
83
|
+
severity. Exits `1` on any high or critical finding. Supports
|
|
84
|
+
`--min-severity {low|medium|high|critical}` and `--json`.
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
$ dscan trail ~/.dscan/traces/
|
|
88
|
+
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
|
|
89
|
+
┃ Severity ┃ Pattern ┃ Tools Involved ┃ Message ┃ Confidence ┃
|
|
90
|
+
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
|
|
91
|
+
│ CRITICAL │ INJECTION_RELAY │ search_web → send_email │ untrusted... │ 85% │
|
|
92
|
+
│ HIGH │ EXFIL_SEQUENCE │ read_file → send_email │ read then... │ 80% │
|
|
93
|
+
└──────────┴─────────────────┴─────────────────────────┴───────────────┴────────────┘
|
|
94
|
+
2 findings across 5 tool calls analysed
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `dscan dashboard`
|
|
98
|
+
|
|
99
|
+
Serves the local web UI at `localhost:4321`. `--port` sets the port;
|
|
100
|
+
`--no-open` skips opening a browser.
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
$ dscan dashboard --no-open
|
|
104
|
+
✓ Dashboard at http://127.0.0.1:4321 (Ctrl-C to stop)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## How trail works
|
|
108
|
+
|
|
109
|
+
`dscan trail` reads your trace files and looks at the order of tool
|
|
110
|
+
calls, not just each call on its own. It groups calls by agent run and
|
|
111
|
+
reports five kinds of suspicious sequences: reading sensitive data and
|
|
112
|
+
then sending it somewhere external (exfiltration), probing for
|
|
113
|
+
permissions and then running a tool the agent never declared
|
|
114
|
+
(reconnaissance), pulling in untrusted web or email content and then
|
|
115
|
+
immediately sending or executing (injection relay), reading several
|
|
116
|
+
different sensitive sources in a row with nothing sent in between (data
|
|
117
|
+
staging), and taking destructive or outbound actions that a read-only
|
|
118
|
+
goal never asked for (goal drift). Each finding carries a severity and a
|
|
119
|
+
confidence score so you can triage.
|
|
120
|
+
|
|
121
|
+
## Contributing
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
git clone https://github.com/DeepScan-Security/dscan
|
|
125
|
+
cd dscan
|
|
126
|
+
pip install -e ".[dev]"
|
|
127
|
+
pytest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Built test-first; coverage stays at or above 80%. See
|
|
131
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for what we need help with, and
|
|
132
|
+
[SECURITY.md](SECURITY.md) to report a vulnerability.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""dscan — an open source agent security suite.
|
|
2
|
+
|
|
3
|
+
Wrap your agent with :func:`watch` to trace, redact, and scan its
|
|
4
|
+
behavior, then inspect everything in a local dashboard::
|
|
5
|
+
|
|
6
|
+
from dscan import watch
|
|
7
|
+
|
|
8
|
+
@watch
|
|
9
|
+
async def my_agent(task: str):
|
|
10
|
+
... # your agent code unchanged
|
|
11
|
+
|
|
12
|
+
# then, from the shell:
|
|
13
|
+
# dscan dashboard # localhost:4321
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dscan.watcher import watch
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = ["watch", "__version__"]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""dscan command-line interface.
|
|
2
|
+
|
|
3
|
+
Defines the ``dscan`` Click command group and its subcommands: ``scan``
|
|
4
|
+
(static prompt/MCP analysis via :mod:`dscan.scanner`), ``trail``
|
|
5
|
+
(call-chain detection via :mod:`dscan.trail`), ``dashboard`` (launches
|
|
6
|
+
:mod:`dscan.dashboard.server`), and ``watch`` (a usage reminder). All
|
|
7
|
+
output is rendered with ``rich``. The package entry point ``dscan``
|
|
8
|
+
resolves to :func:`main`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from dscan import __version__
|
|
22
|
+
from dscan.trail import TrailAnalyzer
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
_SEVERITY_ORDER = ["high", "medium", "low"]
|
|
27
|
+
_SEVERITY_STYLE = {"high": "bold red", "medium": "#f59e0b", "low": "cyan"}
|
|
28
|
+
|
|
29
|
+
# Trail severities, lowest to highest, plus per-row table styles.
|
|
30
|
+
_TRAIL_RANK = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
31
|
+
_TRAIL_DISPLAY_ORDER = ["critical", "high", "medium", "low"]
|
|
32
|
+
_TRAIL_ROW_STYLE = {"critical": "red", "high": "#f59e0b", "medium": "yellow", "low": None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _ok(message: str) -> None:
|
|
36
|
+
console.print(f"[green]✓[/green] {message}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _warn(message: str) -> None:
|
|
40
|
+
console.print(f"[#f59e0b]⚠[/#f59e0b] {message}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _err(message: str) -> None:
|
|
44
|
+
console.print(f"[red]✗[/red] {message}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
@click.version_option(__version__, prog_name="dscan")
|
|
49
|
+
def main() -> None:
|
|
50
|
+
"""dscan — an open source agent security suite.
|
|
51
|
+
|
|
52
|
+
Trace and redact your agent's tool calls (@watch), statically scan
|
|
53
|
+
prompts and MCP configs (dscan scan), and inspect everything in a
|
|
54
|
+
local dashboard (dscan dashboard).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@main.command()
|
|
59
|
+
def watch() -> None:
|
|
60
|
+
"""Show how to instrument an agent (it's a decorator, not a command)."""
|
|
61
|
+
_warn("Add @watch to your agent function. See README for usage.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@main.command()
|
|
65
|
+
@click.argument("path", required=False, default=".")
|
|
66
|
+
@click.option(
|
|
67
|
+
"--prompt",
|
|
68
|
+
"prompt_file",
|
|
69
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
70
|
+
default=None,
|
|
71
|
+
help="Scan a single system-prompt file instead of a directory.",
|
|
72
|
+
)
|
|
73
|
+
def scan(path: str, prompt_file: str | None) -> None:
|
|
74
|
+
"""Statically analyze agent configs and system prompts."""
|
|
75
|
+
from dscan.scanner import scan_directory, scan_file
|
|
76
|
+
|
|
77
|
+
findings = scan_file(prompt_file) if prompt_file else scan_directory(path)
|
|
78
|
+
_render_findings(findings)
|
|
79
|
+
if any(f.severity == "high" for f in findings):
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@main.command()
|
|
84
|
+
@click.option("--host", default="127.0.0.1", show_default=True, help="Host to bind.")
|
|
85
|
+
@click.option("--port", default=4321, show_default=True, help="Port to bind.")
|
|
86
|
+
@click.option(
|
|
87
|
+
"--open/--no-open",
|
|
88
|
+
"open_browser",
|
|
89
|
+
default=True,
|
|
90
|
+
show_default=True,
|
|
91
|
+
help="Open the dashboard in a browser.",
|
|
92
|
+
)
|
|
93
|
+
def dashboard(host: str, port: int, open_browser: bool) -> None:
|
|
94
|
+
"""Launch the local trace dashboard."""
|
|
95
|
+
with console.status(
|
|
96
|
+
f"Starting dashboard at localhost:{port}...", spinner="dots"
|
|
97
|
+
):
|
|
98
|
+
from dscan.dashboard.server import serve
|
|
99
|
+
|
|
100
|
+
_ok(f"Dashboard at [cyan]http://{host}:{port}[/cyan] [dim](Ctrl-C to stop)[/dim]")
|
|
101
|
+
serve(host=host, port=port, open_browser=open_browser)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@main.command()
|
|
105
|
+
@click.argument("path")
|
|
106
|
+
@click.option(
|
|
107
|
+
"--min-severity",
|
|
108
|
+
type=click.Choice(["low", "medium", "high", "critical"]),
|
|
109
|
+
default="low",
|
|
110
|
+
show_default=True,
|
|
111
|
+
help="Hide findings below this severity (display only; exit code still "
|
|
112
|
+
"reflects any high/critical finding).",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--json",
|
|
116
|
+
"as_json",
|
|
117
|
+
is_flag=True,
|
|
118
|
+
default=False,
|
|
119
|
+
help="Output raw JSON instead of a rich table.",
|
|
120
|
+
)
|
|
121
|
+
def trail(path: str, min_severity: str, as_json: bool) -> None:
|
|
122
|
+
"""Detect suspicious tool-call chains (CWAT) in trace files.
|
|
123
|
+
|
|
124
|
+
PATH is a trace file (.ndjson) or a directory of trace files.
|
|
125
|
+
"""
|
|
126
|
+
target = Path(path)
|
|
127
|
+
if not target.exists():
|
|
128
|
+
_err(f"path not found: {path}")
|
|
129
|
+
sys.exit(2)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
traces = _load_trace_dicts(target)
|
|
133
|
+
except OSError as exc: # pragma: no cover - defensive
|
|
134
|
+
_err(f"could not read traces from {path}: {exc}")
|
|
135
|
+
sys.exit(2)
|
|
136
|
+
|
|
137
|
+
# Analyze each session independently so chains never bridge unrelated
|
|
138
|
+
# agent runs (a read in one session + a send in another is not exfil).
|
|
139
|
+
analyzer = TrailAnalyzer()
|
|
140
|
+
all_findings: list = []
|
|
141
|
+
for session in _group_by_session(traces):
|
|
142
|
+
all_findings.extend(analyzer.analyze(session))
|
|
143
|
+
|
|
144
|
+
threshold = _TRAIL_RANK[min_severity]
|
|
145
|
+
shown = [f for f in all_findings if _TRAIL_RANK.get(f.severity, 0) >= threshold]
|
|
146
|
+
total_calls = len(traces)
|
|
147
|
+
|
|
148
|
+
if as_json:
|
|
149
|
+
console.print_json(data=[f.to_dict() for f in shown])
|
|
150
|
+
elif not all_findings:
|
|
151
|
+
_ok(f"No issues found in {total_calls} tool calls")
|
|
152
|
+
elif not shown:
|
|
153
|
+
console.print(
|
|
154
|
+
f"[dim]No findings at or above {min_severity.upper()} — "
|
|
155
|
+
f"{len(all_findings)} lower-severity finding(s) hidden.[/dim]"
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
_render_trail(shown, total_calls)
|
|
159
|
+
|
|
160
|
+
if any(f.severity in ("high", "critical") for f in all_findings):
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _read_ndjson(path: Path) -> list[dict]:
|
|
165
|
+
traces: list[dict] = []
|
|
166
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
167
|
+
line = line.strip()
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
obj = json.loads(line)
|
|
172
|
+
except (json.JSONDecodeError, ValueError):
|
|
173
|
+
continue # skip malformed lines
|
|
174
|
+
if isinstance(obj, dict):
|
|
175
|
+
traces.append(obj)
|
|
176
|
+
return traces
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _load_trace_dicts(target: Path) -> list[dict]:
|
|
180
|
+
files = [target] if target.is_file() else sorted(target.glob("*.ndjson"))
|
|
181
|
+
traces: list[dict] = []
|
|
182
|
+
for file in files:
|
|
183
|
+
traces.extend(_read_ndjson(file))
|
|
184
|
+
return traces
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _group_by_session(traces: list[dict]) -> list[list[dict]]:
|
|
188
|
+
groups: dict[str, list[dict]] = {}
|
|
189
|
+
order: list[str] = []
|
|
190
|
+
for trace in traces:
|
|
191
|
+
sid = str(trace.get("session_id") or "")
|
|
192
|
+
if sid not in groups:
|
|
193
|
+
groups[sid] = []
|
|
194
|
+
order.append(sid)
|
|
195
|
+
groups[sid].append(trace)
|
|
196
|
+
return [
|
|
197
|
+
sorted(groups[sid], key=lambda t: str(t.get("ts") or "")) for sid in order
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _render_trail(findings: list, total_calls: int) -> None:
|
|
202
|
+
table = Table(header_style="bold")
|
|
203
|
+
table.add_column("Severity")
|
|
204
|
+
table.add_column("Pattern")
|
|
205
|
+
table.add_column("Tools Involved")
|
|
206
|
+
table.add_column("Message")
|
|
207
|
+
table.add_column("Confidence", justify="right")
|
|
208
|
+
for severity in _TRAIL_DISPLAY_ORDER:
|
|
209
|
+
for f in (x for x in findings if x.severity == severity):
|
|
210
|
+
table.add_row(
|
|
211
|
+
severity.upper(),
|
|
212
|
+
f.pattern,
|
|
213
|
+
" → ".join(f.calls_involved),
|
|
214
|
+
f.message,
|
|
215
|
+
f"{round(f.confidence * 100)}%",
|
|
216
|
+
style=_TRAIL_ROW_STYLE.get(severity),
|
|
217
|
+
)
|
|
218
|
+
console.print(table)
|
|
219
|
+
console.print(
|
|
220
|
+
f"[bold]{len(findings)}[/bold] findings across "
|
|
221
|
+
f"[bold]{total_calls}[/bold] tool calls analysed"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _render_findings(findings: list) -> None:
|
|
226
|
+
if not findings:
|
|
227
|
+
_ok("No findings.")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
for severity in _SEVERITY_ORDER:
|
|
231
|
+
group = [f for f in findings if f.severity == severity]
|
|
232
|
+
if not group:
|
|
233
|
+
continue
|
|
234
|
+
table = Table(
|
|
235
|
+
title=f"{severity.upper()} ({len(group)})",
|
|
236
|
+
title_style=_SEVERITY_STYLE[severity],
|
|
237
|
+
header_style="bold",
|
|
238
|
+
title_justify="left",
|
|
239
|
+
)
|
|
240
|
+
table.add_column("Rule")
|
|
241
|
+
table.add_column("File")
|
|
242
|
+
table.add_column("Line", justify="right")
|
|
243
|
+
table.add_column("Message")
|
|
244
|
+
table.add_column("Snippet")
|
|
245
|
+
for f in sorted(group, key=lambda x: (x.file, x.line, x.rule)):
|
|
246
|
+
table.add_row(
|
|
247
|
+
f.rule,
|
|
248
|
+
Path(f.file).name,
|
|
249
|
+
str(f.line),
|
|
250
|
+
f.message,
|
|
251
|
+
f.snippet,
|
|
252
|
+
)
|
|
253
|
+
console.print(table)
|
|
254
|
+
|
|
255
|
+
high = sum(1 for f in findings if f.severity == "high")
|
|
256
|
+
if high:
|
|
257
|
+
_err(f"{high} high-severity finding(s).")
|
|
258
|
+
else:
|
|
259
|
+
_warn(f"{len(findings)} finding(s), none high severity.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
main()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""dscan dashboard — local web UI for inspecting agent traces.
|
|
2
|
+
|
|
3
|
+
This package holds the dashboard's aiohttp server (:mod:`dscan.dashboard.server`)
|
|
4
|
+
and its single HTML template. The server reads NDJSON traces from
|
|
5
|
+
``~/.dscan/traces`` (or ``DSCAN_TRACES_DIR``) and exposes them at
|
|
6
|
+
``localhost:4321`` over a small JSON API plus a self-contained page that
|
|
7
|
+
renders sessions, redacted tool calls, and trail findings. It depends
|
|
8
|
+
only on ``aiohttp`` and ``aiofiles``; there are no external front-end
|
|
9
|
+
dependencies.
|
|
10
|
+
"""
|