Skip to content

Commit 125965c

Browse files
committed
Fix JSON output; add ignore unbounded malware option
1 parent 14f1eed commit 125965c

File tree

3 files changed

+89
-51
lines changed

3 files changed

+89
-51
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Search collected SBOMs by PURL, cache them for offline analysis, sync malware se
1515
- Version-aware matching of SBOM packages vs. malware advisories
1616
- Optional SARIF 2.1.0 output per repository for malware matches with optional Code Scanning upload
1717
- YAML ignore file support to suppress specific advisory IDs or PURLs globally or scoped to an org / repo
18+
- Optional suppression of "unbounded" malware advisories that claim all versions are affected (e.g. vulnerable range '*', '>=0')
1819
- Works with GitHub.com, GitHub Enterprise Server, GitHub Enterprise Managed Users and GitHub Enterprise Cloud with Data Residency (custom base URL)
1920
- Reason tracing: every search match shows which query matched; every malware match shows which advisory triggered it
2021
- Interactive REPL for ad‑hoc PURL queries (history, graceful Ctrl+C handling)
@@ -70,6 +71,7 @@ npm run start -- --sync-sboms --enterprise ent --base-url https://github.interna
7071
| `--malware-cache <dir>` | Advisory cache directory (required with malware operations) |
7172
| `--malware-cutoff <ISO-date>` | Ignore advisories whose publishedAt AND updatedAt are both before this date/time (e.g. `2025-09-29` or full timestamp) |
7273
| `--ignore-file <path>` | YAML ignore file (advisories / purls / scoped blocks) to filter malware matches before output |
74+
| `--ignore-unbounded-malware` | Ignore matches whose advisory vulnerable version range covers all versions (e.g. `*`, `>=0`, `0.0.0`) |
7375
| `--sarif-dir <dir>` | Write SARIF 2.1.0 files per repository (with malware matches) |
7476
| `--upload-sarif` | Upload generated SARIF to Code Scanning (requires --match-malware & --sarif-dir and a GitHub token) |
7577
| `--concurrency <n>` | Parallel SBOM fetches (default 5) |
@@ -178,6 +180,26 @@ Rules precedence:
178180
179181
The first matching rule suppresses the finding; output logs will show how many were ignored. Ignored items are fully removed from SARIF and JSON/CSV outputs.
180182
183+
##### Ignoring "Unbounded" Malware Advisories
184+
185+
Some malware advisories list a vulnerable version range that effectively covers every possible version of a package (examples: `*`, `>=0`, `0`, `0.0.0`, `>=0.0.0`). These can create low‑signal noise when you only want to focus on advisories with actionable version scoping.
186+
187+
Use the flag:
188+
189+
```bash
190+
--ignore-unbounded-malware
191+
```
192+
193+
When enabled, any malware match whose `vulnerableVersionRange` normalizes to one of those unbounded patterns is filtered out before JSON / SARIF / CSV output. A summary line (to stderr) reports how many were removed.
194+
195+
Heuristics currently treated as unbounded:
196+
197+
- `*`
198+
- `>=0`, `>0`
199+
- `0`, `0.0.0`, `>=0.0.0`
200+
201+
If you need broader or narrower interpretation (e.g., treat `>=0 <999999.0.0` as unbounded) please file an issue or extend the matcher.
202+
181203
#### Advisory Date Cutoff
182204

183205
Use `--malware-cutoff` to exclude older advisories from matching. An advisory will be skipped if **both** its `publishedAt` and `updatedAt` timestamps are strictly earlier than the cutoff.

package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli.ts

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

Comments
 (0)