Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
12 changes: 12 additions & 0 deletions .changesets/1791413358-125ef0df.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
id: 1791413358-125ef0df
features:
- core
targets:
- cli
- go
- terraform
type: feat
bump: minor
description: mask sensitive request and response body values in HTTP logs and API error messages
author: ThomasRooney
date: "2026-10-07"
321 changes: 321 additions & 0 deletions internal/ast/sensitive_body.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
package ast

import "sort"

// SensitiveBodyGraph locates the values marked sensitive (x-speakeasy-param-sensitive
// or format: password) in the bodies of an operation, by their JSON location,
// so that generated code can mask them when bodies are logged.
//
// Nodes[0] is a sentinel meaning "nothing sensitive below here"; a node
// reference of 0 can be skipped. Roots are the body schemas: one per request
// content type, or one per response status code and content type. Only nodes
// that can reach a sensitive value are kept.
type SensitiveBodyGraph struct {
Nodes []*SensitiveBodyNode
Roots []int
}

// SensitiveBodyNode is a schema in a SensitiveBodyGraph.
type SensitiveBodyNode struct {
// Sensitive masks the whole value. Streams are masked as a whole when any
// of their items holds a sensitive value.
Sensitive bool
// Fields are the declared object properties by wire name (JSON property,
// form field or multipart part name), sorted by name, including those
// holding nothing sensitive (with Node 0), so that a declared name is
// never mistaken for another one or for Values. A property that is not
// listed uses Values.
Fields []SensitiveBodyField
// Item is the schema of array items.
Item int
// Values is the schema of map values and additional properties.
Values int
// Variants are union members; the member a value matches is not known.
Variants []int
// PlainObject, PlainArray, PlainString, PlainNumber and PlainBoolean
// report union members accepting JSON values of that type and holding
// nothing sensitive, so a value of that type that matches none of
// Variants may still match the schema.
PlainObject bool
PlainArray bool
PlainString bool
PlainNumber bool
PlainBoolean bool
}

// SensitiveBodyField is an object property of a SensitiveBodyNode.
type SensitiveBodyField struct {
Name string
Node int
}

// SensitiveRequestBodyGraph returns the sensitive value locations of the
// operation's request body, or nil when it holds none.
func (o *Operation) SensitiveRequestBodyGraph() *SensitiveBodyGraph {
b := newSensitiveBodyBuilder()
b.addRoot(o.requestBodyType())
return b.build()
}

// SensitiveResponseBodyGraph returns the sensitive value locations of all the
// operation's response bodies, including errors, or nil when they hold none.
func (o *Operation) SensitiveResponseBodyGraph() *SensitiveBodyGraph {
b := newSensitiveBodyBuilder()
if o.Response != nil {
for _, response := range o.Response.Responses {
for _, content := range response.Content {
if content != nil && content.Content != nil {
b.addRoot(content.Content.Type)
}
}
}
}
return b.build()
}

// HasSensitiveBodies reports whether the operation's request or response
// bodies hold values marked sensitive.
func (o *Operation) HasSensitiveBodies() bool {
return o.SensitiveRequestBodyGraph() != nil || o.SensitiveResponseBodyGraph() != nil
}

func (o *Operation) requestBodyType() *TypeDef {
if o.Request == nil {
return nil
}
if o.Request.Field != nil && o.Request.Field.Type != nil {
for _, field := range o.Request.Field.Type.Fields {
if field.Annotations.Has(AnnotationTypeRequest) {
return field.Type
}
}
}
if o.Request.IsRequestBody && o.Request.RequestBody != nil {
return o.Request.RequestBody.Type
}
return nil
}

func isSensitiveBodyType(t *TypeDef) bool {
if t.Format == "password" {
return true
}
if t.Extensions == nil {
return false
}
marker := t.Extensions.All["x-speakeasy-param-sensitive"]
return marker == true || marker == "true"
}

type sensitiveBodyBuilder struct {
nodes []*sensitiveBodyBuildNode
roots []int
}

type sensitiveBodyBuildNode struct {
sensitive bool
stream bool
fields []SensitiveBodyField
item int
values int
variants []int
// variantTypes are the schemas of variants, so that pruned variants
// still describe the shapes the union accepts.
variantTypes []*TypeDef
}

func newSensitiveBodyBuilder() *sensitiveBodyBuilder {
return &sensitiveBodyBuilder{nodes: []*sensitiveBodyBuildNode{{}}}
}

// addRoot walks a body schema along the edges given by TypeDef.Children.
// Custom types are walked once per root, by identity and by registration ID
// (which also matches the truncated copies that break recursive references),
// so that recursive schemas terminate.
func (b *sensitiveBodyBuilder) addRoot(t *TypeDef) {
if !containsSensitiveBodyType(t) {
return
}
walkedTypes := map[*TypeDef]int{}
walkedIDs := map[string]int{}
var walk func(t *TypeDef) int
walk = func(t *TypeDef) int {
if t == nil {
return 0
}
if id, ok := walkedTypes[t]; ok {
return id
}
typeID := ""
if t.IsCustomType() && t.ContextStack != nil {
typeID = t.GetRegistrationID() + " name:" + t.Name
if id, ok := walkedIDs[typeID]; ok {
return id
}
}
id := len(b.nodes)
node := &sensitiveBodyBuildNode{}
b.nodes = append(b.nodes, node)
walkedTypes[t] = id
if typeID != "" {
walkedIDs[typeID] = id
}
if isSensitiveBodyType(t) {
node.sensitive = true
return id
}
for field, child := range t.Children {
switch {
case field == nil:
switch t.Type {
case DataTypeArray, DataTypeSet:
node.item = walk(child)
case DataTypeMap:
node.values = walk(child)
case DataTypeEventStream, DataTypeJsonL:
node.stream = true
node.item = walk(child)
case DataTypeUnion:
node.variants = append(node.variants, walk(child))
node.variantTypes = append(node.variantTypes, child)
}
case t.Type != DataTypeClass && t.Type != DataTypeError:
case field.IsAdditionalProperties:
if child != nil {
node.values = walk(child.ItemType)
}
default:
node.fields = append(node.fields, SensitiveBodyField{Name: sensitiveBodyFieldName(field)})
if field.Const == nil {
node.fields[len(node.fields)-1].Node = walk(child)
}
}
}
return id
}
b.roots = append(b.roots, walk(t))
}

func sensitiveBodyFieldName(field *FieldDef) string {
if field.OriginalName != "" {
return field.OriginalName
}
return field.Name
}

// containsSensitiveBodyType reports whether t or any type below it is marked
// sensitive, so that schemas without sensitive values are not walked further.
func containsSensitiveBodyType(t *TypeDef) bool {
for _, typ := range t.Walk() {
if isSensitiveBodyType(typ) {
return true
}
}
return false
}

// build prunes the nodes that cannot reach a sensitive value and renumbers
// the rest.
func (b *sensitiveBodyBuilder) build() *SensitiveBodyGraph {
reachable := make([]bool, len(b.nodes))
for i, node := range b.nodes {
reachable[i] = node.sensitive
}
for changed := true; changed; {
changed = false
for i, node := range b.nodes {
if reachable[i] {
continue
}
children := append([]int{node.item, node.values}, node.variants...)
for _, field := range node.fields {
children = append(children, field.Node)
}
for _, child := range children {
if child != 0 && reachable[child] {
reachable[i] = true
changed = true
break
}
}
}
}

remap := make([]int, len(b.nodes))
graph := &SensitiveBodyGraph{Nodes: []*SensitiveBodyNode{{}}}
for i := 1; i < len(b.nodes); i++ {
if reachable[i] {
remap[i] = len(graph.Nodes)
graph.Nodes = append(graph.Nodes, &SensitiveBodyNode{})
}
}
for _, root := range b.roots {
if root != 0 && reachable[root] {
graph.Roots = append(graph.Roots, remap[root])
}
}
if len(graph.Roots) == 0 {
return nil
}

for i, node := range b.nodes {
if i == 0 || !reachable[i] {
continue
}
out := graph.Nodes[remap[i]]
if node.sensitive || node.stream {
out.Sensitive = true
continue
}
out.Values = remap[node.values]
for _, field := range node.fields {
out.Fields = append(out.Fields, SensitiveBodyField{Name: field.Name, Node: remap[field.Node]})
}
sort.SliceStable(out.Fields, func(a, b int) bool { return out.Fields[a].Name < out.Fields[b].Name })
out.Item = remap[node.item]
for v, variant := range node.variants {
if remap[variant] != 0 {
out.Variants = append(out.Variants, remap[variant])
} else {
out.addPlainShapes(node.variantTypes[v], map[*TypeDef]bool{})
}
}
}
return graph
}

// addPlainShapes records the JSON types of values that t, a union member
// holding nothing sensitive, accepts. Nested unions are followed once each,
// so that recursive unions terminate.
func (n *SensitiveBodyNode) addPlainShapes(t *TypeDef, visited map[*TypeDef]bool) {
if t == nil || visited[t] {
return
}
visited[t] = true
switch t.Type {
case DataTypeClass, DataTypeError, DataTypeMap:
n.PlainObject = true
case DataTypeArray, DataTypeSet:
n.PlainArray = true
case DataTypeUnion:
for _, member := range t.AssociatedTypes {
n.addPlainShapes(member, visited)
}
case DataTypeEnum:
if t.Enum != nil && t.Enum.Type != nil && t.Enum.Type.Type != DataTypeEnum {
n.addPlainShapes(t.Enum.Type, visited)
} else {
n.PlainString = true
}
case DataTypeAny:
n.PlainObject, n.PlainArray, n.PlainString, n.PlainNumber, n.PlainBoolean = true, true, true, true, true
case DataTypeBoolean:
n.PlainBoolean = true
case DataTypeInteger, DataTypeInt32, DataTypeNumber, DataTypeFloat32:
n.PlainNumber = true
case DataTypeBigInt, DataTypeDecimal:
n.PlainNumber, n.PlainString = true, true
case DataTypeString, DataTypeDate, DataTypeDateTime, DataTypeUUID, DataTypeDuration, DataTypeBytes:
n.PlainString = true
}
}
Loading
Loading