Skip to content
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

feat: opt-in creating wg-policy PolicyReport #1030

Merged
merged 1 commit into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func main() {
}

pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
config.DefaultMutableFeatureGate.AddFlag(pflag.CommandLine)
pflag.Parse()

logger := zap.New(zap.UseFlagOptions(&opts))
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
k8s.io/api v0.30.3
k8s.io/apimachinery v0.30.3
k8s.io/client-go v0.30.3
k8s.io/component-base v0.30.3
k8s.io/klog/v2 v2.130.1
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8
sigs.k8s.io/cli-utils v0.37.2
Expand All @@ -29,6 +30,7 @@ require (

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
Expand Down Expand Up @@ -231,6 +233,8 @@ k8s.io/apimachinery v0.30.3 h1:q1laaWCmrszyQuSQCfNB8cFgCuDAoPszKY4ucAjDwHc=
k8s.io/apimachinery v0.30.3/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc=
k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k=
k8s.io/client-go v0.30.3/go.mod h1:8d4pf8vYu665/kUbsxWAQ/JDBNWqfFeZnvFiVdmx89U=
k8s.io/component-base v0.30.3 h1:Ci0UqKWf4oiwy8hr1+E3dsnliKnkMLZMVbWzeorlk7s=
k8s.io/component-base v0.30.3/go.mod h1:C1SshT3rGPCuNtBs14RmVD2xW0EhRSeLvBh7AGk1quA=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
Expand Down
19 changes: 19 additions & 0 deletions internal/config/feature/feature.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package feature

import (
"k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/component-base/featuregate"

"github.com/statnett/image-scanner-operator/internal/config"
)

// PolicyReport will ensure PolicyReport resources are created for completed scan jobs.
const PolicyReport featuregate.Feature = "PolicyReport"

func init() {
runtime.Must(config.DefaultMutableFeatureGate.Add(defaultFeatureGates))
}

var defaultFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{
PolicyReport: {Default: false, PreRelease: featuregate.Alpha},
}
15 changes: 15 additions & 0 deletions internal/config/feature_gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package config

import (
"k8s.io/component-base/featuregate"
)

var (
// DefaultMutableFeatureGate is a mutable version of DefaultFeatureGate.
// Only top-level commands/options setup should make use of this.
DefaultMutableFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate()

// DefaultFeatureGate is a shared global FeatureGate.
// Top-level commands/options setup that needs to modify this feature gate should use DefaultMutableFeatureGate.
DefaultFeatureGate featuregate.FeatureGate = DefaultMutableFeatureGate
)
3 changes: 1 addition & 2 deletions internal/controller/stas/containerimagescan_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,14 @@ func (p *containerImageScanStatusPatch) withScanJob(job *batchv1.Job, successful
return p
}

func (p *containerImageScanStatusPatch) withResults(vulnerabilities []stasv1alpha1.Vulnerability, minSeverity stasv1alpha1.Severity) *containerImageScanStatusPatch {
func (p *containerImageScanStatusPatch) withResults(vulnerabilities []stasv1alpha1.Vulnerability, summary *stasv1alpha1.VulnerabilitySummary, minSeverity stasv1alpha1.Severity) *containerImageScanStatusPatch {
p.minSeverity = &minSeverity

p.patch.Status.Vulnerabilities = make([]stasv1alpha1ac.VulnerabilityApplyConfiguration, len(vulnerabilities))
for i, v := range vulnerabilities {
p.patch.Status.Vulnerabilities[i] = *vulnerabilityPatch(v)
}

summary := vulnerabilitySummary(vulnerabilities, minSeverity)
p.patch.Status.
WithVulnerabilitySummary(stasv1alpha1ac.VulnerabilitySummary().
WithSeverityCount(summary.SeverityCount).
Expand Down
128 changes: 128 additions & 0 deletions internal/controller/stas/policy_report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package stas

import (
"context"
"fmt"
"maps"
"slices"
"strings"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
policyv1alpha2 "sigs.k8s.io/wg-policy-prototypes/policy-report/pkg/api/wgpolicyk8s.io/v1alpha2"

stasv1alpha1 "github.com/statnett/image-scanner-operator/api/stas/v1alpha1"
policyv1alpha2ac "github.com/statnett/image-scanner-operator/internal/wg-policy/applyconfiguration/wgpolicyk8s.io/v1alpha2"
)

func newPolicyReportPatch(cis *stasv1alpha1.ContainerImageScan) *policyReportPatch {
return &policyReportPatch{
cis: cis,
patch: policyv1alpha2ac.PolicyReport(cis.Name, cis.Namespace).
WithScope(
corev1.ObjectReference{
Kind: cis.Spec.Workload.Kind,
Name: cis.Spec.Workload.Name,
},
),
}
}

type policyReportPatch struct {
cis *stasv1alpha1.ContainerImageScan
patch *policyv1alpha2ac.PolicyReportApplyConfiguration
vulnerabilities []stasv1alpha1.Vulnerability
minSeverity stasv1alpha1.Severity
}

func (p *policyReportPatch) withResults(vulnerabilities []stasv1alpha1.Vulnerability, summary *stasv1alpha1.VulnerabilitySummary, minSeverity stasv1alpha1.Severity) *policyReportPatch {
p.vulnerabilities = vulnerabilities
p.minSeverity = minSeverity

p.patch.
WithSummary(policyv1alpha2ac.PolicyReportSummary().
WithSkip(int(summary.SeverityCount[stasv1alpha1.SeverityUnknown.String()])).
WithWarn(int(summary.SeverityCount[stasv1alpha1.SeverityLow.String()] + summary.SeverityCount[stasv1alpha1.SeverityMedium.String()])).
WithFail(int(summary.SeverityCount[stasv1alpha1.SeverityHigh.String()] + summary.SeverityCount[stasv1alpha1.SeverityCritical.String()])))

return p
}

func (p *policyReportPatch) apply(ctx context.Context, c client.Client, scheme *runtime.Scheme) error {
if err := SetControllerReference(p.cis, p.patch.ObjectMetaApplyConfiguration, scheme); err != nil {
return err
}

report := &policyv1alpha2.PolicyReport{}
report.Name = *p.patch.Name
report.Namespace = *p.patch.Namespace

var err error
// Repeat until resource fits in api-server by increasing minimum severity on failure.
for severity := p.minSeverity; severity <= stasv1alpha1.MaxSeverity; severity++ {
p.vulnerabilities = slices.DeleteFunc(p.vulnerabilities, func(v stasv1alpha1.Vulnerability) bool {
return v.Severity < severity
})

p.patch.Results = make([]policyv1alpha2ac.PolicyReportResultApplyConfiguration, len(p.vulnerabilities))
for i, v := range p.vulnerabilities {
p.patch.Results[i] = *policyReportResultPatch(v)
}

err = c.Patch(ctx, report, applyPatch{p.patch}, FieldValidationStrict, client.ForceOwnership, fieldOwner)
if !isResourceTooLargeError(err) {
break
}
}

if err != nil {
return fmt.Errorf("when applying policy report: %w", err)
}

return nil
}

func policyReportResultPatch(v stasv1alpha1.Vulnerability) *policyv1alpha2ac.PolicyReportResultApplyConfiguration {
properties := map[string]string{
"pkgName": v.PkgName,
"pkgPath": v.PkgPath,
"installedVersion": v.InstalledVersion,
"fixedVersion": v.FixedVersion,
"primaryURL": v.PrimaryURL,
}

// Remove properties with empty values to compact report
maps.DeleteFunc(properties, func(k string, v string) bool {
return len(v) == 0
})

return policyv1alpha2ac.PolicyReportResult().
WithCategory("vulnerability scan").
WithSource("image-scanner").
WithPolicy(v.VulnerabilityID).
WithResult(severityToPolicyResult(v.Severity)).
WithSeverity(severityToPolicyResultSeverity(v.Severity)).
WithDescription(v.Title).
WithProperties(properties)
}

func severityToPolicyResultSeverity(severity stasv1alpha1.Severity) policyv1alpha2.PolicyResultSeverity {
switch severity {
case stasv1alpha1.SeverityUnknown:
return ""
default:
return policyv1alpha2.PolicyResultSeverity(strings.ToLower(severity.String()))
}
}

func severityToPolicyResult(severity stasv1alpha1.Severity) policyv1alpha2.PolicyResult {
switch severity {
case stasv1alpha1.SeverityUnknown:
return "skip"
case stasv1alpha1.SeverityLow, stasv1alpha1.SeverityMedium:
return "warn"
default:
return "fail"
}
}
21 changes: 17 additions & 4 deletions internal/controller/stas/scan_job_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (

stasv1alpha1 "github.com/statnett/image-scanner-operator/api/stas/v1alpha1"
"github.com/statnett/image-scanner-operator/internal/config"
"github.com/statnett/image-scanner-operator/internal/config/feature"
"github.com/statnett/image-scanner-operator/internal/controller"
staserrors "github.com/statnett/image-scanner-operator/internal/errors"
"github.com/statnett/image-scanner-operator/internal/pod"
Expand Down Expand Up @@ -178,16 +179,28 @@ func (r *ScanJobReconciler) reconcileCompleteJob(ctx context.Context, job *batch
minSeverity = *cis.Spec.MinSeverity
}

summary := vulnerabilitySummary(vulnerabilities, minSeverity)

if config.DefaultFeatureGate.Enabled(feature.PolicyReport) {
err = newPolicyReportPatch(cis).
withResults(vulnerabilities, summary, minSeverity).
apply(ctx, r.Client, r.Scheme)
if err != nil {
return err
}
}

return newContainerImageStatusPatch(cis).
withScanJob(job, true).
withResults(vulnerabilities, minSeverity).
withResults(vulnerabilities, summary, minSeverity).
apply(ctx, r.Client)
}

func isResourceTooLargeError(err error) bool {
return apierrors.IsInternalError(err) &&
(strings.Contains(err.Error(), "ResourceExhausted") ||
strings.Contains(err.Error(), "request is too large"))
return apierrors.IsRequestEntityTooLargeError(err) ||
apierrors.IsInternalError(err) &&
(strings.Contains(err.Error(), "ResourceExhausted") ||
strings.Contains(err.Error(), "request is too large"))
}

func (r *ScanJobReconciler) reconcileFailedJob(ctx context.Context, job *batchv1.Job, log io.Reader, cis *stasv1alpha1.ContainerImageScan) error {
Expand Down
63 changes: 59 additions & 4 deletions internal/controller/stas/scan_job_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import (
"github.com/stretchr/testify/mock"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/envtest/komega"
policyv1alpha2 "sigs.k8s.io/wg-policy-prototypes/policy-report/pkg/api/wgpolicyk8s.io/v1alpha2"

stasv1alpha1 "github.com/statnett/image-scanner-operator/api/stas/v1alpha1"
"github.com/statnett/image-scanner-operator/internal/trivy"
Expand All @@ -34,7 +36,7 @@ var _ = Describe("Scan Job controller", func() {
})

Context("When scan job is complete", func() {
It("should write scan results back to CIS status", func() {
It("should write scan results back to CIS status and create policy report", func() {
// Create CIS
cis := &stasv1alpha1.ContainerImageScan{}
Expect(yaml.FromFile(path.Join("testdata", "scan-job-successful", "successful-scan-cis.yaml"), cis)).To(Succeed())
Expand Down Expand Up @@ -72,10 +74,28 @@ var _ = Describe("Scan Job controller", func() {
UnfixedCount: 19,
}
Expect(cis.Status.VulnerabilitySummary).To(Equal(expectedVulnSummary))

// Check policy report exists with expected content
report := &policyv1alpha2.PolicyReport{}
report.Name = cis.Name
report.Namespace = cis.Namespace
Expect(komega.Get(report)()).To(Succeed())
Expect(report.Results).To(Not(BeEmpty()))
Expect(report.Results).Should(HaveEach(
WithTransform(func(vulnerability policyv1alpha2.PolicyReportResult) map[string]string {
return vulnerability.Properties
},
Not(BeEmpty()),
),
))
expectedSummary := policyv1alpha2.PolicyReportSummary{
Fail: 19,
}
Expect(report.Summary).To(Equal(expectedSummary))
})

Context("and scan report is too big", func() {
It("should filter report by minimum severity", func() {
It("should filter report by minimum severity and create policy report", func() {
// Create CIS
cis := &stasv1alpha1.ContainerImageScan{}
Expect(yaml.FromFile(path.Join("testdata", "scan-job-successful-long", "cis.yaml"), cis)).To(Succeed())
Expand Down Expand Up @@ -123,10 +143,33 @@ var _ = Describe("Scan Job controller", func() {
UnfixedCount: 5128,
}
Expect(cis.Status.VulnerabilitySummary).To(Equal(expectedVulnSummary))

// Check policy report exists with expected content
report := &policyv1alpha2.PolicyReport{}
report.Name = cis.Name
report.Namespace = cis.Namespace
Expect(komega.Get(report)()).To(Succeed())
Expect(report.Results).To(Not(BeEmpty()))
Expect(report.Results).Should(HaveEach(
WithTransform(func(vulnerability policyv1alpha2.PolicyReportResult) string {
return string(vulnerability.Severity)
},
SatisfyAny(
Equal("critical"),
Equal("high"),
),
),
))
expectedSummary := policyv1alpha2.PolicyReportSummary{
Fail: 3259,
Warn: 7059,
Skip: 77,
}
Expect(report.Summary).To(Equal(expectedSummary))
})
})

Context("but scan report is invalid JSON", func() {
Context("but scan report is invalid JSON and NOT create policy report", func() {
It("should report stalled condition", func() {
// Create CIS
cis := &stasv1alpha1.ContainerImageScan{}
Expand Down Expand Up @@ -156,12 +199,18 @@ var _ = Describe("Scan Job controller", func() {
Expect(condition.Status).To(Equal(metav1.ConditionTrue))
Expect(condition.Reason).To(Equal("ScanReportDecodeError"))
Expect(condition.Message).To(Not(BeEmpty()))

// Check policy report does NOT exist
report := &policyv1alpha2.PolicyReport{}
report.Name = cis.Name
report.Namespace = cis.Namespace
Expect(komega.Get(report)()).Should(WithTransform(errors.ReasonForError, Equal(metav1.StatusReasonNotFound)))
})
})
})

Context("When scan job is failed", func() {
It("should write scan results back to CIS status", func() {
It("should write scan results back to CIS status and NOT create policy report", func() {
// Create CIS
cis := &stasv1alpha1.ContainerImageScan{}
Expect(yaml.FromFile(path.Join("testdata", "scan-job-failed", "failed-scan-cis.yaml"), cis)).To(Succeed())
Expand Down Expand Up @@ -190,6 +239,12 @@ var _ = Describe("Scan Job controller", func() {
Expect(condition.Status).To(Equal(metav1.ConditionTrue))
Expect(condition.Reason).To(Equal("Error"))
Expect(condition.Message).To(Not(BeEmpty()))

// Check policy report does NOT exist
report := &policyv1alpha2.PolicyReport{}
report.Name = cis.Name
report.Namespace = cis.Namespace
Expect(komega.Get(report)()).Should(WithTransform(errors.ReasonForError, Equal(metav1.StatusReasonNotFound)))
})
})
})
Expand Down
Loading