Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
21 changes: 19 additions & 2 deletions backend/src/api/public/v1/akrites-external/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExte
import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
import { getBlastRadiusJob } from '../packages/getBlastRadiusJob'
import { getBlastRadiusJobBatch } from '../packages/getBlastRadiusJobBatch'
import { submitBlastRadiusJob } from '../packages/submitBlastRadiusJob'
import { submitBlastRadiusJobBatch } from '../packages/submitBlastRadiusJobBatch'

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.
// Defaults to 5 requests/hour.
// Defaults to 50 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,
: 50,
Comment thread
ulemons marked this conversation as resolved.
windowMs:
Number.isSafeInteger(blastRadiusRateLimitWindowMs) && blastRadiusRateLimitWindowMs > 0
? blastRadiusRateLimitWindowMs
Expand Down Expand Up @@ -75,7 +77,22 @@ export function akritesExternalRouter(): Router {
const blastRadiusSubRouter = Router()
blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES]))
blastRadiusSubRouter.post('/jobs', blastRadiusRateLimiter, safeWrap(submitBlastRadiusJob))
// Bulk submit multiplies Temporal workflow starts per request (up to
// MAX_BLAST_RADIUS_JOBS_PER_BATCH), so it sits behind the same strict
// blastRadiusRateLimiter as the single-job route, not the regular one.
blastRadiusSubRouter.post(
/^\/jobs:batch\/?$/,
blastRadiusRateLimiter,
safeWrap(submitBlastRadiusJobBatch),
)
blastRadiusSubRouter.get('/jobs/:analysisId', rateLimiter, safeWrap(getBlastRadiusJob))
// Bulk poll is read-only, same cost profile as the other batch endpoints, so
// it uses the regular rateLimiter.
blastRadiusSubRouter.post(
/^\/jobs:batch\/poll\/?$/,
rateLimiter,
safeWrap(getBlastRadiusJobBatch),
)
router.use('/blast-radius', blastRadiusSubRouter)

return router
Expand Down
208 changes: 198 additions & 10 deletions backend/src/api/public/v1/akrites-external/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,16 @@ tags:
- name: Blast Radius
description: >
Advisory reachability analysis — submit (2a) and poll (2b) are both
implemented. Submitting kicks off a Temporal workflow that runs the
npm reachability pipeline (other ecosystems fail fast with
ECOSYSTEM_NOT_SUPPORTED); poll returns job status and, once done,
results. 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).
implemented, each with a bulk counterpart. Submitting kicks off a
Temporal workflow that runs the npm reachability pipeline (other
ecosystems fail fast with ECOSYSTEM_NOT_SUPPORTED); poll returns job
status and, once done, results. Bulk submit (jobs:batch) is capped at
20 jobs per request (10 recommended as the default batch size) — each
entry starts its own workflow — and stays behind the same strict rate
limiter as the single-job route; bulk poll
(jobs:batch/poll) is read-only and capped at 100 like the other batch
endpoints. Same interim scope note as Advisories applies (read:packages,
pending a dedicated read:advisories scope).

components:
securitySchemes:
Expand Down Expand Up @@ -409,7 +411,10 @@ components:
BlastRadiusJobEntry:
type: object
required: [analysisId, advisoryId, package, ecosystem, status]
description: Response body of 2a — one job per request, never wrapped in an array.
description: >
Response body of 2a for a single submitted job. Returned directly (not
wrapped in an array) by the single-job submit; the batch submit (2a bulk)
returns an array of these under `results` — see BlastRadiusJobBatchResponse.
properties:
analysisId:
type: string
Expand All @@ -427,7 +432,74 @@ components:
status:
type: string
enum: [pending, running, done, failed]
description: Always pending — the response is returned before the Temporal workflow runs.
description: >
Pending in the single-job submit response, always returned before the
Temporal workflow runs. In the batch submit response, a job whose
workflow failed to start comes back as failed instead — the rest of
the batch is unaffected.

BlastRadiusJobBatchRequest:
type: object
required: [jobs]
properties:
jobs:
type: array
minItems: 1
maxItems: 20
description: >
Capped much lower than the 100-item read batches — each entry
starts its own Temporal workflow, so the batch multiplies
workflow starts (and reachability-analysis cost) per request.
10 is the recommended default batch size; 20 is the hard limit.
items:
$ref: '#/components/schemas/BlastRadiusJobRequest'

BlastRadiusJobBatchResponse:
type: object
required: [results]
description: >
Plain array in request order, one entry per submitted job — unlike the
read batches there is no found/not-found case, every job is submitted.
properties:
results:
type: array
items:
$ref: '#/components/schemas/BlastRadiusJobEntry'
Comment thread
ulemons marked this conversation as resolved.

BlastRadiusJobPollBatchRequest:
type: object
required: [analysisIds]
properties:
analysisIds:
type: array
minItems: 1
maxItems: 100
items:
type: string
format: uuid
page:
type: integer
minimum: 1
default: 1
pageSize:
type: integer
minimum: 1
maximum: 100
default: 20

BlastRadiusAnalysisBulkEntry:
type: object
required: [requestedAnalysisId, found, analysis]
properties:
requestedAnalysisId:
type: string
found:
type: boolean
analysis:
type: object
nullable: true
allOf:
- $ref: '#/components/schemas/BlastRadiusAnalysis'

BlastRadiusResultConfidence:
type: string
Expand Down Expand Up @@ -1045,6 +1117,122 @@ paths:
schema:
$ref: '#/components/schemas/Error'

/akrites-external/blast-radius/jobs:batch:
post:
operationId: submitBlastRadiusJobBatch
summary: 2a bulk — Submit multiple blast-radius analysis jobs
description: >
One job per array entry, same semantics as the single-job submit —
omit package for an advisory-wide analysis, provide it to narrow to a
single package. Each entry starts its own Temporal workflow, so the
batch is capped at 20 jobs (10 recommended as the default batch
size), much lower than the 100-item read batches, and stays behind
the same strict rate limiter as the single-job route.


A per-job failure does not fail the whole batch — that entry comes
back with status: 'failed' and the rest still submit.
tags: [Blast Radius]
security:
- M2MBearer:
- read:packages
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BlastRadiusJobBatchRequest'
responses:
'202':
description: Jobs accepted, in request order.
content:
application/json:
schema:
$ref: '#/components/schemas/BlastRadiusJobBatchResponse'
'400':
description: Validation error (empty array, >20 items, or an invalid job entry).
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/blast-radius/jobs:batch/poll:
post:
operationId: getBlastRadiusJobBatch
summary: 2b bulk — Poll multiple blast-radius analysis jobs
description: >
Same found/not-found echo shape as the other batch endpoints: an
unknown analysisId comes back { found: false, analysis: null }
instead of 404ing the whole request. Read-only, so it is rate-limited
the same as the other non-blast-radius endpoints, not the strict
submit limiter.
tags: [Blast Radius]
security:
- M2MBearer:
- read:packages
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BlastRadiusJobPollBatchRequest'
responses:
'200':
description: One page of results, in request order.
content:
application/json:
schema:
type: object
required: [page, pageSize, total, results]
properties:
page:
type: integer
pageSize:
type: integer
total:
type: integer
description: Total number of requested analysisIds, across all pages.
results:
type: array
items:
$ref: '#/components/schemas/BlastRadiusAnalysisBulkEntry'
'400':
description: Validation error (empty array, >100 items, or a non-uuid analysisId).
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'

/akrites-external/blast-radius/jobs/{analysisId}:
get:
operationId: getBlastRadiusJob
Expand Down
93 changes: 93 additions & 0 deletions backend/src/api/public/v1/packages/blastRadiusBatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'

import {
MAX_BLAST_RADIUS_JOBS_PER_BATCH,
MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH,
blastRadiusJobBatchRequestSchema,
blastRadiusJobPollBatchRequestSchema,
paginateAnalysisIds,
} from './blastRadiusBatch'

describe('blastRadiusJobBatchRequestSchema', () => {
it('accepts a batch of valid job requests', () => {
const result = blastRadiusJobBatchRequestSchema.safeParse({
jobs: [
{ advisoryId: 'GHSA-jf85-cpcp-j695', ecosystem: 'npm' },
{ advisoryId: 'CVE-2024-12345', ecosystem: 'npm', package: 'pkg:npm/lodash' },
],
})
expect(result.success).toBe(true)
})

it('rejects an empty jobs array', () => {
const result = blastRadiusJobBatchRequestSchema.safeParse({ jobs: [] })
expect(result.success).toBe(false)
})

it('rejects more than MAX_BLAST_RADIUS_JOBS_PER_BATCH jobs', () => {
const jobs = Array.from({ length: MAX_BLAST_RADIUS_JOBS_PER_BATCH + 1 }, () => ({
advisoryId: 'GHSA-jf85-cpcp-j695',
ecosystem: 'npm',
}))
const result = blastRadiusJobBatchRequestSchema.safeParse({ jobs })
expect(result.success).toBe(false)
})

it('rejects a batch containing one invalid job', () => {
const result = blastRadiusJobBatchRequestSchema.safeParse({
jobs: [
{ advisoryId: 'GHSA-jf85-cpcp-j695', ecosystem: 'npm' },
{ advisoryId: 'not-an-advisory-id', ecosystem: 'npm' },
],
})
expect(result.success).toBe(false)
})
})

describe('blastRadiusJobPollBatchRequestSchema', () => {
const validId = '3fa85f64-5717-4562-b3fc-2c963f66afa6'

it('accepts a batch of valid analysisIds and defaults page/pageSize', () => {
const result = blastRadiusJobPollBatchRequestSchema.parse({ analysisIds: [validId] })
expect(result.page).toBe(1)
expect(result.pageSize).toBe(20)
})

it('rejects an empty analysisIds array', () => {
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds: [] })
expect(result.success).toBe(false)
})

it('rejects a non-uuid analysisId', () => {
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds: ['not-a-uuid'] })
expect(result.success).toBe(false)
})

it('rejects more than MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH analysisIds', () => {
const analysisIds = Array.from(
{ length: MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH + 1 },
() => validId,
)
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds })
expect(result.success).toBe(false)
})
})

describe('paginateAnalysisIds', () => {
it('slices the requested page out of the full analysisIds array', () => {
const analysisIds = ['a', 'b', 'c', 'd', 'e']
const result = paginateAnalysisIds({ analysisIds, page: 2, pageSize: 2 })
expect(result).toEqual({
page: 2,
pageSize: 2,
total: 5,
pagedAnalysisIds: ['c', 'd'],
})
})

it('returns an empty page past the end of the array', () => {
const result = paginateAnalysisIds({ analysisIds: ['a'], page: 2, pageSize: 20 })
expect(result.pagedAnalysisIds).toEqual([])
expect(result.total).toBe(1)
})
})
Loading
Loading