Skip to content

Commit 5a50c77

Browse files
authored
fix: gate install success on doctor's auth-pattern security checks (#175)
* fix: gate install success on doctor's auth-pattern security checks The installer ran build/typecheck self-correction but never ran doctor's authPatterns checks, so an insecure GET sign-out (SIGNOUT_GET_HANDLER) could pass the build and ship as a "successful" install while `workos doctor` immediately flagged it. - Add src/lib/validation/security-checks.ts: runs doctor's security subset (GET sign-out, client-leaked/in-source API keys, ungitignored .env, mixed env) against the install dir with no network. - Wire into agent-runner: the self-correction loop now feeds security findings back to the agent, and a final gate throws on error-severity findings that survive retries (success: false, non-zero exit, commit step skipped). - Point the SIGNOUT_GET_HANDLER finding at a live docs URL (old /docs/authkit/sign-out 404s). * chore(deps): bump @workos/skills to 0.6.1 Picks up the Next.js sign-out POST server-action guidance (workos/skills#33), so the installer agent no longer scaffolds an unsafe GET sign-out route. * chore: formatting * test: cover API_KEY_LEAKED_TO_CLIENT blocking path; clarify warning comment Addresses Greptile review on #175: - Add a unit test for the API_KEY_LEAKED_TO_CLIENT blocking code (a NEXT_PUBLIC_-prefixed secret in .env.local), mirroring the API_KEY_IN_SOURCE test, so a regression in the prefix/key detection is caught. - Correct the self-correction comment: warning findings ride along only when a retry is already triggered by an error or build failure; they are otherwise surfaced in the final validation report. * fix: emit retry-summary telemetry only after the security gate passes Addresses Greptile review on #175: - Move the 'agent retry summary' (passed_after_retry: true) capture below the security gate so a blocked install no longer emits a contradictory pass-after-retry event alongside 'security gate blocked install'. - Use obviously-fake fixture key strings in the spec (still satisfy the detection regex) so secrets scanners and the no-hardcoded-secrets rule stay quiet.
1 parent 8c0919f commit 5a50c77

6 files changed

Lines changed: 340 additions & 24 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"@workos-inc/node": "^8.7.0",
5555
"@workos/migrations": "^2.0.0",
5656
"@workos/openapi-spec": "^0.1.0",
57-
"@workos/skills": "0.6.0",
57+
"@workos/skills": "0.6.1",
5858
"chalk": "^5.6.2",
5959
"diff": "^8.0.3",
6060
"fast-glob": "^3.3.3",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/doctor/checks/auth-patterns.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ function checkSignoutGetHandler(ctx: CheckContext): AuthPatternFinding[] {
136136
filePath: relative(ctx.installDir, route),
137137
remediation:
138138
'Convert to a POST server action. GET routes with side effects are vulnerable to CSRF and will be triggered by Next.js link prefetching.',
139-
docsUrl: 'https://workos.com/docs/authkit/sign-out',
139+
docsUrl: 'https://workos.com/docs/authkit/nextjs#ending-the-session',
140140
});
141141
}
142142
}

src/lib/agent-runner.ts

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { getReference } from '@workos/skills';
22
import { SPINNER_MESSAGE, type FrameworkConfig } from './framework-config.js';
33
import { validateInstallation, quickCheckValidateAndFormat } from './validation/index.js';
4+
import {
5+
runInstallSecurityChecks,
6+
securityFindingsToIssues,
7+
formatSecurityFindingsForAgent,
8+
formatBlockingSecurityError,
9+
} from './validation/security-checks.js';
410
import type { InstallerOptions } from '../utils/types.js';
511
import {
612
ensurePackageIsInstalled,
@@ -114,11 +120,27 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal
114120
options,
115121
);
116122

123+
const integration = config.metadata.integration;
124+
117125
const retryConfig: RetryConfig | undefined = options.noValidate
118126
? undefined
119127
: {
120128
maxRetries: options.maxRetries ?? 2,
121-
validateAndFormat: quickCheckValidateAndFormat,
129+
// Self-correction combines two layers: build/typecheck (existing) AND the
130+
// security subset of doctor's auth-pattern checks. The latter is what was
131+
// missing — it's why an insecure GET sign-out could pass the build and
132+
// ship as a "successful" install. Only error-severity security findings
133+
// force a retry; warning findings ride along in the prompt only when a
134+
// retry is already triggered by an error or a build failure (warnings are
135+
// still surfaced in the final validation report regardless).
136+
validateAndFormat: async (workingDirectory: string) => {
137+
const quickPrompt = await quickCheckValidateAndFormat(workingDirectory);
138+
const security = await runInstallSecurityChecks(integration, workingDirectory);
139+
if (quickPrompt === null && security.blocking.length === 0) return null;
140+
return [quickPrompt, formatSecurityFindingsForAgent(security.findings)]
141+
.filter((p): p is string => Boolean(p))
142+
.join('\n\n');
143+
},
122144
};
123145

124146
// Run agent with retry support — agent gets correction prompts on validation failure
@@ -144,34 +166,58 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal
144166
throw new Error(message);
145167
}
146168

147-
// Track retry metrics
148-
if (agentResult.retryCount !== undefined && agentResult.retryCount > 0) {
149-
analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, {
150-
action: 'agent retry summary',
151-
retry_count: agentResult.retryCount,
152-
max_retries: options.maxRetries ?? 2,
153-
passed_after_retry: true,
154-
});
155-
}
156-
157169
// Run full validation after agent (with retries) completes
158170
// Quick checks already ran inside the retry loop — skip build
159171
if (!options.noValidate) {
160-
options.emitter?.emit('validation:start', { framework: config.metadata.integration });
172+
options.emitter?.emit('validation:start', { framework: integration });
161173

162-
const validationResult = await validateInstallation(config.metadata.integration, options.installDir, {
174+
const validationResult = await validateInstallation(integration, options.installDir, {
163175
runBuild: false,
164176
});
165177

166-
if (validationResult.issues.length > 0) {
167-
options.emitter?.emit('validation:issues', { issues: validationResult.issues });
178+
// Run doctor's security subset as the final gate. Its absence here is the
179+
// install-validate ↔ doctor gap: install reported success while `workos
180+
// doctor` immediately found a SIGNOUT_GET_HANDLER hole.
181+
const security = await runInstallSecurityChecks(integration, options.installDir);
182+
const allIssues = [...validationResult.issues, ...securityFindingsToIssues(security.findings)];
183+
184+
if (allIssues.length > 0) {
185+
options.emitter?.emit('validation:issues', { issues: allIssues });
168186
}
169187

170188
options.emitter?.emit('validation:complete', {
171-
passed: validationResult.passed,
172-
issueCount: validationResult.issues.length,
189+
passed: validationResult.passed && security.blocking.length === 0,
190+
issueCount: allIssues.length,
173191
durationMs: validationResult.durationMs,
174192
});
193+
194+
// Block success: an error-severity security finding that survived the
195+
// self-correction retries fails the install rather than shipping silently.
196+
// Throwing routes through the state machine's error state (success: false,
197+
// non-zero exit) and skips the commit/PR steps, leaving the insecure code
198+
// uncommitted for the user to inspect.
199+
if (security.blocking.length > 0) {
200+
analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, {
201+
action: 'security gate blocked install',
202+
integration,
203+
codes: security.blocking.map((f) => f.code).join(','),
204+
});
205+
await analytics.shutdown('error');
206+
throw new Error(formatBlockingSecurityError(security.blocking));
207+
}
208+
}
209+
210+
// Track retry metrics AFTER the security gate. `passed_after_retry` must
211+
// reflect a genuinely successful install, not just an exhausted retry loop —
212+
// emitting it before the gate could pair a "passed after retry" event with a
213+
// "security gate blocked install" failure for the same run.
214+
if (agentResult.retryCount !== undefined && agentResult.retryCount > 0) {
215+
analytics.capture(INSTALLER_INTERACTION_EVENT_NAME, {
216+
action: 'agent retry summary',
217+
retry_count: agentResult.retryCount,
218+
max_retries: options.maxRetries ?? 2,
219+
passed_after_retry: true,
220+
});
175221
}
176222

177223
// Build environment variables from WorkOS credentials
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import {
6+
runInstallSecurityChecks,
7+
securityFindingsToIssues,
8+
formatSecurityFindingsForAgent,
9+
formatBlockingSecurityError,
10+
} from './security-checks.js';
11+
import type { AuthPatternFinding } from '../../doctor/types.js';
12+
13+
function createTempDir(): string {
14+
return mkdtempSync(join(tmpdir(), 'install-security-'));
15+
}
16+
17+
function writeFixtureFile(dir: string, relativePath: string, content: string) {
18+
const fullPath = join(dir, relativePath);
19+
mkdirSync(join(fullPath, '..'), { recursive: true });
20+
writeFileSync(fullPath, content);
21+
}
22+
23+
describe('runInstallSecurityChecks', () => {
24+
let testDir: string;
25+
26+
beforeEach(() => {
27+
testDir = createTempDir();
28+
});
29+
30+
afterEach(() => {
31+
rmSync(testDir, { recursive: true, force: true });
32+
});
33+
34+
it('flags a GET sign-out route for Next.js as a blocking finding', async () => {
35+
writeFixtureFile(testDir, 'app/auth/signout/route.ts', 'export async function GET() { return signOut(); }');
36+
37+
const { findings, blocking } = await runInstallSecurityChecks('nextjs', testDir);
38+
39+
const signout = findings.find((f) => f.code === 'SIGNOUT_GET_HANDLER');
40+
expect(signout).toBeDefined();
41+
expect(blocking.map((f) => f.code)).toContain('SIGNOUT_GET_HANDLER');
42+
});
43+
44+
it('returns no blocking findings for a clean POST sign-out install', async () => {
45+
writeFixtureFile(
46+
testDir,
47+
'app/auth/actions.ts',
48+
"'use server';\nexport async function signOutAction() { await signOut(); }",
49+
);
50+
51+
const { blocking } = await runInstallSecurityChecks('nextjs', testDir);
52+
53+
expect(blocking).toEqual([]);
54+
});
55+
56+
it('treats a hardcoded API key in source as blocking (framework-agnostic)', async () => {
57+
writeFixtureFile(testDir, 'app/page.tsx', 'const key = "sk_test_FIXTUREKEYFORTESTING1";');
58+
59+
const { blocking } = await runInstallSecurityChecks('nextjs', testDir);
60+
61+
expect(blocking.map((f) => f.code)).toContain('API_KEY_IN_SOURCE');
62+
});
63+
64+
it('treats a client-exposed secret API key as blocking', async () => {
65+
// NEXT_PUBLIC_ prefix ships the secret to the browser bundle.
66+
writeFixtureFile(testDir, '.env.local', 'NEXT_PUBLIC_WORKOS_API_KEY=sk_live_FIXTUREKEYFORTESTING1\n');
67+
68+
const { blocking } = await runInstallSecurityChecks('nextjs', testDir);
69+
70+
expect(blocking.map((f) => f.code)).toContain('API_KEY_LEAKED_TO_CLIENT');
71+
});
72+
73+
it('reports warning-severity findings without blocking', async () => {
74+
// .env.local present but not gitignored -> ENV_FILE_NOT_GITIGNORED (warning)
75+
writeFixtureFile(testDir, '.env.local', 'WORKOS_CLIENT_ID=client_test\n');
76+
77+
const { findings, blocking } = await runInstallSecurityChecks('nextjs', testDir);
78+
79+
expect(findings.map((f) => f.code)).toContain('ENV_FILE_NOT_GITIGNORED');
80+
expect(blocking).toEqual([]);
81+
});
82+
83+
it('still runs cross-framework checks for an unknown integration', async () => {
84+
writeFixtureFile(testDir, 'src/app.ts', 'const key = "sk_live_FIXTUREKEYFORTESTING1";');
85+
86+
const { blocking } = await runInstallSecurityChecks('some-backend', testDir);
87+
88+
expect(blocking.map((f) => f.code)).toContain('API_KEY_IN_SOURCE');
89+
});
90+
});
91+
92+
describe('securityFindingsToIssues', () => {
93+
it('maps findings to pattern issues, folding filePath into the message', () => {
94+
const findings: AuthPatternFinding[] = [
95+
{
96+
code: 'SIGNOUT_GET_HANDLER',
97+
severity: 'error',
98+
message: 'Signout uses GET',
99+
filePath: 'app/auth/signout/route.ts',
100+
remediation: 'Use a POST server action.',
101+
},
102+
];
103+
104+
const issues = securityFindingsToIssues(findings);
105+
106+
expect(issues).toEqual([
107+
{
108+
type: 'pattern',
109+
severity: 'error',
110+
message: 'Signout uses GET (app/auth/signout/route.ts)',
111+
hint: 'Use a POST server action.',
112+
},
113+
]);
114+
});
115+
});
116+
117+
describe('formatSecurityFindingsForAgent', () => {
118+
it('returns an empty string when there are no findings', () => {
119+
expect(formatSecurityFindingsForAgent([])).toBe('');
120+
});
121+
122+
it('includes the message, location, and remediation for each finding', () => {
123+
const prompt = formatSecurityFindingsForAgent([
124+
{
125+
code: 'SIGNOUT_GET_HANDLER',
126+
severity: 'error',
127+
message: 'Signout uses GET',
128+
filePath: 'app/auth/signout/route.ts',
129+
remediation: 'Use a POST server action.',
130+
},
131+
]);
132+
133+
expect(prompt).toContain('Signout uses GET');
134+
expect(prompt).toContain('app/auth/signout/route.ts');
135+
expect(prompt).toContain('Use a POST server action.');
136+
});
137+
});
138+
139+
describe('formatBlockingSecurityError', () => {
140+
it('lists each blocking finding by code', () => {
141+
const message = formatBlockingSecurityError([
142+
{ code: 'SIGNOUT_GET_HANDLER', severity: 'error', message: 'Signout uses GET' },
143+
]);
144+
145+
expect(message).toContain('SIGNOUT_GET_HANDLER');
146+
expect(message).toContain('workos doctor');
147+
});
148+
});

0 commit comments

Comments
 (0)