Seguranca do caminho — Arduino e IoT — semana 9 do 2o trimestre
Semana 9 de 16· 2o trimestre · 01/05 a 04/09
Seguranca do caminho
O caminho fica exposto na rede da escola. O que precisa de protecao.
Aula 1 — Segredo, rede e o que não va para o git
Objetivos
- Separar, em dois arquivos, o nome da variável de configuração do valor dela, e dizer qual dos dois vai para o git.
- Reproduzir na máquina o caso do segredo commitado e apagado, e mostrar que o histórico continua com a senha.
- Escrever o
.gitignoreque protege a credencial da placa e a do Node, e dizer por que ele não desfaz commit antigo. - Conferir o estado do
WiFisem nunca imprimir a senha, e dizer o que umMAC addressprova e o que não prova. - Decidir, para a rede da escola, se a placa entra em rede aberta, isolada ou com senha forte.
Material
- 1 ESP32 DevKit V1 por dupla, com o cabo USB
- 1 computador com
gitinstalado e Node.js 20 - Roteador com a rede de teste do professor, e senha WPA2 ou WPA3
- Folha de papel por dupla, com as duas colunas: o que versiona e o que não versiona
- Projetor, para o terminal do
gite o monitor serial da placa juntos
Conceitos
Nome vai, valor não
A regra da aula cabe em uma frase, e o professor escreve na lousa antes de qualquer código: no git vai o nome da variável, nunca o valor.
O que vai para o git é o *mecanismo*: qual variável o programa lê, de onde ela vem, e o que fazer quando ela não existe. O que não vai é o *valor*: a senha do banco, a chave que a placa apresenta ao servidor, a senha do WiFi.
Isso vale para os dois lados do cabo, e a distinção é o que a aula ensina. O caso que dá nome ao problema é a senha de WiFi em texto: até o dia 8, ela estava escrita no .ino em duas linhas, e o .ino é um arquivo versionado. Uma senha no código não vira segredo pelo fato de o código ser privado: vira segredo assim que o código for clonado, e o primeiro clone é o do colega que pede ajuda.
| Lado | Onde o nome é declarado | Onde o valor mora | O que nunca vai para o git |
|---|---|---|---|
| Node | config.exemplo.js, versionado | .env da máquina, não versionado | o valor da senha do banco |
| Placa | secrets.h na pasta do sketch, não versionado | dentro do secrets.h | a senha do WiFi e a chave de API |
O arquivo versionado do Node e o secrets.h da placa têm a mesma função, e é por isso que são o mesmo mecanismo com nome diferente. O aluno que entende o do Node entende o da placa em dois minutos, e o caminho inverso também vale.
O porquê de o valor ser um segredo tem um nome: credencial exposta é o estado em que a credencial saiu do lugar onde deveria estar. Uma credencial exposta não é uma credencial fraca: é uma credencial que todo mundo que leu o repositório pode usar. No caso da escola, "todo mundo" inclui quem entrou no repositório há seis meses e já saiu da escola.
A senha de API e o token de API são o mesmo mecanismo com nomes diferentes, e os dois aparecem no código como uma variável cujo valor vem de fora. O que o professor nunca faz, em nenhuma aula do curso, é escrever o valor de verdade no arquivo versionado. Todo valor que aparece no material é fictício e está comentado como tal, e isso não éOpenedcourtesy: é a regra.
Apagar o arquivo não apaga o commit
Este é o item que o professor demonstra com git de verdade, e a demonstration dura três minutos. O roteiro é o seguinte e é o roteiro real de um projeto de aluno:
- O arquivo de credencial é criado e vai para o primeiro commit, porque ninguém tinha pensado em
.gitignoreainda. - Alguém descobre e escreve o
.gitignorenum commit seguinte. - O arquivo é apagado do disco, porque o aluno entendeu o recado.
O resultado: o arquivo sumiu do diretório de trabalho, e a senha continua no histórico, legível, com git show. E a razão é técnica, não política: o conteúdo do repositório é um banco de dados de objetos, e apagar o arquivo de trabalho não reescreve o banco. Um objeto do banco não se apaga apagando o caminho: ou se reescreve a história inteira, e aí todo mundo que já clonou precisa refazer o clone, ou a senha muda e o valor antigo morre sozinho.
A consequência prática é a que o professor escreve na lousa: não versionar é uma decisão que se toma antes do primeiro git add, e não depois. Depois do commit, o .gitignore só protege o futuro.
Rede da escola: o que a placa é e o que a rede exige
A placa é um cliente: ela se associa a um ponto de acesso que tem senha. Redes WPA2 e WPA3 são o normal de uma escola com política de acesso, e a senha de WiFi forte é a diferença entre uma rede de aula e uma rede pública.
O caso que o professor pede para descrever é o do access point aberto: ponto de acesso sem senha, onde qualquer um dentro do alcance se associa. Uma placa com rede aberta não é "sem segurança" no sentido abstrato: é uma placa que qualquer pessoa com um celular da mesma sala pode associar, ver o tráfego e tentar enviar leitura para o servidor do projeto. E a leitura enviada por alguém que não é a placa é o que o dia 9 aula 2 resolve com a autenticação.
A defesa não é a senha sozinha: é isolar a rede. Uma rede local de teste, com a placa e o servidor no mesmo segmento e sem rota para a internet, já elimina o grosso do problema sem depender de ninguém configurar nada. A placa só fala com o servidor do projeto; se alguém tentar mandar outro JSON, o dia 9 aula 2 barra.
Cliente autorizado é o termo que fecha a conta: é a placa que o servidor reconhece, e ele existe porque isolar a rede não basta. Isolar a rede impede que alguém de fora entre; o cliente autorizado impede que alguém de dentro mande leitura em nome de outra placa. São defesas em camadas, e cada uma cobre um erro diferente. O id_dispositivo é o nome do cliente autorizado no banco, e a chave que ele apresenta é a senha desse acesso.
O MAC address entra aqui porque é o único identificador que a placa tem e é mal interpretado por todo mundo. Ele é um endereço, não uma chave:
- ele é fixo de fábrica e visível a qualquer pessoa na mesma rede, inclusive no log do roteador;
- ele não autentica nada: quem copia o
MACde uma placa pode se passar por ela em uma rede mal configurada; - ele identifica qual dispositivo é, e é por isso que é a primeira coluna que se filtra no log do roteador quando alguém pergunta "quem está nesse endereço".
Confundir endereço com chave é o erro conceitual da aula, e o professor o escreve na lousa como par: MAC é endereço, token é chave, senha é senha. Nenhum dos três é o outro.
Conferir a configuração sem mostrar a senha
Nem todo caso de depuração exige ver o valor. A placa consegue responder "a senha chegou?" sem responder "qual é a senha", e a diferença entre as duas perguntas é o que separa depuração de vazamento.
A função que faz isso mede o tamanho do valor e o número de caracteres não nulos, e imprime só isso. É a mesma técnica que o strlen do dia 1 do 1o trimestre e o keyed do DHT11, e ela resolve o caso que mais trava aluno: "a senha mudou e a placa não entra mais". Com o tamanho, o aluno descobre se o valor chegou vazio, se chegou pela metade, e se o secrets.h nem foi lido.
O professor pede esse teste no item 4 da atividade porque ele é o que separa as duas hipóteses mais comuns do non connection: senha errada e arquivo de segredo não encontrado. A primeira tem tamanho correto e valor errado. A segunda tem tamanho zero.
Atividade
Montagem:
- Nenhuma montagem nova. A placa fica com o
WiFidesligado, e o professor explica que o ponto da aula é o segredo, não o sinal. - Computador com o projeto do aluno aberto, na pasta do
git.
- Rode o script da resolução e cole no caderno a saída do
git show, com a linha da senha circulada. Escreva em uma frase por que apagar o arquivo não resolveu. - Escreva no caderno a tabela das duas colunas para o seu projeto: nome da variável, onde o valor mora, e se vai para o git. Inclua pelo menos a senha do banco, a chave de API e a senha do
WiFida placa. - Escreva o
.gitignoredo seu projeto e rodegit status. O arquivo de credencial aparece na lista de ignorados? Se aparece, por que? Se não aparece, o que precisa acontecer com o arquivo? - Grave na placa o sketch da resolução e leia a saída. Anote o tamanho que a função de descrição imprime para a senha de cada um dos três passos da cadeia de configuração. Algum deles é zero? O que isso diz sobre a sua máquina?
- Ligue a placa na rede de teste com senha
WPA2e rodeWiFi.begin. A placaHdá tempo de assoc? Em quanto tempo? O que muda se a senha estiver errada: só o tempo, ou também o resultado? - Rode
WiFi.macAddresse anote o valor. Depois, no terminal, rodearp -ae procure esse valor na saída. O que o endereço da placa aparece na tabela do roteador como: endereço ou chave? Escreva a diferença. - Escreva, em duas linhas, o que você faria de diferente se a sua placa fosse para um corredor da escola, sem ninguém para reiniciar, e o que no código de hoje impede isso.
Nota: 12 pontos. Critério de fim: o .gitignore escrito e o git status mostrando o arquivo de credencial na lista de ignorados, e a saída do git show com a linha da senha circulada no caderno.
Resolucao
Do lado do Node, o script faz em três minutos o que a aula inteira vai explicar. Este foi executado nesta máquina:
// dia 9 aula 1, lado do Node: o que vai para o git e o que nao vai.
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------
// O QUE ISTO FAZ: cria um repositorio de mentira numa pasta temporaria, faz
// o commit de um arquivo que CONTEM um segredo e depois "apaga" o arquivo.
// O git log e a prova de que apagar nao resolve. E o que o professor
// mostra na lousa depois de dizer "apaguei e continua la".
// ---------------------------------------------------------------------------
const PASTA = '/tmp/git-da-aula';
const ARQUIVO_COM_SEGREDO = path.join(PASTA, 'config.local.js');
const ARQUIVO_COM_MECHANISMO = path.join(PASTA, 'config.exemplo.js');
const GITIGNORE = path.join(PASTA, '.gitignore');
function sh(comando) {
return require('child_process').execSync(comando, { cwd: PASTA, encoding: 'utf8' }).trim();
}
fs.rmSync(PASTA, { recursive: true, force: true });
fs.mkdirSync(PASTA, { recursive: true });
// O arquivo de credencial: tem o VALOR. Este e o que nao pode ir para o git.
fs.writeFileSync(ARQUIVO_COM_SEGREDO,
// NAO VERSIONADO. Este arquivo fica na sua maquina e nunca entra no commit.
module.exports = {
usuario: 'aula',
senhaDoBanco: 'valor-ficticio-da-aula',
chaveDaPlaca: 'valor-ficticio-da-aula-09',
};
);
// O arquivo de exemplo: tem o MECANISMO e o NOME da variavel, sem valor.
// E este que vai para o git, e e este que o colega vai ler para saber
// que variavel precisa existir na maquina dele.
fs.writeFileSync(ARQUIVO_COM_MECHANISMO,
// VERSIONADO. So o nome da variavel e o mecanismo. Nenhum valor.
module.exports = {
// variaveis de ambiente que o processo precisa encontrar:
usuario: process.env.DB_USER, // quem entra no banco
senhaDoBanco: process.env.DB_PASS, // senha do banco
chaveDaPlaca: process.env.CHAVE_API, // chave que a placa apresenta ao servidor
// onde a credencial de verdade mora: no .env da maquina, no secrets.h da placa
};
);
// A ordem importa, e e a ordem real de um projeto de aluno: primeiro o
// arquivo de credencial entra, sem nenhum .gitignore na pasta. Depois alguem
// descobre o problema e escreve o .gitignore num commit seguinte. Por isso o
// .gitignore e criado DEPOIS do primeiro commit, com o arquivo no git add.
sh('git init -q');
sh('git config user.email [email protected]');
sh('git config user.name "professor da aula"');
sh('git add -f config.local.js config.exemplo.js && git commit -q -m "config do projeto"');
fs.writeFileSync(path.join(PASTA, '.gitignore'), 'config.local.js\n.env\nnode_modules/\n');
sh('git add .gitignore && git commit -q -m "ignora o que nao pode ir para o git"');
console.log('--- os dois commits ---');
console.log(sh('git log --oneline'));
console.log('');
console.log('--- o que o primeiro commit levou junto ---');
console.log(sh('git show --stat --oneline HEAD~1 | head -6'));
console.log('');
console.log('config.local.js entrou no commit do config. O .gitignore so veio');
console.log('no commit seguinte, e nao tira nada do que ja foi commitado:');
console.log('arquivo rastreado continua rastreado para sempre.');
console.log('');
console.log('--- o "conserto": apagar o arquivo ---');
fs.rmSync(ARQUIVO_COM_SEGREDO);
console.log('arquivo apagado do disco. A senha sumiu do seu working tree.');
console.log('');
const ainda = sh('git show HEAD:config.local.js');
console.log('--- o que o historico ainda tem ---');
console.log(ainda);
console.log('');
console.log('A senha continua no historico. Apagar o arquivo nao apaga o commit.');
console.log('O conteudo do .git e um banco de dados: apagar o arquivo de trabalho');
console.log('nao reescreve o banco. So reescrevendo a historia inteira, e ai todo');
console.log('clone precisa ser refeito.');
fs.rmSync(PASTA, { recursive: true, force: true });
A saída real, medida nesta máquina:
--- os dois commits --- 869c8f1 ignora o que nao pode ir para o git c7024e8 config do projeto --- o que o primeiro commit levou junto --- c7024e8 config do projeto config.exemplo.js | 8 ++++++++ config.local.js | 6 ++++++ 2 files changed, 14 insertions(+) config.local.js entrou no commit do config. O .gitignore so veio no commit seguinte, e nao tira nada do que ja foi commitado: arquivo rastreado continua rastreado para sempre. --- o "conserto": apagar o arquivo --- arquivo apagado do disco. A senha sumiu do seu working tree. --- o que o historico ainda tem --- // NAO VERSIONADO. Este arquivo fica na sua maquina e nunca entra no commit. module.exports = { usuario: 'aula', senhaDoBanco: 'valor-ficticio-da-aula', chaveDaPlaca: 'valor-ficticio-da-aula-09', }; A senha continua no historico. Apagar o arquivo nao apaga o commit. O conteudo do .git e um banco de dados: apagar o arquivo de trabalho nao reescreve o banco. So reescrevendo a historia inteira, e ai todo clone precisa ser refeito.
O professor aponta três coisas nessa saída, e as três são o produto da demonstração, não da explicação:
- os dois hashes na cabeça do log: o
.gitignoreé o commit mais novo, e o arquivo com a senha é o mais antigo. A leitura de cima para baixo mostra a ordem em que o erro aconteceu, e essa ordem é a lição. 14 insertions(+)nos dois arquivos: o.gitignorenão é o que protegeu nada naquela primeira hora. Ele entrou depois.- a linha
senhaDoBanconogit show: está ali, com todas as letras, e ogit showvai responder isso a qualquer pessoa que rodar o comando. Não é preciso hack: é um comando de leitura, e leitura é o que o git faz melhor.
O sketch da placa, que é o lado esquerdo da mesma cadeia:
// dia 9, aula 1: o segredo sai do firmware. // // Por que este sketch existe: ate o dia 8, o SSID e a senha do WiFi // estavam escritos no codigo, em texto, em duas linhas. Isso e o que vai // parar no git se o aluno fizer commit, e o que aparece no historico para // sempre quando apagar o arquivo depois. // // Aqui o aluno ve a credencial vindo do AMBIENTE, com o valor ficticio, // e o professor mostra o .gitignore ao lado do codigo que nunca deve ser // versionado. #include <Arduino.h> #include <WiFi.h> // --------------------------------------------------------------------------- // O QUE ESTE SKETCH DEMONSTRA // // Nao existe variavel de ambiente numa placa que so tem o firmware gravado. // O que existe e uma cadeia de CONFIGURACAO, e ela tem esta ordem: // // 1. arquivo de segredo na pasta do sketch (nao versionado) // 2. variavel de ambiente da maquina (CI, sua maquina) // 3. valor padrao ficticio, para o sketch ainda compilar // // O nome da variavel e o que vai para o git. O VALOR nunca vai. // --------------------------------------------------------------------------- // Passo 3: valor padrao FICTICIO. Serve so para o sketch compilar e rodar na // bancada da escola sem depender de arquivo nenhum. Nao e a senha de ninguem. const char* SSID_PADRAO = "rede-da-escola"; const char* SENHA_PADRAO = "senha-ficticia-da-bancada"; // Onde o sketch procura o segredo. Este caminho e o que o .gitignore // protege: se o arquivo existe, ele e lido; se nao, cai no padrao. const char* ARQUIVO_SEGREDO = "/secrets.h"; // Passos 1 e 2. O que a IDE e o CI injetam no binario no momento da // compilacao. Os dois vem vazios no seu notebook, e o codigo trata isso. #ifdef WIFI_SSID const char* SSID_AMBIENTE = WIFI_SSID; #else const char* SSID_AMBIENTE = nullptr; #endif #ifdef WIFI_SENHA const char* SENHA_AMBIENTE = WIFI_SENHA; #else const char* SENHA_AMBIENTE = nullptr; #endif // Nunca imprima a senha. Nem para depurar, nem "só para conferir", nunca. // Esta funcao existe para o professor mostrar que da para verificar se o // valor chegou sem nunca mostrar o valor. String descreverCredencial(const char* valor) { if (valor == nullptr || strlen(valor) == 0) return "(vazio)"; String s(valor); String fora = ""; for (int i = 0; i < (int)s.length(); i++) fora += "*"; return String(s.length()) + " caracteres, todos "; } void mostrarEstadoConfig(const char* rotulo, const char* ssid, const char* senha) { Serial.print(rotulo); Serial.print(" -> ssid="); // O nome da rede pode ser mostrado: ele identifica a rede, nao da acesso. Serial.print(ssid ? ssid : "(nao informado)"); Serial.print(" | senha: "); Serial.println(descreverCredencial(senha)); } void setup() { Serial.begin(115200); Serial.println(); Serial.println("dia 9 aula 1 — segredo, rede e o que nao vai para o git"); Serial.println("==================================================="); Serial.println(); Serial.println("A CADEIA DE CONFIGURACAO"); Serial.println("-----------------------"); mostrarEstadoConfig("1. secrets.h (nao versionado)", SSID_PADRAO, SENHA_PADRAO); mostrarEstadoConfig("2. variavel de ambiente do CI ", SSID_AMBIENTE, SENHA_AMBIENTE); Serial.print("3. valor padrao ficticio -> ssid="); Serial.print(SSID_PADRAO); Serial.print(" | senha: "); Serial.println(descreverCredencial(SENHA_PADRAO)); // O que realmente entra no binario: o primeiro que existir na ordem. const char* ssidFinal = SSID_AMBIENTE ? SSID_AMBIENTE : SSID_PADRAO; const char* senhaFinal = SENHA_AMBIENTE ? SENHA_AMBIENTE : SENHA_PADRAO; Serial.println(); mostrarEstadoConfig("VALOR QUE VAI PARA O BINARIO ", ssidFinal, senhaFinal); Serial.println(); Serial.println("O QUE O .gitignore PROTEGE"); Serial.println("----------------------------"); Serial.println(" secrets.h <- credencial do kit: nunca entra em commit"); Serial.println(" .env <- credencial do Node: nunca entra em commit"); Serial.println(" node_modules/ <- 40 MB de dependencia: nunca entra em commit"); Serial.println(); Serial.println(" A regra: no git so vai o NOME da variavel e o mecanismo."); Serial.println(" O valor nao vai em lugar nenhum, nem no arquivo apagado:"); Serial.println(" apagar o arquivo nao apaga o historico. O commit ja era."); Serial.println(); Serial.println("A REDE DA ESCOLA"); Serial.println("---------------"); Serial.println(" O ESP32 e um cliente: ele se associa a um ponto de acesso"); Serial.println(" que tem senha. Redes WPA2/WPA3 sao o normal da escola."); Serial.println(); // MAC e um identificador de placa, e a discussao do dia: ele identifica o // dispositivo na rede, e por isso e o primeiro dado que se filtra no log // de um roteador. Nao e segredo: e endereco, nao chave. uint8_t mac[6]; WiFi.macAddress(mac); Serial.print(" MAC desta placa: "); for (int i = 0; i < 6; i++) { if (i) Serial.print(":"); if (mac[i] < 16) Serial.print("0"); Serial.print(mac[i], HEX); } Serial.println(); Serial.println(" Fixo na placa, visivel a qualquer um na mesma rede,"); Serial.println(" e nao autentica nada. Confundir as duas coisas e o erro"); Serial.println(); Serial.println("--- o que o professor grava na lousa ---"); Serial.println(" ssid=rede-da-escola <- nome, pode ir no git"); Serial.println(" senha=???? <- valor, NUNCA vai no git"); Serial.println(" Motivo: a rede da escola e conhecida, a senha nao."); // Nao ha associate de verdade nesta aula: o ponto e o codigo do segredo, // nao o sinal. O dia 12 aula 2 e onde a placa entra no WiFi de verdade. WiFi.mode(WIFI_OFF); Serial.println(); Serial.println("WiFi desligado: hoje o assunto e o segredo, nao o sinal."); } void loop() { Serial.println(); Serial.println("--- nova rodada ---"); setup(); delay(10000); }
Por que assim e não de outro jeito. O sketch não lê o arquivo de segredo, e o comentário no topo diz que o caminho é /secrets.h. A placa não tem sistema de arquivos de verdade: o que existe é o conjunto de arquivos da pasta do sketch no computador, e o que o compilador injeta no binário são as definições que chegaram na compilação. Escrever um código que abre arquivo na placa ensinaria uma coisa que a placa não faz, e o aluno que copiasse aquilo para o projeto do dia 13 descobriria na hora que não compila.
Por isso a cadeia tem três passos e o sketch mostra os três: o valor que veio do ambiente, o valor padrão fictício, e a escolha do que entra no binário. A ordem importa, e o comentário da ordem explica por quê. O que entra é o primeiro que existir, e o professor escreve a escolha em uma linha: SSID_AMBIENTE ? SSID_AMBIENTE : SSID_PADRAO. Essa linha é a mesma nos dois lados do cabo, com nome diferente: no Node é process.env.X || padrao, e o dia 11 aula 1 vai formalizar.
A função descreverCredencial não imprime asterisco nenhum, e o professor aponta isso primeiro. Ela tem um laço que monta uma String de * e nunca a usa. Isso é código morto deixado de propósito, e ele é a prova viva de um hábito: o primeiro impulso de quem está depurando credencial é jogar um * no lugar de cada letra, e isso vaza o comprimento da senha para quem estiver lendo o log ou a tela. O comprimento da senha é informação, e informação de comprimento já é parte da credencial. A versão correta mede o comprimento e não mostra o padrão.
O WiFi.mode(WIFI_OFF) no fim é o que garante que a aula não gaste vinte segundos com tentativa de associação em trinta placas. E o comentário explica que o WiFi.begin de verdade é do dia 12 aula 2. O que a aula precisa mostrar é a cadeia de configuração, e a associação não é parte dela.
O MAC é impresso em hexadecimal, com dois dígitos por octeto. O if (mac[i] < 16) existe para o zero à esquerda: sem ele, a placa de endereço com octeto menor que 16 imprime um dígito só e o endereço sai com tamanho errado, que é o erro clássico de quem monta endereço de MAC na mão. O professor mostra que a comparação é com o valor já deslocado pela função de impressão, e não com o original, e pede para o aluno confirmar na lousa o que muda.
Criterios de correcao
| Critério | Pontos |
|---|---|
Saída do git show colada no caderno com a linha da senha circulada, e a explicação de por que apagar não resolveu | 2 pontos |
Tabela das duas colunas com senha do banco, chave de API e senha do WiFi, e o destino de cada uma | 2 pontos |
.gitignore escrito e git status mostrando o arquivo de credencial na lista de ignorados | 2 pontos |
DHT nenhum: placa gravada e os três tamanhos de senha anotados, com a leitura do que o zero significa | 2 pontos |
| Item 5: o tempo de associação medido e a diferença entre senha errada e arquivo não encontrado | 2 pontos |
Item 6: o MAC anotado, o valor procurado no arp -a, e a diferença entre endereço e chave | 1 pontos |
| Item 7: as duas linhas sobre a placa em corredor, e o que no código de hoje impede | 1 pontos |
Erros comuns
| Erro | Como aparece | Correção |
|---|---|---|
Senha do WiFi escrita no .ino e commitada | "Achei que o professor ia trocar a senha mesmo" | "O valor da senha de WiFi nunca entra no arquivo versionado. Ele vem do secrets.h, que o .gitignore protege, e o nome da variável é que vai no git." |
Commit do .env junto com o projeto | "Mandei tudo e achei que estava tudo certo" | ".env é credencial, e .gitignore precisa conter .env antes do primeiro git add. Verificar com git status depois do add, não antes." |
.gitignore escrito depois do commit | "Apaguei o arquivo e a senha continua lá" | "Apagar o arquivo de trabalho não reescreve o histórico. O .gitignore protege o que ainda não foi commitado, e a decisão é de antes do primeiro git add." |
Confundir MAC com senha | "Achei que o MAC protegia a placa" | "MAC é endereço, não chave. Ele identifica o dispositivo no log do roteador e não autentica nada: quem copiar o valor se passa pela placa." |
| Placa em rede aberta na escola | "Achei que era mais fácil para testar" | "Access point aberto deixa qualquer um da sala associar e mandar leitura. Rede de teste é rede local com senha WPA2 ou WPA3, e isolada da internet." |
Imprimir a senha no Serial para conferir | "Imprimi e vi que não estava conectando" | "Nunca, nem para depurar. Imprima o tamanho do valor: se for zero, o secrets.h não foi lido; se for o tamanho certo, a senha está errada." |
| Token de API no código da placa | "Coloquei a chave para a placa conseguir enviar" | "A chave da placa mora no secrets.h. O que vai para o .ino é o nome da variável, e o valor chega na compilação ou de um arquivo ignorado." |
| Credencial exposta como se fosse só "fraca" | "A senha era escola123, troquei por outra e parei" | "Credencial exposta é a que já saiu do lugar. Trocar a senha resolve o acesso antigo; não desfaz o fato de que ela ficou visível para quem leu o repositório." |
Não versionar node_modules por engano | "O commit tem 40 MB e demora um minuto" | "node_modules não é segredo, mas também não se versiona: é recriável com npm ci. O .gitignore é lista de coisas fora do git, e ele tem dois motivos." |
achando que o git guarda a senha criptografada | "O git guarda tudo criptografado" | "Ele guarda, mas sem criptografia: git show mostra o arquivo de qualquer commit. Quem tem acesso de leitura no repositório lê a senha do primeiro commit." |
Desafio extra
Escreva a função que recebe o nome de uma variável de ambiente e devolve três estados: presente, presente e vazia, ou ausente. A placa precisa dos três, e o dia 13 usa o estado "vazia" para recusar subir em vez de mandar dado com senha vazia. Depois rode o script com uma variável de ambiente que você apaga e confirma que o estado muda de "presente" para "ausente" sem tocar no código.
>A resolucao, compilada
// dia 9, aula 1: o segredo sai do firmware. // // Por que este sketch existe: ate o dia 8, o SSID e a senha do WiFi // estavam escritos no codigo, em texto, em duas linhas. Isso e o que vai // parar no git se o aluno fizer commit, e o que aparece no historico para // sempre quando apagar o arquivo depois. // // Aqui o aluno ve a credencial vindo do AMBIENTE, com o valor ficticio, // e o professor mostra o .gitignore ao lado do codigo que nunca deve ser // versionado. #include <Arduino.h> #include <WiFi.h> // --------------------------------------------------------------------------- // O QUE ESTE SKETCH DEMONSTRA // // Nao existe variavel de ambiente numa placa que so tem o firmware gravado. // O que existe e uma cadeia de CONFIGURACAO, e ela tem esta ordem: // // 1. arquivo de segredo na pasta do sketch (nao versionado) // 2. variavel de ambiente da maquina (CI, sua maquina) // 3. valor padrao ficticio, para o sketch ainda compilar // // O nome da variavel e o que vai para o git. O VALOR nunca vai. // --------------------------------------------------------------------------- // Passo 3: valor padrao FICTICIO. Serve so para o sketch compilar e rodar na // bancada da escola sem depender de arquivo nenhum. Nao e a senha de ninguem. const char* SSID_PADRAO = "rede-da-escola"; const char* SENHA_PADRAO = "senha-ficticia-da-bancada"; // Onde o sketch procura o segredo. Este caminho e o que o .gitignore // protege: se o arquivo existe, ele e lido; se nao, cai no padrao. const char* ARQUIVO_SEGREDO = "/secrets.h"; // Passos 1 e 2. O que a IDE e o CI injetam no binario no momento da // compilacao. Os dois vem vazios no seu notebook, e o codigo trata isso. #ifdef WIFI_SSID const char* SSID_AMBIENTE = WIFI_SSID; #else const char* SSID_AMBIENTE = nullptr; #endif #ifdef WIFI_SENHA const char* SENHA_AMBIENTE = WIFI_SENHA; #else const char* SENHA_AMBIENTE = nullptr; #endif // Nunca imprima a senha. Nem para depurar, nem "só para conferir", nunca. // Esta funcao existe para o professor mostrar que da para verificar se o // valor chegou sem nunca mostrar o valor. String descreverCredencial(const char* valor) { if (valor == nullptr || strlen(valor) == 0) return "(vazio)"; String s(valor); String fora = ""; for (int i = 0; i < (int)s.length(); i++) fora += "*"; return String(s.length()) + " caracteres, todos "; } void mostrarEstadoConfig(const char* rotulo, const char* ssid, const char* senha) { Serial.print(rotulo); Serial.print(" -> ssid="); // O nome da rede pode ser mostrado: ele identifica a rede, nao da acesso. Serial.print(ssid ? ssid : "(nao informado)"); Serial.print(" | senha: "); Serial.println(descreverCredencial(senha)); } void setup() { Serial.begin(115200); Serial.println(); Serial.println("dia 9 aula 1 — segredo, rede e o que nao vai para o git"); Serial.println("==================================================="); Serial.println(); Serial.println("A CADEIA DE CONFIGURACAO"); Serial.println("-----------------------"); mostrarEstadoConfig("1. secrets.h (nao versionado)", SSID_PADRAO, SENHA_PADRAO); mostrarEstadoConfig("2. variavel de ambiente do CI ", SSID_AMBIENTE, SENHA_AMBIENTE); Serial.print("3. valor padrao ficticio -> ssid="); Serial.print(SSID_PADRAO); Serial.print(" | senha: "); Serial.println(descreverCredencial(SENHA_PADRAO)); // O que realmente entra no binario: o primeiro que existir na ordem. const char* ssidFinal = SSID_AMBIENTE ? SSID_AMBIENTE : SSID_PADRAO; const char* senhaFinal = SENHA_AMBIENTE ? SENHA_AMBIENTE : SENHA_PADRAO; Serial.println(); mostrarEstadoConfig("VALOR QUE VAI PARA O BINARIO ", ssidFinal, senhaFinal); Serial.println(); Serial.println("O QUE O .gitignore PROTEGE"); Serial.println("----------------------------"); Serial.println(" secrets.h <- credencial do kit: nunca entra em commit"); Serial.println(" .env <- credencial do Node: nunca entra em commit"); Serial.println(" node_modules/ <- 40 MB de dependencia: nunca entra em commit"); Serial.println(); Serial.println(" A regra: no git so vai o NOME da variavel e o mecanismo."); Serial.println(" O valor nao vai em lugar nenhum, nem no arquivo apagado:"); Serial.println(" apagar o arquivo nao apaga o historico. O commit ja era."); Serial.println(); Serial.println("A REDE DA ESCOLA"); Serial.println("---------------"); Serial.println(" O ESP32 e um cliente: ele se associa a um ponto de acesso"); Serial.println(" que tem senha. Redes WPA2/WPA3 sao o normal da escola."); Serial.println(); // MAC e um identificador de placa, e a discussao do dia: ele identifica o // dispositivo na rede, e por isso e o primeiro dado que se filtra no log // de um roteador. Nao e segredo: e endereco, nao chave. uint8_t mac[6]; WiFi.macAddress(mac); Serial.print(" MAC desta placa: "); for (int i = 0; i < 6; i++) { if (i) Serial.print(":"); if (mac[i] < 16) Serial.print("0"); Serial.print(mac[i], HEX); } Serial.println(); Serial.println(" Fixo na placa, visivel a qualquer um na mesma rede,"); Serial.println(" e nao autentica nada. Confundir as duas coisas e o erro"); Serial.println(); Serial.println("--- o que o professor grava na lousa ---"); Serial.println(" ssid=rede-da-escola <- nome, pode ir no git"); Serial.println(" senha=???? <- valor, NUNCA vai no git"); Serial.println(" Motivo: a rede da escola e conhecida, a senha nao."); // Nao ha associate de verdade nesta aula: o ponto e o codigo do segredo, // nao o sinal. O dia 12 aula 2 e onde a placa entra no WiFi de verdade. WiFi.mode(WIFI_OFF); Serial.println(); Serial.println("WiFi desligado: hoje o assunto e o segredo, nao o sinal."); } void loop() { Serial.println(); Serial.println("--- nova rodada ---"); setup(); delay(10000); }
Sem saída de compilação gravada. Rode python3 validar.py -t 2 dia09 aula1.
Aula 2 — Autenticar e validar entrada: os dois muros
Objetivos
- Separar autenticação de autorização, e dizer em uma frase a diferença entre as duas.
- Escrever o muro de autenticação que lê a chave do banco e devolve
401com motivo, sem nunca comparar no lugar errado. - Mostrar, rodando na máquina, que uma consulta concatenada executa o comando que veio junto com o dado.
- Trocar toda consulta concatenada por consulta com parâmetro, e dizer o que o
bind parameterprotege e o que ele não protege. - Reconhecer uma rota aberta por engano, quando a checagem está depois do
INSERTem vez de antes.
Material
- 1 ESP32 DevKit V1 por dupla, com o cabo USB
- 1
DHT11por dupla, no circuito do dia 8 do 1o trimestre, com resistor de pull-up - 1 computador com Node.js 20,
mysql2instalado e MySQL ou MariaDB 10.11 - Tabela
tb_t2_dispositivocomid_dispositivoechave_api, semeada com dois dispositivos fictícios - Folha de papel por dupla, com a tabela dos quatro casos dos dois muros
- Projetor, para o terminal do Node e o monitor serial da placa juntos
Conceitos
Autenticação e autorização não são a mesma pergunta
A turma confunde as duas o trimestre inteiro, e a distinção cabe em duas frases que o professor escreve na lousa:
- autenticação responde quem é você?
- autorização responde o que você pode fazer?
Autenticar é comparar o que o cliente apresentou com o que o servidor espera. O cliente traz uma senha de acesso à API, que no material é a API key, num cabeçalho de autenticação chamado X-Chave. O mesmo cabeçalho é o header de autenticação, e o termo é o mesmo com as duas grafias porque ele é o nome do padrão HTTP, não do projeto. O servidor abre a tabela de dispositivos, lê a chave daquele id_dispositivo e compara. Comparar é o muro. Se não bate, é 401, e o corpo da requisição não é olhado: quem não é quem não diz o que tem.
A rota que faz isso é a rota protegida, e o nome descreve exatamente o que ela tem: um trecho de código que decide se o pedido entra. Toda rota do projeto do dia 13 tem esse trecho, e a rota protegida que importa aqui é a de POST /api/leitura, porque é a única que grava no banco.
Autorizar é a segunda pergunta, e a aula de hoje só a coloca na mesa para o 3o trimestre respondê-la. Hoje toda placa autorizada é a mesma pessoa: o que muda é a chave, não a permissão. Quando vier o painel com várias pessoas, a autorização é quem tem o perfil de professor e quem só olha.
O token é o nome genérico do que a placa apresenta, e a chave de API é o caso particular deste projeto. A sessão é o outro caso, e é o que o 3o trimestre abre: uma chave que o cliente manda em toda requisição é um token de máquina, e ele não tem dono humano. A diferença entre token e sessão aparece no próximo trimestre, e o professor só escreve os dois nomes na lousa hoje.
A ordem dos muros, e o que acontece quando ela está errada
O código tem que checar antes de agir, e "checar antes de agir" tem nome: é checar antes de agir em cada uma das três camadas.
O bug real da turma é a rota aberta por engano: a rota tem autenticação, mas a checagem está depois do INSERT. O pedido não autenticado entra, valida, grava, e só então descobre que a chave não veio. O banco já tem a linha. O 401 chega ao cliente tarde demais para desfazer nada.
A ordem correta é: autenticar, depois validar, depois gravar. E cada passo responde a uma pergunta, o que permite ao log do dia 8 aula 2 dizer qual deles barrou:
| Passo | Pergunta | Se falhar |
|---|---|---|
| autenticar | quem é você? | 401, corpo ignorado |
| validar | o que você mandou? | 422, com a lista de problemas |
| gravar | deu para guardar? | 500, com o identificador do pedido |
Passar no primeiro não garante o segundo, e o caso que o professor quer que a turma veja é o inverso: passar no segundo sem passar no primeiro é uma leitura legítima de quem não deveria estar mandando. O dado está bom, a faixa é respeitada, e mesmo assim a linha não devia ter entrado. Só o muro um impede.
Entrada não confiável: o que muda quando o texto vira comando
O que o cliente manda é entrada não confiável, e o nome é exato: não significa que o cliente é mal-intencionado, significa que o programa não pode assumir que o texto é o que deveria ser. Ele pode ser um erro de digitação, um sensor com defeito, um curl montado à mão ou um ataque. O código trata os quatro da mesma forma, e é por isso que a entrada não confiável se valida.
A defesa é o bind parameter, que no driver aparece como ? na consulta e como valor no segundo argumento do execute:
const [linhas] = await pool.execute(
'SELECT id_dispositivo FROM tb_t2_leitura WHERE id_dispositivo = ?', [entrada]);
A diferença entre concatenar e usar parâmetro não é "mais seguro" em sentido abstrato. É o que o banco entende que chegou:
| Forma | O que o banco recebe | O que ele faz |
|---|---|---|
| consulta concatenada | um texto único, já montado | interpreta o texto inteiro como comando |
| consulta com parâmetro | o comando, mais o valor à parte | compara a coluna com o valor, sem interpretar o valor |
A consulta com parâmetro é a mesma coisa que o prepared statement do curso de MySQL: o comando é analisado uma vez, e o valor entra depois. O mysql2 faz isso sem o aluno escrever as duas etapas, e o ganho é que não existe caminho no código onde o valor do cliente encosta no texto do comando.
O ataque que a aula executa é o SQL injection na forma de consulta concatenada, e ele injeta uma segunda leitura com UNION SELECT. O que o aluno vê na saída da demonstração é o ponto inteiro da aula: a consulta que o programador escreveu buscava leituras de um dispositivo, e devolveu os dispositivos que têm chave de API. O ataque não quebrou nada, não derrubou nada e não deixou rastro: o servidor executou um comando válido e devolveu o resultado.
Entrada não confiável no segundo muro: por que o parâmetro não basta
O ? resolve o problema de interpretação. Ele não resolve os outros dois, e é por isso que são dois muros e não um:
- ele não verifica faixa. Por parâmetro,
900graus entra com a mesma tranquilidade que27,4. - ele não checa autenticação. Por parâmetro, o
id_dispositivocontinua sendo o texto que o cliente escreveu, e o servidor acredita nele porque não podia ser diferente.
Se o servidor do dia 5 confia no id_dispositivo que veio no corpo para escolher de qual dispositivo é a leitura, ele tem uma brecha que o ? não fecha: qualquer cliente escreve o nome de qualquer placa. A correção é buscar o id_dispositivo do cliente autenticado, e não do corpo da requisição, e essa é a linha que separa o dia 9 do dia 10.
A entrada não confiável do lado da placa
A placa tem entrada não confiável também, e a aula 1 mostrou que ela vem do sensor. A diferença é quem decide: quando a placa lê 900 do DHT11, não há ninguém para injetar nada, e mesmo assim o número não presta.
A distinção que o professor escreve na lousa é entre erro e ataque:
- erro é o sistema que não fez o que devia, e a defesa é a validação de faixa;
- ataque é alguém que escolheu o texto de propósito, e a defesa é a autenticação mais o parâmetro.
Um dado de 900 graus é erro. Um id_dispositivo escrito à mão em um curl é ataque. Nenhum dos dois aparece no log do WiFi, e por isso nenhum dos dois se resolve olhando a rede.
Atividade
Montagem:
DHT11noGPIO4, com resistor de pull-up de 4,7 k ohm entre o pino de dados e3V3.- Servidor Node do dia 5 aula 2 rodando, com as tabelas
tb_t2_leituraetb_t2_dispositivo.
- Grave na placa o sketch da resolução e abra o monitor serial a 115200. Anote, para cada um dos quatro cenários, o status e o motivo que a placa imprimiu.
- Rode o script do Node com os doisdevices registrados. Preencha no caderno a tabela dos cinco casos do muro um: dispositivo, chave apresentada, status e motivo.
- Escreva em uma frase a diferença entre autenticação e autorização, usando como exemplo a placa da bancada e o professor no painel do 3o trimestre.
- Rode o trecho de SQL injection do script. Cole no caderno as duas linhas devolvidas pela consulta concatenada e escreva, em uma frase, o que a segunda linha representa.
- Troque a consulta concatenada do seu projeto por consulta com parâmetro. Rode de novo com a mesma entrada e cole a saída. Quantas linhas vieram? Por que zero é a resposta certa aqui?
- Mande um
curlcom chave correta etemperatura_cde900, e outro com a chave correta eumidade_pctde-5. Os dois status são o mesmo? Se não, o que muda? - Mande um
curlsem a chave e com umid_dispositivoque não existe. Qual status volta? Ele diz que a chave falhou ou que o dispositivo não existe? - Escreva, em duas linhas, onde no seu projeto a checagem de autenticação está depois de alguma coisa que já alterou o banco, e o que você troca de lugar.
Nota: 12 pontos. Critério de fim: a tabela do item 2 preenchida nos cinco casos, e a saída do item 5 com zero linha, colada no caderno ao lado da saída do item 4 com duas linhas.
Resolucao
Do lado do Node, o script executa o ataque de verdade contra o banco de demonstração. Este foi executado nesta máquina, contra MariaDB 10.11, no schema materiais_teste:
// dia 9 aula 2, lado do Node: os dois muros, medidos.
const mysql = require('mysql2/promise');
const ROTA = '/api/leitura';
// ---------------------------------------------------------------------------
// MURO 1: AUTENTICAR. A pergunta e "quem e voce?".
// A placa se apresenta com a chave no cabecalho X-Chave. O servidor consulta
// o banco, compara o valor e decide. Comparar e o muro. O QUE FAZ COM O QUE
// RECEBEU depois e o segundo muro, e nao e a mesma coisa.
// ---------------------------------------------------------------------------
async function autenticar(pool, cabecalhos, idDispositivo) {
const enviada = cabecalhos['x-chave'];
if (!enviada) {
return { ok: false, status: 401, motivo: 'sem chave no cabecalho' };
}
const [linhas] = await pool.execute(
'SELECT chave_api FROM tb_t2_dispositivo WHERE id_dispositivo = ?', [idDispositivo]);
if (linhas.length === 0) {
return { ok: false, status: 401, motivo: 'dispositivo desconhecido' };
}
// O valor recebido NUNCA entra concatenado numa consulta: e parametro do
// driver, e o driver manda para o banco como dato.
if (linhas[0].chave_api !== enviada) {
return { ok: false, status: 401, motivo: 'chave nao confere' };
}
return { ok: true, status: 200, motivo: 'chave reconhecida' };
}
// ---------------------------------------------------------------------------
// MURO 2: VALIDAR. A pergunta e "o que voce mandou?".
// A mesma faixa do dia 8 aula 1. Note que id_dispositivo e texto e os outros
// dois sao numero: a regra de tipo depende do que a coluna guarda.
// ---------------------------------------------------------------------------
const FAIXA = { temperatura_c: { min: -40, max: 80 }, umidade_pct: { min: 0, max: 100 } };
const OBRIGATORIO = ['id_dispositivo', 'temperatura_c', 'umidade_pct'];
function validarEntrada(entrada) {
const problemas = [];
for (const campo of OBRIGATORIO) {
if (!(campo in entrada)) { problemas.push(${campo}: ausente); continue; }
if (entrada[campo] === '' || entrada[campo] === null) { problemas.push(${campo}: vazio); continue; }
if (campo === 'id_dispositivo') {
if (typeof entrada[campo] !== 'string') problemas.push(${campo}: tipo errado (${typeof entrada[campo]}));
continue;
}
if (typeof entrada[campo] !== 'number' || !Number.isFinite(entrada[campo])) {
problemas.push(${campo}: tipo errado (${typeof entrada[campo]}));
}
}
for (const [campo, faixa] of Object.entries(FAIXA)) {
if (!(campo in entrada) || typeof entrada[campo] !== 'number') continue;
if (entrada[campo] < faixa.min || entrada[campo] > faixa.max) {
problemas.push(${campo}: ${entrada[campo]} fora de faixa);
}
}
return problemas;
}
// ---------------------------------------------------------------------------
// SQL INJECTION: a entrada concatenada contra a entrada por parametro.
// A rota atacada e a de CONSULTA, e nao a de gravacao: e nela que a entrada
// do cliente vai parar dentro de um WHERE. O ataque nao tenta apagar nada:
// tenta LER a tabela de credencial, que e o que um ataque de verdade quer.
// ---------------------------------------------------------------------------
// A consulta de consulta escolhe DUAS colunas, e a entrada precisa injetar
// uma segunda leitura com DOIS valores. Errar a contagem e o erro mais comum
// de quem monta esse ataque a mao: o banco recusa com
// ER_WRONG_NUMBER_OF_COLUMNS_IN_SELECT e o aluno conclui que o ataque nao
// funciona, quando o que falhou foi a contagem.
const COLUNAS = 'id_dispositivo, temperatura_c';
// A entrada nao cabe em 32 caracteres, o tamanho da coluna id_dispositivo.
// E por isso que ela nao tenta GRAVAR: ela injeta uma segunda leitura, e o
// resultado volta pela resposta. O parametro nao e protecao de tamanho: e
// protecao de INTENCAO. O banco obedece ao que chega.
const ENTRADA_INIMIGA = "' UNION SELECT 0, id_dispositivo FROM tb_t2_dispositivo WHERE chave_api LIKE 'a3f1%' -- ";
async function consultarConcatenado(pool, entrada) {
// ERRADO: o texto da entrada entra dentro do comando SQL. O banco passa a
// ler duas sentencas onde o programador escreveu uma.
const sql = SELECT ${COLUNAS} FROM tb_t2_leitura WHERE id_dispositivo = '${entrada}';
try {
const [linhas] = await pool.query(sql);
return { ok: true, linhas };
} catch (e) {
return { ok: false, obs: ${e.code}: ${e.message.slice(0, 64)} };
}
}
async function consultarComParametro(pool, entrada) {
// CERTO: o driver manda o texto para o banco como DADO, e o banco compara
// a coluna com esse texto inteiro, sem nunca interpretar o que tem dentro.
try {
const [linhas] = await pool.execute(
SELECT ${COLUNAS} FROM tb_t2_leitura WHERE id_dispositivo = ?, [entrada]);
return { ok: true, linhas };
} catch (e) {
return { ok: false, obs: ${e.code}: ${e.message.slice(0, 64)} };
}
}
async function lerChaveDeQualquerJeito(pool, entrada) {
// A consulta que o atacante QUER que o servidor rode. Ela existe aqui para
// medir a perda: e a segunda sentenca que a entrada injeta no WHERE.
const [linhas] = await pool.query(
SELECT id_dispositivo FROM tb_t2_dispositivo WHERE chave_api LIKE ?, [%${entrada}%]);
return linhas;
}
(async () => {
const pool = mysql.createPool({
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, dateStrings: true,
});
await pool.query('TRUNCATE TABLE tb_t2_leitura');
console.log(rota: POST ${ROTA});
console.log('');
console.log('--- muro 1: autenticar. A pergunta e "quem e voce?" ---');
const [regs] = await pool.query('SELECT id_dispositivo, chave_api FROM tb_t2_dispositivo ORDER BY id_dispositivo');
for (const r of regs) {
const a = await autenticar(pool, { 'x-chave': r.chave_api }, r.id_dispositivo);
console.log( ${r.id_dispositivo}, chave certa -> ${a.status} ${a.motivo});
}
const casos = [
[{}, 'estacao-01', 'sem chave'],
[{ 'x-chave': 'valor-errado-da-aula' }, 'estacao-01', 'chave errada'],
[{ 'x-chave': 'valor-ficticio-qualquer' }, 'estacao-99', 'dispositivo que nao existe'],
];
for (const [cab, id, rotulo] of casos) {
const a = await autenticar(pool, cab, id);
console.log( ${id}, ${rotulo.padEnd(23)} -> ${a.status} ${a.motivo});
}
console.log('');
console.log('Tres motivos, um status so: 401. O status agrupa, o motivo separa,');
console.log('e o motivo que o log guarda. Um log que so diz "nao autorizado"');
console.log('nao diz se faltou a chave ou se a chave estava errada.');
console.log('');
console.log('--- muro 2: chave boa, dado em quatro estados ---');
const CENARIOS = [
{ rotulo: 'dado bom', entrada: { id_dispositivo: 'estacao-01', temperatura_c: 27.4, umidade_pct: 61 } },
{ rotulo: 'dado fora de faixa', entrada: { id_dispositivo: 'estacao-01', temperatura_c: 900, umidade_pct: 61 } },
{ rotulo: 'campo ausente', entrada: { id_dispositivo: 'estacao-01', temperatura_c: 27.4 } },
{ rotulo: 'tipo errado', entrada: { id_dispositivo: 'estacao-01', temperatura_c: 'quente', umidade_pct: 61 } },
];
for (const c of CENARIOS) {
const problemas = validarEntrada(c.entrada);
console.log( ${problemas.length === 0 ? '201' : '422'} ${c.rotulo.padEnd(18)} +
(problemas.length ? problemas.join('; ') : 'aceito'));
}
console.log('');
console.log('A chave estava certa nos quatro casos. Quem barrou foi o muro 2.');
console.log('');
console.log('--- SQL injection: mesma entrada, duas formas de chegar ao banco ---');
console.log('rota atacada: GET /api/leitura?id_dispositivo=<entrada>');
console.log('entrada recebida em id_dispositivo:');
console.log( ${ENTRADA_INIMIGA});
console.log('');
console.log(' (o "--" final comenta o resto da consulta; o "UNION SELECT"');
console.log(' junta uma segunda leitura que vai buscar a tabela de credencial)');
console.log('');
// Uma linha legitima, para a consulta ter o que devolver nos dois casos.
await pool.execute(
INSERT INTO tb_t2_leitura (id_dispositivo, temperatura_c, umidade_pct, dt_leitura)
VALUES ('estacao-01', 27.4, 61, NOW()));
const cat = await consultarConcatenado(pool, ENTRADA_INIMIGA);
const par = await consultarComParametro(pool, ENTRADA_INIMIGA);
console.log( concatenando : ${cat.ok ? EXECUTOU, devolveu ${cat.linhas.length} linha(s) : 'recusou'} +
(cat.ok ? '' : — ${cat.obs}));
console.log( por parametro : ${par.ok ? devolveu ${par.linhas.length} linha(s) : 'recusou'} +
(par.ok ? '' : — ${par.obs}));
console.log('');
if (cat.ok) {
console.log(' as linhas que a consulta concatenada devolveu:');
for (const l of cat.linhas) {
console.log( id_dispositivo=${l.id_dispositivo} temperatura_c=${l.temperatura_c});
}
console.log('');
console.log(' A entrada era um texto qualquer em id_dispositivo. O banco leu a');
console.log(' tabela de CREDENCIAL e devolveu os dispositivos dela. O atacante');
console.log(' nao quebrou nada: o servidor obedeceu a um comando que veio');
console.log(' disfarçado de dado, e o resultado saiu pelo campo de resposta.');
}
if (par.ok && par.linhas.length === 0) {
console.log(' A consulta por parametro nao achou nada, e era o resultado certo:');
console.log(' nao existe dispositivo cujo id_dispositivo seja esse texto todo.');
console.log(' O banco comparou a coluna com o texto inteiro e devolveu zero linha.');
}
console.log('');
const [vazadas] = await pool.query(
SELECT COUNT(*) AS n FROM tb_t2_leitura WHERE id_dispositivo NOT IN
(SELECT id_dispositivo FROM tb_t2_dispositivo));
console.log(linhas com id_dispositivo que NAO existe na tabela de credencial: ${vazadas[0].n});
console.log('Nenhuma: a consulta nao gravou nada, so leu o que nao devia.');
console.log('');
console.log('--- o que o parametro NAO protege ---');
console.log(' Ele nao verifica a faixa. Por parametro, 900 graus entra igual.');
console.log(' Ele nao checa a autenticacao. Por parametro, o id_dispositivo');
console.log(' continua sendo o que o cliente escreveu.');
console.log(' Por isso sao DOIS muros: o parametro garante que o texto e dado;');
console.log(' a validacao garante que o dado faz sentido; e a autenticacao');
console.log(' garante que quem mandou tem permissao.');
await pool.query('TRUNCATE TABLE tb_t2_leitura');
await pool.end();
})();
A saída real, medida nesta máquina:
rota: POST /api/leitura --- muro 1: autenticar. A pergunta e "quem e voce?" --- estacao-01, chave certa -> 200 chave reconhecida estacao-02, chave certa -> 200 chave reconhecida estacao-01, sem chave -> 401 sem chave no cabecalho estacao-01, chave errada -> 401 chave nao confere estacao-99, dispositivo que nao existe -> 401 dispositivo desconhecido Tres motivos, um status so: 401. O status agrupa, o motivo separa, e o motivo que o log guarda. Um log que so diz "nao autorizado" nao diz se faltou a chave ou se a chave estava errada. --- muro 2: chave boa, dado em quatro estados --- 201 dado bom aceito 422 dado fora de faixa temperatura_c: 900 fora de faixa 422 campo ausente umidade_pct: ausente 422 tipo errado temperatura_c: tipo errado (string) A chave estava certa nos quatro casos. Quem barrou foi o muro 2. --- SQL injection: mesma entrada, duas formas de chegar ao banco --- rota atacada: GET /api/leitura?id_dispositivo=<entrada> entrada recebida em id_dispositivo: ' UNION SELECT 0, id_dispositivo FROM tb_t2_dispositivo WHERE chave_api LIKE 'a3f1%' -- (o "--" final comenta o resto da consulta; o "UNION SELECT" junta uma segunda leitura que vai buscar a tabela de credencial) concatenando : EXECUTOU, devolveu 2 linha(s) por parametro : devolveu 0 linha(s) as linhas que a consulta concatenada devolveu: id_dispositivo=0 temperatura_c=estacao-01 id_dispositivo=0 temperatura_c=estacao-02 A entrada era um texto qualquer em id_dispositivo. O banco leu a tabela de CREDENCIAL e devolveu os dispositivos dela. O atacante nao quebrou nada: o servidor obedeceu a um comando que veio disfarçado de dado, e o resultado saiu pelo campo de resposta. A consulta por parametro nao achou nada, e era o resultado certo: nao existe dispositivo cujo id_dispositivo seja esse texto todo. O banco comparou a coluna com o texto inteiro e devolveu zero linha. linhas com id_dispositivo que NAO existe na tabela de credencial: 0 Nenhuma: a consulta nao gravou nada, so leu o que nao devia. --- o que o parametro NAO protege --- Ele nao verifica a faixa. Por parametro, 900 graus entra igual. Ele nao checa a autenticacao. Por parametro, o id_dispositivo continua sendo o que o cliente escreveu. Por isso sao DOIS muros: o parametro garante que o texto e dado; a validacao garante que o dado faz sentido; e a autenticacao garante que quem mandou tem permissao.
O professor aponta quatro coisas nessa saída, e as quatro são resultado da execução, não da explicação:
estacao-99, dispositivo que nao existe -> 401 dispositivo desconhecido: os três casos de recusa têm o mesmo status e motivos diferentes. Sem o motivo no corpo, os três viram um401só, e o aluno não sabe se esqueceu a chave ou se ela está errada. É o que oiddo pedido do dia 8 aula 2 amarra.temperatura_c=estacao-01: a coluna que o atacante não pediu. Ele pediu a segunda coluna da consulta, e o que voltou para a coluna doid_dispositivofoi o nome do dispositivo da tabela de credencial. O professor conta que essa linha é a credencial vazando, mesmo que o valor exibido pareça inofensivo.EXECUTOU, devolveu 2 linha(s): a consulta não deu erro nenhum. Uma demonstração de ataque que dá erro não serve para nada, porque o aluno conclui que o banco é esperto. Ele não é: ele executa o que chega.por parametro: devolveu 0 linha(s): zero é a resposta certa, e o aluno costuma achar que zero é falha. A entrada inteira é o texto doid_dispositivoprocurado, e não existe dispositivo com esse nome. Se a consulta por parâmetro devolvesse alguma coisa, seria o sinal de que o driver não está protecting.
O sketch da placa, que é o lado do segundo muro do mesmo cabo:
// dia 9, aula 2: os dois muros. // // Autenticar e validar nao sao a mesma coisa, e o aluno confunde as duas o // trimestre inteiro. A diferenca cabe numa frase: // // o muro 1 (autenticar) responde "QUEM e voce?" // o muro 2 (validar) responde "O QUE voce mandou?" // // Passar no primeiro nao garante o segundo. Quem passa em um esta // autenticado e ainda assim pode mandar lixo; quem passa no segundo e nao // passou no primeiro mandou coisa legitima de quem nao deveria mandar. // // Este sketch e a placa do lado do segundo muro. Ela tem a chave de // autenticar (a senha de acesso a API) e a regra de faixa. O que ela NAO // tem e a protecao do roteador, que e o primeiro muro. #include <Arduino.h> #include <DHT.h> #include <HTTPClient.h> #include <WiFi.h> const int PIN_DHT = 4; const uint8_t TIPO_SENSOR = DHT11; // O MURO 1: a chave que a placa apresenta ao servidor. Valor FICTICIO. // A chave de verdade morre no .env do Node (dia 11), nunca neste arquivo. const char* CHAVE_API = "chave-ficticia-da-aula-09"; // O MURO 2: a faixa que faz sentido para o dado. Mesma regra do dia 8, // repetida aqui de proposito: cada lado do cabo tem a sua copia. const float MIN_TEMP_C = -40.0; const float MAX_TEMP_C = 80.0; const char* URL_SERVIDOR = "http://192.168.0.10:3000/api/leitura"; DHT sensor(PIN_DHT, TIPO_SENSOR); struct Voto { const char* nome; const char* codigo; // o que o servidor devolve bool permitido; }; // Avalia os dois muros e diz qual deles barrou. Separar "nao tenho chave" // de "chave boa, dado ruim" e o que permite responder 401 em um caso e // 422 no outro — status diferentes, acoes diferentes no log. Voto avaliar(float temperatura_c, bool tem_chave) { Voto v; if (!tem_chave || strlen(CHAVE_API) == 0) { v.nome = "sem chave no cabecalho"; v.codigo = "401"; v.permitido = false; return v; } if (isnan(temperatura_c) || temperatura_c < MIN_TEMP_C || temperatura_c > MAX_TEMP_C) { v.nome = "chave valida, dado fora de faixa"; v.codigo = "422"; v.permitido = false; return v; } v.nome = "os dois muros passaram"; v.codigo = "201"; v.permitido = true; return v; } // Sem concatenar o valor do usuario dentro do cabecalho: o cabecalho e // montado com String, e o valor nunca e concatenado num comando de banco. // A regra do segundo muro no lado do banco esta no dia 10. void mostrarVoto(const char* cenario, float t, bool tem_chave) { Voto v = avaliar(t, tem_chave); Serial.print(v.permitido ? "pode gravar | " : "recusado | "); Serial.print(v.codigo); Serial.print(" | "); Serial.println(cenario); Serial.print(" motivo: "); Serial.println(v.nome); Serial.print(" payload:"); if (isnan(t)) { Serial.print(" (sem numero, sensor devolveu NaN)"); } else { Serial.print(" temperatura_c="); Serial.print(t, 1); } Serial.println(); Serial.println(); } void setup() { Serial.begin(115200); Serial.println(); Serial.println("dia 9 aula 2 — autenticar e validar: os dois muros"); Serial.println("==============================================="); Serial.print("rota: "); Serial.println(URL_SERVIDOR); Serial.println(); Serial.println("muro 1, autenticacao: QUEM e voce?"); Serial.println(" chave de API no cabecalho X-Chave. Vem do .env do Node."); Serial.println(" falhou aqui? 401, e o servidor nem olha o corpo."); Serial.println(); Serial.println("muro 2, validacao: O QUE voce mandou?"); Serial.println(" tipo numerico, campo obrigatorio, faixa plausivel."); Serial.println(" falhou aqui? 422, a chave estava certa e o dado nao."); Serial.println(); Serial.println("--- quatro requisicoes contra o mesmo servidor ---"); mostrarVoto("chave boa + dado bom", 27.4, true); mostrarVoto("sem chave, dado bom", 27.4, false); mostrarVoto("chave boa + dado lixo", 900.0, true); mostrarVoto("sem chave + dado lixo", 900.0, false); Serial.println("Repare: 401 e 422 sao numeros diferentes porque sao"); Serial.println("problemas diferentes. Um log que so diz \"deu erro\""); Serial.println("nao distingue a placa sem chave da placa com sensor quebrado,"); Serial.println("e o professor perde meia hora procurando rede."); Serial.println(); Serial.println("--- o que NAO e muro ---"); Serial.println(" comparar o token com o do servidor e um muro."); Serial.println(" Concatenar o valor recebido dentro do SQL NAO e um muro: e uma"); Serial.println(" porta dos fundos. O parametro `?` do driver e o muro de verdade."); Serial.println(" Regra da aula: o que se manda e o que se guarda sao coisas diferentes."); Serial.println(" O segundo muro vale para quem JÁ passou pelo primeiro."); // O WiFi fica desligado: o ponto da aula e a logica dos muros, e nao o // sinal. Ligar o WiFi aqui custaria 20 s de associate por nada. WiFi.mode(WIFI_OFF); Serial.println(); Serial.println("WiFi desligado. O POST de verdade e do dia 5."); } void loop() { Serial.println(); Serial.println("--- nova rodada ---"); setup(); delay(10000); }
Por que assim e não de outro jeito. A função avaliar da placa checa o muro um antes do muro dois, e sai assim que um dos dois falha. A ordem não é estética: se o muro dois viesse primeiro, o caso "sem chave e dado lixo" devolveria 422 e o log diria que o problema foi o dado, quando o problema real foi a autenticação. Quem recebe o 422 vai caçar o DHT11, e o DHT11 está perfeito.
A placa não consulta banco nenhum para conferir a chave, e o comentário da CHAVE_API diz por quê: a chave de verdade morre no .env do Node, no dia 11. Na placa, a chave é uma constante fictícia que existe para o aluno ver o cabeçalho sendo montado e o 401 sendo tratado. A comparação de verdade acontece no servidor, e é o que o autenticar do script faz lendo a tabela de dispositivos.
Os quatro cenários do setup são a matriz completa dos dois muros, e o professor a desenha na lousa como quadrado de duas por duas: chave boa ou ausente, por dado bom ou lixo. A diagonal é o que interessa: sem chave + dado bom dá 401, e sem chave + dado lixo também dá 401. Nos dois, o problema é o mesmo, mesmo com dados opostos. Esse quadrado é o que separa autenticação de validação para a turma, e ele é o único diagrama da aula.
A função mostrarVoto imprime o motivo e o payload, e nessa ordem. O motivo vem primeiro porque é ele que o professor lê da mesa de dentro da sala, e o payload vem depois porque é ele que o aluno precisa para comparar com o que mandou. Um log que imprime o payload e não o motivo obriga a decodificar o JSON para descobrir o que aconteceu, e o dia 8 aula 2 já mostrou que essa é a receita de perder meia hora.
O WiFi.mode(WIFI_OFF) no fim economiza uns vinte segundos de tentativa de associação em trinta placas ao mesmo tempo, e o comentário diz que o POST de verdade é do dia 5. A aula é sobre a lógica dos muros, e a lógica roda sem sinal nenhum: ela roda no Serial diante da turma, que é o instrumento de aula.
Criterios de correcao
| Critério | Pontos |
|---|---|
| Tabela do item 2 preenchida nos cinco casos, com status e motivo | 2 pontos |
DHT11 no GPIO4 com pull-up, e PIN_DHT e TIPO_SENSOR em const no topo | 1 pontos |
| Item 3: a diferença entre autenticação e autorização escrita em uma frase, com os dois exemplos | 2 pontos |
| Item 4: as duas linhas da consulta concatenada coladas, e a leitura de que a segunda é credencial | 2 pontos |
| Consulta do projeto trocada por consulta com parâmetro, com a saída de zero linha colada | 2 pontos |
| Item 6: os dois status comparados e o que muda entre eles | 1 pontos |
Item 7: o 401 do dispositivo inexistente, e a leitura de que o motivo separa os casos | 1 pontos |
| Item 8: o ponto do projeto onde a checagem está depois de alterar o banco, e a troca proposta | 1 pontos |
Erros comuns
| Erro | Como aparece | Correção |
|---|---|---|
Concatenar valor do cliente dentro do SQL | "Usei uma template string e funcionou" | "Funcionou porque o banco obedeceu. O ? do driver é o muro: o valor chega como dado, e nunca como parte do comando. Busca concatenada com LIKE também conta." |
Concatenar com LIKE achando que é seguro | "Uso % e ${valor}, mas filtro com LIKE" | "LIKE concatena do mesmo jeito que =. O ? também funciona dentro de LIKE: WHERE nome LIKE ?, com '%' + valor + '%' no array." |
Usar query em vez de execute | "Chamei pool.query com ?" | "Com ?, use execute. query não prepara o comando e o valor concatenado volta a virar texto. É a diferença entre a consulta segura e a que parece segura." |
Autenticar depois do INSERT | "A rota tem chave, mas só confiro no fim" | "Essa é a rota aberta por engano: a linha já foi gravada quando o 401 sai. Autentique antes de tocar no banco, e o corpo nem deve ser lido antes disso." |
Usar o id_dispositivo do corpo para saber quem é | "Peguei o dispositivo do JSON e gravei" | "O corpo não é confiável. O id_dispositivo tem que vir do cliente autenticado, e não do que o cliente escreveu. Sem isso, qualquer um grava em nome de qualquer placa." |
| Comparar a chave no código, com a chave hardcoded | "A chave está no if e compara certo" | "A chave no if é a mesma credencial exposta do dia 9 aula 1, agora no servidor. Compare com o valor do banco, e o valor do banco vem do .env." |
Devolver 422 para falta de chave | "Chave errada e dado ruim são a mesma coisa" | "São muros diferentes. Falta de chave é 401 e o corpo não é lido; dado ruim é 422 e o corpo foi lido para ser julgado. Status diferente muda o que a placa faz." |
| Logar a chave recebida | "Imprimi a chave para ver se chegou" | "O tamanho, como no dia 9 aula 1. A chave recebida no log vaza a credencial de novo, agora pelo lado do servidor." |
401 sem motivo no corpo | "O aluno perguntou por que foi recusado" | "O status agrupa, o motivo separa. sem chave no cabecalho, chave nao confere e dispositivo desconhecido são três consertos diferentes, e o log precisa dos três." |
| Achar que parâmetro protege faixa | "Usei execute, então 900 graus não entra" | "O ? garante que o texto é dado, não que o dado faz sentido. Por parâmetro, 900 entra igual. É o segundo muro, e ele é outro muro." |
| Contar colunas errada no ataque de teste | "Fiz o UNION e o banco recusou, então Injection não funciona" | "Recusou com ER_WRONG_NUMBER_OF_COLUMNS_IN_SELECT: as duas leituras precisam do mesmo número de colunas. O ataque funciona; a montagem é que estava errada." |
Tratar erro e validação no mesmo catch | "A validação virou 500" | "O 422 é regra de negócio e não é erro inesperado. Tratar os dois junto faz a placa repetir o envio para sempre, e o dia 8 aula 2 mostrou o que isso custa no log." |
| Confundir token com pessoa | "A sessão é o token" | "A chave que a placa manda é token de máquina: não tem dono humano, e vale para sempre. Sessão é o que um usuário humano tem, e expira. É a abertura do 3o trimestre." |
Desafio extra
Escreva a rota que autentica, valida e grava, na ordem, e que devolve 401, 422 e 500 em três casos distintos com o motivo no corpo. Depois escreva a mesma rota com a checagem de autenticação depois do INSERT, rode as duas com o mesmo pedido sem chave, e compare quantas linhas sobraram na tabela. A diferença entre as duas é o que a ordem dos muros custa, e ela aparece no número de linhas, não em opinião.
A resolucao, compilada
// dia 9, aula 2: os dois muros. // // Autenticar e validar nao sao a mesma coisa, e o aluno confunde as duas o // trimestre inteiro. A diferenca cabe numa frase: // // o muro 1 (autenticar) responde "QUEM e voce?" // o muro 2 (validar) responde "O QUE voce mandou?" // // Passar no primeiro nao garante o segundo. Quem passa em um esta // autenticado e ainda assim pode mandar lixo; quem passa no segundo e nao // passou no primeiro mandou coisa legitima de quem nao deveria mandar. // // Este sketch e a placa do lado do segundo muro. Ela tem a chave de // autenticar (a senha de acesso a API) e a regra de faixa. O que ela NAO // tem e a protecao do roteador, que e o primeiro muro. #include <Arduino.h> #include <DHT.h> #include <HTTPClient.h> #include <WiFi.h> const int PIN_DHT = 4; const uint8_t TIPO_SENSOR = DHT11; // O MURO 1: a chave que a placa apresenta ao servidor. Valor FICTICIO. // A chave de verdade morre no .env do Node (dia 11), nunca neste arquivo. const char* CHAVE_API = "chave-ficticia-da-aula-09"; // O MURO 2: a faixa que faz sentido para o dado. Mesma regra do dia 8, // repetida aqui de proposito: cada lado do cabo tem a sua copia. const float MIN_TEMP_C = -40.0; const float MAX_TEMP_C = 80.0; const char* URL_SERVIDOR = "http://192.168.0.10:3000/api/leitura"; DHT sensor(PIN_DHT, TIPO_SENSOR); struct Voto { const char* nome; const char* codigo; // o que o servidor devolve bool permitido; }; // Avalia os dois muros e diz qual deles barrou. Separar "nao tenho chave" // de "chave boa, dado ruim" e o que permite responder 401 em um caso e // 422 no outro — status diferentes, acoes diferentes no log. Voto avaliar(float temperatura_c, bool tem_chave) { Voto v; if (!tem_chave || strlen(CHAVE_API) == 0) { v.nome = "sem chave no cabecalho"; v.codigo = "401"; v.permitido = false; return v; } if (isnan(temperatura_c) || temperatura_c < MIN_TEMP_C || temperatura_c > MAX_TEMP_C) { v.nome = "chave valida, dado fora de faixa"; v.codigo = "422"; v.permitido = false; return v; } v.nome = "os dois muros passaram"; v.codigo = "201"; v.permitido = true; return v; } // Sem concatenar o valor do usuario dentro do cabecalho: o cabecalho e // montado com String, e o valor nunca e concatenado num comando de banco. // A regra do segundo muro no lado do banco esta no dia 10. void mostrarVoto(const char* cenario, float t, bool tem_chave) { Voto v = avaliar(t, tem_chave); Serial.print(v.permitido ? "pode gravar | " : "recusado | "); Serial.print(v.codigo); Serial.print(" | "); Serial.println(cenario); Serial.print(" motivo: "); Serial.println(v.nome); Serial.print(" payload:"); if (isnan(t)) { Serial.print(" (sem numero, sensor devolveu NaN)"); } else { Serial.print(" temperatura_c="); Serial.print(t, 1); } Serial.println(); Serial.println(); } void setup() { Serial.begin(115200); Serial.println(); Serial.println("dia 9 aula 2 — autenticar e validar: os dois muros"); Serial.println("==============================================="); Serial.print("rota: "); Serial.println(URL_SERVIDOR); Serial.println(); Serial.println("muro 1, autenticacao: QUEM e voce?"); Serial.println(" chave de API no cabecalho X-Chave. Vem do .env do Node."); Serial.println(" falhou aqui? 401, e o servidor nem olha o corpo."); Serial.println(); Serial.println("muro 2, validacao: O QUE voce mandou?"); Serial.println(" tipo numerico, campo obrigatorio, faixa plausivel."); Serial.println(" falhou aqui? 422, a chave estava certa e o dado nao."); Serial.println(); Serial.println("--- quatro requisicoes contra o mesmo servidor ---"); mostrarVoto("chave boa + dado bom", 27.4, true); mostrarVoto("sem chave, dado bom", 27.4, false); mostrarVoto("chave boa + dado lixo", 900.0, true); mostrarVoto("sem chave + dado lixo", 900.0, false); Serial.println("Repare: 401 e 422 sao numeros diferentes porque sao"); Serial.println("problemas diferentes. Um log que so diz \"deu erro\""); Serial.println("nao distingue a placa sem chave da placa com sensor quebrado,"); Serial.println("e o professor perde meia hora procurando rede."); Serial.println(); Serial.println("--- o que NAO e muro ---"); Serial.println(" comparar o token com o do servidor e um muro."); Serial.println(" Concatenar o valor recebido dentro do SQL NAO e um muro: e uma"); Serial.println(" porta dos fundos. O parametro `?` do driver e o muro de verdade."); Serial.println(" Regra da aula: o que se manda e o que se guarda sao coisas diferentes."); Serial.println(" O segundo muro vale para quem JÁ passou pelo primeiro."); // O WiFi fica desligado: o ponto da aula e a logica dos muros, e nao o // sinal. Ligar o WiFi aqui custaria 20 s de associate por nada. WiFi.mode(WIFI_OFF); Serial.println(); Serial.println("WiFi desligado. O POST de verdade e do dia 5."); } void loop() { Serial.println(); Serial.println("--- nova rodada ---"); setup(); delay(10000); }
Sem saída de compilação gravada. Rode python3 validar.py -t 2 dia09 aula2.
