feat(audit): sanitize stored upstream responses
This commit is contained in:
@@ -11,6 +11,7 @@ const (
|
|||||||
StatusUpstreamRejected Status = "upstream_rejected"
|
StatusUpstreamRejected Status = "upstream_rejected"
|
||||||
StatusUpstreamTimeout Status = "upstream_timeout"
|
StatusUpstreamTimeout Status = "upstream_timeout"
|
||||||
StatusUpstreamUnavailable Status = "upstream_unavailable"
|
StatusUpstreamUnavailable Status = "upstream_unavailable"
|
||||||
|
StatusUpstreamInvalid Status = "upstream_invalid_response"
|
||||||
StatusInternalError Status = "internal_error"
|
StatusInternalError Status = "internal_error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
48
backend/internal/audit/sanitize.go
Normal file
48
backend/internal/audit/sanitize.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SanitizeResponse(raw []byte, maskedCard string) []byte {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if maskedCard == "" {
|
||||||
|
maskedCard = "[redacted]"
|
||||||
|
}
|
||||||
|
|
||||||
|
redactCardFields(value, maskedCard)
|
||||||
|
|
||||||
|
sanitized, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactCardFields(value any, maskedCard string) {
|
||||||
|
switch current := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range current {
|
||||||
|
if strings.EqualFold(key, "card") {
|
||||||
|
current[key] = maskedCard
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
redactCardFields(child, maskedCard)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range current {
|
||||||
|
redactCardFields(child, maskedCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/internal/audit/sanitize_test.go
Normal file
56
backend/internal/audit/sanitize_test.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeResponse(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"data": {
|
||||||
|
"card": "6037997512345678",
|
||||||
|
"iban": "IR820540102680020817909002"
|
||||||
|
},
|
||||||
|
"meta": {"isSuccess": true}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
got := SanitizeResponse(raw, "603799******5678")
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("SanitizeResponse() returned an empty response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(got, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal sanitized response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := decoded["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("sanitized response does not contain data object")
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotCard := data["card"]; gotCard != "603799******5678" {
|
||||||
|
t.Errorf("sanitized card = %v, want masked card", gotCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotIBAN := data["iban"]; gotIBAN != "IR820540102680020817909002" {
|
||||||
|
t.Errorf("sanitized iban = %v, want original iban", gotIBAN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeResponseRejectsInvalidJSON(t *testing.T) {
|
||||||
|
if got := SanitizeResponse([]byte(`not-json`), "603799******5678"); got != nil {
|
||||||
|
t.Errorf("SanitizeResponse() = %q, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeResponseRedactsCardWhenMaskIsEmpty(t *testing.T) {
|
||||||
|
got := SanitizeResponse(
|
||||||
|
[]byte(`{"card":"6037997512345678"}`),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
if string(got) != `{"card":"[redacted]"}` {
|
||||||
|
t.Errorf("SanitizeResponse() = %s, want redacted card", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,3 +25,11 @@ func Validate(number string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Mask(number string) string {
|
||||||
|
if Validate(number) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return number[:6] + "******" + number[12:]
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,3 +53,35 @@ func TestValidate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMask(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
number string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid card number",
|
||||||
|
number: "6037997512345678",
|
||||||
|
want: "603799******5678",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid card number",
|
||||||
|
number: "603799",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non numeric card number",
|
||||||
|
number: "603799ABC2345678",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := Mask(tt.number); got != tt.want {
|
||||||
|
t.Errorf("Mask() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user