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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
- 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 All @@ -57,6 +59,7 @@

- Links from a grouping page to a dependency's page work when the grouping spans ecosystems. Those pages sit at the site root, so the `../details/…` path a plugin builds leads nowhere, which broke `BasicCompliancePlugin`'s flagged entries. `GroupingFlag.detailLink` is optional now and Dependicus resolves it, and `GroupingDetailContext.detailLinkFor` resolves one for a link inside a section's own HTML.

- The due date was wrong on an issue that covers two versions of one dependency. Say `react` 17 needs 17.0.5 with a 28-day threshold and `react` 18 needs 18.2.1 with a 10-day threshold. The issue asked for 18.2.1 but added the 28 days to its release date. It now uses the threshold that belongs to the version it asks for.
- Deprecation detection now works on pnpm 11 and pnpm 12, not just pnpm 10.
- pnpm 12 removed the `pnpm install --resolution-only` flag Dependicus used to find deprecated packages, so `dependicus update` failed outright against any pnpm 12 workspace and produced no output at all.
- pnpm 11 and pnpm 12 skip re-resolving when the lockfile and `node_modules` already agree, which left every package looking undeprecated. Dependicus now asks pnpm to resolve anyway.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/githubissues.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ void dependicusCli({

## One issue per team, and security fixes

GitHub issue specs accept the same `scope`, `usedBy` and `minimumVersion` fields as Linear ones. Return one scoped spec per team to file an issue for each team, and set `minimumVersion` to ask for the release that fixes a vulnerability. [Creating and Updating Linear Issues](linearissues.md#one-issue-per-team) covers both.
GitHub issue specs accept the same `scope`, `usedBy` and `minimumVersion` fields as Linear ones, and get the same `dependencyVersions` in their context. Return one scoped spec per team to file an issue for each team, and set `minimumVersion` to ask for the release that fixes a vulnerability. [Creating and Updating Linear Issues](linearissues.md#one-issue-per-team) covers them.

## CLI flags

Expand Down
8 changes: 7 additions & 1 deletion docs/docs/linearissues.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ getLinearIssueSpec: (context) => {

`context.usedBy` lists the packages that use the version. A spec's `usedBy` limits its issue to the packages that team owns.

`context.dependencyVersions` lists every version of the dependency in the repo, with the packages on each, so a spec can see what else a team is on.

If two versions get specs with the same scope, they share one issue. It lists both versions and asks for the higher of their two `minimumVersion`s. Its due date is that version's release date plus that spec's `thresholdDays`.

Dependicus puts the scope in the title, like `[Dependicus] [npm] [Payments] Update react from ...`, and uses it to find the issue on later runs. Each team's issue is updated and closed separately. A scope can't contain square brackets.

A spec without a scope works as it did before, and so do the issues it filed. A plugin that returns a single spec, like `SecurityPlugin` with its advisory sections, is merged into every scoped spec.
Expand Down Expand Up @@ -122,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
54 changes: 54 additions & 0 deletions src/github-issues/issueReconciler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,4 +690,58 @@ describe('reconcileGitHubIssues with scoped specs', () => {

expect(result).toMatchObject({ created: 0, updated: 2, closed: 0, closedDuplicates: 0 });
});

it("counts a merged issue's due date from the release it asks for", async () => {
setupMocks();
const v1 = makeVersion({ version: '1.2.0', latestVersion: '3.0.0' });
const v2 = makeVersion({ version: '2.0.0', latestVersion: '3.0.0' });
const between: PackageVersionInfo[] = [
{ version: '1.2.5', publishDate: '2024-02-01', isPrerelease: false },
{ version: '2.0.3', publishDate: '2024-05-01', isPrerelease: false },
{ version: '3.0.0', publishDate: '2024-06-01', isPrerelease: false },
];
const root = new RootFactStore();
const scoped = root.scoped('npm');
scoped.setVersionFact('test-pkg', '1.2.0', FactKeys.VERSIONS_BETWEEN, between);
scoped.setVersionFact('test-pkg', '2.0.0', FactKeys.VERSIONS_BETWEEN, between.slice(1));
const fixes: Record<string, { minimumVersion: string; thresholdDays: number }> = {
'1.2.0': { minimumVersion: '1.2.5', thresholdDays: 28 },
'2.0.0': { minimumVersion: '2.0.3', thresholdDays: 10 },
};

await reconcileGitHubIssues(
[{ name: 'test-pkg', ecosystem: 'npm', versions: [v1, v2] }],
root,
baseConfig,
(ctx) => makeSpec({ policy: { type: 'dueDate' }, ...fixes[ctx.currentVersion] }),
);

const createCall = mockOctokit.issues.create.mock.calls[0]![0];
expect(createCall.title).toContain('to at least 2.0.3');
// 2.0.3 shipped 2024-05-01 and its spec said 10 days, not the first
// version's 28.
expect(createCall.title).toContain('(due 2024-05-11)');
});

it('hands the spec every version of the dependency', async () => {
setupMocks();
const v1 = makeVersion({ version: '1.0.0', usedBy: ['web'] });
const v2 = makeVersion({ version: '1.5.0', usedBy: ['admin'] });
const seen: string[][] = [];

await reconcileGitHubIssues(
[{ name: 'test-pkg', ecosystem: 'npm', versions: [v1, v2] }],
makeStore(),
baseConfig,
(ctx) => {
seen.push(
(ctx.dependencyVersions ?? []).map((v) => `${v.version}:${v.usedBy.join(',')}`),
);
return undefined;
},
);

const both = ['1.0.0:web', '1.5.0:admin'];
expect(seen).toEqual([both, both]);
});
});
18 changes: 16 additions & 2 deletions src/github-issues/issueReconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export async function reconcileGitHubIssues(
currentVersion: version.version,
latestVersion: version.latestVersion,
usedBy: version.usedBy,
dependencyVersions: dep.versions,
};

const scopedStore = store.scoped(dep.ecosystem);
Expand Down Expand Up @@ -380,11 +381,23 @@ export async function reconcileGitHubIssues(
}

// Ask for the highest minimum any version needs, so the
// title's version gets its fix too.
existing.minimumVersion = higherVersion(
// title's version gets its fix too. The issue is due
// thresholdDays after that release, so the threshold
// has to be the one the spec set for that release. A
// threshold chosen for 1.2.5, added to 2.0.3's release
// date, lands on the wrong day.
const minimumVersion = higherVersion(
existing.minimumVersion,
ctx.minimumVersion,
);
if (
minimumVersion !== existing.minimumVersion &&
thresholdDays !== undefined &&
!isNotificationsOnly
) {
existing.worstCompliance = { ...existing.worstCompliance, thresholdDays };
}
existing.minimumVersion = minimumVersion;

if (availableMajorVersion && !existing.availableMajorVersion) {
existing.availableMajorVersion = availableMajorVersion;
Expand Down Expand Up @@ -477,6 +490,7 @@ export async function reconcileGitHubIssues(
currentVersion: version.version,
latestVersion: version.latestVersion,
usedBy: version.usedBy,
dependencyVersions: dep.versions,
},
store.scoped(dep.ecosystem),
),
Expand Down
7 changes: 7 additions & 0 deletions src/github-issues/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ export interface VersionContext {
latestVersion: string;
/** The packages that use this version. */
usedBy?: readonly string[];
/**
* Every version of the dependency in the repo, this one included, each
* with the packages that use it. A spec that files one issue per team can
* see what else that team is on, so the versions it covers agree on what
* to ask for.
*/
dependencyVersions?: readonly DependencyVersion[];
}

// ── Internal types (not plugin-facing) ───────────────────────────────
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
49 changes: 49 additions & 0 deletions src/linear/issueReconciler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1690,4 +1690,53 @@ describe('reconcileIssues with scoped specs', () => {
);
expect(input.dueDate).toBe('2024-05-29');
});

it("counts a merged issue's due date from the release it asks for", async () => {
const v1 = makeVersion({ version: '1.2.0', latestVersion: '3.0.0' });
const v2 = makeVersion({ version: '2.0.0', latestVersion: '3.0.0' });
const between: PackageVersionInfo[] = [
{ version: '1.2.5', publishDate: '2024-02-01', isPrerelease: false },
{ version: '2.0.3', publishDate: '2024-05-01', isPrerelease: false },
{ version: '3.0.0', publishDate: '2024-06-01', isPrerelease: false },
];
populateFacts(store, 'test-pkg', v1, { versionsBetween: between });
populateFacts(store, 'test-pkg', v2, { versionsBetween: between.slice(1) });
// Each version's spec sets the threshold that suits its own fix.
const fixes: Record<string, { minimumVersion: string; thresholdDays: number }> = {
'1.2.0': { minimumVersion: '1.2.5', thresholdDays: 28 },
'2.0.0': { minimumVersion: '2.0.3', thresholdDays: 10 },
};

await reconcileIssues([makeDep('test-pkg', [v1, v2])], store, liveConfig, (ctx) => ({
teamId: 'team-web',
policy: { type: 'dueDate' },
...fixes[ctx.currentVersion],
}));

const [input] = mockClient.createIssue.mock.calls[0] as [
{ title: string; dueDate: string },
];
expect(input.title).toContain('to at least 2.0.3');
// 2.0.3 shipped 2024-05-01 and its spec said 10 days. The first
// version's 28 days would have put this on 05-29.
expect(input.dueDate).toBe('2024-05-11');
});

it('hands the spec every version of the dependency', async () => {
const v1 = makeVersion({ version: '1.0.0', usedBy: ['@app/web'] });
const v2 = makeVersion({ version: '1.5.0', usedBy: ['@app/admin'] });
populateFacts(store, 'test-pkg', v1, { versionsBetween: [] });
populateFacts(store, 'test-pkg', v2, { versionsBetween: [] });
const seen: string[][] = [];

await reconcileIssues([makeDep('test-pkg', [v1, v2])], store, liveConfig, (ctx) => {
seen.push(
(ctx.dependencyVersions ?? []).map((v) => `${v.version}:${v.usedBy.join(',')}`),
);
return undefined;
});

const both = ['1.0.0:@app/web', '1.5.0:@app/admin'];
expect(seen).toEqual([both, both]);
});
});
19 changes: 16 additions & 3 deletions src/linear/issueReconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export async function reconcileIssues(
currentVersion: version.version,
latestVersion: version.latestVersion,
usedBy: version.usedBy,
dependencyVersions: dep.versions,
};

const scopedStore = store.scoped(dep.ecosystem);
Expand Down Expand Up @@ -393,13 +394,24 @@ export async function reconcileIssues(
existing.policy = aggregatePolicy(existing.policy, effectivePolicy);
}

// Keep track of major version if available
// Ask for the highest minimum any version needs, so the
// title's version gets its fix too.
existing.minimumVersion = higherVersion(
// title's version gets its fix too. The issue is due
// thresholdDays after that release, so the threshold
// has to be the one the spec set for that release. A
// threshold chosen for 1.2.5, added to 2.0.3's release
// date, lands on the wrong day.
const minimumVersion = higherVersion(
existing.minimumVersion,
ctx.minimumVersion,
);
if (
minimumVersion !== existing.minimumVersion &&
thresholdDays !== undefined &&
!isNotificationsOnly
) {
existing.worstCompliance = { ...existing.worstCompliance, thresholdDays };
}
existing.minimumVersion = minimumVersion;

if (availableMajorVersion && !existing.availableMajorVersion) {
existing.availableMajorVersion = availableMajorVersion;
Expand Down Expand Up @@ -497,6 +509,7 @@ export async function reconcileIssues(
currentVersion: version.version,
latestVersion: version.latestVersion,
usedBy: version.usedBy,
dependencyVersions: dep.versions,
},
store.scoped(dep.ecosystem),
),
Expand Down
7 changes: 7 additions & 0 deletions src/linear/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ export interface VersionContext {
latestVersion: string;
/** The packages that use this version. */
usedBy?: readonly string[];
/**
* Every version of the dependency in the repo, this one included, each
* with the packages that use it. A spec that files one issue per team can
* see what else that team is on, so the versions it covers agree on what
* to ask for.
*/
dependencyVersions?: readonly DependencyVersion[];
}

// ── Internal types (not plugin-facing) ───────────────────────────────
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
Loading
Loading