From b76e54c1ab221b60c2cf141e154e8291fbaa3470 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 22 Jul 2026 12:45:27 +0200 Subject: [PATCH 1/9] feat: first version of blast-radius workflow Signed-off-by: Umberto Sgueglia --- .../public/v1/packages/blastRadiusAnalysis.ts | 127 +++++ .../v1/packages/getBlastRadiusJob.test.ts | 142 +++++ .../public/v1/packages/getBlastRadiusJob.ts | 32 ++ .../src/blast-radius/activities.ts | 84 +++ .../src/blast-radius/agent/prompts.ts | 254 +++++++++ .../src/blast-radius/agent/runner.ts | 118 ++++ .../src/blast-radius/clients/githubPatch.ts | 23 + .../blast-radius/clients/npmAbbreviated.ts | 68 +++ .../src/blast-radius/clients/npmTarball.ts | 70 +++ .../src/blast-radius/clients/osvClient.ts | 108 ++++ .../src/blast-radius/dependentsScan.ts | 297 ++++++++++ .../src/blast-radius/npmManifest.ts | 16 + .../src/blast-radius/semverRange.test.ts | 105 ++++ .../src/blast-radius/semverRange.ts | 87 +++ .../src/blast-radius/stages/dependents.ts | 97 ++++ .../src/blast-radius/stages/intel.ts | 162 ++++++ .../src/blast-radius/stages/reachability.ts | 184 ++++++ .../src/blast-radius/stages/report.ts | 44 ++ .../src/packages/blastRadius.ts | 527 ++++++++++++++++++ 19 files changed, 2545 insertions(+) create mode 100644 backend/src/api/public/v1/packages/blastRadiusAnalysis.ts create mode 100644 backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts create mode 100644 backend/src/api/public/v1/packages/getBlastRadiusJob.ts create mode 100644 services/apps/packages_worker/src/blast-radius/activities.ts create mode 100644 services/apps/packages_worker/src/blast-radius/agent/prompts.ts create mode 100644 services/apps/packages_worker/src/blast-radius/agent/runner.ts create mode 100644 services/apps/packages_worker/src/blast-radius/clients/githubPatch.ts create mode 100644 services/apps/packages_worker/src/blast-radius/clients/npmAbbreviated.ts create mode 100644 services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts create mode 100644 services/apps/packages_worker/src/blast-radius/clients/osvClient.ts create mode 100644 services/apps/packages_worker/src/blast-radius/dependentsScan.ts create mode 100644 services/apps/packages_worker/src/blast-radius/npmManifest.ts create mode 100644 services/apps/packages_worker/src/blast-radius/semverRange.test.ts create mode 100644 services/apps/packages_worker/src/blast-radius/semverRange.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/dependents.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/intel.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/reachability.ts create mode 100644 services/apps/packages_worker/src/blast-radius/stages/report.ts create mode 100644 services/libs/data-access-layer/src/packages/blastRadius.ts 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..193abf77cf --- /dev/null +++ b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts @@ -0,0 +1,127 @@ +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 interface BlastRadiusResultItem { + dependent: string + affected: boolean + 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' +} + +// npm purls use a literal (unencoded) `@` for the scope, matching the contract's +// example response bodies — these are JSON fields, not URL query params, so the +// %40-encoding normalizePurl applies elsewhere in this file group does not apply here. +function toPurl(name: string): string { + return `pkg:npm/${name}` +} + +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') +} + +function toResultItem(row: VerdictResultRow): BlastRadiusResultItem { + return { + dependent: toPurl(row.name), + affected: row.reachable_verdict === 'affected', + confidence: toResultConfidence(row.confidence), + evidence: flattenEvidence(row.evidence), + downloadsLast30Days: row.downloads !== null ? String(row.downloads) : null, + } +} + +// Population-level summary, following the PoC's report.py ground truth: +// totalDependentsInRange = candidates_considered (post phase-1-filter population), +// dependentsAnalyzed = number of verdicts produced, dependentsExcludedUpfront = +// the difference (range-excluded before reachability ran), dependentsAffected = +// count with an 'affected' verdict, affectedPercentage rounded to 1 decimal +// (null when nothing was analyzed, to avoid a misleading 0%). +function toSummary( + candidatesConsidered: number | null, + results: BlastRadiusResultItem[], +): BlastRadiusAnalysisSummary { + const totalDependentsInRange = candidatesConsidered ?? results.length + const dependentsAnalyzed = results.length + const affected = results.filter((r) => r.affected) + + return { + totalDependentsInRange, + dependentsExcludedUpfront: Math.max(totalDependentsInRange - dependentsAnalyzed, 0), + dependentsAnalyzed, + dependentsAffected: affected.length, + affectedPercentage: + dependentsAnalyzed > 0 + ? Math.round((affected.length / dependentsAnalyzed) * 1000) / 10 + : null, + affectedDependents: affected.map((r) => r.dependent), + } +} + +export function toBlastRadiusAnalysis( + analysis: AnalysisDetailRow, + verdictRows: VerdictResultRow[], +): 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(analysis.candidates_considered, 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..add1dfc08b --- /dev/null +++ b/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts @@ -0,0 +1,142 @@ +import type { Request, Response } from 'express' +import { describe, expect, it, vi } from 'vitest' + +import { getBlastRadiusJob } from './getBlastRadiusJob' + +const { getAnalysisDetail, getVerdictResults } = vi.hoisted(() => ({ + getAnalysisDetail: vi.fn(), + getVerdictResults: vi.fn(), +})) + +vi.mock('@crowd/data-access-layer/src/packages/blastRadius', () => ({ + getAnalysisDetail, + getVerdictResults, +})) + +vi.mock('@/db/packagesDb', () => ({ + getPackagesQx: vi.fn().mockResolvedValue({}), +})) + +const ANALYSIS_ID = '11111111-1111-1111-1111-111111111111' + +function mockReqRes(params: unknown) { + getAnalysisDetail.mockClear() + getVerdictResults.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(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', + }, + ]) + + 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, + confidence: 'high', + }), + expect.objectContaining({ + dependent: 'pkg:npm/other-pkg', + affected: false, + 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..bc40fd531d --- /dev/null +++ b/backend/src/api/public/v1/packages/getBlastRadiusJob.ts @@ -0,0 +1,32 @@ +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 verdictRows = + analysis.status === 'done' ? await blastRadiusDal.getVerdictResults(qx, analysisId) : [] + + ok(res, toBlastRadiusAnalysis(analysis, verdictRows)) +} 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..724c17e888 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/activities.ts @@ -0,0 +1,84 @@ +import { Context } 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. +export async function blastRadiusFail(input: { analysisId: string; error: string }): Promise { + log.warn({ analysisId: input.analysisId, error: input.error }, 'blast-radius: analysis failed') + const qx = await getPackagesDb() + await blastRadiusDal.failAnalysis(qx, input.analysisId, 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) + Context.current().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, () => Context.current().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, () => Context.current().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) + Context.current().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..dea372a96d --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/agent/runner.ts @@ -0,0 +1,118 @@ +// @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 +} + +export async function runAnalysisAgent(input: RunAnalysisAgentInput): Promise { + const { prompt, systemPrompt, cwd, model, schema, maxTurns = 15, timeoutMs = 600_000 } = input + + const apiKey = process.env.BLAST_RADIUS_ANTHROPIC_API_KEY + + // Build environment: if API key is set, pass it; otherwise omit to fall back to CLI auth. + const env = apiKey ? { ...process.env, ANTHROPIC_API_KEY: apiKey } : 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'], + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + outputFormat: { + type: 'json_schema', + schema, + }, + abortController: controller, + env, + }, + }) + + let result: AgentRunResult | null = null + let turns = 0 + + for await (const message of q) { + 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..fe00652feb --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts @@ -0,0 +1,70 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { Readable } from 'stream' +import type { ReadableStream as NodeWebReadableStream } from 'stream/web' +import * as tar from 'tar' + +// 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 res = await fetch(tarballUrl) + 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 { + const extractStream = tar.extract({ cwd: tempDir }) as unknown as NodeJS.WritableStream + await new Promise((resolve, reject) => { + Readable.fromWeb(res.body as unknown as NodeWebReadableStream) + .on('error', reject) + .pipe(extractStream) + .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 }) + } +} + +// 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..f380874f30 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts @@ -0,0 +1,108 @@ +// 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 + }> + }> +} + +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} pairs. +// last_affected events clear the fixed version (making the range open-ended). +export function semverRangeEvents( + entry: OsvAffectedPackage, +): Array<{ introduced: string | null; fixed: string | null }> { + if (!entry.ranges) return [] + + const events: Array<{ introduced: string | null; fixed: string | null }> = [] + 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 }) + introduced = null + } else if (event.last_affected) { + events.push({ introduced, fixed: null }) + introduced = null + } + } + + // Trailing open range: introduced but never closed by fixed/last_affected. + if (introduced !== null) { + events.push({ introduced, fixed: null }) + } + } + + return events +} + +// 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' && ref.url.includes('github.com')) { + results.push(ref.url) + } + } + } + + // Then other web refs + if (vuln.references) { + for (const ref of vuln.references) { + if (ref.type !== 'FIX' && ref.url.startsWith('http')) { + 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..092d7278ac --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/dependentsScan.ts @@ -0,0 +1,297 @@ +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') + const matches = content.match(/((?:@[\w.-]+\/)?[\w.-]+)/g) + if (matches) { + matches.forEach((m) => names.add(m)) + } + } + } + + return Array.from(names) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +} + +// Check if a package version declares a dependency on a target package. +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 target of allTargets) { + if (target in deps) { + return { + kind: depKind, + range: deps[target], + } + } + } + } + + 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] ?? {} + if (Object.keys(deps).some((dep) => targets.has(dep))) { + 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 calendar month — + // only for the filtered candidates, not the full high-impact list. + const rangeEnd = new Date() + const rangeStart = new Date(rangeEnd) + rangeStart.setUTCMonth(rangeStart.getUTCMonth() - 1) + 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/semverRange.test.ts b/services/apps/packages_worker/src/blast-radius/semverRange.test.ts new file mode 100644 index 0000000000..25e756acbe --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/semverRange.test.ts @@ -0,0 +1,105 @@ +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' }) + }) + }) + + 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/semverRange.ts b/services/apps/packages_worker/src/blast-radius/semverRange.ts new file mode 100644 index 0000000000..99b7ef87be --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/semverRange.ts @@ -0,0 +1,87 @@ +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) { + rangeSpec = `>=${range.introduced}` + } + + if (rangeSpec && semver.satisfies(v, rangeSpec, { loose: 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 })) { + 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 }) || null +} + +// Helper: check if a range spec is parseable as semver. +function isValidSemverRange(spec: string): boolean { + try { + semver.validRange(spec, { loose: true }) + return true + } 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..b04108d6c8 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/dependents.ts @@ -0,0 +1,97 @@ +import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' +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') + } + + 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, + }) + + // Persist dependents + const dependentInputs = [ + ...scanResult.analyzed.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: 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..1aa39f37b0 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/intel.ts @@ -0,0 +1,162 @@ +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 { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +import { fetchPackument } from '../../npm/fetchPackument' +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 { highestVersion, versionsInRanges } from '../semverRange' + +export async function runIntelStage( + qx: QueryExecutor, + analysisId: string, + advisoryOsvId: string, +): Promise { + const startTime = Date.now() + + try { + // Check if already done + const existing = await blastRadiusDal.getSymbolSpec(qx, analysisId) + if (existing) { + 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}`) + } + + // For now, analyze the first npm entry (PoC behavior) + const entry = npmEntries[0] + const package_ = entry.package.name + const ecosystem = entry.package.ecosystem + + // Resolve vulnerable versions from ranges + const ranges = semverRangeEvents(entry) + const allVersions = Array.from( + new Set( + (osv.affected?.flatMap( + (a) => + a.ranges?.flatMap( + (r) => r.events?.map((e) => e.introduced || e.fixed).filter(Boolean) || [], + ) || [], + ) || []) as string[], + ), + ) + 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 packument = await fetchPackument(package_) + if (!isFetchError(packument)) { + const versionData = packument.versions?.[analyzed] + const tarballUrl = versionData ? asNpmVersionManifest(versionData).dist?.tarball : undefined + if (tarballUrl) { + 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 = url.split('/').slice(-2).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, + }) + + 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 + await blastRadiusDal.resolveAdvisoryAndPackageIds(qx, analysisId, advisoryOsvId, null) + + 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..a3a56ad8e1 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts @@ -0,0 +1,184 @@ +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 results: { cost: number; count: number } = { cost: 0, count: 0 } + + const upsertErrorVerdict = (dependentId: number, reasoning: string, model: string | null) => + blastRadiusDal.upsertVerdict(qx, { + analysisId, + dependentId, + usesPackage: false, + importsVulnerableSymbol: false, + importStyle: null, + reachableVerdict: 'unclear', + confidence: 0, + evidence: null, + reasoning, + model, + turnsUsed: null, + costUsd: 0, + }) + + 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 + const depDir = await mkdtemp(path.join(os.tmpdir(), `dep-${dep.name}-`)) + + try { + // Download and extract + await downloadAndExtractTarball(dep.tarball_url, depDir) + + // Try agent up to MAX_ATTEMPTS times with exponential backoff + 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, + }) + + 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: agentResult.costUsd || 0, + }) + + results.cost += agentResult.costUsd || 0 + results.count++ + return + } + + // Agent error; retry if not last attempt + if (attempt === MAX_ATTEMPTS) { + 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', + ) + 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)) + } + + const duration = Date.now() - startTime + await blastRadiusDal.completeStageRun(qx, analysisId, 'reachability', duration, results.cost) + } 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/libs/data-access-layer/src/packages/blastRadius.ts b/services/libs/data-access-layer/src/packages/blastRadius.ts new file mode 100644 index 0000000000..4517b10fad --- /dev/null +++ b/services/libs/data-access-layer/src/packages/blastRadius.ts @@ -0,0 +1,527 @@ +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: number | 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: number + 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: number + 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', started_at = NOW(), 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(a.id, bra.advisory_id), + package_id = COALESCE($(packageId), bra.package_id), + updated_at = NOW() + FROM advisories a + WHERE bra.id = $(analysisId) + AND a.osv_id = $(advisoryOsvId) + `, + { 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 }, + ) +} + +export async function failAnalysis( + qx: QueryExecutor, + analysisId: string, + errorMessage: string, +): Promise { + await qx.result( + ` + UPDATE blast_radius_analyses + SET + status = 'failed', + error = $(errorMessage), + completed_at = NOW(), + updated_at = NOW() + WHERE id = $(analysisId) + `, + { analysisId, 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, + ) + } +} + +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 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, + started_at = EXCLUDED.started_at + 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 +} From b2e6335e21a320fa312de94a53e5c60a1987c306 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 22 Jul 2026 13:09:21 +0200 Subject: [PATCH 2/9] fix: copilot comments Signed-off-by: Umberto Sgueglia --- .../v1/packages/getBlastRadiusJob.test.ts | 2 +- .../src/blast-radius/clients/npmTarball.ts | 7 ++- .../src/blast-radius/clients/osvClient.ts | 33 +++++++++--- .../src/blast-radius/semverRange.test.ts | 7 +++ .../src/blast-radius/semverRange.ts | 8 +-- .../src/blast-radius/stages/intel.ts | 52 +++++++++++-------- .../src/blast-radius/stages/reachability.ts | 2 +- .../src/packages/blastRadius.ts | 11 ++-- 8 files changed, 82 insertions(+), 40 deletions(-) diff --git a/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts b/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts index add1dfc08b..cbd253178a 100644 --- a/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts +++ b/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts @@ -17,7 +17,7 @@ vi.mock('@/db/packagesDb', () => ({ getPackagesQx: vi.fn().mockResolvedValue({}), })) -const ANALYSIS_ID = '11111111-1111-1111-1111-111111111111' +const ANALYSIS_ID = '11111111-1111-4111-8111-111111111111' function mockReqRes(params: unknown) { getAnalysisDetail.mockClear() diff --git a/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts b/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts index fe00652feb..bee1a7ad3c 100644 --- a/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts +++ b/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts @@ -26,7 +26,12 @@ export async function downloadAndExtractTarball( const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tarball-extract-')) try { - const extractStream = tar.extract({ cwd: tempDir }) as unknown as NodeJS.WritableStream + // 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. + const extractStream = tar.extract({ + cwd: tempDir, + strict: true, + }) as unknown as NodeJS.WritableStream await new Promise((resolve, reject) => { Readable.fromWeb(res.body as unknown as NodeWebReadableStream) .on('error', reject) diff --git a/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts index f380874f30..6e51985fff 100644 --- a/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts +++ b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts @@ -48,14 +48,19 @@ export function affectedNpmEntries(vuln: OsvVuln): OsvAffectedPackage[] { return vuln.affected.filter((a) => a.package?.ecosystem === 'npm') } -// Flatten SEMVER-type ranges in an affected package into {introduced, fixed} pairs. -// last_affected events clear the fixed version (making the range open-ended). +// 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. export function semverRangeEvents( entry: OsvAffectedPackage, -): Array<{ introduced: string | null; fixed: string | null }> { +): Array<{ introduced: string | null; fixed: string | null; lastAffected: string | null }> { if (!entry.ranges) return [] - const events: Array<{ introduced: string | null; fixed: string | null }> = [] + const events: Array<{ + introduced: string | null + fixed: string | null + lastAffected: string | null + }> = [] for (const range of entry.ranges) { if (range.type !== 'SEMVER' || !range.events) continue @@ -65,23 +70,35 @@ export function semverRangeEvents( if (event.introduced) introduced = event.introduced if (event.fixed) { - events.push({ introduced, fixed: event.fixed }) + events.push({ introduced, fixed: event.fixed, lastAffected: null }) introduced = null } else if (event.last_affected) { - events.push({ introduced, fixed: null }) + 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 }) + events.push({ introduced, fixed: null, lastAffected: null }) } } 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[] = [] @@ -89,7 +106,7 @@ export function fixReferenceUrls(vuln: OsvVuln): string[] { // FIX-type refs first if (vuln.references) { for (const ref of vuln.references) { - if (ref.type === 'FIX' && ref.url.includes('github.com')) { + if (ref.type === 'FIX' && isGithubUrl(ref.url)) { results.push(ref.url) } } diff --git a/services/apps/packages_worker/src/blast-radius/semverRange.test.ts b/services/apps/packages_worker/src/blast-radius/semverRange.test.ts index 25e756acbe..d5f702b9de 100644 --- a/services/apps/packages_worker/src/blast-radius/semverRange.test.ts +++ b/services/apps/packages_worker/src/blast-radius/semverRange.test.ts @@ -76,6 +76,13 @@ describe('semverRange', () => { 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', () => { diff --git a/services/apps/packages_worker/src/blast-radius/semverRange.ts b/services/apps/packages_worker/src/blast-radius/semverRange.ts index 99b7ef87be..cb30aa1833 100644 --- a/services/apps/packages_worker/src/blast-radius/semverRange.ts +++ b/services/apps/packages_worker/src/blast-radius/semverRange.ts @@ -21,6 +21,8 @@ export function versionsInRanges(versions: string[], ranges: SemverRange[]): str 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}` } @@ -76,11 +78,11 @@ export function highestVersion(versions: string[]): string | null { return semver.maxSatisfying(valid, '*', { loose: true }) || null } -// Helper: check if a range spec is parseable as semver. +// 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 { - semver.validRange(spec, { loose: true }) - return true + return semver.validRange(spec, { loose: true }) !== null } catch { return false } diff --git a/services/apps/packages_worker/src/blast-radius/stages/intel.ts b/services/apps/packages_worker/src/blast-radius/stages/intel.ts index 1aa39f37b0..26fcb1b575 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/intel.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/intel.ts @@ -28,9 +28,11 @@ export async function runIntelStage( const startTime = Date.now() try { - // Check if already done - const existing = await blastRadiusDal.getSymbolSpec(qx, analysisId) - if (existing) { + // 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 } @@ -50,23 +52,29 @@ export async function runIntelStage( throw new Error(`No npm entries found in advisory ${advisoryOsvId}`) } - // For now, analyze the first npm entry (PoC behavior) - const entry = npmEntries[0] + // 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). + const analysisDetail = await blastRadiusDal.getAnalysisDetail(qx, analysisId) + const requestedPackage = analysisDetail?.package_name + const entry = + (requestedPackage && npmEntries.find((e) => e.package.name === requestedPackage)) || + npmEntries[0] const package_ = entry.package.name const ecosystem = entry.package.ecosystem + // 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 allVersions = Array.from( - new Set( - (osv.affected?.flatMap( - (a) => - a.ranges?.flatMap( - (r) => r.events?.map((e) => e.introduced || e.fixed).filter(Boolean) || [], - ) || [], - ) || []) as string[], - ), - ) const vulnerableVersions = versionsInRanges(allVersions, ranges) const analyzed = highestVersion(vulnerableVersions) @@ -80,14 +88,12 @@ export async function runIntelStage( try { // Download package source (using npm registry) - const packument = await fetchPackument(package_) - if (!isFetchError(packument)) { - const versionData = packument.versions?.[analyzed] - const tarballUrl = versionData ? asNpmVersionManifest(versionData).dist?.tarball : undefined - if (tarballUrl) { - await downloadAndExtractTarball(tarballUrl, pkgsrcDir) - } + 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) @@ -140,7 +146,7 @@ export async function runIntelStage( importSignatures: (output.import_signatures || {}) as Record, exploitPreconditions: String(output.exploit_preconditions || ''), reachabilityNotes: String(output.reachability_notes || ''), - confidence: Number(output.confidence || 0.5), + confidence: Number(output.confidence ?? 0.5), sources: [advisoryOsvId], summary: String(output.summary || ''), }) diff --git a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts index a3a56ad8e1..7715d518fb 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts @@ -73,7 +73,7 @@ export async function runReachabilityStage( const queue = [...dependents] const results: { cost: number; count: number } = { cost: 0, count: 0 } - const upsertErrorVerdict = (dependentId: number, reasoning: string, model: string | null) => + const upsertErrorVerdict = (dependentId: string, reasoning: string, model: string | null) => blastRadiusDal.upsertVerdict(qx, { analysisId, dependentId, diff --git a/services/libs/data-access-layer/src/packages/blastRadius.ts b/services/libs/data-access-layer/src/packages/blastRadius.ts index 4517b10fad..00ef75c9da 100644 --- a/services/libs/data-access-layer/src/packages/blastRadius.ts +++ b/services/libs/data-access-layer/src/packages/blastRadius.ts @@ -49,7 +49,7 @@ export interface DependentInput { export interface VerdictInput { analysisId: string - dependentId: number + dependentId: string usesPackage: boolean importsVulnerableSymbol: boolean importStyle: string | null @@ -81,7 +81,7 @@ export interface AnalysisRow { } export interface DependentRow { - id: number + id: string analysis_id: string name: string excluded_by_range: boolean @@ -448,7 +448,12 @@ export async function startStageRun(qx: QueryExecutor, input: StageRunInput): Pr ($(analysisId), $(stage), $(status), $(model), COALESCE($(startedAt), NOW())) ON CONFLICT (analysis_id, stage) DO UPDATE SET status = EXCLUDED.status, - started_at = EXCLUDED.started_at + model = EXCLUDED.model, + started_at = EXCLUDED.started_at, + completed_at = NULL, + duration_ms = NULL, + cost_usd = 0, + error = NULL RETURNING id `, params, From 2e68abbaef20309749ba3a62ce299bae7fb3e8de Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 22 Jul 2026 14:51:32 +0200 Subject: [PATCH 3/9] fix: copilot comments Signed-off-by: Umberto Sgueglia --- .../api/public/v1/akrites-external/index.ts | 6 +- .../public/v1/akrites-external/openapi.yaml | 203 +++++- .../public/v1/packages/blastRadiusAnalysis.ts | 42 +- .../v1/packages/getBlastRadiusJob.test.ts | 17 +- .../public/v1/packages/getBlastRadiusJob.ts | 9 +- pnpm-lock.yaml | 599 +++++++++++++++++- services/apps/packages_worker/package.json | 2 + .../apps/packages_worker/src/activities.ts | 8 + .../src/blast-radius/clients/osvClient.ts | 6 +- .../blast-radius/packageIdentifier.test.ts | 33 + .../src/blast-radius/packageIdentifier.ts | 24 + .../src/blast-radius/stages/intel.ts | 12 +- .../src/blast-radius/stages/reachability.ts | 24 +- .../src/blast-radius/workflows.ts | 73 ++- .../src/packages/blastRadius.ts | 30 + 15 files changed, 1024 insertions(+), 64 deletions(-) create mode 100644 services/apps/packages_worker/src/blast-radius/packageIdentifier.test.ts create mode 100644 services/apps/packages_worker/src/blast-radius/packageIdentifier.ts diff --git a/backend/src/api/public/v1/akrites-external/index.ts b/backend/src/api/public/v1/akrites-external/index.ts index 0fdefe3121..3cc151838f 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) @@ -75,6 +76,7 @@ export function akritesExternalRouter(): Router { blastRadiusSubRouter.use(blastRadiusRateLimiter) blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES])) blastRadiusSubRouter.post('/jobs', safeWrap(submitBlastRadiusJob)) + blastRadiusSubRouter.get('/jobs/:analysisId', 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..614b5fde55 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: Dependents considered after the phase-1 range filter (Stage 2's population before its top-N cutoff). + 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 index 193abf77cf..d927992c67 100644 --- a/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts +++ b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts @@ -7,9 +7,12 @@ import type { BlastRadiusJobEcosystem, BlastRadiusJobStatus } from './blastRadiu 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 @@ -66,33 +69,47 @@ function flattenEvidence(evidence: Record[] | null): string | n .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: row.reachable_verdict === 'affected', + affected: verdict === 'affected', + verdict, confidence: toResultConfidence(row.confidence), evidence: flattenEvidence(row.evidence), downloadsLast30Days: row.downloads !== null ? String(row.downloads) : null, } } -// Population-level summary, following the PoC's report.py ground truth: -// totalDependentsInRange = candidates_considered (post phase-1-filter population), -// dependentsAnalyzed = number of verdicts produced, dependentsExcludedUpfront = -// the difference (range-excluded before reachability ran), dependentsAffected = -// count with an 'affected' verdict, affectedPercentage rounded to 1 decimal -// (null when nothing was analyzed, to avoid a misleading 0%). +// 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 (null when nothing was analyzed, to avoid a misleading 0%). function toSummary( - candidatesConsidered: number | null, + dependentsExcludedUpfront: number, results: BlastRadiusResultItem[], ): BlastRadiusAnalysisSummary { - const totalDependentsInRange = candidatesConsidered ?? results.length const dependentsAnalyzed = results.length const affected = results.filter((r) => r.affected) return { - totalDependentsInRange, - dependentsExcludedUpfront: Math.max(totalDependentsInRange - dependentsAnalyzed, 0), + totalDependentsInRange: dependentsAnalyzed + dependentsExcludedUpfront, + dependentsExcludedUpfront, dependentsAnalyzed, dependentsAffected: affected.length, affectedPercentage: @@ -106,6 +123,7 @@ function toSummary( export function toBlastRadiusAnalysis( analysis: AnalysisDetailRow, verdictRows: VerdictResultRow[], + dependentsExcludedByRangeCount: number, ): BlastRadiusAnalysis { const status = analysis.status as BlastRadiusJobStatus const done = status === 'done' @@ -121,7 +139,7 @@ export function toBlastRadiusAnalysis( submittedAt: analysis.started_at, completedAt: analysis.completed_at, errorMessage: analysis.error, - summary: done && results ? toSummary(analysis.candidates_considered, results) : null, + 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 index cbd253178a..fa9c3f6229 100644 --- a/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts +++ b/backend/src/api/public/v1/packages/getBlastRadiusJob.test.ts @@ -3,14 +3,18 @@ import { describe, expect, it, vi } from 'vitest' import { getBlastRadiusJob } from './getBlastRadiusJob' -const { getAnalysisDetail, getVerdictResults } = vi.hoisted(() => ({ - getAnalysisDetail: vi.fn(), - getVerdictResults: vi.fn(), -})) +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', () => ({ @@ -22,6 +26,7 @@ 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 @@ -51,6 +56,7 @@ describe('getBlastRadiusJob', () => { await getBlastRadiusJob(req, res) expect(getVerdictResults).not.toHaveBeenCalled() + expect(getDependentsExcludedByRangeCount).not.toHaveBeenCalled() expect(json).toHaveBeenCalledWith( expect.objectContaining({ analysisId: ANALYSIS_ID, @@ -93,6 +99,7 @@ describe('getBlastRadiusJob', () => { reasoning: 'unused', }, ]) + getDependentsExcludedByRangeCount.mockResolvedValue(8) const { req, res, json } = mockReqRes({ analysisId: ANALYSIS_ID }) @@ -113,11 +120,13 @@ describe('getBlastRadiusJob', () => { 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', }), ], diff --git a/backend/src/api/public/v1/packages/getBlastRadiusJob.ts b/backend/src/api/public/v1/packages/getBlastRadiusJob.ts index bc40fd531d..86314127c7 100644 --- a/backend/src/api/public/v1/packages/getBlastRadiusJob.ts +++ b/backend/src/api/public/v1/packages/getBlastRadiusJob.ts @@ -25,8 +25,11 @@ export async function getBlastRadiusJob(req: Request, res: Response): Promise=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/clients/osvClient.ts b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts index 6e51985fff..fd3e620321 100644 --- a/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts +++ b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts @@ -112,10 +112,12 @@ export function fixReferenceUrls(vuln: OsvVuln): string[] { } } - // Then other web refs + // 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' && ref.url.startsWith('http')) { + if (ref.type !== 'FIX' && isGithubUrl(ref.url)) { results.push(ref.url) } } diff --git a/services/apps/packages_worker/src/blast-radius/packageIdentifier.test.ts b/services/apps/packages_worker/src/blast-radius/packageIdentifier.test.ts new file mode 100644 index 0000000000..6f36307932 --- /dev/null +++ b/services/apps/packages_worker/src/blast-radius/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/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/stages/intel.ts b/services/apps/packages_worker/src/blast-radius/stages/intel.ts index 26fcb1b575..aaadec2e7a 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/intel.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/intel.ts @@ -18,6 +18,7 @@ import { semverRangeEvents, } from '../clients/osvClient' import { asNpmVersionManifest } from '../npmManifest' +import { toBareNpmName } from '../packageIdentifier' import { highestVersion, versionsInRanges } from '../semverRange' export async function runIntelStage( @@ -54,14 +55,21 @@ export async function runIntelStage( // 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). + // 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 — @@ -141,7 +149,7 @@ export async function runIntelStage( affectedRanges: ranges, vulnerableVersions, analyzedVersion: analyzed, - relatedAffectedPackages: [], + relatedAffectedPackages, vulnerableSymbols: (output.vulnerable_symbols || []) as Record[], importSignatures: (output.import_signatures || {}) as Record, exploitPreconditions: String(output.exploit_preconditions || ''), diff --git a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts index 7715d518fb..4aac5cbd0c 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts @@ -71,7 +71,6 @@ export async function runReachabilityStage( // Process with concurrency limit (4) const concurrency = 4 const queue = [...dependents] - const results: { cost: number; count: number } = { cost: 0, count: 0 } const upsertErrorVerdict = (dependentId: string, reasoning: string, model: string | null) => blastRadiusDal.upsertVerdict(qx, { @@ -99,8 +98,19 @@ export async function runReachabilityStage( const depDir = await mkdtemp(path.join(os.tmpdir(), `dep-${dep.name}-`)) try { - // Download and extract - await downloadAndExtractTarball(dep.tarball_url, depDir) + // 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 for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { @@ -135,8 +145,6 @@ export async function runReachabilityStage( costUsd: agentResult.costUsd || 0, }) - results.cost += agentResult.costUsd || 0 - results.count++ return } @@ -173,8 +181,12 @@ export async function runReachabilityStage( 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 - await blastRadiusDal.completeStageRun(qx, analysisId, 'reachability', duration, results.cost) + 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) diff --git a/services/apps/packages_worker/src/blast-radius/workflows.ts b/services/apps/packages_worker/src/blast-radius/workflows.ts index 7a6ee8f2da..9318b72bae 100644 --- a/services/apps/packages_worker/src/blast-radius/workflows.ts +++ b/services/apps/packages_worker/src/blast-radius/workflows.ts @@ -1,15 +1,78 @@ -import { log } from '@temporalio/workflow' +import { ApplicationFailure, log, proxyActivities } 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) + } + + await blastRadiusStart({ + analysisId: input.analysisId, + advisoryOsvId: input.advisoryId, + packageName: input.package, + ecosystem: input.ecosystem, + force: input.force, + }) + + try { + 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) { + const errorMessage = err instanceof Error ? err.message : String(err) + await blastRadiusFail({ analysisId: input.analysisId, 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 index 00ef75c9da..709567b64c 100644 --- a/services/libs/data-access-layer/src/packages/blastRadius.ts +++ b/services/libs/data-access-layer/src/packages/blastRadius.ts @@ -349,6 +349,24 @@ export async function insertDependents( } } +// 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 +} + export async function getDependentsNeedingVerdict( qx: QueryExecutor, analysisId: string, @@ -418,6 +436,18 @@ export async function getVerdicts( ) } +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, From 0a7ed66c26f5652667370d4f30449a3d1584ae61 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 22 Jul 2026 15:34:16 +0200 Subject: [PATCH 4/9] fix: copilot comments Signed-off-by: Umberto Sgueglia --- .../public/v1/packages/blastRadiusAnalysis.ts | 8 +-- .../v1/packages/submitBlastRadiusJob.test.ts | 26 +++++++++- .../v1/packages/submitBlastRadiusJob.ts | 23 ++++++-- .../src/blast-radius/activities.ts | 25 +++++++-- .../src/blast-radius/agent/runner.ts | 17 +++++- .../src/blast-radius/clients/npmTarball.ts | 52 +++++++++++++++++-- .../src/blast-radius/dependentsScan.ts | 49 +++++++++++++---- .../src/blast-radius/stages/dependents.ts | 19 ++++++- .../src/blast-radius/stages/intel.ts | 11 +++- .../src/blast-radius/stages/reachability.ts | 14 ++--- .../src/blast-radius/workflows.ts | 32 ++++++++---- .../src/packages/blastRadius.ts | 34 ++++++++++-- .../data-access-layer/src/packages/osv.ts | 22 ++++++++ 13 files changed, 276 insertions(+), 56 deletions(-) diff --git a/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts index d927992c67..d6a5aa8188 100644 --- a/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts +++ b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts @@ -48,11 +48,11 @@ function toResultConfidence(confidence: number): BlastRadiusResultConfidence { return 'low' } -// npm purls use a literal (unencoded) `@` for the scope, matching the contract's -// example response bodies — these are JSON fields, not URL query params, so the -// %40-encoding normalizePurl applies elsewhere in this file group does not apply here. +// 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}` + return `pkg:npm/${name.replace(/^@/, '%40')}` } function flattenEvidence(evidence: Record[] | null): string | null { diff --git a/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts b/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts index 0cec05fd41..32e6b84dd4 100644 --- a/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts +++ b/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts @@ -3,14 +3,26 @@ import { describe, expect, it, vi } from 'vitest' import { submitBlastRadiusJob } from './submitBlastRadiusJob' -const { start } = vi.hoisted(() => ({ start: vi.fn().mockResolvedValue(undefined) })) +const { start, createAnalysis } = vi.hoisted(() => ({ + start: vi.fn().mockResolvedValue(undefined), + createAnalysis: 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, +})) + function mockReqRes(body: unknown) { start.mockClear() + createAnalysis.mockClear() const req = { body } as unknown as Request @@ -30,6 +42,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 +100,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 +111,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 +119,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 +127,6 @@ describe('submitBlastRadiusJob', () => { await expect(submitBlastRadiusJob(req, res)).rejects.toThrow() expect(start).not.toHaveBeenCalled() + expect(createAnalysis).not.toHaveBeenCalled() }) }) diff --git a/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts b/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts index b0e48e29fc..31e0e60da8 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,6 +20,20 @@ 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() diff --git a/services/apps/packages_worker/src/blast-radius/activities.ts b/services/apps/packages_worker/src/blast-radius/activities.ts index 724c17e888..7ce6a28fef 100644 --- a/services/apps/packages_worker/src/blast-radius/activities.ts +++ b/services/apps/packages_worker/src/blast-radius/activities.ts @@ -42,19 +42,34 @@ export async function blastRadiusStart(input: BlastRadiusStartInput): Promise { +// 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, input.analysisId, input.error) + 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) - Context.current().heartbeat() + await runIntelStage(qx, input.analysisId, input.advisoryOsvId, () => + Context.current().heartbeat(), + ) log.info({ analysisId: input.analysisId }, 'blast-radius: intel stage done') } diff --git a/services/apps/packages_worker/src/blast-radius/agent/runner.ts b/services/apps/packages_worker/src/blast-radius/agent/runner.ts index dea372a96d..220551d0e5 100644 --- a/services/apps/packages_worker/src/blast-radius/agent/runner.ts +++ b/services/apps/packages_worker/src/blast-radius/agent/runner.ts @@ -20,10 +20,23 @@ export interface RunAnalysisAgentInput { 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 } = input + const { + prompt, + systemPrompt, + cwd, + model, + schema, + maxTurns = 15, + timeoutMs = 600_000, + onProgress, + } = input const apiKey = process.env.BLAST_RADIUS_ANTHROPIC_API_KEY @@ -60,6 +73,8 @@ export async function runAnalysisAgent(input: RunAnalysisAgentInput): Promise { fs.mkdirSync(destDir, { recursive: true }) - const res = await fetch(tarballUrl) + const controller = new AbortController() + const timeoutHandle = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + let res: Response + try { + res = await fetch(tarballUrl, { signal: controller.signal }) + } finally { + clearTimeout(timeoutHandle) + } if (!res.ok) { throw new Error(`Failed to fetch tarball: ${res.status} ${res.statusText}`) } @@ -28,14 +44,42 @@ export async function downloadAndExtractTarball( 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, - }) as unknown as NodeJS.WritableStream + 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(extractStream) + .pipe(downloadLimiter) + .on('error', reject) + .pipe(extractStream as unknown as NodeJS.WritableStream) .on('finish', resolve) .on('error', reject) }) diff --git a/services/apps/packages_worker/src/blast-radius/dependentsScan.ts b/services/apps/packages_worker/src/blast-radius/dependentsScan.ts index 092d7278ac..a3d7d4aac9 100644 --- a/services/apps/packages_worker/src/blast-radius/dependentsScan.ts +++ b/services/apps/packages_worker/src/blast-radius/dependentsScan.ts @@ -107,9 +107,8 @@ async function fetchHighImpactNames(): Promise { const filePath = path.join(tempDir, file) if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8') - const matches = content.match(/((?:@[\w.-]+\/)?[\w.-]+)/g) - if (matches) { - matches.forEach((m) => names.add(m)) + for (const match of content.matchAll(/'((?:@[\w.-]+\/)?[\w.-]+)'/g)) { + names.add(match[1]) } } } @@ -120,7 +119,34 @@ async function fetchHighImpactNames(): Promise { } } -// Check if a package version declares a dependency on a target package. +// 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, @@ -130,12 +156,10 @@ function declaredDependency( for (const depKind of DEP_KINDS) { const deps = versionData[depKind] ?? {} - for (const target of allTargets) { - if (target in deps) { - return { - kind: depKind, - range: deps[target], - } + for (const [depName, depSpec] of Object.entries(deps)) { + const resolved = resolvedDependencyTarget(depName, depSpec) + if (allTargets.includes(resolved.name)) { + return { kind: depKind, range: resolved.range } } } } @@ -171,7 +195,10 @@ async function candidateNamesFromScan( for (const depKind of DEP_KINDS) { const deps = versionData[depKind] ?? {} - if (Object.keys(deps).some((dep) => targets.has(dep))) { + const hasTarget = Object.entries(deps).some(([depName, depSpec]) => + targets.has(resolvedDependencyTarget(depName, depSpec).name), + ) + if (hasTarget) { hits.push(name) return } diff --git a/services/apps/packages_worker/src/blast-radius/stages/dependents.ts b/services/apps/packages_worker/src/blast-radius/stages/dependents.ts index b04108d6c8..9178e6495d 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/dependents.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/dependents.ts @@ -1,4 +1,5 @@ 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' @@ -33,6 +34,12 @@ export async function runDependentsStage( 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[] @@ -46,11 +53,21 @@ export async function runDependentsStage( 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: null, + packageId: packageIdsByName.get(d.name) ?? null, name: d.name, version: d.version, downloads: d.downloads, diff --git a/services/apps/packages_worker/src/blast-radius/stages/intel.ts b/services/apps/packages_worker/src/blast-radius/stages/intel.ts index aaadec2e7a..8754d1e873 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/intel.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/intel.ts @@ -3,9 +3,11 @@ 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' @@ -25,6 +27,7 @@ export async function runIntelStage( qx: QueryExecutor, analysisId: string, advisoryOsvId: string, + onProgress?: () => void, ): Promise { const startTime = Date.now() @@ -132,6 +135,7 @@ export async function runIntelStage( schema: INTEL_SCHEMA, maxTurns: 15, timeoutMs: 600_000, + onProgress, }) if (agentResult.isError || !agentResult.structuredOutput) { @@ -159,8 +163,11 @@ export async function runIntelStage( summary: String(output.summary || ''), }) - // Resolve advisory_id - await blastRadiusDal.resolveAdvisoryAndPackageIds(qx, analysisId, advisoryOsvId, null) + // 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) diff --git a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts index 4aac5cbd0c..afd03471d8 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts @@ -94,8 +94,10 @@ export async function runReachabilityStage( return } - // Create temp dir for this dependent - const depDir = await mkdtemp(path.join(os.tmpdir(), `dep-${dep.name}-`)) + // 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 @@ -125,6 +127,7 @@ export async function runReachabilityStage( schema: VERDICT_SCHEMA, maxTurns: 15, timeoutMs: 600_000, + onProgress, }) if (!agentResult.isError && agentResult.structuredOutput) { @@ -148,10 +151,9 @@ export async function runReachabilityStage( return } - // Agent error; retry if not last attempt - if (attempt === MAX_ATTEMPTS) { - throw new Error(agentResult.errorMessage || 'Agent failed') - } + // 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 diff --git a/services/apps/packages_worker/src/blast-radius/workflows.ts b/services/apps/packages_worker/src/blast-radius/workflows.ts index 9318b72bae..dff8334a21 100644 --- a/services/apps/packages_worker/src/blast-radius/workflows.ts +++ b/services/apps/packages_worker/src/blast-radius/workflows.ts @@ -1,4 +1,4 @@ -import { ApplicationFailure, log, proxyActivities } from '@temporalio/workflow' +import { ApplicationFailure, log, proxyActivities, rootCause } from '@temporalio/workflow' import type { ITriggerBlastRadiusAnalysis } from '@crowd/types' @@ -52,15 +52,15 @@ export async function analyzeBlastRadius(input: ITriggerBlastRadiusAnalysis): Pr throw buildEcosystemNotSupportedFailure(input.ecosystem) } - await blastRadiusStart({ - analysisId: input.analysisId, - advisoryOsvId: input.advisoryId, - packageName: input.package, - ecosystem: input.ecosystem, - force: input.force, - }) - 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({ @@ -69,8 +69,18 @@ export async function analyzeBlastRadius(input: ITriggerBlastRadiusAnalysis): Pr }) await blastRadiusReport({ analysisId: input.analysisId, advisoryOsvId: input.advisoryId }) } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - await blastRadiusFail({ analysisId: input.analysisId, error: errorMessage }) + // 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') } diff --git a/services/libs/data-access-layer/src/packages/blastRadius.ts b/services/libs/data-access-layer/src/packages/blastRadius.ts index 709567b64c..1fb2362b34 100644 --- a/services/libs/data-access-layer/src/packages/blastRadius.ts +++ b/services/libs/data-access-layer/src/packages/blastRadius.ts @@ -233,22 +233,28 @@ export async function finalizeAnalysis( ) } +// 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, - analysisId: string, + input: BlastRadiusAnalysisInput, errorMessage: string, ): Promise { await qx.result( ` - UPDATE blast_radius_analyses - SET + 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() - WHERE id = $(analysisId) `, - { analysisId, errorMessage }, + { ...input, errorMessage }, ) } @@ -367,6 +373,24 @@ export async function getDependentsExcludedByRangeCount( 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, diff --git a/services/libs/data-access-layer/src/packages/osv.ts b/services/libs/data-access-layer/src/packages/osv.ts index 8ce9c45b84..4aa04f241a 100644 --- a/services/libs/data-access-layer/src/packages/osv.ts +++ b/services/libs/data-access-layer/src/packages/osv.ts @@ -119,6 +119,28 @@ 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. +export async function findPackageIdsByName( + qx: QueryExecutor, + ecosystem: string, + names: string[], +): Promise> { + if (names.length === 0) return new Map() + const rows: Array<{ id: number; 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, Number(r.id)])) +} + export async function upsertAdvisoryPackage( qx: QueryExecutor, input: AdvisoryPackageUpsertInput, From 6bfe683346b1cc06d42ff5ba3191a1929858814d Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 22 Jul 2026 16:14:32 +0200 Subject: [PATCH 5/9] fix: skip bypass permissions Signed-off-by: Umberto Sgueglia --- .../api/public/v1/akrites-external/index.ts | 5 +- .../public/v1/akrites-external/openapi.yaml | 2 +- .../v1/packages/submitBlastRadiusJob.test.ts | 25 +++- .../v1/packages/submitBlastRadiusJob.ts | 41 ++++-- .../src/blast-radius/agent/runner.ts | 7 +- .../src/blast-radius/clients/npmTarball.ts | 122 +++++++++--------- .../src/blast-radius/clients/osvClient.ts | 43 +++--- .../src/blast-radius/dependentsScan.ts | 4 +- .../src/blast-radius/semverRange.ts | 6 +- .../src/packages/blastRadius.ts | 2 +- .../data-access-layer/src/packages/osv.ts | 8 +- 11 files changed, 161 insertions(+), 104 deletions(-) diff --git a/backend/src/api/public/v1/akrites-external/index.ts b/backend/src/api/public/v1/akrites-external/index.ts index 3cc151838f..07bed2dd1b 100644 --- a/backend/src/api/public/v1/akrites-external/index.ts +++ b/backend/src/api/public/v1/akrites-external/index.ts @@ -73,10 +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.get('/jobs/:analysisId', safeWrap(getBlastRadiusJob)) + 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 614b5fde55..08327892fa 100644 --- a/backend/src/api/public/v1/akrites-external/openapi.yaml +++ b/backend/src/api/public/v1/akrites-external/openapi.yaml @@ -470,7 +470,7 @@ components: properties: totalDependentsInRange: type: integer - description: Dependents considered after the phase-1 range filter (Stage 2's population before its top-N cutoff). + 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. diff --git a/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts b/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts index 32e6b84dd4..eb70ad1bd9 100644 --- a/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts +++ b/backend/src/api/public/v1/packages/submitBlastRadiusJob.test.ts @@ -3,9 +3,10 @@ import { describe, expect, it, vi } from 'vitest' import { submitBlastRadiusJob } from './submitBlastRadiusJob' -const { start, createAnalysis } = vi.hoisted(() => ({ +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', () => ({ @@ -18,11 +19,13 @@ vi.mock('@/db/packagesDb', () => ({ 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 @@ -129,4 +132,24 @@ describe('submitBlastRadiusJob', () => { 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 31e0e60da8..d92ace7b75 100644 --- a/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts +++ b/backend/src/api/public/v1/packages/submitBlastRadiusJob.ts @@ -38,20 +38,39 @@ export async function submitBlastRadiusJob(req: Request, res: Response): Promise // 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/services/apps/packages_worker/src/blast-radius/agent/runner.ts b/services/apps/packages_worker/src/blast-radius/agent/runner.ts index 220551d0e5..79aee62ee9 100644 --- a/services/apps/packages_worker/src/blast-radius/agent/runner.ts +++ b/services/apps/packages_worker/src/blast-radius/agent/runner.ts @@ -58,8 +58,11 @@ export async function runAnalysisAgent(input: RunAnalysisAgentInput): Promise controller.abort(), FETCH_TIMEOUT_MS) - let res: Response try { - res = await fetch(tarballUrl, { signal: controller.signal }) - } finally { - clearTimeout(timeoutHandle) - } - 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 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 - }, - }) + 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) - }, - }) + 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) - }) + 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 + 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 }) + copyTreeGuarded(srcBase, destDir) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + } catch (err) { + clearTimeout(timeoutHandle) + throw err } } diff --git a/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts index fd3e620321..3937549536 100644 --- a/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts +++ b/services/apps/packages_worker/src/blast-radius/clients/osvClient.ts @@ -13,6 +13,7 @@ export interface OsvAffectedPackage { last_affected?: string }> }> + versions?: string[] } export interface OsvVuln { @@ -51,36 +52,46 @@ export function affectedNpmEntries(vuln: OsvVuln): OsvAffectedPackage[] { // 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 }> { - if (!entry.ranges) return [] - const events: Array<{ introduced: string | null fixed: string | null lastAffected: string | null }> = [] - for (const range of entry.ranges) { - if (range.type !== 'SEMVER' || !range.events) continue - let introduced: string | null = 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 + 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 + 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 }) } } + } - // 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 }) } } diff --git a/services/apps/packages_worker/src/blast-radius/dependentsScan.ts b/services/apps/packages_worker/src/blast-radius/dependentsScan.ts index a3d7d4aac9..e9def602f6 100644 --- a/services/apps/packages_worker/src/blast-radius/dependentsScan.ts +++ b/services/apps/packages_worker/src/blast-radius/dependentsScan.ts @@ -237,11 +237,11 @@ export async function scanDependents(input: { // 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 calendar month — + // 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.setUTCMonth(rangeStart.getUTCMonth() - 1) + rangeStart.setUTCDate(rangeStart.getUTCDate() - 30) const isoDate = (d: Date) => d.toISOString().slice(0, 10) const downloads = new Map() diff --git a/services/apps/packages_worker/src/blast-radius/semverRange.ts b/services/apps/packages_worker/src/blast-radius/semverRange.ts index cb30aa1833..fc6f77042d 100644 --- a/services/apps/packages_worker/src/blast-radius/semverRange.ts +++ b/services/apps/packages_worker/src/blast-radius/semverRange.ts @@ -27,7 +27,7 @@ export function versionsInRanges(versions: string[], ranges: SemverRange[]): str rangeSpec = `>=${range.introduced}` } - if (rangeSpec && semver.satisfies(v, rangeSpec, { loose: true })) { + if (rangeSpec && semver.satisfies(v, rangeSpec, { loose: true, includePrerelease: true })) { result.push(v) break } @@ -63,7 +63,7 @@ export function rangeIncludesAny( } for (const vuln of vulnerableVersions) { - if (semver.satisfies(vuln, declaredRange, { loose: true })) { + if (semver.satisfies(vuln, declaredRange, { loose: true, includePrerelease: true })) { return { includes: true, check: 'matched' } } } @@ -75,7 +75,7 @@ export function rangeIncludesAny( 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 }) || null + return semver.maxSatisfying(valid, '*', { loose: true, includePrerelease: true }) || null } // Helper: check if a range spec is parseable as semver. semver.validRange returns null diff --git a/services/libs/data-access-layer/src/packages/blastRadius.ts b/services/libs/data-access-layer/src/packages/blastRadius.ts index 1fb2362b34..e76840ecca 100644 --- a/services/libs/data-access-layer/src/packages/blastRadius.ts +++ b/services/libs/data-access-layer/src/packages/blastRadius.ts @@ -34,7 +34,7 @@ export interface SymbolSpecInput { export interface DependentInput { analysisId: string - packageId: number | null + packageId: string | null name: string version: string | null downloads: number | null diff --git a/services/libs/data-access-layer/src/packages/osv.ts b/services/libs/data-access-layer/src/packages/osv.ts index 4aa04f241a..b59b37704f 100644 --- a/services/libs/data-access-layer/src/packages/osv.ts +++ b/services/libs/data-access-layer/src/packages/osv.ts @@ -122,13 +122,15 @@ export async function findPackageId( // 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> { +): Promise> { if (names.length === 0) return new Map() - const rows: Array<{ id: number; name: string }> = await qx.select( + const rows: Array<{ id: string; name: string }> = await qx.select( ` SELECT p.id, u.name FROM packages p @@ -138,7 +140,7 @@ export async function findPackageIdsByName( `, { ecosystem, names }, ) - return new Map(rows.map((r) => [r.name, Number(r.id)])) + return new Map(rows.map((r) => [r.name, r.id])) } export async function upsertAdvisoryPackage( From a406ad8514ff8c2754a49a0bf849cffadf4e2c86 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 22 Jul 2026 17:23:07 +0200 Subject: [PATCH 6/9] fix: use lite llm Signed-off-by: Umberto Sgueglia --- backend/.env.dist.composed | 1 + backend/src/api/public/v1/akrites-external/index.ts | 2 +- backend/src/api/public/v1/index.ts | 3 ++- .../packages_worker/src/blast-radius/agent/runner.ts | 12 ++++++++++-- 4 files changed, 14 insertions(+), 4 deletions(-) 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 07bed2dd1b..4bee6cb9f3 100644 --- a/backend/src/api/public/v1/akrites-external/index.ts +++ b/backend/src/api/public/v1/akrites-external/index.ts @@ -73,7 +73,7 @@ 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(requireScopes([SCOPES.READ_PACKAGES])) + // blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES])) blastRadiusSubRouter.post('/jobs', blastRadiusRateLimiter, safeWrap(submitBlastRadiusJob)) blastRadiusSubRouter.get('/jobs/:analysisId', rateLimiter, safeWrap(getBlastRadiusJob)) router.use('/blast-radius', blastRadiusSubRouter) diff --git a/backend/src/api/public/v1/index.ts b/backend/src/api/public/v1/index.ts index 55e187fe69..8bd5d4658f 100644 --- a/backend/src/api/public/v1/index.ts +++ b/backend/src/api/public/v1/index.ts @@ -43,7 +43,8 @@ export function v1Router(): Router { router.use('/ossprey', oauth2Middleware(AUTH0_CONFIG), osspreyRouter()) router.use('/akrites', oauth2Middleware(AUTH0_CONFIG), akritesRouter()) - router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter()) + router.use('/akrites-external', akritesExternalRouter()) + // router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter()) router.use(() => { throw new NotFoundError() diff --git a/services/apps/packages_worker/src/blast-radius/agent/runner.ts b/services/apps/packages_worker/src/blast-radius/agent/runner.ts index 79aee62ee9..8756c0dfb0 100644 --- a/services/apps/packages_worker/src/blast-radius/agent/runner.ts +++ b/services/apps/packages_worker/src/blast-radius/agent/runner.ts @@ -39,9 +39,17 @@ export async function runAnalysisAgent(input: RunAnalysisAgentInput): Promise Date: Wed, 22 Jul 2026 18:09:12 +0200 Subject: [PATCH 7/9] fix: re add the token check Signed-off-by: Umberto Sgueglia --- .../api/public/v1/akrites-external/index.ts | 2 +- backend/src/api/public/v1/index.ts | 3 +-- .../public/v1/packages/blastRadiusAnalysis.ts | 9 ++++++--- .../src/blast-radius/clients/npmTarball.ts | 3 +-- .../src/blast-radius/stages/intel.ts | 2 +- .../src/blast-radius/stages/reachability.ts | 19 +++++++++++++++---- .../src/packages/blastRadius.ts | 9 +++++---- 7 files changed, 30 insertions(+), 17 deletions(-) diff --git a/backend/src/api/public/v1/akrites-external/index.ts b/backend/src/api/public/v1/akrites-external/index.ts index 4bee6cb9f3..07bed2dd1b 100644 --- a/backend/src/api/public/v1/akrites-external/index.ts +++ b/backend/src/api/public/v1/akrites-external/index.ts @@ -73,7 +73,7 @@ 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(requireScopes([SCOPES.READ_PACKAGES])) + blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES])) blastRadiusSubRouter.post('/jobs', blastRadiusRateLimiter, safeWrap(submitBlastRadiusJob)) blastRadiusSubRouter.get('/jobs/:analysisId', rateLimiter, safeWrap(getBlastRadiusJob)) router.use('/blast-radius', blastRadiusSubRouter) diff --git a/backend/src/api/public/v1/index.ts b/backend/src/api/public/v1/index.ts index 8bd5d4658f..55e187fe69 100644 --- a/backend/src/api/public/v1/index.ts +++ b/backend/src/api/public/v1/index.ts @@ -43,8 +43,7 @@ export function v1Router(): Router { router.use('/ossprey', oauth2Middleware(AUTH0_CONFIG), osspreyRouter()) router.use('/akrites', oauth2Middleware(AUTH0_CONFIG), akritesRouter()) - router.use('/akrites-external', akritesExternalRouter()) - // router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter()) + router.use('/akrites-external', oauth2Middleware(AUTH0_CONFIG), akritesExternalRouter()) router.use(() => { throw new NotFoundError() diff --git a/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts index d6a5aa8188..9cfe73b04f 100644 --- a/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts +++ b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts @@ -99,13 +99,16 @@ function toResultItem(row: VerdictResultRow): BlastRadiusResultItem { // 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 (null when nothing was analyzed, to avoid a misleading 0%). +// 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, @@ -113,8 +116,8 @@ function toSummary( dependentsAnalyzed, dependentsAffected: affected.length, affectedPercentage: - dependentsAnalyzed > 0 - ? Math.round((affected.length / dependentsAnalyzed) * 1000) / 10 + conclusive.length > 0 + ? Math.round((affected.length / conclusive.length) * 1000) / 10 : null, affectedDependents: affected.map((r) => r.dependent), } diff --git a/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts b/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts index e9451c35c7..80f302cee0 100644 --- a/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts +++ b/services/apps/packages_worker/src/blast-radius/clients/npmTarball.ts @@ -91,9 +91,8 @@ export async function downloadAndExtractTarball( } finally { fs.rmSync(tempDir, { recursive: true, force: true }) } - } catch (err) { + } finally { clearTimeout(timeoutHandle) - 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 index 8754d1e873..80eaeee9fc 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/intel.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/intel.ts @@ -111,7 +111,7 @@ export async function runIntelStage( for (const url of patchUrls.slice(0, 3)) { try { const patchText = await fetchPatch(url) - const slug = url.split('/').slice(-2).join('-') + const slug = new URL(url).pathname.split('/').filter(Boolean).join('-') patches[slug] = patchText } catch { // Ignore patch fetch errors diff --git a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts index afd03471d8..01e220f2e7 100644 --- a/services/apps/packages_worker/src/blast-radius/stages/reachability.ts +++ b/services/apps/packages_worker/src/blast-radius/stages/reachability.ts @@ -72,7 +72,12 @@ export async function runReachabilityStage( const concurrency = 4 const queue = [...dependents] - const upsertErrorVerdict = (dependentId: string, reasoning: string, model: string | null) => + const upsertErrorVerdict = ( + dependentId: string, + reasoning: string, + model: string | null, + costUsd = 0, + ) => blastRadiusDal.upsertVerdict(qx, { analysisId, dependentId, @@ -85,7 +90,7 @@ export async function runReachabilityStage( reasoning, model, turnsUsed: null, - costUsd: 0, + costUsd, }) const processOne = async (dep: blastRadiusDal.DependentRow): Promise => { @@ -114,7 +119,11 @@ export async function runReachabilityStage( return } - // Try agent up to MAX_ATTEMPTS times with exponential backoff + // 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) @@ -129,6 +138,7 @@ export async function runReachabilityStage( timeoutMs: 600_000, onProgress, }) + accumulatedCost += agentResult.costUsd || 0 if (!agentResult.isError && agentResult.structuredOutput) { const output = agentResult.structuredOutput @@ -145,7 +155,7 @@ export async function runReachabilityStage( reasoning: String(output.reasoning || ''), model: 'claude-sonnet-5', turnsUsed: agentResult.numTurns, - costUsd: agentResult.costUsd || 0, + costUsd: accumulatedCost, }) return @@ -161,6 +171,7 @@ export async function runReachabilityStage( dep.id, `Agent failed: ${err instanceof Error ? err.message : String(err)}`, 'claude-sonnet-5', + accumulatedCost, ) return } diff --git a/services/libs/data-access-layer/src/packages/blastRadius.ts b/services/libs/data-access-layer/src/packages/blastRadius.ts index e76840ecca..f71cf36f92 100644 --- a/services/libs/data-access-layer/src/packages/blastRadius.ts +++ b/services/libs/data-access-layer/src/packages/blastRadius.ts @@ -140,7 +140,7 @@ export async function markAnalysisRunning(qx: QueryExecutor, analysisId: string) await qx.result( ` UPDATE blast_radius_analyses - SET status = 'running', started_at = NOW(), updated_at = NOW() + SET status = 'running', updated_at = NOW() WHERE id = $(analysisId) AND status = 'pending' `, { analysisId }, @@ -203,12 +203,13 @@ export async function resolveAdvisoryAndPackageIds( ` UPDATE blast_radius_analyses bra SET - advisory_id = COALESCE(a.id, bra.advisory_id), + advisory_id = COALESCE( + (SELECT id FROM advisories WHERE osv_id = $(advisoryOsvId)), + bra.advisory_id + ), package_id = COALESCE($(packageId), bra.package_id), updated_at = NOW() - FROM advisories a WHERE bra.id = $(analysisId) - AND a.osv_id = $(advisoryOsvId) `, { analysisId, advisoryOsvId, packageId }, ) From 28aea9ff765076b2f1ffe572638ec3e8ca4ccde4 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Wed, 22 Jul 2026 18:16:17 +0200 Subject: [PATCH 8/9] fix: lint Signed-off-by: Umberto Sgueglia --- backend/src/api/public/v1/packages/blastRadiusAnalysis.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts index 9cfe73b04f..6bddb7a3e6 100644 --- a/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts +++ b/backend/src/api/public/v1/packages/blastRadiusAnalysis.ts @@ -116,9 +116,7 @@ function toSummary( dependentsAnalyzed, dependentsAffected: affected.length, affectedPercentage: - conclusive.length > 0 - ? Math.round((affected.length / conclusive.length) * 1000) / 10 - : null, + conclusive.length > 0 ? Math.round((affected.length / conclusive.length) * 1000) / 10 : null, affectedDependents: affected.map((r) => r.dependent), } } From 00de0ed5f9e067ccbb37c597c0f0b2f7d6ebd19e Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 23 Jul 2026 10:15:13 +0200 Subject: [PATCH 9/9] fix: refactor code and tests Signed-off-by: Umberto Sgueglia --- .../{ => __tests__}/packageIdentifier.test.ts | 2 +- .../blast-radius/{ => __tests__}/semverRange.test.ts | 2 +- .../packages_worker/src/blast-radius/activities.ts | 12 +++++------- 3 files changed, 7 insertions(+), 9 deletions(-) rename services/apps/packages_worker/src/blast-radius/{ => __tests__}/packageIdentifier.test.ts (94%) rename services/apps/packages_worker/src/blast-radius/{ => __tests__}/semverRange.test.ts (99%) diff --git a/services/apps/packages_worker/src/blast-radius/packageIdentifier.test.ts b/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts similarity index 94% rename from services/apps/packages_worker/src/blast-radius/packageIdentifier.test.ts rename to services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts index 6f36307932..efa480489a 100644 --- a/services/apps/packages_worker/src/blast-radius/packageIdentifier.test.ts +++ b/services/apps/packages_worker/src/blast-radius/__tests__/packageIdentifier.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { toBareNpmName } from './packageIdentifier' +import { toBareNpmName } from '../packageIdentifier' describe('toBareNpmName', () => { it('returns a bare name unchanged', () => { diff --git a/services/apps/packages_worker/src/blast-radius/semverRange.test.ts b/services/apps/packages_worker/src/blast-radius/__tests__/semverRange.test.ts similarity index 99% rename from services/apps/packages_worker/src/blast-radius/semverRange.test.ts rename to services/apps/packages_worker/src/blast-radius/__tests__/semverRange.test.ts index d5f702b9de..d7fa06e5c0 100644 --- a/services/apps/packages_worker/src/blast-radius/semverRange.test.ts +++ b/services/apps/packages_worker/src/blast-radius/__tests__/semverRange.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { highestVersion, rangeIncludesAny, versionsInRanges } from './semverRange' +import { highestVersion, rangeIncludesAny, versionsInRanges } from '../semverRange' describe('semverRange', () => { describe('versionsInRanges', () => { diff --git a/services/apps/packages_worker/src/blast-radius/activities.ts b/services/apps/packages_worker/src/blast-radius/activities.ts index 7ce6a28fef..02525cb656 100644 --- a/services/apps/packages_worker/src/blast-radius/activities.ts +++ b/services/apps/packages_worker/src/blast-radius/activities.ts @@ -1,4 +1,4 @@ -import { Context } from '@temporalio/activity' +import { heartbeat } from '@temporalio/activity' import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius' import { getServiceChildLogger } from '@crowd/logging' @@ -67,9 +67,7 @@ export async function blastRadiusFail( 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, () => - Context.current().heartbeat(), - ) + await runIntelStage(qx, input.analysisId, input.advisoryOsvId, heartbeat) log.info({ analysisId: input.analysisId }, 'blast-radius: intel stage done') } @@ -77,7 +75,7 @@ export async function blastRadiusIntel(input: BlastRadiusActivityInput): Promise 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, () => Context.current().heartbeat()) + await runDependentsStage(qx, input.analysisId, heartbeat) log.info({ analysisId: input.analysisId }, 'blast-radius: dependents stage done') } @@ -85,7 +83,7 @@ export async function blastRadiusDependents(input: BlastRadiusActivityInput): Pr 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, () => Context.current().heartbeat()) + await runReachabilityStage(qx, input.analysisId, heartbeat) log.info({ analysisId: input.analysisId }, 'blast-radius: reachability stage done') } @@ -94,6 +92,6 @@ export async function blastRadiusReport(input: BlastRadiusActivityInput): Promis log.info({ analysisId: input.analysisId }, 'blast-radius: report stage starting') const qx = await getPackagesDb() await runReportStage(qx, input.analysisId) - Context.current().heartbeat() + heartbeat() log.info({ analysisId: input.analysisId }, 'blast-radius: report stage done') }