Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
56 changes: 56 additions & 0 deletions hjson.go
Original file line number Diff line number Diff line change
@@ -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] == '{'
}
202 changes: 202 additions & 0 deletions hjson_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
12 changes: 12 additions & 0 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand Down
22 changes: 22 additions & 0 deletions testdata/test-policy.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
31 changes: 31 additions & 0 deletions testdata/test-policyset.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
]
}
Loading