Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/advisory/advisory-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export interface AdvisoryResult {
package: string;
version: string;
vulnerabilities: any[]; // refine later
/** A PyPI boundary that might still contain this version could not be read. */
unreadableBoundary?: boolean;
}

export interface AdvisorySource {
Expand Down
14 changes: 9 additions & 5 deletions src/advisory/local-advisory-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ export class LocalAdvisorySource implements AdvisorySource {
constructor(private readonly db: LocalAdvisoryDatabase) {}

queryBatch(packages: PackageRef[], _meta?: { batchId?: string }): Promise<AdvisoryResult[]> {
const results = packages.map(pkg => ({
package: pkg.name,
version: pkg.version,
vulnerabilities: this.db.findMatchingVulnerabilityIds(pkg).map(id => ({ id })),
}));
const results = packages.map(pkg => {
const match = this.db.matchPackage(pkg);
return {
package: pkg.name,
version: pkg.version,
vulnerabilities: match.ids.map(id => ({ id })),
...(match.unreadable ? { unreadableBoundary: true } : {}),
};
});

return Promise.resolve(results);
}
Expand Down
103 changes: 90 additions & 13 deletions src/advisory/local-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import type { OsvVuln, PackageRef } from "../types.js";
import { compareVersions, isPreReleaseVersion, looksLikeVersion, normalizeAdvisoryVersion } from "../utils/version.js";
import { compareVersions, isPep440Ecosystem, isPreReleaseVersion, looksLikeVersion, normalizeAdvisoryVersion } from "../utils/version.js";
import { readPep440Range } from "../utils/versions/osv-range.js";
import { pep440Scheme } from "../utils/versions/pep440.js";

type AdvisoryRangeRow = {
advisory_id: string;
Expand Down Expand Up @@ -195,20 +197,36 @@ export class LocalAdvisoryDatabase {
}

findMatchingVulnerabilityIds(pkg: PackageRef): string[] {
if (!looksLikeVersion(pkg.version)) {
return [];
return this.matchPackage(pkg).ids;
}

/**
* Ids whose stored window contains `pkg`, plus whether a PyPI boundary that
* could still contain it could not be read. An unreadable boundary does not
* abort the lookup and is not reported as a clean miss.
*/
matchPackage(pkg: PackageRef): { ids: string[]; unreadable: boolean } {
if (!looksLikeVersion(pkg.version, pkg.ecosystem)) {
// Semver returns no matches for a tag or a range. A PyPI version that
// cannot be read is inconclusive, not unaffected.
return { ids: [], unreadable: isPep440Ecosystem(pkg.ecosystem) };
}

const rows = this.findMatchingIdsStatement.all(pkg.ecosystem, pkg.name) as AdvisoryRangeRow[];

const ids = new Set<string>();
let unreadable = false;
for (const row of rows) {
if (versionMatchesRange(pkg.version, row)) {
if (isPep440Ecosystem(pkg.ecosystem)) {
const verdict = pep440RowVerdict(pkg.version, row);
if (verdict === "unreadable") unreadable = true;
else if (verdict === "match") ids.add(row.advisory_id);
} else if (versionMatchesRange(pkg.version, row, pkg.ecosystem)) {
ids.add(row.advisory_id);
}
}

return [...ids];
return { ids: [...ids], unreadable };
}
}

Expand Down Expand Up @@ -237,35 +255,59 @@ function releaseCore(version: string): string {
* negative for a false positive. No guard is needed for that: a release core
* never contains "-", so it can never equal a pre-release `introduced`.
*/
function versionAtOrAboveIntroduced(version: string, introduced: string): boolean {
if (compareVersions(version, introduced) >= 0) return true;
function versionAtOrAboveIntroduced(version: string, introduced: string, ecosystem: string): boolean {
// PEP 440 orders dev and pre-releases strictly, below the final release.
if (isPep440Ecosystem(ecosystem)) return pep440Scheme.atOrAboveIntroduced(version, introduced);
if (compareVersions(version, introduced, ecosystem) >= 0) return true;
// The pre-release relaxation is a semver rule.
return isPreReleaseVersion(version) && releaseCore(version) === introduced;
}

/** OSV range types whose boundaries are versions we can order. GIT uses commit hashes. */
const COMPARABLE_RANGE_TYPES = new Set(["ECOSYSTEM", "SEMVER"]);

function versionMatchesRange(version: string, row: AdvisoryRangeRow): boolean {
/**
* A PyPI row. The introduced check runs first, so a window that starts above
* the installed version is a miss even when `fixed` cannot be read. An
* unreadable bound that the version might still sit inside is inconclusive,
* not a match and not a throw.
*/
function pep440RowVerdict(version: string, row: AdvisoryRangeRow): "match" | "miss" | "unreadable" {
if (row.introduced && row.introduced !== "0") {
const introduced = normalizeAdvisoryVersion(row.introduced, "PyPI");
if (!introduced) return "unreadable";
if (!versionAtOrAboveIntroduced(version, introduced, "PyPI")) return "miss";
}
if (
(row.fixed && !normalizeAdvisoryVersion(row.fixed, "PyPI"))
|| (row.last_affected && !normalizeAdvisoryVersion(row.last_affected, "PyPI"))
) {
return "unreadable";
}
return versionMatchesRange(version, row, "PyPI") ? "match" : "miss";
}

function versionMatchesRange(version: string, row: AdvisoryRangeRow, ecosystem: string): boolean {
const introduced = row.introduced;
// Defence in depth behind the range-type filter, which stops these rows being
// written in the first place. A database built by an earlier version still
// holds them, and a bound that is not a version must never decide the answer
// by silently failing to exclude (#1191).
const fixed = normalizeAdvisoryVersion(row.fixed);
const lastAffected = normalizeAdvisoryVersion(row.last_affected);
const fixed = normalizeAdvisoryVersion(row.fixed, ecosystem);
const lastAffected = normalizeAdvisoryVersion(row.last_affected, ecosystem);

if (row.fixed && !fixed) return false;
if (row.last_affected && !lastAffected) return false;

if (introduced && introduced !== "0" && !versionAtOrAboveIntroduced(version, introduced)) {
if (introduced && introduced !== "0" && !versionAtOrAboveIntroduced(version, introduced, ecosystem)) {
return false;
}

if (fixed && compareVersions(version, fixed) >= 0) {
if (fixed && compareVersions(version, fixed, ecosystem) >= 0) {
return false;
}

if (lastAffected && compareVersions(version, lastAffected) > 0) {
if (lastAffected && compareVersions(version, lastAffected, ecosystem) > 0) {
return false;
}

Expand Down Expand Up @@ -303,6 +345,10 @@ function deriveAdvisoryPackageRows(vuln: OsvVuln): InsertableAdvisoryRange[] {
if (range.type && !COMPARABLE_RANGE_TYPES.has(range.type)) continue;

const events = range.events ?? [];
if (isPep440Ecosystem(ecosystem)) {
rows.push(...pep440PackageRows(vuln.id, ecosystem, packageName, events));
continue;
}
let introduced: string | null = null;

for (const event of events) {
Expand Down Expand Up @@ -347,3 +393,34 @@ function deriveAdvisoryPackageRows(vuln: OsvVuln): InsertableAdvisoryRange[] {

return rows;
}

function pep440PackageRows(
advisoryId: string,
ecosystem: string,
packageName: string,
events: ReadonlyArray<{ introduced?: string; fixed?: string; last_affected?: string }>,
): InsertableAdvisoryRange[] {
const read = readPep440Range(events);
const rows: InsertableAdvisoryRange[] = [];
for (const window of read.windows) {
rows.push({
advisory_id: advisoryId,
ecosystem,
package_name: packageName,
introduced: window.introduced,
fixed: window.fixed,
last_affected: window.lastAffected,
});
}
for (const boundary of read.unreadable) {
rows.push({
advisory_id: advisoryId,
ecosystem,
package_name: packageName,
introduced: boundary.field === "introduced" ? boundary.value : boundary.introduced,
fixed: boundary.field === "fixed" ? boundary.value : null,
last_affected: boundary.field === "last_affected" ? boundary.value : null,
});
}
return rows;
}
12 changes: 12 additions & 0 deletions src/output/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export function getRecommendedAction(finding: Finding): string {
: "This package has a malicious code advisory. Upgrade or remove the parent package that pulls it in.";
}

if (finding.advisoryBoundaryUnreadable) {
return `The advisory data for ${finding.pkg.name} has an unreadable PyPI boundary, so the fix is inconclusive.`;
}

// Prefer the registry-validated target over the raw advisory hint when
// available - they can disagree (the hint may not be published on npm or
// may itself be vulnerable), and the validated target is what the fix
Expand Down Expand Up @@ -156,6 +160,9 @@ export function summarizeRisk(finding: Finding): string {
? MAL_GIT_SOURCE_PINNED_MESSAGE
: MAL_GIT_SOURCE_FLOATING_MESSAGE;
}
if (finding.advisoryBoundaryUnreadable) {
return `The advisory data for ${finding.pkg.name} has an unreadable PyPI boundary, so the fix is inconclusive.`;
}
let risk = "";
if (finding.severity === "critical" && finding.relationship === "direct") {
risk = "Critical direct dependency. Prioritize this first because the project controls it directly.";
Expand Down Expand Up @@ -232,6 +239,10 @@ export function summarizeNextAction(finding: Finding): string {
: "This package has a malicious code advisory. Upgrade or remove the parent package that pulls it in.";
}

if (finding.advisoryBoundaryUnreadable) {
return `The advisory data for ${finding.pkg.name} has an unreadable PyPI boundary, so the fix is inconclusive.`;
}

if (isUnconfirmedFix(finding)) {
return `Verify a safe version of ${finding.pkg.name} before upgrading.`;
}
Expand Down Expand Up @@ -321,6 +332,7 @@ export function serializeFinding(finding: Finding, plan?: SuggestedFixCommandPla
maliciousGitSource: finding.maliciousGitSource ?? false,
maliciousGitSourcePinned: finding.maliciousGitSourcePinned ?? false,
unresolvedAdvisoryIds: finding.unresolvedAdvisoryIds ?? [],
...(finding.advisoryBoundaryUnreadable ? { advisoryBoundaryUnreadable: true } : {}),
vulnerabilities: finding.vulnerabilities.map(v => ({
id: v.id,
aliases: v.aliases ?? [],
Expand Down
2 changes: 2 additions & 0 deletions src/output/html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,8 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe
? `<span class="badge badge-git-source" title="MAL- advisory found but package resolves from a git source with a floating reference: verify the repository, org, and ref are safe">${escapeHtml(MAL_GIT_SOURCE_FLOATING_DISPLAY)}</span>`
: isMalicious
? `<span class="fix-hint none" title="Malicious code advisory: remove this package">⚠ Malicious</span>`
: finding.advisoryBoundaryUnreadable
? `<span class="fix-hint none" title="The advisory data has an unreadable PyPI boundary, so the fix is inconclusive.">unreadable advisory data</span>`
: `<span class="fix-hint none" title="No known fix: consider replacing this package">⚠ No fix</span>`;

const depPaths = renderDependencyPathsHtml(finding);
Expand Down
3 changes: 3 additions & 0 deletions src/output/printers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ function formatTargetDisplay(finding: Finding, fixVersion: string | null, isSkip
return chalk.yellow(finding.maliciousGitSourcePinned ? MAL_GIT_SOURCE_PINNED_DISPLAY : MAL_GIT_SOURCE_FLOATING_DISPLAY);
}
if (hasMaliciousAdvisory(finding.vulnerabilities)) return chalk.yellow("⚠ Malicious");
if (finding.advisoryBoundaryUnreadable) return chalk.yellow("⚠ unreadable advisory data");
return chalk.yellow("⚠ no fix");
}

Expand Down Expand Up @@ -917,6 +918,8 @@ export function printCompactOutput(
console.log(
` ${chalk.gray(`Fix: upgrade ${finding.recommendedParentUpgrade.package} to ${finding.recommendedParentUpgrade.targetVersion}`)}`,
);
} else if (finding.advisoryBoundaryUnreadable) {
console.log(` ${chalk.gray("Fix: unreadable advisory data; the fix is inconclusive")}`);
} else if (finding.firstFixedVersion) {
const displayFixVersion = finding.validatedFirstFixedVersion ?? finding.firstFixedVersion;
let action: string;
Expand Down
2 changes: 2 additions & 0 deletions src/scan/completeness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export function formatDiagnosticMessage(
return `${count} transitive remediation ${pluralize(count, "attempt")} failed: some fix recommendations may be missing.`;
case "CHAIN_RESOLUTION_FAILURE":
return `${count} chain resolution ${pluralize(count, "attempt")} failed: some validated upgrade chains may be missing.`;
case "UNREADABLE_PYPI_BOUNDARY":
return `${count} ${pluralize(count, "package")} ${pluralize(count, "has", "have")} an unreadable PyPI advisory boundary, so the fix is inconclusive.`;
default:
return `${count} unknown diagnostic occurred.`;
}
Expand Down
Loading
Loading