feat(misconf): include map key in manifest snippet for diagnostics (#9681)

Signed-off-by: nikpivkin <nikita.pivkin@smartforce.io>
This commit is contained in:
Nikita Pivkin
2025-10-22 00:24:11 +06:00
committed by GitHub
parent c32ddfc522
commit 197c9e1dce
7 changed files with 291 additions and 59 deletions

View File

@@ -986,10 +986,20 @@
"CauseMetadata": {
"Provider": "Kubernetes",
"Service": "general",
"StartLine": 5,
"StartLine": 4,
"EndLine": 7,
"Code": {
"Lines": [
{
"Number": 4,
"Content": "metadata:",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": "\u001b[38;5;33mmetadata\u001b[0m:",
"FirstCause": true,
"LastCause": false
},
{
"Number": 5,
"Content": " name: nginx-deployment",
@@ -997,7 +1007,7 @@
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mname\u001b[0m: nginx-deployment",
"FirstCause": true,
"FirstCause": false,
"LastCause": false
},
{
@@ -1135,10 +1145,20 @@
"CauseMetadata": {
"Provider": "Kubernetes",
"Service": "general",
"StartLine": 18,
"StartLine": 17,
"EndLine": 22,
"Code": {
"Lines": [
{
"Number": 17,
"Content": " spec:",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mspec\u001b[0m:",
"FirstCause": true,
"LastCause": false
},
{
"Number": 18,
"Content": " containers:",
@@ -1146,7 +1166,7 @@
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mcontainers\u001b[0m:",
"FirstCause": true,
"FirstCause": false,
"LastCause": false
},
{

View File

@@ -415,10 +415,20 @@
"CauseMetadata": {
"Provider": "Kubernetes",
"Service": "general",
"StartLine": 5,
"StartLine": 4,
"EndLine": 11,
"Code": {
"Lines": [
{
"Number": 4,
"Content": "metadata:",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": "\u001b[38;5;33mmetadata\u001b[0m:",
"FirstCause": true,
"LastCause": false
},
{
"Number": 5,
"Content": " name: testchart",
@@ -426,7 +436,7 @@
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mname\u001b[0m: testchart",
"FirstCause": true,
"FirstCause": false,
"LastCause": false
},
{
@@ -536,10 +546,20 @@
"CauseMetadata": {
"Provider": "Kubernetes",
"Service": "general",
"StartLine": 24,
"StartLine": 23,
"EndLine": 57,
"Code": {
"Lines": [
{
"Number": 23,
"Content": " spec:",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mspec\u001b[0m:",
"FirstCause": true,
"LastCause": false
},
{
"Number": 24,
"Content": " serviceAccountName: testchart",
@@ -547,7 +567,7 @@
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mserviceAccountName\u001b[0m: testchart",
"FirstCause": true,
"FirstCause": false,
"LastCause": false
},
{
@@ -618,20 +638,10 @@
"Truncated": false,
"Highlighted": " \u001b[38;5;33mdrop\u001b[0m:",
"FirstCause": false,
"LastCause": false
},
{
"Number": 32,
"Content": " - ALL",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": " - ALL",
"FirstCause": false,
"LastCause": true
},
{
"Number": 33,
"Number": 32,
"Content": "",
"IsCause": false,
"Annotation": "",

View File

@@ -542,10 +542,20 @@
"CauseMetadata": {
"Provider": "Kubernetes",
"Service": "general",
"StartLine": 30,
"StartLine": 29,
"EndLine": 36,
"Code": {
"Lines": [
{
"Number": 29,
"Content": " securityContext:",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33msecurityContext\u001b[0m:",
"FirstCause": true,
"LastCause": false
},
{
"Number": 30,
"Content": " capabilities:",
@@ -553,7 +563,7 @@
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mcapabilities\u001b[0m:",
"FirstCause": true,
"FirstCause": false,
"LastCause": false
},
{
@@ -640,10 +650,20 @@
"CauseMetadata": {
"Provider": "Kubernetes",
"Service": "general",
"StartLine": 5,
"StartLine": 4,
"EndLine": 11,
"Code": {
"Lines": [
{
"Number": 4,
"Content": "metadata:",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": "\u001b[38;5;33mmetadata\u001b[0m:",
"FirstCause": true,
"LastCause": false
},
{
"Number": 5,
"Content": " name: testchart",
@@ -651,7 +671,7 @@
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mname\u001b[0m: testchart",
"FirstCause": true,
"FirstCause": false,
"LastCause": false
},
{
@@ -761,10 +781,20 @@
"CauseMetadata": {
"Provider": "Kubernetes",
"Service": "general",
"StartLine": 24,
"StartLine": 23,
"EndLine": 57,
"Code": {
"Lines": [
{
"Number": 23,
"Content": " spec:",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mspec\u001b[0m:",
"FirstCause": true,
"LastCause": false
},
{
"Number": 24,
"Content": " serviceAccountName: testchart",
@@ -772,7 +802,7 @@
"Annotation": "",
"Truncated": false,
"Highlighted": " \u001b[38;5;33mserviceAccountName\u001b[0m: testchart",
"FirstCause": true,
"FirstCause": false,
"LastCause": false
},
{
@@ -843,20 +873,10 @@
"Truncated": false,
"Highlighted": " \u001b[38;5;33mdrop\u001b[0m:",
"FirstCause": false,
"LastCause": false
},
{
"Number": 32,
"Content": " - ALL",
"IsCause": true,
"Annotation": "",
"Truncated": false,
"Highlighted": " - ALL",
"FirstCause": false,
"LastCause": true
},
{
"Number": 33,
"Number": 32,
"Content": "",
"IsCause": false,
"Annotation": "",

View File

@@ -130,17 +130,14 @@ func (n *ManifestNode) UnmarshalYAML(node *yaml.Node) error {
}
func (n *ManifestNode) handleSliceTag(node *yaml.Node) error {
var nodes []*ManifestNode
nodes := make([]*ManifestNode, 0, len(node.Content))
maxLine := node.Line
for _, contentNode := range node.Content {
newNode := new(ManifestNode)
newNode.FilePath = n.FilePath
if err := contentNode.Decode(newNode); err != nil {
newNode, err := newManifestNodeFromYaml(n.FilePath, contentNode)
if err != nil {
return err
}
if newNode.EndLine > maxLine {
maxLine = newNode.EndLine
}
maxLine = max(maxLine, newNode.EndLine)
nodes = append(nodes, newNode)
}
n.EndLine = maxLine
@@ -149,29 +146,54 @@ func (n *ManifestNode) handleSliceTag(node *yaml.Node) error {
}
func (n *ManifestNode) handleMapTag(node *yaml.Node) error {
output := make(map[string]*ManifestNode)
var key string
if len(node.Content)%2 != 0 {
return fmt.Errorf("invalid map node at line %d: uneven number of children", node.Line)
}
output := make(map[string]*ManifestNode, len(node.Content)/2)
maxLine := node.Line
for i, contentNode := range node.Content {
if i == 0 || i%2 == 0 {
key = contentNode.Value
} else {
newNode := new(ManifestNode)
newNode.FilePath = n.FilePath
if err := contentNode.Decode(newNode); err != nil {
return err
}
output[key] = newNode
if newNode.EndLine > maxLine {
maxLine = newNode.EndLine
}
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
newNode, err := newManifestNodeFromYaml(n.FilePath, valueNode)
if err != nil {
return err
}
if newNode.Type == TagMap {
// Set StartLine to the key's line so that the map node snippet
// correctly starts at the key in the YAML file
newNode.StartLine = keyNode.Line
}
output[keyNode.Value] = newNode
maxLine = max(maxLine, newNode.EndLine)
}
n.EndLine = maxLine
n.Value = output
return nil
}
func newManifestNodeFromYaml(filePath string, yamlNode *yaml.Node) (*ManifestNode, error) {
newNode := &ManifestNode{
FilePath: filePath,
}
if yamlNode.Tag == "!!null" {
// UnmarshalYAML is not called for null nodes
newNode.StartLine = yamlNode.Line
newNode.EndLine = yamlNode.Line
return newNode, nil
}
if err := yamlNode.Decode(newNode); err != nil {
return nil, err
}
return newNode, nil
}
func (n *ManifestNode) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var valPtr any
var nodeType TagType

View File

@@ -84,6 +84,76 @@ func TestJsonManifestToRego(t *testing.T) {
assert.Equal(t, expected, manifest.ToRego())
}
func TestYamlManifestToRego(t *testing.T) {
content := `apiVersion: v1
kind: Pod
metadata:
name: hello-cpu-limit
foo: null
spec:
containers:
- command:
- sh
- -c
- echo 'Hello' && sleep 1h
- null
image: busybox
name: hello
`
const filePath = "pod.yaml"
manifest, err := parser.ManifestFromYAML(filePath, []byte(content))
require.NoError(t, err)
expected := map[string]any{
"__defsec_metadata": map[string]any{
"filepath": filePath,
"offset": 0,
"startline": 1,
"endline": 14,
},
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"__defsec_metadata": map[string]any{
"filepath": filePath,
"offset": 0,
"startline": 3,
"endline": 5,
},
"name": "hello-cpu-limit",
"foo": nil, // YAML null preserved
},
"spec": map[string]any{
"__defsec_metadata": map[string]any{
"filepath": filePath,
"offset": 0,
"startline": 6,
"endline": 14,
},
"containers": []any{
map[string]any{
"__defsec_metadata": map[string]any{
"filepath": filePath,
"offset": 0,
"startline": 8,
"endline": 14,
},
"command": []any{
"sh",
"-c",
"echo 'Hello' && sleep 1h",
nil, // YAML null preserved here too
},
"image": "busybox",
"name": "hello",
},
},
},
}
assert.Equal(t, expected, manifest.ToRego())
}
func TestManifestToRego(t *testing.T) {
const filePath = "pod.json"
tests := []struct {

View File

@@ -41,8 +41,20 @@ func Parse(_ context.Context, r io.Reader, path string) ([]any, error) {
manifests = append(manifests, manifest.ToRego())
}
offset += len(strings.Split(partial, "\n"))
offset += countLines(partial)
}
return manifests, nil
}
func countLines(s string) int {
if s == "" {
return 1
}
count := strings.Count(s, "\n")
if s[len(s)-1] != '\n' {
count++
}
return count
}

View File

@@ -0,0 +1,78 @@
package parser_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/aquasecurity/trivy/pkg/iac/scanners/kubernetes/parser"
)
func TestParse(t *testing.T) {
const filePath = "test.yaml"
tests := []struct {
name string
src string
expectedOffsets []int
}{
{
name: "empty file",
src: "",
expectedOffsets: nil,
},
{
name: "single YAML without separator",
src: `
apiVersion: v1
kind: Pod
`,
expectedOffsets: []int{0},
},
{
name: "multiple YAML documents",
src: `---
apiVersion: v1
kind: Pod
---
apiVersion: v1
kind: Service
`,
expectedOffsets: []int{1, 3},
},
{
name: "YAML with multiple empty blocks",
src: `---
---
---
apiVersion: v1
kind: Pod
`,
expectedOffsets: []int{3},
},
{
name: "Windows line endings",
src: "---\r\napiVersion: v1\r\nkind: Pod\r\n",
expectedOffsets: []int{1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
manifests, err := parser.Parse(t.Context(), strings.NewReader(tt.src), filePath)
require.NoError(t, err)
require.Len(t, manifests, len(tt.expectedOffsets))
for i, m := range manifests {
manifest := m.(map[string]any)
metadata, ok := manifest["__defsec_metadata"].(map[string]any)
require.True(t, ok)
offset, ok := metadata["offset"].(int)
require.True(t, ok)
require.Equal(t, tt.expectedOffsets[i], offset)
}
})
}
}