Skip to content

Commit f0e678d

Browse files
committed
reports per node
1 parent 8dd7744 commit f0e678d

3 files changed

Lines changed: 284 additions & 16 deletions

File tree

shell/models/compliance.cattle.io.clusterscan.js

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,33 @@ export default class ClusterScan extends SteveModel {
196196

197197
try {
198198
const benchmark = await this._resolveBenchmark();
199-
const { generateXCCDF } = await import(/* webpackChunkName: "xccdf" */'@shell/utils/xccdf');
199+
const { generateXCCDFPerNode } = await import(/* webpackChunkName: "xccdf" */'@shell/utils/xccdf');
200200

201-
const xml = generateXCCDF({
202-
report: report.parsedReport || {},
203-
benchmarkVersion: report.parsedReport?.version || benchmark?.spec?.benchmarkVersion || '',
201+
const parsed = report.parsedReport || {};
202+
const common = {
203+
report: parsed,
204+
benchmarkVersion: parsed.version || benchmark?.spec?.benchmarkVersion || '',
204205
metadata: benchmark?.spec?.benchmarkMetadata || {},
205206
stigChecks: benchmark?.spec?.stigChecks || {},
206-
clusterName: this.spec?.clusterName,
207+
};
208+
209+
const toZip = {};
210+
211+
Object.entries(parsed.nodes || {}).forEach(([role, hosts]) => {
212+
(hosts || []).forEach((hostname) => {
213+
const xml = generateXCCDFPerNode({
214+
...common, hostname, role,
215+
});
216+
217+
toZip[`${ labelFor(report) }--${ hostname }.xml`] = xml;
218+
});
207219
});
208220

209-
downloadFile(`${ labelFor(report) }.xml`, xml, 'application/xml');
221+
if (!isEmpty(toZip)) {
222+
const zip = await generateZip(toZip);
223+
224+
downloadFile(`${ labelFor(report) }-per-node.zip`, zip, 'application/zip');
225+
}
210226
} catch (err) {
211227
this.$dispatch('growl/fromError', { title: 'Error downloading file', err }, { root: true });
212228
}
@@ -218,27 +234,36 @@ export default class ClusterScan extends SteveModel {
218234

219235
try {
220236
const benchmark = await this._resolveBenchmark();
221-
const { generateXCCDF } = await import(/* webpackChunkName: "xccdf" */'@shell/utils/xccdf');
237+
const { generateXCCDFPerNode } = await import(/* webpackChunkName: "xccdf" */'@shell/utils/xccdf');
222238

223239
reports.forEach((report) => {
224240
try {
225-
const xml = generateXCCDF({
226-
report: report.parsedReport || {},
227-
benchmarkVersion: report.parsedReport?.version || benchmark?.spec?.benchmarkVersion || '',
241+
const parsed = report.parsedReport || {};
242+
const common = {
243+
report: parsed,
244+
benchmarkVersion: parsed.version || benchmark?.spec?.benchmarkVersion || '',
228245
metadata: benchmark?.spec?.benchmarkMetadata || {},
229246
stigChecks: benchmark?.spec?.stigChecks || {},
230-
clusterName: this.spec?.clusterName,
231-
});
247+
};
248+
const folder = labelFor(report);
249+
250+
Object.entries(parsed.nodes || {}).forEach(([role, hosts]) => {
251+
(hosts || []).forEach((hostname) => {
252+
const xml = generateXCCDFPerNode({
253+
...common, hostname, role,
254+
});
232255

233-
toZip[`${ labelFor(report) }.xml`] = xml;
256+
toZip[`${ folder }/${ hostname }.xml`] = xml;
257+
});
258+
});
234259
} catch (err) {
235260
this.$dispatch('growl/fromError', { title: 'Error downloading file', err }, { root: true });
236261
}
237262
});
238263

239264
if (!isEmpty(toZip)) {
240265
generateZip(toZip).then((zip) => {
241-
downloadFile(`${ this.id }-reports-xccdf`, zip, 'application/zip');
266+
downloadFile(`${ this.id }-reports-xccdf-per-node.zip`, zip, 'application/zip');
242267
});
243268
}
244269
} catch (err) {

shell/utils/__tests__/xccdf.test.ts

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { generateXCCDF } from '@shell/utils/xccdf';
1+
import { generateXCCDF, generateXCCDFPerNode } from '@shell/utils/xccdf';
22

33
describe('xccdf util: generateXCCDF', () => {
44
const baseReport = {
@@ -193,3 +193,199 @@ describe('xccdf util: generateXCCDF', () => {
193193
expect(xml).toContain('id="xccdf_compliance-operator_rule_5.1.1"');
194194
});
195195
});
196+
197+
describe('xccdf util: generateXCCDFPerNode', () => {
198+
const multiNodeReport = {
199+
version: '1.0',
200+
total: 3,
201+
pass: 1,
202+
nodes: { master: ['m-1'], node: ['w-1', 'w-2'] },
203+
results: [{
204+
id: '1.1',
205+
description: 'Master Node Configuration',
206+
checks: [{
207+
id: '1.1.1',
208+
description: 'master check',
209+
scored: true,
210+
state: 'pass' as const,
211+
}],
212+
}, {
213+
id: '4.1',
214+
description: 'Worker Node Configuration',
215+
checks: [{
216+
id: '4.1.1',
217+
description: 'mixed check',
218+
scored: true,
219+
state: 'mixed' as const,
220+
nodes: ['w-2'],
221+
}, {
222+
id: '4.1.2',
223+
description: 'failing check',
224+
scored: false,
225+
state: 'fail' as const,
226+
}],
227+
}],
228+
};
229+
230+
it('emits a single <target> equal to the hostname', () => {
231+
const xml = generateXCCDFPerNode({
232+
report: multiNodeReport, benchmarkVersion: 'cis-1.7', hostname: 'w-1', role: 'node',
233+
});
234+
235+
expect(xml).toContain('<target>w-1</target>');
236+
expect(xml).not.toContain('<target>w-2</target>');
237+
expect(xml).not.toContain('<target>m-1</target>');
238+
});
239+
240+
it('assigns each per-node document a TestResult id suffixed with the hostname so co-loaded files do not collide', () => {
241+
const a = generateXCCDFPerNode({
242+
report: multiNodeReport, benchmarkVersion: 'cis-1.7', hostname: 'w-1', role: 'node',
243+
});
244+
const b = generateXCCDFPerNode({
245+
report: multiNodeReport, benchmarkVersion: 'cis-1.7', hostname: 'w-2', role: 'node',
246+
});
247+
248+
expect(a).toContain('<TestResult id="xccdf_compliance-operator_testresult_1_w-1"');
249+
expect(b).toContain('<TestResult id="xccdf_compliance-operator_testresult_1_w-2"');
250+
expect(a).not.toContain('id="xccdf_compliance-operator_testresult_1_w-2"');
251+
});
252+
253+
it('emits every rule from the cluster report regardless of role', () => {
254+
const workerXml = generateXCCDFPerNode({
255+
report: multiNodeReport, benchmarkVersion: 'cis-1.7', hostname: 'w-1', role: 'node',
256+
});
257+
const masterXml = generateXCCDFPerNode({
258+
report: multiNodeReport, benchmarkVersion: 'cis-1.7', hostname: 'm-1', role: 'master',
259+
});
260+
261+
[workerXml, masterXml].forEach((xml) => {
262+
expect(xml).toContain('xccdf_compliance-operator_rule_1.1.1');
263+
expect(xml).toContain('xccdf_compliance-operator_rule_4.1.1');
264+
expect(xml).toContain('xccdf_compliance-operator_rule_4.1.2');
265+
});
266+
});
267+
268+
it('maps mixed-state checks to fail for dissenting hosts and pass for the rest', () => {
269+
const dissenter = generateXCCDFPerNode({
270+
report: multiNodeReport, benchmarkVersion: 'cis-1.7', hostname: 'w-2', role: 'node',
271+
});
272+
const compliant = generateXCCDFPerNode({
273+
report: multiNodeReport, benchmarkVersion: 'cis-1.7', hostname: 'w-1', role: 'node',
274+
});
275+
276+
expect(dissenter).toMatch(/idref="xccdf_compliance-operator_rule_4\.1\.1"[\s\S]*?<result>fail<\/result>/);
277+
expect(compliant).toMatch(/idref="xccdf_compliance-operator_rule_4\.1\.1"[\s\S]*?<result>pass<\/result>/);
278+
});
279+
280+
it('treats mixed-state checks with no dissent list as pass for all nodes', () => {
281+
const report = {
282+
...multiNodeReport,
283+
results: [{
284+
id: '4.1',
285+
description: 'g',
286+
checks: [{
287+
id: '4.1.9', description: 'm', state: 'mixed' as const,
288+
}],
289+
}],
290+
};
291+
const xml = generateXCCDFPerNode({
292+
report, benchmarkVersion: 'cis-1.7', hostname: 'w-1', role: 'node',
293+
});
294+
295+
expect(xml).toMatch(/idref="xccdf_compliance-operator_rule_4\.1\.9"[\s\S]*?<result>pass<\/result>/);
296+
});
297+
298+
it('recomputes pass count per node while preserving cluster total as the scoring denominator', () => {
299+
const report = {
300+
version: '1.0',
301+
total: 2,
302+
pass: 1,
303+
nodes: { node: ['w-1', 'w-2'] },
304+
results: [{
305+
id: '1',
306+
description: 'g',
307+
checks: [
308+
{
309+
id: 'a', description: 'a', state: 'pass' as const
310+
},
311+
{
312+
id: 'b', description: 'b', state: 'mixed' as const, nodes: ['w-2'],
313+
},
314+
],
315+
}],
316+
};
317+
const compliant = generateXCCDFPerNode({
318+
report, benchmarkVersion: 'cis-1.7', hostname: 'w-1', role: 'node',
319+
});
320+
const dissenter = generateXCCDFPerNode({
321+
report, benchmarkVersion: 'cis-1.7', hostname: 'w-2', role: 'node',
322+
});
323+
324+
expect(compliant).toMatch(/<score[^>]*>100\.0<\/score>/);
325+
expect(dissenter).toMatch(/<score[^>]*>50\.0<\/score>/);
326+
});
327+
328+
it('preserves full rule metadata (title, fixtext, idents, check) from the cluster report', () => {
329+
const report = {
330+
version: '1.0',
331+
total: 1,
332+
pass: 1,
333+
nodes: { node: ['w-1'] },
334+
results: [{
335+
id: 'V-254554',
336+
description: 'controller manager group',
337+
checks: [{
338+
id: 'V-254554',
339+
description: 'use-service-account-credentials',
340+
audit: '/bin/ps -fC kube-controller-manager',
341+
remediation: 'set use-service-account-credentials=true',
342+
scored: true,
343+
state: 'pass' as const,
344+
}],
345+
}],
346+
};
347+
const xml = generateXCCDFPerNode({
348+
report, benchmarkVersion: 'rke2-stig-1.31-rgs', hostname: 'w-1', role: 'node',
349+
});
350+
351+
expect(xml).toContain('<Group id="V-254554">');
352+
expect(xml).toContain('<check-content>/bin/ps -fC kube-controller-manager</check-content>');
353+
expect(xml).toContain('set use-service-account-credentials=true');
354+
});
355+
356+
it('passes through non-mixed states unchanged', () => {
357+
const report = {
358+
...multiNodeReport,
359+
results: [{
360+
id: '4.1',
361+
description: 'g',
362+
checks: [
363+
{
364+
id: 'a', description: 'a', state: 'pass' as const
365+
},
366+
{
367+
id: 'b', description: 'b', state: 'fail' as const
368+
},
369+
{
370+
id: 'c', description: 'c', state: 'skip' as const
371+
},
372+
{
373+
id: 'd', description: 'd', state: 'warn' as const
374+
},
375+
{
376+
id: 'e', description: 'e', state: 'notApplicable' as const
377+
},
378+
],
379+
}],
380+
};
381+
const xml = generateXCCDFPerNode({
382+
report, benchmarkVersion: 'cis-1.7', hostname: 'w-1', role: 'node',
383+
});
384+
385+
expect(xml).toContain('<result>pass</result>');
386+
expect(xml).toContain('<result>fail</result>');
387+
expect(xml).toContain('<result>notselected</result>');
388+
expect(xml).toContain('<result>informational</result>');
389+
expect(xml).toContain('<result>notapplicable</result>');
390+
});
391+
});

shell/utils/xccdf.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export interface XccdfReportCheck {
1515
audit?: string;
1616
scored?: boolean;
1717
state?: string;
18+
nodes?: string[];
19+
// eslint-disable-next-line camelcase
20+
node_type?: string[];
21+
// eslint-disable-next-line camelcase
22+
actual_value_per_node?: Record<string, string>;
1823
}
1924

2025
export interface XccdfReportGroup {
@@ -82,6 +87,8 @@ export interface GenerateXccdfArgs {
8287
targetFacts?: XccdfTargetFact[];
8388
/** Override the cluster name used for the <target> element. Falls back to metadata.clusterName, then derived node names. */
8489
clusterName?: string;
90+
/** Override the TestResult @id. Required when multiple documents from the same benchmark will be co-loaded into a validator. */
91+
testResultId?: string;
8592
}
8693

8794
const na = (s?: string): string => (s && s.length > 0 ? s : NA);
@@ -184,6 +191,7 @@ export function generateXCCDF({
184191
targetAddresses,
185192
targetFacts,
186193
clusterName,
194+
testResultId,
187195
}: GenerateXccdfArgs): string {
188196
const now = new Date();
189197
const timeStr = now.toISOString().replace(/\.\d{3}Z$/, 'Z');
@@ -301,7 +309,7 @@ export function generateXCCDF({
301309
const targets = clusterName ? [clusterName] : metadata.clusterName ? [metadata.clusterName] : collectTargets(report);
302310

303311
const testResult = benchmark.ele('TestResult', {
304-
id: `${ ID_PREFIX }_${ TEST_RESULT_ID_SUFFIX }`,
312+
id: testResultId || `${ ID_PREFIX }_${ TEST_RESULT_ID_SUFFIX }`,
305313
'start-time': timeStr,
306314
'end-time': timeStr,
307315
version: benchmarkVersion,
@@ -369,3 +377,42 @@ export function generateXCCDF({
369377

370378
return doc.end({ prettyPrint: true });
371379
}
380+
381+
export interface GenerateXccdfPerNodeArgs extends Omit<GenerateXccdfArgs, 'clusterName' | 'targetAddresses' | 'targetFacts'> {
382+
hostname: string;
383+
role: string;
384+
}
385+
386+
const remapCheckForNode = (check: XccdfReportCheck, hostname: string): XccdfReportCheck => {
387+
const isMixed = (check.state || '').toLowerCase() === 'mixed';
388+
const state = isMixed ? ((check.nodes || []).includes(hostname) ? 'fail' : 'pass') : check.state;
389+
390+
return { ...check, state };
391+
};
392+
393+
export function generateXCCDFPerNode(args: GenerateXccdfPerNodeArgs): string {
394+
const {
395+
report, hostname, role, ...rest
396+
} = args;
397+
398+
const perNodeResults: XccdfReportGroup[] = (report.results || []).map((group) => ({
399+
...group,
400+
checks: (group.checks || []).map((c) => remapCheckForNode(c, hostname)),
401+
}));
402+
403+
const passForNode = perNodeResults
404+
.flatMap((g) => g.checks || [])
405+
.filter((c) => (c.state || '').toLowerCase() === 'pass').length;
406+
407+
return generateXCCDF({
408+
...rest,
409+
report: {
410+
...report,
411+
results: perNodeResults,
412+
pass: passForNode,
413+
nodes: { [role]: [hostname] },
414+
},
415+
clusterName: hostname,
416+
testResultId: `${ ID_PREFIX }_${ TEST_RESULT_ID_SUFFIX }_${ safeId(hostname) }`,
417+
});
418+
}

0 commit comments

Comments
 (0)