Skip to content

fix: harden submission containers and stop grading crashes on bad monitor_out - #82

Open
felipelageduarte wants to merge 1 commit into
mainfrom
fix/p0-sandbox-hardening
Open

felipelageduarte wants to merge 1 commit into
mainfrom
fix/p0-sandbox-hardening

Conversation

@felipelageduarte

Copy link
Copy Markdown

Contexto

Parte da mesma auditoria de segurança referenciada no PR companheiro runcodes-icmc/compiler-monitor#14 (documento completo: compiler-monitor-oportunidades.md, anexado naquele PR). Este PR cobre os itens S2/S10 (parcial de S1) e B6 do roadmap P0.

O que mudou

S2 / S10 (parcial de S1) — Container do aluno sem hardening de rede/capabilities (container.py)

client.containers.run(...) não passava nenhum parâmetro de contenção. O código do aluno (compilação + execução de testes) rodava com rede completa e o conjunto default de capabilities do Docker, sem nenhuma necessidade legítima de rede (compilação e execução de teste são 100% locais, contra o workspace montado). Isso abria para: exfiltração de casos de teste para fora da rede, ataque a outros serviços na bridge do Docker, uso do container como ponto de saída.

Adicionado:

network_mode="none",
cap_drop=["ALL"],
security_opt=["no-new-privileges"],

Escopo deliberadamente reduzido: limites de memória/CPU/pids por linguagem (S1/E4 completos no doc) foram propositalmente não incluídos aqui. Java (JVM) e jobs baseados em MPI precisam de orçamentos de memória/processos muito diferentes de um binário C simples, e hoje não existe campo de configuração por linguagem para isso. Chutar um número fixo arrisca quebrar silenciosamente uma classe inteira de submissões sem eu poder testar todas as imagens de linguagem — isso fica de follow-up (ver roadmap P1/P2 no doc).

B6 — grading.py abortava o lote inteiro de testes num monitor_out ruim

process_test_results abria e parseava <test_id>.monitor_out sem nenhum tratamento de erro. Se o arquivo não existisse (monitor nunca rodou) ou estivesse vazio/malformado (ex.: o próprio monitor crashou antes de escrever algo — exatamente o cenário do bug B4 corrigido no PR companheiro), a exceção (FileNotFoundError/KeyError) subia por process_test_results_batch e abortava a avaliação de todos os outros casos de teste daquele commit, não só do caso afetado.

Agora a falha de leitura é capturada, logada (com o id do commit/teste e o motivo), e aquele caso de teste específico é reportado como incorreto — sem afetar os demais.

Evidência: testes antes e depois

Não há CI de testes configurado neste repositório hoje (.github/workflows/ só builda imagem e faz scan de segurança) — os testes existem mas não rodam automaticamente. Rodei manualmente com python -m unittest.

Suíte completa (sem depender de Docker) antes vs. depois, usando git stash para isolar o código antigo:

Baseline (git stash removendo as duas correções, mantendo os testes novos):

test_run_passes_hardening_flags_without_changing_prior_arguments ... ERROR
KeyError: 'network_mode'

test_missing_monitor_out_does_not_raise ... ERROR
FileNotFoundError: [Errno 2] No such file or directory: '.../1.monitor_out'

test_empty_monitor_out_does_not_raise ... ERROR
KeyError: 'signal'

test_malformed_monitor_out_does_not_raise ... ERROR
FileNotFoundError: ... (no .output gerado, pois a função já tinha explodido antes)

test_well_formed_monitor_out_still_works ... ok   <- caminho normal intacto

Depois (git stash pop, código com as correções):

Ran 35 tests in 0.006s
OK

(34 testes já existentes no repo + 5 novos, incluindo o controle test_well_formed_monitor_out_still_works, que prova que o caminho normal/legítimo continua produzindo exatamente o mesmo resultado de antes.)

Lint/type-check: ruff check limpo; basedpyright com 0 erros (restam só warnings de Any inerentes ao uso de MagicMock nos testes, sem impacto em runtime).

Sobre não ter rodado o teste de integração Docker completo (test_process_commit_hello*)

Tentei validar S2 também via o teste de integração real (rcc.tests.engine.test_process_commit_hello, que builda e roda containers de linguagem reais). Isso não foi possível de forma confiável nesta máquina de desenvolvimento (Apple Silicon): o binário gcc estaticamente linkado, ao rodar dentro do container via emulação Rosetta sobre um volume virtiofs, falha com rosetta error: mmap_anonymous_rw mmap failed — e isso acontece mesmo com o código original, sem nenhuma das mudanças deste PR, confirmando que é uma limitação do ambiente de emulação local (Docker Desktop + Rosetta + Apple Silicon), não algo causado por esta mudança.

Para compensar, validei S2/S10 com um teste unitário que mocka o client do Docker e verifica que containers.run() é chamado com exatamente os argumentos anteriores (imagem, volumes, detach, remove) mais os três novos parâmetros de hardening — ou seja, a mudança é comprovadamente aditiva e não toca em nada que determina o que roda dentro do container (imagem, volumes, comando). Isso foi confirmado também via git stash: o mesmo teste falha com KeyError: 'network_mode' contra o código original.

Recomendação: antes do merge, rodar test_process_commit_hello* (ou um smoke test equivalente) num runner amd64 real (CI do GitHub Actions, por exemplo) para confirmar o comportamento ponta-a-ponta com containers de linguagem reais — algo que não pude fazer com confiança nesta máquina.

Arquivos

  • src/rcc/engine/container.py — hardening do containers.run().
  • src/rcc/engine/grading.py — tratamento de erro em process_test_results.
  • src/rcc/tests/test_container_hardening.py — novo, cobre S2/S10.
  • src/rcc/tests/test_grading.py — novo, cobre B6 (+ caso de controle do caminho normal).

…itor_out

Two P0 findings from a security audit of the sandbox pipeline
(compiler-monitor-oportunidades.md), both verified via unit tests added
in this commit and a before/after run against the unmodified code (see
PR description for the full before/after evidence, including why a full
docker end-to-end run could not be reproduced on this contributor's
machine).

- `container.py`'s `run()` started every submission's container with
  full network access and the default Docker capability set. No part of
  the compile/test pipeline needs network (everything happens locally
  against the mounted workspace), so this is pure unused attack surface:
  exfiltrating test cases, reaching other services on the bridge network,
  or using the container as an egress point. Added `network_mode="none"`,
  `cap_drop=["ALL"]`, and `security_opt=["no-new-privileges"]`. These are
  additive constraints only - they do not touch the image, volumes, or
  command, so they have no effect on well-behaved submissions.

  Per-language memory/CPU/pids ceilings (the same audit's S1/E4) are
  intentionally left out of this change: Java's JVM and MPI-based jobs
  need very different budgets than a plain C binary, and there is no
  per-language config field for that yet. Picking one hardcoded number
  risks silently breaking a whole class of submissions untested, so that
  is tracked as follow-up work instead of guessed here.

- `grading.process_test_results` read `<test_id>.monitor_out` with no
  error handling. If the file was missing (monitor never ran) or
  malformed (e.g. the monitor binary itself crashed before writing
  anything - see the paired compiler-monitor fix), this raised
  `FileNotFoundError`/`KeyError` out of `process_test_results_batch`,
  aborting grading for every other test case in the same commit. It now
  catches both, logs which test case and file were unreadable, and
  reports that single test case as incorrect instead of taking the whole
  batch down with it. The well-formed path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant