Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/configs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
WORKING_DIR: 'src'
RUST_TOOLCHAIN: '1.96.0' # keep in sync with src/rust-toolchain.toml
NODE_VERSION: '22.x'
PYTHON_VERSION: '3.12'
PYTHON_VERSION: '3.9'
GO_VERSION: '1.26'
UNIFFI_BINDGEN_GO_TAG: 'v0.7.1+v0.31.0' # keep in sync with bindings-go/README.md and the uniffi pin in bindings-go/Cargo.toml
JAVA_VERSION: '21'
Expand Down
6 changes: 3 additions & 3 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ See the [Node.js API and examples](src/bindings-wasm/README.md).
### Python

Production versions are published to [PyPI](https://pypi.org/project/cloudformation-validate/); prereleases are
published to [TestPyPI](https://test.pypi.org/project/cloudformation-validate/). The package requires Python 3.12 or
published to [TestPyPI](https://test.pypi.org/project/cloudformation-validate/). The package requires Python 3.9 or
later, and its platform-specific wheels have no runtime package dependencies.

```bash
Expand Down Expand Up @@ -95,7 +95,7 @@ See the [Go API and examples](src/bindings-go/README.md).

The JVM library is published to
[Maven Central as `software.amazon.cloudformation:cloudformation-validate`](https://central.sonatype.com/artifact/software.amazon.cloudformation/cloudformation-validate)
and requires JDK 21 or later. The jar includes native libraries for all supported platforms; Maven or Gradle resolves
and requires Java 8 or later. The jar includes native libraries for all supported platforms; Maven or Gradle resolves
JNA, Gson, and the Kotlin standard library.

Gradle (Kotlin DSL):
Expand Down Expand Up @@ -185,7 +185,7 @@ testing the project from source need the tools below. Pinned versions live in
| Kotlin (`kotlinc`) | 2.4.0 | JVM binding build | |
| `ktlint` | 1.8.0 | JVM binding formatting | |
| Gradle | 9.6.1 | JVM binding build/test | Must be on `PATH` - `bindings-jvm/build.sh` and the JVM test runner invoke `gradle` |
| Python | 3.12+ | Python binding build/test, license generation, `scripts/` | `setuptools` for the wheel build; no other packages required |
| Python | 3.9+ | Python binding build/test, license generation, `scripts/` | `setuptools` for the wheel build; no other packages required |
| Go | 1.26+ | Go binding build/test | cgo must be enabled (default); Windows also needs `rustup target add x86_64-pc-windows-gnu` and MinGW-w64 `gcc` |
| `uniffi-bindgen-go` | 0.7.1 | Go binding generation | `cargo install --git https://github.com/NordSecurity/uniffi-bindgen-go --tag v0.7.1+v0.31.0` |
| `git`, `curl`, `openssl` | - | source control, fetching JVM deps, verifying releases | Usually preinstalled |
64 changes: 64 additions & 0 deletions src/bindings-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ diagnostics for the same template and config. A `nil` config uses only the built
| `ValidateStandardFile(path string, config *ValidateConfig)` | `(*StandardReport, error)` | Reads a template from disk, then validates it |
| `ValidateDetailed(template []byte, config *ValidateConfig, filePath string)` | `(*DetailedReport, error)` | Validates bytes with documentation URLs, rule descriptions, phase tags, and `ViolationContext` |
| `ValidateDetailedFile(path string, config *ValidateConfig)` | `(*DetailedReport, error)` | Reads a template from disk, then validates it (detailed) |
| `ValidateAWSAPIRequest(request AWSAPIRequest, config *ValidateConfig)` | `(*AWSAPIRequestValidation, error)` | Classifies and validates an AWS API request offline |
| `ListRules()` | `([]RuleInfo, error)` | Returns metadata for every built-in and loaded custom rule |
| `EngineName()` | `string` | `"rego"` or `"cel"` |
| `Destroy()` | - | Releases the native engine; the engine must not be used afterwards |
Expand Down Expand Up @@ -194,6 +195,69 @@ type PseudoParameterOverrides struct {
}
```

## AWS API Request Validation

Validates an AWS API request by classifying the operation, inferring the CloudFormation resource type, and running
schema and rule validation against a synthesized template - entirely offline. The method returns classification
metadata and an optional `StandardReport` when the request was validated (not skipped for read-only operations).

```go
engine, _ := cfnvalidate.NewRegoEngine(nil)
defer engine.Destroy()

result, err := engine.ValidateAWSAPIRequest(cfnvalidate.AWSAPIRequest{
ServiceName: "s3",
OperationName: "CreateBucket",
Parameters: map[string]any{"Bucket": "my-bucket"},
HTTPMethod: "PUT",
}, nil)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Kind: %s Status: %s Types: %v\n",
result.OperationKind, result.Status, result.ResourceTypes)
if result.Report != nil {
for _, d := range result.Report.Diagnostics {
fmt.Printf(" [%s] %s: %s\n", d.Severity, d.RuleID, d.Message)
}
}
```

### AWSAPIRequest

```go
type AWSAPIRequest struct {
ServiceName string // AWS service (e.g. "s3", "DynamoDb") - case-insensitive
OperationName string // operation name (e.g. "CreateBucket") - case-sensitive
Parameters map[string]any // request parameters: strings, numbers, booleans, []byte, maps, slices, nil
ServicePrefix string // optional signing prefix (e.g. "cloudcontrolapi")
HTTPMethod string // optional HTTP method hint for classification
IsReadOnly *bool // explicit read-only flag - skips validation when true
}
```

`Parameters` values are recursively encoded into the core's tagged value representation. Supported Go types: `nil`,
`bool`, all signed/unsigned integer widths, `float32`/`float64` (finite only), `string`, `[]byte` (as byte arrays),
`time.Time` (as an RFC 3339 UTC string), `json.Number`, slices/arrays, and `map[string]any`. Integer-valued
`json.Number` inputs are preserved across the full signed and unsigned 64-bit range; integer literals outside that range
are represented as unsupported rather than rounded through `float64`. SDK-defined type aliases (e.g.
`types.InstanceType` which is `type InstanceType string`) are handled transparently via their underlying kind.
Non-finite floats, maps with non-string keys, and unsupported types are represented as `UNSUPPORTED` rather than
coerced.

### AWSAPIRequestValidation

```go
type AWSAPIRequestValidation struct {
OperationKind AWSAPIOperationKind // READ_ONLY, CLOUD_FORMATION_CREATE, etc.
Status AWSAPIRequestValidationStatus // VALIDATED or SKIPPED
TemplateSource *AWSAPITemplateSource // TEMPLATE_BODY, SYNTHESIZED_CREATE, etc.
ResourceTypes []string // inferred CloudFormation resource types
Reason string // human-readable explanation
Report *StandardReport // present only when Status is VALIDATED
}
```

## TemplateModel

Parses a template into the resolved `SemanticModel` for direct inspection - the same model the engines evaluate rules
Expand Down
196 changes: 196 additions & 0 deletions src/bindings-go/go/cfnvalidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ package cfnvalidate
import (
"encoding/json"
"fmt"
"math"
"os"
"reflect"
"strconv"
"strings"
"time"

bindings "github.com/aws-cloudformation/cloudformation-validate/src/bindings-go/go/internal/bindings_go"
)
Expand Down Expand Up @@ -77,6 +82,7 @@ func decodeInto[T any](data string, what string) (*T, error) {
type nativeEngine interface {
ValidateStandardJson(template []byte, optionsJson string, filePath string) (string, error)
ValidateDetailedJson(template []byte, optionsJson string, filePath string) (string, error)
ValidateAwsApiRequestJson(requestJson string, optionsJson string) (string, error)
ListRulesJson() (string, error)
EngineName() string
Destroy()
Expand Down Expand Up @@ -195,6 +201,196 @@ func (e *Engine) Destroy() {
e.inner.Destroy()
}

// ValidateAWSAPIRequest classifies and validates an AWS API request against
// CloudFormation schemas and rules entirely offline. The result contains
// operation classification, resource type inference, and an optional
// StandardReport when the request was validated (not skipped).
func (e *Engine) ValidateAWSAPIRequest(request AWSAPIRequest, config *ValidateConfig) (*AWSAPIRequestValidation, error) {
optionsJSON, err := validateConfigJSON(config)
if err != nil {
return nil, err
}
requestJSON, err := marshalAWSAPIRequest(request)
if err != nil {
return nil, err
}
data, err := e.inner.ValidateAwsApiRequestJson(requestJSON, optionsJSON)
if err != nil {
return nil, err
}
return decodeInto[AWSAPIRequestValidation](data, "AWS API request validation")
}

// marshalAWSAPIRequest encodes an AWSAPIRequest into the wire JSON that the
// Rust side expects, converting Go parameter values into tagged AwsApiValue
// objects.
func marshalAWSAPIRequest(request AWSAPIRequest) (string, error) {
wire := awsApiRequestWire{
ServiceName: request.ServiceName,
OperationName: request.OperationName,
Parameters: make(map[string]awsApiValue, len(request.Parameters)),
ServicePrefix: nilIfEmpty(request.ServicePrefix),
HTTPMethod: nilIfEmpty(request.HTTPMethod),
IsReadOnly: request.IsReadOnly,
}
for key, value := range request.Parameters {
wire.Parameters[key] = encodeAwsApiValue(value, 0)
}
data, err := json.Marshal(wire)
if err != nil {
return "", fmt.Errorf("cfnvalidate: encoding AWS API request: %w", err)
}
return string(data), nil
}

func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}

// awsApiRequestWire is the JSON structure consumed by the Rust wire parser.
type awsApiRequestWire struct {
ServiceName string `json:"serviceName"`
OperationName string `json:"operationName"`
Parameters map[string]awsApiValue `json:"parameters"`
ServicePrefix *string `json:"servicePrefix,omitempty"`
HTTPMethod *string `json:"httpMethod,omitempty"`
IsReadOnly *bool `json:"isReadOnly,omitempty"`
}

// awsApiValue is the tagged union wire format matching the core AwsApiValue
// serde representation (tag = "type", rename_all = "SCREAMING_SNAKE_CASE").
// Items and Entries use pointer fields so that empty slices/maps serialize as
// their JSON zero ([] / {}) while remaining absent for unrelated variants.
type awsApiValue struct {
Type string `json:"type"`
Value any `json:"value,omitempty"`
Items *[]awsApiValue `json:"items,omitempty"`
Entries *map[string]awsApiValue `json:"entries,omitempty"`
TypeName string `json:"type_name,omitempty"`
}

// maxEncodeDepth prevents stack overflow on cyclic or deeply nested structures.
const maxEncodeDepth = 64

// encodeAwsApiValue recursively converts a Go value into the tagged wire
// format. It is non-mutating: no pointer is followed through a write path.
// Unsupported types are represented as UNSUPPORTED rather than coerced.
//
// SDK-defined type aliases (e.g. types.InstanceType is a named string) are
// handled via reflect.Kind after concrete type checks, so any alias of a
// scalar kind is encoded correctly without enumerating every SDK type.
func encodeAwsApiValue(v any, depth int) awsApiValue {
if depth > maxEncodeDepth {
return awsApiValue{Type: "UNSUPPORTED", TypeName: "recursion depth exceeded"}
}
if v == nil {
return awsApiValue{Type: "NULL"}
}

// Unwrap interface and pointer layers. Count indirections separately because
// a pointer-to-interface cycle can otherwise loop before recursive
// collection encoding reaches the depth guard.
rv := reflect.ValueOf(v)
indirections := 0
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
if depth+indirections > maxEncodeDepth {
return awsApiValue{Type: "UNSUPPORTED", TypeName: "recursion depth exceeded"}
}
if rv.IsNil() {
return awsApiValue{Type: "NULL"}
}
rv = rv.Elem()
indirections++
}
v = rv.Interface()

// Concrete type checks for stdlib types that carry semantics beyond their
// underlying kind (time.Time and json.Number).
switch val := v.(type) {
case time.Time:
return awsApiValue{Type: "STRING", Value: val.UTC().Format(time.RFC3339Nano)}

case json.Number:
text := string(val)
if i, err := val.Int64(); err == nil {
return awsApiValue{Type: "INTEGER", Value: i}
}
if u, err := strconv.ParseUint(text, 10, 64); err == nil {
return awsApiValue{Type: "UNSIGNED_INTEGER", Value: u}
}
if !strings.ContainsAny(text, ".eE") && json.Valid([]byte(text)) {
return awsApiValue{Type: "UNSUPPORTED", TypeName: "integer outside 64-bit range"}
}
if f, err := val.Float64(); err == nil {
if math.IsInf(f, 0) || math.IsNaN(f) {
return awsApiValue{Type: "UNSUPPORTED", TypeName: "non-finite floating-point number"}
}
return awsApiValue{Type: "NUMBER", Value: f}
}
return awsApiValue{Type: "UNSUPPORTED", TypeName: "unparseable json.Number"}
}

// Kind-based handling covers both built-in types and SDK-defined aliases
// (e.g. types.InstanceType is `type InstanceType string`).
switch rv.Kind() {
case reflect.Bool:
return awsApiValue{Type: "BOOLEAN", Value: rv.Bool()}

case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return awsApiValue{Type: "INTEGER", Value: rv.Int()}

case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return awsApiValue{Type: "UNSIGNED_INTEGER", Value: rv.Uint()}

case reflect.Float32, reflect.Float64:
f := rv.Float()
if math.IsInf(f, 0) || math.IsNaN(f) {
return awsApiValue{Type: "UNSUPPORTED", TypeName: "non-finite floating-point number"}
}
return awsApiValue{Type: "NUMBER", Value: f}

case reflect.String:
return awsApiValue{Type: "STRING", Value: rv.String()}

case reflect.Slice, reflect.Array:
// []byte / [N]byte → BYTES, encoded as a JSON integer array so the
// Rust side receives Vec<u8> from serde (encoding/json marshals
// []byte as base64 which is incompatible with serde's Vec<u8>).
if rv.Type().Elem().Kind() == reflect.Uint8 {
ints := make([]int, rv.Len())
for i := range ints {
ints[i] = int(rv.Index(i).Uint())
}
return awsApiValue{Type: "BYTES", Value: ints}
}
items := make([]awsApiValue, rv.Len())
for i := range items {
items[i] = encodeAwsApiValue(rv.Index(i).Interface(), depth+1)
}
return awsApiValue{Type: "ARRAY", Items: &items}

case reflect.Map:
if rv.Type().Key().Kind() != reflect.String {
return awsApiValue{Type: "UNSUPPORTED", TypeName: "mapping with non-string keys"}
}
entries := make(map[string]awsApiValue, rv.Len())
iter := rv.MapRange()
for iter.Next() {
entries[iter.Key().String()] = encodeAwsApiValue(iter.Value().Interface(), depth+1)
}
return awsApiValue{Type: "OBJECT", Entries: &entries}

case reflect.Struct:
return awsApiValue{Type: "UNSUPPORTED", TypeName: rv.Type().String()}

default:
return awsApiValue{Type: "UNSUPPORTED", TypeName: rv.Type().String()}
}
}

// SchemaValidator validates resources against the compiled CloudFormation
// provider schemas.
type SchemaValidator struct {
Expand Down
Loading
Loading