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
11 changes: 6 additions & 5 deletions src/commands/scan/create-scan-from-github.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { confirm, select } from '@socketsecurity/registry/lib/prompts'
import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts'
import { handleCreateNewScan } from './handle-create-new-scan.mts'
import constants from '../../constants.mts'
import { apiFetch } from '../../utils/api.mts'
import { debugApiRequest, debugApiResponse } from '../../utils/debug.mts'
import { formatErrorWithDetail } from '../../utils/errors.mts'
import { isReportSupportedFile } from '../../utils/glob.mts'
Expand Down Expand Up @@ -402,7 +403,7 @@ async function downloadManifestFile({
debugApiRequest('GET', fileUrl)
let downloadUrlResponse: Response
try {
downloadUrlResponse = await fetch(fileUrl, {
downloadUrlResponse = await apiFetch(fileUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${githubToken}`,
Expand Down Expand Up @@ -466,7 +467,7 @@ async function streamDownloadWithFetch(

try {
debugApiRequest('GET', downloadUrl)
response = await fetch(downloadUrl)
response = await apiFetch(downloadUrl)
debugApiResponse('GET', downloadUrl, response.status)

if (!response.ok) {
Expand Down Expand Up @@ -567,7 +568,7 @@ async function getLastCommitDetails({
debugApiRequest('GET', commitApiUrl)
let commitResponse: Response
try {
commitResponse = await fetch(commitApiUrl, {
commitResponse = await apiFetch(commitApiUrl, {
headers: {
Authorization: `Bearer ${githubToken}`,
},
Expand Down Expand Up @@ -679,7 +680,7 @@ async function getRepoDetails({
let repoDetailsResponse: Response
try {
debugApiRequest('GET', repoApiUrl)
repoDetailsResponse = await fetch(repoApiUrl, {
repoDetailsResponse = await apiFetch(repoApiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${githubToken}`,
Expand Down Expand Up @@ -743,7 +744,7 @@ async function getRepoBranchTree({
let treeResponse: Response
try {
debugApiRequest('GET', treeApiUrl)
treeResponse = await fetch(treeApiUrl, {
treeResponse = await apiFetch(treeApiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${githubToken}`,
Expand Down
149 changes: 146 additions & 3 deletions src/utils/api.mts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
* - Falls back to configured apiBaseUrl or default API_V0_URL
*/

import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'

import { messageWithCauses } from 'pony-cause'

import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
Expand All @@ -37,7 +39,7 @@ import constants, {
HTTP_STATUS_UNAUTHORIZED,
} from '../constants.mts'
import { getRequirements, getRequirementsKey } from './requirements.mts'
import { getDefaultApiToken } from './sdk.mts'
import { getDefaultApiToken, getExtraCaCerts } from './sdk.mts'

import type { CResult } from '../types.mts'
import type { Spinner } from '@socketsecurity/registry/lib/spinner'
Expand All @@ -48,8 +50,149 @@ import type {
SocketSdkSuccessResult,
} from '@socketsecurity/sdk'

const MAX_REDIRECTS = 20
const NO_ERROR_MESSAGE = 'No error message returned'

// Cached HTTPS agent for extra CA certificate support in direct API calls.
let _httpsAgent: HttpsAgent | undefined
let _httpsAgentResolved = false

// Returns an HTTPS agent configured with extra CA certificates when
// SSL_CERT_FILE is set but NODE_EXTRA_CA_CERTS is not.
function getHttpsAgent(): HttpsAgent | undefined {
if (_httpsAgentResolved) {
return _httpsAgent
}
_httpsAgentResolved = true
const ca = getExtraCaCerts()
if (!ca) {
return undefined
}
_httpsAgent = new HttpsAgent({ ca })
return _httpsAgent
}

// Wrapper around fetch that supports extra CA certificates via SSL_CERT_FILE.
// Uses node:https.request with a custom agent when extra CA certs are needed,
// falling back to regular fetch() otherwise. Follows redirects like fetch().
export type ApiFetchInit = {
body?: string | undefined
headers?: Record<string, string> | undefined
method?: string | undefined
}

// Internal httpsRequest-based fetch with redirect support.
function _httpsRequestFetch(
url: string,
init: ApiFetchInit,
agent: HttpsAgent,
redirectCount: number,
): Promise<Response> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = { ...init.headers }
// Set Content-Length for request bodies to avoid chunked transfer encoding.
if (init.body) {
headers['content-length'] = String(Buffer.byteLength(init.body))
}
const req = httpsRequest(
url,
{
method: init.method || 'GET',
headers,
agent,
},
res => {
const { statusCode } = res
// Follow redirects to match fetch() behavior.
if (
statusCode &&
statusCode >= 300 &&
statusCode < 400 &&
res.headers['location']
) {
// Consume the response body to free up memory.
res.resume()
if (redirectCount >= MAX_REDIRECTS) {
reject(new Error('Maximum redirect limit reached'))
return
}
const redirectUrl = new URL(res.headers['location'], url).href
// Strip sensitive headers on cross-origin redirects to match
// fetch() behavior per the Fetch spec.
const originalOrigin = new URL(url).origin
const redirectOrigin = new URL(redirectUrl).origin
let redirectHeaders = init.headers
if (originalOrigin !== redirectOrigin && redirectHeaders) {
redirectHeaders = { ...redirectHeaders }
for (const key of Object.keys(redirectHeaders)) {
const lower = key.toLowerCase()
if (
lower === 'authorization' ||
lower === 'cookie' ||
lower === 'proxy-authorization'
) {
delete redirectHeaders[key]
}
}
}
// 307 and 308 preserve the original method and body.
const preserveMethod = statusCode === 307 || statusCode === 308
resolve(
_httpsRequestFetch(
redirectUrl,
preserveMethod
? { ...init, headers: redirectHeaders }
: { headers: redirectHeaders, method: 'GET' },
agent,
redirectCount + 1,
),
)
return
}
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
const body = Buffer.concat(chunks)
const responseHeaders = new Headers()
for (const [key, value] of Object.entries(res.headers)) {
if (typeof value === 'string') {
responseHeaders.set(key, value)
} else if (Array.isArray(value)) {
for (const v of value) {
responseHeaders.append(key, v)
}
}
}
resolve(
new Response(body, {
status: statusCode ?? 0,
statusText: res.statusMessage ?? '',
headers: responseHeaders,
}),
)
})
res.on('error', reject)
},
)
if (init.body) {
req.write(init.body)
}
req.on('error', reject)
req.end()
})
}

export async function apiFetch(
url: string,
init: ApiFetchInit = {},
): Promise<Response> {
const agent = getHttpsAgent()
if (!agent) {
return await fetch(url, init as globalThis.RequestInit)
}
return await _httpsRequestFetch(url, init, agent, 0)
}

export type CommandRequirements = {
permissions?: string[] | undefined
quota?: number | undefined
Expand Down Expand Up @@ -287,7 +430,7 @@ async function queryApi(path: string, apiToken: string) {
}

const url = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`
const result = await fetch(url, {
const result = await apiFetch(url, {
method: 'GET',
headers: {
Authorization: `Basic ${btoa(`${apiToken}:`)}`,
Expand Down Expand Up @@ -480,7 +623,7 @@ export async function sendApiRequest<T>(
...(body ? { body: JSON.stringify(body) } : {}),
}

result = await fetch(
result = await apiFetch(
`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`,
fetchOptions,
)
Expand Down
Loading
Loading