diff --git a/backend/src/api/public/v1/akrites-external/index.ts b/backend/src/api/public/v1/akrites-external/index.ts index e60f35fc3b..0fdefe3121 100644 --- a/backend/src/api/public/v1/akrites-external/index.ts +++ b/backend/src/api/public/v1/akrites-external/index.ts @@ -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, +}) + export function akritesExternalRouter(): Router { const router = Router() @@ -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 } diff --git a/backend/src/api/public/v1/akrites-external/openapi.yaml b/backend/src/api/public/v1/akrites-external/openapi.yaml index 8394c24331..2624232daf 100644 --- a/backend/src/api/public/v1/akrites-external/openapi.yaml +++ b/backend/src/api/public/v1/akrites-external/openapi.yaml @@ -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 @@ -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: @@ -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] @@ -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 diff --git a/backend/src/api/public/v1/packages/blastRadius.test.ts b/backend/src/api/public/v1/packages/blastRadius.test.ts new file mode 100644 index 0000000000..dce7782fb6 --- /dev/null +++ b/backend/src/api/public/v1/packages/blastRadius.test.ts @@ -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') + }) +}) diff --git a/backend/src/api/public/v1/packages/blastRadius.ts b/backend/src/api/public/v1/packages/blastRadius.ts new file mode 100644 index 0000000000..69c7c5b21a --- /dev/null +++ b/backend/src/api/public/v1/packages/blastRadius.ts @@ -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`, + }), + package: z.string().trim().min(1).nullish(), + force: z.boolean().default(false), +}) + +export type BlastRadiusJobRequest = z.infer + +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', + } +} diff --git a/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts b/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts new file mode 100644 index 0000000000..0cec05fd41 --- /dev/null +++ b/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts @@ -0,0 +1,108 @@ +import type { Request, Response } from 'express' +import { describe, expect, it, vi } from 'vitest' + +import { submitBlastRadiusJob } from './submitBlastRadiusJob' + +const { start } = vi.hoisted(() => ({ start: vi.fn().mockResolvedValue(undefined) })) + +vi.mock('@/db/packagesTemporal', () => ({ + getPackagesTemporalClient: vi.fn().mockResolvedValue({ workflow: { start } }), +})) + +function mockReqRes(body: unknown) { + start.mockClear() + + const req = { body } as unknown as Request + + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + const res = { status } as unknown as Response + + return { req, res, start, status, json } +} + +describe('submitBlastRadiusJob', () => { + it('starts analyzeBlastRadius on the blast-radius-worker task queue and responds 202 pending', async () => { + const { req, res, start, status, json } = mockReqRes({ + advisoryId: 'GHSA-jf85-cpcp-j695', + ecosystem: 'npm', + }) + + await submitBlastRadiusJob(req, res) + + expect(start).toHaveBeenCalledTimes(1) + const [workflowType, options] = start.mock.calls[0] + expect(workflowType).toBe('analyzeBlastRadius') + expect(options.taskQueue).toBe('blast-radius-worker') + expect(options.workflowId).toMatch(/^blast-radius-analysis\//) + expect(options.args[0]).toMatchObject({ + advisoryId: 'GHSA-jf85-cpcp-j695', + package: null, + ecosystem: 'npm', + force: false, + }) + expect(typeof options.args[0].analysisId).toBe('string') + + expect(status).toHaveBeenCalledWith(202) + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + advisoryId: 'GHSA-jf85-cpcp-j695', + package: null, + ecosystem: 'npm', + status: 'pending', + }), + ) + }) + + it('passes package/ecosystem/force through to the workflow args and response', async () => { + const { req, res, start, json } = mockReqRes({ + advisoryId: 'GHSA-jf85-cpcp-j695', + package: 'pkg:npm/lodash', + ecosystem: 'npm', + force: true, + }) + + await submitBlastRadiusJob(req, res) + + const [, options] = start.mock.calls[0] + expect(options.args[0]).toMatchObject({ + package: 'pkg:npm/lodash', + ecosystem: 'npm', + force: true, + }) + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ package: 'pkg:npm/lodash', ecosystem: 'npm' }), + ) + }) + + it('rejects a request missing advisoryId without starting a workflow', async () => { + const { req, res, start } = mockReqRes({ ecosystem: 'npm' }) + + await expect(submitBlastRadiusJob(req, res)).rejects.toThrow() + expect(start).not.toHaveBeenCalled() + }) + + it('rejects an unsupported ecosystem without starting a workflow', async () => { + const { req, res, start } = mockReqRes({ + advisoryId: 'GHSA-jf85-cpcp-j695', + ecosystem: 'pypi', + }) + + await expect(submitBlastRadiusJob(req, res)).rejects.toThrow(/not supported/) + expect(start).not.toHaveBeenCalled() + }) + + it('rejects a missing ecosystem without starting a workflow', async () => { + const { req, res, start } = mockReqRes({ advisoryId: 'GHSA-jf85-cpcp-j695' }) + + await expect(submitBlastRadiusJob(req, res)).rejects.toThrow(/not supported/) + expect(start).not.toHaveBeenCalled() + }) + + it('rejects an advisoryId that is not a GHSA or CVE identifier without starting a workflow', async () => { + const { req, res, start } = mockReqRes({ advisoryId: 'foo', ecosystem: 'npm' }) + + await expect(submitBlastRadiusJob(req, res)).rejects.toThrow() + expect(start).not.toHaveBeenCalled() + }) +}) diff --git a/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts b/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts new file mode 100644 index 0000000000..b0e48e29fc --- /dev/null +++ b/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts @@ -0,0 +1,53 @@ +import type { Request, Response } from 'express' + +import { generateUUIDv4 } from '@crowd/common' +import { ITriggerBlastRadiusAnalysis, TemporalWorkflowId } from '@crowd/types' + +import { getPackagesTemporalClient } from '@/db/packagesTemporal' +import { validateOrThrow } from '@/utils/validation' + +import { blastRadiusJobRequestSchema, toBlastRadiusJobEntry } from './blastRadius' + +// 2a — submit a blast-radius analysis job. Always exactly one job per request; no +// persistence/caching/pipeline yet (see analyzeBlastRadius in packages_worker, +// which currently reports every ecosystem as unsupported). Every submission gets a +// fresh analysisId and status pending — the 7-day cache and GET poll endpoint land +// in a follow-up PR once the analysis is actually persisted. +export async function submitBlastRadiusJob(req: Request, res: Response): Promise { + const body = validateOrThrow(blastRadiusJobRequestSchema, req.body) + + const jobPackage = body.package ?? null + const jobEcosystem = body.ecosystem + + const analysisId = generateUUIDv4() + + // blast-radius-worker polls the packages Temporal namespace, not the API's default + // one (req.temporal) — starting it there would leave the workflow queued forever. + const packagesTemporal = await getPackagesTemporalClient() + + await packagesTemporal.workflow.start('analyzeBlastRadius', { + taskQueue: 'blast-radius-worker', + workflowId: `${TemporalWorkflowId.BLAST_RADIUS_ANALYSIS}/${analysisId}`, + retry: { maximumAttempts: 1 }, + args: [ + { + analysisId, + advisoryId: body.advisoryId, + package: jobPackage, + ecosystem: jobEcosystem, + force: body.force, + } satisfies ITriggerBlastRadiusAnalysis, + ], + }) + + // 202, not the shared ok() helper (200) — the contract accepts the job, it does + // not return a completed result. + res.status(202).json( + toBlastRadiusJobEntry({ + analysisId, + advisoryId: body.advisoryId, + package: jobPackage, + ecosystem: jobEcosystem, + }), + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8c065d672..048ef77fc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1321,6 +1321,9 @@ importers: '@crowd/temporal': specifier: workspace:* version: link:../../libs/temporal + '@crowd/types': + specifier: workspace:* + version: link:../../libs/types '@dsnp/parquetjs': specifier: ^1.7.0 version: 1.8.7(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -7097,11 +7100,11 @@ packages: glob@6.0.4: resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -14332,7 +14335,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.3) '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.6.3) - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.0 tsutils: 3.21.0(typescript@5.6.3) optionalDependencies: @@ -14360,7 +14363,7 @@ snapshots: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.0 @@ -14656,13 +14659,13 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color agent-base@7.1.1: dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -15655,15 +15658,15 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.0(supports-color@5.5.0): + debug@4.4.0: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 5.5.0 - debug@4.4.3: + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 decamelize@1.2.0: {} @@ -17154,14 +17157,14 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -17170,14 +17173,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.4: dependencies: agent-base: 7.1.1 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -17643,7 +17646,7 @@ snapshots: dependencies: chalk: 5.4.1 commander: 13.1.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.2.5 @@ -18108,7 +18111,7 @@ snapshots: nodemon@3.1.0: dependencies: chokidar: 3.6.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) ignore-by-default: 1.0.1 minimatch: 3.1.2 pstree.remy: 1.1.8 @@ -18994,7 +18997,7 @@ snapshots: retry-request@4.2.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) extend: 3.0.2 transitivePeerDependencies: - supports-color @@ -19222,7 +19225,7 @@ snapshots: dependencies: '@types/debug': 4.1.12 '@types/validator': 13.11.9 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) dottie: 2.0.6 inflection: 1.13.4 lodash: 4.17.21 @@ -20140,7 +20143,7 @@ snapshots: vite-node@3.2.4(@types/node@20.12.7)(jiti@2.4.2)(terser@5.43.1)(tsx@4.7.3)(yaml@2.7.0): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.3.5(@types/node@20.12.7)(jiti@2.4.2)(terser@5.43.1)(tsx@4.7.3)(yaml@2.7.0) @@ -20200,7 +20203,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 diff --git a/scripts/builders/packages.env b/scripts/builders/packages.env index 7c3dc10b60..03a4f019dc 100644 --- a/scripts/builders/packages.env +++ b/scripts/builders/packages.env @@ -1,4 +1,4 @@ DOCKERFILE="./services/docker/Dockerfile.packages" CONTEXT="../" REPO="sjc.ocir.io/axbydjxa5zuh/packages" -SERVICES="github-repos-enricher bq-dataset-ingest npm-worker pypi-worker maven-worker osv-worker dockerhub-sync cargo-worker go-worker nuget-worker security-contacts-worker rubygems-worker" +SERVICES="github-repos-enricher bq-dataset-ingest npm-worker pypi-worker maven-worker osv-worker dockerhub-sync cargo-worker go-worker nuget-worker security-contacts-worker rubygems-worker blast-radius-worker" diff --git a/scripts/services/blast-radius-worker.yaml b/scripts/services/blast-radius-worker.yaml new file mode 100644 index 0000000000..818ac49500 --- /dev/null +++ b/scripts/services/blast-radius-worker.yaml @@ -0,0 +1,67 @@ +version: '3.1' + +x-env-args: &env-args + DOCKER_BUILDKIT: 1 + NODE_ENV: docker + SERVICE: blast-radius-worker + CROWD_TEMPORAL_TASKQUEUE: blast-radius-worker + CROWD_TEMPORAL_NAMESPACE: ${CROWD_PACKAGES_TEMPORAL_NAMESPACE} + SHELL: /bin/sh + SUPPRESS_NO_CONFIG_WARNING: 'true' + +services: + blast-radius-worker: + build: + context: ../../ + dockerfile: ./scripts/services/docker/Dockerfile.packages + command: 'pnpm run start:blast-radius-worker' + working_dir: /usr/crowd/app/services/apps/packages_worker + env_file: + - ../../backend/.env.dist.local + - ../../backend/.env.dist.composed + - ../../backend/.env.override.local + - ../../backend/.env.override.composed + environment: + <<: *env-args + restart: always + networks: + - crowd-bridge + + blast-radius-worker-dev: + build: + context: ../../ + dockerfile: ./scripts/services/docker/Dockerfile.packages + command: 'pnpm run dev:blast-radius-worker' + working_dir: /usr/crowd/app/services/apps/packages_worker + # user: '${USER_ID}:${GROUP_ID}' + env_file: + - ../../backend/.env.dist.local + - ../../backend/.env.dist.composed + - ../../backend/.env.override.local + - ../../backend/.env.override.composed + environment: + <<: *env-args + hostname: blast-radius-worker + networks: + - crowd-bridge + volumes: + - ../../services/libs/audit-logs/src:/usr/crowd/app/services/libs/audit-logs/src + - ../../services/libs/common/src:/usr/crowd/app/services/libs/common/src + - ../../services/libs/common_services/src:/usr/crowd/app/services/libs/common_services/src + - ../../services/libs/data-access-layer/src:/usr/crowd/app/services/libs/data-access-layer/src + - ../../services/libs/database/src:/usr/crowd/app/services/libs/database/src + - ../../services/libs/integrations/src:/usr/crowd/app/services/libs/integrations/src + - ../../services/libs/logging/src:/usr/crowd/app/services/libs/logging/src + - ../../services/libs/nango/src:/usr/crowd/app/services/libs/nango/src + - ../../services/libs/opensearch/src:/usr/crowd/app/services/libs/opensearch/src + - ../../services/libs/queue/src:/usr/crowd/app/services/libs/queue/src + - ../../services/libs/redis/src:/usr/crowd/app/services/libs/redis/src + - ../../services/libs/snowflake/src:/usr/crowd/app/services/libs/snowflake/src + - ../../services/libs/telemetry/src:/usr/crowd/app/services/libs/telemetry/src + - ../../services/libs/temporal/src:/usr/crowd/app/services/libs/temporal/src + - ../../services/libs/types/src:/usr/crowd/app/services/libs/types/src + - ../../services/apps/packages_worker/src:/usr/crowd/app/services/apps/packages_worker/src + +networks: + crowd-bridge: + external: true diff --git a/services/apps/packages_worker/package.json b/services/apps/packages_worker/package.json index 8b0cc71bfd..e4deb21964 100644 --- a/services/apps/packages_worker/package.json +++ b/services/apps/packages_worker/package.json @@ -52,6 +52,9 @@ "start:rubygems-worker": "CROWD_TEMPORAL_TASKQUEUE=rubygems-worker SERVICE=rubygems-worker tsx src/bin/rubygems-worker.ts", "dev:rubygems-worker": "CROWD_TEMPORAL_TASKQUEUE=rubygems-worker SERVICE=rubygems-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9244 src/bin/rubygems-worker.ts", "dev:rubygems-worker:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && CROWD_TEMPORAL_TASKQUEUE=rubygems-worker SERVICE=rubygems-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9244 src/bin/rubygems-worker.ts", + "start:blast-radius-worker": "CROWD_TEMPORAL_TASKQUEUE=blast-radius-worker SERVICE=blast-radius-worker tsx src/bin/blast-radius-worker.ts", + "dev:blast-radius-worker": "CROWD_TEMPORAL_TASKQUEUE=blast-radius-worker SERVICE=blast-radius-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9245 src/bin/blast-radius-worker.ts", + "dev:blast-radius-worker:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && CROWD_TEMPORAL_TASKQUEUE=blast-radius-worker SERVICE=blast-radius-worker LOG_LEVEL=trace nodemon --watch src --watch ../../libs --ext ts --exec tsx --inspect=0.0.0.0:9245 src/bin/blast-radius-worker.ts", "backfill:maven": "SERVICE=maven tsx src/bin/maven-backfill.ts", "backfill:maven:local": "set -a && . ../../../backend/.env.dist.local && . ../../../backend/.env.override.local && set +a && SERVICE=maven LOG_LEVEL=info tsx src/bin/maven-backfill.ts", "backfill:maven-repo-url": "SERVICE=maven tsx src/bin/maven-repo-url-backfill.ts", @@ -81,6 +84,7 @@ "@crowd/database": "workspace:*", "@crowd/logging": "workspace:*", "@crowd/slack": "workspace:*", + "@crowd/types": "workspace:*", "@dsnp/parquetjs": "^1.7.0", "@google-cloud/bigquery": "^8.3.1", "@google-cloud/storage": "7.19.0", diff --git a/services/apps/packages_worker/src/bin/blast-radius-worker.ts b/services/apps/packages_worker/src/bin/blast-radius-worker.ts new file mode 100644 index 0000000000..7e71eeaf35 --- /dev/null +++ b/services/apps/packages_worker/src/bin/blast-radius-worker.ts @@ -0,0 +1,8 @@ +import { svc } from '../service' + +// On-demand only — analyzeBlastRadius is triggered per request from the backend's +// submitBlastRadiusJob handler (workflow.start), not on a schedule. +setImmediate(async () => { + await svc.init() + await svc.start() +}) diff --git a/services/apps/packages_worker/src/blast-radius/__tests__/ecosystemSupport.test.ts b/services/apps/packages_worker/src/blast-radius/__tests__/ecosystemSupport.test.ts new file mode 100644 index 0000000000..670cc0483c --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/__tests__/ecosystemSupport.test.ts @@ -0,0 +1,19 @@ +import { ApplicationFailure } from '@temporalio/workflow' +import { describe, expect, it } from 'vitest' + +import { buildEcosystemNotSupportedFailure } from '../ecosystemSupport' + +describe('buildEcosystemNotSupportedFailure', () => { + it('builds a non-retryable ApplicationFailure tagged ECOSYSTEM_NOT_SUPPORTED', () => { + const failure = buildEcosystemNotSupportedFailure('npm') + expect(failure).toBeInstanceOf(ApplicationFailure) + expect(failure.type).toBe('ECOSYSTEM_NOT_SUPPORTED') + expect(failure.nonRetryable).toBe(true) + expect(failure.message).toContain('npm') + }) + + it('falls back to "unknown" when ecosystem is null', () => { + const failure = buildEcosystemNotSupportedFailure(null) + expect(failure.message).toContain('unknown') + }) +}) diff --git a/services/apps/packages_worker/src/blast-radius/ecosystemSupport.ts b/services/apps/packages_worker/src/blast-radius/ecosystemSupport.ts new file mode 100644 index 0000000000..63e67f3c11 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/ecosystemSupport.ts @@ -0,0 +1,13 @@ +import { ApplicationFailure } from '@temporalio/workflow' + +// Pure so it's testable outside the workflow sandbox (Workflow.log/context calls +// throw when invoked outside a running workflow — this helper makes none). +// The reachability pipeline isn't built yet — npm is the only ecosystem that will +// eventually get true reachability analysis, others a basic scoring result — so +// every ecosystem fails today, npm included. +export function buildEcosystemNotSupportedFailure(ecosystem: string | null): ApplicationFailure { + return ApplicationFailure.nonRetryable( + `Blast-radius analysis not supported for ecosystem "${ecosystem ?? 'unknown'}"`, + 'ECOSYSTEM_NOT_SUPPORTED', + ) +} diff --git a/services/apps/packages_worker/src/blast-radius/workflows.ts b/services/apps/packages_worker/src/blast-radius/workflows.ts new file mode 100644 index 0000000000..7a6ee8f2da --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/workflows.ts @@ -0,0 +1,15 @@ +import { log } from '@temporalio/workflow' + +import type { ITriggerBlastRadiusAnalysis } from '@crowd/types' + +import { buildEcosystemNotSupportedFailure } from './ecosystemSupport' + +// 2a's on-demand trigger (see submitBlastRadiusJob in the backend akrites-external +// API). The reachability pipeline isn't built yet, so every request fails with a +// non-retryable "ecosystem not supported" error — see ecosystemSupport.ts. No +// proxyActivities yet: nothing here calls out to an activity. +export async function analyzeBlastRadius(input: ITriggerBlastRadiusAnalysis): Promise { + log.info('analyzeBlastRadius received', { ...input }) + + throw buildEcosystemNotSupportedFailure(input.ecosystem) +} diff --git a/services/apps/packages_worker/src/workflows/index.ts b/services/apps/packages_worker/src/workflows/index.ts index a14aee8ed0..439566dfe4 100644 --- a/services/apps/packages_worker/src/workflows/index.ts +++ b/services/apps/packages_worker/src/workflows/index.ts @@ -30,3 +30,4 @@ export { ingestSecurityContacts, ingestSecurityContactsForPurlWorkflow, } from '../security-contacts/workflows' +export { analyzeBlastRadius } from '../blast-radius/workflows' diff --git a/services/libs/types/src/enums/temporal.ts b/services/libs/types/src/enums/temporal.ts index 27f5370212..dee45ec571 100644 --- a/services/libs/types/src/enums/temporal.ts +++ b/services/libs/types/src/enums/temporal.ts @@ -10,4 +10,6 @@ export enum TemporalWorkflowId { MEMBER_BOT_ANALYSIS_WITH_LLM = 'member-bot-analysis-with-llm', DELETE_ORPHAN_MEMBER = 'delete-orphan-member', + + BLAST_RADIUS_ANALYSIS = 'blast-radius-analysis', } diff --git a/services/libs/types/src/temporal/blastRadius.ts b/services/libs/types/src/temporal/blastRadius.ts new file mode 100644 index 0000000000..4fd06814e3 --- /dev/null +++ b/services/libs/types/src/temporal/blastRadius.ts @@ -0,0 +1,7 @@ +export interface ITriggerBlastRadiusAnalysis { + analysisId: string + advisoryId: string + package: string | null + ecosystem: string + force: boolean +} diff --git a/services/libs/types/src/temporal/index.ts b/services/libs/types/src/temporal/index.ts index 18ac0264ae..bdef2d942f 100644 --- a/services/libs/types/src/temporal/index.ts +++ b/services/libs/types/src/temporal/index.ts @@ -1,2 +1,3 @@ +export * from './blastRadius' export * from './cache' export * from './exports' diff --git a/vitest.config.ts b/vitest.config.ts index b043d5e719..c340b4b939 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,5 @@ +import path from 'node:path' + import { loadEnv } from 'vite' import { defineConfig } from 'vitest/config' @@ -20,6 +22,13 @@ function resolveTestEnv(mode: string): Record { } export default defineConfig(({ mode }) => ({ + resolve: { + // Mirrors backend/tsconfig.json's "@/*" -> "./src/*" path alias, which tsc resolves at + // build/runtime via tsconfig-paths but vite/vitest don't pick up on their own. + alias: { + '@': path.resolve(root, 'backend/src'), + }, + }, test: { env: resolveTestEnv(mode), server: {