From 23a784b19f130521c3a822c754307b7f4575cb05 Mon Sep 17 00:00:00 2001 From: Chakib Hamie Date: Fri, 24 Apr 2026 17:25:28 +0200 Subject: [PATCH] Add Kustomize detector line mapping and hook it into vulnerability line attribution. --- pkg/detector/kustomize/detect.go | 108 +++++ pkg/detector/kustomize/detect_test.go | 369 ++++++++++++++++++ pkg/detector/kustomize/direct_line.go | 211 ++++++++++ pkg/detector/kustomize/generator_line.go | 312 +++++++++++++++ pkg/detector/kustomize/generator_line_test.go | 35 ++ pkg/detector/kustomize/helper_main_test.go | 14 + pkg/detector/kustomize/namehash.go | 21 + pkg/detector/kustomize/namehash_test.go | 27 ++ pkg/engine/inspector.go | 2 + pkg/engine/vulnerability_builder.go | 13 +- pkg/kics/service_test.go | 126 ++++++ 11 files changed, 1237 insertions(+), 1 deletion(-) create mode 100644 pkg/detector/kustomize/detect.go create mode 100644 pkg/detector/kustomize/detect_test.go create mode 100644 pkg/detector/kustomize/direct_line.go create mode 100644 pkg/detector/kustomize/generator_line.go create mode 100644 pkg/detector/kustomize/generator_line_test.go create mode 100644 pkg/detector/kustomize/helper_main_test.go create mode 100644 pkg/detector/kustomize/namehash.go create mode 100644 pkg/detector/kustomize/namehash_test.go diff --git a/pkg/detector/kustomize/detect.go b/pkg/detector/kustomize/detect.go new file mode 100644 index 000000000..a1ccf8bca --- /dev/null +++ b/pkg/detector/kustomize/detect.go @@ -0,0 +1,108 @@ +package kustomize + +import ( + "context" + "path/filepath" + "strings" + + "github.com/DataDog/datadog-iac-scanner/pkg/detector" + "github.com/DataDog/datadog-iac-scanner/pkg/model" + "github.com/DataDog/datadog-iac-scanner/pkg/rootfile" +) + +// DetectKindLine maps Kustomize-rendered findings back to source lines. +type DetectKindLine struct{} + +// DetectLine: direct sources use default YAML tracing; generators/transformers use kustomization lines. +func (DetectKindLine) DetectLine( + ctx context.Context, + file *model.FileMetadata, + searchKey string, + outputLines int, +) model.VulnerabilityLines { + o := file.KustomizeOrigin + if o != nil && o.OriginKind == model.KustomizeOriginDirect && o.SourceFile != "" && o.SourceRepo == "" { + if v := directSourceDetectLine(o.SourceFile, searchKey, outputLines); v.Line > 0 { + return v + } + } + if o == nil || !o.RequiresDetailedLineMapping() { + return detector.DefaultYAMLDetectLine{}.DetectLine(ctx, file, searchKey, outputLines) + } + cfg := o.GeneratorConfigFile + if cfg == "" { + return detector.DefaultYAMLDetectLine{}.DetectLine(ctx, file, searchKey, outputLines) + } + data, err := rootfile.ReadFile(filepath.Clean(cfg)) + if err != nil { + return detector.DefaultYAMLDetectLine{}.DetectLine(ctx, file, searchKey, outputLines) + } + lines := strings.Split(string(data), "\n") + if v, ok := mappedLineFromKustomizeOrigin(ctx, file, o, searchKey, outputLines, lines); ok { + return v + } + return detector.DefaultYAMLDetectLine{}.DetectLine(ctx, file, searchKey, outputLines) +} + +func mappedLineFromKustomizeOrigin( + ctx context.Context, + file *model.FileMetadata, + o *model.KustomizeOrigin, + searchKey string, + outputLines int, + lines []string, +) (model.VulnerabilityLines, bool) { + switch o.OriginKind { + case model.KustomizeOriginGenerator: + name := o.ResourceName + if name == "" { + name = metadataNameFromDoc(file.Document) + } + line1, p := generatorConfigLine(o, name) + return buildMappedVulnLines(line1-1, p, lines, outputLines), true + case model.KustomizeOriginTransformer: + if v := transformerPatchFileLine(ctx, o, searchKey, outputLines); v.Line > 0 { + return v, true + } + if o.OriginalSourceFile != "" && o.OriginalSourceRepo == "" { + if v := directSourceDetectLine(o.OriginalSourceFile, searchKey, outputLines); v.Line > 0 { + return v, true + } + } + line1, p := transformerLineForOrigin(o) + return buildMappedVulnLines(line1-1, p, lines, outputLines), true + default: + return model.VulnerabilityLines{}, false + } +} + +func buildMappedVulnLines(lineIdx int, resolvedPath string, lines []string, outputLines int) model.VulnerabilityLines { + if lineIdx < 0 { + lineIdx = 0 + } + if lineIdx >= len(lines) { + lineIdx = 0 + } + return model.VulnerabilityLines{ + Line: lineIdx + 1, + VulnLines: detector.GetAdjacentVulnLines(lineIdx, outputLines, lines), + LineWithVulnerability: strings.TrimSpace(lines[lineIdx]), + ResolvedFile: resolvedPath, + VulnerablilityLocation: model.ResourceLocation{ + Start: model.ResourceLine{Line: lineIdx + 1, Col: 1}, + End: model.ResourceLine{Line: lineIdx + 1, Col: 1}, + }, + } +} + +func metadataNameFromDoc(doc model.Document) string { + if doc == nil { + return "" + } + m, ok := doc["metadata"].(map[string]interface{}) + if !ok { + return "" + } + n, _ := m["name"].(string) + return n +} diff --git a/pkg/detector/kustomize/detect_test.go b/pkg/detector/kustomize/detect_test.go new file mode 100644 index 000000000..198850eb2 --- /dev/null +++ b/pkg/detector/kustomize/detect_test.go @@ -0,0 +1,369 @@ +package kustomize + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DataDog/datadog-iac-scanner/pkg/model" + resolverkustomize "github.com/DataDog/datadog-iac-scanner/pkg/resolver/kustomize" + "github.com/stretchr/testify/require" +) + +func TestDetectKindLine_Delegates(t *testing.T) { + ctx := context.Background() + lines := []string{"a: 1", "b: 2"} + f := &model.FileMetadata{ + Kind: model.KindKUSTOMIZE, + FilePath: "x.yaml", + LinesOriginalData: &lines, + LineInfoDocument: map[string]interface{}{"a": map[string]interface{}{"_kics_line": float64(1)}}, + Document: map[string]interface{}{"a": 1}, + } + v := DetectKindLine{}.DetectLine(ctx, f, "a", 1) + require.Equal(t, "x.yaml", v.ResolvedFile) + require.NotEqual(t, -1, v.Line) +} + +func TestTransformerLineForOrigin_prefersPatchPathLine(t *testing.T) { + dir := t.TempDir() + kust := filepath.Join(dir, "kustomization.yaml") + content := "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\npatches:\n- path: p.yaml\n" + require.NoError(t, os.WriteFile(kust, []byte(content), 0o600)) + patchPath := filepath.Join(dir, "p.yaml") + o := &model.KustomizeOrigin{ + OriginKind: model.KustomizeOriginTransformer, + GeneratorConfigFile: kust, + Transformations: []model.KustomizeTransformation{{TransformerPath: patchPath}}, + } + line, p := transformerLineForOrigin(o) + require.Equal(t, kust, p) + require.Equal(t, 4, line, "should point at the list entry referencing the patch file") +} + +func TestDirectSourceDetectLine(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "d.yaml") + require.NoError(t, os.WriteFile(p, []byte("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: n\nspec:\n replicas: 1\n"), 0o600)) + v := directSourceDetectLine(p, "metadata.name", 1) + require.Equal(t, 4, v.Line) + require.Equal(t, p, v.ResolvedFile) +} + +func TestDirectSourceDetectLine_ASTNestedSequence(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "d.yaml") + require.NoError(t, os.WriteFile(p, []byte("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: n\nspec:\n template:\n spec:\n containers:\n - name: app\n image: nginx:1.0\n"), 0o600)) + v := directSourceDetectLine(p, "spec.template.spec.containers[0].image", 3) + require.Equal(t, 10, v.Line) + require.Equal(t, "image: nginx:1.0", v.LineWithVulnerability) + require.Equal(t, p, v.ResolvedFile) +} + +func TestTransformerPatchFileLine_nestedContainersUsesAST(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + kust := filepath.Join(dir, "kustomization.yaml") + patchPath := filepath.Join(dir, "patch.yaml") + require.NoError(t, os.WriteFile(kust, []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\npatches:\n- path: patch.yaml\n"), 0o600)) + patchYAML := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + template: + spec: + containers: + - name: sidecar + image: alpine:3 + - name: app + image: nginx:bad +` + require.NoError(t, os.WriteFile(patchPath, []byte(patchYAML), 0o600)) + v := transformerPatchFileLine(ctx, &model.KustomizeOrigin{ + OriginKind: model.KustomizeOriginTransformer, + GeneratorConfigFile: kust, + Transformations: []model.KustomizeTransformation{ + {TransformerPath: patchPath}, + }, + }, "spec.template.spec.containers[1].image", 3) + require.Equal(t, 12, v.Line) + require.Equal(t, patchPath, v.ResolvedFile) + require.Contains(t, v.LineWithVulnerability, "nginx:bad") +} + +func TestTransformerPatchFileLine_prefersPatchFile(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + kust := filepath.Join(dir, "kustomization.yaml") + patchPath := filepath.Join(dir, "patch.yaml") + require.NoError(t, os.WriteFile(kust, []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\npatches:\n- path: patch.yaml\n"), 0o600)) + require.NoError(t, os.WriteFile(patchPath, []byte("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n replicas: 3\n"), 0o600)) + v := transformerPatchFileLine(ctx, &model.KustomizeOrigin{ + OriginKind: model.KustomizeOriginTransformer, + GeneratorConfigFile: kust, + Transformations: []model.KustomizeTransformation{ + {TransformerPath: patchPath}, + }, + }, "spec.replicas", 3) + require.Equal(t, 6, v.Line) + require.Equal(t, patchPath, v.ResolvedFile) + require.Equal(t, "replicas: 3", v.LineWithVulnerability) +} + +func TestTransformerPatchFileLine_usesConfiguredInBaseKustomization(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + base := filepath.Join(repo, "base") + overlay := filepath.Join(repo, "overlay") + require.NoError(t, os.MkdirAll(base, 0o700)) + require.NoError(t, os.MkdirAll(overlay, 0o700)) + + baseKust := filepath.Join(base, "kustomization.yaml") + basePatch := filepath.Join(base, "patch.yaml") + require.NoError(t, os.WriteFile(baseKust, []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\npatches:\n- path: patch.yaml\n"), 0o600)) + require.NoError(t, os.WriteFile(basePatch, []byte("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n replicas: 7\n"), 0o600)) + + v := transformerPatchFileLine(ctx, &model.KustomizeOrigin{ + OriginKind: model.KustomizeOriginTransformer, + GeneratorConfigFile: filepath.Join(overlay, "kustomization.yaml"), + Transformations: []model.KustomizeTransformation{ + { + TransformerPath: filepath.Join(overlay, "patch.yaml"), + ConfiguredIn: baseKust, + FieldPath: "patch.yaml", + }, + }, + }, "spec.replicas", 3) + require.Equal(t, 6, v.Line) + require.Equal(t, basePatch, v.ResolvedFile) + require.Equal(t, "replicas: 7", v.LineWithVulnerability) +} + +func TestTransformerPatchFileLine_DoesNotProbeUnrelatedPatchFiles(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + kust := filepath.Join(dir, "kustomization.yaml") + patchA := filepath.Join(dir, "patch-a.yaml") + patchB := filepath.Join(dir, "patch-b.yaml") + require.NoError(t, os.WriteFile(kust, []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +patches: +- path: patch-a.yaml +- path: patch-b.yaml +`), 0o600)) + require.NoError(t, os.WriteFile(patchA, []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + replicas: 3 +`), 0o600)) + require.NoError(t, os.WriteFile(patchB, []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + replicas: 9 +`), 0o600)) + + v := transformerPatchFileLine(ctx, &model.KustomizeOrigin{ + OriginKind: model.KustomizeOriginTransformer, + GeneratorConfigFile: kust, + Transformations: []model.KustomizeTransformation{ + {TransformerPath: patchA, FieldPath: "patch-a.yaml"}, + }, + }, "spec.replicas", 3) + require.Equal(t, patchA, v.ResolvedFile) + require.Equal(t, "replicas: 3", v.LineWithVulnerability) +} + +func TestDetectKindLine_GeneratorOrigin(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + kust := filepath.Join(dir, "kustomization.yaml") + content := "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nconfigMapGenerator:\n- name: my-map\n literals:\n - k=v\n" + require.NoError(t, os.WriteFile(kust, []byte(content), 0o600)) + + lines := []string{"apiVersion: v1", "kind: ConfigMap", "metadata:", " name: my-map"} + f := &model.FileMetadata{ + Kind: model.KindKUSTOMIZE, + FilePath: filepath.Join(dir, "generated.yaml"), + LinesOriginalData: &lines, + Document: map[string]interface{}{ + "metadata": map[string]interface{}{"name": "my-map"}, + }, + KustomizeOrigin: &model.KustomizeOrigin{ + OriginKind: model.KustomizeOriginGenerator, + GeneratorConfigFile: kust, + ResourceName: "my-map", + }, + } + v := DetectKindLine{}.DetectLine(ctx, f, "data.k", 1) + require.Equal(t, kust, v.ResolvedFile) + require.Equal(t, 4, v.Line, "line should point at the list item declaring the generator name") +} + +func TestDetectKindLine_DirectRemoteOrigin_DoesNotTreatURLAsLocalPath(t *testing.T) { + ctx := context.Background() + lines := []string{"apiVersion: apps/v1", "kind: Deployment"} + f := &model.FileMetadata{ + Kind: model.KindKUSTOMIZE, + FilePath: "rendered.yaml", + LinesOriginalData: &lines, + Document: map[string]interface{}{"kind": "Deployment"}, + KustomizeOrigin: &model.KustomizeOrigin{ + OriginKind: model.KustomizeOriginDirect, + SourceFile: "https://github.com/org/repo/base/deployment.yaml", + SourceRepo: "https://github.com/org/repo", + }, + } + v := DetectKindLine{}.DetectLine(ctx, f, "kind", 1) + require.Equal(t, "rendered.yaml", v.ResolvedFile) +} + +func TestDetectKindLine_TransformerBaseOwnedPatchResolvesIntoBasePatchFile(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + base := filepath.Join(repo, "base") + overlay := filepath.Join(repo, "overlay") + require.NoError(t, os.MkdirAll(base, 0o700)) + require.NoError(t, os.MkdirAll(overlay, 0o700)) + + require.NoError(t, os.WriteFile(filepath.Join(base, "deployment.yaml"), []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + replicas: 1 + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: nginx +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(base, "patch.yaml"), []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + replicas: 9 +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(base, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- deployment.yaml +patches: +- path: patch.yaml +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(overlay, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../base +`), 0o600)) + + r := resolverkustomize.NewResolver(resolverkustomize.Options{ + RepoRoot: repo, + AllowHelmInflation: false, + HelmIncludeCRDs: true, + }) + out, err := r.Resolve(ctx, overlay) + require.NoError(t, err) + require.Empty(t, out.Diagnostics, "%+v", out.Diagnostics) + require.Len(t, out.File, 1) + require.NotNil(t, out.File[0].Origin) + + lines := strings.Split(string(out.File[0].Content), "\n") + f := &model.FileMetadata{ + Kind: model.KindKUSTOMIZE, + FilePath: out.File[0].FileName, + LinesOriginalData: &lines, + Document: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 9, + }, + }, + KustomizeOrigin: out.File[0].Origin, + } + v := DetectKindLine{}.DetectLine(ctx, f, "spec.replicas", 1) + require.Equal(t, filepath.Join(base, "patch.yaml"), v.ResolvedFile) + require.Equal(t, 6, v.Line) + require.Equal(t, "replicas: 9", v.LineWithVulnerability) +} + +func TestDetectKindLine_TransformerFallsBackToOriginalSourceForUntouchedField(t *testing.T) { + ctx := context.Background() + repo := t.TempDir() + base := filepath.Join(repo, "base") + overlay := filepath.Join(repo, "overlay") + require.NoError(t, os.MkdirAll(base, 0o700)) + require.NoError(t, os.MkdirAll(overlay, 0o700)) + + baseDeployment := filepath.Join(base, "deployment.yaml") + require.NoError(t, os.WriteFile(baseDeployment, []byte(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + replicas: 3 + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: nginx +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(base, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- deployment.yaml +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(overlay, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../base +namespace: prod +`), 0o600)) + + r := resolverkustomize.NewResolver(resolverkustomize.Options{ + RepoRoot: repo, + AllowHelmInflation: false, + HelmIncludeCRDs: true, + }) + out, err := r.Resolve(ctx, overlay) + require.NoError(t, err) + require.Empty(t, out.Diagnostics, "%+v", out.Diagnostics) + require.Len(t, out.File, 1) + require.NotNil(t, out.File[0].Origin) + require.Equal(t, model.KustomizeOriginTransformer, out.File[0].Origin.OriginKind) + + lines := strings.Split(string(out.File[0].Content), "\n") + f := &model.FileMetadata{ + Kind: model.KindKUSTOMIZE, + FilePath: out.File[0].FileName, + LinesOriginalData: &lines, + Document: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + KustomizeOrigin: out.File[0].Origin, + } + v := DetectKindLine{}.DetectLine(ctx, f, "spec.replicas", 1) + require.Equal(t, baseDeployment, v.ResolvedFile) + require.Equal(t, 6, v.Line) + require.Equal(t, "replicas: 3", v.LineWithVulnerability) +} diff --git a/pkg/detector/kustomize/direct_line.go b/pkg/detector/kustomize/direct_line.go new file mode 100644 index 000000000..ee7f90386 --- /dev/null +++ b/pkg/detector/kustomize/direct_line.go @@ -0,0 +1,211 @@ +package kustomize + +import ( + "bytes" + "path/filepath" + "strconv" + "strings" + + "github.com/DataDog/datadog-iac-scanner/pkg/detector" + "github.com/DataDog/datadog-iac-scanner/pkg/model" + "github.com/DataDog/datadog-iac-scanner/pkg/rootfile" + yamlv3 "gopkg.in/yaml.v3" +) + +// directSourceDetectLine maps a finding to a plain resources: file when render diverges from source YAML. +// Tries AST walk along searchKey, then a simple key-tail line match. +func directSourceDetectLine(srcPath, searchKey string, outputLines int) model.VulnerabilityLines { + srcPath = filepath.Clean(srcPath) + data, err := rootfile.ReadFile(srcPath) + if err != nil { + return model.VulnerabilityLines{Line: -1} + } + norm := strings.ReplaceAll(string(data), "\r\n", "\n") + lines := strings.Split(norm, "\n") + + if ln := astLineForSearchKey([]byte(norm), searchKey); ln > 0 && ln <= len(lines) { + trim := strings.TrimSpace(lines[ln-1]) + return model.VulnerabilityLines{ + Line: ln, + VulnLines: detector.GetAdjacentVulnLines(ln-1, outputLines, lines), + LineWithVulnerability: trim, + ResolvedFile: srcPath, + VulnerablilityLocation: model.ResourceLocation{ + Start: model.ResourceLine{Line: ln, Col: 1}, + End: model.ResourceLine{Line: ln, Col: 1}, + }, + } + } + + tail := searchKey + if i := strings.LastIndex(searchKey, "."); i >= 0 { + tail = searchKey[i+1:] + } + tail = strings.TrimSpace(tail) + if idx := strings.Index(tail, "["); idx >= 0 { + tail = tail[:idx] + } + if tail == "" { + return model.VulnerabilityLines{Line: -1} + } + for i, line := range lines { + trim := strings.TrimSpace(strings.TrimRight(line, "\r")) + if strings.HasPrefix(trim, tail+":") || strings.HasPrefix(trim, tail+" :") { + return model.VulnerabilityLines{ + Line: i + 1, + VulnLines: detector.GetAdjacentVulnLines(i, outputLines, lines), + LineWithVulnerability: trim, + ResolvedFile: srcPath, + VulnerablilityLocation: model.ResourceLocation{ + Start: model.ResourceLine{Line: i + 1, Col: 1}, + End: model.ResourceLine{Line: i + 1, Col: 1}, + }, + } + } + } + return model.VulnerabilityLines{Line: -1} +} + +// astLineForSearchKey returns the 1-based line for searchKey in multi-doc YAML (dotted path with optional [i]); 0 if none. +func astLineForSearchKey(src []byte, searchKey string) int { + if len(src) == 0 || strings.TrimSpace(searchKey) == "" { + return 0 + } + parts := splitSearchKeyPath(searchKey) + if len(parts) == 0 { + return 0 + } + dec := yamlv3.NewDecoder(bytes.NewReader(src)) + for { + var doc yamlv3.Node + if err := dec.Decode(&doc); err != nil { + break + } + if ln := walkYAMLPath(&doc, parts); ln > 0 { + return ln + } + } + return 0 +} + +type yamlPathPart struct { + key string + index *int +} + +const yamlPathPartInitialCap = 8 + +func splitSearchKeyPath(searchKey string) []yamlPathPart { + out := make([]yamlPathPart, 0, yamlPathPartInitialCap) + for _, raw := range strings.Split(searchKey, ".") { + part := strings.TrimSpace(raw) + if part == "" { + continue + } + p := yamlPathPart{key: part} + if idx := strings.Index(part, "["); idx >= 0 { + p.key = part[:idx] + if end := strings.Index(part[idx:], "]"); end > 1 { + if n, err := strconv.Atoi(part[idx+1 : idx+end]); err == nil { + p.index = &n + } + } + } + out = append(out, p) + } + return out +} + +func walkYAMLPath(node *yamlv3.Node, parts []yamlPathPart) int { + if node == nil || len(parts) == 0 { + return 0 + } + cur := unwrapDocumentNode(node) + var matchLine int + for _, p := range parts { + if cur == nil || p.key == "" { + return 0 + } + switch cur.Kind { + case yamlv3.MappingNode: + next := lookupMappingValue(cur, p.key) + if next == nil { + return matchLine + } + matchLine = next.Line + cur = unwrapDocumentNode(next) + if p.index != nil { + cur = lookupSequenceIndex(cur, *p.index) + if cur == nil { + return matchLine + } + matchLine = cur.Line + } + case yamlv3.SequenceNode: + cur = lookupSequenceElement(cur, p.key, p.index) + if cur == nil { + return matchLine + } + matchLine = cur.Line + default: + return matchLine + } + } + return matchLine +} + +func unwrapDocumentNode(n *yamlv3.Node) *yamlv3.Node { + if n == nil { + return nil + } + if n.Kind == yamlv3.DocumentNode && len(n.Content) > 0 { + return n.Content[0] + } + return n +} + +func lookupMappingValue(node *yamlv3.Node, key string) *yamlv3.Node { + if node == nil || node.Kind != yamlv3.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func lookupSequenceIndex(node *yamlv3.Node, idx int) *yamlv3.Node { + if node == nil || node.Kind != yamlv3.SequenceNode || idx < 0 || idx >= len(node.Content) { + return nil + } + return node.Content[idx] +} + +func lookupSequenceElement(node *yamlv3.Node, key string, idx *int) *yamlv3.Node { + if node == nil || node.Kind != yamlv3.SequenceNode { + return nil + } + if idx != nil { + if elem := lookupSequenceIndex(node, *idx); elem != nil { + if mapped := lookupMappingValue(unwrapDocumentNode(elem), key); mapped != nil { + return mapped + } + return elem + } + } + for _, elem := range node.Content { + elem = unwrapDocumentNode(elem) + if elem == nil { + continue + } + if mapped := lookupMappingValue(elem, key); mapped != nil { + return mapped + } + if nameNode := lookupMappingValue(elem, "name"); nameNode != nil && nameNode.Value == key { + return elem + } + } + return nil +} diff --git a/pkg/detector/kustomize/generator_line.go b/pkg/detector/kustomize/generator_line.go new file mode 100644 index 000000000..c26b79f11 --- /dev/null +++ b/pkg/detector/kustomize/generator_line.go @@ -0,0 +1,312 @@ +package kustomize + +import ( + "bufio" + "bytes" + "context" + "path/filepath" + "strings" + + "github.com/DataDog/datadog-iac-scanner/pkg/detector" + "github.com/DataDog/datadog-iac-scanner/pkg/model" + "github.com/DataDog/datadog-iac-scanner/pkg/rootfile" + zlog "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +const ( + yamlKeyConfigMapGenerator = "configMapGenerator:" + yamlKeySecretGenerator = "secretGenerator:" +) + +func isGeneratorSectionHeader(trimmed string) bool { + return trimmed == yamlKeyConfigMapGenerator || trimmed == yamlKeySecretGenerator +} + +// generatorConfigLine returns the best 1-based line for a generator-produced resource in the kustomization file. +func generatorConfigLine(origin *model.KustomizeOrigin, resourceName string) (line1 int, path string) { + if origin == nil || origin.GeneratorConfigFile == "" || resourceName == "" { + return 1, "" + } + p := filepath.Clean(origin.GeneratorConfigFile) + data, err := rootfile.ReadFile(p) + if err != nil { + return 1, p + } + lines := strings.Split(string(data), "\n") + if ln, pth, ok := generatorLineFromScan(lines, resourceName, p); ok { + return ln, pth + } + return generatorLineFallback(lines, resourceName, p) +} + +func generatorLineFromScan(lines []string, resourceName, p string) (line1 int, path string, ok bool) { + inGen := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if !inGen { + if isGeneratorSectionHeader(trimmed) { + inGen = true + } + continue + } + if line != "" && line[0] != ' ' && line[0] != '\t' { + if strings.HasPrefix(trimmed, "#") { + continue + } + if isGeneratorSectionHeader(trimmed) { + inGen = true + continue + } + inGen = false + continue + } + item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) + if strings.HasPrefix(item, "name:") { + rest := strings.TrimSpace(strings.TrimPrefix(item, "name:")) + rest = strings.Trim(rest, `"'`) + for _, candidate := range generatorNameCandidates(resourceName) { + if rest == candidate { + return i + 1, p, true + } + } + } + } + return 0, "", false +} + +func generatorLineFallback(lines []string, resourceName, p string) (line1 int, path string) { + for i, line := range lines { + for _, candidate := range generatorNameCandidates(resourceName) { + if strings.Contains(line, "name:") && strings.Contains(line, candidate) { + return i + 1, p + } + } + } + return 1, p +} + +// transformerConfigLine returns a 1-based line in the kustomization for transformer-related config. +func transformerConfigLine(origin *model.KustomizeOrigin) (line1 int, path string) { + if origin == nil || origin.GeneratorConfigFile == "" { + return 1, "" + } + p := filepath.Clean(origin.GeneratorConfigFile) + data, err := rootfile.ReadFile(p) + if err != nil { + return 1, p + } + sc := bufio.NewScanner(bytes.NewReader(data)) + keys := []string{"transformers:", "patches:", "patchesStrategicMerge:", "patchesJson6902:"} + for lineNo := 1; sc.Scan(); lineNo++ { + t := strings.TrimSpace(sc.Text()) + for _, k := range keys { + if strings.HasPrefix(t, k) { + return lineNo, p + } + } + } + return 1, p +} + +// transformerPatchReferenceLine is the 1-based line in the kustomization that cites the patch path. +func transformerPatchReferenceLine(origin *model.KustomizeOrigin) (line1 int, path string) { + if origin == nil || origin.GeneratorConfigFile == "" || len(origin.Transformations) == 0 { + return 0, "" + } + for _, tr := range origin.Transformations { + kustPath := transformerDeclaredConfigPath(origin, tr) + kustDir := filepath.Dir(kustPath) + data, err := rootfile.ReadFile(kustPath) + if err != nil { + continue + } + lines := strings.Split(string(data), "\n") + for _, candidate := range transformerPatchCandidatePaths(origin, tr) { + rel, err := filepath.Rel(kustDir, filepath.Clean(candidate)) + if err != nil { + continue + } + rel = filepath.ToSlash(rel) + variants := []string{rel} + if !strings.HasPrefix(rel, "./") { + variants = append(variants, "./"+rel) + } + for lineNo, line := range lines { + t := strings.TrimSpace(line) + for _, want := range variants { + if !strings.Contains(t, want) { + continue + } + if strings.Contains(t, "path:") || strings.HasPrefix(t, "-") { + return lineNo + 1, kustPath + } + } + } + } + } + return 0, "" +} + +func transformerLineForOrigin(origin *model.KustomizeOrigin) (line1 int, path string) { + if origin == nil { + return 1, "" + } + if ln, p := transformerPatchReferenceLine(origin); ln > 0 { + return ln, p + } + return transformerConfigLine(origin) +} + +func transformerDeclaredConfigPath(origin *model.KustomizeOrigin, tr model.KustomizeTransformation) string { + if tr.ConfiguredIn != "" { + return filepath.Clean(tr.ConfiguredIn) + } + if origin == nil { + return "" + } + return filepath.Clean(origin.GeneratorConfigFile) +} + +func transformerPatchCandidatePaths(origin *model.KustomizeOrigin, tr model.KustomizeTransformation) []string { + seen := make(map[string]struct{}) + var out []string + add := func(p string) { + p = filepath.Clean(p) + if p == "" { + return + } + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + + kustPath := transformerDeclaredConfigPath(origin, tr) + kustDir := filepath.Dir(kustPath) + declared := patchPathsDeclaredInKustomization(kustPath) + declaredAbs := make(map[string]struct{}, len(declared)) + for _, rel := range declared { + if kustDir == "" { + continue + } + declaredAbs[filepath.Clean(filepath.Join(kustDir, rel))] = struct{}{} + } + if tr.FieldPath != "" && kustDir != "" { + add(filepath.Join(kustDir, tr.FieldPath)) + } + if tr.TransformerPath != "" { + cleanTransformerPath := filepath.Clean(tr.TransformerPath) + if len(declaredAbs) == 0 { + if tr.FieldPath != "" || cleanTransformerPath != filepath.Clean(kustDir) { + add(cleanTransformerPath) + } + } else if _, ok := declaredAbs[cleanTransformerPath]; ok { + add(cleanTransformerPath) + } + } + if len(out) == 0 { + // Single declared patch only: multiple patches would make this fallback ambiguous. + if len(declared) == 1 && kustDir != "" { + add(filepath.Join(kustDir, declared[0])) + } + } + return out +} + +func appendPatchPathsMixedList(list []interface{}, add func(string)) { + for _, item := range list { + switch x := item.(type) { + case string: + add(x) + case map[string]interface{}: + if p, ok := x["path"].(string); ok { + add(p) + } + } + } +} + +func patchPathsDeclaredInKustomization(kustPath string) []string { + clean := filepath.Clean(kustPath) + data, err := rootfile.ReadFile(clean) + if err != nil { + return nil + } + var doc map[string]interface{} + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil + } + var out []string + add := func(p string) { + p = strings.TrimSpace(p) + if p == "" || strings.Contains(p, "://") { + return + } + out = append(out, p) + } + + if list, ok := doc["patches"].([]interface{}); ok { + appendPatchPathsMixedList(list, add) + } + if list, ok := doc["patchesStrategicMerge"].([]interface{}); ok { + appendPatchPathsMixedList(list, add) + } + if list, ok := doc["patchesJson6902"].([]interface{}); ok { + for _, item := range list { + if m, ok := item.(map[string]interface{}); ok { + if p, ok := m["path"].(string); ok { + add(p) + } + } + } + } + return out +} + +// transformerPatchFileLine maps a finding into the transformer patch file; returns Line 0 when not mappable. +func transformerPatchFileLine( + ctx context.Context, + origin *model.KustomizeOrigin, + searchKey string, + outputLines int, +) model.VulnerabilityLines { + if origin == nil || len(origin.Transformations) == 0 || strings.TrimSpace(searchKey) == "" { + return model.VulnerabilityLines{} + } + for i := len(origin.Transformations) - 1; i >= 0; i-- { + tr := origin.Transformations[i] + for _, p := range transformerPatchCandidatePaths(origin, tr) { + info, err := rootfile.Lstat(p) + if err != nil { + zlog.Ctx(ctx).Debug().Err(err).Str("transformer_patch", p).Msg("kustomize transformer patch path not usable") + continue + } + if info.IsDir() { + continue + } + data, err := rootfile.ReadFile(p) + if err != nil { + continue + } + if ln := astLineForSearchKey(data, searchKey); ln > 0 { + fileLines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + if ln <= len(fileLines) { + t := strings.TrimSpace(strings.TrimRight(fileLines[ln-1], "\r")) + return model.VulnerabilityLines{ + Line: ln, + VulnLines: detector.GetAdjacentVulnLines(ln-1, outputLines, fileLines), + LineWithVulnerability: t, + ResolvedFile: p, + VulnerablilityLocation: model.ResourceLocation{ + Start: model.ResourceLine{Line: ln, Col: 1}, + End: model.ResourceLine{Line: ln, Col: 1}, + }, + } + } + } + } + } + return model.VulnerabilityLines{} +} diff --git a/pkg/detector/kustomize/generator_line_test.go b/pkg/detector/kustomize/generator_line_test.go new file mode 100644 index 000000000..b75181ac8 --- /dev/null +++ b/pkg/detector/kustomize/generator_line_test.go @@ -0,0 +1,35 @@ +package kustomize + +import ( + "os" + "path/filepath" + "testing" + + "github.com/DataDog/datadog-iac-scanner/pkg/model" + "github.com/stretchr/testify/require" +) + +func TestGeneratorConfigLine_secondGeneratorBlock(t *testing.T) { + dir := t.TempDir() + kust := filepath.Join(dir, "kustomization.yaml") + content := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +configMapGenerator: +- name: first + literals: + - a=b +secretGenerator: +- name: secret-a + literals: + - s=t +configMapGenerator: +- name: second + literals: + - c=d +` + require.NoError(t, os.WriteFile(kust, []byte(content), 0o600)) + o := &model.KustomizeOrigin{GeneratorConfigFile: kust} + line, p := generatorConfigLine(o, "second") + require.Equal(t, kust, p) + require.Equal(t, 12, line) +} diff --git a/pkg/detector/kustomize/helper_main_test.go b/pkg/detector/kustomize/helper_main_test.go new file mode 100644 index 000000000..c0e105b10 --- /dev/null +++ b/pkg/detector/kustomize/helper_main_test.go @@ -0,0 +1,14 @@ +package kustomize + +import ( + "os" + "testing" + + resolverkustomize "github.com/DataDog/datadog-iac-scanner/pkg/resolver/kustomize" +) + +// TestMain ensures kustomize subprocess render (re-exec of this test binary) works during detector tests. +func TestMain(m *testing.M) { + resolverkustomize.MaybeRunAsKustomizeRenderHelper() + os.Exit(m.Run()) +} diff --git a/pkg/detector/kustomize/namehash.go b/pkg/detector/kustomize/namehash.go new file mode 100644 index 000000000..58deb0edb --- /dev/null +++ b/pkg/detector/kustomize/namehash.go @@ -0,0 +1,21 @@ +package kustomize + +import "regexp" + +// Default generator name suffix is a 10-char hash; strip it when matching kustomization names. +var kustomizeNameHashSuffix = regexp.MustCompile(`-[a-z0-9]{10}$`) + +func stripKustomizeNameHashSuffix(name string) string { + return kustomizeNameHashSuffix.ReplaceAllString(name, "") +} + +func generatorNameCandidates(resourceName string) []string { + if resourceName == "" { + return nil + } + out := []string{resourceName} + if s := stripKustomizeNameHashSuffix(resourceName); s != resourceName && s != "" { + out = append(out, s) + } + return out +} diff --git a/pkg/detector/kustomize/namehash_test.go b/pkg/detector/kustomize/namehash_test.go new file mode 100644 index 000000000..a3d326be2 --- /dev/null +++ b/pkg/detector/kustomize/namehash_test.go @@ -0,0 +1,27 @@ +package kustomize + +import ( + "os" + "path/filepath" + "testing" + + "github.com/DataDog/datadog-iac-scanner/pkg/model" + "github.com/stretchr/testify/require" +) + +func TestStripKustomizeNameHashSuffix(t *testing.T) { + require.Equal(t, "my-map", stripKustomizeNameHashSuffix("my-map-h4kk2gt9cm")) + require.Equal(t, "x", stripKustomizeNameHashSuffix("x")) +} + +func TestGeneratorConfigLine_matchesWithHashedLookupName(t *testing.T) { + // kustomization declares "my-map"; detector may still receive a hashed name from document metadata. + content := "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nconfigMapGenerator:\n- name: my-map\n literals:\n - k=v\n" + dir := t.TempDir() + p := filepath.Join(dir, "kustomization.yaml") + require.NoError(t, os.WriteFile(p, []byte(content), 0o600)) + o := &model.KustomizeOrigin{GeneratorConfigFile: p} + line, gotPath := generatorConfigLine(o, "my-map-h4kk2gt9cm") + require.Equal(t, p, gotPath) + require.Equal(t, 4, line) +} diff --git a/pkg/engine/inspector.go b/pkg/engine/inspector.go index a4de52a50..724fffb62 100644 --- a/pkg/engine/inspector.go +++ b/pkg/engine/inspector.go @@ -18,6 +18,7 @@ import ( "github.com/DataDog/datadog-iac-scanner/pkg/detector" "github.com/DataDog/datadog-iac-scanner/pkg/detector/docker" "github.com/DataDog/datadog-iac-scanner/pkg/detector/helm" + "github.com/DataDog/datadog-iac-scanner/pkg/detector/kustomize" "github.com/DataDog/datadog-iac-scanner/pkg/detector/terraform" "github.com/DataDog/datadog-iac-scanner/pkg/engine/source" "github.com/DataDog/datadog-iac-scanner/pkg/featureflags" @@ -158,6 +159,7 @@ func NewInspector( lineDetector := detector.NewDetectLine(tracker.GetOutputLines()). Add(helm.DetectKindLine{}, model.KindHELM). + Add(kustomize.DetectKindLine{}, model.KindKUSTOMIZE). Add(docker.DetectKindLine{}, model.KindDOCKER). Add(terraform.DetectKindLine{}, model.KindTerraform) diff --git a/pkg/engine/vulnerability_builder.go b/pkg/engine/vulnerability_builder.go index a57e89386..4cd3fab45 100644 --- a/pkg/engine/vulnerability_builder.go +++ b/pkg/engine/vulnerability_builder.go @@ -62,6 +62,16 @@ func modifyVulSearchKeyReference(doc interface{}, originalSearchKey string, stri return originalSearchKey, false } +func useSearchLineFastPathFor(file *model.FileMetadata) bool { + if slices.Contains([]model.FileKind{model.KindHELM, model.KindTerraform}, file.Kind) { + return false + } + if file.Kind == model.KindKUSTOMIZE { + return false + } + return len(file.ResolvedFiles) == 0 +} + // DefaultVulnerabilityBuilder defines a vulnerability builder to execute default actions of scan var DefaultVulnerabilityBuilder = func(ctx context.Context, qCtx *QueryContext, tracker Tracker, @@ -141,7 +151,8 @@ var DefaultVulnerabilityBuilder = func(ctx context.Context, qCtx *QueryContext, // terraform detection tries to capture blocks whenever possible which is contradictory with the searchLine // only .tf files use this detection, therefore, terraform plans and cdk (json) are not excluded - if !slices.Contains([]model.FileKind{model.KindHELM, model.KindTerraform}, file.Kind) && len(file.ResolvedFiles) == 0 { + useSearchLineFastPath := useSearchLineFastPathFor(file) + if useSearchLineFastPath { searchLineCalc := &searchLineCalculator{ lineNr: -1, vObj: vObj, diff --git a/pkg/kics/service_test.go b/pkg/kics/service_test.go index 39230d1bc..6c7f490c5 100644 --- a/pkg/kics/service_test.go +++ b/pkg/kics/service_test.go @@ -8,6 +8,8 @@ package kics import ( "context" "fmt" + "os" + "path/filepath" "reflect" "sync" "testing" @@ -22,6 +24,8 @@ import ( terraformParser "github.com/DataDog/datadog-iac-scanner/pkg/parser/terraform" yamlParser "github.com/DataDog/datadog-iac-scanner/pkg/parser/yaml/default" "github.com/DataDog/datadog-iac-scanner/pkg/resolver" + kustomizeResolver "github.com/DataDog/datadog-iac-scanner/pkg/resolver/kustomize" + "github.com/stretchr/testify/require" ) // TestService tests the functions [GetVulnerabilities(), StartScan()] and all the methods called by them @@ -77,6 +81,7 @@ func TestService(t *testing.T) { //nolint } for _, tt := range tests { s := make([]*Service, 0, len(tt.fields.Parser)) + resolverDiagnostics := NewResolverDiagnosticsState() for _, parser := range tt.fields.Parser { s = append(s, &Service{ SourceProvider: tt.fields.SourceProvider, @@ -85,6 +90,7 @@ func TestService(t *testing.T) { //nolint Inspector: tt.fields.Inspector, Tracker: tt.fields.Tracker, Resolver: tt.fields.Resolver, + ResolverDiagnostics: resolverDiagnostics, }) } t.Run(fmt.Sprintf("%s", tt.name+"_get_vulnerabilities"), func(t *testing.T) { @@ -139,3 +145,123 @@ func createParserSourceProvider(path string) ([]*parser.Parser, return mockParser, mockFilesSource, mockResolver } + +func TestSaveResolverDiagnostics_DedupesAcrossServicesInSameScan(t *testing.T) { + store := storage.NewMemoryStorage() + state := NewResolverDiagnosticsState() + scanID := "scanID" + diag := model.ResolverDiagnostic{ + FilePath: "/tmp/overlay/kustomization.yaml", + Message: "render failed", + QueryID: "kustomize-render-failed", + Line: 0, + } + + serviceA := &Service{ + Storage: store, + ResolverDiagnostics: state, + } + serviceB := &Service{ + Storage: store, + ResolverDiagnostics: state, + } + + require.NoError(t, serviceA.saveResolverDiagnostics(context.Background(), scanID, []model.ResolverDiagnostic{diag})) + require.NoError(t, serviceB.saveResolverDiagnostics(context.Background(), scanID, []model.ResolverDiagnostic{diag})) + + vulns, err := store.GetVulnerabilities(context.Background(), scanID) + require.NoError(t, err) + require.Len(t, vulns, 1) + require.Equal(t, "", vulns[0].Platform) + require.Equal(t, 1, vulns[0].Line) +} + +// Regression: a single unparseable rendered doc (e.g. Kustomize generator +// output with a virtual filename) must not drop resFiles.Excluded; otherwise +// the walker re-scans patches / base files as raw YAML and produces duplicate +// or partial-document findings. +func TestResolverSink_PropagatesExcludedOnParseFailure(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- pod.yaml +configMapGenerator: +- name: app-config + literals: + - KEY=value +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pod.yaml"), []byte(`apiVersion: v1 +kind: Pod +metadata: + name: demo + namespace: default +spec: + containers: + - name: app + image: nginx:1.21 + envFrom: + - configMapRef: + name: app-config +`), 0o600)) + + resolverWithKustomize, err := resolver.NewBuilder(). + Add(ctx, kustomizeResolver.NewResolver(kustomizeResolver.Options{RepoRoot: root})). + Build(ctx) + require.NoError(t, err) + + kicsParser, err := parser.NewBuilder(ctx). + Add(&yamlParser.Parser{}). + Build([]string{"Kubernetes"}, []string{""}) + require.NoError(t, err) + + tr, err := tracker.NewTracker(1) + require.NoError(t, err) + + service := &Service{ + Storage: storage.NewMemoryStorage(), + ResolverDiagnostics: NewResolverDiagnosticsState(), + Parser: kicsParser[0], + Resolver: resolverWithKustomize, + Tracker: tr, + } + + excluded, err := service.resolverSink(ctx, root, "scanID", false, 0) + require.NoError(t, err) + + // Generator emits a virtual generated-ConfigMap-*.yaml (no file on disk) + // which fails Parser.Parse; excludes must still surface the real sources. + require.Contains(t, excluded, filepath.Join(root, "kustomization.yaml")) + require.Contains(t, excluded, filepath.Join(root, "pod.yaml")) +} + +func TestSaveResolverDiagnostics_UsesServicePlatform(t *testing.T) { + store := storage.NewMemoryStorage() + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(`apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: [] +`), 0o600)) + resolverWithKustomize, err := resolver.NewBuilder(). + Add(context.Background(), kustomizeResolver.NewResolver(kustomizeResolver.Options{RepoRoot: root})). + Build(context.Background()) + require.NoError(t, err) + service := &Service{ + Storage: store, + ResolverDiagnostics: NewResolverDiagnosticsState(), + Parser: &parser.Parser{Platform: []string{"Knative", "Kubernetes"}}, + Resolver: resolverWithKustomize, + } + + require.NoError(t, service.saveResolverDiagnostics(context.Background(), "scanID", []model.ResolverDiagnostic{{ + FilePath: filepath.Join(root, "kustomization.yaml"), + Message: "render failed", + QueryID: "kustomize-render-failed", + }})) + + vulns, err := store.GetVulnerabilities(context.Background(), "scanID") + require.NoError(t, err) + require.Len(t, vulns, 1) + require.Equal(t, "Kubernetes", vulns[0].Platform) +}