|
| 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 | +} |
0 commit comments