Skip to content

Commit 7f698db

Browse files
authored
Merge pull request #84 from sonukapoor/feature/issue-79-advisory-db-freshness
[Enhancement] Add advisory DB freshness metadata and stale warnings
2 parents 3ca68bb + 1b34f59 commit 7f698db

7 files changed

Lines changed: 186 additions & 2 deletions

File tree

src/advisory/local-db.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ type AdvisoryRangeRow = {
1111
last_affected: string | null;
1212
};
1313

14+
export type AdvisoryDbMetadata = {
15+
lastSyncAt: string | null;
16+
sourceUrl: string | null;
17+
};
18+
1419
const SCHEMA_SQL = `
1520
CREATE TABLE IF NOT EXISTS advisories (
1621
id TEXT PRIMARY KEY,
@@ -35,6 +40,12 @@ const SCHEMA_SQL = `
3540
3641
CREATE INDEX IF NOT EXISTS idx_advisory_packages_advisory
3742
ON advisory_packages (advisory_id);
43+
44+
CREATE TABLE IF NOT EXISTS advisory_db_metadata (
45+
id INTEGER PRIMARY KEY CHECK (id = 1),
46+
last_sync_at TEXT,
47+
source_url TEXT
48+
);
3849
`;
3950

4051
export class LocalAdvisoryDatabase {
@@ -59,6 +70,32 @@ export class LocalAdvisoryDatabase {
5970
this.db.close();
6071
}
6172

73+
setMetadata(metadata: AdvisoryDbMetadata): void {
74+
this.db.prepare(`
75+
INSERT INTO advisory_db_metadata (id, last_sync_at, source_url)
76+
VALUES (1, @last_sync_at, @source_url)
77+
ON CONFLICT(id) DO UPDATE SET
78+
last_sync_at = excluded.last_sync_at,
79+
source_url = excluded.source_url
80+
`).run({
81+
last_sync_at: metadata.lastSyncAt,
82+
source_url: metadata.sourceUrl,
83+
});
84+
}
85+
86+
getMetadata(): AdvisoryDbMetadata {
87+
const row = this.db.prepare(`
88+
SELECT last_sync_at, source_url
89+
FROM advisory_db_metadata
90+
WHERE id = 1
91+
`).get() as { last_sync_at: string | null; source_url: string | null } | undefined;
92+
93+
return {
94+
lastSyncAt: row?.last_sync_at ?? null,
95+
sourceUrl: row?.source_url ?? null,
96+
};
97+
}
98+
6299
upsertVulnerability(vuln: OsvVuln): void {
63100
const advisoryRows = deriveAdvisoryPackageRows(vuln);
64101
const advisoryJson = JSON.stringify(vuln);

src/advisory/osv-sync.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { OsvVuln } from "../types.js";
66
import { LocalAdvisoryDatabase } from "./local-db.js";
77

88
const DEFAULT_OSV_NPM_DUMP_URL = "https://storage.googleapis.com/osv-vulnerabilities/npm/all.zip";
9+
export const ADVISORY_DB_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
910

1011
export type SyncOsvAdvisoriesOptions = {
1112
outputPath?: string;
@@ -165,6 +166,11 @@ export async function syncOsvAdvisories(
165166
}
166167
}
167168

169+
db.setMetadata({
170+
lastSyncAt: new Date().toISOString(),
171+
sourceUrl,
172+
});
173+
168174
onProgress?.({
169175
phase: "complete",
170176
advisoryCount,

src/index.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,24 @@ async function main() {
8989
}
9090

9191
let advisorySourceLine: string;
92+
let advisoryDbFreshnessLine: string | null = null;
93+
let advisoryDbWarning: string | null = null;
9294
try {
9395
const advisorySource = createAdvisorySource({
9496
osvUrl: options.osvUrl,
9597
offline: options.offline,
9698
offlineDb: options.offlineDb,
9799
});
98100
advisorySourceLine = advisorySource.sourceLabel;
101+
if (advisorySource.offline) {
102+
const metadata = advisorySource.advisoryDbMetadata;
103+
advisoryDbFreshnessLine = formatAdvisoryDbFreshness(metadata?.lastSyncAt ?? null);
104+
if (advisorySource.advisoryDbIsStale) {
105+
advisoryDbWarning = metadata?.lastSyncAt
106+
? "The local advisory DB appears stale. Re-run `cve-lite advisories sync` to refresh it."
107+
: "The local advisory DB has no recorded sync timestamp. Re-run `cve-lite advisories sync` to refresh it.";
108+
}
109+
}
99110
advisorySource.cleanup();
100111
} catch (error) {
101112
if (options.offline || options.offlineDb) {
@@ -108,6 +119,12 @@ async function main() {
108119
console.log(chalk.gray("Offline mode:") + " " + chalk.yellow("enabled") + " " + chalk.gray("(no external advisory calls will be made)"));
109120
}
110121
console.log(`${chalk.gray("Advisory source:")} ${formatAdvisorySourceLine(advisorySourceLine)}`);
122+
if (advisoryDbFreshnessLine) {
123+
console.log(`${chalk.gray("Advisory DB freshness:")} ${advisoryDbFreshnessLine}`);
124+
}
125+
if (advisoryDbWarning) {
126+
logWarn(advisoryDbWarning, options);
127+
}
111128

112129
const scanInput = loadPackages(projectPath, !!options.prodOnly, searchDepth);
113130
const packages = scanInput.packages;
@@ -195,3 +212,38 @@ function formatAdvisorySourceLine(sourceLabel: string): string {
195212

196213
return `${match[1]} (${chalk.cyan(match[2])})`;
197214
}
215+
216+
function formatAdvisoryDbFreshness(lastSyncAt: string | null): string {
217+
if (!lastSyncAt) {
218+
return chalk.yellow("unknown");
219+
}
220+
221+
const timestamp = Date.parse(lastSyncAt);
222+
if (Number.isNaN(timestamp)) {
223+
return chalk.yellow("unknown");
224+
}
225+
226+
return `${relativeAge(timestamp)} ${chalk.gray(`(${lastSyncAt})`)}`;
227+
}
228+
229+
function relativeAge(timestamp: number): string {
230+
const deltaMs = Math.max(0, Date.now() - timestamp);
231+
const minute = 60 * 1000;
232+
const hour = 60 * minute;
233+
const day = 24 * hour;
234+
235+
if (deltaMs < minute) {
236+
return "just synced";
237+
}
238+
if (deltaMs < hour) {
239+
const minutes = Math.floor(deltaMs / minute);
240+
return `synced ${minutes} minute${minutes === 1 ? "" : "s"} ago`;
241+
}
242+
if (deltaMs < day) {
243+
const hours = Math.floor(deltaMs / hour);
244+
return `synced ${hours} hour${hours === 1 ? "" : "s"} ago`;
245+
}
246+
247+
const days = Math.floor(deltaMs / day);
248+
return `synced ${days} day${days === 1 ? "" : "s"} ago`;
249+
}

src/scanner.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import { createSpinner } from "./output/spinner.js";
77
import { OsvAdvisorySource } from "./advisory/osv-advisory-source.js";
88
import { AdvisorySource } from "./advisory/advisory-source.js";
99
import { LocalAdvisorySource } from "./advisory/local-advisory-source.js";
10-
import { LocalAdvisoryDatabase } from "./advisory/local-db.js";
11-
import { getDefaultAdvisoryDbPath } from "./advisory/osv-sync.js";
10+
import { AdvisoryDbMetadata, LocalAdvisoryDatabase } from "./advisory/local-db.js";
11+
import { ADVISORY_DB_STALE_AFTER_MS, getDefaultAdvisoryDbPath } from "./advisory/osv-sync.js";
1212
import { resolveRecommendedParentUpgrade } from "./remediation/parent-upgrade.js";
1313

1414
type AdvisorySourceContext = {
1515
advisorySource: AdvisorySource;
1616
offline: boolean;
1717
sourceLabel: string;
18+
advisoryDbMetadata: AdvisoryDbMetadata | null;
19+
advisoryDbIsStale: boolean;
1820
cleanup: () => void;
1921
};
2022

@@ -32,6 +34,8 @@ export function createAdvisorySource(options?: {
3234
advisorySource: new LocalAdvisorySource(db),
3335
offline: true,
3436
sourceLabel: `local advisory database (${dbPath})`,
37+
advisoryDbMetadata: db.getMetadata(),
38+
advisoryDbIsStale: isAdvisoryDbStale(db.getMetadata()),
3539
cleanup: () => db.close(),
3640
};
3741
}
@@ -42,6 +46,8 @@ export function createAdvisorySource(options?: {
4246
sourceLabel: options?.osvUrl
4347
? `custom OSV endpoint (${options.osvUrl})`
4448
: "OSV (https://api.osv.dev)",
49+
advisoryDbMetadata: null,
50+
advisoryDbIsStale: false,
4551
cleanup: () => {},
4652
};
4753
}
@@ -226,6 +232,19 @@ export async function scanPackages(
226232
}
227233
}
228234

235+
function isAdvisoryDbStale(metadata: AdvisoryDbMetadata): boolean {
236+
if (!metadata.lastSyncAt) {
237+
return true;
238+
}
239+
240+
const timestamp = Date.parse(metadata.lastSyncAt);
241+
if (Number.isNaN(timestamp)) {
242+
return true;
243+
}
244+
245+
return Date.now() - timestamp > ADVISORY_DB_STALE_AFTER_MS;
246+
}
247+
229248
function classifyRelationship(paths: string[][]): "direct" | "transitive" | "unknown" {
230249
if (paths.length === 0) return "unknown";
231250
const shortest = Math.min(...paths.map(p => p.length));

tests/cli-integration.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ jest.unstable_mockModule("../src/scanner.js", () => ({
5353
: options?.osvUrl
5454
? `custom OSV endpoint (${options.osvUrl})`
5555
: "OSV (https://api.osv.dev)",
56+
advisoryDbMetadata: options?.offline || options?.offlineDb
57+
? { lastSyncAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), sourceUrl: "https://storage.googleapis.com/osv-vulnerabilities/npm/all.zip" }
58+
: null,
59+
advisoryDbIsStale: false,
5660
cleanup: jest.fn(),
5761
})),
5862
}));
@@ -321,6 +325,47 @@ describe("CLI integration", () => {
321325
expect(stripAnsi(result.stdout[0] ?? "")).toContain("Offline mode: enabled");
322326
expect(stripAnsi(result.stdout[1] ?? "")).toContain("Advisory source: local advisory database");
323327
expect(stripAnsi(result.stdout[1] ?? "")).toContain("/tmp/advisories.db");
328+
expect(stripAnsi(result.stdout[2] ?? "")).toContain("Advisory DB freshness: synced");
329+
});
330+
331+
it("warns when the local advisory DB appears stale", async () => {
332+
const createAdvisorySourceMock = (await import("../src/scanner.js")).createAdvisorySource as jest.Mock;
333+
createAdvisorySourceMock.mockReturnValueOnce({
334+
advisorySource: { queryBatch: jest.fn(), getVuln: jest.fn() },
335+
offline: true,
336+
sourceLabel: "local advisory database (/tmp/advisories.db)",
337+
advisoryDbMetadata: {
338+
lastSyncAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
339+
sourceUrl: "https://storage.googleapis.com/osv-vulnerabilities/npm/all.zip",
340+
},
341+
advisoryDbIsStale: true,
342+
cleanup: jest.fn(),
343+
});
344+
345+
parseArgsMock.mockReturnValue({
346+
command: "scan",
347+
options: {
348+
json: true,
349+
offline: true,
350+
offlineDb: "/tmp/advisories.db",
351+
failOn: "critical",
352+
batchSize: "100",
353+
searchDepth: "4",
354+
minSeverity: "medium",
355+
},
356+
projectArg: ".",
357+
});
358+
loadPackagesMock.mockReturnValue(createScanInput({
359+
packages: [{ name: "lodash", version: "4.17.21", ecosystem: "npm", paths: [["project", "lodash"]] }],
360+
}));
361+
362+
const result = await runIndexModule();
363+
364+
expect(result.exitCode).toBe(0);
365+
expect(logWarnMock).toHaveBeenCalledWith(
366+
"The local advisory DB appears stale. Re-run `cve-lite advisories sync` to refresh it.",
367+
expect.anything(),
368+
);
324369
});
325370

326371
it("routes advisories sync through the sync module and exits successfully", async () => {

tests/local-advisory-source.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,24 @@ describe("LocalAdvisorySource", () => {
160160
cleanupDbPath(dbPath);
161161
}
162162
});
163+
164+
it("stores and returns advisory DB metadata", () => {
165+
const dbPath = createTempDbPath();
166+
const db = new LocalAdvisoryDatabase(dbPath);
167+
168+
try {
169+
db.setMetadata({
170+
lastSyncAt: "2026-04-04T00:00:00.000Z",
171+
sourceUrl: "https://storage.googleapis.com/osv-vulnerabilities/npm/all.zip",
172+
});
173+
174+
expect(db.getMetadata()).toEqual({
175+
lastSyncAt: "2026-04-04T00:00:00.000Z",
176+
sourceUrl: "https://storage.googleapis.com/osv-vulnerabilities/npm/all.zip",
177+
});
178+
} finally {
179+
db.close();
180+
cleanupDbPath(dbPath);
181+
}
182+
});
163183
});

tests/osv-sync.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ describe("syncOsvAdvisories", () => {
8686

8787
const db = new LocalAdvisoryDatabase(dbPath, { readonly: true });
8888
try {
89+
expect(db.getMetadata()).toMatchObject({
90+
lastSyncAt: expect.any(String),
91+
sourceUrl: "https://mirror.example/npm/all.zip",
92+
});
8993
expect(db.getVulnerability("OSV-1")).toMatchObject({ id: "OSV-1" });
9094
expect(db.findMatchingVulnerabilityIds({ ecosystem: "npm", name: "lodash", version: "4.17.20" })).toEqual(["OSV-1"]);
9195
expect(db.findMatchingVulnerabilityIds({ ecosystem: "npm", name: "lodash", version: "4.17.21" })).toEqual([]);
@@ -128,6 +132,7 @@ describe("syncOsvAdvisories", () => {
128132

129133
const db = new LocalAdvisoryDatabase(dbPath, { readonly: true });
130134
try {
135+
expect(db.getMetadata().lastSyncAt).toEqual(expect.any(String));
131136
expect(db.getVulnerability("OSV-3")).toMatchObject({ id: "OSV-3" });
132137
} finally {
133138
db.close();

0 commit comments

Comments
 (0)