Skip to content

Commit e294bbc

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/osv-advisory-dedup-CM-1258
2 parents 96bb2ab + 6d840a9 commit e294bbc

21 files changed

Lines changed: 729 additions & 27 deletions

File tree

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,27 @@ import { getAkritesExternalContactDetail } from '../packages/getAkritesExternalC
1111
import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExternalContactDetailBatch'
1212
import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
1313
import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
14+
import { submitBlastRadiusJob } from '../packages/submitBlastRadiusJob'
1415

1516
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
1617

18+
// Blast-radius jobs kick off a Temporal workflow per request, so they get their own,
19+
// much stricter limiter — configurable via env so it can be tuned without a redeploy
20+
// while the pipeline is still a no-op stub. Defaults to 5 requests/hour.
21+
const blastRadiusRateLimitMax = Number(process.env.AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX)
22+
const blastRadiusRateLimitWindowMs = Number(process.env.AKRITES_BLAST_RADIUS_RATE_LIMIT_WINDOW_MS)
23+
24+
const blastRadiusRateLimiter = createRateLimiter({
25+
max:
26+
Number.isSafeInteger(blastRadiusRateLimitMax) && blastRadiusRateLimitMax > 0
27+
? blastRadiusRateLimitMax
28+
: 5,
29+
windowMs:
30+
Number.isSafeInteger(blastRadiusRateLimitWindowMs) && blastRadiusRateLimitWindowMs > 0
31+
? blastRadiusRateLimitWindowMs
32+
: 60 * 60 * 1000,
33+
})
34+
1735
export function akritesExternalRouter(): Router {
1836
const router = Router()
1937

@@ -50,5 +68,14 @@ export function akritesExternalRouter(): Router {
5068
contactsSubRouter.post(/^\/detail:batch\/?$/, safeWrap(getAkritesExternalContactDetailBatch))
5169
router.use('/contacts', contactsSubRouter)
5270

71+
// TODO: the contract gates blast-radius behind a dedicated read:advisories scope
72+
// (same as advisories above — see the scope-naming note in the akrites-external
73+
// OpenAPI). Not issued by Auth0 yet, so reuse READ_PACKAGES for now.
74+
const blastRadiusSubRouter = Router()
75+
blastRadiusSubRouter.use(blastRadiusRateLimiter)
76+
blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES]))
77+
blastRadiusSubRouter.post('/jobs', safeWrap(submitBlastRadiusJob))
78+
router.use('/blast-radius', blastRadiusSubRouter)
79+
5380
return router
5481
}

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

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ info:
1010
1111
1212
Packages, Advisories and Contacts endpoints are implemented. Blast Radius
13-
is specced separately and not yet built.
13+
submit (2a) is implemented but the reachability pipeline is not built yet —
14+
every submitted job currently fails with an ECOSYSTEM_NOT_SUPPORTED error
15+
once the underlying workflow runs. Poll (2b), the 7-day result cache, and
16+
the actual analysis are specced separately and not yet built.
1417
1518
1619
TODO: scopes below (read:packages, read:stewardships) are the existing
@@ -45,6 +48,16 @@ tags:
4548
it, the implementation requires read:maintainer-roles (NOT read:packages).
4649
The response shape is still under discussion upstream (reportingMethods /
4750
reportingGuidelines / integrationHints are reserved and always null today).
51+
- name: Blast Radius
52+
description: >
53+
Advisory reachability analysis — submit (2a) is implemented; poll (2b) is
54+
not built yet. Submitting kicks off a Temporal workflow which currently
55+
fails every job with ECOSYSTEM_NOT_SUPPORTED, since the reachability
56+
pipeline itself hasn't shipped. Rate-limited independently of the other
57+
akrites-external endpoints (default 5 requests/hour, configurable via
58+
AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX / _WINDOW_MS). Same interim scope
59+
note as Advisories applies (read:packages, pending a dedicated
60+
read:advisories scope).
4861
4962
components:
5063
securitySchemes:
@@ -361,6 +374,63 @@ components:
361374
allOf:
362375
- $ref: '#/components/schemas/AdvisoryDetail'
363376

377+
BlastRadiusJobRequest:
378+
type: object
379+
required: [advisoryId, ecosystem]
380+
properties:
381+
advisoryId:
382+
type: string
383+
pattern: '^(GHSA-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}|CVE-\d{4}-\d{4,})$'
384+
description: >
385+
GHSA or CVE identifier. Any other format, including free-text
386+
strings, is rejected with a 400.
387+
example: GHSA-jf85-cpcp-j695
388+
ecosystem:
389+
type: string
390+
enum: [npm]
391+
description: >
392+
Required. The reachability pipeline is npm-only today — any other
393+
value, or a missing ecosystem, is rejected with a 400.
394+
example: npm
395+
package:
396+
type: string
397+
nullable: true
398+
description: >
399+
Optional. Full purl or bare package name. If omitted, the analysis
400+
is advisory-wide: reachability is evaluated across every package
401+
the advisory affects, returned as one aggregate result — not one
402+
job per affected package.
403+
force:
404+
type: boolean
405+
default: false
406+
description: Bypasses the 7-day cache and always triggers a new run. Use sparingly.
407+
408+
BlastRadiusJobEntry:
409+
type: object
410+
required: [analysisId, advisoryId, package, ecosystem, status]
411+
description: Response body of 2a — one job per request, never wrapped in an array.
412+
properties:
413+
analysisId:
414+
type: string
415+
description: Opaque identifier for polling via 2b (not yet implemented).
416+
advisoryId:
417+
type: string
418+
package:
419+
type: string
420+
nullable: true
421+
description: Echoes the request's package. Null when the request omitted it.
422+
ecosystem:
423+
type: string
424+
enum: [npm]
425+
description: Echoes the request's ecosystem. Always present — the request requires it.
426+
status:
427+
type: string
428+
enum: [pending, running, done, failed]
429+
description: >
430+
Always pending today — every job starts a Temporal workflow that
431+
currently fails with ECOSYSTEM_NOT_SUPPORTED once it runs, since
432+
the reachability pipeline isn't built yet.
433+
364434
ContactConfidenceBand:
365435
type: string
366436
enum: [PRIMARY, SECONDARY, FALLBACK, NONE]
@@ -806,6 +876,65 @@ paths:
806876
schema:
807877
$ref: '#/components/schemas/Error'
808878

879+
/akrites-external/blast-radius/jobs:
880+
post:
881+
operationId: submitBlastRadiusJob
882+
summary: 2a — Submit a blast-radius analysis job
883+
description: >
884+
Always exactly one job per request — no bulk submit. Omit package for
885+
an advisory-wide analysis; provide it to narrow to a single package.
886+
887+
888+
Not yet implemented: the 7-day result cache, force-bypass semantics,
889+
and the actual reachability pipeline. Today every submission starts a
890+
fresh Temporal workflow that fails with ECOSYSTEM_NOT_SUPPORTED — the
891+
job is accepted and echoed back as pending, but never completes.
892+
tags: [Blast Radius]
893+
security:
894+
- M2MBearer:
895+
- read:packages
896+
requestBody:
897+
required: true
898+
content:
899+
application/json:
900+
schema:
901+
$ref: '#/components/schemas/BlastRadiusJobRequest'
902+
responses:
903+
'202':
904+
description: Job accepted.
905+
content:
906+
application/json:
907+
schema:
908+
$ref: '#/components/schemas/BlastRadiusJobEntry'
909+
'400':
910+
description: >
911+
Validation error (missing/empty advisoryId), or an unsupported
912+
ecosystem — only npm is supported today, so any other value
913+
(including a missing ecosystem) is rejected before a workflow
914+
is started.
915+
content:
916+
application/json:
917+
schema:
918+
$ref: '#/components/schemas/Error'
919+
'401':
920+
description: Missing or invalid bearer token.
921+
content:
922+
application/json:
923+
schema:
924+
$ref: '#/components/schemas/Error'
925+
'403':
926+
description: Token missing read:packages scope.
927+
content:
928+
application/json:
929+
schema:
930+
$ref: '#/components/schemas/Error'
931+
'429':
932+
description: Too many requests — rate-limited independently of the other endpoints.
933+
content:
934+
application/json:
935+
schema:
936+
$ref: '#/components/schemas/Error'
937+
809938
/akrites-external/contacts/detail:
810939
get:
811940
operationId: getContactDetail
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { blastRadiusJobRequestSchema, toBlastRadiusJobEntry } from './blastRadius'
4+
5+
describe('blastRadiusJobRequestSchema', () => {
6+
it('accepts a minimal advisory-wide request and defaults force to false', () => {
7+
const result = blastRadiusJobRequestSchema.parse({
8+
advisoryId: 'GHSA-jf85-cpcp-j695',
9+
ecosystem: 'npm',
10+
})
11+
expect(result).toEqual({
12+
advisoryId: 'GHSA-jf85-cpcp-j695',
13+
ecosystem: 'npm',
14+
package: undefined,
15+
force: false,
16+
})
17+
})
18+
19+
it('accepts a request scoped to a package with ecosystem and force set', () => {
20+
const result = blastRadiusJobRequestSchema.parse({
21+
advisoryId: 'GHSA-jf85-cpcp-j695',
22+
ecosystem: 'npm',
23+
package: 'pkg:npm/lodash',
24+
force: true,
25+
})
26+
expect(result).toEqual({
27+
advisoryId: 'GHSA-jf85-cpcp-j695',
28+
ecosystem: 'npm',
29+
package: 'pkg:npm/lodash',
30+
force: true,
31+
})
32+
})
33+
34+
it('accepts explicit null for package (advisory-wide, explicit)', () => {
35+
const result = blastRadiusJobRequestSchema.parse({
36+
advisoryId: 'GHSA-jf85-cpcp-j695',
37+
ecosystem: 'npm',
38+
package: null,
39+
})
40+
expect(result.package).toBeNull()
41+
})
42+
43+
it('rejects a missing advisoryId', () => {
44+
const result = blastRadiusJobRequestSchema.safeParse({ ecosystem: 'npm' })
45+
expect(result.success).toBe(false)
46+
})
47+
48+
it('rejects an empty advisoryId', () => {
49+
const result = blastRadiusJobRequestSchema.safeParse({
50+
advisoryId: ' ',
51+
ecosystem: 'npm',
52+
})
53+
expect(result.success).toBe(false)
54+
})
55+
56+
it('rejects an advisoryId that is not a GHSA or CVE identifier', () => {
57+
const result = blastRadiusJobRequestSchema.safeParse({
58+
advisoryId: 'foo',
59+
ecosystem: 'npm',
60+
})
61+
expect(result.success).toBe(false)
62+
})
63+
64+
it('accepts a CVE-formatted advisoryId', () => {
65+
const result = blastRadiusJobRequestSchema.safeParse({
66+
advisoryId: 'CVE-2024-12345',
67+
ecosystem: 'npm',
68+
})
69+
expect(result.success).toBe(true)
70+
})
71+
72+
it('rejects a missing ecosystem', () => {
73+
const result = blastRadiusJobRequestSchema.safeParse({ advisoryId: 'GHSA-jf85-cpcp-j695' })
74+
expect(result.success).toBe(false)
75+
})
76+
77+
it('rejects null ecosystem', () => {
78+
const result = blastRadiusJobRequestSchema.safeParse({
79+
advisoryId: 'GHSA-jf85-cpcp-j695',
80+
ecosystem: null,
81+
})
82+
expect(result.success).toBe(false)
83+
})
84+
85+
it('rejects an unsupported ecosystem', () => {
86+
const result = blastRadiusJobRequestSchema.safeParse({
87+
advisoryId: 'GHSA-jf85-cpcp-j695',
88+
ecosystem: 'pypi',
89+
})
90+
expect(result.success).toBe(false)
91+
})
92+
})
93+
94+
describe('toBlastRadiusJobEntry', () => {
95+
it('builds a pending job entry echoing the request fields', () => {
96+
const entry = toBlastRadiusJobEntry({
97+
analysisId: 'br_01h',
98+
advisoryId: 'GHSA-jf85-cpcp-j695',
99+
package: 'pkg:npm/lodash',
100+
ecosystem: 'npm',
101+
})
102+
expect(entry).toEqual({
103+
analysisId: 'br_01h',
104+
advisoryId: 'GHSA-jf85-cpcp-j695',
105+
package: 'pkg:npm/lodash',
106+
ecosystem: 'npm',
107+
status: 'pending',
108+
})
109+
})
110+
111+
it('echoes a null package for an advisory-wide job', () => {
112+
const entry = toBlastRadiusJobEntry({
113+
analysisId: 'br_01h',
114+
advisoryId: 'GHSA-jf85-cpcp-j695',
115+
package: null,
116+
ecosystem: 'npm',
117+
})
118+
expect(entry.package).toBeNull()
119+
expect(entry.ecosystem).toBe('npm')
120+
expect(entry.status).toBe('pending')
121+
})
122+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { z } from 'zod'
2+
3+
// The reachability pipeline is npm-only for now — every other ecosystem (including
4+
// a missing one) is rejected by the schema below before the Temporal workflow is
5+
// triggered.
6+
export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = ['npm'] as const
7+
8+
// Always exactly one job per request — advisory-wide (package omitted) or narrowed
9+
// to a single package. package accepts either a full purl or a bare package name,
10+
// so it is NOT run through purlFieldSchema/normalizePurl like the other endpoints.
11+
const ADVISORY_ID_PATTERN = /^(GHSA-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}|CVE-\d{4}-\d{4,})$/
12+
13+
export const blastRadiusJobRequestSchema = z.object({
14+
advisoryId: z
15+
.string()
16+
.trim()
17+
.regex(ADVISORY_ID_PATTERN, 'advisoryId must be a GHSA or CVE identifier'),
18+
ecosystem: z.enum(SUPPORTED_BLAST_RADIUS_ECOSYSTEMS, {
19+
error: `Ecosystem is not supported for blast-radius analysis — only ${SUPPORTED_BLAST_RADIUS_ECOSYSTEMS.join(', ')} supported today`,
20+
}),
21+
package: z.string().trim().min(1).nullish(),
22+
force: z.boolean().default(false),
23+
})
24+
25+
export type BlastRadiusJobRequest = z.infer<typeof blastRadiusJobRequestSchema>
26+
27+
export type BlastRadiusJobStatus = 'pending' | 'running' | 'done' | 'failed'
28+
29+
export type BlastRadiusJobEcosystem = (typeof SUPPORTED_BLAST_RADIUS_ECOSYSTEMS)[number]
30+
31+
export interface BlastRadiusJobEntry {
32+
analysisId: string
33+
advisoryId: string
34+
package: string | null
35+
ecosystem: BlastRadiusJobEcosystem
36+
status: BlastRadiusJobStatus
37+
}
38+
39+
// Builds the 2a response body. The pipeline isn't implemented yet, so every freshly
40+
// submitted job comes back pending — see analyzeBlastRadius in packages_worker.
41+
export function toBlastRadiusJobEntry(params: {
42+
analysisId: string
43+
advisoryId: string
44+
package: string | null
45+
ecosystem: BlastRadiusJobEcosystem
46+
}): BlastRadiusJobEntry {
47+
return {
48+
analysisId: params.analysisId,
49+
advisoryId: params.advisoryId,
50+
package: params.package,
51+
ecosystem: params.ecosystem,
52+
status: 'pending',
53+
}
54+
}

0 commit comments

Comments
 (0)