Skip to content

Commit fd4f294

Browse files
committed
feat: add workflow for blast radius api
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 6c91e71 commit fd4f294

18 files changed

Lines changed: 599 additions & 4 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,23 @@ 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: Number.isFinite(blastRadiusRateLimitMax) ? blastRadiusRateLimitMax : 5,
26+
windowMs: Number.isFinite(blastRadiusRateLimitWindowMs)
27+
? blastRadiusRateLimitWindowMs
28+
: 60 * 60 * 1000,
29+
})
30+
1731
export function akritesExternalRouter(): Router {
1832
const router = Router()
1933

@@ -50,5 +64,14 @@ export function akritesExternalRouter(): Router {
5064
contactsSubRouter.post(/^\/detail:batch\/?$/, safeWrap(getAkritesExternalContactDetailBatch))
5165
router.use('/contacts', contactsSubRouter)
5266

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

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

Lines changed: 125 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,58 @@ components:
361374
allOf:
362375
- $ref: '#/components/schemas/AdvisoryDetail'
363376

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

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

0 commit comments

Comments
 (0)