Skip to content
Open
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
4 changes: 2 additions & 2 deletions requirements.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@
"permissions": ["full-scans:create"]
},
"scan:report": {
"quota": 2,
"permissions": ["full-scans:list", "security-policy:read"]
"quota": 1,
"permissions": ["full-scans:list"]
},
"scan:view": {
"quota": 1,
Expand Down
4 changes: 2 additions & 2 deletions src/commands/scan/cmd-scan-report.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ describe('socket scan report', async () => {
$ socket scan report [options] <SCAN_ID> [OUTPUT_PATH]

API Token Requirements
- Quota: 2 units
- Permissions: full-scans:list and security-policy:read
- Quota: 1 unit
- Permissions: full-scans:list

Options
--fold Fold reported alerts to some degree (default 'none')
Expand Down
173 changes: 30 additions & 143 deletions src/commands/scan/fetch-report-data.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,179 +2,66 @@ import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
import { logger } from '@socketsecurity/registry/lib/logger'

import constants from '../../constants.mts'
import { handleApiCallNoSpinner, queryApiSafeText } from '../../utils/api.mts'
import { queryApiSafeText } from '../../utils/api.mts'
import { formatErrorWithDetail } from '../../utils/errors.mts'
import { setupSdk } from '../../utils/sdk.mts'

import type { CResult } from '../../types.mts'
import type { SocketArtifact } from '../../utils/alert/artifact.mts'
import type { SetupSdkOptions } from '../../utils/sdk.mts'
import type { SocketSdkSuccessResult } from '@socketsecurity/sdk'

export type FetchScanData = {
includeLicensePolicy?: boolean | undefined
sdkOpts?: SetupSdkOptions | undefined
}

/**
* This fetches all the relevant pieces of data to generate a report, given a
* full scan ID.
*/
/** Fetch the scan data needed to generate a report. */
export async function fetchScanData(
orgSlug: string,
scanId: string,
options?: FetchScanData | undefined,
): Promise<
CResult<{
scan: SocketArtifact[]
securityPolicy: SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data']
}>
> {
const { includeLicensePolicy, sdkOpts } = {
__proto__: null,
...options,
} as FetchScanData
const sockSdkCResult = await setupSdk(sdkOpts)
if (!sockSdkCResult.ok) {
return sockSdkCResult
}
const sockSdk = sockSdkCResult.data

let policyStatus = 'requested...'
let scanStatus = 'requested...'
let finishedFetching = false

): Promise<CResult<{ scan: SocketArtifact[] }>> {
const { spinner } = constants
spinner.start('Scan result: requested...')

function updateScan(status: string) {
scanStatus = status
updateProgress()
}

function updatePolicy(status: string) {
policyStatus = status
updateProgress()
}

function updateProgress() {
if (finishedFetching) {
spinner.stop()
logger.info(
`Scan result: ${scanStatus}. Security policy: ${policyStatus}.`,
)
} else {
spinner.start(
`Scan result: ${scanStatus}. Security policy: ${policyStatus}.`,
)
}
}

async function fetchScanResult(): Promise<CResult<SocketArtifact[]>> {
try {
const result = await queryApiSafeText(
`orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}${includeLicensePolicy ? '?include_license_details=true' : ''}`,
`orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}${options?.includeLicensePolicy ? '?include_license_details=true' : ''}`,
)

updateScan(`response received`)

if (!result.ok) {
spinner.stop()
logger.info('Scan result: request failed.')
return result
}

const ndJsonString = result.data

// This is nd-json; each line is a json object.
const lines = ndJsonString.split('\n').filter(Boolean)
let ok = true
const data = lines.map(line => {
const data: SocketArtifact[] = []
for (const line of result.data.split('\n').filter(Boolean)) {
try {
return JSON.parse(line)
} catch (e) {
ok = false
data.push(JSON.parse(line))
} catch (error) {
debugFn('error', 'Failed to parse report data line as JSON')
debugDir('error', { error: e, line })
return
debugDir('error', { error, line })
spinner.stop()
logger.info('Scan result: received invalid JSON response.')
return {
ok: false,
message: 'Invalid Socket API response',
cause:
'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.',
}
}
}) as unknown as SocketArtifact[]

if (ok) {
updateScan('success')
return { ok: true, data }
}

updateScan('received invalid JSON response')

spinner.stop()
logger.info('Scan result: success.')
return { ok: true, data: { scan: data } }
} catch (error) {
spinner.stop()
logger.info('Scan result: request failed.')
return {
ok: false,
message: 'Invalid Socket API response',
message: 'Socket API error',
cause:
'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.',
formatErrorWithDetail('Error requesting scan', error) ||
'Error requesting scan: (no error message found)',
}
}

async function fetchSecurityPolicy(): Promise<
CResult<SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data']>
> {
const result = await handleApiCallNoSpinner(
sockSdk.getOrgSecurityPolicy(orgSlug),
'GetOrgSecurityPolicy',
)

updatePolicy('received policy')

return result
}

updateProgress()

const [scan, securityPolicy]: [
CResult<SocketArtifact[]>,
CResult<SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data']>,
] = await Promise.all([
fetchScanResult().catch(e => {
updateScan('failure; unknown blocking error occurred')
return {
ok: false as const,
message: 'Socket API error',
cause:
formatErrorWithDetail('Error requesting scan', e) ||
'Error requesting scan: (no error message found)',
}
}),
fetchSecurityPolicy().catch(e => {
updatePolicy('failure; unknown blocking error occurred')
return {
ok: false as const,
message: 'Socket API error',
cause:
formatErrorWithDetail('Error requesting policy', e) ||
'Error requesting policy: (no error message found)',
}
}),
]).finally(() => {
finishedFetching = true
updateProgress()
})

if (!scan.ok) {
return scan
}
if (!securityPolicy.ok) {
return securityPolicy
}

if (!Array.isArray(scan.data)) {
return {
ok: false,
message: 'Failed to fetch',
cause: 'Was unable to fetch scan result, bailing',
}
}

return {
ok: true,
data: {
scan: scan.data satisfies SocketArtifact[],
securityPolicy: securityPolicy.data,
},
}
}
66 changes: 66 additions & 0 deletions src/commands/scan/fetch-report-data.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
process.env['SOCKET_CLI_API_TOKEN'] = 'test-token'
process.env['SOCKET_CLI_API_BASE_URL'] = 'https://api.socket.dev/v0/'

import nock from 'nock'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { fetchScanData } from './fetch-report-data.mts'

const BASE_HOST = 'https://api.socket.dev'

describe('fetchScanData', () => {
beforeEach(() => {
nock.cleanAll()
nock.disableNetConnect()
})

afterEach(() => {
nock.cleanAll()
nock.enableNetConnect()
})

it('fetches scan alerts with resolved actions without requesting policy', async () => {
nock(BASE_HOST)
.get('/v0/orgs/test-org/full-scans/scan-1')
.reply(
200,
`${JSON.stringify({
type: 'npm',
name: 'lodash',
version: '4.17.21',
alerts: [{ type: 'envVars', action: 'error' }],
})}\n`,
)

const result = await fetchScanData('test-org', 'scan-1')

expect(result).toEqual({
ok: true,
data: {
scan: [
{
type: 'npm',
name: 'lodash',
version: '4.17.21',
alerts: [{ type: 'envVars', action: 'error' }],
},
],
},
})
expect(nock.isDone()).toBe(true)
})

it('requests license details when the license option is enabled', async () => {
nock(BASE_HOST)
.get('/v0/orgs/test-org/full-scans/scan-2')
.query({ include_license_details: 'true' })
.reply(200, '')

const result = await fetchScanData('test-org', 'scan-2', {
includeLicensePolicy: true,
})

expect(result).toEqual({ ok: true, data: { scan: [] } })
expect(nock.isDone()).toBe(true)
})
})
61 changes: 19 additions & 42 deletions src/commands/scan/generate-report.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { getSocketDevPackageOverviewUrlFromPurl } from '../../utils/socket-url.m

import type { FOLD_SETTING, REPORT_LEVEL } from './types.mts'
import type { CResult } from '../../types.mts'
import type { SocketArtifact } from '../../utils/alert/artifact.mts'
import type { ALERT_ACTION, SocketArtifact } from '../../utils/alert/artifact.mts'
import type { Spinner } from '@socketsecurity/registry/lib/spinner'
import type { SocketSdkSuccessResult } from '@socketsecurity/sdk'

type AlertKey = string
type EcoMap = Map<string, ReportLeafNode | PackageMap>
Expand Down Expand Up @@ -40,7 +39,6 @@ export type ReportLeafNode = {
// failed. It won't reflect the healthy state.
export function generateReport(
scan: SocketArtifact[],
securityPolicy: SocketSdkSuccessResult<'getOrgSecurityPolicy'>['data'],
{
fold,
orgSlug,
Expand Down Expand Up @@ -72,7 +70,7 @@ export function generateReport(

// In the context of a report;
// - the alert.severity is irrelevant
// - the securityPolicyDefault is irrelevant
// - policy defaults are irrelevant because the API resolves actions per alert
// - the report defaults to healthy:true with no alerts
// - the appearance of an alert will trigger the policy action;
// - error: healthy will end up as false, add alerts to report
Expand All @@ -92,22 +90,19 @@ export function generateReport(

let healthy = true

const securityRules = securityPolicy.securityPolicyRules
if (securityRules) {
// Note: reportLevel: error > warn > monitor > ignore > defer
scan.forEach(artifact => {
const {
alerts,
name: pkgName = UNKNOWN_VALUE,
type: ecosystem,
version = UNKNOWN_VALUE,
} = artifact
// Note: reportLevel: error > warn > monitor > ignore > defer
scan.forEach(artifact => {
const {
alerts,
name: pkgName = UNKNOWN_VALUE,
type: ecosystem,
version = UNKNOWN_VALUE,
} = artifact

alerts?.forEach(
(alert: NonNullable<SocketArtifact['alerts']>[number]) => {
const alertName = alert.type as keyof typeof securityRules // => policy[type]
const action = securityRules[alertName]?.action || ''
switch (action) {
alerts?.forEach(
(alert: NonNullable<SocketArtifact['alerts']>[number]) => {
const action = alert.action as ALERT_ACTION | undefined
switch (action) {
case constants.REPORT_LEVEL_ERROR: {
healthy = false
if (!short) {
Expand Down Expand Up @@ -180,31 +175,13 @@ export function generateReport(
break
}

case constants.REPORT_LEVEL_DEFER: {
// Not sure but ignore for now. Defer to later ;)
if (!short && reportLevel === constants.REPORT_LEVEL_DEFER) {
addAlert(
artifact,
violations,
fold,
ecosystem,
pkgName,
version,
alert,
action,
)
}
break
}

default: {
// This value was not emitted from the Socket API at the time of writing.
// Alerts without a resolved policy action are not reportable.
}
}
},
)
})
}
}
},
)
})

spinner?.successAndStop(`Generated reported in ${Date.now() - now} ms`)

Expand Down
Loading
Loading