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
3 changes: 3 additions & 0 deletions src/commands/audit-log/cmd-audit-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const config: CliCommandConfig = {
Usage
$ ${command} <org slug>

This feature requires an Enterprise Plan. To learn more about getting access
to this feature and many more, please visit https://socket.dev/pricing

Options
${getFlagListOutput(config.flags, 6)}

Expand Down
55 changes: 41 additions & 14 deletions src/commands/threat-feed/cmd-threat-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,16 @@ import { logger } from '@socketsecurity/registry/lib/logger'
import { getThreatFeed } from './get-threat-feed'
import constants from '../../constants'
import { commonFlags, outputFlags } from '../../flags'
import { AuthError } from '../../utils/errors'
import { meowOrExit } from '../../utils/meow-with-subcommands'
import { getFlagListOutput } from '../../utils/output-formatting'
import { getDefaultToken } from '../../utils/sdk'

import type { CliCommandConfig } from '../../utils/meow-with-subcommands'

const { DRY_RUN_BAIL_TEXT } = constants

const config: CliCommandConfig = {
commandName: 'threat-feed',
description: 'Look up the threat feed',
description: '[beta] View the threat feed',
hidden: false,
flags: {
...commonFlags,
Expand All @@ -37,6 +35,12 @@ const config: CliCommandConfig = {
default: 'desc',
description: 'Order asc or desc by the createdAt attribute.'
},
eco: {
type: 'string',
shortFlag: 'e',
default: '',
description: 'Only show threats for a particular ecosystem'
},
filter: {
type: 'string',
shortFlag: 'f',
Expand All @@ -48,9 +52,35 @@ const config: CliCommandConfig = {
Usage
$ ${command}

This feature requires a Threat Feed license. Please contact
sales@socket.dev if you are interested in purchasing this access.

Options
${getFlagListOutput(config.flags, 6)}

Valid filters:

- anom Anomaly
- c Do not filter
- fp False Positives
- mal Malware and Possible Malware [default]
- joke Joke / Fake
- secret Secrets
- spy Telemetry
- tp False Positives and Unreviewed
- typo Typo-squat
- u Unreviewed
- vuln Vulnerability

Valid ecosystems:

- gem
- golang
- maven
- npm
- nuget
- pypi

Examples
$ ${command}
$ ${command} --perPage=5 --page=2 --direction=asc --filter=joke
Expand Down Expand Up @@ -80,19 +110,16 @@ async function run(
return
}

const apiToken = getDefaultToken()
if (!apiToken) {
throw new AuthError(
'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.'
)
}

await getThreatFeed({
apiToken,
direction: String(cli.flags['direction'] || 'desc'),
ecosystem: String(cli.flags['eco'] || ''),
filter: String(cli.flags['filter'] || 'mal'),
outputJson: Boolean(cli.flags['json']),
page: String(cli.flags['filter'] || '1'),
perPage: Number(cli.flags['per_page'] || 0)
outputKind: cli.flags['json']
? 'json'
: cli.flags['markdown']
? 'markdown'
: 'print',
page: String(cli.flags['page'] || '1'),
perPage: Number(cli.flags['perPage']) || 30
})
}
189 changes: 142 additions & 47 deletions src/commands/threat-feed/get-threat-feed.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import process from 'node:process'

// @ts-ignore
import BoxWidget from 'blessed/lib/widgets/box'
// @ts-ignore
import ScreenWidget from 'blessed/lib/widgets/screen'
// @ts-ignore
Expand All @@ -9,6 +11,10 @@ import { logger } from '@socketsecurity/registry/lib/logger'

import constants from '../../constants'
import { queryAPI } from '../../utils/api'
import { AuthError } from '../../utils/errors'
import { getDefaultToken } from '../../utils/sdk'

import type { Widgets } from 'blessed' // Note: Widgets does not seem to actually work as code :'(

type ThreatResult = {
createdAt: string
Expand All @@ -22,87 +28,170 @@ type ThreatResult = {
}

export async function getThreatFeed({
direction,
ecosystem,
filter,
outputKind,
page,
perPage
}: {
direction: string
ecosystem: string
filter: string
outputKind: 'json' | 'markdown' | 'print'
page: string
perPage: number
}): Promise<void> {
const apiToken = getDefaultToken()
if (!apiToken) {
throw new AuthError(
'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.'
)
}

await getThreatFeedWithToken({
apiToken,
direction,
ecosystem,
filter,
outputKind,
page,
perPage
})
}

async function getThreatFeedWithToken({
apiToken,
direction,
ecosystem,
filter,
outputJson,
outputKind,
page,
perPage
}: {
apiToken: string
outputJson: boolean
perPage: number
page: string
direction: string
ecosystem: string
filter: string
outputKind: 'json' | 'markdown' | 'print'
page: string
perPage: number
}): Promise<void> {
// Lazily access constants.spinner.
const { spinner } = constants

spinner.start('Looking up the threat feed')
const queryParams = new URLSearchParams([
['direction', direction],
['ecosystem', ecosystem],
['filter', filter],
['page', page],
['per_page', String(perPage)]
])

const formattedQueryParams = formatQueryParams({
per_page: perPage,
page,
direction,
filter
}).join('&')
const response = await queryAPI(
`threat-feed?${formattedQueryParams}`,
apiToken
)
spinner.start('Fetching Threat Feed data...')

const response = await queryAPI(`threat-feed?${queryParams}`, apiToken)
const data = <{ results: ThreatResult[]; nextPage: string }>(
await response.json()
)

spinner.stop()
spinner.stop('Threat feed data fetched')

if (outputJson) {
if (outputKind === 'json') {
logger.log(data)
return
}

const screen = new ScreenWidget()
const screen: Widgets.Screen = new ScreenWidget()

const table = new TableWidget({
const table: any = new TableWidget({
keys: 'true',
fg: 'white',
selectedFg: 'white',
selectedBg: 'magenta',
interactive: 'true',
label: 'Threat feed',
width: '100%',
height: '100%',
height: '70%', // Changed from 100% to 70%
border: {
type: 'line',
fg: 'cyan'
},
columnSpacing: 3, //in chars
columnWidth: [9, 30, 10, 17, 13, 100] /*in chars*/
columnWidth: [10, 30, 20, 18, 15, 200],
// TODO: the truncation doesn't seem to work too well yet but when we add
// `pad` alignment fails, when we extend columnSpacing alignment fails
columnSpacing: 1,
truncate: '_'
})

// Create details box at the bottom
const detailsBox: Widgets.BoxElement = new BoxWidget({
bottom: 0,
height: '30%',
width: '100%',
border: {
type: 'line',
fg: 'cyan'
},
label: 'Details',
content:
'Use arrow keys to navigate. Press Enter to select a threat. Press q to exit.',
style: {
fg: 'white'
}
})

// allow control the table with the keyboard
table.focus()

screen.append(table)
screen.append(detailsBox)

const formattedOutput = formatResults(data.results)
const descriptions = data.results.map(d => d.description)

table.setData({
headers: [
'Ecosystem',
'Name',
'Version',
'Threat type',
'Detected at',
'Details'
' Ecosystem',
' Name',
' Version',
' Threat type',
' Detected at',
' Details'
],
data: formattedOutput
})

// Update details box when selection changes
table.rows.on('select item', () => {
const selectedIndex = table.rows.selected
if (selectedIndex !== undefined && selectedIndex >= 0) {
const selectedRow = formattedOutput[selectedIndex]
if (selectedRow) {
// Note: the spacing works around issues with the table; it refuses to pad!
detailsBox.setContent(
`Ecosystem: ${selectedRow[0]}\n` +
`Name: ${selectedRow[1]}\n` +
`Version:${selectedRow[2]}\n` +
`Threat type:${selectedRow[3]}\n` +
`Detected at:${selectedRow[4]}\n` +
`Details: ${selectedRow[5]}\n` +
`Description: ${descriptions[selectedIndex]}`
)
screen.render()
}
}
})

screen.render()

screen.key(['escape', 'q', 'C-c'], () => process.exit(0))
screen.key(['return'], () => {
const selectedIndex = table.rows.selected
screen.destroy()
const selectedRow = formattedOutput[selectedIndex]
console.log(selectedRow)
})
}

function formatResults(data: ThreatResult[]) {
Expand All @@ -111,34 +200,40 @@ function formatResults(data: ThreatResult[]) {
const name = d.purl.split('/')[1]!.split('@')[0]!
const version = d.purl.split('@')[1]!

const timeStart = new Date(d.createdAt).getMilliseconds()
const timeEnd = Date.now()

const diff = getHourDiff(timeStart, timeEnd)
const hourDiff =
diff > 0
? `${diff} hours ago`
: `${getMinDiff(timeStart, timeEnd)} minutes ago`
const timeDiff = msAtHome(d.createdAt)

// Note: the spacing works around issues with the table; it refuses to pad!
return [
ecosystem,
decodeURIComponent(name),
version,
d.threatType,
hourDiff,
` ${version}`,
` ${d.threatType}`,
` ${timeDiff}`,
d.locationHtmlUrl
]
})
}

function formatQueryParams(params: object) {
return Object.entries(params).map(entry => `${entry[0]}=${entry[1]}`)
}
function msAtHome(isoTimeStamp: string): string {
const timeStart = Date.parse(isoTimeStamp)
const timeEnd = Date.now()

function getHourDiff(start: number, end: number) {
return Math.floor((end - start) / 3600000)
}
const rtf = new Intl.RelativeTimeFormat('en', {
numeric: 'always',
style: 'short'
})

function getMinDiff(start: number, end: number) {
return Math.floor((end - start) / 60000)
const delta = timeEnd - timeStart
if (delta < 60 * 60 * 1000) {
return rtf.format(-Math.round(delta / (60 * 1000)), 'minute')
// return Math.round(delta / (60 * 1000)) + ' min ago'
} else if (delta < 24 * 60 * 60 * 1000) {
return rtf.format(-(delta / (60 * 60 * 1000)).toFixed(1), 'hour')
// return (delta / (60 * 60 * 1000)).toFixed(1) + ' hr ago'
} else if (delta < 7 * 24 * 60 * 60 * 1000) {
return rtf.format(-(delta / (24 * 60 * 60 * 1000)).toFixed(1), 'day')
// return (delta / (24 * 60 * 60 * 1000)).toFixed(1) + ' day ago'
} else {
return isoTimeStamp.slice(0, 10)
}
}