From f118e8a1b66e10deb38cf3302c89089667c6dd26 Mon Sep 17 00:00:00 2001 From: Lucas Quaresma <15186323+lrqnet@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:17:25 -0500 Subject: [PATCH] Prepare NetKeep v1.0.8 inventory integration lab --- .github/workflows/release.yml | 8 +- .gitignore | 1 + CHANGELOG.md | 17 +- README.md | 6 +- .../Controllers/IntegrationController.php | 10 +- compose.inventory-lab.yaml | 200 ++++++++++++++++++ compose.yaml | 8 +- docs/ADMINISTRATION.md | 5 + docs/ARCHITECTURE.md | 3 + docs/DEVELOPMENT.md | 5 + docs/INSTALL.md | 2 +- docs/INVENTORY_LAB.md | 40 ++++ docs/RELEASE.md | 22 +- docs/SECURITY_REVIEW_V1.0.8.md | 53 +++++ docs/UPGRADE.md | 17 +- scripts/inventory-lab-verify.sh | 70 ++++++ scripts/inventory-lab.sh | 119 +++++++++++ scripts/release-preflight.sh | 2 +- .../InventoryIntegrationSecurityTest.php | 72 +++++++ tests/Unit/ContainerRuntimeTest.php | 12 +- 20 files changed, 633 insertions(+), 39 deletions(-) create mode 100644 compose.inventory-lab.yaml create mode 100644 docs/INVENTORY_LAB.md create mode 100644 docs/SECURITY_REVIEW_V1.0.8.md create mode 100755 scripts/inventory-lab-verify.sh create mode 100755 scripts/inventory-lab.sh create mode 100644 tests/Feature/NetKeep/InventoryIntegrationSecurityTest.php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 615fc49..b9907e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,8 +79,8 @@ jobs: assert_absent "ghcr.io/lrqnet/netkeep:${VERSION}" assert_absent "docker.io/lrqnet/netkeep-updater:${VERSION}" assert_absent "ghcr.io/lrqnet/netkeep-updater:${VERSION}" - assert_absent "docker.io/lrqnet/netkeep-oxidized:0.37.0-r4" - assert_absent "ghcr.io/lrqnet/netkeep-oxidized:0.37.0-r4" + assert_absent "docker.io/lrqnet/netkeep-oxidized:0.37.0-r5" + assert_absent "ghcr.io/lrqnet/netkeep-oxidized:0.37.0-r5" netkeep-image: needs: preflight @@ -165,7 +165,7 @@ jobs: images: | ghcr.io/lrqnet/netkeep-oxidized docker.io/lrqnet/netkeep-oxidized - tags: type=raw,value=0.37.0-r4 + tags: type=raw,value=0.37.0-r5 - id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 with: @@ -268,7 +268,7 @@ jobs: mkdir -p dist sed \ -e "s#docker.io/lrqnet/netkeep:${VERSION}#docker.io/lrqnet/netkeep@${APP_DIGEST}#g" \ - -e "s#docker.io/lrqnet/netkeep-oxidized:0.37.0-r4#docker.io/lrqnet/netkeep-oxidized@${OXIDIZED_DIGEST}#g" \ + -e "s#docker.io/lrqnet/netkeep-oxidized:0.37.0-r5#docker.io/lrqnet/netkeep-oxidized@${OXIDIZED_DIGEST}#g" \ -e "s#docker.io/lrqnet/netkeep-updater:${VERSION}#docker.io/lrqnet/netkeep-updater@${UPDATER_DIGEST}#g" \ compose.yaml > dist/compose.yaml grep -q "docker.io/lrqnet/netkeep@sha256:" dist/compose.yaml diff --git a/.gitignore b/.gitignore index 1c68837..3615fd9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ .DS_Store .env .env.* +/.inventory-lab.env !.env.example .env.backup .env.production diff --git a/CHANGELOG.md b/CHANGELOG.md index 4676034..329bc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,19 @@ Todas as mudanças relevantes serão registradas aqui. O projeto segue [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [1.0.8] - 2026-07-26 + +### Added + +- laboratório Docker isolado e reproduzível para validar integrações reais com + LibreNMS e NetBox, com portas locais exclusivas, credenciais efêmeras fora + do Git e imagens fixadas por digest; + +### Fixed + +- falhas de sincronização manual de inventário agora preservam somente o código + estável `inventory_sync_failed` no estado e na auditoria, sem persistir ou + enviar detalhes brutos da origem externa ao navegador. ## [1.0.7] - 2026-07-24 @@ -334,7 +346,8 @@ Todas as mudanças relevantes serão registradas aqui. O projeto segue - ambiente PostgreSQL do CI alinhado ao arquivo de teste e asserções de mensagens independentes do idioma configurado. -[Unreleased]: https://github.com/lrqnet/NetKeep/compare/v1.0.7...HEAD +[Unreleased]: https://github.com/lrqnet/NetKeep/compare/v1.0.8...HEAD +[1.0.8]: https://github.com/lrqnet/NetKeep/compare/v1.0.7...v1.0.8 [1.0.7]: https://github.com/lrqnet/NetKeep/compare/v1.0.6...v1.0.7 [1.0.6]: https://github.com/lrqnet/NetKeep/compare/v1.0.5...v1.0.6 [1.0.5]: https://github.com/lrqnet/NetKeep/compare/v1.0.4...v1.0.5 diff --git a/README.md b/README.md index afcb385..cd7e9b5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@
@@ -105,13 +105,13 @@ administrative networks are strongly recommended. ## Quick installation -Create a directory, download the Compose file attached to the v1.0.7 release, +Create a directory, download the Compose file attached to the v1.0.8 release, and start the stack: ```bash sudo mkdir -p /opt/netkeep cd /opt/netkeep -sudo curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.7/compose.yaml +sudo curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.8/compose.yaml sudo docker compose up -d --wait sudo docker compose ps ``` diff --git a/app/Http/Controllers/IntegrationController.php b/app/Http/Controllers/IntegrationController.php index 7aeaf70..20a6d8e 100644 --- a/app/Http/Controllers/IntegrationController.php +++ b/app/Http/Controllers/IntegrationController.php @@ -25,7 +25,9 @@ public function index(): Response 'enabled' => $source->enabled, 'sync_interval' => $source->sync_interval, 'last_synced_at' => $source->last_synced_at, - 'last_error' => $source->last_error, + 'last_error' => $source->last_error === null + ? null + : __('netkeep.integrations.sync_failed'), 'has_token' => filled($source->token), ]), ]); @@ -62,9 +64,9 @@ public function syncInventory(InventorySource $source, InventorySynchronizer $sy $audit->record('integration.inventory_synced', $source, $result); return back()->with('success', __('netkeep.integrations.sync_completed', $result)); - } catch (\Throwable $exception) { - $source->update(['last_error' => $exception->getMessage()]); - $audit->record('integration.inventory_failed', $source, ['error' => $exception->getMessage()]); + } catch (\Throwable) { + $source->update(['last_error' => 'inventory_sync_failed']); + $audit->record('integration.inventory_failed', $source, ['error_code' => 'inventory_sync_failed']); return back()->with('error', __('netkeep.integrations.sync_failed')); } diff --git a/compose.inventory-lab.yaml b/compose.inventory-lab.yaml new file mode 100644 index 0000000..a1496a8 --- /dev/null +++ b/compose.inventory-lab.yaml @@ -0,0 +1,200 @@ +services: + app: + ports: !override + - target: 8080 + published: '${NETKEEP_LAB_HTTP_PORT:-28180}' + host_ip: 127.0.0.1 + protocol: tcp + - target: 8443 + published: '${NETKEEP_LAB_HTTPS_PORT:-28543}' + host_ip: 127.0.0.1 + protocol: tcp + + netbox-postgres: + image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15 + environment: + POSTGRES_DB: netbox + POSTGRES_USER: netbox_lab + POSTGRES_PASSWORD: '${NETKEEP_LAB_NETBOX_DATABASE_PASSWORD}' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U netbox_lab -d netbox'] + interval: 5s + timeout: 5s + retries: 30 + volumes: + - netbox_lab_postgres:/var/lib/postgresql + networks: + - inventory_internal + + netbox-redis: + image: valkey/valkey:8.1.3-alpine@sha256:d827e7f7552cdee40cc7482dbae9da020f42bc47669af6f71182a4ef76a22773 + command: + [ + 'valkey-server', + '--requirepass', + '${NETKEEP_LAB_NETBOX_REDIS_PASSWORD}', + ] + healthcheck: + test: ['CMD-SHELL', 'valkey-cli --pass "$$REDIS_PASSWORD" ping'] + interval: 5s + timeout: 5s + retries: 30 + environment: + REDIS_PASSWORD: '${NETKEEP_LAB_NETBOX_REDIS_PASSWORD}' + networks: + - inventory_internal + + netbox: + image: netboxcommunity/netbox:v4.6-5.0.2@sha256:691ec1a4f569f3dfb9fefd9f086cca1b39689ad59c3eae753712a741447e5e60 + depends_on: + netbox-postgres: + condition: service_healthy + netbox-redis: + condition: service_healthy + environment: + DB_HOST: netbox-postgres + DB_NAME: netbox + DB_USER: netbox_lab + DB_PASSWORD: '${NETKEEP_LAB_NETBOX_DATABASE_PASSWORD}' + REDIS_HOST: netbox-redis + REDIS_PASSWORD: '${NETKEEP_LAB_NETBOX_REDIS_PASSWORD}' + REDIS_CACHE_HOST: netbox-redis + REDIS_CACHE_PASSWORD: '${NETKEEP_LAB_NETBOX_REDIS_PASSWORD}' + SECRET_KEY: '${NETKEEP_LAB_NETBOX_SECRET_KEY}' + SUPERUSER_NAME: netkeep-lab + SUPERUSER_EMAIL: netkeep-lab@example.test + SUPERUSER_PASSWORD: '${NETKEEP_LAB_NETBOX_SUPERUSER_PASSWORD}' + NETKEEP_LAB_API_TOKEN: '${NETKEEP_LAB_NETBOX_V1_TOKEN}' + API_TOKEN_PEPPER_1: '${NETKEEP_LAB_NETBOX_TOKEN_PEPPER}' + GRANIAN_WORKERS: '1' + GRANIAN_BACKPRESSURE: '1' + SKIP_SUPERUSER: 'false' + healthcheck: + test: + [ + 'CMD-SHELL', + 'curl --fail --silent http://localhost:8080/login/ >/dev/null', + ] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + ports: + - target: 8080 + published: '${NETKEEP_LAB_NETBOX_PORT:-28181}' + host_ip: 127.0.0.1 + protocol: tcp + volumes: + - netbox_lab_media:/opt/netbox/netbox/media + networks: + inventory_internal: + egress: + aliases: [netbox-lab] + + netbox-worker: + image: netboxcommunity/netbox:v4.6-5.0.2@sha256:691ec1a4f569f3dfb9fefd9f086cca1b39689ad59c3eae753712a741447e5e60 + command: + [ + '/opt/netbox/venv/bin/python', + '/opt/netbox/netbox/manage.py', + 'rqworker', + ] + depends_on: + netbox: + condition: service_healthy + environment: + DB_HOST: netbox-postgres + DB_NAME: netbox + DB_USER: netbox_lab + DB_PASSWORD: '${NETKEEP_LAB_NETBOX_DATABASE_PASSWORD}' + REDIS_HOST: netbox-redis + REDIS_PASSWORD: '${NETKEEP_LAB_NETBOX_REDIS_PASSWORD}' + REDIS_CACHE_HOST: netbox-redis + REDIS_CACHE_PASSWORD: '${NETKEEP_LAB_NETBOX_REDIS_PASSWORD}' + SECRET_KEY: '${NETKEEP_LAB_NETBOX_SECRET_KEY}' + API_TOKEN_PEPPER_1: '${NETKEEP_LAB_NETBOX_TOKEN_PEPPER}' + SKIP_SUPERUSER: 'true' + networks: + - inventory_internal + + librenms-db: + image: mariadb:11.8.3@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71 + command: + [ + 'mariadbd', + '--innodb-file-per-table=1', + '--character-set-server=utf8mb4', + '--collation-server=utf8mb4_unicode_ci', + ] + environment: + MARIADB_DATABASE: librenms + MARIADB_USER: librenms_lab + MARIADB_PASSWORD: '${NETKEEP_LAB_LIBRENMS_DATABASE_PASSWORD}' + MARIADB_ROOT_PASSWORD: '${NETKEEP_LAB_LIBRENMS_ROOT_PASSWORD}' + healthcheck: + test: + [ + 'CMD-SHELL', + 'mariadb-admin ping -h localhost -u root -p"$$MARIADB_ROOT_PASSWORD" --silent', + ] + interval: 5s + timeout: 5s + retries: 30 + volumes: + - librenms_lab_database:/var/lib/mysql + networks: + - inventory_internal + + librenms-redis: + image: valkey/valkey:8.1.3-alpine@sha256:d827e7f7552cdee40cc7482dbae9da020f42bc47669af6f71182a4ef76a22773 + networks: + - inventory_internal + + librenms: + image: librenms/librenms:25.7.0@sha256:9914600f06af9f45ba1e99516b3187a0009b93c3ce3a0e3260f24d3e462ef2b4 + depends_on: + librenms-db: + condition: service_healthy + environment: + TZ: UTC + PUID: '1000' + PGID: '1000' + DB_HOST: librenms-db + DB_NAME: librenms + DB_USER: librenms_lab + DB_PASSWORD: '${NETKEEP_LAB_LIBRENMS_DATABASE_PASSWORD}' + DB_TIMEOUT: '60' + REDIS_HOST: librenms-redis + CACHE_DRIVER: redis + SESSION_DRIVER: redis + healthcheck: + test: + [ + 'CMD-SHELL', + 'curl --fail --silent http://localhost:8000/ >/dev/null', + ] + interval: 10s + timeout: 5s + retries: 40 + start_period: 90s + ports: + - target: 8000 + published: '${NETKEEP_LAB_LIBRENMS_PORT:-28182}' + host_ip: 127.0.0.1 + protocol: tcp + volumes: + - librenms_lab_data:/data + networks: + inventory_internal: + egress: + aliases: [librenms-lab] + +networks: + inventory_internal: + internal: true + +volumes: + netbox_lab_postgres: + netbox_lab_media: + librenms_lab_database: + librenms_lab_data: diff --git a/compose.yaml b/compose.yaml index 76b98a3..2bf76ed 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ name: netkeep x-netkeep-image: &netkeep-image - image: ${NETKEEP_IMAGE:-docker.io/lrqnet/netkeep:1.0.7} + image: ${NETKEEP_IMAGE:-docker.io/lrqnet/netkeep:1.0.8} x-netkeep-environment: &netkeep-environment APP_ENV: production @@ -251,7 +251,7 @@ services: - egress oxidized: - image: ${NETKEEP_OXIDIZED_IMAGE:-docker.io/lrqnet/netkeep-oxidized:0.37.0-r4} + image: ${NETKEEP_OXIDIZED_IMAGE:-docker.io/lrqnet/netkeep-oxidized:0.37.0-r5} user: '30000:30000' restart: unless-stopped read_only: true @@ -287,7 +287,7 @@ services: - device_access sandbox: - image: ${NETKEEP_OXIDIZED_IMAGE:-docker.io/lrqnet/netkeep-oxidized:0.37.0-r4} + image: ${NETKEEP_OXIDIZED_IMAGE:-docker.io/lrqnet/netkeep-oxidized:0.37.0-r5} command: ['/usr/local/bin/netkeep-sandbox-controller'] user: '30000:30000' restart: unless-stopped @@ -364,7 +364,7 @@ services: - internal updater: - image: ${NETKEEP_UPDATER_IMAGE:-docker.io/lrqnet/netkeep-updater:1.0.7} + image: ${NETKEEP_UPDATER_IMAGE:-docker.io/lrqnet/netkeep-updater:1.0.8} restart: unless-stopped group_add: - '20000' diff --git a/docs/ADMINISTRATION.md b/docs/ADMINISTRATION.md index 7177c2c..a7d5a74 100644 --- a/docs/ADMINISTRATION.md +++ b/docs/ADMINISTRATION.md @@ -11,6 +11,11 @@ somente leitura. Identidade, IP, local, tags e estado podem ser sincronizados, mas credenciais, drivers e agendas de coleta permanecem sob controle do NetKeep. +Uma sincronização que falha mantém a fonte configurada e apresenta somente uma +mensagem segura. O estado e a auditoria registram um código estável, sem corpo +de resposta, token, URL sensível ou detalhes de transporte da origem externa. +Revise conectividade, token e permissões da fonte antes de tentar novamente. + ## Notificações **Notificações** gerencia canais Webhook, Telegram e E-mail/SMTP. O resumo diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3a1f908..de4a839 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -160,6 +160,9 @@ O cliente de saída resolve DNS em modo fail-closed, bloqueia loopback, link-local, metadata, multicast, unspecified, IPv4 mapeado e nomes ou endereços dos serviços Compose. A resolução validada é fixada à conexão e repetida a cada uso. A proteção é aplicada a inventário, webhooks, Telegram, SMTP, S3 e Git. +Falhas de fontes de inventário são reduzidas a códigos estáveis antes de serem +persistidas, auditadas ou expostas na interface; respostas e erros brutos da +origem externa não cruzam esse limite. IPs públicos e privados de equipamentos são permitidos depois da aprovação administrativa. O Oxidized recebe um IP literal da resolução aprovada e não refaz DNS, preservando o UUID como identidade estável do equipamento. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1f21fac..ac6a3c7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -63,6 +63,11 @@ docker compose down Use `docker compose down --volumes` somente para apagar integralmente os dados desse ambiente local. +## Laboratório de integrações + +Para validar LibreNMS e NetBox reais sem compartilhar recursos com a instalação +local, siga o [guia do laboratório de inventário](INVENTORY_LAB.md). + ## Testes PHP isolados ```bash diff --git a/docs/INSTALL.md b/docs/INSTALL.md index cc98a07..5dbb261 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -21,7 +21,7 @@ também são espelhadas no GHCR. ```bash mkdir -p /opt/netkeep cd /opt/netkeep -curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.7/compose.yaml +curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.8/compose.yaml docker compose up -d --wait docker compose ps ``` diff --git a/docs/INVENTORY_LAB.md b/docs/INVENTORY_LAB.md new file mode 100644 index 0000000..9178c04 --- /dev/null +++ b/docs/INVENTORY_LAB.md @@ -0,0 +1,40 @@ +# Laboratório de inventário + +O laboratório local executa uma instância isolada do NetKeep, NetBox e LibreNMS para validar as integrações de inventário com APIs reais. Ele não compartilha contêineres, redes, volumes ou portas com instalações existentes. + +## Iniciar + +```bash +scripts/inventory-lab.sh up +``` + +O primeiro uso cria `.inventory-lab.env` com tokens e senhas efêmeros, com permissões restritas e ignorado pelo Git. Preserve esse arquivo enquanto o laboratório estiver em uso; apagá-lo sem remover os volumes impede a autenticação dos serviços existentes. +O script também prepara contas e tokens de API locais para a validação dos dois +conectores. Não copie esse arquivo para outro ambiente nem o adicione ao Git. + +As interfaces ficam disponíveis apenas no loopback: + +- NetKeep: `https://127.0.0.1:28543` +- NetBox: `http://127.0.0.1:28181` +- LibreNMS: `http://127.0.0.1:28182` + +## Verificação ponta a ponta + +```bash +scripts/inventory-lab-verify.sh +``` + +O verificador adiciona dados fictícios ao NetBox pela API, cria uma fixture +descartável no banco do LibreNMS e confirma que as duas APIs reais são lidas +pelo NetKeep. Cada execução usa identificadores novos e não altera uma +instalação fora do projeto do laboratório. + +## Operação + +```bash +scripts/inventory-lab.sh config +scripts/inventory-lab.sh logs +scripts/inventory-lab.sh down +``` + +`down` interrompe somente os contêineres do laboratório e preserva seus volumes. Não use volumes de instalações NetKeep existentes para testes de integração. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 657d39a..2896c32 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -36,8 +36,8 @@ O token fica somente no cofre de secrets do GitHub e não deve ser salvo em O workflow `.github/workflows/release.yml` é acionado por tags SemVer: ```bash -git tag -a v1.0.7 -m "NetKeep v1.0.7" -git push origin refs/tags/v1.0.7 +git tag -a v1.0.8 -m "NetKeep v1.0.8" +git push origin refs/tags/v1.0.8 ``` O workflow: @@ -49,7 +49,7 @@ O workflow: 3. autentica no GHCR com `GITHUB_TOKEN` e no Docker Hub com os secrets; 4. constrói NetKeep, NetKeep-Updater e NetKeep-Oxidized para `amd64` e `arm64`; 5. publica as tags SemVer do painel e updater e a revisão imutável - `netkeep-oxidized:0.37.0-r4`; + `netkeep-oxidized:0.37.0-r5`; 6. gera SBOM e provenance das imagens e assina seus digests nos dois registries; 7. substitui as tags do Compose pelos digests publicados; @@ -61,20 +61,20 @@ O Compose nunca usa `latest`; a tag existe apenas para descoberta manual. ## Conferência ```bash -docker pull lrqnet/netkeep:1.0.7 -docker image inspect lrqnet/netkeep:1.0.7 +docker pull lrqnet/netkeep:1.0.8 +docker image inspect lrqnet/netkeep:1.0.8 cosign verify \ - --certificate-identity='https://github.com/lrqnet/NetKeep/.github/workflows/release.yml@refs/tags/v1.0.7' \ + --certificate-identity='https://github.com/lrqnet/NetKeep/.github/workflows/release.yml@refs/tags/v1.0.8' \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \ - docker.io/lrqnet/netkeep:1.0.7 + docker.io/lrqnet/netkeep:1.0.8 cosign verify \ - --certificate-identity='https://github.com/lrqnet/NetKeep/.github/workflows/release.yml@refs/tags/v1.0.7' \ + --certificate-identity='https://github.com/lrqnet/NetKeep/.github/workflows/release.yml@refs/tags/v1.0.8' \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \ - docker.io/lrqnet/netkeep-oxidized:0.37.0-r4 + docker.io/lrqnet/netkeep-oxidized:0.37.0-r5 ``` -Verifique também `docker.io/lrqnet/netkeep-updater:1.0.7` com a mesma +Verifique também `docker.io/lrqnet/netkeep-updater:1.0.8` com a mesma identidade e emissor. Se qualquer registry, assinatura ou criação da release falhar depois de uma @@ -89,7 +89,7 @@ Em um servidor novo: ```bash mkdir -p /opt/netkeep cd /opt/netkeep -curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.7/compose.yaml +curl -fsSLO https://github.com/lrqnet/NetKeep/releases/download/v1.0.8/compose.yaml docker compose up -d ``` diff --git a/docs/SECURITY_REVIEW_V1.0.8.md b/docs/SECURITY_REVIEW_V1.0.8.md new file mode 100644 index 0000000..0c23526 --- /dev/null +++ b/docs/SECURITY_REVIEW_V1.0.8.md @@ -0,0 +1,53 @@ +# Revisão de segurança da v1.0.8 + +Data da revisão: 26 de julho de 2026. + +## Escopo + +A revisão cobre as integrações de inventário LibreNMS e NetBox, o laboratório +local de homologação e o tratamento de falhas de sincronização manual. + +## Integrações de inventário + +- as duas integrações permanecem somente leitura em relação às origens e aos + equipamentos; +- tokens ficam cifrados no banco e não são reapresentados pela interface; +- a proteção SSRF continua recusando destinos proibidos, redirecionamentos e + resolução insegura; +- fontes novas criam equipamentos pendentes e desativados; +- alteração de identidade ou IP revoga aprovação e desativa o equipamento; +- ausência externa nunca apaga histórico, backup ou auditoria. + +## Falhas e auditoria + +- uma falha manual é reduzida a `inventory_sync_failed` antes de persistência, + auditoria e resposta Inertia; +- respostas, mensagens de transporte, URLs sensíveis e tokens da origem não + são gravados em `last_error`, auditoria ou enviados ao navegador; +- a configuração da fonte é preservada para nova tentativa depois da correção + operacional. + +## Laboratório local + +- o Compose de homologação usa projeto, volumes, redes e portas de loopback + exclusivos; +- somente app e scheduler recebem aliases de saída para NetBox e LibreNMS; +- bancos, Oxidized, sandbox e demais serviços não recebem essa conectividade; +- credenciais são geradas em arquivo local ignorado pelo Git, com permissões + restritas e sem valores versionados; +- imagens externas são fixadas por digest e validadas para `linux/arm64`. + +## Validações + +- APIs reais de NetBox e LibreNMS foram consultadas pelo NetKeep; +- foram cobertos criação pendente, sincronização manual e agendada, + idempotência, preservação de campos locais, revogação, inatividade, conflito + de IP, tolerância de ausência e indisponibilidade; +- Pint, PHPStan, PHPUnit, ESLint, Prettier, TypeScript, build Vite, Compose, + testes Go e revisão de segredos e diff concluíram sem falhas. + +## Resultado + +Não foi identificado bypass de autorização, aprovação implícita, escrita em +equipamentos ou exposição de credencial. A release publica app e updater como +`1.0.8` e a revisão imutável do Oxidized como `0.37.0-r5`. diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 81ef52e..b0ab2b1 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -7,12 +7,12 @@ socket Docker e sem depender do agente de atualização. ## Atualização manual para instalações sem updater Instalações v1.0.0 e v1.0.1 ainda não possuem o updater integrado. Faça uma -última atualização manual diretamente para v1.0.7: +última atualização manual diretamente para v1.0.8: ```bash cd /opt/netkeep docker compose exec app php artisan netkeep:backup -curl -fsSLo compose.yaml.next https://github.com/lrqnet/NetKeep/releases/download/v1.0.7/compose.yaml +curl -fsSLo compose.yaml.next https://github.com/lrqnet/NetKeep/releases/download/v1.0.8/compose.yaml docker compose -f compose.yaml.next config --quiet mv compose.yaml.next compose.yaml docker compose pull @@ -61,6 +61,17 @@ Quando o manifesto permite rollback e o health check falha, o Compose anterior é restaurado automaticamente. Caso contrário, a operação entra em `recovery_required`; preserve volumes e snapshot e siga o guia de restauração. +## Integrações de inventário na v1.0.8 + +A v1.0.8 mantém as integrações com LibreNMS e NetBox somente leitura e passa +a reduzir falhas de sincronização manual a uma mensagem segura. Dados brutos da +origem externa, inclusive respostas, detalhes de transporte e tokens, não são +persistidos no estado da fonte, na auditoria ou na interface. + +Antes de repetir uma sincronização, revise conectividade, token e permissões +da fonte. A configuração e os dispositivos já importados são preservados; a +falha não cria nem aprova equipamentos parcialmente. + ## Mudanças de coleta e histórico na v1.0.7 A v1.0.7 trata indisponibilidade da chave SSH durante a aprovação como falha @@ -96,7 +107,7 @@ manual. As tags v1.0.4 e v1.0.5 não possuem release, Compose ou manifesto de atualização e não são destinos suportados. A v1.0.4 publicou imagens isoladas; a v1.0.5 foi bloqueada antes da publicação. Instalações existentes devem atualizar -diretamente para v1.0.7. Não use as imagens isoladas da v1.0.4 nem tente +diretamente para v1.0.8. Não use as imagens isoladas da v1.0.4 nem tente reconstruir seus artefatos. ## Mudanças de dados e sandbox na v1.0.3 diff --git a/scripts/inventory-lab-verify.sh b/scripts/inventory-lab-verify.sh new file mode 100755 index 0000000..f5b445b --- /dev/null +++ b/scripts/inventory-lab-verify.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +env_file="$root/.inventory-lab.env" + +if [[ "${NETKEEP_INVENTORY_LAB_SKIP_START:-0}" != '1' ]]; then + "$root/scripts/inventory-lab.sh" up +fi + +set -a +source "$env_file" +set +a + +compose=( + docker compose + --project-name "${NETKEEP_INVENTORY_LAB_PROJECT:-netkeep-inventory-lab}" + --file "$root/compose.yaml" + --file "$root/compose.dev.yaml" + --file "$root/compose.inventory-lab.yaml" +) + +netbox_api="http://127.0.0.1:${NETKEEP_LAB_NETBOX_PORT:-28181}/api" +run_id="$(date +%s)" + +netbox_post() { + curl --fail --silent --show-error \ + -H "Authorization: Token $NETKEEP_LAB_NETBOX_V1_TOKEN" \ + -H 'Content-Type: application/json' \ + -X POST "$netbox_api/$1" \ + --data "$2" +} + +site_id="$(netbox_post 'dcim/sites/' "{\"name\":\"NetKeep Lab Site $run_id\",\"slug\":\"netkeep-lab-site-$run_id\"}" | jq -er '.id')" +manufacturer_id="$(netbox_post 'dcim/manufacturers/' "{\"name\":\"NetKeep Lab Vendor $run_id\",\"slug\":\"netkeep-lab-vendor-$run_id\"}" | jq -er '.id')" +role_id="$(netbox_post 'dcim/device-roles/' "{\"name\":\"Router $run_id\",\"slug\":\"router-$run_id\",\"color\":\"00aa00\"}" | jq -er '.id')" +device_type_id="$(netbox_post 'dcim/device-types/' "{\"model\":\"Lab Router $run_id\",\"slug\":\"lab-router-$run_id\",\"manufacturer\":$manufacturer_id}" | jq -er '.id')" +device_id="$(netbox_post 'dcim/devices/' "{\"name\":\"netbox-lab-$run_id\",\"device_type\":$device_type_id,\"role\":$role_id,\"site\":$site_id,\"status\":\"active\"}" | jq -er '.id')" +interface_id="$(netbox_post 'dcim/interfaces/' "{\"device\":$device_id,\"name\":\"mgmt0\",\"type\":\"1000base-t\"}" | jq -er '.id')" +ip_id="$(netbox_post 'ipam/ip-addresses/' "{\"address\":\"198.18.$((run_id % 250)).10/24\",\"status\":\"active\",\"assigned_object_type\":\"dcim.interface\",\"assigned_object_id\":$interface_id}" | jq -er '.id')" + +curl --fail --silent --show-error \ + -H "Authorization: Token $NETKEEP_LAB_NETBOX_V1_TOKEN" \ + -H 'Content-Type: application/json' \ + -X PATCH "$netbox_api/dcim/devices/$device_id/" \ + --data "{\"primary_ip4\":$ip_id}" \ + >/dev/null + +librenms_ip="198.19.$((run_id % 250)).20" +librenms_sql="INSERT INTO locations (location, timestamp, fixed_coordinates) VALUES ('NetKeep Lab $run_id', UTC_TIMESTAMP(), 0); SET @location_id = LAST_INSERT_ID(); INSERT INTO devices (hostname, sysName, ip, hardware, location_id, os, status, status_reason, type, disabled, snmpver, community) VALUES ('$librenms_ip', 'librenms-lab-$run_id', INET6_ATON('$librenms_ip'), 'Lab Router $run_id', @location_id, 'routeros', 1, '', 'network', 0, 'v2c', 'not-used');" + +"${compose[@]}" exec --no-TTY \ + -e MYSQL_PWD="$NETKEEP_LAB_LIBRENMS_DATABASE_PASSWORD" \ + librenms-db mariadb -u librenms_lab -D librenms -e "$librenms_sql" + +"${compose[@]}" exec --no-TTY \ + -e NETKEEP_LAB_NETBOX_V1_TOKEN="$NETKEEP_LAB_NETBOX_V1_TOKEN" \ + -e NETKEEP_LAB_LIBRENMS_TOKEN="$NETKEEP_LAB_LIBRENMS_TOKEN" \ + app php artisan tinker --execute='$sources = [["name" => "NetBox Lab Verification", "type" => "netbox", "base_url" => "http://netbox-lab:8080", "token" => getenv("NETKEEP_LAB_NETBOX_V1_TOKEN")], ["name" => "LibreNMS Lab Verification", "type" => "librenms", "base_url" => "http://librenms-lab:8000", "token" => getenv("NETKEEP_LAB_LIBRENMS_TOKEN")]]; foreach ($sources as $source) { \App\Models\InventorySource::query()->updateOrCreate(["name" => $source["name"]], ["type" => $source["type"], "base_url" => $source["base_url"], "token" => $source["token"], "settings" => ["grace_period" => 300], "sync_interval" => 300, "enabled" => true, "last_synced_at" => null]); }' \ + >/dev/null + +for source in 'NetBox Lab Verification' 'LibreNMS Lab Verification'; do + source_id="$("${compose[@]}" exec --no-TTY app php artisan tinker --execute="echo \\App\\Models\\InventorySource::query()->where('name', '$source')->value('id');" | tail -n 1)" + "${compose[@]}" exec --no-TTY app php artisan inventory:sync "$source_id" >/dev/null +done + +"${compose[@]}" exec --no-TTY app php artisan tinker --execute="\$netbox = \\App\\Models\\Device::query()->whereHas('inventorySource', fn (\$query) => \$query->where('name', 'NetBox Lab Verification'))->where('external_id', '$device_id')->exists(); \$librenms = \\App\\Models\\Device::query()->whereHas('inventorySource', fn (\$query) => \$query->where('name', 'LibreNMS Lab Verification'))->where('hostname', '$librenms_ip')->exists(); if (! \$netbox || ! \$librenms) { exit(1); }" \ + >/dev/null + +printf '%s\n' 'Inventory lab verification passed.' diff --git a/scripts/inventory-lab.sh b/scripts/inventory-lab.sh new file mode 100755 index 0000000..0d28adb --- /dev/null +++ b/scripts/inventory-lab.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +project="${NETKEEP_INVENTORY_LAB_PROJECT:-netkeep-inventory-lab}" +env_file="$root/.inventory-lab.env" + +export NETKEEP_BIND_IP=127.0.0.1 +export NETKEEP_HTTP_PORT="${NETKEEP_LAB_HTTP_PORT:-28180}" +export NETKEEP_HTTPS_PORT="${NETKEEP_LAB_HTTPS_PORT:-28543}" + +if [[ ! -f "$env_file" ]]; then + umask 077 + { + printf 'NETKEEP_LAB_NETBOX_DATABASE_PASSWORD=%s\n' "$(openssl rand -hex 24)" + printf 'NETKEEP_LAB_NETBOX_REDIS_PASSWORD=%s\n' "$(openssl rand -hex 24)" + printf 'NETKEEP_LAB_NETBOX_SECRET_KEY=%s\n' "$(openssl rand -hex 32)" + printf 'NETKEEP_LAB_NETBOX_SUPERUSER_PASSWORD=%s\n' "$(openssl rand -hex 24)" + printf 'NETKEEP_LAB_NETBOX_V1_TOKEN=%s\n' "$(openssl rand -hex 20)" + printf 'NETKEEP_LAB_NETBOX_TOKEN_PEPPER=%s\n' "$(openssl rand -hex 32)" + printf 'NETKEEP_LAB_LIBRENMS_DATABASE_PASSWORD=%s\n' "$(openssl rand -hex 24)" + printf 'NETKEEP_LAB_LIBRENMS_ROOT_PASSWORD=%s\n' "$(openssl rand -hex 24)" + printf 'NETKEEP_LAB_LIBRENMS_USER_PASSWORD=%s\n' "$(openssl rand -hex 20)" + printf 'NETKEEP_LAB_LIBRENMS_TOKEN=%s\n' "$(openssl rand -hex 16)" + } > "$env_file" +fi + +if ! grep --quiet '^NETKEEP_LAB_NETBOX_TOKEN_PEPPER=' "$env_file"; then + printf 'NETKEEP_LAB_NETBOX_TOKEN_PEPPER=%s\n' "$(openssl rand -hex 32)" >> "$env_file" +fi + +if ! grep --quiet '^NETKEEP_LAB_NETBOX_V1_TOKEN=' "$env_file"; then + printf 'NETKEEP_LAB_NETBOX_V1_TOKEN=%s\n' "$(openssl rand -hex 20)" >> "$env_file" +fi + +if ! grep --quiet '^NETKEEP_LAB_LIBRENMS_USER_PASSWORD=' "$env_file"; then + printf 'NETKEEP_LAB_LIBRENMS_USER_PASSWORD=%s\n' "$(openssl rand -hex 20)" >> "$env_file" +fi + +if ! grep --quiet '^NETKEEP_LAB_LIBRENMS_TOKEN=' "$env_file"; then + printf 'NETKEEP_LAB_LIBRENMS_TOKEN=%s\n' "$(openssl rand -hex 16)" >> "$env_file" +fi + +set -a +source "$env_file" +set +a + +compose=( + docker compose + --project-name "$project" + --file "$root/compose.yaml" + --file "$root/compose.dev.yaml" + --file "$root/compose.inventory-lab.yaml" +) + +wait_for_healthy() { + local service="$1" + local container + local state + + for _ in $(seq 1 90); do + container="$("${compose[@]}" ps --quiet "$service")" + state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container" 2>/dev/null || true)" + + if [[ "$state" == healthy ]]; then + return 0 + fi + + if [[ "$state" == unhealthy || "$state" == exited ]]; then + return 1 + fi + + sleep 5 + done + + return 1 +} + +provision_netbox_token() { + "${compose[@]}" exec --no-TTY netbox \ + /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py shell -c \ + "from os import environ; from users.choices import TokenVersionChoices; from users.models import Token, User; user = User.objects.get(username='netkeep-lab'); token = environ['NETKEEP_LAB_API_TOKEN']; Token.objects.update_or_create(user=user, description='NetKeep lab', defaults={'version': TokenVersionChoices.V1, 'plaintext': token, 'key': None, 'pepper_id': None, 'hmac_digest': None})" \ + >/dev/null +} + +provision_librenms_token() { + "${compose[@]}" exec --no-TTY \ + -e NETKEEP_LAB_LIBRENMS_USER_PASSWORD="$NETKEEP_LAB_LIBRENMS_USER_PASSWORD" \ + librenms sh -lc 'cd /opt/librenms && ./lnms user:add netkeep-lab --password="$NETKEEP_LAB_LIBRENMS_USER_PASSWORD" --role=admin --email=netkeep-lab@example.test --full-name="NetKeep Lab" --quiet || true' \ + >/dev/null + "${compose[@]}" exec --no-TTY \ + -e NETKEEP_LAB_LIBRENMS_TOKEN="$NETKEEP_LAB_LIBRENMS_TOKEN" \ + librenms php /opt/librenms/artisan tinker --execute='$user = \App\Models\User::query()->where("username", "netkeep-lab")->firstOrFail(); $token = \App\Models\ApiToken::query()->where("user_id", $user->user_id)->where("description", "NetKeep lab")->first() ?? new \App\Models\ApiToken; $token->user_id = $user->user_id; $token->description = "NetKeep lab"; $token->token_hash = getenv("NETKEEP_LAB_LIBRENMS_TOKEN"); $token->disabled = false; $token->save();' \ + >/dev/null +} + +case "${1:-up}" in + up) + "${compose[@]}" up --detach --build --quiet-build + wait_for_healthy app + wait_for_healthy netbox + provision_netbox_token + wait_for_healthy librenms + provision_librenms_token + ;; + down) + "${compose[@]}" down + ;; + logs) + "${compose[@]}" logs --follow --tail 200 + ;; + config) + "${compose[@]}" config --quiet + ;; + *) + printf '%s\n' 'Usage: scripts/inventory-lab.sh [up|down|logs|config]' >&2 + exit 64 + ;; +esac diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 6598d0f..76925c5 100644 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -18,7 +18,7 @@ UPDATER_VERSION="$(sed -n 's#.*docker.io/lrqnet/netkeep-updater:\([0-9][0-9.]*\) test "${APP_VERSION}" = "${VERSION}" test "${UPDATER_VERSION}" = "${VERSION}" -test "$(grep -Fc 'docker.io/lrqnet/netkeep-oxidized:0.37.0-r4' compose.yaml)" -eq 2 +test "$(grep -Fc 'docker.io/lrqnet/netkeep-oxidized:0.37.0-r5' compose.yaml)" -eq 2 grep -Fq "## [${VERSION}] - " CHANGELOG.md grep -Fq "releases/tag/${TAG}" README.md grep -Fq "releases/download/${TAG}/compose.yaml" README.md diff --git a/tests/Feature/NetKeep/InventoryIntegrationSecurityTest.php b/tests/Feature/NetKeep/InventoryIntegrationSecurityTest.php new file mode 100644 index 0000000..ea94d71 --- /dev/null +++ b/tests/Feature/NetKeep/InventoryIntegrationSecurityTest.php @@ -0,0 +1,72 @@ +create(['role' => UserRole::Owner]); + $source = InventorySource::query()->create([ + 'type' => 'netbox', + 'name' => 'NetBox Lab', + 'base_url' => 'https://netbox.example.test', + 'token' => 'test-integration-token', + 'enabled' => true, + 'sync_interval' => 900, + ]); + $marker = 'upstream-body-with-test-integration-token'; + $sync = Mockery::mock(InventorySynchronizer::class); + $sync->shouldReceive('sync')->once()->with(Mockery::on( + fn (InventorySource $candidate): bool => $candidate->is($source), + ))->andThrow(new \RuntimeException($marker)); + $this->app->instance(InventorySynchronizer::class, $sync); + + $response = $this->actingAs($owner) + ->from('/integrations') + ->post(route('integrations.inventory.sync', $source)); + + $response->assertRedirect('/integrations') + ->assertSessionHas('error', __('netkeep.integrations.sync_failed')); + $this->assertSame('inventory_sync_failed', $source->refresh()->last_error); + $audit = AuditEvent::query()->where('action', 'integration.inventory_failed')->sole(); + $this->assertSame(['error_code' => 'inventory_sync_failed'], $audit->metadata); + $this->assertStringNotContainsString($marker, $response->getContent()); + $this->assertStringNotContainsString('test-integration-token', $response->getContent()); + } + + public function test_integration_page_exposes_a_translated_generic_error_only(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + InventorySource::query()->create([ + 'type' => 'librenms', + 'name' => 'LibreNMS Lab', + 'base_url' => 'https://librenms.example.test', + 'token' => 'test-integration-token', + 'enabled' => true, + 'sync_interval' => 900, + 'last_error' => 'upstream-body-with-test-integration-token', + ]); + + $response = $this->actingAs($owner)->get('/integrations'); + + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('inventorySources.0.last_error', __('netkeep.integrations.sync_failed')) + ->where('inventorySources.0.has_token', true), + ); + $this->assertStringNotContainsString('test-integration-token', $response->getContent()); + $this->assertStringNotContainsString('upstream-body-with-test-integration-token', $response->getContent()); + } +} diff --git a/tests/Unit/ContainerRuntimeTest.php b/tests/Unit/ContainerRuntimeTest.php index bbdd6e0..98ec262 100644 --- a/tests/Unit/ContainerRuntimeTest.php +++ b/tests/Unit/ContainerRuntimeTest.php @@ -171,11 +171,11 @@ public function test_release_versions_match_the_compose_and_oxidized_revision(): $this->assertIsString($compose); $this->assertIsString($preflight); $this->assertIsString($workflow); - $this->assertStringContainsString('docker.io/lrqnet/netkeep:1.0.7', $compose); - $this->assertStringContainsString('docker.io/lrqnet/netkeep-updater:1.0.7', $compose); - $this->assertSame(2, substr_count($compose, 'docker.io/lrqnet/netkeep-oxidized:0.37.0-r4')); - $this->assertStringContainsString('tags: type=raw,value=0.37.0-r4', $workflow); - $this->assertStringContainsString('docker.io/lrqnet/netkeep-oxidized:0.37.0-r4', $preflight); + $this->assertStringContainsString('docker.io/lrqnet/netkeep:1.0.8', $compose); + $this->assertStringContainsString('docker.io/lrqnet/netkeep-updater:1.0.8', $compose); + $this->assertSame(2, substr_count($compose, 'docker.io/lrqnet/netkeep-oxidized:0.37.0-r5')); + $this->assertStringContainsString('tags: type=raw,value=0.37.0-r5', $workflow); + $this->assertStringContainsString('docker.io/lrqnet/netkeep-oxidized:0.37.0-r5', $preflight); $this->assertSame(3, substr_count($workflow, 'needs: preflight')); $this->assertStringContainsString('sh scripts/release-preflight.sh', $workflow); $this->assertStringContainsString('Reject previously published immutable tags', $workflow); @@ -195,7 +195,7 @@ public function test_release_versions_match_the_compose_and_oxidized_revision(): $preflight, ); $this->assertStringContainsString( - 's#docker.io/lrqnet/netkeep-oxidized:0.37.0-r4#docker.io/lrqnet/netkeep-oxidized@${OXIDIZED_DIGEST}#g', + 's#docker.io/lrqnet/netkeep-oxidized:0.37.0-r5#docker.io/lrqnet/netkeep-oxidized@${OXIDIZED_DIGEST}#g', $workflow, ); }