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
46 changes: 36 additions & 10 deletions backend/src/api/public/v1/packages/listPackages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +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'),
})

Expand All @@ -33,34 +49,40 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
page,
pageSize,
ecosystem,
lifecycle: _lifecycle,
lifecycle,
name,
status,
healthBand,
vulnSeverity,
busFactor1Only,
staleOnly,
unstewardedOnly,
sortBy,
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,
})

const packages = rows.map((r) => ({
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,
Expand All @@ -75,12 +97,16 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
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,
})
}
131 changes: 115 additions & 16 deletions services/libs/data-access-layer/src/osspckgs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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')
}

Copy link
Copy Markdown

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 lifecycle is passed from the list endpoint, the DAL only adds p.status IS NOT NULL and 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e0679d. Configure here.


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')`,
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scorecard repo pick inconsistent

Medium Severity

The new r_sc lateral picks a linked repo with ORDER BY pr.confidence DESC only. Package detail elsewhere breaks ties by preferring declared source, so list health, healthBand, and sortBy=health|risk can use a different scorecard than the detail view for the same package.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e0679d. Configure here.

) r_sc ON true`

const rows: PackageListRow[] = await qx.select(
`
SELECT
Expand All @@ -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)
Expand All @@ -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,
)
Expand Down
Loading