From 29b2676804958f0bc7cc7022760d65766018781e Mon Sep 17 00:00:00 2001 From: John Tuckner Date: Thu, 28 May 2026 15:48:36 -0500 Subject: [PATCH 1/3] feat(mcp): add file analysis, alerts, organizations, and threat feed tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new MCP tools on top of `depscore`, each following main's `lib/-tool.ts` registration pattern: - `organizations` — list orgs the authenticated user belongs to - `alerts` — paginated org alerts with severity/category/artifact filters - `threat_feed` — paginated org threat feed (GET /v0/orgs/{slug}/threat-feed) - `package_files` — file tree for any package on a supported ecosystem - `package_file_contents` — read one published file by content hash - `package_file_grep` — regex search a single file by hash Adds a process-wide LRU blob cache (default 64 MB, tunable via SOCKET_BLOB_CACHE_BYTES) so repeated reads/greps of the same hash skip the socketusercontent fetch across stateless HTTP requests. Extends `buildPurl` with optional qualifiers, an `openvsx` → `vscode` rewrite with auto-added `repository_url`, and a placeholder-version check that only treats `1.0.0` as stale for ecosystems where the model historically defaults to it (npm/pypi) — not for ecosystems that genuinely publish 1.0.0 (chrome, openvsx). --- blob.test.ts | 191 ++++++++++++++++++++ files.test.ts | 234 +++++++++++++++++++++++++ lib/alerts-tool.ts | 137 +++++++++++++++ lib/alerts.ts | 78 +++++++++ lib/blob-cache.ts | 79 +++++++++ lib/blob.ts | 187 ++++++++++++++++++++ lib/depscore-tool.ts | 14 ++ lib/files.ts | 190 ++++++++++++++++++++ lib/organizations-tool.ts | 55 ++++++ lib/organizations.ts | 32 ++++ lib/package-files-tool.ts | 356 ++++++++++++++++++++++++++++++++++++++ lib/purl.ts | 44 ++++- lib/threat-feed-tool.ts | 143 +++++++++++++++ lib/threatFeed.ts | 90 ++++++++++ purl.test.ts | 37 ++++ 15 files changed, 1858 insertions(+), 9 deletions(-) create mode 100644 blob.test.ts create mode 100644 files.test.ts create mode 100644 lib/alerts-tool.ts create mode 100644 lib/alerts.ts create mode 100644 lib/blob-cache.ts create mode 100644 lib/blob.ts create mode 100644 lib/files.ts create mode 100644 lib/organizations-tool.ts create mode 100644 lib/organizations.ts create mode 100644 lib/package-files-tool.ts create mode 100644 lib/threat-feed-tool.ts create mode 100644 lib/threatFeed.ts diff --git a/blob.test.ts b/blob.test.ts new file mode 100644 index 0000000..c9c7307 --- /dev/null +++ b/blob.test.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env node +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fetchBlob } from './lib/blob.ts' + +test('fetchBlob', async (t) => { + await t.test('returns text for UTF-8 content', async () => { + let capturedUrl = '' + const stubFetch = async (input: string | URL | Request, init?: RequestInit) => { + capturedUrl = String(input) + assert.equal((init?.headers as Record)?.['user-agent'], 'socket-mcp/test') + return new Response('hello world', { + status: 200, + headers: { 'content-type': 'text/plain' } + }) + } + + const result = await fetchBlob('Qabc', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch, + userAgent: 'socket-mcp/test' + }) + + assert.equal(capturedUrl, 'https://socketusercontent.com/blob/Qabc') + assert.equal(result.text, 'hello world') + assert.equal(result.bytes, 11) + assert.equal(result.binary, false) + assert.equal(result.truncated, false) + assert.equal(result.contentType, 'text/plain') + }) + + await t.test('flags content with NUL bytes as binary', async () => { + const bytes = new Uint8Array([0x48, 0x65, 0x00, 0x6c, 0x6c, 0x6f]) // "He\0llo" + const stubFetch = async () => new Response(bytes, { status: 200 }) + const result = await fetchBlob('Qbin', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }) + assert.equal(result.binary, true) + assert.equal(result.text, '') + assert.equal(result.bytes, 6) + }) + + await t.test('flags invalid UTF-8 as binary', async () => { + // Invalid UTF-8: 0xC3 followed by an ASCII byte (continuation expected). + // Pad to >4096 bytes so the NUL pre-check doesn't trigger. + const bytes = new Uint8Array(5000) + bytes.fill(0x41) // 'A' + bytes[4500] = 0xc3 + bytes[4501] = 0x28 + const stubFetch = async () => new Response(bytes, { status: 200 }) + const result = await fetchBlob('Qbad', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }) + assert.equal(result.binary, true) + }) + + await t.test('truncates blobs larger than maxBytes', async () => { + const big = new Uint8Array(2048) + big.fill(0x41) + const stubFetch = async () => new Response(big, { status: 200 }) + const result = await fetchBlob('Qbig', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch, + maxBytes: 1024 + }) + assert.equal(result.bytes, 2048, 'reports the full size') + assert.equal(result.truncated, true) + assert.equal(result.text.length, 1024) + }) + + await t.test('throws on non-2xx with status and body', async () => { + const stubFetch = async () => new Response('gone', { status: 404 }) + await assert.rejects( + fetchBlob('Qmissing', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }), + /blob fetch 404 for .* gone/ + ) + }) + + await t.test('merges extraHeaders into the request', async () => { + let capturedHeaders: Record | undefined + const stubFetch = async (_input: string | URL | Request, init?: RequestInit) => { + capturedHeaders = init?.headers as Record | undefined + return new Response('x', { status: 200 }) + } + await fetchBlob('Qa', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch, + userAgent: 'socket-mcp/test', + extraHeaders: { 'tuckner-mcp-test': 'abc123' } + }) + assert.equal(capturedHeaders?.['user-agent'], 'socket-mcp/test') + assert.equal(capturedHeaders?.['tuckner-mcp-test'], 'abc123') + }) + + await t.test('reassembles S-prefixed chunked blobs via the Q-swapped manifest', async () => { + const calls: string[] = [] + const sHash = 'Sxt09IczWTqd76A0fOmQ9RuiScBju_IEMV3495LjEG9k' + const expectedManifestHash = 'Qxt09IczWTqd76A0fOmQ9RuiScBju_IEMV3495LjEG9k' + const manifest = { + _version: '2', + size: 12, + chunks: ['Qchunk0', 'Qchunk1'], + offset: [0, 6] + } + const stubFetch = async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url.endsWith(`/blob/${expectedManifestHash}`)) { + return new Response(JSON.stringify(manifest), { status: 200, headers: { 'content-type': 'application/json' } }) + } + if (url.endsWith('/blob/Qchunk0')) return new Response('hello ', { status: 200 }) + if (url.endsWith('/blob/Qchunk1')) return new Response('world!', { status: 200 }) + return new Response('not found', { status: 404 }) + } + + const result = await fetchBlob(sHash, { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }) + + assert.equal(result.text, 'hello world!') + assert.equal(result.bytes, 12, 'reports manifest size') + assert.equal(result.binary, false) + assert.equal(result.truncated, false) + assert.equal(calls[0], `https://socketusercontent.com/blob/${expectedManifestHash}`, 'fetches manifest first') + assert.ok(calls.includes('https://socketusercontent.com/blob/Qchunk0')) + assert.ok(calls.includes('https://socketusercontent.com/blob/Qchunk1')) + }) + + await t.test('chunked: stops fetching chunks past maxBytes when offsets are present', async () => { + const calls: string[] = [] + const manifest = { + _version: '2', + size: 192, + chunks: ['Qa', 'Qb', 'Qc'], + offset: [0, 64, 128] + } + const stubFetch = async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url.endsWith('/blob/Qmid')) { + return new Response(JSON.stringify(manifest), { status: 200 }) + } + const body = new Uint8Array(64) + body.fill(0x41) + return new Response(body, { status: 200 }) + } + + const result = await fetchBlob('Smid', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch, + maxBytes: 80 // covers chunk 0 fully + part of chunk 1; chunk 2 starts at 128 >= 80 → skip + }) + + assert.equal(result.bytes, 192, 'reports full size from manifest') + assert.equal(result.truncated, true) + assert.equal(result.text.length, 80) + assert.ok(calls.includes('https://socketusercontent.com/blob/Qa')) + assert.ok(calls.includes('https://socketusercontent.com/blob/Qb')) + assert.ok(!calls.includes('https://socketusercontent.com/blob/Qc'), 'skips chunks past maxBytes') + }) + + await t.test('chunked: throws when manifest is not valid JSON', async () => { + const stubFetch = async () => new Response('definitely not json', { status: 200 }) + await assert.rejects( + fetchBlob('Sbroken', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }), + /chunked blob manifest.*not valid JSON/ + ) + }) + + await t.test('encodes hash and strips trailing slash from baseUrl', async () => { + let capturedUrl = '' + const stubFetch = async (input: string | URL | Request) => { + capturedUrl = String(input) + return new Response('x', { status: 200 }) + } + await fetchBlob('Qa/b+c', { + baseUrl: 'https://socketusercontent.com/', + fetchFn: stubFetch as typeof fetch + }) + assert.equal(capturedUrl, 'https://socketusercontent.com/blob/Qa%2Fb%2Bc') + }) +}) diff --git a/files.test.ts b/files.test.ts new file mode 100644 index 0000000..97caa76 --- /dev/null +++ b/files.test.ts @@ -0,0 +1,234 @@ +#!/usr/bin/env node +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + extractFileList, + fetchFileList, + renderTree +} from './lib/files.ts' + +test('extractFileList', async (t) => { + await t.test('parses array of file/dir entries', () => { + const files = extractFileList({ + files: [ + { path: 'package', type: 'dir' }, + { path: 'package/LICENSE', type: 'file', size: 1952, hash: 'Q9x' }, + { path: 'package/index.js', type: 'file', size: 100, hash: 'Qab' } + ] + }) + assert.equal(files.length, 3) + const license = files.find(f => f.path === 'package/LICENSE')! + const dir = files.find(f => f.path === 'package')! + assert.equal(dir.type, 'dir') + assert.equal(license.type, 'file') + assert.equal(license.size, 1952) + assert.equal(license.hash, undefined, 'hash excluded by default') + }) + + await t.test('includes hashes when requested', () => { + const files = extractFileList( + { files: [{ path: 'a.js', type: 'file', size: 100, hash: 'Qa' }] }, + { includeHashes: true } + ) + assert.equal(files[0]!.hash, 'Qa') + }) + + await t.test('skips entries without path', () => { + const files = extractFileList({ + files: [ + { path: 'a.js', type: 'file', size: 1 }, + { type: 'file', size: 2 }, + { path: '', type: 'file', size: 3 } + ] + }) + assert.equal(files.length, 1) + assert.equal(files[0]!.path, 'a.js') + }) + + await t.test('sorts entries by path', () => { + const files = extractFileList({ + files: [ + { path: 'z.js', type: 'file' }, + { path: 'a.js', type: 'file' }, + { path: 'm.js', type: 'file' } + ] + }) + assert.deepEqual(files.map(f => f.path), ['a.js', 'm.js', 'z.js']) + }) + + await t.test('empty/missing files returns empty list', () => { + assert.equal(extractFileList({}).length, 0) + assert.equal(extractFileList({ files: [] }).length, 0) + }) +}) + +test('renderTree', async (t) => { + await t.test('flat layout under one directory', () => { + const tree = renderTree([ + { path: 'package', type: 'dir' }, + { path: 'package/LICENSE', type: 'file', size: 1952 }, + { path: 'package/README.md', type: 'file', size: 1107 } + ]) + assert.equal( + tree, + [ + '└── package/', + ' ├── LICENSE 1.9K', + ' └── README.md 1.1K' + ].join('\n') + ) + }) + + await t.test('directories sort before files at same depth', () => { + const tree = renderTree([ + { path: 'src/a.js', type: 'file', size: 100 }, + { path: 'index.js', type: 'file', size: 200 }, + { path: 'README.md', type: 'file', size: 50 } + ]) + const lines = tree.split('\n') + assert.equal(lines[0], '├── src/') + assert.equal(lines[1], '│ └── a.js 100B') + assert.equal(lines[2], '├── index.js 200B') + assert.equal(lines[3], '└── README.md 50B') + }) + + await t.test('formats sizes in B/K/M', () => { + const tree = renderTree([ + { path: 'tiny.txt', type: 'file', size: 500 }, + { path: 'medium.bin', type: 'file', size: 2048 }, + { path: 'big.bin', type: 'file', size: 5 * 1024 * 1024 } + ]) + assert.match(tree, /tiny\.txt {2}500B/) + assert.match(tree, /medium\.bin {2}2\.0K/) + assert.match(tree, /big\.bin {2}5\.0M/) + }) + + await t.test('shows hash when showHash enabled', () => { + const tree = renderTree( + [{ path: 'a.js', type: 'file', size: 100, hash: 'QabXYZ' }], + { showHash: true } + ) + assert.match(tree, /a\.js {2}100B {2}QabXYZ/) + }) + + await t.test('omits size when showSize false', () => { + const tree = renderTree( + [{ path: 'a.js', type: 'file', size: 100 }], + { showSize: false } + ) + assert.equal(tree, '└── a.js') + }) + + await t.test('infers nested directories from file paths alone', () => { + const tree = renderTree([ + { path: 'src/utils/helper.js', type: 'file', size: 100 } + ]) + assert.equal( + tree, + [ + '└── src/', + ' └── utils/', + ' └── helper.js 100B' + ].join('\n') + ) + }) + + await t.test('empty input returns empty string', () => { + assert.equal(renderTree([]), '') + }) +}) + +test('fetchFileList', async (t) => { + await t.test('builds correct URL and returns tree + totals', async () => { + let capturedUrl = '' + let capturedHeaders: Record | undefined + const stubFetch = async (input: string | URL | Request, init?: RequestInit) => { + capturedUrl = String(input) + capturedHeaders = init?.headers as Record | undefined + return new Response( + JSON.stringify({ + files: [ + { path: 'package', type: 'dir' }, + { path: 'package/index.js', type: 'file', size: 100, hash: 'Qa' } + ] + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + + const result = await fetchFileList('pkg:npm/lodash@4.17.21', { + baseUrl: 'https://api.socket.dev', + fetchFn: stubFetch as typeof fetch, + userAgent: 'socket-mcp/test', + authToken: 'secret-token' + }) + + assert.equal( + capturedUrl, + 'https://api.socket.dev/v0/purl/file-list/' + encodeURIComponent('pkg:npm/lodash@4.17.21') + ) + assert.equal(capturedHeaders?.['user-agent'], 'socket-mcp/test') + assert.equal(capturedHeaders?.['authorization'], 'Bearer secret-token') + assert.equal(result.fileCount, 1, 'directory entries do not count toward fileCount') + assert.equal(result.totalBytes, 100) + assert.match(result.tree, /package\//) + assert.match(result.tree, /index\.js {2}100B/) + }) + + await t.test('throws with status and body on non-2xx', async () => { + const stubFetch = async () => new Response('not found', { status: 404 }) + await assert.rejects( + fetchFileList('pkg:npm/missing@1.0.0', { + baseUrl: 'https://api.socket.dev', + fetchFn: stubFetch as typeof fetch + }), + /file-list endpoint 404 for .* not found/ + ) + }) + + await t.test('merges extraHeaders into the request', async () => { + let capturedHeaders: Record | undefined + const stubFetch = async (_input: string | URL | Request, init?: RequestInit) => { + capturedHeaders = init?.headers as Record | undefined + return new Response(JSON.stringify({ files: [] }), { status: 200 }) + } + await fetchFileList('pkg:npm/lodash@4.17.21', { + baseUrl: 'https://api.socket.dev', + fetchFn: stubFetch as typeof fetch, + extraHeaders: { 'tuckner-mcp-test': 'abc123' } + }) + assert.equal(capturedHeaders?.['tuckner-mcp-test'], 'abc123') + assert.equal(capturedHeaders?.['accept'], 'application/json') + }) + + await t.test('strips trailing slash from baseUrl', async () => { + let capturedUrl = '' + const stubFetch = async (input: string | URL | Request) => { + capturedUrl = String(input) + return new Response(JSON.stringify({ files: [] }), { status: 200 }) + } + await fetchFileList('pkg:npm/lodash@4.17.21', { + baseUrl: 'https://api.socket.dev/', + fetchFn: stubFetch as typeof fetch + }) + assert.ok(capturedUrl.startsWith('https://api.socket.dev/v0/purl/file-list/')) + assert.ok(!capturedUrl.includes('socket.dev//')) + }) + + await t.test('url-encodes PURL qualifiers in the path', async () => { + let capturedUrl = '' + const stubFetch = async (input: string | URL | Request) => { + capturedUrl = String(input) + return new Response(JSON.stringify({ files: [] }), { status: 200 }) + } + await fetchFileList('pkg:pypi/numpy@1.26.0?artifact_id=numpy-1.26.0.tar.gz', { + baseUrl: 'https://api.socket.dev', + fetchFn: stubFetch as typeof fetch + }) + // ? and = inside the PURL must be percent-encoded so they don't get + // interpreted as query-string delimiters. + assert.ok(capturedUrl.includes('%3F'), 'expected ? to be percent-encoded') + assert.ok(capturedUrl.includes('%3D'), 'expected = to be percent-encoded') + assert.equal(capturedUrl.split('?').length, 1, 'no query string on the request') + }) +}) diff --git a/lib/alerts-tool.ts b/lib/alerts-tool.ts new file mode 100644 index 0000000..6d0920b --- /dev/null +++ b/lib/alerts-tool.ts @@ -0,0 +1,137 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { getSocketApiUrl } from './env.ts' +import { fetchAlerts } from './alerts.ts' +import { getStaticApiKey } from './depscore-tool.ts' +import { logger } from './logger.ts' +import { VERSION } from './version.ts' + +const SOCKET_API_BASE_URL = + getSocketApiUrl() || 'https://api.socket.dev' + +const AUTH_REQUIRED_MSG = + 'Authentication is required. Configure SOCKET_API_TOKEN (or a legacy alias) for stdio mode or connect through OAuth-enabled HTTP mode.' + +export function registerAlertsTool(srv: McpServer): void { + srv.registerTool( + 'alerts', + { + title: 'List Alerts Tool', + description: + "List the latest security alerts for a Socket organization with the `alerts` tool. Requires `org_slug` — call the `organizations` tool first if you don't have it. Supports filtering by severity, category, status, artifact type/name, alert type, and repo. Use this to surface supply-chain, vulnerability, quality, license, and maintenance issues across the org's monitored packages. Results are paginated — pass the previous response's `endCursor` as `cursor` to fetch the next page.", + inputSchema: { + org_slug: z + .string() + .describe( + 'Organization slug, e.g. "my-org" (use the `organizations` tool to discover this)', + ), + severity: z + .string() + .optional() + .describe( + 'Comma-separated severities to include: subset of low,medium,high,critical', + ), + status: z + .enum(['open', 'cleared']) + .optional() + .describe('Filter to open or cleared alerts'), + category: z + .string() + .optional() + .describe( + 'Comma-separated categories: subset of supplyChainRisk,maintenance,quality,license,vulnerability', + ), + artifact_type: z + .string() + .optional() + .describe( + 'Comma-separated ecosystems: subset of npm,pypi,gem,maven,golang,nuget,cargo,chrome,openvsx', + ), + artifact_name: z + .string() + .optional() + .describe('Filter to a specific package name'), + alert_type: z + .string() + .optional() + .describe( + 'Comma-separated Socket alert types (e.g. "usesEval,unmaintained")', + ), + repo_slug: z.string().optional().describe('Comma-separated repo slugs'), + per_page: z + .number() + .int() + .min(1) + .max(5000) + .optional() + .describe('Results per page (default 100, max 5000)'), + cursor: z + .string() + .optional() + .describe( + "Pagination cursor — the `endCursor` from a previous response's metadata", + ), + }, + annotations: { + readOnlyHint: true, + }, + }, + async (args, extra) => { + logger.info( + { + tool: 'alerts', + org_slug: args.org_slug, + filters: { + severity: args.severity, + status: args.status, + category: args.category, + artifact_type: args.artifact_type, + alert_type: args.alert_type, + }, + }, + 'tool invoked', + ) + const accessToken = extra.authInfo?.token || getStaticApiKey() + if (!accessToken) { + return { + content: [{ type: 'text', text: AUTH_REQUIRED_MSG }], + isError: true, + } + } + try { + const data = await fetchAlerts({ + baseUrl: SOCKET_API_BASE_URL, + orgSlug: args.org_slug, + userAgent: `socket-mcp/${VERSION}`, + authToken: accessToken, + filters: { + ...(args.severity ? { severity: args.severity } : {}), + ...(args.status ? { status: args.status } : {}), + ...(args.category ? { category: args.category } : {}), + ...(args.artifact_type + ? { artifactType: args.artifact_type } + : {}), + ...(args.artifact_name + ? { artifactName: args.artifact_name } + : {}), + ...(args.alert_type ? { alertType: args.alert_type } : {}), + ...(args.repo_slug ? { repoSlug: args.repo_slug } : {}), + perPage: args.per_page ?? 100, + ...(args.cursor ? { cursor: args.cursor } : {}), + }, + }) + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching alerts for ${args.org_slug}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true, + } + } + }, + ) +} diff --git a/lib/alerts.ts b/lib/alerts.ts new file mode 100644 index 0000000..004ccee --- /dev/null +++ b/lib/alerts.ts @@ -0,0 +1,78 @@ +export interface AlertsFilters { + /** Comma-separated subset of: low,medium,high,critical */ + severity?: string + /** Single value: open | cleared */ + status?: 'open' | 'cleared' + /** Comma-separated subset of: supplyChainRisk,maintenance,quality,license,vulnerability */ + category?: string + /** Comma-separated ecosystems: npm,pypi,gem,maven,golang,nuget,cargo,chrome,openvsx */ + artifactType?: string + /** Single package name to filter to */ + artifactName?: string + /** Comma-separated Socket alert types (e.g. "usesEval,unmaintained") */ + alertType?: string + /** Comma-separated repo slugs */ + repoSlug?: string + /** 1..5000. The API caps at 5000 and defaults to 1000. */ + perPage?: number + /** Pagination cursor from a previous response's endCursor */ + cursor?: string +} + +export interface FetchAlertsOptions { + baseUrl: string + orgSlug: string + filters?: AlertsFilters + fetchFn?: typeof fetch + userAgent?: string + /** Socket access token, sent as `Authorization: Bearer ` when set. */ + authToken?: string + extraHeaders?: Record +} + +/** + * Map the curated `AlertsFilters` shape to the API's flat `filters.*` query + * params. Only set values are included — undefined keys are skipped. + */ +export function buildAlertsQuery ( + filters: AlertsFilters | undefined, + perPageFallback?: number +): URLSearchParams { + const params = new URLSearchParams() + const f = filters ?? {} + if (f.severity) params.set('filters.alertSeverity', f.severity) + if (f.status) params.set('filters.alertStatus', f.status) + if (f.category) params.set('filters.alertCategory', f.category) + if (f.artifactType) params.set('filters.artifactType', f.artifactType) + if (f.artifactName) params.set('filters.artifactName', f.artifactName) + if (f.alertType) params.set('filters.alertType', f.alertType) + if (f.repoSlug) params.set('filters.repoSlug', f.repoSlug) + const perPage = f.perPage ?? perPageFallback + if (typeof perPage === 'number') params.set('per_page', String(perPage)) + if (f.cursor) params.set('startAfterCursor', f.cursor) + return params +} + +/** + * Fetch the latest alerts for an organization from + * `GET /v0/orgs/{org_slug}/alerts`. Returns the parsed JSON body untouched. + */ +export async function fetchAlerts ( + options: FetchAlertsOptions +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, '') + const qs = buildAlertsQuery(options.filters).toString() + const url = `${baseUrl}/v0/orgs/${encodeURIComponent(options.orgSlug)}/alerts${qs ? `?${qs}` : ''}` + + const fetchFn = options.fetchFn ?? fetch + const headers: Record = { accept: 'application/json' } + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.authToken) headers['authorization'] = `Bearer ${options.authToken}` + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + + const res = await fetchFn(url, { headers }) + if (!res.ok) { + throw new Error(`alerts endpoint ${res.status}: ${await res.text()}`) + } + return res.json() +} diff --git a/lib/blob-cache.ts b/lib/blob-cache.ts new file mode 100644 index 0000000..ada57b9 --- /dev/null +++ b/lib/blob-cache.ts @@ -0,0 +1,79 @@ +import { fetchBlob, type BlobResult } from './blob.ts' +import { logger } from './logger.ts' + +// Process-wide LRU blob cache keyed by content-addressed hash. Survives across +// stateless HTTP requests (each request gets a fresh McpServer) so repeated +// reads/greps of the same file skip the socketusercontent fetch. +const BLOB_CACHE_MAX_BYTES = (() => { + const raw = process.env['SOCKET_BLOB_CACHE_BYTES'] + if (!raw) { + return 64 * 1024 * 1024 + } + const n = Number(raw) + return Number.isFinite(n) && n > 0 ? n : 64 * 1024 * 1024 +})() + +const SOCKET_BLOB_URL = + process.env['SOCKET_BLOB_URL'] || 'https://socketusercontent.com' + +const BROWSER_USER_AGENT = + process.env['SOCKET_BROWSER_USER_AGENT'] || + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36' + +// Optional WAF/Cloudflare bypass header sent on every socketusercontent.com +// request. Leave value empty to disable. +const SOCKET_BYPASS_HEADER_NAME = + process.env['SOCKET_BYPASS_HEADER_NAME'] || '' +const SOCKET_BYPASS_HEADER_VALUE = + process.env['SOCKET_BYPASS_HEADER_VALUE'] || '' +const BYPASS_HEADERS: Record = + SOCKET_BYPASS_HEADER_NAME && SOCKET_BYPASS_HEADER_VALUE + ? { [SOCKET_BYPASS_HEADER_NAME]: SOCKET_BYPASS_HEADER_VALUE } + : {} + +const cache = new Map() +let cacheBytes = 0 + +function blobWeight(blob: BlobResult): number { + // Account for a small fixed overhead so binary entries (empty text) still + // occupy a slot. + return blob.text.length + 256 +} + +function evict(): void { + while (cacheBytes > BLOB_CACHE_MAX_BYTES && cache.size > 0) { + const oldest = cache.keys().next().value + if (oldest === undefined) { + break + } + const victim = cache.get(oldest) + cache.delete(oldest) + if (victim) { + cacheBytes -= blobWeight(victim) + } + logger.debug( + { hash: oldest, cacheBytes, cacheSize: cache.size }, + 'blob cache evict', + ) + } +} + +export async function getOrFetchBlob(hash: string): Promise { + const cached = cache.get(hash) + if (cached) { + // LRU bump: re-insert so this entry moves to the end of iteration order. + cache.delete(hash) + cache.set(hash, cached) + return cached + } + const blob = await fetchBlob(hash, { + baseUrl: SOCKET_BLOB_URL, + userAgent: BROWSER_USER_AGENT, + extraHeaders: BYPASS_HEADERS, + onRequest: url => logger.debug({ url }, 'blob request'), + }) + cache.set(hash, blob) + cacheBytes += blobWeight(blob) + evict() + return blob +} diff --git a/lib/blob.ts b/lib/blob.ts new file mode 100644 index 0000000..ba90920 --- /dev/null +++ b/lib/blob.ts @@ -0,0 +1,187 @@ +export interface BlobResult { + bytes: number + contentType: string | null + binary: boolean + truncated: boolean + text: string +} + +export interface FetchBlobOptions { + baseUrl: string + fetchFn?: typeof fetch + userAgent?: string + /** Extra headers merged into the outbound request. Values overwrite user-agent if it's set here. */ + extraHeaders?: Record + /** Hard cap on bytes returned. Larger blobs are truncated and flagged. */ + maxBytes?: number + /** Called with the resolved URL right before each request is dispatched (chunked blobs fire this multiple times). */ + onRequest?: (url: string) => void +} + +const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MB + +interface ChunkedManifest { + _version?: string + size?: number + chunks?: unknown + offset?: unknown +} + +/** + * Decode bytes as UTF-8 in fatal mode to detect binary content. Returns null + * if the bytes don't form valid UTF-8 or contain a NUL byte (typical binary marker). + */ +function tryDecodeText (bytes: Uint8Array): string | null { + // NUL bytes inside the first 4 KB strongly suggest binary; cheap pre-check. + const probeEnd = Math.min(bytes.length, 4096) + for (let i = 0; i < probeEnd; i++) { + if (bytes[i] === 0) return null + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return null + } +} + +interface RawFetchResult { + bytes: Uint8Array + contentType: string | null +} + +/** Single GET against `/blob/`. No prefix logic. */ +async function fetchRawBytes (hash: string, options: FetchBlobOptions): Promise { + const fetchFn = options.fetchFn ?? fetch + const url = `${options.baseUrl.replace(/\/$/, '')}/blob/${encodeURIComponent(hash)}` + + const headers: Record = {} + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + + options.onRequest?.(url) + let res: Response + try { + res = await fetchFn(url, { headers }) + } catch (e) { + const cause = e as Error + throw new Error(`blob request to ${url} failed: ${cause.message}`) + } + if (!res.ok) { + throw new Error(`blob fetch ${res.status} for ${url}: ${await res.text()}`) + } + + return { + bytes: new Uint8Array(await res.arrayBuffer()), + contentType: res.headers.get('content-type') + } +} + +interface ChunkedFetchResult { + /** Concatenated chunk bytes, possibly less than `totalSize` when stopped early at maxBytes. */ + bytes: Uint8Array + /** Total file size from the manifest, regardless of how many chunks were fetched. */ + totalSize: number +} + +/** + * Resolve an S-prefixed chunked blob: fetch the manifest at the Q-swapped hash, + * then fetch the chunks listed in the manifest and concatenate. Honors `maxBytes` + * by stopping at the first chunk past the cap (using the manifest's `offset` array + * when present, otherwise running totals). + */ +async function fetchChunkedBytes ( + sHash: string, + options: FetchBlobOptions, + maxBytes: number +): Promise { + const manifestHash = 'Q' + sHash.slice(1) + const manifestRaw = await fetchRawBytes(manifestHash, options) + + let manifest: ChunkedManifest + try { + manifest = JSON.parse(new TextDecoder('utf-8').decode(manifestRaw.bytes)) as ChunkedManifest + } catch (e) { + throw new Error(`chunked blob manifest at ${manifestHash} is not valid JSON: ${(e as Error).message}`) + } + if (!Array.isArray(manifest.chunks) || manifest.chunks.some(c => typeof c !== 'string' || !c)) { + throw new Error(`chunked blob manifest at ${manifestHash} is missing a valid 'chunks' array`) + } + const chunks = manifest.chunks as string[] + const totalSize = typeof manifest.size === 'number' ? manifest.size : -1 + const offsets = Array.isArray(manifest.offset) && manifest.offset.length === chunks.length + ? manifest.offset.filter((n): n is number => typeof n === 'number') + : null + + // Decide how many chunks we actually need. With offsets we can stop at the first + // chunk whose start is at or past maxBytes; without, we fetch everything and + // truncate after concatenation. + let needed = chunks.length + if (offsets && offsets.length === chunks.length) { + needed = 0 + for (let i = 0; i < chunks.length; i++) { + if (offsets[i]! >= maxBytes) break + needed = i + 1 + } + } + + const chunkBuffers = await Promise.all( + chunks.slice(0, needed).map(async c => (await fetchRawBytes(c, options)).bytes) + ) + + let total = 0 + for (const cb of chunkBuffers) total += cb.length + const concat = new Uint8Array(total) + let pos = 0 + for (const cb of chunkBuffers) { + concat.set(cb, pos) + pos += cb.length + } + + return { + bytes: concat, + totalSize: totalSize >= 0 ? totalSize : total + } +} + +/** + * Fetch a content-addressed blob from socketusercontent.com (or compatible host). + * Handles both single-blob (Q-prefixed) and chunked (S-prefixed) hashes: chunked + * blobs are reconstructed by fetching the manifest at the Q-swapped hash and then + * pulling each listed chunk. Returns text content when the bytes decode as UTF-8 + * without NULs; otherwise marks the response as binary so callers can refuse to + * ship it to an LLM. Bytes beyond `maxBytes` are dropped. + */ +export async function fetchBlob ( + hash: string, + options: FetchBlobOptions +): Promise { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + + let buf: Uint8Array + let contentType: string | null + let originalSize: number + + if (hash[0] === 'S') { + const chunked = await fetchChunkedBytes(hash, options, maxBytes) + buf = chunked.bytes + originalSize = chunked.totalSize + contentType = null + } else { + const raw = await fetchRawBytes(hash, options) + buf = raw.bytes + originalSize = buf.length + contentType = raw.contentType + } + + const truncated = originalSize > maxBytes + const bodyBytes = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf + const decoded = tryDecodeText(bodyBytes) + + return { + bytes: originalSize, + contentType, + binary: decoded === null, + truncated, + text: decoded ?? '' + } +} diff --git a/lib/depscore-tool.ts b/lib/depscore-tool.ts index 864a8c1..e8d728e 100644 --- a/lib/depscore-tool.ts +++ b/lib/depscore-tool.ts @@ -8,6 +8,10 @@ import { buildSocketHeaders } from './http-helpers.ts' import { logger } from './logger.ts' import { buildPurl } from './purl.ts' import { VERSION } from './version.ts' +import { registerAlertsTool } from './alerts-tool.ts' +import { registerOrganizationsTool } from './organizations-tool.ts' +import { registerPackageFilesTools } from './package-files-tool.ts' +import { registerThreatFeedTool } from './threat-feed-tool.ts' interface DepscorePackageInput { ecosystem?: string | undefined @@ -108,6 +112,10 @@ export function createConfiguredServer(): McpServer { async ({ packages, platform }, extra) => handleDepscore(packages, platform, extra.authInfo?.token), ) + registerOrganizationsTool(srv) + registerAlertsTool(srv) + registerThreatFeedTool(srv) + registerPackageFilesTools(srv) return srv } @@ -268,6 +276,12 @@ export function parseSinglePackageBody(responseText: string): string[] { return [formatScoreLine(jsonData)] } +// Read the boot-time static API key. Used by tool modules outside this file +// that share the same token-resolution chain as depscore. +export function getStaticApiKey(): string { + return staticApiKey +} + // Set the static API key. Called once during boot from index.ts. // Subsequent calls overwrite — only the most recent value is used. export function setStaticApiKey(value: string): void { diff --git a/lib/files.ts b/lib/files.ts new file mode 100644 index 0000000..5e23a7b --- /dev/null +++ b/lib/files.ts @@ -0,0 +1,190 @@ +export interface FileListEntry { + path: string + type: 'file' | 'dir' + size?: number + hash?: string +} + +export interface FileListResult { + purl: string + fileCount: number + totalBytes: number + files: FileListEntry[] + tree: string +} + +interface RawFileEntry { + path?: unknown + type?: unknown + size?: unknown + hash?: unknown +} + +interface RawFileListResponse { + files?: RawFileEntry[] +} + +/** + * Normalize the raw `files` array into a sorted, typed list. Hashes are + * dropped unless `includeHashes` is set. + */ +export function extractFileList ( + response: RawFileListResponse, + options: { includeHashes?: boolean } = {} +): FileListEntry[] { + const raw = response.files ?? [] + const entries: FileListEntry[] = [] + for (const item of raw) { + if (!item || typeof item.path !== 'string' || !item.path) continue + const type: 'file' | 'dir' = item.type === 'dir' ? 'dir' : 'file' + const entry: FileListEntry = { path: item.path, type } + if (typeof item.size === 'number') entry.size = item.size + if (options.includeHashes && typeof item.hash === 'string') entry.hash = item.hash + entries.push(entry) + } + entries.sort((a, b) => a.path.localeCompare(b.path)) + return entries +} + +function formatSize (bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K` + return `${(bytes / (1024 * 1024)).toFixed(1)}M` +} + +interface TreeNode { + name: string + isFile: boolean + size?: number + hash?: string + children: Map +} + +function buildTree (entries: FileListEntry[]): TreeNode { + const root: TreeNode = { name: '', isFile: false, children: new Map() } + for (const entry of entries) { + const parts = entry.path.split('/').filter(Boolean) + if (!parts.length) continue + let cur = root + for (let i = 0; i < parts.length; i++) { + const part = parts[i]! + let next = cur.children.get(part) + if (!next) { + next = { name: part, isFile: false, children: new Map() } + cur.children.set(part, next) + } + const isLeaf = i === parts.length - 1 + if (isLeaf && entry.type === 'file') { + next.isFile = true + if (entry.size !== undefined) next.size = entry.size + if (entry.hash !== undefined) next.hash = entry.hash + } + cur = next + } + } + return root +} + +/** + * Render a sorted list of file entries as an indented tree using box-drawing + * characters. Directories sort before files; siblings sort alphabetically. + * Files include size and (optionally) hash inline. + */ +export function renderTree ( + entries: FileListEntry[], + options: { showSize?: boolean, showHash?: boolean } = {} +): string { + const showSize = options.showSize !== false + const showHash = options.showHash === true + const root = buildTree(entries) + const lines: string[] = [] + + const walk = (node: TreeNode, prefix: string) => { + const kids = Array.from(node.children.values()).sort((a, b) => { + if (a.isFile !== b.isFile) return a.isFile ? 1 : -1 + return a.name.localeCompare(b.name) + }) + for (let i = 0; i < kids.length; i++) { + const kid = kids[i]! + const last = i === kids.length - 1 + const branch = last ? '└── ' : '├── ' + const cont = last ? ' ' : '│ ' + let line = prefix + branch + kid.name + if (kid.isFile) { + const meta: string[] = [] + if (showSize && kid.size !== undefined) meta.push(formatSize(kid.size)) + if (showHash && kid.hash) meta.push(kid.hash) + if (meta.length) line += ' ' + meta.join(' ') + } else { + line += '/' + } + lines.push(line) + if (!kid.isFile && kid.children.size > 0) { + walk(kid, prefix + cont) + } + } + } + + walk(root, '') + return lines.join('\n') +} + +export interface FetchFileListOptions { + baseUrl: string + fetchFn?: typeof fetch + includeHashes?: boolean + userAgent?: string + /** Socket access token, sent as `Authorization: Bearer ` when set. */ + authToken?: string + /** Extra headers merged into the outbound request (e.g. WAF bypass token). */ + extraHeaders?: Record + /** Called with the resolved URL right before the request is dispatched. */ + onRequest?: (url: string) => void +} + +/** + * Fetch the file manifest for a PURL from the Socket API's + * `GET /v0/purl/file-list/{purl}` endpoint. The full PURL string is + * URL-encoded into the path. Throws on non-2xx responses with the + * upstream status and body text. + */ +export async function fetchFileList ( + purlStr: string, + options: FetchFileListOptions +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, '') + const url = `${baseUrl}/v0/purl/file-list/${encodeURIComponent(purlStr)}` + + const fetchFn = options.fetchFn ?? fetch + const headers: Record = { accept: 'application/json' } + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.authToken) headers['authorization'] = `Bearer ${options.authToken}` + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + options.onRequest?.(url) + let res: Response + try { + res = await fetchFn(url, { headers }) + } catch (e) { + const cause = e as Error + throw new Error(`file-list request to ${url} failed: ${cause.message}`) + } + if (!res.ok) { + const body = await res.text() + throw new Error(`file-list endpoint ${res.status} for ${url}: ${body}`) + } + + const data = (await res.json()) as RawFileListResponse + const includeHashes = options.includeHashes === true + const files = extractFileList(data, includeHashes ? { includeHashes: true } : {}) + const fileEntries = files.filter(f => f.type === 'file') + const totalBytes = fileEntries.reduce((sum, f) => sum + (f.size ?? 0), 0) + const tree = renderTree(files, { showSize: true, showHash: includeHashes }) + + return { + purl: purlStr, + fileCount: fileEntries.length, + totalBytes, + files, + tree + } +} diff --git a/lib/organizations-tool.ts b/lib/organizations-tool.ts new file mode 100644 index 0000000..8872da7 --- /dev/null +++ b/lib/organizations-tool.ts @@ -0,0 +1,55 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { getSocketApiUrl } from './env.ts' +import { fetchOrganizations } from './organizations.ts' +import { getStaticApiKey } from './depscore-tool.ts' +import { logger } from './logger.ts' +import { VERSION } from './version.ts' + +const SOCKET_API_BASE_URL = + getSocketApiUrl() || 'https://api.socket.dev' + +const AUTH_REQUIRED_MSG = + 'Authentication is required. Configure SOCKET_API_TOKEN (or a legacy alias) for stdio mode or connect through OAuth-enabled HTTP mode.' + +export function registerOrganizationsTool(srv: McpServer): void { + srv.registerTool( + 'organizations', + { + title: 'List Organizations Tool', + description: + "List the Socket organizations the authenticated user belongs to with the `organizations` tool. Use this to discover the `org_slug` values needed by other org-scoped tools (e.g. `alerts`, `threat_feed`), or when the user asks which organizations they have access to.", + inputSchema: {}, + annotations: { + readOnlyHint: true, + }, + }, + async (_args, extra) => { + logger.info({ tool: 'organizations' }, 'tool invoked') + const accessToken = extra.authInfo?.token || getStaticApiKey() + if (!accessToken) { + return { + content: [{ type: 'text', text: AUTH_REQUIRED_MSG }], + isError: true, + } + } + try { + const data = await fetchOrganizations({ + baseUrl: SOCKET_API_BASE_URL, + userAgent: `socket-mcp/${VERSION}`, + authToken: accessToken, + }) + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching organizations: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true, + } + } + }, + ) +} diff --git a/lib/organizations.ts b/lib/organizations.ts new file mode 100644 index 0000000..0c13389 --- /dev/null +++ b/lib/organizations.ts @@ -0,0 +1,32 @@ +export interface FetchOrganizationsOptions { + baseUrl: string + fetchFn?: typeof fetch + userAgent?: string + /** Socket access token, sent as `Authorization: Bearer ` when set. */ + authToken?: string + extraHeaders?: Record +} + +/** + * Fetch the organizations the authenticated user belongs to from + * `GET /v0/organizations`. Returns the parsed JSON body untouched — + * downstream callers decide how to render it. + */ +export async function fetchOrganizations ( + options: FetchOrganizationsOptions +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, '') + const url = `${baseUrl}/v0/organizations` + + const fetchFn = options.fetchFn ?? fetch + const headers: Record = { accept: 'application/json' } + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.authToken) headers['authorization'] = `Bearer ${options.authToken}` + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + + const res = await fetchFn(url, { headers }) + if (!res.ok) { + throw new Error(`organizations endpoint ${res.status}: ${await res.text()}`) + } + return res.json() +} diff --git a/lib/package-files-tool.ts b/lib/package-files-tool.ts new file mode 100644 index 0000000..25876aa --- /dev/null +++ b/lib/package-files-tool.ts @@ -0,0 +1,356 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { getSocketApiUrl } from './env.ts' +import { getStaticApiKey } from './depscore-tool.ts' +import { fetchFileList } from './files.ts' +import { getOrFetchBlob } from './blob-cache.ts' +import { buildPurl } from './purl.ts' +import { logger } from './logger.ts' + +const SOCKET_API_BASE_URL = + getSocketApiUrl() || 'https://api.socket.dev' + +// Internal UA for authenticated calls to socket.dev's file-list endpoint. +const INTERNAL_USER_AGENT = + process.env['SOCKET_INTERNAL_USER_AGENT'] || 'socket-internal-tool/1.0' + +const AUTH_REQUIRED_MSG = + 'Authentication is required. Configure SOCKET_API_TOKEN (or a legacy alias) for stdio mode or connect through OAuth-enabled HTTP mode.' + +function buildPurlForFiles( + ecosystem: string, + depname: string, + version: string, + artifactId?: string, + platform?: string, +): string { + const qualifiers: Record = {} + if (artifactId) { + qualifiers['artifact_id'] = artifactId + } + if (platform) { + qualifiers['platform'] = platform + } + return buildPurl( + ecosystem, + depname, + version, + Object.keys(qualifiers).length ? qualifiers : undefined, + ) +} + +export function registerPackageFilesTools(srv: McpServer): void { + srv.registerTool( + 'package_files', + { + title: 'Package File List Tool', + description: + "List the files published in a package using the `package_files` tool from Socket. Returns a tree of paths and sizes for any package on a supported ecosystem (npm, pypi, gem, cargo, maven, golang, nuget, chrome, openvsx). Useful for inspecting what a dependency ships before installing it. After calling this, use `package_file_contents` with one of the paths to read the file's contents.", + inputSchema: { + ecosystem: z + .string() + .describe( + 'Package ecosystem (e.g., npm, pypi, gem, cargo, maven, golang, nuget, chrome, openvsx)', + ) + .default('npm'), + depname: z + .string() + .describe( + 'Package name (e.g., "lodash", "@babel/core", "org.springframework:spring-core", "meta/pyrefly" for openvsx)', + ), + version: z.string().describe('Package version'), + artifactId: z + .string() + .optional() + .describe( + 'Per-version artifact disambiguator (e.g. PyPI filename, Maven artifact id, NuGet asset). Required when an ecosystem ships multiple artifacts per version.', + ), + platform: z + .string() + .optional() + .describe( + "Platform qualifier for ecosystems with per-OS/arch artifacts (e.g. openvsx: 'linux-x64', 'darwin-arm64', 'win32-x64').", + ), + }, + annotations: { + readOnlyHint: true, + }, + }, + async ({ ecosystem, depname, version, artifactId, platform }, extra) => { + const purlWithQualifiers = buildPurlForFiles( + ecosystem ?? 'npm', + depname, + version, + artifactId, + platform, + ) + logger.info( + { + tool: 'package_files', + ecosystem, + depname, + version, + artifactId, + platform, + purl: purlWithQualifiers, + }, + 'tool invoked', + ) + const accessToken = extra.authInfo?.token || getStaticApiKey() + if (!accessToken) { + return { + content: [{ type: 'text', text: AUTH_REQUIRED_MSG }], + isError: true, + } + } + try { + const result = await fetchFileList(purlWithQualifiers, { + baseUrl: SOCKET_API_BASE_URL, + includeHashes: true, + userAgent: INTERNAL_USER_AGENT, + authToken: accessToken, + onRequest: url => logger.debug({ url }, 'file list request'), + }) + if (result.fileCount === 0) { + return { + content: [ + { type: 'text', text: `No files found for ${result.purl}` }, + ], + } + } + const sizeKb = (result.totalBytes / 1024).toFixed(1) + const header = `${result.purl} — ${result.fileCount} files, ${sizeKb} KB` + return { + content: [{ type: 'text', text: `${header}\n${result.tree}` }], + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching file list for ${purlWithQualifiers}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true, + } + } + }, + ) + + srv.registerTool( + 'package_file_contents', + { + title: 'Package File Contents Tool', + description: + 'Read a single file from a package using the `package_file_contents` tool from Socket. Pass the `hash` printed next to each entry in `package_files` output. Returns up to 1 MB of UTF-8 text; binary files return metadata only.', + inputSchema: { + hash: z + .string() + .describe( + 'Blob hash exactly as shown by `package_files` (the token printed after each file size)', + ), + path: z + .string() + .optional() + .describe( + 'Optional file path for display only; does not affect the lookup', + ), + }, + annotations: { + readOnlyHint: true, + }, + }, + async ({ hash, path }) => { + const label = path ?? hash + logger.info( + { tool: 'package_file_contents', hash, path }, + 'tool invoked', + ) + try { + const blob = await getOrFetchBlob(hash) + if (blob.binary) { + return { + content: [ + { + type: 'text', + text: `${label} appears to be binary (${blob.bytes} bytes, content-type: ${blob.contentType ?? 'unknown'}). Refusing to return binary contents.`, + }, + ], + } + } + const truncationNote = blob.truncated + ? `\n\n[truncated — file is ${blob.bytes} bytes, returning first 1 MB]` + : '' + const header = `${label} (${blob.bytes} bytes)` + return { + content: [ + { + type: 'text', + text: `${header}\n\n${blob.text}${truncationNote}`, + }, + ], + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching blob ${hash}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true, + } + } + }, + ) + + srv.registerTool( + 'package_file_grep', + { + title: 'Package File Grep Tool', + description: + 'Search a single file from a package for lines matching a JavaScript regular expression. Pass the `hash` printed next to each entry in `package_files` output. The file is fetched from Socket once per session and cached, so repeated greps on the same hash skip the network. Returns matching lines with line numbers (grep -n style); binary files are refused. Useful for locating a specific symbol, import, or string inside a dependency without dumping the whole file.', + inputSchema: { + hash: z + .string() + .describe( + 'Blob hash exactly as shown by `package_files` (the token printed after each file size)', + ), + pattern: z + .string() + .describe( + 'JavaScript regular expression. Plain literal strings work too. Anchors and character classes are supported.', + ), + caseInsensitive: z + .boolean() + .optional() + .describe('Match case-insensitively (default: false)'), + contextLines: z + .number() + .int() + .min(0) + .max(5) + .optional() + .describe( + 'Lines of context to show before and after each match (0-5, default: 0)', + ), + maxMatches: z + .number() + .int() + .min(1) + .max(500) + .optional() + .describe( + 'Cap on number of matching lines returned (default: 100, max: 500)', + ), + path: z + .string() + .optional() + .describe( + 'Optional file path for display only; does not affect the lookup', + ), + }, + annotations: { + readOnlyHint: true, + }, + }, + async ({ hash, pattern, caseInsensitive, contextLines, maxMatches, path }) => { + const label = path ?? hash + const cap = maxMatches ?? 100 + const ctx = contextLines ?? 0 + logger.info( + { + tool: 'package_file_grep', + hash, + path, + pattern, + caseInsensitive, + contextLines: ctx, + maxMatches: cap, + }, + 'tool invoked', + ) + let re: RegExp + try { + re = new RegExp(pattern, caseInsensitive ? 'i' : '') + } catch (e) { + const errorMsg = `Invalid regular expression: ${(e as Error).message}` + return { + content: [{ type: 'text', text: errorMsg }], + isError: true, + } + } + try { + const blob = await getOrFetchBlob(hash) + if (blob.binary) { + return { + content: [ + { + type: 'text', + text: `${label} appears to be binary (${blob.bytes} bytes, content-type: ${blob.contentType ?? 'unknown'}). Refusing to grep binary contents.`, + }, + ], + isError: true, + } + } + const lines = blob.text.split('\n') + const matchIndexes: number[] = [] + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i]!)) { + matchIndexes.push(i) + if (matchIndexes.length >= cap) { + break + } + } + } + if (matchIndexes.length === 0) { + return { + content: [ + { + type: 'text', + text: `${label}: no matches for /${pattern}/${caseInsensitive ? 'i' : ''}`, + }, + ], + } + } + const lineWidth = String(lines.length).length + const formatLine = (idx: number, sep: ':' | '-'): string => + `${String(idx + 1).padStart(lineWidth, ' ')}${sep} ${lines[idx]}` + const out: string[] = [] + let lastPrinted = -1 + for (let m = 0; m < matchIndexes.length; m++) { + const matchIdx = matchIndexes[m]! + const start = Math.max(0, matchIdx - ctx) + const end = Math.min(lines.length - 1, matchIdx + ctx) + if (ctx > 0 && lastPrinted >= 0 && start > lastPrinted + 1) { + out.push('--') + } + for (let i = Math.max(start, lastPrinted + 1); i <= end; i++) { + out.push(formatLine(i, i === matchIdx ? ':' : '-')) + } + lastPrinted = end + } + const truncationNote = blob.truncated + ? `\n[note: file is ${blob.bytes} bytes; searched only the first 1 MB]` + : '' + const capNote = + matchIndexes.length >= cap + ? `\n[note: stopped at maxMatches=${cap}; more matches may exist]` + : '' + const header = `${label} — ${matchIndexes.length} match${matchIndexes.length === 1 ? '' : 'es'} for /${pattern}/${caseInsensitive ? 'i' : ''}` + return { + content: [ + { + type: 'text', + text: `${header}\n${out.join('\n')}${truncationNote}${capNote}`, + }, + ], + } + } catch (e) { + const error = e as Error + const errorMsg = `Error grepping blob ${hash}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true, + } + } + }, + ) +} diff --git a/lib/purl.ts b/lib/purl.ts index f0d08e0..a18b19b 100644 --- a/lib/purl.ts +++ b/lib/purl.ts @@ -2,46 +2,72 @@ import { PackageURL } from 'packageurl-js' /** * Build a PURL using packageurl-js for correct encoding across all ecosystems. - * Handles namespace/name splitting per ecosystem (e.g. npm scoped @scope/name, maven groupId:artifactId). + * Handles namespace/name splitting per ecosystem (e.g. npm scoped @scope/name, + * maven groupId:artifactId, openvsx publisher/extension). + * + * The friendly ecosystem name `openvsx` is rewritten to PURL type `vscode` with + * an auto-added `repository_url=https://open-vsx.org` qualifier, matching the + * canonical Socket form (e.g. `pkg:vscode/meta/pyrefly@1.0.0?repository_url=...`). */ export function buildPurl( ecosystem: string, depname: string, version: string, + qualifiers?: Record, ): string { - const type = ecosystem.toLowerCase() + const ecoLower = ecosystem.toLowerCase() + const type = ecoLower === 'openvsx' ? 'vscode' : ecoLower let namespace: string | undefined let name: string - if (type === 'npm' && depname.startsWith('@') && depname.includes('/')) { + if (ecoLower === 'npm' && depname.startsWith('@') && depname.includes('/')) { const slash = depname.indexOf('/') namespace = depname.slice(0, slash) name = depname.slice(slash + 1) } else if ( - type === 'maven' && + ecoLower === 'maven' && (depname.includes(':') || depname.includes('/')) ) { const sep = depname.includes(':') ? ':' : '/' const idx = depname.indexOf(sep) namespace = depname.slice(0, idx) name = depname.slice(idx + 1) - } else if (type === 'golang' && depname.includes('/')) { + } else if (ecoLower === 'golang' && depname.includes('/')) { const lastSlash = depname.lastIndexOf('/') namespace = depname.slice(0, lastSlash) name = depname.slice(lastSlash + 1) + } else if ( + (ecoLower === 'openvsx' || ecoLower === 'vscode') && + depname.includes('/') + ) { + const slash = depname.indexOf('/') + namespace = depname.slice(0, slash) + name = depname.slice(slash + 1) } else { name = depname } - const purlVersion = - version === 'unknown' || version === '1.0.0' || !version - ? undefined - : version + const merged: Record = { ...(qualifiers ?? {}) } + if (ecoLower === 'openvsx' && !merged['repository_url']) { + merged['repository_url'] = 'https://open-vsx.org' + } + + // `1.0.0` is a stale model-default for ecosystems where the model didn't know + // the version (npm/pypi historically). For ecosystems whose extensions/packages + // genuinely publish 1.0.0 (e.g. openvsx, chrome), treat it as a real version. + const placeholderEcosystems = new Set(['npm', 'pypi']) + const isPlaceholderVersion = + version === 'unknown' || + !version || + (version === '1.0.0' && placeholderEcosystems.has(ecoLower)) + const purlVersion = isPlaceholderVersion ? undefined : version const purl = new PackageURL( type, namespace ?? undefined, name, purlVersion ?? undefined, + Object.keys(merged).length ? merged : undefined, + undefined, ) return purl.toString() } diff --git a/lib/threat-feed-tool.ts b/lib/threat-feed-tool.ts new file mode 100644 index 0000000..327a2fb --- /dev/null +++ b/lib/threat-feed-tool.ts @@ -0,0 +1,143 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { getSocketApiUrl } from './env.ts' +import { getStaticApiKey } from './depscore-tool.ts' +import { fetchThreatFeed } from './threatFeed.ts' +import { logger } from './logger.ts' +import { VERSION } from './version.ts' + +const SOCKET_API_BASE_URL = + getSocketApiUrl() || 'https://api.socket.dev' + +const AUTH_REQUIRED_MSG = + 'Authentication is required. Configure SOCKET_API_TOKEN (or a legacy alias) for stdio mode or connect through OAuth-enabled HTTP mode.' + +export function registerThreatFeedTool(srv: McpServer): void { + srv.registerTool( + 'threat_feed', + { + title: 'Threat Feed Tool', + description: + "Look up items in the Socket organization threat feed with the `threat_feed` tool. Requires `org_slug` — call the `organizations` tool first if you don't have it. Returns recently flagged packages (malware, typosquats, obfuscated code, etc.) along with a `nextPageCursor` for pagination. Use `filter` to narrow the threat category (default `mal` for malware), `ecosystem` to scope to a registry, or `name`/`version` to look up a specific package. Pass the previous response's cursor as `cursor` to fetch the next page.", + inputSchema: { + org_slug: z + .string() + .describe( + 'Organization slug, e.g. "my-org" (use the `organizations` tool to discover this)', + ), + filter: z + .string() + .optional() + .describe( + 'Threat category filter (default `mal`). Common values: `mal` (malware), `vuln`, `typ` (typosquat), `obf` (obfuscated), `mjo`, `kes`, `spy`, `ano`, `ucf`, `ptp`, `ual`', + ), + ecosystem: z + .string() + .optional() + .describe( + 'Ecosystem filter, e.g. npm, pypi, gem, maven, golang, nuget, cargo, chrome, openvsx, vscode, huggingface', + ), + name: z.string().optional().describe('Filter by package name'), + version: z.string().optional().describe('Filter by package version'), + is_human_reviewed: z + .boolean() + .optional() + .describe('Only return human-reviewed items (default false)'), + sort: z + .enum(['id', 'created_at', 'updated_at']) + .optional() + .describe('Sort field (default `updated_at`)'), + direction: z + .enum(['asc', 'desc']) + .optional() + .describe('Sort direction (default `desc`)'), + updated_after: z + .string() + .optional() + .describe('ISO timestamp; only return items updated after this'), + created_after: z + .string() + .optional() + .describe('ISO timestamp; only return items created after this'), + per_page: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Results per page (default 30, max 100)'), + cursor: z + .string() + .optional() + .describe( + 'Pagination cursor — the `nextPageCursor` from a previous response', + ), + }, + annotations: { + readOnlyHint: true, + }, + }, + async (args, extra) => { + logger.info( + { + tool: 'threat_feed', + org_slug: args.org_slug, + filters: { + filter: args.filter, + ecosystem: args.ecosystem, + name: args.name, + version: args.version, + }, + }, + 'tool invoked', + ) + const accessToken = extra.authInfo?.token || getStaticApiKey() + if (!accessToken) { + return { + content: [{ type: 'text', text: AUTH_REQUIRED_MSG }], + isError: true, + } + } + try { + const data = await fetchThreatFeed({ + baseUrl: SOCKET_API_BASE_URL, + orgSlug: args.org_slug, + userAgent: `socket-mcp/${VERSION}`, + authToken: accessToken, + filters: { + ...(args.filter ? { filter: args.filter } : {}), + ...(args.ecosystem ? { ecosystem: args.ecosystem } : {}), + ...(args.name ? { name: args.name } : {}), + ...(args.version ? { version: args.version } : {}), + ...(typeof args.is_human_reviewed === 'boolean' + ? { isHumanReviewed: args.is_human_reviewed } + : {}), + ...(args.sort ? { sort: args.sort } : {}), + ...(args.direction ? { direction: args.direction } : {}), + ...(args.updated_after + ? { updatedAfter: args.updated_after } + : {}), + ...(args.created_after + ? { createdAfter: args.created_after } + : {}), + ...(typeof args.per_page === 'number' + ? { perPage: args.per_page } + : {}), + ...(args.cursor ? { cursor: args.cursor } : {}), + }, + }) + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching threat feed for ${args.org_slug}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true, + } + } + }, + ) +} diff --git a/lib/threatFeed.ts b/lib/threatFeed.ts new file mode 100644 index 0000000..a6f1fbf --- /dev/null +++ b/lib/threatFeed.ts @@ -0,0 +1,90 @@ +export interface ThreatFeedFilters { + /** 1..100. API caps at 100 and defaults to 30. */ + perPage?: number + /** Pagination cursor from a previous response. */ + cursor?: string + /** Sort field: id | created_at | updated_at (default updated_at). */ + sort?: 'id' | 'created_at' | 'updated_at' + /** Sort direction: asc | desc (default desc). */ + direction?: 'asc' | 'desc' + /** ISO timestamp; return items updated after this. */ + updatedAfter?: string + /** ISO timestamp; return items created after this. */ + createdAfter?: string + /** + * Threat category filter. Defaults to `mal`. Common values include + * `mal` (malware), `vuln`, `typ` (typosquat), `obf` (obfuscated), + * `mjo` (malicious javascript object), `kes` (known exploits), `spy`, + * `ano` (anomalous), `ucf` (unverified code fetch), `ptp` (potential + * privilege escalation), `ual` (unauthorized access logic). + */ + filter?: string + /** Filter by package name. */ + name?: string + /** Filter by package version. */ + version?: string + /** Defaults to false. When true, only items marked human-reviewed. */ + isHumanReviewed?: boolean + /** Ecosystem filter: npm, pypi, gem, maven, golang, nuget, cargo, chrome, openvsx, etc. */ + ecosystem?: string +} + +export interface FetchThreatFeedOptions { + baseUrl: string + orgSlug: string + filters?: ThreatFeedFilters + fetchFn?: typeof fetch + userAgent?: string + /** Socket access token, sent as `Authorization: Bearer ` when set. */ + authToken?: string + extraHeaders?: Record +} + +/** + * Build the query string for the threat-feed endpoint. Only set values are + * included — undefined keys are skipped. + */ +export function buildThreatFeedQuery ( + filters: ThreatFeedFilters | undefined +): URLSearchParams { + const params = new URLSearchParams() + const f = filters ?? {} + if (typeof f.perPage === 'number') params.set('per_page', String(f.perPage)) + if (f.cursor) params.set('page_cursor', f.cursor) + if (f.sort) params.set('sort', f.sort) + if (f.direction) params.set('direction', f.direction) + if (f.updatedAfter) params.set('updated_after', f.updatedAfter) + if (f.createdAfter) params.set('created_after', f.createdAfter) + if (f.filter) params.set('filter', f.filter) + if (f.name) params.set('name', f.name) + if (f.version) params.set('version', f.version) + if (typeof f.isHumanReviewed === 'boolean') { + params.set('is_human_reviewed', String(f.isHumanReviewed)) + } + if (f.ecosystem) params.set('ecosystem', f.ecosystem) + return params +} + +/** + * Fetch threat-feed items for an organization from + * `GET /v0/orgs/{org_slug}/threat-feed`. Returns the parsed JSON body untouched. + */ +export async function fetchThreatFeed ( + options: FetchThreatFeedOptions +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, '') + const qs = buildThreatFeedQuery(options.filters).toString() + const url = `${baseUrl}/v0/orgs/${encodeURIComponent(options.orgSlug)}/threat-feed${qs ? `?${qs}` : ''}` + + const fetchFn = options.fetchFn ?? fetch + const headers: Record = { accept: 'application/json' } + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.authToken) headers['authorization'] = `Bearer ${options.authToken}` + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + + const res = await fetchFn(url, { headers }) + if (!res.ok) { + throw new Error(`threat-feed endpoint ${res.status}: ${await res.text()}`) + } + return res.json() +} diff --git a/purl.test.ts b/purl.test.ts index 5223e58..a64691f 100644 --- a/purl.test.ts +++ b/purl.test.ts @@ -112,4 +112,41 @@ test('buildPurl produces correct PURLs across all ecosystems', async t => { await t.test('version omitted when empty', () => { assert.strictEqual(buildPurl('npm', 'lodash', ''), 'pkg:npm/lodash') }) + + await t.test('openvsx rewrites to vscode type with repository_url and platform', () => { + assert.strictEqual( + buildPurl('openvsx', 'meta/pyrefly', '1.0.0', { platform: 'linux-x64' }), + 'pkg:vscode/meta/pyrefly@1.0.0?platform=linux-x64&repository_url=https%3A%2F%2Fopen-vsx.org', + ) + }) + + await t.test('openvsx without platform still adds repository_url', () => { + assert.strictEqual( + buildPurl('openvsx', 'meta/pyrefly', '1.0.0'), + 'pkg:vscode/meta/pyrefly@1.0.0?repository_url=https%3A%2F%2Fopen-vsx.org', + ) + }) + + await t.test('vscode (Marketplace) does not auto-add repository_url', () => { + assert.strictEqual( + buildPurl('vscode', 'meta/pyrefly', '1.0.0'), + 'pkg:vscode/meta/pyrefly@1.0.0', + ) + }) + + await t.test('chrome extensions pass through with 1.0.0 kept as real version', () => { + assert.strictEqual( + buildPurl('chrome', 'gighmmpiobklfepjocnamgkkbiglidom', '1.0.0'), + 'pkg:chrome/gighmmpiobklfepjocnamgkkbiglidom@1.0.0', + ) + }) + + await t.test('caller can override openvsx repository_url', () => { + assert.strictEqual( + buildPurl('openvsx', 'meta/pyrefly', '1.0.0', { + repository_url: 'https://example.test', + }), + 'pkg:vscode/meta/pyrefly@1.0.0?repository_url=https%3A%2F%2Fexample.test', + ) + }) }) From 2ff6e3177040a09ac7c47148fb7a8340e2588af7 Mon Sep 17 00:00:00 2001 From: John Tuckner Date: Thu, 28 May 2026 17:03:43 -0500 Subject: [PATCH 2/3] fix(deps): migrate pnpm.overrides to workspace, refresh lockfile pnpm 11.x no longer reads the package.json "pnpm.overrides" field. Move the five overrides not already in pnpm-workspace.yaml (@hono/node-server, fast-uri, hono, zod, zod-to-json-schema) into its overrides: block and drop the dead pnpm field. Refresh the lockfile for the plugin-patch-format-guard hook's catalog deps. Repoint three scripts (test, check, lint) off the stale lib-stable/spawn/spawn export onto process/spawn/child, matching every other script after the lib 6.0.3 restructure. --- package.json | 13 ------------- pnpm-lock.yaml | 21 ++++++++++++++++++--- pnpm-workspace.yaml | 5 +++++ scripts/check.mts | 2 +- scripts/lint.mts | 2 +- scripts/test.mts | 2 +- 6 files changed, 26 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index a8affe4..e04e8c6 100644 --- a/package.json +++ b/package.json @@ -86,19 +86,6 @@ "zod": "3.25.76", "zod-to-json-schema": "3.25.1" }, - "pnpm": { - "overrides": { - "@hono/node-server": "1.19.13", - "fast-uri": "3.1.2", - "hono": "4.12.18", - "ip-address": "10.1.1", - "node-forge": "1.4.0", - "path-to-regexp": "8.4.2", - "tmp": "0.2.5", - "zod": "3.25.76", - "zod-to-json-schema": "3.25.1" - } - }, "devDependencies": { "@sinclair/typebox": "catalog:", "@types/node": "^24.12.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aa441c..39f68a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,14 +26,19 @@ catalogs: version: 19.11.0 overrides: + '@hono/node-server': 1.19.13 '@socketregistry/packageurl-js': 1.4.2 '@socketsecurity/lib': 6.0.3 '@socketsecurity/registry': 2.0.2 '@socketsecurity/sdk': 4.0.1 + fast-uri: 3.1.2 + hono: 4.12.18 ip-address: 10.1.1 node-forge: 1.4.0 path-to-regexp: 8.4.2 tmp: 0.2.5 + zod: 3.25.76 + zod-to-json-schema: 3.25.1 importers: @@ -496,6 +501,16 @@ importers: specifier: 'catalog:' version: 24.9.2 + .claude/hooks/plugin-patch-format-guard: + dependencies: + '@socketsecurity/lib-stable': + specifier: 'catalog:' + version: '@socketsecurity/lib@6.0.3(typescript@5.9.3)' + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.9.2 + .claude/hooks/pointer-comment-guard: devDependencies: '@types/node': @@ -689,7 +704,7 @@ packages: resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.18 '@inquirer/checkbox@3.0.1': resolution: {integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==} @@ -752,7 +767,7 @@ packages: engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 + zod: 3.25.76 peerDependenciesMeta: '@cfworker/json-schema': optional: true @@ -1823,7 +1838,7 @@ packages: zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: - zod: ^3.25 || ^4 + zod: 3.25.76 zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 518ce94..75660eb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -38,10 +38,13 @@ enablePrePostScripts: true # in the default `catalog:` block above. This defeats accidental # local-checkout resolution when a sibling repo is on disk. overrides: + '@hono/node-server': '1.19.13' '@socketregistry/packageurl-js': 'catalog:' '@socketsecurity/lib': 'catalog:' '@socketsecurity/registry': 'catalog:' '@socketsecurity/sdk': 'catalog:' + 'fast-uri': '3.1.2' + 'hono': '4.12.18' 'ip-address': '10.1.1' # CVE-2026-... node-forge prototype-pollution-class advisories #55/#57/#59/#61 'node-forge': '1.4.0' @@ -49,6 +52,8 @@ overrides: 'path-to-regexp': '8.4.2' # advisory #42 arbitrary file/directory write via symlink 'tmp': '0.2.5' + 'zod': '3.25.76' + 'zod-to-json-schema': '3.25.1' minimumReleaseAge: 10080 minimumReleaseAgeExclude: diff --git a/scripts/check.mts b/scripts/check.mts index 8a1785f..e434985 100644 --- a/scripts/check.mts +++ b/scripts/check.mts @@ -12,7 +12,7 @@ * pnpm run check --all # full lint + full type (CI) */ -import { spawnSync } from '@socketsecurity/lib-stable/spawn/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' const args = process.argv.slice(2) diff --git a/scripts/lint.mts b/scripts/lint.mts index 3000884..dd2488e 100644 --- a/scripts/lint.mts +++ b/scripts/lint.mts @@ -23,7 +23,7 @@ * contract so pre-commit hooks and CI work identically across repos. */ -import { spawnSync } from '@socketsecurity/lib-stable/spawn/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import type { SpawnSyncOptions } from 'node:child_process' import { existsSync } from 'node:fs' import path from 'node:path' diff --git a/scripts/test.mts b/scripts/test.mts index c3a2621..9f80ad0 100644 --- a/scripts/test.mts +++ b/scripts/test.mts @@ -7,7 +7,7 @@ * them, but the pre-commit hook always passes one or both. */ -import { spawnSync } from '@socketsecurity/lib-stable/spawn/spawn' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' import process from 'node:process' const args = process.argv.slice(2) From 2fb9fcbeb8ed872ee9bdcd6154b1fb3c2de0b44a Mon Sep 17 00:00:00 2001 From: John Tuckner Date: Thu, 28 May 2026 17:06:03 -0500 Subject: [PATCH 3/3] fix(imports): repoint stale lib logger imports onto logger/default The lib 6.0.3 restructure dropped the bare "logger" subpath's getDefaultLogger export; it now lives at logger/default. Repoint the three mock-client entrypoints and two scripts. The scripts also move off the bare "lib" name onto the lib-stable self-import alias, matching the rest of scripts/. --- mock-client/debug-client.ts | 2 +- mock-client/http-client.ts | 2 +- mock-client/stdio-client.ts | 2 +- scripts/check-versions.ts | 2 +- scripts/lint.mts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mock-client/debug-client.ts b/mock-client/debug-client.ts index 72487bc..1b99ecb 100644 --- a/mock-client/debug-client.ts +++ b/mock-client/debug-client.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process' import path from 'node:path' import readline from 'node:readline' -import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' const logger = getDefaultLogger() diff --git a/mock-client/http-client.ts b/mock-client/http-client.ts index 7b0b1aa..2b880f4 100644 --- a/mock-client/http-client.ts +++ b/mock-client/http-client.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node --experimental-strip-types import path from 'node:path' -import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' const logger = getDefaultLogger() diff --git a/mock-client/stdio-client.ts b/mock-client/stdio-client.ts index 010321e..7a772fb 100644 --- a/mock-client/stdio-client.ts +++ b/mock-client/stdio-client.ts @@ -2,7 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import path from 'node:path' -import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' const logger = getDefaultLogger() diff --git a/scripts/check-versions.ts b/scripts/check-versions.ts index 9da2425..33929c5 100755 --- a/scripts/check-versions.ts +++ b/scripts/check-versions.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import path from 'node:path' -import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' const logger = getDefaultLogger() diff --git a/scripts/lint.mts b/scripts/lint.mts index dd2488e..5fed770 100644 --- a/scripts/lint.mts +++ b/scripts/lint.mts @@ -28,7 +28,7 @@ import type { SpawnSyncOptions } from 'node:child_process' import { existsSync } from 'node:fs' import path from 'node:path' import process from 'node:process' -import { getDefaultLogger } from '@socketsecurity/lib/logger' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' const logger = getDefaultLogger()