diff --git a/CHANGELOG.md b/CHANGELOG.md index c458e97..941dacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,10 @@ All notable changes to this project will be documented in this file. - Showed four Field Notes on the homepage (one featured plus three) so a new post no longer pushes an entry off the page. ### Fixed +- Kept production secret scanning strict without treating the public + `@google/genai` SDK's OAuth schema field name as a leaked credential. The + smoke test now requires a value-shaped `client_secret` assignment before + failing, matching the container smoke scanner. - Added the missing canonical and Open Graph URLs to the Real World Reasoning Agent page so production metadata verification recognizes `https://ryanbaumann.dev/real-world-reasoning-agent/` as its sole owner. diff --git a/LEARNINGS.md b/LEARNINGS.md index b614ad7..d7f1312 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -418,6 +418,20 @@ Evidence: Social cards were regenerated with `ryanbaumann.dev`; Lab metadata now Use next time: Treat a domain move as a dependency inventory, not a string replacement. Check generated text in images, absolute asset URLs, deployment health targets, OAuth origins, email senders, analytics, API referrers, and search ownership before changing DNS. +## 2026-07-27 - Secret scans must distinguish identifiers from values + +Context: Production smoke rejected the Real World Reasoning browser bundle +because the public Gemini SDK contains the OAuth schema identifier +`client_secret`, even though no credential value was present. +Learning: A browser secret scan should match a credential-shaped assignment, +not a field name alone. Keep the rule aligned across local and production smoke +and cover both the allowed schema identifier and a denied long value. +Evidence: `findServerSecretMarker()` accepts the SDK's +`oauth2:client_credentials` schema text and rejects an assigned long +`client_secret` fixture; the focused production-smoke test passes. +Use next time: When a dependency adds an authentication schema, inspect the +matched bytes before excluding the asset or weakening the scan. + ## 2026-07-27 - Production smoke must reach metadata gates after config gates Context: Two deploys built and routed healthy Cloud Run revisions but stopped diff --git a/scripts/lib/production-smoke.mjs b/scripts/lib/production-smoke.mjs index dd2216a..7d49081 100644 --- a/scripts/lib/production-smoke.mjs +++ b/scripts/lib/production-smoke.mjs @@ -1,3 +1,13 @@ +export const SERVER_SECRET_PATTERNS = [ + ['OAuth client secret value', /client_secret["']?\s*[:=]\s*["'][^"'\\\s]{12,}/i], + ['PEM private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----/], + ['Stripe-style live secret key', /sk_live_[0-9A-Za-z]+/], +]; + +export function findServerSecretMarker(text) { + return SERVER_SECRET_PATTERNS.find(([, pattern]) => pattern.test(text)); +} + export function expectedPublicAppNames(apps, rootAppCompatibilityName = '') { const rootApps = apps.filter((app) => app.path === '/'); if (rootAppCompatibilityName && rootApps.length !== 1) { diff --git a/scripts/smoke-production.mjs b/scripts/smoke-production.mjs index ec519eb..7434202 100644 --- a/scripts/smoke-production.mjs +++ b/scripts/smoke-production.mjs @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { expectedPublicAppNames } from './lib/production-smoke.mjs'; +import { expectedPublicAppNames, findServerSecretMarker } from './lib/production-smoke.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const apps = JSON.parse(readFileSync(resolve(REPO_ROOT, 'apps.json'), 'utf8')) @@ -142,9 +142,8 @@ async function main() { await check('served assets contain no server-secret markers', async () => { const combined = servedText.join('\n'); - for (const pattern of [/client_secret/i, /-----BEGIN [A-Z ]*PRIVATE KEY-----/, /sk_live_[0-9A-Za-z]+/]) { - if (pattern.test(combined)) throw new Error(`matched ${pattern}`); - } + const marker = findServerSecretMarker(combined); + if (marker) throw new Error(`matched ${marker[0]}`); }); await check('Strava bundle contains OAuth authorize flow', async () => { diff --git a/scripts/test/smoke-production.test.mjs b/scripts/test/smoke-production.test.mjs index e6f5a64..f94d3d0 100644 --- a/scripts/test/smoke-production.test.mjs +++ b/scripts/test/smoke-production.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { expectedPublicAppNames } from '../lib/production-smoke.mjs'; +import { expectedPublicAppNames, findServerSecretMarker } from '../lib/production-smoke.mjs'; const apps = [ { name: 'fieldwork', path: '/' }, @@ -22,3 +22,9 @@ test('production smoke rejects an ambiguous compatibility override', () => { /requires exactly one public root app/, ); }); + +test('production secret scan allows schema field names but rejects assigned secret values', () => { + assert.equal(findServerSecretMarker('type: "oauth2:client_credentials"; value.client_secret != null'), undefined); + assert.equal(findServerSecretMarker('client_secret: "fixture"'), undefined); + assert.equal(findServerSecretMarker('client_secret: "actual-secret-value-123"')?.[0], 'OAuth client secret value'); +});