@@ -38,6 +38,7 @@ async function main() {
3838 . option ( "output-file" , { type : "string" , describe : "Write search JSON/CSV output to this file. Required when using --cli with JSON/CSV." } )
3939 . option ( "csv" , { type : "boolean" , describe : "Emit results (search + malware matches) as CSV" } )
4040 . option ( "ignore-file" , { type : "string" , describe : "Path to YAML ignore file (advisories, purls, scoped ignores)" } )
41+ . option ( "ignore-unbounded-malware" , { type : "boolean" , default : false , describe : "Ignore malware advisories whose vulnerable range covers all versions (e.g. '*', '>=0')" } )
4142 . check ( args => {
4243 const syncing = ! ! args . syncSboms ;
4344 if ( syncing ) {
@@ -83,6 +84,10 @@ async function main() {
8384
8485 const offline = ! argv . syncSboms ;
8586 const quiet = argv . quiet as boolean ;
87+ const wantJson = ! ! argv . json ;
88+ const wantCsv = ! ! argv . csv ;
89+ const hasOutputFile = ! ! argv . outputFile ;
90+ const wantCli = ! ! argv . cli && hasOutputFile ; // only allow CLI alongside machine output when writing file
8691 const collector = new SbomCollector ( {
8792 token : token ,
8893 enterprise : argv . enterprise as string | undefined ,
@@ -124,6 +129,19 @@ async function main() {
124129 if ( argv [ "match-malware" ] ) {
125130 const { matchMalware, buildSarifPerRepo, writeSarifFiles, uploadSarifPerRepo } = await import ( "./malwareMatcher.js" ) ;
126131 malwareMatches = matchMalware ( mas . getAdvisories ( ) , sboms , { advisoryDateCutoff : argv [ "malware-cutoff" ] as string | undefined } ) ;
132+ // Optional suppression of unbounded version-range advisories
133+ if ( argv [ "ignore-unbounded-malware" ] && malwareMatches ?. length ) {
134+ const before = malwareMatches . length ;
135+ const isUnbounded = ( range : string | null ) => {
136+ if ( ! range ) return false ;
137+ const r = range . trim ( ) ;
138+ if ( r === "*" ) return true ;
139+ const compact = r . replace ( / \s + / g, "" ) ;
140+ return / ^ ( > = | > = ? ) ? 0 ( \. 0 ) { 0 , 2 } $ / i. test ( compact ) ; // '>=0', '>0', '0', '0.0.0'
141+ } ;
142+ malwareMatches = malwareMatches . filter ( m => ! isUnbounded ( m . vulnerableVersionRange ) ) ;
143+ if ( ! quiet ) process . stderr . write ( chalk . yellow ( `Filtered ${ before - malwareMatches . length } unbounded-range malware match(es)` ) + "\n" ) ;
144+ }
127145 // Apply ignore file if provided
128146 if ( argv [ "ignore-file" ] && malwareMatches ?. length ) {
129147 try {
@@ -145,7 +163,8 @@ async function main() {
145163 }
146164 if ( ! quiet ) process . stderr . write ( chalk . magenta ( `Malware matches found: ${ malwareMatches ?. length ?? 0 } ` ) + "\n" ) ;
147165 if ( malwareMatches ) {
148- if ( ! quiet ) {
166+ const showMalwareCli = ( ! wantJson && ! wantCsv ) || wantCli ; // show only in pure CLI or combined mode
167+ if ( showMalwareCli && ! quiet ) {
149168 for ( const m of malwareMatches ) {
150169 process . stdout . write ( `${ m . repo } :: ${ m . purl } => ${ m . advisoryGhsaId } (${ m . vulnerableVersionRange ?? "(no range)" } ) {advisory: ${ m . reason } } ${ m . advisoryPermalink } \n` ) ;
151170 }
@@ -170,13 +189,12 @@ async function main() {
170189 process . stderr . write ( chalk . blue ( "All repositories reused from cache (no new SBOM writes)." ) + "\n" ) ;
171190 }
172191
173- const runSearch = ( purls : string [ ] ) => {
174- const results = collector . searchByPurlsWithReasons ( purls ) ;
175- if ( ! quiet ) process . stderr . write ( chalk . magenta ( `Search results for ${ purls . length } purl(s):` ) + "\n" ) ;
192+ const runSearchCli = ( purls : string [ ] , results : Map < string , { purl : string ; reason: string } [ ] > ) => {
176193 if ( ! results . size ) {
177- if ( ! quiet ) process . stdout . write ( "No matches.\n" ) ;
194+ process . stdout . write ( "No matches.\n" ) ;
178195 return ;
179196 }
197+ if ( ! quiet ) process . stderr . write ( chalk . magenta ( `Search results for ${ purls . length } purl(s):` ) + "\n" ) ;
180198 for ( const [ repo , entries ] of results . entries ( ) ) {
181199 process . stdout . write ( chalk . bold ( repo ) + "\n" ) ;
182200 for ( const { purl, reason } of entries ) process . stdout . write ( ` - ${ purl } {query: ${ reason } }\n` ) ;
@@ -201,55 +219,41 @@ async function main() {
201219 }
202220 const combinedPurlsRaw = [ ...( argv . purl as string [ ] ?? [ ] ) , ...filePurls ] ;
203221 const combinedPurls = combinedPurlsRaw . map ( p => p . startsWith ( "pkg:" ) ? p : `pkg:${ p } ` ) ;
222+ let searchMap : Map < string , { purl : string ; reason: string } [ ] > | undefined ;
204223 if ( combinedPurls . length ) {
205- // We'll also consider CSV export after JSON handling
206- if ( argv . json || argv . outputFile ) {
207- const map = collector . searchByPurlsWithReasons ( combinedPurls ) ;
208- const jsonSearch = Array . from ( map . entries ( ) ) . map ( ( [ repo , entries ] ) => ( { repo, matches : entries } ) ) ;
209- if ( argv . outputFile ) {
210- try {
211- const fs = await import ( "fs" ) ;
212- let existing : { search ?: unknown ; malwareMatches ?: import ( "./malwareMatcher.js" ) . MalwareMatch [ ] } = { } ;
213- if ( fs . existsSync ( argv . outputFile as string ) ) {
214- try { existing = JSON . parse ( fs . readFileSync ( argv . outputFile as string , "utf8" ) ) ; } catch { existing = { } ; }
215- }
216- existing . search = jsonSearch ;
217- if ( malwareMatches ) existing . malwareMatches = existing . malwareMatches || malwareMatches ; // preserve if already set
218- const payload = JSON . stringify ( existing , null , 2 ) + "\n" ;
219- fs . writeFileSync ( argv . outputFile as string , payload , "utf8" ) ;
220- if ( ! quiet ) process . stderr . write ( chalk . green ( `Wrote search JSON to ${ argv . outputFile } ` ) + "\n" ) ;
221- } catch ( e ) {
222- console . error ( chalk . red ( `Failed to write output file: ${ e instanceof Error ? e . message : String ( e ) } ` ) ) ;
223- process . exit ( 1 ) ;
224+ searchMap = collector . searchByPurlsWithReasons ( combinedPurls ) ;
225+ }
226+ if ( wantJson ) {
227+ const jsonSearch = Array . from ( ( searchMap || new Map ( ) ) . entries ( ) ) . map ( ( [ repo , entries ] ) => ( { repo, matches : entries } ) ) ;
228+ if ( hasOutputFile ) {
229+ try {
230+ const fs = await import ( "fs" ) ;
231+ let existing : { search ?: unknown ; malwareMatches ?: import ( "./malwareMatcher.js" ) . MalwareMatch [ ] } = { } ;
232+ if ( fs . existsSync ( argv . outputFile as string ) ) {
233+ try { existing = JSON . parse ( fs . readFileSync ( argv . outputFile as string , "utf8" ) ) ; } catch { existing = { } ; }
224234 }
225- } else {
226- const payloadObj : { search : unknown ; malwareMatches ?: import ( "./malwareMatcher.js" ) . MalwareMatch [ ] } = { search : jsonSearch } ;
227- if ( malwareMatches ) payloadObj . malwareMatches = malwareMatches ;
228- process . stdout . write ( JSON . stringify ( payloadObj , null , 2 ) + "\n" ) ;
229- }
230- // If CLI output requested (either implicit because no --json OR explicit --cli with output-file requirement) then show human form
231- if ( ( ( ! argv . json && ! argv . csv ) && ! argv . outputFile ) || ( argv . cli && ( argv . json || argv . outputFile ) ) ) {
232- runSearch ( combinedPurls ) ;
233- } else if ( argv . cli ) { // This branch only occurs when validation prevented missing output-file
234- runSearch ( combinedPurls ) ;
235+ existing . search = jsonSearch ;
236+ if ( malwareMatches ) existing . malwareMatches = existing . malwareMatches || malwareMatches ;
237+ fs . writeFileSync ( argv . outputFile as string , JSON . stringify ( existing , null , 2 ) + "\n" , "utf8" ) ;
238+ if ( ! quiet ) process . stderr . write ( chalk . green ( `Wrote search JSON to ${ argv . outputFile } ` ) + "\n" ) ;
239+ } catch ( e ) {
240+ console . error ( chalk . red ( `Failed to write output file: ${ ( e as Error ) . message } ` ) ) ;
241+ process . exit ( 1 ) ;
235242 }
236243 } else {
237- // Pure CLI
238- runSearch ( combinedPurls ) ;
244+ const payloadObj : { search : unknown ; malwareMatches ?: import ( "./malwareMatcher.js" ) . MalwareMatch [ ] } = { search : jsonSearch } ;
245+ if ( malwareMatches ) payloadObj . malwareMatches = malwareMatches ;
246+ process . stdout . write ( JSON . stringify ( payloadObj , null , 2 ) + "\n" ) ;
239247 }
240- }
241-
242- // CSV output section (covers search results and malware matches if present)
243- if ( argv . csv ) {
248+ if ( wantCli && searchMap ) runSearchCli ( combinedPurls , searchMap ) ;
249+ } else if ( wantCsv ) {
250+ // CSV output section (covers search results and malware matches if present)
244251 const fs = await import ( "fs" ) ;
245252 // Collect search data if searches were run; reconstruct from collector if we have combinedPurls
246253 const searchRows : Array < { repo : string ; purl : string ; reason : string } > = [ ] ;
247- if ( combinedPurls . length ) {
248- const map = collector . searchByPurlsWithReasons ( combinedPurls ) ;
249- for ( const [ repo , entries ] of map . entries ( ) ) {
250- for ( const { purl, reason } of entries ) {
251- searchRows . push ( { repo, purl, reason } ) ;
252- }
254+ if ( combinedPurls . length && searchMap ) {
255+ for ( const [ repo , entries ] of searchMap . entries ( ) ) {
256+ for ( const { purl, reason } of entries ) searchRows . push ( { repo, purl, reason } ) ;
253257 }
254258 }
255259 const malwareRows : Array < { repo : string ; purl : string ; advisory : string ; range : string | null ; updatedAt : string } > = [ ] ;
@@ -291,17 +295,21 @@ async function main() {
291295 ] . join ( "," ) ) ;
292296 }
293297 const csvPayload = lines . join ( "\n" ) + "\n" ;
294- if ( argv . outFile ) {
298+ if ( hasOutputFile ) {
295299 try {
296- fs . writeFileSync ( argv . outFile as string , csvPayload , "utf8" ) ;
297- if ( ! quiet ) process . stderr . write ( chalk . green ( `Wrote CSV to ${ argv . outFile } ` ) + "\n" ) ;
300+ fs . writeFileSync ( argv . outputFile as string , csvPayload , "utf8" ) ;
301+ if ( ! quiet ) process . stderr . write ( chalk . green ( `Wrote CSV to ${ argv . outputFile } ` ) + "\n" ) ;
298302 } catch ( e ) {
299303 console . error ( chalk . red ( `Failed to write CSV file: ${ e instanceof Error ? e . message : String ( e ) } ` ) ) ;
300304 process . exit ( 1 ) ;
301305 }
302306 } else {
303307 process . stdout . write ( csvPayload ) ;
304308 }
309+ if ( wantCli && searchMap ) runSearchCli ( combinedPurls , searchMap ) ;
310+ } else if ( combinedPurls . length && searchMap ) {
311+ // Pure CLI (no json/csv)
312+ runSearchCli ( combinedPurls , searchMap ) ;
305313 }
306314
307315 // If malware matches were computed but no search JSON writing happened yet and an output file was requested, persist them now.
@@ -361,7 +369,8 @@ async function main() {
361369 }
362370 const list = trimmed . split ( / [ \s , ] + / ) . filter ( Boolean ) ;
363371 try {
364- runSearch ( list ) ;
372+ const map = collector . searchByPurlsWithReasons ( list . map ( p => p . startsWith ( "pkg:" ) ? p : `pkg:${ p } ` ) ) ;
373+ runSearchCli ( list , map ) ;
365374 } catch ( e ) {
366375 console . error ( chalk . red ( ( e as Error ) . message ) ) ;
367376 }
@@ -379,7 +388,8 @@ async function main() {
379388 { name : "purl" , message : "Enter a PURL (blank to exit)" , type : "input" }
380389 ] ) ;
381390 if ( ! ans . purl ) break ;
382- runSearch ( [ ans . purl ] ) ;
391+ const map = collector . searchByPurlsWithReasons ( [ ans . purl . startsWith ( "pkg:" ) ? ans . purl : `pkg:${ ans . purl } ` ] ) ;
392+ runSearchCli ( [ ans . purl ] , map ) ;
383393 }
384394 }
385395 }
0 commit comments