Skip to content

Commit caa79c3

Browse files
committed
Add ignore file and update README
1 parent bff4869 commit caa79c3

File tree

7 files changed

+262
-5
lines changed

7 files changed

+262
-5
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Search collected SBOMs by PURL, cache them for offline analysis, sync malware se
1414
- Sync malware security advisories from the GitHub Advisory Database
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
17+
- YAML ignore file support to suppress specific advisory IDs or PURLs globally or scoped to an org / repo
1718
- Works with GitHub.com, GitHub Enterprise Server, GitHub Enterprise Managed Users and GitHub Enterprise Cloud with Data Residency (custom base URL)
1819
- Reason tracing: every search match shows which query matched; every malware match shows which advisory triggered it
1920
- Interactive REPL for ad‑hoc PURL queries (history, graceful Ctrl+C handling)
@@ -68,6 +69,7 @@ npm run start -- --sync-sboms --enterprise ent --base-url https://github.interna
6869
| `--match-malware` | Match current SBOM set against cached advisories |
6970
| `--malware-cache <dir>` | Advisory cache directory (required with malware operations) |
7071
| `--malware-cutoff <ISO-date>` | Ignore advisories whose publishedAt AND updatedAt are both before this date/time (e.g. `2025-09-29` or full timestamp) |
72+
| `--ignore-file <path>` | YAML ignore file (advisories / purls / scoped blocks) to filter malware matches before output |
7173
| `--sarif-dir <dir>` | Write SARIF 2.1.0 files per repository (with malware matches) |
7274
| `--upload-sarif` | Upload generated SARIF to Code Scanning (requires --match-malware & --sarif-dir and a GitHub token) |
7375
| `--concurrency <n>` | Parallel SBOM fetches (default 5) |
@@ -145,6 +147,37 @@ npm run start -- --sbom-cache sboms --malware-cache malware-cache --match-malwar
145147

146148
If you also perform a search in the same invocation (add `--purl` or `--purl-file`), the JSON file will contain both `malwareMatches` and `search` top-level keys.
147149

150+
#### Ignoring Matches
151+
152+
Provide a YAML ignore file via `--ignore-file` to suppress specific matches (before SARIF generation / JSON output). Structure:
153+
154+
```yaml
155+
# Ignore specific advisory IDs everywhere
156+
advisories:
157+
- GHSA-aaaa-bbbb-cccc
158+
159+
# Ignore by PURL (optional semver/range component after @). If version/range omitted, all versions are ignored.
160+
purls:
161+
- pkg:npm/lodash # any version
162+
- pkg:npm/react@>=18.0.0 <18.3.0
163+
164+
# Scoped ignores (org OR org/repo). Applied only within those scopes.
165+
scoped:
166+
- scope: my-org
167+
advisories: [GHSA-1111-2222-3333]
168+
- scope: my-org/my-repo
169+
purls:
170+
- pkg:maven/com.example/[email protected]
171+
```
172+
173+
Rules precedence:
174+
175+
1. Scoped repo block
176+
2. Scoped org block
177+
3. Global advisories / purls
178+
179+
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.
180+
148181
#### Advisory Date Cutoff
149182
150183
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.

ignore.example.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Example ignore file for github-sbom-toolkit malware matches
2+
# Copy to e.g. sbom-ignore.yml and pass with --ignore-file sbom-ignore.yml
3+
4+
advisories:
5+
# Ignore these advisory IDs globally (all repos)
6+
- GHSA-AAAA-BBBB-CCCC
7+
8+
purls:
9+
# Ignore any version of lodash
10+
- pkg:npm/lodash
11+
# Ignore only these React versions (range semantics via semver)
12+
- pkg:npm/react@>=18.0.0 <18.3.0
13+
14+
scoped:
15+
# Ignore an advisory across an entire org
16+
- scope: my-org
17+
advisories:
18+
- GHSA-1111-2222-3333
19+
# Ignore one specific PURL (exact version) in a single repository
20+
- scope: my-org/my-repo
21+
purls:
22+
- pkg:maven/com.example/[email protected]

package-lock.json

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"p-limit": "^7.1.1",
3030
"packageurl-js": "^2.0.1",
3131
"semver": "^7.7.2",
32+
"yaml": "^2.8.1",
3233
"yargs": "^18.0.0"
3334
},
3435
"devDependencies": {

src/cli.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ async function main() {
3535
.option("purl-file", { type: "string", describe: "Path to file with PURL queries (one per line; supports version ranges & wildcards; # or // for comments)" })
3636
.option("json", { type: "boolean", describe: "Emit search results as JSON to stdout (suppresses human output unless --cli also provided)" })
3737
.option("cli", { type: "boolean", describe: "When used with --json, also emit human-readable CLI output" })
38-
.option("output-file", { type: "string", describe: "Write search JSON output to this file (implied JSON generation). Required when using --cli with JSON." })
38+
.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" })
40+
.option("ignore-file", { type: "string", describe: "Path to YAML ignore file (advisories, purls, scoped ignores)" })
4041
.check(args => {
4142
const syncing = !!args.syncSboms;
4243
if (syncing) {
@@ -62,6 +63,9 @@ async function main() {
6263
if (args.uploadSarif && !args.sarifDir) {
6364
throw new Error("--upload-sarif requires --sarif-dir to write SARIF files prior to upload.");
6465
}
66+
if (args.csv && args.json) {
67+
throw new Error("Use one of --json or --csv")
68+
}
6569
return true;
6670
})
6771
.help()
@@ -120,6 +124,25 @@ async function main() {
120124
if (argv["match-malware"]) {
121125
const { matchMalware, buildSarifPerRepo, writeSarifFiles, uploadSarifPerRepo } = await import("./malwareMatcher.js");
122126
malwareMatches = matchMalware(mas.getAdvisories(), sboms, { advisoryDateCutoff: argv["malware-cutoff"] as string | undefined });
127+
// Apply ignore file if provided
128+
if (argv["ignore-file"] && malwareMatches?.length) {
129+
try {
130+
const { IgnoreMatcher } = await import("./ignore.js");
131+
const matcher = IgnoreMatcher.load(argv["ignore-file"] as string, {});
132+
if (matcher) {
133+
const { kept, ignored } = matcher.filter(malwareMatches);
134+
if (!argv.quiet) {
135+
console.log(chalk.yellow(`Ignored ${ignored.length} malware match(es) via ignore file; ${kept.length} remaining.`));
136+
}
137+
malwareMatches = kept;
138+
// If writing SARIF we intentionally only report kept matches; optionally we could emit a log of ignored reasons.
139+
} else if (!argv.quiet) {
140+
console.log(chalk.yellow(`Ignore file '${argv["ignore-file"]}' not found or failed to parse; proceeding without filtering.`));
141+
}
142+
} catch (e) {
143+
console.error(chalk.red(`Failed applying ignore file: ${(e as Error).message}`));
144+
}
145+
}
123146
if (!quiet) console.log(chalk.magenta(`Malware matches found: ${malwareMatches?.length ?? 0}`));
124147
if (malwareMatches) {
125148
if (!quiet) {
@@ -179,9 +202,8 @@ async function main() {
179202
const combinedPurlsRaw = [...(argv.purl as string[] ?? []), ...filePurls];
180203
const combinedPurls = combinedPurlsRaw.map(p => p.startsWith("pkg:") ? p : `pkg:${p}`);
181204
if (combinedPurls.length) {
182-
const needJson = argv.json || argv.outputFile;
183205
// We'll also consider CSV export after JSON handling
184-
if (needJson) {
206+
if (argv.json || argv.outputFile) {
185207
const map = collector.searchByPurlsWithReasons(combinedPurls);
186208
const jsonSearch = Array.from(map.entries()).map(([repo, entries]) => ({ repo, matches: entries }));
187209
if (argv.outputFile) {
@@ -200,13 +222,13 @@ async function main() {
200222
console.error(chalk.red(`Failed to write output file: ${e instanceof Error ? e.message : String(e)}`));
201223
process.exit(1);
202224
}
203-
} else if (argv.json) {
225+
} else {
204226
const payloadObj: { search: unknown; malwareMatches?: import("./malwareMatcher.js").MalwareMatch[] } = { search: jsonSearch };
205227
if (malwareMatches) payloadObj.malwareMatches = malwareMatches;
206228
process.stdout.write(JSON.stringify(payloadObj, null, 2) + "\n");
207229
}
208230
// If CLI output requested (either implicit because no --json OR explicit --cli with output-file requirement) then show human form
209-
if (!needJson || (argv.cli && needJson)) {
231+
if (((!argv.json && !argv.csv) && !argv.outputFile) || (argv.cli && (argv.json || argv.outputFile))) {
210232
runSearch(combinedPurls);
211233
} else if (argv.cli) { // This branch only occurs when validation prevented missing output-file
212234
runSearch(combinedPurls);

src/ignore.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import * as semver from "semver";
4+
import { MalwareMatch } from "./malwareMatcher.js";
5+
import YAML from "yaml";
6+
7+
/**
8+
* Ignore file schema (YAML)
9+
*
10+
* Example:
11+
* ---
12+
* # Ignore by advisory id everywhere
13+
* advisories:
14+
* - GHSA-xxxx-yyyy-zzzz
15+
*
16+
* # Ignore by PURL (any version) or version range
17+
* purls:
18+
* - pkg:npm/lodash # ignore any version
19+
* - pkg:npm/react@>=18.0.0 <18.3.0 # ignore a version range
20+
*
21+
* # Scoped ignores (repo full name or org). If provided, the ignore only applies within those repos/orgs.
22+
* scoped:
23+
* - scope: my-org # applies to every repo in org
24+
* advisories: [GHSA-1111-2222-3333]
25+
* - scope: my-org/my-repo # applies only to that repository
26+
* purls:
27+
* - pkg:maven/org.example/[email protected]
28+
*/
29+
export interface IgnoreFileRoot {
30+
advisories?: string[];
31+
purls?: string[]; // each may have optional version/range after @
32+
scoped?: Array<ScopedIgnoreBlock>;
33+
}
34+
35+
export interface ScopedIgnoreBlock {
36+
scope: string; // org or org/repo
37+
advisories?: string[];
38+
purls?: string[];
39+
}
40+
41+
export interface IgnoreMatcherOptions {
42+
cwd?: string; // base dir for relative path
43+
}
44+
45+
interface ParsedPurlIgnore {
46+
raw: string;
47+
type: string;
48+
name: string;
49+
versionConstraint?: string; // semver range or exact version
50+
}
51+
52+
function parsePurlIgnore(raw: string): ParsedPurlIgnore | null {
53+
const trimmed = raw.trim();
54+
if (!trimmed) return null;
55+
const full = trimmed.startsWith("pkg:") ? trimmed.slice(4) : trimmed;
56+
const atIdx = full.indexOf("@");
57+
const main = atIdx >= 0 ? full.slice(0, atIdx) : full;
58+
const ver = atIdx >= 0 ? full.slice(atIdx + 1) : undefined;
59+
const slashIdx = main.indexOf("/");
60+
if (slashIdx < 0) return null;
61+
const type = main.slice(0, slashIdx).toLowerCase();
62+
const name = main.slice(slashIdx + 1);
63+
if (!type || !name) return null;
64+
return { raw: trimmed.startsWith("pkg:") ? trimmed : `pkg:${trimmed}`, type, name, versionConstraint: ver };
65+
}
66+
67+
export class IgnoreMatcher {
68+
private globalAdvisories: Set<string> = new Set();
69+
private globalPurls: ParsedPurlIgnore[] = [];
70+
private scoped: Array<{ scope: string; isRepo: boolean; advisories: Set<string>; purls: ParsedPurlIgnore[] } > = [];
71+
72+
static load(filePath: string, opts?: IgnoreMatcherOptions): IgnoreMatcher | undefined {
73+
const abs = path.isAbsolute(filePath) ? filePath : path.join(opts?.cwd || process.cwd(), filePath);
74+
if (!fs.existsSync(abs)) return undefined;
75+
const text = fs.readFileSync(abs, "utf8");
76+
let doc: IgnoreFileRoot | undefined;
77+
try {
78+
const parsed = YAML.parse(text) as unknown;
79+
if (parsed && typeof parsed === "object") doc = parsed as IgnoreFileRoot;
80+
} catch (e) {
81+
console.warn(`Failed to parse ignore file ${abs}: ${(e as Error).message}`);
82+
return undefined;
83+
}
84+
const matcher = new IgnoreMatcher();
85+
if (doc?.advisories) for (const a of doc.advisories) matcher.globalAdvisories.add(a.trim());
86+
if (doc?.purls) for (const p of doc.purls) { const parsed = parsePurlIgnore(p); if (parsed) matcher.globalPurls.push(parsed); }
87+
if (doc?.scoped) {
88+
for (const block of doc.scoped) {
89+
if (!block.scope) continue;
90+
const advisories = new Set<string>();
91+
if (block.advisories) for (const a of block.advisories) advisories.add(a.trim());
92+
const purls: ParsedPurlIgnore[] = [];
93+
if (block.purls) for (const p of block.purls) { const parsed = parsePurlIgnore(p); if (parsed) purls.push(parsed); }
94+
const isRepo = block.scope.includes("/");
95+
matcher.scoped.push({ scope: block.scope.toLowerCase(), isRepo, advisories, purls });
96+
}
97+
}
98+
return matcher;
99+
}
100+
101+
private purlMatches(ignore: ParsedPurlIgnore, candidate: { purl: string; ecosystem: string; packageName: string; version: string | null }): boolean {
102+
const { purl, version } = candidate;
103+
// quick name match: we only stored type+name; ensure purl contains that coordination after pkg:
104+
const body = purl.startsWith("pkg:") ? purl.slice(4) : purl;
105+
const atIdx = body.indexOf("@");
106+
const main = atIdx >= 0 ? body.slice(0, atIdx) : body;
107+
const nameStart = main.indexOf("/");
108+
if (nameStart < 0) return false;
109+
const type = main.slice(0, nameStart).toLowerCase();
110+
const name = main.slice(nameStart + 1);
111+
if (ignore.type !== type) return false;
112+
if (ignore.name.toLowerCase() !== name.toLowerCase()) return false;
113+
if (!ignore.versionConstraint) return true; // any version
114+
if (!version) return false;
115+
const range = ignore.versionConstraint.trim();
116+
try {
117+
if (semver.validRange(range)) {
118+
const coerced = semver.coerce(version)?.version || version;
119+
if (coerced && semver.satisfies(coerced, range, { includePrerelease: true })) return true;
120+
} else if (/^[0-9A-Za-z._-]+$/.test(range)) {
121+
return version === range;
122+
}
123+
} catch { /* ignore */ }
124+
return false;
125+
}
126+
127+
/** Determine whether the given match should be ignored. */
128+
shouldIgnore(match: MalwareMatch): { ignored: boolean; reason?: string } {
129+
// Global advisory
130+
if (this.globalAdvisories.has(match.advisoryGhsaId)) return { ignored: true, reason: `advisory:${match.advisoryGhsaId}` };
131+
// Global purl (with optional range)
132+
for (const p of this.globalPurls) {
133+
if (this.purlMatches(p, match)) return { ignored: true, reason: `purl:${p.raw}` };
134+
}
135+
// Scoped
136+
for (const block of this.scoped) {
137+
if (block.isRepo) {
138+
if (match.repo.toLowerCase() !== block.scope) continue;
139+
} else {
140+
// org scope
141+
if (!match.repo.toLowerCase().startsWith(block.scope + "/")) continue;
142+
}
143+
if (block.advisories.has(match.advisoryGhsaId)) return { ignored: true, reason: `scoped-advisory:${block.scope}:${match.advisoryGhsaId}` };
144+
for (const p of block.purls) if (this.purlMatches(p, match)) return { ignored: true, reason: `scoped-purl:${block.scope}:${p.raw}` };
145+
}
146+
return { ignored: false };
147+
}
148+
149+
filter(matches: MalwareMatch[]): { kept: MalwareMatch[]; ignored: Array<MalwareMatch & { ignoreReason: string }> } {
150+
const kept: MalwareMatch[] = [];
151+
const ignored: Array<MalwareMatch & { ignoreReason: string }> = [];
152+
for (const m of matches) {
153+
const res = this.shouldIgnore(m);
154+
if (res.ignored) ignored.push({ ...m, ignoreReason: res.reason || "unknown" }); else kept.push(m);
155+
}
156+
return { kept, ignored };
157+
}
158+
}

src/types-external.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Minimal module declarations for external packages lacking bundled types at build time in this environment.
2+
declare module 'yaml' {
3+
// Export a parse function returning unknown (caller narrows).
4+
export function parse(src: string): unknown;
5+
export function stringify(obj: unknown): string;
6+
const _default: { parse: typeof parse; stringify: typeof stringify };
7+
export default _default;
8+
}

0 commit comments

Comments
 (0)