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 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`; a compliant host applies `default-src 'none'`, `connect-src 'none'`. For a package: it serves a UI resource and no domain list is found, literal or built at runtime |
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` 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 |
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
@@ -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
- readWider?: undefined;
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
  /**
@@ -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;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAH7B,OAG6B,EAAE,QAAQ,EAFvC,OAEuC;;;;;;;;;;;;EAajD;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;AAGD;;;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;eACZ,MAAM,EAAE;EA0DjC;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"}
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
@@ -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;AAsCnC;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAHpB,OAGoB,EAAE,GAAG,EAFzB,MAEyB,aAgBnC;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,EAAE,GAAG,EAAE,QAAe,EAAE,QAAe,EAAE,IAAW,EAAE,EAFxE,aAEwE,aAYlF;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,aAqB/C;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,MAAM,EAHnB,MAGmB,GAFjB,KAAK,CAIjB;AAED,OAAO,EAAE,YAAY,EAAE,CAAC"}
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.2",
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.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 ? { csp: m.csp ?? null, permissions: m.permissions ?? null, domain: m.domain ?? null } : null);
92
- const left = JSON.stringify(canonical(pick(a)));
93
- const right = JSON.stringify(canonical(pick(b)));
94
- const readWider = (() => {
95
- if (!a?.csp || !b?.csp) return false;
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
- const hosts = new Set();
246
- for (const match of text.matchAll(URL_ATTR_RE)) {
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
- findings.push(finding('MCPAPP001', 'No ui.csp declared; a compliant host applies default-src \'none\'.', uri));
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
- findings.push(...lintUiMeta(effective, uri));
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.mismatch) findings.push(finding('MCPAPP017', 'resources/read and resources/list declare different policies.', uri));
128
+ else if (compared.conflict) findings.push(finding('MCPAPP017', 'resources/read and resources/list declare different policies.', uri));
108
129
  }
109
- if (typeof html === 'string') findings.push(...lintHtml(html, uri));
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
- if (!scan.cspDeclared && servesUi) findings.push(...lintUiMeta({ ui: {} }, root));
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 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. 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) and no domain list is found, literal or built at runtime." },
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. A wider policy at read time means the policy reviewed at connection time is not the one enforced.' },
29
- { 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.' },
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}:${f.region.startColumn}` : ''} ${f.message}`)
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
  }