diff --git a/backend/.env.dist.composed b/backend/.env.dist.composed index efb78e0a52..e82c8f4d05 100644 --- a/backend/.env.dist.composed +++ b/backend/.env.dist.composed @@ -41,3 +41,4 @@ CROWD_PACKAGES_PACKAGIST_MAILTO= # security-contacts-worker SECURITY_CONTACTS_USER_AGENT="lfx-security-contacts-worker" +CROWD_PACKAGES_TEMPORAL_SERVER_URL=temporal:7233 \ No newline at end of file diff --git a/backend/src/api/public/v1/akrites-external/index.ts b/backend/src/api/public/v1/akrites-external/index.ts index 0fdefe3121..07bed2dd1b 100644 --- a/backend/src/api/public/v1/akrites-external/index.ts +++ b/backend/src/api/public/v1/akrites-external/index.ts @@ -11,13 +11,14 @@ import { getAkritesExternalContactDetail } from '../packages/getAkritesExternalC import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExternalContactDetailBatch' import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail' import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch' +import { getBlastRadiusJob } from '../packages/getBlastRadiusJob' 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. +// much stricter limiter — configurable via env so it can be tuned without a redeploy. +// 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) @@ -72,9 +73,9 @@ export function akritesExternalRouter(): Router { // (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)) + blastRadiusSubRouter.post('/jobs', blastRadiusRateLimiter, safeWrap(submitBlastRadiusJob)) + blastRadiusSubRouter.get('/jobs/:analysisId', rateLimiter, safeWrap(getBlastRadiusJob)) 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 2624232daf..08327892fa 100644 --- a/backend/src/api/public/v1/akrites-external/openapi.yaml +++ b/backend/src/api/public/v1/akrites-external/openapi.yaml @@ -10,10 +10,10 @@ info: Packages, Advisories and Contacts endpoints are implemented. Blast Radius - 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. + submit (2a) and poll (2b) are both implemented, backed by a 4-stage + Temporal pipeline (intel, dependents, reachability, report) for npm + packages; other ecosystems fail fast with ECOSYSTEM_NOT_SUPPORTED. The + 7-day result cache is specced separately and not yet built. TODO: scopes below (read:packages, read:stewardships) are the existing @@ -50,11 +50,12 @@ tags: 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 + 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). @@ -412,7 +413,7 @@ components: properties: analysisId: type: string - description: Opaque identifier for polling via 2b (not yet implemented). + description: Opaque identifier for polling via 2b (GET .../jobs/{analysisId}). advisoryId: type: string package: @@ -426,10 +427,118 @@ components: 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. + description: Always pending — the response is returned before the Temporal workflow runs. + + BlastRadiusResultConfidence: + type: string + enum: [high, medium, low] + description: Crosswalk of the 0-1 reachability confidence score into bands (>=0.8 high, >=0.4 medium, else low). + + BlastRadiusResultItem: + type: object + required: [dependent, affected, verdict, confidence, evidence, downloadsLast30Days] + properties: + dependent: + type: string + description: Purl of the analyzed dependent package. + affected: + type: boolean + description: True only when verdict is 'affected'. Kept for backwards compatibility — prefer verdict, since this field can't distinguish 'not_affected' from 'unclear'. + verdict: + type: string + enum: [affected, not_affected, unclear] + description: Raw reachability verdict. 'unclear' covers both a genuine ambiguous read and a persistent agent failure. + confidence: + $ref: '#/components/schemas/BlastRadiusResultConfidence' + evidence: + type: string + nullable: true + description: Flattened file:line — snippet evidence lines from the reachability agent, one per line. + downloadsLast30Days: + type: string + nullable: true + + BlastRadiusAnalysisSummary: + type: object + required: + - totalDependentsInRange + - dependentsExcludedUpfront + - dependentsAnalyzed + - dependentsAffected + - affectedPercentage + - affectedDependents + properties: + totalDependentsInRange: + type: integer + description: Sum of dependentsAnalyzed and dependentsExcludedUpfront; the count of dependents that fell within the vulnerable version range (before top-N cutoff was applied). + dependentsExcludedUpfront: + type: integer + description: totalDependentsInRange minus dependentsAnalyzed. + dependentsAnalyzed: + type: integer + description: Number of dependents that received a reachability verdict. + dependentsAffected: + type: integer + affectedPercentage: + type: number + nullable: true + description: Rounded to 1 decimal. Null when dependentsAnalyzed is 0, to avoid a misleading 0%. + affectedDependents: + type: array + items: + type: string + + BlastRadiusAnalysis: + type: object + required: + - analysisId + - status + - advisoryId + - package + - ecosystem + - submittedAt + - completedAt + - errorMessage + - summary + - results + description: Response body of 2b — poll for job status and, once done, results. + properties: + analysisId: + type: string + status: + type: string + enum: [pending, running, done, failed] + advisoryId: + type: string + package: + type: string + nullable: true + ecosystem: + type: string + enum: [npm] + submittedAt: + type: string + nullable: true + format: date-time + completedAt: + type: string + nullable: true + format: date-time + errorMessage: + type: string + nullable: true + summary: + type: object + nullable: true + allOf: + - $ref: '#/components/schemas/BlastRadiusAnalysisSummary' + description: Null until status is 'done'. + results: + type: array + nullable: true + description: Null until status is 'done'. + items: + $ref: '#/components/schemas/BlastRadiusResultItem' ContactConfidenceBand: type: string @@ -883,12 +992,13 @@ paths: 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. + Starts a Temporal workflow running the 4-stage reachability pipeline + (intel, dependents, reachability, report) for npm; other ecosystems + fail fast with ECOSYSTEM_NOT_SUPPORTED. Poll status/results via + GET /jobs/{analysisId}. - 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. + Not yet implemented: the 7-day result cache and force-bypass semantics. tags: [Blast Radius] security: - M2MBearer: @@ -935,6 +1045,63 @@ paths: schema: $ref: '#/components/schemas/Error' + /akrites-external/blast-radius/jobs/{analysisId}: + get: + operationId: getBlastRadiusJob + summary: 2b — Poll a blast-radius analysis job + description: > + Returns job status and, once status is 'done', the summary and + per-dependent results. summary and results are null for any other + status. + tags: [Blast Radius] + security: + - M2MBearer: + - read:packages + parameters: + - name: analysisId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Job status (and results, once done). + content: + application/json: + schema: + $ref: '#/components/schemas/BlastRadiusAnalysis' + '400': + description: Malformed analysisId (not a UUID). + 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' + '404': + description: No job with this analysisId. + 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/blastRadiusAnalysis.ts b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts new file mode 100644 index 0000000000..6bddb7a3e6 --- /dev/null +++ b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts @@ -0,0 +1,146 @@ +import type { + AnalysisDetailRow, + VerdictResultRow, +} from '@crowd/data-access-layer/src/packages/blastRadius' + +import type { BlastRadiusJobEcosystem, BlastRadiusJobStatus } from './blastRadius' + +export type BlastRadiusResultConfidence = 'high' | 'medium' | 'low' + +export type BlastRadiusVerdict = 'affected' | 'not_affected' | 'unclear' + +export interface BlastRadiusResultItem { + dependent: string + affected: boolean + verdict: BlastRadiusVerdict + confidence: BlastRadiusResultConfidence + evidence: string | null + downloadsLast30Days: string | null +} + +export interface BlastRadiusAnalysisSummary { + totalDependentsInRange: number + dependentsExcludedUpfront: number + dependentsAnalyzed: number + dependentsAffected: number + affectedPercentage: number | null + affectedDependents: string[] +} + +export interface BlastRadiusAnalysis { + analysisId: string + status: BlastRadiusJobStatus + advisoryId: string + package: string | null + ecosystem: BlastRadiusJobEcosystem + submittedAt: string | null + completedAt: string | null + errorMessage: string | null + summary: BlastRadiusAnalysisSummary | null + results: BlastRadiusResultItem[] | null +} + +// Crosswalk from the DB's NUMERIC(3,2) 0-1 confidence score to the contract's +// enum — matches the PoC methodology's confidence bands (report.py). +function toResultConfidence(confidence: number): BlastRadiusResultConfidence { + if (confidence >= 0.8) return 'high' + if (confidence >= 0.4) return 'medium' + return 'low' +} + +// Scoped npm names (@scope/name) need their leading @ percent-encoded to %40 — +// matching both purl.ts's normalizePurl and the contract's own example response +// bodies (e.g. pkg:npm/%40angular/core in openapi.yaml). +function toPurl(name: string): string { + return `pkg:npm/${name.replace(/^@/, '%40')}` +} + +function flattenEvidence(evidence: Record[] | null): string | null { + if (!evidence || evidence.length === 0) return null + return evidence + .map((e) => { + const file = e.file ? String(e.file) : null + const line = e.line !== undefined && e.line !== null ? String(e.line) : null + const snippet = e.snippet ? String(e.snippet) : null + const location = [file, line].filter(Boolean).join(':') + return [location, snippet].filter(Boolean).join(' — ') + }) + .filter(Boolean) + .join('\n') +} + +// reachable_verdict is 'affected' | 'not_affected' | 'unclear' (see VERDICT_SCHEMA in +// agent/prompts.ts) — 'unclear' covers both a genuine ambiguous read and a persistent +// agent failure (upsertErrorVerdict in reachability.ts). affected=false alone can't +// distinguish "confirmed not affected" from "we don't know" — expose the raw verdict +// too so consumers who care can tell the difference. +function toVerdict(reachableVerdict: string): BlastRadiusVerdict { + if (reachableVerdict === 'affected' || reachableVerdict === 'not_affected') { + return reachableVerdict + } + return 'unclear' +} + +function toResultItem(row: VerdictResultRow): BlastRadiusResultItem { + const verdict = toVerdict(row.reachable_verdict) + return { + dependent: toPurl(row.name), + affected: verdict === 'affected', + verdict, + confidence: toResultConfidence(row.confidence), + evidence: flattenEvidence(row.evidence), + downloadsLast30Days: row.downloads !== null ? String(row.downloads) : null, + } +} + +// Population-level summary. dependentsExcludedUpfront comes from the +// blast_radius_dependents rows actually marked excluded_by_range=true — NOT from +// candidates_considered, which is stage 2's phase-1 population and also counts +// candidates the topN walk never reached (so candidatesConsidered - analyzed +// overstates range exclusions). dependentsAnalyzed = number of verdicts produced, +// dependentsAffected = count with an 'affected' verdict, affectedPercentage rounded +// to 1 decimal — computed over conclusive verdicts only (affected/not_affected), so +// persistent agent/tarball failures ('unclear') don't drag it toward a misleading 0%. +// null when nothing conclusive was analyzed. +function toSummary( + dependentsExcludedUpfront: number, + results: BlastRadiusResultItem[], +): BlastRadiusAnalysisSummary { + const dependentsAnalyzed = results.length + const affected = results.filter((r) => r.affected) + const conclusive = results.filter((r) => r.verdict !== 'unclear') + + return { + totalDependentsInRange: dependentsAnalyzed + dependentsExcludedUpfront, + dependentsExcludedUpfront, + dependentsAnalyzed, + dependentsAffected: affected.length, + affectedPercentage: + conclusive.length > 0 ? Math.round((affected.length / conclusive.length) * 1000) / 10 : null, + affectedDependents: affected.map((r) => r.dependent), + } +} + +export function toBlastRadiusAnalysis( + analysis: AnalysisDetailRow, + verdictRows: VerdictResultRow[], + dependentsExcludedByRangeCount: number, +): BlastRadiusAnalysis { + const status = analysis.status as BlastRadiusJobStatus + const done = status === 'done' + + const results = done ? verdictRows.map(toResultItem) : null + + return { + analysisId: analysis.id, + status, + advisoryId: analysis.advisory_osv_id, + package: analysis.package_name, + ecosystem: analysis.ecosystem as BlastRadiusJobEcosystem, + submittedAt: analysis.started_at, + completedAt: analysis.completed_at, + errorMessage: analysis.error, + summary: done && results ? toSummary(dependentsExcludedByRangeCount, results) : null, + results, + } +} diff --git a/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts b/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts new file mode 100644 index 0000000000..fa9c3f6229 --- /dev/null +++ b/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts @@ -0,0 +1,151 @@ +import type { Request, Response } from 'express' +import { describe, expect, it, vi } from 'vitest' + +import { getBlastRadiusJob } from './getBlastRadiusJob' + +const { getAnalysisDetail, getVerdictResults, getDependentsExcludedByRangeCount } = vi.hoisted( + () => ({ + getAnalysisDetail: vi.fn(), + getVerdictResults: vi.fn(), + getDependentsExcludedByRangeCount: vi.fn(), + }), +) + +vi.mock('@crowd/data-access-layer/src/packages/blastRadius', () => ({ + getAnalysisDetail, + getVerdictResults, + getDependentsExcludedByRangeCount, +})) + +vi.mock('@/db/packagesDb', () => ({ + getPackagesQx: vi.fn().mockResolvedValue({}), +})) + +const ANALYSIS_ID = '11111111-1111-4111-8111-111111111111' + +function mockReqRes(params: unknown) { + getAnalysisDetail.mockClear() + getVerdictResults.mockClear() + getDependentsExcludedByRangeCount.mockClear() + + const req = { params } as unknown as Request + + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + const res = { status, json } as unknown as Response + + return { req, res, status, json } +} + +describe('getBlastRadiusJob', () => { + it('returns a pending analysis with no results/summary', async () => { + getAnalysisDetail.mockResolvedValue({ + id: ANALYSIS_ID, + advisory_osv_id: 'GHSA-jf85-cpcp-j695', + package_name: null, + ecosystem: 'npm', + status: 'pending', + error: null, + candidates_considered: null, + started_at: '2026-07-01T00:00:00.000Z', + completed_at: null, + }) + + const { req, res, json } = mockReqRes({ analysisId: ANALYSIS_ID }) + + await getBlastRadiusJob(req, res) + + expect(getVerdictResults).not.toHaveBeenCalled() + expect(getDependentsExcludedByRangeCount).not.toHaveBeenCalled() + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + analysisId: ANALYSIS_ID, + status: 'pending', + summary: null, + results: null, + }), + ) + }) + + it('returns summary and results for a done analysis', async () => { + getAnalysisDetail.mockResolvedValue({ + id: ANALYSIS_ID, + advisory_osv_id: 'GHSA-jf85-cpcp-j695', + package_name: 'lodash', + ecosystem: 'npm', + status: 'done', + error: null, + candidates_considered: 10, + started_at: '2026-07-01T00:00:00.000Z', + completed_at: '2026-07-01T01:00:00.000Z', + }) + getVerdictResults.mockResolvedValue([ + { + name: 'benchmark.js', + version: '2.1.4', + downloads: 500000, + reachable_verdict: 'affected', + confidence: 0.9, + evidence: [{ file: 'index.js', line: 10, snippet: 'require("lodash").merge' }], + reasoning: 'uses merge', + }, + { + name: 'other-pkg', + version: '1.0.0', + downloads: 100, + reachable_verdict: 'not_affected', + confidence: 0.5, + evidence: null, + reasoning: 'unused', + }, + ]) + getDependentsExcludedByRangeCount.mockResolvedValue(8) + + const { req, res, json } = mockReqRes({ analysisId: ANALYSIS_ID }) + + await getBlastRadiusJob(req, res) + + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'done', + summary: expect.objectContaining({ + totalDependentsInRange: 10, + dependentsAnalyzed: 2, + dependentsExcludedUpfront: 8, + dependentsAffected: 1, + affectedPercentage: 50, + affectedDependents: ['pkg:npm/benchmark.js'], + }), + results: [ + expect.objectContaining({ + dependent: 'pkg:npm/benchmark.js', + affected: true, + verdict: 'affected', + confidence: 'high', + }), + expect.objectContaining({ + dependent: 'pkg:npm/other-pkg', + affected: false, + verdict: 'not_affected', + confidence: 'medium', + }), + ], + }), + ) + }) + + it('404s when the analysis does not exist', async () => { + getAnalysisDetail.mockResolvedValue(null) + + const { req, res } = mockReqRes({ analysisId: ANALYSIS_ID }) + + await expect(getBlastRadiusJob(req, res)).rejects.toThrow() + }) + + it('rejects a non-uuid analysisId', async () => { + const { req, res } = mockReqRes({ analysisId: 'not-a-uuid' }) + + await expect(getBlastRadiusJob(req, res)).rejects.toThrow() + expect(getAnalysisDetail).not.toHaveBeenCalled() + }) +}) diff --git a/backend/src/api/public/v1/packages/getBlastRadiusJob.ts b/backend/src/api/public/v1/packages/getBlastRadiusJob.ts new file mode 100644 index 0000000000..86314127c7 --- /dev/null +++ b/backend/src/api/public/v1/packages/getBlastRadiusJob.ts @@ -0,0 +1,35 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError } from '@crowd/common' +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +import { toBlastRadiusAnalysis } from './blastRadiusAnalysis' + +const paramsSchema = z.object({ + analysisId: z.uuid(), +}) + +// 2b — poll a blast-radius analysis job. results/summary are only populated once +// status is 'done' — see toBlastRadiusAnalysis. +export async function getBlastRadiusJob(req: Request, res: Response): Promise { + const { analysisId } = validateOrThrow(paramsSchema, req.params) + + const qx = await getPackagesQx() + const analysis = await blastRadiusDal.getAnalysisDetail(qx, analysisId) + if (!analysis) { + throw new NotFoundError() + } + + const done = analysis.status === 'done' + const verdictRows = done ? await blastRadiusDal.getVerdictResults(qx, analysisId) : [] + const excludedByRangeCount = done + ? await blastRadiusDal.getDependentsExcludedByRangeCount(qx, analysisId) + : 0 + + ok(res, toBlastRadiusAnalysis(analysis, verdictRows, excludedByRangeCount)) +} diff --git a/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts b/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts index 0cec05fd41..eb70ad1bd9 100644 --- a/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts +++ b/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts @@ -3,14 +3,29 @@ import { describe, expect, it, vi } from 'vitest' import { submitBlastRadiusJob } from './submitBlastRadiusJob' -const { start } = vi.hoisted(() => ({ start: vi.fn().mockResolvedValue(undefined) })) +const { start, createAnalysis, failAnalysis } = vi.hoisted(() => ({ + start: vi.fn().mockResolvedValue(undefined), + createAnalysis: vi.fn().mockResolvedValue(undefined), + failAnalysis: vi.fn().mockResolvedValue(undefined), +})) vi.mock('@/db/packagesTemporal', () => ({ getPackagesTemporalClient: vi.fn().mockResolvedValue({ workflow: { start } }), })) +vi.mock('@/db/packagesDb', () => ({ + getPackagesQx: vi.fn().mockResolvedValue({}), +})) + +vi.mock('@crowd/data-access-layer/src/packages/blastRadius', () => ({ + createAnalysis, + failAnalysis, +})) + function mockReqRes(body: unknown) { start.mockClear() + createAnalysis.mockClear() + failAnalysis.mockClear() const req = { body } as unknown as Request @@ -30,6 +45,14 @@ describe('submitBlastRadiusJob', () => { await submitBlastRadiusJob(req, res) + expect(createAnalysis).toHaveBeenCalledTimes(1) + expect(createAnalysis.mock.calls[0][1]).toMatchObject({ + advisoryOsvId: 'GHSA-jf85-cpcp-j695', + packageName: null, + ecosystem: 'npm', + force: false, + }) + expect(start).toHaveBeenCalledTimes(1) const [workflowType, options] = start.mock.calls[0] expect(workflowType).toBe('analyzeBlastRadius') @@ -80,6 +103,7 @@ describe('submitBlastRadiusJob', () => { await expect(submitBlastRadiusJob(req, res)).rejects.toThrow() expect(start).not.toHaveBeenCalled() + expect(createAnalysis).not.toHaveBeenCalled() }) it('rejects an unsupported ecosystem without starting a workflow', async () => { @@ -90,6 +114,7 @@ describe('submitBlastRadiusJob', () => { await expect(submitBlastRadiusJob(req, res)).rejects.toThrow(/not supported/) expect(start).not.toHaveBeenCalled() + expect(createAnalysis).not.toHaveBeenCalled() }) it('rejects a missing ecosystem without starting a workflow', async () => { @@ -97,6 +122,7 @@ describe('submitBlastRadiusJob', () => { await expect(submitBlastRadiusJob(req, res)).rejects.toThrow(/not supported/) expect(start).not.toHaveBeenCalled() + expect(createAnalysis).not.toHaveBeenCalled() }) it('rejects an advisoryId that is not a GHSA or CVE identifier without starting a workflow', async () => { @@ -104,5 +130,26 @@ describe('submitBlastRadiusJob', () => { await expect(submitBlastRadiusJob(req, res)).rejects.toThrow() expect(start).not.toHaveBeenCalled() + expect(createAnalysis).not.toHaveBeenCalled() + }) + + it('marks the analysis failed and rethrows when workflow.start fails', async () => { + const { req, res } = mockReqRes({ + advisoryId: 'GHSA-jf85-cpcp-j695', + ecosystem: 'npm', + }) + start.mockRejectedValueOnce(new Error('temporal unreachable')) + + await expect(submitBlastRadiusJob(req, res)).rejects.toThrow('temporal unreachable') + + expect(failAnalysis).toHaveBeenCalledTimes(1) + const [, input, errorMessage] = failAnalysis.mock.calls[0] + expect(input).toMatchObject({ + advisoryOsvId: 'GHSA-jf85-cpcp-j695', + packageName: null, + ecosystem: 'npm', + force: false, + }) + expect(errorMessage).toBe('temporal unreachable') }) }) diff --git a/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts b/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts index b0e48e29fc..d92ace7b75 100644 --- a/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts +++ b/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts @@ -1,18 +1,17 @@ import type { Request, Response } from 'express' import { generateUUIDv4 } from '@crowd/common' +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' import { ITriggerBlastRadiusAnalysis, TemporalWorkflowId } from '@crowd/types' +import { getPackagesQx } from '@/db/packagesDb' 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. +// 2a — submit a blast-radius analysis job. Always exactly one job per request. +// Every submission gets a fresh analysisId and status pending. export async function submitBlastRadiusJob(req: Request, res: Response): Promise { const body = validateOrThrow(blastRadiusJobRequestSchema, req.body) @@ -21,24 +20,57 @@ export async function submitBlastRadiusJob(req: Request, res: Response): Promise const analysisId = generateUUIDv4() + // Create the pending row synchronously, before starting the workflow — otherwise a + // client that polls GET /jobs/:analysisId immediately after this 202 can race + // blastRadiusStart's own createAnalysis call and get a 404 for a job that was, in + // fact, accepted. blastRadiusStart's createAnalysis upserts the same row, so this + // is safe to run again from the workflow. + const qx = await getPackagesQx() + await blastRadiusDal.createAnalysis(qx, { + id: analysisId, + advisoryOsvId: body.advisoryId, + packageName: jobPackage, + ecosystem: jobEcosystem, + force: body.force, + }) + // 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: [ + try { + 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, + ], + }) + } catch (err) { + // Without this, a workflow.start failure (Temporal unreachable, task queue + // misconfigured, etc.) leaves the row created above stuck 'pending' forever — + // no workflow ever runs to mark it failed, so poll never reaches a terminal state. + const errorMessage = err instanceof Error ? err.message : String(err) + await blastRadiusDal.failAnalysis( + qx, { - analysisId, - advisoryId: body.advisoryId, - package: jobPackage, + id: analysisId, + advisoryOsvId: body.advisoryId, + packageName: jobPackage, ecosystem: jobEcosystem, force: body.force, - } satisfies ITriggerBlastRadiusAnalysis, - ], - }) + }, + errorMessage, + ) + throw err + } // 202, not the shared ok() helper (200) — the contract accepts the job, it does // not return a completed result. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 048ef77fc9..f8e9565781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1297,6 +1297,9 @@ importers: services/apps/packages_worker: dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.3.216 + version: 0.3.217(@anthropic-ai/sdk@0.112.5(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6) '@crowd/archetype-standard': specifier: workspace:* version: link:../../archetypes/standard @@ -1360,6 +1363,9 @@ importers: semver: specifier: ^7.6.0 version: 7.6.0 + tar: + specifier: ^7.5.20 + version: 7.5.21 tsx: specifier: ^4.7.1 version: 4.7.3 @@ -2652,6 +2658,63 @@ packages: '@actions/io@1.1.3': resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.217': + resolution: {integrity: sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.217': + resolution: {integrity: sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.217': + resolution: {integrity: sha512-Bb4AJxqrVPouM4sYIdvX3/AO5womhe70u3Euv+6B5J2OoqcRaWarVvYevX3KRruC5TvlV2Josw14dsL5qVNL+A==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.217': + resolution: {integrity: sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.217': + resolution: {integrity: sha512-qhugNZd77vAoPMIGM8vFHlbwTltFyI1POmfyl0ZJSpc6v7RE9+5+nqL2aGbGSDsDQkEHrJasXURxIeTMn9ut2w==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.217': + resolution: {integrity: sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.217': + resolution: {integrity: sha512-LuaQ+PXZvIToAR81JoiGa6Me9HDma2WH2oiYlAWh43IWaXHyOqgaI1aqSM0BjDhy2UiYWTvGzAopnqPnk+jSBw==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.217': + resolution: {integrity: sha512-4r/T+ze/S/CLZ58tP4Mw52XPmsc/LOrCOd8jZOqM13FCPWdCMU2osWmszEIKGVMRG2cGsaLVDYcks5cWFqjCjw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.217': + resolution: {integrity: sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.112.5': + resolution: {integrity: sha512-FaaGkyS4/zW4n+8Pr9jQkyo5zWcBNw+R+heCLXdoKDUEuALbPALBk6zLAlmsU+veREiaATxVG0TwkK/cQ3NZsQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@apidevtools/openapi-schemas@2.1.0': resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} engines: {node: '>=10'} @@ -3738,6 +3801,12 @@ packages: engines: {node: '>=6'} hasBin: true + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -3759,6 +3828,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -3817,6 +3890,16 @@ packages: peerDependencies: tslib: '2' + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nangohq/frontend@0.52.4': resolution: {integrity: sha512-eH4e73gF6A/pEqIlUS7ZTmOsqPPAckl0DO/H46Xzk11y9wW7CR5Mcq2nEXSnot/z9KzTmFN7BvNOH005iRvNRA==} @@ -4704,6 +4787,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -5267,6 +5353,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -5321,6 +5411,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: @@ -5613,6 +5711,10 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -5782,6 +5884,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -5960,10 +6066,18 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + conventional-changelog-angular@7.0.0: resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} engines: {node: '>=16'} @@ -5983,6 +6097,10 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.4.0: resolution: {integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==} engines: {node: '>= 0.6'} @@ -6058,6 +6176,10 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + crowd-sentiment@1.1.7: resolution: {integrity: sha512-T7PRAOlvsprraM0LRrTI5qGlCqIMGpnCdGMKHLBMYsm1QTTqSvaSWDD4hDA44W9AbzvzKy6Il90BudlBTxa+6A==} @@ -6712,6 +6834,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@0.7.0: resolution: {integrity: sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==} engines: {node: '>=4'} @@ -6738,6 +6868,12 @@ packages: peerDependencies: express: ^4 || ^5 + express-rate-limit@8.6.0: + resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.17.1: resolution: {integrity: sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==} engines: {node: '>= 0.10.0'} @@ -6750,6 +6886,10 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -6785,6 +6925,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-text-encoding@1.0.6: resolution: {integrity: sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==} @@ -6853,6 +6996,10 @@ packages: resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-replace@3.0.0: resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} engines: {node: '>=4.0.0'} @@ -6956,6 +7103,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@11.3.5: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} @@ -7259,6 +7410,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + engines: {node: '>=16.9.0'} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -7303,6 +7458,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -7343,6 +7502,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.1.13: resolution: {integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==} @@ -7419,6 +7582,10 @@ packages: resolution: {integrity: sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==} engines: {node: '>=0.10.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -7567,6 +7734,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -7689,6 +7859,9 @@ packages: jose@4.15.5: resolution: {integrity: sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==} + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + js-beautify@1.15.1: resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} engines: {node: '>=14'} @@ -7729,12 +7902,19 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -8061,6 +8241,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + mem@1.1.0: resolution: {integrity: sha512-nOBDrc/wgpkd3X/JOhMqYR+/eLqlfLP4oQfoBA6QExIxEl+GU01oyEkwWyueyO8110pUKijtiHGhEmYoOn88oQ==} engines: {node: '>=4'} @@ -8082,6 +8266,10 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -8109,6 +8297,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -8172,9 +8364,17 @@ packages: resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + minizlib@1.3.3: resolution: {integrity: sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -8271,6 +8471,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -8765,6 +8969,9 @@ packages: path-to-regexp@0.1.8: resolution: {integrity: sha512-EErxvEqTuliG5GCVHNt3K3UmfKhlOM26QtiJZ6XBnZgCd7n+P5aHNV37wFHGJSpbjN4danT+1CpOFT4giETmRQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@2.0.0: resolution: {integrity: sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==} engines: {node: '>=4'} @@ -8875,6 +9082,10 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkginfo@0.2.3: resolution: {integrity: sha512-7W7wTrE/NsY8xv/DTGjwNIyNah81EQH0MWcTzrHL6pOpMocOGZc0Mbdz9aXxSrp+U0mSmkU8jrNCDCfUs3sOBg==} engines: {node: '>= 0.4.0'} @@ -9020,6 +9231,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + qs@6.7.0: resolution: {integrity: sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==} engines: {node: '>=0.6'} @@ -9054,6 +9269,10 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -9216,6 +9435,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -9309,6 +9532,10 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + sequelize-cli-typescript@3.2.0-c: resolution: {integrity: sha512-LPz+G3/7oe0NJPwIU1BIDw9xNC4/DRbNVTZtahsv8JrREa1KqGSNb4gsAk807EstRH61bOtW2q4tOahXAlD33g==} engines: {node: '>=4.0.0'} @@ -9363,6 +9590,10 @@ packages: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -9421,6 +9652,10 @@ packages: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} engines: {node: '>= 0.4'} @@ -9437,6 +9672,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -9556,6 +9795,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -9564,6 +9806,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -9727,6 +9973,10 @@ packages: engines: {node: '>=4.5'} deprecated: Old versions of tar 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 + tar@7.5.21: + resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} + engines: {node: '>=18'} + tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -9920,6 +10170,9 @@ packages: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@1.3.0: resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} engines: {node: '>=16'} @@ -9991,6 +10244,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + type@2.7.2: resolution: {integrity: sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==} @@ -10464,6 +10721,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -10515,6 +10776,11 @@ packages: resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} engines: {node: '>=12.20'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -10536,6 +10802,52 @@ snapshots: '@actions/io@1.1.3': {} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.217': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.217': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.217': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.217': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.217': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.217': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.217': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.217': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.217(@anthropic-ai/sdk@0.112.5(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.112.5(zod@4.3.6) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) + zod: 4.3.6 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.217 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.217 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.217 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.217 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.217 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.217 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.217 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.217 + + '@anthropic-ai/sdk@0.112.5(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.3.6 + '@apidevtools/openapi-schemas@2.1.0': {} '@apidevtools/swagger-methods@3.0.2': {} @@ -10628,8 +10940,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.572.0 - '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/client-sso-oidc': 3.572.0(@aws-sdk/client-sts@3.572.0) + '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/core': 3.572.0 '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0) '@aws-sdk/middleware-host-header': 3.567.0 @@ -10823,11 +11135,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.572.0': + '@aws-sdk/client-sso-oidc@3.572.0(@aws-sdk/client-sts@3.572.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/core': 3.572.0 '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0) '@aws-sdk/middleware-host-header': 3.567.0 @@ -10866,6 +11178,7 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.6.2 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso@3.556.0': @@ -11041,11 +11354,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)': + '@aws-sdk/client-sts@3.572.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.572.0 + '@aws-sdk/client-sso-oidc': 3.572.0(@aws-sdk/client-sts@3.572.0) '@aws-sdk/core': 3.572.0 '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0) '@aws-sdk/middleware-host-header': 3.567.0 @@ -11084,7 +11397,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.6.2 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.985.0': @@ -11250,7 +11562,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0)': dependencies: - '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/credential-provider-env': 3.568.0 '@aws-sdk/credential-provider-process': 3.572.0 '@aws-sdk/credential-provider-sso': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) @@ -11427,7 +11739,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.568.0(@aws-sdk/client-sts@3.572.0)': dependencies: - '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/types': 3.567.0 '@smithy/property-provider': 2.2.0 '@smithy/types': 2.12.0 @@ -11739,7 +12051,7 @@ snapshots: '@aws-sdk/token-providers@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.572.0 + '@aws-sdk/client-sso-oidc': 3.572.0(@aws-sdk/client-sts@3.572.0) '@aws-sdk/types': 3.567.0 '@smithy/property-provider': 2.2.0 '@smithy/shared-ini-file-loader': 2.4.0 @@ -12597,6 +12909,10 @@ snapshots: protobufjs: 7.6.2 yargs: 17.7.2 + '@hono/node-server@1.19.14(hono@4.12.31)': + dependencies: + hono: 4.12.31 + '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -12620,6 +12936,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -12677,6 +12997,28 @@ snapshots: dependencies: tslib: 2.8.1 + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.31) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) + hono: 4.12.31 + jose: 6.2.4 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@nangohq/frontend@0.52.4': {} '@nangohq/node@0.69.22': @@ -13908,6 +14250,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.4.17': @@ -14639,6 +14983,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.11.3): dependencies: acorn: 8.11.3 @@ -14677,6 +15026,10 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-keywords@5.1.0(ajv@8.17.1): dependencies: ajv: 8.17.1 @@ -15064,6 +15417,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bowser@2.11.0: {} @@ -15260,6 +15627,8 @@ snapshots: chownr@1.1.4: {} + chownr@3.0.0: {} + chrome-trace-event@1.0.4: {} ci-info@2.0.0: {} @@ -15457,8 +15826,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} + content-type@2.0.0: {} + conventional-changelog-angular@7.0.0: dependencies: compare-func: 2.0.0 @@ -15478,6 +15851,8 @@ snapshots: cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} + cookie@0.4.0: {} cookie@0.4.2: {} @@ -15552,6 +15927,12 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + crowd-sentiment@1.1.7: {} crypt@0.0.2: {} @@ -16320,6 +16701,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + execa@0.7.0: dependencies: cross-spawn: 5.1.0 @@ -16356,6 +16743,14 @@ snapshots: dependencies: express: 4.17.1 + express-rate-limit@8.6.0(express@5.2.1): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + express: 5.2.1 + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color + express@4.17.1: dependencies: accepts: 1.3.8 @@ -16463,6 +16858,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.1 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + ext@1.7.0: dependencies: type: 2.7.2 @@ -16497,6 +16925,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-text-encoding@1.0.6: {} fast-uri@3.0.6: {} @@ -16587,6 +17017,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + find-replace@3.0.0: dependencies: array-back: 3.1.0 @@ -16685,6 +17126,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + fs-extra@11.3.5: dependencies: graceful-fs: 4.2.11 @@ -17086,6 +17529,8 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hono@4.12.31: {} + hosted-git-info@2.8.9: {} hpagent@1.2.0: {} @@ -17153,6 +17598,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -17198,6 +17651,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.1.13: {} ieee754@1.2.1: {} @@ -17259,6 +17716,8 @@ snapshots: invert-kv@1.0.0: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.2.0: {} @@ -17369,6 +17828,8 @@ snapshots: is-promise@2.2.2: {} + is-promise@4.0.0: {} + is-regex@1.1.4: dependencies: call-bind: 1.0.7 @@ -17468,6 +17929,8 @@ snapshots: jose@4.15.5: {} + jose@6.2.4: {} + js-beautify@1.15.1: dependencies: config-chain: 1.1.13 @@ -17503,10 +17966,17 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.24.4 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -17837,6 +18307,8 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.0: {} + mem@1.1.0: dependencies: mimic-fn: 1.2.0 @@ -17865,6 +18337,8 @@ snapshots: merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -17884,6 +18358,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@2.6.0: {} @@ -17927,10 +18405,16 @@ snapshots: minipass@7.0.4: {} + minipass@7.1.3: {} + minizlib@1.3.3: dependencies: minipass: 2.9.0 + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -18014,6 +18498,8 @@ snapshots: negotiator@0.6.3: {} + negotiator@1.0.0: {} + neo-async@2.6.2: {} next-tick@1.1.0: {} @@ -18561,6 +19047,8 @@ snapshots: path-to-regexp@0.1.8: {} + path-to-regexp@8.4.2: {} + path-type@2.0.0: dependencies: pify: 2.3.0 @@ -18657,6 +19145,8 @@ snapshots: pify@2.3.0: {} + pkce-challenge@5.0.1: {} + pkginfo@0.2.3: {} possible-typed-array-names@1.0.0: {} @@ -18807,6 +19297,11 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + qs@6.7.0: {} querystring@0.2.0: {} @@ -18838,6 +19333,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -19071,6 +19573,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -19208,6 +19720,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + sequelize-cli-typescript@3.2.0-c: dependencies: bluebird: 3.7.2 @@ -19271,6 +19799,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} set-function-length@1.2.2: @@ -19338,6 +19875,11 @@ snapshots: es-errors: 1.3.0 object-inspect: 1.13.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 @@ -19368,6 +19910,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -19525,10 +20075,17 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.10.0: {} std-env@4.2.0: {} @@ -19719,6 +20276,14 @@ snapshots: safe-buffer: 5.2.1 yallist: 3.1.1 + tar@7.5.21: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + tdigest@0.1.2: dependencies: bintrees: 1.0.2 @@ -19875,6 +20440,8 @@ snapshots: triple-beam@1.4.1: {} + ts-algebra@2.0.0: {} + ts-api-utils@1.3.0(typescript@5.6.3): dependencies: typescript: 5.6.3 @@ -19947,6 +20514,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + type@2.7.2: {} typed-array-buffer@1.0.2: @@ -20504,6 +21077,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml@1.10.2: {} yaml@2.7.0: {} @@ -20577,4 +21152,8 @@ snapshots: yocto-queue@1.2.1: {} + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@4.3.6: {} diff --git a/services/apps/packages_worker/package.json b/services/apps/packages_worker/package.json index 825d0adf18..dac0d8dca7 100644 --- a/services/apps/packages_worker/package.json +++ b/services/apps/packages_worker/package.json @@ -90,6 +90,7 @@ "@crowd/logging": "workspace:*", "@crowd/slack": "workspace:*", "@crowd/types": "workspace:*", + "@anthropic-ai/claude-agent-sdk": "^0.3.216", "@dsnp/parquetjs": "^1.7.0", "@google-cloud/bigquery": "^8.3.1", "@google-cloud/storage": "7.19.0", @@ -102,6 +103,7 @@ "axios": "^1.16.1", "fast-xml-parser": "^5.8.0", "pg-copy-streams": "^7.0.0", + "tar": "^7.5.20", "tsx": "^4.7.1", "typescript": "^5.6.3", "undici": "^5.29.0", diff --git a/services/apps/packages_worker/src/activities.ts b/services/apps/packages_worker/src/activities.ts index 25b3c1a365..e470b91054 100644 --- a/services/apps/packages_worker/src/activities.ts +++ b/services/apps/packages_worker/src/activities.ts @@ -53,3 +53,11 @@ export { processSecurityContactsBatch, ingestSecurityContactsForPurlActivity, } from './security-contacts/activities' +export { + blastRadiusStart, + blastRadiusFail, + blastRadiusIntel, + blastRadiusDependents, + blastRadiusReachability, + blastRadiusReport, +} from './blast-radius/activities' diff --git a/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts b/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts new file mode 100644 index 0000000000..efa480489a --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { toBareNpmName } from '../packageIdentifier' + +describe('toBareNpmName', () => { + it('returns a bare name unchanged', () => { + expect(toBareNpmName('lodash')).toBe('lodash') + }) + + it('returns a scoped bare name unchanged', () => { + expect(toBareNpmName('@babel/core')).toBe('@babel/core') + }) + + it('strips the pkg:npm/ prefix', () => { + expect(toBareNpmName('pkg:npm/lodash')).toBe('lodash') + }) + + it('decodes an encoded scope separator', () => { + expect(toBareNpmName('pkg:npm/%40babel/core')).toBe('@babel/core') + }) + + it('strips a trailing version', () => { + expect(toBareNpmName('pkg:npm/lodash@4.17.21')).toBe('lodash') + }) + + it('strips a trailing version from a scoped purl', () => { + expect(toBareNpmName('pkg:npm/%40babel/core@7.24.0')).toBe('@babel/core') + }) + + it('strips qualifiers and subpath', () => { + expect(toBareNpmName('pkg:npm/lodash@4.17.21?foo=bar#sub')).toBe('lodash') + }) +}) diff --git a/services/apps/packages_worker/src/blast-radius/__tests__/semverRange.test.ts b/services/apps/packages_worker/src/blast-radius/__tests__/semverRange.test.ts new file mode 100644 index 0000000000..d7fa06e5c0 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/__tests__/semverRange.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' + +import { highestVersion, rangeIncludesAny, versionsInRanges } from '../semverRange' + +describe('semverRange', () => { + describe('versionsInRanges', () => { + it('filters versions matching at least one range', () => { + const versions = ['1.0.0', '1.5.0', '2.0.0', '2.5.0', '3.0.0'] + const ranges = [ + { introduced: '1.0.0', fixed: '2.0.0' }, + { introduced: '3.0.0', fixed: null }, + ] + const result = versionsInRanges(versions, ranges) + expect(result).toEqual(['1.0.0', '1.5.0', '3.0.0']) + }) + + it('handles ranges with only introduced (no fixed)', () => { + const versions = ['1.0.0', '1.5.0', '2.0.0'] + const ranges = [{ introduced: '1.5.0', fixed: null }] + const result = versionsInRanges(versions, ranges) + expect(result).toEqual(['1.5.0', '2.0.0']) + }) + + it('returns empty list if no versions match', () => { + const versions = ['1.0.0', '1.5.0'] + const ranges = [{ introduced: '2.0.0', fixed: '3.0.0' }] + const result = versionsInRanges(versions, ranges) + expect(result).toEqual([]) + }) + + it('skips invalid versions', () => { + const versions = ['1.0.0', 'not-a-version', '2.0.0'] + const ranges = [{ introduced: '1.0.0', fixed: '3.0.0' }] + const result = versionsInRanges(versions, ranges) + expect(result).toEqual(['1.0.0', '2.0.0']) + }) + }) + + describe('rangeIncludesAny', () => { + it('matches when vulnerable version is in declared range', () => { + const result = rangeIncludesAny('^1.2.0', ['1.2.5', '1.3.0']) + expect(result).toEqual({ includes: true, check: 'matched' }) + }) + + it('excludes when no vulnerable version is in range', () => { + const result = rangeIncludesAny('^2.0.0', ['1.0.0', '1.5.0']) + expect(result).toEqual({ includes: false, check: 'excluded' }) + }) + + it('conservatively includes git URLs', () => { + const result = rangeIncludesAny('git@github.com:user/repo.git', ['1.0.0']) + expect(result).toEqual({ includes: true, check: 'unparseable-included' }) + }) + + it('conservatively includes file paths', () => { + const result = rangeIncludesAny('file:../local-package', ['1.0.0']) + expect(result).toEqual({ includes: true, check: 'unparseable-included' }) + }) + + it('conservatively includes "latest"', () => { + const result = rangeIncludesAny('latest', ['1.0.0']) + expect(result).toEqual({ includes: true, check: 'unparseable-included' }) + }) + + it('conservatively includes workspace:* specs', () => { + const result = rangeIncludesAny('workspace:*', ['1.0.0']) + expect(result).toEqual({ includes: true, check: 'unparseable-included' }) + }) + + it('handles empty declared range', () => { + const result = rangeIncludesAny('', ['1.0.0']) + expect(result).toEqual({ includes: true, check: 'unparseable-included' }) + }) + + it('handles complex semver ranges', () => { + const result = rangeIncludesAny('>=1.0.0 <2.0.0 || >=3.0.0', ['1.5.0', '3.5.0']) + expect(result).toEqual({ includes: true, check: 'matched' }) + }) + + it('conservatively includes garbage ranges not caught by the known-prefix checks', () => { + // semver.validRange returns null (not a throw) for this — regression test for a bug + // where isValidSemverRange ignored that null and always reported specs as parseable. + const result = rangeIncludesAny('not-a-real-range!!!', ['1.0.0']) + expect(result).toEqual({ includes: true, check: 'unparseable-included' }) + }) + }) + + describe('highestVersion', () => { + it('returns the highest valid version', () => { + const versions = ['1.0.0', '2.5.0', '1.5.0', '2.0.0'] + const result = highestVersion(versions) + expect(result).toBe('2.5.0') + }) + + it('returns null if no valid versions', () => { + const versions = ['not-a-version', 'also-invalid'] + const result = highestVersion(versions) + expect(result).toBeNull() + }) + + it('filters invalid versions and returns highest valid', () => { + const versions = ['1.0.0', 'invalid', '3.0.0', 'also-bad', '2.0.0'] + const result = highestVersion(versions) + expect(result).toBe('3.0.0') + }) + + it('handles empty list', () => { + const result = highestVersion([]) + expect(result).toBeNull() + }) + }) +}) diff --git a/services/apps/packages_worker/src/blast-radius/activities.ts b/services/apps/packages_worker/src/blast-radius/activities.ts new file mode 100644 index 0000000000..02525cb656 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/activities.ts @@ -0,0 +1,97 @@ +import { heartbeat } from '@temporalio/activity' + +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' +import { getServiceChildLogger } from '@crowd/logging' + +import { getPackagesDb } from '../db' + +import { runDependentsStage } from './stages/dependents' +import { runIntelStage } from './stages/intel' +import { runReachabilityStage } from './stages/reachability' +import { runReportStage } from './stages/report' + +const log = getServiceChildLogger('blast-radius') + +export interface BlastRadiusActivityInput { + analysisId: string + advisoryOsvId: string +} + +export interface BlastRadiusStartInput { + analysisId: string + advisoryOsvId: string + packageName: string | null + ecosystem: string + force: boolean +} + +// Activity: create (or reuse, on retrigger) the analysis row and mark it running. +// All DB access for the workflow lives in activities — Temporal workflow code +// must stay deterministic and cannot talk to the database directly. +export async function blastRadiusStart(input: BlastRadiusStartInput): Promise { + log.info({ analysisId: input.analysisId }, 'blast-radius: starting analysis') + const qx = await getPackagesDb() + await blastRadiusDal.createAnalysis(qx, { + id: input.analysisId, + advisoryOsvId: input.advisoryOsvId, + packageName: input.packageName, + ecosystem: input.ecosystem, + force: input.force, + }) + await blastRadiusDal.markAnalysisRunning(qx, input.analysisId) + log.info({ analysisId: input.analysisId }, 'blast-radius: analysis marked running') +} + +// Activity: mark the analysis row failed with the given error message. Takes the +// same fields as blastRadiusStart so failAnalysis can create the row itself if +// blastRadiusStart never got far enough to (see failAnalysis for why). +export async function blastRadiusFail( + input: BlastRadiusStartInput & { error: string }, +): Promise { + log.warn({ analysisId: input.analysisId, error: input.error }, 'blast-radius: analysis failed') + const qx = await getPackagesDb() + await blastRadiusDal.failAnalysis( + qx, + { + id: input.analysisId, + advisoryOsvId: input.advisoryOsvId, + packageName: input.packageName, + ecosystem: input.ecosystem, + force: input.force, + }, + input.error, + ) +} + +// Activity: Stage 1 — vulnerability intelligence +export async function blastRadiusIntel(input: BlastRadiusActivityInput): Promise { + log.info({ analysisId: input.analysisId }, 'blast-radius: intel stage starting') + const qx = await getPackagesDb() + await runIntelStage(qx, input.analysisId, input.advisoryOsvId, heartbeat) + log.info({ analysisId: input.analysisId }, 'blast-radius: intel stage done') +} + +// Activity: Stage 2 — dependents scan +export async function blastRadiusDependents(input: BlastRadiusActivityInput): Promise { + log.info({ analysisId: input.analysisId }, 'blast-radius: dependents stage starting') + const qx = await getPackagesDb() + await runDependentsStage(qx, input.analysisId, heartbeat) + log.info({ analysisId: input.analysisId }, 'blast-radius: dependents stage done') +} + +// Activity: Stage 3 — reachability analysis +export async function blastRadiusReachability(input: BlastRadiusActivityInput): Promise { + log.info({ analysisId: input.analysisId }, 'blast-radius: reachability stage starting') + const qx = await getPackagesDb() + await runReachabilityStage(qx, input.analysisId, heartbeat) + log.info({ analysisId: input.analysisId }, 'blast-radius: reachability stage done') +} + +// Activity: Stage 4 — report aggregation +export async function blastRadiusReport(input: BlastRadiusActivityInput): Promise { + log.info({ analysisId: input.analysisId }, 'blast-radius: report stage starting') + const qx = await getPackagesDb() + await runReportStage(qx, input.analysisId) + heartbeat() + log.info({ analysisId: input.analysisId }, 'blast-radius: report stage done') +} diff --git a/services/apps/packages_worker/src/blast-radius/agent/prompts.ts b/services/apps/packages_worker/src/blast-radius/agent/prompts.ts new file mode 100644 index 0000000000..87756159c5 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/agent/prompts.ts @@ -0,0 +1,254 @@ +// Agent prompts and schemas for Stage 1 (intel) and Stage 3 (reachability). +// Ported from Python PoC (agent/prompts.py). + +export interface VulnerableSymbol { + name: string + kind: string + defined_in: string + exported_as: string[] + notes?: string +} + +export interface SymbolSpec { + vuln_id: string + package: string + summary: string + vulnerable_symbols: VulnerableSymbol[] + import_signatures: Record + exploit_preconditions: string + reachability_notes: string + confidence: number +} + +// ---------- STAGE 1: INTEL ---------- + +export const INTEL_SCHEMA = { + type: 'object', + properties: { + summary: { + type: 'string', + description: '1-2 sentence plain-language summary of the vulnerability', + }, + vulnerable_symbols: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + kind: { type: 'string' }, + defined_in: { type: 'string' }, + exported_as: { type: 'array', items: { type: 'string' } }, + notes: { type: 'string' }, + }, + required: ['name', 'kind', 'defined_in', 'exported_as'], + }, + }, + import_signatures: { + type: 'object', + description: + "Every way a dependent's code could import/reach the vulnerable symbol(s), grouped by style", + properties: { + main_then_member: { type: 'array', items: { type: 'string' } }, + deep_import: { type: 'array', items: { type: 'string' } }, + standalone_pkg: { type: 'array', items: { type: 'string' } }, + aliases_and_wrappers: { type: 'array', items: { type: 'string' } }, + }, + required: ['main_then_member', 'deep_import', 'standalone_pkg', 'aliases_and_wrappers'], + }, + exploit_preconditions: { type: 'string' }, + reachability_notes: { + type: 'string', + description: + 'Guidance for the reachability analysts: what counts as reaching the vulnerability, and what explicitly does NOT', + }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + }, + required: [ + 'summary', + 'vulnerable_symbols', + 'import_signatures', + 'exploit_preconditions', + 'reachability_notes', + 'confidence', + ], +} + +export const INTEL_SYSTEM_PROMPT = `You are a vulnerability analyst. Your working directory contains the FULL SOURCE of the +vulnerable version of an npm package. You are given the security advisory and the patch +(diff) that fixed the vulnerability. + +Your job is to determine, precisely, WHAT is vulnerable — so that downstream analysts can +check whether other packages actually reach the vulnerable code. + +Rules: +- Identify the exact vulnerable function(s)/symbol(s) from the patch and the source. Be + minimal and precise: do NOT include similar-but-unaffected functions. If the patch only + touches an internal helper, trace which PUBLIC exported functions route through it and + list those as the reachable surface (note the internal helper in \`notes\`). +- Read the package source to verify how each vulnerable symbol is exported (main entry, + per-file module paths, re-exports). +- Build \`import_signatures\`: concrete code patterns a JavaScript/TypeScript dependent + would contain if it uses the vulnerable symbol. Cover: importing the main package then + accessing the member (CommonJS and ESM), deep/module-path imports, standalone + per-function npm packages if they exist for this symbol, and common alias/wrapper + patterns. These are the patterns analysts will grep for — make them literal and + greppable, not prose. +- \`reachability_notes\` must state what does NOT count (e.g. sibling functions that look + similar but are not affected) and any conditions required for exploitability. +- Set \`confidence\` for your identification: 0.9+ only if the patch unambiguously + identifies the symbol(s); lower if you had to infer from indirect evidence.` + +export function buildIntelPrompt( + ovsId: string, + aliases: string[], + details: string, + analyzedVersion: string, + patches: Record, +): string { + const parts: string[] = [] + + parts.push(`# Advisory ${ovsId}`) + if (aliases.length > 0) { + parts.push(`Aliases: ${aliases.join(', ')}`) + } + + parts.push(`\n## Advisory text\n${details.slice(0, 8000)}`) + parts.push( + `\nThe working directory contains the source of the vulnerable version ${analyzedVersion}.`, + ) + + if (Object.keys(patches).length > 0) { + parts.push('\n## Fix patch(es)') + const budget = Math.floor(40000 / Math.max(Object.keys(patches).length, 1)) + for (const [slug, text] of Object.entries(patches)) { + parts.push(`\n### ${slug}\n\`\`\`diff\n${text.slice(0, budget)}\n\`\`\``) + } + } else { + parts.push( + '\nNo fix patch could be retrieved. Derive the vulnerable symbols from the ' + + 'advisory text and the package source alone, and lower your confidence accordingly.', + ) + } + + parts.push( + '\nAnalyze the vulnerability and produce the structured output. ' + + 'Verify export paths against the actual source files before answering.', + ) + + return parts.join('\n') +} + +// ---------- STAGE 3: REACHABILITY ---------- + +export const VERDICT_SCHEMA = { + type: 'object', + properties: { + uses_package: { + type: 'boolean', + description: + "Does the dependent's own code reference the vulnerable package (or a standalone per-function variant) at all?", + }, + imports_vulnerable_symbol: { type: 'boolean' }, + import_style: { + type: 'string', + enum: ['main-member', 'deep-import', 'standalone-pkg', 'reexport', 'none'], + description: + "How the VULNERABLE SYMBOL itself is reached — 'none' whenever the vulnerable symbol is not imported/reached, even if the package is imported for other functions", + }, + reachable_verdict: { + type: 'string', + enum: ['affected', 'not_affected', 'unclear'], + }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + evidence: { + type: 'array', + items: { + type: 'object', + properties: { + file: { type: 'string' }, + line: { type: 'integer' }, + snippet: { type: 'string' }, + }, + required: ['file', 'line', 'snippet'], + }, + }, + reasoning: { type: 'string' }, + }, + required: [ + 'uses_package', + 'imports_vulnerable_symbol', + 'import_style', + 'reachable_verdict', + 'confidence', + 'evidence', + 'reasoning', + ], +} + +export function buildReachabilitySystemPrompt(spec: SymbolSpec): string { + const symbolsText = spec.vulnerable_symbols + .map((s) => { + const exportedAs = s.exported_as.length > 0 ? s.exported_as.join(', ') : 'n/a' + const notes = s.notes ? ` — ${s.notes}` : '' + return `- \`${s.name}\` (${s.kind}, defined in ${s.defined_in}; exported as: ${exportedAs})${notes}` + }) + .join('\n') + + const signatures = JSON.stringify(spec.import_signatures, null, 2) + + return `You are a security reachability analyst. Your working directory contains the published +source of ONE npm package (the "dependent") that declares a dependency on +\`${spec.package}\`, which has a known vulnerability (${spec.vuln_id}). + +## The vulnerability +${spec.summary} + +Vulnerable symbol(s) in \`${spec.package}\`: +${symbolsText} + +Exploit preconditions: ${spec.exploit_preconditions} + +Analyst notes: ${spec.reachability_notes} + +## Import signatures to look for +${signatures} + +## Your task +Decide whether THIS dependent's own code actually reaches the vulnerable symbol(s). + +Scope rules — follow strictly: +1. Only the dependent's OWN shipped code counts. Ignore anything under \`node_modules/\`. + Usage of the vulnerable symbol inside the dependent's other dependencies is OUT OF + SCOPE (that is second-level analysis, done separately). +2. Merely importing/depending on \`${spec.package}\` is NOT enough — the vulnerable + symbol itself must be reached. Uses of other functions from the package are irrelevant. +3. Usage only in test files, examples, benchmarks, or build scripts that are not part of + the shipped runtime code → \`not_affected\` (explain in reasoning). +4. If the dependent RE-EXPORTS the vulnerable symbol to its own consumers (barrel files, + wrapper utilities that pass arguments through), that DOES count as \`affected\` with + \`import_style: "reexport"\` — it propagates the vulnerable surface. +5. Watch for indirect reachability inside the dependent's own code: local wrapper + functions, aliased imports, destructuring, dynamic member access like \`_[name]\`. +6. \`import_style\` describes how the VULNERABLE SYMBOL is reached, not how the package is + imported: report \`none\` whenever the vulnerable symbol itself is not reached, even if + the package is imported for other functions. + +Method: grep for the import signatures (and the bare symbol names) across the source, +open every hit, and trace whether the symbol is actually invoked. Check the package's +entry points (package.json main/exports) to understand what ships. Minified/bundled-only +code you cannot confidently interpret → \`unclear\`. + +## Confidence calibration +- 0.8–1.0: direct evidence — you found (or ruled out) the import AND the call site + explicitly; source was readable. +- 0.4–0.8: symbol is imported but the call path is ambiguous (dynamic dispatch, + conditional use, partial minification). +- <0.4 and/or \`unclear\`: source is minified/bundled/absent, or indirection you could + not resolve. + +Report evidence as exact file paths, line numbers, and short verbatim snippets.` +} + +export const REACHABILITY_PROMPT = + 'Analyze this package per your instructions and produce the structured verdict. ' + + 'Start by listing the package structure and grepping for the import signatures.' diff --git a/services/apps/packages_worker/src/blast-radius/agent/runner.ts b/services/apps/packages_worker/src/blast-radius/agent/runner.ts new file mode 100644 index 0000000000..8756c0dfb0 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/agent/runner.ts @@ -0,0 +1,144 @@ +// @anthropic-ai/claude-agent-sdk ships ESM-only; packages_worker compiles to +// CommonJS, so it must be loaded via dynamic import rather than a static one. + +// Agent runner wrapping Claude Agent SDK with read-only tool restrictions, +// API key fallback, structured output, and timeout support. + +export interface AgentRunResult { + structuredOutput: Record | null + isError: boolean + errorMessage: string + numTurns: number + costUsd: number +} + +export interface RunAnalysisAgentInput { + prompt: string + systemPrompt: string + cwd: string + model: string + schema: Record + maxTurns?: number + timeoutMs?: number + // Called on every streamed message (i.e. at least once per agent turn), so a + // caller can heartbeat a Temporal activity during a single up-to-timeoutMs + // agent call rather than only once the whole call returns. + onProgress?: () => void +} + +export async function runAnalysisAgent(input: RunAnalysisAgentInput): Promise { + const { + prompt, + systemPrompt, + cwd, + model, + schema, + maxTurns = 15, + timeoutMs = 600_000, + onProgress, + } = input + + const apiKey = process.env.BLAST_RADIUS_ANTHROPIC_API_KEY + const baseUrl = process.env.BLAST_RADIUS_ANTHROPIC_BASE_URL + + // Build environment: if API key is set, pass it (and an optional base URL override, + // e.g. for routing through a LiteLLM proxy); otherwise omit to fall back to CLI auth. + const env = apiKey + ? { + ...process.env, + ANTHROPIC_API_KEY: apiKey, + ...(baseUrl ? { ANTHROPIC_BASE_URL: baseUrl } : {}), + } + : undefined + + // Setup timeout via AbortController + const controller = new AbortController() + const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs) + + try { + const { query } = await import('@anthropic-ai/claude-agent-sdk') + const q = query({ + prompt, + options: { + systemPrompt, + cwd, + model, + maxTurns, + tools: ['Read', 'Grep', 'Glob'], + disallowedTools: ['Bash', 'Write', 'Edit', 'NotebookEdit', 'WebFetch', 'WebSearch', 'Task'], + // Read/Grep/Glob are read-only — auto-allow them instead of bypassing permissions + // entirely. bypassPermissions requires --dangerously-skip-permissions, which the + // Claude Code CLI refuses to run as root — the exact setup packages_worker's + // container runs under. + allowedTools: ['Read', 'Grep', 'Glob'], + outputFormat: { + type: 'json_schema', + schema, + }, + abortController: controller, + env, + }, + }) + + let result: AgentRunResult | null = null + let turns = 0 + + for await (const message of q) { + onProgress?.() + + if (message.type === 'result') { + turns = message.num_turns ?? 0 + + if (message.subtype !== 'success') { + result = { + structuredOutput: null, + isError: true, + errorMessage: message.errors?.[0] ?? message.subtype ?? 'Unknown error', + numTurns: turns, + costUsd: message.total_cost_usd ?? 0, + } + } else { + const structuredOutput = (message.structured_output ?? null) as Record< + string, + unknown + > | null + result = { + structuredOutput, + isError: message.is_error, + errorMessage: message.is_error + ? message.result || 'Unknown error' + : !structuredOutput + ? `Agent completed without structured output: ${message.result || 'no result text'}` + : '', + numTurns: turns, + costUsd: message.total_cost_usd ?? 0, + } + } + break + } + } + + if (!result) { + return { + structuredOutput: null, + isError: true, + errorMessage: 'No result message received from agent', + numTurns: turns, + costUsd: 0, + } + } + + return result + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + return { + structuredOutput: null, + isError: true, + errorMessage: errorMsg, + numTurns: 0, + costUsd: 0, + } + } finally { + clearTimeout(timeoutHandle) + } +} diff --git a/services/apps/packages_worker/src/blast-radius/clients/githubPatch.ts b/services/apps/packages_worker/src/blast-radius/clients/githubPatch.ts new file mode 100644 index 0000000000..ea0f58ea18 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/clients/githubPatch.ts @@ -0,0 +1,23 @@ +// GitHub patch fetcher — downloads .patch files for commits and PRs. + +// Parse a GitHub URL to create a slug for caching. +export function patchSlug(url: string): string | null { + const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(commit|pull)\/([^/?#]+)/) + if (!match) return null + + const [, owner, repo, type, ref] = match + const typeShort = type === 'commit' ? 'commit' : 'pr' + return `${owner}-${repo}-${typeShort}-${ref.slice(0, 12)}` +} + +// Fetch a patch from a GitHub commit or PR URL. +export async function fetchPatch(url: string): Promise { + const patchUrl = `${url}.patch` + const res = await fetch(patchUrl) + + if (!res.ok) { + throw new Error(`Failed to fetch patch from ${patchUrl}: ${res.status} ${res.statusText}`) + } + + return res.text() +} diff --git a/services/apps/packages_worker/src/blast-radius/clients/npmAbbreviated.ts b/services/apps/packages_worker/src/blast-radius/clients/npmAbbreviated.ts new file mode 100644 index 0000000000..4489f03437 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/clients/npmAbbreviated.ts @@ -0,0 +1,68 @@ +import type { FetchError } from '../../npm/types' + +const REGISTRY = 'https://registry.npmjs.org' +const USER_AGENT = 'lfx-packages-worker/0.1 (+https://lfx.linuxfoundation.org)' + +function encodeNpmName(name: string): string { + return name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name) +} + +export interface AbbreviatedVersion { + dependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record + dist?: { tarball?: string } +} + +export interface AbbreviatedPackument { + 'dist-tags': Record + versions: Record +} + +// npm's abbreviated metadata format (dependencies/dist-tags only, no readme/maintainers/etc.) — +// used for the high-volume candidate pre-scan in dependentsScan.ts, where only the declared +// dependency ranges matter and the full packument payload would be wasted bandwidth. +export async function fetchAbbreviatedPackument( + name: string, +): Promise { + const url = `${REGISTRY}/${encodeNpmName(name)}` + const abort = new AbortController() + const timer = setTimeout(() => abort.abort(), 30_000) + let res: Response + try { + res = await fetch(url, { + headers: { + Accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8', + 'User-Agent': USER_AGENT, + }, + signal: abort.signal, + }) + } catch (err) { + return { kind: 'TRANSIENT', message: String(err) } + } finally { + clearTimeout(timer) + } + + if (res.status === 404) + return { kind: 'NOT_FOUND', message: `${name} not found`, statusCode: 404 } + if (res.status === 429) return { kind: 'RATE_LIMIT', message: 'rate limited', statusCode: 429 } + if (!res.ok) return { kind: 'TRANSIENT', message: `HTTP ${res.status}`, statusCode: res.status } + + let json: unknown + try { + json = await res.json() + } catch { + return { kind: 'MALFORMED', message: 'invalid JSON' } + } + + if ( + typeof json !== 'object' || + json === null || + !('versions' in json) || + !('dist-tags' in json) + ) { + return { kind: 'MALFORMED', message: 'unexpected shape' } + } + + return json as AbbreviatedPackument +} diff --git a/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts b/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts new file mode 100644 index 0000000000..80f302cee0 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts @@ -0,0 +1,118 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { Readable, Transform, Writable } from 'stream' +import type { ReadableStream as NodeWebReadableStream } from 'stream/web' +import * as tar from 'tar' + +// Tarball content here is third-party (dependent packages), not something we +// control — bound both the download and the extraction so a huge or maliciously +// crafted archive (decompression bomb) can't exhaust a worker's disk/CPU. +const FETCH_TIMEOUT_MS = 2 * 60 * 1000 +const MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024 +const MAX_EXTRACTED_BYTES = 500 * 1024 * 1024 +const MAX_EXTRACTED_FILES = 20_000 + +// Downloads an npm tarball and extracts it into destDir, stripping the +// package's own top-level directory (npm tarballs are always wrapped in a +// single `package/` dir) if all entries share one. Guards against path +// traversal since tarball content here is third-party (dependent packages), +// not something we control. +export async function downloadAndExtractTarball( + tarballUrl: string, + destDir: string, +): Promise { + fs.mkdirSync(destDir, { recursive: true }) + + const controller = new AbortController() + const timeoutHandle = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + try { + const res = await fetch(tarballUrl, { signal: controller.signal }) + if (!res.ok) { + throw new Error(`Failed to fetch tarball: ${res.status} ${res.statusText}`) + } + if (!res.body) { + throw new Error('No response body from tarball fetch') + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tarball-extract-')) + try { + // tar rejects (preservePaths defaults to false) any entry whose path would resolve + // outside cwd; strict:true makes that a thrown error instead of a silent warn+skip. + let extractedBytes = 0 + let extractedFiles = 0 + const extractStream = tar.extract({ + cwd: tempDir, + strict: true, + filter: (_entryPath, entry) => { + extractedFiles++ + extractedBytes += entry.size ?? 0 + if (extractedFiles > MAX_EXTRACTED_FILES || extractedBytes > MAX_EXTRACTED_BYTES) { + ;(extractStream as unknown as Writable).destroy( + new Error('Tarball extraction exceeded size/file limits'), + ) + return false + } + return true + }, + }) + + let downloadedBytes = 0 + const downloadLimiter = new Transform({ + transform(chunk, _encoding, callback) { + downloadedBytes += chunk.length + if (downloadedBytes > MAX_DOWNLOAD_BYTES) { + callback(new Error('Tarball download exceeded size limit')) + return + } + callback(null, chunk) + }, + }) + + await new Promise((resolve, reject) => { + Readable.fromWeb(res.body as unknown as NodeWebReadableStream) + .on('error', reject) + .pipe(downloadLimiter) + .on('error', reject) + .pipe(extractStream as unknown as NodeJS.WritableStream) + .on('finish', resolve) + .on('error', reject) + }) + + const items = fs.readdirSync(tempDir) + const commonPrefix = + items.length === 1 && fs.statSync(path.join(tempDir, items[0])).isDirectory() + ? items[0] + : null + const srcBase = commonPrefix ? path.join(tempDir, commonPrefix) : tempDir + + copyTreeGuarded(srcBase, destDir) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + } finally { + clearTimeout(timeoutHandle) + } +} + +// Copies every file under srcBase into destDir, rejecting any entry whose +// resolved path would land outside destDir. +function copyTreeGuarded(srcBase: string, destDir: string): void { + const resolvedDest = path.resolve(destDir) + const entries = fs.readdirSync(srcBase, { recursive: true }) as string[] + + for (const entry of entries) { + const srcPath = path.join(srcBase, entry) + const destPath = path.resolve(destDir, entry) + if (destPath !== resolvedDest && !destPath.startsWith(resolvedDest + path.sep)) { + continue + } + + const stat = fs.statSync(srcPath) + if (stat.isDirectory()) continue + + fs.mkdirSync(path.dirname(destPath), { recursive: true }) + fs.copyFileSync(srcPath, destPath) + } +} diff --git a/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts new file mode 100644 index 0000000000..3937549536 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts @@ -0,0 +1,138 @@ +// OSV.dev API client for fetching vulnerability records by GHSA/CVE ID. + +export interface OsvAffectedPackage { + package: { + ecosystem: string + name: string + } + ranges?: Array<{ + type: string + events?: Array<{ + introduced?: string + fixed?: string + last_affected?: string + }> + }> + versions?: string[] +} + +export interface OsvVuln { + id: string + aliases: string[] + summary?: string + details?: string + references: Array<{ + type?: string + url: string + }> + affected?: OsvAffectedPackage[] + modified?: string + published?: string +} + +const OSV_API_BASE = 'https://api.osv.dev/v1' + +export async function fetchOsvVuln(vulnId: string): Promise { + const url = `${OSV_API_BASE}/vulns/${encodeURIComponent(vulnId)}` + const res = await fetch(url) + + if (!res.ok) { + throw new Error(`OSV.dev fetch failed: ${res.status} ${res.statusText}`) + } + + return (await res.json()) as OsvVuln +} + +// Extract npm-specific affected packages from an OSV record. +export function affectedNpmEntries(vuln: OsvVuln): OsvAffectedPackage[] { + if (!vuln.affected) return [] + return vuln.affected.filter((a) => a.package?.ecosystem === 'npm') +} + +// Flatten SEMVER-type ranges in an affected package into {introduced, fixed, lastAffected} +// triples. last_affected is OSV's inclusive upper bound (distinct from fixed, which is +// exclusive) — it closes the range at that version rather than leaving it open-ended. +// If OSV provides only a versions[] list (no ranges[]), convert each exact version into +// a degenerate range (introduced=v, last_affected=v) to match isInRange semantics. +export function semverRangeEvents( + entry: OsvAffectedPackage, +): Array<{ introduced: string | null; fixed: string | null; lastAffected: string | null }> { + const events: Array<{ + introduced: string | null + fixed: string | null + lastAffected: string | null + }> = [] + + if (entry.ranges) { + for (const range of entry.ranges) { + if (range.type !== 'SEMVER' || !range.events) continue + + let introduced: string | null = null + + for (const event of range.events) { + if (event.introduced) introduced = event.introduced + + if (event.fixed) { + events.push({ introduced, fixed: event.fixed, lastAffected: null }) + introduced = null + } else if (event.last_affected) { + events.push({ introduced, fixed: null, lastAffected: event.last_affected }) + introduced = null + } + } + + // Trailing open range: introduced but never closed by fixed/last_affected. + if (introduced !== null) { + events.push({ introduced, fixed: null, lastAffected: null }) + } + } + } + + // Fallback: if no SEMVER ranges, use explicit version list as exact vulnerable versions. + if (events.length === 0 && (entry.versions ?? []).length > 0) { + for (const v of entry.versions ?? []) { + events.push({ introduced: v, fixed: null, lastAffected: v }) + } + } + + return events +} + +// True only when the URL's host is github.com or a github.com subdomain — a substring +// check like `url.includes('github.com')` would also match hostiles such as +// `evil.com/?x=github.com` or `github.com.evil.com`. +function isGithubUrl(url: string): boolean { + try { + const { hostname } = new URL(url) + return hostname === 'github.com' || hostname.endsWith('.github.com') + } catch { + return false + } +} + +// Extract the first FIX-type GitHub reference (commit or PR), then other web refs. +export function fixReferenceUrls(vuln: OsvVuln): string[] { + const results: string[] = [] + + // FIX-type refs first + if (vuln.references) { + for (const ref of vuln.references) { + if (ref.type === 'FIX' && isGithubUrl(ref.url)) { + results.push(ref.url) + } + } + } + + // Then other GitHub refs. Restricted to github.com like the FIX-type pass above — + // references are attacker-influenced OSV advisory data and feed into fetchPatch, + // which fetches whatever URL it's given. + if (vuln.references) { + for (const ref of vuln.references) { + if (ref.type !== 'FIX' && isGithubUrl(ref.url)) { + results.push(ref.url) + } + } + } + + return results +} diff --git a/services/apps/packages_worker/src/blast-radius/dependentsScan.ts b/services/apps/packages_worker/src/blast-radius/dependentsScan.ts new file mode 100644 index 0000000000..e9def602f6 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/dependentsScan.ts @@ -0,0 +1,324 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +import { fetchBulkPointRange, fetchPointRange } from '../npm/fetchDownloads' +import { fetchPackument } from '../npm/fetchPackument' +import { isFetchError } from '../npm/types' + +import { fetchAbbreviatedPackument } from './clients/npmAbbreviated' +import { downloadAndExtractTarball } from './clients/npmTarball' +import { NpmVersionManifest, asNpmVersionManifest } from './npmManifest' +import { rangeIncludesAny } from './semverRange' + +const SCAN_CONCURRENCY = 32 +const HIGH_IMPACT_CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const DEP_KINDS: Array<'dependencies' | 'peerDependencies' | 'optionalDependencies'> = [ + 'dependencies', + 'peerDependencies', + 'optionalDependencies', +] + +let highImpactNamesCache: { names: string[]; fetchedAt: number } | null = null + +// Runs `fn` over `items` with at most `concurrency` in flight at once. +async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + let next = 0 + async function worker() { + while (next < items.length) { + const i = next++ + await fn(items[i], i) + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)) +} + +export interface DependentCandidate { + name: string + version: string | null + downloads: number | null + declaredRange: string | null + dependencyKind: string | null + rangeIncludesVuln: boolean + rangeCheck: 'matched' | 'excluded' | 'unparseable-included' + tarballUrl: string | null +} + +export interface ScanDependentsResult { + source: string + candidatesConsidered: number + analyzed: DependentCandidate[] + excludedByRange: DependentCandidate[] + excludedByRangeCount: number +} + +// Fetch high-impact npm package names from the npm-high-impact package. Cached in-memory +// for the life of the worker process — the list is a published npm package that changes +// rarely, so re-downloading and re-extracting its tarball on every analysis is wasted work. +async function highImpactNames(): Promise { + if ( + highImpactNamesCache && + Date.now() - highImpactNamesCache.fetchedAt < HIGH_IMPACT_CACHE_TTL_MS + ) { + return highImpactNamesCache.names + } + + const names = await fetchHighImpactNames() + highImpactNamesCache = { names, fetchedAt: Date.now() } + return names +} + +async function fetchHighImpactNames(): Promise { + const packument = await fetchPackument('npm-high-impact') + if (isFetchError(packument)) { + throw new Error(`Failed to fetch npm-high-impact: ${packument.message}`) + } + + const latest = packument['dist-tags']?.latest + if (!latest) { + throw new Error('npm-high-impact has no latest version') + } + + const versionData = packument.versions?.[latest] + if (!versionData) { + throw new Error(`npm-high-impact version ${latest} not found`) + } + + const tarballUrl = asNpmVersionManifest(versionData).dist?.tarball + if (!tarballUrl) { + throw new Error(`npm-high-impact ${latest} has no tarball`) + } + + // Download and extract to temp directory + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-high-impact-')) + + try { + await downloadAndExtractTarball(tarballUrl, tempDir) + + // Parse lib files for package names + const names = new Set() + const files = ['lib/top-download.js', 'lib/top-dependent.js', 'lib/top.js'] + + for (const file of files) { + const filePath = path.join(tempDir, file) + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf-8') + for (const match of content.matchAll(/'((?:@[\w.-]+\/)?[\w.-]+)'/g)) { + names.add(match[1]) + } + } + } + + return Array.from(names) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +} + +// npm aliases ("myLodash": "npm:lodash@^4.17.21") install and execute the aliased +// package under a local name that has nothing to do with the target — a plain +// key lookup misses these entirely. Parses the "npm:[@]" spec value; +// the version-strip mirrors toBareNpmName's since npm scopes' own @ is always +// followed by /, so a trailing @range (no / or @) is never a scope separator. +function parseNpmAlias(spec: string): { name: string; range: string } | null { + if (!spec.startsWith('npm:')) return null + const rest = spec.slice('npm:'.length) + const versionMatch = rest.match(/@([^/@]+)$/) + return { + name: versionMatch ? rest.slice(0, -versionMatch[0].length) : rest, + range: versionMatch ? versionMatch[1] : '*', + } +} + +// Resolves a dependency's real target name and version range, unwrapping an +// npm: alias if present. Shared by declaredDependency and candidateNamesFromScan +// so alias-matching semantics can't drift between the two call sites. +function resolvedDependencyTarget( + depName: string, + depSpec: string, +): { name: string; range: string } { + const alias = parseNpmAlias(depSpec) + return alias ?? { name: depName, range: depSpec } +} + +// Check if a package version declares a dependency on a target package, +// directly or via an npm: alias. +function declaredDependency( + versionData: NpmVersionManifest, + targetPackage: string, + relatedPackages: string[], +): { kind: string; range: string } | null { + const allTargets = [targetPackage, ...relatedPackages] + + for (const depKind of DEP_KINDS) { + const deps = versionData[depKind] ?? {} + for (const [depName, depSpec] of Object.entries(deps)) { + const resolved = resolvedDependencyTarget(depName, depSpec) + if (allTargets.includes(resolved.name)) { + return { kind: depKind, range: resolved.range } + } + } + } + + return null +} + +// Concurrent, lightweight pre-scan: for each candidate name, fetch the abbreviated packument +// (dependency maps only, no readme/maintainers/etc.) and keep it only if its latest version +// declares a dependency on one of the targets. This mirrors the PoC's two-phase design — do the +// expensive full-packument fetch (below, in the ranking walk) only for names that actually +// matter, instead of one full packument per high-impact name. +async function candidateNamesFromScan( + names: string[], + targets: Set, + onProgress?: () => void, +): Promise { + const hits: string[] = [] + let processed = 0 + + await mapWithConcurrency(names, SCAN_CONCURRENCY, async (name) => { + processed++ + if (onProgress && processed % 200 === 0) { + onProgress() + } + + const packument = await fetchAbbreviatedPackument(name) + if (isFetchError(packument)) return + + const latest = packument['dist-tags']?.latest + const versionData = latest ? packument.versions?.[latest] : undefined + if (!versionData) return + + for (const depKind of DEP_KINDS) { + const deps = versionData[depKind] ?? {} + const hasTarget = Object.entries(deps).some(([depName, depSpec]) => + targets.has(resolvedDependencyTarget(depName, depSpec).name), + ) + if (hasTarget) { + hits.push(name) + return + } + } + }) + + return hits +} + +export async function scanDependents(input: { + vulnerablePackage: string + relatedAffectedPackages: string[] + vulnerableVersions: string[] + topN: number + scanLimit?: number + onProgress?: () => void +}): Promise { + const { + vulnerablePackage, + relatedAffectedPackages, + vulnerableVersions, + topN, + scanLimit, + onProgress, + } = input + + // Fetch high-impact names + const names = await highImpactNames() + const targets = new Set([vulnerablePackage, ...relatedAffectedPackages]) + const toScan = (scanLimit && scanLimit > 0 ? names.slice(0, scanLimit) : names).filter( + (n) => !targets.has(n), + ) + + // Phase 1: concurrent, lightweight pre-scan — filters the (potentially ~17k) high-impact + // list down to the names that actually declare a dependency on the target, before doing + // any of the more expensive per-candidate work below. + const candidateNames = await candidateNamesFromScan(toScan, targets, onProgress) + + // Fetch download counts (bulk, max 128 per request) for the last 30 days — + // only for the filtered candidates, not the full high-impact list. + const rangeEnd = new Date() + const rangeStart = new Date(rangeEnd) + rangeStart.setUTCDate(rangeStart.getUTCDate() - 30) + const isoDate = (d: Date) => d.toISOString().slice(0, 10) + + const downloads = new Map() + const unscopedNames = candidateNames.filter((n) => !n.startsWith('@')) + const scopedNames = candidateNames.filter((n) => n.startsWith('@')) + + for (let i = 0; i < unscopedNames.length; i += 128) { + const batch = unscopedNames.slice(i, i + 128) + const result = await fetchBulkPointRange(batch, isoDate(rangeStart), isoDate(rangeEnd)) + if (!isFetchError(result)) { + result.counts.forEach((count: number, name: string) => { + downloads.set(name, count) + }) + } + } + + // fetchBulkPointRange doesn't support scoped package names; fetch those individually + // so scoped candidates aren't silently ranked at 0 downloads and excluded from topN. + await mapWithConcurrency(scopedNames, SCAN_CONCURRENCY, async (name) => { + const result = await fetchPointRange(name, isoDate(rangeStart), isoDate(rangeEnd)) + if (!isFetchError(result)) { + downloads.set(name, result.count) + } + }) + + // Phase 2: walk candidates ranked by downloads (descending), fetching the full packument + // only for names that made it through the phase-1 filter, until top-N pass the range check. + const ranked = [...candidateNames].sort( + (a, b) => (downloads.get(b) ?? 0) - (downloads.get(a) ?? 0), + ) + + const analyzed: DependentCandidate[] = [] + const excludedByRange: DependentCandidate[] = [] + + for (const name of ranked) { + if (analyzed.length >= topN) break + + const packument = await fetchPackument(name) + if (isFetchError(packument)) continue + + const latest = packument['dist-tags']?.latest + if (!latest) continue + + const versionData = packument.versions?.[latest] + if (!versionData) continue + + // Check for declared dependency + const manifest = asNpmVersionManifest(versionData) + const depInfo = declaredDependency(manifest, vulnerablePackage, relatedAffectedPackages) + if (!depInfo) continue + + const { check, includes } = rangeIncludesAny(depInfo.range, vulnerableVersions) + + const candidate: DependentCandidate = { + name, + version: latest, + downloads: downloads.get(name) ?? null, + declaredRange: depInfo.range, + dependencyKind: depInfo.kind, + rangeIncludesVuln: includes, + rangeCheck: check, + tarballUrl: manifest.dist?.tarball ?? null, + } + + if (includes) { + analyzed.push(candidate) + if (analyzed.length >= topN) break + } else { + excludedByRange.push(candidate) + } + } + + return { + source: 'npm-high-impact', + candidatesConsidered: candidateNames.length, + analyzed, + excludedByRange: excludedByRange.slice(0, 200), + excludedByRangeCount: excludedByRange.length, + } +} diff --git a/services/apps/packages_worker/src/blast-radius/npmManifest.ts b/services/apps/packages_worker/src/blast-radius/npmManifest.ts new file mode 100644 index 0000000000..13849db9ac --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/npmManifest.ts @@ -0,0 +1,16 @@ +import type { PackumentVersion } from '../npm/types' + +// The shared Packument/PackumentVersion types (services/apps/packages_worker/src/npm/types.ts) +// only declare the fields the ingest pipeline reads. Blast-radius additionally needs the +// dependency maps and tarball URL, which npm's registry always returns but the shared type +// doesn't model — extending it locally here rather than widening the shared type for one caller. +export interface NpmVersionManifest extends PackumentVersion { + dependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record + dist?: { tarball?: string } +} + +export function asNpmVersionManifest(version: PackumentVersion): NpmVersionManifest { + return version as NpmVersionManifest +} diff --git a/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts b/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts new file mode 100644 index 0000000000..b2a0f4aa1b --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/packageIdentifier.ts @@ -0,0 +1,24 @@ +// The blast-radius submit endpoint accepts either a bare npm package name +// ("lodash", "@babel/core") or a full purl ("pkg:npm/lodash", "pkg:npm/%40babel/core@4.17.21") +// for the `package` field — see blastRadiusJobRequestSchema. OSV affected-package entries and +// the npm registry only ever use bare names, so a purl must be reduced to that form before +// it's compared against them (raw string equality otherwise never matches a purl input). +export function toBareNpmName(input: string): string { + let name = input.trim() + + const q = name.indexOf('?') + const h = name.indexOf('#') + const cut = q === -1 ? h : h === -1 ? q : Math.min(q, h) + if (cut !== -1) name = name.slice(0, cut) + + if (name.startsWith('pkg:npm/')) { + name = name.slice('pkg:npm/'.length) + } + + name = name.replace(/%40/gi, '@') + + // Strip a trailing @version — never a scope separator, which is always followed by `/`. + name = name.replace(/@[^/@]+$/, '') + + return name +} diff --git a/services/apps/packages_worker/src/blast-radius/semverRange.ts b/services/apps/packages_worker/src/blast-radius/semverRange.ts new file mode 100644 index 0000000000..fc6f77042d --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/semverRange.ts @@ -0,0 +1,89 @@ +import * as semver from 'semver' + +// Semver range utilities ported from the Python PoC (semver_utils.py). +// Handles version matching, range detection, and unparseable-range detection. + +export interface SemverRange { + introduced: string | null + fixed: string | null + lastAffected?: string | null +} + +// Filter versions down to those satisfying all given semver ranges. +// A version must be in at least one range to be included. +export function versionsInRanges(versions: string[], ranges: SemverRange[]): string[] { + const result: string[] = [] + + for (const v of versions) { + if (!semver.valid(v, { loose: true })) continue + + for (const range of ranges) { + let rangeSpec = '' + if (range.introduced && range.fixed) { + rangeSpec = `>=${range.introduced} <${range.fixed}` + } else if (range.introduced && range.lastAffected) { + rangeSpec = `>=${range.introduced} <=${range.lastAffected}` + } else if (range.introduced) { + rangeSpec = `>=${range.introduced}` + } + + if (rangeSpec && semver.satisfies(v, rangeSpec, { loose: true, includePrerelease: true })) { + result.push(v) + break + } + } + } + + return result +} + +// Check whether a declared dependency range includes any of the vulnerable versions. +// Unparseable ranges (git URLs, tags, 'latest', workspace:* specs) conservatively +// return includes: true, check: 'unparseable-included'. +export function rangeIncludesAny( + declaredRange: string, + vulnerableVersions: string[], +): { includes: boolean; check: 'matched' | 'excluded' | 'unparseable-included' } { + if (!declaredRange) { + return { includes: true, check: 'unparseable-included' } + } + + // Unparseable patterns: git URLs, file paths, tags, 'latest', workspace:* specs + if ( + declaredRange.startsWith('git') || + declaredRange.startsWith('file:') || + declaredRange.startsWith('http://') || + declaredRange.startsWith('https://') || + declaredRange === 'latest' || + declaredRange.startsWith('workspace:') || + declaredRange.includes('tag:') || + !isValidSemverRange(declaredRange) + ) { + return { includes: true, check: 'unparseable-included' } + } + + for (const vuln of vulnerableVersions) { + if (semver.satisfies(vuln, declaredRange, { loose: true, includePrerelease: true })) { + return { includes: true, check: 'matched' } + } + } + + return { includes: false, check: 'excluded' } +} + +// Get the highest version from a list, or null if none are valid. +export function highestVersion(versions: string[]): string | null { + const valid = versions.filter((v) => semver.valid(v, { loose: true })) + if (valid.length === 0) return null + return semver.maxSatisfying(valid, '*', { loose: true, includePrerelease: true }) || null +} + +// Helper: check if a range spec is parseable as semver. semver.validRange returns null +// for unparseable specs rather than throwing, so the null check is required. +function isValidSemverRange(spec: string): boolean { + try { + return semver.validRange(spec, { loose: true }) !== null + } catch { + return false + } +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/dependents.ts b/services/apps/packages_worker/src/blast-radius/stages/dependents.ts new file mode 100644 index 0000000000..9178e6495d --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/dependents.ts @@ -0,0 +1,114 @@ +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' +import { findPackageIdsByName } from '@crowd/data-access-layer/src/packages/osv' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +import { scanDependents } from '../dependentsScan' + +export async function runDependentsStage( + qx: QueryExecutor, + analysisId: string, + onProgress?: () => void, +): Promise { + const startTime = Date.now() + + try { + // Check if already done. Guard on the stage_run's own status rather than + // candidates_considered — that column is set before completeStageRun, so a + // crash between the two would otherwise make this stage look permanently done. + const existingStatus = await blastRadiusDal.getStageRunStatus(qx, analysisId, 'dependents') + if (existingStatus === 'succeeded') { + return + } + + // Start stage run record + await blastRadiusDal.startStageRun(qx, { + analysisId, + stage: 'dependents', + status: 'running', + model: null, + }) + + // Get symbol spec from stage 1 + const spec = await blastRadiusDal.getSymbolSpec(qx, analysisId) + if (!spec) { + throw new Error('Symbol spec not found; stage 1 (intel) must run first') + } + + // Clear any dependents left over from a prior failed attempt (stage failed + // after insertDependents but before completeStageRun) — upserting by + // (analysis_id, name) below would otherwise leave stale rows around that the + // fresh scan doesn't reproduce, which reachability would then pick up. + await blastRadiusDal.deleteDependents(qx, analysisId) + + const package_ = String(spec.package) + const vulnerableVersions = (spec.vulnerable_versions || []) as string[] + const relatedAffectedPackages = (spec.related_affected_packages || []) as string[] + + // Scan dependents + const scanResult = await scanDependents({ + vulnerablePackage: package_, + relatedAffectedPackages, + vulnerableVersions, + topN: 25, + onProgress, + }) + + // Resolve package_id for the analyzed set only (max topN=25) — these are the ones + // actually surfaced in results/verdicts. excludedByRange (up to 200) never + // reaches a result, so resolving it too would just be extra queries for nothing. + // Batched into a single round-trip rather than one findPackageId call per name. + const packageIdsByName = await findPackageIdsByName( + qx, + 'npm', + scanResult.analyzed.map((d) => d.name), + ) + + // Persist dependents + const dependentInputs = [ + ...scanResult.analyzed.map((d) => ({ + analysisId, + packageId: packageIdsByName.get(d.name) ?? null, + name: d.name, + version: d.version, + downloads: d.downloads, + declaredRange: d.declaredRange, + dependencyKind: d.dependencyKind, + rangeIncludesVuln: d.rangeIncludesVuln, + rangeCheck: d.rangeCheck, + tarballUrl: d.tarballUrl, + excludedByRange: false, + exclusionReason: null, + })), + ...scanResult.excludedByRange.map((d) => ({ + analysisId, + packageId: null, + name: d.name, + version: d.version, + downloads: d.downloads, + declaredRange: d.declaredRange, + dependencyKind: d.dependencyKind, + rangeIncludesVuln: d.rangeIncludesVuln, + rangeCheck: d.rangeCheck, + tarballUrl: d.tarballUrl, + excludedByRange: true, + exclusionReason: `Range does not include vulnerable versions (${d.rangeCheck})`, + })), + ] + + await blastRadiusDal.insertDependents(qx, dependentInputs) + await blastRadiusDal.setDependentsMeta( + qx, + analysisId, + scanResult.source, + scanResult.candidatesConsidered, + ) + + const duration = Date.now() - startTime + await blastRadiusDal.completeStageRun(qx, analysisId, 'dependents', duration, 0) + } catch (err) { + const duration = Date.now() - startTime + const errorMsg = err instanceof Error ? err.message : String(err) + await blastRadiusDal.failStageRun(qx, analysisId, 'dependents', duration, errorMsg) + throw err + } +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/intel.ts b/services/apps/packages_worker/src/blast-radius/stages/intel.ts new file mode 100644 index 0000000000..80eaeee9fc --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/intel.ts @@ -0,0 +1,183 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' +import { findPackageId } from '@crowd/data-access-layer/src/packages/osv' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +import { fetchPackument } from '../../npm/fetchPackument' +import { parseNpmName } from '../../npm/normalize' +import { isFetchError } from '../../npm/types' +import { INTEL_SCHEMA, INTEL_SYSTEM_PROMPT, buildIntelPrompt } from '../agent/prompts' +import { runAnalysisAgent } from '../agent/runner' +import { fetchPatch } from '../clients/githubPatch' +import { downloadAndExtractTarball } from '../clients/npmTarball' +import { + affectedNpmEntries, + fetchOsvVuln, + fixReferenceUrls, + semverRangeEvents, +} from '../clients/osvClient' +import { asNpmVersionManifest } from '../npmManifest' +import { toBareNpmName } from '../packageIdentifier' +import { highestVersion, versionsInRanges } from '../semverRange' + +export async function runIntelStage( + qx: QueryExecutor, + analysisId: string, + advisoryOsvId: string, + onProgress?: () => void, +): Promise { + const startTime = Date.now() + + try { + // Check if already done. Guard on the stage_run's own status rather than symbol-spec + // presence — a crash between upsertSymbolSpec and completeStageRun would otherwise + // leave the stage_run stuck failed/running forever while retries skip past it. + const existingStatus = await blastRadiusDal.getStageRunStatus(qx, analysisId, 'intel') + if (existingStatus === 'succeeded') { + return + } + + // Start stage run record + await blastRadiusDal.startStageRun(qx, { + analysisId, + stage: 'intel', + status: 'running', + model: 'claude-opus-4-8', + }) + + // Fetch OSV record + const osv = await fetchOsvVuln(advisoryOsvId) + + const npmEntries = affectedNpmEntries(osv) + if (npmEntries.length === 0) { + throw new Error(`No npm entries found in advisory ${advisoryOsvId}`) + } + + // Multi-package advisories list one npm entry per affected package — pick the one + // the analysis was actually requested for, falling back to the first entry when no + // specific package was requested (analysis-wide advisory scan). The request accepts + // either a bare name or a full purl (see blastRadiusJobRequestSchema), but OSV entries + // are always bare names, so the requested package must be normalized before comparing. + const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) + const requestedPackage = analysisDetail?.package_name + ? toBareNpmName(analysisDetail.package_name) + : null + const entry = + (requestedPackage && npmEntries.find((e) => e.package.name === requestedPackage)) || + npmEntries[0] + const package_ = entry.package.name + const ecosystem = entry.package.ecosystem + const relatedAffectedPackages = npmEntries + .map((e) => e.package.name) + .filter((name) => name !== package_) + + // Fetch the registry packument so vulnerable-version resolution runs against versions + // npm actually published, not just the OSV range's introduced/fixed boundary strings — + // most advisories only list range boundaries, so that set is typically incomplete and + // silently drops real published versions from vulnerableVersions/analyzed. + const packument = await fetchPackument(package_) + if (isFetchError(packument)) { + throw new Error(`Failed to fetch npm packument for ${package_}: ${packument.message}`) + } + const allVersions = Object.keys(packument.versions || {}) + + // Resolve vulnerable versions from ranges + const ranges = semverRangeEvents(entry) + const vulnerableVersions = versionsInRanges(allVersions, ranges) + + const analyzed = highestVersion(vulnerableVersions) + if (!analyzed) { + throw new Error(`Could not determine analyzed version for ${package_}`) + } + + // Download pkgsrc and patches + const pkgsrcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkgsrc-')) + const patches: Record = {} + + try { + // Download package source (using npm registry) + const versionData = packument.versions?.[analyzed] + const tarballUrl = versionData ? asNpmVersionManifest(versionData).dist?.tarball : undefined + if (!tarballUrl) { + throw new Error(`No tarball URL found for ${package_}@${analyzed}`) + } + await downloadAndExtractTarball(tarballUrl, pkgsrcDir) + + // Fetch up to 3 patches from fix references + const patchUrls = fixReferenceUrls(osv) + for (const url of patchUrls.slice(0, 3)) { + try { + const patchText = await fetchPatch(url) + const slug = new URL(url).pathname.split('/').filter(Boolean).join('-') + patches[slug] = patchText + } catch { + // Ignore patch fetch errors + } + } + + // Run intelligence agent + const agentPrompt = buildIntelPrompt( + osv.id || advisoryOsvId, + osv.aliases || [], + osv.details || osv.summary || '', + analyzed, + patches, + ) + + const agentResult = await runAnalysisAgent({ + prompt: agentPrompt, + systemPrompt: INTEL_SYSTEM_PROMPT, + cwd: pkgsrcDir, + model: 'claude-opus-4-8', + schema: INTEL_SCHEMA, + maxTurns: 15, + timeoutMs: 600_000, + onProgress, + }) + + if (agentResult.isError || !agentResult.structuredOutput) { + throw new Error(`Agent failed: ${agentResult.errorMessage}`) + } + + // Persist symbol spec + const output = agentResult.structuredOutput + await blastRadiusDal.upsertSymbolSpec(qx, { + analysisId, + vulnId: osv.id || advisoryOsvId, + aliases: osv.aliases || [], + package: package_, + ecosystem, + affectedRanges: ranges, + vulnerableVersions, + analyzedVersion: analyzed, + relatedAffectedPackages, + vulnerableSymbols: (output.vulnerable_symbols || []) as Record[], + importSignatures: (output.import_signatures || {}) as Record, + exploitPreconditions: String(output.exploit_preconditions || ''), + reachabilityNotes: String(output.reachability_notes || ''), + confidence: Number(output.confidence ?? 0.5), + sources: [advisoryOsvId], + summary: String(output.summary || ''), + }) + + // Resolve advisory_id and package_id (null if the package isn't in our DB yet — + // resolveAdvisoryAndPackageIds COALESCEs, so this never clobbers an existing value) + const { namespace, name } = parseNpmName(package_) + const packageId = await findPackageId(qx, { ecosystem, namespace, name }) + await blastRadiusDal.resolveAdvisoryAndPackageIds(qx, analysisId, advisoryOsvId, packageId) + + const duration = Date.now() - startTime + await blastRadiusDal.completeStageRun(qx, analysisId, 'intel', duration, agentResult.costUsd) + } finally { + fs.rmSync(pkgsrcDir, { recursive: true, force: true }) + } + } catch (err) { + const duration = Date.now() - startTime + const errorMsg = err instanceof Error ? err.message : String(err) + await blastRadiusDal.failStageRun(qx, analysisId, 'intel', duration, errorMsg) + throw err + } +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts new file mode 100644 index 0000000000..01e220f2e7 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts @@ -0,0 +1,209 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { promisify } from 'util' + +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +import { + REACHABILITY_PROMPT, + SymbolSpec, + VERDICT_SCHEMA, + buildReachabilitySystemPrompt, +} from '../agent/prompts' +import { runAnalysisAgent } from '../agent/runner' +import { downloadAndExtractTarball } from '../clients/npmTarball' + +const mkdtemp = promisify(fs.mkdtemp) + +const MAX_ATTEMPTS = 3 +const RETRY_BACKOFF_BASE = 15_000 // 15 seconds + +// blastRadiusDal.getSymbolSpec returns the raw DB row (JSONB columns as +// unknown); prompts.ts's SymbolSpec is the shape the reachability prompt +// builder actually reads. Field names line up 1:1 with the query's SELECT list. +function toPromptSymbolSpec(row: Record): SymbolSpec { + return { + vuln_id: String(row.vuln_id ?? ''), + package: String(row.package ?? ''), + summary: String(row.summary ?? ''), + vulnerable_symbols: (row.vulnerable_symbols ?? []) as SymbolSpec['vulnerable_symbols'], + import_signatures: (row.import_signatures ?? {}) as SymbolSpec['import_signatures'], + exploit_preconditions: String(row.exploit_preconditions ?? ''), + reachability_notes: String(row.reachability_notes ?? ''), + confidence: Number(row.confidence ?? 0), + } +} + +export async function runReachabilityStage( + qx: QueryExecutor, + analysisId: string, + onProgress?: () => void, +): Promise { + const startTime = Date.now() + + try { + // Check if already done — avoid clobbering a succeeded stage_run's status/started_at + // on a redundant re-invocation (startStageRun's ON CONFLICT always overwrites status). + const existingStatus = await blastRadiusDal.getStageRunStatus(qx, analysisId, 'reachability') + if (existingStatus === 'succeeded') { + return + } + + // Start stage run record + await blastRadiusDal.startStageRun(qx, { + analysisId, + stage: 'reachability', + status: 'running', + model: 'claude-sonnet-5', + }) + + // Get symbol spec and dependents needing verdict + const specRow = await blastRadiusDal.getSymbolSpec(qx, analysisId) + if (!specRow) { + throw new Error('Symbol spec not found') + } + const spec = toPromptSymbolSpec(specRow) + + const dependents = await blastRadiusDal.getDependentsNeedingVerdict(qx, analysisId) + + // Process with concurrency limit (4) + const concurrency = 4 + const queue = [...dependents] + + const upsertErrorVerdict = ( + dependentId: string, + reasoning: string, + model: string | null, + costUsd = 0, + ) => + blastRadiusDal.upsertVerdict(qx, { + analysisId, + dependentId, + usesPackage: false, + importsVulnerableSymbol: false, + importStyle: null, + reachableVerdict: 'unclear', + confidence: 0, + evidence: null, + reasoning, + model, + turnsUsed: null, + costUsd, + }) + + const processOne = async (dep: blastRadiusDal.DependentRow): Promise => { + if (!dep.tarball_url) { + await upsertErrorVerdict(dep.id, 'No tarball URL available', null) + return + } + + // Create temp dir for this dependent. Fixed prefix, not dep.name — scoped + // package names contain a `/` (e.g. @babel/core), which would otherwise + // produce a mkdtemp prefix pointing at a nonexistent intermediate directory. + const depDir = await mkdtemp(path.join(os.tmpdir(), 'blast-radius-dep-')) + + try { + // Download and extract. Isolated from the batch below — a single dependent's + // tarball failure (bad URL, registry 5xx, corrupt archive) must not reject the + // whole Promise.all and fail every other dependent in the batch. + try { + await downloadAndExtractTarball(dep.tarball_url, depDir) + } catch (err) { + await upsertErrorVerdict( + dep.id, + `Tarball download failed: ${err instanceof Error ? err.message : String(err)}`, + null, + ) + return + } + + // Try agent up to MAX_ATTEMPTS times with exponential backoff. costUsd is + // accumulated across attempts — a persistent failure still spends real money + // on every retry, and that spend must show up in the verdict/stage/analysis + // totals even though only the last attempt's outcome is persisted. + let accumulatedCost = 0 + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const systemPrompt = buildReachabilitySystemPrompt(spec) + + const agentResult = await runAnalysisAgent({ + prompt: REACHABILITY_PROMPT, + systemPrompt, + cwd: depDir, + model: 'claude-sonnet-5', + schema: VERDICT_SCHEMA, + maxTurns: 15, + timeoutMs: 600_000, + onProgress, + }) + accumulatedCost += agentResult.costUsd || 0 + + if (!agentResult.isError && agentResult.structuredOutput) { + const output = agentResult.structuredOutput + + await blastRadiusDal.upsertVerdict(qx, { + analysisId, + dependentId: dep.id, + usesPackage: Boolean(output.uses_package), + importsVulnerableSymbol: Boolean(output.imports_vulnerable_symbol), + importStyle: String(output.import_style || 'none'), + reachableVerdict: String(output.reachable_verdict || 'unclear'), + confidence: Number(output.confidence || 0), + evidence: (output.evidence as unknown as Record[]) ?? null, + reasoning: String(output.reasoning || ''), + model: 'claude-sonnet-5', + turnsUsed: agentResult.numTurns, + costUsd: accumulatedCost, + }) + + return + } + + // Agent error (not a thrown exception) — throw so the catch below applies + // the same backoff-or-persist handling as a genuine exception. + throw new Error(agentResult.errorMessage || 'Agent failed') + } catch (err) { + if (attempt === MAX_ATTEMPTS) { + // Last attempt; save error verdict + await upsertErrorVerdict( + dep.id, + `Agent failed: ${err instanceof Error ? err.message : String(err)}`, + 'claude-sonnet-5', + accumulatedCost, + ) + return + } + + // Backoff before retry + const delayMs = RETRY_BACKOFF_BASE * attempt + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + } + } finally { + // Clean up temp dir + fs.rmSync(depDir, { recursive: true, force: true }) + onProgress?.() + } + } + + // Process queue with concurrency limit + while (queue.length > 0) { + const batch = queue.splice(0, concurrency) + await Promise.all(batch.map(processOne)) + } + + // Sum cost from persisted verdicts rather than tracking it locally — a resumed + // run only processes dependents still missing a verdict, so a local counter would + // drop the cost already spent (and recorded) on verdicts from prior attempts. + const duration = Date.now() - startTime + const totalCost = await blastRadiusDal.getVerdictsCost(qx, analysisId) + await blastRadiusDal.completeStageRun(qx, analysisId, 'reachability', duration, totalCost) + } catch (err) { + const duration = Date.now() - startTime + const errorMsg = err instanceof Error ? err.message : String(err) + await blastRadiusDal.failStageRun(qx, analysisId, 'reachability', duration, errorMsg) + throw err + } +} diff --git a/services/apps/packages_worker/src/blast-radius/stages/report.ts b/services/apps/packages_worker/src/blast-radius/stages/report.ts new file mode 100644 index 0000000000..956bbdf971 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/report.ts @@ -0,0 +1,44 @@ +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +// Stage 4: aggregate verdicts and finalize analysis. +// This stage does not render markdown (that's for a future read endpoint). +// It just aggregates metrics and finalizes the analysis row. + +export async function runReportStage(qx: QueryExecutor, analysisId: string): Promise { + const startTime = Date.now() + + try { + // Check if already done — avoid clobbering a succeeded stage_run's status/started_at + // on a redundant re-invocation (startStageRun's ON CONFLICT always overwrites status). + const existingStatus = await blastRadiusDal.getStageRunStatus(qx, analysisId, 'report') + if (existingStatus === 'succeeded') { + return + } + + // Start stage run record + await blastRadiusDal.startStageRun(qx, { + analysisId, + stage: 'report', + status: 'running', + model: null, + }) + + // Aggregate cost. Every stage (intel, dependents, reachability, report) records + // its own cost exactly once via completeStageRun — reachability's stage_run cost + // is already the sum of its per-dependent verdict costs, so summing verdicts here + // too would double-count it. + const finalCostUsd = await blastRadiusDal.getStageRunsCost(qx, analysisId) + + // Finalize analysis + await blastRadiusDal.finalizeAnalysis(qx, analysisId, finalCostUsd) + + const duration = Date.now() - startTime + await blastRadiusDal.completeStageRun(qx, analysisId, 'report', duration, 0) + } catch (err) { + const duration = Date.now() - startTime + const errorMsg = err instanceof Error ? err.message : String(err) + await blastRadiusDal.failStageRun(qx, analysisId, 'report', duration, errorMsg) + throw err + } +} diff --git a/services/apps/packages_worker/src/blast-radius/workflows.ts b/services/apps/packages_worker/src/blast-radius/workflows.ts index 7a6ee8f2da..dff8334a21 100644 --- a/services/apps/packages_worker/src/blast-radius/workflows.ts +++ b/services/apps/packages_worker/src/blast-radius/workflows.ts @@ -1,15 +1,88 @@ -import { log } from '@temporalio/workflow' +import { ApplicationFailure, log, proxyActivities, rootCause } from '@temporalio/workflow' import type { ITriggerBlastRadiusAnalysis } from '@crowd/types' +import type * as activities from './activities' import { buildEcosystemNotSupportedFailure } from './ecosystemSupport' +// Reachability analysis only exists for npm today — every other ecosystem still +// fails fast with a non-retryable failure (see ecosystemSupport.ts). +const SUPPORTED_ECOSYSTEMS = ['npm'] + +const { blastRadiusStart, blastRadiusFail } = proxyActivities({ + startToCloseTimeout: '2 minutes', + retry: { maximumAttempts: 3 }, +}) + +// Intel runs an Opus agent over the downloaded package source (up to 15 turns, +// 10-minute agent timeout in runAnalysisAgent) — give it headroom past that. +const { blastRadiusIntel } = proxyActivities({ + startToCloseTimeout: '20 minutes', + heartbeatTimeout: '5 minutes', + retry: { maximumAttempts: 2 }, +}) + +const { blastRadiusDependents } = proxyActivities({ + startToCloseTimeout: '15 minutes', + heartbeatTimeout: '3 minutes', + retry: { maximumAttempts: 2 }, +}) + +// Reachability downloads and analyzes up to 25 dependents (4 at a time, each with +// up to 3 agent attempts) — the slowest stage, so it gets the largest ceiling. +const { blastRadiusReachability } = proxyActivities({ + startToCloseTimeout: '1 hour', + heartbeatTimeout: '5 minutes', + retry: { maximumAttempts: 2 }, +}) + +const { blastRadiusReport } = proxyActivities({ + startToCloseTimeout: '2 minutes', + retry: { maximumAttempts: 3 }, +}) + // 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. +// API). Each stage is independently resumable (guarded on its own stage_run status — +// see runIntelStage etc.), so a retried workflow (new analysisId reusing the same +// row via force, or a workflow-level retry) skips whatever already succeeded. export async function analyzeBlastRadius(input: ITriggerBlastRadiusAnalysis): Promise { log.info('analyzeBlastRadius received', { ...input }) - throw buildEcosystemNotSupportedFailure(input.ecosystem) + if (!SUPPORTED_ECOSYSTEMS.includes(input.ecosystem)) { + throw buildEcosystemNotSupportedFailure(input.ecosystem) + } + + try { + await blastRadiusStart({ + analysisId: input.analysisId, + advisoryOsvId: input.advisoryId, + packageName: input.package, + ecosystem: input.ecosystem, + force: input.force, + }) + + await blastRadiusIntel({ analysisId: input.analysisId, advisoryOsvId: input.advisoryId }) + await blastRadiusDependents({ analysisId: input.analysisId, advisoryOsvId: input.advisoryId }) + await blastRadiusReachability({ + analysisId: input.analysisId, + advisoryOsvId: input.advisoryId, + }) + await blastRadiusReport({ analysisId: input.analysisId, advisoryOsvId: input.advisoryId }) + } catch (err) { + // rootCause unwraps Temporal's ActivityFailure wrapper (whose own .message is a + // generic "Activity task failed") down to the underlying stage error, so poll's + // errorMessage reflects what actually broke rather than Temporal's wrapper text. + const errorMessage = rootCause(err) ?? (err instanceof Error ? err.message : String(err)) + await blastRadiusFail({ + analysisId: input.analysisId, + advisoryOsvId: input.advisoryId, + packageName: input.package, + ecosystem: input.ecosystem, + force: input.force, + error: errorMessage, + }) + throw ApplicationFailure.nonRetryable(errorMessage, 'BLAST_RADIUS_STAGE_FAILED') + } + + log.info('analyzeBlastRadius complete', { analysisId: input.analysisId }) } diff --git a/services/libs/data-access-layer/src/packages/blastRadius.ts b/services/libs/data-access-layer/src/packages/blastRadius.ts new file mode 100644 index 0000000000..f71cf36f92 --- /dev/null +++ b/services/libs/data-access-layer/src/packages/blastRadius.ts @@ -0,0 +1,587 @@ +import { QueryExecutor } from '../queryExecutor' + +// DAL for blast-radius analysis pipeline. All tables defined in +// backend/src/osspckgs/migrations/V1784700000__blast_radius_analyses.sql. + +// ---- input shapes ---- + +export interface BlastRadiusAnalysisInput { + id: string + advisoryOsvId: string + packageName: string | null + ecosystem: string + force: boolean +} + +export interface SymbolSpecInput { + analysisId: string + vulnId: string + aliases: string[] + package: string + ecosystem: string + affectedRanges: Record[] + vulnerableVersions: string[] + analyzedVersion: string + relatedAffectedPackages: string[] + vulnerableSymbols: Record[] + importSignatures: Record + exploitPreconditions: string + reachabilityNotes: string + confidence: number + sources: string[] + summary: string +} + +export interface DependentInput { + analysisId: string + packageId: string | null + name: string + version: string | null + downloads: number | null + declaredRange: string | null + dependencyKind: string | null + rangeIncludesVuln: boolean | null + rangeCheck: string | null + tarballUrl: string | null + excludedByRange: boolean + exclusionReason: string | null +} + +export interface VerdictInput { + analysisId: string + dependentId: string + usesPackage: boolean + importsVulnerableSymbol: boolean + importStyle: string | null + reachableVerdict: string + confidence: number + evidence: Record[] | null + reasoning: string | null + model: string | null + turnsUsed: number | null + costUsd: number +} + +export interface StageRunInput { + analysisId: string + stage: string + status: string + model: string | null + startedAt?: string +} + +// ---- result shapes ---- + +export interface AnalysisRow { + id: string + advisory_id: number | null + package_id: number | null + status: string + total_cost_usd: number +} + +export interface DependentRow { + id: string + analysis_id: string + name: string + excluded_by_range: boolean + tarball_url: string | null +} + +export interface AnalysisDetailRow { + id: string + advisory_osv_id: string + package_name: string | null + ecosystem: string + status: string + error: string | null + candidates_considered: number | null + started_at: string | null + completed_at: string | null +} + +export interface VerdictResultRow { + name: string + version: string | null + downloads: number | null + reachable_verdict: string + confidence: number + evidence: Record[] | null + reasoning: string | null +} + +// ---- analysis lifecycle ---- + +export async function createAnalysis( + qx: QueryExecutor, + input: BlastRadiusAnalysisInput, +): Promise { + const row = await qx.selectOne( + ` + INSERT INTO blast_radius_analyses + (id, advisory_osv_id, package_name, ecosystem, force, status, started_at, updated_at) + VALUES + ($(id), $(advisoryOsvId), $(packageName), $(ecosystem), $(force), 'pending', NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET + force = EXCLUDED.force, + status = CASE + WHEN blast_radius_analyses.status = 'pending' THEN 'pending' + ELSE blast_radius_analyses.status + END, + updated_at = NOW() + RETURNING id + `, + input, + ) + return row.id as string +} + +export async function markAnalysisRunning(qx: QueryExecutor, analysisId: string): Promise { + await qx.result( + ` + UPDATE blast_radius_analyses + SET status = 'running', updated_at = NOW() + WHERE id = $(analysisId) AND status = 'pending' + `, + { analysisId }, + ) +} + +export async function getAnalysis( + qx: QueryExecutor, + analysisId: string, +): Promise { + return qx.selectOneOrNone( + ` + SELECT id, advisory_id, package_id, status, total_cost_usd + FROM blast_radius_analyses + WHERE id = $(analysisId) + `, + { analysisId }, + ) +} + +export async function getAnalysisDetail( + qx: QueryExecutor, + analysisId: string, +): Promise { + return qx.selectOneOrNone( + ` + SELECT + id, advisory_osv_id, package_name, ecosystem, status, error, + candidates_considered, started_at, completed_at + FROM blast_radius_analyses + WHERE id = $(analysisId) + `, + { analysisId }, + ) +} + +export async function setDependentsMeta( + qx: QueryExecutor, + analysisId: string, + source: string, + candidatesConsidered: number, +): Promise { + await qx.result( + ` + UPDATE blast_radius_analyses + SET dependents_source = $(source), candidates_considered = $(candidatesConsidered), updated_at = NOW() + WHERE id = $(analysisId) + `, + { analysisId, source, candidatesConsidered }, + ) +} + +export async function resolveAdvisoryAndPackageIds( + qx: QueryExecutor, + analysisId: string, + advisoryOsvId: string, + packageId: number | null, +): Promise { + await qx.result( + ` + UPDATE blast_radius_analyses bra + SET + advisory_id = COALESCE( + (SELECT id FROM advisories WHERE osv_id = $(advisoryOsvId)), + bra.advisory_id + ), + package_id = COALESCE($(packageId), bra.package_id), + updated_at = NOW() + WHERE bra.id = $(analysisId) + `, + { analysisId, advisoryOsvId, packageId }, + ) +} + +export async function finalizeAnalysis( + qx: QueryExecutor, + analysisId: string, + totalCostUsd: number, +): Promise { + await qx.result( + ` + UPDATE blast_radius_analyses + SET + status = 'done', + total_cost_usd = $(totalCostUsd), + completed_at = NOW(), + updated_at = NOW() + WHERE id = $(analysisId) + `, + { analysisId, totalCostUsd }, + ) +} + +// Upserts rather than plain-UPDATEs so the analysis always ends up visibly +// 'failed' even if blastRadiusStart itself never got far enough to INSERT the +// row (e.g. its own retries were exhausted by a DB outage) — otherwise poll +// would 404 instead of reporting the failure. +export async function failAnalysis( + qx: QueryExecutor, + input: BlastRadiusAnalysisInput, + errorMessage: string, +): Promise { + await qx.result( + ` + INSERT INTO blast_radius_analyses + (id, advisory_osv_id, package_name, ecosystem, force, status, error, started_at, completed_at, updated_at) + VALUES + ($(id), $(advisoryOsvId), $(packageName), $(ecosystem), $(force), 'failed', $(errorMessage), NOW(), NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET + status = 'failed', + error = $(errorMessage), + completed_at = NOW(), + updated_at = NOW() + `, + { ...input, errorMessage }, + ) +} + +// ---- symbol specs ---- + +export async function upsertSymbolSpec(qx: QueryExecutor, input: SymbolSpecInput): Promise { + const params = { + ...input, + affectedRanges: JSON.stringify(input.affectedRanges), + vulnerableSymbols: JSON.stringify(input.vulnerableSymbols), + importSignatures: JSON.stringify(input.importSignatures), + } + const row = await qx.selectOne( + ` + INSERT INTO blast_radius_symbol_specs + (analysis_id, vuln_id, aliases, package, ecosystem, affected_ranges, + vulnerable_versions, analyzed_version, related_affected_packages, + vulnerable_symbols, import_signatures, exploit_preconditions, + reachability_notes, confidence, sources, summary, created_at) + VALUES + ($(analysisId), $(vulnId), $(aliases)::text[], $(package), $(ecosystem), + $(affectedRanges)::jsonb, $(vulnerableVersions)::text[], + $(analyzedVersion), $(relatedAffectedPackages)::text[], + $(vulnerableSymbols)::jsonb, $(importSignatures)::jsonb, + $(exploitPreconditions), $(reachabilityNotes), $(confidence), + $(sources)::text[], $(summary), NOW()) + ON CONFLICT (analysis_id) DO UPDATE SET + vuln_id = EXCLUDED.vuln_id, + aliases = EXCLUDED.aliases, + affected_ranges = EXCLUDED.affected_ranges, + vulnerable_versions = EXCLUDED.vulnerable_versions, + analyzed_version = EXCLUDED.analyzed_version, + related_affected_packages = EXCLUDED.related_affected_packages, + vulnerable_symbols = EXCLUDED.vulnerable_symbols, + import_signatures = EXCLUDED.import_signatures, + exploit_preconditions = EXCLUDED.exploit_preconditions, + reachability_notes = EXCLUDED.reachability_notes, + confidence = EXCLUDED.confidence, + sources = EXCLUDED.sources, + summary = EXCLUDED.summary + RETURNING id + `, + params, + ) + return row.id as number +} + +export async function getSymbolSpec( + qx: QueryExecutor, + analysisId: string, +): Promise | null> { + return qx.selectOneOrNone( + ` + SELECT + vuln_id, aliases, package, ecosystem, affected_ranges, + vulnerable_versions, analyzed_version, related_affected_packages, + vulnerable_symbols, import_signatures, exploit_preconditions, + reachability_notes, confidence, sources, summary + FROM blast_radius_symbol_specs + WHERE analysis_id = $(analysisId) + `, + { analysisId }, + ) +} + +// ---- dependents ---- + +export async function insertDependents( + qx: QueryExecutor, + dependents: DependentInput[], +): Promise { + if (dependents.length === 0) return + for (const dep of dependents) { + await qx.result( + ` + INSERT INTO blast_radius_dependents + (analysis_id, package_id, name, version, downloads, declared_range, + dependency_kind, range_includes_vuln, range_check, tarball_url, + excluded_by_range, exclusion_reason, created_at) + VALUES + ($(analysisId), $(packageId), $(name), $(version), $(downloads), + $(declaredRange), $(dependencyKind), $(rangeIncludesVuln), + $(rangeCheck), $(tarballUrl), $(excludedByRange), $(exclusionReason), NOW()) + ON CONFLICT (analysis_id, name) DO UPDATE SET + package_id = EXCLUDED.package_id, + version = EXCLUDED.version, + downloads = EXCLUDED.downloads, + declared_range = EXCLUDED.declared_range, + dependency_kind = EXCLUDED.dependency_kind, + range_includes_vuln = EXCLUDED.range_includes_vuln, + range_check = EXCLUDED.range_check, + tarball_url = EXCLUDED.tarball_url, + excluded_by_range = EXCLUDED.excluded_by_range, + exclusion_reason = EXCLUDED.exclusion_reason + `, + dep, + ) + } +} + +// Actual range-excluded count (capped at the 200 rows insertDependents persists per +// analysis) — distinct from candidates_considered, which is the phase-1 population +// before stage 2's topN walk and includes candidates the walk never reached. +export async function getDependentsExcludedByRangeCount( + qx: QueryExecutor, + analysisId: string, +): Promise { + const row = await qx.selectOne( + ` + SELECT COUNT(*) as count + FROM blast_radius_dependents + WHERE analysis_id = $(analysisId) AND excluded_by_range = TRUE + `, + { analysisId }, + ) + return Number(row.count) || 0 +} + +// Clears prior dependents for this analysis before a fresh scan re-inserts. Needed +// because a retry (stage failed after insertDependents but before completeStageRun) +// would otherwise leave stale rows from the earlier attempt's scan around — upserting +// by (analysis_id, name) never removes a name that the new scan didn't produce again, +// so it would stay excluded_by_range=false with no verdict and get picked up by +// reachability, inflating cost/results past the intended top-N. Safe to call +// unconditionally: this always runs before reachability has produced any verdicts +// for the analysis, so there's nothing in blast_radius_verdicts yet to orphan. +export async function deleteDependents(qx: QueryExecutor, analysisId: string): Promise { + await qx.result( + ` + DELETE FROM blast_radius_dependents + WHERE analysis_id = $(analysisId) + `, + { analysisId }, + ) +} + +export async function getDependentsNeedingVerdict( + qx: QueryExecutor, + analysisId: string, +): Promise { + return qx.select( + ` + SELECT id, analysis_id, name, excluded_by_range, tarball_url + FROM blast_radius_dependents + WHERE analysis_id = $(analysisId) + AND excluded_by_range = FALSE + AND id NOT IN (SELECT dependent_id FROM blast_radius_verdicts WHERE analysis_id = $(analysisId)) + ORDER BY downloads DESC NULLS LAST + `, + { analysisId }, + ) +} + +// ---- verdicts ---- + +export async function upsertVerdict(qx: QueryExecutor, input: VerdictInput): Promise { + const params = { + ...input, + evidence: input.evidence === null ? null : JSON.stringify(input.evidence), + } + const row = await qx.selectOne( + ` + INSERT INTO blast_radius_verdicts + (analysis_id, dependent_id, uses_package, imports_vulnerable_symbol, + import_style, reachable_verdict, confidence, evidence, reasoning, + model, turns_used, cost_usd, created_at) + VALUES + ($(analysisId), $(dependentId), $(usesPackage), $(importsVulnerableSymbol), + $(importStyle), $(reachableVerdict), $(confidence), $(evidence)::jsonb, + $(reasoning), $(model), $(turnsUsed), $(costUsd), NOW()) + ON CONFLICT (dependent_id) DO UPDATE SET + uses_package = EXCLUDED.uses_package, + imports_vulnerable_symbol = EXCLUDED.imports_vulnerable_symbol, + import_style = EXCLUDED.import_style, + reachable_verdict = EXCLUDED.reachable_verdict, + confidence = EXCLUDED.confidence, + evidence = EXCLUDED.evidence, + reasoning = EXCLUDED.reasoning, + model = EXCLUDED.model, + turns_used = EXCLUDED.turns_used, + cost_usd = EXCLUDED.cost_usd + RETURNING id + `, + params, + ) + return row.id as number +} + +export async function getVerdicts( + qx: QueryExecutor, + analysisId: string, +): Promise>> { + return qx.select( + ` + SELECT + dependent_id, uses_package, imports_vulnerable_symbol, + import_style, reachable_verdict, confidence, evidence, + reasoning, cost_usd, turns_used + FROM blast_radius_verdicts + WHERE analysis_id = $(analysisId) + `, + { analysisId }, + ) +} + +export async function getVerdictsCost(qx: QueryExecutor, analysisId: string): Promise { + const row = await qx.selectOne( + ` + SELECT COALESCE(SUM(cost_usd), 0) as total_cost + FROM blast_radius_verdicts + WHERE analysis_id = $(analysisId) + `, + { analysisId }, + ) + return (row.total_cost as number) || 0 +} + +export async function getVerdictResults( + qx: QueryExecutor, + analysisId: string, +): Promise { + return qx.select( + ` + SELECT + d.name, d.version, d.downloads, + v.reachable_verdict, v.confidence, v.evidence, v.reasoning + FROM blast_radius_verdicts v + JOIN blast_radius_dependents d ON d.id = v.dependent_id + WHERE v.analysis_id = $(analysisId) + ORDER BY d.downloads DESC NULLS LAST + `, + { analysisId }, + ) +} + +// ---- stage runs (monitoring) ---- + +export async function startStageRun(qx: QueryExecutor, input: StageRunInput): Promise { + const params = { ...input, startedAt: input.startedAt ?? null } + const row = await qx.selectOne( + ` + INSERT INTO blast_radius_stage_runs + (analysis_id, stage, status, model, started_at) + VALUES + ($(analysisId), $(stage), $(status), $(model), COALESCE($(startedAt), NOW())) + ON CONFLICT (analysis_id, stage) DO UPDATE SET + status = EXCLUDED.status, + model = EXCLUDED.model, + started_at = EXCLUDED.started_at, + completed_at = NULL, + duration_ms = NULL, + cost_usd = 0, + error = NULL + RETURNING id + `, + params, + ) + return row.id as number +} + +export async function completeStageRun( + qx: QueryExecutor, + analysisId: string, + stage: string, + durationMs: number, + costUsd: number, +): Promise { + await qx.result( + ` + UPDATE blast_radius_stage_runs + SET + status = 'succeeded', + completed_at = NOW(), + duration_ms = $(durationMs), + cost_usd = $(costUsd) + WHERE analysis_id = $(analysisId) AND stage = $(stage) + `, + { analysisId, stage, durationMs, costUsd }, + ) +} + +export async function failStageRun( + qx: QueryExecutor, + analysisId: string, + stage: string, + durationMs: number, + errorMessage: string, +): Promise { + await qx.result( + ` + UPDATE blast_radius_stage_runs + SET + status = 'failed', + completed_at = NOW(), + duration_ms = $(durationMs), + error = $(errorMessage) + WHERE analysis_id = $(analysisId) AND stage = $(stage) + `, + { analysisId, stage, durationMs, errorMessage }, + ) +} + +export async function getStageRunStatus( + qx: QueryExecutor, + analysisId: string, + stage: string, +): Promise { + const row = await qx.selectOneOrNone( + ` + SELECT status + FROM blast_radius_stage_runs + WHERE analysis_id = $(analysisId) AND stage = $(stage) + `, + { analysisId, stage }, + ) + return row ? (row.status as string) : null +} + +export async function getStageRunsCost(qx: QueryExecutor, analysisId: string): Promise { + const row = await qx.selectOne( + ` + SELECT COALESCE(SUM(cost_usd), 0) as total_cost + FROM blast_radius_stage_runs + WHERE analysis_id = $(analysisId) + `, + { analysisId }, + ) + return (row.total_cost as number) || 0 +} diff --git a/services/libs/data-access-layer/src/packages/osv.ts b/services/libs/data-access-layer/src/packages/osv.ts index 8ce9c45b84..b59b37704f 100644 --- a/services/libs/data-access-layer/src/packages/osv.ts +++ b/services/libs/data-access-layer/src/packages/osv.ts @@ -119,6 +119,30 @@ export async function findPackageId( return (row?.id as number | undefined) ?? null } +// Batched form of findPackageId for a flat list of full package names (e.g. +// "lodash", "@babel/core") — one round-trip instead of one query per name. +// Mirrors getNpmPurlsForChangedNames's namespace/name reconstruction join. +// Returns IDs as strings: packages.id is BIGSERIAL (int8) and pg-promise leaves +// int8 values as strings to avoid silently losing precision via Number() coercion. +export async function findPackageIdsByName( + qx: QueryExecutor, + ecosystem: string, + names: string[], +): Promise> { + if (names.length === 0) return new Map() + const rows: Array<{ id: string; name: string }> = await qx.select( + ` + SELECT p.id, u.name + FROM packages p + JOIN unnest($(names)::text[]) AS u(name) + ON (CASE WHEN p.namespace IS NOT NULL THEN p.namespace || '/' || p.name ELSE p.name END) = u.name + WHERE p.ecosystem = $(ecosystem) + `, + { ecosystem, names }, + ) + return new Map(rows.map((r) => [r.name, r.id])) +} + export async function upsertAdvisoryPackage( qx: QueryExecutor, input: AdvisoryPackageUpsertInput,