-
Notifications
You must be signed in to change notification settings - Fork 730
feat: add filters and risk sort to GET /v1/packages #4203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,22 +57,40 @@ export interface PackageListRow { | |
| stewardshipStatus: string | null | ||
| openVulns: number | ||
| maintainerCount: number | ||
| scorecardScore: number | null | ||
| total: string | ||
| } | ||
|
|
||
| export type HealthBand = 'healthy' | 'fair' | 'concerning' | 'critical' | ||
| export type VulnSeverityFilter = 'any' | 'high' | 'critical' | ||
|
|
||
| export interface ListPackagesOptions { | ||
| page: number | ||
| pageSize: number | ||
| ecosystem?: string | ||
| lifecycle?: string | ||
| name?: string | ||
| status?: string | ||
| healthBand?: HealthBand | ||
| vulnSeverity?: VulnSeverityFilter | ||
| staleOnly: boolean | ||
| unstewardedOnly: boolean | ||
| busFactor1Only: boolean | ||
| sortBy: 'name' | 'impact' | 'openVulns' | ||
| sortBy: 'name' | 'impact' | 'openVulns' | 'health' | 'risk' | ||
| sortDir: 'asc' | 'desc' | ||
| } | ||
|
|
||
| const STALE_MONTHS = 18 | ||
|
|
||
| // Severity stored as uppercase in advisories table. | ||
| // Ranks: CRITICAL=4, HIGH=3, MEDIUM=2, LOW=1 | ||
| const SEVERITY_RANK_EXPR = `MAX(CASE a.severity | ||
| WHEN 'CRITICAL' THEN 4 | ||
| WHEN 'HIGH' THEN 3 | ||
| WHEN 'MEDIUM' THEN 2 | ||
| WHEN 'LOW' THEN 1 | ||
| ELSE 0 END)::int` | ||
|
|
||
| export async function listPackagesForApi( | ||
| qx: QueryExecutor, | ||
| opts: ListPackagesOptions, | ||
|
|
@@ -85,6 +103,55 @@ export async function listPackagesForApi( | |
| params.ecosystem = opts.ecosystem | ||
| } | ||
|
|
||
| if (opts.name) { | ||
| conditions.push('p.name ILIKE $(name)') | ||
| params.name = `%${opts.name}%` | ||
| } | ||
|
|
||
| // Exclude packages with no registry status when a lifecycle filter is active. | ||
| // Full lifecycle column support is pending; this prevents null-lifecycle rows | ||
| // from leaking into filtered results. | ||
| if (opts.lifecycle) { | ||
| conditions.push('p.status IS NOT NULL') | ||
| } | ||
|
|
||
| if (opts.status) { | ||
| // 'unassigned' includes packages that have no stewardship row yet | ||
| if (opts.status === 'unassigned') { | ||
| conditions.push(`(s.status = 'unassigned' OR s.id IS NULL)`) | ||
| } else { | ||
| conditions.push('s.status = $(status)') | ||
| params.status = opts.status | ||
| } | ||
| } | ||
|
|
||
| if (opts.healthBand) { | ||
| // scorecard_score is 0–10; multiply by 10 to get 0–100 health score. | ||
| // Packages with no linked repo (scorecard_score IS NULL) fall into 'critical'. | ||
| if (opts.healthBand === 'healthy') { | ||
| conditions.push('r_sc.scorecard_score >= 7.0') | ||
| } else if (opts.healthBand === 'fair') { | ||
| conditions.push('r_sc.scorecard_score >= 5.0 AND r_sc.scorecard_score < 7.0') | ||
| } else if (opts.healthBand === 'concerning') { | ||
| conditions.push('r_sc.scorecard_score >= 3.0 AND r_sc.scorecard_score < 5.0') | ||
| } else { | ||
| // critical band includes no-repo packages (NULL scorecard) | ||
| conditions.push('(r_sc.scorecard_score IS NULL OR r_sc.scorecard_score < 3.0)') | ||
| } | ||
| } | ||
|
|
||
| if (opts.vulnSeverity) { | ||
| if (opts.vulnSeverity === 'any') { | ||
| conditions.push('ap_counts.cnt > 0') | ||
| } else if (opts.vulnSeverity === 'high') { | ||
| // high includes packages where worst severity is HIGH or CRITICAL | ||
| conditions.push('ap_severity.max_rank >= 3') | ||
| } else { | ||
| // critical: worst severity is CRITICAL only | ||
| conditions.push('ap_severity.max_rank >= 4') | ||
| } | ||
| } | ||
|
|
||
| if (opts.staleOnly) { | ||
| conditions.push( | ||
| `(p.latest_release_at IS NULL OR p.latest_release_at < NOW() - INTERVAL '${STALE_MONTHS} months')`, | ||
|
|
@@ -102,16 +169,56 @@ export async function listPackagesForApi( | |
|
|
||
| const where = `WHERE ${conditions.join(' AND ')}` | ||
|
|
||
| // health is a v2 field — fall back to name sort | ||
| let sortExpr: string | ||
| if (opts.sortBy === 'impact') sortExpr = 'p.impact' | ||
| else if (opts.sortBy === 'openVulns') sortExpr = '"openVulns"' | ||
| else sortExpr = 'LOWER(p.name)' | ||
| if (opts.sortBy === 'impact') { | ||
| sortExpr = 'p.impact' | ||
| } else if (opts.sortBy === 'openVulns') { | ||
| sortExpr = '"openVulns"' | ||
| } else if (opts.sortBy === 'health') { | ||
| sortExpr = 'r_sc.scorecard_score' | ||
| } else if (opts.sortBy === 'risk') { | ||
| // Composite risk score: impact + health deficit + vuln exposure + bus factor + staleness | ||
| sortExpr = `( | ||
| COALESCE(p.impact, 0) * 100 | ||
| + (100.0 - COALESCE(r_sc.scorecard_score, 0) * 10) * 0.8 | ||
| + COALESCE(ap_severity.max_rank, 0) * 15 | ||
| + COALESCE(ap_counts.cnt, 0) * 4 | ||
| + CASE WHEN pm_counts.cnt = 1 THEN 20 ELSE 0 END | ||
| + CASE WHEN (p.latest_release_at IS NULL OR p.latest_release_at < NOW() - INTERVAL '${STALE_MONTHS} months') THEN 15 ELSE 0 END | ||
| )` | ||
| } else { | ||
| sortExpr = 'LOWER(p.name)' | ||
| } | ||
| const sortDir = opts.sortDir === 'desc' ? 'DESC' : 'ASC' | ||
|
|
||
| // Separate paginated params from filter-only params used by the fallback COUNT query | ||
| const queryParams = { ...params, limit: opts.pageSize, offset: (opts.page - 1) * opts.pageSize } | ||
|
|
||
| // Shared LATERAL clauses — included in both the main query and the count fallback | ||
| // so that WHERE conditions referencing them work in both paths. | ||
| const laterals = ` | ||
| LEFT JOIN stewardships s ON s.package_id = p.id | ||
| LEFT JOIN LATERAL ( | ||
| SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id | ||
| ) ap_counts ON true | ||
| LEFT JOIN LATERAL ( | ||
| SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id | ||
| ) pm_counts ON true | ||
| LEFT JOIN LATERAL ( | ||
| SELECT ${SEVERITY_RANK_EXPR} AS max_rank | ||
| FROM advisory_packages ap | ||
| JOIN advisories a ON a.id = ap.advisory_id | ||
| WHERE ap.package_id = p.id | ||
| ) ap_severity ON true | ||
| LEFT JOIN LATERAL ( | ||
| SELECT r.scorecard_score | ||
| FROM package_repos pr | ||
| JOIN repos r ON r.id = pr.repo_id | ||
| WHERE pr.package_id = p.id | ||
| ORDER BY pr.confidence DESC | ||
| LIMIT 1 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scorecard repo pick inconsistentMedium Severity The new Reviewed by Cursor Bugbot for commit 4e0679d. Configure here. |
||
| ) r_sc ON true` | ||
|
|
||
| const rows: PackageListRow[] = await qx.select( | ||
| ` | ||
| SELECT | ||
|
|
@@ -122,15 +229,10 @@ export async function listPackagesForApi( | |
| s.status AS "stewardshipStatus", | ||
| COALESCE(ap_counts.cnt, 0) AS "openVulns", | ||
| pm_counts.cnt AS "maintainerCount", | ||
| r_sc.scorecard_score AS "scorecardScore", | ||
| COUNT(*) OVER() AS total | ||
| FROM packages p | ||
| LEFT JOIN stewardships s ON s.package_id = p.id | ||
| LEFT JOIN LATERAL ( | ||
| SELECT COUNT(*)::int AS cnt FROM advisory_packages WHERE package_id = p.id | ||
| ) ap_counts ON true | ||
| LEFT JOIN LATERAL ( | ||
| SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id | ||
| ) pm_counts ON true | ||
| ${laterals} | ||
| ${where} | ||
| ORDER BY ${sortExpr} ${sortDir} NULLS LAST, p.purl ${sortDir} | ||
| LIMIT $(limit) OFFSET $(offset) | ||
|
|
@@ -147,10 +249,7 @@ export async function listPackagesForApi( | |
| const countRow: { count: string } = await qx.selectOne( | ||
| `SELECT COUNT(*)::text AS count | ||
| FROM packages p | ||
| LEFT JOIN stewardships s ON s.package_id = p.id | ||
| LEFT JOIN LATERAL ( | ||
| SELECT COUNT(*)::int AS cnt FROM package_maintainers pm WHERE pm.package_id = p.id | ||
| ) pm_counts ON true | ||
| ${laterals} | ||
| ${where}`, | ||
| params, | ||
| ) | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lifecycle filter ignores value
Medium Severity
When
lifecycleis passed from the list endpoint, the DAL only addsp.status IS NOT NULLand never compares the requested lifecycle value (active,stable,declining,abandoned). Every lifecycle choice returns the same result set while the response still echoes the chosen filter.Reviewed by Cursor Bugbot for commit 4e0679d. Configure here.