Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ Then type one PURL query per line. Entering a blank line or using Ctrl+C on a bl
| `--repo <name>` | Single repository scope in the form `owner/name` (mutually exclusive with `--enterprise`/`--org` when syncing) |
| `--base-url <url>` | GitHub Enterprise Server REST base URL (e.g. `https://ghe.example.com/api/v3`) |
| `--concurrency <n>` | Parallel SBOM fetches (default 5) |
| `--exclude-archived` | Exclude archived repositories before SBOM collection |
| `--sbom-delay <ms>` | Delay between SBOM fetch requests (default 3000) |
| `--light-delay <ms>` | Delay between lightweight metadata requests (default 100) |
| `--sbom-cache <dir>` | Directory to read/write per‑repo SBOM JSON; required for SBOM syncing and offline use |
Expand Down
1 change: 1 addition & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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,
Expand Down
133 changes: 133 additions & 0 deletions src/exclude-archived.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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<void>((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 });
}
});
});
21 changes: 14 additions & 7 deletions src/sbomCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
Expand All @@ -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<string, { name: string; pushed_at?: string; updated_at?: string; default_branch?: string }[]> = {};
const orgRepoMap: Record<string, { name: string; pushed_at?: string; updated_at?: string; default_branch?: string; archived?: boolean }[]> = {};
let totalRepos = 0;

if (!this.opts.repo) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Comment thread
gubraun marked this conversation as resolved.
Resolved
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) {
Expand Down
Loading