diff --git a/src/commands/download.ts b/src/commands/download.ts index 55169e12b..f09a25568 100644 --- a/src/commands/download.ts +++ b/src/commands/download.ts @@ -20,16 +20,15 @@ const UUID_PATTERN = const CVE_PATTERN = /^CVE-\d{4}-\d+$/i const GHSA_PATTERN = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i -type IdentifierType = 'uuid' | 'cve' | 'ghsa' | 'package' +type IdentifierType = 'uuid' | 'cve' | 'ghsa' interface DownloadArgs { identifier: string - org: string + org?: string cwd: string id?: boolean cve?: boolean ghsa?: boolean - pkg?: boolean yes?: boolean 'api-url'?: string 'api-token'?: string @@ -38,7 +37,7 @@ interface DownloadArgs { /** * Detect the type of identifier based on its format */ -function detectIdentifierType(identifier: string): IdentifierType { +function detectIdentifierType(identifier: string): IdentifierType | null { if (UUID_PATTERN.test(identifier)) { return 'uuid' } @@ -48,8 +47,7 @@ function detectIdentifierType(identifier: string): IdentifierType { if (GHSA_PATTERN.test(identifier)) { return 'ghsa' } - // Default to package search for anything else - return 'package' + return null } /** @@ -165,7 +163,6 @@ async function downloadPatches(args: DownloadArgs): Promise { id: forceId, cve: forceCve, ghsa: forceGhsa, - pkg: forcePackage, yes: skipConfirmation, 'api-url': apiUrl, 'api-token': apiToken, @@ -179,8 +176,18 @@ async function downloadPatches(args: DownloadArgs): Promise { process.env.SOCKET_API_TOKEN = apiToken } - // Get API client - const apiClient = getAPIClientFromEnv() + // Get API client (will use public proxy if no token is set) + const { client: apiClient, usePublicProxy } = getAPIClientFromEnv() + + // Validate that org is provided when using authenticated API + if (!usePublicProxy && !orgSlug) { + throw new Error( + '--org is required when using SOCKET_API_TOKEN. Provide an organization slug.', + ) + } + + // The org slug to use (null when using public proxy) + const effectiveOrgSlug = usePublicProxy ? null : orgSlug ?? null // Determine identifier type let idType: IdentifierType @@ -190,17 +197,21 @@ async function downloadPatches(args: DownloadArgs): Promise { idType = 'cve' } else if (forceGhsa) { idType = 'ghsa' - } else if (forcePackage) { - idType = 'package' } else { - idType = detectIdentifierType(identifier) + const detectedType = detectIdentifierType(identifier) + if (!detectedType) { + throw new Error( + `Unrecognized identifier format: ${identifier}. Expected UUID, CVE ID (CVE-YYYY-NNNNN), or GHSA ID (GHSA-xxxx-xxxx-xxxx).`, + ) + } + idType = detectedType console.log(`Detected identifier type: ${idType}`) } // For UUID, directly fetch and download the patch if (idType === 'uuid') { console.log(`Fetching patch by UUID: ${identifier}`) - const patch = await apiClient.fetchPatch(orgSlug, identifier) + const patch = await apiClient.fetchPatch(effectiveOrgSlug, identifier) if (!patch) { console.log(`No patch found with UUID: ${identifier}`) return true @@ -246,20 +257,12 @@ async function downloadPatches(args: DownloadArgs): Promise { switch (idType) { case 'cve': { console.log(`Searching patches for CVE: ${identifier}`) - searchResponse = await apiClient.searchPatchesByCVE(orgSlug, identifier) + searchResponse = await apiClient.searchPatchesByCVE(effectiveOrgSlug, identifier) break } case 'ghsa': { console.log(`Searching patches for GHSA: ${identifier}`) - searchResponse = await apiClient.searchPatchesByGHSA(orgSlug, identifier) - break - } - case 'package': { - console.log(`Searching patches for package: ${identifier}`) - searchResponse = await apiClient.searchPatchesByPackage( - orgSlug, - identifier, - ) + searchResponse = await apiClient.searchPatchesByGHSA(effectiveOrgSlug, identifier) break } default: @@ -331,7 +334,7 @@ async function downloadPatches(args: DownloadArgs): Promise { for (const searchResult of accessiblePatches) { // Fetch full patch details with blob content - const patch = await apiClient.fetchPatch(orgSlug, searchResult.uuid) + const patch = await apiClient.fetchPatch(effectiveOrgSlug, searchResult.uuid) if (!patch) { console.log(` [fail] ${searchResult.purl} (could not fetch details)`) patchesFailed++ @@ -378,14 +381,14 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = { return yargs .positional('identifier', { describe: - 'Patch identifier (UUID, CVE ID, GHSA ID, or package name)', + 'Patch identifier (UUID, CVE ID, or GHSA ID)', type: 'string', demandOption: true, }) .option('org', { - describe: 'Organization slug', + describe: 'Organization slug (required when using SOCKET_API_TOKEN, optional for public proxy)', type: 'string', - demandOption: true, + demandOption: false, }) .option('id', { describe: 'Force identifier to be treated as a patch UUID', @@ -402,11 +405,6 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = { type: 'boolean', default: false, }) - .option('pkg', { - describe: 'Force identifier to be treated as a package name', - type: 'boolean', - default: false, - }) .option('yes', { alias: 'y', describe: 'Skip confirmation prompt for multiple patches', @@ -427,33 +425,29 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = { type: 'string', }) .example( - '$0 download 12345678-1234-1234-1234-123456789abc --org myorg', - 'Download a patch by UUID', - ) - .example( - '$0 download CVE-2021-44228 --org myorg', - 'Search and download patches for a CVE', + '$0 download CVE-2021-44228', + 'Download free patches for a CVE (no auth required)', ) .example( - '$0 download GHSA-jfhm-5ghh-2f97 --org myorg', - 'Search and download patches for a GHSA', + '$0 download GHSA-jfhm-5ghh-2f97', + 'Download free patches for a GHSA (no auth required)', ) .example( - '$0 download lodash --org myorg --pkg', - 'Search and download patches for a package', + '$0 download 12345678-1234-1234-1234-123456789abc --org myorg', + 'Download a patch by UUID (requires SOCKET_API_TOKEN)', ) .example( '$0 download CVE-2021-44228 --org myorg --yes', - 'Download all matching patches without confirmation', + 'Download all matching patches without confirmation (with auth)', ) .check(argv => { // Ensure only one type flag is set - const typeFlags = [argv.id, argv.cve, argv.ghsa, argv.pkg].filter( + const typeFlags = [argv.id, argv.cve, argv.ghsa].filter( Boolean, ) if (typeFlags.length > 1) { throw new Error( - 'Only one of --id, --cve, --ghsa, or --pkg can be specified', + 'Only one of --id, --cve, or --ghsa can be specified', ) } return true diff --git a/src/utils/api-client.ts b/src/utils/api-client.ts index dcc36fa39..c6e854a77 100644 --- a/src/utils/api-client.ts +++ b/src/utils/api-client.ts @@ -1,6 +1,10 @@ import * as https from 'node:https' import * as http from 'node:http' +// Default public patch API proxy URL for free patches (no auth required) +// Patch API routes are now served via firewall-api-proxy under /patch prefix +const DEFAULT_PATCH_API_PROXY_URL = 'https://firewall-api.socket.dev/patch' + // Full patch response with blob content (from view endpoint) export interface PatchResponse { uuid: string @@ -55,16 +59,23 @@ export interface SearchResponse { export interface APIClientOptions { apiUrl: string - apiToken: string + apiToken?: string + /** + * When true, the client will use the public patch API proxy + * which only provides access to free patches without authentication. + */ + usePublicProxy?: boolean } export class APIClient { private readonly apiUrl: string - private readonly apiToken: string + private readonly apiToken?: string + private readonly usePublicProxy: boolean constructor(options: APIClientOptions) { this.apiUrl = options.apiUrl.replace(/\/$/, '') // Remove trailing slash this.apiToken = options.apiToken + this.usePublicProxy = options.usePublicProxy ?? false } /** @@ -78,12 +89,18 @@ export class APIClient { const isHttps = urlObj.protocol === 'https:' const httpModule = isHttps ? https : http + const headers: Record = { + Accept: 'application/json', + } + + // Only add auth header if we have a token (not using public proxy) + if (this.apiToken) { + headers['Authorization'] = `Bearer ${this.apiToken}` + } + const options: https.RequestOptions = { method: 'GET', - headers: { - Authorization: `Bearer ${this.apiToken}`, - Accept: 'application/json', - }, + headers, } const req = httpModule.request(urlObj, options, res => { @@ -106,11 +123,10 @@ export class APIClient { } else if (res.statusCode === 401) { reject(new Error('Unauthorized: Invalid API token')) } else if (res.statusCode === 403) { - reject( - new Error( - 'Forbidden: Access denied. This may be a paid patch or you may not have access to this organization.', - ), - ) + const msg = this.usePublicProxy + ? 'Forbidden: This patch is only available to paid subscribers. Sign up at https://socket.dev to access paid patches.' + : 'Forbidden: Access denied. This may be a paid patch or you may not have access to this organization.' + reject(new Error(msg)) } else if (res.statusCode === 429) { reject(new Error('Rate limit exceeded. Please try again later.')) } else { @@ -135,10 +151,14 @@ export class APIClient { * Fetch a patch by UUID (full details with blob content) */ async fetchPatch( - orgSlug: string, + orgSlug: string | null, uuid: string, ): Promise { - return this.get(`/v0/orgs/${orgSlug}/patches/view/${uuid}`) + // Public proxy uses simpler URL structure (no org slug needed) + const path = this.usePublicProxy + ? `/view/${uuid}` + : `/v0/orgs/${orgSlug}/patches/view/${uuid}` + return this.get(path) } /** @@ -146,12 +166,14 @@ export class APIClient { * Returns lightweight search results (no blob content) */ async searchPatchesByCVE( - orgSlug: string, + orgSlug: string | null, cveId: string, ): Promise { - const result = await this.get( - `/v0/orgs/${orgSlug}/patches/by-cve/${encodeURIComponent(cveId)}`, - ) + // Public proxy uses simpler URL structure (no org slug needed) + const path = this.usePublicProxy + ? `/by-cve/${encodeURIComponent(cveId)}` + : `/v0/orgs/${orgSlug}/patches/by-cve/${encodeURIComponent(cveId)}` + const result = await this.get(path) return result ?? { patches: [], canAccessPaidPatches: false } } @@ -160,39 +182,46 @@ export class APIClient { * Returns lightweight search results (no blob content) */ async searchPatchesByGHSA( - orgSlug: string, + orgSlug: string | null, ghsaId: string, ): Promise { - const result = await this.get( - `/v0/orgs/${orgSlug}/patches/by-ghsa/${encodeURIComponent(ghsaId)}`, - ) + // Public proxy uses simpler URL structure (no org slug needed) + const path = this.usePublicProxy + ? `/by-ghsa/${encodeURIComponent(ghsaId)}` + : `/v0/orgs/${orgSlug}/patches/by-ghsa/${encodeURIComponent(ghsaId)}` + const result = await this.get(path) return result ?? { patches: [], canAccessPaidPatches: false } } - /** - * Search patches by package name (partial PURL match) - * Returns lightweight search results (no blob content) - */ - async searchPatchesByPackage( - orgSlug: string, - packageQuery: string, - ): Promise { - const result = await this.get( - `/v0/orgs/${orgSlug}/patches/by-package/${encodeURIComponent(packageQuery)}`, - ) - return result ?? { patches: [], canAccessPaidPatches: false } - } } -export function getAPIClientFromEnv(): APIClient { - const apiUrl = process.env.SOCKET_API_URL || 'https://api.socket.dev' +/** + * Get an API client configured from environment variables. + * + * If SOCKET_API_TOKEN is not set, the client will use the public patch API proxy + * which provides free access to free-tier patches without authentication. + * + * Environment variables: + * - SOCKET_API_URL: Override the API URL (defaults to https://api.socket.dev) + * - SOCKET_API_TOKEN: API token for authenticated access to all patches + * - SOCKET_PATCH_PROXY_URL: Override the public proxy URL (defaults to https://patch-api.socket.dev) + */ +export function getAPIClientFromEnv(): { client: APIClient; usePublicProxy: boolean } { const apiToken = process.env.SOCKET_API_TOKEN if (!apiToken) { - throw new Error( - 'SOCKET_API_TOKEN environment variable is required. Please set it to your Socket API token.', - ) + // No token provided - use public proxy for free patches + const proxyUrl = process.env.SOCKET_PATCH_PROXY_URL || DEFAULT_PATCH_API_PROXY_URL + console.log('No SOCKET_API_TOKEN set. Using public patch API proxy (free patches only).') + return { + client: new APIClient({ apiUrl: proxyUrl, usePublicProxy: true }), + usePublicProxy: true, + } } - return new APIClient({ apiUrl, apiToken }) + const apiUrl = process.env.SOCKET_API_URL || 'https://api.socket.dev' + return { + client: new APIClient({ apiUrl, apiToken }), + usePublicProxy: false, + } }