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
4 changes: 3 additions & 1 deletion docs/audit-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,10 @@ One per planned remediation. CVE Lite emits a fix plan rather than applying upgr
|---|---|---|
| `package` | string | package to upgrade |
| `fromVersion` | string | current version, or "unknown" |
| `toVersion` | string | recommended target |
| `toVersion` | string | recommended target of `package` |
| `mechanism` | string | direct-upgrade, parent-upgrade, transitive-resolution |
| `childPackage` | string? | parent-update only: the child refreshed through the parent |
| `childTargetVersion` | string? | parent-update only: the child's destination version |

### `oa.detected`

Expand Down
28 changes: 28 additions & 0 deletions src/audit-log/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ export interface CveFixApplied extends AuditEventBase {
fromVersion: string;
toVersion: string;
mechanism: string; // e.g., "direct-upgrade" | "parent-upgrade" | "transitive-resolution"
// parent-update only, when `package` is the parent: the child being refreshed
// through the parent's existing range. fromVersion/toVersion stay the parent's.
childPackage?: string;
childTargetVersion?: string;
}


/** Fields for a cve.fix.applied audit event from a suggested-fix target. */
export function buildCveFixAppliedEvent(params: {
ts: string;
package: string;
fromVersion: string;
toVersion: string;
mechanism: string;
childPackage?: string;
childTargetVersion?: string;
}): CveFixApplied {
return {
ts: params.ts,
type: "cve.fix.applied",
schemaVersion: AUDIT_LOG_SCHEMA_VERSION,
package: params.package,
fromVersion: params.fromVersion,
toVersion: params.toVersion,
mechanism: params.mechanism,
...(params.childPackage ? { childPackage: params.childPackage } : {}),
...(params.childTargetVersion ? { childTargetVersion: params.childTargetVersion } : {}),
};
}

export interface OaDetected extends AuditEventBase {
Expand Down
5 changes: 4 additions & 1 deletion src/output/cyclonedx.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import type { Finding, PackageRef, SeverityLabel } from "../types.js";
import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js";
import { findSuggestedCommandForFinding } from "../remediation/fix-commands.js";
import { findFixTargetForFinding, findSuggestedCommandForFinding } from "../remediation/fix-commands.js";
import { getRecommendedAction } from "./formatters.js";
import { getCliVersion } from "../utils/version-info.js";
import { buildPurl } from "../utils/purl.js";
Expand Down Expand Up @@ -130,6 +130,9 @@ export function buildCycloneDxBom(
addProperty("fix-version-published-at", finding.fixVersionPublishedAt);
addProperty("versions-scanned", finding.validatedTargetScannedVersions);
addProperty("versions-still-vulnerable", finding.validatedTargetKnownVulnerableVersions);
const planTarget = plan ? findFixTargetForFinding(plan, finding) : null;
addProperty("child-package", planTarget?.childPackage);
addProperty("child-target-version", planTarget?.childTargetVersion);

vulnerabilities.push({
id: cveId,
Expand Down
6 changes: 5 additions & 1 deletion src/output/html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { OverrideFinding } from "../overrides/types.js";
import type { MaintenanceFinding } from "../maintenance/types.js";
import type { LicenseFinding } from "../licenses/types.js";
import { UNVERIFIED_PARENT_UPGRADE_NOTE, type SuggestedFixCommandPlan } from "../remediation/fix-commands.js";
import { formatParentUpdateChildNote } from "../remediation/parent-update-child-note.js";
import { getCompletenessImpact } from "../scan/completeness.js";

export type SerializedFinding = ReturnType<typeof serializeHtmlFinding>;
Expand Down Expand Up @@ -969,7 +970,10 @@ export function renderFixPlan(plan: SuggestedFixCommandPlan | null): string {
const displayVersion = t.targetVersion;
const isMajor = t.currentVersion ? isBreakingUpgrade(t.currentVersion, t.targetVersion) : false;
const breakBadge = isMajor ? ` <span class="break-badge">⚠ breaking</span>` : "";
const versionHtml = t.currentVersion
const childNote = formatParentUpdateChildNote(t);
const versionHtml = childNote
? `<span class="fix-target-pkg">${escapeHtml(t.package)}${t.currentVersion ? `@${escapeHtml(t.currentVersion)}` : ""}</span><span class="fix-target-note">${escapeHtml(childNote)}</span>`
: t.currentVersion
? `<span class="fix-target-pkg">${escapeHtml(t.package)}@${escapeHtml(t.currentVersion)}</span><span class="fix-target-arrow">→</span><span class="fix-target-to">${escapeHtml(displayVersion)}</span>`
: `<span class="fix-target-pkg">${escapeHtml(t.package)}</span><span class="fix-target-arrow">→</span><span class="fix-target-to">${escapeHtml(displayVersion)}</span>`;
const statsHtml = t.scannedVersions != null
Expand Down
26 changes: 22 additions & 4 deletions src/output/printers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Finding, ScanCompleteness, ScanInput, SeverityLabel } from "../types.js";
import type { LicenseFinding } from "../licenses/types.js";
import { chalk, stripAnsi } from "../utils/chalk.js";
import { buildSuggestedFixCommandPlan, findFixTargetForFinding, UNVERIFIED_PARENT_UPGRADE_NOTE, type SuggestedFixTarget, type SuggestedFixCommandPlan } from "../remediation/fix-commands.js";
import { buildSuggestedFixCommandPlan, planVersionForFinding, UNVERIFIED_PARENT_UPGRADE_NOTE, type SuggestedFixTarget, type SuggestedFixCommandPlan } from "../remediation/fix-commands.js";
import { formatParentUpdateChildNote } from "../remediation/parent-update-child-note.js";
import { isBreakingUpgrade } from "../utils/version.js";
import { getPrimaryParent } from "../utils/finding.js";
import {
Expand Down Expand Up @@ -500,8 +501,9 @@ function findingToTableRow(
// Prefer the version the fix plan will actually install. A package that is both
// a direct finding and the parent of other findings needs a higher version than
// its own advisory requires, and showing the lower one here contradicted the fix
// command sitting a few lines above.
const planTarget = plan ? findFixTargetForFinding(plan, finding)?.targetVersion : undefined;
// command sitting a few lines above. For parent-update this is the child's
// destination, not the parent's unchanged version stored on targetVersion.
const planTarget = plan ? planVersionForFinding(plan, finding) : undefined;
const ownFixVersion = finding.validatedFirstFixedVersion ?? finding.firstFixedVersion;
const fixVersion = planTarget ?? ownFixVersion ?? null;
return {
Expand Down Expand Up @@ -544,6 +546,9 @@ type FixTargetLike = {
package: string;
currentVersion?: string;
targetVersion: string;
kind?: SuggestedFixTarget["kind"];
childPackage?: string;
childTargetVersion?: string;
scannedVersions?: number | null;
knownVulnerableVersions?: number | null;
advisoryVersion?: string | null;
Expand All @@ -552,6 +557,19 @@ type FixTargetLike = {
usage?: { imported: boolean; files: string[] } | null;
};

function fixTargetContext(target: FixTargetLike): string | null {
const childNote = formatParentUpdateChildNote(target);
const reason = target.reason ?? target.adjustmentNote ?? null;
if (childNote && reason) {
if (target.childPackage && target.childTargetVersion
&& reason.includes(`${target.childPackage}@${target.childTargetVersion}`)) {
return reason;
}
return `${childNote}. ${reason}`;
}
return childNote ?? reason;
}

function fixTargetToTableRow(target: FixTargetLike, context?: FixRowContext): TableRow {
return {
package: target.package,
Expand All @@ -571,7 +589,7 @@ function fixTargetToTableRow(target: FixTargetLike, context?: FixRowContext): Ta
epss: context?.epss ?? null,
signal: context?.prioritySignal ?? null,
ids: context?.ids,
context: target.reason ?? target.adjustmentNote ?? null,
context: fixTargetContext(target),
};
}

Expand Down
7 changes: 6 additions & 1 deletion src/output/sarif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ type SarifResult = {
fixVersionPublishedAt?: string;
versionsScanned?: number;
versionsStillVulnerable?: number;
childPackage?: string;
childTargetVersion?: string;
};
};

Expand Down Expand Up @@ -170,7 +172,8 @@ export function buildSarifOutput(
// If the recommended fix for this finding lands on a version inside the
// project's configured release cooldown, surface that on the result so it
// reaches Code Scanning (message text for humans, property for tooling).
const cooldown = plan ? findFixTargetForFinding(plan, finding)?.cooldownWarning ?? null : null;
const planTarget = plan ? findFixTargetForFinding(plan, finding) : null;
const cooldown = planTarget?.cooldownWarning ?? null;

const location: SarifLocation = {
physicalLocation: {
Expand Down Expand Up @@ -216,6 +219,8 @@ export function buildSarifOutput(
if (finding.validatedTargetKnownVulnerableVersions != null) {
resultProperties.versionsStillVulnerable = finding.validatedTargetKnownVulnerableVersions;
}
if (planTarget?.childPackage) resultProperties.childPackage = planTarget.childPackage;
if (planTarget?.childTargetVersion) resultProperties.childTargetVersion = planTarget.childTargetVersion;

results.push({
ruleId,
Expand Down
130 changes: 99 additions & 31 deletions src/remediation/fix-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ export type SuggestedFixPackageManager = "npm" | "pnpm" | "yarn" | "bun";
export type SuggestedFixTarget = {
package: string;
currentVersion?: string;
// Version of `package` after the fix. For parent-update this is the parent's
// own version (unchanged): the target refreshes a child through the parent's
// existing range rather than upgrading the parent. See childPackage /
// childTargetVersion for the child being refreshed.
targetVersion: string;
// parent-update only, and only when `package` is the parent rather than the
// child itself: the finding.pkg being refreshed through the parent's range.
childPackage?: string;
childTargetVersion?: string;
scannedVersions?: number | null;
knownVulnerableVersions?: number | null;
// The version the advisory itself suggested, before registry validation moved
Expand Down Expand Up @@ -249,22 +257,14 @@ export function buildSuggestedFixCommandPlan(
finding.relationship === "transitive" &&
finding.recommendedNpmTransitiveRemediation?.kind === "update-parent-within-range"
) {
upsertTarget(targetsByKey, finding, {
upsertTarget(targetsByKey, finding, parentUpdateTarget(finding, urgent, {
package: finding.recommendedNpmTransitiveRemediation.package,
currentVersion: finding.recommendedNpmTransitiveRemediation.currentVersion,
targetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion,
scannedVersions: null,
knownVulnerableVersions: null,
kind: "parent-update",
urgent,
severity: finding.severity,
adjusted: false,
adjustmentNote: null,
childTargetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion,
reason: finding.recommendedNpmTransitiveRemediation.reason,
command: buildNpmUpdateCommand(finding.recommendedNpmTransitiveRemediation.package, finding.recommendedNpmTransitiveRemediation.workspaces),
usage: finding.usage ?? null,
isDev: devLookup.get(`${finding.recommendedNpmTransitiveRemediation.package}@${finding.recommendedNpmTransitiveRemediation.currentVersion}`) ?? false,
});
}));
continue;
}

Expand Down Expand Up @@ -374,21 +374,13 @@ export function buildSuggestedFixCommandPlan(
: packageManager === "yarn"
? `yarn upgrade ${finding.pkg.name}`
: `bun update ${finding.pkg.name}`;
upsertTarget(targetsByKey, finding, {
upsertTarget(targetsByKey, finding, parentUpdateTarget(finding, urgent, {
package: finding.pkg.name,
currentVersion: finding.pkg.version,
targetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion,
scannedVersions: null,
knownVulnerableVersions: null,
kind: "parent-update",
urgent,
severity: finding.severity,
adjusted: false,
adjustmentNote: null,
childTargetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion,
reason: `${finding.pkg.name}@${finding.pkg.version} can be refreshed to ${finding.recommendedNpmTransitiveRemediation.targetChildVersion}+ — no parent upgrade needed.`,
command: lockfileRefreshCommand,
usage: finding.usage ?? null,
});
}));
continue;
}

Expand Down Expand Up @@ -564,11 +556,18 @@ export function findFixTargetForFinding(
): SuggestedFixTarget | null {
const target = plan.targets.find(item => {
if (finding.recommendedNpmTransitiveRemediation?.kind === "update-parent-within-range") {
return (
item.kind === "parent-update" &&
item.targetVersion === finding.recommendedNpmTransitiveRemediation.targetChildVersion &&
(item.package === finding.recommendedNpmTransitiveRemediation.package || item.package === finding.pkg.name)
);
const rem = finding.recommendedNpmTransitiveRemediation;
if (item.kind !== "parent-update") return false;
if (item.package !== rem.package && item.package !== finding.pkg.name) return false;
// Parent-named parent-update: match only on child identity. Falling
// through to targetVersion compares the parent's version to the child's
// destination and can attach the wrong sibling.
if (item.childPackage) {
return item.childPackage === finding.pkg.name
&& item.childTargetVersion === rem.targetChildVersion;
}
// Self-refresh / lockfile refresh: destination stays on targetVersion.
return item.targetVersion === rem.targetChildVersion;
}

if (finding.recommendedParentUpgrade) {
Expand Down Expand Up @@ -654,10 +653,11 @@ function packageManagerSourceLabel(scanInput: ScanInput): string {
// Install targets (direct / parent-upgrade) all mean "install <package>@<targetVersion>",
// so findings that land on the same package merge into one target and the higher
// version wins. A parent-update target means something else: "refresh <vulnerable
// child> within <package>'s declared range", and its targetVersion is the CHILD's
// version, not <package>'s. Merging it with an install target for the same package
// compares versions of two different packages, and the child's version can end up
// pinned onto the parent (`npm install axios@4.0.6`, where 4.0.6 is form-data's
// child> within <package>'s declared range". Its targetVersion is <package>'s own
// version (unchanged); the child's identity lives on childPackage / childTargetVersion
// when package is the parent. Merging it with an install target for the same package
// used to compare versions of two different packages, and the child's version could
// end up pinned onto the parent (`npm install axios@4.0.6`, where 4.0.6 is form-data's
// version - #1007). Keep parent-update targets in their own keyspace, per child, so
// they only ever merge with a refresh of the same child through the same package.
function targetKey(finding: Finding, target: SuggestedFixTarget): string {
Expand All @@ -666,6 +666,63 @@ function targetKey(finding: Finding, target: SuggestedFixTarget): string {
: `install:${target.package}`;
}

// Build a parent-update target whose targetVersion always belongs to `package`.
// When the named package is the parent of the finding, the child's version moves
// onto childPackage / childTargetVersion so renderers cannot treat it as an
// upgrade of the parent (#1152). When the named package is the child itself
// (deep-chain npm update, pnpm/yarn/bun lockfile refresh), targetVersion stays
// the child's destination.
function parentUpdateTarget(
finding: Finding,
urgent: boolean,
args: {
package: string;
currentVersion: string;
childTargetVersion: string;
reason: string;
command: string;
isDev?: boolean;
},
): SuggestedFixTarget {
const namesParent = args.package !== finding.pkg.name;
return {
package: args.package,
currentVersion: args.currentVersion,
targetVersion: namesParent ? args.currentVersion : args.childTargetVersion,
childPackage: namesParent ? finding.pkg.name : undefined,
childTargetVersion: namesParent ? args.childTargetVersion : undefined,
scannedVersions: null,
knownVulnerableVersions: null,
kind: "parent-update",
urgent,
severity: finding.severity,
adjusted: false,
adjustmentNote: null,
reason: args.reason,
command: args.command,
usage: finding.usage ?? null,
isDev: args.isDev,
};
}


/**
* The version of finding.pkg that the plan is aiming for.
* A parent-update that names a different package stores the child's destination
* on childTargetVersion, not on targetVersion (which is the parent's).
*/
export function planVersionForFinding(
plan: SuggestedFixCommandPlan,
finding: Finding,
): string | undefined {
const target = findFixTargetForFinding(plan, finding);
if (!target) return undefined;
if (target.childPackage === finding.pkg.name && target.childTargetVersion) {
return target.childTargetVersion;
}
return target.targetVersion;
}

function upsertTarget(
targetsByKey: Map<string, SuggestedFixTarget>,
finding: Finding,
Expand Down Expand Up @@ -701,6 +758,10 @@ function upsertTarget(
chainProof: existing.chainProof ?? next.chainProof,
chainSafeVersion: existing.chainSafeVersion ?? next.chainSafeVersion,
chainVulnerablePackage: existing.chainVulnerablePackage ?? next.chainVulnerablePackage,
childPackage: existing.childPackage ?? next.childPackage,
childTargetVersion: higherVersion(existing.childTargetVersion, next.childTargetVersion)
?? existing.childTargetVersion
?? next.childTargetVersion,
// Conservative: if either contributing finding's recommendation is unverified,
// keep the merged target unverified rather than overstate coverage.
confidence: existing.confidence === "unverified" || next.confidence === "unverified"
Expand Down Expand Up @@ -805,6 +866,13 @@ function isUpgradeTarget(currentVersion: string, targetVersion: string): boolean
return true;
}

function higherVersion(a?: string, b?: string): string | undefined {
if (!a) return b;
if (!b) return a;
if (looksLikeVersion(a) && looksLikeVersion(b) && compareVersions(b, a) > 0) return b;
return a;
}

function buildSections(
targets: SuggestedFixTarget[],
packageManager: SuggestedFixPackageManager,
Expand Down
13 changes: 13 additions & 0 deletions src/remediation/parent-update-child-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Context note naming the child a parent-update target refreshes, or null.
* Leaf module so printers / fix-runner / create-pr can import without cycling through fix-commands.
*/
export function formatParentUpdateChildNote(target: {
kind?: string;
childPackage?: string;
childTargetVersion?: string;
}): string | null {
if (target.kind && target.kind !== "parent-update") return null;
if (!target.childPackage || !target.childTargetVersion) return null;
return `refresh ${target.childPackage} to ${target.childTargetVersion}`;
}
Loading
Loading