Contratos inteligentes não enxergam o mundo fora da blockchain. Para saber quanto vale um ETH, um protocolo de empréstimos depende de um oráculo, e o mais usado do ecossistema é a rede de Data Feeds da Chainlink. Isso cria dois pontos de confiança bem distintos: de um lado, os operadores de nó, que rodam a infraestrutura que assina e publica os preços; do outro, os protocolos consumidores, que escreveram código assumindo que esses preços estão corretos, frescos e dentro de limites razoáveis.

Este artigo apresenta duas ferramentas open-source em Go que desenvolvi, cada uma voltada a um desses lados. O oraclint é um linter estático de segurança para a configuração de nós Chainlink, que roda antes do deploy. O oraclewatch é um daemon de monitoramento que lê os feeds diretamente da chain e alerta quando as premissas do consumidor deixam de valer. Nenhuma das duas é afiliada à Chainlink Labs.

Conceitos

Data Feeds e o contrato agregador

Um Data Feed é um par de contratos: um proxy, endereço estável que o consumidor chama, e um aggregator, que recebe os relatórios dos nós e guarda o valor consolidado. O consumidor lê o preço com latestRoundData(), que devolve o valor (answer), o identificador da rodada e os timestamps de início e atualização. Um feed é atualizado quando o preço desvia mais que um limiar (deviation threshold) ou quando passa o heartbeat, o intervalo máximo entre atualizações.

Os agregadores também possuem limites, minAnswer e maxAnswer. Relatórios fora desse intervalo são rejeitados, e não "achatados" no limite: o feed simplesmente congela no último valor aceito. Esse detalhe, confirmado no código-fonte dos agregadores implantados, é o centro de um dos incidentes descritos mais adiante.

Sequencer de L2

Em rollups como Arbitrum, Base e OP Mainnet, um único sequencer ordena as transações. Se ele cai, os preços param de ser atualizados, e quando volta, as transações enfileiradas são executadas primeiro, possivelmente contra preços velhos. Por isso a Chainlink publica sequencer uptime feeds, e auditorias costumam exigir que o consumidor verifique o sequencer e respeite um período de carência (grace period) após a recuperação.

O nó Chainlink

Do lado do operador, um nó é um processo que guarda chaves, assina transações e relatórios, e expõe uma interface de operação (porta 6688) que gerencia jobs, chaves e bridges. Sua configuração fica em um config.toml e um secrets.toml, normalmente acompanhados de um docker-compose, manifests Kubernetes e arquivos .env. Boa parte dos comprometimentos de nós não envolve exploits, e sim configuração: interface publicada na internet, banco sem TLS, senha de keystore curta, chave de RPC commitada no repositório.


oraclint: linter de configuração para nós

Ferramentas como tfsec, checkov e kube-bench fazem para Terraform e Kubernetes exatamente o que faltava para nós Chainlink: apontar configurações inseguras antes do deploy, com localização exata e correção sugerida. O oraclint preenche essa lacuna. Ele lê os arquivos do nó e da infraestrutura ao redor, roda totalmente offline e nunca toca em um nó em execução.

Regras verificadas contra o código do nó

São 35 regras distribuídas entre configuração do nó, secrets, permissões de arquivo, docker-compose, Kubernetes e dotenv. Cada uma foi verificada contra o código-fonte e a documentação do nó na versão v2.69.0: nome da chave, valor padrão e comportamento real. Uma regra cujo comportamento não pôde ser confirmado é marcada como experimental ou simplesmente não existe. O README inclusive lista os candidatos descartados e o motivo, como a complexidade da senha do banco (o próprio nó já recusa senhas fracas na inicialização) e o pprof (sempre atrás de autenticação no router do nó).

Ciente dos valores padrão

Uma chave ausente não é tratada como "não configurada": ela é avaliada com o valor padrão real do nó. Quando há múltiplos arquivos de configuração, eles são mesclados com a mesma semântica de override do nó. Isso evita tanto falsos positivos quanto falsos negativos, que são o principal motivo pelo qual linters genéricos de TOML não servem aqui.

Arquitetura

O oraclint é um pipeline linear de pacotes pequenos:

cli → loader → model → engine ← rules (35)
                         │
                         ▼
                 suppress → redact → filter/sort → report (text | JSON | SARIF)

O loader descobre e classifica os arquivos por nome e conteúdo, e o model mantém cada valor associado ao seu arquivo:linha:coluna. Cada regra é um tipo Go com dois métodos e uma função pura do alvo, em seu próprio arquivo. Antes da renderização, toda mensagem passa pelo redact: o oraclint nunca imprime um segredo, com duas camadas de redação garantidas por um teste de fuzzing em todos os formatos de saída. Entradas hostis também são tratadas: alias bombs em YAML, aninhamento profundo, arquivos binários e symlinks que escapam da raiz do scan (a varredura usa os.Root).


oraclint na prática

A instalação pode ser feita com Go, binário de release ou Docker:

go install github.com/brennocm/oraclint/cmd/oraclint@latest
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src:ro" -w /src \
  ghcr.io/brennocm/oraclint:latest scan .

O repositório inclui um nó propositalmente inseguro em examples/insecure-node. Um trecho da saída:

oraclint scan examples/insecure-node
examples/insecure-node/config.toml
  2:1     HIGH     NS-KS-001    InsecureFastScrypt = true makes every keystore use weak scrypt
                                parameters, so a leaked database can be brute-forced offline
          fix: Remove InsecureFastScrypt (default false) and rotate keys created while it was enabled.
  14:1    MEDIUM   NS-WEB-003   WebServer.AllowOrigins contains '*', which allows any website to
                                make cross-origin requests to the node API
          fix: Set WebServer.AllowOrigins to the exact URL(s) used to reach the operator UI.
  54:1    HIGH     NS-RPC-001   EVM chain "1" node "mainnet-infura": HTTPURL uses unencrypted
                                http:// to mainnet.example-rpc.com
          fix: Use https:// and wss:// endpoints, or keep the RPC node on a private network.

examples/insecure-node/docker-compose.yml
  6:5     HIGH     NS-DKR-003   service "chainlink" runs privileged, which gives it full access to the host
          fix: Remove privileged, network_mode: host, pid: host and the listed cap_add entries.
  9:9     HIGH     NS-DKR-001   service "chainlink" publishes operator port 6688 on all host interfaces
          fix: Publish the port on loopback only ("127.0.0.1:6688:6688") and use an SSH tunnel or VPN.
  11:7    HIGH     NS-KS-002    CL_PASSWORD_KEYSTORE is shorter than 16 characters

A regra NS-DKR-001 merece destaque, pois conversa diretamente com o problema discutido no artigo sobre a bridge Tor: o Docker publica portas inserindo suas próprias regras de firewall, então uma porta publicada costuma ser alcançável mesmo com o ufw configurado para bloqueá-la. A correção é publicar a interface de operação apenas em loopback e acessá-la por túnel SSH.

Supressões com justificativa obrigatória

Toda supressão exige um motivo, seja inline ou no .oraclint.yaml. Uma diretiva sem motivo não é aplicada e vira um achado informativo. Os achados suprimidos continuam visíveis no JSON e no SARIF, de modo que a justificativa fica registrada:

[WebServer.TLS]
# oraclint:ignore NS-WEB-001 reason="TLS is terminated at the load balancer"
HTTPSPort = 0

Ao escanear conteúdo não confiável, como pull requests de forks, a flag --ignore-suppressions garante que apenas a sua política seja aplicada, já que o próprio PR poderia trazer um .oraclint.yaml silenciando tudo.

Integração com CI

O repositório é também uma GitHub Action. Com saída em SARIF 2.1.0, os achados aparecem na aba de code scanning do GitHub:

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v7
      - uses: brennocm/oraclint@v0.1.0   # fixe por SHA em produção
        with:
          path: .
          format: sarif
          output: oraclint.sarif
          fail-on: high
      - uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: oraclint.sarif

Os códigos de saída seguem o padrão de linters: 0 sem achados acima do limiar, 1 com achados e 2 para erro de uso. Arquivos que não podem ser parseados geram um achado parse-error de severidade alta, para que um arquivo quebrado falhe o build em vez de passar silenciosamente. No deploy de exemplo (5 arquivos, 35 regras), um scan completo leva cerca de 4 ms.


oraclewatch: monitoramento do lado consumidor

A Chainlink opera e monitora seus Data Feeds. O risco de usar um feed, porém, é do protocolo que o consome. O oraclewatch roda continuamente as mesmas verificações que auditores pedem on-chain (freshness do updatedAt, answer > 0, uptime do sequencer e grace period), além de algumas que só fazem sentido off-chain, e aciona um humano quando algo sai do esperado. Três incidentes reais motivaram o catálogo:

  1. LUNA/USD, maio de 2022. O preço de mercado caiu abaixo do minAnswer do agregador. Como relatórios fora do intervalo são rejeitados, o feed congelou em cerca de US$ 0,107 enquanto a LUNA era negociada a US$ 0,01. Atacantes compraram a mercado, depositaram como colateral pelo preço do oráculo e tomaram empréstimos. Venus e Blizz Finance perderam cerca de US$ 11M e US$ 8,3M. Os checks FW-DEV-001 (desvio de ~970%), FW-STALE-001 e FW-CB-001 sinalizam essa condição.
  2. Moonwell wrsETH, novembro de 2025. Um feed de taxa de câmbio reportou 1 wrsETH = 1,65 milhão de ETH, e o ataque levou 30 segundos. O FW-JUMP-001 sinaliza o salto, e o caso mostra por que um monitor complementa, e não substitui, limites de sanidade on-chain.
  3. Queda do sequencer da Arbitrum, dezembro de 2023. Cerca de 78 minutos fora do ar, cobertos por FW-SEQ-001, FW-SEQ-002 e FW-CHAIN-001.

O catálogo de checks

São 16 checks com IDs estáveis, cada um documentado com a lógica exata, os parâmetros padrão e os falsos positivos conhecidos. Agrupando por propósito:

  1. Saúde do feed: feed parado além do heartbeat (FW-STALE-001), dados de rodada inválidos (FW-INVALID-001), proximidade dos limites do circuit breaker (FW-CB-001) e saltos implausíveis entre rodadas (FW-JUMP-001, experimental).
  2. Mercado: desvio em relação à mediana de várias exchanges (FW-DEV-001), com um estado explícito de "referência não confiável" quando poucas exchanges respondem ou elas discordam (FW-REF-001), e divergência do mesmo par entre redes (FW-XCHAIN-001).
  3. Mudanças no contrato: proxy apontando para um novo agregador (FW-AGG-001) e divergência de decimals ou descrição (FW-META-001).
  4. Confiança na fonte: RPCs que retornam dados diferentes para o mesmo bloco (FW-RPC-001), visões de fork (FW-RPC-002), chain ID errado (FW-RPC-003) e head parado (FW-CHAIN-001).
  5. L2: sequencer fora do ar (FW-SEQ-001) e ainda dentro do grace period (FW-SEQ-002).
  6. O próprio monitor: FW-SELF-001 dispara quando não há leituras bem-sucedidas por N ciclos. Sem dados, o oraclewatch se declara cego em vez de adivinhar.

Um bloco fixo por ciclo

A decisão de design central é que todo valor comparado por um check vem do mesmo bloco. Há uma goroutine por rede, e cada ciclo começa fixando um bloco: min(head) - confirmations entre todos os endpoints disponíveis. Endpoints cujo head está atrasado são excluídos, o hash do bloco é comparado entre eles (a maioria vence) e então cada feed é lido em todos os endpoints naquele bloco exato.

Isso é o que torna correta a regra do FW-RPC-001 de que qualquer diferença é crítica: duas RPCs honestas, consultadas no mesmo bloco, precisam devolver bytes idênticos. Também mantém a medição de staleness ancorada no relógio da própria chain, e não no relógio da máquina que roda o monitor.

Os checks são funções puras, sem I/O e sem relógio, com aritmética exata em math/big. Os resultados alimentam uma máquina de estados de alertas (OK → PENDING → FIRING → RESOLVING) com for:, histerese, cooldown e agrupamento de tempestades de alertas, enviados para Discord, Telegram ou stdout, além de métricas Prometheus e um dashboard Grafana.


oraclewatch na prática

O modo check roda um único ciclo em todas as redes e sai com código 0 (ok), 1 (resultado igual ou acima de --fail-on) ou 2 (erro), servindo para CI ou cron. As URLs de RPC são referenciadas como ${VAR} e expandidas do ambiente apenas em campos de segredo:

cp .env.example .env     # duas RPCs de provedores diferentes por rede, notificadores
make build && set -a && . ./.env && set +a
./bin/oraclewatch check --config configs/example.yaml

Uma execução real contra ETH/USD na Ethereum e na Arbitrum, usando RPCs públicas (saída resumida):

STATUS  CHECK           NETWORK   FEED     SUMMARY
ok      FW-CB-001       ethereum  ETH/USD  bounds are nominal
ok      FW-DEV-001      arbitrum  ETH/USD  within 0.0495% of the market
ok      FW-DEV-001      ethereum  ETH/USD  within 0.2728% of the market
ok      FW-REF-001      ethereum  ETH/USD  reference trusted
ok      FW-RPC-001      arbitrum  ETH/USD  2 endpoints agree
ok      FW-RPC-002      ethereum           endpoints agree on block 26250692
ok      FW-RPC-003      arbitrum           every endpoint serves the configured chain
ok      FW-SEQ-001      arbitrum           sequencer up
ok      FW-SEQ-002      arbitrum           up for 3720h50m50s
ok      FW-STALE-001    arbitrum  ETH/USD  updated 3m10s ago
ok      FW-STALE-001    ethereum  ETH/USD  updated 53m0s ago
ok      FW-XCHAIN-001   *         ETH/USD  ETH/USD consistent across 2 networks

Repare no FW-STALE-001: o feed da Ethereum foi atualizado há 53 minutos e ainda está saudável, porque o heartbeat de ETH/USD na mainnet é de uma hora. É por isso que cada feed carrega seus próprios parâmetros, copiados dos arquivos oficiais, em vez de um limite global.

Para o daemon com Prometheus, Grafana e TLS, há um compose pronto, e a documentação cobre um deploy em uma instância arm64 do Oracle Cloud Always Free, a mesma base usada no OCI-SecOps:

docker compose -f deploy/docker-compose.yml --env-file .env up -d

A configuração de exemplo monitora ETH/USD, BTC/USD e USDC/USD na Ethereum, Arbitrum, Base e OP Mainnet, mais os feeds de uptime de sequencer das três L2s. Nenhum endereço foi digitado à mão: todos vieram dos arquivos de dados da página oficial e foram verificados on-chain contra duas RPCs no mesmo bloco, e um teste garante que o exemplo continue idêntico às listas verificadas.


Segurança das próprias ferramentas

Ferramentas de segurança que rodam ao lado de infraestrutura sensível precisam ser elas mesmas um alvo difícil. Alguns princípios são comuns às duas:

  1. Superfície mínima. O oraclint não acessa a rede e tem três dependências diretas de runtime (cobra, go-toml, yaml). O oraclewatch usa apenas eth_chainId, eth_getBlockByNumber e eth_call; não existe chave privada no programa, e um teste falha se o binário linkar qualquer pacote de gerenciamento de chaves ou assinatura de transações.
  2. Segredos nunca saem do processo. Redação em strings, erros (incluindo *url.Error, que costuma vazar a URL completa da RPC com a API key) e logs, verificada por testes ponta a ponta.
  3. Supply chain. Actions fixadas por SHA, Dependabot, releases pelo GoReleaser com checksums assinados via cosign (keyless, Sigstore), SBOM SPDX e proveniência SLSA verificável com gh attestation verify.
  4. Documentação de ameaças. Cada repositório tem um THREAT_MODEL.md e decisões de design registradas em ADRs.

Limitações

Ambas são deliberadamente honestas sobre o que não fazem. O oraclint é análise estática: ele vê arquivos, não o nó em execução, e as regras valem para a v2.69.0, podendo errar em outras versões. Helm charts precisam ser renderizados com helm template antes.

O oraclewatch tem latência de detecção igual ao intervalo de polling (60 s por padrão) mais as confirmações. Um atacante observando o mesmo feed age dentro de um bloco; o monitor avisa humanos, não impede transações. Ele não substitui a validação on-chain no contrato consumidor, que é a primeira linha de defesa, e não cobre preços derivados que o protocolo compõe a partir de vários feeds.


Considerações finais

Oráculos concentram confiança de duas formas: na infraestrutura que produz os dados e na suposição de quem os consome de que esses dados continuam válidos. O oraclint atua antes do deploy, transformando boas práticas de operação de nó em regras verificáveis que rodam no CI. O oraclewatch atua depois, verificando continuamente se as premissas do consumidor ainda se sustentam.

A lição comum aos dois projetos é que uma ferramenta de segurança vale tanto quanto a precisão das suas premissas. Por isso cada regra foi verificada contra o código do nó, cada check tem um incidente real ou uma pesquisa documentada por trás, e ambos preferem dizer "não sei" (parse-error, FW-SELF-001) a passar silenciosamente.