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
56 changes: 13 additions & 43 deletions src/remediation/npm-registry.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { compareVersions, isPreReleaseVersion, looksLikeVersion } from "../utils/version.js";
import type { CacheFile, OsvVuln } from "../types.js";
import type { OsvVuln } from "../types.js";
import type { DebugLogger } from "../output/debug.js";

function getNpmVersionCacheKey(packageName: string, version: string): string {
return `${packageName}@${version}`;
}

export type Packument = {
versions?: Record<string, {
dependencies?: Record<string, string>;
Expand All @@ -21,6 +17,7 @@ export type PublishedFixVersionResolution = {
candidatesChecked: number;
candidatesKnownVulnerable: number;
candidatesUnknownCoverage: number;
publishedAt: string | null;
};

const packumentCache = new Map<string, Promise<Packument | null>>();
Expand Down Expand Up @@ -109,44 +106,6 @@ export async function packageVersionExists(packageName: string, version: string)
return Boolean(packument.versions && version in packument.versions);
}

export async function resolveVersionPublishDate(
packageName: string,
version: string,
options?: {
cache?: CacheFile;
noCache?: boolean;
},
): Promise<{ publishedAt: string | null; cacheWritten: boolean }> {
const cacheKey = getNpmVersionCacheKey(packageName, version);

if (options?.cache && !options.noCache) {
const cached = options.cache.npmVersionEntries?.[cacheKey];
if (cached) {
return { publishedAt: cached.publishedAt, cacheWritten: false };
}
}

const packument = await fetchPackument(packageName);
if (!packument) return { publishedAt: null, cacheWritten: false };

const publishedAt = packument.time?.[version];
if (!publishedAt) return { publishedAt: null, cacheWritten: false };

let cacheWritten = false;
if (options?.cache) {
if (!options.cache.npmVersionEntries) {
options.cache.npmVersionEntries = {};
}
options.cache.npmVersionEntries[cacheKey] = {
publishedAt,
cachedAt: new Date().toISOString(),
};
cacheWritten = true;
}

return { publishedAt, cacheWritten };
}

export async function resolvePublishedFixVersion(
packageName: string,
fixedVersionHint: string,
Expand All @@ -160,6 +119,7 @@ export async function resolvePublishedFixVersion(
candidatesChecked: 0,
candidatesKnownVulnerable: 0,
candidatesUnknownCoverage: 0,
publishedAt: null,
};
}

Expand All @@ -175,6 +135,7 @@ export async function resolvePublishedFixVersion(
candidatesChecked: 0,
candidatesKnownVulnerable: 0,
candidatesUnknownCoverage: 0,
publishedAt: packument.time?.[fixedVersionHint] ?? null,
Comment thread
luojiyin1987 marked this conversation as resolved.
};
}

Expand All @@ -187,6 +148,7 @@ export async function resolvePublishedFixVersion(
candidatesChecked: 0,
candidatesKnownVulnerable: 0,
candidatesUnknownCoverage: 0,
publishedAt: packument.time?.[nearestPublishedVersion] ?? null,
};
}

Expand All @@ -197,6 +159,7 @@ export async function resolvePublishedFixVersion(
candidatesChecked: 0,
candidatesKnownVulnerable: 0,
candidatesUnknownCoverage: 0,
publishedAt: null,
};
}

Expand All @@ -213,6 +176,7 @@ export async function resolveLowestKnownNonVulnerableVersion(
candidatesChecked: 0,
candidatesKnownVulnerable: 0,
candidatesUnknownCoverage: 0,
publishedAt: null,
};
}

Expand All @@ -224,6 +188,7 @@ export async function resolveLowestKnownNonVulnerableVersion(
candidatesChecked: 0,
candidatesKnownVulnerable: 0,
candidatesUnknownCoverage: 0,
publishedAt: null,
};
}

Expand All @@ -236,6 +201,7 @@ export async function resolveLowestKnownNonVulnerableVersion(
candidatesChecked: 0,
candidatesKnownVulnerable: 0,
candidatesUnknownCoverage: 0,
publishedAt: null,
};
}

Expand All @@ -253,6 +219,7 @@ export async function resolveLowestKnownNonVulnerableVersion(
candidatesChecked: 0,
candidatesKnownVulnerable: 0,
candidatesUnknownCoverage: 0,
publishedAt: null,
};
}

Expand Down Expand Up @@ -293,6 +260,7 @@ export async function resolveLowestKnownNonVulnerableVersion(
candidatesChecked,
candidatesKnownVulnerable,
candidatesUnknownCoverage,
publishedAt: packument.time?.[candidate] ?? null,
};
}

Expand All @@ -304,6 +272,7 @@ export async function resolveLowestKnownNonVulnerableVersion(
candidatesChecked,
candidatesKnownVulnerable,
candidatesUnknownCoverage,
publishedAt: null,
};
}

Expand All @@ -314,6 +283,7 @@ export async function resolveLowestKnownNonVulnerableVersion(
candidatesChecked,
candidatesKnownVulnerable,
candidatesUnknownCoverage,
publishedAt: null,
};
}

Expand Down
52 changes: 25 additions & 27 deletions src/scanner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Finding, NpmTransitiveGraph, OsvVuln, PackageRef, ParsedOptions, ScanInput } from "./types.js";
import type { PublishedFixVersionResolution } from "./remediation/npm-registry.js";
import { chunk, unique, runWithConcurrency } from "./utils/array.js";
import { isPrivateRegistrySource, isGitSource, hasCommitShaPinning } from "./utils/advisory.js";
import { compareVersions, isPreReleaseVersion, looksLikeVersion } from "./utils/version.js";
Expand All @@ -16,7 +17,6 @@ import {
fetchPackument,
resolveLowestKnownNonVulnerableVersion,
resolvePublishedFixVersion,
resolveVersionPublishDate,
} from "./remediation/npm-registry.js";
import { countBySeverity } from "./utils/severity.js";
import { resolveNpmTransitiveRemediation, resolveTransitiveRemediationViaRegistry } from "./remediation/npm-transitive-resolution.js";
Expand Down Expand Up @@ -645,14 +645,33 @@ async function validateOneDirectFixTarget(
);
const fixedVersionHint = finding.firstFixedVersion;

const setPublishedDate = (
resolution: PublishedFixVersionResolution,
) => {
const date = resolution.publishedAt;
finding.fixVersionPublishedAt = date;
if (!date || !options?.cache) return;

const cacheKey = `${finding.pkg.name}@${finding.validatedFirstFixedVersion!}`;
const existing = options.cache.npmVersionEntries?.[cacheKey];
if (!options.noCache && existing?.publishedAt === date) return;

options.cache.npmVersionEntries ??= {};
options.cache.npmVersionEntries[cacheKey] = {
publishedAt: date,
cachedAt: new Date().toISOString(),
};
npmVersionCacheUpdated = true;
};

if (lowestKnownResolution.resolvedVersion) {
finding.validatedFirstFixedVersion = lowestKnownResolution.resolvedVersion;
finding.validatedTargetScannedVersions = lowestKnownResolution.candidatesChecked;
finding.validatedTargetKnownVulnerableVersions = lowestKnownResolution.candidatesKnownVulnerable;

if (!fixedVersionHint || fixedVersionHint === lowestKnownResolution.resolvedVersion) {
finding.fixVersionValidationNote = null;
npmVersionCacheUpdated = await attachFixVersionPublishDate(finding, options) || npmVersionCacheUpdated;
setPublishedDate(lowestKnownResolution);
logFixValidation();
return npmVersionCacheUpdated;
}
Expand All @@ -663,14 +682,15 @@ async function validateOneDirectFixTarget(
hintResolution.note
) {
finding.fixVersionValidationNote = hintResolution.note;
npmVersionCacheUpdated = await attachFixVersionPublishDate(finding, options) || npmVersionCacheUpdated;
// date is for the validated version, not the hint
setPublishedDate(lowestKnownResolution);
Comment thread
luojiyin1987 marked this conversation as resolved.
logFixValidation();
return npmVersionCacheUpdated;
}

finding.fixVersionValidationNote =
`Advisory fixed-version hint ${fixedVersionHint} is still known vulnerable for ${finding.pkg.name}; scanned ${lowestKnownResolution.candidatesChecked} package ${pluralize(lowestKnownResolution.candidatesChecked, "version")} above current version (${lowestKnownResolution.candidatesKnownVulnerable} still known vulnerable); using lowest known non-vulnerable version ${lowestKnownResolution.resolvedVersion}.`;
npmVersionCacheUpdated = await attachFixVersionPublishDate(finding, options) || npmVersionCacheUpdated;
setPublishedDate(lowestKnownResolution);
logFixValidation();
return npmVersionCacheUpdated;
}
Expand All @@ -690,33 +710,11 @@ async function validateOneDirectFixTarget(
finding.fixVersionValidationNote = resolution.note ?? lowestKnownResolution.note;
finding.validatedTargetScannedVersions = null;
finding.validatedTargetKnownVulnerableVersions = null;
npmVersionCacheUpdated = await attachFixVersionPublishDate(finding, options) || npmVersionCacheUpdated;
setPublishedDate(resolution);
logFixValidation();
return npmVersionCacheUpdated;
}

async function attachFixVersionPublishDate(
finding: Finding,
options?: {
cache: ReturnType<typeof loadCache>;
noCache?: boolean;
},
): Promise<boolean> {
const version = finding.validatedFirstFixedVersion;
if (!version) {
finding.fixVersionPublishedAt = null;
return false;
}

const { publishedAt, cacheWritten } = await resolveVersionPublishDate(
finding.pkg.name,
version,
options,
);
finding.fixVersionPublishedAt = publishedAt;
return cacheWritten;
}

function isAdvisoryDbStale(metadata: AdvisoryDbMetadata): boolean {
if (!metadata.lastSyncAt) {
return true;
Expand Down
29 changes: 27 additions & 2 deletions tests/lowest-safe-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import type { OsvVuln } from "../src/types.js";
import {
clearPackumentCache,
resolveLowestKnownNonVulnerableVersion,
resolvePublishedFixVersion,
} from "../src/remediation/npm-registry.js";

const fetchMock = jest.fn();
global.fetch = fetchMock as unknown as typeof fetch;

function mockPackument(versions: string[]) {
function mockPackument(versions: string[], time: Record<string, string> = {}) {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
versions: Object.fromEntries(versions.map(version => [version, {}])),
time,
}),
});
}
Expand All @@ -39,7 +41,10 @@ describe("resolveLowestKnownNonVulnerableVersion", () => {
});

it("skips vulnerable intermediate versions and returns the lowest known non-vulnerable target", async () => {
mockPackument(["1.0.0", "1.0.1", "1.0.2", "1.0.3", "1.0.4"]);
mockPackument(
["1.0.0", "1.0.1", "1.0.2", "1.0.3", "1.0.4"],
{ "1.0.4": "2026-01-04T00:00:00.000Z" },
);
const vulnerabilities: OsvVuln[] = [
createVuln("OSV-1", [{ introduced: "0" }, { fixed: "1.0.2" }]),
createVuln("OSV-2", [{ introduced: "1.0.2" }, { fixed: "1.0.4" }]),
Expand All @@ -53,6 +58,7 @@ describe("resolveLowestKnownNonVulnerableVersion", () => {
expect(result.candidatesChecked).toBe(4);
expect(result.candidatesKnownVulnerable).toBe(3);
expect(result.candidatesUnknownCoverage).toBe(0);
expect(result.publishedAt).toBe("2026-01-04T00:00:00.000Z");
});

it("handles overlapping advisory ranges by validating against all advisories", async () => {
Expand Down Expand Up @@ -99,3 +105,22 @@ describe("resolveLowestKnownNonVulnerableVersion", () => {
expect(result.candidatesUnknownCoverage).toBe(2);
});
});

describe("resolvePublishedFixVersion", () => {
beforeEach(() => {
fetchMock.mockReset();
clearPackumentCache();
});

it("includes the publish date when resolving an exact published fix version", async () => {
mockPackument(
["1.0.0", "1.0.1"],
{ "1.0.1": "2026-01-01T00:00:00.000Z" },
);

const result = await resolvePublishedFixVersion("tar", "1.0.1");

expect(result.resolvedVersion).toBe("1.0.1");
expect(result.publishedAt).toBe("2026-01-01T00:00:00.000Z");
});
});