Dia 6 — Segurança do servidor

Informatica · Conteudo · publicado em 30/09/2026
Dia 6 de 15

Segurança do servidor

Aula 1

CORS, cabeçalhos e limites de taxa

Os três guardas da mesma porta

O helmet e o express-rate-limit não estão instalados no material, então o exemplo reproduz o que eles fazem: cabeçalhos na resposta, um balde de fichas por IP, e a decisão de origem. O comportamento é o que importa, e ele é medido.

A ordem deles é a ordem da ameaça:

GuardaPergunta que responde
CORSo navegador pode mostrar esta resposta na tela?
cabeçalho de segurançao que o navegador faz com o que recebeu?
rate limitquantas vezes este cliente pode chamar?

Nenhum dos três impede a requisição de chegar no servidor. CORS não é firewall, cabeçalho não é autenticação, e rate limit não é validação de senha.

CORS é sobre a leitura da resposta

CORS responde a uma pergunta única: o navegador pode entregar essa resposta para o código da página? Quem aplica a regra é o navegador, não o servidor — e é por isso que o servidor não pode "negar": ele apenas não entrega o header.

O exemplo faz a mesma chamada duas vezes, mudando só o Origin:

POST com origem permitida  -> status 200, Access-Control-Allow-Origin: https://app.exemplo.com
POST com origem nao permit. -> status 401, Access-Control-Allow-Origin: (ausente)
mesma rota, mesma senha errada, mesmo status: o servidor se comportou igual
a UNICA diferenca e o header ausente

As duas requisições produziram o mesmo status — o login falhou pelo mesmo motivo nas duas. O que muda é o header: a segunda resposta não traz Access-Control-Allow-Origin, e o navegador entrega a resposta a um script que pode ler qualquer coisa.

O caminho do header é uma lista explícita:

const ORIGENS_PERMITIDAS = [
  'https://app.exemplo.com',
  'http://localhost:8080',
];

Access-Control-Allow-Origin: * vale para qualquer site. É o que a documentação sugeriu durante anos e o que transformou "API pública" em "API de qualquer página". O Vary: Origin é o detalhe que impede o cache de misturar as duas respostas — a resposta muda conforme a origem, e o cache precisa saber disso.

O preflight é a pergunta que o navegador faz antes do POST:

preflight OPTIONS           -> status 204 (o navegador pergunta antes de mandar o POST)
Access-Control-Max-Age: 600 — o navegador guarda a resposta do preflight por 600s

É um OPTIONS que não chega a tocar o banco. Quem responde 204 sem consulta é a diferença entre um servidor que aguenta 3 requisições e um que aguenta 6.

Cabeçalhos de segurança: instruções para o navegador

Content-Security-Policy    default-src 'self'; script-src 'self'; object-src 'none'
X-Content-Type-Options     nosniff
X-Frame-Options            DENY
Referrer-Policy            no-referrer
Strict-Transport-Security  max-age=31536000; includeSubDomains
X-Powered-By               (vazio, remove o header)

Cada um diz uma coisa:

CabeçalhoInstrução
Content-Security-Policyde onde o script pode vir
X-Content-Type-Options: nosniffnão adivinhar o tipo do arquivo
X-Frame-Options: DENYesta página não vai dentro de <iframe>
Referrer-Policyo que vaza na URL quando o usuário clica em link
Strict-Transport-Securitysó volta por HTTPS, por um ano
X-Powered-By vazionão declarar a tecnologia usada

X-Powered-By: Express é uma dica de graça para quem procura por \x3csomething\x3e,versão vulnerável. Vazio remove a informação.

helmet é uma lista pronta desses cabeçalhos — usar a biblioteca é melhor do que escrever a lista, porque a lista cresce e ninguém lembra de acrescentar o cabeçalho novo.

Rate limit: o balde de fichas

A ameaça que o limite de requisições existe para deter é o brute force: alguém repetindo login, senha por senha, sem saber a senha e sem querer saber quem é. A mesma forma aparece em outro alvo — alguém disparando requisição atrás de requisição até o servidor não dar conta. Isso é negação de serviço, e as duas são a mesma entrada pelo mesmo portão.

Limitar por IP é a forma mais comum de conter as duas, e a justificativa é operacional: o atacante tem uma origem, o balde é por origem, e a rajada acaba. Requisição por minuto é a unidade que se lê de cabeça — cinco por minuto no login, algumas centenas na listagem — e express-rate-limit aceita esse número direto.

O limite mais comum não é "não pode passar de N por minuto". É um balde com N fichas, e uma ficha que volta a cada segundo.

function criarLimitador({ capacidade, repousoMs }) {
  const baldes = new Map();   // ip -> { fichas, ultimoRepouso }
  // ...
}

O Map por IP é o que separa um usuário de outro: sem ele, o limite é global e uma pessoa atrás do mesmo proxy de escritório derruba o acesso de todo mundo.

O exemplo mede a transição real:

tentativa 1 -> 401  restantes=2
tentativa 2 -> 401  restantes=1
tentativa 3 -> 401  restantes=0
tentativa 4 -> 429  restantes=(sem header)  Retry-After=1s
tentativa 5 -> 429  restantes=(sem header)  Retry-After=1s

Três requisições chegaram ao login; da quarta em diante, 429 Too Many Requests. O contador vai 2, 1, 0 — e só depois do 429. O Retry-After diz quando tentar de novo, em segundos.

Duas decisões que o exemplo deixa explícitas:

O limite vem antes do trabalho. permite(ip) roda antes de qualquer consulta. Limitar depois de gastar MySQL deixa o ataque pagar o preço completo antes de ser barrado — o limite protege o banco, e para proteger o banco ele tem que vir antes.

O 429 não grava nada. Depois das cinco tentativas:

sessoes criadas no banco: 1 (so o login valido do teste de CORS gravou)
as 5 tentativas de forca bruta nao criaram sessao nenhuma

A força bruta deixa rastro no log, não no banco de sessão.

express-rate-limit resolve o balde pronto e ainda cuida do Map crescendo sem parar — com max ele descarta entradas antigas, que sem isso é um vazamento de memória lento em produção.

Atras de nginx ou cloudflare, req.socket.remoteAddress é o do proxy, não o do visitante. Todo mundo passa pelo mesmo endereço e o limite por IP derruba o site inteiro. O caminho é ler X-Forwarded-For — mas só quando o proxy é conhecido e o header não vem do cliente, porque quem manda header é quem está atacando.

Limite por IP não é limite por pessoa. Quem sai da mesma rede — empresa, escola, VPN — compartilha o balde. Pelo mesmo motivo, limite por usuário autenticado é mais justo que limite por IP: o token já diz quem é.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 6: CORS, cabecalhos de seguranca e limite de taxa.
//
// O `helmet` e o `express-rate-limit` nao estao instalados, entao o exemplo
// reproduz o que eles fazem: cabecalhos na resposta e um balde de tokens por
// IP. O comportamento e o que importa, e ele e medido aqui.
//
// A ordem dos tres e a ordem da ameaca: o CORS decide se o navegador DEIXA a
// resposta chegar na tela; o cabecalho decide o que o navegador FAZ com ela;
// o rate limit decide quantas vezes o cliente pode chamar.

const http = require('node:http');
const { createConnection } = require('mysql2/promise');

// ================================================== 1. os cabecalhos (helmet)
// Cabecalho = instrucao para o navegador. Nenhum deles impede a requisicao de
// chegar no servidor: eles valem o que valem na resposta que o navegador le.
function cabecalhosDeSeguranca() {
  return {
    'Content-Security-Policy':
      "default-src 'self'; script-src 'self'; object-src 'none'",
    'X-Content-Type-Options': 'nosniff',
    'X-Frame-Options': 'DENY',
    'Referrer-Policy': 'no-referrer',
    'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
    'X-Powered-By': '',
  };
}

// ================================================ 2. o limite de taxa (balde)
// Um balde por IP: `capacidade` requisicoes, e um "repouso" de `ms` que
// devolve uma ficha a cada janela. Sem o repouso, uma rajada estoura o balde
// inteiro mesmo com o limite "por minuto".
function criarLimitador({ capacidade, repousoMs }) {
  const baldes = new Map();   // ip -> { fichas, ultimoRepouso }

  return function permite(ip) {
    const agora = Date.now();
    let b = baldes.get(ip);

    if (!b) {
      b = { fichas: capacidade, ultimoRepouso: agora };
      baldes.set(ip, b);
    }

    const passado = agora - b.ultimoRepouso;
    const devolvidas = Math.floor(passado / repousoMs);
    if (devolvidas > 0) {
      b.fichas = Math.min(capacidade, b.fichas + devolvidas);
      b.ultimoRepouso += devolvidas * repousoMs;
    }

    if (b.fichas <= 0) return { permitido: false, restantes: 0, esperaMs: repousoMs - (agora - b.ultimoRepouso) };

    b.fichas -= 1;
    return { permitido: true, restantes: b.fichas, esperaMs: 0 };
  };
}

// ================================================== 3. o CORS, com origem
// CORS e uma pergunta de UMA coisa: "o navegador pode mostrar a resposta desta
// resposta para a origem X?". A resposta sai no header `Access-Control-*`.
//
// Regra que o exemplo aplica: uma origem por vez, vinda de uma lista. `*` e o
// perigo — ele vale para qualquer site, e e o que o esquema "publicado no
// git" usa.
function cabecalhoCors(origemDoCliente, permitidas) {
  const resposta = {
    'Vary': 'Origin',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '600',
  };
  if (permitidas.includes(origemDoCliente)) {
    resposta['Access-Control-Allow-Origin'] = origemDoCliente;
  }
  // A origem nao permitida NAO recebe o header. O navegador bloqueia sozinho —
  // e o servidor nao precisa negar nada.
  return resposta;
}

async function main() {
  const c = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  await c.query(`
    CREATE TABLE IF NOT EXISTS tb_login (
      id     INT AUTO_INCREMENT PRIMARY KEY,
      nm_user VARCHAR(40) NOT NULL
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await c.query('TRUNCATE TABLE tb_login');

  // As origens que o sistema aceita. Uma API sem interface propria aceita
  // pouca coisa; uma API usada pelo painel web aceita o dominio dele.
  const ORIGENS_PERMITIDAS = [
    'https://app.exemplo.com',
    'http://localhost:8080',
  ];

  // A senha que o exemplo aceita. O VALOR nao entra no arquivo: ele e gerado
  // na hora, a partir de uma frase fixa. Numa aplicacao de verdade este valor
  // vem do `.env` (ver o CONTRATO.md) e nunca do codigo — o que a aula
  // ensina e que um segredo escrito no arquivo vaza no git. Aqui ele e
  // sintetico de proposito, para o exemplo rodar sem configuracao.
  const SENHA_EXEMPLO = 'exemplo-' + 'do-' + 'material';
  const SENHA_ERRADA = SENHA_EXEMPLO + '-errada';

  let permite = criarLimitador({ capacidade: 3, repousoMs: 1000 });

  // O IP do cliente. Atras de nginx ou cloudflare, o que chega em
  // `remoteAddress` e o do PROXY, e nao o do visitante: e por isso que o
  // header `X-Forwarded-For` existe, e por isso que ele so pode ser lido
  // quando o proxy e conhecido.
  const ipDoCliente = (req) => req.socket.remoteAddress || '127.0.0.1';

  const servidor = http.createServer(async (req, res) => {
    const origem = req.headers.origin || '';

    const cab = cabecalhosDeSeguranca();
    Object.assign(cab, cabecalhoCors(origem, ORIGENS_PERMITIDAS));

    // O preflight nao chega a tocar o banco: e so uma pergunta de metodo.
    if (req.method === 'OPTIONS') {
      res.writeHead(204, cab);
      return res.end();
    }

    // O rate limit vem ANTES do trabalho. Limitar depois de gastar MySQL
    // deixa o ataque pagar o preco completo antes de ser barrado.
    const limite = permite(ipDoCliente(req));
    if (!limite.permitido) {
      res.writeHead(429, {
        ...cab,
        'Retry-After': String(Math.ceil(limite.esperaMs / 1000)),
      });
      return res.end(JSON.stringify({ erro: 'limite de requisicoes excedido' }));
    }

    res.setHeader('X-RateLimit-Remaining', String(limite.restantes));

    const partes = [];
    for await (const p of req) partes.push(p);
    const dados = JSON.parse(Buffer.concat(partes).toString('utf8') || '{}');

    if (req.url === '/login' && req.method === 'POST') {
      const valido = Boolean(dados.usuario) && dados.senha === SENHA_EXEMPLO;
      if (valido) {
        await c.execute('INSERT INTO tb_login (nm_user) VALUES (?)',
          [dados.usuario]);
      }
      res.writeHead(valido ? 200 : 401, cab);
      return res.end(JSON.stringify(
        valido ? { ok: true, usuario: dados.usuario } : { erro: 'credencial invalida' }));
    }

    res.writeHead(404, cab);
    res.end(JSON.stringify({ erro: 'rota nao encontrada' }));
  });

  await new Promise((r) => servidor.listen(0, '127.0.0.1', r));
  const base = 'http://127.0.0.1:' + servidor.address().port;
  console.log('servidor com CORS, cabecalhos e rate limit em ' + base);
  console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre');

  // ---------------------------------------------------------- os cabecalhos
  console.log('\n--- 1. os cabecalhos que a resposta traz ---');
  const comCab = await fetch(base + '/inexistente', {
    headers: { Origin: 'https://app.exemplo.com' },
  });
  console.log('status: ' + comCab.status);
  for (const [nome, valor] of Object.entries(cabecalhosDeSeguranca())) {
    console.log('  ' + nome.padEnd(28) + (valor === '' ? '(vazio, remove o header)' : valor));
  }
  console.log('X-Powered-By vazio e o padrao: ele declara a tecnologia usada');
  console.log('o cabecalho de CORS vem separado, porque depende da origem pedida:');
  const cors = cabecalhoCors('https://app.exemplo.com', ORIGENS_PERMITIDAS);
  for (const [nome, valor] of Object.entries(cors)) {
    console.log('  ' + nome.padEnd(28) + valor);
  }

  // ------------------------------------------------------------------ CORS
  console.log('\n--- 2. CORS: origem permitida e origem de qualquer site ---');

  const comCors = await fetch(base + '/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Origin: 'https://app.exemplo.com' },
    body: JSON.stringify({ usuario: 'ana', senha: SENHA_EXEMPLO }),
  });
  console.log('POST com origem permitida  -> status ' + comCors.status
    + ', Access-Control-Allow-Origin: '
    + comCors.headers.get('access-control-allow-origin'));

  const comCors2 = await fetch(base + '/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Origin: 'https://site-estranho.example' },
    body: JSON.stringify({ usuario: 'ana', senha: SENHA_ERRADA }),
  });
  console.log('POST com origem nao permit. -> status ' + comCors2.status
    + ', Access-Control-Allow-Origin: '
    + (comCors2.headers.get('access-control-allow-origin') ?? '(ausente)'));
  console.log('mesma rota, mesma senha errada, mesmo status: o servidor se comportou igual');
  console.log('a UNICA diferenca e o header ausente — e e ele que faz o navegador');
  console.log('nao mostrar a resposta para o site de origem nao permitida');
  console.log('CORS nao protege o banco: ele protege a LEITURA da resposta no navegador');

  const preflight = await fetch(base + '/login', {
    method: 'OPTIONS',
    headers: {
      Origin: 'https://app.exemplo.com',
      'Access-Control-Request-Method': 'POST',
      'Access-Control-Request-Headers': 'Content-Type',
    },
  });
  console.log('\npreflight OPTIONS           -> status ' + preflight.status
    + ' (o navegador pergunta antes de mandar o POST)');
  console.log('Access-Control-Max-Age: ' + preflight.headers.get('access-control-max-age')
    + ' — o navegador guarda a resposta do preflight por 600s');

  // ------------------------------------------------------------ rate limit
  // O balde e recomecado aqui, com nome proprio, para que a sequencia abaixo
  // comece do zero. As requisicoes de CORS acima JA consumiram as fichas do
  // balde deste IP — sem recomecar, o teste comecaria ja em 429 e nao
  // mostraria a transicao.
  console.log('\n--- 3. rate limit: balde novo, 3 fichas por IP ---');
  console.log('as requisicoes de CORS acima consumiram o balde anterior;');
  console.log('agora um balde limpo, com capacidade 3 e 1 ficha por segundo');
  permite = criarLimitador({ capacidade: 3, repousoMs: 1000 });

  let restanteNoCabecalho;
  const tentativa = (n) => fetch(base + '/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ usuario: 'bruno', senha: `${'errada'}-${n}` }),
  });

  for (let i = 1; i <= 5; i++) {
    const r = await tentativa(i);
    restanteNoCabecalho = r.headers.get('x-ratelimit-remaining');
    console.log('tentativa ' + i + ' -> ' + r.status
      + '  restantes=' + (restanteNoCabecalho ?? '(sem header)')
      + (r.status === 429
        ? '  Retry-After=' + r.headers.get('retry-after') + 's' : ''));
  }
  console.log('as tres primeiras chegaram ao login; da quarta em diante, 429');
  console.log('o contador de fichas no header vai 2, 1, 0 e so entao o 429');

  const [tentativas] = await c.query('SELECT COUNT(*) AS n FROM tb_login');
  console.log('\nsessoes criadas no banco: ' + tentativas[0].n
    + ' (so o login valido do teste de CORS gravou)');
  console.log('as 5 tentativas de forca bruta nao criaram sessao nenhuma');

  await new Promise((r) => servidor.close(r));
  console.log('\nservidor encerrado com close().');

  await c.end();
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

servidor com CORS, cabecalhos e rate limit em http://127.0.0.1:46037
a porta muda a cada execucao: e o listen(0) pedindo uma livre

--- 1. os cabecalhos que a resposta traz ---
status: 404
  Content-Security-Policy     default-src 'self'; script-src 'self'; object-src 'none'
  X-Content-Type-Options      nosniff
  X-Frame-Options             DENY
  Referrer-Policy             no-referrer
  Strict-Transport-Security   max-age=31536000; includeSubDomains
  X-Powered-By                (vazio, remove o header)
X-Powered-By vazio e o padrao: ele declara a tecnologia usada
o cabecalho de CORS vem separado, porque depende da origem pedida:
  Vary                        Origin
  Access-Control-Allow-MethodsGET, POST, OPTIONS
  Access-Control-Allow-HeadersContent-Type, Authorization
  Access-Control-Max-Age      600
  Access-Control-Allow-Origin https://app.exemplo.com

--- 2. CORS: origem permitida e origem de qualquer site ---
POST com origem permitida  -> status 200, Access-Control-Allow-Origin: https://app.exemplo.com
POST com origem nao permit. -> status 401, Access-Control-Allow-Origin: (ausente)
mesma rota, mesma senha errada, mesmo status: o servidor se comportou igual
a UNICA diferenca e o header ausente — e e ele que faz o navegador
nao mostrar a resposta para o site de origem nao permitida
CORS nao protege o banco: ele protege a LEITURA da resposta no navegador

preflight OPTIONS           -> status 204 (o navegador pergunta antes de mandar o POST)
Access-Control-Max-Age: 600 — o navegador guarda a resposta do preflight por 600s

--- 3. rate limit: balde novo, 3 fichas por IP ---
as requisicoes de CORS acima consumiram o balde anterior;
agora um balde limpo, com capacidade 3 e 1 ficha por segundo
tentativa 1 -> 401  restantes=2
tentativa 2 -> 401  restantes=1
tentativa 3 -> 401  restantes=0
tentativa 4 -> 429  restantes=(sem header)  Retry-After=1s
tentativa 5 -> 429  restantes=(sem header)  Retry-After=1s
as tres primeiras chegaram ao login; da quarta em diante, 429
o contador de fichas no header vai 2, 1, 0 e so entao o 429

sessoes criadas no banco: 1 (so o login valido do teste de CORS gravou)
as 5 tentativas de forca bruta nao criaram sessao nenhuma

servidor encerrado com close().
Aula 2

Segredo, variável de ambiente e log

Onde o segredo mora, e o que pode ser versionado

A senha do banco nunca entra no git. Nem no .js, nem no .md, nem no

package.json. O que entra é o nome da variável — e nome não é segredo.

A distinção que evita quase todo acidente:

CoisaOnde viveVai no git
DB_USER.envsim, é um nome
DB_NAME.envsim, é um nome
DB_PASS.envnunca, é o valor

Quando alguém commitou a senha, quase sempre não foi por querer: foi por

git add . pegando o .env inteiro, porque a linha do arquivo se parece com

uma linha de configuração e parece inofensiva. Por isso a regra não é

"escreva a senha com cuidado", é que o arquivo não pode estar no caminho do

git.

A variável de ambiente e process.env

variável de ambiente é um par nome/valor que pertence ao processo do sistema

operacional, não ao seu código. O Node entrega todas elas em um objeto só,

process.env, que existe antes da primeira linha do seu arquivo rodar.

typeof process.env.DB_PASS devolve 'string' quando a variável chegou e

'undefined' quando não chegou — e a variável ausente não é erro, é um

valor como outro. É esse o motivo de o erro clássico existir: o código segue,

o valor vai para a consulta como undefined, e a falha aparece vinte linhas

depois, em outro arquivo, com outra mensagem.

process.env não é só o seu .env. São também todas as variáveis do

sistema, e por isso Object.keys(process.env) devolve uma lista longa que não

tem nada a ver com o seu projeto. As três verificações que valem:

typeof process.env.DB_PASS          // 'string' ou 'undefined'
'DB_PASS' in process.env             // true ou false
Object.keys(process.env).length      // quantas o processo inteiro tem

NODE_ENV é a variável que o Node não cria e todo projeto usa. Ela não é

opcional no código: é a que decide se o programa abre log de detalhe, se

mostra a página de erro ou se desliga o que só pode rodar em máquina de

desenvolvimento. Quando ela falta, o padrão seguro é produção, não

desenvolvimento — o contrário de deixar o padrão explícito, que expõe

detalhe em produção por acidente.

O .env não entra no process.env sozinho. Quem faz isso é o dotenv, na

primeira linha do programa:

require('dotenv').config();   // le o .env e escreve dentro de process.env

Sem essa chamada, process.env é só o ambiente do sistema — e o .env fica

sentado ao lado do programa, sem ser lido.

não versionar .env: o .gitignore do segredo

O .gitignore do segredo é um arquivo de três linhas, e cada linha resolve um

caso diferente:

.env
.env.*
!env.exemplo
  • .env recusa o arquivo de credencial.
  • .env.* recusa todo arquivo que comece com .env. — inclusive

.env.producao, que é o jeito mais comum de vazar: o .env está

protegido e o .env.producao, que tem a senha de verdade, não.

  • !env.exemplo volta a aceitar o modelo. O ! é negação, e vem depois:

regra do git mais abaixo vence a de cima.

Esse modelo é o que vai no git, com os nomes e os valores vazios:

DB_HOST=
DB_PORT=
DB_USER=
DB_PASS=
DB_NAME=
NODE_ENV=development

Quem clona o repositório copia env.exemplo para .env e preenche só na sua

máquina. O valor da senha nunca aparece no arquivo que os outros leem.

O .gitignore não é um pedido, é uma regra que o git aplica sozinho. Os

comandos que provam isso:

git check-ignore -v .env     # qual regra recusa, e em que linha do .gitignore
git ls-files .env            # o que o git realmente versiona
git ls-files env.exemplo     # o modelo tem de aparecer aqui

git check-ignore -v sai com 0 quando a regra existe e 1 quando não

existe. Saída 1 para .env é defeito de configuração, não detalhe: o

arquivo está pronto para entrar no próximo git add.

.gitignore só vale para arquivo ainda não commitado. Se o .env entrou

no histórico, apagar a linha do .gitignore não resolve: o valor continua

em todos os commits antigos, e qualquer pessoa com o repositório clonado o

lê. A partir daí a correção é reescrever o histórico e trocar a senha.

A variável que faltou: undefined e a falha cedo

Ler process.env direto em todo lugar espalha o problema: o erro de

configuração aparece onde a conexão é aberta, sem nome e sem arquivo. A

alternativa é uma função que exige a variável e nomeia o erro:

class ErroDeConfiguracao extends Error {
  constructor(nome) {
    super('a variavel de ambiente ' + nome + ' nao chegou no processo');
    this.code = 'ENV_AUSENTE';
    this.nomeVariavel = nome;
  }
}

function exigir(amb, nome) {
  const valor = amb[nome];
  if (!valor) throw new ErroDeConfiguracao(nome);
  return valor;
}

exigir(amb, nome) devolve o valor, ou interrompe o programa com um erro que

carrega o nome da variável. Quem chama nunca chega a abrir a conexão com

uma variável faltando — a falha é cedo e nomeada, e a mensagem diz o arquivo

onde o valor deveria estar:

const config = {
  host: exigir(process.env, 'DB_HOST'),
  user: exigir(process.env, 'DB_USER'),
  password: exigir(process.env, 'DB_PASS'),   // para aqui se faltar
};

A diferença aparece quando a checagem não existe. Sem exigir, o driver

recebe password: undefined e responde ER_ACCESS_DENIED_ERROR. O programa

então procura o problema no driver, no usuário do banco, na permissão da

conta — e o .env aberto na frente, que é a causa, passa despercebido.

descreverValor(valor) é a função que permite olhar o ambiente inteiro sem

vazar nada. Ela devolve uma frase, nunca o valor:

descreverValor(process.env.DB_PASS)
// 'chegou (20 caracteres, valor nao impresso)'
descreverValor(process.env.NODE_ENV)
// 'NAO CHEGOU'

O comprimento muda entre máquinas, o conteúdo não aparece. É assim que se

imprime o estado do .env inteiro sem transformar a saída do programa em um

vazamento.

log de erro: duas leituras, dois formatos

Quem lê um erro são duas audiências, e cada uma precisa de um formato:

RegraO que proíbe
stack trace no console no lado do operadornada — o operador precisa do arquivo e da linha
não vazar caminho do arquivo na respostastack, caminho absoluto, nome de tabela e SQL
dado sensível no logo valor da senha, o corpo inteiro do pedido, o token
informar erro ao clientetraceback cru; o cliente precisa de código e de frase

O stack trace no console é a stack de erro.stack: arquivo, linha e a

cadeia de chamadas. Ela é a ferramenta mais valiosa do operador e a pior

coisa que pode chegar no navegador. Por isso a pilha vai para o arquivo de

log, e a resposta HTTP leva o par de baixo.

respostaDeErro(erro) monta o corpo que o cliente recebe:

const FRASES = {
  ER_DUP_ENTRY: 'esse registro ja existe',
  ER_NO_SUCH_TABLE: 'tabela indisponivel neste momento',
  ENV_AUSENTE: 'falta configuracao no servidor',
};

function respostaDeErro(erro) {
  return {
    erro: erro.code || 'ERRO_DESCONHECIDO',
    mensagem: FRASES[erro.code] || 'falha interna, o time foi avisado',
  };
}

respostaDeErro(erro) devolve erro.code e uma frase traduzida. O

ER_DUP_ENTRY é o que o front-end compara para decidir se oferece "tentar de

novo" ou "cadastre outro registro" — e é por isso que logging sozinho não

serve: quem consome o log em produção precisa do código do erro, não de

uma frase que pode vir traduzida. erro.message sozinho não decide nada,

porque a mensagem muda entre versões do banco e não é estável para comparar.

registrar(nivel, mensagem, extra) monta a linha de log e a devolve em vez de

imprimir direto, que é o que permite conferir o que foi registrado:

function registrar(nivel, mensagem, extra) {
  const hora = new Date().toISOString().slice(11, 19) + ' UTC';
  let linha = hora + ' ' + nivel + ' ' + mensagem;
  if (extra !== undefined) linha += ' ' + JSON.stringify(extra);
  return linha;
}

logDoOperador(nivel, mensagem, detalhe) é a chamada que junta as duas: ela

guarda a linha numa lista e devolve, para o log de erro ir inteiro.

nomesDoEnv(caminho) é o espelho do dotenv, reduzido ao mínimo: lê o

arquivo e devolve só o nome de cada variável e se ela tem valor. O valor é

lido da linha e descartado ali mesmo — o que volta para o programa é

{ nome, preenchida }.

gitComandos(args) roda um comando do git na raiz do projeto e devolve

{ ok, linhas }. O exemplo a usa para perguntar ao git, em vez de afirmar na

frase que o .env está protegido.

dado sensível no log inclui mais do que senha: cabeçalho Authorization,

document.cookie, número de cartão, CPF e o corpo de um pedido de login.

A prática que resolve é o oposto de "filtrar o que não deve aparecer":

registrar uma lista de campos, nunca o objeto inteiro.

log({ usuario, id }) não vaza; log(requisicao.body) vaza tudo o que o

cliente mandou.

O banco guarda o nome: tb_segredo_config

A tabela de configuração de segredos guarda o nome da variável e o que

fazer com ela. Não tem coluna para o valor — e é a ausência da coluna que

torna o vazamento impossível, em vez de improvável.

ColunaO que guarda
idchave da linha
nm_variavelo nome, com UNIQUE: um segredo, um lugar
ds_tiponome (versionável) ou segredo (só no .env)
fl_obrigatoriase o programa pode subir sem ela
ds_verificacaocomo confirmar que chegou, sem mostrar o valor

Com essa tabela a auditoria fica possível: o código lê nm_variavel, procura

o valor no ambiente e grava o nome em log. Uma variável nova entra na tabela

antes de entrar no código, e o .env.example é gerado a partir dela.

A mesma disciplina vale para o log. A verificação final do exemplo compara

cada linha registrada contra o valor verdadeiro do segredo e imprime

quantas casaram — nunca a linha que casou:

const vazou = LINHAS_DE_LOG.filter((l) => l.includes(process.env.DB_PASS));
console.log('linhas que contem o valor de DB_PASS: ' + vazou.length);

LINHAS_DE_LOG.filter(...) devolve as linhas que casaram, e o programa joga

a lista fora e imprime o tamanho. É o teste que roda a cada deploy, e é o que

pega o vazamento antes do vazamento.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 6: segredo, variavel de ambiente e log.
//
// A aula responde tres perguntas, e nesta ordem:
//
//   1. o que PODE ir no git: o NOME do `segredo`. o que nunca vai: o VALOR.
//      O `.gitignore do segredo` e a regra que garante isso, e o
//      `não versionar .env` e o efeito que ela produz.
//   2. o que acontece quando a `variável de ambiente` nao chegou no processo:
//      o `undefined` silencioso, e a `falha cedo` que o troca por um erro
//      com o NOME da variavel
//   3. quem le o `log`: o operador recebe o `stack trace no console` com
//      arquivo e linha, e o `log de erro` completo; o cliente recebe so o
//      `informar erro ao cliente` — `não vazar caminho do arquivo` e
//      `dado sensível no log` valem para os dois lados do `logging`
//
// Nenhuma credencial esta escrita neste arquivo: todo valor vem de
// `process.env`. O unico arquivo lido em disco e o `.env`, e dele sao lidos
// SO os nomes das variaveis — o valor e lido e devolvido descartado.
//
// O `.env` ja esta carregado no `process.env` quando este arquivo comeca: quem
// leu foi o harness com `-r`, e num projeto real quem le e o `dotenv`, na
// primeira linha do programa. Este exemplo nao repete essa chamada — ele
// ensina o que vem DEPOIS dela.

const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createConnection } = require('mysql2/promise');

// A raiz do material: tres niveis acima deste arquivo. O `git` e chamado com
// essa pasta como diretorio, porque `git check-ignore` so responde quando o
// caminho perguntado esta dentro da arvore.
const RAIZ = path.join(__dirname, '..', '..', '..');
const ARQUIVO_ENV = path.join(RAIZ, '.env');

// ================================================ 1. o que tem no ambiente

// Erro de configuracao com nome proprio: e o que separa "o banco recusou
// minha senha" de "a senha nunca chegou". A mensagem leva o NOME da variavel e
// o arquivo onde ela deveria estar — nunca o valor, porque quem le erro de
// configuracao e justamente quem nao pode ver a senha.
class ErroDeConfiguracao extends Error {
  constructor(nome) {
    super('a variavel de ambiente ' + nome + ' nao chegou no processo — '
      + 'ela esta vazia ou ausente no arquivo .env');
    this.code = 'ENV_AUSENTE';
    this.nomeVariavel = nome;
  }
}

// `exigir(amb, nome)` devolve o valor ou para o programa aqui. A assinatura e o
// contrato inteiro da aula: quem chama `exigir` nunca chega a abrir conexao
// com uma variavel faltando.
function exigir(amb, nome) {
  const valor = amb[nome];
  if (!valor) throw new ErroDeConfiguracao(nome);
  return valor;
}

// `descreverValor(valor)` responde "chegou?" sem mostrar o que chegou. E o que
// permite imprimir o estado do ambiente inteiro sem transformar a saida em
// vazamento: o comprimento muda entre maquinas, o conteudo nao aparece.
function descreverValor(valor) {
  if (valor === undefined) return 'NAO CHEGOU';
  if (valor === '') return 'veio VAZIA';
  return 'chegou (' + String(valor).length + ' caracteres, valor nao impresso)';
}

// `nomesDoEnv(caminho)` le o `.env` e devolve so o nome de cada variavel e se
// ela tem valor. O valor e lido da linha e descartado ali mesmo: o que volta
// para o programa e o nome.
function nomesDoEnv(caminho) {
  let bruto;
  try {
    bruto = fs.readFileSync(caminho, 'utf8');
  } catch (erro) {
    return { erro: erro.code, nomes: [] };
  }
  const nomes = [];
  for (const linha of bruto.split('\n')) {
    const limpa = linha.trim();
    if (!limpa || limpa.startsWith('#')) continue;
    const corte = limpa.indexOf('=');
    if (corte === -1) continue;
    nomes.push({
      nome: limpa.slice(0, corte).trim(),
      preenchida: limpa.slice(corte + 1).trim() !== '',
    });
  }
  return { erro: null, nomes };
}

// ============================================================ 2. o log
//
// Uma linha por evento, e nenhum outro lugar do programa escreve log. A funcao
// DEVOLVE a linha em vez de imprimir: e assim que da para guardar em lista e
// conferir o que entrou nela — a unica forma de provar "este log nao tem
// segredo" em vez de confiar que nao tem.
//
// A hora vem do relogio e muda a cada execucao, como a porta do servidor no
// exemplo da aula 1. O formato da linha nao muda.
function registrar(nivel, mensagem, extra) {
  const hora = new Date().toISOString().slice(11, 19) + ' UTC';
  let linha = hora + ' ' + nivel + ' ' + mensagem;
  if (extra !== undefined) linha += ' ' + JSON.stringify(extra);
  return linha;
}

// Tudo que foi para o log do operador nesta execucao. E a lista que a
// verificacao do fim do exemplo percorre.
const LINHAS_DE_LOG = [];

// O operador recebe a pilha inteira. O cliente nunca recebe.
function logDoOperador(nivel, mensagem, detalhe) {
  const linha = registrar(nivel, mensagem, detalhe);
  LINHAS_DE_LOG.push(linha);
  return linha;
}

// `respostaDeErro(erro)` monta o corpo que o cliente recebe quando a consulta
// falha: o codigo, que o front-end pode comparar, e uma frase que o usuario
// le. Sem `stack`, sem caminho de arquivo, sem SQL, sem nome de tabela.
const FRASES_CONHECIDAS = {
  ER_DUP_ENTRY: 'esse registro ja existe',
  ER_NO_SUCH_TABLE: 'tabela indisponivel neste momento',
  ER_ACCESS_DENIED_ERROR: 'credencial recusada pelo banco',
  ER_ACCESS_DENIED_NO_PASSWORD_ERROR: 'credencial recusada pelo banco',
  ER_DBACCESS_DENIED_ERROR: 'base de dados inexistente',
  ENV_AUSENTE: 'falta configuracao no servidor',
};

function respostaDeErro(erro) {
  return {
    erro: erro.code || 'ERRO_DESCONHECIDO',
    mensagem: FRASES_CONHECIDAS[erro.code] || 'falha interna, o time foi avisado',
  };
}

// ============================================ 3. o git respondendo se o `.env`
// entra. `gitComandos(args)` roda o git na raiz do material e devolve as
// linhas. Sem git na maquina, o exemplo avisa e segue: a prova do git e uma
// parte da aula, nao a aula inteira.
function gitComandos(args) {
  const r = spawnSync('git', args, { cwd: RAIZ, encoding: 'utf8' });
  if (r.error) return { ok: false, linhas: ['(git indisponivel nesta maquina)'] };
  return {
    ok: r.status === 0,
    linhas: (r.stdout + r.stderr).split('\n').map((l) => l.trim()).filter(Boolean),
  };
}

async function main() {
  // A conexao que o `process.env` montou. Todo valor abaixo vem de la: e o que
  // prova, na pratica, que o `.env` alimenta o programa.
  const banco = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  console.log('--- 1. o que chegou no ambiente (nomes, nunca valores) ---');
  const [versao] = await banco.query('SELECT VERSION() AS versao');
  console.log('o .env montou a conexao, e o banco respondeu: ' + versao[0].versao);

  console.log('typeof process.env.NODE_ENV: '
    + (typeof process.env.NODE_ENV === 'undefined' ? 'undefined' : 'string'));
  console.log('NODE_ENV: ' + descreverValor(process.env.NODE_ENV)
    + ' — o programa le como "'
    + (process.env.NODE_ENV || 'desenvolvimento') + '"');
  console.log('o "desenvolvimento" acima e um valor de fallback escrito no codigo,');
  console.log('nao veio de variavel nenhuma — e por isso ele nunca mente.');

  const doEnv = nomesDoEnv(ARQUIVO_ENV);
  if (doEnv.erro) {
    console.log('o arquivo .env nao pode ser lido (' + doEnv.erro + ')');
  } else {
    console.log('\nnome         no arquivo .env   no processo   estado do valor');
    for (const linha of doEnv.nomes) {
      console.log('  ' + linha.nome.padEnd(13)
        + (linha.preenchida ? 'preenchida     ' : 'VAZIA          ')
        + (linha.nome in process.env ? 'chegou  ' : 'AUSENTE ')
        + descreverValor(process.env[linha.nome]));
    }
    console.log('\nesquerda: o NOME da variavel, que e o que vai no git.');
    console.log('direita: o VALOR, que existe so nesta maquina e so em memoria.');
  }
  console.log('Object.keys(process.env).length: ' + Object.keys(process.env).length
    + ' variaveis no processo inteiro, nao so as do arquivo .env');

  // ------------------------------------------------------ a prova do git
  console.log('\n--- 2. o git respondendo se o .env entra ---');
  const ignorado = gitComandos(['check-ignore', '-v', '.env']);
  console.log('git check-ignore -v .env (saida ' + (ignorado.ok ? 0 : 1) + '):');
  for (const l of ignorado.linhas) console.log('  ' + l);
  console.log(ignorado.ok
    ? '  a regra acima manda o git recusar o arquivo: ele nem entra no commit'
    : '  o arquivo NAO esta sendo recusado, e isso e defeito de configuracao');

  const rastreado = gitComandos(['ls-files', '.env']);
  console.log('git ls-files .env: '
    + (rastreado.linhas.length ? rastreado.linhas.join(', ')
      : '(nenhuma linha) — o .env nao esta versionado'));

  const modelo = gitComandos(['ls-files', 'env.exemplo']);
  console.log('git ls-files env.exemplo: '
    + (modelo.linhas.length ? modelo.linhas.join(', ')
      : '(nenhuma linha) — o modelo com os NOMES deveria estar versionado'));

  // ------------------------------------ o que acontece quando falta a variavel
  console.log('\n--- 3. a variavel que faltou, e as duas respostas ---');
  console.log('mesma maquina, mesmo .env, uma variavel apagada do ambiente:');
  const semSegredo = Object.assign({}, process.env);
  delete semSegredo.DB_PASS;
  console.log("typeof semSegredo.DB_PASS: " + typeof semSegredo.DB_PASS
    + ' — a variavel sumiu, e o valor gravado no arquivo continua intoc');

  // (a) com a checagem antes de abrir a conexao
  let config = null;
  try {
    config = {
      host: exigir(semSegredo, 'DB_HOST'),
      port: Number(exigir(semSegredo, 'DB_PORT')),
      user: exigir(semSegredo, 'DB_USER'),
      password: exigir(semSegredo, 'DB_PASS'),
      database: exigir(semSegredo, 'DB_NAME'),
    };
  } catch (erro) {
    console.error(logDoOperador('ERRO', erro.code + ' — ' + erro.nomeVariavel
      + ' ausente; o programa parou antes de abrir conexao', { pilha: erro.stack }));
    console.log('  com checagem: ' + erro.code + ' — ' + erro.message);
    console.log('  o que o cliente recebe: ' + JSON.stringify(respostaDeErro(erro)));
  }

  // (b) sem a checagem, deixando o driver descobrir sozinho
  const cRuim = await createConnection({
    host: semSegredo.DB_HOST,
    port: Number(semSegredo.DB_PORT),
    user: semSegredo.DB_USER,
    password: semSegredo.DB_PASS,     // undefined: o driver le como "sem senha"
    database: semSegredo.DB_NAME,
  }).then((c) => ({ ok: c }), (erro) => ({ erro }));

  if (config) console.log('  (a nao parou: o .env tem a variavel)');
  if (cRuim.erro) {
    const erro = cRuim.erro;
    console.error(logDoOperador('ERRO', 'sem checagem — o driver recusou: '
      + erro.code + ' / errno ' + erro.errno, { pilha: erro.stack }));
    console.log('  sem checagem: ' + erro.code + ' (errno ' + erro.errno + ')');
    console.log('  o que o cliente recebe: ' + JSON.stringify(respostaDeErro(erro)));
    console.log('\nas duas linhas a variavel ausente e a mesma: DB_PASS.');
    console.log('a que tem checagem diz o NOME e o arquivo; a outra so diz que o');
    console.log('banco recusou, e o programador procura no driver, no usuario, na');
    console.log('permissao do banco — em tudo menos no .env, que e a causa.');
  }
  if (cRuim.ok) await cRuim.ok.end();

  // ================================================== 4. o log do operador
  // O banco guarda o NOME da variavel e o que fazer com ela. O valor nunca
  // entra: a tabela tem `nm_variavel` e nao tem coluna para valor nenhum.
  console.log('\n--- 4. o nome do segredo no banco, o valor fora ---');
  await banco.query(`
    CREATE TABLE IF NOT EXISTS tb_segredo_config (
      id             INT AUTO_INCREMENT PRIMARY KEY,
      nm_variavel    VARCHAR(60) NOT NULL UNIQUE,
      ds_tipo        VARCHAR(20) NOT NULL,
      fl_obrigatoria TINYINT NOT NULL DEFAULT 1,
      ds_verificacao VARCHAR(90) NOT NULL
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);

  // `IF NOT EXISTS` so verifica o NOME da tabela, nunca a forma dela: uma
  // tabela que ja existe com outra coluna passa sem erro e quebra no INSERT
  // logo depois, com um `ER_BAD_FIELD_ERROR` que nao tem nada a ver com a
  // aula. Esta guarda compara `SHOW COLUMNS` com o que o exemplo espera e
  // refaz a tabela quando a forma nao bate — e e o que torna o exemplo
  // idempotente mesmo depois de uma execucao antiga, ou de outro material que
  // criou a tabela com outro desenho.
  const [colunas] = await banco.query('SHOW COLUMNS FROM tb_segredo_config');
  const nomesEsperados = ['id', 'nm_variavel', 'ds_tipo', 'fl_obrigatoria',
    'ds_verificacao'];
  const bate = nomesEsperados.every((n) => colunas.some((c) => c.Field === n));
  if (!bate) {
    console.log('a tabela ja existia com outra forma (' + colunas.map((c) => c.Field).join(', ')
      + '); refazendo para o desenho deste exemplo');
    await banco.query('DROP TABLE tb_segredo_config');
    await banco.query(`
      CREATE TABLE tb_segredo_config (
        id             INT AUTO_INCREMENT PRIMARY KEY,
        nm_variavel    VARCHAR(60) NOT NULL UNIQUE,
        ds_tipo        VARCHAR(20) NOT NULL,
        fl_obrigatoria TINYINT NOT NULL DEFAULT 1,
        ds_verificacao VARCHAR(90) NOT NULL
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
  }
  await banco.query('DELETE FROM tb_segredo_config');

  // `ds_tipo` separa as duas coisas que sempre se confundem: um NOME, que pode
  // ser versionado, e um VALOR, que nao pode.
  const cadastro = [
    ['DB_HOST', 'nome', 0, 'endereco do servidor; no .env.example ja vem preenchido'],
    ['DB_PORT', 'nome', 0, 'porta do servidor; o padrao do MySQL e 3306'],
    ['DB_USER', 'nome', 0, 'nome do usuario do banco; este e o que pode ir no git'],
    ['DB_NAME', 'nome', 0, 'nome do banco; o mesmo arquivo serve a teste e producao'],
    ['NODE_ENV', 'nome', 0, 'qual ambiente roda; padrao desenvolvimento quando falta'],
    ['DB_PASS', 'segredo', 1, 'valor da senha; NUNCA sai do .env, do log ou do banco'],
  ];
  for (const [nome, tipo, obrig, texto] of cadastro) {
    await banco.query(
      'INSERT INTO tb_segredo_config (nm_variavel, ds_tipo, fl_obrigatoria, '
      + 'ds_verificacao) VALUES (?, ?, ?, ?)', [nome, tipo, obrig, texto]);
  }

  const [registradas] = await banco.query(
    'SELECT nm_variavel, ds_tipo, fl_obrigatoria, ds_verificacao '
    + 'FROM tb_segredo_config ORDER BY id');
  for (const linha of registradas) {
    console.log('  ' + linha.nm_variavel.padEnd(10) + linha.ds_tipo.padEnd(10)
      + (linha.fl_obrigatoria ? 'obrigatoria  ' : 'opcional     ')
      + linha.ds_verificacao);
  }
  console.log('seis variaveis cadastradas, uma so marcada como segredo.');
  console.log('a tabela nao tem coluna para o valor, e por isso o valor nao tem');
  console.log('como entrar: guardar o nome deixa a auditoria possivel e o risco, zero.');

  // O erro de verdade: cadastrar o mesmo nome duas vezes. E o que o log do
  // operador e a resposta do cliente fazem com o MESMO erro.
  console.log('\ncadastrando DB_PASS de novo, para gerar o erro de verdade:');
  try {
    await banco.query(
      'INSERT INTO tb_segredo_config (nm_variavel, ds_tipo, fl_obrigatoria, '
      + 'ds_verificacao) VALUES (?, ?, ?, ?)',
      ['DB_PASS', 'segredo', 1, 'tentativa duplicada']);
    console.log('  gravou (inesperado: o UNIQUE de nm_variavel barraria)');
  } catch (erro) {
    const pilha = String(erro.stack || '');
    const resposta = JSON.stringify(respostaDeErro(erro));
    console.error(logDoOperador('ERRO', 'cadastro de variavel recusado: '
      + erro.code + ' / sqlState ' + erro.sqlState, { pilha }));
    console.log('  terminal (stderr, com a pilha inteira):');
    console.log('    ' + pilha.split('\n')[0]);
    console.log('  pagina (stdout, o que o cliente recebe):');
    console.log('    ' + resposta);
    console.log('  codigo no log: ' + erro.code + ' — comparavel, e ele que decide');
    console.log('  a pilha aponta o arquivo do projeto: ' + pilha.includes('aula2.js'));
    console.log('  a pilha aponta a pasta do projeto: ' + pilha.includes('node-mysql'));
    console.log('  a resposta ao cliente tem caminho de arquivo: ' + /[\\/]/.test(resposta));
    console.log('  a resposta ao cliente tem o nome da tabela: '
      + resposta.includes('tb_segredo_config'));
    console.log('  a resposta ao cliente tem o SQL: ' + resposta.includes('INSERT'));
  }

  // A verificacao final: a lista de log e conferida contra o valor verdadeiro
  // do segredo sem que o valor apareca em lugar nenhum. E o teste que roda em
  // producao, em cada deploy, e que impede o vazamento antes do vazamento.
  const valorDoSegredo = process.env.DB_PASS;
  if (!valorDoSegredo) {
    console.log('\nDB_PASS esta vazia: nao ha valor para comparar com o log.');
  } else {
    const vazou = LINHAS_DE_LOG.filter((l) => l.includes(valorDoSegredo));
    console.log('\n--- 5. a verificacao do log contra o valor verdadeiro ---');
    console.log('linhas de log nesta execucao: ' + LINHAS_DE_LOG.length);
    console.log('linhas que contem o valor de DB_PASS: ' + vazou.length);
    console.log('a comparacao foi feita contra o valor real, e o que voltou foi um');
    console.log('numero — por isso o valor nao sai daqui nem se o log vazar mais tarde.');
  }

  await banco.end();
  console.log('\nexemplo encerrado.');
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

--- 1. o que chegou no ambiente (nomes, nunca valores) ---
o .env montou a conexao, e o banco respondeu: 10.11.14-MariaDB-0ubuntu0.24.04.1
typeof process.env.NODE_ENV: undefined
NODE_ENV: NAO CHEGOU — o programa le como "desenvolvimento"
o "desenvolvimento" acima e um valor de fallback escrito no codigo,
nao veio de variavel nenhuma — e por isso ele nunca mente.

nome         no arquivo .env   no processo   estado do valor
  DB_HOST      preenchida     chegou  chegou (9 caracteres, valor nao impresso)
  DB_PORT      preenchida     chegou  chegou (4 caracteres, valor nao impresso)
  DB_USER      preenchida     chegou  chegou (9 caracteres, valor nao impresso)
  DB_PASS      preenchida     chegou  chegou (20 caracteres, valor nao impresso)
  DB_NAME      preenchida     chegou  chegou (15 caracteres, valor nao impresso)

esquerda: o NOME da variavel, que e o que vai no git.
direita: o VALOR, que existe so nesta maquina e so em memoria.
Object.keys(process.env).length: 111 variaveis no processo inteiro, nao so as do arquivo .env

--- 2. o git respondendo se o .env entra ---
git check-ignore -v .env (saida 0):
  .gitignore:27:.env	.env
  a regra acima manda o git recusar o arquivo: ele nem entra no commit
git ls-files .env: (nenhuma linha) — o .env nao esta versionado
git ls-files env.exemplo: env.exemplo

--- 3. a variavel que faltou, e as duas respostas ---
mesma maquina, mesmo .env, uma variavel apagada do ambiente:
typeof semSegredo.DB_PASS: undefined — a variavel sumiu, e o valor gravado no arquivo continua intoc
  com checagem: ENV_AUSENTE — a variavel de ambiente DB_PASS nao chegou no processo — ela esta vazia ou ausente no arquivo .env
  o que o cliente recebe: {"erro":"ENV_AUSENTE","mensagem":"falta configuracao no servidor"}
  sem checagem: ER_ACCESS_DENIED_ERROR (errno 1045)
  o que o cliente recebe: {"erro":"ER_ACCESS_DENIED_ERROR","mensagem":"credencial recusada pelo banco"}

as duas linhas a variavel ausente e a mesma: DB_PASS.
a que tem checagem diz o NOME e o arquivo; a outra so diz que o
banco recusou, e o programador procura no driver, no usuario, na
permissao do banco — em tudo menos no .env, que e a causa.

--- 4. o nome do segredo no banco, o valor fora ---
  DB_HOST   nome      opcional     endereco do servidor; no .env.example ja vem preenchido
  DB_PORT   nome      opcional     porta do servidor; o padrao do MySQL e 3306
  DB_USER   nome      opcional     nome do usuario do banco; este e o que pode ir no git
  DB_NAME   nome      opcional     nome do banco; o mesmo arquivo serve a teste e producao
  NODE_ENV  nome      opcional     qual ambiente roda; padrao desenvolvimento quando falta
  DB_PASS   segredo   obrigatoria  valor da senha; NUNCA sai do .env, do log ou do banco
seis variaveis cadastradas, uma so marcada como segredo.
a tabela nao tem coluna para o valor, e por isso o valor nao tem
como entrar: guardar o nome deixa a auditoria possivel e o risco, zero.

cadastrando DB_PASS de novo, para gerar o erro de verdade:
  terminal (stderr, com a pilha inteira):
    Error: Duplicate entry 'DB_PASS' for key 'nm_variavel'
  pagina (stdout, o que o cliente recebe):
    {"erro":"ER_DUP_ENTRY","mensagem":"esse registro ja existe"}
  codigo no log: ER_DUP_ENTRY — comparavel, e ele que decide
  a pilha aponta o arquivo do projeto: true
  a pilha aponta a pasta do projeto: true
  a resposta ao cliente tem caminho de arquivo: false
  a resposta ao cliente tem o nome da tabela: false
  a resposta ao cliente tem o SQL: false

--- 5. a verificacao do log contra o valor verdadeiro ---
linhas de log nesta execucao: 3
linhas que contem o valor de DB_PASS: 0
a comparacao foi feita contra o valor real, e o que voltou foi um
numero — por isso o valor nao sai daqui nem se o log vazar mais tarde.

exemplo encerrado.