From 44d2bab6563d3e26ac2ff2d8e62b3d8e6f5c1faf Mon Sep 17 00:00:00 2001 From: Steve Landey Date: Tue, 13 Oct 2026 10:14:29 -0700 Subject: [PATCH] Let getFixVersion count only advisories at least a severity A policy that files tickets for high and critical advisories wants the release that fixes those, due four weeks after it shipped. The fix version on a finding is the lowest release that fixes every advisory, so a medium advisory fixed later moved the ask and the deadline to its release. getFixVersion(store, name, version, { minSeverity: 'high' }) leaves the medium out. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + docs/docs/linearissues.md | 4 ++- src/index.ts | 1 + src/security/SecurityPlugin.test.ts | 39 +++++++++++++++++++++ src/security/SecurityPlugin.ts | 53 ++++++++++++++++++++++++++--- src/security/index.ts | 1 + 6 files changed, 94 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 544cf8a..b50bb3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ - Plugin specs merge per scope. A plugin returning a single spec, like `SecurityPlugin`'s description sections, reaches every scoped issue. - Issue specs can set `minimumVersion`, the lowest release that resolves the issue. The title asks for at least that version, and the due date counts from when it was published. Without it, issues still ask for the first release of the update type needed, which for a vulnerability is often not the release that fixes it. - The Snyk source records which versions fix each advisory, and the lowest version that fixes them all. Tickets and dependency pages show them, and `getFixVersion(store, name, version)` returns it for use as `minimumVersion`. +- `getFixVersion` takes `{ minSeverity }`, to ask for the release that fixes the high and critical advisories and not wait for one that also fixes a medium. - `VersionContext.dependencyVersions` lists every version of the dependency in the repo, with the packages on each, so a spec filing one issue per team can see what else that team is on. ### Changed diff --git a/docs/docs/linearissues.md b/docs/docs/linearissues.md index dd3727a..afeac12 100644 --- a/docs/docs/linearissues.md +++ b/docs/docs/linearissues.md @@ -126,11 +126,13 @@ getLinearIssueSpec: (context, store) => { teamId: 'your-team-uuid', policy: { type: 'dueDate' }, thresholdDays: 28, - minimumVersion: getFixVersion(store, context.name, context.currentVersion), + minimumVersion: getFixVersion(store, context.name, context.currentVersion, { minSeverity: 'high' }), }; }, ``` +`minSeverity` counts only the advisories at least that severe. Without it, a medium advisory fixed later than the high one moves the ask, and the due date, to its release. + ## CLI flags The `make-linear-issues` command accepts these flags in addition to `--dry-run`, `--json-file`, and `--linear-team-id`: diff --git a/src/index.ts b/src/index.ts index 944b5cf..7b3ccee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,7 @@ export type { GitHubIssueAssignment } from './github-issues/index'; /** @group Security */ export { SecurityPlugin, getFixVersion } from './security/index'; +export type { FixVersionOptions } from './security/index'; /** @group Security */ export type { diff --git a/src/security/SecurityPlugin.test.ts b/src/security/SecurityPlugin.test.ts index c131249..132595d 100644 --- a/src/security/SecurityPlugin.test.ts +++ b/src/security/SecurityPlugin.test.ts @@ -324,6 +324,45 @@ describe('fix versions', () => { expect(getFixVersion(storeWith([]), 'body-parser', '1.20.1')).toBeUndefined(); }); + it('getFixVersion can count only advisories at least a severity', () => { + const mixed: SecurityFinding[] = [ + { + source: 'snyk', + sourceLabel: 'Snyk', + severity: 'high', + fixAvailable: true, + fixVersion: '1.20.8', + advisories: [ + { + id: 'SNYK-1', + severity: 'high', + fixAvailable: true, + fixVersions: ['1.20.6'], + url: 'https://security.snyk.io/vuln/SNYK-1', + }, + { + id: 'SNYK-2', + severity: 'medium', + fixAvailable: true, + fixVersions: ['1.20.8'], + url: 'https://security.snyk.io/vuln/SNYK-2', + }, + ], + }, + ]; + expect(getFixVersion(storeWith(mixed), 'body-parser', '1.20.1')).toBe('1.20.8'); + expect( + getFixVersion(storeWith(mixed), 'body-parser', '1.20.1', { minSeverity: 'high' }), + ).toBe('1.20.6'); + // A finding without per-advisory detail counts by its own severity. + const whole: SecurityFinding[] = [ + { source: 'osv', sourceLabel: 'OSV', severity: 'medium', fixVersion: '1.20.9' }, + ]; + expect( + getFixVersion(storeWith(whole), 'body-parser', '1.20.1', { minSeverity: 'high' }), + ).toBeUndefined(); + }); + it('puts the fix versions in ticket descriptions', () => { const plugin = new SecurityPlugin({}); const spec = plugin.getLinearIssueSpec( diff --git a/src/security/SecurityPlugin.ts b/src/security/SecurityPlugin.ts index 88fe7e2..a1e6542 100644 --- a/src/security/SecurityPlugin.ts +++ b/src/security/SecurityPlugin.ts @@ -17,7 +17,7 @@ import { compareVersions } from '../core/utils/versionUtils'; import { OsvSource } from './sources/OsvSource'; import { DepsDevSource } from './sources/DepsDevSource'; import { GitHubAdvisorySource } from './sources/GitHubAdvisorySource'; -import { SnykSource } from './sources/SnykSource'; +import { SnykSource, lowestFixVersion } from './sources/SnykSource'; // ── DependicusPlugin implementation ───────────────────────────────── @@ -537,6 +537,17 @@ function highestFixVersion(findings: readonly SecurityFinding[]): string | undef return highest; } +/** @group Security */ +export interface FixVersionOptions { + /** + * Count only advisories at least this severe. The fix returned is the + * lowest release that resolves them, even when a less severe advisory is + * fixed later. An advisory with no severity of its own takes the + * severity of its finding. + */ + minSeverity?: Severity; +} + /** * The lowest version that fixes the known vulnerabilities in a dependency * version, for issue specs to pass as `minimumVersion`. Undefined when no @@ -545,13 +556,47 @@ function highestFixVersion(findings: readonly SecurityFinding[]): string | undef * @example * getLinearIssueSpec: (ctx, store) => ({ * teamId, - * minimumVersion: getFixVersion(store, ctx.name, ctx.currentVersion), + * minimumVersion: getFixVersion(store, ctx.name, ctx.currentVersion, { minSeverity: 'high' }), * }) * @group Security */ -export function getFixVersion(store: FactStore, name: string, version: string): string | undefined { +export function getFixVersion( + store: FactStore, + name: string, + version: string, + options: FixVersionOptions = {}, +): string | undefined { + const findings = + store.getVersionFact(name, version, SECURITY_FINDINGS_KEY) ?? []; + const { minSeverity } = options; + if (!minSeverity) return highestFixVersion(findings); return highestFixVersion( - store.getVersionFact(name, version, SECURITY_FINDINGS_KEY) ?? [], + findings.map((f) => ({ ...f, fixVersion: fixVersionAtLeast(f, minSeverity, version) })), + ); +} + +function atLeast(severity: Severity | undefined, floor: Severity): boolean { + return ( + severity !== undefined && SEVERITY_ORDER.indexOf(severity) >= SEVERITY_ORDER.indexOf(floor) + ); +} + +/** + * A finding's fix version counting only its advisories at least `floor` + * severe. A finding without per-advisory detail counts or not as a whole. + */ +function fixVersionAtLeast( + finding: SecurityFinding, + floor: Severity, + version: string, +): string | undefined { + if (!finding.advisories?.length) { + return atLeast(finding.severity, floor) ? finding.fixVersion : undefined; + } + const kept = finding.advisories.filter((a) => atLeast(a.severity ?? finding.severity, floor)); + return lowestFixVersion( + version, + kept.map((a) => a.fixVersions ?? []), ); } diff --git a/src/security/index.ts b/src/security/index.ts index ffdadde..d030af1 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -1,4 +1,5 @@ export { SecurityPlugin, getFixVersion } from './SecurityPlugin'; +export type { FixVersionOptions } from './SecurityPlugin'; export type { SecurityPluginConfig, OsvConfig,