@@ -35,6 +35,7 @@ async function main() {
3535 . option ( "json" , { type : "boolean" , describe : "Emit search results as JSON to stdout (suppresses human output unless --cli also provided)" } )
3636 . option ( "cli" , { type : "boolean" , describe : "When used with --json, also emit human-readable CLI output" } )
3737 . option ( "output-file" , { type : "string" , describe : "Write search JSON output to this file (implied JSON generation). Required when using --cli with JSON." } )
38+ . option ( "csv" , { type : "boolean" , describe : "Emit results (search + malware matches) as CSV" } )
3839 . check ( args => {
3940 const syncing = ! ! args . syncSboms ;
4041 if ( syncing ) {
@@ -43,12 +44,9 @@ async function main() {
4344 } else {
4445 if ( ! args . sbomCache ) throw new Error ( "Offline mode requires --sbom-cache (omit --sync-sboms)" ) ;
4546 }
46- // If --cli is specified in combination intending JSON, require an output file to avoid mixed stdout streams.
47- if ( args . cli && ! args . outputFile && ! args . json ) {
48- throw new Error ( "--cli provided without --json/--output-file. Use --json --cli --output-file <path> to emit both." ) ;
49- }
50- if ( args . cli && ! args . outputFile && args . json ) {
51- throw new Error ( "--cli with --json requires --output-file to avoid interleaving JSON and human output on stdout." ) ;
47+ // If --cli is specified in combination with JSON or CSV, require an output file to avoid mixed stdout streams.
48+ if ( args . cli && ! args . outputFile && ( args . json || args . csv ) ) {
49+ throw new Error ( "--cli with --json or --csv requires --output-file to avoid interleaving JSON and human output on stdout." ) ;
5250 }
5351 // check that --malware-cache is provided
5452 if ( args [ "match-malware" ] && ! args [ "malware-cache" ] ) {
@@ -181,6 +179,7 @@ async function main() {
181179 const combinedPurls = combinedPurlsRaw . map ( p => p . startsWith ( "pkg:" ) ? p : `pkg:${ p } ` ) ;
182180 if ( combinedPurls . length ) {
183181 const needJson = argv . json || argv . outputFile ;
182+ // We'll also consider CSV export after JSON handling
184183 if ( needJson ) {
185184 const map = collector . searchByPurlsWithReasons ( combinedPurls ) ;
186185 const jsonSearch = Array . from ( map . entries ( ) ) . map ( ( [ repo , entries ] ) => ( { repo, matches : entries } ) ) ;
@@ -217,6 +216,71 @@ async function main() {
217216 }
218217 }
219218
219+ // CSV output section (covers search results and malware matches if present)
220+ if ( argv . csv ) {
221+ const fs = await import ( "fs" ) ;
222+ // Collect search data if searches were run; reconstruct from collector if we have combinedPurls
223+ let searchRows : Array < { repo : string ; purl : string ; reason : string } > = [ ] ;
224+ if ( combinedPurls . length ) {
225+ const map = collector . searchByPurlsWithReasons ( combinedPurls ) ;
226+ for ( const [ repo , entries ] of map . entries ( ) ) {
227+ for ( const { purl, reason } of entries ) {
228+ searchRows . push ( { repo, purl, reason } ) ;
229+ }
230+ }
231+ }
232+ const malwareRows : Array < { repo : string ; purl : string ; advisory : string ; range : string | null ; updatedAt : string } > = [ ] ;
233+ if ( malwareMatches ) {
234+ for ( const m of malwareMatches ) {
235+ malwareRows . push ( { repo : m . repo , purl : m . purl , advisory : m . advisoryGhsaId , range : m . vulnerableVersionRange , updatedAt : m . advisoryUpdatedAt } ) ;
236+ }
237+ }
238+ // CSV columns: type,repo,purl,reason_or_advisory,range,updatedAt
239+ const header = [ "type" , "repo" , "purl" , "reason_or_advisory" , "range" , "updatedAt" ] ;
240+ const sanitize = ( val : unknown ) : string => {
241+ if ( val === null || val === undefined ) return "" ;
242+ let s = String ( val ) ;
243+ // Neutralize leading characters that can trigger spreadsheet formula execution
244+ if ( / ^ [ = + \- @ ] / . test ( s ) ) s = "'" + s ; // prefix apostrophe to neutralize
245+ // Escape quotes for CSV
246+ if ( / [ " , \n ] / . test ( s ) ) s = '"' + s . replace ( / " / g, '""' ) + '"' ;
247+ return s ;
248+ } ;
249+ const lines : string [ ] = [ header . join ( "," ) ] ;
250+ for ( const r of searchRows ) {
251+ lines . push ( [
252+ "search" ,
253+ sanitize ( r . repo ) ,
254+ sanitize ( r . purl ) ,
255+ sanitize ( r . reason ) ,
256+ "" ,
257+ ""
258+ ] . join ( "," ) ) ;
259+ }
260+ for ( const r of malwareRows ) {
261+ lines . push ( [
262+ "malware" ,
263+ sanitize ( r . repo ) ,
264+ sanitize ( r . purl ) ,
265+ sanitize ( r . advisory ) ,
266+ sanitize ( r . range ?? "" ) ,
267+ sanitize ( r . updatedAt )
268+ ] . join ( "," ) ) ;
269+ }
270+ const csvPayload = lines . join ( "\n" ) + "\n" ;
271+ if ( argv . outFile ) {
272+ try {
273+ fs . writeFileSync ( argv . outFile as string , csvPayload , "utf8" ) ;
274+ if ( ! quiet ) console . log ( chalk . green ( `Wrote CSV to ${ argv . outFile } ` ) ) ;
275+ } catch ( e ) {
276+ console . error ( chalk . red ( `Failed to write CSV file: ${ e instanceof Error ? e . message : String ( e ) } ` ) ) ;
277+ process . exit ( 1 ) ;
278+ }
279+ } else {
280+ process . stdout . write ( csvPayload ) ;
281+ }
282+ }
283+
220284 // If malware matches were computed but no search JSON writing happened yet and an output file was requested, persist them now.
221285 if ( malwareMatches && argv . outputFile ) {
222286 const fs = await import ( "fs" ) ;
0 commit comments