ciphra-mcp 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.
- ciphra_mcp-0.1.0/.gitignore +5 -0
- ciphra_mcp-0.1.0/LICENSE +21 -0
- ciphra_mcp-0.1.0/PKG-INFO +175 -0
- ciphra_mcp-0.1.0/README.md +149 -0
- ciphra_mcp-0.1.0/pyproject.toml +67 -0
- ciphra_mcp-0.1.0/src/ciphra_mcp/__init__.py +1 -0
- ciphra_mcp-0.1.0/src/ciphra_mcp/__main__.py +4 -0
- ciphra_mcp-0.1.0/src/ciphra_mcp/ciphra_client.py +457 -0
- ciphra_mcp-0.1.0/src/ciphra_mcp/server.py +306 -0
- ciphra_mcp-0.1.0/src/ciphra_mcp/types.py +93 -0
ciphra_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sanjay Selvam
|
|
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,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ciphra-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python MCP server for Ciphra. Lets AI coding agents call Ciphra's secret scanner via the Model Context Protocol.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sanjayselvam31/ciphra
|
|
6
|
+
Project-URL: Repository, https://github.com/sanjayselvam31/ciphra
|
|
7
|
+
Project-URL: Issues, https://github.com/sanjayselvam31/ciphra/issues
|
|
8
|
+
Author: Sanjay Selvam
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,ciphra,mcp,scanner,secrets,security
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: mcp>=1.0.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# ciphra-mcp
|
|
28
|
+
|
|
29
|
+
Python MCP server exposing Ciphra's secret scanner to AI agents.
|
|
30
|
+
|
|
31
|
+
## What this is
|
|
32
|
+
|
|
33
|
+
The agent-facing layer for [Ciphra](../README.md). The actual scanning is done by the TypeScript CLI at the repo root; this Python project spawns the CLI as a subprocess and translates between MCP's JSON-RPC protocol and Ciphra's JSON output. With this server registered, agents like Claude Code and Cursor can scan a codebase, validate a key, or audit git history without the user typing CLI commands.
|
|
34
|
+
|
|
35
|
+
## Prerequisites
|
|
36
|
+
|
|
37
|
+
- **Node 20+** — needed for the underlying `ciphra` CLI, which this MCP server shells out to
|
|
38
|
+
- **Python 3.11+** — for the MCP server itself
|
|
39
|
+
- **[uv](https://github.com/astral-sh/uv)** (recommended) or `pip + venv`
|
|
40
|
+
- **`ciphra` on `$PATH`.** The MCP server resolves the CLI via `shutil.which("ciphra")`, so it must be installed globally:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install -g ciphra
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If you get an `EACCES` error on `npm install -g`, your npm prefix is root-owned. The cleanest fix:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
mkdir -p ~/.npm-global
|
|
50
|
+
npm config set prefix ~/.npm-global
|
|
51
|
+
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
|
|
52
|
+
source ~/.zshrc
|
|
53
|
+
npm install -g ciphra
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uvx ciphra-mcp # ephemeral; runs the published wheel on demand
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That's it — `uvx` downloads the wheel from PyPI on first invocation, then runs the entry point. No clone required.
|
|
63
|
+
|
|
64
|
+
If you'd rather have it permanently installed in a project-local venv:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv pip install ciphra-mcp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Building from source (contributors)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cd /path/to/ciphra
|
|
74
|
+
npm install && npm run build # build the TS CLI
|
|
75
|
+
|
|
76
|
+
cd mcp-server
|
|
77
|
+
uv sync --extra dev # creates .venv, installs deps
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
(pip alternative: `python3.11 -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]"`)
|
|
81
|
+
|
|
82
|
+
## Running the server
|
|
83
|
+
|
|
84
|
+
**Standalone (sanity check that it launches):**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv run ciphra-mcp
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The server speaks MCP over stdio and isn't useful interactively. Send EOF (`Ctrl-D`) to exit. Most of the time you won't run this directly — the MCP client launches it for you.
|
|
91
|
+
|
|
92
|
+
**As an MCP server in Claude Code:**
|
|
93
|
+
|
|
94
|
+
Add to `~/.claude.json` (create the file if it doesn't exist):
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"mcpServers": {
|
|
99
|
+
"ciphra": {
|
|
100
|
+
"command": "uvx",
|
|
101
|
+
"args": ["ciphra-mcp"]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Restart Claude Code. The four Ciphra tools become available to the agent automatically.
|
|
108
|
+
|
|
109
|
+
**As an MCP server in Cursor:**
|
|
110
|
+
|
|
111
|
+
Add the same block to `~/.cursor/mcp.json`. Restart Cursor.
|
|
112
|
+
|
|
113
|
+
**Contributor / local-development alternative.** If you're running from a source checkout (the "Building from source" path above) instead of the published wheel, point the MCP client at your local venv:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"mcpServers": {
|
|
118
|
+
"ciphra": {
|
|
119
|
+
"command": "uv",
|
|
120
|
+
"args": ["--directory", "/absolute/path/to/ciphra/mcp-server", "run", "ciphra-mcp"]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## The four MCP tools
|
|
127
|
+
|
|
128
|
+
- **`scan_directory`** — the primary tool for "is this codebase secure?" / "are there leaked keys?" prompts. Scans a directory and (by default) its git history across all 10 supported services. Validation is opt-in. Example: *"Scan this directory for leaked keys."*
|
|
129
|
+
|
|
130
|
+
- **`check_specific_service`** — use when the user has named a single service of concern. Faster than `scan_directory` because only that service's detectors run, and defaults to live validation since scope is narrow. Example: *"I'm worried about Stripe keys in this codebase."*
|
|
131
|
+
|
|
132
|
+
- **`scan_git_history`** — scans only the commit history (current files are not touched). For auditing keys that were committed and later removed but remain recoverable from history. Example: *"Did I ever commit an AWS key I later deleted?"*
|
|
133
|
+
|
|
134
|
+
- **`validate_key`** — checks whether a specific key value is currently active against its service's live API. For "has this key value been used in my repo?" questions, agents should grep first; this tool is for one-shot live-status checks. Example: *"Is `sk_test_abc123` a real Stripe key?"*
|
|
135
|
+
|
|
136
|
+
The exact descriptions live in [src/ciphra_mcp/server.py](src/ciphra_mcp/server.py). Agents read those directly — when in doubt about how a prompt routes, that file is the source of truth.
|
|
137
|
+
|
|
138
|
+
## Running tests
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
uv run pytest
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The suite is full integration: tests spawn the real CLI subprocess. A `pytest_sessionstart` hook checks that `dist/cli/index.js` is current relative to `src/**/*.ts`; if any TS file is newer, the session aborts before collection with the offending paths and an instruction to run `npm run build` from the repo root.
|
|
145
|
+
|
|
146
|
+
Set `CIPHRA_SKIP_STALENESS_CHECK=1` to bypass — useful in CI where the build is guaranteed to have already run.
|
|
147
|
+
|
|
148
|
+
## Layout
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
src/ciphra_mcp/
|
|
152
|
+
server.py FastMCP setup, four @mcp.tool registrations
|
|
153
|
+
ciphra_client.py Async subprocess wrapper around the TS CLI
|
|
154
|
+
types.py TypedDicts mirroring the CLI JSON schema (v1.0)
|
|
155
|
+
tests/
|
|
156
|
+
conftest.py Staleness check + EXPECTED_TOOL_NAMES constant
|
|
157
|
+
test_*.py Integration tests (33 in total)
|
|
158
|
+
scripts/
|
|
159
|
+
verify_handshake.py Manual MCP protocol handshake check
|
|
160
|
+
verify_*.py Per-tool one-shot verification scripts
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Security notes
|
|
164
|
+
|
|
165
|
+
- **Validated keys hit live third-party APIs.** Defaults: `scan_directory` and `scan_git_history` are validate=OFF; `check_specific_service` is validate=ON (the user has narrowed scope to one service, so the cost is bounded); `validate_key` always sends. Don't validate keys you don't own or haven't been explicitly asked about — the call is logged at the third-party service.
|
|
166
|
+
- **Key values are never logged or returned in tool output.** Findings include a redacted preview (first/last 4 chars); validation results contain only status and reason.
|
|
167
|
+
- **`validation: invalid` is not a safety signal.** It means the key isn't currently authenticating — but it was exposed in the codebase, so the leak still happened. Rotate regardless.
|
|
168
|
+
|
|
169
|
+
## Troubleshooting
|
|
170
|
+
|
|
171
|
+
- **`ciphra-mcp: command not found`** — run `uv sync --extra dev` from `mcp-server/`, or activate the venv if using pip.
|
|
172
|
+
- **"TypeScript source is newer than dist/cli/index.js"** — run `npm run build` from the repo root.
|
|
173
|
+
- **Agent says no Ciphra tools are available** — the MCP client likely cached an older state of the server (e.g. from before tools were wired up). Restart the client. New tool descriptions also require a restart — the running Python process captures the description strings at import time.
|
|
174
|
+
- **Tools appear but calls fail with `CiphraNotFoundError`** — the MCP config points at the wrong directory, or `npm run build` hasn't been run. Verify by running `uv run ciphra-mcp` from a terminal in `mcp-server/`; it should launch silently and wait on stdin.
|
|
175
|
+
- **"Server has 0 tools"** in `verify_handshake.py` — almost always a stale running process. Check that `mcp-server/src/ciphra_mcp/server.py` has the four `@mcp.tool(...)` decorators and reload your client.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# ciphra-mcp
|
|
2
|
+
|
|
3
|
+
Python MCP server exposing Ciphra's secret scanner to AI agents.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
The agent-facing layer for [Ciphra](../README.md). The actual scanning is done by the TypeScript CLI at the repo root; this Python project spawns the CLI as a subprocess and translates between MCP's JSON-RPC protocol and Ciphra's JSON output. With this server registered, agents like Claude Code and Cursor can scan a codebase, validate a key, or audit git history without the user typing CLI commands.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- **Node 20+** — needed for the underlying `ciphra` CLI, which this MCP server shells out to
|
|
12
|
+
- **Python 3.11+** — for the MCP server itself
|
|
13
|
+
- **[uv](https://github.com/astral-sh/uv)** (recommended) or `pip + venv`
|
|
14
|
+
- **`ciphra` on `$PATH`.** The MCP server resolves the CLI via `shutil.which("ciphra")`, so it must be installed globally:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g ciphra
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If you get an `EACCES` error on `npm install -g`, your npm prefix is root-owned. The cleanest fix:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
mkdir -p ~/.npm-global
|
|
24
|
+
npm config set prefix ~/.npm-global
|
|
25
|
+
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
|
|
26
|
+
source ~/.zshrc
|
|
27
|
+
npm install -g ciphra
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uvx ciphra-mcp # ephemeral; runs the published wheel on demand
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That's it — `uvx` downloads the wheel from PyPI on first invocation, then runs the entry point. No clone required.
|
|
37
|
+
|
|
38
|
+
If you'd rather have it permanently installed in a project-local venv:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv pip install ciphra-mcp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Building from source (contributors)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cd /path/to/ciphra
|
|
48
|
+
npm install && npm run build # build the TS CLI
|
|
49
|
+
|
|
50
|
+
cd mcp-server
|
|
51
|
+
uv sync --extra dev # creates .venv, installs deps
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
(pip alternative: `python3.11 -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]"`)
|
|
55
|
+
|
|
56
|
+
## Running the server
|
|
57
|
+
|
|
58
|
+
**Standalone (sanity check that it launches):**
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv run ciphra-mcp
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The server speaks MCP over stdio and isn't useful interactively. Send EOF (`Ctrl-D`) to exit. Most of the time you won't run this directly — the MCP client launches it for you.
|
|
65
|
+
|
|
66
|
+
**As an MCP server in Claude Code:**
|
|
67
|
+
|
|
68
|
+
Add to `~/.claude.json` (create the file if it doesn't exist):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"ciphra": {
|
|
74
|
+
"command": "uvx",
|
|
75
|
+
"args": ["ciphra-mcp"]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Restart Claude Code. The four Ciphra tools become available to the agent automatically.
|
|
82
|
+
|
|
83
|
+
**As an MCP server in Cursor:**
|
|
84
|
+
|
|
85
|
+
Add the same block to `~/.cursor/mcp.json`. Restart Cursor.
|
|
86
|
+
|
|
87
|
+
**Contributor / local-development alternative.** If you're running from a source checkout (the "Building from source" path above) instead of the published wheel, point the MCP client at your local venv:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"mcpServers": {
|
|
92
|
+
"ciphra": {
|
|
93
|
+
"command": "uv",
|
|
94
|
+
"args": ["--directory", "/absolute/path/to/ciphra/mcp-server", "run", "ciphra-mcp"]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## The four MCP tools
|
|
101
|
+
|
|
102
|
+
- **`scan_directory`** — the primary tool for "is this codebase secure?" / "are there leaked keys?" prompts. Scans a directory and (by default) its git history across all 10 supported services. Validation is opt-in. Example: *"Scan this directory for leaked keys."*
|
|
103
|
+
|
|
104
|
+
- **`check_specific_service`** — use when the user has named a single service of concern. Faster than `scan_directory` because only that service's detectors run, and defaults to live validation since scope is narrow. Example: *"I'm worried about Stripe keys in this codebase."*
|
|
105
|
+
|
|
106
|
+
- **`scan_git_history`** — scans only the commit history (current files are not touched). For auditing keys that were committed and later removed but remain recoverable from history. Example: *"Did I ever commit an AWS key I later deleted?"*
|
|
107
|
+
|
|
108
|
+
- **`validate_key`** — checks whether a specific key value is currently active against its service's live API. For "has this key value been used in my repo?" questions, agents should grep first; this tool is for one-shot live-status checks. Example: *"Is `sk_test_abc123` a real Stripe key?"*
|
|
109
|
+
|
|
110
|
+
The exact descriptions live in [src/ciphra_mcp/server.py](src/ciphra_mcp/server.py). Agents read those directly — when in doubt about how a prompt routes, that file is the source of truth.
|
|
111
|
+
|
|
112
|
+
## Running tests
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv run pytest
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The suite is full integration: tests spawn the real CLI subprocess. A `pytest_sessionstart` hook checks that `dist/cli/index.js` is current relative to `src/**/*.ts`; if any TS file is newer, the session aborts before collection with the offending paths and an instruction to run `npm run build` from the repo root.
|
|
119
|
+
|
|
120
|
+
Set `CIPHRA_SKIP_STALENESS_CHECK=1` to bypass — useful in CI where the build is guaranteed to have already run.
|
|
121
|
+
|
|
122
|
+
## Layout
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
src/ciphra_mcp/
|
|
126
|
+
server.py FastMCP setup, four @mcp.tool registrations
|
|
127
|
+
ciphra_client.py Async subprocess wrapper around the TS CLI
|
|
128
|
+
types.py TypedDicts mirroring the CLI JSON schema (v1.0)
|
|
129
|
+
tests/
|
|
130
|
+
conftest.py Staleness check + EXPECTED_TOOL_NAMES constant
|
|
131
|
+
test_*.py Integration tests (33 in total)
|
|
132
|
+
scripts/
|
|
133
|
+
verify_handshake.py Manual MCP protocol handshake check
|
|
134
|
+
verify_*.py Per-tool one-shot verification scripts
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Security notes
|
|
138
|
+
|
|
139
|
+
- **Validated keys hit live third-party APIs.** Defaults: `scan_directory` and `scan_git_history` are validate=OFF; `check_specific_service` is validate=ON (the user has narrowed scope to one service, so the cost is bounded); `validate_key` always sends. Don't validate keys you don't own or haven't been explicitly asked about — the call is logged at the third-party service.
|
|
140
|
+
- **Key values are never logged or returned in tool output.** Findings include a redacted preview (first/last 4 chars); validation results contain only status and reason.
|
|
141
|
+
- **`validation: invalid` is not a safety signal.** It means the key isn't currently authenticating — but it was exposed in the codebase, so the leak still happened. Rotate regardless.
|
|
142
|
+
|
|
143
|
+
## Troubleshooting
|
|
144
|
+
|
|
145
|
+
- **`ciphra-mcp: command not found`** — run `uv sync --extra dev` from `mcp-server/`, or activate the venv if using pip.
|
|
146
|
+
- **"TypeScript source is newer than dist/cli/index.js"** — run `npm run build` from the repo root.
|
|
147
|
+
- **Agent says no Ciphra tools are available** — the MCP client likely cached an older state of the server (e.g. from before tools were wired up). Restart the client. New tool descriptions also require a restart — the running Python process captures the description strings at import time.
|
|
148
|
+
- **Tools appear but calls fail with `CiphraNotFoundError`** — the MCP config points at the wrong directory, or `npm run build` hasn't been run. Verify by running `uv run ciphra-mcp` from a terminal in `mcp-server/`; it should launch silently and wait on stdin.
|
|
149
|
+
- **"Server has 0 tools"** in `verify_handshake.py` — almost always a stale running process. Check that `mcp-server/src/ciphra_mcp/server.py` has the four `@mcp.tool(...)` decorators and reload your client.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ciphra-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python MCP server for Ciphra. Lets AI coding agents call Ciphra's secret scanner via the Model Context Protocol."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Sanjay Selvam" },
|
|
11
|
+
]
|
|
12
|
+
keywords = [
|
|
13
|
+
"mcp",
|
|
14
|
+
"security",
|
|
15
|
+
"scanner",
|
|
16
|
+
"secrets",
|
|
17
|
+
"ai-agents",
|
|
18
|
+
"ciphra",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 3 - Alpha",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Security",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"mcp>=1.0.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"pytest-asyncio>=0.23",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/sanjayselvam31/ciphra"
|
|
42
|
+
Repository = "https://github.com/sanjayselvam31/ciphra"
|
|
43
|
+
Issues = "https://github.com/sanjayselvam31/ciphra/issues"
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
ciphra-mcp = "ciphra_mcp.server:main"
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["hatchling"]
|
|
50
|
+
build-backend = "hatchling.build"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/ciphra_mcp"]
|
|
54
|
+
|
|
55
|
+
# Sdist allowlist: explicitly include only what should ship. tests/,
|
|
56
|
+
# scripts/, .venv/, uv.lock, and __pycache__/ stay out.
|
|
57
|
+
[tool.hatch.build.targets.sdist]
|
|
58
|
+
include = [
|
|
59
|
+
"src/",
|
|
60
|
+
"pyproject.toml",
|
|
61
|
+
"README.md",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
asyncio_mode = "auto"
|
|
67
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python MCP server that wraps the Ciphra TypeScript CLI."""
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Subprocess wrapper around the Ciphra TypeScript CLI.
|
|
2
|
+
|
|
3
|
+
The MCP server never talks to detector/scanner code directly. It shells out
|
|
4
|
+
to `ciphra scan --json <path>` and parses stdout. This keeps the TS scanner
|
|
5
|
+
authoritative and avoids a Python re-implementation.
|
|
6
|
+
|
|
7
|
+
CLI exit codes (documented contract):
|
|
8
|
+
0 scan completed; no critical/high findings above threshold
|
|
9
|
+
1 scan completed; critical findings present
|
|
10
|
+
2 scan completed; high findings present (no critical)
|
|
11
|
+
3 scan errored (path missing, invalid arg, etc.)
|
|
12
|
+
|
|
13
|
+
Exit 1 and 2 are NOT failures — they are "findings present" signals. Only
|
|
14
|
+
exit 3 raises CiphraScanError. Default subprocess handling would conflate
|
|
15
|
+
the three; that conflation would silently break the MCP tool's behavior.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import cast
|
|
28
|
+
|
|
29
|
+
from ciphra_mcp.types import (
|
|
30
|
+
EXPECTED_SCHEMA_VERSION,
|
|
31
|
+
KNOWN_SERVICE_IDS,
|
|
32
|
+
ScanResult,
|
|
33
|
+
ServiceId,
|
|
34
|
+
ValidateResult,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CiphraError(Exception):
|
|
41
|
+
"""Base class for all errors raised by the Ciphra subprocess wrapper."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CiphraNotFoundError(CiphraError):
|
|
45
|
+
"""The Ciphra TS CLI could not be located on disk or on PATH."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CiphraScanError(CiphraError):
|
|
49
|
+
"""The Ciphra CLI returned a real error (exit code 3)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CiphraTimeoutError(CiphraError):
|
|
53
|
+
"""The Ciphra subprocess did not finish before the timeout elapsed."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CiphraSchemaVersionError(CiphraError):
|
|
57
|
+
"""The CLI returned JSON with an unexpected schemaVersion."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CiphraNotAGitRepoError(CiphraError):
|
|
61
|
+
"""The given path is not the root of a git repository."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_cli() -> list[str]:
|
|
65
|
+
"""Resolve the command (argv prefix) used to invoke the Ciphra CLI.
|
|
66
|
+
|
|
67
|
+
Lookup order:
|
|
68
|
+
1. $CIPHRA_CLI_PATH if set (path to a node script or executable)
|
|
69
|
+
2. `ciphra` on $PATH
|
|
70
|
+
3. ../../../dist/cli/index.js relative to this file (via `node`)
|
|
71
|
+
"""
|
|
72
|
+
env_path = os.environ.get("CIPHRA_CLI_PATH")
|
|
73
|
+
if env_path:
|
|
74
|
+
p = Path(env_path)
|
|
75
|
+
if not p.exists():
|
|
76
|
+
raise CiphraNotFoundError(
|
|
77
|
+
f"CIPHRA_CLI_PATH points to a path that does not exist: {env_path}"
|
|
78
|
+
)
|
|
79
|
+
if p.suffix == ".js":
|
|
80
|
+
return ["node", str(p)]
|
|
81
|
+
return [str(p)]
|
|
82
|
+
|
|
83
|
+
on_path = shutil.which("ciphra")
|
|
84
|
+
if on_path:
|
|
85
|
+
return [on_path]
|
|
86
|
+
|
|
87
|
+
built = Path(__file__).resolve().parents[3] / "dist" / "cli" / "index.js"
|
|
88
|
+
if built.exists():
|
|
89
|
+
return ["node", str(built)]
|
|
90
|
+
|
|
91
|
+
raise CiphraNotFoundError(
|
|
92
|
+
"Could not locate the Ciphra CLI. Set CIPHRA_CLI_PATH, install ciphra "
|
|
93
|
+
f"globally so it is on $PATH, or run `npm run build` at the repo root "
|
|
94
|
+
f"to produce {built}."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _run_scan_subprocess(
|
|
99
|
+
cli_args: list[str],
|
|
100
|
+
*,
|
|
101
|
+
timeout_seconds: float,
|
|
102
|
+
) -> ScanResult:
|
|
103
|
+
"""Spawn `ciphra scan --json <cli_args...>` and return the parsed result.
|
|
104
|
+
|
|
105
|
+
`cli_args` is everything that follows `scan --json` on the command
|
|
106
|
+
line — typically a path plus flags like `["--severity", "medium",
|
|
107
|
+
"<path>", "--no-validate"]`. The helper prepends `scan` and `--json`
|
|
108
|
+
automatically; do not include those in cli_args.
|
|
109
|
+
|
|
110
|
+
Exit code handling:
|
|
111
|
+
0, 1, 2 -> success (CLI completed; 1/2 mean "findings present")
|
|
112
|
+
3 -> CiphraScanError (real CLI error)
|
|
113
|
+
other -> CiphraScanError (defensive)
|
|
114
|
+
|
|
115
|
+
Used by scan_directory, scan_git_history, and check_specific_service.
|
|
116
|
+
A single helper keeps timeout-kill discipline, schema-version checking,
|
|
117
|
+
and exit-code interpretation in one place.
|
|
118
|
+
"""
|
|
119
|
+
argv = _resolve_cli() + ["scan", "--json"] + cli_args
|
|
120
|
+
|
|
121
|
+
logger.debug("invoking ciphra: %s", " ".join(argv))
|
|
122
|
+
|
|
123
|
+
proc = await asyncio.create_subprocess_exec(
|
|
124
|
+
*argv,
|
|
125
|
+
stdout=asyncio.subprocess.PIPE,
|
|
126
|
+
stderr=asyncio.subprocess.PIPE,
|
|
127
|
+
env={**os.environ, "NO_COLOR": "1"},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
132
|
+
proc.communicate(), timeout=timeout_seconds
|
|
133
|
+
)
|
|
134
|
+
except asyncio.TimeoutError as exc:
|
|
135
|
+
proc.kill()
|
|
136
|
+
try:
|
|
137
|
+
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
|
138
|
+
except asyncio.TimeoutError:
|
|
139
|
+
pass
|
|
140
|
+
raise CiphraTimeoutError(
|
|
141
|
+
f"ciphra scan did not finish within {timeout_seconds}s "
|
|
142
|
+
f"(args={cli_args})"
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
146
|
+
exit_code = proc.returncode
|
|
147
|
+
|
|
148
|
+
if exit_code == 3:
|
|
149
|
+
raise CiphraScanError(
|
|
150
|
+
f"ciphra scan failed (exit 3) for args={cli_args}\n"
|
|
151
|
+
f"stderr:\n{stderr.strip()}"
|
|
152
|
+
)
|
|
153
|
+
if exit_code not in (0, 1, 2):
|
|
154
|
+
raise CiphraScanError(
|
|
155
|
+
f"ciphra scan exited with unexpected code {exit_code} for "
|
|
156
|
+
f"args={cli_args}\nstderr:\n{stderr.strip()}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
160
|
+
try:
|
|
161
|
+
parsed = json.loads(stdout)
|
|
162
|
+
except json.JSONDecodeError as exc:
|
|
163
|
+
raise CiphraScanError(
|
|
164
|
+
f"ciphra scan stdout was not valid JSON (exit {exit_code}):\n"
|
|
165
|
+
f"{stdout[:500]}"
|
|
166
|
+
) from exc
|
|
167
|
+
|
|
168
|
+
if stderr.strip():
|
|
169
|
+
logger.debug("ciphra stderr: %s", stderr.strip())
|
|
170
|
+
|
|
171
|
+
actual_schema = parsed.get("schemaVersion")
|
|
172
|
+
if actual_schema != EXPECTED_SCHEMA_VERSION:
|
|
173
|
+
raise CiphraSchemaVersionError(
|
|
174
|
+
f"Ciphra CLI returned schemaVersion={actual_schema!r}, "
|
|
175
|
+
f"this client expects {EXPECTED_SCHEMA_VERSION!r}. Either upgrade "
|
|
176
|
+
f"ciphra-mcp or downgrade the Ciphra CLI."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return cast(ScanResult, parsed)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def scan_directory(
|
|
183
|
+
path: str,
|
|
184
|
+
*,
|
|
185
|
+
validate: bool = True,
|
|
186
|
+
history: bool = True,
|
|
187
|
+
severity: str = "medium",
|
|
188
|
+
timeout_seconds: float = 60.0,
|
|
189
|
+
) -> ScanResult:
|
|
190
|
+
"""Run `ciphra scan --json <path>` and return the parsed result.
|
|
191
|
+
|
|
192
|
+
Exit codes 0, 1, 2 are all treated as success (scan completed); only
|
|
193
|
+
exit 3 raises CiphraScanError. Non-zero stderr is logged at debug level.
|
|
194
|
+
"""
|
|
195
|
+
cli_args: list[str] = ["--severity", severity, path]
|
|
196
|
+
if not validate:
|
|
197
|
+
cli_args.append("--no-validate")
|
|
198
|
+
if not history:
|
|
199
|
+
cli_args.append("--no-history")
|
|
200
|
+
return await _run_scan_subprocess(cli_args, timeout_seconds=timeout_seconds)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def validate_key(
|
|
204
|
+
service_id: ServiceId,
|
|
205
|
+
key: str,
|
|
206
|
+
*,
|
|
207
|
+
timeout_seconds: float = 15.0,
|
|
208
|
+
) -> ValidateResult:
|
|
209
|
+
"""Run `ciphra validate --json <service_id> --key-stdin` and return the result.
|
|
210
|
+
|
|
211
|
+
The key is written to the subprocess's stdin and never appears on argv,
|
|
212
|
+
in environment variables, or in any log/error output produced by this
|
|
213
|
+
function. The TS side enforces the same discipline (see
|
|
214
|
+
src/cli/validate.ts at the repo root and tests/cli-validate-contract).
|
|
215
|
+
|
|
216
|
+
SECURITY: do not add logging that references the `key` parameter. Do
|
|
217
|
+
not include `key` in any exception message. The Python-side
|
|
218
|
+
marker-key test in test_tools.test_validate_key_does_not_leak_key is
|
|
219
|
+
the regression guard for this rule.
|
|
220
|
+
"""
|
|
221
|
+
argv = _resolve_cli() + ["validate", service_id, "--json", "--key-stdin"]
|
|
222
|
+
|
|
223
|
+
logger.debug("invoking ciphra validate for service=%s", service_id)
|
|
224
|
+
|
|
225
|
+
proc = await asyncio.create_subprocess_exec(
|
|
226
|
+
*argv,
|
|
227
|
+
stdin=asyncio.subprocess.PIPE,
|
|
228
|
+
stdout=asyncio.subprocess.PIPE,
|
|
229
|
+
stderr=asyncio.subprocess.PIPE,
|
|
230
|
+
env={**os.environ, "NO_COLOR": "1"},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# communicate(input=...) writes input to stdin, closes stdin, then
|
|
234
|
+
# concurrently reads stdout+stderr to EOF and waits for the process.
|
|
235
|
+
# We use it (rather than manual write+drain+close) because it handles
|
|
236
|
+
# cross-version asyncio quirks and avoids deadlocking on large stderr.
|
|
237
|
+
payload = (key + "\n").encode("utf-8")
|
|
238
|
+
try:
|
|
239
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
240
|
+
proc.communicate(input=payload),
|
|
241
|
+
timeout=timeout_seconds,
|
|
242
|
+
)
|
|
243
|
+
except asyncio.TimeoutError as exc:
|
|
244
|
+
proc.kill()
|
|
245
|
+
try:
|
|
246
|
+
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
|
247
|
+
except asyncio.TimeoutError:
|
|
248
|
+
pass
|
|
249
|
+
# Do NOT include the key in this message.
|
|
250
|
+
raise CiphraTimeoutError(
|
|
251
|
+
f"ciphra validate did not finish within {timeout_seconds}s "
|
|
252
|
+
f"(service_id={service_id!r})"
|
|
253
|
+
) from exc
|
|
254
|
+
|
|
255
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
256
|
+
exit_code = proc.returncode
|
|
257
|
+
|
|
258
|
+
if exit_code == 3:
|
|
259
|
+
# The TS side guarantees its stderr never contains the key, so
|
|
260
|
+
# passing stderr through is safe. The `key` parameter itself is
|
|
261
|
+
# NEVER included in this message.
|
|
262
|
+
raise CiphraScanError(
|
|
263
|
+
f"ciphra validate failed (exit 3) for service_id={service_id!r}\n"
|
|
264
|
+
f"stderr:\n{stderr.strip()}"
|
|
265
|
+
)
|
|
266
|
+
if exit_code != 0:
|
|
267
|
+
raise CiphraScanError(
|
|
268
|
+
f"ciphra validate exited with unexpected code {exit_code} for "
|
|
269
|
+
f"service_id={service_id!r}\nstderr:\n{stderr.strip()}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
273
|
+
try:
|
|
274
|
+
parsed = json.loads(stdout)
|
|
275
|
+
except json.JSONDecodeError as exc:
|
|
276
|
+
raise CiphraScanError(
|
|
277
|
+
f"ciphra validate stdout was not valid JSON (exit {exit_code})"
|
|
278
|
+
) from exc
|
|
279
|
+
|
|
280
|
+
if stderr.strip():
|
|
281
|
+
logger.debug("ciphra stderr: %s", stderr.strip())
|
|
282
|
+
|
|
283
|
+
actual_schema = parsed.get("schemaVersion")
|
|
284
|
+
if actual_schema != EXPECTED_SCHEMA_VERSION:
|
|
285
|
+
raise CiphraSchemaVersionError(
|
|
286
|
+
f"Ciphra CLI returned schemaVersion={actual_schema!r}, "
|
|
287
|
+
f"this client expects {EXPECTED_SCHEMA_VERSION!r}."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
logger.debug(
|
|
291
|
+
"validated service=%s status=%s",
|
|
292
|
+
service_id,
|
|
293
|
+
parsed.get("validation", {}).get("status"),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return cast(ValidateResult, parsed)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def scan_git_history(
|
|
300
|
+
path: str,
|
|
301
|
+
*,
|
|
302
|
+
validate: bool = False,
|
|
303
|
+
severity: str = "medium",
|
|
304
|
+
timeout_seconds: float = 120.0,
|
|
305
|
+
) -> ScanResult:
|
|
306
|
+
"""Scan only the git commit history of a repository for exposed secrets.
|
|
307
|
+
|
|
308
|
+
The TS CLI has no `--history-only` flag — instead, this function runs
|
|
309
|
+
a normal `ciphra scan --json` (history enabled by default) and then
|
|
310
|
+
filters the findings array to only those with source == "git-history".
|
|
311
|
+
Summary counts are recomputed to match the filtered set. This is the
|
|
312
|
+
ONLY place in the codebase where Python mutates a ScanResult after
|
|
313
|
+
receiving it from the CLI; if you add new mutation points elsewhere,
|
|
314
|
+
add a comment so future readers can grep for them.
|
|
315
|
+
|
|
316
|
+
Why filter on the Python side: keeps the TS CLI surface area minimal
|
|
317
|
+
(no extra flag, no new code path to test) and the filter is O(n) on
|
|
318
|
+
findings. The full git walk happens on the TS side regardless.
|
|
319
|
+
|
|
320
|
+
Git-repo detection: we check `<path>/.git` exists (matches the TS
|
|
321
|
+
side's check at src/scanner/git-history.ts). This is strict — passing
|
|
322
|
+
a subdirectory of a repo raises CiphraNotAGitRepoError even though
|
|
323
|
+
the subdir is technically inside the repo. Symmetric with the CLI
|
|
324
|
+
behavior, simpler than walking parents, no subprocess needed.
|
|
325
|
+
"""
|
|
326
|
+
git_marker = Path(path) / ".git"
|
|
327
|
+
if not git_marker.exists():
|
|
328
|
+
raise CiphraNotAGitRepoError(
|
|
329
|
+
f"Path is not inside a git repository: {path} "
|
|
330
|
+
f"(no .git marker at {git_marker})"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
argv = _resolve_cli() + ["scan", path, "--json", "--severity", severity]
|
|
334
|
+
if not validate:
|
|
335
|
+
argv.append("--no-validate")
|
|
336
|
+
|
|
337
|
+
logger.debug("invoking ciphra scan (history-only filter) for %s", path)
|
|
338
|
+
|
|
339
|
+
proc = await asyncio.create_subprocess_exec(
|
|
340
|
+
*argv,
|
|
341
|
+
stdout=asyncio.subprocess.PIPE,
|
|
342
|
+
stderr=asyncio.subprocess.PIPE,
|
|
343
|
+
env={**os.environ, "NO_COLOR": "1"},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
348
|
+
proc.communicate(), timeout=timeout_seconds
|
|
349
|
+
)
|
|
350
|
+
except asyncio.TimeoutError as exc:
|
|
351
|
+
proc.kill()
|
|
352
|
+
try:
|
|
353
|
+
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
|
354
|
+
except asyncio.TimeoutError:
|
|
355
|
+
pass
|
|
356
|
+
raise CiphraTimeoutError(
|
|
357
|
+
f"ciphra scan_git_history did not finish within {timeout_seconds}s "
|
|
358
|
+
f"(path={path!r})"
|
|
359
|
+
) from exc
|
|
360
|
+
|
|
361
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
362
|
+
exit_code = proc.returncode
|
|
363
|
+
|
|
364
|
+
if exit_code == 3:
|
|
365
|
+
raise CiphraScanError(
|
|
366
|
+
f"ciphra scan failed (exit 3) for path={path!r}\nstderr:\n{stderr.strip()}"
|
|
367
|
+
)
|
|
368
|
+
if exit_code not in (0, 1, 2):
|
|
369
|
+
raise CiphraScanError(
|
|
370
|
+
f"ciphra scan exited with unexpected code {exit_code} for path={path!r}\n"
|
|
371
|
+
f"stderr:\n{stderr.strip()}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
375
|
+
try:
|
|
376
|
+
parsed = json.loads(stdout)
|
|
377
|
+
except json.JSONDecodeError as exc:
|
|
378
|
+
raise CiphraScanError(
|
|
379
|
+
f"ciphra scan stdout was not valid JSON (exit {exit_code}):\n"
|
|
380
|
+
f"{stdout[:500]}"
|
|
381
|
+
) from exc
|
|
382
|
+
|
|
383
|
+
if stderr.strip():
|
|
384
|
+
logger.debug("ciphra stderr: %s", stderr.strip())
|
|
385
|
+
|
|
386
|
+
actual_schema = parsed.get("schemaVersion")
|
|
387
|
+
if actual_schema != EXPECTED_SCHEMA_VERSION:
|
|
388
|
+
raise CiphraSchemaVersionError(
|
|
389
|
+
f"Ciphra CLI returned schemaVersion={actual_schema!r}, "
|
|
390
|
+
f"this client expects {EXPECTED_SCHEMA_VERSION!r}."
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# MUTATION POINT (1 of 1 in this codebase): filter to history-only +
|
|
394
|
+
# recompute summary counts. Everything else in the ScanResult is
|
|
395
|
+
# passed through as-is.
|
|
396
|
+
history_findings = [
|
|
397
|
+
f for f in parsed["findings"] if f.get("source") == "git-history"
|
|
398
|
+
]
|
|
399
|
+
severities = {"critical": 0, "high": 0, "medium": 0, "info": 0}
|
|
400
|
+
by_service: dict[str, int] = {}
|
|
401
|
+
for f in history_findings:
|
|
402
|
+
sev = f.get("severity")
|
|
403
|
+
if sev in severities:
|
|
404
|
+
severities[sev] += 1
|
|
405
|
+
name = f.get("serviceName")
|
|
406
|
+
if name:
|
|
407
|
+
by_service[name] = by_service.get(name, 0) + 1
|
|
408
|
+
|
|
409
|
+
parsed["findings"] = history_findings
|
|
410
|
+
parsed["summary"] = {
|
|
411
|
+
"total": len(history_findings),
|
|
412
|
+
**severities,
|
|
413
|
+
"byService": by_service,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return cast(ScanResult, parsed)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def check_specific_service(
|
|
420
|
+
service_id: ServiceId,
|
|
421
|
+
path: str,
|
|
422
|
+
*,
|
|
423
|
+
validate: bool = True,
|
|
424
|
+
history: bool = True,
|
|
425
|
+
severity: str = "medium",
|
|
426
|
+
timeout_seconds: float = 60.0,
|
|
427
|
+
) -> ScanResult:
|
|
428
|
+
"""Scan a directory for keys belonging to ONE specific named service.
|
|
429
|
+
|
|
430
|
+
Thin wrapper around `ciphra scan --json --service <service_id>`. The
|
|
431
|
+
TS CLI's --service flag pre-filters the detector list so only the
|
|
432
|
+
named service's detectors and validator run — no post-hoc filtering
|
|
433
|
+
happens here.
|
|
434
|
+
|
|
435
|
+
Defaults to validate=True (opposite of scan_directory's MCP default)
|
|
436
|
+
because the user has explicitly narrowed to a single service and the
|
|
437
|
+
validation cost is bounded by the count of findings for that one
|
|
438
|
+
service.
|
|
439
|
+
|
|
440
|
+
service_id is validated against KNOWN_SERVICE_IDS as defense-in-depth:
|
|
441
|
+
FastMCP's Literal catches bad values at the MCP protocol layer, but
|
|
442
|
+
direct Python callers (or future internal use) bypass that check.
|
|
443
|
+
"""
|
|
444
|
+
if service_id not in KNOWN_SERVICE_IDS:
|
|
445
|
+
raise ValueError(
|
|
446
|
+
f"unknown service_id {service_id!r}. Must be one of: "
|
|
447
|
+
f"{', '.join(KNOWN_SERVICE_IDS)}"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
cli_args: list[str] = [
|
|
451
|
+
"--service", service_id, "--severity", severity, path,
|
|
452
|
+
]
|
|
453
|
+
if not validate:
|
|
454
|
+
cli_args.append("--no-validate")
|
|
455
|
+
if not history:
|
|
456
|
+
cli_args.append("--no-history")
|
|
457
|
+
return await _run_scan_subprocess(cli_args, timeout_seconds=timeout_seconds)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Ciphra MCP server (stdio transport).
|
|
2
|
+
|
|
3
|
+
Phase 2.2 complete — `scan_directory`, `validate_key`, `scan_git_history`,
|
|
4
|
+
and `check_specific_service` are all registered.
|
|
5
|
+
|
|
6
|
+
Critical gotcha: stdout is the MCP protocol channel (JSON-RPC). Anything
|
|
7
|
+
written to stdout that isn't a valid protocol message will corrupt the
|
|
8
|
+
session. Logging therefore goes to stderr.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
from typing import Annotated, Literal
|
|
17
|
+
|
|
18
|
+
from mcp.server.fastmcp import FastMCP
|
|
19
|
+
from pydantic import Field
|
|
20
|
+
|
|
21
|
+
from ciphra_mcp import ciphra_client
|
|
22
|
+
|
|
23
|
+
SERVER_NAME = "ciphra"
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
SERVER_VERSION = version("ciphra-mcp")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
SERVER_VERSION = "0.0.0+unknown"
|
|
29
|
+
|
|
30
|
+
logging.basicConfig(
|
|
31
|
+
level=logging.INFO,
|
|
32
|
+
stream=sys.stderr,
|
|
33
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
34
|
+
)
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
mcp = FastMCP(SERVER_NAME)
|
|
38
|
+
# FastMCP's constructor does not accept a server version, so the protocol's
|
|
39
|
+
# initialize response would otherwise advertise the MCP SDK's version
|
|
40
|
+
# (e.g. "1.27.1") under serverInfo. Set the underlying low-level server's
|
|
41
|
+
# version field directly so clients see our package version.
|
|
42
|
+
mcp._mcp_server.version = SERVER_VERSION
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool(
|
|
46
|
+
description=(
|
|
47
|
+
"Use this to answer 'is this codebase secure?' or 'are there "
|
|
48
|
+
"leaked keys?' — scans a directory and its subdirectories (and by "
|
|
49
|
+
"default its git history) for exposed API keys, tokens, and "
|
|
50
|
+
"credentials across 10 services: Stripe, OpenAI, Anthropic, AWS, "
|
|
51
|
+
"GitHub, Google, Twilio, SendGrid, Slack, Supabase. Validation "
|
|
52
|
+
"against live APIs is off by default (set validate=true to "
|
|
53
|
+
"enable). Returns structured findings with severity, file path, "
|
|
54
|
+
"line number, redacted preview, and validation status. For "
|
|
55
|
+
"audits focused on one named service, use check_specific_service. "
|
|
56
|
+
"For validating a single known key value, use validate_key."
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
async def scan_directory(
|
|
60
|
+
path: Annotated[
|
|
61
|
+
str,
|
|
62
|
+
Field(
|
|
63
|
+
description=(
|
|
64
|
+
"Absolute or relative path to the directory to scan. Must "
|
|
65
|
+
"be an existing directory. Use '.' for the current working "
|
|
66
|
+
"directory."
|
|
67
|
+
)
|
|
68
|
+
),
|
|
69
|
+
],
|
|
70
|
+
validate: Annotated[
|
|
71
|
+
bool,
|
|
72
|
+
Field(
|
|
73
|
+
description=(
|
|
74
|
+
"If true, discovered keys are validated against their "
|
|
75
|
+
"respective live APIs to confirm they are active. This "
|
|
76
|
+
"makes network requests to services like Stripe and OpenAI "
|
|
77
|
+
"using the discovered keys. Defaults to false in this MCP "
|
|
78
|
+
"tool (different from the CLI) because the agent should "
|
|
79
|
+
"opt in explicitly when active-key confirmation is needed, "
|
|
80
|
+
"rather than implicitly sending keys to third-party APIs "
|
|
81
|
+
"on every scan. When validation runs, findings include a "
|
|
82
|
+
"validation status. Note that an 'invalid' status does not "
|
|
83
|
+
"mean the leak is safe — the key was still exposed, and "
|
|
84
|
+
"should be rotated regardless. Validation status is "
|
|
85
|
+
"metadata, not a safety signal."
|
|
86
|
+
)
|
|
87
|
+
),
|
|
88
|
+
] = False,
|
|
89
|
+
history: Annotated[
|
|
90
|
+
bool,
|
|
91
|
+
Field(
|
|
92
|
+
description=(
|
|
93
|
+
"If true (default), git commit history is also scanned for "
|
|
94
|
+
"keys that may have been deleted from current files. "
|
|
95
|
+
"Requires the path to be inside a git repository; otherwise "
|
|
96
|
+
"this is skipped silently. Set to false to scan only current "
|
|
97
|
+
"file contents."
|
|
98
|
+
)
|
|
99
|
+
),
|
|
100
|
+
] = True,
|
|
101
|
+
severity: Annotated[
|
|
102
|
+
Literal["critical", "high", "medium", "info"],
|
|
103
|
+
Field(
|
|
104
|
+
description=(
|
|
105
|
+
"Minimum severity threshold for returned findings. One of: "
|
|
106
|
+
"'critical', 'high', 'medium' (default), 'info'. Findings "
|
|
107
|
+
"below the threshold are omitted from the response."
|
|
108
|
+
)
|
|
109
|
+
),
|
|
110
|
+
] = "medium",
|
|
111
|
+
) -> dict:
|
|
112
|
+
return await ciphra_client.scan_directory(
|
|
113
|
+
path,
|
|
114
|
+
validate=validate,
|
|
115
|
+
history=history,
|
|
116
|
+
severity=severity,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
VALIDATE_KEY_DESCRIPTION = (
|
|
121
|
+
"Use this to answer 'is this specific key currently active?' — sends "
|
|
122
|
+
"a single key to its service's live API and returns the result. Only "
|
|
123
|
+
"call when the user owns the key or has explicitly asked; never "
|
|
124
|
+
"validate keys from third-party code, public repos, or pastes "
|
|
125
|
+
"without permission (the call is logged at the service). Returns "
|
|
126
|
+
"status (valid, invalid, unknown, unsupported) and reason; the key "
|
|
127
|
+
"is never echoed back. Validation status is metadata about the "
|
|
128
|
+
"key's current state, not a safety signal — if a key was found in "
|
|
129
|
+
"a codebase, treat the leak as compromised regardless of whether "
|
|
130
|
+
"validation returns 'valid' or 'invalid'. For finding keys-by-"
|
|
131
|
+
"pattern in a codebase (e.g., 'are there any Stripe keys here?'), "
|
|
132
|
+
"use scan_directory or check_specific_service. For finding a "
|
|
133
|
+
"specific literal key value, basic search tools (grep, git log -S) "
|
|
134
|
+
"are usually faster than pattern-based scans."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
SERVICE_ID_PARAM_DESCRIPTION = (
|
|
138
|
+
"Machine-readable service identifier. Must be one of the 10 supported "
|
|
139
|
+
"values. Use the serviceId field from a scan_directory finding, or "
|
|
140
|
+
"pick the appropriate value based on the key's format if the user "
|
|
141
|
+
"supplied a raw key."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
KEY_PARAM_DESCRIPTION = (
|
|
145
|
+
"The API key to validate. Sent to the service's live API to check if "
|
|
146
|
+
"it is active. This value is never logged and never echoed back. "
|
|
147
|
+
"Note that calling this tool with a key creates a record at the "
|
|
148
|
+
"third-party service (their API will log the authentication attempt)."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@mcp.tool(description=VALIDATE_KEY_DESCRIPTION)
|
|
153
|
+
async def validate_key(
|
|
154
|
+
service_id: Annotated[
|
|
155
|
+
Literal[
|
|
156
|
+
"stripe", "openai", "anthropic", "aws", "github",
|
|
157
|
+
"google", "twilio", "sendgrid", "slack", "supabase",
|
|
158
|
+
],
|
|
159
|
+
Field(description=SERVICE_ID_PARAM_DESCRIPTION),
|
|
160
|
+
],
|
|
161
|
+
key: Annotated[str, Field(description=KEY_PARAM_DESCRIPTION)],
|
|
162
|
+
) -> dict:
|
|
163
|
+
return await ciphra_client.validate_key(service_id, key)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
SCAN_GIT_HISTORY_DESCRIPTION = (
|
|
167
|
+
"Use this to answer 'did I ever commit a key I later deleted?' — "
|
|
168
|
+
"walks the git commit history of a repository and returns keys that "
|
|
169
|
+
"were committed at some point but may be absent from current files. "
|
|
170
|
+
"Useful for auditing past commits and branches; keys removed from "
|
|
171
|
+
"HEAD remain recoverable from history and are considered "
|
|
172
|
+
"compromised. Returns findings only from git history — current file "
|
|
173
|
+
"contents are NOT scanned. Path must be inside a git repository. "
|
|
174
|
+
"For a full scan including current files, use scan_directory. For "
|
|
175
|
+
"a current+history scan filtered to one service, use "
|
|
176
|
+
"check_specific_service with history=true (note: that combines "
|
|
177
|
+
"current files and history; this tool is history-only)."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
SCAN_GIT_HISTORY_PATH_PARAM_DESCRIPTION = (
|
|
181
|
+
"Absolute or relative path inside a git repository to audit. The "
|
|
182
|
+
"scan walks the full git history reachable from all branches. Must "
|
|
183
|
+
"be a path inside a git repo — passing a non-git directory raises "
|
|
184
|
+
"an error."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
SCAN_GIT_HISTORY_VALIDATE_PARAM_DESCRIPTION = (
|
|
188
|
+
"If true, discovered historical keys are validated against their "
|
|
189
|
+
"live APIs to check if they're still active. Defaults to false. "
|
|
190
|
+
"When validation runs, findings include a validation status. Note "
|
|
191
|
+
"that an 'invalid' status does not mean the leak is safe — the key "
|
|
192
|
+
"was still exposed (and a key in git history remains recoverable "
|
|
193
|
+
"forever), and should be rotated regardless. Validation status is "
|
|
194
|
+
"metadata, not a safety signal."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
SCAN_GIT_HISTORY_SEVERITY_PARAM_DESCRIPTION = (
|
|
198
|
+
"Minimum severity threshold for returned findings. One of: "
|
|
199
|
+
"'critical', 'high', 'medium' (default), 'info'."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@mcp.tool(description=SCAN_GIT_HISTORY_DESCRIPTION)
|
|
204
|
+
async def scan_git_history(
|
|
205
|
+
path: Annotated[str, Field(description=SCAN_GIT_HISTORY_PATH_PARAM_DESCRIPTION)],
|
|
206
|
+
validate: Annotated[
|
|
207
|
+
bool,
|
|
208
|
+
Field(description=SCAN_GIT_HISTORY_VALIDATE_PARAM_DESCRIPTION),
|
|
209
|
+
] = False,
|
|
210
|
+
severity: Annotated[
|
|
211
|
+
Literal["critical", "high", "medium", "info"],
|
|
212
|
+
Field(description=SCAN_GIT_HISTORY_SEVERITY_PARAM_DESCRIPTION),
|
|
213
|
+
] = "medium",
|
|
214
|
+
) -> dict:
|
|
215
|
+
return await ciphra_client.scan_git_history(
|
|
216
|
+
path, validate=validate, severity=severity
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
CHECK_SPECIFIC_SERVICE_DESCRIPTION = (
|
|
221
|
+
"Use this when the user has named a specific service of concern "
|
|
222
|
+
"('worried about Stripe keys', 'any OpenAI keys leaked?') — scans "
|
|
223
|
+
"a directory for keys belonging to ONE service and validates them "
|
|
224
|
+
"in a single call. Faster and more focused than scan_directory "
|
|
225
|
+
"because only the named service's detectors run. Unlike "
|
|
226
|
+
"scan_directory (which defaults validate=false to avoid network "
|
|
227
|
+
"calls on every scan), this tool defaults validate=true: scope is "
|
|
228
|
+
"narrow, and the user typically wants to know whether keys are "
|
|
229
|
+
"both present and still active. Also scans git history by default. "
|
|
230
|
+
"For a general scan across services, use scan_directory. For "
|
|
231
|
+
"checking a single key value, use validate_key."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
CHECK_SPECIFIC_SERVICE_SERVICE_ID_PARAM_DESCRIPTION = (
|
|
235
|
+
"Machine-readable identifier of the single service to scan for. "
|
|
236
|
+
"Must be one of the 10 supported values. Pick based on the user's "
|
|
237
|
+
"phrasing: 'Stripe' → 'stripe', 'OpenAI' / 'GPT' → 'openai', "
|
|
238
|
+
"'Anthropic' / 'Claude' → 'anthropic', 'AWS' / 'Amazon' → 'aws', etc."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
CHECK_SPECIFIC_SERVICE_PATH_PARAM_DESCRIPTION = (
|
|
242
|
+
"Absolute or relative path to the directory to scan. Same semantics "
|
|
243
|
+
"as scan_directory's path parameter."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
CHECK_SPECIFIC_SERVICE_VALIDATE_PARAM_DESCRIPTION = (
|
|
247
|
+
"If true (default), discovered keys are validated against the "
|
|
248
|
+
"service's live API to confirm they are currently active. Defaults "
|
|
249
|
+
"to true for this tool (unlike scan_directory) because the user has "
|
|
250
|
+
"specifically narrowed to one service and the validation cost is "
|
|
251
|
+
"bounded. Set to false to skip the network calls. When validation "
|
|
252
|
+
"runs, findings include a validation status. Note that an 'invalid' "
|
|
253
|
+
"status does not mean the leak is safe — the key was still exposed, "
|
|
254
|
+
"and should be rotated regardless. Validation status is metadata, "
|
|
255
|
+
"not a safety signal."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
CHECK_SPECIFIC_SERVICE_HISTORY_PARAM_DESCRIPTION = (
|
|
259
|
+
"If true (default), git commit history is also scanned. Set to false "
|
|
260
|
+
"to scan only current files. Requires the path to be inside a git "
|
|
261
|
+
"repository for history scanning; otherwise that part is skipped "
|
|
262
|
+
"silently."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
CHECK_SPECIFIC_SERVICE_SEVERITY_PARAM_DESCRIPTION = (
|
|
266
|
+
"Minimum severity threshold for returned findings. One of: "
|
|
267
|
+
"'critical', 'high', 'medium' (default), 'info'."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@mcp.tool(description=CHECK_SPECIFIC_SERVICE_DESCRIPTION)
|
|
272
|
+
async def check_specific_service(
|
|
273
|
+
service_id: Annotated[
|
|
274
|
+
Literal[
|
|
275
|
+
"stripe", "openai", "anthropic", "aws", "github",
|
|
276
|
+
"google", "twilio", "sendgrid", "slack", "supabase",
|
|
277
|
+
],
|
|
278
|
+
Field(description=CHECK_SPECIFIC_SERVICE_SERVICE_ID_PARAM_DESCRIPTION),
|
|
279
|
+
],
|
|
280
|
+
path: Annotated[str, Field(description=CHECK_SPECIFIC_SERVICE_PATH_PARAM_DESCRIPTION)],
|
|
281
|
+
validate: Annotated[
|
|
282
|
+
bool,
|
|
283
|
+
Field(description=CHECK_SPECIFIC_SERVICE_VALIDATE_PARAM_DESCRIPTION),
|
|
284
|
+
] = True,
|
|
285
|
+
history: Annotated[
|
|
286
|
+
bool,
|
|
287
|
+
Field(description=CHECK_SPECIFIC_SERVICE_HISTORY_PARAM_DESCRIPTION),
|
|
288
|
+
] = True,
|
|
289
|
+
severity: Annotated[
|
|
290
|
+
Literal["critical", "high", "medium", "info"],
|
|
291
|
+
Field(description=CHECK_SPECIFIC_SERVICE_SEVERITY_PARAM_DESCRIPTION),
|
|
292
|
+
] = "medium",
|
|
293
|
+
) -> dict:
|
|
294
|
+
return await ciphra_client.check_specific_service(
|
|
295
|
+
service_id, path,
|
|
296
|
+
validate=validate, history=history, severity=severity,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main() -> None:
|
|
301
|
+
"""Entry point for the `ciphra-mcp` console script.
|
|
302
|
+
|
|
303
|
+
Starts the MCP server over stdio. Blocks until the client disconnects.
|
|
304
|
+
"""
|
|
305
|
+
logger.info("starting %s v%s (stdio)", SERVER_NAME, SERVER_VERSION)
|
|
306
|
+
mcp.run()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""TypedDicts mirroring the Ciphra CLI JSON output schema (schemaVersion 1.0).
|
|
2
|
+
|
|
3
|
+
The TS CLI is the source of truth for this shape; see src/cli/format-json.ts
|
|
4
|
+
in the repo root. If the schemaVersion is ever bumped, both the constant
|
|
5
|
+
below and these TypedDicts must be updated in lockstep.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal, Optional, TypedDict, get_args
|
|
11
|
+
|
|
12
|
+
EXPECTED_SCHEMA_VERSION = "1.0"
|
|
13
|
+
|
|
14
|
+
Severity = Literal["critical", "high", "medium", "info"]
|
|
15
|
+
Source = Literal["file", "git-history"]
|
|
16
|
+
Confidence = Literal["high", "medium", "low"]
|
|
17
|
+
ValidationStatus = Literal["valid", "invalid", "unknown", "unsupported"]
|
|
18
|
+
ServiceId = Literal[
|
|
19
|
+
"stripe",
|
|
20
|
+
"openai",
|
|
21
|
+
"anthropic",
|
|
22
|
+
"aws",
|
|
23
|
+
"github",
|
|
24
|
+
"google",
|
|
25
|
+
"twilio",
|
|
26
|
+
"sendgrid",
|
|
27
|
+
"slack",
|
|
28
|
+
"supabase",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Runtime-iterable tuple derived from the Literal above. Use this for
|
|
32
|
+
# membership checks (e.g. `if x in KNOWN_SERVICE_IDS`); `Literal` itself
|
|
33
|
+
# is a type-time-only construct and doesn't enforce membership at runtime.
|
|
34
|
+
KNOWN_SERVICE_IDS: tuple[str, ...] = get_args(ServiceId)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Validation(TypedDict):
|
|
38
|
+
status: ValidationStatus
|
|
39
|
+
reason: Optional[str]
|
|
40
|
+
checkedAt: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Finding(TypedDict):
|
|
44
|
+
id: str
|
|
45
|
+
severity: Severity
|
|
46
|
+
serviceId: ServiceId
|
|
47
|
+
serviceName: str
|
|
48
|
+
detectorName: str
|
|
49
|
+
filePath: str
|
|
50
|
+
relativePath: str
|
|
51
|
+
line: int
|
|
52
|
+
column: int
|
|
53
|
+
redactedSecret: str
|
|
54
|
+
source: Source
|
|
55
|
+
confidence: Confidence
|
|
56
|
+
commitSha: Optional[str]
|
|
57
|
+
validation: Optional[Validation]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Summary(TypedDict):
|
|
61
|
+
total: int
|
|
62
|
+
critical: int
|
|
63
|
+
high: int
|
|
64
|
+
medium: int
|
|
65
|
+
info: int
|
|
66
|
+
byService: dict[str, int]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CiphraVersion(TypedDict):
|
|
70
|
+
version: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ScanResult(TypedDict):
|
|
74
|
+
schemaVersion: str
|
|
75
|
+
ciphra: CiphraVersion
|
|
76
|
+
scanPath: str
|
|
77
|
+
scannedAt: str
|
|
78
|
+
scanDurationMs: int
|
|
79
|
+
filesScanned: int
|
|
80
|
+
summary: Summary
|
|
81
|
+
findings: list[Finding]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ServiceInfo(TypedDict):
|
|
85
|
+
id: ServiceId
|
|
86
|
+
name: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ValidateResult(TypedDict):
|
|
90
|
+
schemaVersion: str
|
|
91
|
+
ciphra: CiphraVersion
|
|
92
|
+
service: ServiceInfo
|
|
93
|
+
validation: Validation
|