diff --git a/.changeset/secret-placeholder-policy.md b/.changeset/secret-placeholder-policy.md new file mode 100644 index 000000000..cbd95489e --- /dev/null +++ b/.changeset/secret-placeholder-policy.md @@ -0,0 +1,7 @@ +--- +"@croco/auth-better-auth": patch +"create-croco-app": patch +--- + +Generated SaaS apps now use checked Croco secret/config placeholders in provider env examples and docs. +Auth Better Auth now resolves a patched Better Auth runtime dependency. diff --git a/package.json b/package.json index 254e41b55..af239d671 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "setup": "pnpm install && pnpm build && pnpm typecheck && pnpm test", "audit:prod": "pnpm audit --audit-level high --prod", "security:audit-policy": "node --experimental-strip-types scripts/dependency-audit-policy.mts", - "check": "pnpm package-manifests:check && pnpm docs:catalog:check && pnpm provider-certification:check && pnpm docs:api-triggers:check && pnpm problem-registry:check && pnpm docs:examples:check && pnpm release-docs:check && pnpm architecture-policy:check && pnpm architecture:check:circular:allowlist && pnpm dependency-boundaries:check && pnpm security-allowlists:check && pnpm strict-contract-typecheck && pnpm static-misuse:check && pnpm public-api:check && oxlint . && oxfmt --check . --ignore-path=.gitignore --ignore-path=.prettierignore --ignore-path=.oxfmtignore", + "check": "pnpm package-manifests:check && pnpm docs:catalog:check && pnpm provider-certification:check && pnpm docs:api-triggers:check && pnpm problem-registry:check && pnpm docs:examples:check && pnpm release-docs:check && pnpm architecture-policy:check && pnpm architecture:check:circular:allowlist && pnpm dependency-boundaries:check && pnpm security-allowlists:check && pnpm generated-secret-placeholders:check && pnpm strict-contract-typecheck && pnpm static-misuse:check && pnpm public-api:check && oxlint . && oxfmt --check . --ignore-path=.gitignore --ignore-path=.prettierignore --ignore-path=.oxfmtignore", "changeset": "changeset", "deploy": "turbo deploy", "dev": "turbo dev", @@ -35,6 +35,7 @@ "alpha-release:smoke": "node --experimental-strip-types scripts/alpha-release-smoke.mts", "changeset-required:check": "node --experimental-strip-types scripts/changeset-required-check.mts", "create-croco-app:smoke": "node --experimental-strip-types scripts/create-croco-app-generated-smoke.mts", + "generated-secret-placeholders:check": "node --experimental-strip-types scripts/generated-secret-placeholder-policy.mts", "docs:catalog:check": "node --experimental-strip-types scripts/package-docs-check.mts --check", "docs:catalog:write": "node --experimental-strip-types scripts/package-docs-check.mts --write", "dependency-boundaries:check": "node --experimental-strip-types scripts/package-quality-report.mts --boundary-check-only", diff --git a/packages/auth-better-auth/src/libs/BetterAuthFactory.ts b/packages/auth-better-auth/src/libs/BetterAuthFactory.ts index 6e14473b7..9ad7d7a21 100644 --- a/packages/auth-better-auth/src/libs/BetterAuthFactory.ts +++ b/packages/auth-better-auth/src/libs/BetterAuthFactory.ts @@ -6,6 +6,7 @@ import * as schema from "./schema"; export const DRIZZLE_TOKEN = "DRIZZLE_TOKEN"; type BetterAuthDatabase = Parameters[0]; +type BetterAuthInstance = ReturnType; /** * Better Auth 초기화에 필요한 설정입니다. @@ -26,8 +27,6 @@ function createBetterAuthInstance(db: BetterAuthDatabase, config: BetterAuthConf }); } -type BetterAuthInstance = ReturnType; - @Component() /** * Better Auth 인스턴스를 지연 생성하고 재사용하는 팩토리입니다. @@ -41,12 +40,13 @@ export class BetterAuthFactory { ) {} getAuth(): BetterAuthInstance { - if (this.auth) { - return this.auth; + const cachedAuth = this.auth; + if (cachedAuth) { + return cachedAuth; } - this.auth = createBetterAuthInstance(this.db, this.config); - - return this.auth; + const auth = createBetterAuthInstance(this.db, this.config); + this.auth = auth; + return auth; } } diff --git a/packages/create-croco-app/src/generator.ts b/packages/create-croco-app/src/generator.ts index 577986a6f..6f191da49 100644 --- a/packages/create-croco-app/src/generator.ts +++ b/packages/create-croco-app/src/generator.ts @@ -209,6 +209,8 @@ function writeSaasProviderProfileArtifacts(targetDir: string, options: Generator const tenantModelSchema = createTenantModelManifestSchema(); const tenantModelPlaybook = renderTenantModelPlaybook(tenantModelManifest); const providerProfileDocs = renderSaasDeployNotes(manifest); + const providerEnvExample = renderSaasEnvExample(manifest); + const providerSecretsChecklist = renderSaasSecretsChecklist(manifest); const docsDir = join(targetDir, "docs"); const apiServerSrcDir = join(targetDir, "apps", "api-server", "src"); @@ -233,15 +235,17 @@ function writeSaasProviderProfileArtifacts(targetDir: string, options: Generator join(targetDir, "croco.arch.json"), `${JSON.stringify(createArchitecturePolicyManifest(options), null, 2)}\n`, ); - writeFileSync(join(targetDir, ".env.example"), renderSaasEnvExample(manifest)); + writeFileSync(join(targetDir, ".env.example"), providerEnvExample); writeFileSync(join(docsDir, "provider-profile.md"), providerProfileDocs); writeFileSync(join(docsDir, "tenant-model-playbook.md"), tenantModelPlaybook); - writeFileSync(join(docsDir, "secrets-checklist.md"), renderSaasSecretsChecklist(manifest)); + writeFileSync(join(docsDir, "secrets-checklist.md"), providerSecretsChecklist); writeFileSync( join(apiServerSrcDir, "generatedSaasProviderProfile.ts"), [ `export const generatedSaasProviderProfileManifest = ${JSON.stringify(manifest, null, 2)} as const;`, `export const generatedSaasProviderProfileDocs = ${JSON.stringify(providerProfileDocs)} as const;`, + `export const generatedSaasProviderProfileEnvExample = ${JSON.stringify(providerEnvExample)} as const;`, + `export const generatedSaasProviderSecretsChecklist = ${JSON.stringify(providerSecretsChecklist)} as const;`, "", ].join("\n"), ); diff --git a/packages/create-croco-app/src/saas-provider-profiles.ts b/packages/create-croco-app/src/saas-provider-profiles.ts index 9ea7556bb..b0cdd5ba1 100644 --- a/packages/create-croco-app/src/saas-provider-profiles.ts +++ b/packages/create-croco-app/src/saas-provider-profiles.ts @@ -6,6 +6,11 @@ import { type TenantModelCapabilityName, type TenantModelName, } from "@croco/tenant-core/tenant-model"; +import { + renderSafeEnvExampleValue, + renderSecretPlaceholderPolicyTable, + renderSecretsChecklistPlaceholderItems, +} from "./secret-placeholder-policy.js"; export const SAAS_PROVIDER_PROFILE_CHOICES = [ "saas-node-postgres", @@ -103,6 +108,7 @@ export type SaasProviderProfileManifest = { tenantModelManifest: "croco-tenant-model.manifest.json"; tenantModelSchema: "croco-tenant-model.schema.json"; providerDocs: "docs/provider-profile.md"; + secretsChecklist: "docs/secrets-checklist.md"; tenantModelPlaybook: "docs/tenant-model-playbook.md"; envExample: ".env.example"; source: "apps/api-server/src/generatedSaasProviderProfile.ts"; @@ -491,6 +497,7 @@ export function createSaasProviderProfileManifest( tenantModelManifest: "croco-tenant-model.manifest.json", tenantModelSchema: "croco-tenant-model.schema.json", providerDocs: "docs/provider-profile.md", + secretsChecklist: "docs/secrets-checklist.md", tenantModelPlaybook: "docs/tenant-model-playbook.md", envExample: ".env.example", source: "apps/api-server/src/generatedSaasProviderProfile.ts", @@ -574,7 +581,7 @@ export function renderSaasEnvExample(manifest: SaasProviderProfileManifest): str ...[...manifest.env.required, ...manifest.env.optional].flatMap((entry) => [ "", `# ${entry.description}`, - `${entry.name}=${renderEnvExampleValue(entry, manifest.profile.name)}`, + `${entry.name}=${renderSafeEnvExampleValue(entry, manifest.profile.name)}`, ]), "", ]; @@ -582,17 +589,6 @@ export function renderSaasEnvExample(manifest: SaasProviderProfileManifest): str return lines.join("\n"); } -function renderEnvExampleValue( - entry: SaasProviderEnvVar, - profileName: SaasProviderProfileName, -): string { - if (entry.name === "SAAS_PROVIDER_PROFILE") { - return profileName; - } - - return entry.example ?? (entry.secret ? "" : `<${entry.name.toLowerCase()}>`); -} - export function renderSaasSecretsChecklist(manifest: SaasProviderProfileManifest): string { const requiredSecrets = manifest.env.required.filter((entry) => entry.secret); const nonSecretConfig = manifest.env.required.filter((entry) => !entry.secret); @@ -600,13 +596,17 @@ export function renderSaasSecretsChecklist(manifest: SaasProviderProfileManifest return [ `# ${manifest.profile.displayName} Secrets Checklist`, "", + "## Placeholder Policy", + "", + renderSecretPlaceholderPolicyTable(manifest), + "", "## Required Secrets", "", - ...requiredSecrets.map((entry) => `- [ ] ${entry.name}: ${entry.description}`), + ...renderSecretsChecklistPlaceholderItems(manifest, requiredSecrets), "", "## Required Non-Secret Config", "", - ...nonSecretConfig.map((entry) => `- [ ] ${entry.name}: ${entry.description}`), + ...renderSecretsChecklistPlaceholderItems(manifest, nonSecretConfig), "", "## Real-Provider Smoke", "", @@ -641,6 +641,12 @@ export function renderSaasDeployNotes(manifest: SaasProviderProfileManifest): st "Quality gates:", ...manifest.compatibility.qualityGates.map((gate) => `- \`${gate}\``), "", + "## Placeholder Policy", + "", + renderSecretPlaceholderPolicyTable(manifest), + "", + "Generated `.env.example`, provider docs, and secrets checklists must use these safe values. Load real provider credentials from deployment secrets only.", + "", `Runtime target: \`${manifest.profile.runtimeTarget}\``, `Tenant model: \`${tenantModel.name}\` (${tenantModel.displayName})`, "", @@ -684,7 +690,8 @@ function envVar( secret: boolean, example?: string, ): SaasProviderEnvVar { - return { name, description, requiredForRealProvider, secret, example }; + const base = { name, description, requiredForRealProvider, secret }; + return example === undefined ? base : { ...base, example }; } function capability( @@ -694,14 +701,14 @@ function capability( packageName: string | undefined, env: readonly string[], ): SaasProviderCapability { - return { + const base = { capability: capabilityName, provider, status, - packageName, env, notes: `${provider} covers ${capabilityName}.`, }; + return packageName === undefined ? base : { ...base, packageName }; } function capabilities( diff --git a/packages/create-croco-app/src/secret-placeholder-policy.ts b/packages/create-croco-app/src/secret-placeholder-policy.ts new file mode 100644 index 000000000..a412c30a1 --- /dev/null +++ b/packages/create-croco-app/src/secret-placeholder-policy.ts @@ -0,0 +1,582 @@ +export const SECRET_PLACEHOLDER_POLICY_VERSION = "croco.secret-placeholder-policy/v1"; + +export type SecretPlaceholderEnvVar = { + readonly name: string; + readonly requiredForRealProvider: boolean; + readonly secret: boolean; + readonly description?: string; + readonly example?: string; +}; + +export type SecretPlaceholderProfileManifest = { + readonly profile: { + readonly name: string; + }; + readonly env: { + readonly required: readonly SecretPlaceholderEnvVar[]; + readonly optional: readonly SecretPlaceholderEnvVar[]; + }; +}; + +export type SecretPlaceholderArtifacts = { + readonly envExample: string; + readonly providerProfileDocs: string; + readonly secretsChecklist: string; +}; + +export type SecretPlaceholderPolicyViolation = { + readonly artifact: keyof SecretPlaceholderArtifacts; + readonly code: string; + readonly message: string; + readonly line?: number; +}; + +export type GeneratedTemplateSecretAllowlistEntry = { + readonly pathPattern: string; + readonly matchPattern: string; + readonly owner: string; + readonly reason: string; + readonly expiresOn?: string; + readonly reviewBy?: string; +}; + +export type GeneratedTemplateSecretMetadataViolation = { + readonly message: string; + readonly recovery: string; +}; + +export type GeneratedTemplateSecretFinding = { + readonly filePath: string; + readonly line: number; + readonly match: string; + readonly patternId: string; +}; + +export class SecretPlaceholderPolicyError extends Error { + readonly code = "CROCO_SAAS_PROFILE_SECRET_PLACEHOLDER_POLICY_FAILED"; + readonly violations: readonly SecretPlaceholderPolicyViolation[]; + + constructor(violations: readonly SecretPlaceholderPolicyViolation[]) { + super(formatSecretPlaceholderPolicyViolationMessage(violations)); + this.name = "SecretPlaceholderPolicyError"; + this.violations = violations; + } +} + +type CredentialPattern = { + readonly id: string; + readonly pattern: RegExp; +}; + +const sensitiveEnvAssignmentNames = [ + "BETTER_AUTH_SECRET", + "CLERK_SECRET_KEY", + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_API_TOKEN", + "CLOUDINARY_URL", + "DATABASE_URL", + "POLAR_ACCESS_TOKEN", + "POLAR_WEBHOOK_SECRET", + "UPSTASH_QSTASH_CURRENT_SIGNING_KEY", + "UPSTASH_QSTASH_NEXT_SIGNING_KEY", + "UPSTASH_QSTASH_TOKEN", + "UPSTASH_REDIS_REST_TOKEN", + "UPSTASH_REDIS_REST_URL", +] as const; + +const credentialPatterns: readonly CredentialPattern[] = [ + { id: "aws-access-key-id", pattern: /AKIA[0-9A-Z]{16}/g }, + { id: "aws-temporary-access-key-id", pattern: /ASIA[0-9A-Z]{16}/g }, + { id: "github-token", pattern: /gh[pousr]_[A-Za-z0-9_]{30,}/g }, + { id: "openai-api-key", pattern: /sk-[A-Za-z0-9]{32,}/g }, + { id: "anthropic-api-key", pattern: /sk-ant-[A-Za-z0-9_-]{32,}/g }, + { id: "slack-token", pattern: /xox[baprs]-[A-Za-z0-9-]{20,}/g }, + { + id: "secret-env-assignment", + pattern: new RegExp( + `\\b(?:${sensitiveEnvAssignmentNames.join("|")})\\s*=\\s*(["']?)(?!|$)([^\\s"']{12,})\\1`, + "g", + ), + }, +]; + +export function renderSecretPlaceholder(entry: Pick): string { + return ``; +} + +export function renderConfigPlaceholder(entry: Pick): string { + return ``; +} + +export function renderSafeEnvExampleValue( + entry: SecretPlaceholderEnvVar, + profileName: string, +): string { + if (entry.name === "SAAS_PROVIDER_PROFILE") { + return profileName; + } + + if (entry.example === "true" || entry.example === "false") { + return entry.example; + } + + return entry.secret ? renderSecretPlaceholder(entry) : renderConfigPlaceholder(entry); +} + +export function renderSecretPlaceholderPolicyTable( + manifest: SecretPlaceholderProfileManifest, +): string { + return [ + `Policy version: \`${SECRET_PLACEHOLDER_POLICY_VERSION}\``, + "", + "| Env | Safe value | Kind |", + "| --- | --- | --- |", + ...allManifestEnv(manifest).map((entry) => { + const kind = entry.secret ? "secret" : "config"; + const value = renderSafeEnvExampleValue(entry, manifest.profile.name); + + return `| \`${entry.name}\` | \`${value}\` | ${kind} |`; + }), + ].join("\n"); +} + +export function renderSecretsChecklistPlaceholderItems( + manifest: SecretPlaceholderProfileManifest, + entries: readonly SecretPlaceholderEnvVar[], +): readonly string[] { + return entries.map((entry) => { + const value = renderSafeEnvExampleValue(entry, manifest.profile.name); + const description = entry.description ? ` - ${entry.description}` : ""; + + return `- [ ] \`${entry.name}\` = \`${value}\`${description}`; + }); +} + +export function validateSaasProviderSecretPlaceholderPolicy( + manifest: SecretPlaceholderProfileManifest, + artifacts: SecretPlaceholderArtifacts, +): readonly SecretPlaceholderPolicyViolation[] { + const violations: SecretPlaceholderPolicyViolation[] = []; + const envAssignments = parseEnvAssignments(artifacts.envExample); + + for (const entry of allManifestEnv(manifest)) { + const expectedValue = renderSafeEnvExampleValue(entry, manifest.profile.name); + const assignment = envAssignments.get(entry.name); + + if (!assignment) { + violations.push({ + artifact: "envExample", + code: "CROCO_SECRET_PLACEHOLDER_ENV_MISSING", + message: `${entry.name} is missing from .env.example`, + }); + continue; + } + + if (assignment.value !== expectedValue) { + violations.push({ + artifact: "envExample", + code: "CROCO_SECRET_PLACEHOLDER_ENV_UNSAFE", + line: assignment.line, + message: `${entry.name} must use ${expectedValue}, not ${assignment.value || ""}`, + }); + } + } + + for (const entry of allManifestEnv(manifest)) { + const expectedValue = renderSafeEnvExampleValue(entry, manifest.profile.name); + if (!docContainsEntryValue(artifacts.providerProfileDocs, entry.name, expectedValue)) { + violations.push({ + artifact: "providerProfileDocs", + code: "CROCO_SECRET_PLACEHOLDER_PROVIDER_DOCS_MISSING", + message: `docs/provider-profile.md must document ${entry.name} with ${expectedValue}`, + }); + } + if (!docContainsEntryValue(artifacts.secretsChecklist, entry.name, expectedValue)) { + violations.push({ + artifact: "secretsChecklist", + code: "CROCO_SECRET_PLACEHOLDER_CHECKLIST_MISSING", + message: `docs/secrets-checklist.md must document ${entry.name} with ${expectedValue}`, + }); + } + } + + violations.push( + ...scanArtifact("envExample", ".env.example", artifacts.envExample), + ...scanArtifact( + "providerProfileDocs", + "docs/provider-profile.md", + artifacts.providerProfileDocs, + ), + ...scanArtifact("secretsChecklist", "docs/secrets-checklist.md", artifacts.secretsChecklist), + ); + + return violations; +} + +function docContainsEntryValue(doc: string, name: string, value: string): boolean { + return doc + .split(/\r?\n/) + .some((line) => line.includes(`\`${name}\``) && line.includes(`\`${value}\``)); +} + +export function assertSaasProviderSecretPlaceholderPolicy( + manifest: SecretPlaceholderProfileManifest, + artifacts: SecretPlaceholderArtifacts, +): void { + const violations = validateSaasProviderSecretPlaceholderPolicy(manifest, artifacts); + + if (violations.length === 0) { + return; + } + + throw new SecretPlaceholderPolicyError(violations); +} + +function formatSecretPlaceholderPolicyViolationMessage( + violations: readonly SecretPlaceholderPolicyViolation[], +): string { + return [ + "CROCO_SAAS_PROFILE_SECRET_PLACEHOLDER_POLICY_FAILED: generated provider artifacts contain unsafe secret placeholders", + ...violations.map((violation) => { + const line = violation.line === undefined ? "" : `:${violation.line}`; + return `- ${violation.code} ${violation.artifact}${line}: ${violation.message}`; + }), + ].join("\n"); +} + +export function scanGeneratedTemplateSecretText( + filePath: string, + text: string, + allowlists: readonly GeneratedTemplateSecretAllowlistEntry[] = [], +): readonly GeneratedTemplateSecretFinding[] { + const findings: GeneratedTemplateSecretFinding[] = []; + + for (const credentialPattern of credentialPatterns) { + credentialPattern.pattern.lastIndex = 0; + for (const match of text.matchAll(credentialPattern.pattern)) { + const matchedText = match[0]; + const index = match.index ?? 0; + const finding = { + filePath, + line: lineNumberAt(text, index), + match: matchedText, + patternId: credentialPattern.id, + }; + + if (!isFindingAllowlisted(finding, allowlists)) { + findings.push(finding); + } + } + } + + return findings; +} + +export function readGeneratedTemplateSecretAllowlistsFromMetadata( + metadata: unknown, + today: string, +): { + readonly allowlists: readonly GeneratedTemplateSecretAllowlistEntry[]; + readonly violations: readonly GeneratedTemplateSecretMetadataViolation[]; +} { + const violations: GeneratedTemplateSecretMetadataViolation[] = []; + const root = isRecord(metadata) ? metadata : {}; + const secretScan = isRecord(root["secretScan"]) ? root["secretScan"] : {}; + const generatedTemplates = isRecord(secretScan["generatedTemplates"]) + ? secretScan["generatedTemplates"] + : {}; + const rawAllowlists = generatedTemplates["allowlists"]; + + if (rawAllowlists === undefined) { + return { allowlists: [], violations }; + } + + if (!Array.isArray(rawAllowlists)) { + return { + allowlists: [], + violations: [ + { + message: "secretScan.generatedTemplates.allowlists must be an array", + recovery: + "Set secretScan.generatedTemplates.allowlists to an array of reviewed exceptions.", + }, + ], + }; + } + + const allowlists = rawAllowlists.flatMap((entry, index) => + readGeneratedTemplateSecretAllowlistEntry( + isRecord(entry) ? entry : {}, + `secretScan.generatedTemplates.allowlists[${index}]`, + today, + violations, + ), + ); + + return { allowlists, violations }; +} + +function readGeneratedTemplateSecretAllowlistEntry( + entry: Record, + pointer: string, + today: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): readonly GeneratedTemplateSecretAllowlistEntry[] { + const pathPattern = readRequiredMetadataString(entry, "pathPattern", pointer, violations); + const matchPattern = readRequiredMetadataString(entry, "matchPattern", pointer, violations); + const owner = readRequiredMetadataString(entry, "owner", pointer, violations); + const reason = readRequiredMetadataString(entry, "reason", pointer, violations); + const reviewDate = readReviewDate(entry, pointer, today, violations); + + validateRegex(pathPattern, `${pointer}.pathPattern`, violations); + validateRegex(matchPattern, `${pointer}.matchPattern`, violations); + validateGeneratedTemplateAllowlistPatternScope(pathPattern, matchPattern, pointer, violations); + + if (!pathPattern || !matchPattern || !owner || !reason) { + return []; + } + + return [ + { + pathPattern, + matchPattern, + owner, + reason, + ...reviewDate, + }, + ]; +} + +function scanArtifact( + artifact: keyof SecretPlaceholderArtifacts, + filePath: string, + text: string, +): readonly SecretPlaceholderPolicyViolation[] { + return scanGeneratedTemplateSecretText(filePath, text).map((finding) => ({ + artifact, + code: "CROCO_SECRET_PLACEHOLDER_REAL_LOOKING_CREDENTIAL", + line: finding.line, + message: `${filePath} contains ${finding.patternId} shaped value ${finding.match}`, + })); +} + +function allManifestEnv( + manifest: SecretPlaceholderProfileManifest, +): readonly SecretPlaceholderEnvVar[] { + return [...manifest.env.required, ...manifest.env.optional]; +} + +function parseEnvAssignments( + content: string, +): Map { + const assignments = new Map(); + const lines = content.replace(/\r\n/g, "\n").split("\n"); + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return; + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex <= 0) return; + assignments.set(trimmed.slice(0, separatorIndex), { + line: index + 1, + value: trimmed.slice(separatorIndex + 1), + }); + }); + + return assignments; +} + +function isFindingAllowlisted( + finding: GeneratedTemplateSecretFinding, + allowlists: readonly GeneratedTemplateSecretAllowlistEntry[], +): boolean { + return allowlists.some((entry) => { + const pathPattern = safeRegExp(entry.pathPattern); + const matchPattern = safeRegExp(entry.matchPattern); + + return ( + pathPattern?.test(finding.filePath) === true && matchPattern?.test(finding.match) === true + ); + }); +} + +function lineNumberAt(text: string, index: number): number { + return text.slice(0, index).split("\n").length; +} + +function readRequiredMetadataString( + entry: Record, + field: string, + pointer: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): string { + const value = entry[field]; + + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + + violations.push({ + message: `${pointer}.${field} must be a non-empty string`, + recovery: `Add a concrete ${field} value to ${pointer}.`, + }); + return ""; +} + +function readReviewDate( + entry: Record, + pointer: string, + today: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): { readonly expiresOn?: string; readonly reviewBy?: string } { + const reviewBy = typeof entry["reviewBy"] === "string" ? entry["reviewBy"].trim() : ""; + const expiresOn = typeof entry["expiresOn"] === "string" ? entry["expiresOn"].trim() : ""; + + if (!reviewBy && !expiresOn) { + violations.push({ + message: `${pointer} must include reviewBy or expiresOn`, + recovery: `Add a YYYY-MM-DD reviewBy or expiresOn date to ${pointer}.`, + }); + return {}; + } + + if (reviewBy) { + validateReviewDateField(pointer, "reviewBy", reviewBy, today, violations); + } + + if (expiresOn) { + validateReviewDateField(pointer, "expiresOn", expiresOn, today, violations); + } + + return { + ...(expiresOn ? { expiresOn } : {}), + ...(reviewBy ? { reviewBy } : {}), + }; +} + +function validateReviewDateField( + pointer: string, + field: "expiresOn" | "reviewBy", + value: string, + today: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): void { + if (!isValidCalendarDate(value)) { + violations.push({ + message: `${pointer}.${field} must be a valid YYYY-MM-DD date`, + recovery: `Replace ${pointer}.${field} with a valid calendar date.`, + }); + return; + } + + if (value < today) { + violations.push({ + message: `${pointer}.${field} is stale (${value} is before ${today})`, + recovery: `Review the exception and update ${pointer}.${field}, or remove the allowlist entry.`, + }); + } +} + +function isValidCalendarDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return false; + } + + const date = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +function validateRegex( + pattern: string, + pointer: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): void { + if (!pattern) return; + if (safeRegExp(pattern)) return; + + violations.push({ + message: `${pointer} must be a valid and bounded regular expression`, + recovery: `Fix ${pointer} so it compiles and avoids broad or backtracking-heavy constructs.`, + }); +} + +function validateGeneratedTemplateAllowlistPatternScope( + pathPattern: string, + matchPattern: string, + pointer: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): void { + if (pathPattern && !pathPattern.includes("templates/") && !pathPattern.includes("templates\\/")) { + violations.push({ + message: `${pointer}.pathPattern must target generated templates`, + recovery: `Replace ${pointer}.pathPattern with a generated template path pattern such as ^packages/create-croco-app/templates/.`, + }); + } + + if (isCatchAllRegexPattern(pathPattern)) { + violations.push({ + message: `${pointer}.pathPattern must not be a catch-all regular expression`, + recovery: `Narrow ${pointer}.pathPattern to the specific generated template file that needs an exception.`, + }); + } + + if (isCatchAllRegexPattern(matchPattern)) { + violations.push({ + message: `${pointer}.matchPattern must not be a catch-all regular expression`, + recovery: `Narrow ${pointer}.matchPattern to the exact fixture value or assignment prefix that needs an exception.`, + }); + } +} + +function isCatchAllRegexPattern(pattern: string): boolean { + const compactPattern = pattern.replace(/\s/g, ""); + if ( + [ + ".*", + "^.*", + ".*$", + "^.*$", + ".+", + "^.+", + ".+$", + "^.+$", + "[\\s\\S]*", + "^[\\s\\S]*", + "[\\s\\S]*$", + "^[\\s\\S]*$", + ].includes(compactPattern) + ) { + return true; + } + + return [".*", ".+", "[\\s\\S]*"].some((wildcard) => compactPattern.includes(wildcard)); +} + +function safeRegExp(pattern: string): RegExp | null { + if (!isGeneratedTemplateAllowlistRegexSafe(pattern)) { + return null; + } + + try { + return new RegExp(pattern); + } catch { + return null; + } +} + +function isGeneratedTemplateAllowlistRegexSafe(pattern: string): boolean { + if (pattern.length > 256) { + return false; + } + + const compactPattern = pattern.replace(/\s/g, ""); + if (/\\[1-9]/.test(compactPattern)) { + return false; + } + + return !/\((?:\?:)?[^)]*[+*][^)]*\)(?:[+*]|\{\d*,?\d*\})/.test(compactPattern); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/create-croco-app/src/tests/e2e-generation.spec.ts b/packages/create-croco-app/src/tests/e2e-generation.spec.ts index 559172a5b..4cedef84b 100644 --- a/packages/create-croco-app/src/tests/e2e-generation.spec.ts +++ b/packages/create-croco-app/src/tests/e2e-generation.spec.ts @@ -1182,6 +1182,7 @@ describe("E2E: generate()", () => { ); const envExample = readFileSync(join(testDir, ".env.example"), "utf8"); const providerProfileDocs = readFileSync(join(testDir, "docs", "provider-profile.md"), "utf8"); + const secretsChecklist = readFileSync(join(testDir, "docs", "secrets-checklist.md"), "utf8"); const tenantModelPlaybook = readFileSync( join(testDir, "docs", "tenant-model-playbook.md"), "utf8", @@ -1320,6 +1321,7 @@ describe("E2E: generate()", () => { "envExample": ".env.example", "manifest": "croco-saas-profile.manifest.json", "providerDocs": "docs/provider-profile.md", + "secretsChecklist": "docs/secrets-checklist.md", "source": "apps/api-server/src/generatedSaasProviderProfile.ts", "tenantModelManifest": "croco-tenant-model.manifest.json", "tenantModelPlaybook": "docs/tenant-model-playbook.md", @@ -2029,10 +2031,15 @@ describe("E2E: generate()", () => { "webhookVerification", ]); expect(envExample).toContain("SAAS_PROVIDER_PROFILE=saas-cloudflare"); - expect(envExample).toContain("CLOUDFLARE_ACCOUNT_ID="); + expect(envExample).toContain("CLOUDFLARE_ACCOUNT_ID="); + expect(envExample).toContain("R2_BUCKET="); expect(providerProfileDocs).toContain("Capability Matrix"); expect(providerProfileDocs).toContain("Manifest Contract"); expect(providerProfileDocs).toContain("Schema version: `croco.saas-provider-profile/v1`"); + expect(providerProfileDocs).toContain("Policy version: `croco.secret-placeholder-policy/v1`"); + expect(providerProfileDocs).toContain("``"); + expect(secretsChecklist).toContain("``"); + expect(secretsChecklist).toContain("``"); expect(providerProfileDocs).toContain("Tenant model: `workspace`"); expect(providerProfileDocs).toContain("QStash"); expect(tenantModelPlaybook).toContain("Current model: `workspace`"); @@ -2042,12 +2049,18 @@ describe("E2E: generate()", () => { expect(tenantModelPlaybook).toContain("tenant-core/tenant-model-runtime-incompatible"); expect(generatedProfileSource).toContain("saas-cloudflare"); expect(generatedProfileSource).toContain("generatedSaasProviderProfileDocs"); + expect(generatedProfileSource).toContain("generatedSaasProviderProfileEnvExample"); + expect(generatedProfileSource).toContain("generatedSaasProviderSecretsChecklist"); expect(generatedProfileSource).toContain(JSON.stringify(providerProfileDocs)); + expect(generatedProfileSource).toContain(JSON.stringify(envExample)); + expect(generatedProfileSource).toContain(JSON.stringify(secretsChecklist)); const providerProfileCheckSource = readFileSync( join(testDir, "apps", "api-server", "src", "provider-profile-check.ts"), "utf8", ); expect(providerProfileCheckSource).toContain("CROCO_SAAS_PROFILE_VERSION_UNSUPPORTED"); + expect(providerProfileCheckSource).toContain("CROCO_SAAS_PROFILE_ENV_EXAMPLE_DRIFT"); + expect(providerProfileCheckSource).toContain("CROCO_SAAS_PROFILE_SECRETS_CHECKLIST_DRIFT"); expect(providerProfileCheckSource).toContain("CROCO_TENANT_MODEL_VERSION_UNSUPPORTED"); expect(generatedTenantModelSource).toContain("generatedTenantModelManifest"); expect(generatedTenantModelSource).toContain('"workspace"'); @@ -2058,6 +2071,18 @@ describe("E2E: generate()", () => { (source) => `${source}\n`, "CROCO_SAAS_PROFILE_DOCS_DRIFT", ); + expectGeneratedProfileCheckFailureAfterWrite( + testDir, + ".env.example", + (source) => `${source}\n`, + "CROCO_SAAS_PROFILE_ENV_EXAMPLE_DRIFT", + ); + expectGeneratedProfileCheckFailureAfterWrite( + testDir, + "docs/secrets-checklist.md", + (source) => `${source}\n`, + "CROCO_SAAS_PROFILE_SECRETS_CHECKLIST_DRIFT", + ); expectGeneratedProfileCheckFailureAfterWrite( testDir, "croco-saas-profile.manifest.json", diff --git a/packages/create-croco-app/src/tests/secret-placeholder-policy.spec.ts b/packages/create-croco-app/src/tests/secret-placeholder-policy.spec.ts new file mode 100644 index 000000000..37a51dca8 --- /dev/null +++ b/packages/create-croco-app/src/tests/secret-placeholder-policy.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + readGeneratedTemplateSecretAllowlistsFromMetadata, + validateSaasProviderSecretPlaceholderPolicy, + type SecretPlaceholderArtifacts, + type SecretPlaceholderProfileManifest, +} from "../secret-placeholder-policy"; + +describe("secret placeholder policy", () => { + it("requires boolean env documentation to pair each env name with its rendered value", () => { + const manifest: SecretPlaceholderProfileManifest = { + profile: { name: "saas-test" }, + env: { + required: [ + { + name: "FEATURE_ALPHA_ENABLED", + requiredForRealProvider: true, + secret: false, + example: "true", + }, + { + name: "FEATURE_BETA_ENABLED", + requiredForRealProvider: true, + secret: false, + example: "true", + }, + ], + optional: [], + }, + }; + const artifacts: SecretPlaceholderArtifacts = { + envExample: ["FEATURE_ALPHA_ENABLED=true", "FEATURE_BETA_ENABLED=true"].join("\n"), + providerProfileDocs: [ + "| Env | Safe value | Kind |", + "| --- | --- | --- |", + "| `FEATURE_ALPHA_ENABLED` | `true` | config |", + "`FEATURE_BETA_ENABLED` is listed without its safe value.", + ].join("\n"), + secretsChecklist: [ + "- [ ] `FEATURE_ALPHA_ENABLED` = `true`", + "- [ ] `FEATURE_BETA_ENABLED` is listed without its safe value.", + ].join("\n"), + }; + + const violations = validateSaasProviderSecretPlaceholderPolicy(manifest, artifacts); + + expect(violations).toEqual([ + expect.objectContaining({ + artifact: "providerProfileDocs", + code: "CROCO_SECRET_PLACEHOLDER_PROVIDER_DOCS_MISSING", + message: "docs/provider-profile.md must document FEATURE_BETA_ENABLED with true", + }), + expect.objectContaining({ + artifact: "secretsChecklist", + code: "CROCO_SECRET_PLACEHOLDER_CHECKLIST_MISSING", + message: "docs/secrets-checklist.md must document FEATURE_BETA_ENABLED with true", + }), + ]); + }); + + it("rejects wildcard-heavy and backtracking-heavy generated-template allowlist regexes", () => { + const result = readGeneratedTemplateSecretAllowlistsFromMetadata( + { + secretScan: { + generatedTemplates: { + allowlists: [ + { + pathPattern: ".*templates/.*fixture.*", + matchPattern: "^POLAR_ACCESS_TOKEN=", + owner: "security", + reason: "Fixture for broad generated-template path allowlist rejection.", + reviewBy: "2027-01-31", + }, + { + pathPattern: "^templates/fixture\\.env$", + matchPattern: "^(a+)+$", + owner: "security", + reason: "Fixture for nested quantifier rejection.", + reviewBy: "2027-01-31", + }, + ], + }, + }, + }, + "2026-07-03", + ); + + expect(result.violations).toEqual([ + expect.objectContaining({ + message: + "secretScan.generatedTemplates.allowlists[0].pathPattern must not be a catch-all regular expression", + }), + expect.objectContaining({ + message: + "secretScan.generatedTemplates.allowlists[1].matchPattern must be a valid and bounded regular expression", + }), + ]); + }); +}); diff --git a/packages/create-croco-app/src/tests/templates-build.spec.ts b/packages/create-croco-app/src/tests/templates-build.spec.ts index ba00cd9bb..78e3085f4 100644 --- a/packages/create-croco-app/src/tests/templates-build.spec.ts +++ b/packages/create-croco-app/src/tests/templates-build.spec.ts @@ -946,6 +946,22 @@ describe("Base preset README templates", () => { }); }); +describe("Generated secret placeholder helper", () => { + it("matches the canonical create-croco-app helper", () => { + const testsDir = dirname(fileURLToPath(import.meta.url)); + const canonicalHelper = readFileSync( + join(testsDir, "..", "secret-placeholder-policy.ts"), + "utf-8", + ); + const generatedHelper = readFileSync( + templatePath("saas", "apps", "api-server", "src", "secret-placeholder-policy.ts"), + "utf-8", + ); + + expect(generatedHelper).toBe(canonicalHelper); + }); +}); + describe.each(["spa-be-split", "saas", "ai-saas", "admin-console"])( "Shipped template: %s", (template) => { diff --git a/packages/create-croco-app/templates/saas/README.md.hbs b/packages/create-croco-app/templates/saas/README.md.hbs index 5ad8db6be..f69f7cbc9 100644 --- a/packages/create-croco-app/templates/saas/README.md.hbs +++ b/packages/create-croco-app/templates/saas/README.md.hbs @@ -141,15 +141,15 @@ pnpm add @croco/metering-upstash @croco/ratelimit-upstash @croco/tasks-qstash @c ``` ```bash -DATABASE_URL=postgres://... -POLAR_ACCESS_TOKEN=polar_... -POLAR_WEBHOOK_SECRET=whsec_... -POLAR_PRODUCT_ID_TEAM=... -UPSTASH_REDIS_REST_URL=https://... -UPSTASH_REDIS_REST_TOKEN=... -UPSTASH_QSTASH_TOKEN=... -UPSTASH_QSTASH_CURRENT_SIGNING_KEY=... -UPSTASH_QSTASH_NEXT_SIGNING_KEY=... +DATABASE_URL= +POLAR_ACCESS_TOKEN= +POLAR_WEBHOOK_SECRET= +POLAR_PRODUCT_ID_TEAM= +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +UPSTASH_QSTASH_TOKEN= +UPSTASH_QSTASH_CURRENT_SIGNING_KEY= +UPSTASH_QSTASH_NEXT_SIGNING_KEY= SAAS_PROVIDER_PROFILE=drizzle-polar-upstash ``` diff --git a/packages/create-croco-app/templates/saas/apps/api-server/src/provider-profile-check.ts b/packages/create-croco-app/templates/saas/apps/api-server/src/provider-profile-check.ts index 751e73f17..50510e436 100644 --- a/packages/create-croco-app/templates/saas/apps/api-server/src/provider-profile-check.ts +++ b/packages/create-croco-app/templates/saas/apps/api-server/src/provider-profile-check.ts @@ -1,9 +1,12 @@ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { validateTenantModelCompatibility } from "@croco/tenant-core/tenant-model"; +import { assertSaasProviderSecretPlaceholderPolicy } from "./secret-placeholder-policy"; import { generatedSaasProviderProfileDocs, + generatedSaasProviderProfileEnvExample, generatedSaasProviderProfileManifest, + generatedSaasProviderSecretsChecklist, } from "./generatedSaasProviderProfile"; import { generatedTenantModelManifest, @@ -17,6 +20,8 @@ type TenantModelManifestSchema = typeof generatedTenantModelManifestSchema; const PROFILE_MANIFEST_FILE = "croco-saas-profile.manifest.json"; const PROFILE_DOCS_FILE = "docs/provider-profile.md"; +const PROFILE_ENV_EXAMPLE_FILE = ".env.example"; +const PROFILE_SECRETS_CHECKLIST_FILE = "docs/secrets-checklist.md"; const TENANT_MODEL_MANIFEST_FILE = "croco-tenant-model.manifest.json"; const TENANT_MODEL_SCHEMA_FILE = "croco-tenant-model.schema.json"; const TENANT_MODEL_PLAYBOOK_FILE = "docs/tenant-model-playbook.md"; @@ -27,11 +32,18 @@ function main(): void { const mode = readMode(process.argv.slice(2)); const manifest = readProfileManifest(); const providerProfileDocs = readProviderProfileDocs(); + const envExample = readProviderEnvExample(); + const secretsChecklist = readProviderSecretsChecklist(); const tenantModelManifest = readTenantModelManifest(); const tenantModelSchema = readTenantModelSchema(); const tenantModelPlaybook = readTenantModelPlaybook(); - assertManifestMatchesGeneratedSource(manifest, providerProfileDocs); + assertManifestMatchesGeneratedSource(manifest, providerProfileDocs, envExample, secretsChecklist); + assertSaasProviderSecretPlaceholderPolicy(manifest, { + envExample, + providerProfileDocs, + secretsChecklist, + }); assertTenantModelArtifactsMatchGeneratedSource( tenantModelManifest, tenantModelSchema, @@ -85,6 +97,24 @@ function readProviderProfileDocs(): string { return readFileSync(docsPath, "utf8"); } +function readProviderEnvExample(): string { + const envPath = findRootArtifactPath( + PROFILE_ENV_EXAMPLE_FILE, + "CROCO_SAAS_PROFILE_ENV_EXAMPLE_MISSING", + ); + + return readFileSync(envPath, "utf8"); +} + +function readProviderSecretsChecklist(): string { + const checklistPath = findRootArtifactPath( + PROFILE_SECRETS_CHECKLIST_FILE, + "CROCO_SAAS_PROFILE_SECRETS_CHECKLIST_MISSING", + ); + + return readFileSync(checklistPath, "utf8"); +} + function readTenantModelManifest(): TenantModelManifest { const manifestPath = findRootArtifactPath( TENANT_MODEL_MANIFEST_FILE, @@ -141,6 +171,8 @@ function findRootArtifactPath(file: string, diagnosticCode: string): string { function assertManifestMatchesGeneratedSource( manifest: ProfileManifest, providerProfileDocs: string, + envExample: string, + secretsChecklist: string, ): void { if (JSON.stringify(manifest) !== JSON.stringify(generatedSaasProviderProfileManifest)) { throw new Error( @@ -153,6 +185,18 @@ function assertManifestMatchesGeneratedSource( "CROCO_SAAS_PROFILE_DOCS_DRIFT: docs/provider-profile.md differs from generatedSaasProviderProfile.ts", ); } + + if (envExample !== generatedSaasProviderProfileEnvExample) { + throw new Error( + "CROCO_SAAS_PROFILE_ENV_EXAMPLE_DRIFT: .env.example differs from generatedSaasProviderProfile.ts", + ); + } + + if (secretsChecklist !== generatedSaasProviderSecretsChecklist) { + throw new Error( + "CROCO_SAAS_PROFILE_SECRETS_CHECKLIST_DRIFT: docs/secrets-checklist.md differs from generatedSaasProviderProfile.ts", + ); + } } function assertTenantModelArtifactsMatchGeneratedSource( @@ -354,7 +398,8 @@ function isProfileManifest(value: unknown): value is ProfileManifest { value.tenantModel.requiredPackages.every((packageName) => typeof packageName === "string") && value.tenantModel.requiredAdapters.every((adapter) => typeof adapter === "string") && value.tenantModel.requiredCapabilities.every((capability) => typeof capability === "string") && - value.env.required.every(isEnvVar) + value.env.required.every(isEnvVar) && + value.env.optional.every(isEnvVar) ); } @@ -399,7 +444,14 @@ function isTenantModelSchema(value: unknown): value is TenantModelManifestSchema } function isEnvVar(value: unknown): value is ProfileManifest["env"]["required"][number] { - return isRecord(value) && typeof value.name === "string"; + return ( + isRecord(value) && + typeof value.name === "string" && + typeof value.requiredForRealProvider === "boolean" && + typeof value.secret === "boolean" && + (value.description === undefined || typeof value.description === "string") && + (value.example === undefined || typeof value.example === "string") + ); } function readSchemaVersion(value: unknown): string | null { diff --git a/packages/create-croco-app/templates/saas/apps/api-server/src/secret-placeholder-policy.ts b/packages/create-croco-app/templates/saas/apps/api-server/src/secret-placeholder-policy.ts new file mode 100644 index 000000000..a412c30a1 --- /dev/null +++ b/packages/create-croco-app/templates/saas/apps/api-server/src/secret-placeholder-policy.ts @@ -0,0 +1,582 @@ +export const SECRET_PLACEHOLDER_POLICY_VERSION = "croco.secret-placeholder-policy/v1"; + +export type SecretPlaceholderEnvVar = { + readonly name: string; + readonly requiredForRealProvider: boolean; + readonly secret: boolean; + readonly description?: string; + readonly example?: string; +}; + +export type SecretPlaceholderProfileManifest = { + readonly profile: { + readonly name: string; + }; + readonly env: { + readonly required: readonly SecretPlaceholderEnvVar[]; + readonly optional: readonly SecretPlaceholderEnvVar[]; + }; +}; + +export type SecretPlaceholderArtifacts = { + readonly envExample: string; + readonly providerProfileDocs: string; + readonly secretsChecklist: string; +}; + +export type SecretPlaceholderPolicyViolation = { + readonly artifact: keyof SecretPlaceholderArtifacts; + readonly code: string; + readonly message: string; + readonly line?: number; +}; + +export type GeneratedTemplateSecretAllowlistEntry = { + readonly pathPattern: string; + readonly matchPattern: string; + readonly owner: string; + readonly reason: string; + readonly expiresOn?: string; + readonly reviewBy?: string; +}; + +export type GeneratedTemplateSecretMetadataViolation = { + readonly message: string; + readonly recovery: string; +}; + +export type GeneratedTemplateSecretFinding = { + readonly filePath: string; + readonly line: number; + readonly match: string; + readonly patternId: string; +}; + +export class SecretPlaceholderPolicyError extends Error { + readonly code = "CROCO_SAAS_PROFILE_SECRET_PLACEHOLDER_POLICY_FAILED"; + readonly violations: readonly SecretPlaceholderPolicyViolation[]; + + constructor(violations: readonly SecretPlaceholderPolicyViolation[]) { + super(formatSecretPlaceholderPolicyViolationMessage(violations)); + this.name = "SecretPlaceholderPolicyError"; + this.violations = violations; + } +} + +type CredentialPattern = { + readonly id: string; + readonly pattern: RegExp; +}; + +const sensitiveEnvAssignmentNames = [ + "BETTER_AUTH_SECRET", + "CLERK_SECRET_KEY", + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_API_TOKEN", + "CLOUDINARY_URL", + "DATABASE_URL", + "POLAR_ACCESS_TOKEN", + "POLAR_WEBHOOK_SECRET", + "UPSTASH_QSTASH_CURRENT_SIGNING_KEY", + "UPSTASH_QSTASH_NEXT_SIGNING_KEY", + "UPSTASH_QSTASH_TOKEN", + "UPSTASH_REDIS_REST_TOKEN", + "UPSTASH_REDIS_REST_URL", +] as const; + +const credentialPatterns: readonly CredentialPattern[] = [ + { id: "aws-access-key-id", pattern: /AKIA[0-9A-Z]{16}/g }, + { id: "aws-temporary-access-key-id", pattern: /ASIA[0-9A-Z]{16}/g }, + { id: "github-token", pattern: /gh[pousr]_[A-Za-z0-9_]{30,}/g }, + { id: "openai-api-key", pattern: /sk-[A-Za-z0-9]{32,}/g }, + { id: "anthropic-api-key", pattern: /sk-ant-[A-Za-z0-9_-]{32,}/g }, + { id: "slack-token", pattern: /xox[baprs]-[A-Za-z0-9-]{20,}/g }, + { + id: "secret-env-assignment", + pattern: new RegExp( + `\\b(?:${sensitiveEnvAssignmentNames.join("|")})\\s*=\\s*(["']?)(?!|$)([^\\s"']{12,})\\1`, + "g", + ), + }, +]; + +export function renderSecretPlaceholder(entry: Pick): string { + return ``; +} + +export function renderConfigPlaceholder(entry: Pick): string { + return ``; +} + +export function renderSafeEnvExampleValue( + entry: SecretPlaceholderEnvVar, + profileName: string, +): string { + if (entry.name === "SAAS_PROVIDER_PROFILE") { + return profileName; + } + + if (entry.example === "true" || entry.example === "false") { + return entry.example; + } + + return entry.secret ? renderSecretPlaceholder(entry) : renderConfigPlaceholder(entry); +} + +export function renderSecretPlaceholderPolicyTable( + manifest: SecretPlaceholderProfileManifest, +): string { + return [ + `Policy version: \`${SECRET_PLACEHOLDER_POLICY_VERSION}\``, + "", + "| Env | Safe value | Kind |", + "| --- | --- | --- |", + ...allManifestEnv(manifest).map((entry) => { + const kind = entry.secret ? "secret" : "config"; + const value = renderSafeEnvExampleValue(entry, manifest.profile.name); + + return `| \`${entry.name}\` | \`${value}\` | ${kind} |`; + }), + ].join("\n"); +} + +export function renderSecretsChecklistPlaceholderItems( + manifest: SecretPlaceholderProfileManifest, + entries: readonly SecretPlaceholderEnvVar[], +): readonly string[] { + return entries.map((entry) => { + const value = renderSafeEnvExampleValue(entry, manifest.profile.name); + const description = entry.description ? ` - ${entry.description}` : ""; + + return `- [ ] \`${entry.name}\` = \`${value}\`${description}`; + }); +} + +export function validateSaasProviderSecretPlaceholderPolicy( + manifest: SecretPlaceholderProfileManifest, + artifacts: SecretPlaceholderArtifacts, +): readonly SecretPlaceholderPolicyViolation[] { + const violations: SecretPlaceholderPolicyViolation[] = []; + const envAssignments = parseEnvAssignments(artifacts.envExample); + + for (const entry of allManifestEnv(manifest)) { + const expectedValue = renderSafeEnvExampleValue(entry, manifest.profile.name); + const assignment = envAssignments.get(entry.name); + + if (!assignment) { + violations.push({ + artifact: "envExample", + code: "CROCO_SECRET_PLACEHOLDER_ENV_MISSING", + message: `${entry.name} is missing from .env.example`, + }); + continue; + } + + if (assignment.value !== expectedValue) { + violations.push({ + artifact: "envExample", + code: "CROCO_SECRET_PLACEHOLDER_ENV_UNSAFE", + line: assignment.line, + message: `${entry.name} must use ${expectedValue}, not ${assignment.value || ""}`, + }); + } + } + + for (const entry of allManifestEnv(manifest)) { + const expectedValue = renderSafeEnvExampleValue(entry, manifest.profile.name); + if (!docContainsEntryValue(artifacts.providerProfileDocs, entry.name, expectedValue)) { + violations.push({ + artifact: "providerProfileDocs", + code: "CROCO_SECRET_PLACEHOLDER_PROVIDER_DOCS_MISSING", + message: `docs/provider-profile.md must document ${entry.name} with ${expectedValue}`, + }); + } + if (!docContainsEntryValue(artifacts.secretsChecklist, entry.name, expectedValue)) { + violations.push({ + artifact: "secretsChecklist", + code: "CROCO_SECRET_PLACEHOLDER_CHECKLIST_MISSING", + message: `docs/secrets-checklist.md must document ${entry.name} with ${expectedValue}`, + }); + } + } + + violations.push( + ...scanArtifact("envExample", ".env.example", artifacts.envExample), + ...scanArtifact( + "providerProfileDocs", + "docs/provider-profile.md", + artifacts.providerProfileDocs, + ), + ...scanArtifact("secretsChecklist", "docs/secrets-checklist.md", artifacts.secretsChecklist), + ); + + return violations; +} + +function docContainsEntryValue(doc: string, name: string, value: string): boolean { + return doc + .split(/\r?\n/) + .some((line) => line.includes(`\`${name}\``) && line.includes(`\`${value}\``)); +} + +export function assertSaasProviderSecretPlaceholderPolicy( + manifest: SecretPlaceholderProfileManifest, + artifacts: SecretPlaceholderArtifacts, +): void { + const violations = validateSaasProviderSecretPlaceholderPolicy(manifest, artifacts); + + if (violations.length === 0) { + return; + } + + throw new SecretPlaceholderPolicyError(violations); +} + +function formatSecretPlaceholderPolicyViolationMessage( + violations: readonly SecretPlaceholderPolicyViolation[], +): string { + return [ + "CROCO_SAAS_PROFILE_SECRET_PLACEHOLDER_POLICY_FAILED: generated provider artifacts contain unsafe secret placeholders", + ...violations.map((violation) => { + const line = violation.line === undefined ? "" : `:${violation.line}`; + return `- ${violation.code} ${violation.artifact}${line}: ${violation.message}`; + }), + ].join("\n"); +} + +export function scanGeneratedTemplateSecretText( + filePath: string, + text: string, + allowlists: readonly GeneratedTemplateSecretAllowlistEntry[] = [], +): readonly GeneratedTemplateSecretFinding[] { + const findings: GeneratedTemplateSecretFinding[] = []; + + for (const credentialPattern of credentialPatterns) { + credentialPattern.pattern.lastIndex = 0; + for (const match of text.matchAll(credentialPattern.pattern)) { + const matchedText = match[0]; + const index = match.index ?? 0; + const finding = { + filePath, + line: lineNumberAt(text, index), + match: matchedText, + patternId: credentialPattern.id, + }; + + if (!isFindingAllowlisted(finding, allowlists)) { + findings.push(finding); + } + } + } + + return findings; +} + +export function readGeneratedTemplateSecretAllowlistsFromMetadata( + metadata: unknown, + today: string, +): { + readonly allowlists: readonly GeneratedTemplateSecretAllowlistEntry[]; + readonly violations: readonly GeneratedTemplateSecretMetadataViolation[]; +} { + const violations: GeneratedTemplateSecretMetadataViolation[] = []; + const root = isRecord(metadata) ? metadata : {}; + const secretScan = isRecord(root["secretScan"]) ? root["secretScan"] : {}; + const generatedTemplates = isRecord(secretScan["generatedTemplates"]) + ? secretScan["generatedTemplates"] + : {}; + const rawAllowlists = generatedTemplates["allowlists"]; + + if (rawAllowlists === undefined) { + return { allowlists: [], violations }; + } + + if (!Array.isArray(rawAllowlists)) { + return { + allowlists: [], + violations: [ + { + message: "secretScan.generatedTemplates.allowlists must be an array", + recovery: + "Set secretScan.generatedTemplates.allowlists to an array of reviewed exceptions.", + }, + ], + }; + } + + const allowlists = rawAllowlists.flatMap((entry, index) => + readGeneratedTemplateSecretAllowlistEntry( + isRecord(entry) ? entry : {}, + `secretScan.generatedTemplates.allowlists[${index}]`, + today, + violations, + ), + ); + + return { allowlists, violations }; +} + +function readGeneratedTemplateSecretAllowlistEntry( + entry: Record, + pointer: string, + today: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): readonly GeneratedTemplateSecretAllowlistEntry[] { + const pathPattern = readRequiredMetadataString(entry, "pathPattern", pointer, violations); + const matchPattern = readRequiredMetadataString(entry, "matchPattern", pointer, violations); + const owner = readRequiredMetadataString(entry, "owner", pointer, violations); + const reason = readRequiredMetadataString(entry, "reason", pointer, violations); + const reviewDate = readReviewDate(entry, pointer, today, violations); + + validateRegex(pathPattern, `${pointer}.pathPattern`, violations); + validateRegex(matchPattern, `${pointer}.matchPattern`, violations); + validateGeneratedTemplateAllowlistPatternScope(pathPattern, matchPattern, pointer, violations); + + if (!pathPattern || !matchPattern || !owner || !reason) { + return []; + } + + return [ + { + pathPattern, + matchPattern, + owner, + reason, + ...reviewDate, + }, + ]; +} + +function scanArtifact( + artifact: keyof SecretPlaceholderArtifacts, + filePath: string, + text: string, +): readonly SecretPlaceholderPolicyViolation[] { + return scanGeneratedTemplateSecretText(filePath, text).map((finding) => ({ + artifact, + code: "CROCO_SECRET_PLACEHOLDER_REAL_LOOKING_CREDENTIAL", + line: finding.line, + message: `${filePath} contains ${finding.patternId} shaped value ${finding.match}`, + })); +} + +function allManifestEnv( + manifest: SecretPlaceholderProfileManifest, +): readonly SecretPlaceholderEnvVar[] { + return [...manifest.env.required, ...manifest.env.optional]; +} + +function parseEnvAssignments( + content: string, +): Map { + const assignments = new Map(); + const lines = content.replace(/\r\n/g, "\n").split("\n"); + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return; + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex <= 0) return; + assignments.set(trimmed.slice(0, separatorIndex), { + line: index + 1, + value: trimmed.slice(separatorIndex + 1), + }); + }); + + return assignments; +} + +function isFindingAllowlisted( + finding: GeneratedTemplateSecretFinding, + allowlists: readonly GeneratedTemplateSecretAllowlistEntry[], +): boolean { + return allowlists.some((entry) => { + const pathPattern = safeRegExp(entry.pathPattern); + const matchPattern = safeRegExp(entry.matchPattern); + + return ( + pathPattern?.test(finding.filePath) === true && matchPattern?.test(finding.match) === true + ); + }); +} + +function lineNumberAt(text: string, index: number): number { + return text.slice(0, index).split("\n").length; +} + +function readRequiredMetadataString( + entry: Record, + field: string, + pointer: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): string { + const value = entry[field]; + + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + + violations.push({ + message: `${pointer}.${field} must be a non-empty string`, + recovery: `Add a concrete ${field} value to ${pointer}.`, + }); + return ""; +} + +function readReviewDate( + entry: Record, + pointer: string, + today: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): { readonly expiresOn?: string; readonly reviewBy?: string } { + const reviewBy = typeof entry["reviewBy"] === "string" ? entry["reviewBy"].trim() : ""; + const expiresOn = typeof entry["expiresOn"] === "string" ? entry["expiresOn"].trim() : ""; + + if (!reviewBy && !expiresOn) { + violations.push({ + message: `${pointer} must include reviewBy or expiresOn`, + recovery: `Add a YYYY-MM-DD reviewBy or expiresOn date to ${pointer}.`, + }); + return {}; + } + + if (reviewBy) { + validateReviewDateField(pointer, "reviewBy", reviewBy, today, violations); + } + + if (expiresOn) { + validateReviewDateField(pointer, "expiresOn", expiresOn, today, violations); + } + + return { + ...(expiresOn ? { expiresOn } : {}), + ...(reviewBy ? { reviewBy } : {}), + }; +} + +function validateReviewDateField( + pointer: string, + field: "expiresOn" | "reviewBy", + value: string, + today: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): void { + if (!isValidCalendarDate(value)) { + violations.push({ + message: `${pointer}.${field} must be a valid YYYY-MM-DD date`, + recovery: `Replace ${pointer}.${field} with a valid calendar date.`, + }); + return; + } + + if (value < today) { + violations.push({ + message: `${pointer}.${field} is stale (${value} is before ${today})`, + recovery: `Review the exception and update ${pointer}.${field}, or remove the allowlist entry.`, + }); + } +} + +function isValidCalendarDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return false; + } + + const date = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +function validateRegex( + pattern: string, + pointer: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): void { + if (!pattern) return; + if (safeRegExp(pattern)) return; + + violations.push({ + message: `${pointer} must be a valid and bounded regular expression`, + recovery: `Fix ${pointer} so it compiles and avoids broad or backtracking-heavy constructs.`, + }); +} + +function validateGeneratedTemplateAllowlistPatternScope( + pathPattern: string, + matchPattern: string, + pointer: string, + violations: GeneratedTemplateSecretMetadataViolation[], +): void { + if (pathPattern && !pathPattern.includes("templates/") && !pathPattern.includes("templates\\/")) { + violations.push({ + message: `${pointer}.pathPattern must target generated templates`, + recovery: `Replace ${pointer}.pathPattern with a generated template path pattern such as ^packages/create-croco-app/templates/.`, + }); + } + + if (isCatchAllRegexPattern(pathPattern)) { + violations.push({ + message: `${pointer}.pathPattern must not be a catch-all regular expression`, + recovery: `Narrow ${pointer}.pathPattern to the specific generated template file that needs an exception.`, + }); + } + + if (isCatchAllRegexPattern(matchPattern)) { + violations.push({ + message: `${pointer}.matchPattern must not be a catch-all regular expression`, + recovery: `Narrow ${pointer}.matchPattern to the exact fixture value or assignment prefix that needs an exception.`, + }); + } +} + +function isCatchAllRegexPattern(pattern: string): boolean { + const compactPattern = pattern.replace(/\s/g, ""); + if ( + [ + ".*", + "^.*", + ".*$", + "^.*$", + ".+", + "^.+", + ".+$", + "^.+$", + "[\\s\\S]*", + "^[\\s\\S]*", + "[\\s\\S]*$", + "^[\\s\\S]*$", + ].includes(compactPattern) + ) { + return true; + } + + return [".*", ".+", "[\\s\\S]*"].some((wildcard) => compactPattern.includes(wildcard)); +} + +function safeRegExp(pattern: string): RegExp | null { + if (!isGeneratedTemplateAllowlistRegexSafe(pattern)) { + return null; + } + + try { + return new RegExp(pattern); + } catch { + return null; + } +} + +function isGeneratedTemplateAllowlistRegexSafe(pattern: string): boolean { + if (pattern.length > 256) { + return false; + } + + const compactPattern = pattern.replace(/\s/g, ""); + if (/\\[1-9]/.test(compactPattern)) { + return false; + } + + return !/\((?:\?:)?[^)]*[+*][^)]*\)(?:[+*]|\{\d*,?\d*\})/.test(compactPattern); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/scripts/create-croco-app-generated-smoke.mts b/scripts/create-croco-app-generated-smoke.mts index 3673aea32..d01d9b943 100644 --- a/scripts/create-croco-app-generated-smoke.mts +++ b/scripts/create-croco-app-generated-smoke.mts @@ -11,7 +11,11 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, extname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + readGeneratedTemplateSecretAllowlistsFromMetadata, + scanGeneratedTemplateSecretText, +} from "../packages/create-croco-app/src/secret-placeholder-policy.ts"; import { SUPPORTED_CREATE_CROCO_APP_CHOICES } from "../packages/create-croco-app/src/supported-options.ts"; import { createWorkspacePackageIndex, @@ -167,7 +171,7 @@ const generatedSmokeReportDir = resolve( process.env.CROCO_GENERATED_SMOKE_REPORT_DIR ?? join(rootDir, "ci-reports", "generated-apps"), ); const turboPath = join(rootDir, "node_modules", "turbo", "bin", "turbo"); -const smokeRoot = mkdtempSync(join(tmpdir(), "croco-generated-app-smoke-")); +let smokeRoot: string | undefined; const commandTimeoutMs = 600_000; const sourceFileExtensions = new Set([ ".js", @@ -188,6 +192,7 @@ const securityValidationScanFileExtensions = new Set([ ]); const securityValidationScanFileNames = new Set([ ".env", + ".env.example", ".env.local", ".env.development", ".env.production", @@ -1066,156 +1071,171 @@ const smokeCases: readonly SmokeCase[] = [ }, ]; -let smokeReport: GeneratedSmokeReport | undefined; - -try { - const selectedSmokeCases = selectSmokeCases(smokeCases); - const isFilteredRun = selectedSmokeCases.length !== smokeCases.length; - - assertGraphQLSmokeContractCoverage(selectedSmokeCases); +if (isMainModule()) { + let smokeReport: GeneratedSmokeReport | undefined; + const activeSmokeRoot = getSmokeRoot(); - if (isFilteredRun) { - console.log( - `create-croco-app-generated-smoke: selected cases ${selectedSmokeCases.map(({ name }) => name).join(", ")}`, - ); - } else { - assertSmokeCoverage(smokeCases); - assertTemplateMatrixAccountability(smokeCases); - printSmokeCoverageSummary(smokeCases); - } - - smokeReport = createGeneratedSmokeReport(selectedSmokeCases, isFilteredRun); - writeGeneratedSmokeReport(smokeReport); + try { + const selectedSmokeCases = selectSmokeCases(smokeCases); + const isFilteredRun = selectedSmokeCases.length !== smokeCases.length; - runGeneratedAppContractGates(smokeReport); + assertGraphQLSmokeContractCoverage(selectedSmokeCases); - runGateCommand( - smokeReport, - "workspace package build", - process.execPath, - [ - turboPath, - "build", - "--filter=@croco/auth-better-auth...", - "--filter=@croco/auth-clerk...", - "--filter=@croco/auth-drizzle...", - "--filter=@croco/billing-polar...", - "--filter=@croco/cli...", - "--filter=@croco/events-core...", - "--filter=@croco/events-inmemory...", - "--filter=create-croco-app...", - "--filter=@croco/framework-context...", - "--filter=@croco/frontend-cloudflare...", - "--filter=@croco/frontend-problems...", - "--filter=@croco/frontend-react...", - "--filter=@croco/frontend-vite...", - "--filter=@croco/llm-core...", - "--filter=@croco/llm-metering...", - "--filter=@croco/meta-vite...", - "--filter=@croco/lifecycle-core...", - "--filter=@croco/metering-drizzle...", - "--filter=@croco/metering-upstash...", - "--filter=@croco/openapi-spec...", - "--filter=@croco/problems-core...", - "--filter=@croco/preset-cloudflare...", - "--filter=@croco/preset-lambda...", - "--filter=@croco/repository-core...", - "--filter=@croco/retry-core...", - "--filter=@croco/rpc-codegen...", - "--filter=@croco/storage-cloudinary...", - "--filter=@croco/storage-r2...", - "--filter=@croco/tasks-qstash...", - "--filter=@croco/telemetry-api...", - "--filter=@croco/telemetry-sdk-node...", - "--filter=@croco/tenant-core...", - "--filter=@croco/transports-http...", - "--filter=@croco/triggers-qstash...", - "--filter=@croco/tx-drizzle...", - "--force", - ], - rootDir, - ); - assertExists(cliPath, "create-croco-app dist CLI is missing after build"); + if (isFilteredRun) { + console.log( + `create-croco-app-generated-smoke: selected cases ${selectedSmokeCases.map(({ name }) => name).join(", ")}`, + ); + } else { + assertSmokeCoverage(smokeCases); + assertTemplateMatrixAccountability(smokeCases); + printSmokeCoverageSummary(smokeCases); + } - const workspacePackageIndex = createWorkspacePackageIndex(rootDir); - const packedWorkspacePackages = new Map(); - const builtWorkspacePackageNames = new Set(); + smokeReport = createGeneratedSmokeReport(selectedSmokeCases, isFilteredRun); + writeGeneratedSmokeReport(smokeReport); - for (const smokeCase of selectedSmokeCases) { - const projectDir = join(smokeRoot, smokeCase.name); - const caseResult = getSmokeCaseResult(smokeReport, smokeCase.name); + runGeneratedAppContractGates(smokeReport); - runSmokeCaseCommand( + runGateCommand( smokeReport, - caseResult, - "generate", - "node", - [cliPath, projectDir, ...smokeCase.args], + "workspace package build", + process.execPath, + [ + turboPath, + "build", + "--filter=@croco/auth-better-auth...", + "--filter=@croco/auth-clerk...", + "--filter=@croco/auth-drizzle...", + "--filter=@croco/billing-polar...", + "--filter=@croco/cli...", + "--filter=@croco/events-core...", + "--filter=@croco/events-inmemory...", + "--filter=create-croco-app...", + "--filter=@croco/framework-context...", + "--filter=@croco/frontend-cloudflare...", + "--filter=@croco/frontend-problems...", + "--filter=@croco/frontend-react...", + "--filter=@croco/frontend-vite...", + "--filter=@croco/llm-core...", + "--filter=@croco/llm-metering...", + "--filter=@croco/meta-vite...", + "--filter=@croco/lifecycle-core...", + "--filter=@croco/metering-drizzle...", + "--filter=@croco/metering-upstash...", + "--filter=@croco/openapi-spec...", + "--filter=@croco/problems-core...", + "--filter=@croco/preset-cloudflare...", + "--filter=@croco/preset-lambda...", + "--filter=@croco/repository-core...", + "--filter=@croco/retry-core...", + "--filter=@croco/rpc-codegen...", + "--filter=@croco/storage-cloudinary...", + "--filter=@croco/storage-r2...", + "--filter=@croco/tasks-qstash...", + "--filter=@croco/telemetry-api...", + "--filter=@croco/telemetry-sdk-node...", + "--filter=@croco/tenant-core...", + "--filter=@croco/transports-http...", + "--filter=@croco/triggers-qstash...", + "--filter=@croco/tx-drizzle...", + "--force", + ], rootDir, ); - const generatedSmokeRangeOverrides = getGeneratedSmokeRangeOverrides( - projectDir, - join(smokeRoot, "generated-package-packs"), - workspacePackageIndex, - packedWorkspacePackages, - builtWorkspacePackageNames, - ); - rewriteExternalCrocoRanges( - projectDir, - generatedSmokeRangeOverrides, - generatedSmokeExternalCrocoRangeExceptions, - ); - assertGeneratedReadme(projectDir, smokeCase); - assertNoGeneratedSecurityValidationOptOut(projectDir, smokeCase); - writePnpmWorkspaceOverrides(projectDir, generatedSmokeRangeOverrides); - runSmokeCaseCommand( - smokeReport, - caseResult, - "install", - "corepack", - ["pnpm", "install"], - projectDir, - ); - const lockfilePath = join(projectDir, "pnpm-lock.yaml"); - assertExists(lockfilePath, `${smokeCase.name} did not create a pnpm lockfile`); - assertPnpmLockfileUsesLocalTarballOverrides( - lockfilePath, - smokeCase.name, - generatedSmokeRangeOverrides, - ); - assertExists( - join(projectDir, "node_modules"), - `${smokeCase.name} did not install dependencies with pnpm`, - ); + assertExists(cliPath, "create-croco-app dist CLI is missing after build"); + + const workspacePackageIndex = createWorkspacePackageIndex(rootDir); + const packedWorkspacePackages = new Map(); + const builtWorkspacePackageNames = new Set(); + + for (const smokeCase of selectedSmokeCases) { + const projectDir = join(activeSmokeRoot, smokeCase.name); + const caseResult = getSmokeCaseResult(smokeReport, smokeCase.name); + + runSmokeCaseCommand( + smokeReport, + caseResult, + "generate", + "node", + [cliPath, projectDir, ...smokeCase.args], + rootDir, + ); + const generatedSmokeRangeOverrides = getGeneratedSmokeRangeOverrides( + projectDir, + join(activeSmokeRoot, "generated-package-packs"), + workspacePackageIndex, + packedWorkspacePackages, + builtWorkspacePackageNames, + ); + rewriteExternalCrocoRanges( + projectDir, + generatedSmokeRangeOverrides, + generatedSmokeExternalCrocoRangeExceptions, + ); + assertGeneratedReadme(projectDir, smokeCase); + assertNoGeneratedSecurityValidationOptOut(projectDir, smokeCase); + assertNoGeneratedCredentialLookingValues(projectDir, smokeCase); + writePnpmWorkspaceOverrides(projectDir, generatedSmokeRangeOverrides); + runSmokeCaseCommand( + smokeReport, + caseResult, + "install", + "corepack", + ["pnpm", "install"], + projectDir, + ); + const lockfilePath = join(projectDir, "pnpm-lock.yaml"); + assertExists(lockfilePath, `${smokeCase.name} did not create a pnpm lockfile`); + assertPnpmLockfileUsesLocalTarballOverrides( + lockfilePath, + smokeCase.name, + generatedSmokeRangeOverrides, + ); + assertExists( + join(projectDir, "node_modules"), + `${smokeCase.name} did not install dependencies with pnpm`, + ); - for (const validation of smokeCase.validations) { - runValidation(projectDir, smokeCase, validation, smokeReport, caseResult); + for (const validation of smokeCase.validations) { + runValidation(projectDir, smokeCase, validation, smokeReport, caseResult); + } + runGraphQLContractDriftCanaries(projectDir, smokeCase, smokeReport, caseResult); + caseResult.status = "passed"; + writeGeneratedSmokeReport(smokeReport); } - runGraphQLContractDriftCanaries(projectDir, smokeCase, smokeReport, caseResult); - caseResult.status = "passed"; - writeGeneratedSmokeReport(smokeReport); - } - if (!isFilteredRun) { - runSpaBeSplitContractSmoke( - workspacePackageIndex, - packedWorkspacePackages, - builtWorkspacePackageNames, - ); - } + if (!isFilteredRun) { + runSpaBeSplitContractSmoke( + workspacePackageIndex, + packedWorkspacePackages, + builtWorkspacePackageNames, + ); + } - smokeReport.status = "passed"; - writeGeneratedSmokeReport(smokeReport); - console.log("create-croco-app-generated-smoke: all generated app smoke cases passed"); -} catch (error) { - if (smokeReport) { - smokeReport.status = "failed"; - smokeReport.failure = toErrorMessage(error); + smokeReport.status = "passed"; writeGeneratedSmokeReport(smokeReport); + console.log("create-croco-app-generated-smoke: all generated app smoke cases passed"); + } catch (error) { + if (smokeReport) { + smokeReport.status = "failed"; + smokeReport.failure = toErrorMessage(error); + writeGeneratedSmokeReport(smokeReport); + } + throw error; + } finally { + rmSync(activeSmokeRoot, { force: true, recursive: true }); + smokeRoot = undefined; } - throw error; -} finally { - rmSync(smokeRoot, { force: true, recursive: true }); +} + +function isMainModule(): boolean { + const entrypoint = process.argv[1]; + return entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href; +} + +function getSmokeRoot(): string { + smokeRoot ??= mkdtempSync(join(tmpdir(), "croco-generated-app-smoke-")); + return smokeRoot; } function runGeneratedAppContractGates(report: GeneratedSmokeReport): void { @@ -1226,6 +1246,7 @@ function runGeneratedAppContractGates(report: GeneratedSmokeReport): void { ["exec", "oxlint", "packages/create-croco-app/templates"], report, ); + runGate("generated secret placeholder policy", ["generated-secret-placeholders:check"], report); } function runGate(label: string, args: readonly string[], report: GeneratedSmokeReport): void { @@ -1707,6 +1728,67 @@ function assertNoGeneratedSecurityValidationOptOut(projectDir: string, smokeCase ); } +function assertNoGeneratedCredentialLookingValues(projectDir: string, smokeCase: SmokeCase): void { + const metadata = readGeneratedSmokeAllowlistMetadata( + join(rootDir, "scripts", "security-allowlist-metadata.json"), + smokeCase.name, + ); + const allowlistRead = readGeneratedTemplateSecretAllowlistsFromMetadata( + metadata, + new Date().toISOString().slice(0, 10), + ); + + if (allowlistRead.violations.length > 0) { + throw new Error( + [ + `${smokeCase.name} generated secret allowlist metadata is invalid`, + ...allowlistRead.violations.map( + (violation) => `- ${violation.message} Recovery: ${violation.recovery}`, + ), + ].join("\n"), + ); + } + + const findings = collectGeneratedSecurityValidationScanFiles(projectDir).flatMap((filePath) => + scanGeneratedTemplateSecretText( + relative(projectDir, filePath).replace(/\\/g, "/"), + readFileSync(filePath, "utf8"), + allowlistRead.allowlists, + ), + ); + + if (findings.length > 0) { + throw new Error( + [ + `${smokeCase.name} generated files contain credential-shaped values`, + ...findings.map( + (finding) => + `- ${finding.filePath}:${finding.line} ${finding.patternId} ${finding.match}`, + ), + ].join("\n"), + ); + } + + console.log( + `create-croco-app-generated-smoke: ${smokeCase.name} generated secret placeholders are safe`, + ); +} + +export function readGeneratedSmokeAllowlistMetadata( + metadataPath: string, + smokeCaseName: string, +): unknown { + try { + return JSON.parse(readFileSync(metadataPath, "utf8")) as unknown; + } catch (error) { + throw new Error( + `${smokeCaseName} generated secret allowlist metadata is invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + function collectGeneratedSecurityValidationScanFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const entryPath = join(directory, entry.name); @@ -2216,7 +2298,8 @@ function runSpaBeSplitContractSmoke( packedWorkspacePackages: Map, builtWorkspacePackageNames: Set, ): void { - const projectDir = join(smokeRoot, "rest-spa-contracts"); + const contractSmokeRoot = getSmokeRoot(); + const projectDir = join(contractSmokeRoot, "rest-spa-contracts"); const templateDir = join(rootDir, "packages", "create-croco-app", "templates", "spa-be-split"); renderTemplate(templateDir, projectDir, { @@ -2230,7 +2313,7 @@ function runSpaBeSplitContractSmoke( ); const contractSmokeRangeOverrides = getGeneratedSmokeRangeOverrides( projectDir, - join(smokeRoot, "contract-package-packs"), + join(contractSmokeRoot, "contract-package-packs"), workspacePackageIndex, packedWorkspacePackages, builtWorkspacePackageNames, diff --git a/scripts/generated-secret-placeholder-policy.mts b/scripts/generated-secret-placeholder-policy.mts new file mode 100644 index 000000000..fc194ceb8 --- /dev/null +++ b/scripts/generated-secret-placeholder-policy.mts @@ -0,0 +1,266 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { argv, exit, stdout } from "node:process"; +import { fileURLToPath } from "node:url"; +import { + readGeneratedTemplateSecretAllowlistsFromMetadata, + scanGeneratedTemplateSecretText, +} from "../packages/create-croco-app/src/secret-placeholder-policy.ts"; + +type Options = { + readonly metadataPath: string; + readonly rootDir: string; + readonly scanPaths: readonly string[]; + readonly today: string; +}; + +type Violation = { + readonly message: string; + readonly recovery: string; +}; + +const defaultRootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const defaultMetadataPath = "scripts/security-allowlist-metadata.json"; +const defaultScanPaths = ["packages/create-croco-app/templates"] as const; +const ignoredDirectories = new Set([".git", ".turbo", "coverage", "dist", "node_modules", "out"]); +const textExtensions = new Set([ + ".cjs", + ".css", + ".hbs", + ".html", + ".js", + ".json", + ".jsonc", + ".md", + ".mjs", + ".mts", + ".ts", + ".tsx", + ".txt", + ".yaml", + ".yml", +]); +const textFileNames = new Set([ + ".env", + ".env.development", + ".env.example", + ".env.local", + ".env.production", +]); + +function log(message = ""): void { + stdout.write(`${message}\n`); +} + +function main(): void { + const options = parseArgs(argv.slice(2)); + const violations: Violation[] = []; + const metadata = readMetadata(options.metadataPath, violations); + const allowlistRead = readGeneratedTemplateSecretAllowlistsFromMetadata(metadata, options.today); + violations.push(...allowlistRead.violations); + + validateGeneratedHelperDrift(options.rootDir, violations); + + for (const scanPath of options.scanPaths) { + const absolutePath = resolveFromRoot(options.rootDir, scanPath); + if (!existsSync(absolutePath)) { + violations.push({ + message: `generated template scan path is missing: ${scanPath}`, + recovery: `Create ${scanPath}, or remove it from generated-secret-placeholders:check inputs.`, + }); + continue; + } + + for (const filePath of listTextFiles(absolutePath)) { + const relativePath = normalizePath(relative(options.rootDir, filePath)); + const text = readFileSync(filePath, "utf-8"); + const findings = scanGeneratedTemplateSecretText( + relativePath, + text, + allowlistRead.allowlists, + ); + + violations.push( + ...findings.map((finding) => ({ + message: `${finding.filePath}:${finding.line} contains ${finding.patternId} shaped value ${finding.match}`, + recovery: + "Replace it with or , or add a reviewed secretScan.generatedTemplates allowlist entry with owner and reason.", + })), + ); + } + } + + if (violations.length > 0) { + log("generated-secret-placeholder-policy: failed"); + for (const violation of violations) { + log(`- ${violation.message}`); + log(` Recovery: ${violation.recovery}`); + } + exit(1); + } + + log( + `generated-secret-placeholder-policy: passed (${options.scanPaths.length} scan paths, ${allowlistRead.allowlists.length} generated template allowlists).`, + ); +} + +function parseArgs(args: readonly string[]): Options { + let metadataPath = defaultMetadataPath; + let rootDir = defaultRootDir; + const scanPaths: string[] = []; + let today = new Date().toISOString().slice(0, 10); + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + + if (arg === "--root") { + rootDir = requireValue(args, index, arg); + index++; + continue; + } + + if (arg === "--metadata") { + metadataPath = requireValue(args, index, arg); + index++; + continue; + } + + if (arg === "--path") { + scanPaths.push(requireValue(args, index, arg)); + index++; + continue; + } + + if (arg === "--today") { + today = requireValue(args, index, arg); + index++; + continue; + } + + throw new Error(`Unknown option: ${arg}`); + } + + if (!isValidDate(today)) { + throw new Error(`--today must be a valid YYYY-MM-DD date, received ${today}`); + } + + const absoluteRootDir = resolve(rootDir); + + return { + metadataPath: resolveFromRoot(absoluteRootDir, metadataPath), + rootDir: absoluteRootDir, + scanPaths: scanPaths.length > 0 ? scanPaths : defaultScanPaths, + today, + }; +} + +function requireValue(args: readonly string[], index: number, option: string): string { + const value = args[index + 1]; + if (!value) { + throw new Error(`${option} requires a value`); + } + + return value; +} + +function readMetadata(path: string, violations: Violation[]): unknown { + if (!existsSync(path)) { + violations.push({ + message: `security allowlist metadata is missing at ${path}`, + recovery: + "Create scripts/security-allowlist-metadata.json before adding template exceptions.", + }); + return {}; + } + + try { + return JSON.parse(readFileSync(path, "utf-8")) as unknown; + } catch (error) { + violations.push({ + message: `security allowlist metadata is invalid JSON at ${path}`, + recovery: `Fix the JSON syntax: ${error instanceof Error ? error.message : String(error)}`, + }); + return {}; + } +} + +function validateGeneratedHelperDrift(rootDir: string, violations: Violation[]): void { + const sourcePath = resolveFromRoot( + rootDir, + "packages/create-croco-app/src/secret-placeholder-policy.ts", + ); + const templatePath = resolveFromRoot( + rootDir, + "packages/create-croco-app/templates/saas/apps/api-server/src/secret-placeholder-policy.ts", + ); + + if (!existsSync(sourcePath) || !existsSync(templatePath)) { + return; + } + + if (readFileSync(sourcePath, "utf-8") === readFileSync(templatePath, "utf-8")) { + return; + } + + violations.push({ + message: + "CROCO_GENERATED_SECRET_PLACEHOLDER_HELPER_DRIFT: generated app secret-placeholder-policy.ts differs from create-croco-app source", + recovery: + "Copy packages/create-croco-app/src/secret-placeholder-policy.ts to the SaaS generated app template before changing placeholder policy behavior.", + }); +} + +function listTextFiles(root: string): readonly string[] { + const stat = statSync(root); + if (stat.isFile()) { + return isTextFile(root) ? [root] : []; + } + + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const fullPath = join(root, entry.name); + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + files.push(...listTextFiles(fullPath)); + } + continue; + } + + if (entry.isFile() && isTextFile(fullPath)) { + files.push(fullPath); + } + } + + return files.sort(); +} + +function isTextFile(path: string): boolean { + const normalized = normalizePath(path); + const fileName = normalized.slice(normalized.lastIndexOf("/") + 1); + if (textFileNames.has(fileName)) { + return !readFileSync(path).includes(0); + } + + const extension = normalized.includes(".") ? normalized.slice(normalized.lastIndexOf(".")) : ""; + if (!textExtensions.has(extension)) { + return false; + } + + return !readFileSync(path).includes(0); +} + +function resolveFromRoot(rootDir: string, path: string): string { + return resolve(rootDir, path); +} + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/"); +} + +function isValidDate(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00.000Z`)); +} + +main(); diff --git a/scripts/security-allowlist-metadata-check.mts b/scripts/security-allowlist-metadata-check.mts index 2cf0f13b2..75963b387 100644 --- a/scripts/security-allowlist-metadata-check.mts +++ b/scripts/security-allowlist-metadata-check.mts @@ -5,6 +5,10 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, relative, resolve } from "node:path"; import { argv, exit, stdout } from "node:process"; import { fileURLToPath } from "node:url"; +import { + readGeneratedTemplateSecretAllowlistsFromMetadata, + type GeneratedTemplateSecretAllowlistEntry, +} from "../packages/create-croco-app/src/secret-placeholder-policy.ts"; type Options = { readonly explicitGitleaksConfigPath: boolean; @@ -51,6 +55,7 @@ type GitleaksAllowlistEntry = { type SecurityAllowlistMetadata = { readonly auditIgnores: readonly AuditMetadataEntry[]; + readonly generatedTemplateAllowlists: readonly GeneratedTemplateSecretAllowlistEntry[]; readonly gitleaksAllowlists: readonly GitleaksMetadataEntry[]; readonly gitleaksConfigPath?: string; }; @@ -257,9 +262,15 @@ function readMetadata( violations, ), ); + const generatedTemplateAllowlists = readGeneratedTemplateSecretAllowlistsFromMetadata( + root, + today, + ); + violations.push(...generatedTemplateAllowlists.violations); return { auditIgnores: [...auditIgnoreGhsas, ...auditIgnoreCves], + generatedTemplateAllowlists: generatedTemplateAllowlists.allowlists, gitleaksAllowlists: [...gitleaksAllowlists, ...gitleaksIgnoreFingerprints], gitleaksConfigPath, }; @@ -1203,14 +1214,30 @@ function validateGitleaksMetadata( } } +function validateGeneratedTemplateMetadata( + metadata: readonly GeneratedTemplateSecretAllowlistEntry[], + violations: Violation[], +): void { + const keys = metadata.map((entry) => `${entry.pathPattern}\u0000${entry.matchPattern}`); + + for (const duplicateKey of duplicateValues(keys)) { + const [pathPattern, matchPattern] = duplicateKey.split("\u0000"); + violations.push({ + message: `generated template secret allowlist contains duplicate pathPattern ${pathPattern} and matchPattern ${matchPattern}`, + recovery: "Keep one metadata object per generated template secret-scan exception.", + }); + } +} + function printResult( violations: readonly Violation[], effectiveAuditIgnores: readonly string[], effectiveGitleaks: GitleaksEffectiveAllowlists, + generatedTemplateAllowlists: readonly GeneratedTemplateSecretAllowlistEntry[], ): void { if (violations.length === 0) { log( - `security-allowlist-metadata: passed (${effectiveAuditIgnores.length} audit ignores, ${effectiveGitleaks.entries.length} gitleaks allowlist entries, ${effectiveGitleaks.ignoreFingerprints.length} gitleaks ignore fingerprints).`, + `security-allowlist-metadata: passed (${effectiveAuditIgnores.length} audit ignores, ${effectiveGitleaks.entries.length} gitleaks allowlist entries, ${effectiveGitleaks.ignoreFingerprints.length} gitleaks ignore fingerprints, ${generatedTemplateAllowlists.length} generated template allowlists).`, ); return; } @@ -1592,8 +1619,14 @@ function main(): void { validateAuditMetadata(effectiveAuditIgnores, metadata.auditIgnores, violations); validateGitleaksMetadata(effectiveGitleaks, metadata.gitleaksAllowlists, violations); + validateGeneratedTemplateMetadata(metadata.generatedTemplateAllowlists, violations); validateNoInlineGitleaksSuppressions(options, violations); - printResult(violations, effectiveAuditIgnores, effectiveGitleaks); + printResult( + violations, + effectiveAuditIgnores, + effectiveGitleaks, + metadata.generatedTemplateAllowlists, + ); if (violations.length > 0) { exit(1); diff --git a/scripts/security-allowlist-metadata.json b/scripts/security-allowlist-metadata.json index 8aeb08586..9ea841b9d 100644 --- a/scripts/security-allowlist-metadata.json +++ b/scripts/security-allowlist-metadata.json @@ -23,6 +23,9 @@ "reviewBy": "2026-10-31" } ] + }, + "generatedTemplates": { + "allowlists": [] } } } diff --git a/scripts/static-misuse-raw-error-allowlist.json b/scripts/static-misuse-raw-error-allowlist.json index f21854824..25df546e5 100644 --- a/scripts/static-misuse-raw-error-allowlist.json +++ b/scripts/static-misuse-raw-error-allowlist.json @@ -269,7 +269,7 @@ { "package": "create-croco-app", "file": "packages/create-croco-app/src/generator.ts", - "line": 491, + "line": 495, "excerpt": "throw new Error(", "reason": "Existing CLI and generator validation currently flows through command-level Error handling; tracked for future diagnostic-code migration outside this issue.", "owner": "framework-error-handling" @@ -293,7 +293,7 @@ { "package": "create-croco-app", "file": "packages/create-croco-app/src/saas-provider-profiles.ts", - "line": 531, + "line": 538, "excerpt": "throw new Error(", "reason": "Existing CLI and generator validation currently flows through command-level Error handling; tracked for future diagnostic-code migration outside this issue.", "owner": "framework-error-handling" @@ -301,7 +301,7 @@ { "package": "create-croco-app", "file": "packages/create-croco-app/src/saas-provider-profiles.ts", - "line": 544, + "line": 551, "excerpt": "throw new Error(`CROCO_SAAS_PROFILE_PACKAGE_RANGE_MISSING: ${packageName}`);", "reason": "Existing CLI and generator validation currently flows through command-level Error handling; tracked for future diagnostic-code migration outside this issue.", "owner": "framework-error-handling" @@ -309,7 +309,7 @@ { "package": "create-croco-app", "file": "packages/create-croco-app/src/saas-provider-profiles.ts", - "line": 563, + "line": 570, "excerpt": "throw new Error(", "reason": "Existing CLI and generator validation currently flows through command-level Error handling; tracked for future diagnostic-code migration outside this issue.", "owner": "framework-error-handling" diff --git a/scripts/tests/create-croco-app-generated-smoke.spec.ts b/scripts/tests/create-croco-app-generated-smoke.spec.ts index 36717bca5..768c05659 100644 --- a/scripts/tests/create-croco-app-generated-smoke.spec.ts +++ b/scripts/tests/create-croco-app-generated-smoke.spec.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { readGeneratedSmokeAllowlistMetadata } from "../create-croco-app-generated-smoke.mts"; import { createWorkspacePackageIndex, resolveLocalCrocoPackagesForGeneratedProject, @@ -146,6 +147,16 @@ describe("create-croco-app-generated-smoke dependency resolution", () => { ); expect(packageJson.pnpm).toBeUndefined(); }); + + it("reports malformed generated secret allowlist metadata with smoke case context", () => { + const root = createTempRoot(); + const metadataPath = join(root, "security-allowlist-metadata.json"); + writeFileSync(metadataPath, "{ invalid-json"); + + expect(() => readGeneratedSmokeAllowlistMetadata(metadataPath, "saas-golden-path")).toThrow( + /saas-golden-path generated secret allowlist metadata is invalid JSON:/, + ); + }); }); function createTempRoot(): string { diff --git a/scripts/tests/generated-secret-placeholder-policy.spec.ts b/scripts/tests/generated-secret-placeholder-policy.spec.ts new file mode 100644 index 000000000..2c9b0fc48 --- /dev/null +++ b/scripts/tests/generated-secret-placeholder-policy.spec.ts @@ -0,0 +1,206 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const scriptPath = resolve(__dirname, "../generated-secret-placeholder-policy.mts"); +const tempRoots: string[] = []; + +type ScriptResult = { + readonly stdout: string; + readonly stderr: string; + readonly status: number | null; +}; + +describe("generated-secret-placeholder-policy.mts", () => { + afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("passes generated templates with safe Croco placeholders", () => { + const root = createTempRoot(); + writeRepo(root, { + templateFiles: { + "templates/.env.example": [ + "SAAS_PROVIDER_PROFILE=saas-cloudflare", + "CLOUDFLARE_API_TOKEN=", + "R2_BUCKET=", + ].join("\n"), + }, + }); + + const result = runScript(root); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("generated-secret-placeholder-policy: passed"); + }); + + it("fails generated templates with real-looking credential values", () => { + const root = createTempRoot(); + writeRepo(root, { + templateFiles: { + "templates/.env.example": "POLAR_ACCESS_TOKEN=polar_live_token_1234567890\n", + }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("generated-secret-placeholder-policy: failed"); + expect(result.stdout).toContain("templates/.env.example:1 contains secret-env-assignment"); + }); + + it("fails generated templates with every secret-marked provider env assignment name", () => { + const root = createTempRoot(); + writeRepo(root, { + templateFiles: { + "templates/.env.example": [ + "BETTER_AUTH_SECRET=unsafeSecretValue123", + "CLERK_SECRET_KEY=unsafeSecretValue123", + "CLOUDFLARE_ACCOUNT_ID=0123456789abcdef0123456789abcdef", + "CLOUDFLARE_API_TOKEN=unsafeSecretValue123", + "CLOUDINARY_URL=cloudinary://unsafeSecretValue123", + "DATABASE_URL=postgres://unsafeSecretValue123", + "POLAR_ACCESS_TOKEN=unsafeSecretValue123", + "POLAR_WEBHOOK_SECRET=unsafeSecretValue123", + "UPSTASH_QSTASH_CURRENT_SIGNING_KEY=unsafeSecretValue123", + "UPSTASH_QSTASH_NEXT_SIGNING_KEY=unsafeSecretValue123", + "UPSTASH_QSTASH_TOKEN=unsafeSecretValue123", + "UPSTASH_REDIS_REST_TOKEN=unsafeSecretValue123", + "UPSTASH_REDIS_REST_URL=https://unsafe.example.com", + ].join("\n"), + }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout.match(/contains secret-env-assignment/g)).toHaveLength(13); + expect(result.stdout).toContain( + "templates/.env.example:3 contains secret-env-assignment shaped value CLOUDFLARE_ACCOUNT_ID=0123456789abcdef0123456789abcdef", + ); + }); + + it("honors reviewed generated-template allowlists", () => { + const root = createTempRoot(); + writeRepo(root, { + metadata: metadataFixture({ + generatedTemplateAllowlists: [ + { + pathPattern: "^templates/fixture\\.env$", + matchPattern: "^POLAR_ACCESS_TOKEN=", + owner: "security", + reason: "Intentional scanner fixture for generated template allowlist tests.", + reviewBy: "2027-01-31", + }, + ], + }), + templateFiles: { + "templates/fixture.env": "POLAR_ACCESS_TOKEN=polar_live_token_1234567890\n", + }, + }); + + const result = runScript(root); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("1 generated template allowlists"); + }); + + it("reports malformed security allowlist metadata as a policy violation", () => { + const root = createTempRoot(); + writeRepo(root, { + metadataText: "{ invalid-json", + templateFiles: { + "templates/.env.example": "R2_BUCKET=\n", + }, + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("generated-secret-placeholder-policy: failed"); + expect(result.stdout).toContain("security allowlist metadata is invalid JSON"); + expect(result.stdout).toContain("Recovery: Fix the JSON syntax:"); + }); +}); + +function createTempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "croco-generated-secret-policy-")); + tempRoots.push(root); + return root; +} + +function writeRepo( + root: string, + options: { + readonly metadata?: Record; + readonly metadataText?: string; + readonly templateFiles?: Record; + } = {}, +): void { + mkdirSync(join(root, "scripts"), { recursive: true }); + mkdirSync(join(root, "templates"), { recursive: true }); + writeFileSync( + join(root, "scripts/security-allowlist-metadata.json"), + options.metadataText ?? `${JSON.stringify(options.metadata ?? metadataFixture(), null, 2)}\n`, + ); + + for (const [relativePath, content] of Object.entries(options.templateFiles ?? {})) { + const absolutePath = join(root, relativePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, content); + } +} + +function metadataFixture( + options: { + readonly generatedTemplateAllowlists?: readonly Record[]; + } = {}, +): Record { + return { + schemaVersion: 1, + audit: { + ignoreGhsas: [], + ignoreCves: [], + }, + secretScan: { + gitleaks: { + configPath: ".gitleaks.toml", + allowlists: [], + ignoreFingerprints: [], + }, + generatedTemplates: { + allowlists: options.generatedTemplateAllowlists ?? [], + }, + }, + }; +} + +function runScript(root: string): ScriptResult { + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + scriptPath, + "--root", + root, + "--path", + "templates", + "--today", + "2026-07-03", + ], + { + encoding: "utf-8", + timeout: 10_000, + }, + ); + + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + }; +} diff --git a/scripts/tests/security-allowlist-metadata-check.spec.ts b/scripts/tests/security-allowlist-metadata-check.spec.ts index fe3cc8c74..c46839c79 100644 --- a/scripts/tests/security-allowlist-metadata-check.spec.ts +++ b/scripts/tests/security-allowlist-metadata-check.spec.ts @@ -28,7 +28,7 @@ describe("security-allowlist-metadata-check.mts", () => { expect(result.status).toBe(0); expect(result.stdout).toContain( - "security-allowlist-metadata: passed (1 audit ignores, 1 gitleaks allowlist entries, 0 gitleaks ignore fingerprints).", + "security-allowlist-metadata: passed (1 audit ignores, 1 gitleaks allowlist entries, 0 gitleaks ignore fingerprints, 0 generated template allowlists).", ); }); @@ -463,6 +463,14 @@ describe("security-allowlist-metadata-check.mts", () => { ], ignoreFingerprints: [], }, + generatedTemplates: { + allowlists: [ + { + pathPattern: "^templates/fixture\\.env$", + matchPattern: "^POLAR_ACCESS_TOKEN=", + }, + ], + }, }, }, }); @@ -476,12 +484,32 @@ describe("security-allowlist-metadata-check.mts", () => { expect(result.stdout).toContain( "secretScan.gitleaks.allowlists[0].owner must be a non-empty string", ); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[0].owner must be a non-empty string", + ); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[0].reason must be a non-empty string", + ); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[0] must include reviewBy or expiresOn", + ); }); it("fails stale review dates", () => { const root = createTempRoot(); writeRepo(root, { - metadata: metadataFixture({ reviewBy: "2026-07-02" }), + metadata: metadataFixture({ + generatedTemplateAllowlists: [ + { + pathPattern: "^templates/fixture\\.env$", + matchPattern: "^POLAR_ACCESS_TOKEN=", + owner: "security", + reason: "Intentional scanner fixture.", + reviewBy: "2026-07-02", + }, + ], + reviewBy: "2026-07-02", + }), }); const result = runScript(root); @@ -493,6 +521,9 @@ describe("security-allowlist-metadata-check.mts", () => { expect(result.stdout).toContain( "secretScan.gitleaks.allowlists[0].reviewBy is stale (2026-07-02 is before 2026-07-03)", ); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[0].reviewBy is stale (2026-07-02 is before 2026-07-03)", + ); }); it("fails stale expiresOn even when reviewBy is still current", () => { @@ -515,6 +546,71 @@ describe("security-allowlist-metadata-check.mts", () => { ); }); + it("fails generated-template allowlists with invalid calendar review dates", () => { + const root = createTempRoot(); + writeRepo(root, { + metadata: metadataFixture({ + generatedTemplateAllowlists: [ + { + pathPattern: "^templates/fixture\\.env$", + matchPattern: "^POLAR_ACCESS_TOKEN=", + owner: "security", + reason: "Intentional scanner fixture.", + expiresOn: "2026-02-30", + reviewBy: "2026-13-01", + }, + ], + }), + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[0].reviewBy must be a valid YYYY-MM-DD date", + ); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[0].expiresOn must be a valid YYYY-MM-DD date", + ); + }); + + it("fails generated-template allowlists with broad or non-template-scoped regexes", () => { + const root = createTempRoot(); + writeRepo(root, { + metadata: metadataFixture({ + generatedTemplateAllowlists: [ + { + pathPattern: "^.*$", + matchPattern: ".*", + owner: "security", + reason: "Intentionally broad scanner fixture.", + reviewBy: "2027-01-31", + }, + { + pathPattern: "^fixtures/secret\\.env$", + matchPattern: "^POLAR_ACCESS_TOKEN=", + owner: "security", + reason: "Intentionally non-template scanner fixture.", + reviewBy: "2027-01-31", + }, + ], + }), + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[0].pathPattern must not be a catch-all regular expression", + ); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[0].matchPattern must not be a catch-all regular expression", + ); + expect(result.stdout).toContain( + "secretScan.generatedTemplates.allowlists[1].pathPattern must target generated templates", + ); + }); + it("parses flow-style pnpm-workspace auditConfig as an effective audit source", () => { const root = createTempRoot(); writeRepo(root, { @@ -715,6 +811,7 @@ function metadataFixture( readonly auditCves?: readonly Record[]; readonly configPath?: string; readonly expiresOn?: string; + readonly generatedTemplateAllowlists?: readonly Record[]; readonly gitleaksAllowlists?: readonly Record[]; readonly reviewBy?: string; } = {}, @@ -750,6 +847,9 @@ function metadataFixture( ], ignoreFingerprints: [], }, + generatedTemplates: { + allowlists: options.generatedTemplateAllowlists ?? [], + }, }, }; } diff --git a/tsconfig/contract-strict.baseline.json b/tsconfig/contract-strict.baseline.json index bb6031071..ac4abc7a4 100644 --- a/tsconfig/contract-strict.baseline.json +++ b/tsconfig/contract-strict.baseline.json @@ -5045,22 +5045,6 @@ "code": "TS2375", "message": "Type '{ projectName: string; scope: string; preset: GeneratorOptions[\"preset\"]; webApps: string[]; api: GeneratorOptions[\"api\"]; apiHosting: \"standalone\" | \"nextjs\"; backendDeploy: \"lambda\" | \"docker\" | undefined; ... 4 more ...; initGit: boolean; }' is not assignable to type 'GeneratorOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties." }, - { - "packageName": "create-croco-app", - "file": "packages/create-croco-app/src/saas-provider-profiles.ts", - "line": 687, - "column": 3, - "code": "TS2375", - "message": "Type '{ name: string; description: string; requiredForRealProvider: boolean; secret: boolean; example: string | undefined; }' is not assignable to type 'SaasProviderEnvVar' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties." - }, - { - "packageName": "create-croco-app", - "file": "packages/create-croco-app/src/saas-provider-profiles.ts", - "line": 697, - "column": 3, - "code": "TS2375", - "message": "Type '{ capability: SaasProviderCapabilityName; provider: string; status: CapabilityStatus; packageName: string | undefined; env: readonly string[]; notes: string; }' is not assignable to type 'SaasProviderCapability' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties." - }, { "packageName": "create-croco-app", "file": "packages/create-croco-app/src/types.ts",