Merge pull request #1901 from kubescape/copilot/fix-cis-framework-metrics-export

Fix CIS framework metrics not exported to Prometheus /v1/metrics endpoint
This commit is contained in:
Matthias Bertschy
2025-12-05 09:45:06 +01:00
committed by GitHub
2 changed files with 129 additions and 19 deletions

View File

@@ -6,18 +6,27 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/gorilla/schema"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/kubescape/v3/core/cautils"
"github.com/kubescape/kubescape/v3/core/cautils/getter"
apisv1 "github.com/kubescape/opa-utils/httpserver/apis/v1"
utilsapisv1 "github.com/kubescape/opa-utils/httpserver/apis/v1"
utilsmetav1 "github.com/kubescape/opa-utils/httpserver/meta/v1"
"go.opentelemetry.io/otel/trace"
)
// MetricsQueryParams query params for metrics endpoint
type MetricsQueryParams struct {
// Frameworks is a comma-separated list of frameworks to scan
// Example: "nsa,mitre,cis-v1.10.0"
// If not provided, all available frameworks will be scanned
Frameworks string `schema:"frameworks" json:"frameworks"`
}
// Metrics http listener for prometheus support
func (handler *HTTPHandler) Metrics(w http.ResponseWriter, r *http.Request) {
@@ -25,8 +34,16 @@ func (handler *HTTPHandler) Metrics(w http.ResponseWriter, r *http.Request) {
handler.state.setBusy(scanID)
defer handler.state.setNotBusy(scanID)
// Parse query parameters
metricsQueryParams := &MetricsQueryParams{}
if err := schema.NewDecoder().Decode(metricsQueryParams, r.URL.Query()); err != nil {
w.WriteHeader(http.StatusBadRequest)
handler.writeError(w, fmt.Errorf("failed to parse query params, reason: %s", err.Error()), scanID)
return
}
resultsFile := filepath.Join(OutputDir, scanID)
scanInfo := getPrometheusDefaultScanCommand(scanID, resultsFile)
scanInfo := getPrometheusDefaultScanCommand(scanID, resultsFile, metricsQueryParams.Frameworks)
scanParams := &scanRequestParams{
scanQueryParams: &ScanQueryParams{
@@ -69,19 +86,41 @@ func (handler *HTTPHandler) Metrics(w http.ResponseWriter, r *http.Request) {
w.Write(f)
}
func getPrometheusDefaultScanCommand(scanID, resultsFile string) *cautils.ScanInfo {
func getPrometheusDefaultScanCommand(scanID, resultsFile, frameworksParam string) *cautils.ScanInfo {
scanInfo := defaultScanInfo()
scanInfo.UseArtifactsFrom = getter.DefaultLocalStore // Load files from cache (this will prevent kubescape fom downloading the artifacts every time)
scanInfo.UseArtifactsFrom = getter.DefaultLocalStore // Load files from cache (this will prevent kubescape from downloading the artifacts every time)
scanInfo.Submit = false // do not submit results every scan
scanInfo.Local = true // do not submit results every scan
scanInfo.FrameworkScan = true
scanInfo.HostSensorEnabled.SetBool(false) // disable host scanner
scanInfo.ScanAll = false // do not scan all frameworks
scanInfo.ScanID = scanID // scan ID
scanInfo.FailThreshold = 100 // Do not fail scanning
scanInfo.ComplianceThreshold = 0 // Do not fail scanning
scanInfo.Output = resultsFile // results output
scanInfo.Format = envToString("KS_FORMAT", "prometheus") // default output should be json
scanInfo.SetPolicyIdentifiers(getter.NativeFrameworks, apisv1.KindFramework)
scanInfo.Format = envToString("KS_FORMAT", "prometheus") // default output format is prometheus
// Check if specific frameworks are requested via query parameter
if frameworksParam != "" {
// Scan specific frameworks (comma-separated list)
frameworks := splitAndTrim(frameworksParam, ",")
scanInfo.SetPolicyIdentifiers(frameworks, utilsapisv1.KindFramework)
} else {
// Default: scan all available frameworks (including CIS)
scanInfo.ScanAll = true
// Framework identifiers will be set dynamically by the scan process when ScanAll is true
}
return scanInfo
}
// splitAndTrim splits a string by delimiter and trims whitespace from each element
func splitAndTrim(s, sep string) []string {
parts := strings.Split(s, sep)
result := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}

View File

@@ -9,17 +9,88 @@ import (
)
func TestGetPrometheusDefaultScanCommand(t *testing.T) {
scanID := "1234"
outputFile := filepath.Join(OutputDir, scanID)
scanInfo := getPrometheusDefaultScanCommand(scanID, outputFile)
t.Run("default behavior - scan all frameworks", func(t *testing.T) {
scanID := "1234"
outputFile := filepath.Join(OutputDir, scanID)
scanInfo := getPrometheusDefaultScanCommand(scanID, outputFile, "")
assert.Equal(t, scanID, scanInfo.ScanID)
assert.Equal(t, outputFile, scanInfo.Output)
assert.Equal(t, "prometheus", scanInfo.Format)
assert.False(t, scanInfo.Submit)
assert.True(t, scanInfo.Local)
assert.True(t, scanInfo.FrameworkScan)
assert.False(t, scanInfo.ScanAll)
assert.False(t, scanInfo.HostSensorEnabled.GetBool())
assert.Equal(t, getter.DefaultLocalStore, scanInfo.UseArtifactsFrom)
assert.Equal(t, scanID, scanInfo.ScanID)
assert.Equal(t, outputFile, scanInfo.Output)
assert.Equal(t, "prometheus", scanInfo.Format)
assert.False(t, scanInfo.Submit)
assert.True(t, scanInfo.Local)
assert.True(t, scanInfo.FrameworkScan)
assert.True(t, scanInfo.ScanAll) // Scan all available frameworks by default
assert.False(t, scanInfo.HostSensorEnabled.GetBool())
assert.Equal(t, getter.DefaultLocalStore, scanInfo.UseArtifactsFrom)
})
t.Run("specific frameworks via query parameter", func(t *testing.T) {
scanID := "5678"
outputFile := filepath.Join(OutputDir, scanID)
scanInfo := getPrometheusDefaultScanCommand(scanID, outputFile, "nsa,mitre,cis-v1.10.0")
assert.Equal(t, scanID, scanInfo.ScanID)
assert.Equal(t, outputFile, scanInfo.Output)
assert.Equal(t, "prometheus", scanInfo.Format)
assert.False(t, scanInfo.Submit)
assert.True(t, scanInfo.Local)
assert.True(t, scanInfo.FrameworkScan)
assert.False(t, scanInfo.ScanAll) // Don't scan all when specific frameworks are set
assert.False(t, scanInfo.HostSensorEnabled.GetBool())
assert.Equal(t, getter.DefaultLocalStore, scanInfo.UseArtifactsFrom)
// Verify specific frameworks are set
assert.Len(t, scanInfo.PolicyIdentifier, 3)
assert.Equal(t, "nsa", scanInfo.PolicyIdentifier[0].Identifier)
assert.Equal(t, "mitre", scanInfo.PolicyIdentifier[1].Identifier)
assert.Equal(t, "cis-v1.10.0", scanInfo.PolicyIdentifier[2].Identifier)
})
}
func TestSplitAndTrim(t *testing.T) {
tests := []struct {
name string
input string
sep string
expected []string
}{
{
name: "comma-separated with spaces",
input: "nsa, mitre, cis-v1.10.0",
sep: ",",
expected: []string{"nsa", "mitre", "cis-v1.10.0"},
},
{
name: "no spaces",
input: "nsa,mitre,cis-v1.10.0",
sep: ",",
expected: []string{"nsa", "mitre", "cis-v1.10.0"},
},
{
name: "single item",
input: "nsa",
sep: ",",
expected: []string{"nsa"},
},
{
name: "empty string",
input: "",
sep: ",",
expected: []string{},
},
{
name: "whitespace only",
input: " , , ",
sep: ",",
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := splitAndTrim(tt.input, tt.sep)
assert.Equal(t, tt.expected, result)
})
}
}