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.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,9 @@ When available, the request also includes the repository name and commit. Your
source stays on your machine. `--no-apply` and `--dry-run` still call the
planner. They do not run the package manager. Applying does: it runs the
project's own package manager, which reaches the registries that manager is
configured to use.
configured to use. An advisory stays open when its upgrade does not land:
blocked by policy, blocked by a peer conflict, no fixed version is published,
or the install fails. A failed install restores the manifest and lockfile.

```bash
vg login # once; stores the DSN locally
Expand Down
214 changes: 214 additions & 0 deletions src/reporting/commands/fix-advisory-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { AppliedUpgrade } from '../planning/apply.js';
import type { FixPlanResponse, PlannedUpgrade, VulnDelta } from '../planning/types.js';

/**
* `vg fix` prints "Advisories resolved" only after an upgrade lands on a
* published fix. These tests drive the command with a mocked planner and a
* mocked package-manager apply, and assert the four blocked outcomes stay open.
*/

const applyPlan = vi.hoisted(() => vi.fn());

vi.mock('../planning/apply.js', async () => {
const actual = await vi.importActual<typeof import('../planning/apply.js')>('../planning/apply.js');
return { ...actual, applyPlan };
});

import { fixCommand } from './fix.js';

const DSN = 'vibgrate+https://key123:secretabc@us.ingest.vibgrate.com/ws_test';

let dir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;
const prevCredsEnv = process.env.VIBGRATE_CREDENTIALS;

function emptyDelta(): VulnDelta {
return { total: 0, bySeverity: { critical: 0, high: 0, moderate: 0, low: 0, unknown: 0 } };
}

function planned(over: Partial<PlannedUpgrade> = {}): PlannedUpgrade {
return {
package: 'lodash',
ecosystem: 'npm',
from: '4.17.20',
to: '4.17.21',
kind: 'patch',
blastRadius: 'low',
fixes: { ...emptyDelta(), total: 1, bySeverity: { ...emptyDelta().bySeverity, high: 1 } },
reason: 'remediates GHSA-lodash',
advisories: [{ id: 'GHSA-lodash', fixedVersion: '4.17.21' }],
...over,
};
}

function response(upgrade: PlannedUpgrade, over: Partial<FixPlanResponse> = {}): FixPlanResponse {
return {
status: 'ok',
requestId: 'req_blocked',
totalCandidates: 1,
recommended: 'safe',
rationale: 'Low-risk patch.',
unresolved: emptyDelta(),
vulnerabilityData: 'osv',
deepAnalysis: false,
plans: [
{
tier: 'safe',
label: 'Low-risk',
description: 'Patch only.',
upgrades: [upgrade],
excluded: [],
riskScore: 4,
confidence: 'high',
fixes: emptyDelta(),
introduces: emptyDelta(),
},
{ tier: 'balanced', label: 'Balanced', description: '', upgrades: [], excluded: [], riskScore: 0, confidence: 'high', fixes: emptyDelta(), introduces: emptyDelta() },
{ tier: 'aggressive', label: 'Full', description: '', upgrades: [], excluded: [], riskScore: 0, confidence: 'high', fixes: emptyDelta(), introduces: emptyDelta() },
],
...over,
};
}

function fakeResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
clone: () => fakeResponse(body, status),
} as unknown as Response;
}

function writeArtifact(): void {
const artifact = {
schemaVersion: '1.0',
timestamp: '2024-01-01T00:00:00.000Z',
vibgrateVersion: 'test',
rootPath: dir,
projects: [
{
type: 'node',
path: '.',
name: 'fixture',
frameworks: [],
dependencies: [
{ package: 'lodash', section: 'dependencies', currentSpec: '4.17.20', resolvedVersion: '4.17.20', latestStable: '4.17.21', majorsBehind: 0, drift: 'patch-behind' },
],
dependencyAgeBuckets: { current: 0, oneBehind: 1, twoPlusBehind: 0, unknown: 0 },
},
],
drift: { score: 40, riskLevel: 'moderate', components: {} },
findings: [],
};
fs.mkdirSync(path.join(dir, '.vibgrate'), { recursive: true });
fs.writeFileSync(path.join(dir, '.vibgrate', 'scan_result.json'), JSON.stringify(artifact));
}

function output(): string {
return logSpy.mock.calls.map((call: unknown[]) => String(call[0]).replace(/\u001b\[[0-9;]*m/g, '')).join('\n');
}

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-fix-advisory-cmd-'));
process.env.VIBGRATE_DSN = DSN;
process.env.VIBGRATE_CREDENTIALS = path.join(dir, 'no-credentials.json');
process.exitCode = undefined;
applyPlan.mockReset();
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
writeArtifact();
});

afterEach(() => {
delete process.env.VIBGRATE_DSN;
if (prevCredsEnv === undefined) delete process.env.VIBGRATE_CREDENTIALS;
else process.env.VIBGRATE_CREDENTIALS = prevCredsEnv;
process.exitCode = undefined;
logSpy.mockRestore();
errSpy.mockRestore();
vi.unstubAllGlobals();
fs.rmSync(dir, { recursive: true, force: true });
});

describe('vg fix advisory resolution', () => {
it.each([
{
name: 'policy',
upgrade: planned({ block: 'policy' }),
result: { package: 'lodash', to: '4.17.21', status: 'skipped', detail: 'blocked by policy' } satisfies AppliedUpgrade,
reason: 'upgrade blocked by policy',
},
{
name: 'a peer conflict',
upgrade: planned({ block: 'peer' }),
result: { package: 'lodash', to: '4.17.21', status: 'skipped', detail: 'peer conflict' } satisfies AppliedUpgrade,
reason: 'upgrade blocked by a peer conflict',
},
{
name: 'no fixed version',
upgrade: planned({
to: null,
block: 'no-fixed-version',
advisories: [{ id: 'GHSA-lodash', fixedVersion: null }],
}),
result: { package: 'lodash', to: null, status: 'skipped', detail: 'no fixed version' } satisfies AppliedUpgrade,
reason: 'no fixed version',
},
{
name: 'a failed install',
upgrade: planned(),
result: { package: 'lodash', to: '4.17.21', status: 'failed', detail: 'exit 1' } satisfies AppliedUpgrade,
reason: 'upgrade install failed',
},
])('does not mark the advisory resolved when the upgrade is blocked by $name', async ({ upgrade, result, reason }) => {
applyPlan.mockReturnValue([result]);
vi.stubGlobal('fetch', vi.fn(async () => fakeResponse(response(upgrade))));

await fixCommand.parseAsync([dir, '--yes'], { from: 'user' });

const text = output();
expect(text).not.toMatch(/Advisories resolved:/);
expect(text).toContain(`Advisory GHSA-lodash stayed open — ${reason}`);
});

it('does not mark the advisory resolved when a peer conflict blocks apply before the package manager runs', async () => {
const upgrade = planned();
const body = response(upgrade);
body.plans[0].conflicts = [{ packages: ['lodash', 'react'], reason: 'peer dependency', severity: 'blocking' }];
vi.stubGlobal('fetch', vi.fn(async () => fakeResponse(body)));

await fixCommand.parseAsync([dir, '--yes'], { from: 'user' });

expect(applyPlan).not.toHaveBeenCalled();
const text = output();
expect(text).not.toMatch(/Advisories resolved:/);
expect(text).toContain('Advisory GHSA-lodash stayed open — upgrade blocked by a peer conflict');
expect(process.exitCode).toBe(2);
});

it('keeps the advisory open when the planner has no target version', async () => {
const upgrade = planned({ to: null, advisories: [{ id: 'GHSA-lodash', fixedVersion: null }] });
applyPlan.mockReturnValue([{ package: 'lodash', to: null, status: 'skipped', detail: 'no target version' }]);
vi.stubGlobal('fetch', vi.fn(async () => fakeResponse(response(upgrade))));

await fixCommand.parseAsync([dir, '--yes'], { from: 'user' });

const text = output();
expect(text).not.toMatch(/Advisories resolved:/);
expect(text).toContain('Advisory GHSA-lodash stayed open — no fixed version');
});

it('marks the advisory resolved only after the install lands on the fixed version', async () => {
applyPlan.mockReturnValue([{ package: 'lodash', to: '4.17.21', status: 'applied' }]);
vi.stubGlobal('fetch', vi.fn(async () => fakeResponse(response(planned()))));

await fixCommand.parseAsync([dir, '--yes'], { from: 'user' });

expect(output()).toContain('Advisories resolved: GHSA-lodash');
});
});
172 changes: 172 additions & 0 deletions src/reporting/commands/fix-blocked-advisory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runCoreScan, type ScanOptions } from '../../core-open/index.js';
import { applyPlan } from '../planning/apply.js';
import type { PlannedUpgrade, VulnDelta } from '../planning/types.js';
import { attemptsForApply, resolvedAdvisoryIds } from '../planning/advisory-resolution.js';

/**
* A blocked `vg fix` upgrade must not clear the advisory on the next scan.
* The package manager often rewrites the manifest before a failing install;
* if that rewrite stuck, the scan would read the fixed version and drop the
* advisory even though the install never landed.
*/

let dir: string;

afterEach(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});

function emptyDelta(): VulnDelta {
return { total: 0, bySeverity: { critical: 0, high: 0, moderate: 0, low: 0, unknown: 0 } };
}

function upgrade(over: Partial<PlannedUpgrade> = {}): PlannedUpgrade {
return {
package: 'lodash',
ecosystem: 'npm',
from: '4.17.20',
to: '4.17.21',
kind: 'patch',
blastRadius: 'low',
fixes: { ...emptyDelta(), total: 1, bySeverity: { ...emptyDelta().bySeverity, high: 1 } },
reason: 'remediates GHSA-lodash',
advisories: [{ id: 'GHSA-lodash', fixedVersion: '4.17.21' }],
...over,
};
}

function seed(vuln: { fixed?: string }): string {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-fix-advisory-'));
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'fixture', version: '1.0.0', dependencies: { lodash: '4.17.20' } }, null, 2),
);
fs.writeFileSync(
path.join(dir, 'package-lock.json'),
JSON.stringify({
lockfileVersion: 3,
packages: { 'node_modules/lodash': { version: '4.17.20' } },
}),
);
fs.writeFileSync(
path.join(dir, 'versions.json'),
JSON.stringify({
npm: {
lodash: {
latest: '4.17.21',
versions: ['4.17.20', '4.17.21'],
vulns: [
{
id: 'GHSA-lodash',
severity: 'high',
ranges: [vuln.fixed ? { introduced: '0', fixed: vuln.fixed } : { introduced: '0' }],
},
],
},
},
}),
);
return dir;
}

async function openAdvisoryIds(root: string): Promise<string[]> {
const opts: ScanOptions = {
vibgrateVersion: 'test',
format: 'json',
out: path.join(root, 'scratch-scan.json'),
quiet: true,
concurrency: 2,
offline: true,
vulns: true,
packageManifest: path.join(root, 'versions.json'),
};
const artifact = await runCoreScan(root, opts);
const packages = artifact.extended?.vulnerabilities?.packages ?? [];
return packages.flatMap((pkg) => pkg.advisories.map((advisory) => advisory.id)).sort();
}

/** Rewrite the manifest and lockfile the way a package manager does before it fails. */
function mutateToFixed(): void {
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: 'fixture', version: '1.0.0', dependencies: { lodash: '4.17.21' } }, null, 2),
);
fs.writeFileSync(
path.join(dir, 'package-lock.json'),
JSON.stringify({
lockfileVersion: 3,
packages: { 'node_modules/lodash': { version: '4.17.21' } },
}),
);
}

describe('vg fix never marks a blocked upgrade resolved', () => {
it('keeps the advisory open when a failed install rewrote the manifest and lockfile', async () => {
const root = seed({ fixed: '4.17.21' });
expect(await openAdvisoryIds(root)).toEqual(['GHSA-lodash']);

const planned = upgrade();
const results = applyPlan(root, [planned], {
run: () => {
mutateToFixed();
return { ok: false, detail: 'exit 1' };
},
});

expect(results[0].status).toBe('failed');
expect(resolvedAdvisoryIds(attemptsForApply([planned], results))).toEqual([]);
expect(await openAdvisoryIds(root)).toEqual(['GHSA-lodash']);
expect(JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).dependencies.lodash).toBe('4.17.20');
});

it.each([
['policy', 'blocked by policy'],
['peer', 'peer conflict: ERESOLVE unable to resolve dependency tree'],
] as const)('keeps the advisory open when the install is blocked by %s', async (_label, detail) => {
const root = seed({ fixed: '4.17.21' });
const planned = upgrade();
const results = applyPlan(root, [planned], {
run: () => {
mutateToFixed();
return { ok: false, detail };
},
});
expect(resolvedAdvisoryIds(attemptsForApply([planned], results))).toEqual([]);
expect(await openAdvisoryIds(root)).toEqual(['GHSA-lodash']);
});

it('keeps the advisory open when no fixed version is published, even after a successful bump', async () => {
const root = seed({});
expect(await openAdvisoryIds(root)).toEqual(['GHSA-lodash']);
const planned = upgrade({
advisories: [{ id: 'GHSA-lodash', fixedVersion: null }],
to: '4.17.21',
});
const results = applyPlan(root, [planned], {
run: () => {
mutateToFixed();
return { ok: true };
},
});
expect(results[0].status).toBe('applied');
expect(resolvedAdvisoryIds(attemptsForApply([planned], results))).toEqual([]);
expect(await openAdvisoryIds(root)).toEqual(['GHSA-lodash']);
});

it('clears the advisory only when the install lands on the fixed version', async () => {
const root = seed({ fixed: '4.17.21' });
const planned = upgrade();
const results = applyPlan(root, [planned], {
run: () => {
mutateToFixed();
return { ok: true };
},
});
expect(resolvedAdvisoryIds(attemptsForApply([planned], results))).toEqual(['GHSA-lodash']);
expect(await openAdvisoryIds(root)).toEqual([]);
});
});
Loading
Loading