← back to Cli Printing Press
fix(cli): harden browser-sniff PII placeholders (#1639)
7eee7dfcb1d294feeeae6a013b4fd9d5bcaaec61 · 2026-05-18 16:46:18 -0700 · Trevin Chow
Files touched
M internal/artifacts/pii.goM internal/artifacts/pii_test.goM internal/artifacts/secrets.goM internal/artifacts/secrets_test.goM internal/browsersniff/fixtures.goM internal/browsersniff/fixtures_test.goM internal/cli/publish.goM internal/cli/publish_test.goA internal/generator/captured_fixture_test.goM internal/generator/example_value_test.goM internal/generator/generator.goA internal/generator/placeholders.goM internal/generator/templates/captured_test.go.tmplM internal/mcpdesc/params.goM internal/mcpdesc/params_test.goA internal/piiplaceholders/placeholders.goA internal/piiplaceholders/placeholders_test.goM internal/pipeline/lock.goM internal/pipeline/toolsmanifest.goM internal/pipeline/toolsmanifest_test.go
Diff
commit 7eee7dfcb1d294feeeae6a013b4fd9d5bcaaec61
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 18 16:46:18 2026 -0700
fix(cli): harden browser-sniff PII placeholders (#1639)
---
internal/artifacts/pii.go | 36 +++++-
internal/artifacts/pii_test.go | 45 +++++++-
internal/artifacts/secrets.go | 127 +++++++++++++++++++--
internal/artifacts/secrets_test.go | 56 +++++++++
internal/browsersniff/fixtures.go | 108 ++++++++++++++++--
internal/browsersniff/fixtures_test.go | 62 ++++++++++
internal/cli/publish.go | 25 +++-
internal/cli/publish_test.go | 25 ++++
internal/generator/captured_fixture_test.go | 50 ++++++++
internal/generator/example_value_test.go | 9 ++
internal/generator/generator.go | 4 +
internal/generator/placeholders.go | 27 +++++
internal/generator/templates/captured_test.go.tmpl | 12 ++
internal/mcpdesc/params.go | 7 +-
internal/mcpdesc/params_test.go | 22 ++++
internal/piiplaceholders/placeholders.go | 92 +++++++++++++++
internal/piiplaceholders/placeholders_test.go | 22 ++++
internal/pipeline/lock.go | 2 +-
internal/pipeline/toolsmanifest.go | 2 +
internal/pipeline/toolsmanifest_test.go | 6 +-
20 files changed, 709 insertions(+), 30 deletions(-)
diff --git a/internal/artifacts/pii.go b/internal/artifacts/pii.go
index b3aa3478..a0850690 100644
--- a/internal/artifacts/pii.go
+++ b/internal/artifacts/pii.go
@@ -16,6 +16,8 @@ import (
"sort"
"strings"
"time"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/piiplaceholders"
)
// PII gate implementation following the Deterministic Inventory +
@@ -38,6 +40,8 @@ const (
// changing a value is a backward-incompatible ledger format change.
const (
PIIKindCardLast4 = "card-last-4"
+ PIIKindOrderID = "order-id"
+ PIIKindASIN = "asin"
PIIKindEmail = "email"
PIIKindPhoneUS = "phone-us"
PIIKindZipPlus4 = "zip-plus-4"
@@ -100,6 +104,19 @@ var piiDetectors = []piiDetector{
// \b but their length and shape are unambiguous.
pattern: regexp.MustCompile(`(?i)(?:\b(?:card|visa|mastercard|amex|ending in|last\s+4)|x{4,}|\*{4,})[\s:.\-]{0,5}\d{4}`),
},
+ {
+ kind: PIIKindOrderID,
+ // Physical and digital order IDs observed in browser-sniff
+ // captures. The canonical synthetic placeholders are filtered
+ // after matching.
+ pattern: piiplaceholders.OrderIDPattern(),
+ },
+ {
+ kind: PIIKindASIN,
+ // ASIN-shaped product IDs from browser-sniff captures. The
+ // canonical B0EXAMPLE* placeholders are filtered after matching.
+ pattern: piiplaceholders.ASINPattern(),
+ },
{
kind: PIIKindEmail,
// Standard email shape with a TLD of 2+ chars. Word boundaries
@@ -250,6 +267,19 @@ var skippedDirs = map[string]bool{
"build": true,
}
+func isSyntheticPIIPlaceholder(kind, matched string) bool {
+ switch kind {
+ case PIIKindOrderID:
+ return piiplaceholders.IsSyntheticOrderID(matched)
+ case PIIKindASIN:
+ return piiplaceholders.IsSyntheticASIN(matched)
+ case PIIKindPostalAddress:
+ return piiplaceholders.IsSyntheticPostalAddress(matched)
+ default:
+ return false
+ }
+}
+
// FindPII walks root, applies the file-scoping rules, and returns all
// PII-shape matches. Ordering is stable (file, line, column, kind) so
// the JSON output and ledger reconcile cleanly across runs.
@@ -386,12 +416,16 @@ func scanPIIFile(root, path string) ([]PIIFinding, error) {
lineNumber++
for _, det := range piiDetectors {
for _, match := range det.pattern.FindAllStringIndex(line, -1) {
+ matchedSpan := line[match[0]:match[1]]
+ if isSyntheticPIIPlaceholder(det.kind, matchedSpan) {
+ continue
+ }
findings = append(findings, PIIFinding{
Kind: det.kind,
File: relSlash,
Line: lineNumber,
Column: match[0] + 1, // 1-based
- MatchedSpan: line[match[0]:match[1]],
+ MatchedSpan: matchedSpan,
})
}
}
diff --git a/internal/artifacts/pii_test.go b/internal/artifacts/pii_test.go
index 17de3d41..e3fe2fd3 100644
--- a/internal/artifacts/pii_test.go
+++ b/internal/artifacts/pii_test.go
@@ -23,9 +23,12 @@ func TestFindPII_CardLast4(t *testing.T) {
expectKinds []string
}{
{name: "ending-in", line: `"display": "card ending in 4242"`, expectKinds: []string{PIIKindCardLast4}},
- {name: "mask-asterisks", line: `"masked": "****-****-****-1234"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "mask-asterisks", line: `"masked": "****-****-****-4321"`, expectKinds: []string{PIIKindCardLast4}},
{name: "mask-x", line: `"masked": "xxxx-xxxx-xxxx-9999"`, expectKinds: []string{PIIKindCardLast4}},
- {name: "visa-context", line: `"brand": "visa 5678"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "visa-context", line: `"brand": "visa 9876"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "numeric-placeholder-a-still-flags", line: `"brand": "visa 1234"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "numeric-placeholder-b-still-flags", line: `"brand": "amex 5678"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "non-numeric-last4-placeholder", line: `"brand": "visa LAST4"`, expectKinds: nil},
{name: "no-context-bare-4digits", line: `* 1234 changelog bullet`, expectKinds: nil},
{name: "no-context-year", line: `"version": "2024"`, expectKinds: nil},
{name: "no-context-port", line: `"port": "8080"`, expectKinds: nil},
@@ -42,6 +45,43 @@ func TestFindPII_CardLast4(t *testing.T) {
}
}
+func TestFindPII_OrderIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expectKinds []string
+ }{
+ {name: "physical-order", line: `"order_id": "123-4567890-1234567"`, expectKinds: []string{PIIKindOrderID}},
+ {name: "digital-order", line: `"order_id": "D01-4567890-1234567"`, expectKinds: []string{PIIKindOrderID}},
+ {name: "synthetic-physical-order", line: `"order_id": "111-1111111-1111111"`, expectKinds: nil},
+ {name: "synthetic-digital-order", line: `"order_id": "D01-1111111-1111111"`, expectKinds: nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := scanLine(t, tt.line, "test.json")
+ assertKinds(t, got, tt.expectKinds, PIIKindOrderID)
+ })
+ }
+}
+
+func TestFindPII_ASINs(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expectKinds []string
+ }{
+ {name: "real-asin", line: `"asin": "B012345678"`, expectKinds: []string{PIIKindASIN}},
+ {name: "synthetic-asin", line: `"asin": "B0EXAMPLE1"`, expectKinds: nil},
+ {name: "not-asin", line: `"sku": "A012345678"`, expectKinds: nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := scanLine(t, tt.line, "test.json")
+ assertKinds(t, got, tt.expectKinds, PIIKindASIN)
+ })
+ }
+}
+
func TestFindPII_Email(t *testing.T) {
tests := []struct {
name string
@@ -126,6 +166,7 @@ func TestFindPII_PostalAddress(t *testing.T) {
{name: "main-street-title", line: `"address": "1234 Main Street"`, expectKinds: []string{PIIKindPostalAddress}},
{name: "ave-title", line: `"line1": "567 Park Ave"`, expectKinds: []string{PIIKindPostalAddress}},
{name: "drive-lowercase-suffix", line: `"line1": "890 Sunset drive"`, expectKinds: []string{PIIKindPostalAddress}},
+ {name: "synthetic-address", line: `"address": "123 Test St, Anytown, ST 12345"`, expectKinds: nil},
{name: "no-number", line: `"page": "SEE README.MD"`, expectKinds: nil},
{name: "no-suffix", line: `"line": "1234 MAIN"`, expectKinds: nil},
// Regression guards: conversational prose where the name words
diff --git a/internal/artifacts/secrets.go b/internal/artifacts/secrets.go
index dcdaa7dd..0ff5490d 100644
--- a/internal/artifacts/secrets.go
+++ b/internal/artifacts/secrets.go
@@ -10,6 +10,8 @@ import (
"path/filepath"
"regexp"
"strings"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/piiplaceholders"
)
type secretReplacement struct {
@@ -96,7 +98,72 @@ var vendorPrefixSecretPatterns = []vendorPrefixSecretPattern{
},
}
+const structuredCookieLineWindow = 5
+
+var structuredCookieValueLineRE = regexp.MustCompile(`(?i)["']value["']\s*:\s*["']([^"']{8,})["']`)
+
func FindVendorPrefixSecrets(root string) ([]VendorPrefixSecretFinding, error) {
+ return findSecrets(root, vendorPrefixSecretPatterns)
+}
+
+func FindPackageSecrets(root string, cookieNames []string) ([]VendorPrefixSecretFinding, error) {
+ patterns := append([]vendorPrefixSecretPattern(nil), vendorPrefixSecretPatterns...)
+ patterns = append(patterns, cookieSecretPatterns(cookieNames)...)
+ return findSecrets(root, patterns)
+}
+
+func FindSpecDeclaredCookieSecrets(root string, cookieNames []string) ([]VendorPrefixSecretFinding, error) {
+ return findSecrets(root, cookieSecretPatterns(cookieNames))
+}
+
+func cookieSecretPatterns(cookieNames []string) []vendorPrefixSecretPattern {
+ if len(cookieNames) == 0 {
+ return nil
+ }
+
+ patterns := make([]vendorPrefixSecretPattern, 0, len(cookieNames)*3)
+ for _, name := range cookieNames {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ quotedName := regexp.QuoteMeta(name)
+ patterns = append(patterns, vendorPrefixSecretPattern{
+ kind: "cookie-value:" + name,
+ pattern: regexp.MustCompile("(?:^|[\\s\"'`;,{:])" + quotedName + "=([^\\s;\"'&,}]{8,})"),
+ accept: func(candidate string) bool {
+ _, value, ok := strings.Cut(candidate, "=")
+ return ok && !piiplaceholders.IsSyntheticCookieValue(value)
+ },
+ })
+ patterns = append(patterns,
+ structuredCookieSecretPattern(name, regexp.MustCompile(`(?i)["']name["']\s*:\s*["']`+quotedName+`["'][^{}\n]*["']value["']\s*:\s*["']([^"']{8,})["']`)),
+ structuredCookieSecretPattern(name, regexp.MustCompile(`(?i)["']value["']\s*:\s*["']([^"']{8,})["'][^{}\n]*["']name["']\s*:\s*["']`+quotedName+`["']`)),
+ )
+ }
+ return patterns
+}
+
+func structuredCookieSecretPattern(name string, pattern *regexp.Regexp) vendorPrefixSecretPattern {
+ return vendorPrefixSecretPattern{
+ kind: "cookie-value:" + name,
+ pattern: pattern,
+ accept: func(candidate string) bool {
+ matches := pattern.FindStringSubmatch(candidate)
+ return len(matches) == 2 && !piiplaceholders.IsSyntheticCookieValue(matches[1])
+ },
+ }
+}
+
+func FormatVendorPrefixSecretFindings(findings []VendorPrefixSecretFinding) string {
+ lines := make([]string, 0, len(findings))
+ for _, finding := range findings {
+ lines = append(lines, fmt.Sprintf("%s:%d %s", finding.Path, finding.Line, finding.Kind))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func findSecrets(root string, patterns []vendorPrefixSecretPattern) ([]VendorPrefixSecretFinding, error) {
var findings []VendorPrefixSecretFinding
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
@@ -105,7 +172,7 @@ func FindVendorPrefixSecrets(root string) ([]VendorPrefixSecretFinding, error) {
if entry.IsDir() {
return nil
}
- fileFindings, err := scanVendorPrefixSecretFile(root, path)
+ fileFindings, err := scanSecretFile(root, path, patterns)
if err != nil {
return err
}
@@ -115,15 +182,7 @@ func FindVendorPrefixSecrets(root string) ([]VendorPrefixSecretFinding, error) {
return findings, err
}
-func FormatVendorPrefixSecretFindings(findings []VendorPrefixSecretFinding) string {
- lines := make([]string, 0, len(findings))
- for _, finding := range findings {
- lines = append(lines, fmt.Sprintf("%s:%d %s", finding.Path, finding.Line, finding.Kind))
- }
- return strings.Join(lines, "\n")
-}
-
-func scanVendorPrefixSecretFile(root, path string) ([]VendorPrefixSecretFinding, error) {
+func scanSecretFile(root, path string, patterns []vendorPrefixSecretPattern) ([]VendorPrefixSecretFinding, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
@@ -147,6 +206,8 @@ func scanVendorPrefixSecretFile(root, path string) ([]VendorPrefixSecretFinding,
var findings []VendorPrefixSecretFinding
lineNumber := 0
+ cookieNames := declaredCookieNamesFromPatterns(patterns)
+ pendingCookieNames := map[string]int{}
for {
line, readErr := reader.ReadString('\n')
if readErr != nil && readErr != io.EOF {
@@ -156,7 +217,7 @@ func scanVendorPrefixSecretFile(root, path string) ([]VendorPrefixSecretFinding,
break
}
lineNumber++
- for _, pattern := range vendorPrefixSecretPatterns {
+ for _, pattern := range patterns {
if vendorPrefixSecretLineMatch(pattern, line) {
findings = append(findings, VendorPrefixSecretFinding{
Path: rel,
@@ -165,6 +226,29 @@ func scanVendorPrefixSecretFile(root, path string) ([]VendorPrefixSecretFinding,
})
}
}
+ for name, nameLine := range pendingCookieNames {
+ if lineNumber-nameLine > structuredCookieLineWindow {
+ delete(pendingCookieNames, name)
+ }
+ }
+ for _, name := range cookieNames {
+ if structuredCookieNameLineMatch(name, line) {
+ pendingCookieNames[name] = lineNumber
+ }
+ }
+ if matches := structuredCookieValueLineRE.FindStringSubmatch(line); len(matches) == 2 && !piiplaceholders.IsSyntheticCookieValue(matches[1]) {
+ for name, nameLine := range pendingCookieNames {
+ if nameLine == lineNumber {
+ continue
+ }
+ findings = append(findings, VendorPrefixSecretFinding{
+ Path: rel,
+ Line: lineNumber,
+ Kind: "cookie-value:" + name,
+ })
+ delete(pendingCookieNames, name)
+ }
+ }
if readErr == io.EOF {
break
}
@@ -172,6 +256,27 @@ func scanVendorPrefixSecretFile(root, path string) ([]VendorPrefixSecretFinding,
return findings, nil
}
+func declaredCookieNamesFromPatterns(patterns []vendorPrefixSecretPattern) []string {
+ seen := map[string]struct{}{}
+ for _, pattern := range patterns {
+ name, ok := strings.CutPrefix(pattern.kind, "cookie-value:")
+ if !ok || name == "" {
+ continue
+ }
+ seen[name] = struct{}{}
+ }
+ names := make([]string, 0, len(seen))
+ for name := range seen {
+ names = append(names, name)
+ }
+ return names
+}
+
+func structuredCookieNameLineMatch(name, line string) bool {
+ pattern := regexp.MustCompile(`(?i)["']name["']\s*:\s*["']` + regexp.QuoteMeta(name) + `["']`)
+ return pattern.MatchString(line)
+}
+
func vendorPrefixSecretLineMatch(pattern vendorPrefixSecretPattern, line string) bool {
for _, candidate := range pattern.pattern.FindAllString(line, -1) {
if pattern.accept == nil || pattern.accept(candidate) {
diff --git a/internal/artifacts/secrets_test.go b/internal/artifacts/secrets_test.go
index e3e67ce3..a5a63408 100644
--- a/internal/artifacts/secrets_test.go
+++ b/internal/artifacts/secrets_test.go
@@ -82,6 +82,62 @@ func TestFindVendorPrefixSecretsIgnoresPlaceholdersAndBinaryFiles(t *testing.T)
require.Empty(t, findings)
}
+func TestFindSpecDeclaredCookieSecretsReportsCookieNameOnly(t *testing.T) {
+ root := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte("Cookie:session-id=actuallyrealcookievaluexyz; x-main=your-cookie-here; y-main=not-an-example-real-value\n"), 0o644))
+
+ findings, err := FindSpecDeclaredCookieSecrets(root, []string{"session-id", "x-main", "y-main"})
+ require.NoError(t, err)
+ require.Len(t, findings, 2)
+ byKind := map[string]VendorPrefixSecretFinding{}
+ for _, finding := range findings {
+ byKind[finding.Kind] = finding
+ }
+ require.Equal(t, "README.md", byKind["cookie-value:session-id"].Path)
+ require.Equal(t, 1, byKind["cookie-value:session-id"].Line)
+ require.Equal(t, "README.md", byKind["cookie-value:y-main"].Path)
+ require.Equal(t, 1, byKind["cookie-value:y-main"].Line)
+}
+
+func TestFindSpecDeclaredCookieSecretsReportsStructuredNameValueCookies(t *testing.T) {
+ root := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(root, "cookies.json"), []byte(`{"name":"session-id","value":"actuallyrealcookievaluexyz"}`+"\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "cookies-reversed.json"), []byte(`{"value":"anotherrealcookievaluexyz","name":"x-main"}`+"\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "cookies-pretty.json"), []byte("{\n \"name\": \"y-main\",\n \"value\": \"prettyrealcookievaluexyz\"\n}\n"), 0o644))
+
+ findings, err := FindSpecDeclaredCookieSecrets(root, []string{"session-id", "x-main", "y-main"})
+ require.NoError(t, err)
+ require.Len(t, findings, 3)
+ byKind := map[string]VendorPrefixSecretFinding{}
+ for _, finding := range findings {
+ byKind[finding.Kind] = finding
+ }
+ require.Equal(t, "cookies.json", byKind["cookie-value:session-id"].Path)
+ require.Equal(t, 1, byKind["cookie-value:session-id"].Line)
+ require.Equal(t, "cookies-reversed.json", byKind["cookie-value:x-main"].Path)
+ require.Equal(t, 1, byKind["cookie-value:x-main"].Line)
+ require.Equal(t, "cookies-pretty.json", byKind["cookie-value:y-main"].Path)
+ require.Equal(t, 3, byKind["cookie-value:y-main"].Line)
+}
+
+func TestFindPackageSecretsCombinesVendorPrefixAndDeclaredCookies(t *testing.T) {
+ root := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte("Cookie: session-id=actuallyrealcookievaluexyz\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "spec.json"), []byte("\"token\":\""+testSecret("sk", "-or-v1-", "abcdefghijklmnopqrstuvwxyz1234567890")+"\"\n"), 0o644))
+
+ findings, err := FindPackageSecrets(root, []string{"session-id"})
+ require.NoError(t, err)
+ require.Len(t, findings, 2)
+ byKind := map[string]VendorPrefixSecretFinding{}
+ for _, finding := range findings {
+ byKind[finding.Kind] = finding
+ }
+ require.Equal(t, "README.md", byKind["cookie-value:session-id"].Path)
+ require.Equal(t, 1, byKind["cookie-value:session-id"].Line)
+ require.Equal(t, "spec.json", byKind["openrouter-api-key"].Path)
+ require.Equal(t, 1, byKind["openrouter-api-key"].Line)
+}
+
func testSecret(parts ...string) string {
return strings.Join(parts, "")
}
diff --git a/internal/browsersniff/fixtures.go b/internal/browsersniff/fixtures.go
index c5306995..7a3284ad 100644
--- a/internal/browsersniff/fixtures.go
+++ b/internal/browsersniff/fixtures.go
@@ -2,19 +2,29 @@ package browsersniff
import (
"encoding/json"
+ "fmt"
"net/url"
+ "regexp"
"sort"
"strings"
"github.com/mvanhorn/cli-printing-press/v4/internal/discovery"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/piiplaceholders"
)
+type FixtureValue struct {
+ Name string
+ Value string
+}
+
type TestFixture struct {
EndpointName string
Method string
Path string
ParamNames []string
BodyFields []string
+ ParamSamples []FixtureValue
+ BodySamples []FixtureValue
HasAuth bool
}
@@ -58,6 +68,8 @@ func GenerateFixtures(capture *EnrichedCapture) *FixtureSet {
paramNames := make(map[string]struct{})
bodyFields := make(map[string]struct{})
+ paramSamples := make(map[string]string)
+ bodySamples := make(map[string]string)
for _, entry := range group.Entries {
entryFixture := SanitizeForFixture(entry)
@@ -75,10 +87,14 @@ func GenerateFixtures(capture *EnrichedCapture) *FixtureSet {
bodyFields[name] = struct{}{}
paramNames[name] = struct{}{}
}
+ mergeFixtureSamples(paramSamples, entryFixture.ParamSamples)
+ mergeFixtureSamples(bodySamples, entryFixture.BodySamples)
}
fixture.ParamNames = sortedKeys(paramNames)
fixture.BodyFields = sortedKeys(bodyFields)
+ fixture.ParamSamples = sortedFixtureValues(paramSamples)
+ fixture.BodySamples = sortedFixtureValues(bodySamples)
fixtureSet.Fixtures = append(fixtureSet.Fixtures, fixture)
}
@@ -94,51 +110,127 @@ func SanitizeForFixture(entry EnrichedEntry) TestFixture {
parsedURL, err := url.Parse(entry.URL)
if err == nil {
queryNames := make(map[string]struct{}, len(parsedURL.Query()))
- for name := range parsedURL.Query() {
+ querySamples := make(map[string]string, len(parsedURL.Query()))
+ for name, values := range parsedURL.Query() {
queryNames[name] = struct{}{}
+ if len(values) > 0 {
+ querySamples[name] = syntheticFixtureValue(name, values[0])
+ }
}
fixture.ParamNames = sortedKeys(queryNames)
+ fixture.ParamSamples = sortedFixtureValues(querySamples)
}
contentType := strings.ToLower(getHeaderValue(entry.RequestHeaders, "Content-Type"))
- bodyFields := extractBodyFieldNames(entry.RequestBody, contentType)
+ bodyFields, bodySamples := extractBodyFieldSamples(entry.RequestBody, contentType)
fixture.BodyFields = bodyFields
+ fixture.BodySamples = sortedFixtureValues(bodySamples)
fixture.ParamNames = mergeSortedNames(fixture.ParamNames, bodyFields)
+ mergeFixtureSamplesMap := map[string]string{}
+ mergeFixtureSamples(mergeFixtureSamplesMap, fixture.ParamSamples)
+ mergeFixtureSamples(mergeFixtureSamplesMap, fixture.BodySamples)
+ fixture.ParamSamples = sortedFixtureValues(mergeFixtureSamplesMap)
fixture.HasAuth = hasAuthHeaders(entry.RequestHeaders)
return fixture
}
-func extractBodyFieldNames(body string, contentType string) []string {
+func extractBodyFieldSamples(body string, contentType string) ([]string, map[string]string) {
body = strings.TrimSpace(body)
if body == "" {
- return nil
+ return nil, nil
}
switch {
case strings.Contains(contentType, "json"):
var value any
if err := json.Unmarshal([]byte(body), &value); err != nil {
- return nil
+ return nil, nil
}
root := topLevelObject(value)
if root == nil {
- return nil
+ return nil, nil
}
fields := make([]string, 0, len(root))
+ samples := make(map[string]string, len(root))
for key := range root {
fields = append(fields, key)
+ samples[key] = syntheticFixtureValue(key, root[key])
}
sort.Strings(fields)
- return fields
+ return fields, samples
case strings.Contains(contentType, "form-urlencoded"):
values := ParseFormBody(body)
- return sortedKeysFromMap(values)
+ samples := make(map[string]string, len(values))
+ for key, value := range values {
+ samples[key] = syntheticFixtureValue(key, value)
+ }
+ return sortedKeysFromMap(values), samples
+ default:
+ return nil, nil
+ }
+}
+
+var (
+ moneyValueRE = regexp.MustCompile(`^\$?\d+(?:\.\d{2})?$`)
+ dateValueRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
+ cardLast4ValueRE = regexp.MustCompile(`^\d{4}$`)
+)
+
+func syntheticFixtureValue(name string, raw any) string {
+ value := strings.TrimSpace(fmt.Sprint(raw))
+ nameLower := strings.ToLower(name)
+ switch {
+ case piiplaceholders.OrderIDPattern().MatchString(value):
+ if strings.HasPrefix(strings.ToUpper(value), "D01-") {
+ return piiplaceholders.SyntheticDigitalOrderID
+ }
+ return piiplaceholders.SyntheticOrderID
+ case piiplaceholders.ASINPattern().MatchString(value):
+ return piiplaceholders.PrimarySyntheticASIN
+ case cardLast4ValueRE.MatchString(value) && strings.Contains(nameLower, "last"):
+ return piiplaceholders.SyntheticCardLast4
+ case moneyValueRE.MatchString(value) && (strings.Contains(nameLower, "money") || strings.Contains(nameLower, "amount") || strings.Contains(nameLower, "price") || strings.Contains(nameLower, "total")):
+ return piiplaceholders.SyntheticMoney
+ case dateValueRE.MatchString(value) || isDateFixtureField(nameLower):
+ return piiplaceholders.SyntheticDate
default:
+ return "example-value"
+ }
+}
+
+func isDateFixtureField(nameLower string) bool {
+ normalized := strings.ReplaceAll(nameLower, "-", "_")
+ return normalized == "date" || strings.HasSuffix(normalized, "_date")
+}
+
+func mergeFixtureSamples(samples map[string]string, values []FixtureValue) {
+ for _, value := range values {
+ if value.Name == "" {
+ continue
+ }
+ if _, exists := samples[value.Name]; !exists {
+ samples[value.Name] = value.Value
+ }
+ }
+}
+
+func sortedFixtureValues(samples map[string]string) []FixtureValue {
+ if len(samples) == 0 {
return nil
}
+ names := make([]string, 0, len(samples))
+ for name := range samples {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ values := make([]FixtureValue, 0, len(names))
+ for _, name := range names {
+ values = append(values, FixtureValue{Name: name, Value: samples[name]})
+ }
+ return values
}
func hasAuthHeaders(headers map[string]string) bool {
diff --git a/internal/browsersniff/fixtures_test.go b/internal/browsersniff/fixtures_test.go
index 6ee49416..d0ea09b3 100644
--- a/internal/browsersniff/fixtures_test.go
+++ b/internal/browsersniff/fixtures_test.go
@@ -151,6 +151,62 @@ func TestSanitizeForFixture(t *testing.T) {
BodyFields: []string{"client_id", "grant_type"},
},
},
+ {
+ name: "captured value shapes become synthetic samples",
+ entry: EnrichedEntry{
+ Method: "POST",
+ URL: "https://api.example.com/orders?order_id=123-4567890-1234567&asin=B012345678",
+ RequestHeaders: map[string]string{
+ "Content-Type": "application/json",
+ },
+ RequestBody: `{"card_last4":"4242","amount":"99.95","purchased_date":"2026-05-01"}`,
+ },
+ want: TestFixture{
+ Method: "POST",
+ Path: "/orders",
+ ParamNames: []string{"amount", "asin", "card_last4", "order_id", "purchased_date"},
+ BodyFields: []string{"amount", "card_last4", "purchased_date"},
+ ParamSamples: []FixtureValue{
+ {Name: "amount", Value: "12.34"},
+ {Name: "asin", Value: "B0EXAMPLE1"},
+ {Name: "card_last4", Value: "LAST4"},
+ {Name: "order_id", Value: "111-1111111-1111111"},
+ {Name: "purchased_date", Value: "2026-01-15"},
+ },
+ BodySamples: []FixtureValue{
+ {Name: "amount", Value: "12.34"},
+ {Name: "card_last4", Value: "LAST4"},
+ {Name: "purchased_date", Value: "2026-01-15"},
+ },
+ },
+ },
+ {
+ name: "date substring in unrelated field does not become date sample",
+ entry: EnrichedEntry{
+ Method: "POST",
+ URL: "https://api.example.com/check",
+ RequestHeaders: map[string]string{
+ "Content-Type": "application/json",
+ },
+ RequestBody: `{"validate":"pending","candidate":"alice","update":"later"}`,
+ },
+ want: TestFixture{
+ Method: "POST",
+ Path: "/check",
+ ParamNames: []string{"candidate", "update", "validate"},
+ BodyFields: []string{"candidate", "update", "validate"},
+ ParamSamples: []FixtureValue{
+ {Name: "candidate", Value: "example-value"},
+ {Name: "update", Value: "example-value"},
+ {Name: "validate", Value: "example-value"},
+ },
+ BodySamples: []FixtureValue{
+ {Name: "candidate", Value: "example-value"},
+ {Name: "update", Value: "example-value"},
+ {Name: "validate", Value: "example-value"},
+ },
+ },
+ },
}
for _, tt := range tests {
@@ -162,6 +218,12 @@ func TestSanitizeForFixture(t *testing.T) {
assert.Equal(t, tt.want.Path, fixture.Path)
assert.Equal(t, tt.want.ParamNames, fixture.ParamNames)
assert.Equal(t, tt.want.BodyFields, fixture.BodyFields)
+ if tt.want.ParamSamples != nil {
+ assert.Equal(t, tt.want.ParamSamples, fixture.ParamSamples)
+ }
+ if tt.want.BodySamples != nil {
+ assert.Equal(t, tt.want.BodySamples, fixture.BodySamples)
+ }
})
}
}
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 1e2dfe8e..7e6660dd 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -408,10 +408,15 @@ func newPublishPackageCmd() *cobra.Command {
fmt.Fprintln(os.Stderr, "warning: no manuscripts found, packaging without them")
}
- findings, err := artifacts.FindVendorPrefixSecrets(outCLIDir)
+ cookieNames, err := stagedPackageCookieNames(outCLIDir)
if err != nil {
cleanupOnFailure()
- return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("scanning staged package for vendor-prefix tokens: %w", err)}
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("reading staged package cookie auth metadata: %w", err)}
+ }
+ findings, err := artifacts.FindPackageSecrets(outCLIDir, cookieNames)
+ if err != nil {
+ cleanupOnFailure()
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("scanning staged package for secret tokens: %w", err)}
}
piiResult, piiErr := artifacts.RunPIIAudit(outCLIDir)
@@ -1160,6 +1165,22 @@ func hasContent(dir string) bool {
return false
}
+func stagedPackageCookieNames(dir string) ([]string, error) {
+ manifest, err := pipeline.ReadToolsManifest(dir)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ switch strings.ToLower(strings.TrimSpace(manifest.Auth.Type)) {
+ case "cookie", "composed":
+ return manifest.Auth.Cookies, nil
+ default:
+ return nil, nil
+ }
+}
+
// formatCombinedScanError composes the publish-time error message from
// both scanners. Sections appear in fixed order: vendor-prefix tokens,
// then PII pending findings, then PII gate failures. Returns nil when
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index e0fc5dcd..eecef4f6 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -706,6 +706,31 @@ func TestPublishPackageRejectsVendorPrefixSecretsInStagedCLI(t *testing.T) {
assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target")
}
+func TestPublishPackageRejectsSpecDeclaredCookieValuesInStagedCLI(t *testing.T) {
+ for _, authType := range []string{"cookie", "composed"} {
+ t.Run(authType, func(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, "tools-manifest.json"), []byte(`{"auth":{"type":"`+authType+`","cookies":["session-id"]}}`+"\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, "README.md"), []byte("Cookie: session-id=actuallyrealcookievaluexyz\n"), 0o644))
+
+ target := filepath.Join(t.TempDir(), "staging")
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--target", target, "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "vendor-prefix tokens detected")
+ assert.Contains(t, err.Error(), "README.md:1 cookie-value:session-id")
+ assert.NotContains(t, err.Error(), "actuallyrealcookievaluexyz")
+
+ _, statErr := os.Stat(target)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target")
+ })
+ }
+}
+
func TestPublishPackageRejectsPIIInStagedCLI(t *testing.T) {
home := setLibraryTestEnv(t)
cliDir := filepath.Join(home, "library", "test-pp-cli")
diff --git a/internal/generator/captured_fixture_test.go b/internal/generator/captured_fixture_test.go
new file mode 100644
index 00000000..d133f33c
--- /dev/null
+++ b/internal/generator/captured_fixture_test.go
@@ -0,0 +1,50 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/browsersniff"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGenerateCapturedFixtureUsesSyntheticSamples(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("captured-placeholders")
+ outputDir := filepath.Join(t.TempDir(), "captured-placeholders-pp-cli")
+ gen := New(apiSpec, outputDir)
+ gen.FixtureSet = &browsersniff.FixtureSet{
+ Fixtures: []browsersniff.TestFixture{
+ {
+ EndpointName: "get_order",
+ Method: "GET",
+ Path: "/orders",
+ ParamSamples: []browsersniff.FixtureValue{
+ {Name: "amount", Value: "12.34"},
+ {Name: "asin", Value: "B0EXAMPLE1"},
+ {Name: "card_last4", Value: "LAST4"},
+ {Name: "order_id", Value: "111-1111111-1111111"},
+ {Name: "purchased_date", Value: "2026-01-15"},
+ },
+ },
+ },
+ }
+
+ require.NoError(t, gen.Generate())
+ data, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "client_captured_test.go"))
+ require.NoError(t, err)
+ src := string(data)
+
+ require.Contains(t, src, `"order_id"`)
+ require.Contains(t, src, `"111-1111111-1111111"`)
+ require.Contains(t, src, `"asin"`)
+ require.Contains(t, src, `"B0EXAMPLE1"`)
+ require.Contains(t, src, `"card_last4"`)
+ require.Contains(t, src, `"LAST4"`)
+ require.Contains(t, src, `"amount"`)
+ require.Contains(t, src, `"12.34"`)
+ require.Contains(t, src, `"purchased_date"`)
+ require.Contains(t, src, `"2026-01-15"`)
+}
diff --git a/internal/generator/example_value_test.go b/internal/generator/example_value_test.go
index 5159ac00..b2306709 100644
--- a/internal/generator/example_value_test.go
+++ b/internal/generator/example_value_test.go
@@ -56,6 +56,15 @@ func TestExampleValueIDRecognition(t *testing.T) {
{"camel movieId uuid type", spec.Param{Name: "movieId", Type: "uuid"}, uuid},
{"camel personId guid type", spec.Param{Name: "personId", Type: "guid"}, uuid},
+ // Browser-sniff customer-data shapes route to canonical synthetic placeholders.
+ {"asin", spec.Param{Name: "asin", Type: "string"}, "B0EXAMPLE1"},
+ {"card last four", spec.Param{Name: "card_last4", Type: "string"}, "LAST4"},
+ {"recipient", spec.Param{Name: "recipient_name", Type: "string"}, "Test User"},
+ {"address", spec.Param{Name: "shipping_address", Type: "string"}, "123 Test St, Anytown, ST 12345"},
+ {"generic order id stays uuid", spec.Param{Name: "orderId", Type: "string"}, uuid},
+ {"address id stays uuid", spec.Param{Name: "shipping_address_id", Type: "string"}, uuid},
+ {"recipient id stays uuid", spec.Param{Name: "recipient_id", Type: "string"}, uuid},
+
// Negative — does not end in `id`.
{"userIdentifier", spec.Param{Name: "userIdentifier", Type: "string"}, "example-value"},
{"empty name", spec.Param{Name: "", Type: "string"}, "example-value"},
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 43627132..3d26ebfb 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -4275,6 +4275,10 @@ func (g *Generator) mcpParamDescription(p spec.Param) string {
}
func exampleValue(p spec.Param) string {
+ if value, ok := syntheticExampleValue(p.Name); ok {
+ return value
+ }
+
nameLower := strings.ToLower(p.Name)
// camelCase `*Id` carries an exclusion fence so bool/numeric params
diff --git a/internal/generator/placeholders.go b/internal/generator/placeholders.go
new file mode 100644
index 00000000..f50923b2
--- /dev/null
+++ b/internal/generator/placeholders.go
@@ -0,0 +1,27 @@
+package generator
+
+import (
+ "strings"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/piiplaceholders"
+)
+
+// syntheticExampleValue returns canonical fake values for high-risk browser-
+// sniff shapes so generated examples do not echo captured customer data.
+func syntheticExampleValue(paramName string) (string, bool) {
+ nameLower := strings.ToLower(paramName)
+ isIDField := nameLower == "id" || strings.HasSuffix(nameLower, "_id") ||
+ strings.HasSuffix(nameLower, "id")
+ switch {
+ case strings.Contains(nameLower, "asin"):
+ return piiplaceholders.PrimarySyntheticASIN, true
+ case (strings.Contains(nameLower, "card") || strings.Contains(nameLower, "payment")) &&
+ (strings.Contains(nameLower, "last4") || strings.Contains(nameLower, "last_4") || strings.Contains(nameLower, "last-four")):
+ return piiplaceholders.SyntheticCardLast4, true
+ case strings.Contains(nameLower, "recipient") && !isIDField:
+ return piiplaceholders.SyntheticRecipient, true
+ case strings.Contains(nameLower, "address") && !isIDField:
+ return piiplaceholders.SyntheticAddress, true
+ }
+ return "", false
+}
diff --git a/internal/generator/templates/captured_test.go.tmpl b/internal/generator/templates/captured_test.go.tmpl
index 39514208..6350bf39 100644
--- a/internal/generator/templates/captured_test.go.tmpl
+++ b/internal/generator/templates/captured_test.go.tmpl
@@ -10,6 +10,18 @@ import (
{{range .Fixtures}}
func TestCaptured_{{.EndpointName}}_RequestShape(t *testing.T) {
+ sampleParams := map[string]string{
+{{- range .ParamSamples}}
+ {{printf "%q" .Name}}: {{printf "%q" .Value}},
+{{- end}}
+ }
+ sampleBody := map[string]string{
+{{- range .BodySamples}}
+ {{printf "%q" .Name}}: {{printf "%q" .Value}},
+{{- end}}
+ }
+ _, _ = sampleParams, sampleBody
+
var capturedReq *http.Request
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedReq = r
diff --git a/internal/mcpdesc/params.go b/internal/mcpdesc/params.go
index e44346dd..6493f0f3 100644
--- a/internal/mcpdesc/params.go
+++ b/internal/mcpdesc/params.go
@@ -5,6 +5,7 @@ import (
"unicode/utf8"
"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/piiplaceholders"
"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
)
@@ -59,12 +60,12 @@ func NewParamDescriptionCompactorForEndpoints(endpoints []spec.Endpoint) *ParamD
func (c *ParamDescriptionCompactor) Description(p spec.Param) string {
description := naming.OneLineNormalize(p.Description)
if c == nil || description == "" {
- return description
+ return piiplaceholders.SanitizeCapturedExamples(description)
}
if compacted, ok := c.compacted[keyForParamDescription(p, description)]; ok {
- return compacted
+ description = compacted
}
- return description
+ return piiplaceholders.SanitizeCapturedExamples(description)
}
func appendResourceEndpoints(endpoints []spec.Endpoint, resource spec.Resource) []spec.Endpoint {
diff --git a/internal/mcpdesc/params_test.go b/internal/mcpdesc/params_test.go
index 4328c9cb..28673c85 100644
--- a/internal/mcpdesc/params_test.go
+++ b/internal/mcpdesc/params_test.go
@@ -105,3 +105,25 @@ func TestParamDescriptionCompactorTruncatesUnicodeSafely(t *testing.T) {
assert.LessOrEqual(t, utf8.RuneCountInString(got), sharedParamDescriptionMax)
assert.True(t, strings.HasSuffix(got, sharedParamDescriptionTail))
}
+
+func TestParamDescriptionCompactorSanitizesCapturedExamples(t *testing.T) {
+ description := "Order ID, format XXX-XXXXXXX-XXXXXXX (e.g. 123-4567890-1234567), ASIN B012345678, card ending in 4242."
+ api := &spec.APISpec{
+ Resources: map[string]spec.Resource{
+ "orders": {
+ Endpoints: map[string]spec.Endpoint{
+ "get": {Params: []spec.Param{{Name: "order_id", Type: "string", Description: description}}},
+ },
+ },
+ },
+ }
+
+ got := NewParamDescriptionCompactor(api).Description(spec.Param{Name: "order_id", Type: "string", Description: description})
+
+ assert.Contains(t, got, "e.g. 111-1111111-1111111")
+ assert.Contains(t, got, "ASIN B0EXAMPLE1")
+ assert.Contains(t, got, "card ending in LAST4")
+ assert.NotContains(t, got, "123-4567890-1234567")
+ assert.NotContains(t, got, "B012345678")
+ assert.NotContains(t, got, "4242")
+}
diff --git a/internal/piiplaceholders/placeholders.go b/internal/piiplaceholders/placeholders.go
new file mode 100644
index 00000000..838cf4dd
--- /dev/null
+++ b/internal/piiplaceholders/placeholders.go
@@ -0,0 +1,92 @@
+package piiplaceholders
+
+import (
+ "regexp"
+ "strings"
+)
+
+const (
+ SyntheticOrderID = "111-1111111-1111111"
+ SyntheticDigitalOrderID = "D01-1111111-1111111"
+ SyntheticCardLast4 = "LAST4"
+ SyntheticRecipient = "Test User"
+ SyntheticAddress = "123 Test St, Anytown, ST 12345"
+ SyntheticMoney = "12.34"
+ SyntheticDate = "2026-01-15"
+ SyntheticCookieValue = "your-cookie-here"
+ PrimarySyntheticASIN = "B0EXAMPLE1"
+)
+
+var syntheticASINs = [...]string{
+ "B0EXAMPLE1",
+ "B0EXAMPLE2",
+ "B0EXAMPLE3",
+ "B0EXAMPLE4",
+ "B0EXAMPLE5",
+ "B0EXAMPLE6",
+ "B0EXAMPLE7",
+ "B0EXAMPLE8",
+}
+
+var (
+ orderIDExampleRE = regexp.MustCompile(`\b(?:\d{3}|D01)-\d{7}-\d{7}\b`)
+ asinExampleRE = regexp.MustCompile(`\bB0[A-Z0-9]{8}\b`)
+ cardLast4ExampleRE = regexp.MustCompile(`(?i)((?:card|visa|mastercard|amex|ending in|last\s+4)[^0-9]{0,10})\d{4}`)
+ placeholderRE = regexp.MustCompile(`^your-[a-z0-9-]+-here$`)
+ xPlaceholderRE = regexp.MustCompile(`^x+$`)
+)
+
+func OrderIDPattern() *regexp.Regexp {
+ return orderIDExampleRE
+}
+
+func ASINPattern() *regexp.Regexp {
+ return asinExampleRE
+}
+
+// SanitizeCapturedExamples replaces captured-value examples in user-authored
+// parameter descriptions with canonical synthetic values.
+func SanitizeCapturedExamples(description string) string {
+ out := orderIDExampleRE.ReplaceAllStringFunc(description, func(match string) string {
+ if strings.HasPrefix(strings.ToUpper(match), "D01-") {
+ return SyntheticDigitalOrderID
+ }
+ return SyntheticOrderID
+ })
+ out = asinExampleRE.ReplaceAllString(out, PrimarySyntheticASIN)
+ out = cardLast4ExampleRE.ReplaceAllString(out, "${1}"+SyntheticCardLast4)
+ return out
+}
+
+func IsSyntheticOrderID(value string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(value))
+ return normalized == strings.ToLower(SyntheticOrderID) ||
+ normalized == strings.ToLower(SyntheticDigitalOrderID)
+}
+
+func IsSyntheticASIN(value string) bool {
+ for _, candidate := range syntheticASINs {
+ if strings.EqualFold(candidate, strings.TrimSpace(value)) {
+ return true
+ }
+ }
+ return false
+}
+
+func IsSyntheticPostalAddress(value string) bool {
+ return strings.HasPrefix(strings.ToLower(SyntheticAddress), strings.ToLower(strings.TrimSpace(value)))
+}
+
+// IsSyntheticCookieValue reports whether a spec-declared cookie assignment
+// uses placeholder text rather than captured session material.
+func IsSyntheticCookieValue(value string) bool {
+ normalized := strings.ToLower(strings.Trim(strings.TrimSpace(value), `"'`))
+ if normalized == "" {
+ return true
+ }
+ switch normalized {
+ case "example", "example-cookie", SyntheticCookieValue, "<redacted>", "redacted":
+ return true
+ }
+ return placeholderRE.MatchString(normalized) || xPlaceholderRE.MatchString(normalized)
+}
diff --git a/internal/piiplaceholders/placeholders_test.go b/internal/piiplaceholders/placeholders_test.go
new file mode 100644
index 00000000..0243a8da
--- /dev/null
+++ b/internal/piiplaceholders/placeholders_test.go
@@ -0,0 +1,22 @@
+package piiplaceholders
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSanitizeCapturedExamples(t *testing.T) {
+ description := "order 123-4567890-1234567, digital D01-4567890-1234567, ASIN B012345678, card ending in 4242"
+
+ got := SanitizeCapturedExamples(description)
+
+ assert.Contains(t, got, SyntheticOrderID)
+ assert.Contains(t, got, SyntheticDigitalOrderID)
+ assert.Contains(t, got, PrimarySyntheticASIN)
+ assert.Contains(t, got, "card ending in "+SyntheticCardLast4)
+ assert.NotContains(t, got, "123-4567890-1234567")
+ assert.NotContains(t, got, "D01-4567890-1234567")
+ assert.NotContains(t, got, "B012345678")
+ assert.NotContains(t, got, "4242")
+}
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index f5f3586b..8b280799 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -447,7 +447,7 @@ func validatePIIGateForPromote(workingDir string) error {
msg += "gate failures:\n" + artifacts.FormatPIIGateFailures(result.Completion) + "\n"
}
msg += fmt.Sprintf("ledger: %s\n", ledgerPath)
- msg += "scope: phase-1 detectors (card-last-4, email, phone, ZIP+4, postal-address); order-IDs, ASINs, and standalone names are a future detector class.\n"
+ msg += "scope: phase-1 detectors (order-id, ASIN, card-last-4, email, phone, ZIP+4, postal-address); standalone names are a future detector class.\n"
msg += "run `printing-press pii-audit <dir>` and follow skills/printing-press-polish/references/pii-polish.md"
return fmt.Errorf("%s", msg)
}
diff --git a/internal/pipeline/toolsmanifest.go b/internal/pipeline/toolsmanifest.go
index f505c495..3a0a4875 100644
--- a/internal/pipeline/toolsmanifest.go
+++ b/internal/pipeline/toolsmanifest.go
@@ -47,6 +47,7 @@ type ManifestAuth struct {
EnvVarSpecs []spec.AuthEnvVar `json:"env_var_specs,omitempty"`
KeyURL string `json:"key_url,omitempty"`
CookieDomain string `json:"cookie_domain,omitempty"`
+ Cookies []string `json:"cookies,omitempty"`
RequiresBrowserSession bool `json:"requires_browser_session,omitempty"`
BrowserSessionValidationPath string `json:"browser_session_validation_path,omitempty"`
BrowserSessionValidationMethod string `json:"browser_session_validation_method,omitempty"`
@@ -230,6 +231,7 @@ func manifestAuth(auth spec.AuthConfig) ManifestAuth {
EnvVarSpecs: auth.EnvVarSpecs,
KeyURL: auth.KeyURL,
CookieDomain: auth.CookieDomain,
+ Cookies: auth.Cookies,
RequiresBrowserSession: auth.RequiresBrowserSession,
BrowserSessionValidationPath: auth.BrowserSessionValidationPath,
BrowserSessionValidationMethod: auth.BrowserSessionValidationMethod,
diff --git a/internal/pipeline/toolsmanifest_test.go b/internal/pipeline/toolsmanifest_test.go
index b225f2df..d6c01ac0 100644
--- a/internal/pipeline/toolsmanifest_test.go
+++ b/internal/pipeline/toolsmanifest_test.go
@@ -559,7 +559,7 @@ func TestWriteToolsManifest_CookieAuthOnlyNoAuthEndpoints(t *testing.T) {
parsed := &spec.APISpec{
Name: "cookie-api",
BaseURL: "https://api.example.com",
- Auth: spec.AuthConfig{Type: "cookie", EnvVars: []string{"COOKIE"}},
+ Auth: spec.AuthConfig{Type: "cookie", EnvVars: []string{"COOKIE"}, Cookies: []string{"session-id", "x-main"}},
Resources: map[string]spec.Resource{
"Items": {
Endpoints: map[string]spec.Endpoint{
@@ -585,6 +585,7 @@ func TestWriteToolsManifest_CookieAuthOnlyNoAuthEndpoints(t *testing.T) {
assert.Equal(t, "items_public_count", got.Tools[0].Name)
assert.Equal(t, "items_public_list", got.Tools[1].Name)
assert.Equal(t, "partial", got.MCPReady)
+ assert.Equal(t, []string{"session-id", "x-main"}, got.Auth.Cookies)
}
func TestWriteToolsManifest_ComposedAuthOnlyNoAuthEndpoints(t *testing.T) {
@@ -592,7 +593,7 @@ func TestWriteToolsManifest_ComposedAuthOnlyNoAuthEndpoints(t *testing.T) {
parsed := &spec.APISpec{
Name: "composed-api",
BaseURL: "https://api.example.com",
- Auth: spec.AuthConfig{Type: "composed", EnvVars: []string{"AUTH_TOKEN"}},
+ Auth: spec.AuthConfig{Type: "composed", EnvVars: []string{"AUTH_TOKEN"}, Cookies: []string{"session-id"}},
Resources: map[string]spec.Resource{
"Items": {
Endpoints: map[string]spec.Endpoint{
@@ -614,6 +615,7 @@ func TestWriteToolsManifest_ComposedAuthOnlyNoAuthEndpoints(t *testing.T) {
require.Len(t, got.Tools, 1)
assert.Equal(t, "items_public_list", got.Tools[0].Name)
+ assert.Equal(t, []string{"session-id"}, got.Auth.Cookies)
}
func TestWriteToolsManifest_EmptyDescription(t *testing.T) {
← 8c955701 fix(cli): correct live-check and helper emission (#1637)
·
back to Cli Printing Press
·
fix(cli): gate ship plans on agent readiness (#1638) ed17d302 →