@baichen_yu/mcp-guard 0.3.0
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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/RELEASE.md +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +257 -0
- package/dist/mcp/jsonrpc.d.ts +23 -0
- package/dist/mcp/jsonrpc.js +124 -0
- package/dist/mcp/transport_stdio.d.ts +10 -0
- package/dist/mcp/transport_stdio.js +69 -0
- package/dist/mcp/types.d.ts +75 -0
- package/dist/mcp/types.js +1 -0
- package/dist/registry/validator.d.ts +20 -0
- package/dist/registry/validator.js +98 -0
- package/dist/report/json.d.ts +1 -0
- package/dist/report/json.js +8 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +61 -0
- package/dist/report/sarif.d.ts +2 -0
- package/dist/report/sarif.js +33 -0
- package/dist/scan/scanner.d.ts +16 -0
- package/dist/scan/scanner.js +86 -0
- package/dist/security/profiles.d.ts +6 -0
- package/dist/security/profiles.js +25 -0
- package/dist/security/rules/path_traversal.d.ts +2 -0
- package/dist/security/rules/path_traversal.js +24 -0
- package/dist/security/rules/raw_args.d.ts +2 -0
- package/dist/security/rules/raw_args.js +24 -0
- package/dist/security/rules/shell_injection.d.ts +2 -0
- package/dist/security/rules/shell_injection.js +23 -0
- package/dist/security/scorer.d.ts +5 -0
- package/dist/security/scorer.js +4 -0
- package/dist/tests/contract/call_tool.d.ts +2 -0
- package/dist/tests/contract/call_tool.js +14 -0
- package/dist/tests/contract/cancellation.d.ts +2 -0
- package/dist/tests/contract/cancellation.js +22 -0
- package/dist/tests/contract/error_shapes.d.ts +2 -0
- package/dist/tests/contract/error_shapes.js +25 -0
- package/dist/tests/contract/large_payload.d.ts +2 -0
- package/dist/tests/contract/large_payload.js +15 -0
- package/dist/tests/contract/list_tools.d.ts +5 -0
- package/dist/tests/contract/list_tools.js +14 -0
- package/dist/tests/contract/timeout.d.ts +2 -0
- package/dist/tests/contract/timeout.js +25 -0
- package/dist/validate/schema_lints.d.ts +2 -0
- package/dist/validate/schema_lints.js +65 -0
- package/docs/assets/demo.gif +1 -0
- package/docs/cli.md +38 -0
- package/docs/github-action.md +23 -0
- package/docs/index.md +75 -0
- package/docs/quickstart.md +34 -0
- package/docs/releasing.md +39 -0
- package/docs/rules.md +18 -0
- package/docs/security-model.md +14 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# mcp-guard
|
|
2
|
+
|
|
3
|
+
[](./.github/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@baichen_yu/mcp-guard)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Security auditing and policy gating for MCP servers (local + CI), with deterministic tests and Markdown/SARIF outputs.
|
|
8
|
+
|
|
9
|
+
> Formerly **mcp-doctor**.
|
|
10
|
+
|
|
11
|
+
> [!IMPORTANT]
|
|
12
|
+
> Remote mode supports **HTTP JSON-RPC only** (`--http`). SSE is not implemented.
|
|
13
|
+
|
|
14
|
+
## Package name
|
|
15
|
+
|
|
16
|
+
- npm package: `@baichen_yu/mcp-guard` (scoped to avoid name collisions)
|
|
17
|
+
- CLI command after install: `mcp-guard`
|
|
18
|
+
- first scoped publish: `npm publish --access public`
|
|
19
|
+
|
|
20
|
+
## 30-second quickstart
|
|
21
|
+
|
|
22
|
+
### A) No install (npx)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx @baichen_yu/mcp-guard audit --stdio "node fixtures/servers/hello-mcp-server/server.cjs" --out reports --fail-on off
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### B) Global install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm i -g @baichen_yu/mcp-guard
|
|
32
|
+
mcp-guard --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### C) GitHub Action (paste into workflow)
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
jobs:
|
|
39
|
+
mcp-audit:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
permissions:
|
|
42
|
+
security-events: write
|
|
43
|
+
actions: read
|
|
44
|
+
contents: read
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/checkout@v4
|
|
47
|
+
- uses: actions/setup-node@v4
|
|
48
|
+
with:
|
|
49
|
+
node-version: 20
|
|
50
|
+
- uses: ./.github/actions/mcp-guard
|
|
51
|
+
with:
|
|
52
|
+
stdio_command: node fixtures/servers/hello-mcp-server/server.cjs
|
|
53
|
+
fail_on: high
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Report preview
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
# MCP Guard Report
|
|
60
|
+
- Risk score: 100/100
|
|
61
|
+
- Key findings: 0
|
|
62
|
+
- Contract tests: 6/6
|
|
63
|
+
- Target: node fixtures/servers/hello-mcp-server/server.cjs (stdio)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Architecture
|
|
67
|
+
|
|
68
|
+
```mermaid
|
|
69
|
+
graph LR
|
|
70
|
+
CLI[mcp-guard CLI] --> T[Transports: stdio/http]
|
|
71
|
+
T --> RPC[JSON-RPC]
|
|
72
|
+
RPC --> RULES[Rules + Profiles]
|
|
73
|
+
RULES --> REP[Reports: md/json/sarif]
|
|
74
|
+
REP --> GATE[Policy Gate (--fail-on)]
|
|
75
|
+
GATE --> CI[CI / Code Scanning]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
mcp-guard validate --stdio "node server.cjs" --profile default --out reports
|
|
82
|
+
mcp-guard test --stdio "node server.cjs" --out reports
|
|
83
|
+
mcp-guard audit --stdio "node server.cjs" --profile strict --fail-on medium --sarif reports/report.sarif
|
|
84
|
+
mcp-guard audit --http "http://127.0.0.1:4010" --timeout-ms 30000 --fail-on off
|
|
85
|
+
mcp-guard scan --repo . --format md --out reports
|
|
86
|
+
mcp-guard registry lint registry/servers.yaml
|
|
87
|
+
mcp-guard registry verify registry/servers.yaml --sample 5
|
|
88
|
+
mcp-guard registry score registry/servers.yaml
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Docs site (GitHub Pages)
|
|
92
|
+
|
|
93
|
+
- Docs URL pattern: `https://<owner>.github.io/MCP-shariff/`
|
|
94
|
+
- One-time setup: GitHub repo **Settings → Pages → Source → GitHub Actions**
|
|
95
|
+
- Deploy workflow: `.github/workflows/deploy-pages.yml`
|
|
96
|
+
|
|
97
|
+
## Links
|
|
98
|
+
|
|
99
|
+
- Docs: https://tomas-1226.github.io/MCP-shariff/
|
|
100
|
+
- GitHub: https://github.com/TomAs-1226/MCP-shariff
|
|
101
|
+
- npm: https://www.npmjs.com/package/@baichen_yu/mcp-guard
|
|
102
|
+
|
|
103
|
+
## Troubleshooting
|
|
104
|
+
|
|
105
|
+
- Node `>=20` required
|
|
106
|
+
- On Windows, wrap `--stdio` command in double quotes
|
|
107
|
+
- If startup is slow, increase `--timeout-ms`
|
|
108
|
+
- HTTP target must accept JSON-RPC over POST
|
|
109
|
+
|
|
110
|
+
## Releasing
|
|
111
|
+
|
|
112
|
+
See [`docs/releasing.md`](docs/releasing.md) and [`RELEASE.md`](RELEASE.md).
|
|
113
|
+
|
|
114
|
+
### Local release bundle script
|
|
115
|
+
|
|
116
|
+
Use this helper to generate a release-ready tarball locally:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
./scripts/build-release-local.sh
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Publishing troubleshooting
|
|
123
|
+
|
|
124
|
+
Run `npm publish` from the project root that contains `package.json`.
|
|
125
|
+
If you hit `ENOENT` for `package.json`, you are publishing from the wrong directory.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT. See [`LICENSE`](LICENSE).
|
package/RELEASE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Release Checklist
|
|
2
|
+
|
|
3
|
+
1. Freeze CLI/API flags for the target release.
|
|
4
|
+
2. Set npm scope in `package.json` (`@<scope>/mcp-guard`) and verify package metadata.
|
|
5
|
+
3. Run full checks:
|
|
6
|
+
- `npm run fixtures:gen`
|
|
7
|
+
- `npm run lint`
|
|
8
|
+
- `npm test`
|
|
9
|
+
- `npm run build`
|
|
10
|
+
- `npm run docs:build`
|
|
11
|
+
- `npm pack --dry-run`
|
|
12
|
+
4. First scoped publish:
|
|
13
|
+
- `npm publish --access public`
|
|
14
|
+
5. Tag + release:
|
|
15
|
+
- `git tag v0.3.0`
|
|
16
|
+
- `git push origin v0.3.0`
|
|
17
|
+
6. Create GitHub Release notes with:
|
|
18
|
+
- highlights
|
|
19
|
+
- migration note (“formerly mcp-doctor”)
|
|
20
|
+
- limitation note (HTTP JSON-RPC only; no SSE)
|
|
21
|
+
7. Confirm docs site is live on GitHub Pages.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { HttpJsonRpcClient, StdioJsonRpcClient } from './mcp/jsonrpc.js';
|
|
6
|
+
import { StdioTransport } from './mcp/transport_stdio.js';
|
|
7
|
+
import { writeJsonReport } from './report/json.js';
|
|
8
|
+
import { writeMarkdownReport } from './report/markdown.js';
|
|
9
|
+
import { writeSarif } from './report/sarif.js';
|
|
10
|
+
import { verifyRegistry, lintRegistry, parseRegistry } from './registry/validator.js';
|
|
11
|
+
import { tuneFindingsForProfile } from './security/profiles.js';
|
|
12
|
+
import { pathTraversalRule } from './security/rules/path_traversal.js';
|
|
13
|
+
import { rawArgsRule } from './security/rules/raw_args.js';
|
|
14
|
+
import { shellInjectionRule } from './security/rules/shell_injection.js';
|
|
15
|
+
import { computeRiskScore } from './security/scorer.js';
|
|
16
|
+
import { scanConfigs } from './scan/scanner.js';
|
|
17
|
+
import { runCallToolTest } from './tests/contract/call_tool.js';
|
|
18
|
+
import { runCancellationTest } from './tests/contract/cancellation.js';
|
|
19
|
+
import { runErrorShapesTest } from './tests/contract/error_shapes.js';
|
|
20
|
+
import { runLargePayloadTest } from './tests/contract/large_payload.js';
|
|
21
|
+
import { runListToolsTest } from './tests/contract/list_tools.js';
|
|
22
|
+
import { runTimeoutTest } from './tests/contract/timeout.js';
|
|
23
|
+
import { runSchemaLints } from './validate/schema_lints.js';
|
|
24
|
+
const program = new Command();
|
|
25
|
+
function parseProfile(value) {
|
|
26
|
+
if (value === 'default' || value === 'strict' || value === 'paranoid')
|
|
27
|
+
return value;
|
|
28
|
+
throw new Error(`Invalid profile: ${value}`);
|
|
29
|
+
}
|
|
30
|
+
function parseFailOn(value) {
|
|
31
|
+
if (value === 'off' || value === 'low' || value === 'medium' || value === 'high')
|
|
32
|
+
return value;
|
|
33
|
+
throw new Error(`Invalid fail_on value: ${value}`);
|
|
34
|
+
}
|
|
35
|
+
function shouldFailByPolicy(findings, failOn) {
|
|
36
|
+
if (failOn === 'off')
|
|
37
|
+
return false;
|
|
38
|
+
const rank = { off: 99, low: 0, medium: 1, high: 2 };
|
|
39
|
+
const severityRank = { low: 0, medium: 1, high: 2 };
|
|
40
|
+
return findings.some((finding) => severityRank[finding.severity] >= rank[failOn]);
|
|
41
|
+
}
|
|
42
|
+
async function buildClient(options) {
|
|
43
|
+
if (!options.stdio && !options.http)
|
|
44
|
+
throw new Error('One of --stdio or --http is required.');
|
|
45
|
+
if (options.stdio && options.http)
|
|
46
|
+
throw new Error('Use only one transport: --stdio or --http.');
|
|
47
|
+
if (options.stdio) {
|
|
48
|
+
const transport = new StdioTransport();
|
|
49
|
+
await transport.start({ stdioCommand: options.stdio, silent: options.silent });
|
|
50
|
+
return { client: new StdioJsonRpcClient(transport, options.timeoutMs), target: options.stdio, transport: 'stdio' };
|
|
51
|
+
}
|
|
52
|
+
return { client: new HttpJsonRpcClient(options.http, options.timeoutMs), target: options.http, transport: 'http' };
|
|
53
|
+
}
|
|
54
|
+
async function executeSuite(params) {
|
|
55
|
+
const { client, target, transport } = await buildClient({ stdio: params.stdio, http: params.http, timeoutMs: params.timeoutMs, silent: false });
|
|
56
|
+
const findings = [];
|
|
57
|
+
let tools = [];
|
|
58
|
+
const tests = [];
|
|
59
|
+
let protocolVersion = 'unknown';
|
|
60
|
+
try {
|
|
61
|
+
const init = await client.request('initialize', {
|
|
62
|
+
clientInfo: { name: 'mcp-guard', version: '0.3.0' }
|
|
63
|
+
});
|
|
64
|
+
protocolVersion = init.protocolVersion ?? 'unknown';
|
|
65
|
+
const listResult = await runListToolsTest(client);
|
|
66
|
+
tools = listResult.tools;
|
|
67
|
+
if (params.runTests)
|
|
68
|
+
tests.push(listResult.result);
|
|
69
|
+
if (params.runTests) {
|
|
70
|
+
tests.push(await runCallToolTest(client));
|
|
71
|
+
tests.push(await runErrorShapesTest(client));
|
|
72
|
+
tests.push(await runCancellationTest(client));
|
|
73
|
+
tests.push(await runLargePayloadTest(client));
|
|
74
|
+
tests.push(await runTimeoutTest(client));
|
|
75
|
+
}
|
|
76
|
+
if (params.runAudit) {
|
|
77
|
+
findings.push(...runSchemaLints(tools, params.profile));
|
|
78
|
+
findings.push(...pathTraversalRule(tools));
|
|
79
|
+
findings.push(...shellInjectionRule(tools));
|
|
80
|
+
findings.push(...rawArgsRule(tools));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await client.close();
|
|
85
|
+
}
|
|
86
|
+
const tunedFindings = tuneFindingsForProfile(findings, params.profile);
|
|
87
|
+
const { score, breakdown } = computeRiskScore(tunedFindings, params.profile);
|
|
88
|
+
const report = {
|
|
89
|
+
server: { target, transport, protocolVersion },
|
|
90
|
+
tools,
|
|
91
|
+
findings: tunedFindings,
|
|
92
|
+
tests,
|
|
93
|
+
score,
|
|
94
|
+
scoreBreakdown: breakdown,
|
|
95
|
+
generatedAt: new Date().toISOString()
|
|
96
|
+
};
|
|
97
|
+
await mkdir(params.outDir, { recursive: true });
|
|
98
|
+
await writeJsonReport(report, params.outDir);
|
|
99
|
+
await writeMarkdownReport(report, params.outDir);
|
|
100
|
+
if (params.sarif) {
|
|
101
|
+
await writeSarif(tunedFindings, params.sarif);
|
|
102
|
+
}
|
|
103
|
+
if (tests.some((t) => !t.passed))
|
|
104
|
+
return 2;
|
|
105
|
+
if (shouldFailByPolicy(tunedFindings, params.failOn))
|
|
106
|
+
return 2;
|
|
107
|
+
if (tunedFindings.length > 0)
|
|
108
|
+
return 1;
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
program
|
|
112
|
+
.name('mcp-guard')
|
|
113
|
+
.description('Validate, test, and security-audit MCP servers over STDIO or HTTP JSON-RPC')
|
|
114
|
+
.showHelpAfterError();
|
|
115
|
+
for (const commandName of ['validate', 'test', 'audit']) {
|
|
116
|
+
program.command(commandName)
|
|
117
|
+
.description(`${commandName} an MCP server`)
|
|
118
|
+
.option('--stdio <cmd>', 'Command used to launch MCP server over stdio')
|
|
119
|
+
.option('--http <url>', 'HTTP JSON-RPC endpoint URL')
|
|
120
|
+
.option('--out <dir>', 'Output directory', 'reports')
|
|
121
|
+
.option('--sarif <file>', 'SARIF output file (mainly for audit)', 'reports/report.sarif')
|
|
122
|
+
.option('--profile <profile>', 'Rule profile: default|strict|paranoid', parseProfile, 'default')
|
|
123
|
+
.option('--timeout-ms <n>', 'Request timeout in milliseconds', (value) => Number(value), 30000)
|
|
124
|
+
.option('--fail-on <level>', 'Policy threshold: off|low|medium|high', parseFailOn, 'high')
|
|
125
|
+
.action(async (options) => {
|
|
126
|
+
const doTests = commandName === 'test' || commandName === 'audit';
|
|
127
|
+
const doAudit = commandName === 'audit' || commandName === 'validate';
|
|
128
|
+
const code = await executeSuite({
|
|
129
|
+
stdio: options.stdio,
|
|
130
|
+
http: options.http,
|
|
131
|
+
outDir: options.out,
|
|
132
|
+
runTests: doTests,
|
|
133
|
+
runAudit: doAudit,
|
|
134
|
+
sarif: options.sarif,
|
|
135
|
+
profile: options.profile,
|
|
136
|
+
timeoutMs: options.timeoutMs,
|
|
137
|
+
failOn: options.failOn
|
|
138
|
+
});
|
|
139
|
+
process.exit(code);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
program.command('scan')
|
|
143
|
+
.description('Discover MCP server configs in local files and repositories')
|
|
144
|
+
.option('--repo <path>', 'Repository path to scan for known config files')
|
|
145
|
+
.option('--path <file>', 'Direct config file to parse')
|
|
146
|
+
.option('--format <format>', 'Output format: md|json|sarif', 'md')
|
|
147
|
+
.option('--out <dir>', 'Output directory', 'reports')
|
|
148
|
+
.action(async (options) => {
|
|
149
|
+
const result = await scanConfigs(options.repo, options.path);
|
|
150
|
+
for (const server of result.servers) {
|
|
151
|
+
if (server.transport !== 'stdio' || !server.rawCommand)
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
const { client } = await buildClient({ stdio: server.rawCommand, timeoutMs: 1000, silent: true });
|
|
155
|
+
await client.request('initialize', { clientInfo: { name: 'mcp-guard-scan', version: '0.3.0' } }, 1000);
|
|
156
|
+
const listed = (await client.request('tools/list', undefined, 1000)).tools;
|
|
157
|
+
const localFindings = [
|
|
158
|
+
...runSchemaLints(listed, 'default'),
|
|
159
|
+
...pathTraversalRule(listed),
|
|
160
|
+
...shellInjectionRule(listed),
|
|
161
|
+
...rawArgsRule(listed)
|
|
162
|
+
];
|
|
163
|
+
server.reachable = true;
|
|
164
|
+
server.findingsCount = localFindings.length;
|
|
165
|
+
await client.close();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
server.reachable = false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const output = {
|
|
172
|
+
generatedAt: new Date().toISOString(),
|
|
173
|
+
scannedPaths: result.scannedPaths,
|
|
174
|
+
servers: result.servers
|
|
175
|
+
};
|
|
176
|
+
await mkdir(options.out, { recursive: true });
|
|
177
|
+
if (options.format === 'json') {
|
|
178
|
+
await writeJsonReport(output, options.out, 'scan_report.json');
|
|
179
|
+
}
|
|
180
|
+
else if (options.format === 'sarif') {
|
|
181
|
+
await writeSarif([], `${options.out}/scan_report.sarif`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const pseudoReport = {
|
|
185
|
+
server: { target: options.repo ?? options.path ?? '.', transport: 'stdio' },
|
|
186
|
+
tools: [],
|
|
187
|
+
findings: [],
|
|
188
|
+
tests: [],
|
|
189
|
+
score: 100,
|
|
190
|
+
scoreBreakdown: [],
|
|
191
|
+
generatedAt: new Date().toISOString()
|
|
192
|
+
};
|
|
193
|
+
await writeMarkdownReport(pseudoReport, options.out, 'scan_report.md');
|
|
194
|
+
const lines = ['# Scan Report', '', '| Source | Name | Transport | Reachable | Findings | Command |', '| --- | --- | --- | --- | --- | --- |'];
|
|
195
|
+
for (const server of result.servers) {
|
|
196
|
+
lines.push(`| ${server.source} | ${server.name} | ${server.transport} | ${server.reachable ?? false} | ${server.findingsCount ?? '-'} | ${server.command ?? ''} |`);
|
|
197
|
+
}
|
|
198
|
+
await import('node:fs/promises').then((fs) => fs.writeFile(`${options.out}/scan_report.md`, `${lines.join('\n')}\n`, 'utf8'));
|
|
199
|
+
}
|
|
200
|
+
// eslint-disable-next-line no-console
|
|
201
|
+
console.log(`Discovered ${result.servers.length} server definitions`);
|
|
202
|
+
process.exit(0);
|
|
203
|
+
});
|
|
204
|
+
const registry = program.command('registry').description('Registry utilities');
|
|
205
|
+
registry.command('lint')
|
|
206
|
+
.description('Validate registry schema and metadata')
|
|
207
|
+
.argument('<file>', 'Registry yaml file')
|
|
208
|
+
.action(async (file) => {
|
|
209
|
+
const raw = await readFile(file, 'utf8');
|
|
210
|
+
const issues = lintRegistry(raw);
|
|
211
|
+
if (issues.length === 0) {
|
|
212
|
+
// eslint-disable-next-line no-console
|
|
213
|
+
console.log(`Registry lint ok: ${parseRegistry(raw).length} entries`);
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
for (const issue of issues) {
|
|
217
|
+
// eslint-disable-next-line no-console
|
|
218
|
+
console.log(`${issue.severity.toUpperCase()} L${issue.line}: ${issue.message}`);
|
|
219
|
+
}
|
|
220
|
+
process.exit(issues.some((issue) => issue.severity === 'error') ? 2 : 1);
|
|
221
|
+
});
|
|
222
|
+
registry.command('score')
|
|
223
|
+
.description('Write local registry scoreboard')
|
|
224
|
+
.argument('<file>', 'Registry yaml file')
|
|
225
|
+
.action(async (file) => {
|
|
226
|
+
const raw = await readFile(file, 'utf8');
|
|
227
|
+
const entries = parseRegistry(raw);
|
|
228
|
+
const rows = ['# Registry Scoreboard', '', '| Server | Score |', '| --- | --- |'];
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
const score = entry.permissions && entry.permissions.length > 0 ? 90 : 70;
|
|
231
|
+
rows.push(`| ${String(entry.name ?? basename(file))} | ${score} |`);
|
|
232
|
+
}
|
|
233
|
+
await mkdir('results', { recursive: true });
|
|
234
|
+
await import('node:fs/promises').then((fs) => fs.writeFile('results/scoreboard.md', `${rows.join('\n')}\n`, 'utf8'));
|
|
235
|
+
// eslint-disable-next-line no-console
|
|
236
|
+
console.log('Wrote results/scoreboard.md');
|
|
237
|
+
process.exit(0);
|
|
238
|
+
});
|
|
239
|
+
registry.command('verify')
|
|
240
|
+
.description('Offline validation for completeness and unsafe command patterns')
|
|
241
|
+
.argument('<file>', 'Registry yaml file')
|
|
242
|
+
.option('--sample <n>', 'Number of entries to verify', (value) => Number(value), 5)
|
|
243
|
+
.action(async (file, options) => {
|
|
244
|
+
const raw = await readFile(file, 'utf8');
|
|
245
|
+
const issues = verifyRegistry(raw, options.sample);
|
|
246
|
+
if (issues.length === 0) {
|
|
247
|
+
// eslint-disable-next-line no-console
|
|
248
|
+
console.log('Registry verify passed with no issues');
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
for (const issue of issues) {
|
|
252
|
+
// eslint-disable-next-line no-console
|
|
253
|
+
console.log(`${issue.severity.toUpperCase()} L${issue.line}: ${issue.message}`);
|
|
254
|
+
}
|
|
255
|
+
process.exit(issues.some((issue) => issue.severity === 'error') ? 2 : 1);
|
|
256
|
+
});
|
|
257
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { StdioTransport } from './transport_stdio.js';
|
|
2
|
+
import { NormalizedError, RpcClient } from './types.js';
|
|
3
|
+
export declare class StdioJsonRpcClient implements RpcClient {
|
|
4
|
+
private readonly transport;
|
|
5
|
+
private readonly timeoutMs;
|
|
6
|
+
private nextId;
|
|
7
|
+
private pending;
|
|
8
|
+
constructor(transport: StdioTransport, timeoutMs?: number);
|
|
9
|
+
request<T>(method: string, params?: unknown, timeoutOverrideMs?: number): Promise<T>;
|
|
10
|
+
normalizeError(error: unknown): NormalizedError;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
private handleLine;
|
|
13
|
+
}
|
|
14
|
+
export declare class HttpJsonRpcClient implements RpcClient {
|
|
15
|
+
private readonly url;
|
|
16
|
+
private readonly timeoutMs;
|
|
17
|
+
private nextId;
|
|
18
|
+
constructor(url: string, timeoutMs?: number);
|
|
19
|
+
request<T>(method: string, params?: unknown, timeoutOverrideMs?: number): Promise<T>;
|
|
20
|
+
normalizeError(error: unknown): NormalizedError;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare function normalizeError(error: unknown): NormalizedError;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export class StdioJsonRpcClient {
|
|
2
|
+
transport;
|
|
3
|
+
timeoutMs;
|
|
4
|
+
nextId = 1;
|
|
5
|
+
pending = new Map();
|
|
6
|
+
constructor(transport, timeoutMs = 5000) {
|
|
7
|
+
this.transport = transport;
|
|
8
|
+
this.timeoutMs = timeoutMs;
|
|
9
|
+
this.transport.onLine((line) => this.handleLine(line));
|
|
10
|
+
}
|
|
11
|
+
async request(method, params, timeoutOverrideMs) {
|
|
12
|
+
const id = this.nextId++;
|
|
13
|
+
const payload = {
|
|
14
|
+
jsonrpc: '2.0',
|
|
15
|
+
id,
|
|
16
|
+
method,
|
|
17
|
+
params
|
|
18
|
+
};
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const timer = setTimeout(() => {
|
|
21
|
+
this.pending.delete(id);
|
|
22
|
+
reject(new Error(`Request timed out: ${method}`));
|
|
23
|
+
}, timeoutOverrideMs ?? this.timeoutMs);
|
|
24
|
+
this.pending.set(id, { resolve: resolve, reject, timer });
|
|
25
|
+
this.transport.send(JSON.stringify(payload));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
normalizeError(error) {
|
|
29
|
+
return normalizeError(error);
|
|
30
|
+
}
|
|
31
|
+
async close() {
|
|
32
|
+
await this.transport.stop();
|
|
33
|
+
}
|
|
34
|
+
handleLine(line) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(line);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const pending = this.pending.get(parsed.id);
|
|
43
|
+
if (!pending) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
clearTimeout(pending.timer);
|
|
47
|
+
this.pending.delete(parsed.id);
|
|
48
|
+
if ('error' in parsed) {
|
|
49
|
+
const err = parsed.error;
|
|
50
|
+
pending.reject(err);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
pending.resolve(parsed.result);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export class HttpJsonRpcClient {
|
|
57
|
+
url;
|
|
58
|
+
timeoutMs;
|
|
59
|
+
nextId = 1;
|
|
60
|
+
constructor(url, timeoutMs = 5000) {
|
|
61
|
+
this.url = url;
|
|
62
|
+
this.timeoutMs = timeoutMs;
|
|
63
|
+
}
|
|
64
|
+
async request(method, params, timeoutOverrideMs) {
|
|
65
|
+
const id = this.nextId++;
|
|
66
|
+
const payload = { jsonrpc: '2.0', id, method, params };
|
|
67
|
+
const maxAttempts = 3;
|
|
68
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timeout = setTimeout(() => controller.abort(), timeoutOverrideMs ?? this.timeoutMs);
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(this.url, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'content-type': 'application/json' },
|
|
75
|
+
body: JSON.stringify(payload),
|
|
76
|
+
signal: controller.signal
|
|
77
|
+
});
|
|
78
|
+
const parsed = (await response.json());
|
|
79
|
+
if ('error' in parsed) {
|
|
80
|
+
throw parsed.error;
|
|
81
|
+
}
|
|
82
|
+
return parsed.result;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (attempt === maxAttempts || !(error instanceof Error) || !error.message.includes('fetch')) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
const backoff = 50 * 2 ** (attempt - 1);
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error('Unreachable retry state');
|
|
96
|
+
}
|
|
97
|
+
normalizeError(error) {
|
|
98
|
+
return normalizeError(error);
|
|
99
|
+
}
|
|
100
|
+
async close() {
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function normalizeError(error) {
|
|
105
|
+
if (typeof error === 'object' && error !== null && 'code' in error && 'message' in error) {
|
|
106
|
+
const err = error;
|
|
107
|
+
return {
|
|
108
|
+
code: Number(err.code),
|
|
109
|
+
message: String(err.message),
|
|
110
|
+
data: err.data
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (error instanceof Error) {
|
|
114
|
+
return {
|
|
115
|
+
code: -32000,
|
|
116
|
+
message: error.message
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
code: -32001,
|
|
121
|
+
message: 'Unknown error',
|
|
122
|
+
data: error
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ServerConfig } from './types.js';
|
|
2
|
+
export declare class StdioTransport {
|
|
3
|
+
private proc;
|
|
4
|
+
private buffer;
|
|
5
|
+
private listeners;
|
|
6
|
+
start(config: ServerConfig): Promise<void>;
|
|
7
|
+
onLine(cb: (line: string) => void): void;
|
|
8
|
+
send(line: string): void;
|
|
9
|
+
stop(): Promise<void>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
export class StdioTransport {
|
|
4
|
+
proc = null;
|
|
5
|
+
buffer = '';
|
|
6
|
+
listeners = [];
|
|
7
|
+
async start(config) {
|
|
8
|
+
if (this.proc) {
|
|
9
|
+
throw new Error('Transport already started');
|
|
10
|
+
}
|
|
11
|
+
if (!config.stdioCommand) {
|
|
12
|
+
throw new Error('stdioCommand is required for StdioTransport');
|
|
13
|
+
}
|
|
14
|
+
const env = { ...process.env, ...(config.env ?? {}) };
|
|
15
|
+
const proc = spawn(config.stdioCommand, {
|
|
16
|
+
cwd: config.cwd,
|
|
17
|
+
env,
|
|
18
|
+
shell: true,
|
|
19
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
20
|
+
});
|
|
21
|
+
this.proc = proc;
|
|
22
|
+
proc.stdout.on('data', (chunk) => {
|
|
23
|
+
this.buffer += chunk.toString('utf8');
|
|
24
|
+
let idx = this.buffer.indexOf('\n');
|
|
25
|
+
while (idx >= 0) {
|
|
26
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
27
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
28
|
+
if (line) {
|
|
29
|
+
this.listeners.forEach((cb) => cb(line));
|
|
30
|
+
}
|
|
31
|
+
idx = this.buffer.indexOf('\n');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
proc.stderr.on('data', (chunk) => {
|
|
35
|
+
const message = chunk.toString('utf8').trim();
|
|
36
|
+
if (!config.silent && message.length > 0) {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.error(`[server stderr] ${message}`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
proc.once('exit', (code, signal) => {
|
|
42
|
+
if (!config.silent && code !== 0 && signal !== 'SIGTERM') {
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.error(`Server exited unexpectedly (code=${code}, signal=${signal})`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
await once(proc, 'spawn');
|
|
48
|
+
}
|
|
49
|
+
onLine(cb) {
|
|
50
|
+
this.listeners.push(cb);
|
|
51
|
+
}
|
|
52
|
+
send(line) {
|
|
53
|
+
if (!this.proc) {
|
|
54
|
+
throw new Error('Transport not started');
|
|
55
|
+
}
|
|
56
|
+
this.proc.stdin.write(`${line}\n`);
|
|
57
|
+
}
|
|
58
|
+
async stop() {
|
|
59
|
+
if (!this.proc) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const proc = this.proc;
|
|
63
|
+
this.proc = null;
|
|
64
|
+
proc.kill('SIGTERM');
|
|
65
|
+
const timeout = setTimeout(() => proc.kill('SIGKILL'), 1000);
|
|
66
|
+
await once(proc, 'exit').catch(() => undefined);
|
|
67
|
+
clearTimeout(timeout);
|
|
68
|
+
}
|
|
69
|
+
}
|