-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Compliance dashboard UI and API #171312
Compliance dashboard UI and API #171312
Changes from 14 commits
09a00e0
81b7c2c
190f5b8
eaebc40
58485f1
f91fd17
c03ee4e
f33f65e
a2d884d
e4c84fe
605c38f
622da00
0e2c06a
df1a906
1ce83b7
bde5b2e
92394f7
f6ad4ae
f8111c5
0591bf5
09a914a
d14b4da
8239363
a134050
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0; you may not use this file except in compliance with the Elastic License | ||
* 2.0. | ||
*/ | ||
|
||
export const MAPPING_VERSION_DELIMITER = '_'; | ||
|
||
export const toBenchmarkDocFieldKey = (benchmarkId: string, benchmarkVersion: string) => { | ||
if (benchmarkVersion.includes(MAPPING_VERSION_DELIMITER)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: seems like the linter is ok with it but I'm personally not a big fan of
or have a ternary in the return |
||
return `${benchmarkId};${benchmarkVersion.replaceAll('_', '.')}`; | ||
return `${benchmarkId};${benchmarkVersion}`; | ||
}; | ||
|
||
export const toBenchmarkMappingFieldKey = (benchmarkVersion: string) => { | ||
return `${benchmarkVersion.replaceAll('.', '_')}`; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,13 +14,16 @@ import type { | |
PosturePolicyTemplate, | ||
ComplianceDashboardData, | ||
GetComplianceDashboardRequest, | ||
ComplianceDashboardDataV2, | ||
} from '../../../common/types'; | ||
import { LATEST_FINDINGS_INDEX_DEFAULT_NS, STATS_ROUTE_PATH } from '../../../common/constants'; | ||
import { getGroupedFindingsEvaluation } from './get_grouped_findings_evaluation'; | ||
import { ClusterWithoutTrend, getClusters } from './get_clusters'; | ||
import { getStats } from './get_stats'; | ||
import { CspRouter } from '../../types'; | ||
import { getTrends, Trends } from './get_trends'; | ||
import { BenchmarkWithoutTrend, getBenchmarks } from './get_benchmarks'; | ||
import { toBenchmarkDocFieldKey } from '../../lib/mapping_field_util'; | ||
|
||
export interface KeyDocCount<TKey = string> { | ||
key: TKey; | ||
|
@@ -36,6 +39,23 @@ const getClustersTrends = (clustersWithoutTrends: ClusterWithoutTrend[], trends: | |
})), | ||
})); | ||
|
||
const getBenchmarksTrends = (benchmarksWithoutTrends: BenchmarkWithoutTrend[], trends: Trends) => { | ||
return benchmarksWithoutTrends.map((benchmark) => ({ | ||
...benchmark, | ||
trend: trends.map(({ timestamp, benchmarks: benchmarksTrendData }) => { | ||
const benchmarkIdVersion = toBenchmarkDocFieldKey( | ||
benchmark.meta.benchmarkId, | ||
benchmark.meta.benchmarkVersion | ||
); | ||
|
||
return { | ||
timestamp, | ||
...benchmarksTrendData[benchmarkIdVersion], | ||
}; | ||
}), | ||
})); | ||
}; | ||
|
||
const getSummaryTrend = (trends: Trends) => | ||
trends.map(({ timestamp, summary }) => ({ timestamp, ...summary })); | ||
|
||
|
@@ -56,6 +76,7 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) => | |
}, | ||
async (context, request, response) => { | ||
const cspContext = await context.csp; | ||
const logger = cspContext.logger; | ||
|
||
try { | ||
const esClient = cspContext.esClient.asCurrentUser; | ||
|
@@ -79,16 +100,16 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) => | |
|
||
const [stats, groupedFindingsEvaluation, clustersWithoutTrends, trends] = | ||
await Promise.all([ | ||
getStats(esClient, query, pitId, runtimeMappings), | ||
getGroupedFindingsEvaluation(esClient, query, pitId, runtimeMappings), | ||
getClusters(esClient, query, pitId, runtimeMappings), | ||
getTrends(esClient, policyTemplate), | ||
getStats(logger, esClient, query, pitId, runtimeMappings), | ||
getGroupedFindingsEvaluation(logger, esClient, query, pitId, runtimeMappings), | ||
getClusters(logger, esClient, query, pitId, runtimeMappings), | ||
getTrends(logger, esClient, policyTemplate), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: logger usually passes as the last argument in our plugin. |
||
]); | ||
|
||
// Try closing the PIT, if it fails we can safely ignore the error since it closes itself after the keep alive | ||
// ends. Not waiting on the promise returned from the `closePointInTime` call to avoid delaying the request | ||
esClient.closePointInTime({ id: pitId }).catch((err) => { | ||
cspContext.logger.warn(`Could not close PIT for stats endpoint: ${err}`); | ||
logger.warn(`Could not close PIT for stats endpoint: ${err}`); | ||
}); | ||
|
||
const clusters = getClustersTrends(clustersWithoutTrends, trends); | ||
|
@@ -106,7 +127,80 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) => | |
}); | ||
} catch (err) { | ||
const error = transformError(err); | ||
cspContext.logger.error(`Error while fetching CSP stats: ${err}`); | ||
logger.error(`Error while fetching CSP stats: ${err}`); | ||
logger.error(err.stack); | ||
|
||
return response.customError({ | ||
body: { message: error.message }, | ||
statusCode: error.statusCode, | ||
}); | ||
} | ||
} | ||
) | ||
.addVersion( | ||
{ | ||
version: '2', | ||
validate: { | ||
request: { | ||
params: getComplianceDashboardSchema, | ||
}, | ||
}, | ||
}, | ||
async (context, request, response) => { | ||
const cspContext = await context.csp; | ||
const logger = cspContext.logger; | ||
|
||
try { | ||
const esClient = cspContext.esClient.asCurrentUser; | ||
|
||
const { id: pitId } = await esClient.openPointInTime({ | ||
index: LATEST_FINDINGS_INDEX_DEFAULT_NS, | ||
keep_alive: '30s', | ||
}); | ||
|
||
const params: GetComplianceDashboardRequest = request.params; | ||
const policyTemplate = params.policy_template as PosturePolicyTemplate; | ||
|
||
// runtime mappings create the `safe_posture_type` field, which equals to `kspm` or `cspm` based on the value and existence of the `posture_type` field which was introduced at 8.7 | ||
// the `query` is then being passed to our getter functions to filter per posture type even for older findings before 8.7 | ||
const runtimeMappings: MappingRuntimeFields = getSafePostureTypeRuntimeMapping(); | ||
const query: QueryDslQueryContainer = { | ||
bool: { | ||
filter: [{ term: { safe_posture_type: policyTemplate } }], | ||
}, | ||
}; | ||
|
||
const [stats, groupedFindingsEvaluation, benchmarksWithoutTrends, trends] = | ||
await Promise.all([ | ||
getStats(logger, esClient, query, pitId, runtimeMappings), | ||
getGroupedFindingsEvaluation(logger, esClient, query, pitId, runtimeMappings), | ||
getBenchmarks(logger, esClient, query, pitId, runtimeMappings), | ||
getTrends(logger, esClient, policyTemplate), | ||
]); | ||
|
||
// Try closing the PIT, if it fails we can safely ignore the error since it closes itself after the keep alive | ||
// ends. Not waiting on the promise returned from the `closePointInTime` call to avoid delaying the request | ||
esClient.closePointInTime({ id: pitId }).catch((err) => { | ||
logger.warn(`Could not close PIT for stats endpoint: ${err}`); | ||
}); | ||
|
||
const benchmarks = getBenchmarksTrends(benchmarksWithoutTrends, trends); | ||
const trend = getSummaryTrend(trends); | ||
|
||
const body: ComplianceDashboardDataV2 = { | ||
stats, | ||
groupedFindingsEvaluation, | ||
benchmarks, | ||
trend, | ||
}; | ||
|
||
return response.ok({ | ||
body, | ||
}); | ||
} catch (err) { | ||
const error = transformError(err); | ||
logger.error(`Error while fetching v2 CSP stats: ${err}`); | ||
logger.error(err.stack); | ||
|
||
return response.customError({ | ||
body: { message: error.message }, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0; you may not use this file except in compliance with the Elastic License | ||
* 2.0. | ||
*/ | ||
|
||
import { BenchmarkBucket, getBenchmarksFromAggs } from './get_benchmarks'; | ||
|
||
const mockBenchmarkBuckets: BenchmarkBucket[] = [ | ||
{ | ||
key: 'cis_aws', | ||
doc_count: 12, | ||
aggs_by_benchmark_version: { | ||
buckets: [ | ||
{ | ||
key: 'v1.5.0', | ||
doc_count: 12, | ||
asset_count: { | ||
value: 1, | ||
}, | ||
aggs_by_resource_type: { | ||
buckets: [ | ||
{ | ||
key: 'foo_type', | ||
doc_count: 6, | ||
passed_findings: { | ||
doc_count: 3, | ||
}, | ||
failed_findings: { | ||
doc_count: 3, | ||
}, | ||
score: { | ||
value: 0.5, | ||
}, | ||
}, | ||
{ | ||
key: 'boo_type', | ||
doc_count: 6, | ||
passed_findings: { | ||
doc_count: 3, | ||
}, | ||
failed_findings: { | ||
doc_count: 3, | ||
}, | ||
score: { | ||
value: 0.5, | ||
}, | ||
}, | ||
], | ||
}, | ||
aggs_by_benchmark_name: { | ||
buckets: [ | ||
{ | ||
key: 'CIS Amazon Web Services Foundations', | ||
doc_count: 12, | ||
}, | ||
], | ||
}, | ||
passed_findings: { | ||
doc_count: 6, | ||
}, | ||
failed_findings: { | ||
doc_count: 6, | ||
}, | ||
}, | ||
], | ||
}, | ||
}, | ||
]; | ||
|
||
describe('getBenchmarksFromAggs', () => { | ||
it('should return value matching ComplianceDashboardDataV2["benchmarks"]', async () => { | ||
const benchmarks = getBenchmarksFromAggs(mockBenchmarkBuckets); | ||
expect(benchmarks).toEqual([ | ||
{ | ||
meta: { | ||
benchmarkId: 'cis_aws', | ||
benchmarkVersion: 'v1.5.0', | ||
benchmarkName: 'CIS Amazon Web Services Foundations', | ||
assetCount: 1, | ||
}, | ||
stats: { | ||
totalFindings: 12, | ||
totalFailed: 6, | ||
totalPassed: 6, | ||
postureScore: 50.0, | ||
}, | ||
groupedFindingsEvaluation: [ | ||
{ | ||
name: 'foo_type', | ||
totalFindings: 6, | ||
totalFailed: 3, | ||
totalPassed: 3, | ||
postureScore: 50.0, | ||
}, | ||
{ | ||
name: 'boo_type', | ||
totalFindings: 6, | ||
totalFailed: 3, | ||
totalPassed: 3, | ||
postureScore: 50.0, | ||
}, | ||
], | ||
}, | ||
]); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's add some basic unit tests for these utils
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also maybe some comments on why we need these mapping utils, it's was a bit hard to figure it out just from looking at the code