Skip to content

Commit b3c8812

Browse files
committed
feat: first version of blast-radius workflow
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent b2dd456 commit b3c8812

19 files changed

Lines changed: 2545 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type {
2+
AnalysisDetailRow,
3+
VerdictResultRow,
4+
} from '@crowd/data-access-layer/src/packages/blastRadius'
5+
6+
import type { BlastRadiusJobEcosystem, BlastRadiusJobStatus } from './blastRadius'
7+
8+
export type BlastRadiusResultConfidence = 'high' | 'medium' | 'low'
9+
10+
export interface BlastRadiusResultItem {
11+
dependent: string
12+
affected: boolean
13+
confidence: BlastRadiusResultConfidence
14+
evidence: string | null
15+
downloadsLast30Days: string | null
16+
}
17+
18+
export interface BlastRadiusAnalysisSummary {
19+
totalDependentsInRange: number
20+
dependentsExcludedUpfront: number
21+
dependentsAnalyzed: number
22+
dependentsAffected: number
23+
affectedPercentage: number | null
24+
affectedDependents: string[]
25+
}
26+
27+
export interface BlastRadiusAnalysis {
28+
analysisId: string
29+
status: BlastRadiusJobStatus
30+
advisoryId: string
31+
package: string | null
32+
ecosystem: BlastRadiusJobEcosystem
33+
submittedAt: string | null
34+
completedAt: string | null
35+
errorMessage: string | null
36+
summary: BlastRadiusAnalysisSummary | null
37+
results: BlastRadiusResultItem[] | null
38+
}
39+
40+
// Crosswalk from the DB's NUMERIC(3,2) 0-1 confidence score to the contract's
41+
// enum — matches the PoC methodology's confidence bands (report.py).
42+
function toResultConfidence(confidence: number): BlastRadiusResultConfidence {
43+
if (confidence >= 0.8) return 'high'
44+
if (confidence >= 0.4) return 'medium'
45+
return 'low'
46+
}
47+
48+
// npm purls use a literal (unencoded) `@` for the scope, matching the contract's
49+
// example response bodies — these are JSON fields, not URL query params, so the
50+
// %40-encoding normalizePurl applies elsewhere in this file group does not apply here.
51+
function toPurl(name: string): string {
52+
return `pkg:npm/${name}`
53+
}
54+
55+
function flattenEvidence(evidence: Record<string, unknown>[] | null): string | null {
56+
if (!evidence || evidence.length === 0) return null
57+
return evidence
58+
.map((e) => {
59+
const file = e.file ? String(e.file) : null
60+
const line = e.line !== undefined && e.line !== null ? String(e.line) : null
61+
const snippet = e.snippet ? String(e.snippet) : null
62+
const location = [file, line].filter(Boolean).join(':')
63+
return [location, snippet].filter(Boolean).join(' — ')
64+
})
65+
.filter(Boolean)
66+
.join('\n')
67+
}
68+
69+
function toResultItem(row: VerdictResultRow): BlastRadiusResultItem {
70+
return {
71+
dependent: toPurl(row.name),
72+
affected: row.reachable_verdict === 'affected',
73+
confidence: toResultConfidence(row.confidence),
74+
evidence: flattenEvidence(row.evidence),
75+
downloadsLast30Days: row.downloads !== null ? String(row.downloads) : null,
76+
}
77+
}
78+
79+
// Population-level summary, following the PoC's report.py ground truth:
80+
// totalDependentsInRange = candidates_considered (post phase-1-filter population),
81+
// dependentsAnalyzed = number of verdicts produced, dependentsExcludedUpfront =
82+
// the difference (range-excluded before reachability ran), dependentsAffected =
83+
// count with an 'affected' verdict, affectedPercentage rounded to 1 decimal
84+
// (null when nothing was analyzed, to avoid a misleading 0%).
85+
function toSummary(
86+
candidatesConsidered: number | null,
87+
results: BlastRadiusResultItem[],
88+
): BlastRadiusAnalysisSummary {
89+
const totalDependentsInRange = candidatesConsidered ?? results.length
90+
const dependentsAnalyzed = results.length
91+
const affected = results.filter((r) => r.affected)
92+
93+
return {
94+
totalDependentsInRange,
95+
dependentsExcludedUpfront: Math.max(totalDependentsInRange - dependentsAnalyzed, 0),
96+
dependentsAnalyzed,
97+
dependentsAffected: affected.length,
98+
affectedPercentage:
99+
dependentsAnalyzed > 0
100+
? Math.round((affected.length / dependentsAnalyzed) * 1000) / 10
101+
: null,
102+
affectedDependents: affected.map((r) => r.dependent),
103+
}
104+
}
105+
106+
export function toBlastRadiusAnalysis(
107+
analysis: AnalysisDetailRow,
108+
verdictRows: VerdictResultRow[],
109+
): BlastRadiusAnalysis {
110+
const status = analysis.status as BlastRadiusJobStatus
111+
const done = status === 'done'
112+
113+
const results = done ? verdictRows.map(toResultItem) : null
114+
115+
return {
116+
analysisId: analysis.id,
117+
status,
118+
advisoryId: analysis.advisory_osv_id,
119+
package: analysis.package_name,
120+
ecosystem: analysis.ecosystem as BlastRadiusJobEcosystem,
121+
submittedAt: analysis.started_at,
122+
completedAt: analysis.completed_at,
123+
errorMessage: analysis.error,
124+
summary: done && results ? toSummary(analysis.candidates_considered, results) : null,
125+
results,
126+
}
127+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { Request, Response } from 'express'
2+
import { describe, expect, it, vi } from 'vitest'
3+
4+
import { getBlastRadiusJob } from './getBlastRadiusJob'
5+
6+
const { getAnalysisDetail, getVerdictResults } = vi.hoisted(() => ({
7+
getAnalysisDetail: vi.fn(),
8+
getVerdictResults: vi.fn(),
9+
}))
10+
11+
vi.mock('@crowd/data-access-layer/src/packages/blastRadius', () => ({
12+
getAnalysisDetail,
13+
getVerdictResults,
14+
}))
15+
16+
vi.mock('@/db/packagesDb', () => ({
17+
getPackagesQx: vi.fn().mockResolvedValue({}),
18+
}))
19+
20+
const ANALYSIS_ID = '11111111-1111-1111-1111-111111111111'
21+
22+
function mockReqRes(params: unknown) {
23+
getAnalysisDetail.mockClear()
24+
getVerdictResults.mockClear()
25+
26+
const req = { params } as unknown as Request
27+
28+
const json = vi.fn()
29+
const status = vi.fn().mockReturnValue({ json })
30+
const res = { status, json } as unknown as Response
31+
32+
return { req, res, status, json }
33+
}
34+
35+
describe('getBlastRadiusJob', () => {
36+
it('returns a pending analysis with no results/summary', async () => {
37+
getAnalysisDetail.mockResolvedValue({
38+
id: ANALYSIS_ID,
39+
advisory_osv_id: 'GHSA-jf85-cpcp-j695',
40+
package_name: null,
41+
ecosystem: 'npm',
42+
status: 'pending',
43+
error: null,
44+
candidates_considered: null,
45+
started_at: '2026-07-01T00:00:00.000Z',
46+
completed_at: null,
47+
})
48+
49+
const { req, res, json } = mockReqRes({ analysisId: ANALYSIS_ID })
50+
51+
await getBlastRadiusJob(req, res)
52+
53+
expect(getVerdictResults).not.toHaveBeenCalled()
54+
expect(json).toHaveBeenCalledWith(
55+
expect.objectContaining({
56+
analysisId: ANALYSIS_ID,
57+
status: 'pending',
58+
summary: null,
59+
results: null,
60+
}),
61+
)
62+
})
63+
64+
it('returns summary and results for a done analysis', async () => {
65+
getAnalysisDetail.mockResolvedValue({
66+
id: ANALYSIS_ID,
67+
advisory_osv_id: 'GHSA-jf85-cpcp-j695',
68+
package_name: 'lodash',
69+
ecosystem: 'npm',
70+
status: 'done',
71+
error: null,
72+
candidates_considered: 10,
73+
started_at: '2026-07-01T00:00:00.000Z',
74+
completed_at: '2026-07-01T01:00:00.000Z',
75+
})
76+
getVerdictResults.mockResolvedValue([
77+
{
78+
name: 'benchmark.js',
79+
version: '2.1.4',
80+
downloads: 500000,
81+
reachable_verdict: 'affected',
82+
confidence: 0.9,
83+
evidence: [{ file: 'index.js', line: 10, snippet: 'require("lodash").merge' }],
84+
reasoning: 'uses merge',
85+
},
86+
{
87+
name: 'other-pkg',
88+
version: '1.0.0',
89+
downloads: 100,
90+
reachable_verdict: 'not_affected',
91+
confidence: 0.5,
92+
evidence: null,
93+
reasoning: 'unused',
94+
},
95+
])
96+
97+
const { req, res, json } = mockReqRes({ analysisId: ANALYSIS_ID })
98+
99+
await getBlastRadiusJob(req, res)
100+
101+
expect(json).toHaveBeenCalledWith(
102+
expect.objectContaining({
103+
status: 'done',
104+
summary: expect.objectContaining({
105+
totalDependentsInRange: 10,
106+
dependentsAnalyzed: 2,
107+
dependentsExcludedUpfront: 8,
108+
dependentsAffected: 1,
109+
affectedPercentage: 50,
110+
affectedDependents: ['pkg:npm/benchmark.js'],
111+
}),
112+
results: [
113+
expect.objectContaining({
114+
dependent: 'pkg:npm/benchmark.js',
115+
affected: true,
116+
confidence: 'high',
117+
}),
118+
expect.objectContaining({
119+
dependent: 'pkg:npm/other-pkg',
120+
affected: false,
121+
confidence: 'medium',
122+
}),
123+
],
124+
}),
125+
)
126+
})
127+
128+
it('404s when the analysis does not exist', async () => {
129+
getAnalysisDetail.mockResolvedValue(null)
130+
131+
const { req, res } = mockReqRes({ analysisId: ANALYSIS_ID })
132+
133+
await expect(getBlastRadiusJob(req, res)).rejects.toThrow()
134+
})
135+
136+
it('rejects a non-uuid analysisId', async () => {
137+
const { req, res } = mockReqRes({ analysisId: 'not-a-uuid' })
138+
139+
await expect(getBlastRadiusJob(req, res)).rejects.toThrow()
140+
expect(getAnalysisDetail).not.toHaveBeenCalled()
141+
})
142+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { NotFoundError } from '@crowd/common'
5+
import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius'
6+
7+
import { getPackagesQx } from '@/db/packagesDb'
8+
import { ok } from '@/utils/api'
9+
import { validateOrThrow } from '@/utils/validation'
10+
11+
import { toBlastRadiusAnalysis } from './blastRadiusAnalysis'
12+
13+
const paramsSchema = z.object({
14+
analysisId: z.uuid(),
15+
})
16+
17+
// 2b — poll a blast-radius analysis job. results/summary are only populated once
18+
// status is 'done' — see toBlastRadiusAnalysis.
19+
export async function getBlastRadiusJob(req: Request, res: Response): Promise<void> {
20+
const { analysisId } = validateOrThrow(paramsSchema, req.params)
21+
22+
const qx = await getPackagesQx()
23+
const analysis = await blastRadiusDal.getAnalysisDetail(qx, analysisId)
24+
if (!analysis) {
25+
throw new NotFoundError()
26+
}
27+
28+
const verdictRows =
29+
analysis.status === 'done' ? await blastRadiusDal.getVerdictResults(qx, analysisId) : []
30+
31+
ok(res, toBlastRadiusAnalysis(analysis, verdictRows))
32+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Context } from '@temporalio/activity'
2+
3+
import * as blastRadiusDal from '@crowd/data-access-layer/src/packages/blastRadius'
4+
import { getServiceChildLogger } from '@crowd/logging'
5+
6+
import { getPackagesDb } from '../db'
7+
8+
import { runDependentsStage } from './stages/dependents'
9+
import { runIntelStage } from './stages/intel'
10+
import { runReachabilityStage } from './stages/reachability'
11+
import { runReportStage } from './stages/report'
12+
13+
const log = getServiceChildLogger('blast-radius')
14+
15+
export interface BlastRadiusActivityInput {
16+
analysisId: string
17+
advisoryOsvId: string
18+
}
19+
20+
export interface BlastRadiusStartInput {
21+
analysisId: string
22+
advisoryOsvId: string
23+
packageName: string | null
24+
ecosystem: string
25+
force: boolean
26+
}
27+
28+
// Activity: create (or reuse, on retrigger) the analysis row and mark it running.
29+
// All DB access for the workflow lives in activities — Temporal workflow code
30+
// must stay deterministic and cannot talk to the database directly.
31+
export async function blastRadiusStart(input: BlastRadiusStartInput): Promise<void> {
32+
log.info({ analysisId: input.analysisId }, 'blast-radius: starting analysis')
33+
const qx = await getPackagesDb()
34+
await blastRadiusDal.createAnalysis(qx, {
35+
id: input.analysisId,
36+
advisoryOsvId: input.advisoryOsvId,
37+
packageName: input.packageName,
38+
ecosystem: input.ecosystem,
39+
force: input.force,
40+
})
41+
await blastRadiusDal.markAnalysisRunning(qx, input.analysisId)
42+
log.info({ analysisId: input.analysisId }, 'blast-radius: analysis marked running')
43+
}
44+
45+
// Activity: mark the analysis row failed with the given error message.
46+
export async function blastRadiusFail(input: { analysisId: string; error: string }): Promise<void> {
47+
log.warn({ analysisId: input.analysisId, error: input.error }, 'blast-radius: analysis failed')
48+
const qx = await getPackagesDb()
49+
await blastRadiusDal.failAnalysis(qx, input.analysisId, input.error)
50+
}
51+
52+
// Activity: Stage 1 — vulnerability intelligence
53+
export async function blastRadiusIntel(input: BlastRadiusActivityInput): Promise<void> {
54+
log.info({ analysisId: input.analysisId }, 'blast-radius: intel stage starting')
55+
const qx = await getPackagesDb()
56+
await runIntelStage(qx, input.analysisId, input.advisoryOsvId)
57+
Context.current().heartbeat()
58+
log.info({ analysisId: input.analysisId }, 'blast-radius: intel stage done')
59+
}
60+
61+
// Activity: Stage 2 — dependents scan
62+
export async function blastRadiusDependents(input: BlastRadiusActivityInput): Promise<void> {
63+
log.info({ analysisId: input.analysisId }, 'blast-radius: dependents stage starting')
64+
const qx = await getPackagesDb()
65+
await runDependentsStage(qx, input.analysisId, () => Context.current().heartbeat())
66+
log.info({ analysisId: input.analysisId }, 'blast-radius: dependents stage done')
67+
}
68+
69+
// Activity: Stage 3 — reachability analysis
70+
export async function blastRadiusReachability(input: BlastRadiusActivityInput): Promise<void> {
71+
log.info({ analysisId: input.analysisId }, 'blast-radius: reachability stage starting')
72+
const qx = await getPackagesDb()
73+
await runReachabilityStage(qx, input.analysisId, () => Context.current().heartbeat())
74+
log.info({ analysisId: input.analysisId }, 'blast-radius: reachability stage done')
75+
}
76+
77+
// Activity: Stage 4 — report aggregation
78+
export async function blastRadiusReport(input: BlastRadiusActivityInput): Promise<void> {
79+
log.info({ analysisId: input.analysisId }, 'blast-radius: report stage starting')
80+
const qx = await getPackagesDb()
81+
await runReportStage(qx, input.analysisId)
82+
Context.current().heartbeat()
83+
log.info({ analysisId: input.analysisId }, 'blast-radius: report stage done')
84+
}

0 commit comments

Comments
 (0)