Skip to content
Merged
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
27 changes: 27 additions & 0 deletions backend/src/api/public/v1/akrites-external/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,27 @@ import { getAkritesExternalContactDetail } from '../packages/getAkritesExternalC
import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExternalContactDetailBatch'
import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
import { submitBlastRadiusJob } from '../packages/submitBlastRadiusJob'

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

// Blast-radius jobs kick off a Temporal workflow per request, so they get their own,
// much stricter limiter — configurable via env so it can be tuned without a redeploy
// while the pipeline is still a no-op stub. Defaults to 5 requests/hour.
const blastRadiusRateLimitMax = Number(process.env.AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX)
const blastRadiusRateLimitWindowMs = Number(process.env.AKRITES_BLAST_RADIUS_RATE_LIMIT_WINDOW_MS)

const blastRadiusRateLimiter = createRateLimiter({
max:
Number.isSafeInteger(blastRadiusRateLimitMax) && blastRadiusRateLimitMax > 0
? blastRadiusRateLimitMax
: 5,
windowMs:
Number.isSafeInteger(blastRadiusRateLimitWindowMs) && blastRadiusRateLimitWindowMs > 0
? blastRadiusRateLimitWindowMs
: 60 * 60 * 1000,
})
Comment thread
Copilot marked this conversation as resolved.

export function akritesExternalRouter(): Router {
const router = Router()

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

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

return router
}
131 changes: 130 additions & 1 deletion backend/src/api/public/v1/akrites-external/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ info:


Packages, Advisories and Contacts endpoints are implemented. Blast Radius
is specced separately and not yet built.
submit (2a) is implemented but the reachability pipeline is not built yet —
every submitted job currently fails with an ECOSYSTEM_NOT_SUPPORTED error
once the underlying workflow runs. Poll (2b), the 7-day result cache, and
the actual analysis are specced separately and not yet built.


TODO: scopes below (read:packages, read:stewardships) are the existing
Expand Down Expand Up @@ -45,6 +48,16 @@ tags:
it, the implementation requires read:maintainer-roles (NOT read:packages).
The response shape is still under discussion upstream (reportingMethods /
reportingGuidelines / integrationHints are reserved and always null today).
- name: Blast Radius
description: >
Advisory reachability analysis — submit (2a) is implemented; poll (2b) is
not built yet. Submitting kicks off a Temporal workflow which currently
fails every job with ECOSYSTEM_NOT_SUPPORTED, since the reachability
pipeline itself hasn't shipped. Rate-limited independently of the other
akrites-external endpoints (default 5 requests/hour, configurable via
AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX / _WINDOW_MS). Same interim scope
note as Advisories applies (read:packages, pending a dedicated
read:advisories scope).

components:
securitySchemes:
Expand Down Expand Up @@ -361,6 +374,63 @@ components:
allOf:
- $ref: '#/components/schemas/AdvisoryDetail'

BlastRadiusJobRequest:
type: object
required: [advisoryId, ecosystem]
properties:
advisoryId:
type: string
pattern: '^(GHSA-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}|CVE-\d{4}-\d{4,})$'
description: >
GHSA or CVE identifier. Any other format, including free-text
strings, is rejected with a 400.
example: GHSA-jf85-cpcp-j695
ecosystem:
type: string
enum: [npm]
description: >
Required. The reachability pipeline is npm-only today — any other
value, or a missing ecosystem, is rejected with a 400.
example: npm
package:
type: string
nullable: true
description: >
Optional. Full purl or bare package name. If omitted, the analysis
is advisory-wide: reachability is evaluated across every package
the advisory affects, returned as one aggregate result — not one
job per affected package.
force:
type: boolean
default: false
description: Bypasses the 7-day cache and always triggers a new run. Use sparingly.

BlastRadiusJobEntry:
type: object
required: [analysisId, advisoryId, package, ecosystem, status]
description: Response body of 2a — one job per request, never wrapped in an array.
properties:
analysisId:
type: string
description: Opaque identifier for polling via 2b (not yet implemented).
advisoryId:
type: string
package:
type: string
nullable: true
description: Echoes the request's package. Null when the request omitted it.
ecosystem:
type: string
enum: [npm]
description: Echoes the request's ecosystem. Always present — the request requires it.
status:
type: string
enum: [pending, running, done, failed]
description: >
Always pending today — every job starts a Temporal workflow that
currently fails with ECOSYSTEM_NOT_SUPPORTED once it runs, since
the reachability pipeline isn't built yet.

ContactConfidenceBand:
type: string
enum: [PRIMARY, SECONDARY, FALLBACK, NONE]
Expand Down Expand Up @@ -806,6 +876,65 @@ paths:
schema:
$ref: '#/components/schemas/Error'

/akrites-external/blast-radius/jobs:
post:
operationId: submitBlastRadiusJob
summary: 2a — Submit a blast-radius analysis job
description: >
Always exactly one job per request — no bulk submit. Omit package for
an advisory-wide analysis; provide it to narrow to a single package.


Not yet implemented: the 7-day result cache, force-bypass semantics,
and the actual reachability pipeline. Today every submission starts a
fresh Temporal workflow that fails with ECOSYSTEM_NOT_SUPPORTED — the
job is accepted and echoed back as pending, but never completes.
tags: [Blast Radius]
security:
- M2MBearer:
- read:packages
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BlastRadiusJobRequest'
responses:
'202':
description: Job accepted.
content:
application/json:
schema:
$ref: '#/components/schemas/BlastRadiusJobEntry'
'400':
description: >
Validation error (missing/empty advisoryId), or an unsupported
ecosystem — only npm is supported today, so any other value
(including a missing ecosystem) is rejected before a workflow
is started.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid bearer token.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: Token missing read:packages scope.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: Too many requests — rate-limited independently of the other endpoints.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'

/akrites-external/contacts/detail:
get:
operationId: getContactDetail
Expand Down
122 changes: 122 additions & 0 deletions backend/src/api/public/v1/packages/blastRadius.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'

import { blastRadiusJobRequestSchema, toBlastRadiusJobEntry } from './blastRadius'

describe('blastRadiusJobRequestSchema', () => {
it('accepts a minimal advisory-wide request and defaults force to false', () => {
const result = blastRadiusJobRequestSchema.parse({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'npm',
})
expect(result).toEqual({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'npm',
package: undefined,
force: false,
})
})

it('accepts a request scoped to a package with ecosystem and force set', () => {
const result = blastRadiusJobRequestSchema.parse({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'npm',
package: 'pkg:npm/lodash',
force: true,
})
expect(result).toEqual({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'npm',
package: 'pkg:npm/lodash',
force: true,
})
})

it('accepts explicit null for package (advisory-wide, explicit)', () => {
const result = blastRadiusJobRequestSchema.parse({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'npm',
package: null,
})
expect(result.package).toBeNull()
})

it('rejects a missing advisoryId', () => {
const result = blastRadiusJobRequestSchema.safeParse({ ecosystem: 'npm' })
expect(result.success).toBe(false)
})

it('rejects an empty advisoryId', () => {
const result = blastRadiusJobRequestSchema.safeParse({
advisoryId: ' ',
ecosystem: 'npm',
})
expect(result.success).toBe(false)
})

it('rejects an advisoryId that is not a GHSA or CVE identifier', () => {
const result = blastRadiusJobRequestSchema.safeParse({
advisoryId: 'foo',
ecosystem: 'npm',
})
expect(result.success).toBe(false)
})

it('accepts a CVE-formatted advisoryId', () => {
const result = blastRadiusJobRequestSchema.safeParse({
advisoryId: 'CVE-2024-12345',
ecosystem: 'npm',
})
expect(result.success).toBe(true)
})

it('rejects a missing ecosystem', () => {
const result = blastRadiusJobRequestSchema.safeParse({ advisoryId: 'GHSA-jf85-cpcp-j695' })
expect(result.success).toBe(false)
})

it('rejects null ecosystem', () => {
const result = blastRadiusJobRequestSchema.safeParse({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: null,
})
expect(result.success).toBe(false)
})

it('rejects an unsupported ecosystem', () => {
const result = blastRadiusJobRequestSchema.safeParse({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'pypi',
})
expect(result.success).toBe(false)
})
})

describe('toBlastRadiusJobEntry', () => {
it('builds a pending job entry echoing the request fields', () => {
const entry = toBlastRadiusJobEntry({
analysisId: 'br_01h',
advisoryId: 'GHSA-jf85-cpcp-j695',
package: 'pkg:npm/lodash',
ecosystem: 'npm',
})
expect(entry).toEqual({
analysisId: 'br_01h',
advisoryId: 'GHSA-jf85-cpcp-j695',
package: 'pkg:npm/lodash',
ecosystem: 'npm',
status: 'pending',
})
})

it('echoes a null package for an advisory-wide job', () => {
const entry = toBlastRadiusJobEntry({
analysisId: 'br_01h',
advisoryId: 'GHSA-jf85-cpcp-j695',
package: null,
ecosystem: 'npm',
})
expect(entry.package).toBeNull()
expect(entry.ecosystem).toBe('npm')
expect(entry.status).toBe('pending')
})
})
54 changes: 54 additions & 0 deletions backend/src/api/public/v1/packages/blastRadius.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { z } from 'zod'

// The reachability pipeline is npm-only for now — every other ecosystem (including
// a missing one) is rejected by the schema below before the Temporal workflow is
// triggered.
export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = ['npm'] as const

// Always exactly one job per request — advisory-wide (package omitted) or narrowed
// to a single package. package accepts either a full purl or a bare package name,
// so it is NOT run through purlFieldSchema/normalizePurl like the other endpoints.
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,})$/

export const blastRadiusJobRequestSchema = z.object({
advisoryId: z
.string()
.trim()
.regex(ADVISORY_ID_PATTERN, 'advisoryId must be a GHSA or CVE identifier'),
ecosystem: z.enum(SUPPORTED_BLAST_RADIUS_ECOSYSTEMS, {
error: `Ecosystem is not supported for blast-radius analysis — only ${SUPPORTED_BLAST_RADIUS_ECOSYSTEMS.join(', ')} supported today`,
Comment thread
ulemons marked this conversation as resolved.
}),
package: z.string().trim().min(1).nullish(),
force: z.boolean().default(false),
})

export type BlastRadiusJobRequest = z.infer<typeof blastRadiusJobRequestSchema>

export type BlastRadiusJobStatus = 'pending' | 'running' | 'done' | 'failed'

export type BlastRadiusJobEcosystem = (typeof SUPPORTED_BLAST_RADIUS_ECOSYSTEMS)[number]

export interface BlastRadiusJobEntry {
analysisId: string
advisoryId: string
package: string | null
ecosystem: BlastRadiusJobEcosystem
status: BlastRadiusJobStatus
}

// Builds the 2a response body. The pipeline isn't implemented yet, so every freshly
// submitted job comes back pending — see analyzeBlastRadius in packages_worker.
export function toBlastRadiusJobEntry(params: {
analysisId: string
advisoryId: string
package: string | null
ecosystem: BlastRadiusJobEcosystem
}): BlastRadiusJobEntry {
return {
analysisId: params.analysisId,
advisoryId: params.advisoryId,
package: params.package,
ecosystem: params.ecosystem,
status: 'pending',
}
}
Loading
Loading