Skip to content

Commit 9cfdcd2

Browse files
committed
Added CSV output
1 parent d133a19 commit 9cfdcd2

File tree

1 file changed

+70
-6
lines changed

1 file changed

+70
-6
lines changed

src/cli.ts

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)