Files
renovate/lib/workers/repository/process/vulnerabilities.spec.ts
Sergei Zharinov 09ff8d22b6 test: Validate minimumReleaseAge is ignored for security updates (#44272)
* test: validate minimumReleaseAge does not apply to security updates

* test: add end-to-end coverage for security-update minimumReleaseAge bypass

Drive the real vulnerability flow (appendVulnerabilityPackageRules) through the
real lookupUpdates, instead of only simulating the generated packageRule. Also
reword the inline comment and use luxon/asTimestamp for timestamps.

* test: fix type-check on e2e lookup config

Cast the spread config to satisfy partial() overload resolution, matching the
existing `getConfig() as never` idiom in the lookup spec.

* test: drop redundant inline lookup test in favor of end-to-end coverage

The simulated lookup test hand-built the security packageRule; the end-to-end
test plus the generated-rule assertion in vulnerabilities.spec.ts cover the same
behavior against the real flow, so the inline test added no unique coverage.

* test(vulnerability): align lookup config types
2026-09-04 09:31:58 +00:00

2701 lines
80 KiB
TypeScript

import type { Osv, OsvOffline } from '@renovatebot/osv-offline';
import { codeBlock } from 'common-tags';
import { DateTime } from 'luxon';
import { mockFn } from 'vitest-mock-extended';
import { type RenovateConfig, logger, partial } from '~test/util.ts';
import { getConfig } from '../../../config/defaults.ts';
import type { PackageRuleInputConfig } from '../../../config/types.ts';
import { MavenDatasource } from '../../../modules/datasource/maven/index.ts';
import type { PackageFile } from '../../../modules/manager/types.ts';
import { applyPackageRules } from '../../../util/package-rules/index.ts';
import { Result } from '../../../util/result.ts';
import { asTimestamp } from '../../../util/timestamp.ts';
import * as lookup from './lookup/index.ts';
import type { LookupUpdateConfig } from './lookup/types.ts';
import { Vulnerabilities } from './vulnerabilities.ts';
const getVulnerabilitiesMock =
mockFn<typeof OsvOffline.prototype.getVulnerabilities>();
const createMock = vi.fn();
const getMavenReleases = vi.spyOn(MavenDatasource.prototype, 'getReleases');
const postprocessMavenRelease = vi.spyOn(
MavenDatasource.prototype,
'postprocessRelease',
);
vi.mock('@renovatebot/osv-offline', () => {
return {
__esModule: true,
OsvOffline: class {
static create() {
return createMock();
}
},
};
});
describe('workers/repository/process/vulnerabilities', () => {
describe('create()', () => {
beforeEach(resetOsv);
it('works, and is a singleton', async () => {
createMock.mockResolvedValue({
getVulnerabilities: getVulnerabilitiesMock,
});
await expect(Vulnerabilities.create()).resolves.not.toThrow();
await expect(Vulnerabilities.create()).resolves.not.toThrow();
expect(createMock).toHaveBeenCalledTimes(1);
});
it('throws when osv-offline error', async () => {
createMock.mockRejectedValue(new Error());
await expect(Vulnerabilities.create()).rejects.toThrow(Error);
});
});
describe('fetchVulnerabilities()', () => {
let config: RenovateConfig;
let vulnerabilities: Vulnerabilities;
beforeAll(async () => {
resetOsv();
createMock.mockResolvedValue({
getVulnerabilities: getVulnerabilitiesMock,
});
vulnerabilities = await Vulnerabilities.create();
});
beforeEach(() => {
config = getConfig();
config.packageRules = [];
});
it('return list of Vulnerabilities', async () => {
const packageFiles: Record<string, PackageFile[]> = {
poetry: [
{
deps: [
{ depName: 'django', currentValue: '3.2', datasource: 'pypi' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-qrw5-5h28-modded',
modified: '',
affected: [
{
package: {
name: 'django',
ecosystem: 'PyPI',
purl: 'pkg:pypi/django',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '3.0' }, { fixed: '3.3.8' }],
},
],
},
{
package: {
name: 'django',
ecosystem: 'PyPI',
purl: 'pkg:pypi/django',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '3.2' }, { fixed: '3.2.16' }],
},
],
},
],
},
]);
const vulnerabilityList = await vulnerabilities.fetchVulnerabilities(
config,
packageFiles,
);
expect(vulnerabilityList).toMatchObject([
{
packageName: 'django',
depVersion: '3.2',
fixedVersion: '>= 3.3.8',
datasource: 'pypi',
},
{
packageName: 'django',
depVersion: '3.2',
fixedVersion: '>= 3.2.16',
datasource: 'pypi',
},
]);
});
describe('malicious packages', () => {
it('are marked for dependencies with a MAL- advisory ID against their current version with malicious-version-in-use', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depType: 'devDependencies',
depName: 'axios',
currentValue: '1.14.1',
datasource: 'npm',
prettyDepType: 'devDependency',
lockedVersion: '1.14.1',
updates: [],
packageName: 'axios',
},
],
packageFile: 'package.json',
},
],
pip: [
{
deps: [
{
depName: 'num2words',
currentValue: '0.5.15',
datasource: 'pypi',
updates: [],
packageName: 'num2words',
},
],
packageFile: 'go.mod',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-2307',
aliases: ['GHSA-fw8c-xr5c-95f9'],
affected: [
{
package: {
ecosystem: 'npm',
name: 'axios',
},
versions: ['0.30.4', '1.14.1'],
database_specific: {
cwes: [
{
cweId: 'CWE-506',
description:
'The product contains code that appears to be malicious in nature.',
name: 'Embedded Malicious Code',
},
],
},
},
],
},
]);
// GHSA-jxr6-qrxx-2ph2 has a corresponding MAL-2025-6794, but because this isn't literally MAL-2025-6794, we don't apply it
getVulnerabilitiesMock.mockResolvedValueOnce([
{
affected: [
{
database_specific: {
source:
'https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/07/GHSA-jxr6-qrxx-2ph2/GHSA-jxr6-qrxx-2ph2.json',
},
package: {
ecosystem: 'PyPI',
name: 'num2words',
purl: 'pkg:pypi/num2words',
},
ranges: [
{
events: [
{
introduced: '0.5.15',
},
{
last_affected: '0.5.16',
},
],
type: 'ECOSYSTEM',
},
],
},
],
aliases: ['MAL-2025-6794', 'PYSEC-2025-72'],
id: 'GHSA-jxr6-qrxx-2ph2',
modified: '2025-08-06T04:27:26.046626Z',
published: '2025-07-31T19:33:29Z',
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(packageFiles.npm[0].deps[0].skipReason).toEqual(
'malicious-version-in-use',
);
expect(packageFiles.npm[0].deps[0].skipStage).toEqual('lookup');
// and it does not apply to the dependency that doesn't match
expect(packageFiles.pip[0].deps[0].skipReason).toBeUndefined();
expect(packageFiles.pip[0].deps[0].skipStage).toBeUndefined();
// validation to make sure that these were both valid advisories
expect(logger.logger.debug).toHaveBeenCalledWith(
'Vulnerability MAL-2026-2307 affects axios 1.14.1',
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'Vulnerability GHSA-jxr6-qrxx-2ph2 affects num2words 0.5.15',
);
});
it('are logged', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depType: 'devDependencies',
depName: 'axios',
currentValue: '1.14.1',
datasource: 'npm',
prettyDepType: 'devDependency',
lockedVersion: '1.14.1',
updates: [],
packageName: 'axios',
},
],
packageFile: 'package.json',
},
],
pip: [
{
deps: [
{
depName: 'num2words',
currentValue: '0.5.15',
datasource: 'pypi',
updates: [],
packageName: 'num2words',
},
],
packageFile: 'go.mod',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-2307',
aliases: ['GHSA-fw8c-xr5c-95f9'],
affected: [
{
package: {
ecosystem: 'npm',
name: 'axios',
},
versions: ['0.30.4', '1.14.1'],
database_specific: {
cwes: [
{
cweId: 'CWE-506',
description:
'The product contains code that appears to be malicious in nature.',
name: 'Embedded Malicious Code',
},
],
},
},
],
},
]);
// GHSA-jxr6-qrxx-2ph2 has a corresponding MAL-2025-6794, but because this isn't literally MAL-2025-6794, we don't apply it
getVulnerabilitiesMock.mockResolvedValueOnce([
{
affected: [
{
database_specific: {
source:
'https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/07/GHSA-jxr6-qrxx-2ph2/GHSA-jxr6-qrxx-2ph2.json',
},
package: {
ecosystem: 'PyPI',
name: 'num2words',
purl: 'pkg:pypi/num2words',
},
ranges: [
{
events: [
{
introduced: '0.5.15',
},
{
last_affected: '0.5.16',
},
],
type: 'ECOSYSTEM',
},
],
},
],
aliases: ['MAL-2025-6794', 'PYSEC-2025-72'],
id: 'GHSA-jxr6-qrxx-2ph2',
modified: '2025-08-06T04:27:26.046626Z',
published: '2025-07-31T19:33:29Z',
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
{
packageFile: 'package.json',
depName: 'axios',
packageName: 'axios',
manager: 'npm',
datasource: 'npm',
currentVersion: '1.14.1',
},
'Marking axios as skipReason=malicious-version-in-use, as it is affected by MAL-2026-2307',
);
});
it('are not counted if the affected versions do not match', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depType: 'devDependencies',
depName: 'axios',
currentValue: '1.14.0',
datasource: 'npm',
prettyDepType: 'devDependency',
lockedVersion: '1.14.0',
updates: [],
packageName: 'axios',
},
],
packageFile: 'package.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-2307',
aliases: ['GHSA-fw8c-xr5c-95f9'],
affected: [
{
package: {
ecosystem: 'npm',
name: 'axios',
},
versions: ['0.30.4', '1.14.1'],
database_specific: {
cwes: [
{
cweId: 'CWE-506',
description:
'The product contains code that appears to be malicious in nature.',
name: 'Embedded Malicious Code',
},
],
},
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(packageFiles.npm[0].deps[0].skipReason).toBeUndefined();
});
it('handles a MAL- advisory with no affected field', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depType: 'devDependencies',
depName: 'axios',
currentValue: '1.14.1',
datasource: 'npm',
prettyDepType: 'devDependency',
lockedVersion: '1.14.1',
updates: [],
packageName: 'axios',
},
],
packageFile: 'package.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-2307',
aliases: ['GHSA-fw8c-xr5c-95f9'],
affected: [
{
package: {
ecosystem: 'npm',
name: 'axios',
},
versions: ['1.14.1'],
},
],
},
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-9999',
aliases: [],
},
]);
await expect(
vulnerabilities.appendVulnerabilityPackageRules(config, packageFiles),
).resolves.not.toThrow();
});
it('handles a malicious dependency where updates is undefined', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depType: 'devDependencies',
depName: 'axios',
currentValue: '1.14.1',
datasource: 'npm',
prettyDepType: 'devDependency',
lockedVersion: '1.14.1',
packageName: 'axios',
},
],
packageFile: 'package.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-2307',
aliases: ['GHSA-fw8c-xr5c-95f9'],
affected: [
{
package: {
ecosystem: 'npm',
name: 'axios',
},
versions: ['1.14.1'],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(packageFiles.npm[0].deps[0].skipReason).toEqual(
'malicious-version-in-use',
);
});
describe('when a malicious dependency update is proposed', () => {
it('applies to dependency updates, and sets malicious-update-proposed', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depType: 'devDependencies',
depName: 'axios',
currentValue: '1.14.0',
datasource: 'npm',
prettyDepType: 'devDependency',
lockedVersion: '1.14.0',
updates: [
{
newVersion: '1.14.1',
},
],
packageName: 'axios',
},
],
packageFile: 'package.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-2307',
aliases: ['GHSA-fw8c-xr5c-95f9'],
affected: [
{
package: {
ecosystem: 'npm',
name: 'axios',
},
versions: ['0.30.4', '1.14.1'],
database_specific: {
cwes: [
{
cweId: 'CWE-506',
description:
'The product contains code that appears to be malicious in nature.',
name: 'Embedded Malicious Code',
},
],
},
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(packageFiles.npm[0].deps[0].skipReason).toEqual(
'malicious-update-proposed',
);
expect(packageFiles.npm[0].deps[0].skipStage).toEqual('lookup');
});
it('logs', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depType: 'devDependencies',
depName: 'axios',
currentValue: '1.14.0',
datasource: 'npm',
prettyDepType: 'devDependency',
lockedVersion: '1.14.0',
updates: [
{
newVersion: '1.14.1',
},
],
packageName: 'axios',
},
],
packageFile: 'package.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-2307',
aliases: ['GHSA-fw8c-xr5c-95f9'],
affected: [
{
package: {
ecosystem: 'npm',
name: 'axios',
},
versions: ['0.30.4', '1.14.1'],
database_specific: {
cwes: [
{
cweId: 'CWE-506',
description:
'The product contains code that appears to be malicious in nature.',
name: 'Embedded Malicious Code',
},
],
},
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
{
packageFile: 'package.json',
depName: 'axios',
packageName: 'axios',
manager: 'npm',
datasource: 'npm',
currentVersion: '1.14.0',
newVersion: '1.14.1',
},
"Marking axios's update to 1.14.1 as skipReason=malicious-update-proposed, as it is affected by MAL-2026-2307",
);
});
it('falls back to update.newValue when newVersion is missing, and skips updates that are not malicious', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depType: 'devDependencies',
depName: 'axios',
currentValue: '1.14.0',
datasource: 'npm',
prettyDepType: 'devDependency',
lockedVersion: '1.14.0',
updates: [
{
newValue: '1.14.2',
},
{
newValue: '1.14.1',
},
],
packageName: 'axios',
},
],
packageFile: 'package.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
modified: '2026-04-07T14:41:20Z',
published: '2026-03-31T03:15:49Z',
schema_version: '1.7.4',
id: 'MAL-2026-2307',
aliases: ['GHSA-fw8c-xr5c-95f9'],
affected: [
{
package: {
ecosystem: 'npm',
name: 'axios',
},
versions: ['1.14.1'],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(packageFiles.npm[0].deps[0].skipReason).toEqual(
'malicious-update-proposed',
);
expect(packageFiles.npm[0].deps[0].skipStage).toEqual('lookup');
});
});
});
it('handles sub-ecosystems (e.g. Packagist:drupal)', async () => {
const packageFiles: Record<string, PackageFile[]> = {
composer: [
{
deps: [
{
depName: 'drupal/ai',
currentValue: '1.0.6',
datasource: 'packagist',
},
],
packageFile: 'composer.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'DRUPAL-CONTRIB-2025-119',
modified: '',
affected: [
{
package: {
name: 'drupal/ai',
ecosystem: 'Packagist:https://packages.drupal.org/8',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { fixed: '1.0.7' }],
},
],
},
],
},
]);
const vulnerabilityList = await vulnerabilities.fetchVulnerabilities(
config,
packageFiles,
);
expect(vulnerabilityList).toMatchObject([
{
packageName: 'drupal/ai',
depVersion: '1.0.6',
fixedVersion: '>= 1.0.7',
datasource: 'packagist',
},
]);
});
it('does not match unrelated ecosystem prefixes', async () => {
const packageFiles: Record<string, PackageFile[]> = {
composer: [
{
deps: [
{
depName: 'drupal/ai',
currentValue: '1.0.6',
datasource: 'packagist',
},
],
packageFile: 'composer.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'FAKE-PACKAGISTSOMETHING-1',
modified: '',
affected: [
{
package: {
name: 'drupal/ai',
ecosystem: 'PackagistSomething',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { fixed: '1.0.7' }],
},
],
},
],
},
]);
const vulnerabilityList = await vulnerabilities.fetchVulnerabilities(
config,
packageFiles,
);
expect(vulnerabilityList).toEqual([]);
});
});
describe('appendVulnerabilityPackageRules()', () => {
let config: RenovateConfig;
let vulnerabilities: Vulnerabilities;
const lodashVulnerability: Osv.Vulnerability = {
id: 'GHSA-x5rq-j2xg-h7qm',
modified: '',
affected: [
{
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '0.0.0' }, { fixed: '4.17.11' }],
},
],
package: { name: 'lodash', ecosystem: 'npm' },
},
],
references: [
{
type: 'ADVISORY',
url: 'https://nvd.nist.gov/vuln/detail/CVE-2019-1010266',
},
],
};
beforeAll(async () => {
resetOsv();
createMock.mockResolvedValue({
getVulnerabilities: getVulnerabilitiesMock,
});
vulnerabilities = await Vulnerabilities.create();
});
beforeEach(() => {
config = getConfig();
config.packageRules = [];
});
it('unsupported datasource', async () => {
const packageFiles: Record<string, PackageFile[]> = {
dockerfile: [
{
deps: [{ depName: 'node', datasource: 'docker' }],
packageFile: 'some-file',
},
],
};
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.trace).toHaveBeenCalledWith(
'Cannot map datasource docker to OSV ecosystem',
);
});
it('package found but no vulnerabilities', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [{ depName: 'lodash', datasource: 'npm' }],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.trace).toHaveBeenCalledWith(
'No vulnerabilities found in OSV database for lodash',
);
});
it('vulnerability without affected field', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'lodash', currentValue: '4.17.11', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-p6mc-m468-83gw',
modified: '',
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(0);
});
it('withdrawn vulnerability', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'lodash', currentValue: '4.17.10', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
...lodashVulnerability,
withdrawn: '2021-11-29T18:17:00Z',
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.trace).toHaveBeenCalledWith(
'Skipping withdrawn vulnerability GHSA-x5rq-j2xg-h7qm',
);
expect(config.packageRules).toHaveLength(0);
});
it('invalid dep version', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depName: 'lodash',
currentValue: '#4.17.11',
datasource: 'npm',
},
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([lodashVulnerability]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'Skipping vulnerability lookup for package lodash due to unsupported version #4.17.11',
);
});
it('exception while fetching vulnerabilities', async () => {
const err = new Error('unknown');
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
packageFile: 'some-file',
deps: [
{
depName: 'lodash',
currentValue: '4.17.11',
datasource: 'npm',
},
],
},
],
};
getVulnerabilitiesMock.mockRejectedValueOnce(err);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.warn).toHaveBeenCalledWith(
{ err, packageName: 'lodash' },
'Error fetching vulnerability information for package',
);
});
it('log event with invalid version', async () => {
const event = { fixed: '^6.0' };
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depName: 'lodash',
currentValue: '4.17.11',
datasource: 'npm',
},
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-xxxx-yyyy-zzzz',
modified: '',
affected: [
{
package: {
name: 'lodash',
ecosystem: 'npm',
purl: 'pkg:npm/lodash',
},
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '0' }, event],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
{ event },
'Skipping OSV event with invalid version',
);
});
it('no version or range affected', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'fake', currentValue: '4.17.11', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-xxxx-yyyy-zzzz',
modified: '',
affected: [
{
package: { name: 'fake', ecosystem: 'npm', purl: 'pkg:npm/fake' },
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(0);
});
it('no fixed version available', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'fake', currentValue: '4.17.11', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-xxxx-yyyy-zzzz',
modified: '',
affected: [
{
package: { name: 'fake', ecosystem: 'npm', purl: 'pkg:npm/fake' },
versions: ['4.17.11'],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'No fixed version available for vulnerability GHSA-xxxx-yyyy-zzzz in fake 4.17.11',
);
});
it('does not accidentally downgrade versions due to fixed version for other range', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'fake', currentValue: '1.5.1', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-xxxx-yyyy-zzzz',
modified: '',
affected: [
{
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '0' }, { fixed: '1.1.0' }],
},
{
type: 'SEMVER',
events: [{ introduced: '1.3.0' }],
},
],
package: { name: 'fake', ecosystem: 'npm' },
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'No fixed version available for vulnerability GHSA-xxxx-yyyy-zzzz in fake 1.5.1',
);
});
it('vulnerability with multiple unsorted events', async () => {
const packageFiles: Record<string, PackageFile[]> = {
gomod: [
{
deps: [
{ depName: 'stdlib', currentValue: '1.7.5', datasource: 'go' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GO-2022-0187',
modified: '',
aliases: ['CVE-2017-8932'],
affected: [
{
package: {
name: 'stdlib',
ecosystem: 'Go',
purl: 'pkg:golang/stdlib',
},
ranges: [
{
type: 'SEMVER',
events: [
{ introduced: '1.6.0' },
{ fixed: '1.8.5' },
{ introduced: '1.8.3' },
{ fixed: '1.7.6' },
],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'Vulnerability GO-2022-0187 affects stdlib 1.7.5',
);
expect(logger.logger.debug).toHaveBeenCalledWith(
{
datasource: 'go',
versioning: 'semver',
},
'Setting allowed version >= 1.7.6 to fix vulnerability GO-2022-0187 in stdlib 1.7.5',
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['go'],
matchPackageNames: ['stdlib'],
matchCurrentVersion: '1.7.5',
allowedVersions: '>= 1.7.6',
isVulnerabilityAlert: true,
// security updates inherit `vulnerabilityAlerts`, which disables `minimumReleaseAge`
force: { minimumReleaseAge: null },
},
]);
});
it('creates vulnerability alert for go toolchain directive using stdlib', async () => {
const packageFiles: Record<string, PackageFile[]> = {
gomod: [
{
deps: [
{
depName: 'go',
depType: 'toolchain',
currentValue: '1.23.6',
datasource: 'golang-version',
},
],
packageFile: 'go.mod',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GO-2025-3563',
modified: '',
aliases: ['CVE-2025-22871'],
affected: [
{
package: {
name: 'stdlib',
ecosystem: 'Go',
purl: 'pkg:golang/stdlib',
},
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '1.23.0' }, { fixed: '1.23.8' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'Vulnerability GO-2025-3563 affects go 1.23.6',
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['golang-version'],
matchPackageNames: ['go'],
matchCurrentVersion: '1.23.6',
matchDepTypes: ['toolchain'],
allowedVersions: '>= 1.23.8',
isVulnerabilityAlert: true,
},
]);
});
it('does not apply go stdlib toolchain remediation to the module go directive', async () => {
const packageFiles: Record<string, PackageFile[]> = {
gomod: [
{
deps: [
{
depName: 'go',
depType: 'golang',
currentValue: '1.26.0',
datasource: 'golang-version',
versioning: 'go-mod-directive',
},
{
depName: 'go',
depType: 'toolchain',
currentValue: '1.26.5',
datasource: 'golang-version',
},
],
packageFile: 'go.mod',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GO-2026-0001',
modified: '',
aliases: ['CVE-2026-0001'],
affected: [
{
package: {
name: 'stdlib',
ecosystem: 'Go',
purl: 'pkg:golang/stdlib',
},
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '1.26.0' }, { fixed: '1.26.6' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['golang-version'],
matchPackageNames: ['go'],
matchCurrentVersion: '1.26.5',
matchDepTypes: ['toolchain'],
allowedVersions: '>= 1.26.6',
isVulnerabilityAlert: true,
},
]);
const toolchainDep: PackageRuleInputConfig & {
allowedVersions?: string;
} = await applyPackageRules({
packageRules: config.packageRules,
depName: 'go',
packageName: 'go',
depType: 'toolchain',
currentValue: '1.26.5',
datasource: 'golang-version',
versioning: 'semver',
});
expect(toolchainDep.allowedVersions).toBe('>= 1.26.6');
expect(toolchainDep.isVulnerabilityAlert).toBe(true);
const golangDep: PackageRuleInputConfig & { allowedVersions?: string } =
await applyPackageRules({
packageRules: config.packageRules,
depName: 'go',
packageName: 'go',
depType: 'golang',
currentValue: '1.26.0',
datasource: 'golang-version',
versioning: 'go-mod-directive',
});
expect(golangDep.allowedVersions).toBeUndefined();
expect(golangDep.isVulnerabilityAlert).toBeUndefined();
});
it('skips vulnerability lookup for go module directive', async () => {
const packageFiles: Record<string, PackageFile[]> = {
gomod: [
{
deps: [
{
depName: 'go',
depType: 'golang',
currentValue: '1.23.5',
datasource: 'golang-version',
},
],
packageFile: 'go.mod',
},
],
};
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(0);
});
it('does not scope npm remediation rules by depType', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depName: 'lodash',
depType: 'dependencies',
currentValue: '4.17.10',
datasource: 'npm',
},
],
packageFile: 'package.json',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([lodashVulnerability]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules?.[0]).not.toHaveProperty('matchDepTypes');
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['npm'],
matchPackageNames: ['lodash'],
matchCurrentVersion: '4.17.10',
allowedVersions: '>= 4.17.11',
isVulnerabilityAlert: true,
},
]);
});
it('sets default datasource versioning to align with allowedVersions on packageRule', async () => {
const packageFiles: Record<string, PackageFile[]> = {
gomod: [
{
deps: [
{
depName:
'software.amazon.encryption.s3:amazon-s3-encryption-client-java',
currentValue: '3.4.0',
datasource: 'maven',
},
],
packageFile: 'pom.xml',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-x44p-gvrj-pj2r',
modified: '',
aliases: ['CVE-2025-14763'],
affected: [
{
package: {
ecosystem: 'Maven',
name: 'software.amazon.encryption.s3:amazon-s3-encryption-client-java',
purl: 'pkg:maven/software.amazon.encryption.s3/amazon-s3-encryption-client-java',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { fixed: '4.0.0' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'Vulnerability GHSA-x44p-gvrj-pj2r affects software.amazon.encryption.s3:amazon-s3-encryption-client-java 3.4.0',
);
expect(logger.logger.debug).toHaveBeenCalledWith(
{
datasource: 'maven',
versioning: 'maven',
},
'Setting allowed version [4.0.0,) to fix vulnerability GHSA-x44p-gvrj-pj2r in software.amazon.encryption.s3:amazon-s3-encryption-client-java 3.4.0',
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['maven'],
matchPackageNames: [
'software.amazon.encryption.s3:amazon-s3-encryption-client-java',
],
matchCurrentVersion: '3.4.0',
allowedVersions: '[4.0.0,)',
versioning: 'maven',
isVulnerabilityAlert: true,
},
]);
});
it('proposes a fresh security fix immediately despite minimumReleaseAge', async () => {
const packageFiles: Record<string, PackageFile[]> = {
maven: [
{
deps: [
{
depName: 'org.example:lib',
currentValue: '1.0.0',
datasource: 'maven',
},
],
packageFile: 'pom.xml',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-1111-2222-3333',
modified: '',
affected: [
{
package: {
ecosystem: 'Maven',
name: 'org.example:lib',
purl: 'pkg:maven/org.example/lib',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { fixed: '1.0.1' }],
},
],
},
],
},
]);
// The user enforces a long release-age delay with strict filtering.
config.minimumReleaseAge = '14 days';
config.internalChecksFilter = 'strict';
// The real vulnerability flow appends the security packageRule to config.
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
// The fix was released yesterday: far inside the 14-day window, so a normal
// update would be held as a pending status check.
getMavenReleases.mockResolvedValueOnce({
releases: [
{ version: '1.0.0' },
{
version: '1.0.1',
releaseTimestamp: asTimestamp(
DateTime.now().minus({ days: 1 }).toISO(),
),
},
],
});
postprocessMavenRelease.mockImplementation((_, release) =>
Promise.resolve(release),
);
// Feed the config that the vulnerability flow just mutated into the real lookup.
const dep = packageFiles.maven[0].deps[0];
const lookupConfig = partial<LookupUpdateConfig>({
...config,
abandonmentThreshold: config.abandonmentThreshold ?? undefined,
manager: 'maven',
packageName: dep.depName,
currentValue: dep.currentValue!,
datasource: dep.datasource,
versioning: 'maven',
});
const { updates } = await Result.wrap(
lookup.lookupUpdates(lookupConfig),
).unwrapOrThrow();
// The fix is proposed right away, with no pending status check.
expect(updates).toHaveLength(1);
expect(updates[0]).toMatchObject({ newVersion: '1.0.1' });
expect(updates[0]).not.toHaveProperty('pendingChecks');
});
it('vulnerability with multiple affected entries and version ranges', async () => {
const packageFiles: Record<string, PackageFile[]> = {
poetry: [
{
deps: [
{ depName: 'django', currentValue: '3.2', datasource: 'pypi' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-qrw5-5h28-modded',
modified: '',
affected: [
{
package: {
name: 'django',
ecosystem: 'PyPI',
purl: 'pkg:pypi/django',
},
ranges: [
{
type: 'GIT',
repo: 'https://github.com/django/django',
events: [
{ introduced: '0' },
{ fixed: '5b6b257fa7ec37ff27965358800c67e2dd11c924' },
],
},
{
type: 'ECOSYSTEM',
events: [{ introduced: '3.2' }, { fixed: '3.2.16' }],
},
],
versions: ['3.2.1', '3.2.10', '3.2.9'],
},
{
package: {
name: 'django',
ecosystem: 'PyPI',
purl: 'pkg:pypi/django',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '4.0' }, { fixed: '4.0.8' }],
},
],
versions: ['4.0', '4.0.1', '4.0.6', '4.0.7'],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['pypi'],
matchPackageNames: ['django'],
matchCurrentVersion: '3.2',
allowedVersions: '>= 3.2.16',
isVulnerabilityAlert: true,
},
]);
});
it('package rules are sorted by fixed version even if affected is unsorted', async () => {
const packageFiles: Record<string, PackageFile[]> = {
poetry: [
{
deps: [
{ depName: 'django', currentValue: '3.2', datasource: 'pypi' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-qrw5-5h28-modded',
modified: '',
affected: [
{
package: {
name: 'django',
ecosystem: 'PyPI',
purl: 'pkg:pypi/django',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '3.0' }, { fixed: '3.3.8' }],
},
],
},
{
package: {
name: 'django',
ecosystem: 'PyPI',
purl: 'pkg:pypi/django',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '3.2' }, { fixed: '3.2.16' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(2);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['pypi'],
matchPackageNames: ['django'],
matchCurrentVersion: '3.2',
allowedVersions: '>= 3.2.16',
isVulnerabilityAlert: true,
},
{
matchDatasources: ['pypi'],
matchPackageNames: ['django'],
matchCurrentVersion: '3.2',
allowedVersions: '>= 3.3.8',
isVulnerabilityAlert: true,
},
]);
});
it('filters not applicable vulnerability', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'lodash', currentValue: '4.17.11', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([lodashVulnerability]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(0);
});
it('returns a single packageRule for regex manager', async () => {
const packageFiles: Record<string, PackageFile[]> = {
regex: [
{
deps: [
{
depName: 'tiny_http',
currentValue: '0.1.2',
datasource: 'crate',
},
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'RUSTSEC-2020-0031',
summary:
'HTTP Request smuggling through malformed Transfer Encoding headers',
details:
'HTTP pipelining issues and request smuggling attacks are possible due to incorrect Transfer encoding header parsing.\n\nIt is possible conduct HTTP request smuggling attacks (CL:TE/TE:TE) by sending invalid Transfer Encoding headers.\n\nBy manipulating the HTTP response the attacker could poison a web-cache, perform an XSS attack, or obtain sensitive information from requests other than their own.',
aliases: ['CVE-2020-35884', 'SOME-1234-5678'],
modified: '',
affected: [
{
package: {
name: 'tiny_http',
ecosystem: 'crates.io',
purl: 'pkg:cargo/tiny_http',
},
ranges: [
{
type: 'SEMVER',
events: [
{ introduced: '0' },
{ fixed: '0.6.3' },
{ introduced: '0.7.0-0' },
{ fixed: '0.8.0' },
],
},
],
},
],
severity: [
{
type: 'CVSS_V2',
score: 'AV:N/AC:L/Au:N/C:P/I:P/A:N',
},
{
type: 'CVSS_V3',
score: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N',
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['crate'],
matchPackageNames: ['tiny_http'],
matchCurrentVersion: '0.1.2',
allowedVersions: '>= 0.6.3',
isVulnerabilityAlert: true,
prBodyNotes: [
// oxlint-disable-next-line prefer-template
'\n\n' +
codeBlock`
---
### HTTP Request smuggling through malformed Transfer Encoding headers
[CVE-2020-35884](https://nvd.nist.gov/vuln/detail/CVE-2020-35884) / [RUSTSEC-2020-0031](https://rustsec.org/advisories/RUSTSEC-2020-0031.html) / SOME-1234-5678
<details>
<summary>More information</summary>
#### Details
HTTP pipelining issues and request smuggling attacks are possible due to incorrect Transfer encoding header parsing.
It is possible conduct HTTP request smuggling attacks (CL:TE/TE:TE) by sending invalid Transfer Encoding headers.
By manipulating the HTTP response the attacker could poison a web-cache, perform an XSS attack, or obtain sensitive information from requests other than their own.
#### Severity
- CVSS Score: 6.5 / 10 (Medium)
- Vector String: \`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N\`
#### References
No references.
This data is provided by [OSV](https://osv.dev/vulnerability/RUSTSEC-2020-0031) and the [Rust Advisory Database](https://github.com/RustSec/advisory-db) ([CC0 1.0](https://github.com/rustsec/advisory-db/blob/main/LICENSE.txt)).
</details>
`,
],
},
]);
});
it('returns multiple packageRules for different vulnerabilities', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'lodash', currentValue: '4.17.10', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
lodashVulnerability,
{
id: 'GHSA-p6mc-m468-83gw',
modified: '',
affected: [
{
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '0' }, { fixed: '4.17.20' }],
},
],
package: { name: 'lodash', ecosystem: 'npm' },
},
],
severity: [
{
type: 'CVSS_V3',
score: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H',
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(2);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['npm'],
matchPackageNames: ['lodash'],
matchCurrentVersion: '4.17.10',
allowedVersions: '>= 4.17.11',
isVulnerabilityAlert: true,
},
{
matchDatasources: ['npm'],
matchPackageNames: ['lodash'],
matchCurrentVersion: '4.17.10',
allowedVersions: '>= 4.17.20',
isVulnerabilityAlert: true,
},
]);
});
it('returns packageRules for Hackage', async () => {
const packageFiles: Record<string, PackageFile[]> = {
hackage: [
{
deps: [
{
depName: 'aeson',
currentValue: '0.4.0.0',
datasource: 'hackage',
},
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'HSEC-2023-0001',
summary: 'Hash flooding vulnerability in aeson',
details:
'# Hash flooding vulnerability in aeson\n\n*aeson* was vulnerable to hash flooding (a.k.a. hash DoS). The\nissue is a consequence of the HashMap implementation from\n*unordered-containers*. It results in a denial of service through\nCPU consumption. This technique has been used in real-world attacks\nagainst a variety of languages, libraries and frameworks over the\nyears.\n',
aliases: ['CVE-2022-3433'],
modified: '2023-06-13T09:03:52Z',
affected: [
{
package: {
ecosystem: 'Hackage',
name: 'aeson',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0.4.0.0' }, { fixed: '2.0.1.0' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['hackage'],
matchPackageNames: ['aeson'],
matchCurrentVersion: '0.4.0.0',
allowedVersions: '>= 2.0.1.0',
isVulnerabilityAlert: true,
},
]);
});
it('filters not applicable vulnerability based on last_affected version', async () => {
const packageFiles: Record<string, PackageFile[]> = {
poetry: [
{
deps: [
{ depName: 'quokka', currentValue: '1.2.3', datasource: 'pypi' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-xxxx-yyyy-zzzz',
modified: '',
affected: [
{
package: {
name: 'quokka',
ecosystem: 'PyPI',
purl: 'pkg:pypi/quokka',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { last_affected: '0.4.0' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).not.toHaveBeenCalledExactlyOnceWith(
'OSV advisory GHSA-xxxx-yyyy-zzzz lists quokka 1.2.3 as vulnerable',
);
expect(config.packageRules).toHaveLength(0);
});
it('describe fixed version as ecosystem-specific version constraint', async () => {
const packageFiles: Record<string, PackageFile[]> = {
maven: [
{
deps: [
{
depName: 'com.guicedee.services:log4j-core',
currentValue: '1.0.10.1',
datasource: 'maven',
},
],
packageFile: 'some-file1',
},
],
nuget: [
{
deps: [
{
depName: 'SharpZipLib',
currentValue: '1.3.0',
datasource: 'nuget',
},
],
packageFile: 'some-file2',
},
],
npm: [
{
deps: [
{
depName: 'lodash',
currentValue: '4.17.15',
datasource: 'npm',
},
],
packageFile: 'some-file3',
},
],
};
getVulnerabilitiesMock.mockResolvedValue([
{
id: 'GHSA-jfh8-c2jp-5v3q',
modified: '',
affected: [
{
package: {
name: 'com.guicedee.services:log4j-core',
ecosystem: 'Maven',
purl: 'pkg:maven/com.guicedee.services/log4j-core',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { fixed: '1.2.1.2-jre17' }],
},
],
},
],
},
{
id: ' GHSA-mm6g-mmq6-53ff',
modified: '',
affected: [
{
package: {
name: 'SharpZipLib',
ecosystem: 'NuGet',
purl: 'pkg:nuget/SharpZipLib',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { fixed: '1.3.3' }],
},
],
},
],
},
{
id: 'GHSA-29mw-wpgm-hmr9',
modified: '',
affected: [
{
package: {
name: 'lodash',
ecosystem: 'npm',
purl: 'pkg:npm/lodash',
},
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '0' }, { fixed: '4.17.21' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['maven'],
matchPackageNames: ['com.guicedee.services:log4j-core'],
matchCurrentVersion: '1.0.10.1',
allowedVersions: '[1.2.1.2-jre17,)',
},
{
matchDatasources: ['nuget'],
matchPackageNames: ['SharpZipLib'],
matchCurrentVersion: '1.3.0',
allowedVersions: '[1.3.3,)',
},
{
matchDatasources: ['npm'],
matchPackageNames: ['lodash'],
matchCurrentVersion: '4.17.15',
allowedVersions: '>= 4.17.21',
},
]);
});
it('describe last_affected version as ecosystem-specific version constraint', async () => {
const packageFiles: Record<string, PackageFile[]> = {
maven: [
{
deps: [
{
depName: 'com.guicedee.services:log4j-core',
currentValue: '1.0.10.1',
datasource: 'maven',
},
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-jfh8-c2jp-5v3q',
modified: '',
affected: [
{
package: {
name: 'com.guicedee.services:log4j-core',
ecosystem: 'Maven',
purl: 'pkg:maven/com.guicedee.services/log4j-core',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '0' },
{ last_affected: '1.2.1.2-jre17' },
],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['maven'],
matchPackageNames: ['com.guicedee.services:log4j-core'],
matchCurrentVersion: '1.0.10.1',
allowedVersions: '(1.2.1.2-jre17,)',
},
]);
});
it('returns packageRule for deps-edn package using OSV Maven ecosystem', async () => {
const packageFiles: Record<string, PackageFile[]> = {
'deps-edn': [
{
deps: [
{
depName: 'org.clojure/clojure',
packageName: 'org.clojure:clojure',
currentValue: '1.10.0',
datasource: 'clojure',
},
],
packageFile: 'deps.edn',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-jfh8-c2jp-clj1',
modified: '',
affected: [
{
package: {
name: 'org.clojure:clojure',
ecosystem: 'Maven',
purl: 'pkg:maven/org.clojure/clojure',
},
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { fixed: '1.11.0' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['clojure'],
matchPackageNames: ['org.clojure:clojure'],
matchCurrentVersion: '1.10.0',
allowedVersions: '[1.11.0,)',
},
]);
});
it('returns packageRule based on last_affected version', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'lodash', currentValue: '0.5.0', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-xxxx-yyyy-zzzz',
modified: '',
affected: [
{
package: {
name: 'lodash',
ecosystem: 'npm',
purl: 'pkg:npm/lodash',
},
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '0' }, { last_affected: '0.2.0' }],
},
{
type: 'SEMVER',
events: [{ introduced: '0.4.0' }, { last_affected: '0.8.0' }],
},
],
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['npm'],
matchPackageNames: ['lodash'],
matchCurrentVersion: '0.5.0',
allowedVersions: '> 0.8.0',
isVulnerabilityAlert: true,
prBodyNotes: [
// oxlint-disable-next-line prefer-template
'\n\n' +
codeBlock`
---
### [GHSA-xxxx-yyyy-zzzz](https://github.com/advisories/GHSA-xxxx-yyyy-zzzz)
<details>
<summary>More information</summary>
#### Details
No details.
#### Severity
Unknown
#### References
No references.
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-xxxx-yyyy-zzzz) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
`,
],
},
]);
});
it('handles invalid CVSS scores gracefully', async () => {
const packageFiles: Record<string, PackageFile[]> = {
poetry: [
{
deps: [
{
depName: 'django-mfa2',
currentValue: '2.5.0',
datasource: 'pypi',
},
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'PYSEC-2022-303',
modified: '',
affected: [
{
ranges: [
{
type: 'ECOSYSTEM',
events: [{ introduced: '0' }, { fixed: '2.5.1' }],
},
],
package: { name: 'django-mfa2', ecosystem: 'PyPI' },
},
],
severity: [
{
type: 'CVSS_V3',
score: 'some-invalid-score',
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'Error processing CVSS vector some-invalid-score',
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['pypi'],
matchPackageNames: ['django-mfa2'],
matchCurrentVersion: '2.5.0',
allowedVersions: '>= 2.5.1',
isVulnerabilityAlert: true,
prBodyNotes: [
// oxlint-disable-next-line prefer-template
'\n\n' +
codeBlock`
---
### PYSEC-2022-303
<details>
<summary>More information</summary>
#### Details
No details.
#### Severity
- CVSS Score: Unknown
- Vector String: \`some-invalid-score\`
#### References
No references.
This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2022-303) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)).
</details>
`,
],
},
]);
});
it('prefer CVSS_V4 scores over CVSS_V3', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{
depName: 'mattermost-desktop',
currentValue: '5.8.0',
datasource: 'npm',
},
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'GHSA-xgq9-7gw6-jr5r',
modified: '',
affected: [
{
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '0' }, { fixed: '5.9.0' }],
},
],
package: { name: 'mattermost-desktop', ecosystem: 'npm' },
},
],
severity: [
{
type: 'CVSS_V3',
score: 'CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N',
},
{
type: 'CVSS_V4',
score:
'CVSS:4.0/AV:L/AC:H/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N',
},
],
references: [
{
type: 'ADVISORY',
url: 'https://nvd.nist.gov/vuln/detail/CVE-2024-45835',
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['npm'],
matchPackageNames: ['mattermost-desktop'],
matchCurrentVersion: '5.8.0',
allowedVersions: '>= 5.9.0',
isVulnerabilityAlert: true,
prBodyNotes: [
// oxlint-disable-next-line prefer-template
'\n\n' +
codeBlock`
---
### [GHSA-xgq9-7gw6-jr5r](https://github.com/advisories/GHSA-xgq9-7gw6-jr5r)
<details>
<summary>More information</summary>
#### Details
No details.
#### Severity
- CVSS Score: 2.0 / 10 (Low)
- Vector String: \`CVSS:4.0/AV:L/AC:H/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N\`
#### References
- [https://nvd.nist.gov/vuln/detail/CVE-2024-45835](https://nvd.nist.gov/vuln/detail/CVE-2024-45835)
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-xgq9-7gw6-jr5r) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
`,
],
},
]);
});
it('show severity text in GHSA advisories without CVSS score', async () => {
const packageFiles: Record<string, PackageFile[]> = {
npm: [
{
deps: [
{ depName: 'lodash', currentValue: '4.17.10', datasource: 'npm' },
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
...lodashVulnerability,
database_specific: {
severity: 'MODERATE',
},
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['npm'],
matchPackageNames: ['lodash'],
matchCurrentVersion: '4.17.10',
allowedVersions: '>= 4.17.11',
isVulnerabilityAlert: true,
prBodyNotes: [
// oxlint-disable-next-line prefer-template
'\n\n' +
codeBlock`
---
### [GHSA-x5rq-j2xg-h7qm](https://github.com/advisories/GHSA-x5rq-j2xg-h7qm)
<details>
<summary>More information</summary>
#### Details
No details.
#### Severity
Moderate
#### References
- [https://nvd.nist.gov/vuln/detail/CVE-2019-1010266](https://nvd.nist.gov/vuln/detail/CVE-2019-1010266)
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-x5rq-j2xg-h7qm) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>
`,
],
},
]);
});
it('formats headings of vulnerability details', async () => {
const packageFiles: Record<string, PackageFile[]> = {
regex: [
{
deps: [
{
depName: 'sys-info',
currentValue: '0.6.0',
datasource: 'crate',
},
],
packageFile: 'some-file',
},
],
};
getVulnerabilitiesMock.mockResolvedValueOnce([
{
id: 'RUSTSEC-2020-0100',
summary:
'Double free when calling `sys_info::disk_info` from multiple threads',
details:
'Affected versions of `sys-info` use a static, global, list to store temporary disk information while running. The function that cleans up this list,\n`DFCleanup`, assumes a single threaded environment and will try to free the same memory twice in a multithreaded environment.\n\nThis results in consistent double-frees and segfaults when calling `sys_info::disk_info` from multiple threads at once.\n\nThe issue was fixed by moving the global variable into a local scope.\n\n## Safer Alternatives:\n - [`sysinfo`](https://crates.io/crates/sysinfo)',
aliases: ['CVE-2020-36434'],
modified: '',
affected: [
{
package: {
name: 'sys-info',
ecosystem: 'crates.io',
purl: 'pkg:cargo/sys-info',
},
ranges: [
{
type: 'SEMVER',
events: [{ introduced: '0.0.0-0' }, { fixed: '0.8.0' }],
},
],
database_specific: {
cvss: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
},
},
],
},
]);
await vulnerabilities.appendVulnerabilityPackageRules(
config,
packageFiles,
);
expect(config.packageRules).toHaveLength(1);
expect(config.packageRules).toMatchObject([
{
matchDatasources: ['crate'],
matchPackageNames: ['sys-info'],
matchCurrentVersion: '0.6.0',
allowedVersions: '>= 0.8.0',
isVulnerabilityAlert: true,
prBodyNotes: [
// oxlint-disable-next-line prefer-template
'\n\n' +
codeBlock`
---
### Double free when calling \`sys_info::disk_info\` from multiple threads
[CVE-2020-36434](https://nvd.nist.gov/vuln/detail/CVE-2020-36434) / [RUSTSEC-2020-0100](https://rustsec.org/advisories/RUSTSEC-2020-0100.html)
<details>
<summary>More information</summary>
#### Details
Affected versions of \`sys-info\` use a static, global, list to store temporary disk information while running. The function that cleans up this list,
\`DFCleanup\`, assumes a single threaded environment and will try to free the same memory twice in a multithreaded environment.
This results in consistent double-frees and segfaults when calling \`sys_info::disk_info\` from multiple threads at once.
The issue was fixed by moving the global variable into a local scope.
##### Safer Alternatives:
- [\`sysinfo\`](https://crates.io/crates/sysinfo)
#### Severity
- CVSS Score: 9.8 / 10 (Critical)
- Vector String: \`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H\`
#### References
No references.
This data is provided by [OSV](https://osv.dev/vulnerability/RUSTSEC-2020-0100) and the [Rust Advisory Database](https://github.com/RustSec/advisory-db) ([CC0 1.0](https://github.com/rustsec/advisory-db/blob/main/LICENSE.txt)).
</details>
`,
],
},
]);
});
});
describe('evaluateCvssVector', () => {
it.each`
input | output
${'some-invalid-vector'} | ${['', '']}
${'CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N'} | ${['5.3', 'MEDIUM']}
${'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'} | ${['9.8', 'CRITICAL']}
${'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L'} | ${['5.3', 'MEDIUM']}
${'AV:N/AC:L/Au:N/C:P/I:P/A:P'} | ${['7.5', 'UNKNOWN']}
${'AV:N'} | ${['0.0', 'UNKNOWN']}
${'CVSS:3.1/AV:N'} | ${['0.0', 'NONE']}
`('$input', ({ input, output }) => {
expect(Vulnerabilities.evaluateCvssVector(input)).toMatchObject(output);
});
});
});
function resetOsv() {
// @ts-expect-error - reset the cached OSV client to avoid state leak between tests
Vulnerabilities.osvOffline = undefined;
}