diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 9f71d244..ac61ffa9 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -27,10 +27,8 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - - name: DEVGUARD_API_URL - value: "https://api.devguard.org" - - name: DEVGUARD_PROJECT_NAME - value: "devguard-operator" + - name: DEVGUARD_PROJECT_URL + value: "https://api.main.devguard.org/api/v1/organizations/l3montree-cybersecurity/projects/cluster/dynamic-project" ports: - containerPort: 8081 name: http diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml index a721bf47..5bdb09f8 100644 --- a/deploy/rbac.yaml +++ b/deploy/rbac.yaml @@ -2,6 +2,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: devguard-operator + namespace: devguard --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -29,6 +30,12 @@ rules: - get - update - watch +- apiGroups: + - apps + resources: + - replicasets + verbs: + - get --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/devguard_target.go b/devguard_target.go index 677695a9..25a15911 100644 --- a/devguard_target.go +++ b/devguard_target.go @@ -23,22 +23,44 @@ type DevGuardTarget struct { } type DevGuardRequest struct { - Verb string `json:"verb"` - ProjectExternalEntityID string `json:"projectExternalEntityId"` - ProjectName string `json:"projectName"` - AssetExternalEntityID string `json:"assetExternalEntityId"` - AssetName string `json:"assetName"` - AssetVersion string `json:"assetVersion"` - Sbom json.RawMessage `json:"sbom,omitempty"` + Verb string `json:"verb"` + ProjectExternalEntityID string `json:"projectExternalEntityId"` + ProjectName string `json:"projectName"` + ProjectDescription string `json:"projectDescription,omitempty"` + SubProjectExternalEntityID string `json:"subProjectExternalEntityId,omitempty"` + SubProjectName string `json:"subProjectName,omitempty"` + SubProjectDescription string `json:"subProjectDescription,omitempty"` + AssetExternalEntityID string `json:"assetExternalEntityId"` + AssetName string `json:"assetName"` + AssetDescription string `json:"assetDescription,omitempty"` + AssetVersionName string `json:"assetVersionName,omitempty"` + Artifact string `json:"artifact,omitempty"` + Sbom json.RawMessage `json:"sbom,omitempty"` } type projectAssetsResponse struct { ProjectExternalEntityID string `json:"projectExternalEntityId"` ProjectName string `json:"projectName"` - Assets []struct { - AssetExternalEntityID string `json:"assetExternalEntityId"` - AssetName string `json:"assetName"` - Versions []string `json:"versions"` + SubProjects []struct { + SubProjectExternalEntityID string `json:"subProjectExternalEntityId,omitempty"` + SubProjectName string `json:"subProjectName,omitempty"` + SubProjectDescription string `json:"subProjectDescription,omitempty"` + Assets []struct { + AssetExternalEntityID string `json:"assetExternalEntityId"` + AssetName string `json:"assetName"` + AssetVersions []struct { + AssetVersionName string `json:"assetVersionName"` + Artifacts []string `json:"artifacts"` + } `json:"assetVersions,omitempty"` + } `json:"assets"` + } `json:"subProjects,omitempty"` + Assets []struct { + AssetExternalEntityID string `json:"assetExternalEntityId"` + AssetName string `json:"assetName"` + AssetVersions []struct { + AssetVersionName string `json:"assetVersionName"` + Artifacts []string `json:"artifacts"` + } `json:"assetVersions,omitempty"` } `json:"assets"` } @@ -77,15 +99,35 @@ func (g *DevGuardTarget) LoadImages() ([]kubernetes.ImageInNamespace, error) { result := make([]kubernetes.ImageInNamespace, 0) for _, a := range assets { for _, asset := range a.Assets { - for _, version := range asset.Versions { - fullImage := asset.AssetExternalEntityID + ":" + version - result = append(result, kubernetes.ImageInNamespace{ - Namespace: a.ProjectExternalEntityID, - Image: &libk8s.RegistryImage{ - ImageID: fullImage, - Image: fullImage, - }, - }) + for _, version := range asset.AssetVersions { + for _, artifact := range version.Artifacts { + result = append(result, kubernetes.ImageInNamespace{ + Namespace: a.ProjectExternalEntityID, + ContainerName: asset.AssetExternalEntityID, + Image: &libk8s.RegistryImage{ + ImageID: g.buildImageNameFromArtifact(artifact), + Image: g.buildImageNameFromArtifact(artifact), + }, + }) + } + } + } + for _, sp := range a.SubProjects { + for _, asset := range sp.Assets { + for _, version := range asset.AssetVersions { + for _, artifact := range version.Artifacts { + result = append(result, kubernetes.ImageInNamespace{ + Namespace: a.ProjectExternalEntityID, + ControllerName: &sp.SubProjectExternalEntityID, + ContainerName: asset.AssetExternalEntityID, + Image: &libk8s.RegistryImage{ + Image: g.buildImageNameFromArtifact(artifact), + ImageID: g.buildImageNameFromArtifact(artifact), + }, + }, + ) + } + } } } } @@ -93,9 +135,40 @@ func (g *DevGuardTarget) LoadImages() ([]kubernetes.ImageInNamespace, error) { return result, nil } -func (g *DevGuardTarget) ProcessSbom(ctx *TargetContext) error { +func (g *DevGuardTarget) buildArtifactName(image *libk8s.RegistryImage) string { + if strings.HasPrefix(image.Image, "pkg:oci/") { + return image.Image + } + + imageRepo, tag, shortName, err := getRepoWithVersion(image) + if err != nil { + slog.Error("Could not parse image!!!", "image", image.Image) + return image.Image + } + if tag == "" { + tag = "latest" + } + return "pkg:oci/" + shortName + "@" + tag + "?repository_url=" + imageRepo +} + +func (g *DevGuardTarget) buildImageNameFromArtifact(artifact string) string { + if !strings.HasPrefix(artifact, "pkg:oci/") { + return artifact + } + parts := strings.SplitN(artifact, "@", 2) + if len(parts) != 2 { + return artifact + } + p := strings.SplitN(parts[1], "?repository_url=", 2) + if len(p) != 2 { + return artifact + } + digest := p[0] + repo := p[1] + return repo + ":" + digest +} - assetName, version := getRepoWithVersion(ctx.Image) +func (g *DevGuardTarget) ProcessSbom(ctx *TargetContext) error { if ctx.Sbom == "" { slog.Info("Empty SBOM - skip image", "image", ctx.Image.ImageID) @@ -106,11 +179,22 @@ func (g *DevGuardTarget) ProcessSbom(ctx *TargetContext) error { Verb: "update", ProjectExternalEntityID: ctx.Pod.PodNamespace, ProjectName: ctx.Pod.PodNamespace, - AssetExternalEntityID: assetName, - AssetName: assetName, - AssetVersion: version, + ProjectDescription: "Namespace", + AssetExternalEntityID: ctx.Container.Name, + AssetName: ctx.Container.Name, + AssetVersionName: "latest", + Artifact: g.buildArtifactName(ctx.Image), Sbom: json.RawMessage(ctx.Sbom), } + if ctx.Pod.OwnerReferences.Kind == "Deployment" || ctx.Pod.OwnerReferences.Kind == "DaemonSet" || ctx.Pod.OwnerReferences.Kind == "StatefulSet" { + payload.SubProjectExternalEntityID = ctx.Pod.OwnerReferences.Name + payload.SubProjectName = ctx.Pod.OwnerReferences.Name + payload.SubProjectDescription = ctx.Pod.OwnerReferences.Kind + + payload.AssetDescription = "container" + } else { + payload.AssetDescription = "container controlled by " + ctx.Pod.OwnerReferences.Kind + " " + ctx.Pod.OwnerReferences.Name + } jsonBody, err := json.Marshal(payload) if err != nil { @@ -124,7 +208,7 @@ func (g *DevGuardTarget) ProcessSbom(ctx *TargetContext) error { req.Header.Set("Content-Type", "application/json") - slog.Info("Sending SBOM to DevGuard", "assetName", assetName, "version", version) + slog.Info("Sending SBOM to DevGuard", "Namespace", ctx.Pod.PodNamespace, "Container", ctx.Container.Name) _, err = g.client.Do(req) if err != nil { @@ -132,7 +216,7 @@ func (g *DevGuardTarget) ProcessSbom(ctx *TargetContext) error { return err } - slog.Info("Uploaded SBOM to DevGuard", "assetName", assetName, "version", version) + slog.Info("Uploaded SBOM to DevGuard", "Namespace", ctx.Pod.PodNamespace, "Container", ctx.Container.Name) return nil } @@ -143,16 +227,23 @@ func (g *DevGuardTarget) Remove(images []kubernetes.ImageInNamespace) error { wg.Add(1) go func(img kubernetes.ImageInNamespace) { defer wg.Done() + slog.Debug("Removing asset from DevGuard", "Namespace", img.Namespace, "Container", img.ContainerName) - name, version := getRepoWithVersion(img.Image) + controllerName := "" + if img.ControllerName != nil { + controllerName = *img.ControllerName + + } payload := DevGuardRequest{ - Verb: "delete", - ProjectExternalEntityID: img.Namespace, - ProjectName: img.Namespace, - AssetExternalEntityID: name, - AssetName: name, - AssetVersion: version, + Verb: "delete", + ProjectExternalEntityID: img.Namespace, + ProjectName: img.Namespace, + SubProjectExternalEntityID: controllerName, + AssetExternalEntityID: img.ContainerName, + AssetName: img.ContainerName, + AssetVersionName: "latest", + Artifact: g.buildArtifactName(img.Image), } jsonBody, err := json.Marshal(payload) @@ -169,7 +260,7 @@ func (g *DevGuardTarget) Remove(images []kubernetes.ImageInNamespace) error { req.Header.Set("Content-Type", "application/json") - slog.Info("Deleting asset", "projectName", img.Namespace, "assetName", name, "assetVersion", version) + slog.Info("Deleting asset", "Namespace", img.Namespace, "Container", img.ContainerName) _, err = g.client.Do(req) if err != nil { @@ -183,23 +274,12 @@ func (g *DevGuardTarget) Remove(images []kubernetes.ImageInNamespace) error { return nil } -func getRepoWithVersion(image *libk8s.RegistryImage) (string, string) { +func getRepoWithVersion(image *libk8s.RegistryImage) (string, string, string, error) { imageRef, err := parser.Parse(image.Image) if err != nil { slog.Error("Could not parse image", "image", image.Image) - return "", "" - } - - projectName := imageRef.Repository() - - if strings.Index(image.Image, "sha256") != 0 { - imageRef, err = parser.Parse(image.Image) - if err != nil { - slog.Error("Could not parse image", "image", image.Image) - return "", "" - } + return "", "", "", err } - version := imageRef.Tag() - return projectName, version + return imageRef.Repository(), imageRef.Tag(), imageRef.ShortName(), nil } diff --git a/kubernetes/image.go b/kubernetes/image.go index 876dc993..e799834c 100644 --- a/kubernetes/image.go +++ b/kubernetes/image.go @@ -11,8 +11,10 @@ import ( ) type ImageInNamespace struct { - Namespace string - Image *oci.RegistryImage + Namespace string + ControllerName *string + ContainerName string + Image *oci.RegistryImage } func (i ImageInNamespace) String() string { diff --git a/kubernetes/kubernetes.go b/kubernetes/kubernetes.go index d652d893..a1c32740 100644 --- a/kubernetes/kubernetes.go +++ b/kubernetes/kubernetes.go @@ -10,6 +10,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" meta "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/cache" @@ -23,6 +24,11 @@ type KubeClient struct { fallbackPullSecret []*oci.KubeCreds } +type PodInfo struct { + libk8s.PodInfo + OwnerReferences meta.OwnerReference +} + var ( AnnotationTemplate = "devguard.org/%s" /* #nosec */ @@ -51,10 +57,11 @@ func (client *KubeClient) StartPodInformer(podLabelSelector string, handler cach return &corev1.Pod{ ObjectMeta: meta.ObjectMeta{ - Name: pod.Name, - Namespace: pod.Namespace, - Annotations: pod.Annotations, - Labels: pod.Labels, + Name: pod.Name, + Namespace: pod.Namespace, + Annotations: pod.Annotations, + Labels: pod.Labels, + OwnerReferences: pod.OwnerReferences, }, Status: corev1.PodStatus{ InitContainerStatuses: pod.Status.InitContainerStatuses, @@ -85,6 +92,21 @@ func loadFallbackPullSecret(client *libk8s.KubeClient, namespace, name string) [ return fallbackPullSecret } +func (client *KubeClient) ExtractPodInfos(pod corev1.Pod) PodInfo { + owner, err := client.getOwner(pod.Namespace, pod.OwnerReferences) + if err != nil { + slog.Warn("failed to get owner reference for pod, proceeding without owner reference", "namespace", pod.Namespace, "pod", pod.Name, "err", err) + owner = v1.OwnerReference{ + Kind: "Pod", + Name: pod.Name, + } + } + return PodInfo{ + PodInfo: client.Client.ExtractPodInfos(pod), + OwnerReferences: owner, + } +} + func (client *KubeClient) InjectPullSecrets(pod libk8s.PodInfo) { for _, container := range pod.Containers { container.Image.PullSecrets = client.Client.LoadSecrets(pod.PodNamespace, pod.PullSecretNames) @@ -95,13 +117,53 @@ func (client *KubeClient) InjectPullSecrets(pod libk8s.PodInfo) { } } -func (client *KubeClient) LoadImageInfos(namespaces []corev1.Namespace, podLabelSelector string) ([]libk8s.PodInfo, []ImageInNamespace) { - podInfos := client.Client.LoadPodInfos(namespaces, podLabelSelector) +func (client *KubeClient) getOwner(namespace string, refs []v1.OwnerReference) (v1.OwnerReference, error) { + for _, ref := range refs { + if ref.Controller != nil && *ref.Controller { + + if ref.Kind == "Deployment" || ref.Kind == "StatefulSet" || ref.Kind == "DaemonSet" { + return ref, nil + } + + switch ref.Kind { + case "ReplicaSet": + // list the owner of the replicaset + rs, err := client.Client.Client.AppsV1().ReplicaSets(namespace).Get(context.Background(), ref.Name, v1.GetOptions{}) + if err != nil { + return v1.OwnerReference{}, err + } + + return client.getOwner(namespace, rs.OwnerReferences) + default: + return ref, nil + } + + } else { + continue + } + + } + return v1.OwnerReference{}, fmt.Errorf("no controller owner reference found") +} + +func (client *KubeClient) LoadImageInfos(namespaces []corev1.Namespace, podLabelSelector string) ([]PodInfo, []ImageInNamespace) { + podInfos := make([]PodInfo, 0) allImages := make([]ImageInNamespace, 0) - for _, pod := range podInfos { - for _, container := range pod.Containers { - allImages = append(allImages, ImageInNamespace{Namespace: pod.PodNamespace, Image: container.Image}) + for _, ns := range namespaces { + pods, err := client.Client.Client.CoreV1().Pods(ns.Name).List(context.Background(), meta.ListOptions{LabelSelector: podLabelSelector}) + if err != nil { + slog.Warn("failed to list pods", "namespace", ns.Name, "err", err) + continue + } + + for _, pod := range pods.Items { + info := client.ExtractPodInfos(pod) + podInfos = append(podInfos, info) + ownerName := info.OwnerReferences.Name + for _, container := range info.Containers { + allImages = append(allImages, ImageInNamespace{Namespace: pod.Namespace, Image: container.Image, ContainerName: container.Name, ControllerName: &ownerName}) + } } } diff --git a/processor.go b/processor.go index ab028280..3f810c5c 100644 --- a/processor.go +++ b/processor.go @@ -36,20 +36,20 @@ func (p *Processor) ListenForPods() { UpdateFunc: func(old, new interface{}) { oldPod := old.(*corev1.Pod) newPod := new.(*corev1.Pod) - oldInfo := p.K8s.Client.ExtractPodInfos(*oldPod) - newInfo := p.K8s.Client.ExtractPodInfos(*newPod) + oldInfo := p.K8s.ExtractPodInfos(*oldPod) + newInfo := p.K8s.ExtractPodInfos(*newPod) var removedContainers []*libk8s.ContainerInfo - newInfo.Containers, removedContainers = getChangedContainers(oldInfo, newInfo) + newInfo.Containers, removedContainers = getChangedContainers(oldInfo.PodInfo, newInfo.PodInfo) p.scanPod(newInfo) - p.cleanupImagesIfNeeded(newInfo.PodNamespace, removedContainers, informer.GetStore().List()) + p.cleanupImagesIfNeeded(newInfo, removedContainers, informer.GetStore().List()) }, DeleteFunc: func(obj interface{}) { pod := obj.(*corev1.Pod) - info := p.K8s.Client.ExtractPodInfos(*pod) + info := p.K8s.ExtractPodInfos(*pod) - p.cleanupImagesIfNeeded(info.PodNamespace, info.Containers, informer.GetStore().List()) + p.cleanupImagesIfNeeded(info, info.Containers, informer.GetStore().List()) }, }) @@ -61,7 +61,7 @@ func (p *Processor) ListenForPods() { p.runInformerAsync(informer) } -func (p *Processor) ProcessAllPods(pods []libk8s.PodInfo, allImages []kubernetes.ImageInNamespace) { +func (p *Processor) ProcessAllPods(pods []kubernetes.PodInfo, allImages []kubernetes.ImageInNamespace) { p.executeScans(pods, allImages) } @@ -74,9 +74,9 @@ func getImageName(img *oci.RegistryImage) string { return img.Image } -func (p *Processor) scanPod(pod libk8s.PodInfo) { +func (p *Processor) scanPod(pod kubernetes.PodInfo) { errOccurred := false - p.K8s.InjectPullSecrets(pod) + p.K8s.InjectPullSecrets(pod.PodInfo) for _, container := range pod.Containers { alreadyScanned := p.imageMap[pod.PodNamespace+"/"+getImageName(container.Image)] @@ -99,7 +99,7 @@ func (p *Processor) scanPod(pod libk8s.PodInfo) { } if !errOccurred && len(pod.Containers) > 0 { - p.K8s.UpdatePodAnnotation(pod) + p.K8s.UpdatePodAnnotation(pod.PodInfo) } } @@ -112,7 +112,7 @@ func initTargets() []Target { return targets } -func (p *Processor) executeScans(pods []libk8s.PodInfo, allImages []kubernetes.ImageInNamespace) { +func (p *Processor) executeScans(pods []kubernetes.PodInfo, allImages []kubernetes.ImageInNamespace) { for _, pod := range pods { p.scanPod(pod) } @@ -127,7 +127,7 @@ func (p *Processor) executeScans(pods []libk8s.PodInfo, allImages []kubernetes.I removableImages := make([]kubernetes.ImageInNamespace, 0) for _, t := range targetImages { - slog.Debug("Checking image for removal", "image", t.String()) + // slog.Debug("Checking image for removal", "image", t.String()) if !containsImage(allImages, t) { removableImages = append(removableImages, t) delete(p.imageMap, t.String()) @@ -167,6 +167,9 @@ func containsImage(images []kubernetes.ImageInNamespace, target kubernetes.Image } for _, candidate := range images { + if candidate.Namespace != target.Namespace || candidate.ContainerName != target.ContainerName || (candidate.ControllerName != nil && target.ControllerName != nil && *candidate.ControllerName != *target.ControllerName) { + continue + } candidateParsed, err := parser.Parse(candidate.Image.Image) if err != nil { continue @@ -189,19 +192,19 @@ func containsContainerImage(containers []*libk8s.ContainerInfo, image string) bo return false } -func (p *Processor) cleanupImagesIfNeeded(namespace string, removedContainers []*libk8s.ContainerInfo, allPods []interface{}) { +func (p *Processor) cleanupImagesIfNeeded(info kubernetes.PodInfo, removedContainers []*libk8s.ContainerInfo, allPods []interface{}) { images := make([]kubernetes.ImageInNamespace, 0) for _, c := range removedContainers { found := false for _, po := range allPods { pod := po.(*corev1.Pod) - info := p.K8s.Client.ExtractPodInfos(*pod) + info := p.K8s.ExtractPodInfos(*pod) found = found || containsContainerImage(info.Containers, c.Image.ImageID) } if !found { - imageWithNamespace := kubernetes.ImageInNamespace{Namespace: namespace, Image: c.Image} + imageWithNamespace := kubernetes.ImageInNamespace{Namespace: info.PodNamespace, Image: c.Image, ContainerName: c.Name, ControllerName: &info.OwnerReferences.Name} images = append(images, imageWithNamespace) delete(p.imageMap, imageWithNamespace.String()) @@ -251,7 +254,7 @@ func (p *Processor) runInformerAsync(informer cache.SharedIndexInformer) { slog.Info("Finished cache sync") pods := informer.GetStore().List() - missingPods := make([]libk8s.PodInfo, 0) + missingPods := make([]kubernetes.PodInfo, 0) allImages := make([]kubernetes.ImageInNamespace, 0) for _, t := range p.Targets { @@ -265,13 +268,15 @@ func (p *Processor) runInformerAsync(informer cache.SharedIndexInformer) { for _, po := range pods { pod := po.(*corev1.Pod) slog.Debug("Pod found", "pod", pod.Name, "namespace", pod.Namespace) - info := p.K8s.Client.ExtractPodInfos(*pod) + info := p.K8s.ExtractPodInfos(*pod) for _, c := range info.Containers { - allImages = append(allImages, kubernetes.ImageInNamespace{Namespace: info.PodNamespace, Image: c.Image}) + allImages = append(allImages, kubernetes.ImageInNamespace{Namespace: info.PodNamespace, Image: c.Image, ContainerName: c.Name, ControllerName: &info.OwnerReferences.Name}) if !containsImage(targetImages, kubernetes.ImageInNamespace{ - Image: c.Image, - Namespace: info.PodNamespace, + Image: c.Image, + Namespace: info.PodNamespace, + ContainerName: c.Name, + ControllerName: &info.OwnerReferences.Name, }) { missingPods = append(missingPods, info) slog.Debug("Pod needs to be analyzed", "pod", info.PodName, "namespace", info.PodNamespace) diff --git a/target.go b/target.go index b21b793b..1ffe6e63 100644 --- a/target.go +++ b/target.go @@ -9,7 +9,7 @@ import ( type TargetContext struct { Image *oci.RegistryImage Container *libk8s.ContainerInfo - Pod *libk8s.PodInfo + Pod *kubernetes.PodInfo Sbom string } @@ -19,6 +19,6 @@ type Target interface { Remove(images []kubernetes.ImageInNamespace) error } -func NewContext(sbom string, image *oci.RegistryImage, container *libk8s.ContainerInfo, pod *libk8s.PodInfo) *TargetContext { +func NewContext(sbom string, image *oci.RegistryImage, container *libk8s.ContainerInfo, pod *kubernetes.PodInfo) *TargetContext { return &TargetContext{image, container, pod, sbom} } diff --git a/trivy.go b/trivy.go index a0192aa4..a7a33752 100644 --- a/trivy.go +++ b/trivy.go @@ -58,7 +58,10 @@ func (t Trivy) downloadImageToLocalFilesystem(img *oci.RegistryImage) (string, e ctx := context.Background() - repoName, tag := getRepoWithVersion(img) + repoName, tag, _, err := getRepoWithVersion(img) + if err != nil { + return "", errors.Wrap(err, "could not parse image reference") + } repo, err := remote.NewRepository(repoName) if err != nil { return "", errors.Wrap(err, "could not create remote repository") @@ -124,6 +127,7 @@ func (t *Trivy) ExecuteTrivy(img *oci.RegistryImage) (string, error) { "--format", "cyclonedx", "--output", sbomFile.Name(), "--input", tmpDir, + "--cache-dir", "/tmp/.cache/trivy", "-c", "trivy.yaml", )