Skip to content

Commit 2014e50

Browse files
committed
Fix threat-feed, improve ui, add eco filter
1 parent 0d373cb commit 2014e50

File tree

2 files changed

+176
-63
lines changed

2 files changed

+176
-63
lines changed

src/commands/threat-feed/cmd-threat-feed.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,16 @@ import { logger } from '@socketsecurity/registry/lib/logger'
33
import { getThreatFeed } from './get-threat-feed'
44
import constants from '../../constants'
55
import { commonFlags, outputFlags } from '../../flags'
6-
import { AuthError } from '../../utils/errors'
76
import { meowOrExit } from '../../utils/meow-with-subcommands'
87
import { getFlagListOutput } from '../../utils/output-formatting'
9-
import { getDefaultToken } from '../../utils/sdk'
108

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

1311
const { DRY_RUN_BAIL_TEXT } = constants
1412

1513
const config: CliCommandConfig = {
1614
commandName: 'threat-feed',
17-
description: 'Look up the threat feed',
15+
description: '[beta] Look at the threat feed',
1816
hidden: false,
1917
flags: {
2018
...commonFlags,
@@ -37,6 +35,12 @@ const config: CliCommandConfig = {
3735
default: 'desc',
3836
description: 'Order asc or desc by the createdAt attribute.'
3937
},
38+
ecoSystem: {
39+
type: 'string',
40+
shortFlag: 'e',
41+
default: '',
42+
description: 'Only show threats for a particular eco system'
43+
},
4044
filter: {
4145
type: 'string',
4246
shortFlag: 'f',
@@ -51,6 +55,32 @@ const config: CliCommandConfig = {
5155
Options
5256
${getFlagListOutput(config.flags, 6)}
5357
58+
This feature requires an Enterprise Plan with Threat Feed add-on. Please
59+
contact sales@socket.dev if you would like access to this feature.
60+
61+
Valid filters:
62+
63+
- c Do not filter
64+
- u Unreviewed
65+
- fp False Positives
66+
- tp False Positives and Unreviewed
67+
- mal Malware and Possible Malware [default]
68+
- vuln Vulnerability
69+
- anom Anomaly
70+
- secret Secret
71+
- joke Joke / Fake
72+
- spy Telemetry
73+
- typo Typo-squat
74+
75+
Valid eco systems:
76+
77+
- gem
78+
- golang
79+
- maven
80+
- npm
81+
- nuget
82+
- pypi
83+
5484
Examples
5585
$ ${command}
5686
$ ${command} --perPage=5 --page=2 --direction=asc --filter=joke
@@ -80,19 +110,16 @@ async function run(
80110
return
81111
}
82112

83-
const apiToken = getDefaultToken()
84-
if (!apiToken) {
85-
throw new AuthError(
86-
'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.'
87-
)
88-
}
89-
90113
await getThreatFeed({
91-
apiToken,
92114
direction: String(cli.flags['direction'] || 'desc'),
115+
ecoSystem: String(cli.flags['ecoSystem'] || ''),
93116
filter: String(cli.flags['filter'] || 'mal'),
94-
outputJson: Boolean(cli.flags['json']),
95-
page: String(cli.flags['filter'] || '1'),
96-
perPage: Number(cli.flags['per_page'] || 0)
117+
outputKind: cli.flags['json']
118+
? 'json'
119+
: cli.flags['markdown']
120+
? 'markdown'
121+
: 'print',
122+
page: String(cli.flags['page'] || '1'),
123+
perPage: Number(cli.flags['perPage']) || 30
97124
})
98125
}
Lines changed: 135 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import process from 'node:process'
22

3+
// @ts-ignore
4+
import BoxWidget from 'blessed/lib/widgets/box'
35
// @ts-ignore
46
import ScreenWidget from 'blessed/lib/widgets/screen'
57
// @ts-ignore
@@ -9,6 +11,10 @@ import { logger } from '@socketsecurity/registry/lib/logger'
911

1012
import constants from '../../constants'
1113
import { queryAPI } from '../../utils/api'
14+
import { AuthError } from '../../utils/errors'
15+
import { getDefaultToken } from '../../utils/sdk'
16+
17+
import type { Widgets } from 'blessed' // Note: Widgets does not seem to actually work as code :'(
1218

1319
type ThreatResult = {
1420
createdAt: string
@@ -22,87 +28,169 @@ type ThreatResult = {
2228
}
2329

2430
export async function getThreatFeed({
31+
direction,
32+
ecoSystem,
33+
filter,
34+
outputKind,
35+
page,
36+
perPage
37+
}: {
38+
direction: string
39+
ecoSystem: string
40+
filter: string
41+
outputKind: 'json' | 'markdown' | 'print'
42+
page: string
43+
perPage: number
44+
}): Promise<void> {
45+
const apiToken = getDefaultToken()
46+
if (!apiToken) {
47+
throw new AuthError(
48+
'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.'
49+
)
50+
}
51+
52+
await getThreatFeedWithToken({
53+
apiToken,
54+
direction,
55+
ecoSystem,
56+
filter,
57+
outputKind,
58+
page,
59+
perPage
60+
})
61+
}
62+
63+
async function getThreatFeedWithToken({
2564
apiToken,
2665
direction,
66+
ecoSystem,
2767
filter,
28-
outputJson,
68+
outputKind,
2969
page,
3070
perPage
3171
}: {
3272
apiToken: string
33-
outputJson: boolean
34-
perPage: number
35-
page: string
3673
direction: string
74+
ecoSystem: string
3775
filter: string
76+
outputKind: 'json' | 'markdown' | 'print'
77+
page: string
78+
perPage: number
3879
}): Promise<void> {
3980
// Lazily access constants.spinner.
4081
const { spinner } = constants
4182

42-
spinner.start('Looking up the threat feed')
83+
const queryParams = new URLSearchParams([
84+
['direction', direction],
85+
['ecosystem', ecoSystem],
86+
['filter', filter],
87+
['page', page],
88+
['per_page', String(perPage)]
89+
])
4390

44-
const formattedQueryParams = formatQueryParams({
45-
per_page: perPage,
46-
page,
47-
direction,
48-
filter
49-
}).join('&')
50-
const response = await queryAPI(
51-
`threat-feed?${formattedQueryParams}`,
52-
apiToken
53-
)
91+
spinner.start('Fetching Threat Feed data...')
92+
93+
const response = await queryAPI(`threat-feed?${queryParams}`, apiToken)
5494
const data = <{ results: ThreatResult[]; nextPage: string }>(
5595
await response.json()
5696
)
5797

58-
spinner.stop()
98+
spinner.stop('Threat feed data fetched')
5999

60-
if (outputJson) {
100+
if (outputKind === 'json') {
61101
logger.log(data)
62102
return
63103
}
64104

65-
const screen = new ScreenWidget()
105+
const screen: Widgets.Screen = new ScreenWidget()
66106

67-
const table = new TableWidget({
107+
const table: any = new TableWidget({
68108
keys: 'true',
69109
fg: 'white',
70110
selectedFg: 'white',
71111
selectedBg: 'magenta',
72112
interactive: 'true',
73113
label: 'Threat feed',
74114
width: '100%',
75-
height: '100%',
115+
height: '70%', // Changed from 100% to 70%
76116
border: {
77117
type: 'line',
78118
fg: 'cyan'
79119
},
80-
columnSpacing: 3, //in chars
81-
columnWidth: [9, 30, 10, 17, 13, 100] /*in chars*/
120+
columnWidth: [10, 30, 20, 18, 15, 200],
121+
// TODO: the truncation doesn't seem to work too well yet but when we add
122+
// `pad` alignment fails, when we extend columnSpacing alignment fails
123+
columnSpacing: 1,
124+
truncate: '_'
125+
})
126+
127+
// Create details box at the bottom
128+
const detailsBox: Widgets.BoxElement = new BoxWidget({
129+
bottom: 0,
130+
height: '30%',
131+
width: '100%',
132+
border: {
133+
type: 'line',
134+
fg: 'cyan'
135+
},
136+
label: 'Details',
137+
content:
138+
'Use arrow keys to navigate. Press Enter to select a threat. Press q to exit.',
139+
style: {
140+
fg: 'white'
141+
}
82142
})
83143

84144
// allow control the table with the keyboard
85145
table.focus()
86146

87147
screen.append(table)
148+
screen.append(detailsBox)
88149

89150
const formattedOutput = formatResults(data.results)
151+
const descriptions = data.results.map(d => d.description)
90152

91153
table.setData({
92154
headers: [
93-
'Ecosystem',
94-
'Name',
95-
'Version',
96-
'Threat type',
97-
'Detected at',
98-
'Details'
155+
' Ecosystem',
156+
' Name',
157+
' Version',
158+
' Threat type',
159+
' Detected at',
160+
' Details'
99161
],
100162
data: formattedOutput
101163
})
102164

165+
// Update details box when selection changes
166+
table.rows.on('select item', () => {
167+
const selectedIndex = table.rows.selected
168+
if (selectedIndex !== undefined && selectedIndex >= 0) {
169+
const selectedRow = formattedOutput[selectedIndex]
170+
if (selectedRow) {
171+
detailsBox.setContent(
172+
`Ecosystem: ${selectedRow[0]}\n` +
173+
`Name: ${selectedRow[1]}\n` +
174+
`Version:${selectedRow[2]}\n` +
175+
`Threat type:${selectedRow[3]}\n` +
176+
`Detected at:${selectedRow[4]}\n` +
177+
`Details: ${selectedRow[5]}\n` +
178+
`Description: ${descriptions[selectedIndex]}`
179+
)
180+
screen.render()
181+
}
182+
}
183+
})
184+
103185
screen.render()
104186

105187
screen.key(['escape', 'q', 'C-c'], () => process.exit(0))
188+
screen.key(['return'], () => {
189+
const selectedIndex = table.rows.selected
190+
screen.destroy()
191+
const selectedRow = formattedOutput[selectedIndex]
192+
console.log(selectedRow)
193+
})
106194
}
107195

108196
function formatResults(data: ThreatResult[]) {
@@ -111,34 +199,32 @@ function formatResults(data: ThreatResult[]) {
111199
const name = d.purl.split('/')[1]!.split('@')[0]!
112200
const version = d.purl.split('@')[1]!
113201

114-
const timeStart = new Date(d.createdAt).getMilliseconds()
115-
const timeEnd = Date.now()
116-
117-
const diff = getHourDiff(timeStart, timeEnd)
118-
const hourDiff =
119-
diff > 0
120-
? `${diff} hours ago`
121-
: `${getMinDiff(timeStart, timeEnd)} minutes ago`
202+
const timeDiff = msAtHome(d.createdAt)
122203

204+
// Note: the spacing works around issues with the table; it refuses to pad!
123205
return [
124206
ecosystem,
125207
decodeURIComponent(name),
126-
version,
127-
d.threatType,
128-
hourDiff,
208+
' ' + version,
209+
' ' + d.threatType,
210+
' ' + timeDiff,
129211
d.locationHtmlUrl
130212
]
131213
})
132214
}
133215

134-
function formatQueryParams(params: object) {
135-
return Object.entries(params).map(entry => `${entry[0]}=${entry[1]}`)
136-
}
137-
138-
function getHourDiff(start: number, end: number) {
139-
return Math.floor((end - start) / 3600000)
140-
}
141-
142-
function getMinDiff(start: number, end: number) {
143-
return Math.floor((end - start) / 60000)
216+
function msAtHome(isoTimeStamp: string): string {
217+
const timeStart = new Date(isoTimeStamp).getTime()
218+
const timeEnd = Date.now()
219+
220+
const delta = timeEnd - timeStart
221+
if (delta < 60 * 60 * 1000) {
222+
return Math.round(delta / (60 * 1000)) + ' min ago'
223+
} else if (delta < 24 * 60 * 60 * 1000) {
224+
return (delta / (60 * 60 * 1000)).toFixed(1) + ' hr ago'
225+
} else if (delta < 7 * 24 * 60 * 60 * 1000) {
226+
return (delta / (24 * 60 * 60 * 1000)).toFixed(1) + ' day ago'
227+
} else {
228+
return isoTimeStamp.slice(0, 8)
229+
}
144230
}

0 commit comments

Comments
 (0)