agent-mcp-guard 0.4.3 → 0.4.4
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/README.md +14 -2
- package/action.yml +9 -0
- package/docs/baseline.md +2 -2
- package/docs/business-playbook.md +2 -1
- package/docs/github-action.md +22 -5
- package/docs/launch-checklist.md +1 -1
- package/docs/marketplace-action-readme.md +16 -3
- package/docs/marketplace.md +6 -5
- package/docs/policy.md +50 -0
- package/docs/roadmap.md +3 -3
- package/docs/rules.md +5 -1
- package/docs/trusted-publishing.md +2 -2
- package/examples/mcp-guard-policy.json +16 -0
- package/examples/sample-report.md +1 -1
- package/package.json +1 -1
- package/scripts/action-comment.js +3 -0
- package/scripts/action-summary.js +3 -0
- package/scripts/prepare-marketplace-action.js +1 -0
- package/site/e2e/report.html +3 -1
- package/site/e2e/report.json +5 -2
- package/site/e2e/report.md +1 -1
- package/site/e2e/report.sarif +1 -1
- package/src/baseline.js +3 -1
- package/src/cli.js +25 -1
- package/src/init.js +24 -3
- package/src/policy.js +109 -0
- package/src/report.js +50 -1
- package/src/rules.js +142 -0
- package/src/scan.js +7 -2
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Live demo PR: [mcp-guard-demo#1](https://github.com/ChaoYue0307/mcp-guard-demo/p
|
|
|
19
19
|
<a href="https://github.com/marketplace/actions/mcp-guard-mcp-security-scanner"><img alt="GitHub Marketplace" src="https://img.shields.io/badge/Marketplace-mcp--guard-0f766e?logo=github"></a>
|
|
20
20
|
<a href="https://github.com/ChaoYue0307/mcp-guard/actions"><img alt="CI" src="https://github.com/ChaoYue0307/mcp-guard/actions/workflows/ci.yml/badge.svg"></a>
|
|
21
21
|
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-111827"></a>
|
|
22
|
-
<a href="https://github.com/ChaoYue0307/mcp-guard/releases/tag/v0.4.
|
|
22
|
+
<a href="https://github.com/ChaoYue0307/mcp-guard/releases/tag/v0.4.4"><img alt="Release" src="https://img.shields.io/github/v/release/ChaoYue0307/mcp-guard?color=7c2d12"></a>
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
25
|
## Install
|
|
@@ -71,6 +71,12 @@ Use in CI:
|
|
|
71
71
|
mcp-guard scan --config .mcp.json --fail-on high
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
Enforce a team policy for approved commands, packages, directories, and remote URLs:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
mcp-guard scan --config .mcp.json --policy .mcp-guard-policy.json --fail-on high
|
|
78
|
+
```
|
|
79
|
+
|
|
74
80
|
Accept known findings and fail only on new risk:
|
|
75
81
|
|
|
76
82
|
```bash
|
|
@@ -81,9 +87,10 @@ mcp-guard scan --config .mcp.json --baseline .mcp-guard-baseline.json --fail-on
|
|
|
81
87
|
Use the GitHub Action:
|
|
82
88
|
|
|
83
89
|
```yaml
|
|
84
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
90
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
85
91
|
with:
|
|
86
92
|
config: .mcp.json
|
|
93
|
+
# policy: .mcp-guard-policy.json
|
|
87
94
|
baseline: .mcp-guard-baseline.json
|
|
88
95
|
fail-on: high
|
|
89
96
|
comment-pr: "true"
|
|
@@ -117,6 +124,7 @@ For the GitHub Action workflow, inspect the public demo repository: [ChaoYue0307
|
|
|
117
124
|
| Broad filesystem access | Home, root, Desktop, Documents, and Downloads are high-blast-radius paths. |
|
|
118
125
|
| Remote MCP URLs | Data may leave the local trust boundary. |
|
|
119
126
|
| Dangerous command patterns | `rm -rf`, `sudo`, `chmod 777`, and curl-pipe-shell should block review. |
|
|
127
|
+
| Policy violations | Teams can enforce approved commands, packages, directories, and remote URLs. |
|
|
120
128
|
|
|
121
129
|
## Team Workflow
|
|
122
130
|
|
|
@@ -129,6 +137,8 @@ For the GitHub Action workflow, inspect the public demo repository: [ChaoYue0307
|
|
|
129
137
|
|
|
130
138
|
The GitHub Action can also post an optional pull request comment with the active finding summary.
|
|
131
139
|
|
|
140
|
+
For stricter governance, commit `.mcp-guard-policy.json` and define the commands, remote packages, filesystem roots, and remote MCP endpoints the team has approved. See [Policy files](docs/policy.md).
|
|
141
|
+
|
|
132
142
|
For a guided setup, run:
|
|
133
143
|
|
|
134
144
|
```bash
|
|
@@ -193,6 +203,7 @@ Typical scope:
|
|
|
193
203
|
|
|
194
204
|
- install and run the CLI against redacted local MCP configs;
|
|
195
205
|
- create the GitHub Action workflow;
|
|
206
|
+
- define an initial `.mcp-guard-policy.json`;
|
|
196
207
|
- generate and review an initial baseline;
|
|
197
208
|
- enable PR comments and optional GitHub code scanning;
|
|
198
209
|
- record missing rules or config shapes as product feedback.
|
|
@@ -203,6 +214,7 @@ Contact: [hechaoyue0307@gmail.com](mailto:hechaoyue0307@gmail.com)
|
|
|
203
214
|
|
|
204
215
|
- [Rule reference](docs/rules.md)
|
|
205
216
|
- [Baseline and allowlist](docs/baseline.md)
|
|
217
|
+
- [Policy files](docs/policy.md)
|
|
206
218
|
- [GitHub Action](docs/github-action.md)
|
|
207
219
|
- [Marketplace publishing plan](docs/marketplace.md)
|
|
208
220
|
- [Privacy and security](docs/privacy-and-security.md)
|
package/action.yml
CHANGED
|
@@ -19,6 +19,10 @@ inputs:
|
|
|
19
19
|
description: Optional mcp-guard baseline/allowlist JSON path. Matching findings are accepted and do not fail the workflow.
|
|
20
20
|
required: false
|
|
21
21
|
default: ""
|
|
22
|
+
policy:
|
|
23
|
+
description: Optional mcp-guard policy JSON path. Leave empty to auto-load .mcp-guard-policy.json when present.
|
|
24
|
+
required: false
|
|
25
|
+
default: ""
|
|
22
26
|
comment-pr:
|
|
23
27
|
description: "Post or update a pull request comment with the scan summary. Requires pull-requests: write permission."
|
|
24
28
|
required: false
|
|
@@ -67,6 +71,7 @@ runs:
|
|
|
67
71
|
uses: actions/setup-node@v6
|
|
68
72
|
with:
|
|
69
73
|
node-version: "24"
|
|
74
|
+
package-manager-cache: false
|
|
70
75
|
|
|
71
76
|
- name: Generate reports
|
|
72
77
|
id: reports
|
|
@@ -74,6 +79,7 @@ runs:
|
|
|
74
79
|
env:
|
|
75
80
|
MCP_GUARD_CONFIG: ${{ inputs.config }}
|
|
76
81
|
MCP_GUARD_BASELINE: ${{ inputs.baseline }}
|
|
82
|
+
MCP_GUARD_POLICY: ${{ inputs.policy }}
|
|
77
83
|
MCP_GUARD_FAIL_ON: ${{ inputs.fail-on }}
|
|
78
84
|
MCP_GUARD_OUTPUT_DIR: ${{ inputs.output-dir }}
|
|
79
85
|
run: |
|
|
@@ -89,6 +95,9 @@ runs:
|
|
|
89
95
|
if [ -n "${MCP_GUARD_BASELINE}" ]; then
|
|
90
96
|
scan_args+=(--baseline "${MCP_GUARD_BASELINE}")
|
|
91
97
|
fi
|
|
98
|
+
if [ -n "${MCP_GUARD_POLICY}" ]; then
|
|
99
|
+
scan_args+=(--policy "${MCP_GUARD_POLICY}")
|
|
100
|
+
fi
|
|
92
101
|
|
|
93
102
|
markdown_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.md"
|
|
94
103
|
html_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.html"
|
package/docs/baseline.md
CHANGED
|
@@ -30,7 +30,7 @@ If the scan finds only baseline-accepted findings, the exit code is `0`. If a ne
|
|
|
30
30
|
## GitHub Action
|
|
31
31
|
|
|
32
32
|
```yaml
|
|
33
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
33
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
34
34
|
with:
|
|
35
35
|
config: .mcp.json
|
|
36
36
|
baseline: .mcp-guard-baseline.json
|
|
@@ -45,7 +45,7 @@ The generated Markdown, HTML, JSON, and PR comment separate active findings from
|
|
|
45
45
|
{
|
|
46
46
|
"version": 1,
|
|
47
47
|
"generatedAt": "2026-05-10T00:00:00.000Z",
|
|
48
|
-
"toolVersion": "0.4.
|
|
48
|
+
"toolVersion": "0.4.4",
|
|
49
49
|
"findings": [
|
|
50
50
|
{
|
|
51
51
|
"fingerprint": "mcpg_a009b2c2",
|
|
@@ -17,6 +17,7 @@ Deliverables:
|
|
|
17
17
|
- install the CLI and GitHub Action;
|
|
18
18
|
- run `mcp-guard init` or generate an equivalent workflow manually;
|
|
19
19
|
- generate Markdown, HTML, JSON, and SARIF reports;
|
|
20
|
+
- define an initial `.mcp-guard-policy.json` for approved commands, packages, directories, and remote URLs;
|
|
20
21
|
- create an initial baseline for accepted known findings;
|
|
21
22
|
- enable PR comments and optional SARIF upload;
|
|
22
23
|
- document missing rule requests for future product work;
|
|
@@ -39,7 +40,7 @@ I built mcp-guard, an open-source local scanner for MCP and AI agent tool config
|
|
|
39
40
|
|
|
40
41
|
It checks for risky shell access, unpinned npx packages, broad filesystem permissions, exposed secrets, and remote MCP servers.
|
|
41
42
|
|
|
42
|
-
It now includes `mcp-guard init`, which creates a GitHub Action workflow
|
|
43
|
+
It now includes `mcp-guard init`, which creates a GitHub Action workflow, can generate a baseline for accepted current findings, and can enforce a committed policy for approved MCP commands, packages, directories, and URLs.
|
|
43
44
|
|
|
44
45
|
I am collecting real-world MCP and AI agent config patterns from teams using Claude, Cursor, Codex, or MCP in production-like workflows. If you can share a redacted config or run the CLI locally, your feedback can help improve the scanner's rules and reports.
|
|
45
46
|
```
|
package/docs/github-action.md
CHANGED
|
@@ -4,7 +4,9 @@ Use the `mcp-guard` action to scan MCP and AI agent tool configuration in pull r
|
|
|
4
4
|
|
|
5
5
|
The action runs the CLI from the pinned GitHub Action tag, generates Markdown, HTML, JSON, and SARIF reports, writes a job summary, uploads reports as an artifact, and fails the job when findings meet your selected severity threshold.
|
|
6
6
|
|
|
7
|
-
It can also use a committed baseline to accept known findings and optionally post a pull request comment with only the active findings.
|
|
7
|
+
It can also use a committed baseline to accept known findings, enforce a committed policy file, and optionally post a pull request comment with only the active findings.
|
|
8
|
+
|
|
9
|
+
If `.mcp-guard-policy.json` is committed at the repository root, the CLI auto-loads it. Use the `policy` input when the policy file lives elsewhere.
|
|
8
10
|
|
|
9
11
|
Marketplace/action repository: <https://github.com/ChaoYue0307/mcp-guard-action>
|
|
10
12
|
|
|
@@ -35,7 +37,7 @@ jobs:
|
|
|
35
37
|
runs-on: ubuntu-latest
|
|
36
38
|
steps:
|
|
37
39
|
- uses: actions/checkout@v6
|
|
38
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
40
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
39
41
|
with:
|
|
40
42
|
config: .mcp.json
|
|
41
43
|
fail-on: high
|
|
@@ -63,7 +65,7 @@ jobs:
|
|
|
63
65
|
runs-on: ubuntu-latest
|
|
64
66
|
steps:
|
|
65
67
|
- uses: actions/checkout@v6
|
|
66
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
68
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
67
69
|
with:
|
|
68
70
|
config: .mcp.json
|
|
69
71
|
fail-on: high
|
|
@@ -75,7 +77,7 @@ jobs:
|
|
|
75
77
|
Use `fail-on: none` when you want artifacts and summaries without blocking a pull request.
|
|
76
78
|
|
|
77
79
|
```yaml
|
|
78
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
80
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
79
81
|
with:
|
|
80
82
|
fail-on: none
|
|
81
83
|
```
|
|
@@ -91,7 +93,7 @@ mcp-guard scan --config .mcp.json --write-baseline .mcp-guard-baseline.json
|
|
|
91
93
|
Commit `.mcp-guard-baseline.json`, then reference it from the action:
|
|
92
94
|
|
|
93
95
|
```yaml
|
|
94
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
96
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
95
97
|
with:
|
|
96
98
|
config: .mcp.json
|
|
97
99
|
baseline: .mcp-guard-baseline.json
|
|
@@ -100,6 +102,20 @@ Commit `.mcp-guard-baseline.json`, then reference it from the action:
|
|
|
100
102
|
|
|
101
103
|
Reports will show active findings separately from findings accepted by the baseline.
|
|
102
104
|
|
|
105
|
+
## Policy Mode
|
|
106
|
+
|
|
107
|
+
Use a policy when you want CI to enforce approved commands, packages, directories, and remote URLs.
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
111
|
+
with:
|
|
112
|
+
config: .mcp.json
|
|
113
|
+
policy: .mcp-guard-policy.json
|
|
114
|
+
fail-on: high
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
See [Policy files](policy.md) for the file format.
|
|
118
|
+
|
|
103
119
|
## Inputs
|
|
104
120
|
|
|
105
121
|
| Input | Default | Description |
|
|
@@ -107,6 +123,7 @@ Reports will show active findings separately from findings accepted by the basel
|
|
|
107
123
|
| `config` | empty | Optional MCP config path. Empty scans default project and user config locations. |
|
|
108
124
|
| `fail-on` | `high` | Fails the job for `critical`, `high`, `medium`, or `low` findings. Use `none` for report-only mode. |
|
|
109
125
|
| `baseline` | empty | Optional baseline/allowlist JSON path. Matching findings are accepted and do not fail the workflow. |
|
|
126
|
+
| `policy` | empty | Optional policy JSON path. Empty auto-loads `.mcp-guard-policy.json` when present. |
|
|
110
127
|
| `comment-pr` | `false` | Posts or updates a pull request comment with the scan summary. Requires `pull-requests: write`. |
|
|
111
128
|
| `output-dir` | `mcp-guard-report` | Directory for generated reports. |
|
|
112
129
|
| `upload-artifact` | `true` | Uploads generated reports as a workflow artifact. |
|
package/docs/launch-checklist.md
CHANGED
|
@@ -23,7 +23,7 @@ jobs:
|
|
|
23
23
|
runs-on: ubuntu-latest
|
|
24
24
|
steps:
|
|
25
25
|
- uses: actions/checkout@v6
|
|
26
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
26
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
27
27
|
with:
|
|
28
28
|
config: .mcp.json
|
|
29
29
|
fail-on: high
|
|
@@ -42,7 +42,7 @@ jobs:
|
|
|
42
42
|
runs-on: ubuntu-latest
|
|
43
43
|
steps:
|
|
44
44
|
- uses: actions/checkout@v6
|
|
45
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
45
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
46
46
|
with:
|
|
47
47
|
config: .mcp.json
|
|
48
48
|
fail-on: high
|
|
@@ -56,6 +56,7 @@ jobs:
|
|
|
56
56
|
| `config` | empty | Optional MCP config path. Empty scans default project and user config locations. |
|
|
57
57
|
| `fail-on` | `high` | Fails the job for `critical`, `high`, `medium`, or `low` findings. Use `none` for report-only mode. |
|
|
58
58
|
| `baseline` | empty | Optional baseline/allowlist JSON path. Matching findings are accepted and do not fail the workflow. |
|
|
59
|
+
| `policy` | empty | Optional policy JSON path. Empty auto-loads `.mcp-guard-policy.json` when present. |
|
|
59
60
|
| `comment-pr` | `false` | Posts or updates a pull request comment with the scan summary. Requires `pull-requests: write`. |
|
|
60
61
|
| `output-dir` | `mcp-guard-report` | Directory for generated reports. |
|
|
61
62
|
| `upload-artifact` | `true` | Uploads generated reports as a workflow artifact. |
|
|
@@ -95,13 +96,25 @@ mcp-guard scan --config .mcp.json --write-baseline .mcp-guard-baseline.json
|
|
|
95
96
|
Then enforce only new findings:
|
|
96
97
|
|
|
97
98
|
```yaml
|
|
98
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
99
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
99
100
|
with:
|
|
100
101
|
config: .mcp.json
|
|
101
102
|
baseline: .mcp-guard-baseline.json
|
|
102
103
|
fail-on: high
|
|
103
104
|
```
|
|
104
105
|
|
|
106
|
+
## Policy Mode
|
|
107
|
+
|
|
108
|
+
Commit `.mcp-guard-policy.json` or pass `policy` to enforce approved commands, remote packages, directories, and remote MCP URLs.
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
112
|
+
with:
|
|
113
|
+
config: .mcp.json
|
|
114
|
+
policy: .mcp-guard-policy.json
|
|
115
|
+
fail-on: high
|
|
116
|
+
```
|
|
117
|
+
|
|
105
118
|
## Transparent Example
|
|
106
119
|
|
|
107
120
|
Inspect a committed input config, reproduction commands, and generated Markdown, HTML, JSON, and SARIF artifacts:
|
package/docs/marketplace.md
CHANGED
|
@@ -82,16 +82,17 @@ Code quality
|
|
|
82
82
|
Current release title:
|
|
83
83
|
|
|
84
84
|
```text
|
|
85
|
-
v0.4.
|
|
85
|
+
v0.4.4
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
Release notes:
|
|
89
89
|
|
|
90
90
|
```text
|
|
91
|
-
|
|
91
|
+
Policy enforcement release.
|
|
92
92
|
|
|
93
|
-
- Adds
|
|
94
|
-
-
|
|
93
|
+
- Adds `.mcp-guard-policy.json` support for approved commands, packages, directories, and remote URLs.
|
|
94
|
+
- Adds the `policy` GitHub Action input and automatic root policy discovery.
|
|
95
|
+
- Adds MCP070-MCP074 policy findings.
|
|
95
96
|
- Keeps Node.js 24, PR comments, artifacts, and SARIF upload support.
|
|
96
97
|
```
|
|
97
98
|
|
|
@@ -105,7 +106,7 @@ Completed:
|
|
|
105
106
|
- README, docs, and website examples now use:
|
|
106
107
|
|
|
107
108
|
```yaml
|
|
108
|
-
- uses: ChaoYue0307/mcp-guard-action@v0.4.
|
|
109
|
+
- uses: ChaoYue0307/mcp-guard-action@v0.4.4
|
|
109
110
|
```
|
|
110
111
|
|
|
111
112
|
Remaining Marketplace web step:
|
package/docs/policy.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Policy Files
|
|
2
|
+
|
|
3
|
+
Use a policy file when a team wants an explicit approval boundary for MCP servers, not just heuristic risk findings.
|
|
4
|
+
|
|
5
|
+
`mcp-guard scan` automatically loads `.mcp-guard-policy.json` from the working directory when the file exists. You can also pass a policy explicitly:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
mcp-guard scan --config .mcp.json --policy .mcp-guard-policy.json --fail-on high
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Disable automatic policy loading with:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
mcp-guard scan --no-policy
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Example
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"version": 1,
|
|
22
|
+
"allowedCommands": ["node", "uvx"],
|
|
23
|
+
"allowedPackages": ["@approved/mcp-server"],
|
|
24
|
+
"allowedDirectories": ["./workspace"],
|
|
25
|
+
"allowedRemoteUrls": ["https://approved.example.com"]
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Each field is optional. Empty or omitted fields are not enforced.
|
|
30
|
+
|
|
31
|
+
## Fields
|
|
32
|
+
|
|
33
|
+
| Field | Meaning |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `allowedCommands` | Approved command basenames, such as `node`, `docker`, or `uvx`. |
|
|
36
|
+
| `allowedPackages` | Approved remote-runner package names, without requiring a version suffix. |
|
|
37
|
+
| `allowedDirectories` | Approved filesystem roots. Relative paths resolve from the scan working directory. |
|
|
38
|
+
| `allowedRemoteUrls` | Approved remote MCP URL origins or path prefixes. |
|
|
39
|
+
|
|
40
|
+
## Policy Findings
|
|
41
|
+
|
|
42
|
+
| Rule | Severity | What it detects |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| MCP070 | High | MCP server command is not in `allowedCommands`. |
|
|
45
|
+
| MCP071 | High | Remote package runner uses a package outside `allowedPackages`. |
|
|
46
|
+
| MCP072 | High | MCP server `cwd` is outside `allowedDirectories`. |
|
|
47
|
+
| MCP073 | High | Filesystem argument is outside `allowedDirectories`. |
|
|
48
|
+
| MCP074 | High | Remote MCP URL is outside `allowedRemoteUrls`. |
|
|
49
|
+
|
|
50
|
+
Policy findings are additive. A policy does not suppress the built-in rules for shell execution, unpinned packages, broad filesystem access, secret-like values, or dangerous commands.
|
package/docs/roadmap.md
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
- Baseline/allowlist mode for accepting known findings and failing only on new risks.
|
|
13
13
|
- Optional GitHub pull request comments from the Marketplace Action.
|
|
14
14
|
- `mcp-guard init` for bootstrapping a GitHub Action workflow and optional baseline.
|
|
15
|
+
- Policy file enforcement for approved commands, packages, directories, and remote URLs.
|
|
15
16
|
- npm Trusted Publishing workflow prepared for tokenless release publishing.
|
|
16
17
|
|
|
17
18
|
## Next
|
|
18
19
|
|
|
19
20
|
1. More MCP client discovery paths.
|
|
20
21
|
2. Rule packs mapped to MCP security best practices.
|
|
21
|
-
3.
|
|
22
|
-
4.
|
|
23
|
-
5. Safer default remediation snippets for common MCP servers.
|
|
22
|
+
3. `mcp-guard audit` mode for review-ready reports.
|
|
23
|
+
4. Safer default remediation snippets for common MCP servers.
|
|
24
24
|
|
|
25
25
|
## Later
|
|
26
26
|
|
package/docs/rules.md
CHANGED
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
| MCP050 | Critical | Dangerous command pattern such as `rm -rf`, `sudo`, `chmod 777`, or curl pipe to shell. |
|
|
19
19
|
| MCP060 | Medium | Remote MCP server URL configured. |
|
|
20
20
|
| MCP061 | High | Secret-like header configured for a remote MCP server. |
|
|
21
|
+
| MCP070 | High | MCP server command is outside `allowedCommands` policy. |
|
|
22
|
+
| MCP071 | High | Remote MCP package is outside `allowedPackages` policy. |
|
|
23
|
+
| MCP072 | High | MCP server working directory is outside `allowedDirectories` policy. |
|
|
24
|
+
| MCP073 | High | Filesystem argument is outside `allowedDirectories` policy. |
|
|
25
|
+
| MCP074 | High | Remote MCP URL is outside `allowedRemoteUrls` policy. |
|
|
21
26
|
|
|
22
27
|
## Severity Model
|
|
23
28
|
|
|
@@ -32,4 +37,3 @@
|
|
|
32
37
|
- The scanner does not upload configs.
|
|
33
38
|
- Detection is heuristic and will miss some risks.
|
|
34
39
|
- A clean report is not a security guarantee.
|
|
35
|
-
|
|
@@ -36,7 +36,7 @@ Configure this once on npmjs.com:
|
|
|
36
36
|
After this is saved, run the workflow from GitHub Actions with the release tag, for example:
|
|
37
37
|
|
|
38
38
|
```text
|
|
39
|
-
v0.4.
|
|
39
|
+
v0.4.4
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
## Release Flow After Setup
|
|
@@ -44,7 +44,7 @@ v0.4.3
|
|
|
44
44
|
1. Update `package.json` and `src/cli.js`.
|
|
45
45
|
2. Run `npm test` and `npm run release:check`.
|
|
46
46
|
3. Commit and push to `main`.
|
|
47
|
-
4. Create a GitHub release tag such as `v0.4.
|
|
47
|
+
4. Create a GitHub release tag such as `v0.4.4`.
|
|
48
48
|
5. Run the `Publish npm` workflow with the same tag.
|
|
49
49
|
6. Verify npm:
|
|
50
50
|
|
package/package.json
CHANGED
|
@@ -29,6 +29,9 @@ lines.push(`Active findings: **${report.summary.findingCount}**`);
|
|
|
29
29
|
if (acceptedCount > 0 || report.baseline?.enabled) {
|
|
30
30
|
lines.push(`Accepted by baseline: **${acceptedCount}**`);
|
|
31
31
|
}
|
|
32
|
+
if (report.policy?.path) {
|
|
33
|
+
lines.push(`Policy: **${report.policy.path}**`);
|
|
34
|
+
}
|
|
32
35
|
lines.push(`Fail threshold: **${failOn || "high"}**`);
|
|
33
36
|
if (runUrl) {
|
|
34
37
|
lines.push(`Workflow run: ${runUrl}`);
|
|
@@ -33,6 +33,9 @@ lines.push(`Active findings: **${report.summary.findingCount}**`);
|
|
|
33
33
|
if (acceptedCount > 0 || report.baseline?.enabled) {
|
|
34
34
|
lines.push(`Accepted by baseline: **${acceptedCount}**`);
|
|
35
35
|
}
|
|
36
|
+
if (report.policy?.path) {
|
|
37
|
+
lines.push(`Policy: **${report.policy.path}**`);
|
|
38
|
+
}
|
|
36
39
|
lines.push(`Fail threshold: **${failOn || "high"}**`);
|
|
37
40
|
lines.push("");
|
|
38
41
|
|
package/site/e2e/report.html
CHANGED
|
@@ -297,7 +297,7 @@
|
|
|
297
297
|
<div class="metric"><strong>1</strong><span>Scanned files</span></div>
|
|
298
298
|
<div class="metric"><strong>3</strong><span>MCP servers</span></div>
|
|
299
299
|
<div class="metric"><strong>9</strong><span>Active findings</span></div>
|
|
300
|
-
<div class="metric"><strong>2026-05-10
|
|
300
|
+
<div class="metric"><strong>2026-05-10 14:01 UTC</strong><span>Generated</span></div>
|
|
301
301
|
</div>
|
|
302
302
|
</div>
|
|
303
303
|
<aside class="scorecard" aria-label="Risk score">
|
|
@@ -324,6 +324,8 @@
|
|
|
324
324
|
<div class="table-wrap"><table><thead><tr><th>Path</th></tr></thead><tbody><tr><td><code>site/e2e/claude_desktop_config.json</code></td></tr></tbody></table></div>
|
|
325
325
|
</section>
|
|
326
326
|
|
|
327
|
+
|
|
328
|
+
|
|
327
329
|
<section>
|
|
328
330
|
<h2>MCP Server Inventory</h2>
|
|
329
331
|
<div class="table-wrap"><table>
|
package/site/e2e/report.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"metadata": {
|
|
3
|
-
"generatedAt": "2026-05-
|
|
3
|
+
"generatedAt": "2026-05-10T14:01:28.980Z",
|
|
4
4
|
"cwd": ".",
|
|
5
5
|
"home": "~",
|
|
6
|
-
"
|
|
6
|
+
"policyPath": "",
|
|
7
|
+
"policyEnabled": false,
|
|
8
|
+
"toolVersion": "0.4.4"
|
|
7
9
|
},
|
|
10
|
+
"policy": null,
|
|
8
11
|
"scannedFiles": [
|
|
9
12
|
"site/e2e/claude_desktop_config.json"
|
|
10
13
|
],
|
package/site/e2e/report.md
CHANGED
package/site/e2e/report.sarif
CHANGED
package/src/baseline.js
CHANGED
|
@@ -107,6 +107,8 @@ export function summarize(findings, servers, scannedFiles, acceptedFindingCount
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
const rawRiskScore = counts.critical * 20 + counts.high * 10 + counts.medium * 4 + counts.low;
|
|
111
|
+
|
|
110
112
|
return {
|
|
111
113
|
scannedFileCount: scannedFiles.length,
|
|
112
114
|
serverCount: servers.length,
|
|
@@ -115,7 +117,7 @@ export function summarize(findings, servers, scannedFiles, acceptedFindingCount
|
|
|
115
117
|
acceptedFindingCount,
|
|
116
118
|
totalFindingCount: findings.length + acceptedFindingCount,
|
|
117
119
|
counts,
|
|
118
|
-
riskScore:
|
|
120
|
+
riskScore: Math.min(100, rawRiskScore)
|
|
119
121
|
};
|
|
120
122
|
}
|
|
121
123
|
|
package/src/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { scan } from "./scan.js";
|
|
|
6
6
|
import { generateHtmlReport, generateJsonReport, generateMarkdownReport, generateSarifReport, generateTextReport } from "./report.js";
|
|
7
7
|
import { compareSeverity, severityRank } from "./severity.js";
|
|
8
8
|
|
|
9
|
-
const VERSION = "0.4.
|
|
9
|
+
const VERSION = "0.4.4";
|
|
10
10
|
|
|
11
11
|
export async function runCli(argv, io) {
|
|
12
12
|
const args = argv.slice(2);
|
|
@@ -36,6 +36,7 @@ export async function runCli(argv, io) {
|
|
|
36
36
|
includeDefaults: options.includeDefaults,
|
|
37
37
|
workflowPath: options.workflowPath,
|
|
38
38
|
baselinePath: options.baselinePath,
|
|
39
|
+
policyPath: options.policyPath,
|
|
39
40
|
failOn: options.failOn,
|
|
40
41
|
commentPr: options.commentPr,
|
|
41
42
|
uploadSarif: options.uploadSarif,
|
|
@@ -68,6 +69,8 @@ export async function runCli(argv, io) {
|
|
|
68
69
|
env: io.env,
|
|
69
70
|
configPaths: options.configPaths,
|
|
70
71
|
includeDefaults: options.includeDefaults,
|
|
72
|
+
policyPath: options.policyPath,
|
|
73
|
+
includePolicy: options.includePolicy,
|
|
71
74
|
toolVersion: VERSION
|
|
72
75
|
});
|
|
73
76
|
|
|
@@ -106,6 +109,7 @@ function parseInitArgs(args, defaultCwd) {
|
|
|
106
109
|
includeDefaults: true,
|
|
107
110
|
workflowPath: "",
|
|
108
111
|
baselinePath: "",
|
|
112
|
+
policyPath: "",
|
|
109
113
|
failOn: "high",
|
|
110
114
|
commentPr: true,
|
|
111
115
|
uploadSarif: false,
|
|
@@ -119,6 +123,7 @@ function parseInitArgs(args, defaultCwd) {
|
|
|
119
123
|
options.baselinePath = path.join(options.cwd, ".mcp-guard-baseline.json");
|
|
120
124
|
let workflowPathProvided = false;
|
|
121
125
|
let baselinePathProvided = false;
|
|
126
|
+
let policyPathProvided = false;
|
|
122
127
|
|
|
123
128
|
for (let index = 0; index < args.length; index += 1) {
|
|
124
129
|
const arg = args[index];
|
|
@@ -134,6 +139,10 @@ function parseInitArgs(args, defaultCwd) {
|
|
|
134
139
|
options.useBaseline = true;
|
|
135
140
|
baselinePathProvided = true;
|
|
136
141
|
index += 1;
|
|
142
|
+
} else if (arg === "--policy") {
|
|
143
|
+
options.policyPath = resolveInputPath(readValue(args, index, arg), options.cwd);
|
|
144
|
+
policyPathProvided = true;
|
|
145
|
+
index += 1;
|
|
137
146
|
} else if (arg === "--write-baseline" || arg === "--write-allowlist") {
|
|
138
147
|
options.writeBaseline = true;
|
|
139
148
|
options.useBaseline = true;
|
|
@@ -160,6 +169,9 @@ function parseInitArgs(args, defaultCwd) {
|
|
|
160
169
|
if (!baselinePathProvided) {
|
|
161
170
|
options.baselinePath = path.join(options.cwd, ".mcp-guard-baseline.json");
|
|
162
171
|
}
|
|
172
|
+
if (!policyPathProvided && options.policyPath) {
|
|
173
|
+
options.policyPath = path.join(options.cwd, ".mcp-guard-policy.json");
|
|
174
|
+
}
|
|
163
175
|
index += 1;
|
|
164
176
|
} else if (arg === "--no-defaults") {
|
|
165
177
|
options.includeDefaults = false;
|
|
@@ -185,6 +197,8 @@ function parseScanArgs(args, defaultCwd) {
|
|
|
185
197
|
failOn: "none",
|
|
186
198
|
baselinePath: "",
|
|
187
199
|
writeBaselinePath: "",
|
|
200
|
+
policyPath: "",
|
|
201
|
+
includePolicy: true,
|
|
188
202
|
baselineReason: "Accepted current MCP findings"
|
|
189
203
|
};
|
|
190
204
|
|
|
@@ -217,9 +231,14 @@ function parseScanArgs(args, defaultCwd) {
|
|
|
217
231
|
} else if (arg === "--baseline-reason") {
|
|
218
232
|
options.baselineReason = readValue(args, index, arg);
|
|
219
233
|
index += 1;
|
|
234
|
+
} else if (arg === "--policy") {
|
|
235
|
+
options.policyPath = resolveInputPath(readValue(args, index, arg), options.cwd);
|
|
236
|
+
index += 1;
|
|
220
237
|
} else if (arg === "--cwd") {
|
|
221
238
|
options.cwd = path.resolve(readValue(args, index, arg));
|
|
222
239
|
index += 1;
|
|
240
|
+
} else if (arg === "--no-policy") {
|
|
241
|
+
options.includePolicy = false;
|
|
223
242
|
} else if (arg === "--no-defaults") {
|
|
224
243
|
options.includeDefaults = false;
|
|
225
244
|
} else {
|
|
@@ -279,6 +298,7 @@ Init options:
|
|
|
279
298
|
-c, --config <path> MCP config path to reference in the workflow. Can be repeated for baseline generation.
|
|
280
299
|
--fail-on <severity> Workflow fail threshold. Default: high.
|
|
281
300
|
--baseline <path> Reference an existing baseline JSON file in the workflow.
|
|
301
|
+
--policy <path> Reference an MCP guard policy file in the workflow.
|
|
282
302
|
--write-baseline Generate a baseline from current findings and reference it in the workflow.
|
|
283
303
|
--baseline-reason <text>
|
|
284
304
|
Reason stored for newly written baseline entries.
|
|
@@ -301,7 +321,10 @@ Scan options:
|
|
|
301
321
|
Write current findings to a baseline JSON file.
|
|
302
322
|
--baseline-reason <text>
|
|
303
323
|
Reason stored for newly written baseline entries.
|
|
324
|
+
--policy <path> Enforce an explicit policy file.
|
|
325
|
+
Default: auto-load .mcp-guard-policy.json when present.
|
|
304
326
|
--cwd <path> Working directory for project config discovery.
|
|
327
|
+
--no-policy Do not auto-load .mcp-guard-policy.json.
|
|
305
328
|
--no-defaults Only scan paths passed with --config.
|
|
306
329
|
|
|
307
330
|
Examples:
|
|
@@ -312,6 +335,7 @@ Examples:
|
|
|
312
335
|
mcp-guard scan --format html --output mcp-guard-report.html
|
|
313
336
|
mcp-guard scan --format sarif --output mcp-guard.sarif
|
|
314
337
|
mcp-guard scan --config .mcp.json --fail-on high
|
|
338
|
+
mcp-guard scan --config .mcp.json --policy .mcp-guard-policy.json --fail-on high
|
|
315
339
|
mcp-guard scan --config .mcp.json --write-baseline .mcp-guard-baseline.json
|
|
316
340
|
mcp-guard scan --config .mcp.json --baseline .mcp-guard-baseline.json --fail-on high
|
|
317
341
|
`;
|
package/src/init.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { writeBaselineFile } from "./baseline.js";
|
|
4
4
|
import { discoverConfigFiles } from "./discovery.js";
|
|
5
5
|
import { displayPath } from "./fingerprint.js";
|
|
6
|
+
import { DEFAULT_POLICY_FILE } from "./policy.js";
|
|
6
7
|
import { scan } from "./scan.js";
|
|
7
8
|
|
|
8
9
|
export async function initProject({
|
|
@@ -12,6 +13,7 @@ export async function initProject({
|
|
|
12
13
|
includeDefaults = true,
|
|
13
14
|
workflowPath,
|
|
14
15
|
baselinePath,
|
|
16
|
+
policyPath = "",
|
|
15
17
|
failOn = "high",
|
|
16
18
|
commentPr = true,
|
|
17
19
|
uploadSarif = false,
|
|
@@ -30,6 +32,7 @@ export async function initProject({
|
|
|
30
32
|
explicitConfigPaths: configPaths,
|
|
31
33
|
discoveredConfigPaths
|
|
32
34
|
});
|
|
35
|
+
const workflowPolicyPath = policyPath || await defaultPolicyPath(cwd);
|
|
33
36
|
const files = [];
|
|
34
37
|
|
|
35
38
|
if (writeBaseline) {
|
|
@@ -46,6 +49,7 @@ export async function initProject({
|
|
|
46
49
|
env,
|
|
47
50
|
configPaths: baselineConfigPaths,
|
|
48
51
|
includeDefaults: false,
|
|
52
|
+
includePolicy: false,
|
|
49
53
|
toolVersion
|
|
50
54
|
});
|
|
51
55
|
const baseline = await writeBaselineFileIfAllowed(baselinePath, result, {
|
|
@@ -65,6 +69,7 @@ export async function initProject({
|
|
|
65
69
|
actionRef: `ChaoYue0307/mcp-guard-action@v${toolVersion}`,
|
|
66
70
|
configPath: workflowConfigPath ? displayPath(workflowConfigPath, cwd) : "",
|
|
67
71
|
baselinePath: useBaseline || writeBaseline ? displayPath(baselinePath, cwd) : "",
|
|
72
|
+
policyPath: workflowPolicyPath ? displayPath(workflowPolicyPath, cwd) : "",
|
|
68
73
|
failOn,
|
|
69
74
|
commentPr,
|
|
70
75
|
uploadSarif
|
|
@@ -80,10 +85,11 @@ export async function initProject({
|
|
|
80
85
|
dryRun,
|
|
81
86
|
workflowPath,
|
|
82
87
|
baselinePath,
|
|
88
|
+
policyPath: workflowPolicyPath,
|
|
83
89
|
configPath: workflowConfigPath,
|
|
84
90
|
discoveredConfigPaths,
|
|
85
91
|
files,
|
|
86
|
-
nextSteps: buildNextSteps({ workflowPath, baselinePath, writeBaseline, uploadSarif })
|
|
92
|
+
nextSteps: buildNextSteps({ workflowPath, baselinePath, policyPath: workflowPolicyPath, writeBaseline, uploadSarif })
|
|
87
93
|
};
|
|
88
94
|
}
|
|
89
95
|
|
|
@@ -99,6 +105,9 @@ export function renderInitSummary(result, cwd) {
|
|
|
99
105
|
} else {
|
|
100
106
|
lines.push("Config: default discovery paths");
|
|
101
107
|
}
|
|
108
|
+
if (result.policyPath) {
|
|
109
|
+
lines.push(`Policy: ${displayPath(result.policyPath, cwd)}`);
|
|
110
|
+
}
|
|
102
111
|
|
|
103
112
|
for (const file of result.files) {
|
|
104
113
|
const label = file.action === "skipped" ? "Skipped" : actionLabel(file.action);
|
|
@@ -115,7 +124,7 @@ export function renderInitSummary(result, cwd) {
|
|
|
115
124
|
return `${lines.join("\n")}\n`;
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
export function renderGithubWorkflow({ actionRef, configPath, baselinePath, failOn, commentPr, uploadSarif }) {
|
|
127
|
+
export function renderGithubWorkflow({ actionRef, configPath, baselinePath, policyPath, failOn, commentPr, uploadSarif }) {
|
|
119
128
|
const permissions = [" contents: read"];
|
|
120
129
|
if (commentPr) {
|
|
121
130
|
permissions.push(" pull-requests: write");
|
|
@@ -131,6 +140,9 @@ export function renderGithubWorkflow({ actionRef, configPath, baselinePath, fail
|
|
|
131
140
|
if (baselinePath) {
|
|
132
141
|
inputs.push(` baseline: ${quoteYaml(baselinePath)}`);
|
|
133
142
|
}
|
|
143
|
+
if (policyPath) {
|
|
144
|
+
inputs.push(` policy: ${quoteYaml(policyPath)}`);
|
|
145
|
+
}
|
|
134
146
|
inputs.push(` fail-on: ${quoteYaml(failOn)}`);
|
|
135
147
|
if (commentPr) {
|
|
136
148
|
inputs.push(' comment-pr: "true"');
|
|
@@ -208,6 +220,11 @@ async function writeTextFileIfAllowed(filePath, content, { force, dryRun }) {
|
|
|
208
220
|
};
|
|
209
221
|
}
|
|
210
222
|
|
|
223
|
+
async function defaultPolicyPath(cwd) {
|
|
224
|
+
const filePath = path.join(cwd, DEFAULT_POLICY_FILE);
|
|
225
|
+
return await fileExists(filePath) ? filePath : "";
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
async function fileExists(filePath) {
|
|
212
229
|
try {
|
|
213
230
|
await fs.access(filePath);
|
|
@@ -217,7 +234,7 @@ async function fileExists(filePath) {
|
|
|
217
234
|
}
|
|
218
235
|
}
|
|
219
236
|
|
|
220
|
-
function buildNextSteps({ workflowPath, baselinePath, writeBaseline, uploadSarif }) {
|
|
237
|
+
function buildNextSteps({ workflowPath, baselinePath, policyPath, writeBaseline, uploadSarif }) {
|
|
221
238
|
const steps = [
|
|
222
239
|
`Review ${path.basename(workflowPath)} before committing it.`,
|
|
223
240
|
"Run mcp-guard scan locally and confirm the findings are expected.",
|
|
@@ -228,6 +245,10 @@ function buildNextSteps({ workflowPath, baselinePath, writeBaseline, uploadSarif
|
|
|
228
245
|
steps.splice(1, 0, `Review ${path.basename(baselinePath)} because accepted findings will not fail CI.`);
|
|
229
246
|
}
|
|
230
247
|
|
|
248
|
+
if (policyPath) {
|
|
249
|
+
steps.splice(1, 0, `Review ${path.basename(policyPath)} because it defines approved commands, packages, directories, and remote URLs.`);
|
|
250
|
+
}
|
|
251
|
+
|
|
231
252
|
if (uploadSarif) {
|
|
232
253
|
steps.push("Confirm GitHub code scanning is enabled for SARIF results.");
|
|
233
254
|
}
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_POLICY_FILE = ".mcp-guard-policy.json";
|
|
5
|
+
|
|
6
|
+
export async function loadPolicy({ cwd, home, policyPath = "", includePolicy = true }) {
|
|
7
|
+
if (!includePolicy) return null;
|
|
8
|
+
|
|
9
|
+
const explicit = Boolean(policyPath);
|
|
10
|
+
const resolvedPath = explicit ? resolvePolicyPath(policyPath, cwd) : path.join(cwd, DEFAULT_POLICY_FILE);
|
|
11
|
+
|
|
12
|
+
if (!explicit && !(await fileExists(resolvedPath))) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = JSON.parse(await fs.readFile(resolvedPath, "utf8"));
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
+
throw new Error(`Invalid policy file ${resolvedPath}: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return normalizePolicy(raw, resolvedPath, { cwd, home });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function policyForReport(policy) {
|
|
28
|
+
if (!policy) return null;
|
|
29
|
+
return {
|
|
30
|
+
path: policy.path,
|
|
31
|
+
version: policy.version,
|
|
32
|
+
allowedCommands: [...policy.allowedCommands],
|
|
33
|
+
allowedPackages: [...policy.allowedPackages],
|
|
34
|
+
allowedDirectories: [...policy.allowedDirectories],
|
|
35
|
+
allowedRemoteUrls: [...policy.allowedRemoteUrls]
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizePolicy(raw, policyPath, context) {
|
|
40
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
41
|
+
throw new Error("Policy must be a JSON object.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const allowedCommands = stringArray(raw.allowedCommands, "allowedCommands")
|
|
45
|
+
.map((command) => path.basename(command).toLowerCase());
|
|
46
|
+
const allowedPackages = stringArray(raw.allowedPackages, "allowedPackages")
|
|
47
|
+
.map(packageIdentity);
|
|
48
|
+
const allowedDirectories = stringArray(raw.allowedDirectories, "allowedDirectories");
|
|
49
|
+
const allowedRemoteUrls = stringArray(raw.allowedRemoteUrls, "allowedRemoteUrls")
|
|
50
|
+
.map(normalizePolicyUrl);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
path: policyPath,
|
|
54
|
+
version: raw.version || 1,
|
|
55
|
+
allowedCommands: new Set(allowedCommands),
|
|
56
|
+
allowedPackages: new Set(allowedPackages),
|
|
57
|
+
allowedDirectories,
|
|
58
|
+
allowedDirectoryPaths: allowedDirectories.map((directory) => normalizePolicyPath(directory, context)),
|
|
59
|
+
allowedRemoteUrls,
|
|
60
|
+
raw: raw
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stringArray(value, fieldName) {
|
|
65
|
+
if (value == null) return [];
|
|
66
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) {
|
|
67
|
+
throw new Error(`${fieldName} must be an array of non-empty strings.`);
|
|
68
|
+
}
|
|
69
|
+
return value.map((item) => item.trim());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function packageIdentity(packageName) {
|
|
73
|
+
if (packageName.startsWith("@")) {
|
|
74
|
+
const secondAt = packageName.indexOf("@", 1);
|
|
75
|
+
return secondAt > 1 ? packageName.slice(0, secondAt) : packageName;
|
|
76
|
+
}
|
|
77
|
+
const at = packageName.lastIndexOf("@");
|
|
78
|
+
return at > 0 ? packageName.slice(0, at) : packageName;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizePolicyPath(value, context) {
|
|
82
|
+
if (value === "~") return path.normalize(context.home);
|
|
83
|
+
if (value.startsWith("~/")) return path.join(context.home, value.slice(2));
|
|
84
|
+
if (path.isAbsolute(value)) return path.normalize(value);
|
|
85
|
+
return path.resolve(context.cwd, value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizePolicyUrl(value) {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(value);
|
|
91
|
+
const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
|
|
92
|
+
return `${parsed.origin}${pathname}`;
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error(`allowedRemoteUrls contains an invalid URL: ${value}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolvePolicyPath(value, cwd) {
|
|
99
|
+
return path.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function fileExists(filePath) {
|
|
103
|
+
try {
|
|
104
|
+
await fs.access(filePath);
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/report.js
CHANGED
|
@@ -12,6 +12,9 @@ export function generateTextReport(result) {
|
|
|
12
12
|
lines.push(`Accepted by baseline: ${result.summary.acceptedFindingCount}`);
|
|
13
13
|
lines.push(`Total observed findings: ${result.summary.totalFindingCount}`);
|
|
14
14
|
}
|
|
15
|
+
if (hasPolicy(result)) {
|
|
16
|
+
lines.push(`Policy: ${displayPath(result.policy.path, result.metadata.cwd)}`);
|
|
17
|
+
}
|
|
15
18
|
lines.push(`Risk score: ${result.summary.riskScore}`);
|
|
16
19
|
lines.push(`Critical: ${result.summary.counts.critical} High: ${result.summary.counts.high} Medium: ${result.summary.counts.medium} Low: ${result.summary.counts.low}`);
|
|
17
20
|
lines.push("");
|
|
@@ -69,6 +72,9 @@ export function generateMarkdownReport(result) {
|
|
|
69
72
|
lines.push(`- Total observed findings: ${result.summary.totalFindingCount}`);
|
|
70
73
|
lines.push(`- Baseline: \`${result.baseline.path || "enabled"}\``);
|
|
71
74
|
}
|
|
75
|
+
if (hasPolicy(result)) {
|
|
76
|
+
lines.push(`- Policy: \`${displayPath(result.policy.path, result.metadata.cwd)}\``);
|
|
77
|
+
}
|
|
72
78
|
lines.push(`- Risk score: ${result.summary.riskScore}`);
|
|
73
79
|
lines.push(`- Critical: ${result.summary.counts.critical}`);
|
|
74
80
|
lines.push(`- High: ${result.summary.counts.high}`);
|
|
@@ -87,6 +93,17 @@ export function generateMarkdownReport(result) {
|
|
|
87
93
|
}
|
|
88
94
|
lines.push("");
|
|
89
95
|
|
|
96
|
+
if (hasPolicy(result)) {
|
|
97
|
+
lines.push("## Policy");
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push(`- Path: \`${displayPath(result.policy.path, result.metadata.cwd)}\``);
|
|
100
|
+
lines.push(`- Allowed commands: ${inlineList(result.policy.allowedCommands)}`);
|
|
101
|
+
lines.push(`- Allowed packages: ${inlineList(result.policy.allowedPackages)}`);
|
|
102
|
+
lines.push(`- Allowed directories: ${inlineList(result.policy.allowedDirectories)}`);
|
|
103
|
+
lines.push(`- Allowed remote URLs: ${inlineList(result.policy.allowedRemoteUrls)}`);
|
|
104
|
+
lines.push("");
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
lines.push("## MCP Server Inventory");
|
|
91
108
|
lines.push("");
|
|
92
109
|
if (result.servers.length === 0) {
|
|
@@ -510,6 +527,11 @@ export function generateHtmlReport(result) {
|
|
|
510
527
|
${renderScannedFiles(safeResult)}
|
|
511
528
|
</section>
|
|
512
529
|
|
|
530
|
+
${hasPolicy(safeResult) ? ` <section>
|
|
531
|
+
<h2>Policy</h2>
|
|
532
|
+
${renderPolicy(safeResult)}
|
|
533
|
+
</section>` : ""}
|
|
534
|
+
|
|
513
535
|
<section>
|
|
514
536
|
<h2>MCP Server Inventory</h2>
|
|
515
537
|
${renderServerTable(servers, safeResult.metadata.cwd)}
|
|
@@ -549,8 +571,13 @@ function sanitizeResult(result) {
|
|
|
549
571
|
metadata: {
|
|
550
572
|
...result.metadata,
|
|
551
573
|
cwd: ".",
|
|
552
|
-
home: "~"
|
|
574
|
+
home: "~",
|
|
575
|
+
policyPath: result.metadata.policyPath ? displayPath(result.metadata.policyPath, cwd) : ""
|
|
553
576
|
},
|
|
577
|
+
policy: result.policy ? {
|
|
578
|
+
...result.policy,
|
|
579
|
+
path: displayPath(result.policy.path, cwd)
|
|
580
|
+
} : null,
|
|
554
581
|
scannedFiles: result.scannedFiles.map((file) => displayPath(file, cwd)),
|
|
555
582
|
servers: result.servers.map((server) => ({
|
|
556
583
|
name: server.name,
|
|
@@ -673,6 +700,19 @@ function renderScannedFiles(result) {
|
|
|
673
700
|
return `<div class="table-wrap"><table><thead><tr><th>Path</th></tr></thead><tbody>${items}</tbody></table></div>`;
|
|
674
701
|
}
|
|
675
702
|
|
|
703
|
+
function renderPolicy(result) {
|
|
704
|
+
const policy = result.policy;
|
|
705
|
+
const rows = [
|
|
706
|
+
["Path", policy.path],
|
|
707
|
+
["Allowed commands", policy.allowedCommands.join(", ") || "-"],
|
|
708
|
+
["Allowed packages", policy.allowedPackages.join(", ") || "-"],
|
|
709
|
+
["Allowed directories", policy.allowedDirectories.join(", ") || "-"],
|
|
710
|
+
["Allowed remote URLs", policy.allowedRemoteUrls.join(", ") || "-"]
|
|
711
|
+
].map(([key, value]) => `<tr><th>${escapeHtml(key)}</th><td>${codeOrDash(value)}</td></tr>`).join("");
|
|
712
|
+
|
|
713
|
+
return `<div class="table-wrap"><table><tbody>${rows}</tbody></table></div>`;
|
|
714
|
+
}
|
|
715
|
+
|
|
676
716
|
function renderServerTable(servers, cwd) {
|
|
677
717
|
if (servers.length === 0) {
|
|
678
718
|
return `<p class="empty">No MCP servers were found.</p>`;
|
|
@@ -774,10 +814,19 @@ function hasBaseline(result) {
|
|
|
774
814
|
return Boolean(result.baseline?.enabled);
|
|
775
815
|
}
|
|
776
816
|
|
|
817
|
+
function hasPolicy(result) {
|
|
818
|
+
return Boolean(result.policy);
|
|
819
|
+
}
|
|
820
|
+
|
|
777
821
|
function hasAcceptedFindings(result) {
|
|
778
822
|
return (result.acceptedFindings || []).length > 0;
|
|
779
823
|
}
|
|
780
824
|
|
|
825
|
+
function inlineList(items) {
|
|
826
|
+
if (!items || items.length === 0) return "not restricted";
|
|
827
|
+
return items.map((item) => `\`${item}\``).join(", ");
|
|
828
|
+
}
|
|
829
|
+
|
|
781
830
|
function escapeHtml(value) {
|
|
782
831
|
return String(value ?? "")
|
|
783
832
|
.replaceAll("&", "&")
|
package/src/rules.js
CHANGED
|
@@ -31,6 +31,11 @@ export function evaluateServer(server, context) {
|
|
|
31
31
|
findings.push(...ruleDangerousCommandPattern(server));
|
|
32
32
|
findings.push(...ruleRemoteUrl(server));
|
|
33
33
|
findings.push(...ruleHeaders(server));
|
|
34
|
+
findings.push(...rulePolicyAllowedCommand(server, context));
|
|
35
|
+
findings.push(...rulePolicyAllowedPackage(server, context));
|
|
36
|
+
findings.push(...rulePolicyAllowedWorkingDirectory(server, context));
|
|
37
|
+
findings.push(...rulePolicyAllowedFilesystemArgs(server, context));
|
|
38
|
+
findings.push(...rulePolicyAllowedRemoteUrl(server, context));
|
|
34
39
|
|
|
35
40
|
return findings;
|
|
36
41
|
}
|
|
@@ -220,6 +225,103 @@ function ruleHeaders(server) {
|
|
|
220
225
|
return findings;
|
|
221
226
|
}
|
|
222
227
|
|
|
228
|
+
function rulePolicyAllowedCommand(server, context) {
|
|
229
|
+
const policy = context.policy;
|
|
230
|
+
if (!policy || policy.allowedCommands.size === 0 || !server.command) return [];
|
|
231
|
+
|
|
232
|
+
const command = commandBase(server.command);
|
|
233
|
+
if (policy.allowedCommands.has(command)) return [];
|
|
234
|
+
|
|
235
|
+
return [finding({
|
|
236
|
+
id: "MCP070",
|
|
237
|
+
severity: "high",
|
|
238
|
+
title: "MCP server command is not allowed by policy",
|
|
239
|
+
server,
|
|
240
|
+
evidence: `command=${server.command} allowed=${listSet(policy.allowedCommands)}`,
|
|
241
|
+
recommendation: "Use an approved command, or add this command to allowedCommands only after review."
|
|
242
|
+
})];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function rulePolicyAllowedPackage(server, context) {
|
|
246
|
+
const policy = context.policy;
|
|
247
|
+
if (!policy || policy.allowedPackages.size === 0) return [];
|
|
248
|
+
|
|
249
|
+
const command = commandBase(server.command);
|
|
250
|
+
const usesRemoteRunner = REMOTE_EXEC_COMMANDS.has(command) || (PACKAGE_MANAGER_COMMANDS.has(command) && server.args[0] === "dlx");
|
|
251
|
+
if (!usesRemoteRunner) return [];
|
|
252
|
+
|
|
253
|
+
const packageArg = firstPackageArg(server.args);
|
|
254
|
+
if (!packageArg) return [];
|
|
255
|
+
|
|
256
|
+
const packageName = packageIdentity(packageArg);
|
|
257
|
+
if (policy.allowedPackages.has(packageName)) return [];
|
|
258
|
+
|
|
259
|
+
return [finding({
|
|
260
|
+
id: "MCP071",
|
|
261
|
+
severity: "high",
|
|
262
|
+
title: "Remote MCP package is not allowed by policy",
|
|
263
|
+
server,
|
|
264
|
+
evidence: `package=${packageName} allowed=${listSet(policy.allowedPackages)}`,
|
|
265
|
+
recommendation: "Use an approved MCP package, or add this package to allowedPackages only after review."
|
|
266
|
+
})];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function rulePolicyAllowedWorkingDirectory(server, context) {
|
|
270
|
+
const policy = context.policy;
|
|
271
|
+
if (!policy || policy.allowedDirectoryPaths.length === 0 || !server.cwd) return [];
|
|
272
|
+
|
|
273
|
+
const normalized = normalizePath(server.cwd, context);
|
|
274
|
+
if (isAllowedPath(normalized, policy.allowedDirectoryPaths)) return [];
|
|
275
|
+
|
|
276
|
+
return [finding({
|
|
277
|
+
id: "MCP072",
|
|
278
|
+
severity: "high",
|
|
279
|
+
title: "MCP server working directory is outside policy",
|
|
280
|
+
server,
|
|
281
|
+
evidence: `cwd=${server.cwd} allowed=${policy.allowedDirectories.join(", ")}`,
|
|
282
|
+
recommendation: "Move this server into an approved workspace directory, or add the directory to allowedDirectories after review."
|
|
283
|
+
})];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function rulePolicyAllowedFilesystemArgs(server, context) {
|
|
287
|
+
const policy = context.policy;
|
|
288
|
+
if (!policy || policy.allowedDirectoryPaths.length === 0) return [];
|
|
289
|
+
|
|
290
|
+
const findings = [];
|
|
291
|
+
for (const arg of server.args) {
|
|
292
|
+
const value = filesystemPathFromArg(arg);
|
|
293
|
+
if (!value) continue;
|
|
294
|
+
|
|
295
|
+
const normalized = normalizePath(value, context);
|
|
296
|
+
if (isAllowedPath(normalized, policy.allowedDirectoryPaths)) continue;
|
|
297
|
+
|
|
298
|
+
findings.push(finding({
|
|
299
|
+
id: "MCP073",
|
|
300
|
+
severity: "high",
|
|
301
|
+
title: "MCP server filesystem argument is outside policy",
|
|
302
|
+
server,
|
|
303
|
+
evidence: `arg=${arg} allowed=${policy.allowedDirectories.join(", ")}`,
|
|
304
|
+
recommendation: "Limit filesystem arguments to approved directories, or update allowedDirectories only after review."
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
return findings;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function rulePolicyAllowedRemoteUrl(server, context) {
|
|
311
|
+
const policy = context.policy;
|
|
312
|
+
if (!policy || policy.allowedRemoteUrls.length === 0 || !server.url) return [];
|
|
313
|
+
if (isAllowedRemoteUrl(server.url, policy.allowedRemoteUrls)) return [];
|
|
314
|
+
|
|
315
|
+
return [finding({
|
|
316
|
+
id: "MCP074",
|
|
317
|
+
severity: "high",
|
|
318
|
+
title: "Remote MCP URL is not allowed by policy",
|
|
319
|
+
server,
|
|
320
|
+
evidence: `url=${server.url} allowed=${policy.allowedRemoteUrls.join(", ")}`,
|
|
321
|
+
recommendation: "Use an approved remote MCP endpoint, or add this URL to allowedRemoteUrls only after review."
|
|
322
|
+
})];
|
|
323
|
+
}
|
|
324
|
+
|
|
223
325
|
function finding({ id, severity, title, server, evidence, recommendation }) {
|
|
224
326
|
return {
|
|
225
327
|
id,
|
|
@@ -252,6 +354,15 @@ function isPinnedPackage(packageName) {
|
|
|
252
354
|
return at > 0 && at < packageName.length - 1;
|
|
253
355
|
}
|
|
254
356
|
|
|
357
|
+
function packageIdentity(packageName) {
|
|
358
|
+
if (packageName.startsWith("@")) {
|
|
359
|
+
const secondAt = packageName.indexOf("@", 1);
|
|
360
|
+
return secondAt > 1 ? packageName.slice(0, secondAt) : packageName;
|
|
361
|
+
}
|
|
362
|
+
const at = packageName.lastIndexOf("@");
|
|
363
|
+
return at > 0 ? packageName.slice(0, at) : packageName;
|
|
364
|
+
}
|
|
365
|
+
|
|
255
366
|
function valueFromArg(arg) {
|
|
256
367
|
if (!arg) return "";
|
|
257
368
|
const equalIndex = arg.indexOf("=");
|
|
@@ -260,6 +371,14 @@ function valueFromArg(arg) {
|
|
|
260
371
|
return "";
|
|
261
372
|
}
|
|
262
373
|
|
|
374
|
+
function filesystemPathFromArg(arg) {
|
|
375
|
+
const value = valueFromArg(arg);
|
|
376
|
+
if (!value) return "";
|
|
377
|
+
if (value === "~" || value.startsWith("~/")) return value;
|
|
378
|
+
if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../")) return value;
|
|
379
|
+
return "";
|
|
380
|
+
}
|
|
381
|
+
|
|
263
382
|
function normalizePath(value, context) {
|
|
264
383
|
if (!value) return "";
|
|
265
384
|
if (value === "~") return context.home;
|
|
@@ -268,3 +387,26 @@ function normalizePath(value, context) {
|
|
|
268
387
|
return path.resolve(context.cwd, value);
|
|
269
388
|
}
|
|
270
389
|
|
|
390
|
+
function isAllowedPath(filePath, allowedPaths) {
|
|
391
|
+
return allowedPaths.some((allowedPath) => {
|
|
392
|
+
const normalizedAllowed = path.normalize(allowedPath);
|
|
393
|
+
return filePath === normalizedAllowed || filePath.startsWith(`${normalizedAllowed}${path.sep}`);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isAllowedRemoteUrl(value, allowedUrls) {
|
|
398
|
+
let target;
|
|
399
|
+
try {
|
|
400
|
+
target = new URL(value);
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const targetPath = target.pathname === "/" ? "" : target.pathname.replace(/\/+$/, "");
|
|
406
|
+
const targetValue = `${target.origin}${targetPath}`;
|
|
407
|
+
return allowedUrls.some((allowed) => targetValue === allowed || targetValue.startsWith(`${allowed}/`));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function listSet(items) {
|
|
411
|
+
return [...items].join(", ");
|
|
412
|
+
}
|
package/src/scan.js
CHANGED
|
@@ -3,12 +3,14 @@ import path from "node:path";
|
|
|
3
3
|
import { extractServers, loadConfigFile } from "./config.js";
|
|
4
4
|
import { discoverConfigFiles } from "./discovery.js";
|
|
5
5
|
import { findingFingerprint } from "./fingerprint.js";
|
|
6
|
+
import { loadPolicy, policyForReport } from "./policy.js";
|
|
6
7
|
import { evaluateServer } from "./rules.js";
|
|
7
8
|
import { sortFindings } from "./severity.js";
|
|
8
9
|
import { summarize } from "./baseline.js";
|
|
9
10
|
|
|
10
|
-
export async function scan({ cwd, env, configPaths = [], includeDefaults = true, toolVersion = "0.0.0" }) {
|
|
11
|
+
export async function scan({ cwd, env, configPaths = [], includeDefaults = true, policyPath = "", includePolicy = true, toolVersion = "0.0.0" }) {
|
|
11
12
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
13
|
+
const policy = await loadPolicy({ cwd, home, policyPath, includePolicy });
|
|
12
14
|
const explicitPaths = configPaths.map((item) => path.resolve(item));
|
|
13
15
|
const shouldDiscover = explicitPaths.length === 0 && includeDefaults;
|
|
14
16
|
const discoveredPaths = shouldDiscover ? await discoverConfigFiles({ cwd, env }) : [];
|
|
@@ -63,7 +65,7 @@ export async function scan({ cwd, env, configPaths = [], includeDefaults = true,
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
for (const server of servers) {
|
|
66
|
-
findings.push(...evaluateServer(server, { cwd, home }));
|
|
68
|
+
findings.push(...evaluateServer(server, { cwd, home, policy }));
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
const sortedFindings = sortFindings(findings).map((finding) => ({
|
|
@@ -76,8 +78,11 @@ export async function scan({ cwd, env, configPaths = [], includeDefaults = true,
|
|
|
76
78
|
generatedAt: new Date().toISOString(),
|
|
77
79
|
cwd,
|
|
78
80
|
home,
|
|
81
|
+
policyPath: policy?.path || "",
|
|
82
|
+
policyEnabled: Boolean(policy),
|
|
79
83
|
toolVersion
|
|
80
84
|
},
|
|
85
|
+
policy: policyForReport(policy),
|
|
81
86
|
scannedFiles,
|
|
82
87
|
servers,
|
|
83
88
|
findings: sortedFindings,
|