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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/docs/linearissues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions src/security/SecurityPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
53 changes: 49 additions & 4 deletions src/security/SecurityPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────

Expand Down Expand Up @@ -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
Expand All @@ -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<SecurityFinding[]>(name, version, SECURITY_FINDINGS_KEY) ?? [];
const { minSeverity } = options;
if (!minSeverity) return highestFixVersion(findings);
return highestFixVersion(
store.getVersionFact<SecurityFinding[]>(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 ?? []),
);
}

Expand Down
1 change: 1 addition & 0 deletions src/security/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { SecurityPlugin, getFixVersion } from './SecurityPlugin';
export type { FixVersionOptions } from './SecurityPlugin';
export type {
SecurityPluginConfig,
OsvConfig,
Expand Down
Loading