Skip to content

Commit aedbd56

Browse files
committed
feat: add container image cve triage template
Signed-off-by: zebbern <185730623+zebbern@users.noreply.github.com>
1 parent ccb33e3 commit aedbd56

3 files changed

Lines changed: 291 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
{
2+
"_metadata": {
3+
"name": "Container Image CVE Triage",
4+
"description": "Supply-chain research workflow that scans a public container image with Trivy, ranks fixable CVEs, and writes an actionable image upgrade report.",
5+
"category": "container-security",
6+
"tags": ["container", "image", "cve", "trivy", "supply-chain"],
7+
"author": "sentris-team",
8+
"version": "1.0.0"
9+
},
10+
"manifest": {
11+
"name": "Container Image CVE Triage",
12+
"description": "Scan container images for known CVEs and produce a prioritized remediation report.",
13+
"version": "1.0.0",
14+
"author": "sentris-team",
15+
"category": "container-security",
16+
"tags": ["container", "image", "cve", "trivy", "supply-chain"],
17+
"entryPoint": "trigger_1",
18+
"nodeCount": 4,
19+
"edgeCount": 7
20+
},
21+
"graph": {
22+
"name": "Container Image CVE Triage",
23+
"description": "Container image CVE triage workflow for authorized supply-chain and deployment research.",
24+
"nodes": [
25+
{
26+
"id": "trigger_1",
27+
"type": "core.workflow.entrypoint",
28+
"position": { "x": 100, "y": 260 },
29+
"data": {
30+
"label": "Container Image Input",
31+
"config": {
32+
"params": {
33+
"runtimeInputs": [
34+
{
35+
"id": "imageRef",
36+
"label": "Container image reference",
37+
"type": "text",
38+
"required": true,
39+
"description": "Public or locally available container image reference, for example nginx:1.25 or node:18-alpine."
40+
},
41+
{
42+
"id": "deploymentContext",
43+
"label": "Deployment context",
44+
"type": "text",
45+
"required": false,
46+
"defaultValue": "",
47+
"description": "Where the image is used, exposure level, or business context for ranking CVE urgency."
48+
},
49+
{
50+
"id": "authorizationNotes",
51+
"label": "Authorization notes",
52+
"type": "text",
53+
"required": false,
54+
"defaultValue": "",
55+
"description": "Program scope, ownership notes, or testing constraints to include in the report."
56+
}
57+
]
58+
},
59+
"inputOverrides": {}
60+
}
61+
}
62+
},
63+
{
64+
"id": "trivy_image_scan",
65+
"type": "sentris.trivy.run",
66+
"position": { "x": 420, "y": 260 },
67+
"data": {
68+
"label": "Scan Image CVEs",
69+
"config": {
70+
"params": {
71+
"scanType": "image",
72+
"severity": ["CRITICAL", "HIGH", "MEDIUM"],
73+
"format": "json"
74+
},
75+
"inputOverrides": {}
76+
}
77+
}
78+
},
79+
{
80+
"id": "assemble_image_cve_report",
81+
"type": "core.logic.script",
82+
"position": { "x": 760, "y": 260 },
83+
"data": {
84+
"label": "Rank Image CVEs",
85+
"config": {
86+
"params": {
87+
"variables": [
88+
{ "name": "imageRef", "type": "string" },
89+
{ "name": "deploymentContext", "type": "string" },
90+
{ "name": "authorizationNotes", "type": "string" },
91+
{ "name": "vulnerabilities", "type": "list-json" },
92+
{ "name": "vulnerabilityCount", "type": "number" }
93+
],
94+
"returns": [{ "name": "report", "type": "json" }],
95+
"code": "export function script(input) {\n const vulnerabilities = Array.isArray(input.vulnerabilities) ? input.vulnerabilities : [];\n const severityRank = { critical: 4, high: 3, medium: 2, low: 1, info: 0, unknown: 0 };\n const normalizeSeverity = (value) => String(value || 'unknown').toLowerCase();\n const rankFor = (value) => severityRank[normalizeSeverity(value)] ?? 0;\n const hasFix = (finding) => typeof finding?.fixedVersion === 'string' && finding.fixedVersion.trim().length > 0;\n const priorityBandFor = (finding) => {\n const rank = rankFor(finding?.severity);\n if (rank >= 4) return 'immediate';\n if (rank >= 3) return hasFix(finding) ? 'high-fix-available' : 'high-review';\n if (rank >= 2) return hasFix(finding) ? 'scheduled-fix' : 'watchlist';\n return 'backlog';\n };\n const reasonsFor = (finding) => {\n const severity = normalizeSeverity(finding?.severity);\n const reasons = [`${severity} severity`];\n if (hasFix(finding)) reasons.push('fixed version available');\n if (/openssl|libssl|gnutls|curl|nginx|apache|node|java|glibc|busybox/i.test(String(finding?.pkgName || ''))) reasons.push('security-sensitive package');\n if (/internet|external|public|edge|reverse proxy|api/i.test(String(input.deploymentContext || ''))) reasons.push('deployment context suggests external exposure');\n return reasons;\n };\n const priorityFindings = vulnerabilities\n .map((finding) => {\n const rank = rankFor(finding?.severity);\n const fixBoost = hasFix(finding) ? 25 : 0;\n const exposureBoost = /internet|external|public|edge|reverse proxy|api/i.test(String(input.deploymentContext || '')) ? 10 : 0;\n const packageBoost = /openssl|libssl|gnutls|curl|nginx|apache|node|java|glibc|busybox/i.test(String(finding?.pkgName || '')) ? 8 : 0;\n return {\n vulnerabilityId: finding?.vulnerabilityId,\n pkgName: finding?.pkgName,\n installedVersion: finding?.installedVersion,\n fixedVersion: finding?.fixedVersion,\n severity: normalizeSeverity(finding?.severity),\n title: finding?.title,\n description: finding?.description,\n primaryUrl: finding?.primaryUrl,\n priorityScore: rank * 100 + fixBoost + exposureBoost + packageBoost,\n priorityBand: priorityBandFor(finding),\n priorityReasons: reasonsFor(finding)\n };\n })\n .filter((finding) => finding.vulnerabilityId && rankFor(finding.severity) >= 2)\n .sort((a, b) => b.priorityScore - a.priorityScore || String(a.pkgName || '').localeCompare(String(b.pkgName || '')))\n .slice(0, 100);\n const countsBySeverity = priorityFindings.reduce((acc, finding) => {\n acc[finding.severity] = (acc[finding.severity] || 0) + 1;\n return acc;\n }, {});\n const highestSeverity = priorityFindings.reduce((current, finding) => rankFor(finding.severity) > rankFor(current) ? finding.severity : current, 'none');\n const fixableFindings = priorityFindings.filter((finding) => typeof finding.fixedVersion === 'string' && finding.fixedVersion.trim().length > 0).length;\n return {\n report: {\n summary: {\n imageRef: input.imageRef || null,\n vulnerabilityCount: Number(input.vulnerabilityCount || vulnerabilities.length),\n actionableFindings: priorityFindings.length,\n fixableFindings,\n highestSeverity,\n countsBySeverity\n },\n deploymentContext: input.deploymentContext || null,\n authorizationNotes: input.authorizationNotes || null,\n priorityFindings,\n nextSteps: [\n 'Prioritize immediate and high-fix-available findings first',\n 'Upgrade the base image or affected OS package when fixedVersion is listed',\n 'Confirm whether the vulnerable package is reachable in the deployed image path',\n 'Rebuild and rescan the image after remediation'\n ]\n }\n };\n}"
96+
},
97+
"inputOverrides": {}
98+
}
99+
}
100+
},
101+
{
102+
"id": "artifact_report",
103+
"type": "core.artifact.writer",
104+
"position": { "x": 1100, "y": 260 },
105+
"data": {
106+
"label": "Save Image CVE Report",
107+
"config": {
108+
"params": {
109+
"fileExtension": ".json",
110+
"mimeType": "application/json",
111+
"saveToRunArtifacts": true,
112+
"publishToArtifactLibrary": false
113+
},
114+
"inputOverrides": {
115+
"artifactName": "container-image-cve-triage-{{date}}"
116+
}
117+
}
118+
}
119+
}
120+
],
121+
"edges": [
122+
{
123+
"id": "trigger_1-trivy_image_scan-imageRef",
124+
"source": "trigger_1",
125+
"target": "trivy_image_scan",
126+
"sourceHandle": "imageRef",
127+
"targetHandle": "target"
128+
},
129+
{
130+
"id": "trigger_1-assemble_image_cve_report-imageRef",
131+
"source": "trigger_1",
132+
"target": "assemble_image_cve_report",
133+
"sourceHandle": "imageRef",
134+
"targetHandle": "imageRef"
135+
},
136+
{
137+
"id": "trigger_1-assemble_image_cve_report-deploymentContext",
138+
"source": "trigger_1",
139+
"target": "assemble_image_cve_report",
140+
"sourceHandle": "deploymentContext",
141+
"targetHandle": "deploymentContext"
142+
},
143+
{
144+
"id": "trigger_1-assemble_image_cve_report-authorizationNotes",
145+
"source": "trigger_1",
146+
"target": "assemble_image_cve_report",
147+
"sourceHandle": "authorizationNotes",
148+
"targetHandle": "authorizationNotes"
149+
},
150+
{
151+
"id": "trivy_image_scan-assemble_image_cve_report-vulnerabilities",
152+
"source": "trivy_image_scan",
153+
"target": "assemble_image_cve_report",
154+
"sourceHandle": "vulnerabilities",
155+
"targetHandle": "vulnerabilities"
156+
},
157+
{
158+
"id": "trivy_image_scan-assemble_image_cve_report-vulnerabilityCount",
159+
"source": "trivy_image_scan",
160+
"target": "assemble_image_cve_report",
161+
"sourceHandle": "vulnerabilityCount",
162+
"targetHandle": "vulnerabilityCount"
163+
},
164+
{
165+
"id": "assemble_image_cve_report-artifact_report-report",
166+
"source": "assemble_image_cve_report",
167+
"target": "artifact_report",
168+
"sourceHandle": "report",
169+
"targetHandle": "content"
170+
}
171+
]
172+
},
173+
"requiredSecrets": []
174+
}

backend/src/templates/__tests__/seed-templates.spec.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const seedTemplatesDir = join(import.meta.dir, '../../../scripts/seed-templates'
99
const newTemplateFiles = [
1010
'api-surface-exposure-triage.json',
1111
'bug-bounty-recon-triage.json',
12+
'container-image-cve-triage.json',
1213
'cve-impact-research-brief.json',
1314
'exposed-service-cve-mapper.json',
1415
'github-repo-dependency-cve-triage.json',
@@ -1487,4 +1488,115 @@ describe('new seed templates', () => {
14871488
reason: 'Auth-protected live subdomain',
14881489
});
14891490
});
1491+
1492+
it('container-image-cve-triage uses Trivy image scanning with bounded severity', () => {
1493+
const filePath = join(seedTemplatesDir, 'container-image-cve-triage.json');
1494+
const template = JSON.parse(readFileSync(filePath, 'utf8'));
1495+
const graph = template.graph;
1496+
const trivyNode = graph.nodes.find((node: { id: string }) => node.id === 'trivy_image_scan');
1497+
const assembleNode = graph.nodes.find(
1498+
(node: { id: string }) => node.id === 'assemble_image_cve_report',
1499+
);
1500+
const entrypoint = graph.nodes.find((node: { id: string }) => node.id === 'trigger_1');
1501+
const runtimeInputs = entrypoint.data.config.params.runtimeInputs;
1502+
const edges = graph.edges.map(
1503+
(edge: { source: string; target: string; sourceHandle?: string; targetHandle?: string }) =>
1504+
`${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`,
1505+
);
1506+
1507+
expect(template._metadata.category).toBe('container-security');
1508+
expect(template._metadata.tags).toEqual(
1509+
expect.arrayContaining(['container', 'image', 'cve', 'trivy', 'supply-chain']),
1510+
);
1511+
expect(runtimeInputs.find((input: { id: string }) => input.id === 'imageRef')).toMatchObject({
1512+
required: true,
1513+
type: 'text',
1514+
});
1515+
expect(
1516+
runtimeInputs.find((input: { id: string }) => input.id === 'deploymentContext'),
1517+
).toMatchObject({
1518+
required: false,
1519+
defaultValue: '',
1520+
});
1521+
expect(trivyNode?.type).toBe('sentris.trivy.run');
1522+
expect(trivyNode.data.config.params).toMatchObject({
1523+
scanType: 'image',
1524+
severity: ['CRITICAL', 'HIGH', 'MEDIUM'],
1525+
format: 'json',
1526+
});
1527+
expect(assembleNode?.type).toBe('core.logic.script');
1528+
expect(edges).toEqual(
1529+
expect.arrayContaining([
1530+
'trigger_1:imageRef->trivy_image_scan:target',
1531+
'trivy_image_scan:vulnerabilities->assemble_image_cve_report:vulnerabilities',
1532+
'trivy_image_scan:vulnerabilityCount->assemble_image_cve_report:vulnerabilityCount',
1533+
'trigger_1:deploymentContext->assemble_image_cve_report:deploymentContext',
1534+
'assemble_image_cve_report:report->artifact_report:content',
1535+
]),
1536+
);
1537+
});
1538+
1539+
it('container-image-cve-triage prioritizes fixable critical and high image CVEs', () => {
1540+
const filePath = join(seedTemplatesDir, 'container-image-cve-triage.json');
1541+
const template = JSON.parse(readFileSync(filePath, 'utf8'));
1542+
const assembleNode = template.graph.nodes.find(
1543+
(node: { id: string }) => node.id === 'assemble_image_cve_report',
1544+
);
1545+
1546+
const result = runTemplateScript<{ report: { summary: any; priorityFindings: any[] } }>(
1547+
assembleNode.data.config.params.code,
1548+
{
1549+
imageRef: 'nginx:1.25',
1550+
deploymentContext: 'Internet-facing reverse proxy in a bug bounty target.',
1551+
vulnerabilityCount: 4,
1552+
vulnerabilities: [
1553+
{
1554+
vulnerabilityId: 'CVE-2024-0001',
1555+
pkgName: 'openssl',
1556+
installedVersion: '3.0.0',
1557+
fixedVersion: '3.0.8',
1558+
severity: 'CRITICAL',
1559+
title: 'Critical TLS issue',
1560+
primaryUrl: 'https://example.test/CVE-2024-0001',
1561+
},
1562+
{
1563+
vulnerabilityId: 'CVE-2024-0002',
1564+
pkgName: 'zlib',
1565+
installedVersion: '1.2.11',
1566+
severity: 'HIGH',
1567+
title: 'No fix yet',
1568+
},
1569+
{
1570+
vulnerabilityId: 'CVE-2024-0003',
1571+
pkgName: 'bash',
1572+
installedVersion: '5.1',
1573+
fixedVersion: '5.2',
1574+
severity: 'MEDIUM',
1575+
title: 'Medium fixable issue',
1576+
},
1577+
],
1578+
},
1579+
);
1580+
1581+
expect(result.report.summary).toMatchObject({
1582+
imageRef: 'nginx:1.25',
1583+
vulnerabilityCount: 4,
1584+
actionableFindings: 3,
1585+
fixableFindings: 2,
1586+
highestSeverity: 'critical',
1587+
});
1588+
expect(result.report.priorityFindings.map((finding) => finding.vulnerabilityId)).toEqual([
1589+
'CVE-2024-0001',
1590+
'CVE-2024-0002',
1591+
'CVE-2024-0003',
1592+
]);
1593+
expect(result.report.priorityFindings[0]).toMatchObject({
1594+
pkgName: 'openssl',
1595+
fixedVersion: '3.0.8',
1596+
priorityBand: 'immediate',
1597+
});
1598+
expect(result.report.priorityFindings[0].priorityReasons).toEqual(
1599+
expect.arrayContaining(['critical severity', 'fixed version available']),
1600+
);
1601+
});
14901602
});

scripts/template-library-live-audit-utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ export function createTemplateLiveAuditInputs(): TemplateLiveAuditInputs {
172172
version: '5.6.1',
173173
deploymentNotes: 'Live audit fixture for known public CVE research.',
174174
},
175+
'Container Image CVE Triage': {
176+
imageRef: 'alpine:3.18',
177+
deploymentContext: 'Live audit fixture: small public Linux base image for bounded CVE triage.',
178+
authorizationNotes: 'Live audit fixture using a public container image.',
179+
},
175180
'Exposed Service CVE Mapper': {
176181
targets: ['scanme.nmap.org'],
177182
authorizationNotes: 'Live audit fixture: Nmap-provided scan target for bounded service checks.',

0 commit comments

Comments
 (0)