diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc577d..830d5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,29 @@ e este projeto segue [Semantic Versioning](https://semver.org/lang/pt-BR/). ## [Unreleased] +### Added + +- `GitignoreGenerator` → `.gitignore` (sempre gerado, faz parte da base) e + `DockerignoreGenerator` → `.dockerignore` (parte do grupo `--no-docker`). + Antes, a gem não gerava nenhum dos dois: um `git add .` logo após o `init` + podia commitar `.env`, `coverage/`, `log/`, `tmp/` etc., e o `Dockerfile` + gerado fazia `COPY . .` sem filtro nenhum — segredos e até o histórico do + `.git` entravam nas camadas da imagem Docker. O `.clignore` já listava + esses padrões como sensíveis para assistentes de IA, mas nada impedia de + fato que fossem parar em `git`/imagem Docker. Novas specs: + `spec/generators/gitignore_generator_spec.rb` e + `spec/generators/dockerignore_generator_spec.rb`. + ### Fixed +- **Segurança**: o estágio `production` do `Dockerfile.erb` rodava como + root (nenhum estágio tinha diretiva `USER`). Adicionado usuário + `app` não-root nesse estágio (`groupadd`/`useradd` + `COPY --chown`); + os estágios `builder`/`development` continuam como root, de propósito, + para não quebrar o bind mount de desenvolvimento (`docker-compose.yml` + monta `.:/app`, e um usuário não-root ali gera conflito de permissão + com o UID do host). Nova spec em + `spec/generators/dockerfile_generator_spec.rb`. - **Segurança**: `ExampleDomainGenerator` (usado por `--example-domain`) aceitava qualquer string como `entity_name` e a interpolava sem validação em caminhos de arquivo (`lib/domain/#{entity_name}.rb` etc.), permitindo path traversal diff --git a/lib/agentic_dev_workflow/cli.rb b/lib/agentic_dev_workflow/cli.rb index a06a5f9..2f1b762 100644 --- a/lib/agentic_dev_workflow/cli.rb +++ b/lib/agentic_dev_workflow/cli.rb @@ -4,10 +4,12 @@ require_relative 'generators/claude_md_generator' require_relative 'generators/roadmap_generator' require_relative 'generators/clignore_generator' +require_relative 'generators/gitignore_generator' require_relative 'generators/rspec_generator' require_relative 'generators/rubocop_generator' require_relative 'generators/dockerfile_generator' require_relative 'generators/docker_compose_generator' +require_relative 'generators/dockerignore_generator' require_relative 'generators/git_hub_actions_generator' require_relative 'generators/example_domain_generator' require_relative 'generators/observability_generator' @@ -45,6 +47,7 @@ def base_generators(target_dir) Generators::ClaudeMdGenerator.new(target_dir: target_dir), Generators::RoadmapGenerator.new(target_dir: target_dir), Generators::ClignoreGenerator.new(target_dir: target_dir), + Generators::GitignoreGenerator.new(target_dir: target_dir), Generators::RspecGenerator.new(target_dir: target_dir), Generators::RubocopGenerator.new(target_dir: target_dir, profile: options['profile']) ] @@ -66,7 +69,8 @@ def example_domain_requested? def docker_generators(target_dir) [ Generators::DockerfileGenerator.new(target_dir: target_dir), - Generators::DockerComposeGenerator.new(target_dir: target_dir) + Generators::DockerComposeGenerator.new(target_dir: target_dir), + Generators::DockerignoreGenerator.new(target_dir: target_dir) ] end diff --git a/lib/agentic_dev_workflow/generators/dockerignore_generator.rb b/lib/agentic_dev_workflow/generators/dockerignore_generator.rb new file mode 100644 index 0000000..2aa14a1 --- /dev/null +++ b/lib/agentic_dev_workflow/generators/dockerignore_generator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_relative 'base_generator' + +module AgenticDevWorkflow + module Generators + # Gera o arquivo .dockerignore no diretório alvo, evitando que segredos + # (.env, *.pem, *.key) e o histórico do .git entrem no contexto de build + # e, por consequência, nas camadas da imagem gerada pelo Dockerfile. Não + # sobrescreve um .dockerignore existente. + class DockerignoreGenerator < BaseGenerator + TEMPLATE = load_template('dockerignore.erb') + + def generate + write_file('.dockerignore', render(TEMPLATE)) + end + end + end +end diff --git a/lib/agentic_dev_workflow/generators/gitignore_generator.rb b/lib/agentic_dev_workflow/generators/gitignore_generator.rb new file mode 100644 index 0000000..9808267 --- /dev/null +++ b/lib/agentic_dev_workflow/generators/gitignore_generator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_relative 'base_generator' + +module AgenticDevWorkflow + module Generators + # Gera o arquivo .gitignore no diretório alvo, cobrindo os mesmos + # padrões sensíveis listados em .clignore (segredos, credenciais) mais + # os artefatos padrão de um projeto Ruby. Não sobrescreve um .gitignore + # existente. + class GitignoreGenerator < BaseGenerator + TEMPLATE = load_template('gitignore.erb') + + def generate + write_file('.gitignore', render(TEMPLATE)) + end + end + end +end diff --git a/lib/agentic_dev_workflow/templates/Dockerfile.erb b/lib/agentic_dev_workflow/templates/Dockerfile.erb index cf8b555..0c14155 100644 --- a/lib/agentic_dev_workflow/templates/Dockerfile.erb +++ b/lib/agentic_dev_workflow/templates/Dockerfile.erb @@ -15,7 +15,9 @@ COPY . . CMD ["bundle", "exec", "rspec"] FROM ruby:${RUBY_VERSION}-slim AS production +RUN groupadd --system app && useradd --system --gid app --create-home --home-dir /app app WORKDIR /app COPY --from=builder /usr/local/bundle /usr/local/bundle -COPY . . +COPY --chown=app:app . . +USER app CMD ["bundle", "exec", "rackup"] diff --git a/lib/agentic_dev_workflow/templates/dockerignore.erb b/lib/agentic_dev_workflow/templates/dockerignore.erb new file mode 100644 index 0000000..cada73d --- /dev/null +++ b/lib/agentic_dev_workflow/templates/dockerignore.erb @@ -0,0 +1,15 @@ +# Impede que segredos e o histórico do git entrem no contexto de build e, +# por consequência, nas camadas da imagem gerada pelo Dockerfile. +.git +.env +.env.* +*.pem +*.key +config/master.key +config/credentials/*.key + +/coverage/ +/log/ +/tmp/ +/.bundle/ +*.gem diff --git a/lib/agentic_dev_workflow/templates/gitignore.erb b/lib/agentic_dev_workflow/templates/gitignore.erb new file mode 100644 index 0000000..ab18618 --- /dev/null +++ b/lib/agentic_dev_workflow/templates/gitignore.erb @@ -0,0 +1,17 @@ +# Segredos e credenciais — nunca commitar. +.env +.env.* +*.pem +*.key +config/master.key +config/credentials/*.key + +# Dependências e artefatos gerados. +/vendor/ +/node_modules/ +/tmp/ +/log/ +/coverage/ +/pkg/ +/.bundle/ +*.gem diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index 852fd73..f94c2dd 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -22,11 +22,13 @@ def path_in_target(*parts) expect(File.exist?(path_in_target('CLAUDE.md'))).to be true expect(File.exist?(path_in_target('ROADMAP.md'))).to be true expect(File.exist?(path_in_target('.clignore'))).to be true + expect(File.exist?(path_in_target('.gitignore'))).to be true expect(File.exist?(path_in_target('.rspec'))).to be true expect(File.exist?(path_in_target('spec', 'spec_helper.rb'))).to be true expect(File.exist?(path_in_target('.rubocop.yml'))).to be true expect(File.exist?(path_in_target('Dockerfile'))).to be true expect(File.exist?(path_in_target('docker-compose.yml'))).to be true + expect(File.exist?(path_in_target('.dockerignore'))).to be true expect(File.exist?(path_in_target('.github', 'workflows', 'ci.yml'))).to be true expect(File.exist?(path_in_target('docker-compose.observability.yml'))).to be true expect(File.exist?(path_in_target('observability', 'prometheus.yml'))).to be true @@ -44,6 +46,13 @@ def path_in_target(*parts) expect(File.exist?(path_in_target('Dockerfile'))).to be false expect(File.exist?(path_in_target('docker-compose.yml'))).to be false + expect(File.exist?(path_in_target('.dockerignore'))).to be false + end + + it 'gera .gitignore mesmo com --no-docker (não faz parte do grupo docker)' do + described_class.start(['init', target_dir, '--no-docker']) + + expect(File.exist?(path_in_target('.gitignore'))).to be true end it 'não gera .github/workflows/ci.yml quando --no-ci' do diff --git a/spec/generators/dockerfile_generator_spec.rb b/spec/generators/dockerfile_generator_spec.rb index 3af7b7a..c3a9fc4 100644 --- a/spec/generators/dockerfile_generator_spec.rb +++ b/spec/generators/dockerfile_generator_spec.rb @@ -29,6 +29,13 @@ expect(content).to include('AS production') end + it 'roda o estágio de produção com um usuário não-root' do + generator.generate + production_stage = File.read(dockerfile_path).split('AS production').last + + expect(production_stage).to include('USER app') + end + it 'não sobrescreve um Dockerfile já existente (idempotência)' do File.write(dockerfile_path, 'conteúdo customizado pelo usuário') diff --git a/spec/generators/dockerignore_generator_spec.rb b/spec/generators/dockerignore_generator_spec.rb new file mode 100644 index 0000000..af2ea12 --- /dev/null +++ b/spec/generators/dockerignore_generator_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fakefs/spec_helpers' +require 'agentic_dev_workflow/generators/dockerignore_generator' + +RSpec.describe AgenticDevWorkflow::Generators::DockerignoreGenerator do + include FakeFS::SpecHelpers + + let(:target_dir) { '/fake/project' } + let(:generator) { described_class.new(target_dir: target_dir) } + let(:dockerignore_path) { File.join(target_dir, '.dockerignore') } + + before { FileUtils.mkdir_p(target_dir) } + + describe '#generate' do + it 'cria o arquivo .dockerignore no diretório alvo' do + generator.generate + + expect(File.exist?(dockerignore_path)).to be true + end + + it 'exclui segredos e o histórico do git do contexto de build' do + generator.generate + content = File.read(dockerignore_path) + + expect(content).to include('.git') + expect(content).to include('.env') + expect(content).to include('*.pem') + expect(content).to include('*.key') + expect(content).to include('config/master.key') + end + + it 'não exclui spec/ (necessário para o estágio development rodar rspec no container)' do + generator.generate + content = File.read(dockerignore_path) + + expect(content).not_to include('/spec/') + end + + it 'não sobrescreve um .dockerignore já existente (idempotência)' do + File.write(dockerignore_path, 'conteúdo customizado pelo usuário') + + generator.generate + + expect(File.read(dockerignore_path)).to eq('conteúdo customizado pelo usuário') + end + end +end diff --git a/spec/generators/gitignore_generator_spec.rb b/spec/generators/gitignore_generator_spec.rb new file mode 100644 index 0000000..9bc0743 --- /dev/null +++ b/spec/generators/gitignore_generator_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fakefs/spec_helpers' +require 'agentic_dev_workflow/generators/gitignore_generator' + +RSpec.describe AgenticDevWorkflow::Generators::GitignoreGenerator do + include FakeFS::SpecHelpers + + let(:target_dir) { '/fake/project' } + let(:generator) { described_class.new(target_dir: target_dir) } + let(:gitignore_path) { File.join(target_dir, '.gitignore') } + + before { FileUtils.mkdir_p(target_dir) } + + describe '#generate' do + it 'cria o arquivo .gitignore no diretório alvo' do + generator.generate + + expect(File.exist?(gitignore_path)).to be true + end + + it 'inclui os padrões de segredos e credenciais' do + generator.generate + content = File.read(gitignore_path) + + expect(content).to include('.env') + expect(content).to include('*.pem') + expect(content).to include('*.key') + expect(content).to include('config/master.key') + end + + it 'inclui os artefatos padrão de um projeto Ruby' do + generator.generate + content = File.read(gitignore_path) + + expect(content).to include('/coverage/') + expect(content).to include('/tmp/') + expect(content).to include('/log/') + expect(content).to include('*.gem') + end + + it 'não sobrescreve um .gitignore já existente (idempotência)' do + File.write(gitignore_path, 'conteúdo customizado pelo usuário') + + generator.generate + + expect(File.read(gitignore_path)).to eq('conteúdo customizado pelo usuário') + end + end +end