feat(security): block credentials from reaching the LLM via prompt secret scan
All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s
Prompts were sent to the model with no secret scan, so a pasted API key would leak to the LLM. Added findSecretInPrompt in @bmm/types (tight provider-key patterns: Anthropic/OpenAI/GitHub/AWS/Google/Slack/Stripe/JWT/private-key) shared by both sides. The web wizard blocks before sending with a clear message; the API preview and preview-stream endpoints reject with secret_in_prompt as the hard guarantee. Credential VALUES already never touched the model - they are entered in the separate encrypted step 2; this closes the remaining leak path where a user pastes a key into the prompt itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee4713f82c
commit
be02600759
@ -28,6 +28,7 @@ import {
|
|||||||
IterateServerInput,
|
IterateServerInput,
|
||||||
PreviewInput,
|
PreviewInput,
|
||||||
type SpecEdit,
|
type SpecEdit,
|
||||||
|
findSecretInPrompt,
|
||||||
} from '@bmm/types';
|
} from '@bmm/types';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -62,6 +63,16 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
// Never let a credential reach the LLM. Reject prompts that contain a
|
||||||
|
// real-looking key/token before the model call. (Values belong in the
|
||||||
|
// separate encrypted credential fields, not the prompt.)
|
||||||
|
const leakedSecret = findSecretInPrompt(parsed.data.prompt);
|
||||||
|
if (leakedSecret) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'secret_in_prompt',
|
||||||
|
detail: `Your prompt looks like it contains ${leakedSecret}. Remove it — API keys must never go in the prompt (it is sent to the AI model). You will add credentials in their own encrypted fields after the spec is generated.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const billing = await getOrgBilling(user.orgId);
|
const billing = await getOrgBilling(user.orgId);
|
||||||
if (billing.suspended) {
|
if (billing.suspended) {
|
||||||
@ -190,6 +201,16 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
// Never let a credential reach the LLM. Reject prompts that contain a
|
||||||
|
// real-looking key/token before the model call. (Values belong in the
|
||||||
|
// separate encrypted credential fields, not the prompt.)
|
||||||
|
const leakedSecret = findSecretInPrompt(parsed.data.prompt);
|
||||||
|
if (leakedSecret) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'secret_in_prompt',
|
||||||
|
detail: `Your prompt looks like it contains ${leakedSecret}. Remove it — API keys must never go in the prompt (it is sent to the AI model). You will add credentials in their own encrypted fields after the spec is generated.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const billing = await getOrgBilling(user.orgId);
|
const billing = await getOrgBilling(user.orgId);
|
||||||
if (billing.suspended) {
|
if (billing.suspended) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { InstallSnippets } from '@/components/install-snippets';
|
|||||||
import { StreamingLogs } from '@/components/streaming-logs';
|
import { StreamingLogs } from '@/components/streaming-logs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiFetch, apiSseStream } from '@/lib/api';
|
import { apiFetch, apiSseStream } from '@/lib/api';
|
||||||
|
import { findSecretInPrompt } from '@bmm/types';
|
||||||
import { Loader2, RotateCcw, X } from 'lucide-react';
|
import { Loader2, RotateCcw, X } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
@ -240,6 +241,16 @@ function NewServerPageInner() {
|
|||||||
setError('Name and slug are required.');
|
setError('Name and slug are required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep credentials out of the model: block a prompt that contains a real
|
||||||
|
// key/token before it is ever sent. Credentials go in the encrypted fields
|
||||||
|
// in the next step, never in the prompt.
|
||||||
|
const leaked = findSecretInPrompt(prompt);
|
||||||
|
if (leaked) {
|
||||||
|
setError(
|
||||||
|
`Looks like your prompt contains ${leaked}. Remove it — API keys must never go in the prompt (it is sent to the AI). You'll add credentials in their own encrypted fields in the next step.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStep('analyzing');
|
setStep('analyzing');
|
||||||
|
|
||||||
// Streaming preview: pipes Anthropic's token deltas back as SSE. Cloudflare's
|
// Streaming preview: pipes Anthropic's token deltas back as SSE. Cloudflare's
|
||||||
|
|||||||
@ -212,3 +212,30 @@ export const InstallTarget = z.enum([
|
|||||||
'raw-url',
|
'raw-url',
|
||||||
]);
|
]);
|
||||||
export type InstallTarget = z.infer<typeof InstallTarget>;
|
export type InstallTarget = z.infer<typeof InstallTarget>;
|
||||||
|
|
||||||
|
// ---- Prompt secret guard ----
|
||||||
|
// Unambiguous provider key/token shapes. Used to reject a prompt that contains
|
||||||
|
// a real credential BEFORE it is sent to the LLM — keys must never reach the
|
||||||
|
// model. Kept tight (provider prefixes / structural markers) so normal prompts
|
||||||
|
// don't trip it. Shared by the API preview endpoints and the web wizard.
|
||||||
|
const PROMPT_SECRET_PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [
|
||||||
|
{ name: 'an Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
||||||
|
{ name: 'an OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}/ },
|
||||||
|
{ name: 'an OpenAI key', re: /\bsk-[A-Za-z0-9]{32,}\b/ },
|
||||||
|
{ name: 'a GitHub token', re: /\bgh[posru]_[A-Za-z0-9]{30,}\b/ },
|
||||||
|
{ name: 'an AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
||||||
|
{ name: 'a Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
||||||
|
{ name: 'a Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
|
||||||
|
{ name: 'a Stripe secret key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/ },
|
||||||
|
{ name: 'a Stripe publishable key', re: /\bpk_(?:live|test)_[A-Za-z0-9]{20,}/ },
|
||||||
|
{ name: 'a JWT / bearer token', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
|
||||||
|
{ name: 'a private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** First secret-like token found in the text, else null. */
|
||||||
|
export function findSecretInPrompt(text: string): string | null {
|
||||||
|
for (const { name, re } of PROMPT_SECRET_PATTERNS) {
|
||||||
|
if (re.test(text)) return name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user