diff --git a/.env.example b/.env.example index da291467..c93b4a63 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,20 @@ SCRAPER_URL=http://localhost:8081 # Endereço em que o próprio scraper-go escuta (opcional, default :8081). GO_SCRAPER_ADDR=:8081 +# --------------------------------------------------------------------------- +# ATS Forge — microserviço de geração de currículos (DOCX/PDF) +# --------------------------------------------------------------------------- +# URL base do serviço ats-forge (porta 8089). Usada pelo módulo resume do +# backend (POST /resume/generate) como caminho server-side opcional. +ATS_FORGE_URL=http://localhost:8089 +# Segredo compartilhado enviado no header x-api-key. Deixe vazio em dev para +# desabilitar a autenticação serviço-a-serviço; defina em produção. +# OBS: o frontend chama o ats-forge diretamente, então mantenha vazio se o +# navegador for o chamador (não exponha segredos no frontend). +ATS_FORGE_API_KEY= +# URL do ats-forge usada pelo FRONTEND (navegador) para gerar currículos. +VITE_ATS_FORGE_URL=http://localhost:8089 + # --------------------------------------------------------------------------- # Observabilidade # --------------------------------------------------------------------------- diff --git a/SECURITY_AUDIT_LINEAR_CARDS.md b/SECURITY_AUDIT_LINEAR_CARDS.md new file mode 100644 index 00000000..129d5ed2 --- /dev/null +++ b/SECURITY_AUDIT_LINEAR_CARDS.md @@ -0,0 +1,979 @@ +# Análise de Segurança — Projeto Candidate (ambiente MASTER) + +> **Documento de referência para criação de cards no Linear (time Painel Vagas / PAV).** +> Resultado de uma revisão AppSec read-only do branch `master`. Nenhum código foi alterado, nenhum PR foi criado e nenhum card foi criado automaticamente. +> Data da análise: **2026-10-01** · Branch: `master` · Commit base: `a4a274e` + +--- + +## 1. Resumo da análise + +Foi realizada uma análise de segurança abrangente (estática, orientada a fluxo) de todos os componentes do monorepo **Candidate**, cobrindo autenticação/autorização, gerenciamento de sessão e tokens, controle de acesso (RBAC/IDOR/BOLA), validação e sanitização de entrada, injeções (SQL/NoSQL/Command), XSS/CSRF/SSRF, exposição de dados sensíveis/PII, secrets e variáveis de ambiente, CORS, rate limiting, uploads/downloads, deserialização, dependências vulneráveis, logs, endpoints administrativos, comunicação entre serviços, Docker/infra, CI/CD e Electron. + +**Postura geral:** a base é sólida em vários pontos críticos — criptografia de PII com **AES-256-GCM** (IV aleatório + auth tag), hashing de senha com **argon2id** endurecido, acesso ao banco 100% parametrizado via Drizzle (sem SQL injection), ownership consistente em recursos por usuário (sem IDOR nos endpoints de dados), CORS com allowlist fail-closed, cabeçalhos de segurança fortes na API, e Electron endurecido (contextIsolation/sandbox). Os problemas concentram-se em: **linking de contas OAuth**, **ciclo de vida de sessão/token**, **superfície administrativa não autenticada entre serviços**, **exposição de PII em massa**, **stack de observabilidade sem autenticação** e **hardening de infraestrutura/dependências**. + +### Totais por severidade + +| Severidade | Qtde | +|------------|------| +| 🔴 Critical | 1 | +| 🟠 High | 8 | +| 🟡 Medium | 13 | +| 🟢 Low | 17 | +| **Total** | **39** | + +### Mapa de prioridade sugerido para o Linear + +| Severidade | Prioridade Linear | +|------------|-------------------| +| 🔴 Critical | 1 — Urgente | +| 🟠 High | 2 — Alta | +| 🟡 Medium | 3 — Média | +| 🟢 Low | 4 — Baixa | + +> Padrão de criação: **Team =** `Painel Vagas` · **State =** `Backlog`. + +--- + +## 2. Componentes analisados + +| Componente | Stack | Caminho | +|------------|-------|---------| +| Scraper | Go 1.26 | `scraper-go/` (94 arquivos `.go`) | +| Backend / APIs | Node + TypeScript (Express 5, Drizzle, iron-session) | `backend/src/` (132 arquivos `.ts`) | +| Frontend (público) | React 19 + Vite | `frontend/` | +| Frontend Administrativo | React + Vite | `front_admin/` | +| Autenticação/Autorização | OAuth (Google/GitHub/LinkedIn) + credenciais | `backend/src/modules/auth/`, `middleware/`, `modules/admin/permissions/` | +| Infra / Containers | Docker Compose, Dockerfiles | `docker/`, `docker-compose*.yml` | +| Observabilidade | Prometheus/Grafana/Loki/Promtail/cAdvisor | `observability/`, `docker-compose.observability.yml` | +| CI/CD | GitHub Actions | `.github/workflows/` | +| Dependências | npm + Go modules | `package.json`, `scraper-go/go.mod` | + +> **Electron** foi analisado mas **não gera card**: passou com hardening correto (contextIsolation/sandbox/nodeIntegration) e sem conteúdo remoto — nenhuma vulnerabilidade encontrada. + +--- + +## 3. Lista de vulnerabilidades e severidade + +| # | Título | Componente | Severidade | +|---|--------|------------|------------| +| SEC-01 | Account takeover via OAuth com e-mail não verificado (auto-linking) | Backend (Auth) | 🔴 Critical | +| SEC-02 | API HTTP do scraper-go sem autenticação (inclui rotas /admin) | Scraper Go / Infra | 🟠 High | +| SEC-03 | Tokens OAuth (access/refresh) armazenados em texto puro | Backend (Auth) | 🟠 High | +| SEC-04 | Bloqueio/alteração de role/exclusão não revoga sessões ativas | Backend (Auth) | 🟠 High | +| SEC-05 | Ausência de proteção CSRF + cookie SameSite=None em produção | Backend (Auth) | 🟠 High | +| SEC-06 | Grafana exposto com credenciais padrão (admin/admin) em 0.0.0.0 | Infra | 🟠 High | +| SEC-07 | Dependência `xlsx` 0.18.5 com Prototype Pollution + ReDoS | Dependências | 🟠 High | +| SEC-08 | URLs não confiáveis do scraper renderizadas em `href` (javascript:/data:) | Frontend / Frontend ADM | 🟠 High | +| SEC-09 | Frontend ADM sem CSP/headers de segurança (sem vercel.json) | Frontend ADM | 🟠 High | +| SEC-10 | Retorno em massa de PII descriptografada (CPF/e-mail/telefone) para admin | Backend (API) | 🟡 Medium | +| SEC-11 | SSRF via `apiUrl` fornecido por arquivo no adapter Lever (sem allowlist) | Scraper Go | 🟡 Medium | +| SEC-12 | Parâmetros de scrape sem limites → DoS/amplificação | Scraper Go | 🟡 Medium | +| SEC-13 | Handlers JSON do Go sem limite de tamanho de corpo | Scraper Go | 🟡 Medium | +| SEC-14 | Respostas externas decodificadas sem `io.LimitReader` | Scraper Go | 🟡 Medium | +| SEC-15 | Endpoint `/metrics` do backend sem autenticação | Backend (API) | 🟡 Medium | +| SEC-16 | Enumeração de usuários (409 no registro + timing no login) | Backend (Auth) | 🟡 Medium | +| SEC-17 | RBAC administrativo aplicado apenas no cliente (verificar backend) | Frontend ADM | 🟡 Medium | +| SEC-18 | Loki publicado com autenticação desabilitada | Infra | 🟡 Medium | +| SEC-19 | Prometheus/observabilidade expostos sem auth em 0.0.0.0 | Infra | 🟡 Medium | +| SEC-20 | Containers executando como root (sem diretiva USER) | Infra | 🟡 Medium | +| SEC-21 | Credenciais padrão fracas de banco (`vagas/vagas`) em .env.example | Config | 🟡 Medium | +| SEC-22 | `SESSION_SECRET` não validado no boot + sessão sem TTL | Backend (Auth) | 🟡 Medium | +| SEC-23 | Mensagens de erro internas vazadas ao cliente | Backend (API) | 🟢 Low | +| SEC-24 | Injeção de wildcard LIKE na busca de usuários (admin) | Backend (API) | 🟢 Low | +| SEC-25 | IP do audit log obtido de `X-Forwarded-For` falsificável | Backend (API) | 🟢 Low | +| SEC-26 | Credenciais de admin hardcoded no script de seed | Backend (API) | 🟢 Low | +| SEC-27 | Validação de param UUID ausente em algumas rotas | Backend (API) | 🟢 Low | +| SEC-28 | Provider de credenciais morto com argon2 default (fraco) | Backend (Auth) | 🟢 Low | +| SEC-29 | Rate-limit em memória por instância + IP via XFF falsificável | Backend (Auth) | 🟢 Low | +| SEC-30 | Troca de token do GitHub ignora erros HTTP | Backend (Auth) | 🟢 Low | +| SEC-31 | `/metrics` e `/health` do scraper-go sem autenticação | Scraper Go | 🟢 Low | +| SEC-32 | Chaves de API de providers embutidas em URLs de saída | Scraper Go | 🟢 Low | +| SEC-33 | Conexão Redis/Valkey em texto puro sem auth por padrão | Scraper Go / Infra | 🟢 Low | +| SEC-34 | Montagens sensíveis do host na stack de observabilidade | Infra | 🟢 Low | +| SEC-35 | Imagens de observabilidade com tags mutáveis (`:latest`) | Infra | 🟢 Low | +| SEC-36 | Workflows CI sem `permissions` de menor privilégio | CI/CD | 🟢 Low | +| SEC-37 | Nome de branch de PR não confiável interpolado em github-script | CI/CD | 🟢 Low | +| SEC-38 | CSP do nginx de produção (frontend) permite `http://localhost:3001` | Frontend | 🟢 Low | +| SEC-39 | CSP de borda com pontos fracos menores (img-src/style-src) | Config | 🟢 Low | + +> **Informacional (sem card):** `scraper-go/internal/kwsync/kwsync.go` define um consumidor de fila (`KWSYNC_ENABLED`) que confia em JSON da lista Valkey `scraper:keywords:pending` sem validação e grava em `keywords.json` em disco. Atualmente **não está conectado** em `cmd/server` (apenas `scheduler.Start` é chamado), portanto inacessível. Revisar antes de habilitar. + +--- + +## 4. Cards detalhados + +Cada bloco abaixo é um card independente pronto para o Linear. + +--- + +### SEC-01 — Account takeover via OAuth com e-mail não verificado (auto-linking) + +- **Componente:** Backend (Autenticação) +- **Severidade:** 🔴 Critical — Prioridade Linear: 1 (Urgente) +- **Descrição:** No login via OAuth, quando nenhum `account` corresponde ao provider id, o código busca um usuário existente por `profile.email` e vincula silenciosamente o novo provedor a essa conta — **sem verificar se o provedor afirmou que o e-mail é verificado**. `users.emailVerified` nunca é consultado no fluxo. O provider do GitHub agrava com `primaryEmail ?? user.email`, usando um e-mail possivelmente não verificado. +- **Localização no código:** + - `backend/src/modules/users/functions/findOrCreateUser.ts` (linhas ~49-65) + - `backend/src/modules/auth/providers/github.ts` (linhas ~47-53) +- **Evidência:** + ```ts + const existingByEmail = await findUserByEmail(profile.email, tx); + if (existingByEmail) { + await createAccount({ userId: existingByEmail.id, provider, profile }, tx); + return { user: existingByEmail, isNewUser: false }; + } + ``` + ```ts + const primaryEmail = emails.find((e) => e.primary && e.verified)?.email; + return { id: String(user.id), email: primaryEmail ?? user.email, ... }; + ``` +- **Impacto:** Tomada de conta completa. A sessão é emitida para o `userId`/`role` da vítima, dando ao atacante acesso total à conta (inclusive contas com role elevado). +- **Cenário de exploração:** A vítima cadastra-se por e-mail/senha ou Google. O atacante cria uma conta GitHub (ou provedor cujo e-mail ele controla) com o e-mail da vítima e faz "login". `findOrCreateUser` casa pelo e-mail e vincula a identidade do atacante ao usuário da vítima; o callback emite sessão da vítima. +- **Correção recomendada:** Só fazer auto-link por e-mail quando o provedor afirmar `email_verified === true` (Google/LinkedIn expõem a claim; remover o fallback `?? user.email` do GitHub). Caso contrário, exigir fluxo autenticado de linking ou confirmação de posse do e-mail. Persistir e aplicar `emailVerified`. +- **Critérios de aceite:** + - [ ] Auto-link por e-mail ocorre apenas quando o provedor confirma e-mail verificado. + - [ ] Fallback `?? user.email` do GitHub removido; e-mail não verificado nunca vincula a conta existente. + - [ ] Linking de provedor a conta existente exige autenticação prévia ou verificação de posse de e-mail. + - [ ] Teste automatizado cobre tentativa de takeover via e-mail não verificado (deve falhar/exigir verificação). + +--- + +### SEC-02 — API HTTP do scraper-go sem autenticação (inclui rotas /admin) + +- **Componente:** Scraper Go / Infra (comunicação entre serviços) +- **Severidade:** 🟠 High — Prioridade Linear: 2 (Alta) +- **Descrição:** O `ServeMux` do scraper-go registra todas as rotas sem qualquer middleware de autenticação/autorização e sem segredo compartilhado. Rotas "administrativas" (`POST /admin/scrape`, `GET /admin/jobs`, `GET /admin/jobs/count`, `GET /admin/scrape/status`) e de escrita (`POST /scrape`, `POST /api/keywords`) estão totalmente abertas. O backend chama o serviço via `SCRAPER_URL=http://scraper-go:8081` sem header `Authorization`. `GET /admin/jobs` com `limit<=0` retorna **todos** os jobs. +- **Localização no código:** + - `scraper-go/cmd/server/server.go` (registro de rotas, linhas ~73-94) + - `scraper-go/cmd/server/admin_handlers.go` (`handleTriggerScrape` ~18, `handleGetJobs` ~121) + - `scraper-go/cmd/server/handlers.go` (`GetSample` → `GetAll`, ~344) +- **Evidência:** + ```go + mux.Handle("POST /admin/scrape", handleTriggerScrape(scheduler, bgCtx)) + mux.Handle("GET /admin/jobs", handleGetJobs(jobStore)) + mux.Handle("POST /api/keywords", handleSaveKeywords(kwStore)) + ``` +- **Impacto:** Qualquer workload na rede `vagas-net`, um pivô de SSRF ou um ingress mal configurado pode disparar scrapes custosos, exfiltrar toda a base de vagas e envenenar a configuração de keywords (persistida sem TTL). +- **Cenário de exploração:** `GET /admin/jobs?limit=0` (dump completo) e `POST /api/keywords` para alterar palavras-chave. **Mitigação atual:** a porta 8081 é apenas `expose` (interna), não publicada no host. +- **Correção recomendada:** Exigir segredo compartilhado (bearer) ou mTLS entre backend e scraper-go, validado em middleware; proteger `/admin` e rotas de escrita; manter 8081 interno; nunca retornar o dataset completo sem auth e sem limite máximo. +- **Critérios de aceite:** + - [ ] Todas as rotas de escrita e `/admin/*` exigem autenticação (token/mTLS). + - [ ] Backend envia a credencial em todas as chamadas ao scraper-go. + - [ ] `GET /admin/jobs` impõe limite máximo; `limit<=0` não retorna tudo. + - [ ] Porta 8081 permanece não publicada no host. + +--- + +### SEC-03 — Tokens OAuth (access/refresh) armazenados em texto puro + +- **Componente:** Backend (Autenticação) +- **Severidade:** 🟠 High — Prioridade Linear: 2 (Alta) +- **Descrição:** `accessToken` e `refreshToken` dos provedores são gravados no banco como colunas `text` sem criptografia, apesar de existir a camada de criptografia de PII (`encryptText`, AES-256-GCM). +- **Localização no código:** + - `backend/src/modules/users/functions/createAccount.ts` (linhas ~17-26) + - `backend/src/db/schema/accounts.ts` (linhas ~24-25) +- **Evidência:** + ```ts + accessToken: profile.access_token ?? null, + refreshToken: profile.refresh_token ?? null, + ``` + ```ts + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + ``` +- **Impacto:** Qualquer leitura do banco (SQLi em outro ponto, vazamento de backup, breach read-only, admin malicioso) expõe tokens OAuth vivos, reutilizáveis contra Google/GitHub/LinkedIn. +- **Cenário de exploração:** Exfiltração do banco → replay dos tokens nas APIs dos provedores. +- **Correção recomendada:** Criptografar os tokens em repouso com `encryptText` (GCM), ou deixar de persisti-los se não forem usados. Descriptografar apenas no momento do refresh. +- **Critérios de aceite:** + - [ ] Colunas de token armazenam apenas ciphertext (GCM) ou os tokens deixam de ser persistidos. + - [ ] Descriptografia ocorre somente no momento do uso. + - [ ] Migration cobre dados existentes. + +--- + +### SEC-04 — Bloqueio/alteração de role/exclusão não revoga sessões ativas + +- **Componente:** Backend (Autenticação / Controle de acesso) +- **Severidade:** 🟠 High — Prioridade Linear: 2 (Alta) +- **Descrição:** As sessões são cookies iron-session stateless carregando `userId` e `role`. `requireAuth` apenas verifica a existência de `userId`; nunca verifica `users.isBlocked`. `requireRole`/`requirePermission` leem o `role` direto do cookie. Ações de admin (block, change_role, delete) mutam apenas o banco — não há `sessionVersion` nem revogação. +- **Localização no código:** + - `backend/src/middleware/requireAuth.ts` (linhas ~4-10) + - `backend/src/modules/admin/permissions/requireRole.ts` (linhas ~7-19) + - `backend/src/modules/admin/users/adminUsers.service.ts` (block/role/delete) +- **Evidência:** + ```ts + export function requireAuth(req, res, next) { + if (!req.session?.userId) { /* ... */ return 401; } + next(); // nunca checa isBlocked, nunca relê role + } + ``` + ```ts + if (ROLE_LEVEL[role] < ROLE_LEVEL[minRole]) { return 403; } // role vem do cookie + ``` +- **Impacto:** (a) Usuário bloqueado mantém acesso até o cookie expirar; (b) admin rebaixado mantém `role` elevado no cookie; (c) usuário excluído ainda passa por `requireAuth`. +- **Cenário de exploração:** Admin bloqueia conta comprometida, mas o atacante continua operando com o cookie válido; admin demovido continua acessando endpoints de super_admin. +- **Correção recomendada:** Em cada requisição autenticada, consultar o usuário e rejeitar se `isBlocked`/excluído; obter `role` do banco (ou cache), ou adicionar `sessionVersion` incrementado em block/role-change/delete. Definir TTL (ver SEC-22). +- **Critérios de aceite:** + - [ ] Requisições de usuário bloqueado/excluído são rejeitadas imediatamente após a ação. + - [ ] Mudança de role invalida privilégios anteriores sem depender da expiração do cookie. + - [ ] Testes cobrem block/demote/delete com sessão ativa. + +--- + +### SEC-05 — Ausência de proteção CSRF + cookie SameSite=None em produção + +- **Componente:** Backend (Autenticação) +- **Severidade:** 🟠 High — Prioridade Linear: 2 (Alta) +- **Descrição:** A autenticação é exclusivamente por cookie de sessão HttpOnly e, em produção, o cookie é emitido com `sameSite: "none"`, desativando a proteção CSRF nativa do navegador. Não há middleware de token anti-CSRF nem validação de `Origin`/`Referer` nas rotas que mudam estado. +- **Localização no código:** + - `backend/src/lib/session.ts` (linhas ~9-13) + - `backend/src/app.ts` (sem middleware CSRF) +- **Evidência:** + ```ts + cookieOptions: { secure: isProd, httpOnly: true, sameSite: isProd ? "none" : "lax" } + ``` +- **Impacto:** `SameSite=None` envia o cookie em requisições cross-site. Logout CSRF funciona sem corpo; qualquer endpoint futuro que aceite form/urlencoded/text-plain, ou falha de CORS, torna-se explorável. +- **Cenário de exploração:** Página maliciosa dispara requisição cross-site; o cookie acompanha. Hoje mitigado parcialmente por `express.json()` (só `application/json`) + CORS, mas a dependência é frágil. +- **Correção recomendada:** `SameSite=Lax`/`Strict` quando front e API compartilham site; se cross-site, token CSRF (double-submit/synchronizer) e/ou validação estrita de `Origin`/`Referer`. +- **Critérios de aceite:** + - [ ] Cookie usa `SameSite=Lax`/`Strict`, ou há token CSRF validado em todas as rotas mutantes. + - [ ] Teste confirma rejeição de requisição cross-site sem token/origin válido. + +--- + +### SEC-06 — Grafana exposto com credenciais padrão (admin/admin) em 0.0.0.0 + +- **Componente:** Infra (Observabilidade) +- **Severidade:** 🟠 High — Prioridade Linear: 2 (Alta) +- **Descrição:** O serviço Grafana não define `GF_SECURITY_ADMIN_PASSWORD`/`GF_SECURITY_ADMIN_USER` nem hardening de auth anônima, subindo com `admin/admin`. A porta é publicada como `3002:3000` sem bind de IP (0.0.0.0). Datasources marcados `editable: true`. +- **Localização no código:** + - `docker-compose.observability.yml` (serviço grafana, linhas ~13-23) + - `observability/grafana/.../datasources.yml` +- **Evidência:** + ```yaml + image: grafana/grafana + ports: + - "3002:3000" # sem GF_SECURITY_ADMIN_PASSWORD + ``` +- **Impacto:** Qualquer um que alcance a porta 3002 entra como admin/admin, lê todos os dashboards e usa o proxy de datasource do Grafana para consultar Prometheus/Loki internamente. +- **Cenário de exploração:** Acesso à porta 3002 → login admin/admin → pivô via datasource proxy. +- **Correção recomendada:** Definir senha forte via secret, desabilitar cadastro, bind em 127.0.0.1 (ou proxy autenticado), `editable: false`. +- **Critérios de aceite:** + - [ ] Grafana exige senha forte (sem admin/admin) via secret. + - [ ] Porta não exposta em 0.0.0.0. + - [ ] Datasources não editáveis pela UI. + +--- + +### SEC-07 — Dependência `xlsx` 0.18.5 com Prototype Pollution + ReDoS + +- **Componente:** Dependências (Backend) +- **Severidade:** 🟠 High — Prioridade Linear: 2 (Alta) +- **Descrição:** `xlsx` (SheetJS) 0.18.5 do registro npm é afetado por **CVE-2023-30533** (prototype pollution) e **CVE-2024-22363** (ReDoS). Não há versão corrigida no npm; o fornecedor distribui correções apenas pelo CDN próprio (>=0.19/0.20). +- **Localização no código:** + - `package.json` (linha ~57: `"xlsx": "^0.18.5"`) +- **Evidência:** + ```json + "xlsx": "^0.18.5" + ``` +- **Impacto:** Planilha maliciosa parseada → prototype pollution (potencial RCE/DoS) e ReDoS travando o event loop do Node. +- **Cenário de exploração:** Upload/importação de XLSX malicioso processado pela lib. +- **Correção recomendada:** Migrar para o build do CDN do fornecedor (`xlsx@^0.20.x` de cdn.sheetjs.com) ou substituir por `exceljs`. Nunca parsear planilhas não confiáveis com 0.18.5. +- **Critérios de aceite:** + - [ ] `xlsx` atualizado para versão sem as CVEs (ou substituído por `exceljs`). + - [ ] `npm audit`/scanner não reporta CVE-2023-30533 nem CVE-2024-22363. + +--- + +### SEC-08 — URLs não confiáveis do scraper renderizadas em `href` (javascript:/data:) + +- **Componente:** Frontend e Frontend ADM +- **Severidade:** 🟠 High (admin) / Medium (frontend, mitigado por CSP) — Prioridade Linear: 2 (Alta) +- **Descrição:** `job.url` / `job.jobLink` vêm do pipeline do scraper (não confiável) e são passados direto para ``. O React 19 não sanitiza `href`; valores como `javascript:...` ou `data:text/html,...` são renderizados. O validador `safeExternalUrl()` (allowlist http/https) existe e é usado em `FormattedJobDescription.tsx`, mas **não é aplicado** nestas células de link. +- **Localização no código:** + - `frontend/src/domains/jobs/presentation/components/JobsTableCard.tsx` (~L187-195) + - `frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx` (~L164-168) + - `front_admin/src/modules/scrapers/ScrapersPage.tsx` (~L204-211) +- **Evidência:** + ```tsx + {job.title} + ``` +- **Impacto:** XSS armazenado disparado ao clicar. No admin é o mais grave: um link `javascript:` clicado por um admin pode disparar scrapes, bloquear/alterar role/excluir usuários como aquele admin. O admin **não** serve CSP (SEC-09), tornando-o diretamente explorável. +- **Cenário de exploração:** Vaga maliciosa com `url=javascript:...`; admin clica na `ScrapersPage`. +- **Correção recomendada:** Reutilizar `safeExternalUrl()` (http/https) em todo anchor alimentado por dados do scraper; renderizar `` quando falhar. +- **Critérios de aceite:** + - [ ] Todo `href` alimentado por dados de vaga passa por `safeExternalUrl()`. + - [ ] URLs com esquema não http/https não são renderizadas como link. + - [ ] Teste cobre `javascript:`/`data:` nas três telas. + +--- + +### SEC-09 — Frontend ADM sem CSP/headers de segurança (sem vercel.json) + +- **Componente:** Frontend ADM +- **Severidade:** 🟠 High — Prioridade Linear: 2 (Alta) +- **Descrição:** O `frontend/` público possui `vercel.json` e `nginx.conf` com CSP forte + X-Frame-Options/X-Content-Type-Options/Referrer-Policy. O `front_admin/` possui apenas `nginx.conf`. Se o admin for implantado na Vercel (como o público), será servido **sem CSP e sem headers de segurança** — a superfície de maior privilégio é a menos protegida. +- **Localização no código:** + - `front_admin/` (ausência de `vercel.json`); comparar com `frontend/vercel.json` +- **Evidência:** `frontend/vercel.json` presente com `script-src 'self'; ... frame-ancestors 'none'`; `front_admin/vercel.json` ausente. +- **Impacto:** Sem CSP, o XSS de `href javascript:` (SEC-08), injeção de script inline e clickjacking ficam sem mitigação no console administrativo. +- **Cenário de exploração:** Atacante enquadra (iframe) ou injeta no admin e age com privilégios de admin. Compõe com SEC-08. +- **Correção recomendada:** Adicionar `vercel.json` ao `front_admin` espelhando os headers do frontend (ou confirmar que o nginx é o único deploy). Manter `script-src 'self'`. +- **Critérios de aceite:** + - [ ] Admin serve CSP, X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy em produção. + - [ ] Caminho de deploy do admin documentado e validado (headers presentes na resposta real). + +--- + +### SEC-10 — Retorno em massa de PII descriptografada (CPF/e-mail/telefone) para admin + +- **Componente:** Backend (API / Exposição de dados) +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** `GET /admin/users` (lista) e `GET /admin/users/:id` mapeiam cada linha por `toPublicUser`, que descriptografa e-mail, telefone e **CPF** de todos os usuários retornados (até 100 por chamada). +- **Localização no código:** + - `backend/src/modules/admin/users/adminUsers.repository.ts` (`findMany` ~53-58, `findById` ~61-64) + - `backend/src/modules/users/users.mapper.ts` (`toPublicUser` ~125-164) +- **Evidência:** + ```ts + return { data: data.map(toPublicUser), total, limit, offset }; + cpf: cpfEncrypted ? decryptText(cpfEncrypted) : user.cpf, + phone: phoneEncrypted ? decryptText(phoneEncrypted) : user.phone, + ``` +- **Impacto:** Qualquer conta com role `admin` (não só super_admin) pode exfiltrar a PII sensível de toda a base em massa — anula a criptografia em nível de campo; risco LGPD. +- **Cenário de exploração:** Admin itera offsets em `GET /admin/users?limit=100` e coleta todos os CPFs. +- **Correção recomendada:** Não descriptografar CPF/telefone na projeção de lista (mascarar/omitir); descriptografar só na visualização de registro único com permissão dedicada e auditoria por revelação. +- **Critérios de aceite:** + - [ ] Listagem não retorna CPF/telefone/e-mail em texto puro. + - [ ] Revelação de PII completa exige permissão específica e gera auditoria. + +--- + +### SEC-11 — SSRF via `apiUrl` fornecido por arquivo no adapter Lever (sem allowlist) + +- **Componente:** Scraper Go +- **Severidade:** 🟡 Medium (High se os arquivos de companies/tenants forem remotos ou graváveis) — Prioridade Linear: 3 (Média) +- **Descrição:** Cada entrada de `leverCompanies.json` tem um `apiUrl` copiado direto para `a.apiURL` e retornado por `postingsEndpoint()` como a URL completa, sem validação de scheme/host e sem allowlist. O path do arquivo vem de `LEVER_COMPANIES_FILE` sem validação. +- **Localização no código:** + - `scraper-go/internal/adapters/lever/adapter.go` (`FetchLeverSlugs` ~105-126; `postingsEndpoint` ~253-263; `BuildLeverAdapters` ~443-469) + - `scraper-go/internal/interfaces/leverCompanies.json` +- **Evidência:** + ```go + func (a *LeverAdapter) postingsEndpoint() string { + if a.apiURL != "" { + return a.apiURL + } + ``` +- **Impacto:** Quem influenciar o JSON de companies pode apontar o adapter para hosts internos (ex.: `http://169.254.169.254/...`) e o serviço busca/parseia a resposta. +- **Cenário de exploração:** `apiUrl` apontado para metadata/serviço interno → exfiltração via corpo retornado. +- **Correção recomendada:** Allowlist estrita de hosts (`api.lever.co`, `api.eu.lever.co`), rejeitar não-https/host fora, derivar endpoint do slug no servidor, validar paths de `LEVER_COMPANIES_FILE`/`GREENHOUSE_COMPANIES_FILE`/`INHIRE_TENANTS_FILE` contra base dir. +- **Critérios de aceite:** + - [ ] Hosts de saída validados contra allowlist; schemes não-https rejeitados. + - [ ] Paths de arquivos de config restritos a diretório-base esperado. + - [ ] Teste bloqueia `apiUrl` malicioso (metadata/host interno). + +--- + +### SEC-12 — Parâmetros de scrape sem limites → DoS/amplificação + +- **Componente:** Scraper Go +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** Campos de `ScrapeRequest` passam quase sem limites. `MaxPagesPerKeyword`, `WaitBetweenSearchesMs`, `PageTimeoutMs` e o slice `Keywords` nunca têm teto; adapters só aplicam default quando o valor é `<= 0`. (`MaxConcurrency` É corretamente limitado.) +- **Localização no código:** + - `scraper-go/cmd/server/handlers.go` (`handleScrape` ~33-53; `searchConfigFromRuntime` ~94-118) + - `scraper-go/internal/domain/job.go` (`ScrapeRequest` ~31-45) +- **Evidência:** + ```go + maxPages := req.MaxPagesPerKeyword + if maxPages <= 0 { + maxPages = defaultTheMuseMaxPages + } + ``` +- **Impacto:** `POST /scrape` (não autenticado, SEC-02) com `maxPagesPerKeyword` enorme e muitas keywords martela provedores pelo timeout do pipeline (~15min), consumindo CPU/memória/banda e arriscando ban de IP. +- **Cenário de exploração:** `POST /scrape {"maxPagesPerKeyword":1000000, "keywords":[...]}`. +- **Correção recomendada:** Impor máximos no servidor (limitar `MaxPagesPerKeyword`, `ResultsPerPage`, tamanho de `Keywords`, mínimo de `WaitBetweenSearchesMs`), espelhando os caps de batch de `internal/config/config.go`. +- **Critérios de aceite:** + - [ ] Parâmetros de scrape têm tetos impostos no servidor. + - [ ] Requisição com valores abusivos é normalizada/rejeitada. + +--- + +### SEC-13 — Handlers JSON do Go sem limite de tamanho de corpo + +- **Componente:** Scraper Go +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** `handleScrape` e `handleSaveKeywords` chamam `json.NewDecoder(r.Body).Decode(...)` sem `http.MaxBytesReader`. +- **Localização no código:** + - `scraper-go/cmd/server/handlers.go` (`handleScrape` ~35; `handleSaveKeywords` ~150) +- **Evidência:** + ```go + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + ``` +- **Impacto:** Cliente não autenticado envia corpo arbitrariamente grande → alocação/buffering e DoS por exaustão de memória. +- **Cenário de exploração:** `POST /api/keywords` com corpo de centenas de MB. +- **Correção recomendada:** `http.MaxBytesReader(w, r.Body, N)` + `DisallowUnknownFields` onde apropriado. +- **Critérios de aceite:** + - [ ] Corpos têm limite de tamanho; exceder retorna 413/400. + +--- + +### SEC-14 — Respostas externas decodificadas sem `io.LimitReader` + +- **Componente:** Scraper Go +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** Todos os adapters (exceto o caminho de *detalhe* do InHire) decodificam `resp.Body` com `json.NewDecoder(resp.Body)` sem limite. Um upstream hostil — ou alvo de SSRF via SEC-11 — pode retornar corpo ilimitado ou gzip-bomb. +- **Localização no código:** + - `scraper-go/internal/adapters/{greenhouse,lever,jooble,gupy,themuse,adzuna}/adapter.go` + - Padrão correto a copiar: `inhire/adapter.go` (~239, ~694) +- **Evidência:** + ```go + var data greenhouseListResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + ``` +- **Impacto:** OOM do scraper a partir de uma única resposta upstream gigante; amplificado com vários adapters concorrentes. +- **Cenário de exploração:** Upstream retorna resposta/compressão descomunal. +- **Correção recomendada:** `io.LimitReader(resp.Body, maxBytes)` antes de decodificar, consistente em todos os adapters. +- **Critérios de aceite:** + - [ ] Todos os adapters limitam o tamanho do corpo lido do upstream. + +--- + +### SEC-15 — Endpoint `/metrics` do backend sem autenticação + +- **Componente:** Backend (API) +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** O endpoint Prometheus `/metrics` é registrado na raiz do app, fora de `withSession`/`requireAuth` e da rota RBAC `/admin/observability/metrics`. +- **Localização no código:** + - `backend/src/app.ts` (linhas ~68-71) +- **Evidência:** + ```ts + app.get("/metrics", async (_req, res) => { + res.set("Content-Type", register.contentType); + res.end(await register.metrics()); + }); + ``` +- **Impacto:** Divulgação de dados operacionais internos (rotas, volumes, latências, contador `jobSearchesTotal`); bypass da rota admin já protegida. +- **Cenário de exploração:** `GET /metrics` por qualquer um que alcance o serviço. +- **Correção recomendada:** Exigir auth + permissão de observabilidade ou restringir a rede interna/bearer. (Checar `/docs` Swagger em `server.ts` também.) +- **Critérios de aceite:** + - [ ] `/metrics` exige autenticação/allowlist de rede. + +--- + +### SEC-16 — Enumeração de usuários (409 no registro + timing no login) + +- **Componente:** Backend (Autenticação) +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** (a) `register` retorna `409 "Email já cadastrado"` quando o e-mail existe. (b) `login` curto-circuita sem argon2 quando a credencial não existe, enquanto e-mail existente sempre roda `argon2.verify` (lento) — oráculo de timing. +- **Localização no código:** + - `backend/src/modules/auth/credentials.service.ts` (`register` ~43-48; `login` ~109-119) + - `backend/src/modules/auth/providers/credentials.ts` (`verifyCredentials` ~40-54) +- **Evidência:** + ```ts + if (existingCredential) { throw AppError.conflict("Email já cadastrado"); } + if (!credential) { throw AppError.unauthorized("Credenciais inválidas"); } + const valid = await argon2.verify(credential.passwordHash, password); + ``` +- **Impacto:** Enumeração de e-mails cadastrados para phishing/credential-stuffing. +- **Cenário de exploração:** Probing de e-mails medindo status/tempo de resposta. +- **Correção recomendada:** Resposta genérica no registro; no login, sempre executar `argon2.verify` dummy contra hash constante quando não houver credencial. +- **Critérios de aceite:** + - [ ] Registro não revela existência da conta. + - [ ] Login com tempo equivalente para e-mail existente vs. inexistente. + +--- + +### SEC-17 — RBAC administrativo aplicado apenas no cliente (verificar backend) + +- **Componente:** Frontend ADM +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** A matriz de permissões está hardcoded no bundle do navegador e anexada ao usuário no cliente a partir de `/auth/me`. Acesso a rotas e gating de features decididos no React. **Nota:** a auditoria do backend indicou RBAC server-side robusto; este card é para **confirmar** que *todo* endpoint privilegiado reforça role/permission no servidor. +- **Localização no código:** + - `front_admin/src/lib/api/auth.api.ts` (`ROLE_PERMISSIONS` ~L10-33) + - `front_admin/src/app/routes/ProtectedRoute.tsx` (`ROLE_HIERARCHY` ~L6-23) + - `front_admin/src/modules/auth/hooks/useAuth.ts` (`hasPermission` ~L144-145) +- **Evidência:** + ```ts + if (ROLE_HIERARCHY[isLoggedIn.role] < ROLE_HIERARCHY[minRole]) return ; + ``` +- **Impacto:** Modelo de privilégios legível no bundle; se algum endpoint depender da UI para gating, role baixo chama direto. +- **Cenário de exploração:** Usuário de baixo privilégio chama endpoint admin diretamente. +- **Correção recomendada:** Tratar guards de cliente como UX; garantir authz server-side por ação/role em todos os endpoints; considerar entregar permissões pelo servidor. +- **Critérios de aceite:** + - [ ] Inventário confirma `requireRole`/`requirePermission` no backend para cada endpoint admin. + - [ ] Teste de autorização negativa (role baixo → 403). + +--- + +### SEC-18 — Loki publicado com autenticação desabilitada + +- **Componente:** Infra (Observabilidade) +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** `auth_enabled: false` e Loki publicado em 0.0.0.0:3100. +- **Localização no código:** + - `observability/loki/loki-config.yml` (linha ~1) + - `docker-compose.observability.yml` (ports `3100:3100`, ~28-29) +- **Evidência:** + ```yaml + auth_enabled: false + ``` +- **Impacto:** Qualquer cliente no host lê/empurra logs (possível PII) e consulta o store sem credenciais. +- **Cenário de exploração:** `GET`/`POST` na API do Loki a partir da rede do host. +- **Correção recomendada:** Não publicar 3100 (interno em `vagas-net`) ou gateway autenticado; auth multi-tenant. +- **Critérios de aceite:** + - [ ] Loki não acessível sem autenticação a partir do host. + +--- + +### SEC-19 — Prometheus/observabilidade expostos sem auth em 0.0.0.0 + +- **Componente:** Infra (Observabilidade) +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** Prometheus publicado em 0.0.0.0:9091 sem auth (não tem auth nativa). Exporters/alertmanager com config default. +- **Localização no código:** + - `docker-compose.observability.yml` (prometheus ports `9091:9090`, ~2-11) + - `observability/alertmanager/alertmanager.yml` +- **Evidência:** + ```yaml + ports: + - "9091:9090" + ``` +- **Impacto:** Endpoints revelam topologia/metricas internas a quem alcançar a porta; auxilia reconhecimento. +- **Cenário de exploração:** `GET http://host:9091`. +- **Correção recomendada:** Bind em 127.0.0.1/rede de gerência; proxy autenticado. +- **Critérios de aceite:** + - [ ] Portas de observabilidade não expostas em 0.0.0.0 sem auth. + +--- + +### SEC-20 — Containers executando como root (sem diretiva USER) + +- **Componente:** Infra (Containers) +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** Backend e scraper-go não dropam privilégios. Backend roda `npx tsx src/server.ts` como root; runtime Go (alpine) roda `/go-scraper` como root. Sem `cap_drop`/`read_only`/`no-new-privileges`. +- **Localização no código:** + - `docker/node.Dockerfile` (stage backend ~15-21, sem `USER`) + - `scraper-go/Dockerfile` (stage runtime ~17-33, sem `USER`) +- **Evidência:** Dockerfiles sem linha `USER`; serviços compose sem `security_opt`/`cap_drop`. +- **Impacto:** RCE no backend/scraper escala para root dentro do container, ampliando o raio de um escape. +- **Cenário de exploração:** RCE no serviço → root no container. +- **Correção recomendada:** `USER` não-root em ambos os Dockerfiles, `security_opt: [no-new-privileges:true]`, `cap_drop: [ALL]`, root FS read-only onde viável. +- **Critérios de aceite:** + - [ ] Containers de backend e scraper rodam como não-root. + - [ ] `no-new-privileges` e `cap_drop: [ALL]` aplicados. + +--- + +### SEC-21 — Credenciais padrão fracas de banco (`vagas/vagas`) em .env.example + +- **Componente:** Config / Infra +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** `POSTGRES_USER=vagas` / `POSTGRES_PASSWORD=vagas` / `POSTGRES_DB=vagas` e `DATABASE_URL=postgresql://vagas:vagas@...`. Defaults literais copiados para o `.env` real; consumidos por `docker-compose.infra.yml`/`docker-compose.migrate.yml`/postgres-exporter. +- **Localização no código:** + - `.env.example` (linhas ~52-58) + - `docker-compose.infra.yml` (~6-8), `docker-compose.migrate.yml` (~12), `docker-compose.observability.yml` (~55) +- **Evidência:** + ``` + POSTGRES_PASSWORD=vagas + DATABASE_URL=postgresql://vagas:vagas@localhost:5432/vagas + ``` +- **Impacto:** Se a porta do Postgres for exposta ou a `vagas-net` alcançável, `vagas:vagas` dá acesso total (PII criptografada + dados). +- **Cenário de exploração:** Conexão direta ao Postgres com credenciais conhecidas. +- **Correção recomendada:** `.env.example` com `POSTGRES_PASSWORD` vazio + comentário para gerar valor forte; nunca senha funcional por padrão. +- **Critérios de aceite:** + - [ ] `.env.example` não contém senha de banco funcional. + - [ ] Documentação instrui a gerar senha forte. + +--- + +### SEC-22 — `SESSION_SECRET` não validado no boot + sessão sem TTL + +- **Componente:** Backend (Autenticação) / Config +- **Severidade:** 🟡 Medium — Prioridade Linear: 3 (Média) +- **Descrição:** `password: process.env.SESSION_SECRET!` usa non-null assertion sem validação de presença/tamanho (iron-session exige ≥32 chars). Nenhum `ttl`/`maxAge` configurado. +- **Localização no código:** + - `backend/src/lib/session.ts` (linhas ~5-14) +- **Evidência:** + ```ts + export const sessionOptions: SessionOptions = { + password: process.env.SESSION_SECRET!, + cookieName: "vagas_session", + cookieOptions: { secure: isProd, httpOnly: true, sameSite: isProd ? "none" : "lax" }, + }; + ``` +- **Impacto:** Secret vazio/fraco sobe silenciosamente; secret fraco permite ataque offline ao cookie selado → forja de sessão. Sem TTL amplia o impacto da falta de revogação (SEC-04). +- **Cenário de exploração:** Deploy com `SESSION_SECRET` ausente/curto. +- **Correção recomendada:** Validar ≥32 chars no boot (fail fast, como `encryption.ts`); definir `ttl`/`maxAge`; considerar array de secrets para rotação. +- **Critérios de aceite:** + - [ ] App falha no boot se `SESSION_SECRET` ausente/curto. + - [ ] Sessão tem TTL/maxAge explícito. + +--- + +### SEC-23 — Mensagens de erro internas vazadas ao cliente + +- **Componente:** Backend (API) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** Alguns handlers retornam `(error as Error).message` cru em todos os ambientes, contornando o `errorHandler` global (que suprime `cause` em produção). +- **Localização no código:** + - `backend/src/modules/jobs/controllers/searchJobs.controller.ts` (~33-40) + - `backend/src/routes/keywords.routes.ts` (~23-28) + - `backend/src/routes/superAdmin.routes.ts` (~41-51) +- **Evidência:** + ```ts + res.status(500).json({ message: "Erro ao recuperar vagas em memória.", error: (error as Error).message }); + ``` +- **Impacto:** Texto de exceção (erros de driver DB/cache) retornado a chamadores, auxiliando recon. +- **Cenário de exploração:** Forçar erro e ler detalhes internos. +- **Correção recomendada:** Remover `error.message` das respostas (ou `next(error)`). +- **Critérios de aceite:** + - [ ] Respostas de erro não contêm detalhes internos em produção. + +--- + +### SEC-24 — Injeção de wildcard LIKE na busca de usuários (admin) + +- **Componente:** Backend (API) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** `search` do usuário embutido em `ilike` como `%${filters.search}%` sem escapar `%`/`_`. Não é SQLi (Drizzle parametriza), mas os metacaracteres de padrão são interpretados. +- **Localização no código:** + - `backend/src/modules/admin/users/adminUsers.repository.ts` (`findMany` ~30-41) +- **Evidência:** + ```ts + ilike(users.username, `%${filters.search}%`), + ilike(users.displayName, `%${filters.search}%`), + ``` +- **Impacto:** Ampliar matches arbitrariamente ou forçar varreduras `ILIKE` custosas (DoS menor). +- **Cenário de exploração:** `search=%`. +- **Correção recomendada:** Escapar `%`, `_`, `\` no termo antes de interpolar. +- **Critérios de aceite:** + - [ ] Metacaracteres LIKE escapados no termo de busca. + +--- + +### SEC-25 — IP do audit log obtido de `X-Forwarded-For` falsificável + +- **Componente:** Backend (API / Logs) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** O `ip` do audit é lido direto do header `x-forwarded-for` em vez do `req.ip` validado pelo proxy (`trust proxy = 1`). +- **Localização no código:** + - `backend/src/modules/admin/audit/audit.service.ts` (`fromRequest` ~40-43) +- **Evidência:** + ```ts + ip: (req.headers["x-forwarded-for"] as string)?.split(",")[0].trim() ?? req.socket.remoteAddress, + ``` +- **Impacto:** Ator em ações sensíveis pode forjar `X-Forwarded-For` para poluir a atribuição de IP (anti-forense). +- **Cenário de exploração:** `X-Forwarded-For: 1.2.3.4` ao executar ação admin. +- **Correção recomendada:** Usar `req.ip`. +- **Critérios de aceite:** + - [ ] Audit log usa `req.ip`; header manual não influencia o IP registrado. + +--- + +### SEC-26 — Credenciais de admin hardcoded no script de seed + +- **Componente:** Backend (API / Scripts) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** O seed cria `admin@localhost.test` / `Admin@123456` (role `admin`) e um usuário com senhas fixas, sem guard impedindo rodar contra DB não-local. +- **Localização no código:** + - `backend/src/scripts/seed.ts` (`SEED_USERS` ~39-56) +- **Evidência:** + ```ts + { username: "local.admin", displayName: "Local Admin", + email: "admin@localhost.test", password: "Admin@123456", role: "admin" }, + ``` +- **Impacto:** Se o seed rodar contra staging/produção, provisiona admin previsível. +- **Cenário de exploração:** Execução acidental do seed em ambiente compartilhado. +- **Correção recomendada:** Assert `NODE_ENV !== "production"` (abortar) e/ou senhas via env. +- **Critérios de aceite:** + - [ ] Seed aborta fora de dev/local. + - [ ] Senhas de seed não fixas/usáveis em prod. + +--- + +### SEC-27 — Validação de param UUID ausente em algumas rotas + +- **Componente:** Backend (API) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** Rotas sem `validate({ params })`; `:id` não-UUID chega ao `eq()` (uuid). Ownership mantido no serviço (sem IDOR) — defesa em profundidade. +- **Localização no código:** + - `backend/src/routes/notifications.routes.ts` (`PATCH /:id/read` ~31) + - `backend/src/routes/savedJobs.routes.ts` (`GET /:id`, `GET /:id/events` ~23-28) +- **Evidência:** + ```ts + router.patch("/:id/read", (req, res, next) => { controller.markRead(req, res).catch(next); }); + ``` +- **Impacto:** `:id` malformado → erro de cast do Postgres como 500. +- **Cenário de exploração:** `PATCH /abc/read`. +- **Correção recomendada:** Aplicar os schemas de params uuid existentes. +- **Critérios de aceite:** + - [ ] Rotas retornam 400 para `:id` não-UUID. + +--- + +### SEC-28 — Provider de credenciais morto com argon2 default (fraco) + +- **Componente:** Backend (Autenticação) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** `registerWithCredentials` faz hash com `argon2.hash(password)` (default) em vez do `argonOptions` endurecido, e insere credencial com `userId: ""` (FK inválida). Código morto, mas exportado. +- **Localização no código:** + - `backend/src/modules/auth/providers/credentials.ts` (`registerWithCredentials` ~24-31) +- **Evidência:** + ```ts + const passwordHash = await argon2.hash(password); // default params + await db.insert(credentials).values({ email: encryptText(normalizedEmail), emailHash, passwordHash, userId: "" }); + ``` +- **Impacto:** Se religado: hashes mais fracos e linhas órfãs/colisão de unique constraint. +- **Cenário de exploração:** N/A (código morto) — risco se religado sem revisão. +- **Correção recomendada:** Remover ou alinhar a `argonOptions` + `userId` real. +- **Critérios de aceite:** + - [ ] Função removida ou alinhada ao padrão endurecido. + +--- + +### SEC-29 — Rate-limit em memória por instância + IP via XFF falsificável + +- **Componente:** Backend (Autenticação) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** Sem `VALKEY_URL`, o limiter usa `Map` local (não compartilhado entre réplicas). IP de `req.ip` com `trust proxy = 1` pode ser spoofado se a topologia difere. Endpoints OAuth sem rate limiting. +- **Localização no código:** + - `backend/src/middleware/rateLimit.ts` (~30-32, ~106-108, ~141-146) + - `backend/src/app.ts` (`app.set("trust proxy", 1)` ~38) +- **Evidência:** + ```ts + const entry = process.env.VALKEY_URL ? await consumeFromValkey(...) : await consumeFromMemory(...); + function clientIp(req) { return req.ip || req.socket.remoteAddress || "unknown"; } + ``` +- **Impacto:** Brute force distribuído / rotação de IP via XFF dilui o guarda de 20/IP. (O limiter por conta de 5/tentativa permanece como backstop.) +- **Cenário de exploração:** Credential-stuffing de uma origem spoofando XFF. +- **Correção recomendada:** Exigir store compartilhado (Valkey) em produção; confirmar `trust proxy` igual ao nº real de hops; rate limiting nos endpoints OAuth. +- **Critérios de aceite:** + - [ ] Produção exige store de rate-limit compartilhado. + - [ ] Endpoints OAuth têm rate limiting. + +--- + +### SEC-30 — Troca de token do GitHub ignora erros HTTP + +- **Componente:** Backend (Autenticação) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** Diferente do LinkedIn, a troca do GitHub nunca checa `tokenRes.ok`; em falha, `access_token` é `undefined`, chamadas seguintes rodam com `Bearer undefined` e `String(user.id)` vira `"undefined"`, fluindo para `findOrCreateUser`. +- **Localização no código:** + - `backend/src/modules/auth/providers/github.ts` (~19-56) +- **Evidência:** + ```ts + const { access_token } = await tokenRes.json(); // sem checar tokenRes.ok + return { id: String(user.id), email: primaryEmail ?? user.email, ... }; + ``` +- **Impacto:** Perfil malformado; aresta a endurecer junto de SEC-01. +- **Cenário de exploração:** Falha transitória na troca de token. +- **Correção recomendada:** Checar `tokenRes.ok`/`userRes.ok` e lançar; rejeitar quando `user.id` ausente. +- **Critérios de aceite:** + - [ ] Falha HTTP na troca de token do GitHub resulta em erro explícito. + +--- + +### SEC-31 — `/metrics` e `/health` do scraper-go sem autenticação + +- **Componente:** Scraper Go +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** `GET /metrics` (promhttp) e `GET /health` sem auth; `/health` revela o backend de cache. +- **Localização no código:** + - `scraper-go/cmd/server/server.go` (~77-78); `handlers.go` (`/health` ~120-128) +- **Evidência:** + ```go + mux.Handle("GET /metrics", promhttp.Handler()) + ``` +- **Impacto:** Reconhecimento (internals Go/process, build info) por qualquer um na rede. +- **Cenário de exploração:** `GET /metrics` interno. +- **Correção recomendada:** Restringir `/metrics` à rede/credencial de monitoramento; `/health` mínimo. +- **Critérios de aceite:** + - [ ] `/metrics` do scraper restrito a rede/credencial de monitoramento. + +--- + +### SEC-32 — Chaves de API de providers embutidas em URLs de saída + +- **Componente:** Scraper Go +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** Chave do Jooble no path da URL; `app_id`/`app_key` do Adzuna na query string. Segredos em URL vazam por logs de proxy/upstream. +- **Localização no código:** + - `scraper-go/internal/adapters/jooble/adapter.go` (`fetchPage` ~232-236) + - `scraper-go/internal/adapters/adzuna/adapter.go` (`buildURL` ~91-92) +- **Evidência:** + ```go + endpoint = endpoint + "/" + a.apiKey + ``` +- **Impacto:** Se algum intermediário logar URLs completas, as credenciais são divulgadas. (O serviço em si não loga.) +- **Cenário de exploração:** Logs de proxy capturam a URL com a chave. +- **Correção recomendada:** Auth via header onde suportado; garantir não-logging; rotacionar chaves se logs puderem tê-las capturado. +- **Critérios de aceite:** + - [ ] Chaves fora de URL onde possível, ou garantia documentada de não-logging. + +--- + +### SEC-33 — Conexão Redis/Valkey em texto puro sem auth por padrão + +- **Componente:** Scraper Go / Infra +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** Conexão de `VALKEY_URL` via `redis.ParseURL`, default `redis://localhost:6379` (texto puro, sem senha). TLS/auth só se o operador fornecer. Dados sem criptografia no Valkey. (Chaves seguras — prefixos fixos + sha256.) +- **Localização no código:** + - `scraper-go/cmd/server/server.go` (`newRedisClient` ~129-151) + - `scraper-go/internal/cache/factory.go` (~13-36) +- **Evidência:** `redis.ParseURL(VALKEY_URL)` com default `redis://localhost:6379`. +- **Impacto:** Em deploy com Valkey não autenticado, jobs cacheados, keywords, quota e run-lock são legíveis/graváveis por quem tiver acesso de rede. +- **Cenário de exploração:** Acesso de rede ao Valkey sem auth. +- **Correção recomendada:** `rediss://` + credenciais em produção, validar no startup, isolar a rede. +- **Critérios de aceite:** + - [ ] Produção usa Valkey autenticado/criptografado; startup valida. + +--- + +### SEC-34 — Montagens sensíveis do host na stack de observabilidade + +- **Componente:** Infra (Observabilidade) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** Promtail monta `/var/run/docker.sock` (ro) e logs; node-exporter roda com `pid: host` e monta a raiz do host ro; cadvisor monta raiz/docker ro. Combinado com containers root (SEC-20), amplia a superfície de escape. +- **Localização no código:** + - `docker-compose.observability.yml` (promtail ~42-43; node-exporter ~72-74; cadvisor ~82-86) +- **Evidência:** + ```yaml + - /var/run/docker.sock:/var/run/docker.sock:ro + - /:/host:ro,rslave # com pid: host + ``` +- **Impacto:** Leitura do FS do host + socket do Docker auxilia escalonamento/recon se um container de observabilidade for comprometido. +- **Cenário de exploração:** Comprometimento de container de observabilidade → recon do host. +- **Correção recomendada:** Rodar observabilidade em host isolado; preferir docker-socket-proxy; remover o mount do socket do promtail se desnecessário. +- **Critérios de aceite:** + - [ ] Montagens do host restritas/isoladas; socket do Docker via proxy ou removido onde desnecessário. + +--- + +### SEC-35 — Imagens de observabilidade com tags mutáveis (`:latest`) + +- **Componente:** Infra (Supply chain) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** A maioria das imagens de observabilidade não tem tag, resolvendo para `:latest` (prometheus, grafana, exporters, node-exporter, cadvisor, alertmanager). +- **Localização no código:** + - `docker-compose.observability.yml` (linhas ~3, 14, 52, 61, 70, 80, 92) +- **Evidência:** + ```yaml + image: prom/prometheus # sem tag + image: grafana/grafana # sem tag + ``` +- **Impacto:** Builds não reprodutíveis e drift de supply-chain; imagem upstream nova/envenenada puxada automaticamente no rebuild. +- **Cenário de exploração:** Upstream comprometido entregue via `:latest`. +- **Correção recomendada:** Pinar versões/digests (como já feito para loki/promtail e postgres/valkey). +- **Critérios de aceite:** + - [ ] Todas as imagens de observabilidade pinadas por versão/digest. + +--- + +### SEC-36 — Workflows CI sem `permissions` de menor privilégio + +- **Componente:** CI/CD +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** Nenhum workflow declara `permissions:`, então o `GITHUB_TOKEN` recebe o escopo default do repo. `ci.yml` roda `npm ci` + build em `pull_request`. +- **Localização no código:** + - `.github/workflows/ci.yml` (sem `permissions:`, ~1-39) + - `.github/workflows/block-master-pr.yml` (sem `permissions:`) +- **Evidência:** `ci.yml` com `on: pull_request` e nenhuma chave `permissions:`. +- **Impacto:** PR malicioso que dispare scripts de build/postinstall roda com token amplo. (Usa `pull_request`, não `pull_request_target` — evita o pwn-request clássico.) +- **Cenário de exploração:** PR de fork com script de install malicioso. +- **Correção recomendada:** `permissions: contents: read` no `ci.yml` e `pull-requests: write` escopado no `block-master-pr.yml`; considerar não rodar scripts de install não confiáveis em PRs de fork. +- **Critérios de aceite:** + - [ ] Workflows declaram `permissions` de menor privilégio. + +--- + +### SEC-37 — Nome de branch de PR não confiável interpolado em github-script + +- **Componente:** CI/CD +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** O `head.ref` do PR é embutido num template-literal do corpo de comentário via `github-script`. É usado pelo client `github` autenticado (não shell) e o workflow é `pull_request` — **não** é command injection nem expõe secrets. Hardening apenas. +- **Localização no código:** + - `.github/workflows/block-master-pr.yml` (bloco `script:`) +- **Evidência:** + ```js + const sourceBranch = context.payload.pull_request.head.ref; + ``` +- **Impacto:** Mínimo — conteúdo cosmético de comentário. +- **Cenário de exploração:** Branch com nome especial refletido no comentário. +- **Correção recomendada:** Opcionalmente sanitizar/encodar o ref no comentário. +- **Critérios de aceite:** + - [ ] Ref do branch sanitizado/encodado no comentário (opcional/hardening). + +--- + +### SEC-38 — CSP do nginx de produção (frontend) permite `http://localhost:3001` + +- **Componente:** Frontend (Config) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** O CSP servido pelo nginx inclui `http://localhost:3001` (backend de dev) no `connect-src`, junto das APIs HTTPS. Origem de dev residual num header de produção que afrouxa a política. +- **Localização no código:** + - `frontend/nginx.conf` (linha do `connect-src`) +- **Evidência:** + ``` + connect-src 'self' http://localhost:3001 https://api.github.com https://api.candidate.app.br https://jobsglobalscraper.ddns.net + ``` +- **Impacto:** Baixo; higiene de config e potencial mixed-content. (A versão Vercel omite localhost.) +- **Cenário de exploração:** N/A direto; afrouxamento de política. +- **Correção recomendada:** Remover `http://localhost:3001` do CSP de produção do nginx. +- **Critérios de aceite:** + - [ ] CSP de produção do frontend sem `http://localhost:3001`. + +--- + +### SEC-39 — CSP de borda com pontos fracos menores (img-src/style-src) + +- **Componente:** Config (Edge/Vercel) +- **Severidade:** 🟢 Low — Prioridade Linear: 4 (Baixa) +- **Descrição:** `style-src 'self' 'unsafe-inline'` permite estilos inline (tradeoff React, aceitável) e `img-src 'self' data: https:` permite imagens de qualquer origem HTTPS (amplo). `script-src` está correto como `'self'`. +- **Localização no código:** + - `vercel.json` (header CSP, linha ~8) +- **Evidência:** + ``` + style-src 'self' 'unsafe-inline' ...; img-src 'self' data: https: + ``` +- **Impacto:** Baixo — `unsafe-inline` em styles habilita injeção CSS limitada; `img-src` amplo enfraquece marginalmente controles de exfil. Sem enfraquecimento de script. +- **Cenário de exploração:** Exfil via requisição de imagem para host arbitrário. +- **Correção recomendada:** Restringir `img-src` aos hosts usados; estilos por nonce/hash a longo prazo. +- **Critérios de aceite:** + - [ ] `img-src` restrito a hosts necessários. + +--- + +## 5. Controles verificados como corretos (não geram card) + +Registrado para contexto — validados no código e **corretos**: + +- **Criptografia de PII:** AES-256-GCM com IV aleatório de 12 bytes + auth tag, formato versionado, validação estrita de chave. Sem reuso de IV, sem ECB/CBC. (`lib/security/encryption.ts`) +- **Hashing de senha:** argon2id (64MB / t=3 / p=4) nos fluxos reais. (`credentials.service.ts`, `adminUsers.repository.ts`) +- **Hash pesquisável:** HMAC-SHA256 com secret obrigatório e `timingSafeEqual`. (`lib/security/searchableHash.ts`) +- **Sem SQL injection:** acesso via query builder parametrizado do Drizzle; únicos `sql\`\`` são constantes (`now()`). +- **IDOR/BOLA bem tratado:** savedJobs, applicationNotes, notifications, keywords e user preferences escopam tudo por `userId` + id do recurso; `assertOwnsResource` retorna 404. +- **OAuth state:** `randomBytes(16)`, armazenado server-side e validado antes do uso (CSRF-on-callback presente); redireciona só ao `FRONTEND_URL` fixo (sem open redirect). +- **RBAC server-side:** `requireRole(min)` + `requirePermission(resource, action)`; matriz default-deny; `IMMUTABLE_RULES`; `delete`/`change_role` restritos a super_admin; `can()` falha seguro. +- **Mass assignment prevenido:** zod via `validate()` (strip de desconhecidos); `updateProfileSchema` não expõe `role`/`isBlocked`. +- **Sem SSRF no backend:** chamadas de saída usam host:port de env; `scraperClient` com timeout de 5s. +- **Sem command injection**, **sem path traversal** com entrada do usuário, **sem deserialização insegura** relevante. +- **Headers/hardening:** `express.json({ limit: "16kb" })`, `x-powered-by` off, CSP estrito na API, `X-Frame-Options: DENY`, nosniff, HSTS; errorHandler suprime `cause` em produção. +- **CORS:** allowlist explícita, fail-closed em produção; `credentials: true` só com allowlist (sem `*`). +- **Frontend:** tokens **não** em localStorage/sessionStorage (cookie HttpOnly); sem `dangerouslySetInnerHTML`/`eval`; `FormattedJobDescription` sanitiza HTML de scraper; `rel="noopener noreferrer"`; sem secrets no bundle. +- **Electron endurecido:** `contextIsolation:true`, `nodeIntegration:false`, `sandbox:true`; sem conteúdo remoto; `preload.js` não expõe nada; CSP estrito em `loading.html`. +- **Segredos:** `.env` **não** versionado nem no histórico git; `.env.example` sem secrets reais; `.env` montado read-only no scraper-go; `ENCRYPTION_MASTER_KEY`/`SEARCH_KEY` falham fechado se ausentes/inválidos. +- **CI:** usa `on: pull_request` (não `pull_request_target`), então PRs de fork não recebem secrets. +- **Proxy Vercel não é open proxy:** destino `https://jobsglobalscraper.ddns.net` hardcoded; só o path é influenciável. + +--- + +## 6. Priorização sugerida + +1. **Imediato:** SEC-01 (takeover OAuth), SEC-08 + SEC-09 (XSS href + admin sem CSP — corrigir juntos), SEC-06 (Grafana admin/admin), SEC-07 (xlsx). +2. **Curto prazo:** SEC-03 (tokens em texto puro), SEC-04 (revogação de sessão), SEC-05 (CSRF/SameSite), SEC-02 (auth backend↔scraper). +3. **Médio prazo:** SEC-10 a SEC-22. +4. **Hardening contínuo:** SEC-23 a SEC-39. + +--- + +> **Nota de processo:** esta atividade não implementou correções, não abriu PRs, não alterou o ambiente MASTER e não criou cards no Linear. Este README é o único artefato de saída e serve como fonte para a criação manual dos cards (time Painel Vagas / PAV). diff --git a/backend/src/app.ts b/backend/src/app.ts index 6400d934..a7566297 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,6 +13,7 @@ import adminRoutes from "./routes/admin.routes"; import { jobsRoutes } from "./routes/jobs.routes"; import { keywordsRoutes } from "./routes/keywords.routes"; import { notificationsRoutes } from "./routes/notifications.routes"; +import { resumeRoutes } from "./routes/resume.routes"; import { savedJobsRoutes } from "./routes/savedJobs.routes"; import superAdminRoutes from "./routes/superAdmin.routes"; import supportRoutes from "./routes/support.routes"; @@ -44,6 +45,7 @@ export function createJobsApiApp() { apiV1.use("/keywords", withSession, requireAuth, keywordsRoutes); apiV1.use("/notifications", withSession, requireAuth, notificationsRoutes); apiV1.use("/saved-jobs", withSession, requireAuth, savedJobsRoutes); + apiV1.use("/resume", withSession, requireAuth, resumeRoutes); apiV1.use("/admin", withSession, supportRoutes); apiV1.use("/admin", withSession, adminRoutes); apiV1.use("/admin", withSession, superAdminRoutes); @@ -57,6 +59,7 @@ export function createJobsApiApp() { app.use("/keywords", withSession, requireAuth, keywordsRoutes); app.use("/notifications", withSession, requireAuth, notificationsRoutes); app.use("/saved-jobs", withSession, requireAuth, savedJobsRoutes); + app.use("/resume", withSession, requireAuth, resumeRoutes); app.use("/admin", withSession, supportRoutes); app.use("/admin", withSession, adminRoutes); app.use("/admin", withSession, superAdminRoutes); diff --git a/backend/src/config.ts b/backend/src/config.ts index 7ebf7ea2..ebc4a4c8 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -36,6 +36,8 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean { export const config = { scraperUrl: process.env.SCRAPER_URL ?? "http://scraper-go:8081", prometheusUrl: process.env.PROMETHEUS_URL ?? "http://prometheus:9090", + atsForgeUrl: process.env.ATS_FORGE_URL ?? "http://ats-forge:8089", + atsForgeApiKey: process.env.ATS_FORGE_API_KEY?.trim() ?? "", }; function parseNumber(value: string | undefined, fallback: number): number { diff --git a/backend/src/modules/resume/resume.client.ts b/backend/src/modules/resume/resume.client.ts new file mode 100644 index 00000000..c3ffd633 --- /dev/null +++ b/backend/src/modules/resume/resume.client.ts @@ -0,0 +1,112 @@ +import { config } from "../../config"; +import { AppError } from "../../lib/errors"; +import type { + AtsReport, + GeneratedResume, + JobTarget, + NormalizedProfile, + ResumeAnalysis, + ResumeFormat, + ResumeSources, +} from "./resume.types"; + +// GitHub enrichment happens inside ats-forge, so allow a longer budget. +const TIMEOUT_MS = 20000; + +export interface GenerateResumeRequest { + profile: NormalizedProfile; + job?: JobTarget | null; + sources?: ResumeSources | null; + about?: string | null; + format: ResumeFormat; + filename?: string; +} + +function decodeReport(headerValue: string | null): AtsReport | null { + if (!headerValue) return null; + try { + const json = Buffer.from(headerValue, "base64").toString("utf-8"); + return JSON.parse(json) as AtsReport; + } catch { + return null; + } +} + +function filenameFromDisposition( + disposition: string | null, + fallback: string, +): string { + if (!disposition) return fallback; + const match = /filename="?([^"]+)"?/i.exec(disposition); + return match?.[1] ?? fallback; +} + +/** + * Thin HTTP client for the ats-forge resume microservice. Mirrors the + * `scraperClient` pattern: a single `request` helper with a timeout that maps + * transport/HTTP failures onto `AppError`. + */ +function buildHeaders(): Record { + const headers: Record = { "Content-Type": "application/json" }; + if (config.atsForgeApiKey) headers["x-api-key"] = config.atsForgeApiKey; + return headers; +} + +async function postToAtsForge( + path: string, + payload: GenerateResumeRequest, +): Promise { + let response: Response; + try { + response = await fetch(`${config.atsForgeUrl}${path}`, { + method: "POST", + headers: buildHeaders(), + body: JSON.stringify(payload), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (err) { + throw AppError.internal( + "Não foi possível contatar o serviço de geração de currículos.", + { cause: (err as Error).message }, + ); + } + + if (response.status === 400) { + const body = await response.json().catch(() => null); + throw AppError.validation( + body?.message ?? "Dados insuficientes para gerar o currículo.", + body?.details, + ); + } + + if (!response.ok) { + throw AppError.internal( + `Falha ao gerar currículo (ats-forge respondeu HTTP ${response.status}).`, + ); + } + + return response; +} + +export const resumeClient = { + async generate(payload: GenerateResumeRequest): Promise { + const response = await postToAtsForge("/resumes/generate", payload); + + const arrayBuffer = await response.arrayBuffer(); + return { + content: Buffer.from(arrayBuffer), + contentType: + response.headers.get("content-type") ?? "application/octet-stream", + filename: filenameFromDisposition( + response.headers.get("content-disposition"), + `${payload.filename ?? "curriculo"}.${payload.format}`, + ), + atsReport: decodeReport(response.headers.get("x-ats-report")), + }; + }, + + async analyze(payload: GenerateResumeRequest): Promise { + const response = await postToAtsForge("/resumes/analyze", payload); + return (await response.json()) as ResumeAnalysis; + }, +}; diff --git a/backend/src/modules/resume/resume.controller.ts b/backend/src/modules/resume/resume.controller.ts new file mode 100644 index 00000000..489f4fa8 --- /dev/null +++ b/backend/src/modules/resume/resume.controller.ts @@ -0,0 +1,103 @@ +import { Request, Response } from "express"; +import { getIronSession } from "iron-session"; +import { AppError } from "../../lib/errors"; +import { sessionOptions } from "../../lib/session"; +import { Session } from "../types/auth.types"; +import { ResumeService } from "./resume.service"; +import type { JobTarget } from "./resume.types"; + +export class ResumeController { + constructor(private readonly service: ResumeService) {} + + private async getSession(req: Request, res: Response) { + return getIronSession(req, res, sessionOptions); + } + + private async requireUserId(req: Request, res: Response): Promise { + const session = await this.getSession(req, res); + if (!session.userId) { + throw AppError.unauthorized(); + } + return session.userId; + } + + private parseParams(req: Request) { + const { + format, + jobTitle, + jobDescription, + jobUrl, + language, + githubUrl, + linkedinUrl, + about, + experiences, + } = req.body as { + format: "docx" | "pdf" | "md"; + jobTitle?: string; + jobDescription?: string; + jobUrl?: string; + language?: string; + githubUrl?: string; + linkedinUrl?: string; + about?: string; + experiences?: Array<{ + company: string; + role: string; + period?: string; + description?: string; + stack?: string[]; + }>; + }; + + const job: JobTarget | null = + jobTitle || jobDescription || jobUrl + ? { title: jobTitle, description: jobDescription, url: jobUrl, language } + : null; + + const sources = + githubUrl || linkedinUrl + ? { github: githubUrl, linkedin: linkedinUrl } + : null; + + return { + format, + job, + sources, + about: about ?? null, + experiences: experiences ?? null, + }; + } + + // POST /resume/analyze + async analyze(req: Request, res: Response) { + const userId = await this.requireUserId(req, res); + const analysis = await this.service.analyzeForUser(userId, this.parseParams(req)); + return res.status(200).json(analysis); + } + + // POST /resume/generate + async generate(req: Request, res: Response) { + const userId = await this.requireUserId(req, res); + const resume = await this.service.generateForUser(userId, this.parseParams(req)); + + res.setHeader("Content-Type", resume.contentType); + res.setHeader( + "Content-Disposition", + `attachment; filename="${resume.filename}"`, + ); + if (resume.atsReport) { + res.setHeader("X-Ats-Score", String(resume.atsReport.score)); + res.setHeader( + "X-Ats-Report", + Buffer.from(JSON.stringify(resume.atsReport), "utf-8").toString("base64"), + ); + res.setHeader( + "Access-Control-Expose-Headers", + "X-Ats-Score, X-Ats-Report, Content-Disposition", + ); + } + + return res.status(200).send(resume.content); + } +} diff --git a/backend/src/modules/resume/resume.mapper.ts b/backend/src/modules/resume/resume.mapper.ts new file mode 100644 index 00000000..7a655b7d --- /dev/null +++ b/backend/src/modules/resume/resume.mapper.ts @@ -0,0 +1,77 @@ +import type { PublicUser } from "../users/users.mapper"; +import type { NormalizedProfile } from "./resume.types"; + +type TechExperience = { name: string; years: number }; + +function isTechExperience(value: unknown): value is TechExperience { + return ( + typeof value === "object" && + value !== null && + typeof (value as TechExperience).name === "string" && + typeof (value as TechExperience).years === "number" + ); +} + +function resolveName(user: PublicUser): string { + if (user.displayName?.trim()) return user.displayName.trim(); + const full = [user.firstName, user.lastName] + .filter((p): p is string => Boolean(p && p.trim())) + .join(" ") + .trim(); + if (full) return full; + if (user.username?.trim()) return user.username.trim(); + return "Candidato"; +} + +/** + * Maps the candidate's decrypted profile (the `users` row) into the normalized + * profile the ats-forge engine consumes. Only data the candidate actually + * provided is forwarded — every field is tagged `source: "candidate"` and + * nothing is fabricated (integration spec §7). + */ +export function toNormalizedProfile(user: PublicUser): NormalizedProfile { + const techExperiences: TechExperience[] = Array.isArray(user.technologyExperiences) + ? user.technologyExperiences.filter(isTechExperience) + : []; + const yearsByTech = new Map( + techExperiences.map((t) => [t.name.toLowerCase(), t.years]), + ); + + const technologies = Array.isArray(user.technologies) ? user.technologies : []; + + const skills: NormalizedProfile["skills"] = technologies.map((name) => ({ + name, + years: yearsByTech.get(name.toLowerCase()), + category: "Competências", + source: "candidate" as const, + })); + + // Include tech experiences that are not already covered by `technologies`. + for (const te of techExperiences) { + if (!technologies.some((t) => t.toLowerCase() === te.name.toLowerCase())) { + skills.push({ + name: te.name, + years: te.years, + category: "Competências", + source: "candidate", + }); + } + } + + return { + name: resolveName(user), + headline: user.level?.trim() || undefined, + // Summary intentionally omitted: the engine derives an honest summary from + // the candidate's own evidence instead of inventing one. + contact: { + email: user.email?.trim() || undefined, + phone: user.phone?.trim() || undefined, + }, + experience: [], + education: [], + skills, + projects: [], + links: [], + languages: [], + }; +} diff --git a/backend/src/modules/resume/resume.service.ts b/backend/src/modules/resume/resume.service.ts new file mode 100644 index 00000000..9a7ce046 --- /dev/null +++ b/backend/src/modules/resume/resume.service.ts @@ -0,0 +1,101 @@ +import { db } from "../../db/client"; +import { DB } from "../../db/types/types"; +import { AppError } from "../../lib/errors"; +import { UsersRepository } from "../users/users.repository"; +import { resumeClient } from "./resume.client"; +import { toNormalizedProfile } from "./resume.mapper"; +import type { + GeneratedResume, + JobTarget, + NormalizedProfile, + ResumeAnalysis, + ResumeFormat, + ResumeSources, +} from "./resume.types"; + +export interface ExperienceEntry { + company: string; + role: string; + period?: string; + description?: string; + stack?: string[]; +} + +export interface GenerateResumeParams { + format: ResumeFormat; + job?: JobTarget | null; + sources?: ResumeSources | null; + about?: string | null; + experiences?: ExperienceEntry[] | null; +} + +export class ResumeService { + constructor(private readonly tx: DB = db) {} + + /** + * Builds a resume for the authenticated candidate. The candidate is always + * resolved from the session-derived `userId` — never from client input — so a + * candidate can only ever generate their own resume (spec §19, anti-IDOR). + */ + private async buildProfile( + userId: string, + params: GenerateResumeParams, + ): Promise { + const user = await new UsersRepository(this.tx).findById(userId); + if (!user) { + throw AppError.notFound("Usuário não encontrado"); + } + + const profile = toNormalizedProfile(user); + + // Real professional experience provided by the candidate (e.g. the jobs they + // hold on LinkedIn). Mapped as `source: "manual"` — never fabricated. + const experiences = params.experiences ?? []; + if (experiences.length > 0) { + profile.experience = experiences.map((exp) => ({ + company: exp.company, + role: exp.role, + period: exp.period, + stack: exp.stack, + highlights: exp.description + ? exp.description + .split(/\r?\n/) + .map((line) => line.replace(/^[-•*]\s*/, "").trim()) + .filter(Boolean) + : [], + source: "manual" as const, + })); + } + + return profile; + } + + async generateForUser( + userId: string, + params: GenerateResumeParams, + ): Promise { + const profile = await this.buildProfile(userId, params); + return resumeClient.generate({ + profile, + job: params.job ?? null, + sources: params.sources ?? null, + about: params.about ?? null, + format: params.format, + }); + } + + /** Same inputs as generate, but returns a JSON preview + ATS analysis. */ + async analyzeForUser( + userId: string, + params: GenerateResumeParams, + ): Promise { + const profile = await this.buildProfile(userId, params); + return resumeClient.analyze({ + profile, + job: params.job ?? null, + sources: params.sources ?? null, + about: params.about ?? null, + format: params.format, + }); + } +} diff --git a/backend/src/modules/resume/resume.types.ts b/backend/src/modules/resume/resume.types.ts new file mode 100644 index 00000000..5fb3936a --- /dev/null +++ b/backend/src/modules/resume/resume.types.ts @@ -0,0 +1,130 @@ +/** + * HTTP contract shared with the ats-forge resume microservice. Kept as a local + * type (not a cross-repo import) because ats-forge is an independent backend + * reachable only over HTTP — we depend on its API shape, not its code. + */ + +export type ResumeFormat = "docx" | "pdf" | "md"; + +export type DataSource = "github" | "linkedin" | "candidate" | "manual"; + +export interface NormalizedProfile { + name: string; + headline?: string; + summary?: string; + contact: { + email?: string; + phone?: string; + location?: string; + website?: string; + }; + experience: Array<{ + company: string; + role: string; + period?: string; + startDate?: string; + endDate?: string; + current?: boolean; + stack?: string[]; + highlights?: string[]; + results?: string[]; + source?: DataSource; + }>; + education: Array<{ + institution: string; + degree?: string; + field?: string; + period?: string; + source?: DataSource; + }>; + skills: Array<{ + name: string; + years?: number; + category?: string; + source?: DataSource; + }>; + projects: Array<{ + name: string; + description?: string; + url?: string; + stack?: string[]; + highlights?: string[]; + source?: DataSource; + }>; + links: Array<{ type: string; url: string; label?: string }>; + languages: Array<{ name: string; level?: string }>; +} + +export interface JobTarget { + title?: string; + description?: string; + url?: string; + language?: string; + seniority?: string; +} + +export interface ResumeSources { + github?: string; + linkedin?: string; +} + +export type AtsStatus = "PASSED" | "NEEDS_IMPROVEMENT" | "INSUFFICIENT_DATA"; + +export interface AtsReport { + score: number; + status?: AtsStatus; + breakdown: { + keywords: number; + experience: number; + technicalSkills: number; + structure: number; + achievements: number; + readability: number; + }; + matchedKeywords: string[]; + missingKeywords: string[]; + weakSections?: string[]; + recommendations?: string[]; + warnings?: string[]; +} + +export interface GeneratedResume { + content: Buffer; + contentType: string; + filename: string; + atsReport: AtsReport | null; +} + +export interface ResumePreview { + name: string; + title: string; + summary: string; + contact: { email: string; phone: string; portfolio: string }; + links: { linkedin: string; github: string }; + skills: Record; + experience: Array<{ + empresa: string; + cargo: string; + periodo: string; + stack: string; + atividades: string[]; + resultados: string[]; + }>; + projects: Array<{ + name: string; + stack: string; + description: string; + highlights: string[]; + url?: string; + }>; + education: string[]; + languages: string[]; +} + +export interface ResumeAnalysis { + resume: ResumePreview; + atsReport: AtsReport; + warnings: string[]; + sourcesUsed: string[]; + job: { title: string | null; hasDescription: boolean }; +} diff --git a/backend/src/modules/resume/schemas/resume.schemas.ts b/backend/src/modules/resume/schemas/resume.schemas.ts new file mode 100644 index 00000000..05bb40bc --- /dev/null +++ b/backend/src/modules/resume/schemas/resume.schemas.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +const experienceSchema = z.object({ + company: z.string().min(1).max(200), + role: z.string().min(1).max(200), + period: z.string().max(100).optional(), + description: z.string().max(2000).optional(), + stack: z.array(z.string().max(80)).max(40).optional(), +}); + +export const generateResumeSchema = z.object({ + format: z.enum(["docx", "pdf", "md"]).default("docx"), + jobTitle: z.string().max(200).optional(), + // Capped below the app-wide 16kb JSON body limit (see app.ts). + jobDescription: z.string().max(12000).optional(), + jobUrl: z.string().url().max(500).optional(), + language: z.string().max(10).optional(), + githubUrl: z.string().max(300).optional(), + linkedinUrl: z.string().max(300).optional(), + // "Sobre" do LinkedIn colado pelo candidato (base do resumo profissional). + about: z.string().max(4000).optional(), + experiences: z.array(experienceSchema).max(20).optional(), +}); + +export type ExperienceInput = z.infer; + +export type GenerateResumeData = z.infer; diff --git a/backend/src/routes/resume.routes.ts b/backend/src/routes/resume.routes.ts new file mode 100644 index 00000000..d66e6cf0 --- /dev/null +++ b/backend/src/routes/resume.routes.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; +import { validate } from "../middleware/validate"; +import { ResumeController } from "../modules/resume/resume.controller"; +import { ResumeService } from "../modules/resume/resume.service"; +import { generateResumeSchema } from "../modules/resume/schemas/resume.schemas"; + +const router = Router(); +const resumeService = new ResumeService(); +const resumeController = new ResumeController(resumeService); + +router.post( + "/generate", + validate({ body: generateResumeSchema }), + (req, res, next) => { + resumeController.generate(req, res).catch(next); + }, +); + +router.post( + "/analyze", + validate({ body: generateResumeSchema }), + (req, res, next) => { + resumeController.analyze(req, res).catch(next); + }, +); + +export { router as resumeRoutes }; diff --git a/backend/src/scripts/backfillUserPii.ts b/backend/src/scripts/backfillUserPii.ts index bbedf364..2fb79096 100644 --- a/backend/src/scripts/backfillUserPii.ts +++ b/backend/src/scripts/backfillUserPii.ts @@ -154,7 +154,7 @@ async function main() { ); } -main().catch((error) => { +export const completion = main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); diff --git a/backend/src/scripts/seed.ts b/backend/src/scripts/seed.ts index 98af8f7e..03385227 100644 --- a/backend/src/scripts/seed.ts +++ b/backend/src/scripts/seed.ts @@ -267,7 +267,7 @@ async function main() { console.log("Seed concluído."); } -main() +export const completion = main() .catch((error) => { console.error("Falha ao rodar o seed:", error); process.exitCode = 1; diff --git a/backend/tests/integration/routes/resume.routes.test.ts b/backend/tests/integration/routes/resume.routes.test.ts new file mode 100644 index 00000000..f025affd --- /dev/null +++ b/backend/tests/integration/routes/resume.routes.test.ts @@ -0,0 +1,195 @@ +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../src/lib/errors"; + +// ── ResumeService mock ──────────────────────────────────────────────────────── + +const mockResumeService = vi.hoisted(() => ({ + generateForUser: vi.fn(), + analyzeForUser: vi.fn(), +})); + +vi.mock("../../../src/modules/resume/resume.service", () => ({ + ResumeService: class { + constructor() { + return mockResumeService; + } + }, +})); + +// ── iron-session ────────────────────────────────────────────────────────────── + +vi.mock("iron-session", () => ({ + getIronSession: vi.fn(), +})); + +import { getIronSession } from "iron-session"; +import { createJobsApiApp } from "../../../src/app"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const fixtureSession = { + userId: "user_abc", + save: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), +}; + +const fixtureAtsReport = { + score: 82, + breakdown: { + keywordCoverage: 80, + skillsMatch: 90, + completeness: 75, + structure: 100, + }, + matchedKeywords: ["node.js", "typescript"], + missingKeywords: ["kubernetes"], + suggestions: ["Adicione um resumo profissional."], +}; + +const fixtureResume = { + content: Buffer.from("%PDF-1.3 fake"), + contentType: "application/pdf", + filename: "Ana_Souza.pdf", + atsReport: fixtureAtsReport, +}; + +// ───────────────────────────────────────────────────────────────────────────── + +describe("Integration - Resume Routes", () => { + let app: ReturnType; + const BASE = "/resume"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getIronSession).mockResolvedValue(fixtureSession as any); + mockResumeService.generateForUser.mockResolvedValue(fixtureResume); + mockResumeService.analyzeForUser.mockResolvedValue({ + resume: { name: "Ana Souza", skills: {} }, + atsReport: fixtureAtsReport, + warnings: [], + sourcesUsed: ["candidate", "github"], + job: { title: "Backend", hasDescription: true }, + }); + app = createJobsApiApp(); + }); + + describe("POST /analyze", () => { + it("retorna 200 com o preview + análise ATS", async () => { + const res = await request(app) + .post(`${BASE}/analyze`) + .send({ format: "pdf", jobTitle: "Backend", jobDescription: "node.js" }) + .expect(200); + + expect(res.body.resume.name).toBe("Ana Souza"); + expect(res.body.atsReport.score).toBe(82); + expect(res.body.sourcesUsed).toContain("github"); + expect(mockResumeService.analyzeForUser).toHaveBeenCalledWith( + "user_abc", + expect.objectContaining({ format: "pdf" }), + ); + }); + + it("retorna 401 sem sessão", async () => { + vi.mocked(getIronSession).mockResolvedValueOnce({ userId: undefined } as any); + await request(app).post(`${BASE}/analyze`).send({ format: "pdf" }).expect(401); + }); + }); + + describe("POST /generate", () => { + it("gera o currículo e retorna 200 com o arquivo", async () => { + const res = await request(app) + .post(`${BASE}/generate`) + .send({ format: "pdf" }) + .expect(200); + + expect(res.headers["content-type"]).toContain("application/pdf"); + expect(res.headers["content-disposition"]).toContain("Ana_Souza.pdf"); + expect(res.headers["x-ats-score"]).toBe("82"); + expect(res.headers["x-ats-report"]).toBeDefined(); + }); + + it("chama o service com o userId da sessão (isolamento por candidato)", async () => { + await request(app) + .post(`${BASE}/generate`) + .send({ format: "docx", jobTitle: "Backend", jobDescription: "Node.js" }) + .expect(200); + + expect(mockResumeService.generateForUser).toHaveBeenCalledWith( + "user_abc", + expect.objectContaining({ + format: "docx", + job: expect.objectContaining({ + title: "Backend", + description: "Node.js", + }), + }), + ); + }); + + it("encaminha os links de GitHub/LinkedIn como sources", async () => { + await request(app) + .post(`${BASE}/generate`) + .send({ + format: "pdf", + githubUrl: "https://github.com/ana", + linkedinUrl: "https://www.linkedin.com/in/ana", + }) + .expect(200); + + expect(mockResumeService.generateForUser).toHaveBeenCalledWith( + "user_abc", + expect.objectContaining({ + sources: { + github: "https://github.com/ana", + linkedin: "https://www.linkedin.com/in/ana", + }, + }), + ); + }); + + it("usa formato docx como padrão quando não informado", async () => { + await request(app).post(`${BASE}/generate`).send({}).expect(200); + + expect(mockResumeService.generateForUser).toHaveBeenCalledWith( + "user_abc", + expect.objectContaining({ format: "docx", job: null }), + ); + }); + + it("retorna 400 para formato inválido (Zod)", async () => { + const res = await request(app) + .post(`${BASE}/generate`) + .send({ format: "xlsx" }) + .expect(400); + + expect(res.body.code).toBe("VALIDATION_ERROR"); + }); + + it("retorna 401 quando a sessão não tem userId", async () => { + vi.mocked(getIronSession).mockResolvedValueOnce({ + userId: undefined, + } as any); + + await request(app).post(`${BASE}/generate`).send({ format: "pdf" }).expect(401); + + expect(mockResumeService.generateForUser).not.toHaveBeenCalled(); + }); + + it("retorna 404 quando o usuário não existe", async () => { + mockResumeService.generateForUser.mockRejectedValueOnce( + AppError.notFound("Usuário não encontrado"), + ); + + await request(app).post(`${BASE}/generate`).send({ format: "pdf" }).expect(404); + }); + + it("propaga 500 quando o ats-forge está indisponível", async () => { + mockResumeService.generateForUser.mockRejectedValueOnce( + AppError.internal("Não foi possível contatar o serviço de geração de currículos."), + ); + + await request(app).post(`${BASE}/generate`).send({ format: "pdf" }).expect(500); + }); + }); +}); diff --git a/backend/tests/unit/resume/resume.client.test.ts b/backend/tests/unit/resume/resume.client.test.ts new file mode 100644 index 00000000..0fea64d8 --- /dev/null +++ b/backend/tests/unit/resume/resume.client.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { isAppError } from "../../../src/lib/errors"; +import type { NormalizedProfile } from "../../../src/modules/resume/resume.types"; + +const mockConfig = vi.hoisted(() => ({ + atsForgeUrl: "http://ats-forge:8089", + atsForgeApiKey: "", +})); + +vi.mock("../../../src/config", () => ({ config: mockConfig })); + +import { resumeClient } from "../../../src/modules/resume/resume.client"; + +const profile: NormalizedProfile = { + name: "Ana", + contact: {}, + experience: [], + education: [], + skills: [], + projects: [], + links: [], + languages: [], +}; + +function makeResponse( + opts: Partial<{ + ok: boolean; + status: number; + headers: Record; + json: unknown; + buffer: Buffer; + }>, +): Response { + const headers = new Map( + Object.entries(opts.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]), + ); + return { + ok: opts.ok ?? true, + status: opts.status ?? 200, + headers: { get: (k: string) => headers.get(k.toLowerCase()) ?? null }, + json: () => Promise.resolve(opts.json ?? {}), + arrayBuffer: () => + Promise.resolve( + (opts.buffer ?? Buffer.from("%PDF-1.3")).buffer.slice(0), + ), + } as unknown as Response; +} + +const report = { score: 88, matchedKeywords: ["node.js"] }; +const reportB64 = Buffer.from(JSON.stringify(report), "utf-8").toString("base64"); + +beforeEach(() => { + mockConfig.atsForgeApiKey = ""; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("resumeClient.generate", () => { + it("retorna conteúdo, contentType, filename e atsReport no sucesso", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + makeResponse({ + headers: { + "content-type": "application/pdf", + "content-disposition": 'attachment; filename="Ana.pdf"', + "x-ats-report": reportB64, + }, + buffer: Buffer.from("%PDF-1.3 data"), + }), + ), + ), + ); + + const result = await resumeClient.generate({ profile, format: "pdf" }); + + expect(result.contentType).toBe("application/pdf"); + expect(result.filename).toBe("Ana.pdf"); + expect(result.atsReport?.score).toBe(88); + expect(Buffer.isBuffer(result.content)).toBe(true); + }); + + it("usa fallback de filename e contentType quando headers ausentes", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(makeResponse({ headers: {} }))), + ); + + const result = await resumeClient.generate({ profile, format: "docx" }); + expect(result.filename).toBe("curriculo.docx"); + expect(result.contentType).toBe("application/octet-stream"); + expect(result.atsReport).toBeNull(); + }); + + it("retorna atsReport null quando o header é base64 inválido", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(makeResponse({ headers: { "x-ats-report": "@@nao-base64@@" } })), + ), + ); + const result = await resumeClient.generate({ profile, format: "pdf" }); + expect(result.atsReport).toBeNull(); + }); + + it("envia o header x-api-key quando configurado", async () => { + mockConfig.atsForgeApiKey = "secret"; + const fetchMock = vi.fn(() => Promise.resolve(makeResponse({ headers: {} }))); + vi.stubGlobal("fetch", fetchMock); + + await resumeClient.generate({ profile, format: "pdf" }); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect((init.headers as Record)["x-api-key"]).toBe("secret"); + }); + + it("mapeia HTTP 400 para AppError de validação", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + makeResponse({ ok: false, status: 400, json: { message: "Perfil inválido", details: {} } }), + ), + ), + ); + + await expect(resumeClient.generate({ profile, format: "pdf" })).rejects.toSatisfy( + (e: unknown) => isAppError(e) && e.statusCode === 400, + ); + }); + + it("usa mensagem padrão no 400 quando o corpo não é JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: false, + status: 400, + headers: { get: () => null }, + json: () => Promise.reject(new Error("not json")), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + } as unknown as Response), + ), + ); + await expect(resumeClient.generate({ profile, format: "pdf" })).rejects.toSatisfy( + (e: unknown) => isAppError(e) && e.statusCode === 400, + ); + }); + + it("cai no filename de fallback quando o Content-Disposition não traz filename", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(makeResponse({ headers: { "content-disposition": "attachment" } })), + ), + ); + const result = await resumeClient.generate({ profile, format: "md" }); + expect(result.filename).toBe("curriculo.md"); + }); + + it("analyze retorna o JSON de análise do ats-forge", async () => { + const analysis = { resume: { name: "Ana" }, atsReport: { score: 90 }, sourcesUsed: ["candidate"] }; + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(makeResponse({ headers: {}, json: analysis })), + ), + ); + + const result = await resumeClient.analyze({ profile, format: "pdf" }); + expect(result).toEqual(analysis); + }); + + it("analyze propaga erro de validação (400)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(makeResponse({ ok: false, status: 400, json: { message: "x" } }))), + ); + await expect(resumeClient.analyze({ profile, format: "pdf" })).rejects.toSatisfy( + (e: unknown) => isAppError(e) && e.statusCode === 400, + ); + }); + + it("mapeia HTTP não-ok para AppError interno", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(makeResponse({ ok: false, status: 503 }))), + ); + + await expect(resumeClient.generate({ profile, format: "pdf" })).rejects.toSatisfy( + (e: unknown) => isAppError(e) && e.statusCode === 500, + ); + }); + + it("mapeia falha de rede para AppError interno", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("ECONNREFUSED"))), + ); + + await expect(resumeClient.generate({ profile, format: "pdf" })).rejects.toSatisfy( + (e: unknown) => isAppError(e) && e.statusCode === 500, + ); + }); +}); diff --git a/backend/tests/unit/resume/resume.controller.test.ts b/backend/tests/unit/resume/resume.controller.test.ts new file mode 100644 index 00000000..67a40af3 --- /dev/null +++ b/backend/tests/unit/resume/resume.controller.test.ts @@ -0,0 +1,108 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { isAppError } from "../../../src/lib/errors"; + +vi.mock("iron-session", () => ({ getIronSession: vi.fn() })); + +import { getIronSession } from "iron-session"; +import { ResumeController } from "../../../src/modules/resume/resume.controller"; + +function makeRes() { + const res = { + setHeader: vi.fn(), + status: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + return res as unknown as Response & { + setHeader: ReturnType; + status: ReturnType; + send: ReturnType; + json: ReturnType; + }; +} + +const baseGenerated = { + content: Buffer.from("%PDF"), + contentType: "application/pdf", + filename: "Ana.pdf", + atsReport: { score: 90 }, +}; + +describe("ResumeController.generate", () => { + const service = { generateForUser: vi.fn(), analyzeForUser: vi.fn() }; + const controller = new ResumeController(service as never); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getIronSession).mockResolvedValue({ userId: "u1" } as never); + service.generateForUser.mockResolvedValue(baseGenerated); + service.analyzeForUser.mockResolvedValue({ resume: { name: "Ana" }, atsReport: { score: 90 } }); + }); + + it("analyze retorna o JSON da análise", async () => { + const res = makeRes(); + await controller.analyze( + { body: { format: "pdf", jobTitle: "Backend" } } as Request, + res, + ); + expect(res.status).toHaveBeenCalledWith(200); + const arg = service.analyzeForUser.mock.calls[0][1]; + expect(arg.job).toMatchObject({ title: "Backend" }); + }); + + it("analyze exige autenticação", async () => { + vi.mocked(getIronSession).mockResolvedValueOnce({} as never); + const res = makeRes(); + await expect( + controller.analyze({ body: {} } as Request, res), + ).rejects.toSatisfy((e: unknown) => isAppError(e) && e.statusCode === 401); + }); + + it("lança unauthorized quando a sessão não tem userId", async () => { + vi.mocked(getIronSession).mockResolvedValueOnce({} as never); + const res = makeRes(); + await expect( + controller.generate({ body: {} } as Request, res), + ).rejects.toSatisfy((e: unknown) => isAppError(e) && e.statusCode === 401); + expect(service.generateForUser).not.toHaveBeenCalled(); + }); + + it("gera e envia o arquivo com headers de ATS score", async () => { + const res = makeRes(); + await controller.generate( + { + body: { + format: "pdf", + jobTitle: "Backend", + githubUrl: "https://github.com/ana", + experiences: [{ company: "A", role: "Dev" }], + }, + } as Request, + res, + ); + + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/pdf"); + expect(res.setHeader).toHaveBeenCalledWith("X-Ats-Score", "90"); + expect(res.send).toHaveBeenCalledWith(baseGenerated.content); + + const arg = service.generateForUser.mock.calls[0][1]; + expect(arg.job).toMatchObject({ title: "Backend" }); + expect(arg.sources).toMatchObject({ github: "https://github.com/ana" }); + expect(arg.experiences).toHaveLength(1); + }); + + it("não define headers de score quando não há atsReport", async () => { + service.generateForUser.mockResolvedValueOnce({ ...baseGenerated, atsReport: null }); + const res = makeRes(); + await controller.generate({ body: { format: "docx" } } as Request, res); + + const scoreCall = res.setHeader.mock.calls.find((c) => c[0] === "X-Ats-Score"); + expect(scoreCall).toBeUndefined(); + + const arg = service.generateForUser.mock.calls[0][1]; + expect(arg.job).toBeNull(); + expect(arg.sources).toBeNull(); + expect(arg.experiences).toBeNull(); + }); +}); diff --git a/backend/tests/unit/resume/resume.mapper.test.ts b/backend/tests/unit/resume/resume.mapper.test.ts new file mode 100644 index 00000000..9ac608e6 --- /dev/null +++ b/backend/tests/unit/resume/resume.mapper.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { toNormalizedProfile } from "../../../src/modules/resume/resume.mapper"; +import type { PublicUser } from "../../../src/modules/users/users.mapper"; + +function baseUser(overrides: Partial = {}): PublicUser { + return { + id: "user_1", + firstName: "Ana", + lastName: "Souza", + displayName: null, + username: "ana", + email: "ana@example.com", + emailVerified: true, + avatarUrl: null, + phone: "+55 11 90000-0000", + cpf: null, + technologies: ["TypeScript", "Node.js"], + technologyExperiences: [{ name: "TypeScript", years: 5 }], + level: "Pleno", + role: "user", + isBlocked: false, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, + ...overrides, + } as unknown as PublicUser; +} + +describe("toNormalizedProfile", () => { + it("resolve o nome a partir de firstName + lastName quando não há displayName", () => { + const profile = toNormalizedProfile(baseUser()); + expect(profile.name).toBe("Ana Souza"); + }); + + it("prefere displayName quando presente", () => { + const profile = toNormalizedProfile(baseUser({ displayName: "Ana S." })); + expect(profile.name).toBe("Ana S."); + }); + + it("mapeia contato, headline (level) e skills com anos de experiência", () => { + const profile = toNormalizedProfile(baseUser()); + expect(profile.contact.email).toBe("ana@example.com"); + expect(profile.contact.phone).toBe("+55 11 90000-0000"); + expect(profile.headline).toBe("Pleno"); + + const ts = profile.skills.find((s) => s.name === "TypeScript"); + expect(ts?.years).toBe(5); + expect(profile.skills.map((s) => s.name)).toContain("Node.js"); + }); + + it("não inventa experiência, formação nem projetos", () => { + const profile = toNormalizedProfile(baseUser()); + expect(profile.experience).toEqual([]); + expect(profile.education).toEqual([]); + expect(profile.projects).toEqual([]); + expect(profile.summary).toBeUndefined(); + }); + + it("usa fallback 'Candidato' quando não há nome nem username", () => { + const profile = toNormalizedProfile( + baseUser({ + firstName: null, + lastName: null, + displayName: null, + username: null, + }), + ); + expect(profile.name).toBe("Candidato"); + }); + + it("inclui tech experiences não presentes em technologies", () => { + const profile = toNormalizedProfile( + baseUser({ + technologies: ["TypeScript"], + technologyExperiences: [{ name: "Go", years: 2 }], + }), + ); + const go = profile.skills.find((s) => s.name === "Go"); + expect(go?.years).toBe(2); + }); + + it("usa o username quando não há nome completo nem displayName", () => { + const profile = toNormalizedProfile( + baseUser({ firstName: null, lastName: null, displayName: null, username: "ana" }), + ); + expect(profile.name).toBe("ana"); + }); + + it("ignora technologyExperiences inválidas e arrays nulos", () => { + const profile = toNormalizedProfile( + baseUser({ + technologies: null as unknown as string[], + technologyExperiences: [ + { name: "X" } as unknown as { name: string; years: number }, + null as unknown as { name: string; years: number }, + { name: "Node.js", years: 3 }, + ], + }), + ); + expect(profile.skills.map((s) => s.name)).toEqual(["Node.js"]); + }); + + it("omite headline, email e phone quando ausentes", () => { + const profile = toNormalizedProfile( + baseUser({ level: null, email: null, phone: null }), + ); + expect(profile.headline).toBeUndefined(); + expect(profile.contact.email).toBeUndefined(); + expect(profile.contact.phone).toBeUndefined(); + }); + + it("lida com technologyExperiences não sendo um array", () => { + const profile = toNormalizedProfile( + baseUser({ + technologies: ["TypeScript"], + technologyExperiences: null as unknown as { name: string; years: number }[], + }), + ); + expect(profile.skills.map((s) => s.name)).toEqual(["TypeScript"]); + expect(profile.skills[0].years).toBeUndefined(); + }); +}); diff --git a/backend/tests/unit/resume/resume.service.test.ts b/backend/tests/unit/resume/resume.service.test.ts new file mode 100644 index 00000000..6deaeac1 --- /dev/null +++ b/backend/tests/unit/resume/resume.service.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { isAppError } from "../../../src/lib/errors"; + +const mockFindById = vi.hoisted(() => vi.fn()); +const mockGenerate = vi.hoisted(() => vi.fn()); +const mockAnalyze = vi.hoisted(() => vi.fn()); + +vi.mock("../../../src/modules/users/users.repository", () => ({ + UsersRepository: class { + findById = mockFindById; + }, +})); + +vi.mock("../../../src/modules/resume/resume.client", () => ({ + resumeClient: { generate: mockGenerate, analyze: mockAnalyze }, +})); + +import { ResumeService } from "../../../src/modules/resume/resume.service"; + +const user = { + id: "u1", + displayName: "Ana Souza", + firstName: "Ana", + lastName: "Souza", + email: "ana@example.com", + phone: "+55 11 90000-0000", + technologies: ["TypeScript"], + technologyExperiences: [{ name: "TypeScript", years: 5 }], + level: "Pleno", +}; + +const generated = { + content: Buffer.from("%PDF"), + contentType: "application/pdf", + filename: "Ana.pdf", + atsReport: null, +}; + +describe("ResumeService.generateForUser", () => { + let service: ResumeService; + + beforeEach(() => { + vi.clearAllMocks(); + mockFindById.mockResolvedValue(user); + mockGenerate.mockResolvedValue(generated); + mockAnalyze.mockResolvedValue({ resume: { name: "Ana Souza" }, atsReport: { score: 90 } }); + service = new ResumeService({} as never); + }); + + it("analyzeForUser monta o perfil e chama o client.analyze", async () => { + const result = await service.analyzeForUser("u1", { + format: "pdf", + job: { title: "Backend" }, + sources: { github: "https://github.com/ana" }, + }); + expect(result).toMatchObject({ atsReport: { score: 90 } }); + expect(mockAnalyze).toHaveBeenCalledTimes(1); + expect(mockAnalyze.mock.calls[0][0].profile.name).toBe("Ana Souza"); + expect(mockAnalyze.mock.calls[0][0].sources).toEqual({ github: "https://github.com/ana" }); + }); + + it("analyzeForUser usa job/sources null quando ausentes", async () => { + await service.analyzeForUser("u1", { format: "docx" }); + const arg = mockAnalyze.mock.calls[0][0]; + expect(arg.job).toBeNull(); + expect(arg.sources).toBeNull(); + }); + + it("analyzeForUser lança notFound quando o usuário não existe", async () => { + mockFindById.mockResolvedValueOnce(undefined); + await expect( + service.analyzeForUser("ghost", { format: "pdf" }), + ).rejects.toSatisfy((e: unknown) => isAppError(e) && e.statusCode === 404); + }); + + it("lança notFound quando o usuário não existe", async () => { + mockFindById.mockResolvedValueOnce(undefined); + await expect( + service.generateForUser("ghost", { format: "pdf" }), + ).rejects.toSatisfy((e: unknown) => isAppError(e) && e.statusCode === 404); + expect(mockGenerate).not.toHaveBeenCalled(); + }); + + it("monta o perfil normalizado e chama o ats-forge (sem experiência)", async () => { + const result = await service.generateForUser("u1", { + format: "pdf", + job: { title: "Backend" }, + sources: { github: "https://github.com/ana" }, + about: "Engenheiro backend focado em integrações.", + }); + + expect(result).toBe(generated); + const arg = mockGenerate.mock.calls[0][0]; + expect(arg.profile.name).toBe("Ana Souza"); + expect(arg.profile.experience).toEqual([]); + expect(arg.job).toEqual({ title: "Backend" }); + expect(arg.sources).toEqual({ github: "https://github.com/ana" }); + expect(arg.about).toBe("Engenheiro backend focado em integrações."); + }); + + it("inclui experiências manuais quebrando a descrição em bullets", async () => { + await service.generateForUser("u1", { + format: "pdf", + experiences: [ + { + company: "Empresa A", + role: "Dev", + period: "2021 – Atual", + description: "- Fiz APIs\n• Otimizei queries\n\nAutomatizei deploys", + stack: ["Node.js"], + }, + ], + }); + + const arg = mockGenerate.mock.calls[0][0]; + expect(arg.profile.experience).toHaveLength(1); + expect(arg.profile.experience[0]).toMatchObject({ + company: "Empresa A", + role: "Dev", + period: "2021 – Atual", + stack: ["Node.js"], + source: "manual", + }); + expect(arg.profile.experience[0].highlights).toEqual([ + "Fiz APIs", + "Otimizei queries", + "Automatizei deploys", + ]); + }); + + it("trata experiência sem descrição (highlights vazios)", async () => { + await service.generateForUser("u1", { + format: "pdf", + experiences: [{ company: "X", role: "Y" }], + }); + const arg = mockGenerate.mock.calls[0][0]; + expect(arg.profile.experience[0].highlights).toEqual([]); + }); + + it("passa job/sources como null quando ausentes", async () => { + await service.generateForUser("u1", { format: "docx" }); + const arg = mockGenerate.mock.calls[0][0]; + expect(arg.job).toBeNull(); + expect(arg.sources).toBeNull(); + }); +}); diff --git a/backend/tests/unit/scripts/backfillUserPii.test.ts b/backend/tests/unit/scripts/backfillUserPii.test.ts index 10ba4b14..8f17875a 100644 --- a/backend/tests/unit/scripts/backfillUserPii.test.ts +++ b/backend/tests/unit/scripts/backfillUserPii.test.ts @@ -104,10 +104,13 @@ describe("backfillUserPii script", () => { vi.restoreAllMocks(); }); + // timeout ampliado: o import dinâmico do script pode levar vários segundos + // sob a carga da suíte completa (pre-push), estourando o padrão de 5s do vitest. it("reports pending records in dry-run without persisting changes", async () => { - await import("../../../src/scripts/backfillUserPii"); + const { completion } = await import("../../../src/scripts/backfillUserPii"); + await completion; - await vi.waitFor(() => expect(console.log).toHaveBeenCalledOnce()); + expect(console.log).toHaveBeenCalledOnce(); const report = JSON.parse(vi.mocked(console.log).mock.calls[0][0] as string); expect(report).toEqual({ @@ -116,14 +119,15 @@ describe("backfillUserPii script", () => { credentials: { scanned: 1, pending: 1 }, }); expect(databaseMocks.update).not.toHaveBeenCalled(); - }); + }, 30000); it("writes encrypted user fields and normalized credential email with --write", async () => { process.argv = [...originalArgv, "--write"]; - await import("../../../src/scripts/backfillUserPii"); + const { completion } = await import("../../../src/scripts/backfillUserPii"); + await completion; - await vi.waitFor(() => expect(console.log).toHaveBeenCalledOnce()); + expect(console.log).toHaveBeenCalledOnce(); expect(databaseMocks.update).toHaveBeenCalledTimes(2); const userValues = databaseMocks.update.mock.results[0].value.set.mock.calls[0][0]; @@ -146,5 +150,5 @@ describe("backfillUserPii script", () => { email: "encrypted:person@example.com", emailHash: "hash:person@example.com", }); - }); + }, 30000); }); diff --git a/backend/tests/unit/scripts/seed.test.ts b/backend/tests/unit/scripts/seed.test.ts index 5ccf7746..ca32d529 100644 --- a/backend/tests/unit/scripts/seed.test.ts +++ b/backend/tests/unit/scripts/seed.test.ts @@ -56,10 +56,13 @@ describe("seed script", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); }); + // timeout ampliado: o import dinâmico do script pode levar vários segundos + // sob a carga da suíte completa (pre-push), estourando o padrão de 5s do vitest. it("preserves existing records while refreshing profile and catalog data", async () => { - await import("../../../src/scripts/seed"); + const { completion } = await import("../../../src/scripts/seed"); + await completion; - await vi.waitFor(() => expect(seedMocks.poolEnd).toHaveBeenCalledOnce()); + expect(seedMocks.poolEnd).toHaveBeenCalledOnce(); expect(seedMocks.credentialsFindFirst).toHaveBeenCalledTimes(2); expect(seedMocks.update).toHaveBeenCalledOnce(); @@ -68,5 +71,5 @@ describe("seed script", () => { expect(seedMocks.insert).not.toHaveBeenCalled(); expect(seedMocks.transaction).not.toHaveBeenCalled(); expect(seedMocks.seedCatalogJobs).toHaveBeenCalledOnce(); - }); + }, 30000); }); diff --git a/docker-compose.yml b/docker-compose.yml index 5a5a1a85..f8113654 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,27 @@ services: - vagas-net restart: unless-stopped + ats-forge: + build: + context: ../ats-forge + dockerfile: Dockerfile + container_name: vagas-ats-forge + environment: + - PORT=8089 + - ATS_FORGE_API_KEY=${ATS_FORGE_API_KEY-} + # Publicado porque o frontend (navegador) chama o ats-forge diretamente. + ports: + - "8089:8089" + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:8089/health"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + networks: + - vagas-net + restart: unless-stopped + backend: build: context: . @@ -62,6 +83,8 @@ services: - PROMETHEUS_URL=http://prometheus:9090 - SCRAPER_URL=http://scraper-go:8081 - GO_SCRAPER_URL=http://scraper-go:8081 + - ATS_FORGE_URL=http://ats-forge:8089 + - ATS_FORGE_API_KEY=${ATS_FORGE_API_KEY-} - KEYWORDS_STORAGE_MODE=${KEYWORDS_STORAGE_MODE:-env} - CACHE_TTL_MS=${CACHE_TTL_MS:-600000} - REDIS_KEY_PREFIX=${REDIS_KEY_PREFIX:-vagas-full} @@ -72,6 +95,8 @@ services: depends_on: scraper-go: condition: service_healthy + ats-forge: + condition: service_healthy networks: - vagas-net restart: unless-stopped diff --git a/frontend/src/domains/new_dashboard/components/profile/GenerateResumeCard.tsx b/frontend/src/domains/new_dashboard/components/profile/GenerateResumeCard.tsx new file mode 100644 index 00000000..caae4078 --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/profile/GenerateResumeCard.tsx @@ -0,0 +1,390 @@ +import { useState } from "react"; +import { + Briefcase, + FileText, + Github, + Linkedin, + Loader2, + Plus, + Sparkles, + Trash2, +} from "lucide-react"; +import { Eye } from "lucide-react"; +import { isApiError } from "@/shared/lib/apiError"; +import { + analyzeResume, + generateResume, + type ExperienceInput, + type ResumeAnalysis, + type ResumeFormat, +} from "../../infrastructure/resumeApi"; +import { ResumePreview } from "./ResumePreview"; + +interface ExperienceRow { + company: string; + role: string; + period: string; + description: string; +} + +const emptyExperience: ExperienceRow = { + company: "", + role: "", + period: "", + description: "", +}; + +type Status = + | { kind: "idle" } + | { kind: "loading" } + | { kind: "success"; atsScore: number | null; filename: string } + | { kind: "error"; message: string }; + +function friendlyError(error: unknown): string { + if (isApiError(error)) { + switch (error.code) { + case "UNAUTHORIZED": + return "Sua sessão expirou. Entre novamente para gerar o currículo."; + case "VALIDATION_ERROR": + return "Não há dados suficientes para gerar o currículo. Preencha seu perfil ou informe um GitHub público."; + case "NOT_FOUND": + return "Perfil não encontrado."; + case "INTERNAL_ERROR": + return "O serviço de geração de currículos está indisponível. Tente novamente em instantes."; + default: + return "Não foi possível gerar o currículo agora. Tente novamente em instantes."; + } + } + return "Não foi possível gerar o currículo agora. Tente novamente em instantes."; +} + +function scoreColor(score: number): string { + if (score >= 75) return "text-emerald-600 dark:text-emerald-400"; + if (score >= 50) return "text-amber-600 dark:text-amber-400"; + return "text-red-600 dark:text-red-400"; +} + +export function GenerateResumeCard() { + const [format, setFormat] = useState("pdf"); + const [jobTitle, setJobTitle] = useState(""); + const [jobDescription, setJobDescription] = useState(""); + const [githubUrl, setGithubUrl] = useState(""); + const [linkedinUrl, setLinkedinUrl] = useState(""); + const [about, setAbout] = useState(""); + const [experiences, setExperiences] = useState([]); + const [status, setStatus] = useState({ kind: "idle" }); + const [analysis, setAnalysis] = useState(null); + const [preview, setPreview] = useState<"idle" | "loading" | "error">("idle"); + const [previewError, setPreviewError] = useState(""); + + const isLoading = status.kind === "loading"; + + const buildParams = () => { + const cleanedExperiences: ExperienceInput[] = experiences + .filter((e) => e.company.trim() && e.role.trim()) + .map((e) => ({ + company: e.company.trim(), + role: e.role.trim(), + period: e.period.trim() || undefined, + description: e.description.trim() || undefined, + })); + + return { + jobTitle: jobTitle.trim() || undefined, + jobDescription: jobDescription.trim() || undefined, + githubUrl: githubUrl.trim() || undefined, + linkedinUrl: linkedinUrl.trim() || undefined, + about: about.trim() || undefined, + experiences: cleanedExperiences.length ? cleanedExperiences : undefined, + }; + }; + + const handlePreview = async () => { + setPreview("loading"); + setPreviewError(""); + try { + const result = await analyzeResume(buildParams()); + setAnalysis(result); + setPreview("idle"); + } catch (error) { + setPreview("error"); + setPreviewError(friendlyError(error)); + } + }; + + const addExperience = () => + setExperiences((rows) => [...rows, { ...emptyExperience }]); + const removeExperience = (index: number) => + setExperiences((rows) => rows.filter((_, i) => i !== index)); + const updateExperience = ( + index: number, + field: keyof ExperienceRow, + value: string, + ) => + setExperiences((rows) => + rows.map((row, i) => (i === index ? { ...row, [field]: value } : row)), + ); + + const handleGenerate = async () => { + setStatus({ kind: "loading" }); + try { + const result = await generateResume({ format, ...buildParams() }); + setStatus({ + kind: "success", + atsScore: result.atsScore, + filename: result.filename, + }); + } catch (error) { + setStatus({ kind: "error", message: friendlyError(error) }); + } + }; + + return ( +
+
+ + + +
+

+ Gerar currículo via ATS-forge +

+

+ Gera um currículo otimizado para ATS com seu nome, a vaga e seus + perfis públicos de GitHub e LinkedIn. +

+
+
+ +
+ + + + + + + +
+ +