11import process from 'node:process'
22
3+ // @ts -ignore
4+ import BoxWidget from 'blessed/lib/widgets/box'
35// @ts -ignore
46import ScreenWidget from 'blessed/lib/widgets/screen'
57// @ts -ignore
@@ -9,6 +11,10 @@ import { logger } from '@socketsecurity/registry/lib/logger'
911
1012import constants from '../../constants'
1113import { 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
1319type ThreatResult = {
1420 createdAt : string
@@ -22,87 +28,169 @@ type ThreatResult = {
2228}
2329
2430export 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
108196function 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