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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ All `logger.error()` and `logger.log()` calls include empty string:
- **Type imports**: Always `import type`
- **Nullish values**: Prefer `undefined` over `null` - use `undefined` for absent/missing values

#### HTTP Requests

- **🚨 NEVER use `fetch()`** - use `createGetRequest`/`createRequestWithJson` from `src/http-client.ts`
- `fetch()` bypasses the SDK's HTTP stack (retries, timeouts, hooks, agent config)
- `fetch()` cannot be intercepted by nock in tests, forcing c8 ignore blocks
- For external URLs (e.g., firewall API), pass a different `baseUrl` to `createGetRequest`

#### Working Directory

- **🚨 NEVER use `process.chdir()`** - use `{ cwd }` options and absolute paths instead
Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export const MAX_STREAM_SIZE = 100 * 1024 * 1024
// Public blob store URL for patch downloads
export const SOCKET_PUBLIC_BLOB_STORE_URL = 'https://socketusercontent.com'

// Max components to check via parallel firewall API before switching to batch.
export const MAX_FIREWALL_COMPONENTS = 8

// Public firewall API URL for per-package lookups (unauthenticated, fast).
export const SOCKET_FIREWALL_API_URL = 'https://firewall-api.socket.dev/purl'

Expand Down
44 changes: 20 additions & 24 deletions src/socket-sdk-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
DEFAULT_RETRY_DELAY,
DEFAULT_USER_AGENT,
httpAgentNames,
MAX_FIREWALL_COMPONENTS,
MAX_HTTP_TIMEOUT,
MAX_STREAM_SIZE,
MIN_HTTP_TIMEOUT,
Expand Down Expand Up @@ -966,52 +967,48 @@ export class SocketSdk {
/**
* Check packages for malware and security alerts.
*
* For public tokens, uses the firewall API (per-package, unauthenticated)
* which returns full artifact data including score and alert details.
* Alerts are filtered using the client-side publicPolicy map.
* For small sets (≤ MAX_FIREWALL_COMPONENTS), uses parallel firewall API
* requests which return full artifact data including score and alert details.
*
* For org tokens, uses the batch PURL API with full org policy.
* Alerts are filtered using the server-assigned action.
* For larger sets, uses the batch PURL API for efficiency.
*
* Both paths return the same normalized result shape.
* Both paths normalize alerts through publicPolicy and only return
* malware-relevant results.
*
* @param components - Array of package URLs to check
* @returns Normalized results with policy-filtered alerts per package
*/
async checkMalware(
components: Array<{ purl: string }>,
): Promise<SocketSdkGenericResult<MalwareCheckResult>> {
const isPublicToken = this.#apiToken === SOCKET_PUBLIC_API_TOKEN
/* c8 ignore next 3 - c8 ignored: because public token path uses global fetch() to SOCKET_FIREWALL_API_URL which cannot be intercepted by nock or local HTTP servers; tested via the isolated checkMalware test with mocked fetch */
if (isPublicToken) {
if (components.length <= MAX_FIREWALL_COMPONENTS) {
return this.#checkMalwareFirewall(components)
}
return this.#checkMalwareBatch(components)
}

// Public token path: parallel firewall API requests per PURL.
// Small-set path: parallel firewall API requests per PURL.
// Returns full artifact data (score, alert props, categories, fix info).
/* c8 ignore start - c8 ignored: because #checkMalwareFirewall uses global fetch() to an external URL (firewall-api.socket.dev) that cannot be intercepted in the main test suite */
async #checkMalwareFirewall(
components: Array<{ purl: string }>,
): Promise<SocketSdkGenericResult<MalwareCheckResult>> {
const packages: MalwareCheckPackage[] = []
const results = await Promise.allSettled(
components.map(async ({ purl }) => {
const url = `${SOCKET_FIREWALL_API_URL}/${encodeURIComponent(purl)}`
const resp = await fetch(url, {
signal: AbortSignal.timeout(
this.#reqOptions.timeout ?? DEFAULT_HTTP_TIMEOUT,
),
})
if (!resp.ok) return undefined
return (await resp.json()) as Record<string, unknown>
const urlPath = `/${encodeURIComponent(purl)}`
const response = await createGetRequest(
SOCKET_FIREWALL_API_URL,
urlPath,
this.#reqOptions,
)
if (!isResponseOk(response)) return undefined
const json = await getResponseJson(response)
return json as unknown as SocketArtifact
}),
)
for (const settled of results) {
if (settled.status === 'rejected' || !settled.value) continue
const artifact = settled.value as SocketArtifact
packages.push(SocketSdk.#normalizeArtifact(artifact, publicPolicy))
packages.push(SocketSdk.#normalizeArtifact(settled.value, publicPolicy))
}
return {
cause: undefined,
Expand All @@ -1021,9 +1018,8 @@ export class SocketSdk {
success: true,
}
}
/* c8 ignore stop */

// Org token path: single batch PURL API request.
// Multi-component path: batch PURL API request, normalized to publicPolicy.
async #checkMalwareBatch(
components: Array<{ purl: string }>,
): Promise<SocketSdkGenericResult<MalwareCheckResult>> {
Expand All @@ -1042,7 +1038,7 @@ export class SocketSdk {
}
const packages: MalwareCheckPackage[] = []
for (const artifact of result.data as SocketArtifact[]) {
packages.push(SocketSdk.#normalizeArtifact(artifact))
packages.push(SocketSdk.#normalizeArtifact(artifact, publicPolicy))
}
return {
cause: undefined,
Expand Down
116 changes: 61 additions & 55 deletions test/unit/coverage-non-error-paths.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* #executeWithRetry onRetry branches (401/403, 429 with Retry-After),
* #getResponseText 50MB size limit,
* #getTtlForEndpoint / cache config (number, object with endpoint, default),
* #checkMalwareFirewall internals (rejected/undefined settled),
* #checkMalwareBatch normalize with publicPolicy,
* downloadOrgFullScanFilesAsTar streaming,
* streamFullScan data/error/end handlers,
* uploadManifestFiles edge case
Expand All @@ -27,6 +27,7 @@ import { PassThrough } from 'node:stream'

import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'

import { MAX_FIREWALL_COMPONENTS } from '../../src/constants.js'
import {
createRequestBodyForFilepaths,
createUploadRequest,
Expand Down Expand Up @@ -641,67 +642,70 @@ describe('SocketSdk - #parseRetryAfter via retry behavior', () => {
})

// =============================================================================
// 4e. socket-sdk-class.ts — #checkMalwareFirewall internals (lines 990-1018)
// Specifically: rejected promise and resp.ok=false branches
// 4e. socket-sdk-class.ts — #checkMalwareBatch normalize with publicPolicy
// Specifically: alerts with/without fix, ignore actions filtered
// =============================================================================

describe('SocketSdk - checkMalwareFirewall internals', () => {
describe('SocketSdk - checkMalware batch normalize with publicPolicy', () => {
const artifact = {
alerts: [
{
category: 'supplyChainRisk',
fix: { description: 'Remove package', type: 'remove' },
key: 'mal-1',
props: { note: 'data exfil' },
severity: 'critical',
type: 'malware',
},
{
// Alert without fix property — criticalCVE is 'warn' in publicPolicy
category: 'quality',
key: 'cve-1',
props: {},
severity: 'high',
type: 'criticalCVE',
},
{
// deprecated is 'ignore' in publicPolicy — should be filtered out
category: 'misc',
key: 'dep-1',
props: {},
severity: 'low',
type: 'deprecated',
},
],
name: 'evil-pkg',
namespace: undefined,
score: {
license: 0.9,
maintenance: 0.8,
overall: 0.1,
quality: 0.7,
supplyChain: 0.0,
vulnerability: 0.0,
},
type: 'npm',
version: '1.0.0',
}

const getBaseUrl = setupLocalHttpServer(
(req: IncomingMessage, res: ServerResponse) => {
const url = req.url || ''

// Batch purl path (org token) — exercises #normalizeArtifact.
// Batch purl path — exercises #normalizeArtifact with publicPolicy.
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
})
req.on('end', () => {
if (url.includes('/purl') && req.method === 'POST') {
const artifact = {
alerts: [
{
action: 'error',
category: 'supplyChainRisk',
fix: { description: 'Remove package', type: 'remove' },
key: 'mal-1',
props: { note: 'data exfil' },
severity: 'critical',
type: 'malware',
},
{
// Alert without fix property
action: 'warn',
category: 'quality',
key: 'cve-1',
props: {},
severity: 'high',
type: 'criticalCVE',
},
{
// Alert with ignore action — should be filtered out
action: 'ignore',
category: 'misc',
key: 'dep-1',
props: {},
severity: 'low',
type: 'deprecated',
},
],
name: 'evil-pkg',
namespace: undefined,
score: {
license: 0.9,
maintenance: 0.8,
overall: 0.1,
quality: 0.7,
supplyChain: 0.0,
vulnerability: 0.0,
},
type: 'npm',
version: '1.0.0',
}
const parsed = JSON.parse(body)
const count = parsed.components?.length ?? 0
const lines = Array.from({ length: count }, () =>
JSON.stringify(artifact),
).join('\n')
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' })
res.end(`${JSON.stringify(artifact)}\n`)
res.end(`${lines}\n`)
} else {
res.writeHead(404)
res.end()
Expand All @@ -711,21 +715,23 @@ describe('SocketSdk - checkMalwareFirewall internals', () => {
)

it('should normalize artifact with fix and without fix, filtering ignore actions', async () => {
const client = new SocketSdk('org-api-token', {
const count = MAX_FIREWALL_COMPONENTS + 1
const client = new SocketSdk('test-api-token', {
baseUrl: `${getBaseUrl()}/v0/`,
retries: 0,
})

const result = await client.checkMalware([
{ purl: 'pkg:npm/evil-pkg@1.0.0' },
])
const components = Array.from({ length: count }, (_, i) => ({
purl: `pkg:npm/evil-pkg@${i + 1}.0.0`,
}))
const result = await client.checkMalware(components)

expect(result.success).toBe(true)
if (!result.success) return
expect(result.data).toHaveLength(1)
expect(result.data).toHaveLength(count)
const pkg = result.data[0]!

// Two alerts should remain (error + warn), ignore is filtered
// Two alerts should remain (error + warn via publicPolicy), deprecated is filtered
expect(pkg.alerts).toHaveLength(2)

// First alert has fix
Expand Down
Loading