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
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,12 @@ updates:
dependency-type: "production"
development-dependencies:
dependency-type: "development"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
open-pull-requests-limit: 5
assignees:
- "aegilops"
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
build-and-test:
name: Build and test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
node-version: ['22.x', '24.x']

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm

- name: Restore packages
run: npm ci

- name: Build
run: npm run build

- name: Test
run: npm test
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [Unreleased]

Added:

- 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.

Fixed:

- Corrected the example test output in the README, which named a repository that is not in the fixtures and omitted the branch match.

## [2025-12-09] – 0.2.0 - Branch scanning and dependency submission

Added:
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ npm run build

## 🧪 Test

The repo ships with a minimal test fixture to validate end-to-end malware matching without making network calls.
The repo ships with minimal test fixtures (`fixtures/`) that validate end-to-end malware matching and PURL search without making network calls.

1. Build the project:

Expand All @@ -442,7 +442,13 @@ npm install
npm run build
```

2. Run the test harness script:
2. Run the tests:

```bash
npm test
```

That runs the unit tests (Node's built-in test runner) plus the two fixture harness scripts. To run a single harness on its own:

```bash
node dist/test-fixture-match.js
Expand All @@ -452,7 +458,8 @@ You should see output similar to:

```text
Matches:
chalk-org/chalk-repo => pkg:npm/chalk@5.6.1 matched advisory GHSA-test-chalk-561 range =5.6.1
advanced-security/test-sbom-repo => pkg:npm/chalk@5.6.1 matched advisory GHSA-test-chalk-561 range =5.6.1
advanced-security/test-sbom-repo => pkg:npm/chalk@5.6.1 matched advisory GHSA-test-chalk-561 range =5.6.1 on branch test
```

Alternatively, you can exercise the CLI purely offline using the fixtures (no token required):
Expand All @@ -461,6 +468,8 @@ Alternatively, you can exercise the CLI purely offline using the fixtures (no to
npm run start -- --sbom-cache fixtures/sboms --malware-cache fixtures/malware-cache --match-malware
```

See [TESTING.md](TESTING.md) for more detail. Tests also run in CI on every push and pull request.

## 🚦 Rate Limiting

Standard & secondary rate limits trigger an automatic retry (up to 2 times).
Expand Down
54 changes: 54 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Testing

All tests run offline against the fixtures in `fixtures/`, so no GitHub token or network access is required.

## Running the tests

```bash
npm install
npm run build
npm test
```

`npm test` runs against the compiled output in `dist/`, so `npm run build` must be run first (or again, after changing anything in `src/`).

The same three steps run in CI (`.github/workflows/ci.yml`) on every push to `main` and on every pull request.

## What is covered

`npm test` runs two kinds of test:

### Unit tests

Node's built-in test runner (`node --test`) runs the `src/*.test.ts` files, so no test framework dependency is needed:

| File | Covers |
| ---- | ------ |
| `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 |

### Fixture harness scripts

These are also runnable on their own, and print their results for inspection:

| Script | Covers |
| ------ | ------ |
| `node dist/test-fixture-match.js` | End-to-end malware matching against `fixtures/` |
| `node dist/test-branch-search.js` | PURL search over a synthetic repository SBOM with branch dependency diffs |

## Adding tests

Add a new `src/<name>.test.ts` file using `node:test` and `node:assert/strict`, then add the compiled path (`dist/<name>.test.js`) to the `test` script in `package.json`. Test files are listed explicitly rather than globbed so the runner behaves the same on every supported Node version and platform.

## Fixtures

| Fixture | Contents |
| ------- | -------- |
| `fixtures/sboms/advanced-security/test-sbom-repo/sbom.json` | An SPDX SBOM with `chalk@5.6.1` and `left-pad@1.3.0`, plus a dependency review diff adding `chalk@5.6.1` on a `test` branch |
| `fixtures/malware-cache/malware-advisories.json` | A single fake malware advisory, `GHSA-test-chalk-561`, covering npm `chalk` version `=5.6.1` |

Matching these two fixtures produces two matches: one on the default branch and one on the `test` branch.

## End-to-end testing

Unit tests do not cover the GitHub API paths. Before submitting changes that touch collection, sync or upload, also run the CLI against a test organization and Enterprise and check the results are as expected, as described in [CONTRIBUTING.md](CONTRIBUTING.md).
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 dist/test-fixture-match.js && node dist/test-branch-search.js"
"test": "node --test dist/fixtures.test.js dist/cli.test.js && node dist/test-fixture-match.js && node dist/test-branch-search.js"
},
"engines": {
"node": ">=18.0.0"
Expand Down
57 changes: 57 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";

// Tests run from the compiled output in `dist`, so the repository root is one level up
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const cli = fileURLToPath(new URL("./cli.js", import.meta.url));

const FIXTURE_REPO = "advanced-security/test-sbom-repo";

/** Run the built CLI offline against the fixtures, from the repository root */
function runCli(args: string[]) {
const result = spawnSync(process.execPath, [cli, ...args], {
cwd: repoRoot,
encoding: "utf8",
// No token is provided: these invocations must work entirely offline
env: { ...process.env, GITHUB_TOKEN: "", GH_TOKEN: "" }
});
assert.equal(result.status, 0, `CLI exited with ${result.status}\n${result.stderr}`);
return result;
}

describe("CLI against the fixtures", () => {
it("matches malware advisories offline, as documented in the README", () => {
const { stdout } = runCli([
"--sbom-cache", path.join("fixtures", "sboms"),
"--malware-cache", path.join("fixtures", "malware-cache"),
"--match-malware"
]);
const matchLines = stdout.split("\n").filter(line => line.includes("GHSA-test-chalk-561"));
assert.equal(matchLines.length, 2);
assert.match(matchLines[0], /^advanced-security\/test-sbom-repo :: pkg:npm\/chalk@5\.6\.1 => GHSA-test-chalk-561 \(=5\.6\.1\)/);
assert.match(matchLines[1], /\[branch: test\]/);
});

it("searches by PURL and emits JSON", () => {
const { stdout } = runCli([
"--sbom-cache", path.join("fixtures", "sboms"),
"--purl", "pkg:npm/chalk@5.6.1",
"--json"
]);
const payload = JSON.parse(stdout) as { search: { repo: string; matches: { purl: string; reason: string }[] }[] };
assert.equal(payload.search.length, 1);
assert.equal(payload.search[0].repo, FIXTURE_REPO);
assert.deepEqual(payload.search[0].matches.map(m => m.purl), ["pkg:npm/chalk@5.6.1", "pkg:npm/chalk@5.6.1@test"]);
});

it("reports no matches for a package that is not in the SBOM", () => {
const { stdout } = runCli([
"--sbom-cache", path.join("fixtures", "sboms"),
"--purl", "pkg:npm/not-in-the-sbom@1.0.0"
]);
assert.match(stdout, /No matches\./);
});
});
153 changes: 153 additions & 0 deletions src/fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { before, describe, it } from "node:test";
import { fileURLToPath } from "node:url";

import { IgnoreMatcher } from "./ignore.js";
import { MalwareAdvisoryNode, MalwareAdvisorySync } from "./malwareAdvisories.js";
import { matchMalware } from "./malwareMatcher.js";
import { SbomCollector } from "./sbomCollector.js";
import { readAll } from "./serialization.js";
import { RepositorySbom } from "./types.js";

// Tests run from the compiled output in `dist`, so the repository root is one level up
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const sbomFixtureDir = path.join(repoRoot, "fixtures", "sboms");
const malwareFixtureDir = path.join(repoRoot, "fixtures", "malware-cache");

const FIXTURE_REPO = "advanced-security/test-sbom-repo";
const FIXTURE_GHSA = "GHSA-test-chalk-561";

/** Load the advisory cache the same way the CLI does (offline, no token required) */
function loadFixtureAdvisories(): MalwareAdvisoryNode[] {
return new MalwareAdvisorySync({ token: "", cacheDir: malwareFixtureDir, quiet: true }).getAdvisories();
}

function fixtureMatches() {
return matchMalware(loadFixtureAdvisories(), readAll(sbomFixtureDir));
}

describe("SBOM fixture", () => {
it("loads the shipped SBOM from the fixture cache", () => {
const sboms = readAll(sbomFixtureDir);
assert.equal(sboms.length, 1);
assert.equal(sboms[0].repo, FIXTURE_REPO);
assert.equal(sboms[0].org, "advanced-security");
assert.deepEqual(sboms[0].packages.map(p => p.name).sort(), ["chalk", "left-pad"]);
});

it("revives branch diffs as a Map keyed by head branch", () => {
const diffs = readAll(sbomFixtureDir)[0].branchDiffs;
if (!(diffs instanceof Map)) throw new Error("branchDiffs should be revived as a Map");
const diff = diffs.get("test");
if (!diff) throw new Error("expected a diff for the 'test' branch");
assert.equal(diff.base, "main");
assert.deepEqual(diff.changes.map(c => `${c.changeType}:${c.packageURL}`), ["added:pkg:npm/chalk@5.6.1"]);
});
});

describe("malware advisory fixture", () => {
it("loads the shipped advisory from the fixture cache", () => {
const advisories = loadFixtureAdvisories();
assert.equal(advisories.length, 1);
assert.equal(advisories[0].ghsaId, FIXTURE_GHSA);
assert.deepEqual(
advisories[0].vulnerabilities.map(v => `${v.ecosystem}:${v.name}:${v.vulnerableVersionRange}`),
["NPM:chalk:=5.6.1"]
);
});
});

describe("malware matching", () => {
it("matches chalk 5.6.1 on the default branch and on the 'test' branch", () => {
const matches = fixtureMatches();
assert.equal(matches.length, 2);
for (const match of matches) {
assert.equal(match.repo, FIXTURE_REPO);
assert.equal(match.purl, "pkg:npm/chalk@5.6.1");
assert.equal(match.advisoryGhsaId, FIXTURE_GHSA);
assert.equal(match.vulnerableVersionRange, "=5.6.1");
}
assert.deepEqual(matches.map(m => m.branch), [undefined, "test"]);
});

it("does not match a package outside the vulnerable version range", () => {
const sboms: RepositorySbom[] = [{
repo: FIXTURE_REPO,
org: "advanced-security",
retrievedAt: new Date().toISOString(),
packages: [{ name: "chalk", version: "5.6.2", purl: "pkg:npm/chalk@5.6.2" }]
}];
assert.deepEqual(matchMalware(loadFixtureAdvisories(), sboms), []);
});

it("honours the advisory date cutoff", () => {
const advisories = loadFixtureAdvisories();
const sboms = readAll(sbomFixtureDir);
// The fixture advisory was both published and updated on 2025-09-29
assert.equal(matchMalware(advisories, sboms, { advisoryDateCutoff: "2025-09-29" }).length, 2);
assert.equal(matchMalware(advisories, sboms, { advisoryDateCutoff: "2025-09-30" }).length, 0);
});
});

describe("ignore file", () => {
it("parses the shipped ignore.example.yml", () => {
const matcher = IgnoreMatcher.load("ignore.example.yml", { cwd: repoRoot });
if (!matcher) throw new Error("expected ignore.example.yml to parse");
// The example file does not reference the fixture advisory, so nothing is filtered out
const { kept, ignored } = matcher.filter(fixtureMatches());
assert.equal(kept.length, 2);
assert.equal(ignored.length, 0);
});

it("filters matches for an ignored advisory", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sbom-toolkit-ignore-"));
try {
const ignoreFile = path.join(dir, "ignore.yml");
fs.writeFileSync(ignoreFile, `advisories:\n - ${FIXTURE_GHSA}\n`, "utf8");
const matcher = IgnoreMatcher.load(ignoreFile);
if (!matcher) throw new Error("expected the ignore file to parse");
const { kept, ignored } = matcher.filter(fixtureMatches());
assert.equal(kept.length, 0);
assert.equal(ignored.length, 2);
assert.deepEqual(new Set(ignored.map(i => i.ignoreReason)), new Set([`advisory:${FIXTURE_GHSA}`]));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe("PURL search", () => {
let collector: SbomCollector;

before(async () => {
collector = new SbomCollector({ token: undefined, loadFromDir: sbomFixtureDir, syncSboms: false, quiet: true });
await collector.collect();
});

it("loads the fixture SBOM offline", () => {
assert.deepEqual(collector.getAllSboms().map(s => s.repo), [FIXTURE_REPO]);
});

it("matches an exact PURL on the default branch and on branch diffs", () => {
const results = collector.searchByPurlsWithReasons(["pkg:npm/chalk@5.6.1"]);
assert.deepEqual(results.get(FIXTURE_REPO)?.map(e => e.purl), ["pkg:npm/chalk@5.6.1", "pkg:npm/chalk@5.6.1@test"]);
});

it("matches a semver range, adding the 'pkg:' prefix when it is omitted", () => {
const entries = collector.searchByPurlsWithReasons(["npm/chalk@>=5.0.0 <6.0.0"]).get(FIXTURE_REPO);
assert.deepEqual(entries?.map(e => e.purl), ["pkg:npm/chalk@5.6.1", "pkg:npm/chalk@5.6.1@test"]);
assert.deepEqual(new Set(entries?.map(e => e.reason)), new Set(["pkg:npm/chalk@>=5.0.0 <6.0.0"]));
});

it("matches a wildcard query", () => {
const results = collector.searchByPurlsWithReasons(["pkg:npm/left-pad@*"]);
assert.deepEqual(results.get(FIXTURE_REPO)?.map(e => e.purl), ["pkg:npm/left-pad@1.3.0"]);
});

it("returns no results for a package that is not in the SBOM", () => {
assert.equal(collector.searchByPurlsWithReasons(["pkg:npm/not-in-the-sbom@1.0.0"]).size, 0);
});
});
Loading