mcp-app-lint 0.1.2 → 0.1.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 +6 -4
- package/dist/analyze.d.mts +7 -1
- package/dist/analyze.d.mts.map +1 -1
- package/dist/lint.d.mts +4 -1
- package/dist/lint.d.mts.map +1 -1
- package/package.json +2 -2
- package/src/analyze.mjs +32 -20
- package/src/lint.mjs +29 -7
- package/src/rules.mjs +3 -3
- package/src/sarif.mjs +1 -1
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ npx mcp-app-lint --dir . --format sarif --out mcp-app-lint.sarif --fail-on error
|
|
|
54
54
|
|
|
55
55
|
| Id | Name | Level | What it means |
|
|
56
56
|
| --- | --- | --- | --- |
|
|
57
|
-
| MCPAPP001 | csp-missing | warning | no `ui.csp
|
|
57
|
+
| MCPAPP001 | csp-missing | warning | no `ui.csp`, but the UI loads from or calls an external host (an attribute such as `src`/`href`, or an absolute URL in `fetch`, `WebSocket`, `EventSource`, XHR, `sendBeacon`, `import()`); a compliant host applies `default-src 'none'`, `connect-src 'none'` and blocks it. A self-contained UI needs no CSP and is not reported. For a package: it serves a UI resource, no domain list is found (literal or built at runtime), and one of its HTML documents references a host |
|
|
58
58
|
| MCPAPP002 | csp-allows-every-host | error | `*` or a scheme-only entry (`https:`) expands to every host |
|
|
59
59
|
| MCPAPP003 | csp-wildcard-host | warning | `*.example.com` allows every subdomain |
|
|
60
60
|
| MCPAPP004 | csp-insecure-scheme | warning | `http:` origin other than loopback |
|
|
@@ -70,8 +70,8 @@ npx mcp-app-lint --dir . --format sarif --out mcp-app-lint.sarif --fail-on error
|
|
|
70
70
|
| MCPAPP014 | html-form-action | note | a form that posts somewhere |
|
|
71
71
|
| MCPAPP015 | html-external-host | note | scripts, styles, images or media from external hosts |
|
|
72
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`
|
|
74
|
-
| MCPAPP018 | meta-read-wider-than-list | error | the read-time
|
|
73
|
+
| MCPAPP017 | meta-list-read-mismatch | warning | `resources/read` and `resources/list` both declare a field (`csp`, `permissions`, `domain`) with different values; a listing with no CSP is not a mismatch, since the SDK puts the CSP in the read result |
|
|
74
|
+
| MCPAPP018 | meta-read-wider-than-list | error | both declare a CSP and the read-time one adds an entry (domain or wildcard) the listing did not declare under the same key |
|
|
75
75
|
| MCPAPP019 | csp-sink-tenant-host | note | one bucket, worker or app on a storage or serverless service from the denylist (`acct.blob.core.windows.net`, `pub-<id>.r2.dev`); fine while the owner keeps the name |
|
|
76
76
|
|
|
77
77
|
Hand-written means an inline script with short lines and not too many of them; bundles carry
|
|
@@ -82,7 +82,9 @@ In a package scan, domain lists are read where they are written: as a literal ar
|
|
|
82
82
|
constant or shorthand defined in the same file (`resourceDomains: DOMAINS`, `{ connectDomains }`).
|
|
83
83
|
Each CSP finding points at that file and line. A list built at runtime counts as declared, so it
|
|
84
84
|
does not raise MCPAPP001, but its entries cannot be checked. Packages that only mention the MCP
|
|
85
|
-
Apps MIME type or `ui://` (hosts, renderers, SDKs) are not treated as serving a UI resource.
|
|
85
|
+
Apps MIME type or `ui://` (hosts, renderers, SDKs) are not treated as serving a UI resource. Hosts are
|
|
86
|
+
looked for in the package's HTML documents only: a `fetch` in server code is not bound by the CSP,
|
|
87
|
+
so it does not count.
|
|
86
88
|
|
|
87
89
|
The rules come out of the [MCP Apps UI census](../../census) in this repository; the analyzer is
|
|
88
90
|
shared. The HTML sink detection is `eslint-plugin-render-policy`'s `no-unsafe-innerhtml`. The sink
|
package/dist/analyze.d.mts
CHANGED
|
@@ -36,18 +36,23 @@ export declare function analyzeUiMeta(meta: unknown): {
|
|
|
36
36
|
};
|
|
37
37
|
/**
|
|
38
38
|
* Does `resources/read` declare a different policy than `resources/list`? The host that checked list is then wrong.
|
|
39
|
+
* `mismatch` is any difference, for the census. A listing may stay silent: the MCP Apps SDK puts the CSP in the read
|
|
40
|
+
* result only. So `conflict` and `readWider`, which the rules use, compare a field (csp, permissions, domain) only
|
|
41
|
+
* when both sides declare it; `readWider` is a CSP entry at read time that the listing's same key does not have.
|
|
39
42
|
* @param {unknown} listMeta `_meta` of the listing entry
|
|
40
43
|
* @param {unknown} readMeta `_meta` of the read result's content item
|
|
41
44
|
*/
|
|
42
45
|
export declare function compareListRead(listMeta: unknown, readMeta: unknown): {
|
|
43
46
|
comparable: boolean;
|
|
44
47
|
mismatch: boolean;
|
|
45
|
-
|
|
48
|
+
conflict: boolean;
|
|
49
|
+
readWider: boolean;
|
|
46
50
|
listOnly?: undefined;
|
|
47
51
|
readOnly?: undefined;
|
|
48
52
|
} | {
|
|
49
53
|
comparable: boolean;
|
|
50
54
|
mismatch: boolean;
|
|
55
|
+
conflict: boolean;
|
|
51
56
|
readWider: boolean;
|
|
52
57
|
listOnly: boolean;
|
|
53
58
|
readOnly: boolean;
|
|
@@ -130,6 +135,7 @@ export declare function analyzeHtml(html: unknown): {
|
|
|
130
135
|
formsWithAction: number;
|
|
131
136
|
metaCsp: boolean;
|
|
132
137
|
externalHosts: string[];
|
|
138
|
+
networkHosts: string[];
|
|
133
139
|
sinkHosts: string[];
|
|
134
140
|
};
|
|
135
141
|
/**
|
package/dist/analyze.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze.d.mts","sourceRoot":"","sources":["../src/analyze.mjs"],"names":[],"mappings":"AAgBG,YAA+B,MAAM,GAA3B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAQ;AAEpC,YAAoF,MAAM,GAAhF,gBAAgB,GAAG,iBAAiB,GAAG,cAAc,GAAG,gBAAgB,CAAQ;AAK9F,eAAO,MAAM,OAAO,8BAA8B,CAAC;AAEnD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAHlB,OAGkB,GAFhB,MAAM,GAAG,IAAI,CAWzB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAFvB,OAEuB;;;;;;;;;;;;;;;;;;;;;;EAoBjC;AAeD
|
|
1
|
+
{"version":3,"file":"analyze.d.mts","sourceRoot":"","sources":["../src/analyze.mjs"],"names":[],"mappings":"AAgBG,YAA+B,MAAM,GAA3B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAQ;AAEpC,YAAoF,MAAM,GAAhF,gBAAgB,GAAG,iBAAiB,GAAG,cAAc,GAAG,gBAAgB,CAAQ;AAK9F,eAAO,MAAM,OAAO,8BAA8B,CAAC;AAEnD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAHlB,OAGkB,GAFhB,MAAM,GAAG,IAAI,CAWzB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAFvB,OAEuB;;;;;;;;;;;;;;;;;;;;;;EAoBjC;AAeD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAH7B,OAG6B,EAAE,QAAQ,EAFvC,OAEuC;;;;;;;;;;;;;;EAWjD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAFvB,OAEuB;;;;;;EAkBjC;AAED,+FAA+F;AAC/F,eAAO,MAAM,gBAAgB,UAA0G,CAAC;AAyDrI,YAA+D,eAAe,GAApE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAiB;AAI9E,YAAkB,QAAQ,GAC1B;IAAmB,IAAI,EAAZ,MAAM,CACjB;IAAmB,MAAM,EAAd,MAAM,CACjB;;;;IAAoB,WAAW,EAApB,OAAO,CAClB;;;;IAAoB,OAAO,EAAhB,OAAO,CAClB;IAAmB,OAAO,EAAf,MAAM,CACnB;CAAA,CAAA;AAZD;;;GAGG;AACH;;;;;;;;GAQG;AAEH;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAH9B,MAG8B,GAF5B,eAAe,EAAE,CAc7B;AAqBD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAFrB,OAEqB;;;;qBAMA,MAAM,EAAE;;;;;;;kBAOX,KAAK,CAAC,eAAe,GAAG;QAAE,WAAW,EAAE,OAAO,CAAA;KAAE,CAAC;;;;WAIxD,QAAQ,EAAE;;;;;;mBAMF,MAAM,EAAE;kBAET,MAAM,EAAE;eACX,MAAM,EAAE;EAiDjC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAFvB,MAEuB,WAKjC;AAEG,YAA0C,YAAY,GAA5C,UAAU,CAAC,OAAO,WAAW,CAAC,CAAc;AAA1D,6DAA6D;AAE7D;;;;;GAKG;AACH,wBAAgB,aAAa,CAJc,CAAC,SAA9B;IAAE,aAAa,EAAE,MAAM,EAAE,CAAA;CAAK,EAId,QAAQ,EAH3B,CAG2B,GAFzB,CAAC,GAAG;IAAE,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAIvC"}
|
package/dist/lint.d.mts
CHANGED
|
@@ -31,10 +31,13 @@ export type ResourceInput = {
|
|
|
31
31
|
};
|
|
32
32
|
/**
|
|
33
33
|
* Findings about one `_meta` object of a UI resource (the resolved one: read, falling back to list).
|
|
34
|
+
* `hosts` are the hosts the UI references (see lintResource); without a CSP the host blocks them,
|
|
35
|
+
* and that is MCPAPP001. A UI that references none needs no CSP: the restrictive default fits it.
|
|
34
36
|
* @param {unknown} meta
|
|
35
37
|
* @param {string} uri
|
|
38
|
+
* @param {readonly string[]} [hosts]
|
|
36
39
|
*/
|
|
37
|
-
export declare function lintUiMeta(meta: unknown, uri: string): Finding[];
|
|
40
|
+
export declare function lintUiMeta(meta: unknown, uri: string, hosts?: readonly string[]): Finding[];
|
|
38
41
|
/**
|
|
39
42
|
* Findings about a UI resource as served: listing entry, read result content item, HTML.
|
|
40
43
|
* @param {ResourceInput} resource
|
package/dist/lint.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lint.d.mts","sourceRoot":"","sources":["../src/lint.mjs"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAe,YAAY,EAA2D,MAAM,eAAe,CAAC;AAMhH,OAAQ,KAAA,EAAE,WAAW,EAAE,MAAM,WAAW,CACxC;AAAA,OAAQ,KAAA,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAMpC,YAAkB,OAAO,GACzB;IAAmB,MAAM,EAAd,MAAM,CACjB;;;;IAAmB,KAAK,AAAxB,CACA,EADW,KAAK,CAChB;IAAmB,OAAO,EAAf,MAAM,CACjB;IAAmB,GAAG,EAAX,MAAM,CACjB;IAAyD,MAAM,AAA/D,CACA,EADW;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CACtD;IAA4C,OAAO,AAAnD,CACA,EADW;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CACzC;IAAqC,UAAU,AAA/C,CAAgD,EAArC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAc;CAAA,CAAA;AAE/C,YAAuD,YAAY,GAAzD,IAAI,CAAC,OAAO,EAAE,QAAQ,GAAG,SAAS,GAAG,KAAK,CAAC,CAAc;AAIpE,YAAkB,aAAa,GAC/B;IAAmB,GAAG,EAAX,MAAM,CACjB;IAAqB,QAAQ,AAA7B,CACA,EADW,OAAO,CAClB;IAAqB,QAAQ,AAA7B,CACA,EADW,OAAO,CAClB;IAA2B,IAAI,AAA/B,CAAgC,EAArB,MAAM,GAAG,IAAI,CAAQ;CAAA,CAAA;
|
|
1
|
+
{"version":3,"file":"lint.d.mts","sourceRoot":"","sources":["../src/lint.mjs"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAe,YAAY,EAA2D,MAAM,eAAe,CAAC;AAMhH,OAAQ,KAAA,EAAE,WAAW,EAAE,MAAM,WAAW,CACxC;AAAA,OAAQ,KAAA,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAMpC,YAAkB,OAAO,GACzB;IAAmB,MAAM,EAAd,MAAM,CACjB;;;;IAAmB,KAAK,AAAxB,CACA,EADW,KAAK,CAChB;IAAmB,OAAO,EAAf,MAAM,CACjB;IAAmB,GAAG,EAAX,MAAM,CACjB;IAAyD,MAAM,AAA/D,CACA,EADW;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CACtD;IAA4C,OAAO,AAAnD,CACA,EADW;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CACzC;IAAqC,UAAU,AAA/C,CAAgD,EAArC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAc;CAAA,CAAA;AAE/C,YAAuD,YAAY,GAAzD,IAAI,CAAC,OAAO,EAAE,QAAQ,GAAG,SAAS,GAAG,KAAK,CAAC,CAAc;AAIpE,YAAkB,aAAa,GAC/B;IAAmB,GAAG,EAAX,MAAM,CACjB;IAAqB,QAAQ,AAA7B,CACA,EADW,OAAO,CAClB;IAAqB,QAAQ,AAA7B,CACA,EADW,OAAO,CAClB;IAA2B,IAAI,AAA/B,CAAgC,EAArB,MAAM,GAAG,IAAI,CAAQ;CAAA,CAAA;AAgDnC;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAJpB,OAIoB,EAAE,GAAG,EAHzB,MAGyB,EAAE,KAAK,AAFxC,CAAkC,EAA1B,SAAS,MAAM,EAEsB,aAgB/C;AASD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,EAAE,GAAG,EAAE,QAAe,EAAE,QAAe,EAAE,IAAW,EAAE,EAFxE,aAEwE,aAalF;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAHlB,MAGkB,EAAE,GAAG,EAFvB,MAEuB,aAEjC;AAiCD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAHpB,OAGoB,EAAE,GAAG,AAFjC,CAAqB,EAAb,MAEwC,aAelD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAHzB,WAGyB,EAAE,IAAI,AAFvC,CAAsB,EAAd,MAEqC,aAsB/C;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAHnB,MAGmB,GAFjB,KAAK,CAIjB;AAED,OAAO,EAAE,YAAY,EAAE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-app-lint",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=22.12"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@render-policy/core": "^0.1.
|
|
32
|
+
"@render-policy/core": "^0.1.4",
|
|
33
33
|
"eslint": "^10.11.0",
|
|
34
34
|
"eslint-plugin-render-policy": "^0.1.2"
|
|
35
35
|
},
|
package/src/analyze.mjs
CHANGED
|
@@ -80,22 +80,23 @@ function canonical(value) {
|
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Does `resources/read` declare a different policy than `resources/list`? The host that checked list is then wrong.
|
|
83
|
+
* `mismatch` is any difference, for the census. A listing may stay silent: the MCP Apps SDK puts the CSP in the read
|
|
84
|
+
* result only. So `conflict` and `readWider`, which the rules use, compare a field (csp, permissions, domain) only
|
|
85
|
+
* when both sides declare it; `readWider` is a CSP entry at read time that the listing's same key does not have.
|
|
83
86
|
* @param {unknown} listMeta `_meta` of the listing entry
|
|
84
87
|
* @param {unknown} readMeta `_meta` of the read result's content item
|
|
85
88
|
*/
|
|
86
89
|
export function compareListRead(listMeta, readMeta) {
|
|
87
90
|
const a = uiMetaOf(listMeta);
|
|
88
91
|
const b = uiMetaOf(readMeta);
|
|
89
|
-
if (!a && !b) return { comparable: false, mismatch: false };
|
|
92
|
+
if (!a && !b) return { comparable: false, mismatch: false, conflict: false, readWider: false };
|
|
93
|
+
const FIELDS = /** @type {const} */ (['csp', 'permissions', 'domain']);
|
|
90
94
|
/** @param {UiMeta | null} m */
|
|
91
|
-
const pick = (m) => (m ?
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const readWider = (() => {
|
|
95
|
-
|
|
96
|
-
return CSP_KEYS.some((k) => (b.csp[k]?.length ?? 0) > (a.csp[k]?.length ?? 0) || (b.csp[k] ?? []).some((/** @type {unknown} */ d) => String(d).includes('*') && !(a.csp[k] ?? []).includes(d)));
|
|
97
|
-
})();
|
|
98
|
-
return { comparable: true, mismatch: left !== right, readWider, listOnly: !!a && !b, readOnly: !a && !!b };
|
|
95
|
+
const pick = (m) => (m ? Object.fromEntries(FIELDS.map((f) => [f, m[f] ?? null])) : null);
|
|
96
|
+
const same = (/** @type {unknown} */ x, /** @type {unknown} */ y) => JSON.stringify(canonical(x)) === JSON.stringify(canonical(y));
|
|
97
|
+
const conflict = FIELDS.some((f) => a?.[f] != null && b?.[f] != null && !same(a[f], b[f]));
|
|
98
|
+
const readWider = !!(a?.csp && b?.csp) && CSP_KEYS.some((k) => (b.csp[k] ?? []).some((/** @type {unknown} */ d) => !(a.csp[k] ?? []).includes(d)));
|
|
99
|
+
return { comparable: true, mismatch: !same(pick(a), pick(b)), conflict, readWider, listOnly: !!a && !b, readOnly: !a && !!b };
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
/**
|
|
@@ -211,6 +212,24 @@ export function postMessageStarCalls(code) {
|
|
|
211
212
|
return calls;
|
|
212
213
|
}
|
|
213
214
|
const URL_ATTR_RE = /\b(?:src|href|action|poster|data)\s*=\s*["']([^"']+)["']/gi;
|
|
215
|
+
// A network call with an absolute URL literal: fetch, sendBeacon, import(), WebSocket, EventSource, XHR open.
|
|
216
|
+
const NETWORK_CALL_RE = /(?:\b(?:fetch|sendBeacon|import)\s*\(|\bnew\s+(?:WebSocket|EventSource)\s*\(|\.open\s*\(\s*["'`][A-Za-z]+["'`]\s*,)\s*["'`]((?:https?|wss?):\/\/[^"'`\s]+)/g;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Hostnames of URLs, skipping the ones that do not parse.
|
|
220
|
+
* @param {Iterable<string>} urls
|
|
221
|
+
*/
|
|
222
|
+
function hostnames(urls) {
|
|
223
|
+
const hosts = new Set();
|
|
224
|
+
for (const url of urls) {
|
|
225
|
+
try {
|
|
226
|
+
hosts.add(new URL(url.startsWith('//') ? `https:${url}` : url).hostname.toLowerCase());
|
|
227
|
+
} catch {
|
|
228
|
+
/* ignore */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return [...hosts].sort();
|
|
232
|
+
}
|
|
214
233
|
|
|
215
234
|
/**
|
|
216
235
|
* Static signals in a UI resource's HTML. Heuristic by design; the report says so.
|
|
@@ -240,19 +259,12 @@ export function analyzeHtml(html) {
|
|
|
240
259
|
formsWithAction: (text.match(/<form\b[^>]*\baction\s*=/gi) ?? []).length,
|
|
241
260
|
metaCsp: /<meta\b[^>]*http-equiv\s*=\s*["']content-security-policy["']/i.test(text),
|
|
242
261
|
externalHosts: /** @type {string[]} */ ([]),
|
|
262
|
+
// Hosts that script calls with an absolute URL (fetch, WebSocket, …), bundles included.
|
|
263
|
+
networkHosts: /** @type {string[]} */ ([]),
|
|
243
264
|
sinkHosts: /** @type {string[]} */ ([]),
|
|
244
265
|
};
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const value = match[1] ?? '';
|
|
248
|
-
if (!/^(https?:)?\/\//i.test(value)) continue;
|
|
249
|
-
try {
|
|
250
|
-
hosts.add(new URL(value.startsWith('//') ? `https:${value}` : value).hostname.toLowerCase());
|
|
251
|
-
} catch {
|
|
252
|
-
/* ignore */
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
result.externalHosts = [...hosts].sort();
|
|
266
|
+
result.externalHosts = hostnames([...text.matchAll(URL_ATTR_RE)].map((m) => m[1] ?? '').filter((v) => /^(https?:)?\/\//i.test(v)));
|
|
267
|
+
result.networkHosts = hostnames([...text.matchAll(NETWORK_CALL_RE)].map((m) => m[1] ?? ''));
|
|
256
268
|
for (const match of text.matchAll(SCRIPT_RE)) {
|
|
257
269
|
const attrs = match[1] ?? '';
|
|
258
270
|
const body = match[2] ?? '';
|
package/src/lint.mjs
CHANGED
|
@@ -69,18 +69,31 @@ function cspEntryFindings(key, raw, uri, extra) {
|
|
|
69
69
|
return findings;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The hosts a UI document loads from or calls, sorted and without duplicates.
|
|
74
|
+
* @param {Iterable<{ externalHosts?: string[], networkHosts?: string[] }>} docs
|
|
75
|
+
*/
|
|
76
|
+
function referencedHosts(docs) {
|
|
77
|
+
const hosts = new Set();
|
|
78
|
+
for (const doc of docs) for (const host of [...(doc.externalHosts ?? []), ...(doc.networkHosts ?? [])]) hosts.add(host);
|
|
79
|
+
return [...hosts].sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
/**
|
|
73
83
|
* Findings about one `_meta` object of a UI resource (the resolved one: read, falling back to list).
|
|
84
|
+
* `hosts` are the hosts the UI references (see lintResource); without a CSP the host blocks them,
|
|
85
|
+
* and that is MCPAPP001. A UI that references none needs no CSP: the restrictive default fits it.
|
|
74
86
|
* @param {unknown} meta
|
|
75
87
|
* @param {string} uri
|
|
88
|
+
* @param {readonly string[]} [hosts]
|
|
76
89
|
*/
|
|
77
|
-
export function lintUiMeta(meta, uri) {
|
|
90
|
+
export function lintUiMeta(meta, uri, hosts = []) {
|
|
78
91
|
/** @type {Finding[]} */
|
|
79
92
|
const findings = [];
|
|
80
93
|
const ui = uiMetaOf(meta);
|
|
81
94
|
const analysis = analyzeUiMeta(meta);
|
|
82
95
|
if (!analysis.cspDeclared) {
|
|
83
|
-
|
|
96
|
+
if (hosts.length > 0) findings.push(cspMissing(hosts, uri));
|
|
84
97
|
} else {
|
|
85
98
|
for (const key of CSP_KEYS) {
|
|
86
99
|
for (const raw of ui?.csp?.[key] ?? []) findings.push(...cspEntryFindings(key, raw, uri, { logical: { name: `csp.${key}`, kind: 'member' } }));
|
|
@@ -92,6 +105,13 @@ export function lintUiMeta(meta, uri) {
|
|
|
92
105
|
return findings;
|
|
93
106
|
}
|
|
94
107
|
|
|
108
|
+
/**
|
|
109
|
+
* @param {readonly string[]} hosts
|
|
110
|
+
* @param {string} uri
|
|
111
|
+
*/
|
|
112
|
+
const cspMissing = (hosts, uri) =>
|
|
113
|
+
finding('MCPAPP001', `No ui.csp declared, but the UI references ${hosts.join(', ')}; a compliant host applies default-src 'none' and blocks them.`, uri, { properties: { hosts: [...hosts] } });
|
|
114
|
+
|
|
95
115
|
/**
|
|
96
116
|
* Findings about a UI resource as served: listing entry, read result content item, HTML.
|
|
97
117
|
* @param {ResourceInput} resource
|
|
@@ -100,13 +120,14 @@ export function lintResource({ uri, listMeta = null, readMeta = null, html = nul
|
|
|
100
120
|
/** @type {Finding[]} */
|
|
101
121
|
const findings = [];
|
|
102
122
|
const effective = readMeta ?? listMeta;
|
|
103
|
-
|
|
123
|
+
const analysis = typeof html === 'string' ? withSinkHosts(analyzeHtml(html)) : null;
|
|
124
|
+
findings.push(...lintUiMeta(effective, uri, analysis ? referencedHosts([analysis]) : []));
|
|
104
125
|
if (listMeta && readMeta) {
|
|
105
126
|
const compared = compareListRead(listMeta, readMeta);
|
|
106
127
|
if (compared.readWider) findings.push(finding('MCPAPP018', 'resources/read declares a wider policy than resources/list.', uri));
|
|
107
|
-
else if (compared.
|
|
128
|
+
else if (compared.conflict) findings.push(finding('MCPAPP017', 'resources/read and resources/list declare different policies.', uri));
|
|
108
129
|
}
|
|
109
|
-
if (
|
|
130
|
+
if (analysis) findings.push(...htmlFindings(analysis, uri));
|
|
110
131
|
return findings;
|
|
111
132
|
}
|
|
112
133
|
|
|
@@ -187,11 +208,12 @@ export function lintPackageScan(scan, root = '.') {
|
|
|
187
208
|
if (!scan.cspDeclared) delete fake.ui.csp;
|
|
188
209
|
if (Array.isArray(scan.domainSites)) {
|
|
189
210
|
// Each entry where it is written, so code scanning can point at the line.
|
|
190
|
-
|
|
211
|
+
const hosts = referencedHosts(scan.html ?? []);
|
|
212
|
+
if (!scan.cspDeclared && servesUi && hosts.length > 0) findings.push(cspMissing(hosts, root));
|
|
191
213
|
for (const site of scan.domainSites) findings.push(...cspEntryFindings(site.key, site.raw, `${root}/${site.file}`.replace(/^\.\//, ''), { region: { startLine: site.line } }));
|
|
192
214
|
findings.push(...lintUiMeta({ ui: { csp: {}, permissions: fake.ui.permissions } }, root).filter((f) => f.ruleId === 'MCPAPP007'));
|
|
193
215
|
} else {
|
|
194
|
-
findings.push(...lintUiMeta(fake, root));
|
|
216
|
+
findings.push(...lintUiMeta(fake, root, servesUi ? referencedHosts(scan.html ?? []) : []));
|
|
195
217
|
}
|
|
196
218
|
}
|
|
197
219
|
for (const doc of scan.html ?? []) {
|
package/src/rules.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @type {readonly Rule[]}
|
|
10
10
|
*/
|
|
11
11
|
export const RULES = [
|
|
12
|
-
{ id: 'MCPAPP001', name: 'csp-missing', level: 'warning', short: 'UI
|
|
12
|
+
{ id: 'MCPAPP001', name: 'csp-missing', level: 'warning', short: 'UI references external hosts but declares no CSP domain list', full: "A compliant host applies default-src 'none' and connect-src 'none' to a resource without ui.csp, so the hosts the UI loads from or calls are blocked. Reported when the HTML references an external host (src, href and similar attributes) or calls one with an absolute URL (fetch, WebSocket, EventSource, XMLHttpRequest, sendBeacon, import()); a UI that references none needs no CSP. For a package, reported when it serves a UI resource (registers one, or sets the MCP Apps MIME type or a ui:// URI on a resource), no domain list is found, literal or built at runtime, and one of its HTML documents references a host." },
|
|
13
13
|
{ 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." },
|
|
14
14
|
{ 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.' },
|
|
15
15
|
{ 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:. Loopback origins are reported as development origins (MCPAPP006) instead.' },
|
|
@@ -25,8 +25,8 @@ export const RULES = [
|
|
|
25
25
|
{ 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." },
|
|
26
26
|
{ 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.' },
|
|
27
27
|
{ 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.' },
|
|
28
|
-
{ 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.
|
|
29
|
-
{ id: 'MCPAPP018', name: 'meta-read-wider-than-list', level: 'error', short: 'resources/read widens the policy seen at resources/list', full: '
|
|
28
|
+
{ 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. Reported when both sides declare the same field (csp, permissions or domain) with different values and the read-time CSP adds nothing (that is MCPAPP018). A listing that declares no CSP is not a mismatch: the MCP Apps SDK puts the CSP in the read result only.' },
|
|
29
|
+
{ id: 'MCPAPP018', name: 'meta-read-wider-than-list', level: 'error', short: 'resources/read widens the policy seen at resources/list', full: 'Both sides declare a CSP and the read-time one has an entry the listing does not have under the same key (a new domain, a replaced one, or a wildcard). This is the pattern a host that reviews at connection time cannot see.' },
|
|
30
30
|
{ id: 'MCPAPP019', name: 'csp-sink-tenant-host', level: 'note', short: 'CSP entry names one account on a multi-tenant service', full: 'The host is a single bucket, worker or app on a storage or serverless service from the sink denylist (acct.blob.core.windows.net, pub-<id>.r2.dev). It reaches only that account, which is fine while the owner keeps it; a deleted bucket or app name can be registered by someone else.' },
|
|
31
31
|
];
|
|
32
32
|
|
package/src/sarif.mjs
CHANGED
|
@@ -68,6 +68,6 @@ export function toSarif(findings, { invocation = {} } = {}) {
|
|
|
68
68
|
export function toText(findings) {
|
|
69
69
|
if (findings.length === 0) return 'no findings\n';
|
|
70
70
|
return `${findings
|
|
71
|
-
.map((f) => `${(f.level ?? levelOf(f.ruleId)).padEnd(7)} ${f.ruleId} ${f.uri}${f.region ? `:${f.region.startLine}
|
|
71
|
+
.map((f) => `${(f.level ?? levelOf(f.ruleId)).padEnd(7)} ${f.ruleId} ${f.uri}${f.region ? `:${f.region.startLine}${f.region.startColumn ? `:${f.region.startColumn}` : ''}` : ''} ${f.message}`)
|
|
72
72
|
.join('\n')}\n`;
|
|
73
73
|
}
|