Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 23 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,23 @@ 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.isFinite(blastRadiusRateLimitMax) ? blastRadiusRateLimitMax : 5,
windowMs: Number.isFinite(blastRadiusRateLimitWindowMs)
? 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 +64,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
}
126 changes: 125 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,58 @@ components:
allOf:
- $ref: '#/components/schemas/AdvisoryDetail'

BlastRadiusJobRequest:
type: object
required: [advisoryId]
properties:
advisoryId:
type: string
description: GHSA or CVE identifier.
example: GHSA-jf85-cpcp-j695
ecosystem:
type: string
nullable: true
description: Optional, only meaningful alongside package.
example: npm
Comment thread
ulemons marked this conversation as resolved.
Outdated
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
nullable: true
description: Echoes the request's ecosystem. Null when the request omitted 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 +871,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
99 changes: 99 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,99 @@
import { describe, expect, it } from 'vitest'

import {
blastRadiusJobRequestSchema,
isSupportedBlastRadiusEcosystem,
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' })
expect(result).toEqual({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: undefined,
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/ecosystem (advisory-wide, explicit)', () => {
const result = blastRadiusJobRequestSchema.parse({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: null,
package: null,
})
expect(result.ecosystem).toBeNull()
expect(result.package).toBeNull()
})

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

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

describe('isSupportedBlastRadiusEcosystem', () => {
it('accepts npm', () => {
expect(isSupportedBlastRadiusEcosystem('npm')).toBe(true)
})

it('rejects any other ecosystem', () => {
expect(isSupportedBlastRadiusEcosystem('pypi')).toBe(false)
})

it('rejects null and undefined', () => {
expect(isSupportedBlastRadiusEcosystem(null)).toBe(false)
expect(isSupportedBlastRadiusEcosystem(undefined)).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 null package/ecosystem for an advisory-wide job', () => {
const entry = toBlastRadiusJobEntry({
analysisId: 'br_01h',
advisoryId: 'GHSA-jf85-cpcp-j695',
package: null,
ecosystem: null,
})
expect(entry.package).toBeNull()
expect(entry.ecosystem).toBeNull()
expect(entry.status).toBe('pending')
})
})
52 changes: 52 additions & 0 deletions backend/src/api/public/v1/packages/blastRadius.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { z } from 'zod'

// The reachability pipeline is npm-only for now — every other ecosystem (including
// missing/null) is rejected at the API layer before the Temporal workflow is triggered.
export const SUPPORTED_BLAST_RADIUS_ECOSYSTEMS = ['npm'] as const

export type SupportedBlastRadiusEcosystem = (typeof SUPPORTED_BLAST_RADIUS_ECOSYSTEMS)[number]

export function isSupportedBlastRadiusEcosystem(
ecosystem: string | null | undefined,
): ecosystem is SupportedBlastRadiusEcosystem {
return (SUPPORTED_BLAST_RADIUS_ECOSYSTEMS as readonly string[]).includes(ecosystem ?? '')
}

// 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.
export const blastRadiusJobRequestSchema = z.object({
advisoryId: z.string().trim().min(1, 'advisoryId is required'),
Comment thread
ulemons marked this conversation as resolved.
Outdated
ecosystem: z.string().trim().min(1).nullish(),
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 interface BlastRadiusJobEntry {
analysisId: string
advisoryId: string
package: string | null
ecosystem: string | null
Comment thread
ulemons marked this conversation as resolved.
Outdated
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: string | null
}): BlastRadiusJobEntry {
return {
analysisId: params.analysisId,
advisoryId: params.advisoryId,
package: params.package,
ecosystem: params.ecosystem,
status: 'pending',
}
}
Loading
Loading