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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 39 additions & 45 deletions src/commands/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
}
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -165,7 +163,6 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
id: forceId,
cve: forceCve,
ghsa: forceGhsa,
pkg: forcePackage,
yes: skipConfirmation,
'api-url': apiUrl,
'api-token': apiToken,
Expand All @@ -179,8 +176,18 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
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
Expand All @@ -190,17 +197,21 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
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
Expand Down Expand Up @@ -246,20 +257,12 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
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:
Expand Down Expand Up @@ -331,7 +334,7 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {

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++
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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
Expand Down
109 changes: 69 additions & 40 deletions src/utils/api-client.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -78,12 +89,18 @@ export class APIClient {
const isHttps = urlObj.protocol === 'https:'
const httpModule = isHttps ? https : http

const headers: Record<string, string> = {
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 => {
Expand All @@ -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 {
Expand All @@ -135,23 +151,29 @@ export class APIClient {
* Fetch a patch by UUID (full details with blob content)
*/
async fetchPatch(
orgSlug: string,
orgSlug: string | null,
uuid: string,
): Promise<PatchResponse | null> {
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)
}

/**
* Search patches by CVE ID
* Returns lightweight search results (no blob content)
*/
async searchPatchesByCVE(
orgSlug: string,
orgSlug: string | null,
cveId: string,
): Promise<SearchResponse> {
const result = await this.get<SearchResponse>(
`/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<SearchResponse>(path)
return result ?? { patches: [], canAccessPaidPatches: false }
}

Expand All @@ -160,39 +182,46 @@ export class APIClient {
* Returns lightweight search results (no blob content)
*/
async searchPatchesByGHSA(
orgSlug: string,
orgSlug: string | null,
ghsaId: string,
): Promise<SearchResponse> {
const result = await this.get<SearchResponse>(
`/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<SearchResponse>(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<SearchResponse> {
const result = await this.get<SearchResponse>(
`/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,
}
}