diff --git a/.github/workflows/02-tests-ci.yml b/.github/workflows/02-tests-ci.yml index 3a9801b..d493261 100644 --- a/.github/workflows/02-tests-ci.yml +++ b/.github/workflows/02-tests-ci.yml @@ -20,10 +20,16 @@ jobs: - name: "Checkout do código" uses: actions/checkout@v4 - # INSIRA AQUI A LÓGICA PARA RODAR OS TESTES E VERIFICAR A COBERTURA - ### - ### - ### + - name: "Setup Node" + uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: "Instalar Deps" + run: npm ci + + - name: "Executar tests" + run: npm run tests - name: "Extrair porcentagem de cobertura" # Esse step será validado pelo desafio, não altere o nome. No final, ele deve gerar o output "coverage" com a porcentagem de cobertura. id: coverage @@ -32,6 +38,12 @@ jobs: echo "Coverage: $COVERAGE%" echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT + - name: "Valida se os testes passaram" + if: ${{ steps.coverage.outputs.coverage < env.COVERAGE_MIN }} + run: | + echo "Cobertura mínima não atendida: Esperado - ${{ env.COVERAGE_MIN }}%. Atingido: ${{ steps.coverage.outputs.coverage }}% " + exit 1 + generate-certificate: # DAQUI PARA BAIXO, NÃO ALTERAR name: "Desafio Nível 2 - Certificado" runs-on: ubuntu-latest diff --git a/.github/workflows/03-build-containers.yml b/.github/workflows/03-build-containers.yml new file mode 100644 index 0000000..bd39347 --- /dev/null +++ b/.github/workflows/03-build-containers.yml @@ -0,0 +1,70 @@ +name: "Nível 3: Containers e Segurança" + +on: + pull_request: + types: [closed] + branches: [ main ] + +permissions: + contents: read + packages: write + +env: + CHALLENGE_LEVEL: 3 + CHALLENGE_NAME: "containers-e-seguranca" + REGISTRY: ghcr.io + +jobs: + build-scan-and-push: + name: "Build, Lint, Trivy Scan e Push no GHCR" + if: #???? + runs-on: ubuntu-latest + + steps: + # AQUI VAI O CÓDIGO DO DESAFIO :) + + generate-certificate: # DAQUI PARA BAIXO, NÃO ALTERAR + name: "Desafio Nível 3 - Certificado" + needs: build-scan-and-push + if: success() + runs-on: ubuntu-latest + steps: + - name: "Gerar certificado" + run: | + mkdir -p certificates + cat > certificates/level-3-certificate.md << EOF + # Certificado de Conclusão - Nível 3 + + **Descomplicando Github Actions - GitHub Actions Edition** + --- + + Este certificado atesta que **${{ github.actor }}** concluiu com sucesso: + ## Nível 3: Containers e Segurança + + **Competências desenvolvidas:** + - Build de imagem Docker + - Lint de Dockerfile com Hadolint + - Scan de vulnerabilidades com Trivy (CRITICAL = 0) + - Relatório de vulnerabilidades como artefato + - Smoke test de execução do container + - Publicação no GitHub Container Registry (GHCR) condicionada ao scan + - Boas práticas de supply chain + + **Data de conclusão:** $(date) + **Repositório:** ${{ github.repository }} + **Workflow:** ${{ github.run_id }} + + --- + **Badge conquistado:** Containers e Segurança + + --- + *Certificado gerado automaticamente pelo GitHub Actions* + *LINUXtips* + EOF + + - name: "Upload do certificado" + uses: actions/upload-artifact@v4 + with: + name: level-3-certificate + path: certificates/ + retention-days: 30 diff --git a/DESAFIO-03.md b/DESAFIO-03.md new file mode 100644 index 0000000..42c622e --- /dev/null +++ b/DESAFIO-03.md @@ -0,0 +1,69 @@ +# Desafio 03 – Containers e Segurança (GHCR) + +Opa, beleza? Foi mal estar atrapalhando seu sextou aí, mas a gente tá com um problema aqui na empresa e precisamos da sua ajuda. Seguinte, aquela aplicação que implementamos os pipelines iniciais de setup e os testes agora está sofrendo uma modernização e vai passar a utilizar docker. Com isso, precisamos que você crie pra gente um terceiro pipeline que faça o seguinte: + +## Requisitos do pipeline + +- Deve existir um novo workflow do GitHub Actions para o Nível 3 (containers e segurança). +- O disparo deve acontecer quando um Pull Request para a branch `main` for fechado com merge (evento `pull_request`, `types: [closed]` + condição `github.event.pull_request.merged == true`). +- O workflow deve: + - Fazer login no GitHub Container Registry (GHCR) utilizando `GITHUB_TOKEN`. + - Montar a tag da imagem sempre com o SHA do commit, e com `owner`, `IMAGE_NAME` e `registry` em minúsculas. + - Executar lint do `Dockerfile` com **Hadolint**, salvando o resultado em `lint-report.txt` e falhando se forem encontrados os problemas **DL3006** ou **DL3008**. + - Buildar a imagem Docker para permitir o scan de vulnerabilidades. + - Executar o scan de vulnerabilidades com **Trivy** na imagem construída. + - O resultado deve ser salvo em um relatório `trivy-report.txt` e publicado como artefato, mesmo que não haja falhas. + - O workflow deve falhar caso o Trivy encontre vulnerabilidades de severidade **CRITICAL**. + - Executar um **smoke test** da imagem rodando `node --version`. Caso não haja saída, o job deve falhar. + - Somente publicar no GHCR se **todas** as validações acima forem aprovadas. + - Gerar o artefato `level-3-certificate.md` (não alterar a seção do certificado, assim como nos desafios anteriores). + +### Importante: Proteção de branch + +Antes de executar, configure a proteção da branch `main` como mostramos na live de quarta. Isso garante a qualidade do ciclo de revisão e evita merges diretos na `main` sem validações. Veja a demonstração aqui: [AO VIVO - Descomplicando Github Actions - Resolvendo Desafio](https://www.youtube.com/watch?v=VihvfGx58IY). + +## Variável obrigatória + +- Crie uma variável de repositório chamada `IMAGE_NAME` (em: Settings > Secrets and variables > Actions > Variables) com o nome da aplicação a ser usada no nome da imagem (ex.: `desafio3-linuxtips-gha`). +- Essa variável é obrigatória e será utilizada para compor o nome final da imagem no GHCR. + +## Actions obrigatórias + +Você deve utilizar exatamente estas actions nas versões a seguir: + +- `docker/login-action@v3` +- `docker/build-push-action@v6` +- `aquasecurity/trivy-action@0.28.0` +- `docker/build-push-action@v6` + +### Política de lint (Hadolint) + +Para nós, os checks do Hadolint devem focar nos itens que mais impactam segurança e reprodutibilidade. Portanto, este pipeline deve falhar apenas quando forem detectadas as regras abaixo: + +- DL3006: uso consistente e fixação do gerenciador de pacotes/base da imagem (garante builds reprodutíveis); +- DL3008: instalação de pacotes sem pin de versões (evita deriva de dependências e janelas de vulnerabilidade). + +Por política da empresa e conformidade de supply chain, consideramos DL3006 e DL3008 bloqueadores. + +## Regras de publicação da imagem + +- **Registry**: `ghcr.io` +- **Tag obrigatória**: o SHA do commit (`${{ github.sha }}`) +- **Nome completo (exemplo)**: `ghcr.io//:` +- O nome completo deve ser convertido para minúsculas para evitar erros no push. + +## Critérios de aceite + +- [ ] Workflow Nível 3 dispara somente após PR mergeado na `main`. +- [ ] `Dockerfile` analisado com **Hadolint**; gerar artefato `lint-report.txt`. +- [ ] O workflow falha se Hadolint encontrar **DL3006** ou **DL3008** +- [ ] Build local com **Trivy**. +- [ ] Relatório `trivy-report.txt` gerado e publicado como artefato. +- [ ] Workflow falha se o scan encontrar vulnerabilidades CRITICAL. +- [ ] Smoke test da imagem executa `node --version` e falha se não houver saída. +- [ ] Push realizado no GHCR apenas se todas as verificações passarem. +- [ ] Uso das actions nas versões exigidas. + +Boa sorte e nos vemos no sábado, dia 20/09, para resolver esse desafio na nossa live das 13h + +#VAI diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f769424 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:18-alpine AS base + +WORKDIR /app + +RUN apk add --no-cache gcompat=1.1.0-r4 + +COPY package*.json ./ + +RUN npm ci --omit=dev + +COPY . . + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +CMD ["node", "server.js"] + + diff --git a/public/index.html b/public/index.html index adb2c2d..c994b6d 100644 --- a/public/index.html +++ b/public/index.html @@ -51,7 +51,7 @@

0

-

Desafios 01 e 02

+

Desafios 01 a 03

diff --git a/public/script.js b/public/script.js index d189139..383cfdb 100644 --- a/public/script.js +++ b/public/script.js @@ -16,6 +16,13 @@ const challenges = [ description: 'Implemente testes com Jest e atinja cobertura mínima de 80% no pipeline. Gere e faça upload do certificado do nível 2.', badge: 'testes-automatizados', reward: 'Badge: Desafio 02 Concluído' + }, + { + id: 'containers-seguros', + title: 'Desafio 03 - Containers e Segurança', + description: 'Construa a imagem Docker, escaneie com Trivy e publique no ECR apenas se não houver vulnerabilidades CRÍTICAS. Gere e faça upload do certificado do nível 3.', + badge: 'containers-seguros', + reward: 'Badge: Desafio 03 Concluído' } ]; @@ -229,7 +236,7 @@ function updateBadgesDisplay() { // Atualizar seção do certificado function updateCertificateSection() { const certificateSection = document.getElementById('certificateSection'); - const hasAnyCertificate = appState.badges.includes('first-steps') || appState.badges.includes('testes-automatizados'); + const hasAnyCertificate = appState.badges.includes('first-steps') || appState.badges.includes('testes-automatizados') || appState.badges.includes('containers-seguros'); if (certificateSection) { certificateSection.style.display = hasAnyCertificate ? 'block' : 'none'; @@ -553,7 +560,7 @@ Badges disponíveis: first-steps, testes-automatizados window.onBadgeClick = function(badgeId) { const username = document.getElementById('certificateUsername').value.trim(); if (!username) return; - const level = badgeId === 'testes-automatizados' ? 2 : 1; + const level = badgeId === 'containers-seguros' ? 3 : (badgeId === 'testes-automatizados' ? 2 : 1); selectedCertificateLevel = level; generateCertificate(); } diff --git a/server.js b/server.js index 07bde16..28f186c 100644 --- a/server.js +++ b/server.js @@ -21,7 +21,7 @@ app.use(express.static(path.join(__dirname, 'public'))); // Simulação de dados de progresso (em produção seria um banco de dados) let learningProgress = { - totalChallenges: 2, + totalChallenges: 3, completedChallenges: 0, badges: [], lastUpdate: new Date().toISOString(), @@ -48,6 +48,13 @@ const availableBadges = { icon: 'check-circle', color: '#8957e5', badgeText: 'DESAFIO 02 CONCLUÍDO' + }, + 'containers-seguros': { + name: 'Containers e Segurança', + description: 'Completou o Desafio 03 - Containers e Segurança', + icon: 'check-circle', + color: '#1f6feb', + badgeText: 'DESAFIO 03 CONCLUÍDO' } }; @@ -108,8 +115,9 @@ app.get('/api/certificate/:username', (req, res) => { const hasLevel1 = learningProgress.badges.includes('first-steps'); const hasLevel2 = learningProgress.badges.includes('testes-automatizados'); + const hasLevel3 = learningProgress.badges.includes('containers-seguros'); - if (!hasLevel1 && !hasLevel2) { + if (!hasLevel1 && !hasLevel2 && !hasLevel3) { return res.status(404).json({ error: 'Certificado não disponível. Complete o desafio primeiro!' }); } @@ -119,6 +127,9 @@ app.get('/api/certificate/:username', (req, res) => { if (levelParam === 2 && !hasLevel2) { return res.status(404).json({ error: 'Certificado do nível 2 não disponível.' }); } + if (levelParam === 3 && !hasLevel3) { + return res.status(404).json({ error: 'Certificado do nível 3 não disponível.' }); + } const currentDate = new Date().toLocaleDateString('pt-BR', { year: 'numeric', @@ -130,15 +141,30 @@ app.get('/api/certificate/:username', (req, res) => { const renderLevel = (() => { if (levelParam === 1) return 1; if (levelParam === 2) return 2; - return hasLevel2 ? 2 : 1; + if (levelParam === 3) return 3; + if (hasLevel3) return 3; + if (hasLevel2) return 2; + return 1; })(); - const competenciesLine1 = renderLevel === 2 - ? '✓ Automação de testes ✓ Cobertura mínima 80%' - : '✓ Configuração de workflow básico ✓ Uso de actions do marketplace'; - const competenciesLine2 = renderLevel === 2 - ? '✓ Execução de Jest ✓ Relatório e validação de cobertura' - : '✓ Definição de jobs e steps ✓ Variáveis de ambiente ✓ Build e health check automatizados'; + const competencies = (() => { + if (renderLevel === 3) { + return { + line1: '✓ Build de imagem Docker ✓ Lint de Dockerfile ✓ Segurança de containers', + line2: '✓ Trivy Scan ✓ Smoke Tests ✓ Push no GHCR' + }; + } + if (renderLevel === 2) { + return { + line1: '✓ Automação de testes ✓ Cobertura mínima 80%', + line2: '✓ Execução de Jest ✓ Relatório e validação de cobertura' + }; + } + return { + line1: '✓ Configuração de workflow básico ✓ Uso de actions do marketplace', + line2: '✓ Definição de jobs e steps ✓ Variáveis de ambiente ✓ Build e health check automatizados' + }; + })(); const certificateSVG = ` @@ -172,7 +198,7 @@ app.get('/api/certificate/:username', (req, res) => { - ${renderLevel === 2 ? 'Desafio 02 - Testes Automatizados' : 'Desafio 01 - GitHub Actions Básico'} + ${renderLevel === 3 ? 'Desafio 03 - Containers e Segurança' : renderLevel === 2 ? 'Desafio 02 - Testes Automatizados' : 'Desafio 01 - GitHub Actions Básico'} @@ -181,10 +207,10 @@ app.get('/api/certificate/:username', (req, res) => { - ${competenciesLine1} + ${competencies.line1} - ${competenciesLine2} + ${competencies.line2} @@ -237,9 +263,16 @@ app.post('/api/check-github-status', async (req, res) => { run.name && (run.name.includes('Nível 2') || run.name.includes('Testing')) ); + const challenge3Runs = data.workflow_runs.filter(run => + run.status === 'completed' && + run.conclusion === 'success' && + run.name && (run.name.includes('Nível 3') || run.name.includes('Containers') || run.name.includes('Security')) + ); + // Verificar artefatos por nível (certificado gerado) let hasArtifactsLevel1 = false; let hasArtifactsLevel2 = false; + let hasArtifactsLevel3 = false; if (successfulRuns.length > 0) { const latestL1 = successfulRuns[0]; const artifactsUrlL1 = `https://api.github.com/repos/${username}/${repository}/actions/runs/${latestL1.id}/artifacts`; @@ -264,6 +297,18 @@ app.post('/api/check-github-status', async (req, res) => { console.log('Erro ao verificar artefatos L2:', error); } } + if (challenge3Runs.length > 0) { + const latestL3 = challenge3Runs[0]; + const artifactsUrlL3 = `https://api.github.com/repos/${username}/${repository}/actions/runs/${latestL3.id}/artifacts`; + try { + const respL3 = await fetch(artifactsUrlL3); + const dataL3 = await respL3.json(); + const names = (dataL3.artifacts || []).map(a => a.name || ''); + hasArtifactsLevel3 = names.some(n => n.includes('level-3-certificate')); + } catch (error) { + console.log('Erro ao verificar artefatos L3:', error); + } + } // Verificar se o repositório tem o nome exato (case insensitive) const validRepoNames = [ @@ -277,6 +322,7 @@ app.post('/api/check-github-status', async (req, res) => { const canAwardLevel1 = successfulRuns.length > 0 && repoNameValid && hasArtifactsLevel1; const canAwardLevel2 = challenge2Runs.length > 0 && repoNameValid && hasArtifactsLevel2; + const canAwardLevel3 = challenge3Runs.length > 0 && repoNameValid && hasArtifactsLevel3; // Atualizar métrica de commits da branch padrão try { @@ -320,6 +366,16 @@ app.post('/api/check-github-status', async (req, res) => { } } + if (canAwardLevel3 && !learningProgress.badges.includes('containers-seguros')) { + earnedBadges.push('containers-seguros'); + if (!learningProgress.badges.includes('testes-automatizados')) { + earnedBadges.push('testes-automatizados'); + } + if (!learningProgress.badges.includes('first-steps')) { + earnedBadges.push('first-steps'); + } + } + if (earnedBadges.length > 0) { // Aplicar ganhos (evitar duplicados) for (const b of earnedBadges) { @@ -329,10 +385,12 @@ app.post('/api/check-github-status', async (req, res) => { } learningProgress.completedChallenges = Math.max( learningProgress.completedChallenges, - learningProgress.badges.includes('testes-automatizados') ? 2 : 1 + learningProgress.badges.includes('containers-seguros') ? 3 : learningProgress.badges.includes('testes-automatizados') ? 2 : 1 ); learningProgress.stats.successfulBuilds += 1; - if (earnedBadges.includes('testes-automatizados')) { + if (earnedBadges.includes('containers-seguros')) { + learningProgress.stats.deployments += 1; + } else if (earnedBadges.includes('testes-automatizados')) { learningProgress.stats.testsRun += 1; } else { learningProgress.stats.commits += 1; @@ -343,15 +401,15 @@ app.post('/api/check-github-status', async (req, res) => { success: true, badgeEarned: true, earnedBadges: Array.from(new Set(earnedBadges)), - level: earnedBadges.includes('testes-automatizados') ? 2 : 1, - certificateReady: hasArtifactsLevel2 || hasArtifactsLevel1, + level: earnedBadges.includes('containers-seguros') ? 3 : earnedBadges.includes('testes-automatizados') ? 2 : 1, + certificateReady: hasArtifactsLevel3 || hasArtifactsLevel2 || hasArtifactsLevel1, username: username, message: 'Progresso atualizado com sucesso!', progress: learningProgress }); } - if ((successfulRuns.length > 0 || challenge2Runs.length > 0) && !(hasArtifactsLevel1 || hasArtifactsLevel2)) { + if ((successfulRuns.length > 0 || challenge2Runs.length > 0 || challenge3Runs.length > 0) && !(hasArtifactsLevel1 || hasArtifactsLevel2 || hasArtifactsLevel3)) { return res.json({ success: true, badgeEarned: false, @@ -397,7 +455,7 @@ app.post('/api/workflow-complete', (req, res) => { } // Verificar se é o workflow correto - if ((workflowName.includes('Basic CI') || workflowName.includes('Nível 2')) && certificateGenerated === true) { + if ((workflowName.includes('Basic CI') || workflowName.includes('Nível 2') || workflowName.includes('Nível 3')) && certificateGenerated === true) { // Atualizar progresso automaticamente if (!learningProgress.badges.includes('first-steps')) { learningProgress.badges.push('first-steps'); @@ -424,6 +482,13 @@ app.post('/api/workflow-complete', (req, res) => { learningProgress.stats.testsRun += 1; learningProgress.lastUpdate = new Date().toISOString(); } + // Para nível 3, adicionar badge específico + if (workflowName.includes('Nível 3') && !learningProgress.badges.includes('containers-seguros')) { + learningProgress.badges.push('containers-seguros'); + learningProgress.completedChallenges = Math.max(learningProgress.completedChallenges, 3); + learningProgress.stats.deployments += 1; + learningProgress.lastUpdate = new Date().toISOString(); + } return res.json({ success: true, @@ -469,7 +534,7 @@ app.get('/api/repository-info', (req, res) => { /* istanbul ignore next */ app.post('/api/reset', (req, res) => { learningProgress = { - totalChallenges: 2, + totalChallenges: 3, completedChallenges: 0, badges: [], lastUpdate: new Date().toISOString(),