mcp-app-lint 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 render-policy contributors
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 CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # mcp-app-lint
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ SARIF findings about what an MCP App declares and what its HTML does. Meant to be run by MCP
4
+ scanners and CI, or by hand on a package, a directory, or the JSON a server returned.
5
+
6
+ ```
7
+ npx mcp-app-lint --dir ./my-server # a package directory, built or source
8
+ npx mcp-app-lint --package @scope/name # an npm package: downloaded, scanned, deleted
9
+ npx mcp-app-lint --read read.json --list list.json --tools tools.json
10
+ # results of resources/read, resources/list, tools/list
11
+ npx mcp-app-lint --html app.html # one UI document
12
+ ```
13
+
14
+ Options: `--out file.sarif`, `--format sarif|text`, `--fail-on error|warning|note|none` (default
15
+ `error`; the exit code is 1 when a finding reaches the threshold, 2 on usage errors).
16
+
17
+ ## Use in CI (GitHub code scanning)
18
+
19
+ This repository ships a composite action that runs the scanner and uploads the SARIF to code
20
+ scanning, so findings appear in the Security tab and on pull requests:
21
+
22
+ ```yaml
23
+ # .github/workflows/mcp-app-lint.yml
24
+ name: mcp-app-lint
25
+ on: [push, pull_request]
26
+ permissions:
27
+ contents: read
28
+ security-events: write # required for the SARIF upload
29
+ jobs:
30
+ scan:
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@v5
34
+ - uses: shteynu/render-policy@v1
35
+ with:
36
+ directory: .
37
+ fail-on: error # error | warning | note | none
38
+ ```
39
+
40
+ Inputs: `directory` (default `.`), `fail-on` (default `error`), `upload-sarif` (default `true`),
41
+ `sarif-file` (default `mcp-app-lint.sarif`), `version` (npm dist-tag or version, default `latest`),
42
+ `args` (passed through), `node-version` (default `22`, empty to use the runner's Node). The SARIF
43
+ is written even when findings fail the job, so it still uploads; paths are repo-relative so code
44
+ scanning maps them to the source.
45
+
46
+ Any CI can run the CLI directly and read the exit code (`0` clean, `1` findings at or above
47
+ `--fail-on`, `2` usage error):
48
+
49
+ ```
50
+ npx mcp-app-lint --dir . --format sarif --out mcp-app-lint.sarif --fail-on error
51
+ ```
52
+
53
+ ## Rules
54
+
55
+ | Id | Name | Level | What it means |
56
+ | --- | --- | --- | --- |
57
+ | MCPAPP001 | csp-missing | warning | no `ui.csp`; a compliant host applies `default-src 'none'`, `connect-src 'none'` |
58
+ | MCPAPP002 | csp-allows-every-host | error | `*` or a scheme-only entry (`https:`) expands to every host |
59
+ | MCPAPP003 | csp-wildcard-host | warning | `*.example.com` allows every subdomain |
60
+ | MCPAPP004 | csp-insecure-scheme | warning | `http:` origin |
61
+ | MCPAPP005 | csp-sink-host | error | host on the sink denylist (request catchers, tunnels, form builders, public buckets) |
62
+ | MCPAPP006 | csp-development-origin | note | localhost, 127.0.0.1, `blob:` in published metadata |
63
+ | MCPAPP007 | permissions-sensitive | note | camera, microphone or geolocation requested |
64
+ | MCPAPP008 | tool-side-effects-app-visible | warning | tool without `readOnlyHint` callable by the app |
65
+ | MCPAPP009 | tool-visibility-implicit | note | tool with UI metadata relies on the default `["model", "app"]` |
66
+ | MCPAPP010 | html-dynamic-innerhtml | warning | hand-written inline script assigns dynamic content to an HTML sink (with line and column) |
67
+ | MCPAPP011 | html-postmessage-wildcard | warning | hand-written `postMessage(…, '*')` |
68
+ | MCPAPP012 | html-inline-handlers | note | `on*` attributes |
69
+ | MCPAPP013 | html-eval | warning | `eval` or `new Function` in hand-written script |
70
+ | MCPAPP014 | html-form-action | note | a form that posts somewhere |
71
+ | MCPAPP015 | html-external-host | note | scripts, styles, images or media from external hosts |
72
+ | MCPAPP016 | html-sink-host | error | a URL in the document points at a sink host |
73
+ | MCPAPP017 | meta-list-read-mismatch | warning | `resources/read` declares a different policy than `resources/list` |
74
+ | MCPAPP018 | meta-read-wider-than-list | error | the read-time policy adds domains or wildcards the listing did not declare |
75
+
76
+ Hand-written means an inline script with short lines and not too many of them; bundles carry
77
+ framework internals and the SDK bridge (which posts to `'*'` by design) and are not reported
78
+ for MCPAPP010, MCPAPP011 and MCPAPP013.
79
+
80
+ The rules come out of the [MCP Apps UI census](../../census) in this repository; the analyzer is
81
+ shared. The HTML sink detection is `eslint-plugin-render-policy`'s `no-unsafe-innerhtml`. The sink
82
+ denylist is `@render-policy/core`'s. The package also exports the analysis functions (`analyzeHtml`,
83
+ `analyzeUiMeta`, `compareListRead`, `scanPackageDir`, …) and `categorizeDomain`, the heuristic host
84
+ category (fonts, analytics, maps, storage, media, cdn, api, …) the census report tabulates; it is
85
+ data for aggregates, not a rule.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.mjs';
3
+
4
+ run(process.argv.slice(2)).then(
5
+ (code) => process.exit(code),
6
+ (error) => {
7
+ process.stderr.write(`mcp-app-lint: ${error.message}\n`);
8
+ process.exit(2);
9
+ },
10
+ );
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "mcp-app-lint",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "SARIF findings about what an MCP App declares (CSP domain lists, permissions, tool visibility) and what its HTML does (innerHTML sinks, postMessage to '*', external hosts, data sinks).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "mcp-app-lint": "./bin/mcp-app-lint.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.mjs"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test test/*.test.mjs"
20
+ },
21
+ "engines": {
22
+ "node": ">=22.12"
23
+ },
24
+ "dependencies": {
25
+ "@render-policy/core": "^0.1.0",
26
+ "eslint": "^10.11.0",
27
+ "eslint-plugin-render-policy": "^0.1.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "keywords": [
33
+ "mcp",
34
+ "mcp-apps",
35
+ "sarif",
36
+ "security",
37
+ "csp",
38
+ "lint"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/shteynu/render-policy.git",
43
+ "directory": "packages/mcp-app-lint"
44
+ }
45
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Pure analysis over what a server or package declares: the `_meta.ui` of its UI resources,
3
+ * its tools, and the HTML of the resources themselves. No network here.
4
+ */
5
+ import { createRequire } from 'node:module';
6
+ import { classifyDomains } from './domains.mjs';
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ const CSP_KEYS = ['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'];
11
+ const PERMISSION_KEYS = ['camera', 'microphone', 'geolocation', 'clipboardWrite'];
12
+ export const UI_MIME = 'text/html;profile=mcp-app';
13
+
14
+ /** `_meta.ui` in the modern shape, or the legacy flat `ui/...` keys folded into one object. */
15
+ export function uiMetaOf(meta) {
16
+ if (!meta || typeof meta !== 'object') return null;
17
+ const modern = meta.ui && typeof meta.ui === 'object' ? { ...meta.ui } : {};
18
+ for (const [key, value] of Object.entries(meta)) {
19
+ if (key.startsWith('ui/')) modern[key.slice(3)] = value;
20
+ }
21
+ return Object.keys(modern).length > 0 ? modern : null;
22
+ }
23
+
24
+ export function analyzeUiMeta(meta) {
25
+ const ui = uiMetaOf(meta);
26
+ const csp = ui?.csp && typeof ui.csp === 'object' ? ui.csp : null;
27
+ const domains = {};
28
+ for (const key of CSP_KEYS) domains[key] = classifyDomains(csp?.[key]);
29
+ const permissions = ui?.permissions && typeof ui.permissions === 'object' ? PERMISSION_KEYS.filter((k) => k in ui.permissions) : [];
30
+ return {
31
+ hasUiMeta: ui !== null,
32
+ cspDeclared: csp !== null,
33
+ cspEmpty: csp !== null && CSP_KEYS.every((k) => !Array.isArray(csp[k]) || csp[k].length === 0),
34
+ domains,
35
+ anyWildcard: CSP_KEYS.some((k) => domains[k].wildcards > 0),
36
+ anyFullWildcard: CSP_KEYS.some((k) => domains[k].fullWildcards > 0),
37
+ anyInsecure: CSP_KEYS.some((k) => domains[k].insecure > 0),
38
+ sinks: CSP_KEYS.flatMap((k) => domains[k].sinks),
39
+ permissions,
40
+ domain: typeof ui?.domain === 'string' ? ui.domain : null,
41
+ prefersBorder: ui?.prefersBorder === true,
42
+ };
43
+ }
44
+
45
+ function canonical(value) {
46
+ if (Array.isArray(value)) return value.map(canonical);
47
+ if (value && typeof value === 'object') {
48
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
49
+ }
50
+ return value;
51
+ }
52
+
53
+ /** Does `resources/read` declare a different policy than `resources/list`? The host that checked list is then wrong. */
54
+ export function compareListRead(listMeta, readMeta) {
55
+ const a = uiMetaOf(listMeta);
56
+ const b = uiMetaOf(readMeta);
57
+ if (!a && !b) return { comparable: false, mismatch: false };
58
+ const pick = (m) => (m ? { csp: m.csp ?? null, permissions: m.permissions ?? null, domain: m.domain ?? null } : null);
59
+ const left = JSON.stringify(canonical(pick(a)));
60
+ const right = JSON.stringify(canonical(pick(b)));
61
+ const readWider = (() => {
62
+ if (!a?.csp || !b?.csp) return false;
63
+ return CSP_KEYS.some((k) => (b.csp[k]?.length ?? 0) > (a.csp[k]?.length ?? 0) || (b.csp[k] ?? []).some((d) => String(d).includes('*') && !(a.csp[k] ?? []).includes(d)));
64
+ })();
65
+ return { comparable: true, mismatch: left !== right, readWider, listOnly: !!a && !b, readOnly: !a && !!b };
66
+ }
67
+
68
+ export function analyzeTools(tools) {
69
+ const list = Array.isArray(tools) ? tools : [];
70
+ let withUi = 0;
71
+ let visibleToApp = 0;
72
+ let sideEffectsVisibleToApp = 0;
73
+ let appOnly = 0;
74
+ for (const tool of list) {
75
+ const ui = uiMetaOf(tool?._meta);
76
+ if (!ui) continue;
77
+ if (ui.resourceUri) withUi += 1;
78
+ const visibility = Array.isArray(ui.visibility) ? ui.visibility : ['model', 'app'];
79
+ const app = visibility.includes('app');
80
+ if (app) visibleToApp += 1;
81
+ if (app && !visibility.includes('model')) appOnly += 1;
82
+ const readOnly = tool?.annotations?.readOnlyHint === true;
83
+ if (app && !readOnly) sideEffectsVisibleToApp += 1;
84
+ }
85
+ return { total: list.length, withUi, visibleToApp, appOnly, sideEffectsVisibleToApp };
86
+ }
87
+
88
+ let linter = null;
89
+ function lintInlineScript(code) {
90
+ if (!linter) {
91
+ const { Linter } = require('eslint');
92
+ const plugin = require('eslint-plugin-render-policy').default;
93
+ const instance = new Linter({ configType: 'flat' });
94
+ const config = [
95
+ {
96
+ files: ['**/*.js'],
97
+ plugins: { 'render-policy': plugin },
98
+ rules: { 'render-policy/no-unsafe-innerhtml': 'error' },
99
+ languageOptions: { ecmaVersion: 2024, sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true } } },
100
+ },
101
+ ];
102
+ linter = (source) => instance.verify(source, config, { filename: 'inline.js' });
103
+ }
104
+ return linter(code);
105
+ }
106
+
107
+ const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
108
+ const URL_ATTR_RE = /\b(?:src|href|action|poster|data)\s*=\s*["']([^"']+)["']/gi;
109
+
110
+ /** Static signals in a UI resource's HTML. Heuristic by design; the report says so. */
111
+ export function analyzeHtml(html) {
112
+ const text = String(html ?? '');
113
+ const result = {
114
+ bytes: text.length,
115
+ inlineScripts: 0,
116
+ handwrittenScripts: 0,
117
+ externalScripts: [],
118
+ unsafeInnerHtml: 0,
119
+ unsafeInnerHtmlHandwritten: 0,
120
+ evalLikeHandwritten: 0,
121
+ postMessageStarHandwritten: 0,
122
+ lintErrors: 0,
123
+ unparsedScripts: 0,
124
+ inlineHandlers: (text.match(/\son[a-z]+\s*=/gi) ?? []).length,
125
+ sinks: [],
126
+ evalLike: (text.match(/\beval\s*\(|new\s+Function\s*\(/g) ?? []).length,
127
+ postMessageStar: (text.match(/postMessage\s*\([^)]*['"]\*['"]/g) ?? []).length,
128
+ forms: (text.match(/<form\b/gi) ?? []).length,
129
+ formsWithAction: (text.match(/<form\b[^>]*\baction\s*=/gi) ?? []).length,
130
+ metaCsp: /<meta\b[^>]*http-equiv\s*=\s*["']content-security-policy["']/i.test(text),
131
+ externalHosts: [],
132
+ sinkHosts: [],
133
+ };
134
+ const hosts = new Set();
135
+ for (const match of text.matchAll(URL_ATTR_RE)) {
136
+ const value = match[1];
137
+ if (!/^(https?:)?\/\//i.test(value)) continue;
138
+ try {
139
+ hosts.add(new URL(value.startsWith('//') ? `https:${value}` : value).hostname.toLowerCase());
140
+ } catch {
141
+ /* ignore */
142
+ }
143
+ }
144
+ result.externalHosts = [...hosts].sort();
145
+ for (const match of text.matchAll(SCRIPT_RE)) {
146
+ const attrs = match[1] ?? '';
147
+ const body = match[2] ?? '';
148
+ const src = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(attrs)?.[1];
149
+ if (src) {
150
+ result.externalScripts.push(src);
151
+ continue;
152
+ }
153
+ const type = /\btype\s*=\s*["']([^"']+)["']/i.exec(attrs)?.[1]?.toLowerCase();
154
+ if (type && !/javascript|module|ecmascript/.test(type)) continue;
155
+ result.inlineScripts += 1;
156
+ // A bundled or minified script mostly carries framework internals; "handwritten" scripts
157
+ // (short lines, few of them) are what the app author wrote and can change.
158
+ const handwritten = isHandwritten(body);
159
+ if (handwritten) {
160
+ result.handwrittenScripts += 1;
161
+ result.evalLikeHandwritten += (body.match(/\beval\s*\(|new\s+Function\s*\(/g) ?? []).length;
162
+ result.postMessageStarHandwritten += (body.match(/postMessage\s*\([^)]*['"]\*['"]/g) ?? []).length;
163
+ }
164
+ try {
165
+ const messages = lintInlineScript(body);
166
+ const fatal = messages.filter((m) => m.fatal);
167
+ if (fatal.length > 0) {
168
+ result.unparsedScripts += 1;
169
+ continue;
170
+ }
171
+ const hits = messages.filter((m) => m.ruleId === 'render-policy/no-unsafe-innerhtml');
172
+ result.unsafeInnerHtml += hits.length;
173
+ if (handwritten) result.unsafeInnerHtmlHandwritten += hits.length;
174
+ const scriptLine = text.slice(0, match.index).split('\n').length + (match[0].slice(0, match[0].indexOf('>') + 1).split('\n').length - 1);
175
+ for (const hit of hits) result.sinks.push({ line: scriptLine + hit.line - 1, column: hit.column, handwritten, message: hit.message });
176
+ } catch {
177
+ result.unparsedScripts += 1;
178
+ }
179
+ }
180
+ return result;
181
+ }
182
+
183
+ /** Short lines and not too many of them: written by a person, not emitted by a bundler. */
184
+ export function isHandwritten(code) {
185
+ const lines = code.split('\n');
186
+ const longest = Math.max(0, ...lines.map((l) => l.length));
187
+ const average = code.length / Math.max(1, lines.length);
188
+ return longest < 2000 && average < 200 && lines.length < 3000;
189
+ }
190
+
191
+ /** Everything the HTML analysis reports about hosts, joined with the sink denylist. */
192
+ export function withSinkHosts(analysis) {
193
+ const { sinkFor } = require('./domains.mjs');
194
+ return { ...analysis, sinkHosts: analysis.externalHosts.filter((h) => sinkFor(h) !== null) };
195
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * mcp-app-lint: SARIF findings about what an MCP App declares and what its HTML does.
3
+ *
4
+ * mcp-app-lint --dir ./my-server # a package directory (built or source)
5
+ * mcp-app-lint --package @scope/name # an npm package (downloaded, scanned, deleted)
6
+ * mcp-app-lint --read read.json [--list list.json] [--tools tools.json]
7
+ * # results of resources/read, resources/list, tools/list
8
+ * mcp-app-lint --html app.html # one UI document
9
+ * Options: --out file.sarif, --format sarif|text (default sarif), --fail-on error|warning|note|none (default error)
10
+ */
11
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { UI_MIME } from './analyze.mjs';
15
+ import { lintHtml, lintPackageScan, lintResource, lintTools, levelOf } from './lint.mjs';
16
+ import { packageMeta, scanPackage, scanPackageDir } from './npm.mjs';
17
+ import { toSarif, toText } from './sarif.mjs';
18
+
19
+ const LEVEL_RANK = { error: 3, warning: 2, note: 1, none: 0 };
20
+
21
+ function parseArgs(argv) {
22
+ const options = { format: 'sarif', failOn: 'error' };
23
+ for (let i = 0; i < argv.length; i += 1) {
24
+ const arg = argv[i];
25
+ const next = () => argv[++i];
26
+ if (arg === '--dir') options.dir = next();
27
+ else if (arg === '--package') options.package = next();
28
+ else if (arg === '--read') options.read = next();
29
+ else if (arg === '--list') options.list = next();
30
+ else if (arg === '--tools') options.tools = next();
31
+ else if (arg === '--html') options.html = next();
32
+ else if (arg === '--out') options.out = next();
33
+ else if (arg === '--format') options.format = next();
34
+ else if (arg === '--fail-on') options.failOn = next();
35
+ else if (arg === '--help' || arg === '-h') options.help = true;
36
+ else throw new Error(`unknown argument ${arg}`);
37
+ }
38
+ return options;
39
+ }
40
+
41
+ const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'));
42
+ const isUiResource = (r) => (r?.mimeType ?? '').startsWith('text/html') || String(r?.uri ?? '').startsWith('ui://');
43
+
44
+ /** A filesystem path under the working directory becomes a forward-slash repo-relative uri; anything else is unchanged. */
45
+ function relativeUri(uri) {
46
+ if (typeof uri !== 'string' || !path.isAbsolute(uri)) return uri;
47
+ const rel = path.relative(process.cwd(), uri);
48
+ return rel && !rel.startsWith('..') ? rel.split(path.sep).join('/') : uri;
49
+ }
50
+
51
+ export async function run(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
52
+ const options = parseArgs(argv);
53
+ if (options.help || (!options.dir && !options.package && !options.read && !options.html && !options.tools)) {
54
+ stderr.write('usage: mcp-app-lint (--dir <path> | --package <name> | --read <read.json> [--list <list.json>] | --html <file>) [--tools <tools.json>] [--out file] [--format sarif|text] [--fail-on error|warning|note|none]\n');
55
+ return 2;
56
+ }
57
+ const findings = [];
58
+
59
+ if (options.dir) {
60
+ const scan = await scanPackageDir(path.resolve(options.dir));
61
+ findings.push(...lintPackageScan(scan, options.dir));
62
+ }
63
+ if (options.package) {
64
+ const meta = await packageMeta(options.package);
65
+ if (!meta.tarball) throw new Error(`${options.package}: no tarball`);
66
+ const work = path.join(os.tmpdir(), 'mcp-app-lint');
67
+ await mkdir(work, { recursive: true });
68
+ try {
69
+ const scan = await scanPackage(meta, work);
70
+ findings.push(...lintPackageScan(scan, `npm:${meta.name}@${meta.version}`));
71
+ } finally {
72
+ await rm(work, { recursive: true, force: true });
73
+ }
74
+ }
75
+ if (options.read) {
76
+ const read = await readJson(options.read);
77
+ const contents = Array.isArray(read?.contents) ? read.contents : Array.isArray(read) ? read : [read];
78
+ const listing = options.list ? await readJson(options.list) : null;
79
+ const listEntries = Array.isArray(listing?.resources) ? listing.resources : Array.isArray(listing) ? listing : listing ? [listing] : [];
80
+ for (const item of contents) {
81
+ if (!isUiResource(item) && item.mimeType !== UI_MIME) continue;
82
+ const entry = listEntries.find((r) => r.uri === item.uri) ?? null;
83
+ findings.push(...lintResource({ uri: item.uri ?? options.read, listMeta: entry?._meta ?? null, readMeta: item._meta ?? null, html: typeof item.text === 'string' ? item.text : null }));
84
+ }
85
+ }
86
+ if (options.html) {
87
+ findings.push(...lintHtml(await readFile(options.html, 'utf8'), options.html));
88
+ }
89
+ if (options.tools) {
90
+ const tools = await readJson(options.tools);
91
+ findings.push(...lintTools(Array.isArray(tools?.tools) ? tools.tools : tools, options.tools));
92
+ }
93
+
94
+ // Code scanning maps a SARIF location by a repo-relative path. A filesystem uri under the
95
+ // working directory is made relative; ui://, npm: and tools/list locations are left as they are.
96
+ const located = findings.map((f) => ({ ...f, uri: relativeUri(f.uri) }));
97
+
98
+ const output = options.format === 'text' ? toText(located) : `${JSON.stringify(toSarif(located, { invocation: { commandLine: `mcp-app-lint ${argv.join(' ')}` } }), null, 2)}\n`;
99
+ if (options.out) await writeFile(options.out, output);
100
+ else stdout.write(output);
101
+
102
+ const threshold = LEVEL_RANK[options.failOn] ?? 3;
103
+ const worst = Math.max(0, ...located.map((f) => LEVEL_RANK[f.level ?? levelOf(f.ruleId)] ?? 0));
104
+ return threshold > 0 && worst >= threshold ? 1 : 0;
105
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Classification of the domain patterns MCP Apps declare in `_meta.ui.csp`
3
+ * (connectDomains, resourceDomains, frameDomains, baseUriDomains). A pattern may be a bare
4
+ * host, a host with a scheme, a scheme alone, or carry a wildcard. Sink hosts come from the
5
+ * render-policy denylist: services that exist to receive whatever is sent to them.
6
+ */
7
+ import { SINK_DENYLIST } from '@render-policy/core';
8
+
9
+ /** Schemes that never name a network host; alone they widen nothing. */
10
+ const LOCAL_SCHEMES = new Set(['blob', 'data', 'filesystem', 'mediastream', 'about']);
11
+ /** Schemes whose transport is cleartext. */
12
+ const INSECURE_SCHEMES = new Set(['http', 'ws']);
13
+
14
+ const SCHEME_ONLY_RE = /^([a-z][a-z0-9+.-]*):$/i;
15
+ const SCHEME_HOST_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
16
+ const DEV_HOST_RE = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|.*\.localhost)$/i;
17
+
18
+ export function parseDomainPattern(raw) {
19
+ const value = String(raw ?? '').trim();
20
+ const schemeOnly = SCHEME_ONLY_RE.exec(value);
21
+ const schemeHost = schemeOnly ? null : SCHEME_HOST_RE.exec(value);
22
+ const scheme = (schemeOnly ?? schemeHost)?.[1]?.toLowerCase() ?? null;
23
+ const withoutScheme = schemeOnly ? '' : value.slice(schemeHost?.[0].length ?? 0);
24
+ const hostPort = withoutScheme.split('/')[0] ?? '';
25
+ const host = (hostPort.startsWith('[') ? hostPort.slice(0, hostPort.indexOf(']') + 1) : hostPort.split(':')[0]).toLowerCase();
26
+ const localScheme = scheme !== null && LOCAL_SCHEMES.has(scheme);
27
+ const wildcard = host.includes('*');
28
+ // A bare '*' or a scheme-source such as 'https:' expands to a CSP source matching every host.
29
+ const full = host === '*' || (schemeOnly !== null && !localScheme);
30
+ const leadingWildcard = host.startsWith('*.');
31
+ const bareHost = leadingWildcard ? host.slice(2) : host.replace(/\*/g, '');
32
+ return {
33
+ raw: value,
34
+ scheme,
35
+ schemeOnly: schemeOnly !== null,
36
+ localScheme,
37
+ development: localScheme || DEV_HOST_RE.test(host),
38
+ host,
39
+ wildcard,
40
+ full,
41
+ leadingWildcard,
42
+ bareHost,
43
+ insecureScheme: scheme !== null && INSECURE_SCHEMES.has(scheme),
44
+ };
45
+ }
46
+
47
+ /** The sink denylist entry a host pattern falls under, or null. */
48
+ export function sinkFor(host) {
49
+ const candidate = String(host ?? '').toLowerCase().replace(/^\*\./, '');
50
+ if (!candidate) return null;
51
+ for (const entry of SINK_DENYLIST.entries) {
52
+ const hostPattern = entry.pattern.split('/')[0].toLowerCase();
53
+ if (candidate === hostPattern || candidate.endsWith(`.${hostPattern}`)) return entry;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Coarse categories for the hosts apps declare or load from. Heuristic, by well-known hosts
60
+ * and naming conventions; a host that fits several falls into the first one listed. Used for
61
+ * aggregates only, so a misfiled host moves a count by one and names nothing.
62
+ */
63
+ export const DOMAIN_CATEGORIES = ['every-host', 'development', 'sink', 'fonts', 'analytics', 'maps', 'storage', 'media', 'cdn', 'api', 'other'];
64
+
65
+ const HOST_RULES = [
66
+ ['fonts', /^(fonts\.googleapis\.com|fonts\.gstatic\.com|(use|p)\.typekit\.net|fonts\.bunny\.net|rsms\.me|(use|kit|ka-f)\.fontawesome\.com|fonts\.cdnfonts\.com)$/],
67
+ ['analytics', /(^|\.)(google-analytics\.com|googletagmanager\.com|analytics\.google\.com|doubleclick\.net|segment\.(io|com)|mixpanel\.com|amplitude\.com|plausible\.io|posthog\.com|sentry\.io|hotjar\.com|clarity\.ms|newrelic\.com|nr-data\.net|datadoghq-browser-agent\.com|browser-intake-datadoghq\.com|events\.mapbox\.com|fullstory\.com|heap\.io|intercom\.io|launchdarkly\.com)$/],
68
+ ['maps', /(^|\.)(mapbox\.com|openstreetmap\.org|maptiler\.com|arcgis\.com|here\.com|tomtom\.com|geojson\.io|cartocdn\.com|stadiamaps\.com|maps\.googleapis\.com|maps\.gstatic\.com|places\.googleapis\.com)$/],
69
+ ['storage', /(^|\.)(storage\.googleapis\.com|firebasestorage\.googleapis\.com|amazonaws\.com|blob\.core\.windows\.net|myqcloud\.com|aliyuncs\.com|r2\.dev|r2\.cloudflarestorage\.com|digitaloceanspaces\.com|backblazeb2\.com|supabase\.co|storage\.yandexcloud\.net)$|^(s3|storage|bucket|blob|files|uploads)\./],
70
+ ['media', /(^|\.)(googleusercontent\.com|scdn\.co|spotifycdn\.com|licdn\.com|cdninstagram\.com|fbcdn\.net|tiktokcdn(-[a-z]+)?\.com|media-amazon\.com|ssl-images-amazon\.com|ytimg\.com|imgur\.com|giphy\.com|unsplash\.com|pexels\.com|gravatar\.com|ui-avatars\.com|replicate\.delivery|fal\.media|twimg\.com|wp\.com|slack-edge\.com)$|^(img|imgs|image|images|media|avatars?|photos?|pics?|icons?|thumbs?)(-[a-z0-9]+)?\./],
71
+ ['cdn', /(^|\.)(unpkg\.com|jsdelivr\.net|cdnjs\.cloudflare\.com|esm\.sh|esm\.run|skypack\.dev|jspm\.io|githubusercontent\.com|github\.io|gitlab\.io|cdn\.tailwindcss\.com|code\.jquery\.com|bootstrapcdn\.com|ajax\.googleapis\.com|gstatic\.com|cloudfront\.net|akamaihd\.net|akamaized\.net|fastly\.net|azureedge\.net|cdn\.socket\.io|polyfill\.io|vercel\.app|netlify\.app|pages\.dev)$|^(cdn|static|assets|s)\d*(-[a-z0-9]+)?\.|cdn/],
72
+ ['api', /^(api|apis|rest|graphql|gateway)(-[a-z0-9]+)?\d*\.|\.api\.|(^|\.)googleapis\.com$/],
73
+ ];
74
+
75
+ /** Category of a parsed domain pattern (see parseDomainPattern). */
76
+ export function categorizeDomain(pattern) {
77
+ const p = typeof pattern === 'string' ? parseDomainPattern(pattern) : pattern;
78
+ if (p.full) return 'every-host';
79
+ if (p.development) return 'development';
80
+ if (sinkFor(p.bareHost)) return 'sink';
81
+ for (const [category, re] of HOST_RULES) if (re.test(p.bareHost)) return category;
82
+ return 'other';
83
+ }
84
+
85
+ /** Category of a bare host (as found in HTML: script src, img src, …). */
86
+ export function categorizeHost(host) {
87
+ return categorizeDomain(parseDomainPattern(String(host ?? '').includes('://') ? host : `https://${host}`));
88
+ }
89
+
90
+ export function classifyDomains(list) {
91
+ const patterns = (Array.isArray(list) ? list : []).map(parseDomainPattern);
92
+ return {
93
+ count: patterns.length,
94
+ wildcards: patterns.filter((p) => p.wildcard).length,
95
+ fullWildcards: patterns.filter((p) => p.full).length,
96
+ insecure: patterns.filter((p) => p.insecureScheme).length,
97
+ sinks: patterns.map((p) => sinkFor(p.bareHost)).filter(Boolean).map((e) => e.category),
98
+ hosts: patterns.map((p) => p.host),
99
+ categories: patterns.map(categorizeDomain),
100
+ };
101
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ export { analyzeHtml, analyzeTools, analyzeUiMeta, compareListRead, isHandwritten, UI_MIME, uiMetaOf, withSinkHosts } from './analyze.mjs';
2
+ export { categorizeDomain, categorizeHost, classifyDomains, DOMAIN_CATEGORIES, parseDomainPattern, sinkFor } from './domains.mjs';
3
+ export { levelOf, lintHtml, lintPackageScan, lintResource, lintTools, lintUiMeta } from './lint.mjs';
4
+ export { downloadTarball, extractTarball, packageMeta, scanPackage, scanPackageDir, searchPackages, UI_SDKS } from './npm.mjs';
5
+ export { RULES, RULE_INDEX, ruleByName } from './rules.mjs';
6
+ export { toSarif, toText, TOOL_VERSION } from './sarif.mjs';
7
+ export { run } from './cli.mjs';
package/src/lint.mjs ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * From analysis to findings. A finding is { ruleId, level?, message, uri, region?, logical?, properties? }.
3
+ */
4
+ import { analyzeHtml, analyzeTools, analyzeUiMeta, compareListRead, uiMetaOf, withSinkHosts } from './analyze.mjs';
5
+ import { parseDomainPattern, sinkFor } from './domains.mjs';
6
+ import { RULES } from './rules.mjs';
7
+
8
+ const DEV_HOST_RE = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])$/i;
9
+ const CSP_KEYS = ['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'];
10
+
11
+ const finding = (ruleId, message, uri, extra = {}) => ({ ruleId, message, uri, ...extra });
12
+
13
+ /** Findings about one `_meta.ui` object (the resolved one: read, falling back to list). */
14
+ export function lintUiMeta(meta, uri) {
15
+ const findings = [];
16
+ const ui = uiMetaOf(meta);
17
+ const analysis = analyzeUiMeta(meta);
18
+ if (!analysis.cspDeclared) {
19
+ findings.push(finding('MCPAPP001', 'No ui.csp declared; a compliant host applies default-src \'none\'.', uri));
20
+ } else {
21
+ for (const key of CSP_KEYS) {
22
+ for (const raw of ui?.csp?.[key] ?? []) {
23
+ const pattern = parseDomainPattern(raw);
24
+ const logical = { name: `csp.${key}`, kind: 'member' };
25
+ if (pattern.full) findings.push(finding('MCPAPP002', `${key} entry "${raw}" allows every host.`, uri, { logical }));
26
+ else if (pattern.wildcard) findings.push(finding('MCPAPP003', `${key} entry "${raw}" allows every subdomain.`, uri, { logical }));
27
+ if (pattern.insecureScheme) findings.push(finding('MCPAPP004', `${key} entry "${raw}" uses ${pattern.scheme}:.`, uri, { logical }));
28
+ const sink = sinkFor(pattern.bareHost);
29
+ if (sink) findings.push(finding('MCPAPP005', `${key} entry "${raw}" is on the sink denylist (${sink.category}: ${sink.pattern}).`, uri, { logical }));
30
+ if (DEV_HOST_RE.test(pattern.host) || pattern.scheme === 'blob') findings.push(finding('MCPAPP006', `${key} entry "${raw}" is a development origin.`, uri, { logical }));
31
+ }
32
+ }
33
+ }
34
+ for (const permission of analysis.permissions) {
35
+ if (permission !== 'clipboardWrite') findings.push(finding('MCPAPP007', `Requests the ${permission} permission.`, uri, { logical: { name: `permissions.${permission}`, kind: 'member' } }));
36
+ }
37
+ return findings;
38
+ }
39
+
40
+ /** Findings about a UI resource as served: listing entry, read result content item, HTML. */
41
+ export function lintResource({ uri, listMeta = null, readMeta = null, html = null }) {
42
+ const findings = [];
43
+ const effective = readMeta ?? listMeta;
44
+ findings.push(...lintUiMeta(effective, uri));
45
+ if (listMeta && readMeta) {
46
+ const compared = compareListRead(listMeta, readMeta);
47
+ if (compared.readWider) findings.push(finding('MCPAPP018', 'resources/read declares a wider policy than resources/list.', uri));
48
+ else if (compared.mismatch) findings.push(finding('MCPAPP017', 'resources/read and resources/list declare different policies.', uri));
49
+ }
50
+ if (typeof html === 'string') findings.push(...lintHtml(html, uri));
51
+ return findings;
52
+ }
53
+
54
+ /** Findings about the HTML of a UI resource. */
55
+ export function lintHtml(html, uri) {
56
+ return htmlFindings(withSinkHosts(analyzeHtml(html)), uri);
57
+ }
58
+
59
+ /** The HTML rules over one analysis, from a live document or a stored scan. */
60
+ function htmlFindings(a, uri) {
61
+ const findings = [];
62
+ if (Array.isArray(a.sinks)) {
63
+ for (const sink of a.sinks.filter((s) => s.handwritten)) findings.push(finding('MCPAPP010', sink.message, uri, { region: { startLine: sink.line, startColumn: sink.column } }));
64
+ } else if ((a.unsafeInnerHtmlHandwritten ?? 0) > 0) {
65
+ // A scan from before sinks carried positions: one finding per document.
66
+ findings.push(finding('MCPAPP010', `${a.unsafeInnerHtmlHandwritten} dynamic HTML sink(s) in hand-written script.`, uri));
67
+ }
68
+ if ((a.postMessageStarHandwritten ?? 0) > 0) findings.push(finding('MCPAPP011', `${a.postMessageStarHandwritten} hand-written postMessage(…, '*') call(s).`, uri));
69
+ if ((a.inlineHandlers ?? 0) > 0) findings.push(finding('MCPAPP012', `${a.inlineHandlers} inline event handler attribute(s).`, uri));
70
+ if ((a.evalLikeHandwritten ?? 0) > 0) findings.push(finding('MCPAPP013', `${a.evalLikeHandwritten} eval/new Function use(s) in hand-written script.`, uri));
71
+ if ((a.formsWithAction ?? 0) > 0) findings.push(finding('MCPAPP014', `${a.formsWithAction} form(s) with an action.`, uri));
72
+ if ((a.externalHosts?.length ?? 0) > 0) findings.push(finding('MCPAPP015', `References external hosts: ${a.externalHosts.join(', ')}.`, uri, { properties: { hosts: a.externalHosts } }));
73
+ for (const host of a.sinkHosts ?? []) findings.push(finding('MCPAPP016', `References sink host ${host}.`, uri));
74
+ return findings;
75
+ }
76
+
77
+ /** Findings about a tools/list result. */
78
+ export function lintTools(tools, uri = 'tools/list') {
79
+ const findings = [];
80
+ for (const tool of Array.isArray(tools) ? tools : []) {
81
+ const ui = uiMetaOf(tool?._meta);
82
+ if (!ui) continue;
83
+ const explicit = Array.isArray(ui.visibility);
84
+ const visibility = explicit ? ui.visibility : ['model', 'app'];
85
+ const logical = { name: tool.name ?? '?', kind: 'function' };
86
+ if (!explicit && ui.resourceUri) findings.push(finding('MCPAPP009', `Tool "${tool.name}" relies on the default visibility ["model", "app"].`, uri, { logical }));
87
+ if (visibility.includes('app') && tool?.annotations?.readOnlyHint !== true) {
88
+ findings.push(finding('MCPAPP008', `Tool "${tool.name}" has no readOnlyHint and is callable by the app.`, uri, { logical }));
89
+ }
90
+ }
91
+ return findings;
92
+ }
93
+
94
+ /** Findings about a scanned package directory (see scanPackageDir). */
95
+ export function lintPackageScan(scan, root = '.') {
96
+ const findings = [];
97
+ if (scan.declaresUi) {
98
+ const fake = { ui: { csp: scan.cspDeclared ? Object.fromEntries(Object.entries(scan.domains).filter(([, v]) => v.length > 0)) : undefined, permissions: Object.fromEntries(scan.permissions.map((p) => [p, {}])) } };
99
+ if (!scan.cspDeclared) delete fake.ui.csp;
100
+ findings.push(...lintUiMeta(fake, root));
101
+ }
102
+ for (const doc of scan.html ?? []) {
103
+ findings.push(...htmlFindings(doc, `${root}/${doc.file}`.replace(/^\.\//, '')));
104
+ }
105
+ return findings;
106
+ }
107
+
108
+ export function levelOf(ruleId) {
109
+ return RULES.find((r) => r.id === ruleId)?.level ?? 'warning';
110
+ }
111
+
112
+ export { analyzeTools };
package/src/npm.mjs ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * npm as a source: keyword search, package metadata, tarballs downloaded with a size cap and
3
+ * scanned for what the package declares about its UI resources. Public data only.
4
+ */
5
+ import { execFile } from 'node:child_process';
6
+ import { createWriteStream } from 'node:fs';
7
+ import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { Readable } from 'node:stream';
10
+ import { pipeline } from 'node:stream/promises';
11
+ import { promisify } from 'node:util';
12
+ import { analyzeHtml, withSinkHosts } from './analyze.mjs';
13
+ import { classifyDomains } from './domains.mjs';
14
+
15
+ const execFileAsync = promisify(execFile);
16
+ const REGISTRY = 'https://registry.npmjs.org';
17
+ const UA = 'mcp-ui-census/0.1 (+https://github.com/shteynu/render-policy)';
18
+ export const UI_SDKS = ['@modelcontextprotocol/ext-apps', '@mcp-ui/server', '@mcp-ui/client'];
19
+
20
+ async function getJson(url, timeoutMs = 30000) {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
23
+ try {
24
+ const response = await fetch(url, { headers: { accept: 'application/json', 'user-agent': UA }, signal: controller.signal });
25
+ if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status });
26
+ return await response.json();
27
+ } finally {
28
+ clearTimeout(timer);
29
+ }
30
+ }
31
+
32
+ /** All packages a search query returns (the API pages by `from`, 250 per page). */
33
+ export async function searchPackages(text, { max = 2000 } = {}) {
34
+ const names = [];
35
+ for (let from = 0; from < max; from += 250) {
36
+ const data = await getJson(`${REGISTRY}/-/v1/search?text=${encodeURIComponent(text)}&size=250&from=${from}`);
37
+ const objects = Array.isArray(data.objects) ? data.objects : [];
38
+ for (const object of objects) names.push(object.package?.name);
39
+ if (objects.length < 250) break;
40
+ }
41
+ return names.filter(Boolean);
42
+ }
43
+
44
+ /** Latest version manifest (small): dependencies, tarball, size. */
45
+ export async function packageMeta(name) {
46
+ const manifest = await getJson(`${REGISTRY}/${encodeURIComponent(name).replace('%40', '@')}/latest`);
47
+ if (!manifest?.version) throw new Error(`${name}: no latest version`);
48
+ const deps = { ...(manifest.dependencies ?? {}), ...(manifest.peerDependencies ?? {}), ...(manifest.optionalDependencies ?? {}) };
49
+ return {
50
+ name,
51
+ version: manifest.version,
52
+ description: manifest.description ?? '',
53
+ repository: manifest.repository?.url ?? null,
54
+ tarball: manifest.dist?.tarball ?? null,
55
+ unpackedSize: manifest.dist?.unpackedSize ?? null,
56
+ uiSdks: UI_SDKS.filter((sdk) => sdk in deps),
57
+ dependsOnMcpSdk: '@modelcontextprotocol/sdk' in deps,
58
+ };
59
+ }
60
+
61
+ export async function downloadTarball(url, dest, { maxBytes = 25 * 1024 * 1024, timeoutMs = 60000 } = {}) {
62
+ const controller = new AbortController();
63
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
64
+ try {
65
+ const response = await fetch(url, { headers: { 'user-agent': UA }, signal: controller.signal });
66
+ if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
67
+ const length = Number(response.headers.get('content-length') ?? 0);
68
+ if (length > maxBytes) throw Object.assign(new Error(`tarball is ${length} bytes, over the cap`), { kind: 'too-large' });
69
+ let received = 0;
70
+ const capped = Readable.fromWeb(response.body);
71
+ capped.on('data', (chunk) => {
72
+ received += chunk.length;
73
+ if (received > maxBytes) capped.destroy(Object.assign(new Error('tarball exceeded the cap while streaming'), { kind: 'too-large' }));
74
+ });
75
+ await pipeline(capped, createWriteStream(dest));
76
+ return received;
77
+ } finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }
81
+
82
+ export async function extractTarball(file, dir) {
83
+ await mkdir(dir, { recursive: true });
84
+ await execFileAsync('tar', ['-xzf', file, '-C', dir], { maxBuffer: 1024 * 1024 });
85
+ }
86
+
87
+ const SOURCE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.mts', '.cts']);
88
+ const CSP_ARRAY_RE = /\b(connectDomains|resourceDomains|frameDomains|baseUriDomains)\s*:\s*\[([^\]]*)\]/g;
89
+ const PERMISSIONS_RE = /\bpermissions\s*:\s*\{([^}]*)\}/g;
90
+ const VISIBILITY_RE = /\bvisibility\s*:\s*\[([^\]]*)\]/g;
91
+ const EMBEDDED_HTML_RE = /<!doctype html>[\s\S]*?<\/html>/gi;
92
+
93
+ async function* walk(dir, depth = 0) {
94
+ if (depth > 12) return;
95
+ let entries;
96
+ try {
97
+ entries = await readdir(dir, { withFileTypes: true });
98
+ } catch {
99
+ return;
100
+ }
101
+ for (const entry of entries) {
102
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
103
+ const full = path.join(dir, entry.name);
104
+ if (entry.isDirectory()) yield* walk(full, depth + 1);
105
+ else if (entry.isFile()) yield full;
106
+ }
107
+ }
108
+
109
+ const stringLiterals = (text) => [...text.matchAll(/["'`]([^"'`]*)["'`]/g)].map((m) => m[1]).filter(Boolean);
110
+
111
+ /** What an unpacked package says about its UI resources. Regex-based on purpose: a census, not a compiler. */
112
+ export async function scanPackageDir(dir, { maxFiles = 4000, maxFileBytes = 2 * 1024 * 1024 } = {}) {
113
+ const result = {
114
+ files: 0,
115
+ sourceFiles: 0,
116
+ appResourceRegistrations: 0,
117
+ uiMimeMentions: 0,
118
+ uiUriMentions: 0,
119
+ cspArrays: 0,
120
+ domains: { connectDomains: [], resourceDomains: [], frameDomains: [], baseUriDomains: [] },
121
+ permissions: new Set(),
122
+ toolVisibility: { app: 0, model: 0 },
123
+ readOnlyHints: 0,
124
+ html: [],
125
+ embeddedHtml: 0,
126
+ };
127
+ for await (const file of walk(dir)) {
128
+ result.files += 1;
129
+ if (result.files > maxFiles) break;
130
+ const ext = path.extname(file).toLowerCase();
131
+ const isSource = SOURCE_EXT.has(ext);
132
+ const isHtml = ext === '.html' || ext === '.htm';
133
+ if (!isSource && !isHtml) continue;
134
+ const info = await stat(file);
135
+ if (info.size > maxFileBytes) continue;
136
+ const text = await readFile(file, 'utf8').catch(() => '');
137
+ if (isHtml) {
138
+ result.html.push({ file: path.relative(dir, file), ...withSinkHosts(analyzeHtml(text)) });
139
+ continue;
140
+ }
141
+ result.sourceFiles += 1;
142
+ result.appResourceRegistrations += (text.match(/\bregisterAppResource\s*\(/g) ?? []).length;
143
+ result.uiMimeMentions += (text.match(/text\/html;profile=mcp-app/g) ?? []).length;
144
+ result.uiUriMentions += (text.match(/["'`]ui:\/\//g) ?? []).length;
145
+ for (const match of text.matchAll(CSP_ARRAY_RE)) {
146
+ result.cspArrays += 1;
147
+ result.domains[match[1]].push(...stringLiterals(match[2]));
148
+ }
149
+ for (const match of text.matchAll(PERMISSIONS_RE)) {
150
+ for (const key of ['camera', 'microphone', 'geolocation', 'clipboardWrite']) if (new RegExp(`\\b${key}\\b`).test(match[1])) result.permissions.add(key);
151
+ }
152
+ for (const match of text.matchAll(VISIBILITY_RE)) {
153
+ if (/['"]app['"]/.test(match[1])) result.toolVisibility.app += 1;
154
+ if (/['"]model['"]/.test(match[1])) result.toolVisibility.model += 1;
155
+ }
156
+ result.readOnlyHints += (text.match(/readOnlyHint\s*:\s*true/g) ?? []).length;
157
+ for (const match of text.matchAll(EMBEDDED_HTML_RE)) {
158
+ result.embeddedHtml += 1;
159
+ if (result.html.length < 40) result.html.push({ file: `${path.relative(dir, file)}#embedded`, ...withSinkHosts(analyzeHtml(match[0])) });
160
+ }
161
+ }
162
+ const classified = {};
163
+ for (const key of Object.keys(result.domains)) classified[key] = classifyDomains(result.domains[key]);
164
+ return {
165
+ ...result,
166
+ permissions: [...result.permissions],
167
+ domainsClassified: classified,
168
+ declaresUi: result.appResourceRegistrations + result.uiMimeMentions + result.uiUriMentions > 0,
169
+ cspDeclared: result.cspArrays > 0,
170
+ };
171
+ }
172
+
173
+ /** Download, extract, scan, clean up. */
174
+ export async function scanPackage(meta, workDir, options = {}) {
175
+ const safe = meta.name.replace(/[^a-z0-9]+/gi, '_');
176
+ const tgz = path.join(workDir, `${safe}.tgz`);
177
+ const dir = path.join(workDir, safe);
178
+ try {
179
+ const bytes = await downloadTarball(meta.tarball, tgz, options);
180
+ await extractTarball(tgz, dir);
181
+ const scan = await scanPackageDir(dir, options);
182
+ return { ok: true, bytes, ...scan };
183
+ } finally {
184
+ await rm(tgz, { force: true });
185
+ await rm(dir, { recursive: true, force: true });
186
+ }
187
+ }
package/src/rules.mjs ADDED
@@ -0,0 +1,24 @@
1
+ /** The rules, in SARIF terms. Ids are stable; names are what a scanner shows. */
2
+ export const RULES = [
3
+ { id: 'MCPAPP001', name: 'csp-missing', level: 'warning', short: 'UI resource declares no CSP domain list', full: "A compliant host applies default-src 'none' and connect-src 'none' to a resource without ui.csp. Declare the domains the app uses, or confirm it needs none." },
4
+ { id: 'MCPAPP002', name: 'csp-allows-every-host', level: 'error', short: 'CSP entry allows every host', full: "A bare '*' or a scheme-only entry such as 'https:' is expanded by hosts into a CSP source that allows every host, including request catchers and tunnels. Name the hosts." },
5
+ { id: 'MCPAPP003', name: 'csp-wildcard-host', level: 'warning', short: 'CSP entry allows every subdomain', full: 'A *.example.com entry allows every subdomain, including user-controlled ones on multi-tenant services. Prefer exact hosts.' },
6
+ { id: 'MCPAPP004', name: 'csp-insecure-scheme', level: 'warning', short: 'CSP entry uses http:', full: 'An http: origin can be read and modified in transit. Use https:.' },
7
+ { id: 'MCPAPP005', name: 'csp-sink-host', level: 'error', short: 'CSP entry names a data sink', full: 'The host is on a denylist of services that exist to receive data: request catchers, tunnels, anonymous serverless endpoints, form builders, public object storage. An app allowed to reach it can carry tool results out.' },
8
+ { id: 'MCPAPP006', name: 'csp-development-origin', level: 'note', short: 'CSP entry is a development origin', full: 'localhost, 127.0.0.1, 0.0.0.0 or blob: in published metadata is usually a leftover from development.' },
9
+ { id: 'MCPAPP007', name: 'permissions-sensitive', level: 'note', short: 'UI resource requests a sensitive permission', full: 'camera, microphone or geolocation is requested for the sandbox. Hosts should ask the user.' },
10
+ { id: 'MCPAPP008', name: 'tool-side-effects-app-visible', level: 'warning', short: 'Side-effecting tool callable by the app', full: 'A tool without readOnlyHint is visible to the app (visibility includes "app", the default). A compromised or injected app can call it with the user\'s session; hosts should require consent.' },
11
+ { id: 'MCPAPP009', name: 'tool-visibility-implicit', level: 'note', short: 'Tool with UI metadata relies on the default visibility', full: 'visibility defaults to ["model", "app"]. Declare it explicitly so the app-callable surface is a decision, not a default.' },
12
+ { id: 'MCPAPP010', name: 'html-dynamic-innerhtml', level: 'warning', short: 'Inline script assigns dynamic content to an HTML sink', full: "innerHTML, outerHTML, insertAdjacentHTML, setHTMLUnsafe, createContextualFragment or document.write receives a non-static value. With the default script-src 'unsafe-inline', an injected tool result becomes script inside the sandbox. Render through a policy or use textContent." },
13
+ { id: 'MCPAPP011', name: 'html-postmessage-wildcard', level: 'warning', short: "Inline script posts a message to '*'", full: "postMessage(…, '*') delivers the message to whatever embeds the document. The SDK bridge does this by design; hand-written calls may leak data to a different embedder." },
14
+ { id: 'MCPAPP012', name: 'html-inline-handlers', level: 'note', short: 'Inline event handlers in the document', full: 'on* attributes are script; they are also what an injection uses. Attach handlers from a script instead.' },
15
+ { id: 'MCPAPP013', name: 'html-eval', level: 'warning', short: 'Inline script uses eval or new Function', full: 'String evaluation turns any injected value into script.' },
16
+ { id: 'MCPAPP014', name: 'html-form-action', level: 'note', short: 'Form posts to a URL', full: "The specification's CSP has no form-action directive; a form is a way out of the sandbox for data. Check the destination." },
17
+ { id: 'MCPAPP015', name: 'html-external-host', level: 'note', short: 'Document references external hosts', full: 'Scripts, styles, images or media are loaded from hosts outside the document. They must be declared in resourceDomains or the host blocks them.' },
18
+ { id: 'MCPAPP016', name: 'html-sink-host', level: 'error', short: 'Document references a data sink', full: 'A URL in the document points at a service on the sink denylist.' },
19
+ { id: 'MCPAPP017', name: 'meta-list-read-mismatch', level: 'warning', short: 'resources/read declares a different policy than resources/list', full: 'Hosts must prefer the read-time policy. A wider policy at read time means the policy reviewed at connection time is not the one enforced.' },
20
+ { id: 'MCPAPP018', name: 'meta-read-wider-than-list', level: 'error', short: 'resources/read widens the policy seen at resources/list', full: 'The read-time policy adds domains or wildcards the listing did not declare. This is the pattern a host that reviews at connection time cannot see.' },
21
+ ];
22
+
23
+ export const RULE_INDEX = new Map(RULES.map((rule, index) => [rule.id, index]));
24
+ export const ruleByName = (name) => RULES.find((r) => r.name === name);
package/src/sarif.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import { RULES, RULE_INDEX } from './rules.mjs';
2
+ import { levelOf } from './lint.mjs';
3
+
4
+ export const TOOL_VERSION = '0.1.0';
5
+
6
+ /** A SARIF 2.1.0 log with one run. */
7
+ export function toSarif(findings, { invocation = {} } = {}) {
8
+ const results = findings.map((f) => ({
9
+ ruleId: f.ruleId,
10
+ ruleIndex: RULE_INDEX.get(f.ruleId),
11
+ level: f.level ?? levelOf(f.ruleId),
12
+ message: { text: f.message },
13
+ locations: [
14
+ {
15
+ physicalLocation: {
16
+ artifactLocation: { uri: f.uri },
17
+ ...(f.region ? { region: f.region } : {}),
18
+ },
19
+ ...(f.logical ? { logicalLocations: [f.logical] } : {}),
20
+ },
21
+ ],
22
+ ...(f.properties ? { properties: f.properties } : {}),
23
+ }));
24
+ return {
25
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
26
+ version: '2.1.0',
27
+ runs: [
28
+ {
29
+ tool: {
30
+ driver: {
31
+ name: 'mcp-app-lint',
32
+ version: TOOL_VERSION,
33
+ informationUri: 'https://github.com/shteynu/render-policy/tree/main/packages/mcp-app-lint',
34
+ rules: RULES.map((rule) => ({
35
+ id: rule.id,
36
+ name: rule.name,
37
+ shortDescription: { text: rule.short },
38
+ fullDescription: { text: rule.full },
39
+ help: { text: rule.full },
40
+ defaultConfiguration: { level: rule.level },
41
+ properties: { tags: ['security', 'mcp-apps'] },
42
+ })),
43
+ },
44
+ },
45
+ invocations: [{ executionSuccessful: true, ...invocation }],
46
+ results,
47
+ },
48
+ ],
49
+ };
50
+ }
51
+
52
+ export function toText(findings) {
53
+ if (findings.length === 0) return 'no findings\n';
54
+ return `${findings
55
+ .map((f) => `${(f.level ?? levelOf(f.ruleId)).padEnd(7)} ${f.ruleId} ${f.uri}${f.region ? `:${f.region.startLine}:${f.region.startColumn}` : ''} ${f.message}`)
56
+ .join('\n')}\n`;
57
+ }