operator/controllers/execution/scans/scan_reconciler.go
// SPDX-FileCopyrightText: the secureCodeBox authors
//
// SPDX-License-Identifier: Apache-2.0
package scancontrollers
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
executionv1 "github.com/secureCodeBox/secureCodeBox/operator/apis/execution/v1"
util "github.com/secureCodeBox/secureCodeBox/operator/utils"
batch "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
resource "k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func (r *ScanReconciler) startScan(scan *executionv1.Scan) error {
ctx := context.Background()
namespacedName := fmt.Sprintf("%s/%s", scan.Namespace, scan.Name)
log := r.Log.WithValues("scan_init", namespacedName)
jobs, err := r.getJobsForScan(scan, client.MatchingLabels{"securecodebox.io/job-type": "scanner"})
if err != nil {
return err
}
if len(jobs.Items) > 0 {
log.V(8).Info("Job already exists. Doesn't need to be created.")
return nil
}
// Add s3 storage finalizer to scan
if !containsString(scan.ObjectMeta.Finalizers, s3StorageFinalizer) {
scan.ObjectMeta.Finalizers = append(scan.ObjectMeta.Finalizers, s3StorageFinalizer)
if err := r.Update(context.Background(), scan); err != nil {
return err
}
}
// get the ScanType for the scan
var scanTypeSpec executionv1.ScanTypeSpec
if scan.Spec.ResourceMode == nil || *scan.Spec.ResourceMode == executionv1.NamespaceLocal {
var scanType executionv1.ScanType
if err := r.Get(ctx, types.NamespacedName{Name: scan.Spec.ScanType, Namespace: scan.Namespace}, &scanType); err != nil {
log.V(7).Info("Unable to fetch ScanType")
scan.Status.State = "Errored"
scan.Status.ErrorDescription = fmt.Sprintf("Configured ScanType '%s' not found in '%s' namespace. You'll likely need to deploy the ScanType.", scan.Spec.ScanType, scan.Namespace)
if err := r.Status().Update(ctx, scan); err != nil {
r.Log.Error(err, "unable to update Scan status")
return err
}
return fmt.Errorf("No ScanType of type '%s' found", scan.Spec.ScanType)
}
log.Info("Matching ScanType Found", "ScanType", scanType.Name)
scanTypeSpec = scanType.Spec
} else if *scan.Spec.ResourceMode == executionv1.ClusterWide {
var clusterScanType executionv1.ClusterScanType
if err := r.Get(ctx, types.NamespacedName{Name: scan.Spec.ScanType}, &clusterScanType); err != nil {
r.Log.Error(err, "Failing around")
log.V(7).Info("Unable to fetch ClusterScanType")
scan.Status.State = "Errored"
scan.Status.ErrorDescription = fmt.Sprintf("Configured ClusterScanType '%s' not found in global ClusterScanTypes. You'll likely need to deploy the ScanType.", scan.Spec.ScanType)
if err := r.Status().Update(ctx, scan); err != nil {
r.Log.Error(err, "unable to update Scan status")
return err
}
return fmt.Errorf("No ClusterScanType of type '%s' found", scan.Spec.ScanType)
}
log.Info("Matching ClusterScanType Found", "ClusterScanType", clusterScanType.Name)
scanTypeSpec = clusterScanType.Spec
}
rules := []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Resources: []string{"pods"},
Verbs: []string{"get"},
},
}
r.ensureServiceAccountExists(
scan.Namespace,
"lurker",
"Lurker is used to extract results from secureCodeBox Scans. It needs rights to get and watch the status of pods to see when the scans have finished.",
rules,
)
job, err := r.constructJobForScan(scan, &scanTypeSpec)
if err != nil {
log.Error(err, "unable to create job object from ScanType / ClusterScanType")
return err
}
log.V(7).Info("Constructed Job object", "job args", strings.Join(job.Spec.Template.Spec.Containers[0].Args, ", "))
if err := r.Create(ctx, job); err != nil {
log.Error(err, "unable to create Job for Scan", "job", job)
return err
}
scan.Status.State = "Scanning"
scan.Status.RawResultType = scanTypeSpec.ExtractResults.Type
scan.Status.RawResultFile = filepath.Base(scanTypeSpec.ExtractResults.Location)
urlExpirationDuration, err := util.GetUrlExpirationDuration(util.ScanController)
if err != nil {
r.Log.Error(err, "Failed to parse scan url expiration")
panic(err)
}
// this time is hardcoded as its not used internally by the scb so it should be longer lasting
findingsDownloadURL, err := r.PresignedGetURL(*scan, "findings.json", 7*24*time.Hour)
if err != nil {
r.Log.Error(err, "Could not get presigned url from s3 or compatible storage provider")
return err
}
scan.Status.FindingDownloadLink = findingsDownloadURL
rawResultDownloadURL, err := r.PresignedGetURL(*scan, scan.Status.RawResultFile, 7*24*time.Hour)
if err != nil {
return err
}
scan.Status.RawResultDownloadLink = rawResultDownloadURL
findingsHeadURL, err := r.PresignedHeadURL(*scan, "findings.json", urlExpirationDuration)
if err != nil {
r.Log.Error(err, "Could not get presigned head url from s3 or compatible storage provider")
return err
}
scan.Status.FindingHeadLink = findingsHeadURL
rawResultsHeadURL, err := r.PresignedHeadURL(*scan, scan.Status.RawResultFile, urlExpirationDuration)
if err != nil {
r.Log.Error(err, "Could not get presigned head url from s3 or compatible storage provider")
return err
}
scan.Status.RawResultHeadLink = rawResultsHeadURL
if err := r.Status().Update(ctx, scan); err != nil {
log.Error(err, "unable to update Scan status")
return err
}
log.V(7).Info("created Job for Scan", "job", job)
return nil
}
// Checking if scan has completed
func (r *ScanReconciler) checkIfScanIsCompleted(scan *executionv1.Scan) error {
ctx := context.Background()
status, err := r.checkIfJobIsCompleted(scan, client.MatchingLabels{"securecodebox.io/job-type": "scanner"})
if err != nil {
return err
}
switch status {
case completed:
r.Log.V(7).Info("Scan is completed")
scan.Status.State = "ScanCompleted"
if err := r.Status().Update(ctx, scan); err != nil {
r.Log.Error(err, "unable to update Scan status")
return err
}
case failed:
scan.Status.State = "Errored"
scan.Status.ErrorDescription = "Failed to run the Scan Container, check k8s Job and its logs for more details"
if err := r.Status().Update(ctx, scan); err != nil {
r.Log.Error(err, "unable to update Scan status")
return err
}
}
// Either Incomplete or Unknown, nothing we can do, other then giving it some more time...
return nil
}
func (r *ScanReconciler) constructJobForScan(scan *executionv1.Scan, scanTypeSpec *executionv1.ScanTypeSpec) (*batch.Job, error) {
filename := filepath.Base(scanTypeSpec.ExtractResults.Location)
urlExpirationDuration, err := util.GetUrlExpirationDuration(util.ScanController)
if err != nil {
r.Log.Error(err, "Failed to parse scan url expiration")
panic(err)
}
resultUploadURL, err := r.PresignedPutURL(*scan, filename, urlExpirationDuration)
if err != nil {
r.Log.Error(err, "Could not get presigned url from s3 or compatible storage provider")
return nil, err
}
if len(scanTypeSpec.JobTemplate.Spec.Template.Spec.Containers) < 1 {
return nil, errors.New("ScanType must at least contain one container in which the scanner is running")
}
labels := scan.ObjectMeta.DeepCopy().Labels
if labels == nil {
labels = make(map[string]string)
}
labels["securecodebox.io/job-type"] = "scanner"
job := &batch.Job{
ObjectMeta: metav1.ObjectMeta{
Labels: labels,
GenerateName: util.TruncateName(fmt.Sprintf("scan-%s", scan.Name)),
Namespace: scan.Namespace,
},
Spec: *scanTypeSpec.JobTemplate.Spec.DeepCopy(),
}
job.Spec.Template.Labels = util.MergeStringMaps(job.Spec.Template.Labels, scan.ObjectMeta.DeepCopy().Labels)
//add recommend kubernetes "managed by" label, to tell the SCB container autodiscovery to ignore the scan pod
podLabels := job.Spec.Template.Labels
if podLabels == nil {
podLabels = make(map[string]string)
}
podLabels["app.kubernetes.io/managed-by"] = "securecodebox"
job.Spec.Template.Labels = podLabels
podAnnotations := scanTypeSpec.JobTemplate.DeepCopy().Annotations
if podAnnotations == nil {
podAnnotations = make(map[string]string)
}
podAnnotations["auto-discovery.securecodebox.io/ignore"] = "true"
// Ensuring that istio doesn't inject a sidecar proxy.
podAnnotations["sidecar.istio.io/inject"] = "false"
job.Spec.Template.Annotations = podAnnotations
if job.Spec.Template.Spec.ServiceAccountName == "" {
job.Spec.Template.Spec.ServiceAccountName = "lurker"
}
// merging volume definition from ScanType (if existing) with standard results volume
if job.Spec.Template.Spec.Containers[0].VolumeMounts == nil || len(job.Spec.Template.Spec.Containers[0].VolumeMounts) == 0 {
job.Spec.Template.Spec.Volumes = []corev1.Volume{}
}
job.Spec.Template.Spec.Volumes = append(job.Spec.Template.Spec.Volumes, corev1.Volume{
Name: "scan-results",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
})
// merging volume mounts (for the primary scanner container) from ScanType (if existing) with standard results volume mount
if job.Spec.Template.Spec.Containers[0].VolumeMounts == nil || len(job.Spec.Template.Spec.Containers[0].VolumeMounts) == 0 {
job.Spec.Template.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{}
}
job.Spec.Template.Spec.Containers[0].VolumeMounts = append(
job.Spec.Template.Spec.Containers[0].VolumeMounts,
corev1.VolumeMount{
Name: "scan-results",
MountPath: "/home/securecodebox/",
},
)
// Get lurker image config from env
lurkerImage := os.Getenv("LURKER_IMAGE")
if lurkerImage == "" {
lurkerImage = "securecodebox/lurker:latest"
}
lurkerPullPolicyRaw := os.Getenv("LURKER_PULL_POLICY")
var lurkerPullPolicy corev1.PullPolicy
switch lurkerPullPolicyRaw {
case "Always":
lurkerPullPolicy = corev1.PullAlways
case "IfNotPresent":
lurkerPullPolicy = corev1.PullIfNotPresent
case "Never":
lurkerPullPolicy = corev1.PullNever
case "":
lurkerPullPolicy = corev1.PullAlways
default:
return nil, fmt.Errorf("Unknown imagePull Policy for lurker: %s", lurkerPullPolicyRaw)
}
falsePointer := false
truePointer := true
lurkerSidecar := &corev1.Container{
Name: "lurker",
Image: lurkerImage,
ImagePullPolicy: lurkerPullPolicy,
Args: []string{
"--container",
job.Spec.Template.Spec.Containers[0].Name,
"--file",
scanTypeSpec.ExtractResults.Location,
"--url",
resultUploadURL,
},
Env: []corev1.EnvVar{
{
Name: "NAMESPACE",
ValueFrom: &corev1.EnvVarSource{
FieldRef: &corev1.ObjectFieldSelector{
FieldPath: "metadata.namespace",
},
},
},
},
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("20m"),
corev1.ResourceMemory: resource.MustParse("20Mi"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("100m"),
corev1.ResourceMemory: resource.MustParse("100Mi"),
},
},
VolumeMounts: []corev1.VolumeMount{
{
Name: "scan-results",
MountPath: "/home/securecodebox/",
ReadOnly: true,
},
},
SecurityContext: &corev1.SecurityContext{
RunAsNonRoot: &truePointer,
AllowPrivilegeEscalation: &falsePointer,
ReadOnlyRootFilesystem: &truePointer,
Privileged: &falsePointer,
Capabilities: &corev1.Capabilities{
Drop: []corev1.Capability{"all"},
},
},
}
customCACertificate, isConfigured := os.LookupEnv("CUSTOM_CA_CERTIFICATE_EXISTING_CERTIFICATE")
r.Log.Info("Configuring customCACerts for lurker", "customCACertificate", customCACertificate, "isConfigured", isConfigured)
if customCACertificate != "" {
job.Spec.Template.Spec.Volumes = append(job.Spec.Template.Spec.Volumes, corev1.Volume{
Name: "ca-certificate",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: customCACertificate,
},
},
},
})
certificateName := os.Getenv("CUSTOM_CA_CERTIFICATE_NAME")
lurkerSidecar.VolumeMounts = append(lurkerSidecar.VolumeMounts, corev1.VolumeMount{
Name: "ca-certificate",
ReadOnly: true,
MountPath: "/etc/ssl/certs/" + certificateName,
SubPath: certificateName,
})
}
job.Spec.Template.Spec.Containers = append(job.Spec.Template.Spec.Containers, *lurkerSidecar)
if err := ctrl.SetControllerReference(scan, job, r.Scheme); err != nil {
return nil, err
}
command := append(
scanTypeSpec.JobTemplate.Spec.Template.Spec.Containers[0].Command,
scan.Spec.Parameters...,
)
// Merge Env from ScanTemplate with Env defined in scan
job.Spec.Template.Spec.Containers[0].Env = append(
job.Spec.Template.Spec.Containers[0].Env,
scan.Spec.Env...,
)
// Merge VolumeMounts from ScanTemplate with VolumeMounts defined in scan
job.Spec.Template.Spec.Containers[0].VolumeMounts = append(
job.Spec.Template.Spec.Containers[0].VolumeMounts,
scan.Spec.VolumeMounts...,
)
// Merge Volumes from ScanTemplate with Volumes defined in scan
job.Spec.Template.Spec.Volumes = append(
job.Spec.Template.Spec.Volumes,
scan.Spec.Volumes...,
)
// Merge initContainers from ScanTemplate with initContainers defined in scan
job.Spec.Template.Spec.InitContainers = append(
job.Spec.Template.Spec.InitContainers,
scan.Spec.InitContainers...,
)
if len(scan.Spec.Resources.Requests) != 0 || len(scan.Spec.Resources.Limits) != 0 {
job.Spec.Template.Spec.Containers[0].Resources = scan.Spec.Resources
}
// Set affinity from ScanTemplate
if scan.Spec.Affinity != nil {
job.Spec.Template.Spec.Affinity = scan.Spec.Affinity
}
// Replace (not merge!) tolerations from template with those specified in the scan job, if there are any.
// (otherwise keep those from the template)
if scan.Spec.Tolerations != nil {
job.Spec.Template.Spec.Tolerations = scan.Spec.Tolerations
}
// Using command over args
job.Spec.Template.Spec.Containers[0].Command = command
job.Spec.Template.Spec.Containers[0].Args = nil
return job, nil
}