← back to Cli Printing Press
fix(cli): filter crowd-sniff auth env var hints by API name relevance (#86)
7f02e107ae1e584cfce1047942247b7787abe98d · 2026-03-30 21:00:18 -0700 · Trevin Chow
extractEnvVarHint now prefers env vars containing the API name (e.g.,
STEAM_API_KEY for api "steam") over generic matches (e.g.,
COOKIE_SECRET). Returns empty when no relevant hint is found, letting
deriveEnvVar() generate a sensible default from the API name.
Previously the first env var matching any auth keyword won, which
picked COOKIE_SECRET from a random SDK for the Steam API.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-03-31-001-fix-auth-envvar-hint-relevance-plan.mdM internal/crowdsniff/npm.goM internal/crowdsniff/npm_test.goM lefthook.yml
Diff
commit 7f02e107ae1e584cfce1047942247b7787abe98d
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon Mar 30 21:00:18 2026 -0700
fix(cli): filter crowd-sniff auth env var hints by API name relevance (#86)
extractEnvVarHint now prefers env vars containing the API name (e.g.,
STEAM_API_KEY for api "steam") over generic matches (e.g.,
COOKIE_SECRET). Returns empty when no relevant hint is found, letting
deriveEnvVar() generate a sensible default from the API name.
Previously the first env var matching any auth keyword won, which
picked COOKIE_SECRET from a random SDK for the Steam API.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...3-31-001-fix-auth-envvar-hint-relevance-plan.md | 76 ++++++++++++++++++++++
internal/crowdsniff/npm.go | 39 +++++++----
internal/crowdsniff/npm_test.go | 49 +++++++++++---
lefthook.yml | 2 +-
4 files changed, 144 insertions(+), 22 deletions(-)
diff --git a/docs/plans/2026-03-31-001-fix-auth-envvar-hint-relevance-plan.md b/docs/plans/2026-03-31-001-fix-auth-envvar-hint-relevance-plan.md
new file mode 100644
index 00000000..4fef7e2f
--- /dev/null
+++ b/docs/plans/2026-03-31-001-fix-auth-envvar-hint-relevance-plan.md
@@ -0,0 +1,76 @@
+---
+title: "fix: Filter crowd-sniff auth env var hints by API name relevance"
+type: fix
+status: active
+date: 2026-03-31
+origin: docs/retros/2026-03-30-steam-retro.md
+---
+
+# fix: Filter crowd-sniff auth env var hints by API name relevance
+
+## Overview
+
+`extractEnvVarHint()` returns the first env var matching the auth keyword pattern, regardless of whether it's related to the API being sniffed. For Steam, it picked `COOKIE_SECRET` from a random SDK instead of deriving `STEAM_API_KEY` from the API name.
+
+## Problem Frame
+
+When crowd-sniff scans npm SDK source code, it finds all `process.env.X` references matching auth keywords (API, KEY, TOKEN, SECRET). The first match wins, even if it's from unrelated code in the SDK (cookie handling, session management). The generated CLI then uses this wrong env var name, causing auth to silently fail unless the user notices and fixes it.
+
+## Requirements Trace
+
+- R1. Env var hints containing the API name (case-insensitive) are preferred over generic matches
+- R2. If no API-name-relevant hint is found, fall back to `deriveEnvVar()` (generates `STEAM_API_KEY` from api name + auth type) rather than using a random match
+- R3. No regressions for APIs where the env var hint IS relevant (e.g., `process.env.NOTION_API_KEY` for a Notion API scan)
+
+## Scope Boundaries
+
+- Only changes `extractEnvVarHint()` in `internal/crowdsniff/npm.go`
+- Does not change how auth type/header/in are detected — only the env var name
+
+## Key Technical Decisions
+
+- **Prefer API-name match over first-match**: Score candidates by whether they contain the API name. `STEAM_API_KEY` scores higher than `COOKIE_SECRET` when sniffing "steam".
+- **Fall back to deriveEnvVar, not first match**: If no candidate contains the API name, return empty string and let `deriveEnvVar()` in specgen.go generate a sensible default. A derived name (`STEAM_API_KEY`) is better than a random match (`COOKIE_SECRET`).
+
+## Implementation Units
+
+- [ ] **Unit 1: Add API name relevance filter to extractEnvVarHint**
+
+ **Goal:** `extractEnvVarHint` prefers env vars containing the API name, falls back to empty string if none match.
+
+ **Requirements:** R1, R2, R3
+
+ **Dependencies:** None
+
+ **Files:**
+ - Modify: `internal/crowdsniff/npm.go` — change `extractEnvVarHint` signature to accept api name, filter candidates
+ - Modify: `internal/crowdsniff/npm.go` — update call site in `GrepAuth` to pass api name
+ - Test: `internal/crowdsniff/npm_test.go`
+
+ **Approach:**
+ - Change `extractEnvVarHint(content string)` to `extractEnvVarHint(content string, apiName string)`
+ - Collect all matches, then score: contains upper(apiName) → priority 1, generic auth keyword only → priority 2
+ - Return the highest-priority match. If only priority 2 matches exist, return empty string (let deriveEnvVar handle it)
+ - Update `GrepAuth` to accept and pass the api name — it currently receives `sourceTier` but not the api name. Add `apiName` parameter.
+ - Update callers: `processPackageTarball` and `Discover` pass the api name through
+
+ **Patterns to follow:**
+ - Existing `GrepAuth` function signature and pattern
+
+ **Test scenarios:**
+ - Happy path: Content with `process.env.STEAM_API_KEY` and `process.env.COOKIE_SECRET`, api name "steam" → returns `STEAM_API_KEY`
+ - Happy path: Content with `process.env.NOTION_API_KEY`, api name "notion" → returns `NOTION_API_KEY`
+ - Edge case: Content with only `process.env.COOKIE_SECRET`, api name "steam" → returns empty (falls back to deriveEnvVar)
+ - Edge case: Content with no env var references → returns empty
+ - Edge case: API name with special chars (e.g., "cal.com") → normalized for matching
+ - Negative test: Content with `process.env.STEAM_API_KEY`, api name "notion" → does NOT return STEAM_API_KEY (wrong API)
+
+ **Verification:**
+ - `go build ./...` and `go test ./internal/crowdsniff/...` pass
+ - Run `printing-press crowd-sniff --api steam` → env var is `STEAM_API_KEY`, not `COOKIE_SECRET`
+
+## Sources & References
+
+- **Origin:** [docs/retros/2026-03-30-steam-retro.md](docs/retros/2026-03-30-steam-retro.md) — finding #2 (auth detection)
+- Target file: `internal/crowdsniff/npm.go:579-593` (`extractEnvVarHint`)
+- Related: `internal/crowdsniff/specgen.go` (`deriveEnvVar` fallback)
diff --git a/internal/crowdsniff/npm.go b/internal/crowdsniff/npm.go
index ad6540cc..b132b777 100644
--- a/internal/crowdsniff/npm.go
+++ b/internal/crowdsniff/npm.go
@@ -158,7 +158,7 @@ func (s *NPMSource) Discover(ctx context.Context, apiName string) (SourceResult,
}
// Download and extract tarball.
- endpoints, baseURLs, authPatterns, processErr := s.processPackageTarball(ctx, tarballURL, pkg.Name, tier, downloads[pkg.Name])
+ endpoints, baseURLs, authPatterns, processErr := s.processPackageTarball(ctx, tarballURL, pkg.Name, tier, apiName, downloads[pkg.Name])
if processErr != nil {
fmt.Fprintf(os.Stderr, "crowd-sniff: failed to process %s: %v\n", pkg.Name, processErr)
continue
@@ -288,7 +288,7 @@ func (s *NPMSource) fetchTarballURL(ctx context.Context, name, version string) (
}
// processPackageTarball downloads a tarball, extracts it, and greps for endpoints, auth patterns, and base URLs.
-func (s *NPMSource) processPackageTarball(ctx context.Context, tarballURL, pkgName, tier string, weeklyDownloads int) ([]DiscoveredEndpoint, []string, []DiscoveredAuth, error) {
+func (s *NPMSource) processPackageTarball(ctx context.Context, tarballURL, pkgName, tier, apiName string, weeklyDownloads int) ([]DiscoveredEndpoint, []string, []DiscoveredAuth, error) {
// Security: validate tarball URL is HTTPS.
parsed, err := url.Parse(tarballURL)
if err != nil {
@@ -370,7 +370,7 @@ func (s *NPMSource) processPackageTarball(ctx context.Context, tarballURL, pkgNa
_ = weeklyDownloads // Used for future priority sorting; tier stays the same.
// Extract auth patterns from the combined source content.
- authPatterns := GrepAuth(allContent.String(), tier)
+ authPatterns := GrepAuth(allContent.String(), tier, apiName)
return allEndpoints, allBaseURLs, authPatterns, nil
}
@@ -518,7 +518,7 @@ var (
// any detected auth configurations. Designed for high precision — it only
// reports patterns that are clearly auth-related. False negatives are
// acceptable; false positives are not.
-func GrepAuth(content string, sourceTier string) []DiscoveredAuth {
+func GrepAuth(content string, sourceTier string, apiName string) []DiscoveredAuth {
var auths []DiscoveredAuth
seen := make(map[string]bool) // dedup by Type+In+Header
@@ -566,7 +566,7 @@ func GrepAuth(content string, sourceTier string) []DiscoveredAuth {
}
// Look for env var hints.
- envVarHint := extractEnvVarHint(content)
+ envVarHint := extractEnvVarHint(content, apiName)
// Apply env var hint to the first detected auth.
if envVarHint != "" && len(auths) > 0 {
@@ -577,17 +577,34 @@ func GrepAuth(content string, sourceTier string) []DiscoveredAuth {
}
// extractEnvVarHint scans content for process.env references that look
-// auth-related and returns the first match.
-func extractEnvVarHint(content string) string {
+// auth-related. Prefers env vars containing the API name (e.g., STEAM_API_KEY
+// for api "steam") over generic matches (e.g., COOKIE_SECRET). Returns empty
+// string if no API-name-relevant hint is found — the caller falls back to
+// deriveEnvVar() which generates a sensible default from the API name.
+func extractEnvVarHint(content string, apiName string) string {
matches := envVarHintPattern.FindAllStringSubmatch(content, -1)
+ upperAPI := strings.ToUpper(strings.ReplaceAll(apiName, ".", "_"))
+
+ var candidates []string
for _, m := range matches {
- // Group 1 is process.env.VAR, group 2 is process.env['VAR'].
+ var name string
if m[1] != "" {
- return m[1]
+ name = m[1]
+ } else if m[2] != "" {
+ name = m[2]
}
- if m[2] != "" {
- return m[2]
+ if name != "" {
+ candidates = append(candidates, name)
}
}
+
+ // Prefer candidates containing the API name.
+ for _, c := range candidates {
+ if upperAPI != "" && strings.Contains(c, upperAPI) {
+ return c
+ }
+ }
+
+ // No API-name-relevant match — return empty to let deriveEnvVar() handle it.
return ""
}
diff --git a/internal/crowdsniff/npm_test.go b/internal/crowdsniff/npm_test.go
index a054e610..51090228 100644
--- a/internal/crowdsniff/npm_test.go
+++ b/internal/crowdsniff/npm_test.go
@@ -656,6 +656,7 @@ class API {
tarballServer.URL+"/tarball.tgz",
"test-sdk",
TierCommunitySDK,
+ "testapi",
500,
)
@@ -684,6 +685,7 @@ class API {
"http://evil.com/tarball.tgz",
"evil-sdk",
TierCommunitySDK,
+ "testapi",
0,
)
@@ -714,6 +716,7 @@ class API {
tarballServer.URL+"/tarball.tgz",
"test-sdk",
TierCommunitySDK,
+ "testapi",
0,
)
@@ -748,6 +751,7 @@ class API {
tarballServer.URL+"/tarball.tgz",
"symlink-sdk",
TierCommunitySDK,
+ "testapi",
0,
)
@@ -880,6 +884,7 @@ class SteamAPI {
tarballServer.URL+"/tarball.tgz",
"steam-sdk",
TierCommunitySDK,
+ "testapi",
500,
)
@@ -966,7 +971,7 @@ class SteamAPI {
}
}
`
- auths := GrepAuth(content, TierCommunitySDK)
+ auths := GrepAuth(content, TierCommunitySDK, "testapi")
require.Len(t, auths, 1)
assert.Equal(t, "api_key", auths[0].Type)
@@ -989,7 +994,7 @@ class Client {
}
}
`
- auths := GrepAuth(content, TierOfficialSDK)
+ auths := GrepAuth(content, TierOfficialSDK, "testapi")
require.Len(t, auths, 1)
assert.Equal(t, "bearer_token", auths[0].Type)
@@ -1004,7 +1009,7 @@ class Client {
content := "const headers = { 'Authorization': `Bearer ${this.apiKey}` };"
- auths := GrepAuth(content, TierCommunitySDK)
+ auths := GrepAuth(content, TierCommunitySDK, "testapi")
require.Len(t, auths, 1)
assert.Equal(t, "bearer_token", auths[0].Type)
@@ -1021,7 +1026,7 @@ function makeRequest(url, apiKey) {
return fetch(url, { headers });
}
`
- auths := GrepAuth(content, TierCommunitySDK)
+ auths := GrepAuth(content, TierCommunitySDK, "testapi")
require.Len(t, auths, 1)
assert.Equal(t, "api_key", auths[0].Type)
@@ -1038,7 +1043,7 @@ class Client {
createUser(data) { return this.post("/v1/users", data); }
}
`
- auths := GrepAuth(content, TierCommunitySDK)
+ auths := GrepAuth(content, TierCommunitySDK, "testapi")
assert.Empty(t, auths)
})
@@ -1053,7 +1058,7 @@ class SteamAPI {
}
}
`
- auths := GrepAuth(content, TierCommunitySDK)
+ auths := GrepAuth(content, TierCommunitySDK, "steam")
require.NotEmpty(t, auths)
assert.Equal(t, "STEAM_API_KEY", auths[0].EnvVarHint)
@@ -1064,16 +1069,38 @@ class SteamAPI {
content := `const key = process.env['NOTION_API_KEY'];`
- auths := GrepAuth(content, TierOfficialSDK)
+ auths := GrepAuth(content, TierOfficialSDK, "testapi")
// No specific auth pattern found for the key usage, but env var hint
// won't be applied without an auth match. This verifies extractEnvVarHint works.
- hint := extractEnvVarHint(content)
+ hint := extractEnvVarHint(content, "notion")
assert.Equal(t, "NOTION_API_KEY", hint)
// Even without a matching auth pattern, we get no false positives.
_ = auths
})
+ t.Run("env var hint filters by API name relevance", func(t *testing.T) {
+ t.Parallel()
+
+ content := `
+const secret = process.env.COOKIE_SECRET;
+const key = process.env.STEAM_API_KEY;
+class Client {
+ request(url) { return fetch(url + '?key=' + key); }
+}
+`
+ // API name "steam" → picks STEAM_API_KEY, not COOKIE_SECRET
+ auths := GrepAuth(content, TierCommunitySDK, "steam")
+ require.NotEmpty(t, auths)
+ assert.Equal(t, "STEAM_API_KEY", auths[0].EnvVarHint)
+
+ // API name "notion" → no match, hint empty (deriveEnvVar handles it)
+ auths2 := GrepAuth(content, TierCommunitySDK, "notion")
+ if len(auths2) > 0 {
+ assert.Empty(t, auths2[0].EnvVarHint)
+ }
+ })
+
t.Run("multiple auth patterns detected", func(t *testing.T) {
t.Parallel()
@@ -1086,7 +1113,7 @@ class DualAuthClient {
}
}
`
- auths := GrepAuth(content, TierCommunitySDK)
+ auths := GrepAuth(content, TierCommunitySDK, "testapi")
// Should detect both bearer and X-Api-Key.
assert.GreaterOrEqual(t, len(auths), 2)
@@ -1108,7 +1135,7 @@ function getUser(userId) {
return this.get("/users/" + userId, params);
}
`
- auths := GrepAuth(content, TierCommunitySDK)
+ auths := GrepAuth(content, TierCommunitySDK, "testapi")
require.Len(t, auths, 1)
assert.Equal(t, "api_key", auths[0].Type)
@@ -1348,6 +1375,7 @@ class SteamAPI {
tarballServer.URL+"/tarball.tgz",
"steam-sdk",
TierCommunitySDK,
+ "testapi",
500,
)
@@ -1391,6 +1419,7 @@ class API {
tarballServer.URL+"/tarball.tgz",
"test-sdk",
TierCommunitySDK,
+ "testapi",
0,
)
diff --git a/lefthook.yml b/lefthook.yml
index 90ae45eb..5a1da209 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -13,5 +13,5 @@ pre-push:
fail_text: "gofmt failed. Run 'gofmt -w .' to fix."
lint:
glob: "*.go"
- run: golangci-lint run {push_files}
+ run: golangci-lint run ./...
fail_text: "golangci-lint failed. Fix lint errors before pushing."
← dfc20ac0 fix(skills): add mandatory publish checkpoint after archive
·
back to Cli Printing Press
·
fix(cli): four generator improvements from Redfin retro (#89 6c3c8db8 →