diff --git a/scripts/ci/validate-content-policy.mjs b/scripts/ci/validate-content-policy.mjs index cab99a42a7..0bf23b02db 100644 --- a/scripts/ci/validate-content-policy.mjs +++ b/scripts/ci/validate-content-policy.mjs @@ -364,6 +364,8 @@ function githubSourceRef(value) { if (url.hostname.toLowerCase() === "raw.githubusercontent.com") { if (parts.length < 4) return null; return { + owner: parts[0].toLowerCase(), + repo: parts[1].replace(/\.git$/i, "").toLowerCase(), ref: parts[2], path: parts.slice(3).join("/"), }; @@ -374,6 +376,8 @@ function githubSourceRef(value) { (parts[2] === "blob" || parts[2] === "raw") ) { return { + owner: parts[0].toLowerCase(), + repo: parts[1].replace(/\.git$/i, "").toLowerCase(), ref: parts[3], path: parts.slice(4).join("/"), }; @@ -388,13 +392,106 @@ function isScriptPath(value) { return /\.(?:sh|bash|zsh|ps1)$/i.test(normalizeText(value)); } -function isImmutableGithubScriptSourceUrl(value) { - const source = githubSourceRef(value); - return Boolean( - source && - FULL_COMMIT_SHA_PATTERN.test(source.ref) && - isScriptPath(source.path), +function githubRepoRef(value) { + const raw = normalizeText(value); + if (!raw) return null; + try { + const url = new URL(raw); + if ( + url.protocol !== "https:" || + url.hostname.toLowerCase() !== "github.com" + ) { + return null; + } + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length < 2) return null; + return { + owner: parts[0].toLowerCase(), + repo: parts[1].replace(/\.git$/i, "").toLowerCase(), + }; + } catch { + return null; + } +} + +function githubRepoKey(source) { + if (!source?.owner || !source?.repo) return ""; + return `${source.owner}/${source.repo}`; +} + +function collectGitCloneRepos(value) { + const text = normalizeText(value); + const repos = []; + const clonePattern = + /\bgit\s+clone\b[^\n;&|]*?(https:\/\/github\.com\/[^\s`'"<>]+|git@github\.com:[^\s`'"<>]+)/gi; + for (const match of text.matchAll(clonePattern)) { + const rawRepo = match[1].replace( + /^git@github\.com:/i, + "https://github.com/", + ); + const repo = githubRepoRef(rawRepo); + if (repo) repos.push(repo); + } + return repos; +} + +function normalizeScriptPath(value) { + return normalizeText(value) + .replace(/^['"`]+|['"`]+$/g, "") + .replace(/^(?:\.\.?\/)+/, "") + .replace(/\/{2,}/g, "/") + .toLowerCase(); +} + +function collectLocalScriptInstallRefs(value) { + const text = normalizeText(value); + const scriptPattern = + /(?:^|[\s`;&|])(?:(?:bash|sh|zsh|pwsh|powershell)\s+)?((?:\.{1,2}\/|[\w.-]+\/)?[\w./-]*(?:install|setup|start|bootstrap|init)[\w.-]*\.(?:sh|bash|zsh|ps1))\b/gim; + return [...text.matchAll(scriptPattern)] + .map((match) => ({ + path: normalizeScriptPath(match[1]), + index: match.index ?? 0, + })) + .filter((script) => script.path); +} + +function checkoutCommitIndex(value, commit) { + const escapedCommit = commit.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = normalizeText(value).match( + new RegExp( + `\\bgit\\s+(?:checkout|switch\\s+--detach)\\s+(?:[^\\n;&|]*\\s)?${escapedCommit}\\b`, + "i", + ), + ); + return match?.index ?? -1; +} + +function hasBoundImmutableGithubScriptEvidence(sourceUrls, installText) { + const clonedRepoKeys = new Set( + collectGitCloneRepos(installText).map(githubRepoKey), ); + if (!clonedRepoKeys.size) return false; + const executedScripts = collectLocalScriptInstallRefs(installText); + if (!executedScripts.length) return false; + + return sourceUrls.some((value) => { + const source = githubSourceRef(value); + if ( + !source || + !FULL_COMMIT_SHA_PATTERN.test(source.ref) || + !isScriptPath(source.path) || + !clonedRepoKeys.has(githubRepoKey(source)) + ) { + return false; + } + + const checkoutIndex = checkoutCommitIndex(installText, source.ref); + if (checkoutIndex < 0) return false; + const scriptPath = normalizeScriptPath(source.path); + return executedScripts.some( + (script) => script.path === scriptPath && checkoutIndex < script.index, + ); + }); } function isMutableGithubSourceUrl(value) { @@ -735,7 +832,7 @@ function addContentRiskSignals(report, fields, content) { if ( referencesClonedLocalScriptInstall(installText) && - !submittedSourceUrls.some(isImmutableGithubScriptSourceUrl) + !hasBoundImmutableGithubScriptEvidence(submittedSourceUrls, installText) ) { const mutableSources = submittedSourceUrls.filter(isMutableGithubSourceUrl); addFlag( diff --git a/tests/content-policy-validation.test.ts b/tests/content-policy-validation.test.ts index 5e374b33e5..e4db01efe1 100644 --- a/tests/content-policy-validation.test.ts +++ b/tests/content-policy-validation.test.ts @@ -157,6 +157,136 @@ cd example-mcp ); }); + it("rejects unrelated immutable script evidence for cloned local scripts", () => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "heyclaude-content-policy-"), + ); + const unrelatedRevision = "0123456789abcdef0123456789abcdef01234567"; + const content = `--- +title: Example MCP +category: mcp +description: Example MCP server with unrelated pinned script evidence. +repoUrl: https://github.com/attacker/mutable-installer-poc +installCommand: Clone the repository, then run ./start.sh. +safetyNotes: + - Runs a local startup script from cloned source. +sourceUrls: + - https://raw.githubusercontent.com/unrelated/benign/${unrelatedRevision}/scripts/safe.sh +--- + +Clone the repository and start the server: + +\`\`\`bash +git clone https://github.com/attacker/mutable-installer-poc.git +cd mutable-installer-poc +./start.sh +\`\`\` +`; + + const result = runContentPolicy(tmpDir, content, "same_repo_direct", [ + { + filename: "content/mcp/example-mcp.mdx", + status: "added", + content, + }, + ]); + + expect(result.status).not.toBe(0); + const output = JSON.parse(fs.readFileSync(result.outputJson, "utf8")); + expect(output.reviewFlags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "mutable_script_install_source" }), + ]), + ); + }); + + it("rejects immutable evidence for a different script path in the cloned repo", () => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "heyclaude-content-policy-"), + ); + const revision = "9845479d0aeb7523abaab85723d0dfcf832fe1d3"; + const content = `--- +title: Example MCP +category: mcp +description: Example MCP server with pinned evidence for the wrong script. +repoUrl: https://github.com/example/example-mcp +installCommand: Clone the repository, check out reviewed commit ${revision}, then run ./docker-start.sh. +safetyNotes: + - Runs a local Docker stack from cloned source. +sourceUrls: + - https://raw.githubusercontent.com/example/example-mcp/${revision}/scripts/safe-start.sh +--- + +Clone the repository and start the stack: + +\`\`\`bash +git clone https://github.com/example/example-mcp.git +cd example-mcp +git checkout ${revision} +./docker-start.sh +\`\`\` +`; + + const result = runContentPolicy(tmpDir, content, "same_repo_direct", [ + { + filename: "content/mcp/example-mcp.mdx", + status: "added", + content, + }, + ]); + + expect(result.status).not.toBe(0); + const output = JSON.parse(fs.readFileSync(result.outputJson, "utf8")); + expect(output.reviewFlags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "mutable_script_install_source" }), + ]), + ); + }); + + it("rejects immutable script evidence unless install instructions check out that commit", () => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "heyclaude-content-policy-"), + ); + const revision = "9845479d0aeb7523abaab85723d0dfcf832fe1d3"; + const content = `--- +title: Example MCP +category: mcp +description: Example MCP server with a pinned script URL but mutable checkout. +repoUrl: https://github.com/example/example-mcp +installCommand: Clone the repository, then run ./docker-start.sh. +safetyNotes: + - Runs a local Docker stack from cloned source. +sourceUrls: + - https://raw.githubusercontent.com/example/example-mcp/${revision}/docker-start.sh +--- + +Clone the repository and start the stack: + +\`\`\`bash +git clone https://github.com/example/example-mcp.git +cd example-mcp +./docker-start.sh +\`\`\` +`; + + const result = runContentPolicy(tmpDir, content, "same_repo_direct", [ + { + filename: "content/mcp/example-mcp.mdx", + status: "added", + content, + }, + ]); + + expect(result.status).not.toBe(0); + const output = JSON.parse(fs.readFileSync(result.outputJson, "utf8")); + expect(output.reviewFlags).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "mutable_script_install_source" }), + ]), + ); + }); + it("allows cloned local scripts with immutable script source evidence", () => { const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "heyclaude-content-policy-"),