diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b447a9..b76a344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Added: +- Optional archived-repository filtering during SBOM collection via `--exclude-archived`; default behavior is unchanged. - Continuous integration: GitHub Actions workflow that restores packages, builds and tests on every push to `main` and every pull request. - Unit tests using Node's built-in test runner (no new dependencies) covering the shipped fixtures: SBOM and advisory cache loading, branch diffs, malware matching and the advisory date cutoff, the ignore file, PURL search, and the documented offline CLI invocations. - [TESTING.md](TESTING.md) documenting how to run the tests and what they cover. diff --git a/README.md b/README.md index 8896a37..7d92dbc 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,7 @@ Then type one PURL query per line. Entering a blank line or using Ctrl+C on a bl | `--repo ` | Single repository scope in the form `owner/name` (mutually exclusive with `--enterprise`/`--org` when syncing) | | `--base-url ` | GitHub Enterprise Server REST base URL (e.g. `https://ghe.example.com/api/v3`) | | `--concurrency ` | Parallel SBOM fetches (default 5) | +| `--exclude-archived` | Exclude archived repositories before SBOM collection | | `--sbom-delay ` | Delay between SBOM fetch requests (default 3000) | | `--light-delay ` | Delay between lightweight metadata requests (default 100) | | `--sbom-cache ` | Directory to read/write per‑repo SBOM JSON; required for SBOM syncing and offline use | diff --git a/TESTING.md b/TESTING.md index 8990c2e..6aa7f5b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -26,6 +26,7 @@ Node's built-in test runner (`node --test`) runs the `src/*.test.ts` files, so n | ---- | ------ | | `src/fixtures.test.ts` | Loading the SBOM and malware advisory fixtures, branch diff revival, malware matching (including the advisory date cutoff), the `ignore.example.yml` ignore file, and PURL search (exact, semver range and wildcard queries) | | `src/cli.test.ts` | Running the built CLI offline against the fixtures, as documented in the [README](README.md): malware matching, JSON search output, and the no-match case | +| `src/exclude-archived.test.ts` | Collecting organization SBOMs with archived repositories included by default and excluded when requested, using a local mock GitHub API | ### Fixture harness scripts diff --git a/package.json b/package.json index 2a96781..3b96e1f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev": "tsx src/cli.ts", "lint": "eslint . --ext .ts --max-warnings=0", "lint:fix": "eslint . --ext .ts --fix", - "test": "node --test dist/fixtures.test.js dist/cli.test.js && node dist/test-fixture-match.js && node dist/test-branch-search.js" + "test": "node --test dist/fixtures.test.js dist/cli.test.js dist/exclude-archived.test.js && node dist/test-fixture-match.js && node dist/test-branch-search.js" }, "engines": { "node": ">=18.0.0" diff --git a/src/cli.ts b/src/cli.ts index 831b22c..2770cea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ async function main() { .option("concurrency", { type: "number", default: 5 }) .option("sbom-delay", { type: "number", default: 3000, describe: "Delay (ms) between SBOM fetch requests" }) .option("light-delay", { type: "number", default: 100, describe: "Delay (ms) between lightweight metadata requests (org/repo listing, commit head checks)" }) + .option("exclude-archived", { type: "boolean", default: false, describe: "Exclude archived repositories before SBOM collection" }) .option("sbom-cache", { type: "string", describe: "Directory to read/write cached SBOM JSON files" }) .option("purl", { type: "array", describe: "One or more PURL strings to search (supports suffix * wildcard after slash)" }) .option("sync-sboms", { type: "boolean", default: false, describe: "Fetch SBOMs from GitHub (write to --sbom-cache if provided) instead of offline-only" }) @@ -130,6 +131,7 @@ async function main() { concurrency: argv.concurrency as number, delayMsBetweenRepos: argv["sbom-delay"] as number, lightDelayMs: argv["light-delay"] as number, + excludeArchived: argv["exclude-archived"] as boolean, loadFromDir: argv["sbom-cache"] as string | undefined, syncSboms: argv.syncSboms as boolean, showProgressBar: argv.progress as boolean, diff --git a/src/exclude-archived.test.ts b/src/exclude-archived.test.ts new file mode 100644 index 0000000..5614f8d --- /dev/null +++ b/src/exclude-archived.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { createServer, type Server } from "node:http"; + +import { SbomCollector } from "./sbomCollector.js"; +import { writeOne } from "./serialization.js"; +import type { RepositorySbom } from "./types.js"; + +const repositories = [ + { name: "active-repo", archived: false, pushed_at: "2026-01-01T00:00:00Z", default_branch: "main" }, + { name: "archived-repo", archived: true, pushed_at: "2025-01-01T00:00:00Z", default_branch: "main" } +]; +let server: Server; +let baseUrl: string; +let sbomRequests: string[] = []; + +before(async () => { + server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? "/", baseUrl); + if (requestUrl.pathname === "/api/v3/orgs/example-org/repos") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(repositories)); + return; + } + + const sbomMatch = requestUrl.pathname.match(/^\/api\/v3\/repos\/example-org\/([^/]+)\/dependency-graph\/sbom$/); + if (sbomMatch) { + sbomRequests.push(sbomMatch[1]); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ sbom: { packages: [] } })); + return; + } + + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ message: `Unexpected request: ${requestUrl.pathname}` })); + }); + + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected local mock server address"); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); +}); + +async function collect(excludeArchived: boolean) { + sbomRequests = []; + const collector = new SbomCollector({ + token: "test-token", + baseUrl, + org: "example-org", + syncSboms: true, + quiet: true, + lightDelayMs: 0, + delayMsBetweenRepos: 0, + excludeArchived + }); + + const sboms = await collector.collect(); + return { sboms, summary: collector.getSummary(), sbomRequests: [...sbomRequests] }; +} + +function makeCachedSbom(repo: string, repoPushedAt: string): RepositorySbom { + return { + repo: `example-org/${repo}`, + org: "example-org", + retrievedAt: "2026-01-01T00:00:00Z", + repoPushedAt, + defaultBranch: "main", + packages: [] + }; +} + +describe("exclude archived repositories", () => { + it("collects archived repositories by default", async () => { + const result = await collect(false); + + assert.equal(result.summary.repositoryCount, 2); + assert.deepEqual(result.sbomRequests.sort(), ["active-repo", "archived-repo"]); + assert.deepEqual(result.sboms.map(sbom => sbom.repo).sort(), [ + "example-org/active-repo", + "example-org/archived-repo" + ]); + }); + + it("skips archived repositories when enabled", async () => { + const result = await collect(true); + + assert.equal(result.summary.repositoryCount, 1); + assert.deepEqual(result.sbomRequests, ["active-repo"]); + assert.deepEqual(result.sboms.map(sbom => sbom.repo), ["example-org/active-repo"]); + }); + + it("excludes cached archived SBOMs while retaining active cached baselines", async () => { + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "sbom-toolkit-archived-")); + try { + writeOne(makeCachedSbom("active-repo", "2026-01-01T00:00:00Z"), { outDir: cacheDir }); + writeOne(makeCachedSbom("archived-repo", "2025-01-01T00:00:00Z"), { outDir: cacheDir }); + sbomRequests = []; + + const collector = new SbomCollector({ + token: "test-token", + baseUrl, + org: "example-org", + loadFromDir: cacheDir, + syncSboms: true, + quiet: true, + lightDelayMs: 0, + delayMsBetweenRepos: 0, + excludeArchived: true + }); + + const sboms = await collector.collect(); + const summary = collector.getSummary(); + + assert.deepEqual(sboms.map(sbom => sbom.repo), ["example-org/active-repo"]); + assert.deepEqual(sbomRequests, []); + assert.equal(summary.repositoryCount, 1); + assert.equal(summary.successCount, 1); + assert.equal(summary.failedCount, 0); + assert.equal(summary.skippedCount, 1); + } finally { + fs.rmSync(cacheDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/sbomCollector.ts b/src/sbomCollector.ts index 6ce9850..be90e31 100644 --- a/src/sbomCollector.ts +++ b/src/sbomCollector.ts @@ -18,6 +18,7 @@ export interface CollectorOptions { ghes?: boolean; // Is this a GHES instance? concurrency?: number; // parallel repo SBOM fetches includePrivate?: boolean; + excludeArchived?: boolean; // when true, skip archived repos during listing delayMsBetweenRepos?: number; lightDelayMs?: number; // delay for lightweight (non-SBOM) requests loadFromDir?: string; // optional pre-existing serialized SBOM directory @@ -82,6 +83,7 @@ export class SbomCollector { baseUrl: o.baseUrl, concurrency: o.concurrency ?? 5, includePrivate: o.includePrivate ?? true, + excludeArchived: o.excludeArchived ?? false, delayMsBetweenRepos: o.delayMsBetweenRepos ?? 5000, lightDelayMs: o.lightDelayMs ?? 500, loadFromDir: o.loadFromDir, @@ -171,6 +173,12 @@ export class SbomCollector { throw new Error("No Octokit instance; token may be missing"); } + // Cached SBOMs are baselines for a sync, not results outside the current repository scope. + this.sboms = []; + this.summary.successCount = 0; + this.summary.failedCount = 0; + this.summary.skippedCount = 0; + if (this.opts.enterprise && !this.opts.quiet) { process.stderr.write(chalk.blue(`Getting list of organizations for enterprise ${this.opts.enterprise}`) + "\n"); } @@ -179,7 +187,7 @@ export class SbomCollector { this.summary.orgs = orgs; // Pre-list all repos if showing progress bar so we know the total upfront - const orgRepoMap: Record = {}; + const orgRepoMap: Record = {}; let totalRepos = 0; if (!this.opts.repo) { @@ -234,7 +242,6 @@ export class SbomCollector { try { if (new Date(repo.pushed_at) <= new Date(baseline.repoPushedAt)) { // repo pushed_at unchanged -> skip - newSboms.push(baseline); this.summary.skippedCount++; this.decisions[fullName] = `Skipping (no new pushes since last fetch)`; skipped = true; @@ -252,7 +259,6 @@ export class SbomCollector { const previousRetrieval = new Date(baseline.retrievedAt).getTime(); if (commitTime <= previousRetrieval) { // Default branch head hasn't advanced since last SBOM retrieval -> skip - newSboms.push(baseline); this.summary.skippedCount++; this.decisions[fullName] = `Skipping (default branch commit not newer than previous SBOM: ${commitDate} <= ${baseline.retrievedAt})`; skipped = true; @@ -432,10 +438,10 @@ export class SbomCollector { } } - private async listOrgRepos(org: string): Promise<{ name: string; pushed_at?: string; updated_at?: string; default_branch?: string }[]> { + private async listOrgRepos(org: string): Promise<{ name: string; pushed_at?: string; updated_at?: string; default_branch?: string; archived?: boolean }[]> { if (!this.octokit) throw new Error("No Octokit instance"); - interface RepoMeta { name: string; pushed_at?: string; updated_at?: string; default_branch?: string } + interface RepoMeta { name: string; pushed_at?: string; updated_at?: string; default_branch?: string; archived?: boolean } const repos: RepoMeta[] = []; const per_page = 100; let page = 1; @@ -446,9 +452,10 @@ export class SbomCollector { await new Promise(r => setTimeout(r, this.opts.lightDelayMs)); - const items = resp.data as Array<{ name: string; pushed_at?: string; updated_at?: string; default_branch?: string }>; + const items = resp.data as Array<{ name: string; pushed_at?: string; updated_at?: string; default_branch?: string; archived?: boolean }>; for (const r of items) { - repos.push({ name: r.name, pushed_at: r.pushed_at, updated_at: r.updated_at, default_branch: r.default_branch }); + if (this.opts.excludeArchived && r.archived) continue; + repos.push({ name: r.name, pushed_at: r.pushed_at, updated_at: r.updated_at, default_branch: r.default_branch, archived: r.archived }); } if (items.length < per_page) done = true; else page++; } catch (e) {