mcp-app-lint 0.1.0 → 0.1.2
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 +18 -6
- package/dist/analyze.d.mts +153 -0
- package/dist/analyze.d.mts.map +1 -0
- package/dist/cli.d.mts +25 -0
- package/dist/cli.d.mts.map +1 -0
- package/dist/domains.d.mts +76 -0
- package/dist/domains.d.mts.map +1 -0
- package/dist/index.d.mts +44 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/lint.d.mts +68 -0
- package/dist/lint.d.mts.map +1 -0
- package/dist/npm.d.mts +152 -0
- package/dist/npm.d.mts.map +1 -0
- package/dist/rules.d.mts +22 -0
- package/dist/rules.d.mts.map +1 -0
- package/dist/sarif.d.mts +80 -0
- package/dist/sarif.d.mts.map +1 -0
- package/package.json +11 -4
- package/src/analyze.mjs +150 -21
- package/src/cli.mjs +43 -6
- package/src/domains.mjs +108 -12
- package/src/index.mjs +22 -2
- package/src/lint.mjs +123 -23
- package/src/npm.mjs +134 -17
- package/src/rules.mjs +18 -7
- package/src/sarif.mjs +18 -2
package/dist/sarif.d.mts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Finding } from './lint.mjs';
|
|
2
|
+
/** @import { Finding } from './lint.mjs' */
|
|
3
|
+
/**
|
|
4
|
+
* The version of this package, as the SARIF driver reports it. Read from package.json, which every
|
|
5
|
+
* install ships, so it cannot fall behind a release.
|
|
6
|
+
* @type {string}
|
|
7
|
+
*/
|
|
8
|
+
export declare const TOOL_VERSION: string;
|
|
9
|
+
/**
|
|
10
|
+
* A SARIF 2.1.0 log with one run.
|
|
11
|
+
* @param {readonly Finding[]} findings
|
|
12
|
+
* @param {{ invocation?: Record<string, unknown> }} [options] extra fields for the run's invocation, such as `commandLine`
|
|
13
|
+
*/
|
|
14
|
+
export declare function toSarif(findings: readonly Finding[], { invocation }?: {
|
|
15
|
+
invocation?: Record<string, unknown>;
|
|
16
|
+
}): {
|
|
17
|
+
$schema: string;
|
|
18
|
+
version: string;
|
|
19
|
+
runs: {
|
|
20
|
+
tool: {
|
|
21
|
+
driver: {
|
|
22
|
+
name: string;
|
|
23
|
+
version: string;
|
|
24
|
+
informationUri: string;
|
|
25
|
+
rules: {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
shortDescription: {
|
|
29
|
+
text: string;
|
|
30
|
+
};
|
|
31
|
+
fullDescription: {
|
|
32
|
+
text: string;
|
|
33
|
+
};
|
|
34
|
+
help: {
|
|
35
|
+
text: string;
|
|
36
|
+
};
|
|
37
|
+
defaultConfiguration: {
|
|
38
|
+
level: import("./rules.mjs").Level;
|
|
39
|
+
};
|
|
40
|
+
properties: {
|
|
41
|
+
tags: string[];
|
|
42
|
+
};
|
|
43
|
+
}[];
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
invocations: {
|
|
47
|
+
executionSuccessful: boolean;
|
|
48
|
+
}[];
|
|
49
|
+
results: {
|
|
50
|
+
ruleId: string;
|
|
51
|
+
ruleIndex: number | undefined;
|
|
52
|
+
level: import("./rules.mjs").Level;
|
|
53
|
+
message: {
|
|
54
|
+
text: string;
|
|
55
|
+
};
|
|
56
|
+
locations: {
|
|
57
|
+
physicalLocation: {
|
|
58
|
+
artifactLocation: {
|
|
59
|
+
uri: string;
|
|
60
|
+
};
|
|
61
|
+
region?: {
|
|
62
|
+
startLine: number;
|
|
63
|
+
startColumn?: number;
|
|
64
|
+
} | undefined;
|
|
65
|
+
};
|
|
66
|
+
logicalLocations?: {
|
|
67
|
+
name: string;
|
|
68
|
+
kind: string;
|
|
69
|
+
}[] | undefined;
|
|
70
|
+
}[];
|
|
71
|
+
properties?: Record<string, unknown> | undefined;
|
|
72
|
+
}[];
|
|
73
|
+
}[];
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* One line per finding: level, rule, location, message.
|
|
77
|
+
* @param {readonly Finding[]} findings
|
|
78
|
+
*/
|
|
79
|
+
export declare function toText(findings: readonly Finding[]): string;
|
|
80
|
+
//# sourceMappingURL=sarif.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sarif.d.mts","sourceRoot":"","sources":["../src/sarif.mjs"],"names":[],"mappings":"AAII,OAAQ,KAAA,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAAzC,4CAA4C;AAE5C;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAFf,MAEuG,CAAC;AAElH;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAHrB,SAAS,OAAO,EAGK,EAAE,EAAE,UAAe,EAAE,AAFlD,CACF,EADU;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAES;;;;;YAuBhD,MAAM;gBACJ,IAAI;gBACJ,OAAO;gBACP,cAAc;gBACd,KAAK;;;;wBAGiB,IAAI;;;wBACL,IAAI;;;wBACf,IAAI;;;wBACY,KAAK;;;wBACf,IAAI;;;;;;;;;;;;;gBA7BjB,IAAI;;;;;wBAIW,GAAG;;;;;;;;;;;;;;;EAkChC;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,QAAQ,EAFpB,SAAS,OAAO,EAEI,UAK9B"}
|
package/package.json
CHANGED
|
@@ -1,30 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-app-lint",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"mcp-app-lint": "./bin/mcp-app-lint.mjs"
|
|
9
9
|
},
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
10
11
|
"exports": {
|
|
11
|
-
".":
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.mts",
|
|
14
|
+
"default": "./src/index.mjs"
|
|
15
|
+
}
|
|
12
16
|
},
|
|
13
17
|
"files": [
|
|
14
18
|
"bin",
|
|
15
19
|
"src",
|
|
20
|
+
"dist",
|
|
16
21
|
"README.md"
|
|
17
22
|
],
|
|
18
23
|
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json && tsc -p test/types/tsconfig.json",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
19
26
|
"test": "node --test test/*.test.mjs"
|
|
20
27
|
},
|
|
21
28
|
"engines": {
|
|
22
29
|
"node": ">=22.12"
|
|
23
30
|
},
|
|
24
31
|
"dependencies": {
|
|
25
|
-
"@render-policy/core": "^0.1.
|
|
32
|
+
"@render-policy/core": "^0.1.1",
|
|
26
33
|
"eslint": "^10.11.0",
|
|
27
|
-
"eslint-plugin-render-policy": "^0.1.
|
|
34
|
+
"eslint-plugin-render-policy": "^0.1.2"
|
|
28
35
|
},
|
|
29
36
|
"publishConfig": {
|
|
30
37
|
"access": "public"
|
package/src/analyze.mjs
CHANGED
|
@@ -3,28 +3,50 @@
|
|
|
3
3
|
* its tools, and the HTML of the resources themselves. No network here.
|
|
4
4
|
*/
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
|
-
import { classifyDomains } from './domains.mjs';
|
|
6
|
+
import { classifyDomains, sinkFor } from './domains.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @import { Linter } from 'eslint'
|
|
10
|
+
* @import { ClassifiedDomains } from './domains.mjs'
|
|
11
|
+
*/
|
|
7
12
|
|
|
8
13
|
const require = createRequire(import.meta.url);
|
|
9
14
|
|
|
15
|
+
/**
|
|
16
|
+
* `_meta.ui` as a server sent it: JSON from the wire or from source, so every field is checked where it is read.
|
|
17
|
+
* @typedef {Record<string, any>} UiMeta
|
|
18
|
+
*/
|
|
19
|
+
/** @typedef {'connectDomains' | 'resourceDomains' | 'frameDomains' | 'baseUriDomains'} CspKey */
|
|
20
|
+
|
|
21
|
+
/** @type {readonly CspKey[]} */
|
|
10
22
|
const CSP_KEYS = ['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'];
|
|
11
23
|
const PERMISSION_KEYS = ['camera', 'microphone', 'geolocation', 'clipboardWrite'];
|
|
12
24
|
export const UI_MIME = 'text/html;profile=mcp-app';
|
|
13
25
|
|
|
14
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* `_meta.ui` in the modern shape, or the legacy flat `ui/...` keys folded into one object.
|
|
28
|
+
* @param {unknown} meta a `_meta` object
|
|
29
|
+
* @returns {UiMeta | null}
|
|
30
|
+
*/
|
|
15
31
|
export function uiMetaOf(meta) {
|
|
16
32
|
if (!meta || typeof meta !== 'object') return null;
|
|
17
|
-
const
|
|
33
|
+
const ui = /** @type {{ ui?: unknown }} */ (meta).ui;
|
|
34
|
+
/** @type {UiMeta} */
|
|
35
|
+
const modern = ui && typeof ui === 'object' ? { ...ui } : {};
|
|
18
36
|
for (const [key, value] of Object.entries(meta)) {
|
|
19
37
|
if (key.startsWith('ui/')) modern[key.slice(3)] = value;
|
|
20
38
|
}
|
|
21
39
|
return Object.keys(modern).length > 0 ? modern : null;
|
|
22
40
|
}
|
|
23
41
|
|
|
42
|
+
/**
|
|
43
|
+
* What one `_meta` object declares: the CSP domain lists classified, permissions, domain.
|
|
44
|
+
* @param {unknown} meta a `_meta` object
|
|
45
|
+
*/
|
|
24
46
|
export function analyzeUiMeta(meta) {
|
|
25
47
|
const ui = uiMetaOf(meta);
|
|
26
48
|
const csp = ui?.csp && typeof ui.csp === 'object' ? ui.csp : null;
|
|
27
|
-
const domains = {};
|
|
49
|
+
const domains = /** @type {Record<CspKey, ClassifiedDomains>} */ ({});
|
|
28
50
|
for (const key of CSP_KEYS) domains[key] = classifyDomains(csp?.[key]);
|
|
29
51
|
const permissions = ui?.permissions && typeof ui.permissions === 'object' ? PERMISSION_KEYS.filter((k) => k in ui.permissions) : [];
|
|
30
52
|
return {
|
|
@@ -36,35 +58,50 @@ export function analyzeUiMeta(meta) {
|
|
|
36
58
|
anyFullWildcard: CSP_KEYS.some((k) => domains[k].fullWildcards > 0),
|
|
37
59
|
anyInsecure: CSP_KEYS.some((k) => domains[k].insecure > 0),
|
|
38
60
|
sinks: CSP_KEYS.flatMap((k) => domains[k].sinks),
|
|
61
|
+
tenantSinks: CSP_KEYS.flatMap((k) => domains[k].tenantSinks),
|
|
39
62
|
permissions,
|
|
40
63
|
domain: typeof ui?.domain === 'string' ? ui.domain : null,
|
|
41
64
|
prefersBorder: ui?.prefersBorder === true,
|
|
42
65
|
};
|
|
43
66
|
}
|
|
44
67
|
|
|
68
|
+
/**
|
|
69
|
+
* @param {unknown} value
|
|
70
|
+
* @returns {unknown}
|
|
71
|
+
*/
|
|
45
72
|
function canonical(value) {
|
|
46
73
|
if (Array.isArray(value)) return value.map(canonical);
|
|
47
74
|
if (value && typeof value === 'object') {
|
|
48
|
-
|
|
75
|
+
const object = /** @type {Record<string, unknown>} */ (value);
|
|
76
|
+
return Object.fromEntries(Object.keys(object).sort().map((k) => [k, canonical(object[k])]));
|
|
49
77
|
}
|
|
50
78
|
return value;
|
|
51
79
|
}
|
|
52
80
|
|
|
53
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Does `resources/read` declare a different policy than `resources/list`? The host that checked list is then wrong.
|
|
83
|
+
* @param {unknown} listMeta `_meta` of the listing entry
|
|
84
|
+
* @param {unknown} readMeta `_meta` of the read result's content item
|
|
85
|
+
*/
|
|
54
86
|
export function compareListRead(listMeta, readMeta) {
|
|
55
87
|
const a = uiMetaOf(listMeta);
|
|
56
88
|
const b = uiMetaOf(readMeta);
|
|
57
89
|
if (!a && !b) return { comparable: false, mismatch: false };
|
|
90
|
+
/** @param {UiMeta | null} m */
|
|
58
91
|
const pick = (m) => (m ? { csp: m.csp ?? null, permissions: m.permissions ?? null, domain: m.domain ?? null } : null);
|
|
59
92
|
const left = JSON.stringify(canonical(pick(a)));
|
|
60
93
|
const right = JSON.stringify(canonical(pick(b)));
|
|
61
94
|
const readWider = (() => {
|
|
62
95
|
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)));
|
|
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)));
|
|
64
97
|
})();
|
|
65
98
|
return { comparable: true, mismatch: left !== right, readWider, listOnly: !!a && !b, readOnly: !a && !!b };
|
|
66
99
|
}
|
|
67
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Counts over the tools of a tools/list result: with a UI, visible to the app, app-only, side-effecting and app-visible.
|
|
103
|
+
* @param {unknown} tools
|
|
104
|
+
*/
|
|
68
105
|
export function analyzeTools(tools) {
|
|
69
106
|
const list = Array.isArray(tools) ? tools : [];
|
|
70
107
|
let withUi = 0;
|
|
@@ -85,17 +122,25 @@ export function analyzeTools(tools) {
|
|
|
85
122
|
return { total: list.length, withUi, visibleToApp, appOnly, sideEffectsVisibleToApp };
|
|
86
123
|
}
|
|
87
124
|
|
|
125
|
+
/** Escaping helpers whose result the census treats as text: by name, not by implementation. */
|
|
126
|
+
export const ESCAPE_FUNCTIONS = ['esc', 'escapeHtml', 'escapeHTML', 'htmlEscape', 'escapeAttr', 'escapeHtmlAttr', 'DOMPurify.sanitize'];
|
|
127
|
+
|
|
128
|
+
/** @type {((source: string) => Linter.LintMessage[]) | null} */
|
|
88
129
|
let linter = null;
|
|
130
|
+
// The rule runs twice over each script: strict, and with ESCAPE_FUNCTIONS. A sink only the strict
|
|
131
|
+
// run reports gets every dynamic part through an escaping helper.
|
|
132
|
+
/** @param {string} code */
|
|
89
133
|
function lintInlineScript(code) {
|
|
90
134
|
if (!linter) {
|
|
91
135
|
const { Linter } = require('eslint');
|
|
92
136
|
const plugin = require('eslint-plugin-render-policy').default;
|
|
93
137
|
const instance = new Linter({ configType: 'flat' });
|
|
138
|
+
/** @type {Linter.Config[]} */
|
|
94
139
|
const config = [
|
|
95
140
|
{
|
|
96
141
|
files: ['**/*.js'],
|
|
97
|
-
plugins: { 'render-policy': plugin },
|
|
98
|
-
rules: { 'render-policy/no-unsafe-innerhtml': 'error' },
|
|
142
|
+
plugins: { 'render-policy': plugin, escaped: plugin },
|
|
143
|
+
rules: { 'render-policy/no-unsafe-innerhtml': 'error', 'escaped/no-unsafe-innerhtml': ['error', { escapeFunctions: ESCAPE_FUNCTIONS }] },
|
|
99
144
|
languageOptions: { ecmaVersion: 2024, sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
100
145
|
},
|
|
101
146
|
];
|
|
@@ -105,35 +150,101 @@ function lintInlineScript(code) {
|
|
|
105
150
|
}
|
|
106
151
|
|
|
107
152
|
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
153
|
+
const POST_MESSAGE_RE = /([\w$.]*)\s*\.\s*postMessage\s*\(/g;
|
|
154
|
+
const PROTOCOL_TARGET_RE = /(^|\.)(parent|top)$/;
|
|
155
|
+
// MCP Apps JSON-RPC, and the message types of the mcp-ui protocol.
|
|
156
|
+
const PROTOCOL_MESSAGE_RE = /\bjsonrpc\s*:|\bmethod\s*:\s*['"`]ui\/|\btype\s*:\s*['"`](ui-[a-z-]+|prompt|link|tool|intent|notify)['"`]/;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The argument text of a call whose opening parenthesis ends at `start`, or null if unbalanced.
|
|
160
|
+
* @param {string} code
|
|
161
|
+
* @param {number} start
|
|
162
|
+
*/
|
|
163
|
+
function callArguments(code, start) {
|
|
164
|
+
let depth = 1;
|
|
165
|
+
let quote = null;
|
|
166
|
+
for (let i = start; i < code.length && i < start + 20000; i += 1) {
|
|
167
|
+
const c = code[i];
|
|
168
|
+
if (quote) {
|
|
169
|
+
if (c === '\\') i += 1;
|
|
170
|
+
else if (c === quote) quote = null;
|
|
171
|
+
} else if (c === '"' || c === "'" || c === '`') quote = c;
|
|
172
|
+
else if (c === '(' || c === '{' || c === '[') depth += 1;
|
|
173
|
+
else if (c === ')' || c === '}' || c === ']') {
|
|
174
|
+
depth -= 1;
|
|
175
|
+
if (depth === 0) return code.slice(start, i);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A `postMessage(…, '*')` call: 1-based position, and whether it carries the app protocol to the host.
|
|
183
|
+
* @typedef {{ line: number, column: number, protocol: boolean }} PostMessageCall
|
|
184
|
+
*/
|
|
185
|
+
/**
|
|
186
|
+
* A dynamic HTML sink the lint rule reported, positioned in the document.
|
|
187
|
+
* @typedef {object} HtmlSink
|
|
188
|
+
* @property {number} line
|
|
189
|
+
* @property {number} column
|
|
190
|
+
* @property {boolean} handwritten the script it is in is hand-written, not a bundle
|
|
191
|
+
* @property {boolean} escaped every dynamic part of the value goes through an escaping helper
|
|
192
|
+
* @property {string} message
|
|
193
|
+
*/
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* postMessage calls whose target origin is '*', with position and whether they carry the app protocol to the host.
|
|
197
|
+
* @param {string} code
|
|
198
|
+
* @returns {PostMessageCall[]}
|
|
199
|
+
*/
|
|
200
|
+
export function postMessageStarCalls(code) {
|
|
201
|
+
/** @type {PostMessageCall[]} */
|
|
202
|
+
const calls = [];
|
|
203
|
+
for (const match of code.matchAll(POST_MESSAGE_RE)) {
|
|
204
|
+
const args = callArguments(code, match.index + match[0].length);
|
|
205
|
+
if (args === null || !/,\s*['"`]\*['"`]\s*(,[^,]*)?,?\s*$/.test(args)) continue;
|
|
206
|
+
const before = code.slice(0, match.index);
|
|
207
|
+
const line = before.split('\n').length;
|
|
208
|
+
const column = match.index - before.lastIndexOf('\n');
|
|
209
|
+
calls.push({ line, column, protocol: PROTOCOL_TARGET_RE.test(match[1] ?? '') && PROTOCOL_MESSAGE_RE.test(args) });
|
|
210
|
+
}
|
|
211
|
+
return calls;
|
|
212
|
+
}
|
|
108
213
|
const URL_ATTR_RE = /\b(?:src|href|action|poster|data)\s*=\s*["']([^"']+)["']/gi;
|
|
109
214
|
|
|
110
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Static signals in a UI resource's HTML. Heuristic by design; the report says so.
|
|
217
|
+
* @param {unknown} html
|
|
218
|
+
*/
|
|
111
219
|
export function analyzeHtml(html) {
|
|
112
220
|
const text = String(html ?? '');
|
|
113
221
|
const result = {
|
|
114
222
|
bytes: text.length,
|
|
115
223
|
inlineScripts: 0,
|
|
116
224
|
handwrittenScripts: 0,
|
|
117
|
-
externalScripts: [],
|
|
225
|
+
externalScripts: /** @type {string[]} */ ([]),
|
|
118
226
|
unsafeInnerHtml: 0,
|
|
119
227
|
unsafeInnerHtmlHandwritten: 0,
|
|
120
228
|
evalLikeHandwritten: 0,
|
|
121
229
|
postMessageStarHandwritten: 0,
|
|
230
|
+
postMessageStarHandwrittenNonProtocol: 0,
|
|
231
|
+
unsafeInnerHtmlHandwrittenUnescaped: 0,
|
|
232
|
+
postMessages: /** @type {Array<PostMessageCall & { handwritten: boolean }>} */ ([]),
|
|
122
233
|
lintErrors: 0,
|
|
123
234
|
unparsedScripts: 0,
|
|
124
235
|
inlineHandlers: (text.match(/\son[a-z]+\s*=/gi) ?? []).length,
|
|
125
|
-
sinks: [],
|
|
236
|
+
sinks: /** @type {HtmlSink[]} */ ([]),
|
|
126
237
|
evalLike: (text.match(/\beval\s*\(|new\s+Function\s*\(/g) ?? []).length,
|
|
127
238
|
postMessageStar: (text.match(/postMessage\s*\([^)]*['"]\*['"]/g) ?? []).length,
|
|
128
239
|
forms: (text.match(/<form\b/gi) ?? []).length,
|
|
129
240
|
formsWithAction: (text.match(/<form\b[^>]*\baction\s*=/gi) ?? []).length,
|
|
130
241
|
metaCsp: /<meta\b[^>]*http-equiv\s*=\s*["']content-security-policy["']/i.test(text),
|
|
131
|
-
externalHosts: [],
|
|
132
|
-
sinkHosts: [],
|
|
242
|
+
externalHosts: /** @type {string[]} */ ([]),
|
|
243
|
+
sinkHosts: /** @type {string[]} */ ([]),
|
|
133
244
|
};
|
|
134
245
|
const hosts = new Set();
|
|
135
246
|
for (const match of text.matchAll(URL_ATTR_RE)) {
|
|
136
|
-
const value = match[1];
|
|
247
|
+
const value = match[1] ?? '';
|
|
137
248
|
if (!/^(https?:)?\/\//i.test(value)) continue;
|
|
138
249
|
try {
|
|
139
250
|
hosts.add(new URL(value.startsWith('//') ? `https:${value}` : value).hostname.toLowerCase());
|
|
@@ -161,6 +272,11 @@ export function analyzeHtml(html) {
|
|
|
161
272
|
result.evalLikeHandwritten += (body.match(/\beval\s*\(|new\s+Function\s*\(/g) ?? []).length;
|
|
162
273
|
result.postMessageStarHandwritten += (body.match(/postMessage\s*\([^)]*['"]\*['"]/g) ?? []).length;
|
|
163
274
|
}
|
|
275
|
+
const scriptLine = text.slice(0, match.index).split('\n').length + (match[0].slice(0, match[0].indexOf('>') + 1).split('\n').length - 1);
|
|
276
|
+
for (const call of postMessageStarCalls(body)) {
|
|
277
|
+
result.postMessages.push({ ...call, line: scriptLine + call.line - 1, handwritten });
|
|
278
|
+
if (handwritten && !call.protocol) result.postMessageStarHandwrittenNonProtocol += 1;
|
|
279
|
+
}
|
|
164
280
|
try {
|
|
165
281
|
const messages = lintInlineScript(body);
|
|
166
282
|
const fatal = messages.filter((m) => m.fatal);
|
|
@@ -169,10 +285,14 @@ export function analyzeHtml(html) {
|
|
|
169
285
|
continue;
|
|
170
286
|
}
|
|
171
287
|
const hits = messages.filter((m) => m.ruleId === 'render-policy/no-unsafe-innerhtml');
|
|
288
|
+
const unescaped = new Set(messages.filter((m) => m.ruleId === 'escaped/no-unsafe-innerhtml').map((m) => `${m.line}:${m.column}`));
|
|
172
289
|
result.unsafeInnerHtml += hits.length;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
290
|
+
for (const hit of hits) {
|
|
291
|
+
const escaped = !unescaped.has(`${hit.line}:${hit.column}`);
|
|
292
|
+
if (handwritten) result.unsafeInnerHtmlHandwritten += 1;
|
|
293
|
+
if (handwritten && !escaped) result.unsafeInnerHtmlHandwrittenUnescaped += 1;
|
|
294
|
+
result.sinks.push({ line: scriptLine + hit.line - 1, column: hit.column, handwritten, escaped, message: hit.message });
|
|
295
|
+
}
|
|
176
296
|
} catch {
|
|
177
297
|
result.unparsedScripts += 1;
|
|
178
298
|
}
|
|
@@ -180,7 +300,10 @@ export function analyzeHtml(html) {
|
|
|
180
300
|
return result;
|
|
181
301
|
}
|
|
182
302
|
|
|
183
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* Short lines and not too many of them: written by a person, not emitted by a bundler.
|
|
305
|
+
* @param {string} code
|
|
306
|
+
*/
|
|
184
307
|
export function isHandwritten(code) {
|
|
185
308
|
const lines = code.split('\n');
|
|
186
309
|
const longest = Math.max(0, ...lines.map((l) => l.length));
|
|
@@ -188,8 +311,14 @@ export function isHandwritten(code) {
|
|
|
188
311
|
return longest < 2000 && average < 200 && lines.length < 3000;
|
|
189
312
|
}
|
|
190
313
|
|
|
191
|
-
/**
|
|
314
|
+
/** @typedef {ReturnType<typeof analyzeHtml>} HtmlAnalysis */
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Everything the HTML analysis reports about hosts, joined with the sink denylist.
|
|
318
|
+
* @template {{ externalHosts: string[] }} T
|
|
319
|
+
* @param {T} analysis
|
|
320
|
+
* @returns {T & { sinkHosts: string[] }}
|
|
321
|
+
*/
|
|
192
322
|
export function withSinkHosts(analysis) {
|
|
193
|
-
const { sinkFor } = require('./domains.mjs');
|
|
194
323
|
return { ...analysis, sinkHosts: analysis.externalHosts.filter((h) => sinkFor(h) !== null) };
|
|
195
324
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* mcp-app-lint --html app.html # one UI document
|
|
9
9
|
* Options: --out file.sarif, --format sarif|text (default sarif), --fail-on error|warning|note|none (default error)
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { UI_MIME } from './analyze.mjs';
|
|
@@ -16,13 +16,38 @@ import { lintHtml, lintPackageScan, lintResource, lintTools, levelOf } from './l
|
|
|
16
16
|
import { packageMeta, scanPackage, scanPackageDir } from './npm.mjs';
|
|
17
17
|
import { toSarif, toText } from './sarif.mjs';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* @import { Writable } from 'node:stream'
|
|
21
|
+
* @import { Finding } from './lint.mjs'
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} CliOptions
|
|
25
|
+
* @property {string} format
|
|
26
|
+
* @property {string} failOn
|
|
27
|
+
* @property {string} [dir]
|
|
28
|
+
* @property {string} [package]
|
|
29
|
+
* @property {string} [read]
|
|
30
|
+
* @property {string} [list]
|
|
31
|
+
* @property {string} [tools]
|
|
32
|
+
* @property {string} [html]
|
|
33
|
+
* @property {string} [out]
|
|
34
|
+
* @property {boolean} [help]
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** @type {Record<string, number>} */
|
|
19
38
|
const LEVEL_RANK = { error: 3, warning: 2, note: 1, none: 0 };
|
|
20
39
|
|
|
40
|
+
/** @param {readonly string[]} argv */
|
|
21
41
|
function parseArgs(argv) {
|
|
42
|
+
/** @type {CliOptions} */
|
|
22
43
|
const options = { format: 'sarif', failOn: 'error' };
|
|
23
44
|
for (let i = 0; i < argv.length; i += 1) {
|
|
24
45
|
const arg = argv[i];
|
|
25
|
-
const next = () =>
|
|
46
|
+
const next = () => {
|
|
47
|
+
const value = argv[++i];
|
|
48
|
+
if (value === undefined) throw new Error(`${arg} needs a value`);
|
|
49
|
+
return value;
|
|
50
|
+
};
|
|
26
51
|
if (arg === '--dir') options.dir = next();
|
|
27
52
|
else if (arg === '--package') options.package = next();
|
|
28
53
|
else if (arg === '--read') options.read = next();
|
|
@@ -38,22 +63,35 @@ function parseArgs(argv) {
|
|
|
38
63
|
return options;
|
|
39
64
|
}
|
|
40
65
|
|
|
66
|
+
/** @type {(file: string) => Promise<any>} */
|
|
41
67
|
const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'));
|
|
68
|
+
/** @type {(r: any) => boolean} */
|
|
42
69
|
const isUiResource = (r) => (r?.mimeType ?? '').startsWith('text/html') || String(r?.uri ?? '').startsWith('ui://');
|
|
43
70
|
|
|
44
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* A filesystem path under the working directory becomes a forward-slash repo-relative uri; anything else is unchanged.
|
|
73
|
+
* @param {string} uri
|
|
74
|
+
*/
|
|
45
75
|
function relativeUri(uri) {
|
|
46
76
|
if (typeof uri !== 'string' || !path.isAbsolute(uri)) return uri;
|
|
47
77
|
const rel = path.relative(process.cwd(), uri);
|
|
48
78
|
return rel && !rel.startsWith('..') ? rel.split(path.sep).join('/') : uri;
|
|
49
79
|
}
|
|
50
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The command line, without the process exit: resolves to the exit code (0 clean, 1 findings at or
|
|
83
|
+
* above --fail-on, 2 usage error). Throws on unreadable input or a failed download.
|
|
84
|
+
* @param {readonly string[]} argv arguments after the program name
|
|
85
|
+
* @param {{ stdout?: Pick<Writable, 'write'>, stderr?: Pick<Writable, 'write'> }} [io]
|
|
86
|
+
* @returns {Promise<number>}
|
|
87
|
+
*/
|
|
51
88
|
export async function run(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
52
89
|
const options = parseArgs(argv);
|
|
53
90
|
if (options.help || (!options.dir && !options.package && !options.read && !options.html && !options.tools)) {
|
|
54
91
|
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
92
|
return 2;
|
|
56
93
|
}
|
|
94
|
+
/** @type {Finding[]} */
|
|
57
95
|
const findings = [];
|
|
58
96
|
|
|
59
97
|
if (options.dir) {
|
|
@@ -63,8 +101,7 @@ export async function run(argv, { stdout = process.stdout, stderr = process.stde
|
|
|
63
101
|
if (options.package) {
|
|
64
102
|
const meta = await packageMeta(options.package);
|
|
65
103
|
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 });
|
|
104
|
+
const work = await mkdtemp(path.join(os.tmpdir(), 'mcp-app-lint-'));
|
|
68
105
|
try {
|
|
69
106
|
const scan = await scanPackage(meta, work);
|
|
70
107
|
findings.push(...lintPackageScan(scan, `npm:${meta.name}@${meta.version}`));
|
|
@@ -79,7 +116,7 @@ export async function run(argv, { stdout = process.stdout, stderr = process.stde
|
|
|
79
116
|
const listEntries = Array.isArray(listing?.resources) ? listing.resources : Array.isArray(listing) ? listing : listing ? [listing] : [];
|
|
80
117
|
for (const item of contents) {
|
|
81
118
|
if (!isUiResource(item) && item.mimeType !== UI_MIME) continue;
|
|
82
|
-
const entry = listEntries.find((r) => r.uri === item.uri) ?? null;
|
|
119
|
+
const entry = listEntries.find((/** @type {any} */ r) => r.uri === item.uri) ?? null;
|
|
83
120
|
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
121
|
}
|
|
85
122
|
}
|