Skip to content

Commit a5b65c1

Browse files
Merge pull request #9 from SocketDev/feature/public-patch-proxy-support
Add support for public patch API proxy
2 parents 5c892f0 + a4a3e5f commit a5b65c1

File tree

2 files changed

+108
-85
lines changed

2 files changed

+108
-85
lines changed

src/commands/download.ts

Lines changed: 39 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,15 @@ const UUID_PATTERN =
2020
const CVE_PATTERN = /^CVE-\d{4}-\d+$/i
2121
const GHSA_PATTERN = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i
2222

23-
type IdentifierType = 'uuid' | 'cve' | 'ghsa' | 'package'
23+
type IdentifierType = 'uuid' | 'cve' | 'ghsa'
2424

2525
interface DownloadArgs {
2626
identifier: string
27-
org: string
27+
org?: string
2828
cwd: string
2929
id?: boolean
3030
cve?: boolean
3131
ghsa?: boolean
32-
pkg?: boolean
3332
yes?: boolean
3433
'api-url'?: string
3534
'api-token'?: string
@@ -38,7 +37,7 @@ interface DownloadArgs {
3837
/**
3938
* Detect the type of identifier based on its format
4039
*/
41-
function detectIdentifierType(identifier: string): IdentifierType {
40+
function detectIdentifierType(identifier: string): IdentifierType | null {
4241
if (UUID_PATTERN.test(identifier)) {
4342
return 'uuid'
4443
}
@@ -48,8 +47,7 @@ function detectIdentifierType(identifier: string): IdentifierType {
4847
if (GHSA_PATTERN.test(identifier)) {
4948
return 'ghsa'
5049
}
51-
// Default to package search for anything else
52-
return 'package'
50+
return null
5351
}
5452

5553
/**
@@ -165,7 +163,6 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
165163
id: forceId,
166164
cve: forceCve,
167165
ghsa: forceGhsa,
168-
pkg: forcePackage,
169166
yes: skipConfirmation,
170167
'api-url': apiUrl,
171168
'api-token': apiToken,
@@ -179,8 +176,18 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
179176
process.env.SOCKET_API_TOKEN = apiToken
180177
}
181178

182-
// Get API client
183-
const apiClient = getAPIClientFromEnv()
179+
// Get API client (will use public proxy if no token is set)
180+
const { client: apiClient, usePublicProxy } = getAPIClientFromEnv()
181+
182+
// Validate that org is provided when using authenticated API
183+
if (!usePublicProxy && !orgSlug) {
184+
throw new Error(
185+
'--org is required when using SOCKET_API_TOKEN. Provide an organization slug.',
186+
)
187+
}
188+
189+
// The org slug to use (null when using public proxy)
190+
const effectiveOrgSlug = usePublicProxy ? null : orgSlug ?? null
184191

185192
// Determine identifier type
186193
let idType: IdentifierType
@@ -190,17 +197,21 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
190197
idType = 'cve'
191198
} else if (forceGhsa) {
192199
idType = 'ghsa'
193-
} else if (forcePackage) {
194-
idType = 'package'
195200
} else {
196-
idType = detectIdentifierType(identifier)
201+
const detectedType = detectIdentifierType(identifier)
202+
if (!detectedType) {
203+
throw new Error(
204+
`Unrecognized identifier format: ${identifier}. Expected UUID, CVE ID (CVE-YYYY-NNNNN), or GHSA ID (GHSA-xxxx-xxxx-xxxx).`,
205+
)
206+
}
207+
idType = detectedType
197208
console.log(`Detected identifier type: ${idType}`)
198209
}
199210

200211
// For UUID, directly fetch and download the patch
201212
if (idType === 'uuid') {
202213
console.log(`Fetching patch by UUID: ${identifier}`)
203-
const patch = await apiClient.fetchPatch(orgSlug, identifier)
214+
const patch = await apiClient.fetchPatch(effectiveOrgSlug, identifier)
204215
if (!patch) {
205216
console.log(`No patch found with UUID: ${identifier}`)
206217
return true
@@ -246,20 +257,12 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
246257
switch (idType) {
247258
case 'cve': {
248259
console.log(`Searching patches for CVE: ${identifier}`)
249-
searchResponse = await apiClient.searchPatchesByCVE(orgSlug, identifier)
260+
searchResponse = await apiClient.searchPatchesByCVE(effectiveOrgSlug, identifier)
250261
break
251262
}
252263
case 'ghsa': {
253264
console.log(`Searching patches for GHSA: ${identifier}`)
254-
searchResponse = await apiClient.searchPatchesByGHSA(orgSlug, identifier)
255-
break
256-
}
257-
case 'package': {
258-
console.log(`Searching patches for package: ${identifier}`)
259-
searchResponse = await apiClient.searchPatchesByPackage(
260-
orgSlug,
261-
identifier,
262-
)
265+
searchResponse = await apiClient.searchPatchesByGHSA(effectiveOrgSlug, identifier)
263266
break
264267
}
265268
default:
@@ -331,7 +334,7 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
331334

332335
for (const searchResult of accessiblePatches) {
333336
// Fetch full patch details with blob content
334-
const patch = await apiClient.fetchPatch(orgSlug, searchResult.uuid)
337+
const patch = await apiClient.fetchPatch(effectiveOrgSlug, searchResult.uuid)
335338
if (!patch) {
336339
console.log(` [fail] ${searchResult.purl} (could not fetch details)`)
337340
patchesFailed++
@@ -378,14 +381,14 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = {
378381
return yargs
379382
.positional('identifier', {
380383
describe:
381-
'Patch identifier (UUID, CVE ID, GHSA ID, or package name)',
384+
'Patch identifier (UUID, CVE ID, or GHSA ID)',
382385
type: 'string',
383386
demandOption: true,
384387
})
385388
.option('org', {
386-
describe: 'Organization slug',
389+
describe: 'Organization slug (required when using SOCKET_API_TOKEN, optional for public proxy)',
387390
type: 'string',
388-
demandOption: true,
391+
demandOption: false,
389392
})
390393
.option('id', {
391394
describe: 'Force identifier to be treated as a patch UUID',
@@ -402,11 +405,6 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = {
402405
type: 'boolean',
403406
default: false,
404407
})
405-
.option('pkg', {
406-
describe: 'Force identifier to be treated as a package name',
407-
type: 'boolean',
408-
default: false,
409-
})
410408
.option('yes', {
411409
alias: 'y',
412410
describe: 'Skip confirmation prompt for multiple patches',
@@ -427,33 +425,29 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = {
427425
type: 'string',
428426
})
429427
.example(
430-
'$0 download 12345678-1234-1234-1234-123456789abc --org myorg',
431-
'Download a patch by UUID',
432-
)
433-
.example(
434-
'$0 download CVE-2021-44228 --org myorg',
435-
'Search and download patches for a CVE',
428+
'$0 download CVE-2021-44228',
429+
'Download free patches for a CVE (no auth required)',
436430
)
437431
.example(
438-
'$0 download GHSA-jfhm-5ghh-2f97 --org myorg',
439-
'Search and download patches for a GHSA',
432+
'$0 download GHSA-jfhm-5ghh-2f97',
433+
'Download free patches for a GHSA (no auth required)',
440434
)
441435
.example(
442-
'$0 download lodash --org myorg --pkg',
443-
'Search and download patches for a package',
436+
'$0 download 12345678-1234-1234-1234-123456789abc --org myorg',
437+
'Download a patch by UUID (requires SOCKET_API_TOKEN)',
444438
)
445439
.example(
446440
'$0 download CVE-2021-44228 --org myorg --yes',
447-
'Download all matching patches without confirmation',
441+
'Download all matching patches without confirmation (with auth)',
448442
)
449443
.check(argv => {
450444
// Ensure only one type flag is set
451-
const typeFlags = [argv.id, argv.cve, argv.ghsa, argv.pkg].filter(
445+
const typeFlags = [argv.id, argv.cve, argv.ghsa].filter(
452446
Boolean,
453447
)
454448
if (typeFlags.length > 1) {
455449
throw new Error(
456-
'Only one of --id, --cve, --ghsa, or --pkg can be specified',
450+
'Only one of --id, --cve, or --ghsa can be specified',
457451
)
458452
}
459453
return true

src/utils/api-client.ts

Lines changed: 69 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import * as https from 'node:https'
22
import * as http from 'node:http'
33

4+
// Default public patch API proxy URL for free patches (no auth required)
5+
// Patch API routes are now served via firewall-api-proxy under /patch prefix
6+
const DEFAULT_PATCH_API_PROXY_URL = 'https://firewall-api.socket.dev/patch'
7+
48
// Full patch response with blob content (from view endpoint)
59
export interface PatchResponse {
610
uuid: string
@@ -55,16 +59,23 @@ export interface SearchResponse {
5559

5660
export interface APIClientOptions {
5761
apiUrl: string
58-
apiToken: string
62+
apiToken?: string
63+
/**
64+
* When true, the client will use the public patch API proxy
65+
* which only provides access to free patches without authentication.
66+
*/
67+
usePublicProxy?: boolean
5968
}
6069

6170
export class APIClient {
6271
private readonly apiUrl: string
63-
private readonly apiToken: string
72+
private readonly apiToken?: string
73+
private readonly usePublicProxy: boolean
6474

6575
constructor(options: APIClientOptions) {
6676
this.apiUrl = options.apiUrl.replace(/\/$/, '') // Remove trailing slash
6777
this.apiToken = options.apiToken
78+
this.usePublicProxy = options.usePublicProxy ?? false
6879
}
6980

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

92+
const headers: Record<string, string> = {
93+
Accept: 'application/json',
94+
}
95+
96+
// Only add auth header if we have a token (not using public proxy)
97+
if (this.apiToken) {
98+
headers['Authorization'] = `Bearer ${this.apiToken}`
99+
}
100+
81101
const options: https.RequestOptions = {
82102
method: 'GET',
83-
headers: {
84-
Authorization: `Bearer ${this.apiToken}`,
85-
Accept: 'application/json',
86-
},
103+
headers,
87104
}
88105

89106
const req = httpModule.request(urlObj, options, res => {
@@ -106,11 +123,10 @@ export class APIClient {
106123
} else if (res.statusCode === 401) {
107124
reject(new Error('Unauthorized: Invalid API token'))
108125
} else if (res.statusCode === 403) {
109-
reject(
110-
new Error(
111-
'Forbidden: Access denied. This may be a paid patch or you may not have access to this organization.',
112-
),
113-
)
126+
const msg = this.usePublicProxy
127+
? 'Forbidden: This patch is only available to paid subscribers. Sign up at https://socket.dev to access paid patches.'
128+
: 'Forbidden: Access denied. This may be a paid patch or you may not have access to this organization.'
129+
reject(new Error(msg))
114130
} else if (res.statusCode === 429) {
115131
reject(new Error('Rate limit exceeded. Please try again later.'))
116132
} else {
@@ -135,23 +151,29 @@ export class APIClient {
135151
* Fetch a patch by UUID (full details with blob content)
136152
*/
137153
async fetchPatch(
138-
orgSlug: string,
154+
orgSlug: string | null,
139155
uuid: string,
140156
): Promise<PatchResponse | null> {
141-
return this.get(`/v0/orgs/${orgSlug}/patches/view/${uuid}`)
157+
// Public proxy uses simpler URL structure (no org slug needed)
158+
const path = this.usePublicProxy
159+
? `/view/${uuid}`
160+
: `/v0/orgs/${orgSlug}/patches/view/${uuid}`
161+
return this.get(path)
142162
}
143163

144164
/**
145165
* Search patches by CVE ID
146166
* Returns lightweight search results (no blob content)
147167
*/
148168
async searchPatchesByCVE(
149-
orgSlug: string,
169+
orgSlug: string | null,
150170
cveId: string,
151171
): Promise<SearchResponse> {
152-
const result = await this.get<SearchResponse>(
153-
`/v0/orgs/${orgSlug}/patches/by-cve/${encodeURIComponent(cveId)}`,
154-
)
172+
// Public proxy uses simpler URL structure (no org slug needed)
173+
const path = this.usePublicProxy
174+
? `/by-cve/${encodeURIComponent(cveId)}`
175+
: `/v0/orgs/${orgSlug}/patches/by-cve/${encodeURIComponent(cveId)}`
176+
const result = await this.get<SearchResponse>(path)
155177
return result ?? { patches: [], canAccessPaidPatches: false }
156178
}
157179

@@ -160,39 +182,46 @@ export class APIClient {
160182
* Returns lightweight search results (no blob content)
161183
*/
162184
async searchPatchesByGHSA(
163-
orgSlug: string,
185+
orgSlug: string | null,
164186
ghsaId: string,
165187
): Promise<SearchResponse> {
166-
const result = await this.get<SearchResponse>(
167-
`/v0/orgs/${orgSlug}/patches/by-ghsa/${encodeURIComponent(ghsaId)}`,
168-
)
188+
// Public proxy uses simpler URL structure (no org slug needed)
189+
const path = this.usePublicProxy
190+
? `/by-ghsa/${encodeURIComponent(ghsaId)}`
191+
: `/v0/orgs/${orgSlug}/patches/by-ghsa/${encodeURIComponent(ghsaId)}`
192+
const result = await this.get<SearchResponse>(path)
169193
return result ?? { patches: [], canAccessPaidPatches: false }
170194
}
171195

172-
/**
173-
* Search patches by package name (partial PURL match)
174-
* Returns lightweight search results (no blob content)
175-
*/
176-
async searchPatchesByPackage(
177-
orgSlug: string,
178-
packageQuery: string,
179-
): Promise<SearchResponse> {
180-
const result = await this.get<SearchResponse>(
181-
`/v0/orgs/${orgSlug}/patches/by-package/${encodeURIComponent(packageQuery)}`,
182-
)
183-
return result ?? { patches: [], canAccessPaidPatches: false }
184-
}
185196
}
186197

187-
export function getAPIClientFromEnv(): APIClient {
188-
const apiUrl = process.env.SOCKET_API_URL || 'https://api.socket.dev'
198+
/**
199+
* Get an API client configured from environment variables.
200+
*
201+
* If SOCKET_API_TOKEN is not set, the client will use the public patch API proxy
202+
* which provides free access to free-tier patches without authentication.
203+
*
204+
* Environment variables:
205+
* - SOCKET_API_URL: Override the API URL (defaults to https://api.socket.dev)
206+
* - SOCKET_API_TOKEN: API token for authenticated access to all patches
207+
* - SOCKET_PATCH_PROXY_URL: Override the public proxy URL (defaults to https://patch-api.socket.dev)
208+
*/
209+
export function getAPIClientFromEnv(): { client: APIClient; usePublicProxy: boolean } {
189210
const apiToken = process.env.SOCKET_API_TOKEN
190211

191212
if (!apiToken) {
192-
throw new Error(
193-
'SOCKET_API_TOKEN environment variable is required. Please set it to your Socket API token.',
194-
)
213+
// No token provided - use public proxy for free patches
214+
const proxyUrl = process.env.SOCKET_PATCH_PROXY_URL || DEFAULT_PATCH_API_PROXY_URL
215+
console.log('No SOCKET_API_TOKEN set. Using public patch API proxy (free patches only).')
216+
return {
217+
client: new APIClient({ apiUrl: proxyUrl, usePublicProxy: true }),
218+
usePublicProxy: true,
219+
}
195220
}
196221

197-
return new APIClient({ apiUrl, apiToken })
222+
const apiUrl = process.env.SOCKET_API_URL || 'https://api.socket.dev'
223+
return {
224+
client: new APIClient({ apiUrl, apiToken }),
225+
usePublicProxy: false,
226+
}
198227
}

0 commit comments

Comments
 (0)