Skip to content

Commit f0c4b78

Browse files
authored
feat: Add output and limit to vulnerability-analyze (#59)
1 parent 08b966e commit f0c4b78

File tree

11 files changed

+343
-97
lines changed

11 files changed

+343
-97
lines changed

nodes/DependencyAnalytics/Actions.spec.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ describe('Tests for actions/vulnerability.ts', () => {
342342
const parsedPurls = ['pkg:npm/[email protected]'];
343343
(parsePurls as jest.Mock).mockReturnValue(parsedPurls);
344344

345-
const fakeResult = { vulnerabilities: [{ id: 'CVE-2024-5678' }] };
345+
const fakeResult = { advisories: [] };
346346
(authedRequest as jest.Mock).mockResolvedValue(fakeResult);
347347

348348
const result = await vulnerability.analyze({ ctx, itemIndex: 0 });
@@ -415,13 +415,11 @@ describe('Tests for actions/vulnerability.ts', () => {
415415

416416
const sbomResponse = { id: 'sbom-123' };
417417
const advisoryResponse = [{ id: 'ADV-1' }];
418-
419418
(authedRequest as jest.Mock)
420419
.mockResolvedValueOnce(sbomResponse)
421420
.mockResolvedValueOnce(advisoryResponse);
422421

423422
const result = await vulnerability.analyze({ ctx, itemIndex: 0 });
424-
425423
expect(authedRequest).toHaveBeenNthCalledWith(
426424
1,
427425
ctx,
@@ -445,7 +443,7 @@ describe('Tests for actions/vulnerability.ts', () => {
445443
{
446444
json: {
447445
sbomId: 'sbom-123',
448-
advisories: advisoryResponse,
446+
advisories: [{ shaped: { id: 'ADV-1' } }],
449447
},
450448
},
451449
]);

nodes/DependencyAnalytics/DependencyAnalytics.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,16 @@ test('It contains all expected displayNames in properties', () => {
2828
'Operation',
2929
'Sorting',
3030
'Selected Fields',
31+
'Selected Fields (Advisory)',
3132
'Operation',
3233
'Input Type',
3334
'PURLs',
3435
'SBOM SHA-256',
3536
'Sorting',
3637
'Selected Fields',
3738
'Operation',
39+
'Sorting',
3840
];
39-
4041
expect(displayNames).toEqual(expectedDisplayNames);
4142
});
4243

nodes/DependencyAnalytics/Utils.spec.ts

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ describe('Tests for simplify.ts', () => {
228228
severity: 'high',
229229
score: 9.1,
230230
cwe: 'CWE-79',
231-
advisories: 2,
231+
advisories: [{}, {}],
232232
reserved: false,
233233
withdrawn: false,
234234
});
@@ -244,7 +244,7 @@ describe('Tests for simplify.ts', () => {
244244
severity: null,
245245
score: null,
246246
cwe: null,
247-
advisories: 0,
247+
advisories: [],
248248
reserved: null,
249249
withdrawn: null,
250250
});
@@ -265,32 +265,30 @@ describe('Tests for simplify.ts', () => {
265265
};
266266
const result = simplifyAdvisory(item);
267267
expect(result).toEqual({
268-
documentId: 'doc-1',
268+
uuid: null,
269269
identifier: 'ADV-001',
270+
document_id: 'doc-1',
270271
title: 'Advisory title',
271-
issuer: 'SecurityTeam',
272-
published: '2024-01-01',
273-
modified: '2024-01-02',
274-
severity: 'medium',
275-
score: 5.4,
272+
sha256: null,
276273
size: 1234,
277-
ingested: '2024-02-01',
274+
average_severity: 'medium',
275+
average_score: 5.4,
276+
vulnerabilities: [],
278277
});
279278
});
280279

281280
test('It should handle missing issuer and optional fields', () => {
282281
const result = simplifyAdvisory({ identifier: 'ADV-002' });
283282
expect(result).toMatchObject({
284-
documentId: null,
283+
uuid: null,
285284
identifier: 'ADV-002',
285+
document_id: null,
286286
title: null,
287-
issuer: null,
288-
published: null,
289-
modified: null,
290-
severity: null,
291-
score: null,
287+
sha256: null,
292288
size: null,
293-
ingested: null,
289+
average_severity: null,
290+
average_score: null,
291+
vulnerabilities: [],
294292
});
295293
});
296294

@@ -329,13 +327,11 @@ describe('Tests for simplify.ts', () => {
329327
};
330328

331329
const result = simplifyOne('vulnerability', vulnObj);
332-
333330
expect(result).toHaveProperty('identifier', 'CVE-2024-1234');
334331
expect(result).toHaveProperty('title', 'Test Vulnerability');
335332
expect(result).toHaveProperty('severity', 'critical');
336333
expect(result).toHaveProperty('score', 9.8);
337334
expect(result).toHaveProperty('cwe', 'CWE-79');
338-
expect(result).toHaveProperty('advisories', 3);
339335
});
340336

341337
test('It should call simplifyAdvisory when resource is advisory', () => {
@@ -350,13 +346,11 @@ describe('Tests for simplify.ts', () => {
350346
};
351347

352348
const result = simplifyOne('advisory', advisoryObj);
353-
354-
expect(result).toHaveProperty('documentId', 'doc-123');
349+
expect(result).toHaveProperty('document_id', 'doc-123');
355350
expect(result).toHaveProperty('identifier', 'RHSA-2024-0001');
356351
expect(result).toHaveProperty('title', 'Security Advisory');
357-
expect(result).toHaveProperty('issuer', 'Red Hat');
358-
expect(result).toHaveProperty('severity', 'high');
359-
expect(result).toHaveProperty('score', 7.5);
352+
expect(result).toHaveProperty('average_severity', 'high');
353+
expect(result).toHaveProperty('average_score', 7.5);
360354
expect(result).toHaveProperty('size', 5000);
361355
});
362356

@@ -371,18 +365,18 @@ describe('Tests for simplify.ts', () => {
371365

372366
test('It should handle empty objects for vulnerability resource', () => {
373367
const result = simplifyOne('vulnerability', {});
374-
375368
expect(result).toHaveProperty('identifier', null);
376369
expect(result).toHaveProperty('title', null);
377-
expect(result).toHaveProperty('advisories', 0);
370+
expect(result).toHaveProperty('advisories', []);
378371
});
379372

380373
test('It should handle empty objects for advisory resource', () => {
381374
const result = simplifyOne('advisory', {});
382-
383-
expect(result).toHaveProperty('documentId', null);
375+
expect(result).toHaveProperty('document_id', null);
384376
expect(result).toHaveProperty('identifier', null);
385-
expect(result).toHaveProperty('issuer', null);
377+
expect(result).toHaveProperty('average_severity', null);
378+
expect(result).toHaveProperty('average_score', null);
379+
expect(result).toHaveProperty('vulnerabilities', []);
386380
});
387381
});
388382
});
@@ -447,7 +441,7 @@ describe('Tests for http.ts', () => {
447441
getNodeParameter: jest.fn().mockReturnValue('clientCredentials'),
448442
};
449443
const result = chooseCredential(mockCtx as any, 1);
450-
expect(result).toBe('trustifyClientOAuth2Api');
444+
expect(result).toBe('trustifyClientCredsOAuth2Api');
451445
});
452446

453447
test('It should simulate authedRequest call', async () => {

nodes/DependencyAnalytics/actions/vulnerability.ts

Lines changed: 202 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,148 @@ export async function analyze({ ctx, itemIndex }: { ctx: IExecuteFunctions; item
6969
};
7070

7171
try {
72-
const res = await authedRequest(ctx, credentialName, options);
73-
return [{ json: res } as INodeExecutionData];
72+
const res = (await authedRequest(ctx, credentialName, options)) as any;
73+
74+
const rules: SortRule[] = readSortRules(ctx, itemIndex, 'vulnerability');
75+
const mode = ctx.getNodeParameter('outputMode', itemIndex, 'simplified') as
76+
| 'simplified'
77+
| 'raw'
78+
| 'selected';
79+
80+
// Build enriched advisories with vulnerability fields and originating package (purl)
81+
const enrichedAdvisories: any[] = [];
82+
83+
const isPlainObject = (v: any) => v && typeof v === 'object' && !Array.isArray(v);
84+
if (isPlainObject(res)) {
85+
// Prefer the map keyed by PURL shape: { [purl]: { details: [...] } }
86+
for (const [pkgPurl, data] of Object.entries(res as Record<string, any>)) {
87+
// Support both shapes:
88+
// 1) { details: [...] }
89+
// 2) [ { ...vulnerabilityDetail... }, ... ]
90+
const details = Array.isArray(data)
91+
? (data as any[])
92+
: Array.isArray((data as any)?.details)
93+
? (data as any).details
94+
: [];
95+
if (details.length === 0 && Array.isArray((data as any)?.advisories)) {
96+
// Fallback: direct advisories array
97+
for (const adv of (data as any).advisories as any[]) {
98+
enrichedAdvisories.push({ ...adv, package: pkgPurl });
99+
}
100+
}
101+
for (const d of details) {
102+
const affected = (d as any)?.status?.affected;
103+
if (Array.isArray(affected)) {
104+
for (const adv of affected) {
105+
// Merge advisory with vulnerability-level fields and the originating package
106+
enrichedAdvisories.push({
107+
...adv,
108+
package: pkgPurl,
109+
normative: (d as any)?.normative ?? (d as any)?.status?.normative ?? null,
110+
identifier: (d as any)?.identifier ?? adv?.identifier ?? null,
111+
title: (d as any)?.title ?? adv?.title ?? null,
112+
description: (d as any)?.description ?? null,
113+
reserved: (d as any)?.reserved ?? null,
114+
published: (d as any)?.published ?? adv?.published ?? null,
115+
modified: (d as any)?.modified ?? adv?.modified ?? null,
116+
withdrawn: (d as any)?.withdrawn ?? adv?.withdrawn ?? null,
117+
discovered: (d as any)?.discovered ?? null,
118+
released: (d as any)?.released ?? null,
119+
cwes: (d as any)?.cwes ?? adv?.cwes ?? null,
120+
status: (d as any)?.status ?? null,
121+
});
122+
}
123+
}
124+
}
125+
}
126+
} else {
127+
// Fallback to previous generic flattening while trying to retain package
128+
const buckets: any[] = Array.isArray(res)
129+
? res
130+
: Array.isArray(res?.items)
131+
? res.items
132+
: Array.isArray(res?.vulnerabilities)
133+
? res.vulnerabilities
134+
: [res];
135+
136+
for (const bucket of buckets) {
137+
if (bucket && typeof bucket === 'object') {
138+
if (Array.isArray((bucket as any).advisories)) {
139+
for (const adv of (bucket as any).advisories as any[]) {
140+
const pkg = (adv as any)?.packages?.[0]?.purl ?? null;
141+
enrichedAdvisories.push({ ...adv, package: pkg });
142+
}
143+
continue;
144+
}
145+
const candidates: any[] = [];
146+
if (Array.isArray((bucket as any).details)) candidates.push(bucket);
147+
for (const v of Object.values(bucket)) {
148+
if (v && typeof v === 'object') candidates.push(v);
149+
}
150+
for (const cand of candidates) {
151+
const details = Array.isArray((cand as any)?.details) ? (cand as any).details : [];
152+
for (const d of details) {
153+
const affected = (d as any)?.status?.affected;
154+
if (Array.isArray(affected)) {
155+
for (const adv of affected) {
156+
const pkg =
157+
(d as any)?.status?.packages?.[0]?.purl ??
158+
(adv as any)?.packages?.[0]?.purl ??
159+
null;
160+
enrichedAdvisories.push({
161+
...adv,
162+
package: pkg,
163+
normative: (d as any)?.normative ?? (d as any)?.status?.normative ?? null,
164+
identifier: (d as any)?.identifier ?? adv?.identifier ?? null,
165+
title: (d as any)?.title ?? adv?.title ?? null,
166+
description: (d as any)?.description ?? null,
167+
reserved: (d as any)?.reserved ?? null,
168+
published: (d as any)?.published ?? adv?.published ?? null,
169+
modified: (d as any)?.modified ?? adv?.modified ?? null,
170+
withdrawn: (d as any)?.withdrawn ?? adv?.withdrawn ?? null,
171+
discovered: (d as any)?.discovered ?? null,
172+
released: (d as any)?.released ?? null,
173+
cwes: (d as any)?.cwes ?? adv?.cwes ?? null,
174+
status: (d as any)?.status ?? null,
175+
});
176+
}
177+
}
178+
}
179+
}
180+
}
181+
}
182+
}
183+
184+
let out = enrichedAdvisories;
185+
186+
if (rules.length) out = [...out].sort((a, b) => multiCmp(a, b, rules));
187+
188+
const limit = (ctx.getNodeParameter('limit', itemIndex, 50) as number) || 50;
189+
out = out.slice(0, limit);
190+
191+
// RAW mode: return flattened advisories under a consistent key
192+
if (mode === 'raw') {
193+
return [{ json: { advisories: out } } as INodeExecutionData];
194+
}
195+
196+
// Simplified: project minimal advisory fields locally to avoid advisory-specific simplifier
197+
if (mode === 'simplified') {
198+
const simplified = out.map((a: any) => ({
199+
uuid: a?.uuid ?? null,
200+
normative: a?.normative ?? null,
201+
identifier: a?.identifier ?? null,
202+
document_id: a?.document_id ?? null,
203+
package: a?.package ?? null,
204+
title: a?.title ?? null,
205+
description: a?.description ?? null,
206+
score: a?.score ?? null,
207+
}));
208+
return [{ json: { advisories: simplified } } as INodeExecutionData];
209+
}
210+
211+
// Selected: use generic advisory shaper
212+
const finalItems = out.map((it) => shapeOutput(ctx, itemIndex, 'advisory', it));
213+
return [{ json: { advisories: finalItems } } as INodeExecutionData];
74214
} catch (err: any) {
75215
if (ctx.continueOnFail()) {
76216
return [{ json: { message: err.message, request: { purls } } } as INodeExecutionData];
@@ -103,7 +243,65 @@ export async function analyze({ ctx, itemIndex }: { ctx: IExecuteFunctions; item
103243
returnFullResponse: false,
104244
};
105245

106-
const advisories = await authedRequest(ctx, credentialName, advOpts);
246+
const advisories = (await authedRequest(ctx, credentialName, advOpts)) as any;
247+
248+
const rules: SortRule[] = readSortRules(ctx, itemIndex, 'vulnerability');
249+
let out = advisories;
250+
if (Array.isArray(advisories) && rules.length) {
251+
out = [...advisories].sort((a, b) => multiCmp(a, b, rules));
252+
}
253+
254+
const limit = (ctx.getNodeParameter('limit', itemIndex, 50) as number) || 50;
255+
if (Array.isArray(out)) out = out.slice(0, limit);
256+
257+
const mode = ctx.getNodeParameter('outputMode', itemIndex, 'simplified') as
258+
| 'simplified'
259+
| 'raw'
260+
| 'selected';
261+
262+
// RAW mode: return advisories as-is under a consistent key
263+
if (mode === 'raw') {
264+
return [
265+
{ json: { sbomId, advisories: Array.isArray(out) ? out : [out] } } as INodeExecutionData,
266+
];
267+
}
268+
269+
// Simplified mode: custom sbom-sha advisory projection per user requirements
270+
if (mode === 'simplified') {
271+
const simplified = (Array.isArray(out) ? out : [out]).map((item: any) => {
272+
const toScores = (scores: any) => (Array.isArray(scores) ? scores : null);
273+
const statusArr = Array.isArray(item?.status)
274+
? item.status.map((s: any) => ({
275+
normative: s?.normative ?? null,
276+
identifier: item?.identifier ?? null,
277+
title: s?.title ?? null,
278+
description: s?.description ?? null,
279+
averageSeverity: s?.average_severity ?? null,
280+
averageScore: s?.average_score ?? null,
281+
status: s?.status ?? null,
282+
packages: s?.packages ?? null,
283+
score: toScores(item?.scores) ?? toScores(s?.scores),
284+
}))
285+
: [];
286+
return {
287+
uuid: item?.uuid ?? null,
288+
identifier: item?.identifier ?? null,
289+
title: item?.title ?? null,
290+
status: statusArr,
291+
};
292+
});
293+
294+
return [{ json: { sbomId, advisories: simplified } } as INodeExecutionData];
295+
}
296+
297+
// Selected fields mode: reuse normal advisory shaping
298+
const shaped = Array.isArray(out)
299+
? out.map((it: any) => shapeOutput(ctx, itemIndex, 'advisory', it))
300+
: shapeOutput(ctx, itemIndex, 'advisory', out);
107301

108-
return [{ json: { sbomId, advisories } } as INodeExecutionData];
302+
return [
303+
{
304+
json: { sbomId, advisories: Array.isArray(shaped) ? shaped : [shaped] },
305+
} as INodeExecutionData,
306+
];
109307
}

0 commit comments

Comments
 (0)