Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions dist/detectors/codex-config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises';
import { lineOfTomlKey, parseToml } from 'agent-gov-core';
import { configPath } from '../discovery.js';
import { isUnpinnedCommand, serverCommand } from '../mcp-risk.js';
import { isUnpinnedCommand, serverCommand, remoteEndpoint, isUnencryptedEndpoint } from '../mcp-risk.js';
export const CODEX_CONFIG_FILE = '.codex/config.toml';
export const CODEX_TARGET_PATHS = [CODEX_CONFIG_FILE];
export async function detectCodexConfigDrift(oldRoot, newRoot) {
Expand Down Expand Up @@ -114,6 +114,23 @@ async function detectCodexMcpDrift(oldRoot, newRoot) {
recommendation: 'Pin executable packages to an exact version and avoid pipe-to-shell installation commands.'
});
}
const endpoint = remoteEndpoint(newServer);
if ((!oldServer || commandChanged) && endpoint) {
const unencrypted = isUnencryptedEndpoint(endpoint);
findings.push({
kind: 'scope_trail.codex_mcp_remote_endpoint',
severity: unencrypted ? 'critical' : 'high',
file: CODEX_CONFIG_FILE,
line: lineForServer(newServer),
subject: name,
message: unencrypted
? `Codex MCP server "${name}" points at an unencrypted remote endpoint: ${endpoint}.`
: `Codex MCP server "${name}" points at remote endpoint: ${endpoint}.`,
recommendation: unencrypted
? 'Use https:// for remote MCP endpoints — prompt data and tool executions must not go over unencrypted transport.'
: 'Confirm the endpoint is trusted and does not expose unexpected data or tools to external hosts.'
});
}
}
return findings;
}
Expand Down Expand Up @@ -152,7 +169,10 @@ async function readCodexMcpServers(root) {
args: Array.isArray(entry.args)
? entry.args.filter((arg) => typeof arg === 'string')
: undefined,
url: typeof entry.url === 'string' ? entry.url : undefined
url: typeof entry.url === 'string' ? entry.url : undefined,
serverUrl: typeof entry.serverUrl === 'string'
? entry.serverUrl
: (typeof entry.server_url === 'string' ? entry.server_url : undefined)
});
}
return servers;
Expand All @@ -161,7 +181,7 @@ function lineForServer(server) {
// Point at the leaf the reviewer most needs to see — `command`
// first, then any of the args/url keys. Fall back to file-level
// when nothing matches so the finding still surfaces.
for (const leaf of ['command', 'args', 'url']) {
for (const leaf of ['command', 'args', 'url', 'serverUrl', 'server_url']) {
const line = lineOfTomlKey(server.text, `mcp_servers.${server.name}.${leaf}`);
if (line) {
return line;
Expand Down
42 changes: 18 additions & 24 deletions dist/detectors/mcp.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readdir } from 'node:fs/promises';
import { configPath, isRecord, lineOfJsonKey, lineOfJsonStringValue, readJsonObjectWithSource } from '../discovery.js';
import { isPipeToShellCommand, isUnpinnedCommand, serverCommand } from '../mcp-risk.js';
import { isPipeToShellCommand, isUnpinnedCommand, serverCommand, remoteEndpoint, isRemoteEndpoint, isUnencryptedEndpoint } from '../mcp-risk.js';
const MCP_CONFIGS = [
{ path: '.mcp.json', serverKeys: ['mcpServers'] },
{ path: '.cursor/mcp.json', serverKeys: ['mcpServers', 'servers'] },
Expand Down Expand Up @@ -87,6 +87,23 @@ export async function detectMcpDrift(oldRoot, newRoot) {
recommendation: 'Pin executable packages to an exact version and avoid pipe-to-shell installation commands.'
});
}
const endpoint = remoteEndpoint(newServer);
if ((!oldServer || serverCommand(newServer) !== serverCommand(oldServer)) && endpoint) {
const unencrypted = isUnencryptedEndpoint(endpoint);
findings.push({
kind: 'scope_trail.mcp_remote_endpoint',
severity: unencrypted ? 'critical' : 'high',
file: config.path,
line: lineForRemoteEndpoint(newServer) ?? newServer.line,
subject: name,
message: unencrypted
? `MCP server "${name}" points at an unencrypted remote endpoint: ${endpoint}.`
: `MCP server "${name}" points at remote endpoint: ${endpoint}.`,
recommendation: unencrypted
? 'Use https:// for remote MCP endpoints — prompt data and tool executions must not go over unencrypted transport.'
: 'Confirm the endpoint is trusted and does not expose unexpected data or tools to external hosts.'
});
}
}
}
for (const path of await listMcpSampleConfigPaths(oldRoot, newRoot)) {
Expand Down Expand Up @@ -284,29 +301,6 @@ function lineForUnpinnedCommand(server) {
function lineForRemoteEndpoint(server) {
return firstLineForValues(server, [server.url, server.serverUrl], isRemoteEndpoint);
}
function remoteEndpoint(server) {
return [server.url, server.serverUrl].find((value) => Boolean(value && isRemoteEndpoint(value)));
}
function isRemoteEndpoint(value) {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
return !['localhost', '127.0.0.1', '::1'].includes(url.hostname);
}
catch {
return false;
}
}
function isUnencryptedEndpoint(value) {
try {
return new URL(value).protocol === 'http:';
}
catch {
return false;
}
}
function firstLineForValues(server, values, predicate = () => true) {
const source = getSourceText(server);
for (const value of values) {
Expand Down
23 changes: 23 additions & 0 deletions dist/mcp-risk.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,26 @@ function hasExactVersion(value) {
const version = value.slice(packageVersion + 1);
return /^\d+\.\d+\.\d+/.test(version);
}
export function remoteEndpoint(spec) {
return [spec.url, spec.serverUrl].find((value) => Boolean(value && isRemoteEndpoint(value)));
}
export function isRemoteEndpoint(value) {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
return !['localhost', '127.0.0.1', '::1'].includes(url.hostname);
}
catch {
return false;
}
}
export function isUnencryptedEndpoint(value) {
try {
return new URL(value).protocol === 'http:';
}
catch {
return false;
}
}
33 changes: 30 additions & 3 deletions src/detectors/codex-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { readFile } from 'node:fs/promises';
import { lineOfTomlKey, parseToml } from 'agent-gov-core';
import { configPath } from '../discovery.js';
import { isUnpinnedCommand, serverCommand, type McpCommandShape } from '../mcp-risk.js';
import {
isUnpinnedCommand,
serverCommand,
remoteEndpoint,
isUnencryptedEndpoint,
type McpCommandShape
} from '../mcp-risk.js';
import type { Finding } from '../types.js';

export const CODEX_CONFIG_FILE = '.codex/config.toml';
Expand Down Expand Up @@ -136,6 +142,24 @@ async function detectCodexMcpDrift(oldRoot: string, newRoot: string): Promise<Fi
recommendation: 'Pin executable packages to an exact version and avoid pipe-to-shell installation commands.'
});
}

const endpoint = remoteEndpoint(newServer);
if ((!oldServer || commandChanged) && endpoint) {
const unencrypted = isUnencryptedEndpoint(endpoint);
findings.push({
kind: 'scope_trail.codex_mcp_remote_endpoint',
severity: unencrypted ? 'critical' : 'high',
file: CODEX_CONFIG_FILE,
line: lineForServer(newServer),
subject: name,
message: unencrypted
? `Codex MCP server "${name}" points at an unencrypted remote endpoint: ${endpoint}.`
: `Codex MCP server "${name}" points at remote endpoint: ${endpoint}.`,
recommendation: unencrypted
? 'Use https:// for remote MCP endpoints — prompt data and tool executions must not go over unencrypted transport.'
: 'Confirm the endpoint is trusted and does not expose unexpected data or tools to external hosts.'
});
}
}

return findings;
Expand Down Expand Up @@ -178,7 +202,10 @@ async function readCodexMcpServers(root: string): Promise<Map<string, CodexMcpSe
args: Array.isArray(entry.args)
? entry.args.filter((arg): arg is string => typeof arg === 'string')
: undefined,
url: typeof entry.url === 'string' ? entry.url : undefined
url: typeof entry.url === 'string' ? entry.url : undefined,
serverUrl: typeof entry.serverUrl === 'string'
? entry.serverUrl
: (typeof entry.server_url === 'string' ? entry.server_url : undefined)
});
}

Expand All @@ -189,7 +216,7 @@ function lineForServer(server: CodexMcpServer): number | undefined {
// Point at the leaf the reviewer most needs to see — `command`
// first, then any of the args/url keys. Fall back to file-level
// when nothing matches so the finding still surfaces.
for (const leaf of ['command', 'args', 'url']) {
for (const leaf of ['command', 'args', 'url', 'serverUrl', 'server_url']) {
const line = lineOfTomlKey(server.text, `mcp_servers.${server.name}.${leaf}`);
if (line) {
return line;
Expand Down
51 changes: 26 additions & 25 deletions src/detectors/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { readdir } from 'node:fs/promises';
import { configPath, isRecord, lineOfJsonKey, lineOfJsonStringValue, readJsonObjectWithSource } from '../discovery.js';
import { isPipeToShellCommand, isUnpinnedCommand, serverCommand } from '../mcp-risk.js';
import {
isPipeToShellCommand,
isUnpinnedCommand,
serverCommand,
remoteEndpoint,
isRemoteEndpoint,
isUnencryptedEndpoint
} from '../mcp-risk.js';
import type { Finding, McpServerConfig, Severity } from '../types.js';

const MCP_CONFIGS = [
Expand Down Expand Up @@ -106,6 +113,24 @@ export async function detectMcpDrift(oldRoot: string, newRoot: string): Promise<
recommendation: 'Pin executable packages to an exact version and avoid pipe-to-shell installation commands.'
});
}

const endpoint = remoteEndpoint(newServer);
if ((!oldServer || serverCommand(newServer) !== serverCommand(oldServer)) && endpoint) {
const unencrypted = isUnencryptedEndpoint(endpoint);
findings.push({
kind: 'scope_trail.mcp_remote_endpoint',
severity: unencrypted ? 'critical' : 'high',
file: config.path,
line: lineForRemoteEndpoint(newServer) ?? newServer.line,
subject: name,
message: unencrypted
? `MCP server "${name}" points at an unencrypted remote endpoint: ${endpoint}.`
: `MCP server "${name}" points at remote endpoint: ${endpoint}.`,
recommendation: unencrypted
? 'Use https:// for remote MCP endpoints — prompt data and tool executions must not go over unencrypted transport.'
: 'Confirm the endpoint is trusted and does not expose unexpected data or tools to external hosts.'
});
}
}
}

Expand Down Expand Up @@ -345,30 +370,6 @@ function lineForRemoteEndpoint(server: McpServerModel): number | undefined {
return firstLineForValues(server, [server.url, server.serverUrl], isRemoteEndpoint);
}

function remoteEndpoint(server: McpServerModel): string | undefined {
return [server.url, server.serverUrl].find((value): value is string => Boolean(value && isRemoteEndpoint(value)));
}

function isRemoteEndpoint(value: string): boolean {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}

return !['localhost', '127.0.0.1', '::1'].includes(url.hostname);
} catch {
return false;
}
}

function isUnencryptedEndpoint(value: string): boolean {
try {
return new URL(value).protocol === 'http:';
} catch {
return false;
}
}

function firstLineForValues(
server: McpServerModel,
Expand Down
26 changes: 26 additions & 0 deletions src/mcp-risk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,29 @@ function hasExactVersion(value: string): boolean {
const version = value.slice(packageVersion + 1);
return /^\d+\.\d+\.\d+/.test(version);
}

export function remoteEndpoint(spec: McpCommandShape): string | undefined {
return [spec.url, spec.serverUrl].find((value): value is string => Boolean(value && isRemoteEndpoint(value)));
}

export function isRemoteEndpoint(value: string): boolean {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}

return !['localhost', '127.0.0.1', '::1'].includes(url.hostname);
} catch {
return false;
}
}

export function isUnencryptedEndpoint(value: string): boolean {
try {
return new URL(value).protocol === 'http:';
} catch {
return false;
}
}

37 changes: 37 additions & 0 deletions test/codex-mcp-drift.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,40 @@ test('codex_mcp_server_command_changed fires when an existing server changes its
rmSync(root, { recursive: true, force: true });
}
});

test('codex_mcp_remote_endpoint: http:// fires critical severity, https:// fires high', async () => {
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');

const root = mkdtempSync(join(tmpdir(), 'scopetrail-codex-http-'));
try {
const oldDir = join(root, 'old');
const newDir = join(root, 'new');
mkdirSync(join(oldDir, '.codex'), { recursive: true });
mkdirSync(join(newDir, '.codex'), { recursive: true });

writeFileSync(
join(oldDir, '.codex', 'config.toml'),
'[mcp_servers]\n'
);
writeFileSync(
join(newDir, '.codex', 'config.toml'),
'[mcp_servers.plain-remote]\nserverUrl = "http://mcp.example.com/insecure"\n\n[mcp_servers.tls-remote]\nserverUrl = "https://mcp.example.com/safe"\n'
);

const findings = await detectCodexConfigDrift(oldDir, newDir);
const remoteEndpoint = findings.filter((f) => f.kind === 'scope_trail.codex_mcp_remote_endpoint');
const bySubject = Object.fromEntries(remoteEndpoint.map((f) => [f.subject, f]));

assert.ok(bySubject['plain-remote'], 'expected active codex remote_endpoint finding for plain-remote');
assert.equal(bySubject['plain-remote'].severity, 'critical');
assert.match(bySubject['plain-remote'].message, /unencrypted/);

assert.ok(bySubject['tls-remote'], 'expected active codex remote_endpoint finding for tls-remote');
assert.equal(bySubject['tls-remote'].severity, 'high');
assert.doesNotMatch(bySubject['tls-remote'].message, /unencrypted/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

39 changes: 39 additions & 0 deletions test/mcp-drift.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ test('detects MCP drift in Windsurf config files', async () => {
findings.map((finding) => [finding.file, finding.kind, finding.subject, finding.line]),
[
['.codeium/windsurf/mcp_config.json', 'scope_trail.mcp_server_command_changed', 'team-registry', 4],
['.codeium/windsurf/mcp_config.json', 'scope_trail.mcp_remote_endpoint', 'team-registry', 4],
['.codeium/windsurf/mcp_config.json', 'scope_trail.mcp_server_added', 'browser-tools', 6],
['.codeium/windsurf/mcp_config.json', 'scope_trail.unpinned_mcp_command', 'browser-tools', 8]
]
Expand Down Expand Up @@ -237,3 +238,41 @@ test('detects prefixed MCP config example drift without treating it as active se
]
);
});

test('mcp_remote_endpoint: http:// fires critical severity, https:// fires high', async () => {
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');

const root = mkdtempSync(join(tmpdir(), 'scopetrail-active-http-'));
try {
const oldDir = join(root, 'old');
const newDir = join(root, 'new');
mkdirSync(oldDir, { recursive: true });
mkdirSync(newDir, { recursive: true });
writeFileSync(join(oldDir, '.mcp.json'), JSON.stringify({ mcpServers: {} }));
writeFileSync(
join(newDir, '.mcp.json'),
JSON.stringify({
mcpServers: {
'plain-remote': { serverUrl: 'http://mcp.example.com/insecure' },
'tls-remote': { serverUrl: 'https://mcp.example.com/safe' }
}
}, null, 2)
);

const findings = await detectMcpDrift(oldDir, newDir);
const remoteEndpoint = findings.filter((f) => f.kind === 'scope_trail.mcp_remote_endpoint');
const bySubject = Object.fromEntries(remoteEndpoint.map((f) => [f.subject, f]));

assert.ok(bySubject['plain-remote'], 'expected active remote_endpoint finding for plain-remote');
assert.equal(bySubject['plain-remote'].severity, 'critical');
assert.match(bySubject['plain-remote'].message, /unencrypted/);

assert.ok(bySubject['tls-remote'], 'expected active remote_endpoint finding for tls-remote');
assert.equal(bySubject['tls-remote'].severity, 'high');
assert.doesNotMatch(bySubject['tls-remote'].message, /unencrypted/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});