Allow decoding of anchorectl json files (#3973)

* allow decoding of import sbom file shape

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* address formatting

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add file mode and type processing

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* use type to interpret the raw value

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* safe mode convert should use uint32

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* simpler decoder type

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman 2025-06-10 15:03:50 -04:00 committed by GitHub
parent cfa7cc5be9
commit 79b6d5daa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 477 additions and 4 deletions

View File

@ -1,5 +1,10 @@
package model package model
import (
"encoding/json"
"fmt"
)
// Document represents the syft cataloging findings as a JSON document // Document represents the syft cataloging findings as a JSON document
type Document struct { type Document struct {
Artifacts []Package `json:"artifacts"` // Artifacts is the list of packages discovered and placed into the catalog Artifacts []Package `json:"artifacts"` // Artifacts is the list of packages discovered and placed into the catalog
@ -11,6 +16,25 @@ type Document struct {
Schema Schema `json:"schema"` // Schema is a block reserved for defining the version for the shape of this JSON document and where to find the schema document to validate the shape Schema Schema `json:"schema"` // Schema is a block reserved for defining the version for the shape of this JSON document and where to find the schema document to validate the shape
} }
func (d *Document) UnmarshalJSON(data []byte) error {
type Alias *Document
aux := Alias(d)
if err := json.Unmarshal(data, aux); err != nil {
return fmt.Errorf("could not unmarshal syft JSON document: %w", err)
}
// in previous versions of anchorectl, the file modes were stored as decimal values instead of octal.
if d.Schema.Version == "1.0.0" && d.Descriptor.Name == "anchorectl" {
// convert all file modes from decimal to octal
for i := range d.Files {
d.Files[i].Metadata.Mode = convertFileModeToBase8(d.Files[i].Metadata.Mode)
}
}
return nil
}
// Descriptor describes what created the document as well as surrounding metadata // Descriptor describes what created the document as well as surrounding metadata
type Descriptor struct { type Descriptor struct {
Name string `json:"name"` Name string `json:"name"`

View File

@ -0,0 +1,99 @@
package model
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDocumentUnmarshalJSON_SchemaDetection(t *testing.T) {
tests := []struct {
name string
jsonData string
modes []int
}{
{
name: "schema version 1.0.0 + anchorectl",
jsonData: `{
"files": [
{"metadata": {"mode": 493}},
{"metadata": {"mode": 420}}
],
"schema": {"version": "1.0.0"},
"descriptor": {
"name": "anchorectl"
}
}`,
modes: []int{755, 644},
},
{
name: "schema version 1.0.0 + syft",
jsonData: `{
"files": [
{"metadata": {"mode": 755}},
{"metadata": {"mode": 644}}
],
"schema": {"version": "1.0.0"},
"descriptor": {
"name": "syft"
}
}`,
modes: []int{755, 644},
},
{
name: "schema version 2.0.0 + anchorectl",
jsonData: `{
"files": [
{"metadata": {"mode": 755}},
{"metadata": {"mode": 644}}
],
"schema": {"version": "2.0.0"},
"descriptor": {
"name": "anchorectl"
}
}`,
modes: []int{755, 644},
},
{
name: "missing schema version should not convert modes",
jsonData: `{
"files": [
{"metadata": {"mode": 755}}
],
"schema": {}
}`,
modes: []int{755},
},
{
name: "empty files array with version 1.0.0",
jsonData: `{
"files": [],
"schema": {"version": "1.0.0"},
"descriptor": {
"name": "anchorectl"
}
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var doc Document
err := json.Unmarshal([]byte(tt.jsonData), &doc)
if err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
var modes []int
for _, file := range doc.Files {
modes = append(modes, file.Metadata.Mode)
}
require.Len(t, doc.Files, len(tt.modes), "Unexpected number of files")
assert.Equal(t, tt.modes, modes, "File modes do not match expected values")
})
}
}

View File

@ -1,6 +1,11 @@
package model package model
import ( import (
"encoding/json"
"fmt"
"strconv"
stereoFile "github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/license" "github.com/anchore/syft/syft/license"
) )
@ -26,6 +31,44 @@ type FileMetadataEntry struct {
Size int64 `json:"size"` Size int64 `json:"size"`
} }
func (f *FileMetadataEntry) UnmarshalJSON(data []byte) error {
type Alias FileMetadataEntry
aux := (*Alias)(f)
if err := json.Unmarshal(data, aux); err == nil {
// we should have at least one field set to a non-zero value... otherwise this is a legacy entry
if f.Mode != 0 || f.Type != "" || f.LinkDestination != "" ||
f.UserID != 0 || f.GroupID != 0 || f.MIMEType != "" || f.Size != 0 {
return nil
}
}
var legacy sbomImportLegacyFileMetadataEntry
if err := json.Unmarshal(data, &legacy); err != nil {
return err
}
f.Mode = legacy.Mode
f.Type = string(legacy.Type)
f.LinkDestination = legacy.LinkDestination
f.UserID = legacy.UserID
f.GroupID = legacy.GroupID
f.MIMEType = legacy.MIMEType
f.Size = legacy.Size
return nil
}
type sbomImportLegacyFileMetadataEntry struct {
Mode int `json:"Mode"`
Type intOrStringFileType `json:"Type"`
LinkDestination string `json:"LinkDestination"`
UserID int `json:"UserID"`
GroupID int `json:"GroupID"`
MIMEType string `json:"MIMEType"`
Size int64 `json:"Size"`
}
type FileLicense struct { type FileLicense struct {
Value string `json:"value"` Value string `json:"value"`
SPDXExpression string `json:"spdxExpression"` SPDXExpression string `json:"spdxExpression"`
@ -38,3 +81,28 @@ type FileLicenseEvidence struct {
Offset int `json:"offset"` Offset int `json:"offset"`
Extent int `json:"extent"` Extent int `json:"extent"`
} }
type intOrStringFileType string
func (lt *intOrStringFileType) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
*lt = intOrStringFileType(str)
return nil
}
var num stereoFile.Type
if err := json.Unmarshal(data, &num); err != nil {
return fmt.Errorf("file.Type must be either string or int, got: %s", string(data))
}
*lt = intOrStringFileType(num.String())
return nil
}
func convertFileModeToBase8(rawMode int) int {
octalStr := fmt.Sprintf("%o", rawMode)
// we don't need to check that this is a valid octal string since the input is always an integer
result, _ := strconv.Atoi(octalStr)
return result
}

View File

@ -0,0 +1,277 @@
package model
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_FileMetadataEntry_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
jsonData []byte
expected FileMetadataEntry
}{
{
name: "unmarshal current format",
jsonData: []byte(`{
"mode": 644,
"type": "RegularFile",
"linkDestination": "/usr/bin/python3",
"userID": 1000,
"groupID": 1000,
"mimeType": "text/plain",
"size": 10174
}`),
expected: FileMetadataEntry{
Mode: 644,
Type: "RegularFile",
LinkDestination: "/usr/bin/python3",
UserID: 1000,
GroupID: 1000,
MIMEType: "text/plain",
Size: 10174,
},
},
{
name: "unmarshal legacy sbom import format",
jsonData: []byte(`{
"Mode": 644,
"Type": "RegularFile",
"LinkDestination": "/usr/bin/python3",
"UserID": 1000,
"GroupID": 1000,
"MIMEType": "text/plain",
"Size": 10174
}`),
expected: FileMetadataEntry{
Mode: 644,
Type: "RegularFile",
LinkDestination: "/usr/bin/python3",
UserID: 1000,
GroupID: 1000,
MIMEType: "text/plain",
Size: 10174,
},
},
{
name: "unmarshal legacy sbom import format - integer type",
jsonData: []byte(`{
"Mode": 644,
"Type": 0,
"LinkDestination": "/usr/bin/python3",
"UserID": 1000,
"GroupID": 1000,
"MIMEType": "text/plain",
"Size": 10174
}`),
expected: FileMetadataEntry{
Mode: 644,
Type: "RegularFile",
LinkDestination: "/usr/bin/python3",
UserID: 1000,
GroupID: 1000,
MIMEType: "text/plain",
Size: 10174,
},
},
{
name: "unmarshal minimal current format",
jsonData: []byte(`{
"mode": 0,
"type": "RegularFile",
"userID": 0,
"groupID": 0,
"size": 0
}`),
expected: FileMetadataEntry{
Type: "RegularFile",
},
},
{
name: "unmarshal minimal legacy format",
jsonData: []byte(`{
"Mode": 0,
"Type": "RegularFile",
"UserID": 0,
"GroupID": 0,
"Size": 0
}`),
expected: FileMetadataEntry{
Type: "RegularFile",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var actual FileMetadataEntry
err := actual.UnmarshalJSON(test.jsonData)
require.NoError(t, err)
if diff := cmp.Diff(test.expected, actual); diff != "" {
t.Errorf("FileMetadataEntry mismatch (-expected +actual):\n%s", diff)
}
})
}
}
func Test_intOrStringFileType_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
jsonData []byte
expected string
wantErr require.ErrorAssertionFunc
}{
// string inputs - should pass through unchanged
{
name: "string RegularFile",
jsonData: []byte(`"RegularFile"`),
expected: "RegularFile",
},
{
name: "string HardLink",
jsonData: []byte(`"HardLink"`),
expected: "HardLink",
},
{
name: "string Directory",
jsonData: []byte(`"Directory"`),
expected: "Directory",
},
{
name: "string custom value",
jsonData: []byte(`"CustomFileType"`),
expected: "CustomFileType",
},
// integer inputs - should convert to string representation
{
name: "int 0 (TypeRegular)",
jsonData: []byte(`0`),
expected: "RegularFile",
},
{
name: "int 1 (TypeHardLink)",
jsonData: []byte(`1`),
expected: "HardLink",
},
{
name: "int 2 (TypeSymLink)",
jsonData: []byte(`2`),
expected: "SymbolicLink",
},
{
name: "int 3 (TypeCharacterDevice)",
jsonData: []byte(`3`),
expected: "CharacterDevice",
},
{
name: "int 4 (TypeBlockDevice)",
jsonData: []byte(`4`),
expected: "BlockDevice",
},
{
name: "int 5 (TypeDirectory)",
jsonData: []byte(`5`),
expected: "Directory",
},
{
name: "int 6 (TypeFIFO)",
jsonData: []byte(`6`),
expected: "FIFONode",
},
{
name: "int 7 (TypeSocket)",
jsonData: []byte(`7`),
expected: "Socket",
},
{
name: "int 8 (TypeIrregular)",
jsonData: []byte(`8`),
expected: "IrregularFile",
},
{
name: "unknown int",
jsonData: []byte(`99`),
expected: "Unknown",
},
{
name: "negative int",
jsonData: []byte(`-1`),
expected: "Unknown",
},
{
name: "null value",
jsonData: []byte(`null`),
},
{
name: "invalid JSON",
jsonData: []byte(`{`),
wantErr: require.Error,
},
{
name: "boolean value",
jsonData: []byte(`true`),
wantErr: require.Error,
},
{
name: "array value",
jsonData: []byte(`[]`),
wantErr: require.Error,
},
{
name: "object value",
jsonData: []byte(`{}`),
wantErr: require.Error,
},
{
name: "float value",
jsonData: []byte(`1.5`),
wantErr: require.Error,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.wantErr == nil {
test.wantErr = require.NoError
}
var ft intOrStringFileType
err := ft.UnmarshalJSON(test.jsonData)
test.wantErr(t, err)
if err != nil {
return
}
assert.Equal(t, test.expected, string(ft))
})
}
}
func Test_convertFileModeToBase8(t *testing.T) {
tests := []struct {
name string
input int
expected int
}{
{
name: "no permissions",
input: 0,
expected: 0,
},
{
name: "symlink + rwxrwxrwx",
input: 134218239,
expected: 1000000777,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := convertFileModeToBase8(tt.input)
require.Equal(t, tt.expected, actual)
})
}
}

View File

@ -144,13 +144,12 @@ func toSyftFiles(files []model.File) sbom.Artifacts {
} }
func safeFileModeConvert(val int) (fs.FileMode, error) { func safeFileModeConvert(val int) (fs.FileMode, error) {
if val < math.MinInt32 || val > math.MaxInt32 { mode, err := strconv.ParseInt(strconv.Itoa(val), 8, 64)
// Value is out of the range that int32 can represent if mode < 0 || mode > math.MaxUint32 {
// value is out of the range that int32 can represent
return 0, fmt.Errorf("value %d is out of the range that int32 can represent", val) return 0, fmt.Errorf("value %d is out of the range that int32 can represent", val)
} }
// Safe to convert to os.FileMode
mode, err := strconv.ParseInt(strconv.Itoa(val), 8, 64)
if err != nil { if err != nil {
return 0, err return 0, err
} }

View File

@ -491,6 +491,12 @@ func Test_safeFileModeConvert(t *testing.T) {
want: os.FileMode(511), // 777 in octal equals 511 in decimal want: os.FileMode(511), // 777 in octal equals 511 in decimal
wantErr: false, wantErr: false,
}, },
{
name: "valid perm with symlink type",
val: 1000000777, // symlink + rwxrwxrwx
want: os.FileMode(0o1000000777), // 134218239
wantErr: false,
},
{ {
name: "outside int32 high", name: "outside int32 high",
val: int(math.MaxInt32) + 1, val: int(math.MaxInt32) + 1,