Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9f38417
feat(go,terraform): mask sensitive body values in HTTP logs
ThomasRooney Oct 7, 2026
0938bf5
refactor(go): compute the sensitive body graph in the generator AST
ThomasRooney Oct 8, 2026
915ea61
feat(go): mask sensitive values in form and multipart bodies
ThomasRooney Oct 8, 2026
55c788d
fix(go): mask schema-mismatched values and only multipart/form-data p…
ThomasRooney Oct 8, 2026
b231d25
Merge branch 'main' into fix/terraform-sensitive-response-logs
ThomasRooney Oct 8, 2026
0144d41
fix(go): keep values matching plain union variants readable
2ynn Oct 8, 2026
0247cd5
fix(go): match plain union members by shape when redacting
2ynn Oct 8, 2026
69a0749
fix(go): accept only text a plain string member can hold when redacting
2ynn Oct 8, 2026
b7166cc
fix(go): treat int32 and float32 union members as numbers when redacting
2ynn Oct 8, 2026
a7b6eae
Merge remote-tracking branch 'origin/main' into fix/terraform-sensiti…
2ynn Oct 8, 2026
0bf0439
fix(go): match form fields and parts by exact name before array suffix
2ynn Oct 8, 2026
7fec620
fix(go): resolve bracketed form names per schema when redacting
2ynn Oct 8, 2026
67c01a0
fix(go): mask duplicate keys, encoded parts and mislabelled forms whe…
2ynn Oct 8, 2026
c025b40
fix(go): ignore leading whitespace when detecting JSON labelled as a …
2ynn Oct 8, 2026
fdb2f76
fix(go): keep repeated plain scalar items readable when redacting
2ynn Oct 8, 2026
f79abd8
fix(cli): redact request-body values marked sensitive in diagnostics
2ynn Oct 1, 2026
67bb3a5
fix(cli): merge sensitive paths for split request content types
2ynn Oct 7, 2026
12e958d
fix(cli): redact request bodies marked sensitive at the root
2ynn Oct 7, 2026
6b3d5da
fix(cli): follow schema references when redacting recursive bodies
2ynn Oct 7, 2026
0aa9a11
fix(cli): keep the redactJSON doc comment
2ynn Oct 7, 2026
3472349
fix(cli): keep declared fields readable beside sensitive map values
2ynn Oct 7, 2026
3d2c4c4
fix(cli): show JSON-encoded form fields as sent when nothing is redacted
2ynn Oct 7, 2026
ace147a
test(cli): cover arrays of objects in multipart and form diagnostics …
2ynn Oct 7, 2026
432cfab
refactor(cli): mask sensitive body values through the SDK redact package
2ynn Oct 8, 2026
27111d8
test(cli): cover a sensitive form and multipart property whose name e…
2ynn Oct 8, 2026
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
10 changes: 10 additions & 0 deletions .changesets/1790872792-577bf8cd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1790872792-577bf8cd
features:
- core
targets:
- cli
type: fix
bump: patch
description: '--dry-run and --debug mask request and response body values marked sensitive (x-speakeasy-param-sensitive or format: password) through the SDK redact package, including whole bodies, recursive schemas and form or multipart fields'
author: 2ynn
date: "2026-10-01"
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