Skip to content

Commit 50de99b

Browse files
authored
feat: add advisories api (CM-1335) (#4350)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 9cca88d commit 50de99b

11 files changed

Lines changed: 808 additions & 18 deletions

backend/src/api/public/v1/akrites-external/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { requireScopes } from '@/api/public/middlewares/requireScopes'
55
import { safeWrap } from '@/middlewares/errorMiddleware'
66
import { SCOPES } from '@/security/scopes'
77

8+
import { getAkritesExternalAdvisoryDetail } from '../packages/getAkritesExternalAdvisoryDetail'
9+
import { getAkritesExternalAdvisoryDetailBatch } from '../packages/getAkritesExternalAdvisoryDetailBatch'
810
import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
911
import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
1012

@@ -23,5 +25,16 @@ export function akritesExternalRouter(): Router {
2325
packagesSubRouter.post(/^\/detail:batch\/?$/, safeWrap(getAkritesExternalPackageDetailBatch))
2426
router.use('/packages', packagesSubRouter)
2527

28+
// TODO: the contract gates advisories behind a dedicated read:advisories scope
29+
// (see the scope-naming note in the akrites-external OpenAPI). That scope isn't
30+
// issued by Auth0 yet, so reuse READ_PACKAGES for now — advisories are package
31+
// security data and, unlike the packages endpoints above, need no stewardship read.
32+
const advisoriesSubRouter = Router()
33+
advisoriesSubRouter.use(rateLimiter)
34+
advisoriesSubRouter.use(requireScopes([SCOPES.READ_PACKAGES]))
35+
advisoriesSubRouter.get('/detail', safeWrap(getAkritesExternalAdvisoryDetail))
36+
advisoriesSubRouter.post(/^\/detail:batch\/?$/, safeWrap(getAkritesExternalAdvisoryDetailBatch))
37+
router.use('/advisories', advisoriesSubRouter)
38+
2639
return router
2740
}

backend/src/api/public/v1/akrites-external/openapi.yaml

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ info:
99
entirely between Akrites and Auth0.
1010
1111
12-
Only the Packages endpoints are implemented so far. Advisories, Blast
13-
Radius, and Contacts are specced separately and not yet built.
12+
Packages and Advisories endpoints are implemented. Blast Radius and
13+
Contacts are specced separately and not yet built.
1414
1515
1616
TODO: scopes below (read:packages, read:stewardships) are the existing
@@ -30,6 +30,13 @@ security:
3030
tags:
3131
- name: Packages
3232
description: Package detail — requires read:packages and read:stewardships (see TODO above).
33+
- name: Advisories
34+
description: >
35+
Security advisories for a package, split out of package detail. The draft
36+
contract gates these behind a dedicated read:advisories scope; until Auth0
37+
issues it, the implementation reuses read:packages (advisories need no
38+
stewardship read). Confirm the final scope name (read:advisories vs
39+
cdp:advisories:read) with Akrites/product.
3340
3441
components:
3542
securitySchemes:
@@ -298,6 +305,57 @@ components:
298305
allOf:
299306
- $ref: '#/components/schemas/PackageDetail'
300307

308+
Advisory:
309+
type: object
310+
required: [osvId, severity, resolution, isCritical]
311+
properties:
312+
osvId:
313+
type: string
314+
example: GHSA-xxxx-xxxx-xxxx
315+
severity:
316+
type: string
317+
enum: [critical, high, moderate, low, null]
318+
nullable: true
319+
description: >
320+
Lowercased advisory severity. Any stored value outside this enum is
321+
returned as null rather than echoed verbatim.
322+
resolution:
323+
type: string
324+
enum: [open, patched, null]
325+
nullable: true
326+
description: >
327+
Whether the package's latest version is still affected. Null when it
328+
can't be determined (no latest version, or no affected ranges recorded).
329+
isCritical:
330+
type: boolean
331+
nullable: true
332+
description: cvss >= 7.0. Null when the advisory has no CVSS score.
333+
334+
AdvisoryDetail:
335+
type: object
336+
required: [purl, advisories]
337+
properties:
338+
purl:
339+
type: string
340+
advisories:
341+
type: array
342+
items:
343+
$ref: '#/components/schemas/Advisory'
344+
345+
AdvisoryDetailBulkEntry:
346+
type: object
347+
required: [requestedPurl, found, advisories]
348+
properties:
349+
requestedPurl:
350+
type: string
351+
found:
352+
type: boolean
353+
advisories:
354+
type: object
355+
nullable: true
356+
allOf:
357+
- $ref: '#/components/schemas/AdvisoryDetail'
358+
301359
paths:
302360
/akrites-external/packages/detail:
303361
get:
@@ -401,3 +459,104 @@ paths:
401459
application/json:
402460
schema:
403461
$ref: '#/components/schemas/Error'
462+
463+
/akrites-external/advisories/detail:
464+
get:
465+
operationId: getAdvisoryDetail
466+
summary: Get advisories for a package by PURL
467+
tags: [Advisories]
468+
security:
469+
- M2MBearer:
470+
- read:packages
471+
parameters:
472+
- name: purl
473+
in: query
474+
required: true
475+
schema:
476+
type: string
477+
example: pkg:npm/%40angular/core
478+
responses:
479+
'200':
480+
description: Advisories for the package (empty array when the package has none).
481+
content:
482+
application/json:
483+
schema:
484+
$ref: '#/components/schemas/AdvisoryDetail'
485+
'400':
486+
description: Malformed purl.
487+
content:
488+
application/json:
489+
schema:
490+
$ref: '#/components/schemas/Error'
491+
'401':
492+
description: Missing or invalid bearer token.
493+
content:
494+
application/json:
495+
schema:
496+
$ref: '#/components/schemas/Error'
497+
'403':
498+
description: Token missing read:packages scope.
499+
content:
500+
application/json:
501+
schema:
502+
$ref: '#/components/schemas/Error'
503+
'404':
504+
description: Package not found.
505+
content:
506+
application/json:
507+
schema:
508+
$ref: '#/components/schemas/Error'
509+
510+
/akrites-external/advisories/detail:batch:
511+
post:
512+
operationId: getAdvisoryDetailBatch
513+
summary: Bulk advisory lookup
514+
tags: [Advisories]
515+
security:
516+
- M2MBearer:
517+
- read:packages
518+
requestBody:
519+
required: true
520+
content:
521+
application/json:
522+
schema:
523+
type: object
524+
required: [purls]
525+
properties:
526+
purls:
527+
type: array
528+
minItems: 1
529+
maxItems: 100
530+
items:
531+
type: string
532+
responses:
533+
'200':
534+
description: Results in the same order as the request.
535+
content:
536+
application/json:
537+
schema:
538+
type: object
539+
required: [results]
540+
properties:
541+
results:
542+
type: array
543+
items:
544+
$ref: '#/components/schemas/AdvisoryDetailBulkEntry'
545+
'400':
546+
description: Validation error (empty array, >100 items, malformed purl).
547+
content:
548+
application/json:
549+
schema:
550+
$ref: '#/components/schemas/Error'
551+
'401':
552+
description: Missing or invalid bearer token.
553+
content:
554+
application/json:
555+
schema:
556+
$ref: '#/components/schemas/Error'
557+
'403':
558+
description: Token missing read:packages scope.
559+
content:
560+
application/json:
561+
schema:
562+
$ref: '#/components/schemas/Error'
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import type { AkritesExternalAdvisoryRow } from '@crowd/data-access-layer'
4+
5+
import { toAkritesExternalAdvisoryDetail } from './akritesExternalAdvisoryDetail'
6+
7+
const purl = 'pkg:npm/lodash'
8+
9+
function row(overrides: Partial<AkritesExternalAdvisoryRow> = {}): AkritesExternalAdvisoryRow {
10+
return {
11+
purl,
12+
osvId: 'GHSA-1111-1111-1111',
13+
severity: 'critical',
14+
resolution: 'open',
15+
isCritical: true,
16+
...overrides,
17+
}
18+
}
19+
20+
describe('toAkritesExternalAdvisoryDetail', () => {
21+
it('maps well-formed advisory rows to the akrites-external shape', () => {
22+
const result = toAkritesExternalAdvisoryDetail(purl, [
23+
row({ osvId: 'GHSA-aaaa', severity: 'critical', resolution: 'open', isCritical: true }),
24+
row({ osvId: 'GHSA-bbbb', severity: 'high', resolution: 'patched', isCritical: false }),
25+
])
26+
expect(result.purl).toBe(purl)
27+
expect(result.advisories).toEqual([
28+
{ osvId: 'GHSA-aaaa', severity: 'critical', resolution: 'open', isCritical: true },
29+
{ osvId: 'GHSA-bbbb', severity: 'high', resolution: 'patched', isCritical: false },
30+
])
31+
})
32+
33+
it('returns an empty advisories array for the found-but-advisory-less sentinel row', () => {
34+
// A found package with no advisories comes back as a single null-osvId row.
35+
const result = toAkritesExternalAdvisoryDetail(purl, [
36+
row({ osvId: null, severity: null, resolution: null, isCritical: null }),
37+
])
38+
expect(result.advisories).toEqual([])
39+
})
40+
41+
it('crosswalks the DB medium severity to the contract moderate value', () => {
42+
// The DB normalizes the middle band to MEDIUM; the contract calls it moderate.
43+
const result = toAkritesExternalAdvisoryDetail(purl, [row({ severity: 'medium' })])
44+
expect(result.advisories[0].severity).toBe('moderate')
45+
})
46+
47+
it('coerces a severity outside the known vocabulary to null', () => {
48+
const result = toAkritesExternalAdvisoryDetail(purl, [row({ severity: 'info' })])
49+
expect(result.advisories[0].severity).toBeNull()
50+
})
51+
52+
it('passes through null severity and null isCritical (unscored advisory)', () => {
53+
const result = toAkritesExternalAdvisoryDetail(purl, [
54+
row({ osvId: 'GHSA-cccc', severity: null, resolution: 'open', isCritical: null }),
55+
])
56+
expect(result.advisories[0]).toEqual({
57+
osvId: 'GHSA-cccc',
58+
severity: null,
59+
resolution: 'open',
60+
isCritical: null,
61+
})
62+
})
63+
64+
it('keeps the accepted moderate severity value', () => {
65+
const result = toAkritesExternalAdvisoryDetail(purl, [row({ severity: 'moderate' })])
66+
expect(result.advisories[0].severity).toBe('moderate')
67+
})
68+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { AkritesExternalAdvisoryRow } from '@crowd/data-access-layer'
2+
3+
export type AkritesExternalAdvisorySeverity = 'critical' | 'high' | 'moderate' | 'low' | null
4+
export type AkritesExternalAdvisoryResolution = 'open' | 'patched' | null
5+
6+
export interface AkritesExternalAdvisory {
7+
osvId: string
8+
severity: AkritesExternalAdvisorySeverity
9+
resolution: AkritesExternalAdvisoryResolution
10+
isCritical: boolean | null
11+
}
12+
13+
export interface AkritesExternalAdvisoryDetail {
14+
purl: string
15+
advisories: AkritesExternalAdvisory[]
16+
}
17+
18+
export interface AdvisoryDetailBulkEntry {
19+
requestedPurl: string
20+
found: boolean
21+
advisories: AkritesExternalAdvisoryDetail | null
22+
}
23+
24+
// Maps the DB's (lowercased) severity vocabulary to the contract's enum. The DB
25+
// normalizes the middle band to MEDIUM (initial_schema.sql; osv/extractSeverity.ts),
26+
// whereas the Akrites contract calls that same level `moderate` — hence the explicit
27+
// medium → moderate crosswalk. Anything unrecognized (or null) maps to null so the
28+
// response never violates the published enum.
29+
const SEVERITY_CROSSWALK: Record<string, AkritesExternalAdvisorySeverity> = {
30+
critical: 'critical',
31+
high: 'high',
32+
medium: 'moderate',
33+
moderate: 'moderate',
34+
low: 'low',
35+
}
36+
37+
function toAkritesSeverity(severity: string | null): AkritesExternalAdvisorySeverity {
38+
if (severity === null) return null
39+
return SEVERITY_CROSSWALK[severity] ?? null
40+
}
41+
42+
// Builds the AdvisoryDetail for a single purl from its DAL rows. Rows carry a null
43+
// osvId sentinel for a found-but-advisory-less package (see AkritesExternalAdvisoryRow) —
44+
// those are dropped here so `advisories` is an empty array, not a list with a null entry.
45+
export function toAkritesExternalAdvisoryDetail(
46+
purl: string,
47+
rows: AkritesExternalAdvisoryRow[],
48+
): AkritesExternalAdvisoryDetail {
49+
const advisories: AkritesExternalAdvisory[] = rows
50+
.filter((r): r is AkritesExternalAdvisoryRow & { osvId: string } => r.osvId !== null)
51+
.map((r) => ({
52+
osvId: r.osvId,
53+
severity: toAkritesSeverity(r.severity),
54+
resolution: r.resolution,
55+
isCritical: r.isCritical,
56+
}))
57+
58+
return { purl, advisories }
59+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Request, Response } from 'express'
2+
3+
import { NotFoundError } from '@crowd/common'
4+
import { getAdvisoriesByPurls } from '@crowd/data-access-layer'
5+
6+
import { getPackagesQx } from '@/db/packagesDb'
7+
import { ok } from '@/utils/api'
8+
import { validateOrThrow } from '@/utils/validation'
9+
10+
import { toAkritesExternalAdvisoryDetail } from './akritesExternalAdvisoryDetail'
11+
import { purlQuerySchema } from './purl'
12+
13+
export async function getAkritesExternalAdvisoryDetail(req: Request, res: Response): Promise<void> {
14+
const { purl } = validateOrThrow(purlQuerySchema, req.query)
15+
16+
const qx = await getPackagesQx()
17+
const rows = await getAdvisoriesByPurls(qx, [purl])
18+
19+
// No rows at all means the package itself doesn't exist. A package that exists but
20+
// has no advisories still yields one (null-osvId) sentinel row, so it 200s with [].
21+
if (rows.length === 0) {
22+
throw new NotFoundError()
23+
}
24+
25+
ok(res, toAkritesExternalAdvisoryDetail(purl, rows))
26+
}

0 commit comments

Comments
 (0)