feat: add labels-to-copy flag to copy workload labels to reports

Add a new --labels-to-copy CLI flag that allows users to specify which
labels from Kubernetes workloads should be extracted and included in
scan reports. This makes it easier to tie scan results back to app
teams or repositories by including relevant labels like 'app', 'team',
or 'environment' in the report output.

Changes:
- Add LabelsToCopy field to ScanInfo and OPASessionObj structs
- Add --labels-to-copy flag to scan command
- Add ResourceLabels field to PostureReportWithSeverity for JSON output
- Implement extractResourceLabels function to extract specified labels
- Add unit tests for label extraction functionality

Fixes #1660

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: majiayu000 <1835304752@qq.com>
This commit is contained in:
majiayu000
2025-12-31 06:20:29 +08:00
co-authored by Claude Opus 4.5
parent 1225540590
commit 46eb266064
6 changed files with 186 additions and 5 deletions
+1
View File
@@ -93,6 +93,7 @@ func GetScanCommand(ks meta.IKubescape) *cobra.Command {
scanCmd.PersistentFlags().BoolVarP(&scanInfo.EnableRegoPrint, "enable-rego-prints", "", false, "Enable sending to rego prints to the logs (use with debug log level: -l debug)")
scanCmd.PersistentFlags().BoolVarP(&scanInfo.ScanImages, "scan-images", "", false, "Scan resources images")
scanCmd.PersistentFlags().BoolVarP(&scanInfo.UseDefaultMatchers, "use-default-matchers", "", true, "Use default matchers (true) or CPE matchers (false) for image scanning")
scanCmd.PersistentFlags().StringSliceVar(&scanInfo.LabelsToCopy, "labels-to-copy", nil, "Labels to copy from workloads to scan reports for easy identification. e.g: --labels-to-copy=app,team,environment")
scanCmd.PersistentFlags().MarkDeprecated("fail-threshold", "use '--compliance-threshold' flag instead. Flag will be removed at 1.Dec.2023")
scanCmd.PersistentFlags().MarkDeprecated("create-account", "Create account is no longer supported. In case of a missing Account ID and a configured backend server, a new account id will be generated automatically by Kubescape. Feel free to contact the Kubescape maintainers for more information.")
+2
View File
@@ -69,6 +69,7 @@ type OPASessionObj struct {
TopWorkloadsByScore []reporthandling.IResource
TemplateMapping map[string]MappingNodes // Map chart obj to template (only for rendering from path)
TriggeredByCLI bool
LabelsToCopy []string // Labels to copy from workloads to scan reports
}
func NewOPASessionObj(ctx context.Context, frameworks []reporthandling.Framework, k8sResources K8SResources, scanInfo *ScanInfo) *OPASessionObj {
@@ -87,6 +88,7 @@ func NewOPASessionObj(ctx context.Context, frameworks []reporthandling.Framework
OmitRawResources: scanInfo.OmitRawResources,
TriggeredByCLI: scanInfo.TriggeredByCLI,
TemplateMapping: make(map[string]MappingNodes),
LabelsToCopy: scanInfo.LabelsToCopy,
}
}
+1
View File
@@ -140,6 +140,7 @@ type ScanInfo struct {
UseDefaultMatchers bool
ChartPath string
FilePath string
LabelsToCopy []string // Labels to copy from workloads to scan reports
scanningContext *ScanningContext
cleanups []func()
}
@@ -121,8 +121,9 @@ func printConfigurationsScanning(opaSessionObj *cautils.OPASessionObj, imageScan
}
// Convert to PostureReportWithSeverity to add severity field to controls
// and extract specified labels from workloads
finalizedReport := FinalizeResults(opaSessionObj)
reportWithSeverity := ConvertToPostureReportWithSeverity(finalizedReport)
reportWithSeverity := ConvertToPostureReportWithSeverityAndLabels(finalizedReport, opaSessionObj.LabelsToCopy, opaSessionObj.AllResources)
r, err := json.Marshal(reportWithSeverity)
_, err = jp.writer.Write(r)
+50 -4
View File
@@ -61,6 +61,7 @@ type PostureReportWithSeverity struct {
Attributes []reportsummary.PostureAttributes `json:"attributes"`
Results []ResultWithSeverity `json:"results,omitempty"`
Metadata reporthandlingv2.Metadata `json:"metadata,omitempty"`
ResourceLabels map[string]map[string]string `json:"resourceLabels,omitempty"` // map[resourceID]map[labelKey]labelValue - extracted labels from workloads
}
// enrichControlsWithSeverity adds severity field to controls based on scoreFactor
@@ -103,12 +104,24 @@ func enrichResultsWithSeverity(results []resourcesresults.Result, controlSummari
// ConvertToPostureReportWithSeverity converts PostureReport to PostureReportWithSeverity
func ConvertToPostureReportWithSeverity(report *reporthandlingv2.PostureReport) *PostureReportWithSeverity {
return ConvertToPostureReportWithSeverityAndLabels(report, nil, nil)
}
// ConvertToPostureReportWithSeverityAndLabels converts PostureReport to PostureReportWithSeverity
// and extracts specified labels from workloads
func ConvertToPostureReportWithSeverityAndLabels(report *reporthandlingv2.PostureReport, labelsToCopy []string, allResources map[string]workloadinterface.IMetadata) *PostureReportWithSeverity {
if report == nil {
return nil
}
enrichedControls := enrichControlsWithSeverity(report.SummaryDetails.Controls)
enrichedResults := enrichResultsWithSeverity(report.Results, report.SummaryDetails.Controls)
// Extract labels from resources if labelsToCopy is specified
var resourceLabels map[string]map[string]string
if len(labelsToCopy) > 0 && allResources != nil {
resourceLabels = extractResourceLabels(allResources, labelsToCopy)
}
return &PostureReportWithSeverity{
ReportGenerationTime: report.ReportGenerationTime.Format("2006-01-02T15:04:05Z07:00"),
ClusterAPIServerInfo: report.ClusterAPIServerInfo,
@@ -126,13 +139,46 @@ func ConvertToPostureReportWithSeverity(report *reporthandlingv2.PostureReport)
Score: report.SummaryDetails.Score,
ComplianceScore: report.SummaryDetails.ComplianceScore,
},
Resources: report.Resources,
Attributes: report.Attributes,
Results: enrichedResults,
Metadata: report.Metadata,
Resources: report.Resources,
Attributes: report.Attributes,
Results: enrichedResults,
Metadata: report.Metadata,
ResourceLabels: resourceLabels,
}
}
// extractResourceLabels extracts specified labels from all resources
func extractResourceLabels(allResources map[string]workloadinterface.IMetadata, labelsToCopy []string) map[string]map[string]string {
resourceLabels := make(map[string]map[string]string)
for resourceID, resource := range allResources {
// IMetadata doesn't have GetLabels, need to cast to IBasicWorkload
basicWorkload, ok := resource.(workloadinterface.IBasicWorkload)
if !ok {
continue
}
labels := basicWorkload.GetLabels()
if labels == nil {
continue
}
extractedLabels := make(map[string]string)
for _, labelKey := range labelsToCopy {
if value, exists := labels[labelKey]; exists {
extractedLabels[labelKey] = value
}
}
// Only add to result if at least one label was found
if len(extractedLabels) > 0 {
resourceLabels[resourceID] = extractedLabels
}
}
return resourceLabels
}
// FinalizeResults finalize the results objects by copying data from map to lists
func FinalizeResults(data *cautils.OPASessionObj) *reporthandlingv2.PostureReport {
report := reporthandlingv2.PostureReport{
@@ -1,12 +1,14 @@
package printer
import (
"encoding/json"
"testing"
v5 "github.com/anchore/grype/grype/db/v5"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
"github.com/kubescape/k8s-interface/workloadinterface"
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling/printer/v2/prettyprinter/tableprinter/imageprinter"
"github.com/stretchr/testify/assert"
)
@@ -742,3 +744,131 @@ func TestSetSeverityToSummaryMap(t *testing.T) {
})
}
}
func createWorkloadWithLabels(name, namespace string, labels map[string]string) workloadinterface.IMetadata {
// Convert labels to map[string]interface{} for JSON marshaling
labelsInterface := make(map[string]interface{})
for k, v := range labels {
labelsInterface[k] = v
}
obj := map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{
"name": name,
"namespace": namespace,
"labels": labelsInterface,
},
"spec": map[string]interface{}{
"template": map[string]interface{}{
"spec": map[string]interface{}{
"containers": []interface{}{},
},
},
},
}
objBytes, _ := json.Marshal(obj)
workload, _ := workloadinterface.NewWorkload(objBytes)
return workload
}
func TestExtractResourceLabels(t *testing.T) {
tests := []struct {
name string
allResources map[string]workloadinterface.IMetadata
labelsToCopy []string
want map[string]map[string]string
}{
{
name: "empty resources",
allResources: map[string]workloadinterface.IMetadata{},
labelsToCopy: []string{"app", "team"},
want: map[string]map[string]string{},
},
{
name: "empty labels to copy",
allResources: map[string]workloadinterface.IMetadata{},
labelsToCopy: []string{},
want: map[string]map[string]string{},
},
{
name: "single resource with matching labels",
allResources: map[string]workloadinterface.IMetadata{
"resource-1": createWorkloadWithLabels("test-deploy", "default", map[string]string{
"app": "myapp",
"team": "platform",
"version": "v1",
}),
},
labelsToCopy: []string{"app", "team"},
want: map[string]map[string]string{
"resource-1": {
"app": "myapp",
"team": "platform",
},
},
},
{
name: "single resource with partial matching labels",
allResources: map[string]workloadinterface.IMetadata{
"resource-1": createWorkloadWithLabels("test-deploy", "default", map[string]string{
"app": "myapp",
}),
},
labelsToCopy: []string{"app", "team"},
want: map[string]map[string]string{
"resource-1": {
"app": "myapp",
},
},
},
{
name: "single resource with no matching labels",
allResources: map[string]workloadinterface.IMetadata{
"resource-1": createWorkloadWithLabels("test-deploy", "default", map[string]string{
"version": "v1",
}),
},
labelsToCopy: []string{"app", "team"},
want: map[string]map[string]string{},
},
{
name: "multiple resources with various labels",
allResources: map[string]workloadinterface.IMetadata{
"resource-1": createWorkloadWithLabels("deploy-1", "default", map[string]string{
"app": "app1",
"team": "team1",
}),
"resource-2": createWorkloadWithLabels("deploy-2", "default", map[string]string{
"app": "app2",
}),
"resource-3": createWorkloadWithLabels("deploy-3", "default", map[string]string{
"version": "v1",
}),
},
labelsToCopy: []string{"app", "team"},
want: map[string]map[string]string{
"resource-1": {
"app": "app1",
"team": "team1",
},
"resource-2": {
"app": "app2",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractResourceLabels(tt.allResources, tt.labelsToCopy)
assert.Equal(t, len(tt.want), len(got), "number of resources with extracted labels should match")
for resourceID, wantLabels := range tt.want {
gotLabels, ok := got[resourceID]
assert.True(t, ok, "resource %s should be present in result", resourceID)
assert.Equal(t, wantLabels, gotLabels, "labels for resource %s should match", resourceID)
}
})
}
}