"A IA não erra por maldade. Erra por padrão. E o padrão dela é funcionalidade — não segurança."

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!
export const supabase = createClient(supabaseUrl, supabaseKey).env.local e hardcoda o valor:// lib/supabase.ts
const supabase = createClient(
'https://xyzproject.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6...'
)// components/Pricing.tsx
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)NEXT_PUBLIC_ é incluída no bundle JavaScript que o navegador do usuário baixa. Qualquer pessoa pode abrir o DevTools, ir na aba Sources, e procurar por strings como eyJ (início de JWT base64), sk_live_ (Stripe secret key), ou service_role.secret_key permite criar cobranças, acessar dados de clientes, modificar assinaturas e emitir reembolsos. Quando o secret está hardcoded (segundo exemplo), o risco é duplo: além da exposição no frontend, o valor vai para o repositório git. Bots automatizados escaneiam repositórios públicos no GitHub em tempo real. Pesquisadores já demonstraram que um secret pushado para um repo público é detectado em menos de 30 segundos.// lib/supabase-client.ts — CLIENT (browser)
import { createBrowserClient } from '@supabase/ssr'
export const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // anon key — segura para o frontend
)// lib/supabase-server.ts — SERVER ONLY
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import 'server-only' // impede importação no client
export async function createSupabaseServer() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // SEM prefixo NEXT_PUBLIC_
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookies) =>
cookies.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
),
},
}
)
}// lib/env.ts — Validação de variáveis de ambiente
import { z } from 'zod'
const envSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
})
export const env = envSchema.parse(process.env)NEXT_PUBLIC_ apenas para a SUPABASE_URL e a ANON_KEY. Toda outra chave fica sem o prefixo — invisível para o browser.import 'server-only' no topo do arquivo server garante um erro de build se alguém tentar importar no client.NEXT_PUBLIC_ (exceto SUPABASE_URL e SUPABASE_ANON_KEY).env para dentro do códigoimport 'server-only' em módulos que acessam secrets// app/api/invoices/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('id', params.id)
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json(data)
}// app/api/invoices/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createSupabaseServer } from '@/lib/supabase-server'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const supabase = await createSupabaseServer()
// SECURITY: Verificar sessão
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// SECURITY: Buscar invoice E verificar ownership na mesma query
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('id', params.id)
.eq('user_id', user.id) // ownership check
.single()
if (error || !data) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(data)
}.eq('user_id', user.id) na query — o banco só retorna a invoice se ela pertencer ao usuário..eq('user_id', user.id) ou equivalente// app/api/products/search/route.ts
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get('q') || ''
const { data, error } = await supabase
.rpc('search_products', { search_term: query })
return NextResponse.json(data)
}-- VULNERÁVEL: usa EXECUTE com concatenação direta
CREATE OR REPLACE FUNCTION search_products(search_term TEXT)
RETURNS SETOF products AS $$
BEGIN
RETURN QUERY EXECUTE
'SELECT * FROM products WHERE name ILIKE ''%' || search_term || '%''';
END;
$$ LANGUAGE plpgsql;// Prisma raw query — VULNERÁVEL
const results = await prisma.$queryRawUnsafe(
`SELECT * FROM products WHERE name ILIKE '%${query}%'`
)EXECUTE com concatenação direta do search_term. Um atacante pode enviar como parâmetro de busca:%'; DROP TABLE products; --SELECT * FROM products WHERE name ILIKE '%%'; DROP TABLE products; --%'-- Função RPC segura com parameterized query
CREATE OR REPLACE FUNCTION search_products(search_term TEXT)
RETURNS SETOF products AS $$
BEGIN
RETURN QUERY
SELECT * FROM products WHERE name ILIKE '%' || search_term || '%';
END;
$$ LANGUAGE plpgsql;EXECUTE, o PostgreSQL trata search_term como um valor, não como parte do SQL. Não há possibilidade de injeção. Para a API route, adicione validação:// app/api/products/search/route.ts
import { z } from 'zod'
const searchSchema = z.object({
q: z.string().min(1).max(100).trim(),
})
export async function GET(request: NextRequest) {
const parsed = searchSchema.safeParse({
q: request.nextUrl.searchParams.get('q'),
})
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid search query' }, { status: 400 })
}
const supabase = await createSupabaseServer()
const { data, error } = await supabase
.from('products')
.select('id, name, price, image_url')
.ilike('name', `%${parsed.data.q}%`)
.limit(20)
return NextResponse.json(data)
}// Prisma seguro
const results = await prisma.$queryRaw`
SELECT * FROM products WHERE name ILIKE ${'%' + query + '%'}
`
// Ou melhor ainda, use o query builder:
const results = await prisma.product.findMany({
where: { name: { contains: query, mode: 'insensitive' } },
take: 20,
})$queryRawUnsafe do Prisma ou EXECUTE com concatenação no PostgreSQL.limit() para evitar data dumpingSELECT *)// components/BlogPost.tsx
export function BlogPost({ post }: { post: Post }) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<p dangerouslySetInnerHTML={{ __html: post.authorBio }} />
</article>
)
}// components/Comment.tsx
export function Comment({ comment }: { comment: Comment }) {
return (
<div>
<strong>{comment.userName}</strong>
<div dangerouslySetInnerHTML={{ __html: comment.body }} />
</div>
)
}dangerouslySetInnerHTML existe no React por um motivo — e o nome não é por acaso. Ele renderiza HTML bruto no DOM sem nenhuma sanitização. Se o conteúdo vem de um input de usuário (comentário, bio, post), um atacante pode injetar JavaScript que será executado no navegador de todos que visualizarem aquele conteúdo.// components/SafeHTML.tsx
import DOMPurify from 'isomorphic-dompurify'
interface SafeHTMLProps {
html: string
className?: string
}
export function SafeHTML({ html, className }: SafeHTMLProps) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br',
'ul', 'ol', 'li', 'h2', 'h3', 'blockquote', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
})
return <div className={className} dangerouslySetInnerHTML={{ __html: clean }} />
}// components/BlogPost.tsx — SEGURO
import { SafeHTML } from '@/components/SafeHTML'
export function BlogPost({ post }: { post: Post }) {
return (
<article>
<h1>{post.title}</h1>
<SafeHTML html={post.content} />
</article>
)
}// components/Comment.tsx — SEGURO (sem HTML, apenas texto)
export function Comment({ comment }: { comment: Comment }) {
return (
<div>
<strong>{comment.userName}</strong>
<p>{comment.body}</p> {/* React escapa automaticamente */}
</div>
)
}{}. Use {comment.body} direto, sem dangerouslySetInnerHTML. Reserve o HTML renderizado apenas para conteúdo rico (blog posts, documentos) que realmente precisa de formatação.dangerouslySetInnerHTML sem sanitização com DOMPurifySafeHTML) com whitelist de tags permitidas// app/api/auth/login/route.ts — INSEGURO
export async function POST(request: NextRequest) {
const { email, password } = await request.json()
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
return NextResponse.json({ error: error.message }, { status: 401 })
}
return NextResponse.json({
token: data.session?.access_token,
refresh_token: data.session?.refresh_token,
user: data.user
})
}// lib/auth.ts — INSEGURO
export async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) })
const data = await res.json()
localStorage.setItem('access_token', data.token)
localStorage.setItem('refresh_token', data.refresh_token)
return data.user
}// middleware.ts — INCOMPLETO
export function middleware(request: NextRequest) {
// Verifica apenas se o token existe, não se é válido
const token = request.cookies.get('token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}access_token, o atacante faz requisições como se fosse o usuário. Com o refresh_token, o atacante mantém acesso permanente.token existe, mas não valida se é um JWT legítimo, se não expirou, se não foi revogado. Um cookie com qualquer valor — token=123 — passa pela verificação.error.message do Supabase pode revelar se um email está cadastrado ("User not found" vs "Invalid password"), permitindo enumeração de contas.// lib/supabase-middleware.ts
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, {
...options,
httpOnly: true, // JS não pode ler
secure: true, // Apenas HTTPS
sameSite: 'lax', // Anti-CSRF
})
})
},
},
}
)
// SECURITY: Valida o token real, não apenas a existência do cookie
const { data: { user } } = await supabase.auth.getUser()
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
}// app/api/auth/login/route.ts — SEGURO
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 tentativas por minuto
})
export async function POST(request: NextRequest) {
// SECURITY: Rate limiting por IP
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
}
const { data, error } = await supabase.auth.signInWithPassword({
email: parsed.data.email,
password: parsed.data.password,
})
if (error) {
// SECURITY: Mensagem genérica — não revela se o email existe
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
// Sessão gerenciada via cookies httpOnly pelo Supabase SSR — nada no localStorage
return NextResponse.json({ user: { id: data.user.id, email: data.user.email } })
}auth.getUser()), não apenas a existência do cookiehttpOnly: true, secure: true, sameSite: 'lax'-- migrations/001_initial.sql — SEM RLS
CREATE TABLE profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
owner_id UUID REFERENCES profiles(id),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
status TEXT DEFAULT 'todo',
project_id UUID REFERENCES projects(id),
assigned_to UUID REFERENCES profiles(id),
created_at TIMESTAMPTZ DEFAULT now()
);ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow all" ON tasks
FOR ALL USING (true) WITH CHECK (true);// Qualquer visitante pode fazer isso no console do navegador
const { data } = await supabase.from('profiles').select('*')
// Retorna TODOS os perfis de TODOS os usuários
const { data: tasks } = await supabase.from('tasks').select('*')
// Retorna TODAS as tarefas de TODOS os projetos-- migrations/001_initial.sql — SEGURO
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Usuário só vê seu próprio perfil
CREATE POLICY "Users can view own profile" ON profiles
FOR SELECT USING (auth.uid() = id);
-- Usuário só edita seu próprio perfil
CREATE POLICY "Users can update own profile" ON profiles
FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Dono vê seus projetos
CREATE POLICY "Owners can view own projects" ON projects
FOR SELECT USING (auth.uid() = owner_id);
-- Dono pode criar projetos (owner_id setado automaticamente)
CREATE POLICY "Users can create projects" ON projects
FOR INSERT WITH CHECK (auth.uid() = owner_id);
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- Usuário vê tasks dos projetos que ele possui
CREATE POLICY "Users can view tasks of own projects" ON tasks
FOR SELECT USING (
EXISTS (
SELECT 1 FROM projects
WHERE projects.id = tasks.project_id
AND projects.owner_id = auth.uid()
)
);ALTER TABLE x ENABLE ROW LEVEL SECURITYUSING (true) ou WITH CHECK (true) sem justificativa documentadaauth.uid() para verificar ownership nas policiesEXISTS com subquery na tabela pai// app/api/profile/route.ts — INSEGURO
export async function PUT(request: NextRequest) {
const body = await request.json()
const userId = body.userId
const { data, error } = await supabase
.from('profiles')
.update(body) // ← body inteiro, sem filtro
.eq('id', userId)
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json(data)
}profiles tem campos como role, subscription_tier, is_admin, ou credits, o atacante pode enviar:{
"userId": "meu-id",
"full_name": "Nome Normal",
"role": "admin",
"subscription_tier": "enterprise",
"credits": 999999
}// app/api/profile/route.ts — SEGURO
import { z } from 'zod'
// SECURITY: Schema define EXATAMENTE quais campos são aceitos
const updateProfileSchema = z.object({
full_name: z.string().min(1).max(100).optional(),
avatar_url: z.string().url().optional(),
bio: z.string().max(500).optional(),
})
export async function PUT(request: NextRequest) {
const supabase = await createSupabaseServer()
// SECURITY: Verificar sessão
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// SECURITY: Validar e filtrar input
const body = await request.json()
const parsed = updateProfileSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid data' }, { status: 400 })
}
// SECURITY: Apenas campos validados são enviados ao banco
// SECURITY: user.id vem da sessão, não do request body
const { data, error } = await supabase
.from('profiles')
.update(parsed.data)
.eq('id', user.id)
.select('id, full_name, avatar_url, bio')
.single()
return NextResponse.json(data)
}role, is_admin, credits são ignorados.user.id vem da sessão autenticada, não do body — o atacante não pode alterar o perfil de outro usuário..select() filtra os campos retornados — não vaza colunas internas.req.body ou request.json() diretamente para .update() ou .insert()auth.getUser()), nunca do request body.select() explícitorole, is_admin, subscription, credits NUNCA devem ser aceitos via API pública// app/api/auth/forgot-password/route.ts — SEM RATE LIMIT
export async function POST(request: NextRequest) {
const { email } = await request.json()
await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_URL}/auth/reset`,
})
return NextResponse.json({ message: 'Check your email' })
}// app/api/contact/route.ts — SEM RATE LIMIT
export async function POST(request: NextRequest) {
const { name, email, message } = await request.json()
await sendEmail({ to: 'admin@example.com', subject: `Contact: ${name}`, body: message })
return NextResponse.json({ success: true })
}// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
// Rate limiters com diferentes limites por contexto
export const authLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 req/min para auth
prefix: 'rl:auth',
})
export const apiLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(30, '1 m'), // 30 req/min para APIs
prefix: 'rl:api',
})
export const contactLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(3, '1 h'), // 3 req/hora para contato
prefix: 'rl:contact',
})// lib/with-rate-limit.ts — Helper reutilizável
export async function withRateLimit(
request: NextRequest,
limiter: Ratelimit
): Promise<NextResponse | null> {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const { success, remaining, reset } = await limiter.limit(ip)
if (!success) {
return NextResponse.json(
{ error: 'Too many requests. Try again later.' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
}
}
)
}
return null // sem bloqueio, prosseguir
}// app/api/auth/forgot-password/route.ts — SEGURO
export async function POST(request: NextRequest) {
// SECURITY: Rate limiting
const blocked = await withRateLimit(request, authLimiter)
if (blocked) return blocked
// SECURITY: Resposta idêntica para email existente ou não
return NextResponse.json({ message: 'If an account exists, check your email.' })
}X-RateLimit-Remaining, Retry-After)next.config.js funcional, mas sem nenhuma configuração de segurança:// next.config.js — SEM HEADERS DE SEGURANÇA
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['your-project.supabase.co'],
},
}
module.exports = nextConfig// CORS permissivo — INSEGURO
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': '*',
},
})frame-ancestors no CSP, o site pode ser carregado dentro de um iframe em um site malicioso — o atacante cria uma página que sobrepõe botões invisíveis sobre o iframe do seu site, fazendo o usuário clicar em ações sem perceber. Isso se chama clickjacking.Access-Control-Allow-Origin: *) significa que qualquer site da internet pode fazer requisições à sua API. Se o usuário está logado no seu SaaS e visita um site malicioso, esse site pode usar o fetch para chamar suas APIs usando os cookies do usuário — lendo e modificando dados.// next.config.js — SEGURO
const nextConfig = {
images: {
remotePatterns: [{ protocol: 'https', hostname: '*.supabase.co' }],
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'X-DNS-Prefetch-Control', value: 'off' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://js.stripe.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' https://*.supabase.co data: blob:",
"connect-src 'self' https://*.supabase.co https://api.stripe.com",
"frame-src https://js.stripe.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ')
},
],
},
]
},
}
module.exports = nextConfig// lib/cors.ts
const ALLOWED_ORIGINS = [
'https://seudominio.com.br',
'https://app.seudominio.com.br',
]
export function getCorsHeaders(request: NextRequest) {
const origin = request.headers.get('origin')
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin)
return {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
}next.config.js (CSP, HSTS, X-Frame-Options, etc.)Access-Control-Allow-Origin: * — sempre especifique domínios permitidosnpm install express-validator # OK — pacote legítimo
npm install mongo-sanitize # Problema — pacote abandonado, 7 anos sem update
npm install react-markdown-it # Problema — nome similar ao legítimo (react-markdown)
npm install lodash.get # Problema — micro-pacote desnecessário, target de supply chain.gitignore gerado frequentemente exclui o lockfile:# .gitignore gerado pela IA
node_modules/
.env
.env.local
package-lock.json # ← ERRADO: lockfile deveria ser commitadonpm install, o malware é instalado no seu projeto. Demonstrado em escala em 2024.package-lock.json commitado, cada npm install em um novo ambiente resolve as versões novamente. Se uma dependência publicou uma versão maliciosa entre o seu último install e o deploy, o ambiente de produção instala a versão comprometida.# 1. Audite as dependências atuais
npm audit
# 2. Corrija vulnerabilidades automáticas
npm audit fix
# 3. Para vulnerabilidades que precisam de major version bump
npm audit fix --force # CUIDADO: pode quebrar compatibilidade
# 4. Verifique pacotes suspeitos com Socket.dev
npx socket optimize
# 5. Verifique o lockfile está commitado
git add package-lock.json// package.json — adicione scripts de segurança
{
"scripts": {
"preinstall": "npx only-allow npm",
"audit": "npm audit --audit-level=high",
"audit:fix": "npm audit fix",
"deps:check": "npx depcheck",
"deps:outdated": "npm outdated"
}
}# .github/workflows/security.yml — CI/CD check
name: Security Audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm audit --audit-level=high# .gitignore — CORRETO
node_modules/
.env
.env.local
.env.production
# NÃO inclua package-lock.json aqui — ele DEVE ser commitadopackage-lock.json no repositórionpm audit antes do deploynpx socket optimize para detectar riscos de supply chainnpm audit --audit-level=high como step obrigatório