Skip to content
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
37 changes: 20 additions & 17 deletions backend/src/api/public/v1/packages/batchGetStewardship.ts
Original file line number Diff line number Diff line change
@@ -1,11 +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 { OpenVulns, StewardshipSummary } from './types'
import type { StewardshipSummary } from './types'

const MAX_PURLS = 100

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

// 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: OpenVulns = { low: 0, medium: 0, high: 0, critical: 0 }
for (const advisory of detail.security.advisories) {
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: detail.stewardship.status,
stewards: detail.stewardship.stewards,
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.

},
Comment on lines +43 to +47
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Declared repo uses mapped URL

Medium Severity

provenance.repositoryMapping.declaredRepo prefers repoUrl from the best-confidence repo join before declaredRepositoryUrl, so the field can expose an inferred mapping instead of the package’s declared repository.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8a2b724. Configure here.

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)
}
67 changes: 30 additions & 37 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 @@ -18,15 +20,14 @@ 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(),
busFactor1Only: booleanQueryParam,
lifecycle: z.enum(lifecycleValues).optional(), // TODO: filter not yet implemented in DAL
busFactor1Only: booleanQueryParam, // TODO: filter not yet implemented in DAL
staleOnly: booleanQueryParam,
unstewardedOnly: booleanQueryParam,
sortBy: z.enum(['name', 'health', 'impact', 'openVulns']).default('name'),
sortDir: z.enum(['asc', 'desc']).default('asc'),
})
Comment on lines 19 to 29

// 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 !== null && 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,
Comment on lines +62 to +67
Comment on lines +62 to +67
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,
Comment on lines 81 to 83
})
}
4 changes: 4 additions & 0 deletions backend/src/conf/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export const PRODUCT_DB_CONFIG: IDatabaseConfig = config.has('productDb')
? config.get<IDatabaseConfig>('productDb')
: undefined

export const PACKAGES_DB_CONFIG: IDatabaseConfig | undefined = config.has('packagesDb')
? config.get<IDatabaseConfig>('packagesDb')
: undefined

export const SEGMENT_CONFIG: SegmentConfiguration = config.get<SegmentConfiguration>('segment')

export const COMPREHEND_CONFIG: ComprehendConfiguration =
Expand Down
23 changes: 23 additions & 0 deletions backend/src/db/packagesDb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getDbConnection } from '@crowd/data-access-layer/src/database'
import { QueryExecutor, pgpQx } from '@crowd/data-access-layer/src/queryExecutor'

import { PACKAGES_DB_CONFIG } from '@/conf'

let _init: Promise<QueryExecutor> | undefined

export function getPackagesQx(): Promise<QueryExecutor> {
if (!_init) {
if (!PACKAGES_DB_CONFIG) {
throw new Error(
'Packages DB is not configured — set CROWD_PACKAGES_DB_* environment variables',
)
}
_init = getDbConnection(PACKAGES_DB_CONFIG)
.then(pgpQx)
.catch((err) => {
_init = undefined
throw err
})
}
return _init
}
Comment thread
Copilot marked this conversation as resolved.
1 change: 1 addition & 0 deletions services/libs/data-access-layer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export * from './osspckgs/packages'
export * from './osspckgs/repos'
export * from './osspckgs/stewardships'
export * from './osspckgs/versions'
export * from './osspckgs/api'
Loading
Loading