diff --git a/package-lock.json b/package-lock.json index 56fb93a..21cc907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,12 @@ "@opentelemetry/instrumentation-express": "^0.59.0", "@opentelemetry/instrumentation-http": "^0.211.0", "@opentelemetry/instrumentation-winston": "^0.55.0", - "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/resources": "^2.5.0", "@opentelemetry/sdk-logs": "^0.211.0", - "@opentelemetry/sdk-metrics": "^2.2.0", + "@opentelemetry/sdk-metrics": "^2.5.0", "@opentelemetry/sdk-node": "^0.211.0", - "@opentelemetry/sdk-trace-base": "^2.2.0", - "@opentelemetry/sdk-trace-node": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/sdk-trace-node": "^2.5.0", "@opentelemetry/semantic-conventions": "^1.39.0", "cors": "^2.8.6", "express": "^5.0.1", @@ -36,12 +36,12 @@ "github-advisory-mcp": "dist/index.js" }, "devDependencies": { - "@ai-sdk/azure": "^3.0.27", + "@ai-sdk/azure": "^3.0.28", "@azure/identity": "^4.13.0", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^25.2.3", - "ai": "^6.0.79", + "ai": "^6.0.81", "dotenv": "^17.2.4", "typescript": "^5.7.2", "vitest": "^4.0.18" @@ -51,15 +51,15 @@ } }, "node_modules/@ai-sdk/azure": { - "version": "3.0.27", - "resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.27.tgz", - "integrity": "sha512-UEaWnOlMRYErQoZsFNxuF0shQ/XhsFcVFm6LbA1RMOBsfWkBtKCTaz+dDsbB8dzGRCa2knZR2thtdYwwSKRyQA==", + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.29.tgz", + "integrity": "sha512-z+nhcjBpu7Rmgfh+fmF9vpANVEwu7loNWlCvDo8fxrDpL44caOIKLjqKerEcgbBj3+NLfgZot1FOzvdjgEpvbw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/openai": "3.0.26", + "@ai-sdk/openai": "3.0.28", "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.14" + "@ai-sdk/provider-utils": "4.0.15" }, "engines": { "node": ">=18" @@ -69,14 +69,14 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.40", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.40.tgz", - "integrity": "sha512-AK6UQIPv342zl5srFmhAHmq/rtOfmNvCkQ0J12B93KNmU1AN3eP8DO5KxhSATLffPWWGK66qWLUgU22J7RcE9A==", + "version": "3.0.44", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.44.tgz", + "integrity": "sha512-zSKCsmSr+pSYMXQIg6eJell21gJZ6S/VTANS9bmDj2di9pew6hOld4mb57HJvnSLR2Hr+v8BSeQvEzxadR9eDQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.14", + "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "engines": { @@ -87,14 +87,14 @@ } }, "node_modules/@ai-sdk/openai": { - "version": "3.0.26", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.26.tgz", - "integrity": "sha512-W/hiwxIfG29IO0Fob1HwWpFssMsNrxWoX8A7DwNGOtKArDBmJNuGzQeU/k0Fnh8WyvZEnfxkjO4oXkSXfVBayg==", + "version": "3.0.28", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.28.tgz", + "integrity": "sha512-m2Dm6fwUzMksqnPrd5f/WZ4cZ9GTZHpzsVO6jxKQwwc84gFHzAFZmUCG0C5mV7XlPOw4mwaiYV3HfLiEfphvvA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.14" + "@ai-sdk/provider-utils": "4.0.15" }, "engines": { "node": ">=18" @@ -117,9 +117,9 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.14.tgz", - "integrity": "sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==", + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", + "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -880,6 +880,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -993,6 +994,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3051,6 +3053,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3077,15 +3080,15 @@ } }, "node_modules/ai": { - "version": "6.0.79", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.79.tgz", - "integrity": "sha512-xmZHdJu3g+tbafqU+RDat/cfL4C9UUehjZuwn3+Il88E6T2gZdSRm2h/TV9Txaqj86vz9OSmdrELDUKWJB+Kog==", + "version": "6.0.84", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.84.tgz", + "integrity": "sha512-dKYjHiWDvegJARWsl8pWCaeUWb1VJOzEdP9Hh7fWBCHUTF95c2TyZcUqbR5/XwKtWVue/8T9E/k5DPT9nFkF9Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "3.0.40", + "@ai-sdk/gateway": "3.0.44", "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.14", + "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "engines": { @@ -3709,6 +3712,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4018,6 +4022,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4657,6 +4662,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5370,6 +5376,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5705,6 +5712,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 4752600..c7b3cfb 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,12 @@ "@opentelemetry/instrumentation-express": "^0.59.0", "@opentelemetry/instrumentation-http": "^0.211.0", "@opentelemetry/instrumentation-winston": "^0.55.0", - "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/resources": "^2.5.0", "@opentelemetry/sdk-logs": "^0.211.0", - "@opentelemetry/sdk-metrics": "^2.2.0", + "@opentelemetry/sdk-metrics": "^2.5.0", "@opentelemetry/sdk-node": "^0.211.0", - "@opentelemetry/sdk-trace-base": "^2.2.0", - "@opentelemetry/sdk-trace-node": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/sdk-trace-node": "^2.5.0", "@opentelemetry/semantic-conventions": "^1.39.0", "cors": "^2.8.6", "express": "^5.0.1", @@ -55,12 +55,12 @@ "zod": "^4.3.6" }, "devDependencies": { - "@ai-sdk/azure": "^3.0.27", + "@ai-sdk/azure": "^3.0.28", "@azure/identity": "^4.13.0", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^25.2.3", - "ai": "^6.0.79", + "ai": "^6.0.81", "dotenv": "^17.2.4", "typescript": "^5.7.2", "vitest": "^4.0.18" diff --git a/src/datasources/local-repository.ts b/src/datasources/local-repository.ts index 8988dde..a29d395 100644 --- a/src/datasources/local-repository.ts +++ b/src/datasources/local-repository.ts @@ -301,6 +301,38 @@ export class LocalRepositoryDataSource implements IAdvisoryDataSource { }; } + /** + * Parse date filter string and return start/end dates + * Supports: "2026-01-27" (single day) or "2026-01-01..2026-01-31" (range) + */ + private parseDateFilter(dateStr: string): { start: string; end: string } { + // Defense-in-depth: ensure dateStr is a string (HTTP query params can be arrays) + const str = Array.isArray(dateStr) ? String(dateStr[0]) : String(dateStr); + if (str.includes('..')) { + const [start, end] = str.split('..'); + // End date: include full day by using next day midnight + const endDate = new Date(end + 'T00:00:00Z'); + endDate.setUTCDate(endDate.getUTCDate() + 1); + return { start: start + 'T00:00:00Z', end: endDate.toISOString() }; + } + // Single date: filter for that specific day + const startDate = new Date(str + 'T00:00:00Z'); + const endDate = new Date(str + 'T00:00:00Z'); + endDate.setUTCDate(endDate.getUTCDate() + 1); + return { start: startDate.toISOString(), end: endDate.toISOString() }; + } + + /** + * Filter advisories by date range + */ + private filterByDateRange(advisories: Advisory[], field: 'published_at' | 'updated_at', dateStr: string): Advisory[] { + const { start, end } = this.parseDateFilter(dateStr); + return advisories.filter(a => { + const date = a[field]; + return date >= start && date < end; + }); + } + /** * List advisories with optional filtering */ @@ -349,11 +381,11 @@ export class LocalRepositoryDataSource implements IAdvisoryDataSource { } if (options.published) { - results = results.filter(a => a.published_at >= options.published!); + results = this.filterByDateRange(results, 'published_at', options.published); } if (options.updated) { - results = results.filter(a => a.updated_at >= options.updated!); + results = this.filterByDateRange(results, 'updated_at', options.updated); } // Sort results diff --git a/src/local-server.ts b/src/local-server.ts index c67f8a9..7174dbc 100644 --- a/src/local-server.ts +++ b/src/local-server.ts @@ -1,168 +1,184 @@ -/** - * Local HTTP server that mimics GitHub Advisories REST API - * Serves advisory data from local repository - */ - -import express, { Request, Response } from 'express'; -import cors from 'cors'; -import { LocalRepositoryDataSource } from './datasources/local-repository.js'; -import { AdvisoryListOptions } from './types/data-source.js'; - -export interface LocalServerConfig { - repositoryPath: string; - port?: number; - host?: string; -} - -/** - * Create and start a local HTTP server for advisory data - */ -export async function createLocalAdvisoryServer(config: LocalServerConfig) { - const app = express(); - const port = config.port || 3000; - const host = config.host || 'localhost'; - - const dataSource = new LocalRepositoryDataSource(config.repositoryPath); - - // Enable CORS for browser access - app.use(cors()); - app.use(express.json()); - - // Health check endpoint - app.get('/health', (_req: Request, res: Response) => { - res.json({ - status: 'ok', - source: 'local-repository', - repository: config.repositoryPath, - }); - }); - - // List advisories (mimics GET /advisories) - app.get('/advisories', async (req: Request, res: Response) => { - try { - const options: AdvisoryListOptions = { - ghsa_id: req.query.ghsa_id as string, - cve_id: req.query.cve_id as string, - ecosystem: req.query.ecosystem as string, - severity: req.query.severity as string, - cwes: req.query.cwes ? (Array.isArray(req.query.cwes) ? req.query.cwes as string[] : [req.query.cwes as string]) : undefined, - is_withdrawn: req.query.is_withdrawn === 'true' ? true : req.query.is_withdrawn === 'false' ? false : undefined, - affects: req.query.affects as string, - published: req.query.published as string, - updated: req.query.updated as string, - modified: req.query.modified as string, - per_page: req.query.per_page ? parseInt(req.query.per_page as string) : undefined, - page: req.query.page ? parseInt(req.query.page as string) : undefined, - sort: (req.query.sort as 'published' | 'updated') || 'published', - direction: (req.query.direction as 'asc' | 'desc') || 'desc', - }; - - const advisories = await dataSource.listAdvisories(options); - res.json(advisories); - } catch (error) { - console.error('Error listing advisories:', error); - res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // Get specific advisory (mimics GET /advisories/{ghsa_id}) - app.get('/advisories/:ghsa_id', async (req: Request, res: Response) => { - try { - const advisory = await dataSource.getAdvisory(req.params.ghsa_id); - - // Return 404 if advisory not found (GitHub API behavior) - if (advisory === null) { - res.status(404).json({ - message: 'Not Found', - documentation_url: 'https://docs.github.com/rest/security-advisories/global-advisories', - }); - return; - } - - res.json(advisory); - } catch (error) { - console.error('Error getting advisory %s:', req.params.ghsa_id, error); - if (error instanceof Error && error.message.includes('not found')) { - res.status(404).json({ - error: 'Not found', - message: error.message, - }); - } else { - res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - }); - - // Search advisories (custom endpoint for convenience) - app.get('/search', async (req: Request, res: Response) => { - try { - const query = req.query.q as string; - if (!query) { - res.status(400).json({ error: 'Missing query parameter: q' }); - return; - } - - const options: AdvisoryListOptions = { - ecosystem: req.query.ecosystem as string, - severity: req.query.severity as string, - per_page: req.query.per_page ? parseInt(req.query.per_page as string) : undefined, - }; - - const advisories = await dataSource.searchAdvisories!(query, options); - res.json(advisories); - } catch (error) { - console.error('Error searching advisories:', error); - res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); - - // Start server - return new Promise<{ server: any; url: string }>((resolve, reject) => { - const server = app.listen(port, host, () => { - const url = `http://${host}:${port}`; - console.error(`Local Advisory Server listening at ${url}`); - console.error(`Repository: ${config.repositoryPath}`); - console.error(`Endpoints:`); - console.error(` GET ${url}/health`); - console.error(` GET ${url}/advisories`); - console.error(` GET ${url}/advisories/:ghsa_id`); - console.error(` GET ${url}/search?q=`); - resolve({ server, url }); - }).on('error', reject); - }); -} - -/** - * Standalone server CLI - */ -if (import.meta.url === `file://${process.argv[1]}`) { - const repoPath = process.env.ADVISORY_REPO_PATH || process.argv[2]; - const port = parseInt(process.env.PORT || process.argv[3] || '3000'); - - if (!repoPath) { - console.error('Usage: node local-server.js [port]'); - console.error(' or: ADVISORY_REPO_PATH= PORT= node local-server.js'); - process.exit(1); - } - - createLocalAdvisoryServer({ repositoryPath: repoPath, port }) - .then(({ url }) => { - console.error(`\nServer ready! Test with:`); - console.error(` curl ${url}/health`); - console.error(` curl "${url}/advisories?ecosystem=npm&per_page=5"`); - }) - .catch((error) => { - console.error('Failed to start server:', error); - process.exit(1); - }); -} - +/** + * Local HTTP server that mimics GitHub Advisories REST API + * Serves advisory data from local repository + */ + +import express, { Request, Response } from 'express'; +import cors from 'cors'; +import { LocalRepositoryDataSource } from './datasources/local-repository.js'; +import { AdvisoryListOptions } from './types/data-source.js'; + +export interface LocalServerConfig { + repositoryPath: string; + port?: number; + host?: string; +} + +/** + * Safely coerce an Express query parameter to a string. + * Express `req.query` values may be string, string[], or undefined. + * If the value is an array (repeated query param), the first element is used. + */ +function queryString(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; + return undefined; +} + +/** + * Create and start a local HTTP server for advisory data + */ +export async function createLocalAdvisoryServer(config: LocalServerConfig) { + const app = express(); + const port = config.port || 3000; + const host = config.host || 'localhost'; + + const dataSource = new LocalRepositoryDataSource(config.repositoryPath); + + // Enable CORS for browser access + app.use(cors()); + app.use(express.json()); + + // Health check endpoint + app.get('/health', (_req: Request, res: Response) => { + res.json({ + status: 'ok', + source: 'local-repository', + repository: config.repositoryPath, + }); + }); + + // List advisories (mimics GET /advisories) + app.get('/advisories', async (req: Request, res: Response) => { + try { + const isWithdrawn = queryString(req.query.is_withdrawn); + const perPage = queryString(req.query.per_page); + const page = queryString(req.query.page); + const sortParam = queryString(req.query.sort); + const dirParam = queryString(req.query.direction); + + const options: AdvisoryListOptions = { + ghsa_id: queryString(req.query.ghsa_id), + cve_id: queryString(req.query.cve_id), + ecosystem: queryString(req.query.ecosystem), + severity: queryString(req.query.severity), + cwes: req.query.cwes ? (Array.isArray(req.query.cwes) ? req.query.cwes as string[] : [req.query.cwes as string]) : undefined, + is_withdrawn: isWithdrawn === 'true' ? true : isWithdrawn === 'false' ? false : undefined, + affects: queryString(req.query.affects), + published: queryString(req.query.published), + updated: queryString(req.query.updated), + modified: queryString(req.query.modified), + per_page: perPage ? parseInt(perPage) : undefined, + page: page ? parseInt(page) : undefined, + sort: (sortParam === 'published' || sortParam === 'updated') ? sortParam : 'published', + direction: (dirParam === 'asc' || dirParam === 'desc') ? dirParam : 'desc', + }; + + const advisories = await dataSource.listAdvisories(options); + res.json(advisories); + } catch (error) { + console.error('Error listing advisories:', error); + res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // Get specific advisory (mimics GET /advisories/{ghsa_id}) + app.get('/advisories/:ghsa_id', async (req: Request, res: Response) => { + try { + const advisory = await dataSource.getAdvisory(req.params.ghsa_id); + + // Return 404 if advisory not found (GitHub API behavior) + if (advisory === null) { + res.status(404).json({ + message: 'Not Found', + documentation_url: 'https://docs.github.com/rest/security-advisories/global-advisories', + }); + return; + } + + res.json(advisory); + } catch (error) { + console.error('Error getting advisory %s:', req.params.ghsa_id, error); + if (error instanceof Error && error.message.includes('not found')) { + res.status(404).json({ + error: 'Not found', + message: error.message, + }); + } else { + res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + }); + + // Search advisories (custom endpoint for convenience) + app.get('/search', async (req: Request, res: Response) => { + try { + const query = req.query.q as string; + if (!query) { + res.status(400).json({ error: 'Missing query parameter: q' }); + return; + } + + const options: AdvisoryListOptions = { + ecosystem: req.query.ecosystem as string, + severity: req.query.severity as string, + per_page: req.query.per_page ? parseInt(req.query.per_page as string) : undefined, + }; + + const advisories = await dataSource.searchAdvisories!(query, options); + res.json(advisories); + } catch (error) { + console.error('Error searching advisories:', error); + res.status(500).json({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + // Start server + return new Promise<{ server: any; url: string }>((resolve, reject) => { + const server = app.listen(port, host, () => { + const url = `http://${host}:${port}`; + console.error(`Local Advisory Server listening at ${url}`); + console.error(`Repository: ${config.repositoryPath}`); + console.error(`Endpoints:`); + console.error(` GET ${url}/health`); + console.error(` GET ${url}/advisories`); + console.error(` GET ${url}/advisories/:ghsa_id`); + console.error(` GET ${url}/search?q=`); + resolve({ server, url }); + }).on('error', reject); + }); +} + +/** + * Standalone server CLI + */ +if (import.meta.url === `file://${process.argv[1]}`) { + const repoPath = process.env.ADVISORY_REPO_PATH || process.argv[2]; + const port = parseInt(process.env.PORT || process.argv[3] || '3000'); + + if (!repoPath) { + console.error('Usage: node local-server.js [port]'); + console.error(' or: ADVISORY_REPO_PATH= PORT= node local-server.js'); + process.exit(1); + } + + createLocalAdvisoryServer({ repositoryPath: repoPath, port }) + .then(({ url }) => { + console.error(`\nServer ready! Test with:`); + console.error(` curl ${url}/health`); + console.error(` curl "${url}/advisories?ecosystem=npm&per_page=5"`); + }) + .catch((error) => { + console.error('Failed to start server:', error); + process.exit(1); + }); +} diff --git a/src/refresh-database.ts b/src/refresh-database.ts index 20f2f3e..24afb64 100644 --- a/src/refresh-database.ts +++ b/src/refresh-database.ts @@ -4,7 +4,7 @@ */ import { spawn } from "child_process"; -import { existsSync } from "fs"; +import { existsSync, mkdirSync } from "fs"; import { join } from "path"; import { createLogger } from "./logger.js"; @@ -90,7 +90,8 @@ export async function refreshAdvisoryDatabase( // Clone with depth=1 (shallow) const parentDir = join(repoPath, ".."); const repoName = repoPath.split(/[/\\]/).pop() || "advisory-database"; - + // Ensure parent directory exists (spawn fails with ENOENT if cwd is missing) + mkdirSync(parentDir, { recursive: true }); await runGit( ["clone", "--depth=1", "--branch=main", ADVISORY_REPO_URL, repoName], parentDir, diff --git a/src/tools/advisories.ts b/src/tools/advisories.ts index f8b7a74..822cf6a 100644 --- a/src/tools/advisories.ts +++ b/src/tools/advisories.ts @@ -44,18 +44,18 @@ function buildQueryString(params: Record): string { * List global security advisories from local database */ export const listAdvisoriesSchema = z.object({ - ghsa_id: z.string().optional().describe('GHSA identifier'), - cve_id: z.string().optional().describe('CVE identifier'), + ghsa_id: z.string().optional().describe('GHSA identifier (e.g., "GHSA-xxxx-xxxx-xxxx")'), + cve_id: z.string().optional().describe('CVE identifier (e.g., "CVE-2026-12345")'), ecosystem: z.enum(['rubygems', 'npm', 'pip', 'maven', 'nuget', 'composer', 'go', 'rust', 'erlang', 'actions', 'pub', 'other', 'swift']).optional().describe('Package ecosystem'), severity: z.enum(['low', 'medium', 'high', 'critical', 'unknown']).optional().describe('Severity level'), cwes: z.string().optional().describe('Comma-separated CWE identifiers (e.g., "79,284,22")'), is_withdrawn: z.boolean().optional().describe('Filter withdrawn advisories'), - affects: z.string().optional().describe('Package name filter'), - published: z.string().optional().describe('Published date or range'), - updated: z.string().optional().describe('Updated date or range'), - per_page: z.number().min(1).max(100).optional().describe('Results per page (max 100)'), - direction: z.enum(['asc', 'desc']).optional().describe('Sort direction'), - sort: z.enum(['updated', 'published']).optional().describe('Sort field') + affects: z.string().optional().describe('Package name filter (partial match, e.g., "express" matches "express-session")'), + published: z.string().optional().describe('Filter by published date in YYYY-MM-DD format. Single date returns that day only. Range format: "2026-01-01..2026-01-31" returns inclusive range. Examples: "2026-01-27" (single day), "2026-01-01..2026-01-31" (January 2026)'), + updated: z.string().optional().describe('Filter by updated date in YYYY-MM-DD format. Single date returns that day only. Range format: "2026-01-01..2026-01-31" returns inclusive range'), + per_page: z.number().min(1).max(100).optional().describe('Results per page (default: 30, max: 100)'), + direction: z.enum(['asc', 'desc']).optional().describe('Sort direction (default: desc, newest first)'), + sort: z.enum(['updated', 'published']).optional().describe('Sort field (default: published)') }); export async function listAdvisories(params: unknown): Promise { diff --git a/test/e2e/globalSetup.ts b/test/e2e/globalSetup.ts new file mode 100644 index 0000000..ab75a9e --- /dev/null +++ b/test/e2e/globalSetup.ts @@ -0,0 +1,50 @@ +/** + * Vitest global setup for E2E tests. + * Ensures the advisory-database repository is cloned before tests start. + */ + +import { execFileSync } from "child_process"; +import { existsSync, mkdirSync } from "fs"; +import { join, resolve } from "path"; + +const ADVISORY_REPO_URL = "https://github.com/github/advisory-database.git"; + +export default function globalSetup() { + const repoPath = + process.env.ADVISORY_REPO_PATH || + resolve("./external/advisory-database"); + const gitDir = join(repoPath, ".git"); + + if (existsSync(gitDir)) { + console.log(`[globalSetup] Advisory database already exists at ${repoPath}`); + return; + } + + console.log(`[globalSetup] Cloning advisory-database to ${repoPath}...`); + console.log(`[globalSetup] This may take 1-2 minutes on first run.`); + + const parentDir = join(repoPath, ".."); + const repoName = repoPath.split(/[/\\]/).pop() || "advisory-database"; + + mkdirSync(parentDir, { recursive: true }); + + try { + execFileSync( + "git", + ["clone", "--depth=1", "--branch=main", ADVISORY_REPO_URL, repoName], + { + cwd: parentDir, + stdio: "inherit", + timeout: 180_000, // 3 minutes + } + ); + console.log(`[globalSetup] Advisory database cloned successfully.`); + } catch (err) { + console.warn( + `[globalSetup] Failed to clone advisory database: ${err instanceof Error ? err.message : err}` + ); + console.warn( + `[globalSetup] E2E tests that require advisory data will be skipped or return empty results.` + ); + } +} diff --git a/test/e2e/mcp-server.test.ts b/test/e2e/mcp-server.test.ts index cf0097a..2ab5fab 100644 --- a/test/e2e/mcp-server.test.ts +++ b/test/e2e/mcp-server.test.ts @@ -91,7 +91,14 @@ describe("MCP Advisory Server E2E Tests", () => { if (!sessionId) { sessionId = await initializeMCPSession(baseUrl); } - }); + // Warmup: trigger advisory database index build (reads all JSON files from disk). + // First call takes 60-90s to scan the full advisory-database repo; + // subsequent calls use the in-memory cache and complete in milliseconds. + await callMCPTool(baseUrl, sessionId, "list_advisories", { + ecosystem: "npm", + per_page: 1, + }); + }, 120000); // 2 minutes for initial database index build it("should list npm advisories", async () => { const response = await callMCPTool(baseUrl, sessionId, "list_advisories", { @@ -163,6 +170,81 @@ describe("MCP Advisory Server E2E Tests", () => { }); }); + describe("Date Range Filtering", () => { + beforeAll(async () => { + if (!sessionId) { + sessionId = await initializeMCPSession(baseUrl); + } + }); + + it("should filter by single date (exact day)", async () => { + // First, get some advisories to find a date with data + const response = await callMCPTool(baseUrl, sessionId, "list_advisories", { + per_page: 10, + }); + const content = JSON.parse(response.result.content[0].text); + + if (content.advisories.length > 0) { + // Extract date from first advisory (YYYY-MM-DD) + const testDate = content.advisories[0].published_at.split('T')[0]; + + // Query for that specific date + const dateResponse = await callMCPTool(baseUrl, sessionId, "list_advisories", { + published: testDate, + per_page: 50, + }); + const dateContent = JSON.parse(dateResponse.result.content[0].text); + + // All results should be from that date only + dateContent.advisories.forEach((adv: any) => { + const advDate = adv.published_at.split('T')[0]; + expect(advDate).toBe(testDate); + }); + } + }); + + it("should filter by date range", async () => { + // Get recent advisories to find valid date range + const response = await callMCPTool(baseUrl, sessionId, "list_advisories", { + per_page: 20, + direction: 'desc', + }); + const content = JSON.parse(response.result.content[0].text); + + if (content.advisories.length >= 2) { + // Use the range from oldest to newest in our sample + const dates = content.advisories.map((a: any) => a.published_at.split('T')[0]); + const startDate = dates[dates.length - 1]; // oldest + const endDate = dates[0]; // newest + + // Query with range + const rangeResponse = await callMCPTool(baseUrl, sessionId, "list_advisories", { + published: `${startDate}..${endDate}`, + per_page: 100, + }); + const rangeContent = JSON.parse(rangeResponse.result.content[0].text); + + // All results should be within range (inclusive) + rangeContent.advisories.forEach((adv: any) => { + const advDate = adv.published_at.split('T')[0]; + expect(advDate >= startDate).toBe(true); + expect(advDate <= endDate).toBe(true); + }); + } + }); + + it("should return empty for date with no advisories", async () => { + // Use a far-future date that shouldn't have any advisories + const response = await callMCPTool(baseUrl, sessionId, "list_advisories", { + published: "2099-01-01", + per_page: 10, + }); + const content = JSON.parse(response.result.content[0].text); + expect(content.count).toBe(0); + expect(content.advisories).toEqual([]); + }); + }); + describe("Get Advisory Tool", () => { let testGhsaId: string; diff --git a/test/test-utils.ts b/test/test-utils.ts index a76b4a0..1451a71 100644 --- a/test/test-utils.ts +++ b/test/test-utils.ts @@ -67,6 +67,7 @@ export async function startMCPServer( MCP_PORT: port.toString(), ADVISORY_API_PORT: apiPort.toString(), ADVISORY_REPO_PATH: repoPath, + ADVISORY_REFRESH_ON_START: "false", // globalSetup handles cloning }, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/test/unit/refresh-database.test.ts b/test/unit/refresh-database.test.ts index febdaf3..fefc194 100644 --- a/test/unit/refresh-database.test.ts +++ b/test/unit/refresh-database.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { spawn, ChildProcess } from "child_process"; import { existsSync } from "fs"; +import { join } from "path"; import { refreshAdvisoryDatabase, startPeriodicRefresh, @@ -13,6 +14,7 @@ vi.mock("child_process", () => ({ vi.mock("fs", () => ({ existsSync: vi.fn(), + mkdirSync: vi.fn(), })); // Mock logger to avoid console spam in tests @@ -91,7 +93,7 @@ describe("refresh-database", () => { expect(mockSpawn).toHaveBeenCalledWith( "git", expect.arrayContaining(["clone", "--depth=1", "--branch=main"]), - expect.objectContaining({ cwd: "/path/to" }) + expect.objectContaining({ cwd: join("/path/to/advisory-database", "..") }) ); }); diff --git a/vitest.config.ts b/vitest.config.ts index 511b276..582f1fb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ globals: true, environment: "node", setupFiles: ["dotenv/config"], + globalSetup: ["./test/e2e/globalSetup.ts"], watch: false, passWithNoTests: true, testTimeout: 30000,