Skip to content

Commit bfaae1e

Browse files
Merge pull request kubescape#102 from kubescape/compliance-score
Add compliance score
2 parents 066863f + fb03e31 commit bfaae1e

File tree

5 files changed

+268
-6
lines changed

5 files changed

+268
-6
lines changed

reporthandling/results/v1/reportsummary/datastructures.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ type SummaryDetails struct {
2525

2626
// FrameworkSummary summary of scanning from a single framework perspective
2727
type FrameworkSummary struct {
28-
Controls ControlSummaries `json:"controls,omitempty"` // mapping of control - map[<control ID>]<control summary>
29-
Name string `json:"name"` // framework name
30-
Status apis.ScanningStatus `json:"status"`
31-
Version string `json:"version"`
32-
StatusCounters StatusCounters `json:"ResourceCounters"` // Backward compatibility
33-
Score float32 `json:"score"`
28+
Controls ControlSummaries `json:"controls,omitempty"` // mapping of control - map[<control ID>]<control summary>
29+
Name string `json:"name"` // framework name
30+
Status apis.ScanningStatus `json:"status"`
31+
Version string `json:"version"`
32+
StatusCounters StatusCounters `json:"ResourceCounters"` // Backward compatibility
33+
Score float32 `json:"score"`
34+
ComplianceScore float32 `json:"complianceScore"`
3435
}
3536

3637
// ControlSummary summary of scanning from a single control perspective

reporthandling/results/v1/reportsummary/frameworksummarymethods.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ func (frameworkSummary *FrameworkSummary) initResourcesSummary(controlInfoMap ma
6161
frameworkSummary.CalculateStatus()
6262
}
6363

64+
// =================================== ComplianceScore ============================================
65+
66+
// GetComplianceScore returns framework ComplianceScore
67+
func (frameworkSummary *FrameworkSummary) GetComplianceScore() float32 {
68+
return frameworkSummary.ComplianceScore
69+
}
70+
6471
// =================================== Score ============================================
6572

6673
// GetScore return framework score

reporthandling/results/v1/reportsummary/interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type IFrameworkSummary interface {
2828
IPolicies
2929
ListControls() []IControlSummary
3030
NumberOfControls() ICounters
31+
GetComplianceScore() float32
3132
}
3233

3334
type IControlSummary interface {

score/score.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"sync"
1010

11+
"github.com/kubescape/go-logger"
12+
"github.com/kubescape/go-logger/helpers"
1113
k8sinterface "github.com/kubescape/k8s-interface/k8sinterface"
1214
"github.com/kubescape/k8s-interface/workloadinterface"
1315
armoupautils "github.com/kubescape/opa-utils/objectsenvelopes"
@@ -345,3 +347,86 @@ func (su ScoreUtil) debugf(format string, args ...any) {
345347
func max32(a, b float32) float32 {
346348
return float32(math.Max(float64(a), float64(b)))
347349
}
350+
351+
// ==================================== Compliance Score ====================================
352+
353+
// SetPostureReportComplianceScores calculates and populates scores for all controls, frameworks and whole scan.
354+
func (su *ScoreUtil) SetPostureReportComplianceScores(report *v2.PostureReport) error {
355+
// call CalculatePostureReportV2 to set frameworks.score for backward compatibility
356+
// afterwards we will override the controls.score and summeryDetails.score
357+
// and set frameworks.complianceScore
358+
// TODO: remove CalculatePostureReportV2 call after we deprecate frameworks.score
359+
if err := su.CalculatePostureReportV2(report); err != nil {
360+
return err
361+
}
362+
// set compliance score for each framework
363+
for i := range report.SummaryDetails.Frameworks {
364+
// set compliance score for framework and all controls in framework
365+
report.SummaryDetails.Frameworks[i].ComplianceScore = su.GetFrameworkComplianceScore(&report.SummaryDetails.Frameworks[i])
366+
logger.L().Debug("set framework score", helpers.String("framework name", report.SummaryDetails.Frameworks[i].GetName()), helpers.Int("ComplianceScore", int(report.SummaryDetails.Frameworks[i].GetComplianceScore())))
367+
}
368+
// set compliance score per control
369+
sumScore := su.ControlsSummariesComplianceScore(&report.SummaryDetails.Controls, "")
370+
// set compliance score for whole scan
371+
summaryScore := float32(0)
372+
if len(report.SummaryDetails.Controls) > 0 {
373+
summaryScore = sumScore / float32(len(report.SummaryDetails.Controls))
374+
}
375+
report.SummaryDetails.Score = summaryScore
376+
return nil
377+
}
378+
379+
// ControlsSummariesComplianceScore sets the controls compliance score
380+
// and returns the sum of all controls scores
381+
func (su *ScoreUtil) ControlsSummariesComplianceScore(ctrls *reportsummary.ControlSummaries, frameworkName string) (sumScore float32) {
382+
for ctrlID := range *ctrls {
383+
ctrl := (*ctrls)[ctrlID]
384+
ctrl.Score = 0
385+
ctrl.Score = su.GetControlComplianceScore(&ctrl, frameworkName)
386+
(*ctrls)[ctrlID] = ctrl
387+
logger.L().Debug("set control score", helpers.String("controlID", ctrl.GetID()), helpers.Int("score", int(ctrl.GetScore())))
388+
sumScore += ctrl.GetScore()
389+
}
390+
return sumScore
391+
}
392+
393+
// GetFrameworkComplianceScore returns the compliance score for a given framework (as a percentage)
394+
// The framework compliance score is the average of all controls scores in that framework
395+
func (su *ScoreUtil) GetFrameworkComplianceScore(framework *reportsummary.FrameworkSummary) (frameworkScore float32) {
396+
sumScore := su.ControlsSummariesComplianceScore(&framework.Controls, framework.GetName())
397+
if len(framework.Controls) > 0 {
398+
frameworkScore = sumScore / float32(len(framework.Controls))
399+
}
400+
return frameworkScore
401+
}
402+
403+
// GetControlComplianceScore returns the compliance score for a given control (as a percentage).
404+
func (su *ScoreUtil) GetControlComplianceScore(ctrl reportsummary.IControlSummary, _ /*frameworkName*/ string) (ctrlScore float32) {
405+
resourcesIDs := ctrl.ListResourcesIDs()
406+
passedResourceIDS := resourcesIDs.Passed()
407+
allResourcesIDSIter := resourcesIDs.All()
408+
409+
numOfPassedResources := float32(0)
410+
numOfAllResources := float32(0)
411+
412+
for i := range passedResourceIDS {
413+
if _, ok := su.resources[passedResourceIDS[i]]; ok {
414+
numOfPassedResources += 1
415+
}
416+
}
417+
418+
for allResourcesIDSIter.HasNext() {
419+
resourceID := allResourcesIDSIter.Next()
420+
if _, ok := su.resources[resourceID]; ok {
421+
numOfAllResources += 1
422+
}
423+
}
424+
425+
if numOfAllResources > 0 {
426+
ctrlScore = (numOfPassedResources / numOfAllResources) * 100
427+
} else {
428+
logger.L().Debug("no resources were given for this control, score is 0", helpers.String("controlID", ctrl.GetID()))
429+
}
430+
431+
return ctrlScore
432+
}

score/score_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,3 +783,171 @@ func mockResources(t testing.TB) map[string]workloadinterface.IMetadata {
783783
"resource-10": reporthandling.NewResource(mocks.GetResourceByType(t, "Pod", mocks.WithName("resource-10"))),
784784
}
785785
}
786+
787+
// ================================ compliance score tests ================================
788+
789+
func TestGetControlComplianceScore(t *testing.T) {
790+
var resourceWithFailed, resourceWithPassed helpers.AllLists
791+
resourceWithFailed.Append(apis.StatusFailed, "resource-1", "resource-2")
792+
resourceWithFailed.Append(apis.StatusPassed, "resource-3")
793+
resourceWithPassed.Append(apis.StatusPassed, "resource-4")
794+
795+
var resourceWithFailed2, resourceWithPassed2 helpers.AllLists
796+
resourceWithFailed2.Append(apis.StatusFailed, "resource-5", "resource-6")
797+
resourceWithFailed2.Append(apis.StatusPassed, "resource-7", "resource-8")
798+
resourceWithPassed2.Append(apis.StatusPassed, "resource-9", "resource-10")
799+
t.Parallel()
800+
801+
t.Run("with empty control report", func(t *testing.T) {
802+
t.Parallel()
803+
804+
resources := mockResources(t)
805+
s := ScoreUtil{isDebugMode: true, resources: resources}
806+
controlReport := reportsummary.ControlSummary{
807+
Name: "empty",
808+
ControlID: "empty",
809+
ResourceIDs: helpers.AllLists{},
810+
}
811+
812+
require.Equal(t, float32(0), s.GetControlComplianceScore(&controlReport, ""),
813+
"empty control report should return a score equals to 0",
814+
)
815+
})
816+
817+
t.Run("with control report", func(t *testing.T) {
818+
t.Parallel()
819+
820+
resources := mockResources(t)
821+
s := ScoreUtil{isDebugMode: true, resources: resources}
822+
controlReport := reportsummary.ControlSummary{
823+
Name: "mock-control-1",
824+
ControlID: "mock-control-1",
825+
ResourceIDs: resourceWithFailed2,
826+
}
827+
828+
require.Equal(t, float32(50), s.GetControlComplianceScore(&controlReport, ""),
829+
"control report should return a score equals to 50",
830+
)
831+
})
832+
}
833+
834+
func TestSetPostureReportComplianceScores(t *testing.T) {
835+
t.Parallel()
836+
837+
t.Run("with empty report", func(t *testing.T) {
838+
t.Parallel()
839+
840+
s := NewScore(map[string]workloadinterface.IMetadata{})
841+
report := &v2.PostureReport{
842+
SummaryDetails: reportsummary.SummaryDetails{Frameworks: []reportsummary.FrameworkSummary{{Name: "empty", Controls: reportsummary.ControlSummaries{}}}},
843+
Results: []resourcesresults.Result{},
844+
Resources: []reporthandling.Resource{},
845+
}
846+
847+
require.Errorf(t, s.SetPostureReportComplianceScores(report),
848+
"empty framework should return an error",
849+
)
850+
851+
require.Equal(t, float32(0), report.SummaryDetails.Frameworks[0].Score,
852+
"empty framework should return an error and have a score equals to 0",
853+
)
854+
})
855+
856+
t.Run("with skipped report", func(t *testing.T) {
857+
t.Parallel()
858+
859+
s := NewScore(map[string]workloadinterface.IMetadata{})
860+
report := &v2.PostureReport{
861+
SummaryDetails: reportsummary.SummaryDetails{Frameworks: []reportsummary.FrameworkSummary{{Name: "skipped", Controls: reportsummary.ControlSummaries{
862+
"skipped1": reportsummary.ControlSummary{
863+
Name: "skipped1",
864+
ControlID: "Skippie1",
865+
Description: "skipper",
866+
},
867+
"skipped2": reportsummary.ControlSummary{
868+
Name: "skipped2",
869+
ControlID: "Skippie2",
870+
Description: "skipper",
871+
},
872+
}}}},
873+
Results: []resourcesresults.Result{},
874+
Resources: []reporthandling.Resource{},
875+
}
876+
877+
require.Errorf(t, s.SetPostureReportComplianceScores(report),
878+
"empty framework should return an error",
879+
)
880+
881+
require.Equal(t, float32(0), report.SummaryDetails.Frameworks[0].Score,
882+
"empty framework should return an error and have a score equals to 0",
883+
)
884+
})
885+
886+
t.Run("with mock report", func(t *testing.T) {
887+
t.Parallel()
888+
889+
resources, report := mockPostureReportV2(t)
890+
s := ScoreUtil{
891+
isDebugMode: true,
892+
resources: resources,
893+
}
894+
895+
require.NoErrorf(t, s.SetPostureReportComplianceScores(report),
896+
"mock framework should not return an error",
897+
)
898+
899+
const (
900+
expectedScoreFramework1 = float32(62.577965)
901+
expectedScoreFramework2 = float32(46.42857)
902+
expectedComplianceScoreFramework1 = float32(66.66667)
903+
expectedComplianceScoreFramework2 = float32(75)
904+
expectedSummary = float32(70.833336)
905+
)
906+
907+
t.Run("assert control scores", func(t *testing.T) {
908+
require.Len(t, report.SummaryDetails.Controls, 4)
909+
for _, control := range report.SummaryDetails.Controls {
910+
var expectedForControl float64
911+
912+
switch control.ControlID {
913+
case "control-1":
914+
expectedForControl = 33.333336
915+
case "control-2":
916+
expectedForControl = 100 // passed
917+
case "control-3":
918+
expectedForControl = 50
919+
case "control-4":
920+
expectedForControl = 100 // passed
921+
}
922+
923+
assert.InDeltaf(t, expectedForControl, control.Score, 1e-6,
924+
"unexpected summarized score for control %q", control.ControlID,
925+
)
926+
}
927+
})
928+
929+
t.Run("assert framework scores", func(t *testing.T) {
930+
assert.InDeltaf(t, expectedScoreFramework1, report.SummaryDetails.Frameworks[0].Score, 1e-6,
931+
"unexpected summarized score for framework[0]",
932+
)
933+
assert.InDeltaf(t, expectedScoreFramework2, report.SummaryDetails.Frameworks[1].Score, 1e-6,
934+
"unexpected summarized score for framework[1]",
935+
)
936+
})
937+
938+
t.Run("assert framework compliance scores", func(t *testing.T) {
939+
assert.InDeltaf(t, expectedComplianceScoreFramework1, report.SummaryDetails.Frameworks[0].ComplianceScore, 1e-6,
940+
"unexpected summarized compliance score for framework[0]",
941+
)
942+
assert.InDeltaf(t, expectedComplianceScoreFramework2, report.SummaryDetails.Frameworks[1].ComplianceScore, 1e-6,
943+
"unexpected summarized compliance score for framework[1]",
944+
)
945+
})
946+
947+
t.Run("assert final score", func(t *testing.T) {
948+
assert.InDeltaf(t, expectedSummary, report.SummaryDetails.Score, 1e-6,
949+
"unexpected summarized final score",
950+
)
951+
})
952+
})
953+
}

0 commit comments

Comments
 (0)