diff --git a/go.mod b/go.mod index 55b5c42..a9d9eca 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/carabiner-dev/attestation v0.2.1 github.com/carabiner-dev/policy v0.5.5 + github.com/hjson/hjson-go/v4 v4.7.1 google.golang.org/protobuf v1.36.12 ) diff --git a/go.sum b/go.sum index 6a5734b..653bbc7 100644 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= +github.com/hjson/hjson-go/v4 v4.7.1 h1:nC/dZ7GCvcFa9KXR3YJzufloeWRLovFAI4XmiAl7jy8= +github.com/hjson/hjson-go/v4 v4.7.1/go.mod h1:4zx6c7Y0vWcm8IRyVoQJUHAPJLXLvbG6X8nk1RLigSo= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= diff --git a/hjson.go b/hjson.go new file mode 100644 index 0000000..4864c95 --- /dev/null +++ b/hjson.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2025 Carabiner Systems, Inc +// SPDX-License-Identifier: Apache-2.0 + +package predicates + +import ( + "bytes" + "encoding/json" + + "github.com/carabiner-dev/attestation" + "github.com/hjson/hjson-go/v4" +) + +// normalizeToJSON returns the JSON form of a policy material document. +// Policies, policy sets and groups are authored in JSON or HJSON (comments, +// unquoted keys, multiline strings); attestations only ever carry JSON. Valid +// JSON is returned untouched, HJSON is converted, and data that is neither +// (or that does not describe an object) is reported as not being a policy +// document at all through attestation.ErrNotCorrectFormat, so the caller +// can move on to other predicate parsers. +func normalizeToJSON(data []byte) ([]byte, error) { + // HJSON accepts a braceless root object, so empty input would parse + // as an empty document. Reject it explicitly. + if len(bytes.TrimSpace(data)) == 0 { + return nil, attestation.ErrNotCorrectFormat + } + if json.Valid(data) { + if !isJSONObject(data) { + return nil, attestation.ErrNotCorrectFormat + } + return data, nil + } + + // HJSON is lenient (braceless roots, bare words as strings), so noise + // can parse as an empty object. Only a non-empty object counts as a + // human-authored policy document. + var parsed any + if err := hjson.Unmarshal(data, &parsed); err != nil { + return nil, attestation.ErrNotCorrectFormat + } + doc, ok := parsed.(map[string]any) + if !ok || len(doc) == 0 { + return nil, attestation.ErrNotCorrectFormat + } + normalized, err := json.Marshal(parsed) + if err != nil { + return nil, attestation.ErrNotCorrectFormat + } + return normalized, nil +} + +// isJSONObject reports if a valid JSON document is an object. +func isJSONObject(data []byte) bool { + trimmed := bytes.TrimLeft(data, " \t\r\n") + return len(trimmed) > 0 && trimmed[0] == '{' +} diff --git a/hjson_test.go b/hjson_test.go new file mode 100644 index 0000000..de06809 --- /dev/null +++ b/hjson_test.go @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright 2025 Carabiner Systems, Inc +// SPDX-License-Identifier: Apache-2.0 + +package predicates + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/carabiner-dev/attestation" + "google.golang.org/protobuf/encoding/protojson" + + papi "github.com/carabiner-dev/policy/api/v1" +) + +// hjsonPolicySet is a policy set as humans write it: comments, unquoted +// keys, a multiline description and trailing commas. +const hjsonPolicySet = `{ + // A policy set written in HJSON + id: hjson-set + meta: { + description: + ''' + # Release verification + Multi-line markdown. + ''' + version: 1, + } + common: { + identities: [ + { + sigstore: { + issuerMatch: { exact: "https://token.actions.githubusercontent.com" } + identityMatch: { regex: "^https://github\\.com/org/repo/\\.github/workflows/release\\.yaml@refs/tags/v.*$" } + } + } + ] + } + policies: [ + { + id: slsa-builder-id + source: { location: { uri: "git+https://github.com/carabiner-dev/policies#slsa/slsa-builder-id.json" } } + }, + ] +}` + +const hjsonPolicy = `{ + id: has-provenance + meta: { description: "Requires a provenance attestation" } + tenets: [ + { + id: exists + predicates: { types: ["https://slsa.dev/provenance/v1"] } + code: "size(predicates) > 0" + } + ] +}` + +const hjsonPolicyGroup = `{ + id: release-group + meta: { description: "A group" } + blocks: [ + { + id: block-a + policies: [ { id: "has-provenance" } ] + } + ] +}` + +func TestNormalizeToJSON(t *testing.T) { + for _, tc := range []struct { + name string + data string + wantNotFormat bool + wantID any + }{ + {"json-untouched", `{"id": "x", "meta": {"version": 1}}`, false, "x"}, + {"hjson-converted", "{\n // comment\n id: y\n meta: { version: 1 }\n}", false, "y"}, + {"hjson-braceless-root", "id: z\nmeta: { version: 1 }", false, "z"}, + {"empty", "", true, ""}, + {"whitespace", " \n\t", true, ""}, + {"json-array", `[1, 2]`, true, ""}, + {"json-string", `"just a string"`, true, ""}, + {"binary-garbage", "\x00\x01{{{", true, ""}, + {"hjson-empty-document", "// nothing here\n", true, ""}, + {"json-empty-object-kept", `{}`, false, nil}, + {"unbalanced", "{ id: x", true, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := normalizeToJSON([]byte(tc.data)) + if tc.wantNotFormat { + if !errors.Is(err, attestation.ErrNotCorrectFormat) { + t.Fatalf("expected ErrNotCorrectFormat, got err=%v out=%q", err, out) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !json.Valid(out) { + t.Fatalf("normalized output is not JSON: %q", out) + } + var doc map[string]any + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + if doc["id"] != tc.wantID { + t.Fatalf("id = %v, want %v", doc["id"], tc.wantID) + } + }) + } +} + +func TestParser_HJSONPolicyMaterials(t *testing.T) { + parser := New() + + t.Run("policyset", func(t *testing.T) { + pred, err := parser.ParsePolicySetPredicate([]byte(hjsonPolicySet)) + if err != nil { + t.Fatalf("ParsePolicySetPredicate() error = %v", err) + } + set, ok := pred.(*PolicySet) + if !ok { + t.Fatalf("wrong type %T", pred) + } + if set.Parsed.GetId() != "hjson-set" { + t.Errorf("id = %q", set.Parsed.GetId()) + } + if set.Parsed.GetMeta().GetVersion() != 1 { + t.Errorf("version = %d", set.Parsed.GetMeta().GetVersion()) + } + if got := set.Parsed.GetCommon().GetIdentities()[0].GetSigstore().GetIdentityMatch().GetRegex(); got == "" { + t.Error("identity regex was lost in normalization") + } + if got := set.Parsed.GetPolicies()[0].GetSource().GetLocation().GetUri(); got == "" { + t.Error("remote reference was lost in normalization") + } + // The data exposed downstream is JSON, so consumers that only speak + // JSON (protojson, the policy parser) can read it back. + if !json.Valid(set.GetData()) { + t.Fatalf("GetData() is not JSON: %q", set.GetData()) + } + reparsed := &papi.PolicySet{} + if err := protojson.Unmarshal(set.GetData(), reparsed); err != nil { + t.Fatalf("GetData() does not round-trip through protojson: %v", err) + } + if set.GetType() != PredicateTypePolicySet { + t.Errorf("type = %s", set.GetType()) + } + }) + + t.Run("policy", func(t *testing.T) { + pred, err := parser.ParsePolicyPredicate([]byte(hjsonPolicy)) + if err != nil { + t.Fatalf("ParsePolicyPredicate() error = %v", err) + } + policy, ok := pred.(*Policy) + if !ok { + t.Fatalf("wrong type %T", pred) + } + if policy.Parsed.GetId() != "has-provenance" || len(policy.Parsed.GetTenets()) != 1 { + t.Errorf("unexpected policy: %v", policy.Parsed) + } + if !json.Valid(policy.GetData()) { + t.Error("GetData() is not JSON") + } + }) + + t.Run("policygroup", func(t *testing.T) { + pred, err := parser.ParsePolicyGroupPredicate([]byte(hjsonPolicyGroup)) + if err != nil { + t.Fatalf("ParsePolicyGroupPredicate() error = %v", err) + } + group, ok := pred.(*PolicyGroup) + if !ok { + t.Fatalf("wrong type %T", pred) + } + if group.Parsed.GetId() != "release-group" || len(group.Parsed.GetBlocks()) != 1 { + t.Errorf("unexpected group: %v", group.Parsed) + } + }) + + t.Run("dispatcher-detects-hjson-policyset", func(t *testing.T) { + pred, err := parser.Parse([]byte(hjsonPolicySet)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if pred.GetType() != PredicateTypePolicySet { + t.Errorf("type = %s, want %s", pred.GetType(), PredicateTypePolicySet) + } + }) + + t.Run("not-a-document-is-not-this-format", func(t *testing.T) { + for _, data := range []string{"", " ", "[1,2]", "\"text\""} { + _, err := parser.ParsePolicySetPredicate([]byte(data)) + if !errors.Is(err, attestation.ErrNotCorrectFormat) { + t.Errorf("data %q: expected ErrNotCorrectFormat, got %v", data, err) + } + } + }) +} diff --git a/parser.go b/parser.go index 921a54b..eef05c4 100644 --- a/parser.go +++ b/parser.go @@ -106,6 +106,10 @@ func (p *ParserPolicySetPredicate) SupportsType(predTypes ...attestation.Predica } func (p *Parser) ParsePolicySetPredicate(data []byte) (attestation.Predicate, error) { + data, err := normalizeToJSON(data) + if err != nil { + return nil, err + } set := &papi.PolicySet{} if err := protojson.Unmarshal(data, set); err != nil { if strings.Contains(err.Error(), "proto:") && strings.Contains(err.Error(), "unknown field") { @@ -139,6 +143,10 @@ func (p *ParserPolicyPredicate) SupportsType(predTypes ...attestation.PredicateT } func (p *Parser) ParsePolicyPredicate(data []byte) (attestation.Predicate, error) { + data, err := normalizeToJSON(data) + if err != nil { + return nil, err + } policy := &papi.Policy{} if err := protojson.Unmarshal(data, policy); err != nil { if strings.Contains(err.Error(), "proto:") && strings.Contains(err.Error(), "unknown field") { @@ -169,6 +177,10 @@ func (p *ParserPolicyGroupPredicate) SupportsType(predTypes ...attestation.Predi } func (p *Parser) ParsePolicyGroupPredicate(data []byte) (attestation.Predicate, error) { + data, err := normalizeToJSON(data) + if err != nil { + return nil, err + } group := &papi.PolicyGroup{} if err := protojson.Unmarshal(data, group); err != nil { if strings.Contains(err.Error(), "proto:") && strings.Contains(err.Error(), "unknown field") { diff --git a/testdata/test-policy.json b/testdata/test-policy.json new file mode 100644 index 0000000..123bd4d --- /dev/null +++ b/testdata/test-policy.json @@ -0,0 +1,22 @@ +{ + "id": "has-provenance", + "meta": { + "description": "Verify the artifact was built in the expected environment", + "assert_mode": "AND" + }, + "tenets": [ + { + "code": "size(predicates) > 0", + "predicates": { + "types": ["https://slsa.dev/provenance/v0.2"] + }, + "assessment": { + "message": "Found a signed SLSA provenance attestation" + }, + "error": { + "message": "No provenance data found", + "guidance": "Set up the project's build to generate a slsa attestation" + } + } + ] +} diff --git a/testdata/test-policyset.json b/testdata/test-policyset.json new file mode 100644 index 0000000..f5d87ba --- /dev/null +++ b/testdata/test-policyset.json @@ -0,0 +1,31 @@ +{ + "id": "slsa", + + "meta": { + "runtime": "cel@v0" + }, + "policies": [ + { + "id": "has-provenance", + "meta": { + "description": "Verify the artifact was built in the expected environment", + "assert_mode": "AND" + }, + "tenets": [ + { + "code": "size(predicates) > 0", + "predicates": { + "types": ["https://slsa.dev/provenance/v0.2"] + }, + "assessment": { + "message": "Found a signed SLSA provenance attestation" + }, + "error": { + "message": "No provenance data found", + "guidance": "Set up the project's build to generate a slsa attestation" + } + } + ] + } + ] +} diff --git a/testdata/test-results.json b/testdata/test-results.json new file mode 100644 index 0000000..e4958b8 --- /dev/null +++ b/testdata/test-results.json @@ -0,0 +1,104 @@ +{ + "date_start": "2025-05-06T01:11:25.904Z", + "date_end": "2025-05-06T01:11:25.904Z", + "results": [ + { + "date_start": "2025-05-06T01:11:25.904Z", + "date_end": "2025-05-06T01:11:25.905Z", + "status": "PASS", + "policy": { + "id": "OSPS-GV-02" + }, + "eval_results": [ + { + "date": "2025-05-06T01:11:25.904Z", + "status": "PASS", + "output": {}, + "statements": [ + { + "type": "http://github.com/carabiner-dev/snappy/specs/repo.yaml", + "attestation": { + "digest": { + "sha256": "c89eb6a4518042b28365fdd42d86575fd2030499d6f2e9295b32b1d9cf896da5", + "sha512": "8cd0a3ee3d2e041281a2b01034f36a21f5d548a22f294e3adb19caa238b22d853fe73701b8c3dbfcb17f146e3c47f49109ec51acc6da075981813eb8b4555a51" + } + } + } + ], + "assessment": { + "message": "Found attested repository data" + } + }, + { + "date": "2025-05-06T01:11:25.905Z", + "id": "01", + "status": "PASS", + "output": { + "issues": true + }, + "statements": [ + { + "type": "http://github.com/carabiner-dev/snappy/specs/repo.yaml", + "attestation": { + "digest": { + "sha256": "c89eb6a4518042b28365fdd42d86575fd2030499d6f2e9295b32b1d9cf896da5", + "sha512": "8cd0a3ee3d2e041281a2b01034f36a21f5d548a22f294e3adb19caa238b22d853fe73701b8c3dbfcb17f146e3c47f49109ec51acc6da075981813eb8b4555a51" + } + } + } + ], + "assessment": { + "message": "Issues feature is enabled in the repository" + } + } + ], + "meta": { + "description": "The project MUST have one or more mechanisms for public discussions about proposed changes and usage obstacles.", + "assert_mode": "AND", + "controls": [ + { + "class": "OSPS", + "id": "GV-02" + } + ], + "enforce": "ON" + }, + "chain": [ + { + "source": { + "name": "hello-world-linux-amd64", + "uri": "hello-world-linux-amd64", + "digest": { + "sha256": "2d64e0b39fbe4ca33c8f2365eb1646e2cfb66d69c7e1f94ad95ab89e1e78be69", + "sha512": "3f4116a21a3f85a9856109366ce543e0922dd7cec75741f186a34b1cff1c255adafcd7899c9f6d01d6a0458f3512c45e07c845faffde7cb5908341cd443ce134" + } + }, + "destination": { + "name": "github.com/carabiner-dev/demo-repo", + "uri": "https://github.com/carabiner-dev/demo-repo", + "digest": { + "sha256": "73b9f9806668f6fccf8f49cb0c778bb4fb054f49003ba726fd59913e429154da" + } + }, + "link": { + "type": "https://slsa.dev/provenance/v0.2", + "attestation": { + "digest": { + "sha256": "082b4d976f26b5e49007ca4a6f627963e06dd887ec088d92b491c34414b400cb", + "sha512": "7533402fd875440db27cf2c95fde1411a86057bf774ff684bd047f98913bf050a8002c20aef47b80d4a1790d8b4785838000f71ebb2560d6374700e562fd017d" + } + } + } + } + ], + "subject": { + "name": "github.com/carabiner-dev/demo-repo", + "uri": "https://github.com/carabiner-dev/demo-repo", + "digest": { + "sha256": "73b9f9806668f6fccf8f49cb0c778bb4fb054f49003ba726fd59913e429154da" + } + } + } + ] + +}