From 217f6b91eaf3b3ef910920fb7649c8e36b64da6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Thu, 11 Jun 2026 21:25:39 +0200 Subject: [PATCH 1/3] feat(packages): add name search filter to list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gašper Grom --- backend/src/api/public/v1/packages/listPackages.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/api/public/v1/packages/listPackages.ts b/backend/src/api/public/v1/packages/listPackages.ts index 9f305f5955..f97ae41afa 100644 --- a/backend/src/api/public/v1/packages/listPackages.ts +++ b/backend/src/api/public/v1/packages/listPackages.ts @@ -21,6 +21,7 @@ const querySchema = z.object({ pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE), ecosystem: z.string().trim().optional(), lifecycle: z.enum(lifecycleValues).optional(), // TODO: filter not yet implemented in DAL + name: z.string().trim().optional(), busFactor1Only: booleanQueryParam, staleOnly: booleanQueryParam, unstewardedOnly: booleanQueryParam, @@ -34,6 +35,7 @@ export async function listPackages(req: Request, res: Response): Promise { pageSize, ecosystem, lifecycle: _lifecycle, + name, busFactor1Only, staleOnly, unstewardedOnly, @@ -49,6 +51,7 @@ export async function listPackages(req: Request, res: Response): Promise { page, pageSize, ecosystem, + name, staleOnly, unstewardedOnly, busFactor1Only, @@ -76,6 +79,7 @@ export async function listPackages(req: Request, res: Response): Promise { filters: { ecosystem: ecosystem ?? null, lifecycle: null, // TODO: filter not yet implemented in DAL + name: name ?? null, busFactor1Only, staleOnly, unstewardedOnly, From a0d4d26d365d0f89de3abe82efccb1329b02a19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Thu, 11 Jun 2026 21:26:14 +0200 Subject: [PATCH 2/3] feat(packages): add name search filter to DAL listPackagesForApi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gašper Grom --- services/libs/data-access-layer/src/osspckgs/api.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index a7460fcad1..954e6e5152 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -64,6 +64,7 @@ export interface ListPackagesOptions { page: number pageSize: number ecosystem?: string + name?: string staleOnly: boolean unstewardedOnly: boolean busFactor1Only: boolean @@ -85,6 +86,11 @@ export async function listPackagesForApi( params.ecosystem = opts.ecosystem } + if (opts.name) { + conditions.push('p.name ILIKE $(name)') + params.name = `%${opts.name}%` + } + if (opts.staleOnly) { conditions.push( `(p.latest_release_at IS NULL OR p.latest_release_at < NOW() - INTERVAL '${STALE_MONTHS} months')`, From 4e0679d3a2ac4b4baa980012cba9fc7af1360480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C5=A1per=20Grom?= Date: Thu, 11 Jun 2026 23:19:23 +0200 Subject: [PATCH 3/3] feat: add status, healthBand, vulnSeverity filters and risk sort to packages list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gašper Grom --- .../api/public/v1/packages/listPackages.ts | 42 ++++-- .../data-access-layer/src/osspckgs/api.ts | 125 +++++++++++++++--- 2 files changed, 141 insertions(+), 26 deletions(-) diff --git a/backend/src/api/public/v1/packages/listPackages.ts b/backend/src/api/public/v1/packages/listPackages.ts index f97ae41afa..9d15196807 100644 --- a/backend/src/api/public/v1/packages/listPackages.ts +++ b/backend/src/api/public/v1/packages/listPackages.ts @@ -15,17 +15,32 @@ const MAX_PAGE_SIZE = 100 const booleanQueryParam = z.preprocess((v) => v === 'true', z.boolean()).default(false) const lifecycleValues = ['active', 'stable', 'declining', 'abandoned'] as const +const stewardshipStatusValues = [ + 'unassigned', + 'open', + 'assessing', + 'active', + 'needs_attention', + 'escalated', + 'blocked', + 'inactive', +] as const +const healthBandValues = ['healthy', 'fair', 'concerning', 'critical'] as const +const vulnSeverityValues = ['any', 'high', 'critical'] as const const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE), ecosystem: z.string().trim().optional(), - lifecycle: z.enum(lifecycleValues).optional(), // TODO: filter not yet implemented in DAL + lifecycle: z.enum(lifecycleValues).optional(), name: z.string().trim().optional(), + status: z.enum(stewardshipStatusValues).optional(), + healthBand: z.enum(healthBandValues).optional(), + vulnSeverity: z.enum(vulnSeverityValues).optional(), busFactor1Only: booleanQueryParam, staleOnly: booleanQueryParam, unstewardedOnly: booleanQueryParam, - sortBy: z.enum(['name', 'health', 'impact', 'openVulns']).default('name'), + sortBy: z.enum(['name', 'health', 'impact', 'openVulns', 'risk']).default('name'), sortDir: z.enum(['asc', 'desc']).default('asc'), }) @@ -34,8 +49,11 @@ export async function listPackages(req: Request, res: Response): Promise { page, pageSize, ecosystem, - lifecycle: _lifecycle, + lifecycle, name, + status, + healthBand, + vulnSeverity, busFactor1Only, staleOnly, unstewardedOnly, @@ -43,19 +61,20 @@ export async function listPackages(req: Request, res: Response): Promise { sortDir, } = validateOrThrow(querySchema, req.query) - // health is a v2 field with no backing column yet — fall back to name sort - const effectiveSortBy = sortBy === 'health' ? 'name' : sortBy - const qx = await getPackagesQx() const { rows, total } = await listPackagesForApi(qx, { page, pageSize, ecosystem, + lifecycle, name, + status, + healthBand, + vulnSeverity, staleOnly, unstewardedOnly, busFactor1Only, - sortBy: effectiveSortBy, + sortBy, sortDir, }) @@ -63,7 +82,7 @@ export async function listPackages(req: Request, res: Response): Promise { purl: r.purl, name: r.name, ecosystem: r.ecosystem, - health: null, + health: r.scorecardScore != null ? Math.round(Number(r.scorecardScore) * 10) : null, impact: r.criticalityScore != null ? Math.round(Number(r.criticalityScore) * 100) : null, lifecycle: null, maintainerBusFactor: r.maintainerCount, @@ -78,13 +97,16 @@ export async function listPackages(req: Request, res: Response): Promise { total, filters: { ecosystem: ecosystem ?? null, - lifecycle: null, // TODO: filter not yet implemented in DAL + lifecycle: lifecycle ?? null, name: name ?? null, + status: status ?? null, + healthBand: healthBand ?? null, + vulnSeverity: vulnSeverity ?? null, busFactor1Only, staleOnly, unstewardedOnly, }, - sort: { by: effectiveSortBy, dir: sortDir }, + sort: { by: sortBy, dir: sortDir }, packages, }) } diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 954e6e5152..21c0296165 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -57,23 +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, @@ -91,6 +108,50 @@ export async function listPackagesForApi( 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')`, @@ -108,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 + ) r_sc ON true` + const rows: PackageListRow[] = await qx.select( ` SELECT @@ -128,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) @@ -153,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, )