From 2014e50f80f6477034c4cc87c2de3bd940bea2fc Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Wed, 5 Mar 2025 12:15:01 +0100 Subject: [PATCH 1/3] Fix threat-feed, improve ui, add eco filter --- src/commands/threat-feed/cmd-threat-feed.ts | 55 ++++-- src/commands/threat-feed/get-threat-feed.ts | 184 ++++++++++++++------ 2 files changed, 176 insertions(+), 63 deletions(-) diff --git a/src/commands/threat-feed/cmd-threat-feed.ts b/src/commands/threat-feed/cmd-threat-feed.ts index def62171b..04738fd6e 100644 --- a/src/commands/threat-feed/cmd-threat-feed.ts +++ b/src/commands/threat-feed/cmd-threat-feed.ts @@ -3,10 +3,8 @@ 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' @@ -14,7 +12,7 @@ const { DRY_RUN_BAIL_TEXT } = constants const config: CliCommandConfig = { commandName: 'threat-feed', - description: 'Look up the threat feed', + description: '[beta] Look at the threat feed', hidden: false, flags: { ...commonFlags, @@ -37,6 +35,12 @@ const config: CliCommandConfig = { default: 'desc', description: 'Order asc or desc by the createdAt attribute.' }, + ecoSystem: { + type: 'string', + shortFlag: 'e', + default: '', + description: 'Only show threats for a particular eco system' + }, filter: { type: 'string', shortFlag: 'f', @@ -51,6 +55,32 @@ const config: CliCommandConfig = { Options ${getFlagListOutput(config.flags, 6)} + This feature requires an Enterprise Plan with Threat Feed add-on. Please + contact sales@socket.dev if you would like access to this feature. + + Valid filters: + + - c Do not filter + - u Unreviewed + - fp False Positives + - tp False Positives and Unreviewed + - mal Malware and Possible Malware [default] + - vuln Vulnerability + - anom Anomaly + - secret Secret + - joke Joke / Fake + - spy Telemetry + - typo Typo-squat + + Valid eco systems: + + - gem + - golang + - maven + - npm + - nuget + - pypi + Examples $ ${command} $ ${command} --perPage=5 --page=2 --direction=asc --filter=joke @@ -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['ecoSystem'] || ''), 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 }) } diff --git a/src/commands/threat-feed/get-threat-feed.ts b/src/commands/threat-feed/get-threat-feed.ts index d1ce61a11..5d86d8bae 100644 --- a/src/commands/threat-feed/get-threat-feed.ts +++ b/src/commands/threat-feed/get-threat-feed.ts @@ -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 @@ -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 @@ -22,49 +28,83 @@ 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 { + 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 { // 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', @@ -72,37 +112,85 @@ export async function getThreatFeed({ 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) { + 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[]) { @@ -111,34 +199,32 @@ 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 getHourDiff(start: number, end: number) { - return Math.floor((end - start) / 3600000) -} - -function getMinDiff(start: number, end: number) { - return Math.floor((end - start) / 60000) +function msAtHome(isoTimeStamp: string): string { + const timeStart = new Date(isoTimeStamp).getTime() + const timeEnd = Date.now() + + const delta = timeEnd - timeStart + if (delta < 60 * 60 * 1000) { + return Math.round(delta / (60 * 1000)) + ' min ago' + } else if (delta < 24 * 60 * 60 * 1000) { + return (delta / (60 * 60 * 1000)).toFixed(1) + ' hr ago' + } else if (delta < 7 * 24 * 60 * 60 * 1000) { + return (delta / (24 * 60 * 60 * 1000)).toFixed(1) + ' day ago' + } else { + return isoTimeStamp.slice(0, 8) + } } From 58a3f751ddd6fe400449389fc8a34aebe7495507 Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Wed, 5 Mar 2025 12:22:14 +0100 Subject: [PATCH 2/3] spacing --- src/commands/threat-feed/get-threat-feed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/threat-feed/get-threat-feed.ts b/src/commands/threat-feed/get-threat-feed.ts index 5d86d8bae..34260ad6d 100644 --- a/src/commands/threat-feed/get-threat-feed.ts +++ b/src/commands/threat-feed/get-threat-feed.ts @@ -154,7 +154,7 @@ async function getThreatFeedWithToken({ headers: [ ' Ecosystem', ' Name', - ' Version', + ' Version', ' Threat type', ' Detected at', ' Details' From a5a2cbc56b72623f86c556d5a27b6fea5a94d9c8 Mon Sep 17 00:00:00 2001 From: Peter van der Zee Date: Wed, 5 Mar 2025 15:12:11 +0100 Subject: [PATCH 3/3] Apply feedback --- src/commands/audit-log/cmd-audit-log.ts | 3 ++ src/commands/threat-feed/cmd-threat-feed.ts | 26 +++++++-------- src/commands/threat-feed/get-threat-feed.ts | 37 +++++++++++++-------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/commands/audit-log/cmd-audit-log.ts b/src/commands/audit-log/cmd-audit-log.ts index 1d0d9c440..d65dfc9df 100644 --- a/src/commands/audit-log/cmd-audit-log.ts +++ b/src/commands/audit-log/cmd-audit-log.ts @@ -43,6 +43,9 @@ const config: CliCommandConfig = { Usage $ ${command} + 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)} diff --git a/src/commands/threat-feed/cmd-threat-feed.ts b/src/commands/threat-feed/cmd-threat-feed.ts index 04738fd6e..5bd4e9f06 100644 --- a/src/commands/threat-feed/cmd-threat-feed.ts +++ b/src/commands/threat-feed/cmd-threat-feed.ts @@ -12,7 +12,7 @@ const { DRY_RUN_BAIL_TEXT } = constants const config: CliCommandConfig = { commandName: 'threat-feed', - description: '[beta] Look at the threat feed', + description: '[beta] View the threat feed', hidden: false, flags: { ...commonFlags, @@ -35,11 +35,11 @@ const config: CliCommandConfig = { default: 'desc', description: 'Order asc or desc by the createdAt attribute.' }, - ecoSystem: { + eco: { type: 'string', shortFlag: 'e', default: '', - description: 'Only show threats for a particular eco system' + description: 'Only show threats for a particular ecosystem' }, filter: { type: 'string', @@ -52,27 +52,27 @@ 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)} - This feature requires an Enterprise Plan with Threat Feed add-on. Please - contact sales@socket.dev if you would like access to this feature. - Valid filters: + - anom Anomaly - c Do not filter - - u Unreviewed - fp False Positives - - tp False Positives and Unreviewed - mal Malware and Possible Malware [default] - - vuln Vulnerability - - anom Anomaly - - secret Secret - joke Joke / Fake + - secret Secrets - spy Telemetry + - tp False Positives and Unreviewed - typo Typo-squat + - u Unreviewed + - vuln Vulnerability - Valid eco systems: + Valid ecosystems: - gem - golang @@ -112,7 +112,7 @@ async function run( await getThreatFeed({ direction: String(cli.flags['direction'] || 'desc'), - ecoSystem: String(cli.flags['ecoSystem'] || ''), + ecosystem: String(cli.flags['eco'] || ''), filter: String(cli.flags['filter'] || 'mal'), outputKind: cli.flags['json'] ? 'json' diff --git a/src/commands/threat-feed/get-threat-feed.ts b/src/commands/threat-feed/get-threat-feed.ts index 34260ad6d..7fa0d8b28 100644 --- a/src/commands/threat-feed/get-threat-feed.ts +++ b/src/commands/threat-feed/get-threat-feed.ts @@ -29,14 +29,14 @@ type ThreatResult = { export async function getThreatFeed({ direction, - ecoSystem, + ecosystem, filter, outputKind, page, perPage }: { direction: string - ecoSystem: string + ecosystem: string filter: string outputKind: 'json' | 'markdown' | 'print' page: string @@ -52,7 +52,7 @@ export async function getThreatFeed({ await getThreatFeedWithToken({ apiToken, direction, - ecoSystem, + ecosystem, filter, outputKind, page, @@ -63,7 +63,7 @@ export async function getThreatFeed({ async function getThreatFeedWithToken({ apiToken, direction, - ecoSystem, + ecosystem, filter, outputKind, page, @@ -71,7 +71,7 @@ async function getThreatFeedWithToken({ }: { apiToken: string direction: string - ecoSystem: string + ecosystem: string filter: string outputKind: 'json' | 'markdown' | 'print' page: string @@ -82,7 +82,7 @@ async function getThreatFeedWithToken({ const queryParams = new URLSearchParams([ ['direction', direction], - ['ecosystem', ecoSystem], + ['ecosystem', ecosystem], ['filter', filter], ['page', page], ['per_page', String(perPage)] @@ -168,6 +168,7 @@ async function getThreatFeedWithToken({ 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` + @@ -205,26 +206,34 @@ function formatResults(data: ThreatResult[]) { return [ ecosystem, decodeURIComponent(name), - ' ' + version, - ' ' + d.threatType, - ' ' + timeDiff, + ` ${version}`, + ` ${d.threatType}`, + ` ${timeDiff}`, d.locationHtmlUrl ] }) } function msAtHome(isoTimeStamp: string): string { - const timeStart = new Date(isoTimeStamp).getTime() + const timeStart = Date.parse(isoTimeStamp) const timeEnd = Date.now() + const rtf = new Intl.RelativeTimeFormat('en', { + numeric: 'always', + style: 'short' + }) + const delta = timeEnd - timeStart if (delta < 60 * 60 * 1000) { - return Math.round(delta / (60 * 1000)) + ' min ago' + 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 (delta / (60 * 60 * 1000)).toFixed(1) + ' hr ago' + 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 (delta / (24 * 60 * 60 * 1000)).toFixed(1) + ' day ago' + 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, 8) + return isoTimeStamp.slice(0, 10) } }