← back to Cli Printing Press
fix(cli): block vendor-prefix secrets during publish (#852)
8cf5459c947eacb00a94b9fae465ac761d7dc4ff · 2026-05-10 00:03:33 -0700 · Trevin Chow
* fix(cli): block vendor-prefix secrets during publish
* fix(cli): address vendor scan review feedback
Files touched
M internal/artifacts/secrets.goM internal/artifacts/secrets_test.goM internal/cli/publish.goM internal/cli/publish_test.goM skills/printing-press-publish/SKILL.mdM skills/printing-press/references/secret-protection.md
Diff
commit 8cf5459c947eacb00a94b9fae465ac761d7dc4ff
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 00:03:33 2026 -0700
fix(cli): block vendor-prefix secrets during publish (#852)
* fix(cli): block vendor-prefix secrets during publish
* fix(cli): address vendor scan review feedback
---
internal/artifacts/secrets.go | 133 ++++++++++++++++++++-
internal/artifacts/secrets_test.go | 66 ++++++++--
internal/cli/publish.go | 14 +++
internal/cli/publish_test.go | 45 +++++++
skills/printing-press-publish/SKILL.md | 32 +++--
.../printing-press/references/secret-protection.md | 8 ++
6 files changed, 276 insertions(+), 22 deletions(-)
diff --git a/internal/artifacts/secrets.go b/internal/artifacts/secrets.go
index 857e45f1..dcdaa7dd 100644
--- a/internal/artifacts/secrets.go
+++ b/internal/artifacts/secrets.go
@@ -1,6 +1,16 @@
package artifacts
-import "regexp"
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
type secretReplacement struct {
pattern *regexp.Regexp
@@ -17,7 +27,11 @@ var archivedSpecSecretPatterns = []secretReplacement{
replacement: `Bearer <REDACTED_STRIPE_TOKEN_EXAMPLE>`,
},
{
- pattern: regexp.MustCompile(`Bearer (?:ghp|gho)_[A-Za-z0-9]{20,}`),
+ pattern: regexp.MustCompile(`sk-or-v1-[A-Za-z0-9_-]{24,}`),
+ replacement: `<REDACTED_OPENROUTER_TOKEN_EXAMPLE>`,
+ },
+ {
+ pattern: regexp.MustCompile(`Bearer (?:ghp|gho|ghs)_[A-Za-z0-9]{20,}`),
replacement: `Bearer <REDACTED_GITHUB_TOKEN_EXAMPLE>`,
},
{
@@ -51,3 +65,118 @@ func RedactArchivedSpecSecrets(data []byte) []byte {
}
return out
}
+
+type VendorPrefixSecretFinding struct {
+ Path string
+ Line int
+ Kind string
+}
+
+type vendorPrefixSecretPattern struct {
+ kind string
+ pattern *regexp.Regexp
+ accept func(string) bool
+}
+
+var vendorPrefixSecretPatterns = []vendorPrefixSecretPattern{
+ {kind: "openrouter-api-key", pattern: regexp.MustCompile(`sk-or-v1-[A-Za-z0-9_-]{24,}`)},
+ {kind: "stripe-secret-key", pattern: regexp.MustCompile(`sk_(?:live|test)_[A-Za-z0-9]{16,}`)},
+ {kind: "calcom-api-key", pattern: regexp.MustCompile(`cal_(?:live|test)_[A-Za-z0-9]{16,}`)},
+ {kind: "github-token", pattern: regexp.MustCompile(`(?:ghp|gho|ghs)_[A-Za-z0-9]{36,}`)},
+ {kind: "github-fine-grained-token", pattern: regexp.MustCompile(`github_pat_[A-Za-z0-9_]{60,}`)},
+ {kind: "slack-token", pattern: regexp.MustCompile(`xox[abprs]-[A-Za-z0-9-]{32,}`)},
+ {kind: "slack-app-token", pattern: regexp.MustCompile(`xapp-[A-Za-z0-9-]{32,}`)},
+ {kind: "google-api-key", pattern: regexp.MustCompile(`AIza[A-Za-z0-9_-]{20,}`)},
+ {
+ kind: "aws-access-key",
+ pattern: regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`),
+ accept: func(candidate string) bool {
+ return !strings.Contains(candidate, "EXAMPLE")
+ },
+ },
+}
+
+func FindVendorPrefixSecrets(root string) ([]VendorPrefixSecretFinding, error) {
+ var findings []VendorPrefixSecretFinding
+ err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ fileFindings, err := scanVendorPrefixSecretFile(root, path)
+ if err != nil {
+ return err
+ }
+ findings = append(findings, fileFindings...)
+ return nil
+ })
+ 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) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = file.Close() }()
+
+ reader := bufio.NewReaderSize(file, 8192)
+ probe, err := reader.Peek(8192)
+ if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
+ return nil, err
+ }
+ if bytes.Contains(probe, []byte{0}) {
+ return nil, nil
+ }
+
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return nil, err
+ }
+ rel = filepath.ToSlash(rel)
+
+ var findings []VendorPrefixSecretFinding
+ lineNumber := 0
+ for {
+ line, readErr := reader.ReadString('\n')
+ if readErr != nil && readErr != io.EOF {
+ return nil, readErr
+ }
+ if line == "" && readErr == io.EOF {
+ break
+ }
+ lineNumber++
+ for _, pattern := range vendorPrefixSecretPatterns {
+ if vendorPrefixSecretLineMatch(pattern, line) {
+ findings = append(findings, VendorPrefixSecretFinding{
+ Path: rel,
+ Line: lineNumber,
+ Kind: pattern.kind,
+ })
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ }
+ return findings, nil
+}
+
+func vendorPrefixSecretLineMatch(pattern vendorPrefixSecretPattern, line string) bool {
+ for _, candidate := range pattern.pattern.FindAllString(line, -1) {
+ if pattern.accept == nil || pattern.accept(candidate) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/artifacts/secrets_test.go b/internal/artifacts/secrets_test.go
index f5142513..e3e67ce3 100644
--- a/internal/artifacts/secrets_test.go
+++ b/internal/artifacts/secrets_test.go
@@ -1,26 +1,32 @@
package artifacts
import (
+ "os"
+ "path/filepath"
+ "strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestRedactArchivedSpecSecretsRemovesVendorTokenExamples(t *testing.T) {
- input := []byte(`Examples:
-Authorization: Bearer secret-token:vendor_production_wma_24SCp4G81X3yHL4Wq8FgzuaP9ye3VKf2mgTDctXyRg5HY_example
-Authorization: Bearer sk_live_1234567890abcdefghijkl
-Authorization: Bearer ghp_1234567890abcdefghijklmnopqrstuvwx
-Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456
-X-API-Key: abcdefghijklmnopqrstuvwxyz123456
-"apiKey": "abcdefghijklmnopqrstuvwxyz123456"
-api_key: abcdefghijklmnopqrstuvwxyz123456
-https://api.example.com/widgets?access_token=abcdefghijklmnopqrstuvwxyz123456
-`)
+ input := []byte(strings.Join([]string{
+ "Examples:",
+ "Authorization: Bearer secret-token:vendor_production_wma_24SCp4G81X3yHL4Wq8FgzuaP9ye3VKf2mgTDctXyRg5HY_example",
+ "Authorization: Bearer " + testSecret("sk", "-or-v1-", "abcdefghijklmnopqrstuvwxyz1234567890"),
+ "Authorization: Bearer " + testSecret("sk", "_live_", "1234567890abcdefghijkl"),
+ "Authorization: Bearer " + testSecret("ghp", "_1234567890abcdefghijklmnopqrstuvwx"),
+ "Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456",
+ "X-API-Key: abcdefghijklmnopqrstuvwxyz123456",
+ `"apiKey": "abcdefghijklmnopqrstuvwxyz123456"`,
+ "api_key: abcdefghijklmnopqrstuvwxyz123456",
+ "https://api.example.com/widgets?access_token=abcdefghijklmnopqrstuvwxyz123456",
+ }, "\n"))
got := string(RedactArchivedSpecSecrets(input))
require.Contains(t, got, "Authorization: Bearer secret-token:<REDACTED_TOKEN_EXAMPLE>")
+ require.Contains(t, got, "Authorization: Bearer <REDACTED_OPENROUTER_TOKEN_EXAMPLE>")
require.Contains(t, got, "Authorization: Bearer <REDACTED_STRIPE_TOKEN_EXAMPLE>")
require.Contains(t, got, "Authorization: Bearer <REDACTED_GITHUB_TOKEN_EXAMPLE>")
require.Contains(t, got, "Authorization: Bearer <REDACTED_BEARER_TOKEN_EXAMPLE>")
@@ -29,7 +35,8 @@ https://api.example.com/widgets?access_token=abcdefghijklmnopqrstuvwxyz123456
require.Contains(t, got, "api_key: <REDACTED_CREDENTIAL_EXAMPLE>")
require.Contains(t, got, "access_token=<REDACTED_CREDENTIAL_EXAMPLE>")
require.NotContains(t, got, "vendor_production")
- require.NotContains(t, got, "sk_live_1234567890")
+ require.NotContains(t, got, testSecret("sk", "-or-v1-", "abcdefghijklmnopqrstuvwxyz"))
+ require.NotContains(t, got, testSecret("sk", "_live_", "1234567890"))
require.NotContains(t, got, "ghp_1234567890")
require.NotContains(t, got, "abcdefghijklmnopqrstuvwxyz123456")
}
@@ -41,3 +48,40 @@ func TestRedactArchivedSpecSecretsKeepsPlaceholders(t *testing.T) {
require.Equal(t, string(input), got)
}
+
+func TestFindVendorPrefixSecretsReportsFileAndLine(t *testing.T) {
+ root := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(root, ".manuscripts", "run-1", "research"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "spec.json"), []byte("{\n \"token\": \""+testSecret("sk", "-or-v1-", "abcdefghijklmnopqrstuvwxyz1234567890")+"\"\n}\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "aws.txt"), []byte("key="+testSecret("AK", "IA", "1234567890ABCDEF")+"\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, ".manuscripts", "run-1", "research", "openapi.json"), []byte("Authorization: Bearer "+testSecret("sk", "_live_", "1234567890abcdefghijklmnop")+"\n"), 0o644))
+
+ findings, err := FindVendorPrefixSecrets(root)
+ require.NoError(t, err)
+ require.Len(t, findings, 3)
+ byPath := map[string]VendorPrefixSecretFinding{}
+ for _, finding := range findings {
+ byPath[finding.Path] = finding
+ }
+ require.Equal(t, 1, byPath["aws.txt"].Line)
+ require.Equal(t, "aws-access-key", byPath["aws.txt"].Kind)
+ require.Equal(t, 2, byPath["spec.json"].Line)
+ require.Equal(t, "openrouter-api-key", byPath["spec.json"].Kind)
+ require.Equal(t, 1, byPath[".manuscripts/run-1/research/openapi.json"].Line)
+ require.Equal(t, "stripe-secret-key", byPath[".manuscripts/run-1/research/openapi.json"].Kind)
+}
+
+func TestFindVendorPrefixSecretsIgnoresPlaceholdersAndBinaryFiles(t *testing.T) {
+ root := t.TempDir()
+ readme := "Use sk-EXAMPLE-KEY, " + testSecret("AK", "IA", "IOSFODNN7EXAMPLE") + ", or your-key-here for setup.\n"
+ require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte(readme), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "blob.bin"), []byte{0, 's', 'k', '_', 'l', 'i', 'v', 'e', '_', '1', '2', '3'}, 0o644))
+
+ findings, err := FindVendorPrefixSecrets(root)
+ require.NoError(t, err)
+ require.Empty(t, findings)
+}
+
+func testSecret(parts ...string) string {
+ return strings.Join(parts, "")
+}
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index e65fd9ce..66989251 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -11,6 +11,7 @@ import (
"strings"
"time"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
catalogpkg "github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
"github.com/mvanhorn/cli-printing-press/v4/internal/govulncheck"
"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
@@ -378,6 +379,19 @@ func newPublishPackageCmd() *cobra.Command {
fmt.Fprintln(os.Stderr, "warning: no manuscripts found, packaging without them")
}
+ findings, err := artifacts.FindVendorPrefixSecrets(outCLIDir)
+ if err != nil {
+ cleanupOnFailure()
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("scanning staged package for vendor-prefix tokens: %w", err)}
+ }
+ if len(findings) > 0 {
+ cleanupOnFailure()
+ return &ExitError{
+ Code: ExitPublishError,
+ Err: fmt.Errorf("vendor-prefix tokens detected in staged package:\n%s", artifacts.FormatVendorPrefixSecretFindings(findings)),
+ }
+ }
+
// Success — remove stashed old CLI dirs
removeStashedDirs(stashedDirs)
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index c166de1c..3923e195 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -629,6 +629,47 @@ func TestPublishPackageIncludesManuscripts(t *testing.T) {
assert.NoError(t, err, "shipcheck proofs should be in staged package")
}
+func TestPublishPackageRejectsVendorPrefixSecretsInStagedCLI(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, "spec.json"), []byte("{\n \"token\": \""+testSecret("sk", "-or-v1-", "abcdefghijklmnopqrstuvwxyz1234567890")+"\"\n}\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(), "spec.json:2 openrouter-api-key")
+
+ _, statErr := os.Stat(target)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target")
+}
+
+func TestPublishPackageRejectsVendorPrefixSecretsInManuscripts(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+ runID := "20260329-100000"
+ researchFile := filepath.Join(home, "manuscripts", "test", runID, "research", "openapi.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(researchFile), 0o755))
+ require.NoError(t, os.WriteFile(researchFile, []byte("Authorization: Bearer "+testSecret("sk", "_live_", "1234567890abcdefghijklmnop")+"\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(), ".manuscripts/20260329-100000/research/openapi.json:1 stripe-secret-key")
+
+ _, statErr := os.Stat(target)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target")
+}
+
func TestFindMostRecentRun(t *testing.T) {
dir := t.TempDir()
@@ -1014,3 +1055,7 @@ func writePublishablePhase5Pass(t *testing.T) {
AuthContext: pipeline.Phase5AuthContext{Type: "none"},
})
}
+
+func testSecret(parts ...string) string {
+ return strings.Join(parts, "")
+}
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index 648a0480..fa1cba9a 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -408,6 +408,8 @@ printing-press publish package \
Parse the JSON result. Note the `staged_dir`, `module_path`, `manuscripts_included`, and `run_id`. The `module_path` field confirms the Go module path that was set in the packaged CLI's `go.mod` and import paths.
+`publish package` performs the mandatory vendor-prefix secret scan over the staged CLI, including copied manuscripts, before returning success. If it reports `vendor-prefix tokens detected`, stop and remove or redact the reported file:line findings before retrying. This is a hard gate and does not depend on `gitleaks`, `trufflehog`, or destination-repo push protection.
+
Then copy the staged CLI into the publish repo, replacing any existing version:
```bash
@@ -899,9 +901,21 @@ if the user provided an API key. By the time publish runs, the Printing Press's
mistakes should already be caught. But the user may have edited files between
generation and publish.
-### What publish checks (best-effort, warn-only)
+### What publish checks
+
+1. **Mandatory binary scan:** `printing-press publish package` scans the staged CLI and manuscripts for live-looking vendor-prefix tokens (`sk-or-v1-*`, `sk_live_*`, `ghp_*`, `ghs_*`, `xoxb-*`, `AKIA*`, and similar). If it fails with `vendor-prefix tokens detected`, treat the package as unpublishable. Do not copy, commit, push, or open a PR until the reported file:line findings are removed or redacted.
+
+2. **If the user's exact API key value is known**, scan the packaged tree before creating the PR. This catches edits or manuscripts added after Phase 5.5:
+ ```bash
+ if [ -n "$API_KEY_VALUE" ] && [ ${#API_KEY_VALUE} -ge 16 ]; then
+ if grep -rF "$API_KEY_VALUE" "$PUBLISH_REPO_DIR/library/<category>/<api-slug>" 2>/dev/null; then
+ echo "BLOCKING: API key value found in staged publish tree."
+ exit 1
+ fi
+ fi
+ ```
-1. **If `gitleaks` or `trufflehog` is installed**, run it on the staged directory:
+3. **If `gitleaks` or `trufflehog` is installed**, run it as an enrichment pass on the staged directory:
```bash
if command -v gitleaks >/dev/null 2>&1; then
gitleaks detect --source "<staging-dir>/library" --no-git --verbose 2>&1
@@ -910,24 +924,24 @@ generation and publish.
fi
```
These tools use vendor-specific patterns (Steam keys, Stripe keys, GitHub
- tokens) with low false-positive rates. Their findings are warnings — the
- user reviews and decides.
+ tokens) with low false-positive rates. Their findings add detector breadth
+ beyond the mandatory floor. Review any finding before proceeding.
-2. **If no scanning tool is installed**, do a lightweight check:
+4. **Always do the lightweight structural check:**
- Verify no `.env` files, `session-state.json`, or `config.toml` with
real credentials exist in the staged directory
- Check README examples use `"your-key-here"` placeholders, not real values
- Check manuscripts (if included) don't contain auth headers or cookie values
-3. **Never include** in the staged directory:
+5. **Never include** in the staged directory:
- `.env` files
- `session-state.json`
- Config files with real credentials
- HAR captures with un-stripped auth headers
-If any issues are found, warn the user and ask whether to proceed. The user
-makes the final call — they may have intentionally included something the scan
-flagged (e.g., a test fixture with a fake key). Don't block silently.
+If the mandatory binary scan or exact-value scan finds issues, stop. For
+external-tool or lightweight structural findings, warn the user and ask whether
+to proceed. The user makes the final call on those non-mandatory findings.
### PII pattern scanning (mandatory)
diff --git a/skills/printing-press/references/secret-protection.md b/skills/printing-press/references/secret-protection.md
index b0c11e9a..5aaa7fc2 100644
--- a/skills/printing-press/references/secret-protection.md
+++ b/skills/printing-press/references/secret-protection.md
@@ -139,15 +139,19 @@ recovery prompt. Pattern set:
```bash
PII_AUTO_REDACT=(
+ 'openrouter-api-key|sk-or-v1-[A-Za-z0-9_-]{24,}|<REDACTED:openrouter-api-key>'
'bearer-stripe-live|Bearer sk_live_[A-Za-z0-9]{20,}|Bearer <REDACTED:stripe-live-token>'
'bearer-stripe-test|Bearer sk_test_[A-Za-z0-9]{20,}|Bearer <REDACTED:stripe-test-token>'
'bearer-cal-live|Bearer cal_live_[A-Za-z0-9]{20,}|Bearer <REDACTED:cal-live-token>'
'bearer-cal-test|Bearer cal_test_[A-Za-z0-9]{20,}|Bearer <REDACTED:cal-test-token>'
'bearer-github-pat|Bearer ghp_[A-Za-z0-9]{36,}|Bearer <REDACTED:github-pat>'
'bearer-github-oauth|Bearer gho_[A-Za-z0-9]{36,}|Bearer <REDACTED:github-oauth>'
+ 'bearer-github-server|Bearer ghs_[A-Za-z0-9]{36,}|Bearer <REDACTED:github-server-token>'
'bearer-github-fine|Bearer github_pat_[A-Za-z0-9_]{60,}|Bearer <REDACTED:github-fine-grained-pat>'
'slack-user-token|xoxp-[A-Za-z0-9-]{40,}|<REDACTED:slack-user-token>'
'slack-bot-token|xoxb-[A-Za-z0-9-]{40,}|<REDACTED:slack-bot-token>'
+ 'slack-app-token|xapp-[A-Za-z0-9-]{32,}|<REDACTED:slack-app-token>'
+ 'google-api-key|AIza[A-Za-z0-9_-]{20,}|<REDACTED:google-api-key>'
)
for entry in "${PII_AUTO_REDACT[@]}"; do
@@ -162,6 +166,10 @@ for entry in "${PII_AUTO_REDACT[@]}"; do
done
```
+Do not add a simple `AKIA[0-9A-Z]{16}` shell auto-redaction rule here. AWS
+access keys have the same shape as AWS's canonical documentation placeholder,
+so the binary publish scan handles them with a placeholder allowlist instead.
+
These patterns are vendor-anchored and the prefix character class is restrictive
enough that the false-positive rate is effectively zero. Adding a new vendor
pattern is safe — extend the list when shipping a new printed CLI for a vendor
← 534c24ad chore(ci): speed up PR checks via shard split + lighter lint
·
back to Cli Printing Press
·
fix(cli): emit multipart requests for upload endpoints (#904 fc291cae →