mirror of
https://github.com/kubescape/kubescape.git
synced 2026-04-15 06:58:11 +00:00
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>
480 lines
16 KiB
Go
480 lines
16 KiB
Go
package cautils
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/kubescape/backend/pkg/versioncheck"
|
|
giturl "github.com/kubescape/go-git-url"
|
|
"github.com/kubescape/go-logger"
|
|
"github.com/kubescape/go-logger/helpers"
|
|
"github.com/kubescape/k8s-interface/k8sinterface"
|
|
"github.com/kubescape/kubescape/v3/core/cautils/getter"
|
|
apisv1 "github.com/kubescape/opa-utils/httpserver/apis/v1"
|
|
"github.com/kubescape/opa-utils/objectsenvelopes"
|
|
"github.com/kubescape/opa-utils/reporthandling"
|
|
reporthandlingv2 "github.com/kubescape/opa-utils/reporthandling/v2"
|
|
)
|
|
|
|
type ScanningContext string
|
|
|
|
const (
|
|
ContextCluster ScanningContext = "cluster"
|
|
ContextFile ScanningContext = "single-file"
|
|
ContextDir ScanningContext = "local-dir"
|
|
ContextGitLocal ScanningContext = "git-local"
|
|
ContextGitRemote ScanningContext = "git-remote"
|
|
)
|
|
|
|
const ( // deprecated
|
|
ScopeCluster = "cluster"
|
|
)
|
|
const (
|
|
localControlInputsFilename string = "controls-inputs.json"
|
|
LocalExceptionsFilename string = "exceptions.json"
|
|
LocalAttackTracksFilename string = "attack-tracks.json"
|
|
)
|
|
|
|
type BoolPtrFlag struct {
|
|
valPtr *bool
|
|
}
|
|
|
|
func NewBoolPtr(b *bool) BoolPtrFlag {
|
|
return BoolPtrFlag{valPtr: b}
|
|
}
|
|
|
|
func (bpf *BoolPtrFlag) Type() string {
|
|
return "bool"
|
|
}
|
|
|
|
func (bpf *BoolPtrFlag) String() string {
|
|
if bpf.valPtr != nil {
|
|
return fmt.Sprintf("%v", *bpf.valPtr)
|
|
}
|
|
return ""
|
|
}
|
|
func (bpf *BoolPtrFlag) Get() *bool {
|
|
return bpf.valPtr
|
|
}
|
|
func (bpf *BoolPtrFlag) GetBool() bool {
|
|
if bpf.valPtr == nil {
|
|
return false
|
|
}
|
|
return *bpf.valPtr
|
|
}
|
|
|
|
func (bpf *BoolPtrFlag) SetBool(val bool) {
|
|
bpf.valPtr = &val
|
|
}
|
|
|
|
func (bpf *BoolPtrFlag) Set(val string) error {
|
|
switch val {
|
|
case "true":
|
|
bpf.SetBool(true)
|
|
case "false":
|
|
bpf.SetBool(false)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TODO - UPDATE
|
|
type ViewTypes string
|
|
type EnvScopeTypes string
|
|
type ManageClusterTypes string
|
|
|
|
const (
|
|
ResourceViewType ViewTypes = "resource"
|
|
SecurityViewType ViewTypes = "security"
|
|
ControlViewType ViewTypes = "control"
|
|
)
|
|
|
|
type PolicyIdentifier struct {
|
|
Identifier string // policy Identifier e.g. c-0012 for control, nsa,mitre for frameworks
|
|
Kind apisv1.NotificationPolicyKind // policy kind e.g. Framework,Control,Rule
|
|
}
|
|
|
|
type ScanInfo struct {
|
|
Getters // TODO - remove from object
|
|
PolicyIdentifier []PolicyIdentifier // TODO - remove from object
|
|
UseExceptions string // Load file with exceptions configuration
|
|
ControlsInputs string // Load file with inputs for controls
|
|
AttackTracks string // Load file with attack tracks
|
|
UseFrom []string // Load framework from local file (instead of download). Use when running offline
|
|
UseDefault bool // Load framework from cached file (instead of download). Use when running offline
|
|
UseArtifactsFrom string // Load artifacts from local path. Use when running offline
|
|
VerboseMode bool // Display all the input resources and not only failed resources
|
|
View string //
|
|
Format string // Format results (table, json, junit ...)
|
|
Output string // Store results in an output file, Output file name
|
|
FormatVersion string // Output object can be different between versions, this is for testing and backward compatibility
|
|
CustomClusterName string // Set the custom name of the cluster
|
|
ExcludedNamespaces string // used for host scanner namespace
|
|
IncludeNamespaces string //
|
|
InputPatterns []string // Yaml files input patterns
|
|
Silent bool // Silent mode - Do not print progress logs
|
|
FailThreshold float32 // DEPRECATED - Failure score threshold
|
|
ComplianceThreshold float32 // Compliance score threshold
|
|
FailThresholdSeverity string // Severity at and above which the command should fail
|
|
Submit bool // Submit results to Kubescape Cloud BE
|
|
ScanID string // Report id of the current scan
|
|
HostSensorEnabled BoolPtrFlag // Deploy Kubescape K8s host scanner to collect data from certain controls
|
|
HostSensorYamlPath string // Path to hostsensor file
|
|
Local bool // Do not submit results
|
|
AccountID string // account ID
|
|
AccessKey string // access key
|
|
FrameworkScan bool // false if scanning control
|
|
ScanAll bool // true if scan all frameworks
|
|
OmitRawResources bool // true if omit raw resources from the output
|
|
PrintAttackTree bool // true if print attack tree
|
|
EnableRegoPrint bool // true if print rego
|
|
ScanObject *objectsenvelopes.ScanObject // identifies a single resource (k8s object) to be scanned
|
|
IsDeletedScanObject bool // indicates whether the ScanObject is a deleted K8S resource
|
|
TriggeredByCLI bool // indicates whether the scan was triggered by the CLI
|
|
ScanType ScanTypes
|
|
ScanImages bool
|
|
UseDefaultMatchers bool
|
|
ChartPath string
|
|
FilePath string
|
|
LabelsToCopy []string // Labels to copy from workloads to scan reports
|
|
scanningContext *ScanningContext
|
|
cleanups []func()
|
|
}
|
|
|
|
type Getters struct {
|
|
ExceptionsGetter getter.IExceptionsGetter
|
|
ControlsInputsGetter getter.IControlsInputsGetter
|
|
PolicyGetter getter.IPolicyGetter
|
|
AttackTracksGetter getter.IAttackTracksGetter
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) Init(ctx context.Context) {
|
|
scanInfo.setUseFrom()
|
|
scanInfo.setUseArtifactsFrom(ctx)
|
|
if scanInfo.ScanID == "" {
|
|
scanInfo.ScanID = uuid.NewString()
|
|
}
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) Cleanup() {
|
|
for _, cleanup := range scanInfo.cleanups {
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) setUseArtifactsFrom(ctx context.Context) {
|
|
if scanInfo.UseArtifactsFrom == "" {
|
|
return
|
|
}
|
|
// UseArtifactsFrom must be a path without a filename
|
|
dir, file := filepath.Split(scanInfo.UseArtifactsFrom)
|
|
if dir == "" {
|
|
scanInfo.UseArtifactsFrom = file
|
|
} else if strings.Contains(file, ".json") {
|
|
scanInfo.UseArtifactsFrom = dir
|
|
}
|
|
// set frameworks files
|
|
files, err := os.ReadDir(scanInfo.UseArtifactsFrom)
|
|
if err != nil {
|
|
logger.L().Ctx(ctx).Fatal("failed to read files from directory", helpers.String("dir", scanInfo.UseArtifactsFrom), helpers.Error(err))
|
|
}
|
|
framework := &reporthandling.Framework{}
|
|
for _, f := range files {
|
|
filePath := filepath.Join(scanInfo.UseArtifactsFrom, f.Name())
|
|
file, err := os.ReadFile(filePath)
|
|
if err == nil {
|
|
if err := json.Unmarshal(file, framework); err == nil {
|
|
scanInfo.UseFrom = append(scanInfo.UseFrom, filepath.Join(scanInfo.UseArtifactsFrom, f.Name()))
|
|
}
|
|
}
|
|
}
|
|
// set config-inputs file
|
|
scanInfo.ControlsInputs = filepath.Join(scanInfo.UseArtifactsFrom, localControlInputsFilename)
|
|
// set exceptions
|
|
scanInfo.UseExceptions = filepath.Join(scanInfo.UseArtifactsFrom, LocalExceptionsFilename)
|
|
|
|
// set attack tracks
|
|
scanInfo.AttackTracks = filepath.Join(scanInfo.UseArtifactsFrom, LocalAttackTracksFilename)
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) setUseFrom() {
|
|
if scanInfo.UseDefault {
|
|
for _, policy := range scanInfo.PolicyIdentifier {
|
|
scanInfo.UseFrom = append(scanInfo.UseFrom, getter.GetDefaultPath(policy.Identifier+".json"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Formats returns a slice of output formats that have been requested for a given scan
|
|
func (scanInfo *ScanInfo) Formats() []string {
|
|
formatString := scanInfo.Format
|
|
if formatString != "" {
|
|
return strings.Split(scanInfo.Format, ",")
|
|
} else {
|
|
return []string{}
|
|
}
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) SetScanType(scanType ScanTypes) {
|
|
scanInfo.ScanType = scanType
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) SetPolicyIdentifiers(policies []string, kind apisv1.NotificationPolicyKind) {
|
|
for _, policy := range policies {
|
|
if !scanInfo.contains(policy) {
|
|
newPolicy := PolicyIdentifier{}
|
|
newPolicy.Kind = kind
|
|
newPolicy.Identifier = policy
|
|
scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) contains(policyName string) bool {
|
|
for _, policy := range scanInfo.PolicyIdentifier {
|
|
if policy.Identifier == policyName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func scanInfoToScanMetadata(ctx context.Context, scanInfo *ScanInfo) *reporthandlingv2.Metadata {
|
|
metadata := &reporthandlingv2.Metadata{}
|
|
|
|
metadata.ScanMetadata.Formats = []string{scanInfo.Format}
|
|
metadata.ScanMetadata.FormatVersion = scanInfo.FormatVersion
|
|
metadata.ScanMetadata.Submit = scanInfo.Submit
|
|
|
|
// TODO - Add excluded and included namespaces
|
|
// if len(scanInfo.ExcludedNamespaces) > 1 {
|
|
// opaSessionObj.Metadata.ScanMetadata.ExcludedNamespaces = strings.Split(scanInfo.ExcludedNamespaces[1:], ",")
|
|
// }
|
|
// if len(scanInfo.IncludeNamespaces) > 1 {
|
|
// opaSessionObj.Metadata.ScanMetadata.IncludeNamespaces = strings.Split(scanInfo.IncludeNamespaces[1:], ",")
|
|
// }
|
|
|
|
// scan type
|
|
if len(scanInfo.PolicyIdentifier) > 0 {
|
|
metadata.ScanMetadata.TargetType = string(scanInfo.PolicyIdentifier[0].Kind)
|
|
}
|
|
// append frameworks
|
|
for _, policy := range scanInfo.PolicyIdentifier {
|
|
metadata.ScanMetadata.TargetNames = append(metadata.ScanMetadata.TargetNames, policy.Identifier)
|
|
}
|
|
|
|
metadata.ScanMetadata.KubescapeVersion = versioncheck.BuildNumber
|
|
metadata.ScanMetadata.VerboseMode = scanInfo.VerboseMode
|
|
metadata.ScanMetadata.FailThreshold = scanInfo.FailThreshold
|
|
metadata.ScanMetadata.ComplianceThreshold = scanInfo.ComplianceThreshold
|
|
metadata.ScanMetadata.HostScanner = scanInfo.HostSensorEnabled.GetBool()
|
|
metadata.ScanMetadata.VerboseMode = scanInfo.VerboseMode
|
|
metadata.ScanMetadata.ControlsInputs = scanInfo.ControlsInputs
|
|
|
|
switch scanInfo.GetScanningContext() {
|
|
case ContextCluster:
|
|
// cluster
|
|
metadata.ScanMetadata.ScanningTarget = reporthandlingv2.Cluster
|
|
case ContextFile:
|
|
// local file
|
|
metadata.ScanMetadata.ScanningTarget = reporthandlingv2.File
|
|
case ContextGitLocal:
|
|
// local-git
|
|
metadata.ScanMetadata.ScanningTarget = reporthandlingv2.GitLocal
|
|
case ContextGitRemote:
|
|
// remote
|
|
metadata.ScanMetadata.ScanningTarget = reporthandlingv2.Repo
|
|
case ContextDir:
|
|
// directory
|
|
metadata.ScanMetadata.ScanningTarget = reporthandlingv2.Directory
|
|
|
|
}
|
|
|
|
scanInfo.setContextMetadata(ctx, &metadata.ContextMetadata)
|
|
|
|
return metadata
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) GetInputFiles() string {
|
|
if len(scanInfo.InputPatterns) > 0 {
|
|
return scanInfo.InputPatterns[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) GetScanningContext() ScanningContext {
|
|
if scanInfo.scanningContext == nil {
|
|
scanningContext := scanInfo.getScanningContext(scanInfo.GetInputFiles())
|
|
scanInfo.scanningContext = &scanningContext
|
|
}
|
|
return *scanInfo.scanningContext
|
|
}
|
|
|
|
// getScanningContext get scanning context from the input param
|
|
// this function should be called only once. Call GetScanningContext() to get the scanning context
|
|
func (scanInfo *ScanInfo) getScanningContext(input string) ScanningContext {
|
|
// cluster
|
|
if input == "" {
|
|
return ContextCluster
|
|
}
|
|
|
|
// Check if input is a URL (http:// or https://)
|
|
isURL := isHTTPURL(input)
|
|
|
|
// git url
|
|
if _, err := giturl.NewGitURL(input); err == nil {
|
|
if repo, err := CloneGitRepo(&input); err == nil {
|
|
if _, err := NewLocalGitRepository(repo); err == nil {
|
|
scanInfo.cleanups = append(scanInfo.cleanups, func() {
|
|
_ = os.RemoveAll(repo)
|
|
})
|
|
return ContextGitRemote
|
|
}
|
|
}
|
|
// If giturl.NewGitURL succeeded but cloning failed, the input is a git URL
|
|
// that couldn't be cloned. Don't treat it as a local path.
|
|
// The clone error was already logged by CloneGitRepo.
|
|
// Return ContextDir to prevent the URL from being joined with the current directory
|
|
// and to trigger a "no files found" error with the actual URL (not a mangled path).
|
|
return ContextDir
|
|
}
|
|
|
|
// If it looks like a URL but wasn't recognized as a git URL, still don't treat it as a local path
|
|
if isURL {
|
|
logger.L().Error("URL provided but not recognized as a valid git repository. Ensure the URL is correct and accessible", helpers.String("url", input))
|
|
return ContextDir
|
|
}
|
|
|
|
if !filepath.IsAbs(input) { // parse path
|
|
if o, err := os.Getwd(); err == nil {
|
|
input = filepath.Join(o, input)
|
|
}
|
|
}
|
|
|
|
// local git repo
|
|
if _, err := NewLocalGitRepository(input); err == nil {
|
|
return ContextGitLocal
|
|
}
|
|
|
|
// single file
|
|
if isFile(input) {
|
|
return ContextFile
|
|
}
|
|
|
|
// dir/glob
|
|
return ContextDir
|
|
}
|
|
|
|
func (scanInfo *ScanInfo) setContextMetadata(ctx context.Context, contextMetadata *reporthandlingv2.ContextMetadata) {
|
|
input := scanInfo.GetInputFiles()
|
|
switch scanInfo.GetScanningContext() {
|
|
case ContextCluster:
|
|
contextMetadata.ClusterContextMetadata = &reporthandlingv2.ClusterMetadata{
|
|
ContextName: k8sinterface.GetContextName(),
|
|
}
|
|
case ContextDir:
|
|
contextMetadata.DirectoryContextMetadata = &reporthandlingv2.DirectoryContextMetadata{
|
|
BasePath: getAbsPath(input),
|
|
HostName: getHostname(),
|
|
}
|
|
// add repo context for submitting
|
|
contextMetadata.RepoContextMetadata = &reporthandlingv2.RepoContextMetadata{
|
|
Provider: "none",
|
|
Repo: fmt.Sprintf("path@%s", getAbsPath(input)),
|
|
Owner: getHostname(),
|
|
Branch: "none",
|
|
DefaultBranch: "none",
|
|
LocalRootPath: getAbsPath(input),
|
|
}
|
|
|
|
case ContextFile:
|
|
contextMetadata.FileContextMetadata = &reporthandlingv2.FileContextMetadata{
|
|
FilePath: getAbsPath(input),
|
|
HostName: getHostname(),
|
|
}
|
|
// add repo context for submitting
|
|
contextMetadata.RepoContextMetadata = &reporthandlingv2.RepoContextMetadata{
|
|
Provider: "none",
|
|
Repo: fmt.Sprintf("file@%s", getAbsPath(input)),
|
|
Owner: getHostname(),
|
|
Branch: "none",
|
|
DefaultBranch: "none",
|
|
LocalRootPath: getAbsPath(input),
|
|
}
|
|
case ContextGitLocal:
|
|
// local
|
|
repoContext, err := metadataGitLocal(input)
|
|
if err != nil {
|
|
logger.L().Ctx(ctx).Warning("in setContextMetadata", helpers.Interface("case", ContextGitLocal), helpers.Error(err))
|
|
}
|
|
contextMetadata.RepoContextMetadata = repoContext
|
|
case ContextGitRemote:
|
|
// remote
|
|
repoContext, err := metadataGitLocal(GetClonedPath(input))
|
|
if err != nil {
|
|
logger.L().Ctx(ctx).Warning("in setContextMetadata", helpers.Interface("case", ContextGitRemote), helpers.Error(err))
|
|
}
|
|
contextMetadata.RepoContextMetadata = repoContext
|
|
}
|
|
}
|
|
|
|
func metadataGitLocal(input string) (*reporthandlingv2.RepoContextMetadata, error) {
|
|
gitParser, err := NewLocalGitRepository(input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w", err)
|
|
}
|
|
remoteURL, err := gitParser.GetRemoteUrl()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w", err)
|
|
}
|
|
repoContext := &reporthandlingv2.RepoContextMetadata{}
|
|
gitParserURL, err := giturl.NewGitURL(remoteURL)
|
|
if err != nil {
|
|
return repoContext, fmt.Errorf("%w", err)
|
|
}
|
|
gitParserURL.SetBranchName(gitParser.GetBranchName())
|
|
|
|
repoContext.Provider = gitParserURL.GetProvider()
|
|
repoContext.Repo = gitParserURL.GetRepoName()
|
|
repoContext.Owner = gitParserURL.GetOwnerName()
|
|
repoContext.Branch = gitParserURL.GetBranchName()
|
|
repoContext.RemoteURL = gitParserURL.GetURL().String()
|
|
|
|
commit, err := gitParser.GetLastCommit()
|
|
if err != nil {
|
|
return repoContext, fmt.Errorf("%w", err)
|
|
}
|
|
repoContext.LastCommit = reporthandling.LastCommit{
|
|
Hash: commit.SHA,
|
|
Date: commit.Committer.Date,
|
|
CommitterName: commit.Committer.Name,
|
|
}
|
|
repoContext.LocalRootPath, _ = gitParser.GetRootDir()
|
|
|
|
return repoContext, nil
|
|
}
|
|
func getHostname() string {
|
|
if h, e := os.Hostname(); e == nil {
|
|
return h
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getAbsPath(p string) string {
|
|
if !filepath.IsAbs(p) { // parse path
|
|
if o, err := os.Getwd(); err == nil {
|
|
return filepath.Join(o, p)
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// isHTTPURL checks if the input string is an HTTP or HTTPS URL
|
|
func isHTTPURL(input string) bool {
|
|
return strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://")
|
|
}
|