dscan-security 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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__"]
dscan/cli.py ADDED
@@ -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
+ """
@@ -0,0 +1,184 @@
1
+ """Dashboard web server.
2
+
3
+ A small aiohttp app that reads NDJSON traces and serves a local UI plus
4
+ a JSON API:
5
+
6
+ - ``GET /`` — the dashboard HTML with trace data injected.
7
+ - ``GET /api/traces`` — all traces (newest first).
8
+ - ``GET /api/traces/{session_id}`` — one session's detail.
9
+
10
+ Traces are read from ``DSCAN_TRACES_DIR`` (default ``~/.dscan/traces``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import aiofiles
22
+ from aiohttp import web
23
+
24
+ __all__ = [
25
+ "read_traces",
26
+ "build_sessions",
27
+ "compute_stats",
28
+ "make_app",
29
+ "serve",
30
+ ]
31
+
32
+ _TEMPLATE = Path(__file__).parent / "templates" / "index.html"
33
+
34
+ # Typed application key for the configured traces directory.
35
+ _TRACES_DIR_KEY: web.AppKey[Any] = web.AppKey("traces_dir", object)
36
+
37
+
38
+ def _resolve_dir(traces_dir: str | os.PathLike[str] | None) -> Path:
39
+ if traces_dir is not None:
40
+ return Path(traces_dir)
41
+ env = os.environ.get("DSCAN_TRACES_DIR")
42
+ if env:
43
+ return Path(env)
44
+ return Path.home() / ".dscan" / "traces"
45
+
46
+
47
+ def _utc_today() -> str:
48
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
49
+
50
+
51
+ async def read_traces(traces_dir: str | os.PathLike[str] | None = None) -> list[dict[str, Any]]:
52
+ """Read and parse every NDJSON trace, newest (by ``ts``) first.
53
+
54
+ Malformed lines are skipped rather than raising.
55
+ """
56
+ directory = _resolve_dir(traces_dir)
57
+ if not directory.is_dir():
58
+ return []
59
+
60
+ traces: list[dict[str, Any]] = []
61
+ for path in sorted(directory.glob("*.ndjson")):
62
+ async with aiofiles.open(path, encoding="utf-8") as f:
63
+ content = await f.read()
64
+ for line in content.splitlines():
65
+ line = line.strip()
66
+ if not line:
67
+ continue
68
+ try:
69
+ obj = json.loads(line)
70
+ except (json.JSONDecodeError, ValueError):
71
+ continue
72
+ if isinstance(obj, dict):
73
+ traces.append(obj)
74
+
75
+ traces.sort(key=lambda t: str(t.get("ts") or ""), reverse=True)
76
+ return traces
77
+
78
+
79
+ def build_sessions(traces: list[dict[str, Any]]) -> list[dict[str, Any]]:
80
+ """Group traces into sessions, newest session first."""
81
+ sessions: dict[str, dict[str, Any]] = {}
82
+ for trace in traces:
83
+ sid = str(trace.get("session_id") or "unknown")
84
+ session = sessions.get(sid)
85
+ if session is None:
86
+ session = {
87
+ "session_id": sid,
88
+ "agent": trace.get("agent", "agent"),
89
+ "ts": trace.get("ts", ""),
90
+ "flagged": False,
91
+ "calls": [],
92
+ }
93
+ sessions[sid] = session
94
+ session["calls"].append(trace)
95
+ if trace.get("flagged"):
96
+ session["flagged"] = True
97
+ if str(trace.get("ts") or "") > str(session["ts"] or ""):
98
+ session["ts"] = trace.get("ts", "")
99
+
100
+ result = list(sessions.values())
101
+ for session in result:
102
+ session["calls"].sort(key=lambda c: str(c.get("ts") or ""))
103
+ session["count"] = len(session["calls"])
104
+ result.sort(key=lambda s: str(s.get("ts") or ""), reverse=True)
105
+ return result
106
+
107
+
108
+ def compute_stats(traces: list[dict[str, Any]]) -> dict[str, int]:
109
+ """Top-bar stats: calls today, total flagged, agents active, and the
110
+ count of CRITICAL trail findings today (distinct from secrets flags)."""
111
+ today = _utc_today()
112
+ todays = [t for t in traces if str(t.get("ts") or "").startswith(today)]
113
+ return {
114
+ "total_calls_today": len(todays),
115
+ "flagged": sum(1 for t in traces if t.get("flagged")),
116
+ "agents_active": len({t.get("agent") for t in todays}),
117
+ "critical": sum(
118
+ 1
119
+ for t in todays
120
+ for f in (t.get("trail_findings") or [])
121
+ if isinstance(f, dict) and f.get("severity") == "critical"
122
+ ),
123
+ }
124
+
125
+
126
+ # --------------------------------------------------------------------------
127
+ # HTTP handlers
128
+ # --------------------------------------------------------------------------
129
+ async def _index(request: web.Request) -> web.Response:
130
+ traces = await read_traces(request.app[_TRACES_DIR_KEY])
131
+ async with aiofiles.open(_TEMPLATE, encoding="utf-8") as f:
132
+ template = await f.read()
133
+ data = json.dumps(traces).replace("<", "\\u003c")
134
+ html = template.replace("__DSCAN_DATA__", data)
135
+ return web.Response(text=html, content_type="text/html")
136
+
137
+
138
+ async def _traces(request: web.Request) -> web.Response:
139
+ return web.json_response(await read_traces(request.app[_TRACES_DIR_KEY]))
140
+
141
+
142
+ async def _session(request: web.Request) -> web.Response:
143
+ sid = request.match_info["session_id"]
144
+ sessions = build_sessions(await read_traces(request.app[_TRACES_DIR_KEY]))
145
+ for session in sessions:
146
+ if session["session_id"] == sid:
147
+ return web.json_response(session)
148
+ return web.json_response({"error": "session not found"}, status=404)
149
+
150
+
151
+ def make_app(traces_dir: str | os.PathLike[str] | None = None) -> web.Application:
152
+ """Build the dashboard aiohttp application."""
153
+ app = web.Application()
154
+ app[_TRACES_DIR_KEY] = traces_dir
155
+ app.add_routes(
156
+ [
157
+ web.get("/", _index),
158
+ web.get("/api/traces", _traces),
159
+ web.get("/api/traces/{session_id}", _session),
160
+ ]
161
+ )
162
+ return app
163
+
164
+
165
+ def serve(
166
+ host: str = "127.0.0.1",
167
+ port: int = 4321,
168
+ *,
169
+ open_browser: bool = True,
170
+ traces_dir: str | os.PathLike[str] | None = None,
171
+ ) -> None:
172
+ """Run the dashboard server (blocking)."""
173
+ app = make_app(traces_dir)
174
+
175
+ if open_browser:
176
+
177
+ async def _open(_: web.Application) -> None:
178
+ import webbrowser
179
+
180
+ webbrowser.open(f"http://{host}:{port}")
181
+
182
+ app.on_startup.append(_open)
183
+
184
+ web.run_app(app, host=host, port=port, print=None)