agent-mcp-guard 0.1.1 → 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/README.md CHANGED
@@ -14,7 +14,7 @@ Website: [chaoyue0307.github.io/mcp-guard](https://chaoyue0307.github.io/mcp-gua
14
14
  <a href="https://www.npmjs.com/package/agent-mcp-guard"><img alt="npm version" src="https://img.shields.io/npm/v/agent-mcp-guard?color=0f766e"></a>
15
15
  <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>
16
16
  <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-111827"></a>
17
- <a href="https://github.com/ChaoYue0307/mcp-guard/releases/tag/v0.1.1"><img alt="Release" src="https://img.shields.io/github/v/release/ChaoYue0307/mcp-guard?color=7c2d12"></a>
17
+ <a href="https://github.com/ChaoYue0307/mcp-guard/releases/tag/v0.3.0"><img alt="Release" src="https://img.shields.io/github/v/release/ChaoYue0307/mcp-guard?color=7c2d12"></a>
18
18
  </p>
19
19
 
20
20
  ## Install
@@ -36,12 +36,33 @@ Generate a Markdown report:
36
36
  mcp-guard scan --format markdown --output mcp-guard-report.md
37
37
  ```
38
38
 
39
+ Generate an HTML report:
40
+
41
+ ```bash
42
+ mcp-guard scan --format html --output mcp-guard-report.html
43
+ ```
44
+
45
+ Generate SARIF for GitHub code scanning:
46
+
47
+ ```bash
48
+ mcp-guard scan --format sarif --output mcp-guard.sarif
49
+ ```
50
+
39
51
  Use in CI:
40
52
 
41
53
  ```bash
42
54
  mcp-guard scan --config .mcp.json --fail-on high
43
55
  ```
44
56
 
57
+ Use the GitHub Action:
58
+
59
+ ```yaml
60
+ - uses: ChaoYue0307/mcp-guard@v0.3.0
61
+ with:
62
+ fail-on: high
63
+ upload-sarif: "true"
64
+ ```
65
+
45
66
  ## What It Finds
46
67
 
47
68
  | Risk | Why it matters |
@@ -98,7 +119,7 @@ MCP configs often contain sensitive local paths, internal hostnames, tokens, and
98
119
  - no config upload;
99
120
  - no external API call;
100
121
  - secret-like values redacted in reports;
101
- - text, Markdown, and JSON output for local review and CI.
122
+ - text, Markdown, HTML, JSON, and SARIF output for local review, CI artifacts, and GitHub code scanning.
102
123
 
103
124
  ## Commercial Support
104
125
 
@@ -113,6 +134,7 @@ Service details: [docs/paid-audit.md](docs/paid-audit.md)
113
134
  ## Documentation
114
135
 
115
136
  - [Rule reference](docs/rules.md)
137
+ - [GitHub Action](docs/github-action.md)
116
138
  - [Privacy and security](docs/privacy-and-security.md)
117
139
  - [Roadmap](docs/roadmap.md)
118
140
  - [Business playbook](docs/business-playbook.md)
package/action.yml ADDED
@@ -0,0 +1,135 @@
1
+ name: mcp-guard
2
+ description: Scan MCP and AI agent tool configuration for risky commands, secrets, and broad permissions.
3
+ author: mcp-guard
4
+
5
+ branding:
6
+ icon: shield
7
+ color: green
8
+
9
+ inputs:
10
+ config:
11
+ description: Optional MCP config path to scan. Leave empty to scan default project and user config locations.
12
+ required: false
13
+ default: ""
14
+ fail-on:
15
+ description: Fail the workflow when a finding is at least this severity. Use critical, high, medium, low, or none.
16
+ required: false
17
+ default: high
18
+ output-dir:
19
+ description: Directory where reports will be written.
20
+ required: false
21
+ default: mcp-guard-report
22
+ upload-artifact:
23
+ description: Upload generated reports as a workflow artifact.
24
+ required: false
25
+ default: "true"
26
+ upload-sarif:
27
+ description: "Upload SARIF to GitHub code scanning. Requires security-events: write permission."
28
+ required: false
29
+ default: "false"
30
+ artifact-name:
31
+ description: Artifact name for generated reports.
32
+ required: false
33
+ default: mcp-guard-report
34
+
35
+ outputs:
36
+ markdown-report:
37
+ description: Path to the generated Markdown report.
38
+ value: ${{ steps.reports.outputs.markdown-report }}
39
+ html-report:
40
+ description: Path to the generated HTML report.
41
+ value: ${{ steps.reports.outputs.html-report }}
42
+ json-report:
43
+ description: Path to the generated JSON report.
44
+ value: ${{ steps.reports.outputs.json-report }}
45
+ sarif-report:
46
+ description: Path to the generated SARIF report.
47
+ value: ${{ steps.reports.outputs.sarif-report }}
48
+ exit-code:
49
+ description: mcp-guard threshold exit code.
50
+ value: ${{ steps.reports.outputs.exit-code }}
51
+
52
+ runs:
53
+ using: composite
54
+ steps:
55
+ - name: Set up Node.js
56
+ uses: actions/setup-node@v4
57
+ with:
58
+ node-version: "20"
59
+
60
+ - name: Generate reports
61
+ id: reports
62
+ shell: bash
63
+ env:
64
+ MCP_GUARD_CONFIG: ${{ inputs.config }}
65
+ MCP_GUARD_FAIL_ON: ${{ inputs.fail-on }}
66
+ MCP_GUARD_OUTPUT_DIR: ${{ inputs.output-dir }}
67
+ run: |
68
+ set -euo pipefail
69
+
70
+ guard_bin="${GITHUB_ACTION_PATH}/bin/mcp-guard.js"
71
+ mkdir -p "${MCP_GUARD_OUTPUT_DIR}"
72
+
73
+ scan_args=()
74
+ if [ -n "${MCP_GUARD_CONFIG}" ]; then
75
+ scan_args+=(--config "${MCP_GUARD_CONFIG}")
76
+ fi
77
+
78
+ markdown_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.md"
79
+ html_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.html"
80
+ json_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.json"
81
+ sarif_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard.sarif"
82
+
83
+ node "${guard_bin}" scan "${scan_args[@]}" --format markdown --output "${markdown_report}" --fail-on none
84
+ node "${guard_bin}" scan "${scan_args[@]}" --format html --output "${html_report}" --fail-on none
85
+ node "${guard_bin}" scan "${scan_args[@]}" --format json --output "${json_report}" --fail-on none
86
+ node "${guard_bin}" scan "${scan_args[@]}" --format sarif --output "${sarif_report}" --fail-on none
87
+
88
+ set +e
89
+ node "${guard_bin}" scan "${scan_args[@]}" --fail-on "${MCP_GUARD_FAIL_ON}"
90
+ status="$?"
91
+ set -e
92
+
93
+ {
94
+ echo "markdown-report=${markdown_report}"
95
+ echo "html-report=${html_report}"
96
+ echo "json-report=${json_report}"
97
+ echo "sarif-report=${sarif_report}"
98
+ echo "exit-code=${status}"
99
+ } >> "${GITHUB_OUTPUT}"
100
+
101
+ - name: Write job summary
102
+ if: ${{ always() && steps.reports.outputs.json-report != '' }}
103
+ shell: bash
104
+ env:
105
+ MCP_GUARD_FAIL_ON: ${{ inputs.fail-on }}
106
+ run: |
107
+ node "${GITHUB_ACTION_PATH}/scripts/action-summary.js" \
108
+ "${{ steps.reports.outputs.json-report }}" \
109
+ "${{ steps.reports.outputs.markdown-report }}" \
110
+ "${{ steps.reports.outputs.html-report }}" \
111
+ "${{ steps.reports.outputs.sarif-report }}" \
112
+ "${MCP_GUARD_FAIL_ON}" >> "${GITHUB_STEP_SUMMARY}"
113
+
114
+ - name: Upload report artifact
115
+ if: ${{ always() && inputs.upload-artifact == 'true' }}
116
+ uses: actions/upload-artifact@v4
117
+ with:
118
+ name: ${{ inputs.artifact-name }}
119
+ path: ${{ inputs.output-dir }}
120
+
121
+ - name: Upload SARIF to code scanning
122
+ if: ${{ always() && inputs.upload-sarif == 'true' && steps.reports.outputs.sarif-report != '' }}
123
+ uses: github/codeql-action/upload-sarif@v3
124
+ with:
125
+ sarif_file: ${{ steps.reports.outputs.sarif-report }}
126
+
127
+ - name: Enforce severity threshold
128
+ shell: bash
129
+ env:
130
+ MCP_GUARD_EXIT_CODE: ${{ steps.reports.outputs.exit-code }}
131
+ run: |
132
+ if [ -z "${MCP_GUARD_EXIT_CODE}" ]; then
133
+ exit 1
134
+ fi
135
+ exit "${MCP_GUARD_EXIT_CODE}"
@@ -13,9 +13,10 @@ AI Agent/MCP Security Audit.
13
13
  Deliverables:
14
14
 
15
15
  - MCP server inventory;
16
- - `mcp-guard` scan report;
16
+ - `mcp-guard` Markdown, HTML, JSON, and SARIF scan reports;
17
17
  - manual review of high-risk findings;
18
18
  - prioritized remediation plan;
19
+ - optional GitHub Action setup for continuous scans;
19
20
  - 60-minute hardening call;
20
21
  - optional PR with safer config changes.
21
22
 
@@ -61,4 +62,3 @@ Weak:
61
62
  - vague security interest;
62
63
  - requests for a full dashboard before any audit;
63
64
  - only free users with toy configs.
64
-
@@ -0,0 +1,87 @@
1
+ # GitHub Action
2
+
3
+ Use the `mcp-guard` action to scan MCP and AI agent tool configuration in pull requests and CI.
4
+
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
+
7
+ ## Basic Workflow
8
+
9
+ ```yaml
10
+ name: mcp-guard
11
+
12
+ on:
13
+ pull_request:
14
+ push:
15
+ branches: [main]
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ scan:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: ChaoYue0307/mcp-guard@v0.3.0
26
+ with:
27
+ fail-on: high
28
+ ```
29
+
30
+ ## Upload SARIF to GitHub Security
31
+
32
+ Enable SARIF upload when you want findings in the repository Security tab. The workflow needs `security-events: write`.
33
+
34
+ ```yaml
35
+ name: mcp-guard
36
+
37
+ on:
38
+ pull_request:
39
+ push:
40
+ branches: [main]
41
+
42
+ permissions:
43
+ contents: read
44
+ security-events: write
45
+
46
+ jobs:
47
+ scan:
48
+ runs-on: ubuntu-latest
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+ - uses: ChaoYue0307/mcp-guard@v0.3.0
52
+ with:
53
+ config: .mcp.json
54
+ fail-on: high
55
+ upload-sarif: "true"
56
+ ```
57
+
58
+ ## Report-Only Mode
59
+
60
+ Use `fail-on: none` when you want artifacts and summaries without blocking a pull request.
61
+
62
+ ```yaml
63
+ - uses: ChaoYue0307/mcp-guard@v0.3.0
64
+ with:
65
+ fail-on: none
66
+ ```
67
+
68
+ ## Inputs
69
+
70
+ | Input | Default | Description |
71
+ | --- | --- | --- |
72
+ | `config` | empty | Optional MCP config path. Empty scans default project and user config locations. |
73
+ | `fail-on` | `high` | Fails the job for `critical`, `high`, `medium`, or `low` findings. Use `none` for report-only mode. |
74
+ | `output-dir` | `mcp-guard-report` | Directory for generated reports. |
75
+ | `upload-artifact` | `true` | Uploads generated reports as a workflow artifact. |
76
+ | `upload-sarif` | `false` | Uploads SARIF to GitHub code scanning. Requires `security-events: write`. |
77
+ | `artifact-name` | `mcp-guard-report` | Name of the uploaded artifact. |
78
+
79
+ ## Outputs
80
+
81
+ | Output | Description |
82
+ | --- | --- |
83
+ | `markdown-report` | Path to the generated Markdown report. |
84
+ | `html-report` | Path to the generated HTML report. |
85
+ | `json-report` | Path to the generated JSON report. |
86
+ | `sarif-report` | Path to the generated SARIF report. |
87
+ | `exit-code` | `0` when below threshold, `2` when findings met the threshold. |
package/docs/roadmap.md CHANGED
@@ -5,25 +5,24 @@
5
5
  ## Now
6
6
 
7
7
  - CLI config scanning.
8
- - Text, Markdown, and redacted JSON output.
8
+ - Text, Markdown, HTML, redacted JSON, and SARIF output.
9
9
  - Rules for shell wrappers, remote package runners, unpinned packages, broad filesystem access, secret-like env vars/headers, and remote MCP URLs.
10
10
  - CI usage with `--fail-on`.
11
+ - GitHub Action wrapper that writes a job summary, uploads Markdown/HTML/JSON/SARIF artifacts, and can upload SARIF to GitHub code scanning.
11
12
 
12
13
  ## Next
13
14
 
14
- 1. GitHub Action wrapper.
15
- 2. HTML audit report.
16
- 3. More MCP client discovery paths.
17
- 4. Rule packs mapped to MCP security best practices.
15
+ 1. More MCP client discovery paths.
16
+ 2. Rule packs mapped to MCP security best practices.
17
+ 3. Policy file for approved commands, packages, directories, and remote URLs.
18
+ 4. Baseline mode: accept known findings and fail only on new risks.
18
19
  5. `mcp-guard audit` mode for client-ready reports.
19
20
 
20
21
  ## Later
21
22
 
22
- 1. Policy file: approved commands, packages, directories, and remote URLs.
23
- 2. Baseline mode: accept known findings and fail only on new risks.
24
- 3. SBOM/package metadata checks for MCP server packages.
25
- 4. Local web report viewer.
26
- 5. Hosted team dashboard only after repeated paid audit demand.
23
+ 1. SBOM/package metadata checks for MCP server packages.
24
+ 2. Local web report viewer.
25
+ 3. Hosted team dashboard only after repeated paid audit demand.
27
26
 
28
27
  ## Product Principles
29
28
 
@@ -32,4 +31,3 @@
32
31
  - Avoid noisy rules that do not change behavior.
33
32
  - Prefer workflow integration over dashboards.
34
33
  - Services first, SaaS later.
35
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mcp-guard",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Open-source CLI scanner for risky MCP server and AI agent tool configuration.",
5
5
  "type": "module",
6
6
  "homepage": "https://chaoyue0307.github.io/mcp-guard/",
@@ -32,7 +32,9 @@
32
32
  "security",
33
33
  "cli",
34
34
  "scanner",
35
- "devsecops"
35
+ "devsecops",
36
+ "sarif",
37
+ "github-actions"
36
38
  ],
37
39
  "author": "",
38
40
  "license": "Apache-2.0",
@@ -40,6 +42,8 @@
40
42
  "bin",
41
43
  "src",
42
44
  "README.md",
45
+ "action.yml",
46
+ "scripts/action-summary.js",
43
47
  "LICENSE",
44
48
  "SECURITY.md",
45
49
  "docs",
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const [jsonReportPath, markdownReportPath, htmlReportPath, sarifReportPath, failOn] = process.argv.slice(2);
7
+
8
+ if (!jsonReportPath) {
9
+ process.stderr.write("Usage: action-summary.js <json-report> <markdown-report> <html-report> <sarif-report> <fail-on>\n");
10
+ process.exit(1);
11
+ }
12
+
13
+ const report = JSON.parse(fs.readFileSync(jsonReportPath, "utf8"));
14
+ const counts = report.summary.counts;
15
+ const topFindings = report.findings.slice(0, 8);
16
+
17
+ const lines = [];
18
+ lines.push("## mcp-guard scan");
19
+ lines.push("");
20
+ lines.push(`Risk score: **${report.summary.riskScore}**`);
21
+ lines.push("");
22
+ lines.push("| Severity | Count |");
23
+ lines.push("| --- | ---: |");
24
+ lines.push(`| Critical | ${counts.critical} |`);
25
+ lines.push(`| High | ${counts.high} |`);
26
+ lines.push(`| Medium | ${counts.medium} |`);
27
+ lines.push(`| Low | ${counts.low} |`);
28
+ lines.push("");
29
+ lines.push(`Scanned files: **${report.summary.scannedFileCount}**`);
30
+ lines.push(`MCP servers: **${report.summary.serverCount}**`);
31
+ lines.push(`Findings: **${report.summary.findingCount}**`);
32
+ lines.push(`Fail threshold: **${failOn || "high"}**`);
33
+ lines.push("");
34
+
35
+ if (topFindings.length === 0) {
36
+ lines.push("No findings.");
37
+ } else {
38
+ lines.push("### Top findings");
39
+ lines.push("");
40
+ lines.push("| Severity | Rule | Server | Finding |");
41
+ lines.push("| --- | --- | --- | --- |");
42
+ for (const finding of topFindings) {
43
+ lines.push(`| ${cell(finding.severity)} | ${cell(finding.id)} | ${cell(finding.serverName)} | ${cell(finding.title)} |`);
44
+ }
45
+ }
46
+
47
+ lines.push("");
48
+ lines.push("### Reports");
49
+ lines.push("");
50
+ lines.push(`- Markdown: \`${relative(markdownReportPath)}\``);
51
+ lines.push(`- HTML: \`${relative(htmlReportPath)}\``);
52
+ lines.push(`- JSON: \`${relative(jsonReportPath)}\``);
53
+ lines.push(`- SARIF: \`${relative(sarifReportPath)}\``);
54
+ lines.push("");
55
+
56
+ process.stdout.write(`${lines.join("\n")}\n`);
57
+
58
+ function cell(value) {
59
+ return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " ");
60
+ }
61
+
62
+ function relative(filePath) {
63
+ if (!filePath) return "";
64
+ return path.relative(process.cwd(), path.resolve(filePath)) || ".";
65
+ }
package/src/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { scan } from "./scan.js";
4
- import { generateJsonReport, generateMarkdownReport, generateTextReport } from "./report.js";
4
+ import { generateHtmlReport, generateJsonReport, generateMarkdownReport, generateSarifReport, generateTextReport } from "./report.js";
5
5
  import { compareSeverity, severityRank } from "./severity.js";
6
6
 
7
- const VERSION = "0.1.1";
7
+ const VERSION = "0.3.0";
8
8
 
9
9
  export async function runCli(argv, io) {
10
10
  const args = argv.slice(2);
@@ -80,8 +80,8 @@ function parseScanArgs(args, defaultCwd) {
80
80
  } else if (arg === "--format" || arg === "-f") {
81
81
  options.format = readValue(args, index, arg);
82
82
  index += 1;
83
- if (!["text", "markdown", "json"].includes(options.format)) {
84
- throw new Error("--format must be one of: text, markdown, json");
83
+ if (!["text", "markdown", "json", "html", "sarif"].includes(options.format)) {
84
+ throw new Error("--format must be one of: text, markdown, json, html, sarif");
85
85
  }
86
86
  } else if (arg === "--fail-on") {
87
87
  options.failOn = readValue(args, index, arg);
@@ -121,6 +121,12 @@ function renderReport(result, format) {
121
121
  if (format === "markdown") {
122
122
  return generateMarkdownReport(result);
123
123
  }
124
+ if (format === "html") {
125
+ return generateHtmlReport(result);
126
+ }
127
+ if (format === "sarif") {
128
+ return `${generateSarifReport(result)}\n`;
129
+ }
124
130
  return generateTextReport(result);
125
131
  }
126
132
 
@@ -142,7 +148,7 @@ Usage:
142
148
  Scan options:
143
149
  -c, --config <path> Scan a specific MCP config file. Can be repeated.
144
150
  -o, --output <path> Write report to a file.
145
- -f, --format <format> text, markdown, or json. Default: text.
151
+ -f, --format <format> text, markdown, json, html, or sarif. Default: text.
146
152
  --fail-on <severity> Exit 2 when finding severity is at least threshold.
147
153
  critical, high, medium, low, none. Default: none.
148
154
  --cwd <path> Working directory for project config discovery.
@@ -151,6 +157,8 @@ Scan options:
151
157
  Examples:
152
158
  mcp-guard scan
153
159
  mcp-guard scan --format markdown --output mcp-guard-report.md
160
+ mcp-guard scan --format html --output mcp-guard-report.html
161
+ mcp-guard scan --format sarif --output mcp-guard.sarif
154
162
  mcp-guard scan --config .mcp.json --fail-on high
155
163
  `;
156
164
  }
package/src/report.js CHANGED
@@ -105,6 +105,396 @@ export function generateJsonReport(result) {
105
105
  return JSON.stringify(sanitizeResult(result), null, 2);
106
106
  }
107
107
 
108
+ export function generateSarifReport(result) {
109
+ const rules = buildSarifRules(result.findings);
110
+ const sarif = {
111
+ version: "2.1.0",
112
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
113
+ runs: [
114
+ {
115
+ tool: {
116
+ driver: {
117
+ name: "mcp-guard",
118
+ informationUri: "https://github.com/ChaoYue0307/mcp-guard",
119
+ semanticVersion: result.metadata.toolVersion,
120
+ rules
121
+ }
122
+ },
123
+ automationDetails: {
124
+ id: "mcp-guard/"
125
+ },
126
+ invocations: [
127
+ {
128
+ executionSuccessful: true,
129
+ workingDirectory: {
130
+ uri: uriFromPath(result.metadata.cwd, result.metadata.cwd)
131
+ }
132
+ }
133
+ ],
134
+ results: result.findings.map((finding) => sarifResult(finding, result.metadata.cwd))
135
+ }
136
+ ]
137
+ };
138
+
139
+ return JSON.stringify(sarif, null, 2);
140
+ }
141
+
142
+ export function generateHtmlReport(result) {
143
+ const safeResult = sanitizeResult(result);
144
+ const riskTone = riskToneForScore(safeResult.summary.riskScore);
145
+ const findings = safeResult.findings;
146
+ const servers = safeResult.servers;
147
+
148
+ return `<!doctype html>
149
+ <html lang="en">
150
+ <head>
151
+ <meta charset="utf-8">
152
+ <meta name="viewport" content="width=device-width, initial-scale=1">
153
+ <title>mcp-guard Scan Report</title>
154
+ <style>
155
+ :root {
156
+ color-scheme: light;
157
+ --bg: #f8fafc;
158
+ --panel: #ffffff;
159
+ --ink: #111827;
160
+ --muted: #5b6575;
161
+ --line: #d9e2ec;
162
+ --soft: #eef4f7;
163
+ --critical: #b91c1c;
164
+ --high: #c2410c;
165
+ --medium: #a16207;
166
+ --low: #0f766e;
167
+ --info: #1d4ed8;
168
+ --shadow: 0 20px 55px rgba(15, 23, 42, 0.08);
169
+ }
170
+
171
+ * { box-sizing: border-box; }
172
+
173
+ body {
174
+ margin: 0;
175
+ background: var(--bg);
176
+ color: var(--ink);
177
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
178
+ line-height: 1.5;
179
+ }
180
+
181
+ main {
182
+ width: min(1120px, calc(100% - 32px));
183
+ margin: 0 auto;
184
+ padding: 28px 0 48px;
185
+ }
186
+
187
+ .hero {
188
+ background: #ffffff;
189
+ border: 1px solid var(--line);
190
+ border-radius: 8px;
191
+ box-shadow: var(--shadow);
192
+ padding: 28px;
193
+ display: grid;
194
+ grid-template-columns: minmax(0, 1fr) 240px;
195
+ gap: 24px;
196
+ align-items: stretch;
197
+ }
198
+
199
+ .eyebrow {
200
+ margin: 0 0 8px;
201
+ color: var(--info);
202
+ font-size: 13px;
203
+ font-weight: 700;
204
+ letter-spacing: 0;
205
+ text-transform: uppercase;
206
+ }
207
+
208
+ h1, h2 {
209
+ letter-spacing: 0;
210
+ line-height: 1.08;
211
+ }
212
+
213
+ h1 {
214
+ margin: 0;
215
+ font-size: 34px;
216
+ }
217
+
218
+ h2 {
219
+ margin: 0 0 14px;
220
+ font-size: 21px;
221
+ }
222
+
223
+ .lead {
224
+ max-width: 720px;
225
+ margin: 12px 0 0;
226
+ color: var(--muted);
227
+ font-size: 16px;
228
+ }
229
+
230
+ .scorecard {
231
+ border-radius: 8px;
232
+ border: 1px solid var(--line);
233
+ background: var(--soft);
234
+ padding: 18px;
235
+ min-height: 176px;
236
+ display: flex;
237
+ flex-direction: column;
238
+ justify-content: space-between;
239
+ }
240
+
241
+ .score-label {
242
+ margin: 0;
243
+ color: var(--muted);
244
+ font-size: 13px;
245
+ font-weight: 700;
246
+ text-transform: uppercase;
247
+ }
248
+
249
+ .score-value {
250
+ margin: 8px 0;
251
+ font-size: 58px;
252
+ font-weight: 800;
253
+ line-height: 1;
254
+ color: var(--${riskTone});
255
+ }
256
+
257
+ .score-caption {
258
+ margin: 0;
259
+ color: var(--muted);
260
+ font-size: 14px;
261
+ }
262
+
263
+ .grid {
264
+ display: grid;
265
+ grid-template-columns: repeat(4, minmax(0, 1fr));
266
+ gap: 12px;
267
+ margin: 18px 0 0;
268
+ }
269
+
270
+ .metric {
271
+ background: var(--panel);
272
+ border: 1px solid var(--line);
273
+ border-radius: 8px;
274
+ padding: 14px;
275
+ }
276
+
277
+ .metric strong {
278
+ display: block;
279
+ font-size: 24px;
280
+ line-height: 1.1;
281
+ }
282
+
283
+ .metric span {
284
+ display: block;
285
+ margin-top: 6px;
286
+ color: var(--muted);
287
+ font-size: 13px;
288
+ }
289
+
290
+ section {
291
+ margin-top: 22px;
292
+ background: var(--panel);
293
+ border: 1px solid var(--line);
294
+ border-radius: 8px;
295
+ box-shadow: 0 10px 30px rgba(15, 23, 42, 0.04);
296
+ padding: 22px;
297
+ }
298
+
299
+ .severity-row {
300
+ display: grid;
301
+ grid-template-columns: repeat(4, minmax(0, 1fr));
302
+ gap: 10px;
303
+ }
304
+
305
+ .severity {
306
+ border: 1px solid var(--line);
307
+ border-radius: 8px;
308
+ padding: 12px;
309
+ min-height: 78px;
310
+ }
311
+
312
+ .severity b {
313
+ display: block;
314
+ font-size: 22px;
315
+ line-height: 1.1;
316
+ }
317
+
318
+ .severity span {
319
+ display: block;
320
+ margin-top: 6px;
321
+ color: var(--muted);
322
+ font-size: 13px;
323
+ text-transform: capitalize;
324
+ }
325
+
326
+ .critical { color: var(--critical); }
327
+ .high { color: var(--high); }
328
+ .medium { color: var(--medium); }
329
+ .low { color: var(--low); }
330
+
331
+ .table-wrap {
332
+ width: 100%;
333
+ overflow-x: auto;
334
+ border: 1px solid var(--line);
335
+ border-radius: 8px;
336
+ }
337
+
338
+ table {
339
+ width: 100%;
340
+ min-width: 760px;
341
+ border-collapse: collapse;
342
+ background: var(--panel);
343
+ }
344
+
345
+ th, td {
346
+ padding: 12px 14px;
347
+ border-bottom: 1px solid var(--line);
348
+ text-align: left;
349
+ vertical-align: top;
350
+ font-size: 14px;
351
+ }
352
+
353
+ th {
354
+ color: #374151;
355
+ background: #f1f5f9;
356
+ font-size: 12px;
357
+ text-transform: uppercase;
358
+ }
359
+
360
+ tr:last-child td { border-bottom: 0; }
361
+
362
+ code {
363
+ padding: 2px 5px;
364
+ border-radius: 5px;
365
+ background: #eef2f7;
366
+ color: #111827;
367
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
368
+ font-size: 0.92em;
369
+ white-space: normal;
370
+ overflow-wrap: anywhere;
371
+ }
372
+
373
+ .pill {
374
+ display: inline-flex;
375
+ align-items: center;
376
+ min-height: 24px;
377
+ padding: 3px 8px;
378
+ border-radius: 999px;
379
+ background: #f1f5f9;
380
+ font-size: 12px;
381
+ font-weight: 700;
382
+ text-transform: uppercase;
383
+ }
384
+
385
+ .pill.critical { background: #fee2e2; }
386
+ .pill.high { background: #ffedd5; }
387
+ .pill.medium { background: #fef3c7; }
388
+ .pill.low { background: #ccfbf1; }
389
+
390
+ .empty {
391
+ margin: 0;
392
+ color: var(--muted);
393
+ border: 1px dashed var(--line);
394
+ border-radius: 8px;
395
+ padding: 16px;
396
+ background: #fbfdff;
397
+ }
398
+
399
+ .notes {
400
+ color: var(--muted);
401
+ font-size: 14px;
402
+ }
403
+
404
+ .notes ul {
405
+ margin: 10px 0 0;
406
+ padding-left: 18px;
407
+ }
408
+
409
+ @media (max-width: 780px) {
410
+ main {
411
+ width: min(100% - 20px, 1120px);
412
+ padding-top: 10px;
413
+ }
414
+
415
+ .hero {
416
+ grid-template-columns: 1fr;
417
+ padding: 20px;
418
+ }
419
+
420
+ h1 { font-size: 28px; }
421
+
422
+ .grid,
423
+ .severity-row {
424
+ grid-template-columns: repeat(2, minmax(0, 1fr));
425
+ }
426
+ }
427
+
428
+ @media (max-width: 460px) {
429
+ .grid,
430
+ .severity-row {
431
+ grid-template-columns: 1fr;
432
+ }
433
+ }
434
+ </style>
435
+ </head>
436
+ <body>
437
+ <main>
438
+ <header class="hero">
439
+ <div>
440
+ <p class="eyebrow">mcp-guard scan report</p>
441
+ <h1>AI agent tool risk review</h1>
442
+ <p class="lead">Local-first review of MCP server configuration, startup commands, remote endpoints, filesystem scope, and secret-like values.</p>
443
+ <div class="grid">
444
+ ${metric("Scanned files", safeResult.summary.scannedFileCount)}
445
+ ${metric("MCP servers", safeResult.summary.serverCount)}
446
+ ${metric("Findings", safeResult.summary.findingCount)}
447
+ ${metric("Generated", formatDate(safeResult.metadata.generatedAt))}
448
+ </div>
449
+ </div>
450
+ <aside class="scorecard" aria-label="Risk score">
451
+ <div>
452
+ <p class="score-label">Risk score</p>
453
+ <p class="score-value">${escapeHtml(safeResult.summary.riskScore)}</p>
454
+ </div>
455
+ <p class="score-caption">${escapeHtml(riskCaption(safeResult.summary.riskScore))}</p>
456
+ </aside>
457
+ </header>
458
+
459
+ <section>
460
+ <h2>Severity Summary</h2>
461
+ <div class="severity-row">
462
+ ${severityCard("critical", safeResult.summary.counts.critical)}
463
+ ${severityCard("high", safeResult.summary.counts.high)}
464
+ ${severityCard("medium", safeResult.summary.counts.medium)}
465
+ ${severityCard("low", safeResult.summary.counts.low)}
466
+ </div>
467
+ </section>
468
+
469
+ <section>
470
+ <h2>Scanned Files</h2>
471
+ ${renderScannedFiles(safeResult)}
472
+ </section>
473
+
474
+ <section>
475
+ <h2>MCP Server Inventory</h2>
476
+ ${renderServerTable(servers, safeResult.metadata.cwd)}
477
+ </section>
478
+
479
+ <section>
480
+ <h2>Findings</h2>
481
+ ${renderFindingsTable(findings)}
482
+ </section>
483
+
484
+ <section class="notes">
485
+ <h2>Review Notes</h2>
486
+ <ul>
487
+ <li>Secret-like values are redacted before rendering this report.</li>
488
+ <li>Review each server before granting access to files, shells, SaaS accounts, or production systems.</li>
489
+ <li>This report assists security review and does not guarantee that every issue was found.</li>
490
+ </ul>
491
+ </section>
492
+ </main>
493
+ </body>
494
+ </html>
495
+ `;
496
+ }
497
+
108
498
  function cell(value) {
109
499
  return String(value).replaceAll("|", "\\|").replaceAll("\n", "<br>");
110
500
  }
@@ -134,3 +524,195 @@ function sanitizeResult(result) {
134
524
  summary: result.summary
135
525
  };
136
526
  }
527
+
528
+ function buildSarifRules(findings) {
529
+ const unique = new Map();
530
+ for (const finding of findings) {
531
+ if (unique.has(finding.id)) continue;
532
+ unique.set(finding.id, {
533
+ id: finding.id,
534
+ name: finding.id,
535
+ shortDescription: {
536
+ text: finding.title
537
+ },
538
+ fullDescription: {
539
+ text: finding.title
540
+ },
541
+ help: {
542
+ text: finding.recommendation,
543
+ markdown: finding.recommendation
544
+ },
545
+ defaultConfiguration: {
546
+ level: sarifLevel(finding.severity)
547
+ },
548
+ properties: {
549
+ severity: finding.severity,
550
+ tags: ["mcp", "ai-agent", "security"]
551
+ }
552
+ });
553
+ }
554
+ return [...unique.values()];
555
+ }
556
+
557
+ function sarifResult(finding, cwd) {
558
+ return {
559
+ ruleId: finding.id,
560
+ level: sarifLevel(finding.severity),
561
+ message: {
562
+ text: `${finding.title}. ${finding.evidence} Fix: ${finding.recommendation}`
563
+ },
564
+ locations: [
565
+ {
566
+ physicalLocation: {
567
+ artifactLocation: {
568
+ uri: uriFromPath(finding.configPath, cwd)
569
+ },
570
+ region: {
571
+ startLine: 1,
572
+ startColumn: 1
573
+ }
574
+ },
575
+ logicalLocations: [
576
+ {
577
+ name: finding.serverName,
578
+ kind: "object"
579
+ }
580
+ ]
581
+ }
582
+ ],
583
+ partialFingerprints: {
584
+ "mcp-guard/rule-server-evidence": fingerprint(`${finding.id}:${finding.serverName}:${finding.evidence}`)
585
+ },
586
+ properties: {
587
+ severity: finding.severity,
588
+ serverName: finding.serverName,
589
+ evidence: finding.evidence,
590
+ recommendation: finding.recommendation
591
+ }
592
+ };
593
+ }
594
+
595
+ function sarifLevel(severity) {
596
+ if (severity === "critical" || severity === "high") return "error";
597
+ if (severity === "medium") return "warning";
598
+ return "note";
599
+ }
600
+
601
+ function uriFromPath(filePath, cwd) {
602
+ const display = displayPath(filePath, cwd) || ".";
603
+ return display.split("/").map(encodeURIComponent).join("/");
604
+ }
605
+
606
+ function fingerprint(value) {
607
+ let hash = 2166136261;
608
+ for (let index = 0; index < value.length; index += 1) {
609
+ hash ^= value.charCodeAt(index);
610
+ hash = Math.imul(hash, 16777619);
611
+ }
612
+ return (hash >>> 0).toString(16).padStart(8, "0");
613
+ }
614
+
615
+ function metric(label, value) {
616
+ return `<div class="metric"><strong>${escapeHtml(value)}</strong><span>${escapeHtml(label)}</span></div>`;
617
+ }
618
+
619
+ function severityCard(severity, count) {
620
+ return `<div class="severity ${severity}"><b>${escapeHtml(count)}</b><span>${escapeHtml(severity)}</span></div>`;
621
+ }
622
+
623
+ function renderScannedFiles(result) {
624
+ if (result.scannedFiles.length === 0) {
625
+ return `<p class="empty">No MCP config files were found.</p>`;
626
+ }
627
+
628
+ const items = result.scannedFiles
629
+ .map((file) => `<tr><td><code>${escapeHtml(displayPath(file, result.metadata.cwd))}</code></td></tr>`)
630
+ .join("");
631
+
632
+ return `<div class="table-wrap"><table><thead><tr><th>Path</th></tr></thead><tbody>${items}</tbody></table></div>`;
633
+ }
634
+
635
+ function renderServerTable(servers, cwd) {
636
+ if (servers.length === 0) {
637
+ return `<p class="empty">No MCP servers were found.</p>`;
638
+ }
639
+
640
+ const rows = servers.map((server) => {
641
+ const env = kvList(server.env);
642
+ const headers = kvList(server.headers);
643
+ return `<tr>
644
+ <td><strong>${escapeHtml(server.name)}</strong><br><code>${escapeHtml(displayPath(server.configPath, cwd))}</code></td>
645
+ <td>${codeOrDash(server.command)}</td>
646
+ <td>${codeOrDash(server.args.join(" "))}</td>
647
+ <td>${codeOrDash(server.cwd)}</td>
648
+ <td>${codeOrDash(server.url)}</td>
649
+ <td>${env || "-"}</td>
650
+ <td>${headers || "-"}</td>
651
+ </tr>`;
652
+ }).join("");
653
+
654
+ return `<div class="table-wrap"><table>
655
+ <thead><tr><th>Server</th><th>Command</th><th>Args</th><th>CWD</th><th>URL</th><th>Env</th><th>Headers</th></tr></thead>
656
+ <tbody>${rows}</tbody>
657
+ </table></div>`;
658
+ }
659
+
660
+ function renderFindingsTable(findings) {
661
+ if (findings.length === 0) {
662
+ return `<p class="empty">No findings. Keep reviewing new MCP servers and agent tools before adding them.</p>`;
663
+ }
664
+
665
+ const rows = findings.map((finding) => `<tr>
666
+ <td><span class="pill ${escapeHtml(finding.severity)}">${escapeHtml(finding.severity)}</span></td>
667
+ <td><code>${escapeHtml(finding.id)}</code></td>
668
+ <td>${escapeHtml(finding.serverName)}</td>
669
+ <td>${escapeHtml(finding.title)}</td>
670
+ <td>${codeOrDash(finding.evidence)}</td>
671
+ <td>${escapeHtml(finding.recommendation)}</td>
672
+ </tr>`).join("");
673
+
674
+ return `<div class="table-wrap"><table>
675
+ <thead><tr><th>Severity</th><th>Rule</th><th>Server</th><th>Finding</th><th>Evidence</th><th>Recommendation</th></tr></thead>
676
+ <tbody>${rows}</tbody>
677
+ </table></div>`;
678
+ }
679
+
680
+ function kvList(record) {
681
+ return Object.entries(record || {})
682
+ .map(([key, value]) => `<code>${escapeHtml(key)}=${escapeHtml(value)}</code>`)
683
+ .join("<br>");
684
+ }
685
+
686
+ function codeOrDash(value) {
687
+ if (!value) return "-";
688
+ return `<code>${escapeHtml(value)}</code>`;
689
+ }
690
+
691
+ function formatDate(value) {
692
+ const date = new Date(value);
693
+ if (Number.isNaN(date.getTime())) return value;
694
+ return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
695
+ }
696
+
697
+ function riskToneForScore(score) {
698
+ if (score >= 80) return "critical";
699
+ if (score >= 50) return "high";
700
+ if (score >= 20) return "medium";
701
+ return "low";
702
+ }
703
+
704
+ function riskCaption(score) {
705
+ if (score >= 80) return "Critical review recommended before enabling these tools.";
706
+ if (score >= 50) return "High risk configuration; review before team use.";
707
+ if (score >= 20) return "Moderate risk; confirm the intended permission scope.";
708
+ return "Low risk based on the current rule set.";
709
+ }
710
+
711
+ function escapeHtml(value) {
712
+ return String(value ?? "")
713
+ .replaceAll("&", "&amp;")
714
+ .replaceAll("<", "&lt;")
715
+ .replaceAll(">", "&gt;")
716
+ .replaceAll('"', "&quot;")
717
+ .replaceAll("'", "&#39;");
718
+ }