Skip to content
Open
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
7 changes: 7 additions & 0 deletions backend/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@
"password": "PRODUCT_DB_PASSWORD",
"database": "PRODUCT_DB_DATABASE"
},
"packagesDb": {
"host": "CROWD_PACKAGES_DB_WRITE_HOST",
"port": "CROWD_PACKAGES_DB_PORT",
"user": "CROWD_PACKAGES_DB_USERNAME",
"password": "CROWD_PACKAGES_DB_PASSWORD",
"database": "CROWD_PACKAGES_DB_DATABASE"
},
"segment": {
"writeKey": "CROWD_SEGMENT_WRITE_KEY"
},
Expand Down
49 changes: 19 additions & 30 deletions backend/src/api/public/v1/packages/batchGetStewardship.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { getPackagesByStewardshipPurls } from '@crowd/data-access-layer'

import { getPackagesQx } from '@/db/packagesDb'
import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

import { MOCK_DETAILS } from './mockData'
import type { StewardshipSummary } from './types'

const MAX_PURLS = 100

Expand All @@ -15,43 +18,29 @@ const bodySchema = z.object({
.max(MAX_PURLS, `Maximum ${MAX_PURLS} purls per request`),
})

interface StewardshipSummary {
name: string
ecosystem: string
lifecycle: string
health: number
impact: number
openVulns: { low: number; medium: number; high: number; critical: number }
stewardship: string
stewards: null
lastActivityAt: null
lastActivityDescription: null
}

// TODO: replace with real DB queries once stewardship tables land
export async function batchGetStewardship(req: Request, res: Response): Promise<void> {
const { purls } = validateOrThrow(bodySchema, req.body)

const qx = await getPackagesQx()
const rows = await getPackagesByStewardshipPurls(qx, purls)

const byPurl = new Map(rows.map((r) => [r.purl, r]))

const packages: Record<string, StewardshipSummary | null> = {}
for (const purl of purls) {
const detail = MOCK_DETAILS[purl]
if (!detail) {
const row = byPurl.get(purl)
if (!row) {
packages[purl] = null
} else {
const openVulns = { low: 0, medium: 0, high: 0, critical: 0 }
for (const advisory of detail.security.advisories) {
if (advisory.severity in openVulns) {
openVulns[advisory.severity] += 1
}
}
packages[purl] = {
name: detail.name,
ecosystem: detail.ecosystem,
lifecycle: detail.general.riskSignals.lifecycle,
health: detail.general.healthScore.total,
impact: detail.general.impact.impactScore,
openVulns,
stewardship: 'unassigned',
name: row.name,
ecosystem: row.ecosystem,
lifecycle: null,
health: null,
impact:
row.criticalityScore != null ? Math.round(Number(row.criticalityScore) * 100) : null,
openVulns: null,
stewardship: (row.stewardshipStatus ?? 'unassigned') as StewardshipSummary['stewardship'],
stewards: null,
lastActivityAt: null,
lastActivityDescription: null,
Expand Down
81 changes: 71 additions & 10 deletions backend/src/api/public/v1/packages/getPackage.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,90 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { BadRequestError, NotFoundError } from '@crowd/common'
import { NotFoundError } from '@crowd/common'
import { getAdvisoriesByPackageId, getPackageDetailByPurl } from '@crowd/data-access-layer'

import { getPackagesQx } from '@/db/packagesDb'
import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

import { MOCK_DETAILS } from './mockData'
import type { StewardshipStatus } from './types'

const querySchema = z.object({
purl: z.string().trim().min(1),
purl: z
.string()
.trim()
.min(1)
.refine((v) => v.startsWith('pkg:'), { message: 'purl must start with pkg:' }),
})

// TODO: replace with real DB queries once packages DB is wired into the backend
export async function getPackage(req: Request, res: Response): Promise<void> {
const { purl } = validateOrThrow(querySchema, req.query)

if (!purl.startsWith('pkg:')) {
throw new BadRequestError('Invalid purl format: must start with pkg:')
}
const qx = await getPackagesQx()
const pkg = await getPackageDetailByPurl(qx, purl)

const detail = MOCK_DETAILS[purl]
if (!detail) {
if (!pkg) {
throw new NotFoundError()
}

ok(res, detail)
const advisories = await getAdvisoriesByPackageId(qx, pkg.id)

ok(res, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why transitiveReach, maintainerBusFactor, resolution, securityContacts (this one we should check with Mouad how he is storing these) are null?

purl: pkg.purl,
name: pkg.name,
ecosystem: pkg.ecosystem,
general: {
healthScore: null,
impact: {
impactScore:
pkg.criticalityScore != null ? Math.round(Number(pkg.criticalityScore) * 100) : null,
downloadsLastMonth:
pkg.downloadsLast30d != null ? parseInt(pkg.downloadsLast30d, 10) : null,
dependentPackages: pkg.dependentPackagesCount ?? null,
dependentRepos: pkg.dependentReposCount ?? null,
transitiveReach: pkg.transitiveReach,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

transitiveReach wrong API format

Medium Severity

Package detail returns transitiveReach as a numeric percentile rank from SQL, while the public schema and previous mock responses use a human-readable string such as Top 0.4%. Consumers expecting the documented string format will misinterpret or fail to render the field.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3066140. Configure here.

},
riskSignals: {
lifecycle: null,
maintainerBusFactor: pkg.maintainerCount,
lastRelease: pkg.latestReleaseAt ? pkg.latestReleaseAt.toISOString() : null,
hasSecurityFile: pkg.hasSecurityFile,
openSSFScorecard: pkg.scorecardScore != null ? Number(pkg.scorecardScore) : null,
},
},
assessment: {},
security: {
securityContacts: null,
advisories: advisories.map((a) => ({
osvId: a.osvId,
severity: a.severity,
resolution: a.resolution,
})),
cvd: {
isPvrEnabled: null,
hasSecurityPolicyEnabled: pkg.hasSecurityPolicy,
tier0Steward: null,
criticalVulnerabilityFlag: pkg.hasCriticalVulnerability,
},
},
provenance: {
repositoryMapping: {
declaredRepo: pkg.repoUrl ?? pkg.repositoryUrl ?? pkg.declaredRepositoryUrl ?? null,
mappingConfidence:
pkg.repoMappingConfidence != null ? Number(pkg.repoMappingConfidence) : null,
lastCommitAt: pkg.repoLastCommitAt ? pkg.repoLastCommitAt.toISOString() : null,
},
supplyChainIntegrity: {
buildProvenance: null,
signedReleases: null,
},
},
stewardship: {
status: (pkg.stewardshipStatus ?? 'unassigned') as StewardshipStatus,
stewards: null,
lastActivityAt: null,
},
history: {},
})
}
10 changes: 6 additions & 4 deletions backend/src/api/public/v1/packages/getPackagesMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { Request, Response } from 'express'

import { ok } from '@/utils/api'
import { getPackageMetrics } from '@crowd/data-access-layer'

import { MOCK_METRICS } from './mockData'
import { getPackagesQx } from '@/db/packagesDb'
import { ok } from '@/utils/api'

// TODO: replace with real DB queries once packages DB is wired into the backend
export async function getPackagesMetrics(req: Request, res: Response): Promise<void> {
ok(res, MOCK_METRICS)
const qx = await getPackagesQx()
const metrics = await getPackageMetrics(qx)
ok(res, metrics)
}
63 changes: 28 additions & 35 deletions backend/src/api/public/v1/packages/listPackages.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { listPackagesForApi } from '@crowd/data-access-layer'

import { getPackagesQx } from '@/db/packagesDb'
import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

import { MOCK_DETAILS, MOCK_PACKAGES } from './mockData'
import type { StewardshipStatus } from './types'

const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100
const STALE_MONTHS = 18

const booleanQueryParam = z.preprocess((v) => v === 'true', z.boolean()).default(false)

Expand All @@ -26,7 +28,6 @@ const querySchema = z.object({
sortDir: z.enum(['asc', 'desc']).default('asc'),
})

// TODO: replace with real DB queries once packages DB is wired into the backend
export async function listPackages(req: Request, res: Response): Promise<void> {
const {
page,
Expand All @@ -40,40 +41,32 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
sortDir,
} = validateOrThrow(querySchema, req.query)

const staleThreshold = new Date()
staleThreshold.setMonth(staleThreshold.getMonth() - STALE_MONTHS)

let filtered = MOCK_PACKAGES.filter((p) => {
if (ecosystem && p.ecosystem !== ecosystem) return false
if (lifecycle && p.lifecycle !== lifecycle) return false
if (busFactor1Only && p.maintainerBusFactor !== 1) return false
if (unstewardedOnly && p.stewardship !== 'unassigned') return false
if (staleOnly) {
const lastRelease = MOCK_DETAILS[p.purl]?.general.riskSignals.lastRelease
if (!lastRelease || new Date(lastRelease) >= staleThreshold) return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ignored list query filters

Medium Severity

GET /packages still validates and echoes lifecycle and busFactor1Only, but those values are no longer passed into listPackagesForApi, so results stay unfiltered while the response filters object implies they were applied.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2d2aa85. Configure here.

}
return true
})
// health is a v2 field with no backing column yet — fall back to name sort
const effectiveSortBy = sortBy === 'health' ? 'name' : sortBy

filtered = filtered.sort((a, b) => {
let cmp = 0
if (sortBy === 'name') {
cmp = a.name.localeCompare(b.name)
} else if (sortBy === 'health') {
cmp = (a.health ?? 0) - (b.health ?? 0)
} else if (sortBy === 'impact') {
cmp = (a.impact ?? 0) - (b.impact ?? 0)
} else if (sortBy === 'openVulns') {
const sumA = a.openVulns.low + a.openVulns.medium + a.openVulns.high + a.openVulns.critical
const sumB = b.openVulns.low + b.openVulns.medium + b.openVulns.high + b.openVulns.critical
cmp = sumA - sumB
}
return sortDir === 'desc' ? -cmp : cmp
const qx = await getPackagesQx()
const { rows, total } = await listPackagesForApi(qx, {
page,
pageSize,
ecosystem,
staleOnly,
unstewardedOnly,
sortBy: effectiveSortBy,
sortDir,
})
Comment on lines +47 to 56

const total = filtered.length
const start = (page - 1) * pageSize
const packages = filtered.slice(start, start + pageSize)
const packages = rows.map((r) => ({
purl: r.purl,
name: r.name,
ecosystem: r.ecosystem,
health: null,
impact: r.criticalityScore != null ? Math.round(Number(r.criticalityScore) * 100) : null,
lifecycle: null,
maintainerBusFactor: null,
openVulns: r.openVulns,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

List openVulns wrong response shape

High Severity

GET /packages now puts a plain integer in openVulns, but the public contract and OpenVulns type expect an object with low, medium, high, and critical counts (or null). Clients that read severity fields from the list response will get wrong or missing data.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3066140. Configure here.

stewardship: (r.stewardshipStatus ?? 'unassigned') as StewardshipStatus,
stewards: null,
}))

ok(res, {
page,
Expand All @@ -86,7 +79,7 @@ export async function listPackages(req: Request, res: Response): Promise<void> {
staleOnly,
unstewardedOnly,
},
sort: { by: sortBy, dir: sortDir },
sort: { by: effectiveSortBy, dir: sortDir },
packages,
})
}
Loading
Loading