diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc47cfe..6b291ef 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v6 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -23,7 +23,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | @@ -32,7 +32,7 @@ jobs: type=sha - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: deploy/Dockerfile diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b19e52f..d256ff4 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Build with Jekyll uses: actions/jekyll-build-pages@v1 @@ -43,4 +43,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 0bb07a4..1742161 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Local AI assistant context (not committed) +CLAUDE.md +.claude/ + # Binaries *.exe *.exe~ @@ -15,10 +19,10 @@ __debug_bin* # Code coverage profiles and other test artifacts *.out -coverage.* +/coverage.out +/coverage.html *.coverprofile profile.cov -coverage.html # Dependency directories vendor/ @@ -65,3 +69,19 @@ Thumbs.db *.log *.pid tmp/ + +# AI agent local instructions and tooling artifacts +AGENTS.md +CLAUDE.md +GEMINI.md +COPILOT.md +.claude/ +.codex/ +.cursor/ +.aider/ +.roo/ +.windsurf/ +.continue/ +.cline/ +.mcp/ +.github/copilot-instructions.md diff --git a/.golangci.yml b/.golangci.yml index aaf4015..84d9ed8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,7 +10,8 @@ formatters: - goimports settings: goimports: - local-prefixes: github.com/scalytics/kafgraph + local-prefixes: + - github.com/scalytics/kafgraph linters: enable: @@ -29,8 +30,6 @@ linters: - errcheck - gosec settings: - govet: - shadow: true revive: rules: - name: exported diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b325e6b..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,168 +0,0 @@ -# KafGraph - -KafGraph is the distributed shared brain of collaborating agents — a graph database -and reflection engine written in Go. It ingests agent conversation data from Apache -Kafka topics, structures it as a property graph, and provides temporal reflection -services that allow agents and teams to learn from past interactions. - -## Build Commands - -```bash -make build # Build the kafgraph binary -make build-linux # Cross-compile for linux/amd64 -make install # Install to $GOPATH/bin -make clean # Remove build artifacts -``` - -## Test Commands - -```bash -make test # Run all unit + E2E tests -make test-unit # Unit tests only -make test-e2e # End-to-end tests (in-process, uses temp BadgerDB) -make test-integration # Integration tests (requires docker-compose) -make test-fuzz # Fuzz tests (Go native fuzzing) -make test-race # Unit + E2E with -race detector -``` - -## Lint & Format - -```bash -make lint # Run golangci-lint -make lint-fix # Auto-fix lint issues -make vet # go vet -make fmt # gofmt -w -make fmt-check # Check formatting (CI gate) -``` - -## Coverage - -```bash -make cover # Generate coverage report -make cover-html # Open HTML coverage report -make cover-check # Fail if coverage < 80% -``` - -## Docker - -```bash -make docker-build # Build multi-stage Docker image -make docker-push # Push to registry -make docker-up # Start dev environment (MinIO + Kafka + KafGraph) -make docker-down # Stop dev environment -make docker-logs # Tail dev environment logs -``` - -## Docs - -```bash -make docs-serve # Serve Jekyll docs locally (http://localhost:4000) -make docs-build # Build static Jekyll site -make docs-sync-check # Verify docs match code (CI gate) -``` - -## Requirements & Specs - -```bash -make req-index # Regenerate SPEC/FR/INDEX.md -make spec-check # Validate requirement files -``` - -## Release & Quality Gates - -```bash -make commit-check # Pre-push gate: fmt + vet + race + coverage -make release-check # Full pre-release gate: lint + test + cover + docs + fmt -make ci # Simulate CI pipeline locally -``` - -## Code Maintenance Procedures - -**Never commit directly to main after the initial scaffold.** Configure GitHub branch -protection rules: require PR with 0 approvals minimum, merge only after all CI checks -pass. A single broken commit can block the workflow for hours — clean code in main is -worth the discipline. - -### Pre-push checklist (`make commit-check`) - -Every push must pass: `go fmt`, `go vet`, `go test -race`, 80% coverage minimum, and -`govulncheck`. Run `make commit-check` before every push. The pre-commit hook -(`hack/pre-commit.sh`) enforces formatting and linting automatically. - -### CI pipeline (runs on every PR) - -1. `golangci-lint` — static analysis with strict config -2. `go vet` — correctness checks -3. `go test -race` — race condition detection on all tests -4. Coverage gate — 80% minimum, 100% on critical paths (storage, graph, server) -5. `gofmt` check — consistent formatting -6. License header check — Apache 2.0 on all `.go` files -7. `gosec` — security-focused static analysis (blocks merge on findings) -8. `govulncheck` — known vulnerability scanning in dependencies -9. Docs sync check — documentation matches code - -### Security scanning (free, no GitHub Advanced Security needed) - -Instead of CodeQL ($30/month), we use two free tools that cover the same ground: -- **gosec** — Go-specific security linter (SQL injection, command injection, hardcoded - credentials, weak crypto, etc.). Runs via golangci-lint on every PR. -- **govulncheck** — Go's official vulnerability checker. Scans dependencies against the - Go vulnerability database. Runs on every PR and locally via `make vulncheck`. - -Both block merge on findings. Run `make sec` locally to check both. - -### Branch protection rules (configure in GitHub Settings > Rules) - -- Require pull request before merging (0 reviews minimum) -- Require status checks to pass: `ci`, `security` -- No direct pushes to main after initial setup - -## Conventions - -- **Go version**: 1.25+ with `CGO_ENABLED=0` (static linking) -- **Linter**: golangci-lint with config from `.golangci.yml` -- **Coverage**: 80% minimum enforced by `hack/coverage.sh`; 100% on critical packages -- **Race detection**: all tests run with `-race` in CI -- **Security**: gosec + govulncheck on all PRs (free, blocks merge on findings) -- **License**: Apache 2.0 header on all `.go` files (enforced by `hack/license-header.sh`) -- **Sync gate**: every public API change must update `docs/` and tests (release gate) -- **Requirements**: tracked as `SPEC/FR/req-NNN.md` with monotonic numbering -- **Phases**: tracked in `SPEC/PLAN.md` -- **Commit messages**: imperative mood, reference `req-NNN` where applicable -- **No direct commits to main**: always use PRs after initial scaffold - -## Architecture - -- **SPEC/initial-idea.md** — project vision and motivation -- **SPEC/requirements.md** — full functional, non-functional, and integration requirements -- **SPEC/solution-design.md** — technology selection, layer architecture, data model -- **SPEC/about-agent-brains.md** — agent brain concept and design rationale -- **SPEC/kafclaw-topic-reference.md** — KafClaw topic hierarchy and wire format -- **SPEC/PLAN.md** — phase tracker (0: Foundation → 8: Hardening) - -## Key Packages - -| Package | Purpose | -|---------|---------| -| `cmd/kafgraph` | Entry point, CLI setup | -| `internal/config` | Viper-based configuration loader | -| `internal/graph` | Core graph API (CRUD nodes/edges, property graph model) | -| `internal/storage` | Storage engines (BadgerDB default) | -| `internal/reflect` | Reflection Engine (scheduler, cycle runner, scorer, feedback checker) | -| `internal/server` | Bolt v4 protocol, HTTP API, Brain Tool API | - -## Reference Repos - -- **KafScale** (`github.com/scalytics/platform`) — Go infrastructure patterns, Makefile, Docker, CI/CD -- **KafClaw** (`github.com/kamir/KafClaw`) — Agent skills system, SKILL.md manifests, Jekyll docs - -## Skills - -Each brain tool is defined as a skill in `skills/brain_*/SKILL.md`: -- `brain_search` — Semantic search across the knowledge graph -- `brain_recall` — Load accumulated agent context -- `brain_capture` — Write insights/decisions into the brain -- `brain_recent` — Browse recent activity -- `brain_patterns` — Surface recurring themes and connections -- `brain_reflect` — Trigger on-demand reflection cycle -- `brain_feedback` — Submit human feedback on reflection cycles diff --git a/Makefile b/Makefile index a1eb98d..f7c940f 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ LDFLAGS := -s -w \ BIN_DIR := bin STAMP_DIR := .build +TOOLS_BIN_DIR := $(STAMP_DIR)/bin DOCKER_REGISTRY ?= ghcr.io/scalytics DOCKER_IMAGE ?= $(DOCKER_REGISTRY)/kafgraph @@ -36,6 +37,8 @@ COVERAGE_FILE := coverage.out GOLANGCI_LINT ?= golangci-lint GOLANGCI_FLAGS ?= --config .golangci.yml +GOVULNCHECK_VERSION ?= latest +GOVULNCHECK ?= $(abspath $(TOOLS_BIN_DIR))/govulncheck JEKYLL ?= bundle exec jekyll DOCS_DIR := docs @@ -139,14 +142,18 @@ fmt-check: ## Check formatting (CI gate) # ─── Security ──────────────────────────────────────────────────────────────── +$(GOVULNCHECK): + @mkdir -p $(TOOLS_BIN_DIR) + GOBIN=$(abspath $(TOOLS_BIN_DIR)) $(GO) install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) + .PHONY: sec -sec: ## Run security checks (gosec via lint + govulncheck) +sec: $(GOVULNCHECK) ## Run security checks (gosec via lint + govulncheck) $(GOLANGCI_LINT) run $(GOLANGCI_FLAGS) --enable-only gosec ./... - govulncheck ./... + $(GOVULNCHECK) ./... .PHONY: vulncheck -vulncheck: ## Check for known vulnerabilities in dependencies - govulncheck ./... +vulncheck: $(GOVULNCHECK) ## Check for known vulnerabilities in dependencies + $(GOVULNCHECK) ./... # ─── Docker ────────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index fd21b07..2bd1bb5 100644 --- a/README.md +++ b/README.md @@ -14,23 +14,57 @@ Apache 2.0. Open beta. --- ## What KafGraph is - + KafGraph is the memory layer for AI agent teams. It ingests every conversation, decision, and artifact that flows through your agent group, structures it as a queryable property graph, and exposes it back to agents through tool calls. No agent ever starts from zero. - -``` -Agent conversations → Kafka topics → KafScale processor → KafGraph (BadgerDB) - │ - ┌────────────────────┤ - │ │ - Brain Tool API Cypher / Bolt v4 - (agent access) (tooling access) + +```mermaid +%%{init: {'theme':'neutral', 'themeVariables': { + 'primaryColor':'transparent', + 'primaryBorderColor':'#1D9E75', + 'primaryTextColor':'#1D9E75', + 'lineColor':'#1D9E75', + 'clusterBkg':'transparent', + 'clusterBorder':'#1D9E75', + 'edgeLabelBackground':'transparent' +}}}%% +flowchart LR + subgraph Agents["AI agent team"] + A1[agent.researcher] + A2[agent.coder] + A3[agent.reviewer] + A4[agent.lead] + end + + subgraph Transport["Transport"] + K[Kafka topics
group.<name>.*] + S[(S3 segments
MinIO)] + end + + subgraph KG["KafGraph"] + P[KafScale processor
5-layer pipeline] + G[(Property graph
BadgerDB)] + BT[Brain Tool API
7 tools] + Bolt[Bolt v4 / OpenCypher] + end + + A1 & A2 & A3 & A4 -- conversations,
decisions,
shared memory --> K + K --> S + S -- direct read,
no broker hop --> P + P --> G + G --> BT + G --> Bolt + BT -- brain_search
brain_recall
brain_capture
brain_reflect --> A1 & A2 & A3 & A4 + Bolt -- inspection,
dashboards --> External((Bolt clients)) + + classDef default fill:transparent,stroke:#1D9E75,color:#1D9E75 + class A1,A2,A3,A4,P,G,BT,Bolt,K,S,External default ``` - + Two deployment modes from one binary: - + * **Embedded.** A local brain for one agent. BadgerDB on disk, Brain Tool API on localhost. Useful for per-agent personalization, coding agents on a developer laptop, or air-gapped single-agent workloads. @@ -38,6 +72,7 @@ Two deployment modes from one binary: membership via `hashicorp/memberlist`, agentID partitioning via FNV-1a, cross-partition fan-out RPC. The mode no other agent memory system ships. + ## Why "shared brain" Every other agent memory system on the market (Mem0, Zep / Graphiti, Cognee, diff --git a/go.mod b/go.mod index 1dca63d..f8946b9 100644 --- a/go.mod +++ b/go.mod @@ -1,36 +1,38 @@ module github.com/scalytics/kafgraph -go 1.25 +go 1.25.0 require ( - github.com/blevesearch/bleve/v2 v2.5.7 + github.com/blevesearch/bleve/v2 v2.6.0 github.com/dgraph-io/badger/v4 v4.9.1 github.com/hashicorp/memberlist v0.5.4 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect + github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect github.com/armon/go-metrics v0.4.1 // indirect - github.com/bits-and-blooms/bitset v1.22.0 // indirect - github.com/blevesearch/bleve_index_api v1.2.11 // indirect - github.com/blevesearch/geo v0.2.4 // indirect - github.com/blevesearch/go-faiss v1.0.26 // indirect + github.com/bits-and-blooms/bitset v1.24.2 // indirect + github.com/blevesearch/bleve_index_api v1.3.11 // indirect + github.com/blevesearch/geo v0.2.5 // indirect + github.com/blevesearch/go-faiss v1.1.0 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/gtreap v0.1.1 // indirect - github.com/blevesearch/mmap-go v1.0.4 // indirect - github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect + github.com/blevesearch/mmap-go v1.2.0 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.4.7 // indirect github.com/blevesearch/segment v0.9.1 // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect - github.com/blevesearch/vellum v1.1.0 // indirect - github.com/blevesearch/zapx/v11 v11.4.2 // indirect - github.com/blevesearch/zapx/v12 v12.4.2 // indirect - github.com/blevesearch/zapx/v13 v13.4.2 // indirect - github.com/blevesearch/zapx/v14 v14.4.2 // indirect - github.com/blevesearch/zapx/v15 v15.4.2 // indirect - github.com/blevesearch/zapx/v16 v16.2.8 // indirect + github.com/blevesearch/vellum v1.2.0 // indirect + github.com/blevesearch/zapx/v11 v11.4.3 // indirect + github.com/blevesearch/zapx/v12 v12.4.3 // indirect + github.com/blevesearch/zapx/v13 v13.4.3 // indirect + github.com/blevesearch/zapx/v14 v14.4.3 // indirect + github.com/blevesearch/zapx/v15 v15.4.3 // indirect + github.com/blevesearch/zapx/v16 v16.3.4 // indirect + github.com/blevesearch/zapx/v17 v17.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect @@ -39,7 +41,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang/snappy v0.0.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -65,17 +67,16 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.etcd.io/bbolt v1.4.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.7 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b5f7554..884ec0d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= -github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= +github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw= +github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -12,45 +12,46 @@ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= -github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= -github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= -github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s= -github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= -github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= -github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= -github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI= -github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= +github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= +github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blevesearch/bleve/v2 v2.6.0 h1:Cyd3dd4q5tCbOV8MnKUVRUDYMHOir9xn12NZzXVSEd4= +github.com/blevesearch/bleve/v2 v2.6.0/go.mod h1:gLmI8lWgHgrIYf7UpUX7JISI1CaqC6VScu46mHThuAY= +github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4= +github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE= +github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4= +github.com/blevesearch/go-faiss v1.1.0 h1:xM7Jc0ZUCv5lssG9Ohj3Jv0SdTpxcUABU1dDt9XVsc4= +github.com/blevesearch/go-faiss v1.1.0/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= -github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= -github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= -github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY= -github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc= +github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI= +github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0= +github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw= +github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs= github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= -github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= -github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y= -github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= -github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= -github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= -github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= -github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= -github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= -github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= -github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= -github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= -github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= -github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI= -github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= +github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0= +github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k= +github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY= +github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo= +github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE= +github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM= +github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU= +github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0= +github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg= +github.com/blevesearch/zapx/v17 v17.1.2 h1:avbOk2igaASNoiy0BE/jPgcxAnRI2PGeydeP4hg7Ikk= +github.com/blevesearch/zapx/v17 v17.1.2/go.mod h1:WQObxKrqUX7cd0G1GMvDfc/bmZzQvoy7APOPimx7DiI= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -96,8 +97,8 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= @@ -194,8 +195,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= @@ -218,7 +219,6 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -226,36 +226,36 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -268,16 +268,15 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -299,7 +298,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hack/coverage.sh b/hack/coverage.sh new file mode 100755 index 0000000..db5f1cd --- /dev/null +++ b/hack/coverage.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Copyright 2026 Scalytics, Inc. +# Copyright 2026 Mirko Kämpf +# +# Fail if total Go coverage drops below the configured threshold. +# Usage: hack/coverage.sh + +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +COVERAGE_FILE="$1" +MIN_COVERAGE="$2" + +if [ ! -f "$COVERAGE_FILE" ]; then + echo "FAIL: coverage file not found: $COVERAGE_FILE" >&2 + exit 1 +fi + +TOTAL_COVERAGE="$( + go tool cover -func="$COVERAGE_FILE" \ + | awk '/^total:/ { sub(/%/, "", $3); print $3 }' +)" + +if [ -z "$TOTAL_COVERAGE" ]; then + echo "FAIL: could not read total coverage from $COVERAGE_FILE" >&2 + exit 1 +fi + +if awk -v total="$TOTAL_COVERAGE" -v min="$MIN_COVERAGE" 'BEGIN { exit !(total + 0 >= min + 0) }'; then + echo "PASS: coverage ${TOTAL_COVERAGE}% >= ${MIN_COVERAGE}%" + exit 0 +fi + +echo "FAIL: coverage ${TOTAL_COVERAGE}% < ${MIN_COVERAGE}%" +exit 1 diff --git a/internal/compliance/dataflow.go b/internal/compliance/dataflow.go index 2fdae82..1316134 100644 --- a/internal/compliance/dataflow.go +++ b/internal/compliance/dataflow.go @@ -173,9 +173,10 @@ type DataFlowValidationResult struct { func (r *DataFlowValidationResult) SummaryText() string { pass, fail := 0, 0 for _, c := range r.Checks { - if c.Status == EvalPass { + switch c.Status { + case EvalPass: pass++ - } else if c.Status == EvalFail { + case EvalFail: fail++ } } @@ -223,12 +224,12 @@ func hasEdgeLabel(edges EdgeList, label string) bool { type gdprFlow001 struct{} -func (r *gdprFlow001) ID() string { return "GDPR-FLOW-001" } +func (r *gdprFlow001) ID() string { return "GDPR-FLOW-001" } func (r *gdprFlow001) Framework() Framework { return FrameworkGDPR } -func (r *gdprFlow001) Module() string { return "dataflow" } -func (r *gdprFlow001) Article() string { return "Art. 30" } -func (r *gdprFlow001) Title() string { return "Data categories must be documented per data flow" } -func (r *gdprFlow001) Severity() Severity { return SeverityHigh } +func (r *gdprFlow001) Module() string { return "dataflow" } +func (r *gdprFlow001) Article() string { return "Art. 30" } +func (r *gdprFlow001) Title() string { return "Data categories must be documented per data flow" } +func (r *gdprFlow001) Severity() Severity { return SeverityHigh } func (r *gdprFlow001) Evaluate(g GraphQuerier) ([]RuleResult, error) { return checkHasEdge(g, r.ID(), r.Severity(), "DataFlow", "CARRIES") @@ -238,12 +239,12 @@ func (r *gdprFlow001) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprFlow002 struct{} -func (r *gdprFlow002) ID() string { return "GDPR-FLOW-002" } +func (r *gdprFlow002) ID() string { return "GDPR-FLOW-002" } func (r *gdprFlow002) Framework() Framework { return FrameworkGDPR } -func (r *gdprFlow002) Module() string { return "dataflow" } -func (r *gdprFlow002) Article() string { return "Art. 6" } -func (r *gdprFlow002) Title() string { return "Legal basis required for every data flow" } -func (r *gdprFlow002) Severity() Severity { return SeverityCritical } +func (r *gdprFlow002) Module() string { return "dataflow" } +func (r *gdprFlow002) Article() string { return "Art. 6" } +func (r *gdprFlow002) Title() string { return "Legal basis required for every data flow" } +func (r *gdprFlow002) Severity() Severity { return SeverityCritical } func (r *gdprFlow002) Evaluate(g GraphQuerier) ([]RuleResult, error) { return checkHasEdge(g, r.ID(), r.Severity(), "DataFlow", "GOVERNED_BY") @@ -253,12 +254,12 @@ func (r *gdprFlow002) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprFlow003 struct{} -func (r *gdprFlow003) ID() string { return "GDPR-FLOW-003" } +func (r *gdprFlow003) ID() string { return "GDPR-FLOW-003" } func (r *gdprFlow003) Framework() Framework { return FrameworkGDPR } -func (r *gdprFlow003) Module() string { return "dataflow" } -func (r *gdprFlow003) Article() string { return "Art. 44-49" } -func (r *gdprFlow003) Title() string { return "International transfers require adequate safeguards" } -func (r *gdprFlow003) Severity() Severity { return SeverityCritical } +func (r *gdprFlow003) Module() string { return "dataflow" } +func (r *gdprFlow003) Article() string { return "Art. 44-49" } +func (r *gdprFlow003) Title() string { return "International transfers require adequate safeguards" } +func (r *gdprFlow003) Severity() Severity { return SeverityCritical } func (r *gdprFlow003) Evaluate(g GraphQuerier) ([]RuleResult, error) { flows, err := g.NodesByLabel("DataFlow") @@ -285,12 +286,12 @@ func (r *gdprFlow003) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprFlow004 struct{} -func (r *gdprFlow004) ID() string { return "GDPR-FLOW-004" } +func (r *gdprFlow004) ID() string { return "GDPR-FLOW-004" } func (r *gdprFlow004) Framework() Framework { return FrameworkGDPR } -func (r *gdprFlow004) Module() string { return "dataflow" } -func (r *gdprFlow004) Article() string { return "Art. 9" } -func (r *gdprFlow004) Title() string { return "Special category data flows require explicit consent" } -func (r *gdprFlow004) Severity() Severity { return SeverityCritical } +func (r *gdprFlow004) Module() string { return "dataflow" } +func (r *gdprFlow004) Article() string { return "Art. 9" } +func (r *gdprFlow004) Title() string { return "Special category data flows require explicit consent" } +func (r *gdprFlow004) Severity() Severity { return SeverityCritical } func (r *gdprFlow004) Evaluate(g GraphQuerier) ([]RuleResult, error) { flows, err := g.NodesByLabel("DataFlow") @@ -325,11 +326,13 @@ func (r *gdprFlow004) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprFlow005 struct{} -func (r *gdprFlow005) ID() string { return "GDPR-FLOW-005" } +func (r *gdprFlow005) ID() string { return "GDPR-FLOW-005" } func (r *gdprFlow005) Framework() Framework { return FrameworkGDPR } -func (r *gdprFlow005) Module() string { return "dataflow" } -func (r *gdprFlow005) Article() string { return "Art. 30" } -func (r *gdprFlow005) Title() string { return "Active processing activities should have data flows defined" } +func (r *gdprFlow005) Module() string { return "dataflow" } +func (r *gdprFlow005) Article() string { return "Art. 30" } +func (r *gdprFlow005) Title() string { + return "Active processing activities should have data flows defined" +} func (r *gdprFlow005) Severity() Severity { return SeverityMedium } func (r *gdprFlow005) Evaluate(g GraphQuerier) ([]RuleResult, error) { @@ -371,12 +374,12 @@ func (r *gdprFlow005) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprInsp001 struct{} -func (r *gdprInsp001) ID() string { return "GDPR-INSP-001" } +func (r *gdprInsp001) ID() string { return "GDPR-INSP-001" } func (r *gdprInsp001) Framework() Framework { return FrameworkGDPR } -func (r *gdprInsp001) Module() string { return "inspection" } -func (r *gdprInsp001) Article() string { return "Art. 5(2)" } -func (r *gdprInsp001) Title() string { return "No inspection findings overdue" } -func (r *gdprInsp001) Severity() Severity { return SeverityHigh } +func (r *gdprInsp001) Module() string { return "inspection" } +func (r *gdprInsp001) Article() string { return "Art. 5(2)" } +func (r *gdprInsp001) Title() string { return "No inspection findings overdue" } +func (r *gdprInsp001) Severity() Severity { return SeverityHigh } func (r *gdprInsp001) Evaluate(g GraphQuerier) ([]RuleResult, error) { findings, err := g.NodesByLabel("InspectionFinding") @@ -415,12 +418,12 @@ func (r *gdprInsp001) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprInsp002 struct{} -func (r *gdprInsp002) ID() string { return "GDPR-INSP-002" } +func (r *gdprInsp002) ID() string { return "GDPR-INSP-002" } func (r *gdprInsp002) Framework() Framework { return FrameworkGDPR } -func (r *gdprInsp002) Module() string { return "inspection" } -func (r *gdprInsp002) Article() string { return "Art. 5(2)" } -func (r *gdprInsp002) Title() string { return "Completed remediations must be verified" } -func (r *gdprInsp002) Severity() Severity { return SeverityMedium } +func (r *gdprInsp002) Module() string { return "inspection" } +func (r *gdprInsp002) Article() string { return "Art. 5(2)" } +func (r *gdprInsp002) Title() string { return "Completed remediations must be verified" } +func (r *gdprInsp002) Severity() Severity { return SeverityMedium } func (r *gdprInsp002) Evaluate(g GraphQuerier) ([]RuleResult, error) { actions, err := g.NodesByLabel("RemediationAction") diff --git a/internal/compliance/engine.go b/internal/compliance/engine.go index 916de50..d78dbd4 100644 --- a/internal/compliance/engine.go +++ b/internal/compliance/engine.go @@ -156,15 +156,15 @@ func (e *Engine) RunScan(ctx context.Context, req ScanRequest) (*ScanResult, err func (e *Engine) storeScanResults(result *ScanResult) error { // Create ComplianceScan node. scanNode, err := e.graph.CreateNode("ComplianceScan", graph.Properties{ - "scanId": result.ScanID, - "framework": string(result.Framework), - "triggeredBy": result.TriggeredBy, - "startedAt": result.StartedAt.Format(time.RFC3339), - "completedAt": result.CompletedAt.Format(time.RFC3339), - "passCount": result.PassCount, - "failCount": result.FailCount, + "scanId": result.ScanID, + "framework": string(result.Framework), + "triggeredBy": result.TriggeredBy, + "startedAt": result.StartedAt.Format(time.RFC3339), + "completedAt": result.CompletedAt.Format(time.RFC3339), + "passCount": result.PassCount, + "failCount": result.FailCount, "warningCount": result.WarningCount, - "score": result.Score, + "score": result.Score, }) if err != nil { return fmt.Errorf("create scan node: %w", err) diff --git a/internal/compliance/engine_test.go b/internal/compliance/engine_test.go index 9e645e2..eac960c 100644 --- a/internal/compliance/engine_test.go +++ b/internal/compliance/engine_test.go @@ -52,12 +52,12 @@ type staticRule struct { evalFn func(GraphQuerier) ([]RuleResult, error) } -func (r *staticRule) ID() string { return r.id } -func (r *staticRule) Framework() Framework { return r.framework } -func (r *staticRule) Module() string { return r.module } -func (r *staticRule) Article() string { return r.article } -func (r *staticRule) Title() string { return r.title } -func (r *staticRule) Severity() Severity { return r.severity } +func (r *staticRule) ID() string { return r.id } +func (r *staticRule) Framework() Framework { return r.framework } +func (r *staticRule) Module() string { return r.module } +func (r *staticRule) Article() string { return r.article } +func (r *staticRule) Title() string { return r.title } +func (r *staticRule) Severity() Severity { return r.severity } func (r *staticRule) Evaluate(g GraphQuerier) ([]RuleResult, error) { return r.evalFn(g) } func TestEngineRegisterAndRules(t *testing.T) { @@ -121,3 +121,238 @@ func TestSeverityWeight(t *testing.T) { } } } + +func TestEngineRunScanStoresResults(t *testing.T) { + g := newTestGraph(t) + e := NewEngine(g) + + passRule := &staticRule{ + id: "PASS-001", framework: FrameworkGDPR, module: "ops", + article: "Art. 1", title: "pass", severity: SeverityHigh, + evalFn: func(GraphQuerier) ([]RuleResult, error) { + return []RuleResult{{ + RuleID: "PASS-001", + Status: EvalPass, + NodeID: "node-1", + Details: "ok", + Severity: SeverityHigh, + }}, nil + }, + } + naRule := &staticRule{ + id: "NA-001", framework: FrameworkGDPR, module: "ops", + article: "Art. 2", title: "na", severity: SeverityCritical, + evalFn: func(GraphQuerier) ([]RuleResult, error) { + return nil, nil + }, + } + warnRule := &staticRule{ + id: "WARN-001", framework: FrameworkGDPR, module: "ops", + article: "Art. 3", title: "warn", severity: SeverityMedium, + evalFn: func(GraphQuerier) ([]RuleResult, error) { + return nil, context.DeadlineExceeded + }, + } + filteredRule := &staticRule{ + id: "SKIP-001", framework: FrameworkSOC2, module: "ops", + article: "Art. 4", title: "skip", severity: SeverityLow, + evalFn: func(GraphQuerier) ([]RuleResult, error) { + t.Fatal("filtered rule should not be evaluated") + return nil, nil + }, + } + + e.RegisterRule(passRule) + e.RegisterRule(naRule) + e.RegisterRule(warnRule) + e.RegisterRule(filteredRule) + + if err := e.EnsureFrameworkNodes(); err != nil { + t.Fatalf("EnsureFrameworkNodes: %v", err) + } + + result, err := e.RunScan(context.Background(), ScanRequest{ + Framework: FrameworkGDPR, + Module: "ops", + }) + if err != nil { + t.Fatalf("RunScan: %v", err) + } + + if result.ScanID == "" { + t.Fatal("expected scan ID") + } + if result.PassCount != 1 || result.WarningCount != 1 || result.NACount != 1 || result.FailCount != 0 { + t.Fatalf("unexpected counts: %+v", result) + } + if got, want := result.Score, CalculateScore(result.Evaluations); got != want { + t.Fatalf("score = %v, want %v", got, want) + } + if len(result.Evaluations) != 3 { + t.Fatalf("expected 3 evaluations, got %d", len(result.Evaluations)) + } + + scans, err := g.NodesByLabel("ComplianceScan") + if err != nil { + t.Fatalf("NodesByLabel(ComplianceScan): %v", err) + } + if len(scans) != 1 { + t.Fatalf("expected 1 ComplianceScan node, got %d", len(scans)) + } + + evals, err := g.NodesByLabel("ComplianceEvaluation") + if err != nil { + t.Fatalf("NodesByLabel(ComplianceEvaluation): %v", err) + } + if len(evals) != 3 { + t.Fatalf("expected 3 ComplianceEvaluation nodes, got %d", len(evals)) + } + + ruleNodes, err := g.NodesByLabel("ComplianceRule") + if err != nil { + t.Fatalf("NodesByLabel(ComplianceRule): %v", err) + } + if len(ruleNodes) != 4 { + t.Fatalf("expected 4 ComplianceRule nodes, got %d", len(ruleNodes)) + } + + scanEdges, err := g.Neighbors(scans[0].ID) + if err != nil { + t.Fatalf("Neighbors(scan): %v", err) + } + foundScope := false + for _, edge := range scanEdges { + if edge.Label == "SCOPED_TO" { + foundScope = true + break + } + } + if !foundScope { + t.Fatal("expected SCOPED_TO edge for scan") + } + + foundPartOfScan := false + foundEvaluatedBy := false + for _, eval := range evals { + edges, err := g.Neighbors(eval.ID) + if err != nil { + t.Fatalf("Neighbors(eval): %v", err) + } + for _, edge := range edges { + if edge.Label == "PART_OF_SCAN" { + foundPartOfScan = true + } + if edge.Label == "EVALUATED_BY" { + foundEvaluatedBy = true + } + } + } + if !foundPartOfScan { + t.Fatal("expected PART_OF_SCAN edge") + } + if !foundEvaluatedBy { + t.Fatal("expected EVALUATED_BY edge") + } +} + +func TestEngineRunScanCanceled(t *testing.T) { + g := newTestGraph(t) + e := NewEngine(g) + e.RegisterRule(&staticRule{ + id: "CANCEL-001", framework: FrameworkGDPR, module: "ops", severity: SeverityHigh, + evalFn: func(GraphQuerier) ([]RuleResult, error) { + t.Fatal("rule should not run after cancellation") + return nil, nil + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := e.RunScan(ctx, ScanRequest{Framework: FrameworkGDPR, Module: "ops"}) + if err != context.Canceled { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestEnsureFrameworkNodesIdempotent(t *testing.T) { + g := newTestGraph(t) + e := NewEngine(g) + e.RegisterRule(&staticRule{id: "R1", framework: FrameworkGDPR, module: "setup", article: "Art. 1", title: "one", severity: SeverityHigh}) + e.RegisterRule(&staticRule{id: "R2", framework: FrameworkGDPR, module: "ops", article: "Art. 2", title: "two", severity: SeverityMedium}) + e.RegisterRule(&staticRule{id: "R3", framework: FrameworkSOC2, module: "ops", article: "CC1", title: "three", severity: SeverityLow}) + + if err := e.EnsureFrameworkNodes(); err != nil { + t.Fatalf("first EnsureFrameworkNodes: %v", err) + } + if err := e.EnsureFrameworkNodes(); err != nil { + t.Fatalf("second EnsureFrameworkNodes: %v", err) + } + + frameworks, err := g.NodesByLabel("ComplianceFramework") + if err != nil { + t.Fatalf("NodesByLabel(ComplianceFramework): %v", err) + } + if len(frameworks) != 2 { + t.Fatalf("expected 2 framework nodes, got %d", len(frameworks)) + } + + rules, err := g.NodesByLabel("ComplianceRule") + if err != nil { + t.Fatalf("NodesByLabel(ComplianceRule): %v", err) + } + if len(rules) != 3 { + t.Fatalf("expected 3 rule nodes, got %d", len(rules)) + } + + defineEdges := 0 + for _, rule := range rules { + edges, err := g.Neighbors(rule.ID) + if err != nil { + t.Fatalf("Neighbors(rule): %v", err) + } + for _, edge := range edges { + if edge.Label == "DEFINES_RULE" { + defineEdges++ + } + } + } + if defineEdges != 3 { + t.Fatalf("expected 3 DEFINES_RULE edges, got %d", defineEdges) + } +} + +func TestGraphAdapter(t *testing.T) { + g := newTestGraph(t) + from, err := g.CreateNode("Agent", map[string]any{"name": "alice"}) + if err != nil { + t.Fatalf("CreateNode(from): %v", err) + } + to, err := g.CreateNode("Agent", map[string]any{"name": "bob"}) + if err != nil { + t.Fatalf("CreateNode(to): %v", err) + } + if _, err := g.CreateEdge("KNOWS", from.ID, to.ID, nil); err != nil { + t.Fatalf("CreateEdge: %v", err) + } + + adapter := &graphAdapter{g: g} + nodes, err := adapter.NodesByLabel("Agent") + if err != nil { + t.Fatalf("NodesByLabel: %v", err) + } + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(nodes)) + } + + edges, err := adapter.Neighbors(string(from.ID)) + if err != nil { + t.Fatalf("Neighbors: %v", err) + } + if len(edges) != 1 { + t.Fatalf("expected 1 edge, got %d", len(edges)) + } + if edges[0].Label != "KNOWS" || edges[0].From != string(from.ID) || edges[0].To != string(to.ID) { + t.Fatalf("unexpected edge: %+v", edges[0]) + } +} diff --git a/internal/compliance/hardcoded.go b/internal/compliance/hardcoded.go index 7767e9e..8c0ea92 100644 --- a/internal/compliance/hardcoded.go +++ b/internal/compliance/hardcoded.go @@ -47,12 +47,12 @@ func RegisterGDPRRules(e *Engine) { type gdprSetup001 struct{} -func (r *gdprSetup001) ID() string { return "GDPR-SETUP-001" } +func (r *gdprSetup001) ID() string { return "GDPR-SETUP-001" } func (r *gdprSetup001) Framework() Framework { return FrameworkGDPR } -func (r *gdprSetup001) Module() string { return "setup" } -func (r *gdprSetup001) Article() string { return "Art. 37" } -func (r *gdprSetup001) Title() string { return "DPO designation required" } -func (r *gdprSetup001) Severity() Severity { return SeverityCritical } +func (r *gdprSetup001) Module() string { return "setup" } +func (r *gdprSetup001) Article() string { return "Art. 37" } +func (r *gdprSetup001) Title() string { return "DPO designation required" } +func (r *gdprSetup001) Severity() Severity { return SeverityCritical } func (r *gdprSetup001) Evaluate(g GraphQuerier) ([]RuleResult, error) { nodes, err := g.NodesByLabel("OrgSetup") @@ -92,12 +92,12 @@ func (r *gdprSetup001) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprRopa001 struct{} -func (r *gdprRopa001) ID() string { return "GDPR-ROPA-001" } +func (r *gdprRopa001) ID() string { return "GDPR-ROPA-001" } func (r *gdprRopa001) Framework() Framework { return FrameworkGDPR } -func (r *gdprRopa001) Module() string { return "ropa" } -func (r *gdprRopa001) Article() string { return "Art. 6" } -func (r *gdprRopa001) Title() string { return "Legal basis required for all processing activities" } -func (r *gdprRopa001) Severity() Severity { return SeverityCritical } +func (r *gdprRopa001) Module() string { return "ropa" } +func (r *gdprRopa001) Article() string { return "Art. 6" } +func (r *gdprRopa001) Title() string { return "Legal basis required for all processing activities" } +func (r *gdprRopa001) Severity() Severity { return SeverityCritical } func (r *gdprRopa001) Evaluate(g GraphQuerier) ([]RuleResult, error) { return checkPropertyNotEmpty(g, r.ID(), r.Severity(), "ProcessingActivity", "legalBasis") @@ -107,12 +107,12 @@ func (r *gdprRopa001) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprRopa002 struct{} -func (r *gdprRopa002) ID() string { return "GDPR-ROPA-002" } +func (r *gdprRopa002) ID() string { return "GDPR-ROPA-002" } func (r *gdprRopa002) Framework() Framework { return FrameworkGDPR } -func (r *gdprRopa002) Module() string { return "ropa" } -func (r *gdprRopa002) Article() string { return "Art. 30" } -func (r *gdprRopa002) Title() string { return "Retention period required for processing activities" } -func (r *gdprRopa002) Severity() Severity { return SeverityHigh } +func (r *gdprRopa002) Module() string { return "ropa" } +func (r *gdprRopa002) Article() string { return "Art. 30" } +func (r *gdprRopa002) Title() string { return "Retention period required for processing activities" } +func (r *gdprRopa002) Severity() Severity { return SeverityHigh } func (r *gdprRopa002) Evaluate(g GraphQuerier) ([]RuleResult, error) { return checkPropertyNotEmpty(g, r.ID(), r.Severity(), "ProcessingActivity", "retentionPeriod") @@ -122,12 +122,12 @@ func (r *gdprRopa002) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprRopa003 struct{} -func (r *gdprRopa003) ID() string { return "GDPR-ROPA-003" } +func (r *gdprRopa003) ID() string { return "GDPR-ROPA-003" } func (r *gdprRopa003) Framework() Framework { return FrameworkGDPR } -func (r *gdprRopa003) Module() string { return "ropa" } -func (r *gdprRopa003) Article() string { return "Art. 30" } -func (r *gdprRopa003) Title() string { return "Data categories must be documented per activity" } -func (r *gdprRopa003) Severity() Severity { return SeverityMedium } +func (r *gdprRopa003) Module() string { return "ropa" } +func (r *gdprRopa003) Article() string { return "Art. 30" } +func (r *gdprRopa003) Title() string { return "Data categories must be documented per activity" } +func (r *gdprRopa003) Severity() Severity { return SeverityMedium } func (r *gdprRopa003) Evaluate(g GraphQuerier) ([]RuleResult, error) { return checkHasEdge(g, r.ID(), r.Severity(), "ProcessingActivity", "PROCESSES_CATEGORY") @@ -137,12 +137,12 @@ func (r *gdprRopa003) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprRopa004 struct{} -func (r *gdprRopa004) ID() string { return "GDPR-ROPA-004" } +func (r *gdprRopa004) ID() string { return "GDPR-ROPA-004" } func (r *gdprRopa004) Framework() Framework { return FrameworkGDPR } -func (r *gdprRopa004) Module() string { return "ropa" } -func (r *gdprRopa004) Article() string { return "Art. 32" } -func (r *gdprRopa004) Title() string { return "Technical/organizational measures required" } -func (r *gdprRopa004) Severity() Severity { return SeverityHigh } +func (r *gdprRopa004) Module() string { return "ropa" } +func (r *gdprRopa004) Article() string { return "Art. 32" } +func (r *gdprRopa004) Title() string { return "Technical/organizational measures required" } +func (r *gdprRopa004) Severity() Severity { return SeverityHigh } func (r *gdprRopa004) Evaluate(g GraphQuerier) ([]RuleResult, error) { return checkHasEdge(g, r.ID(), r.Severity(), "ProcessingActivity", "PROTECTED_BY") @@ -152,12 +152,12 @@ func (r *gdprRopa004) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprDSR001 struct{} -func (r *gdprDSR001) ID() string { return "GDPR-DSR-001" } +func (r *gdprDSR001) ID() string { return "GDPR-DSR-001" } func (r *gdprDSR001) Framework() Framework { return FrameworkGDPR } -func (r *gdprDSR001) Module() string { return "dsr" } -func (r *gdprDSR001) Article() string { return "Art. 12" } -func (r *gdprDSR001) Title() string { return "No DSR requests overdue" } -func (r *gdprDSR001) Severity() Severity { return SeverityCritical } +func (r *gdprDSR001) Module() string { return "dsr" } +func (r *gdprDSR001) Article() string { return "Art. 12" } +func (r *gdprDSR001) Title() string { return "No DSR requests overdue" } +func (r *gdprDSR001) Severity() Severity { return SeverityCritical } func (r *gdprDSR001) Evaluate(g GraphQuerier) ([]RuleResult, error) { nodes, err := g.NodesByLabel("DataSubjectRequest") @@ -197,7 +197,7 @@ func (r *gdprDSR001) Evaluate(g GraphQuerier) ([]RuleResult, error) { if now.After(deadline) { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: n.ID, - Details: fmt.Sprintf("DSR overdue (deadline: %s)", deadlineStr), + Details: fmt.Sprintf("DSR overdue (deadline: %s)", deadlineStr), Severity: r.Severity(), }) } else { @@ -214,12 +214,12 @@ func (r *gdprDSR001) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprDSR002 struct{} -func (r *gdprDSR002) ID() string { return "GDPR-DSR-002" } +func (r *gdprDSR002) ID() string { return "GDPR-DSR-002" } func (r *gdprDSR002) Framework() Framework { return FrameworkGDPR } -func (r *gdprDSR002) Module() string { return "dsr" } -func (r *gdprDSR002) Article() string { return "Art. 15-22" } -func (r *gdprDSR002) Title() string { return "Completed DSRs must have response details" } -func (r *gdprDSR002) Severity() Severity { return SeverityHigh } +func (r *gdprDSR002) Module() string { return "dsr" } +func (r *gdprDSR002) Article() string { return "Art. 15-22" } +func (r *gdprDSR002) Title() string { return "Completed DSRs must have response details" } +func (r *gdprDSR002) Severity() Severity { return SeverityHigh } func (r *gdprDSR002) Evaluate(g GraphQuerier) ([]RuleResult, error) { nodes, err := g.NodesByLabel("DataSubjectRequest") @@ -236,7 +236,7 @@ func (r *gdprDSR002) Evaluate(g GraphQuerier) ([]RuleResult, error) { if resp == "" { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: n.ID, - Details: "Completed DSR missing responseDetails", + Details: "Completed DSR missing responseDetails", Severity: r.Severity(), }) } else { @@ -253,12 +253,12 @@ func (r *gdprDSR002) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprBreach001 struct{} -func (r *gdprBreach001) ID() string { return "GDPR-BREACH-001" } +func (r *gdprBreach001) ID() string { return "GDPR-BREACH-001" } func (r *gdprBreach001) Framework() Framework { return FrameworkGDPR } -func (r *gdprBreach001) Module() string { return "breach" } -func (r *gdprBreach001) Article() string { return "Art. 33" } -func (r *gdprBreach001) Title() string { return "Authority notification within 72 hours" } -func (r *gdprBreach001) Severity() Severity { return SeverityCritical } +func (r *gdprBreach001) Module() string { return "breach" } +func (r *gdprBreach001) Article() string { return "Art. 33" } +func (r *gdprBreach001) Title() string { return "Authority notification within 72 hours" } +func (r *gdprBreach001) Severity() Severity { return SeverityCritical } func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) { nodes, err := g.NodesByLabel("DataBreach") @@ -271,7 +271,7 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) { if sev != "high" && sev != "critical" { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalPass, NodeID: n.ID, - Details: "Low/medium breach — 72h rule not mandatory", + Details: "Low/medium breach — 72h rule not mandatory", Severity: r.Severity(), }) continue @@ -281,7 +281,7 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) { if notifiedStr == "" { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: n.ID, - Details: "High/critical breach not yet notified to authority", + Details: "High/critical breach not yet notified to authority", Severity: r.Severity(), }) continue @@ -291,7 +291,7 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) { if err1 != nil || err2 != nil { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalWarning, NodeID: n.ID, - Details: "Cannot parse breach timestamps", + Details: "Cannot parse breach timestamps", Severity: r.Severity(), }) continue @@ -299,13 +299,13 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) { if notified.Sub(discovered) > 72*time.Hour { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: n.ID, - Details: fmt.Sprintf("Notification took %s (> 72h)", notified.Sub(discovered)), + Details: fmt.Sprintf("Notification took %s (> 72h)", notified.Sub(discovered)), Severity: r.Severity(), }) } else { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalPass, NodeID: n.ID, - Details: "Authority notified within 72h", + Details: "Authority notified within 72h", Severity: r.Severity(), }) } @@ -317,12 +317,12 @@ func (r *gdprBreach001) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprBreach002 struct{} -func (r *gdprBreach002) ID() string { return "GDPR-BREACH-002" } +func (r *gdprBreach002) ID() string { return "GDPR-BREACH-002" } func (r *gdprBreach002) Framework() Framework { return FrameworkGDPR } -func (r *gdprBreach002) Module() string { return "breach" } -func (r *gdprBreach002) Article() string { return "Art. 34" } -func (r *gdprBreach002) Title() string { return "Data subjects notified for special category breaches" } -func (r *gdprBreach002) Severity() Severity { return SeverityCritical } +func (r *gdprBreach002) Module() string { return "breach" } +func (r *gdprBreach002) Article() string { return "Art. 34" } +func (r *gdprBreach002) Title() string { return "Data subjects notified for special category breaches" } +func (r *gdprBreach002) Severity() Severity { return SeverityCritical } func (r *gdprBreach002) Evaluate(g GraphQuerier) ([]RuleResult, error) { nodes, err := g.NodesByLabel("DataBreach") @@ -343,7 +343,7 @@ func (r *gdprBreach002) Evaluate(g GraphQuerier) ([]RuleResult, error) { if !involvesSpecial { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalPass, NodeID: n.ID, - Details: "Breach does not involve special categories", + Details: "Breach does not involve special categories", Severity: r.Severity(), }) continue @@ -352,7 +352,7 @@ func (r *gdprBreach002) Evaluate(g GraphQuerier) ([]RuleResult, error) { if notifiedStr == "" { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: n.ID, - Details: "Breach involves special categories but subjects not notified", + Details: "Breach involves special categories but subjects not notified", Severity: r.Severity(), }) } else { @@ -369,12 +369,12 @@ func (r *gdprBreach002) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprDPIA001 struct{} -func (r *gdprDPIA001) ID() string { return "GDPR-DPIA-001" } +func (r *gdprDPIA001) ID() string { return "GDPR-DPIA-001" } func (r *gdprDPIA001) Framework() Framework { return FrameworkGDPR } -func (r *gdprDPIA001) Module() string { return "dpia" } -func (r *gdprDPIA001) Article() string { return "Art. 35" } -func (r *gdprDPIA001) Title() string { return "DPIA required for high-risk processing" } -func (r *gdprDPIA001) Severity() Severity { return SeverityCritical } +func (r *gdprDPIA001) Module() string { return "dpia" } +func (r *gdprDPIA001) Article() string { return "Art. 35" } +func (r *gdprDPIA001) Title() string { return "DPIA required for high-risk processing" } +func (r *gdprDPIA001) Severity() Severity { return SeverityCritical } func (r *gdprDPIA001) Evaluate(g GraphQuerier) ([]RuleResult, error) { activities, err := g.NodesByLabel("ProcessingActivity") @@ -408,7 +408,7 @@ func (r *gdprDPIA001) Evaluate(g GraphQuerier) ([]RuleResult, error) { } else { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: a.ID, - Details: "High-risk activity missing DPIA", + Details: "High-risk activity missing DPIA", Severity: r.Severity(), }) } @@ -420,12 +420,12 @@ func (r *gdprDPIA001) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprDPIA002 struct{} -func (r *gdprDPIA002) ID() string { return "GDPR-DPIA-002" } +func (r *gdprDPIA002) ID() string { return "GDPR-DPIA-002" } func (r *gdprDPIA002) Framework() Framework { return FrameworkGDPR } -func (r *gdprDPIA002) Module() string { return "dpia" } -func (r *gdprDPIA002) Article() string { return "Art. 35" } -func (r *gdprDPIA002) Title() string { return "Every DPIA must identify risks" } -func (r *gdprDPIA002) Severity() Severity { return SeverityHigh } +func (r *gdprDPIA002) Module() string { return "dpia" } +func (r *gdprDPIA002) Article() string { return "Art. 35" } +func (r *gdprDPIA002) Title() string { return "Every DPIA must identify risks" } +func (r *gdprDPIA002) Severity() Severity { return SeverityHigh } func (r *gdprDPIA002) Evaluate(g GraphQuerier) ([]RuleResult, error) { dpias, err := g.NodesByLabel("DPIA") @@ -450,7 +450,7 @@ func (r *gdprDPIA002) Evaluate(g GraphQuerier) ([]RuleResult, error) { } else { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: d.ID, - Details: "DPIA has no identified risks", + Details: "DPIA has no identified risks", Severity: r.Severity(), }) } @@ -462,12 +462,12 @@ func (r *gdprDPIA002) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprProc001 struct{} -func (r *gdprProc001) ID() string { return "GDPR-PROC-001" } +func (r *gdprProc001) ID() string { return "GDPR-PROC-001" } func (r *gdprProc001) Framework() Framework { return FrameworkGDPR } -func (r *gdprProc001) Module() string { return "processor" } -func (r *gdprProc001) Article() string { return "Art. 28" } -func (r *gdprProc001) Title() string { return "Active processors must have signed contracts" } -func (r *gdprProc001) Severity() Severity { return SeverityHigh } +func (r *gdprProc001) Module() string { return "processor" } +func (r *gdprProc001) Article() string { return "Art. 28" } +func (r *gdprProc001) Title() string { return "Active processors must have signed contracts" } +func (r *gdprProc001) Severity() Severity { return SeverityHigh } func (r *gdprProc001) Evaluate(g GraphQuerier) ([]RuleResult, error) { nodes, err := g.NodesByLabel("DataProcessor") @@ -485,7 +485,7 @@ func (r *gdprProc001) Evaluate(g GraphQuerier) ([]RuleResult, error) { } else { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: n.ID, - Details: fmt.Sprintf("Processor contract status: %s", status), + Details: fmt.Sprintf("Processor contract status: %s", status), Severity: r.Severity(), }) } @@ -497,12 +497,12 @@ func (r *gdprProc001) Evaluate(g GraphQuerier) ([]RuleResult, error) { type gdprEvidence001 struct{} -func (r *gdprEvidence001) ID() string { return "GDPR-EVIDENCE-001" } +func (r *gdprEvidence001) ID() string { return "GDPR-EVIDENCE-001" } func (r *gdprEvidence001) Framework() Framework { return FrameworkGDPR } -func (r *gdprEvidence001) Module() string { return "evidence" } -func (r *gdprEvidence001) Article() string { return "Art. 5(2)" } -func (r *gdprEvidence001) Title() string { return "Compliant checklist items must have evidence" } -func (r *gdprEvidence001) Severity() Severity { return SeverityMedium } +func (r *gdprEvidence001) Module() string { return "evidence" } +func (r *gdprEvidence001) Article() string { return "Art. 5(2)" } +func (r *gdprEvidence001) Title() string { return "Compliant checklist items must have evidence" } +func (r *gdprEvidence001) Severity() Severity { return SeverityMedium } func (r *gdprEvidence001) Evaluate(g GraphQuerier) ([]RuleResult, error) { nodes, err := g.NodesByLabel("ChecklistItem") @@ -531,7 +531,7 @@ func (r *gdprEvidence001) Evaluate(g GraphQuerier) ([]RuleResult, error) { } else { results = append(results, RuleResult{ RuleID: r.ID(), Status: EvalFail, NodeID: n.ID, - Details: "Compliant checklist item has no evidence attached", + Details: "Compliant checklist item has no evidence attached", Severity: r.Severity(), }) } @@ -588,13 +588,13 @@ func checkHasEdge(g GraphQuerier, ruleID string, sev Severity, label, edgeLabel if found { results = append(results, RuleResult{ RuleID: ruleID, Status: EvalPass, NodeID: n.ID, - Details: fmt.Sprintf("%s has %s edge", label, edgeLabel), + Details: fmt.Sprintf("%s has %s edge", label, edgeLabel), Severity: sev, }) } else { results = append(results, RuleResult{ RuleID: ruleID, Status: EvalFail, NodeID: n.ID, - Details: fmt.Sprintf("%s missing %s edge", label, edgeLabel), + Details: fmt.Sprintf("%s missing %s edge", label, edgeLabel), Severity: sev, }) } diff --git a/internal/compliance/hardcoded_test.go b/internal/compliance/hardcoded_test.go index fbd0964..ffea617 100644 --- a/internal/compliance/hardcoded_test.go +++ b/internal/compliance/hardcoded_test.go @@ -182,3 +182,93 @@ func TestRegisterGDPRRules(t *testing.T) { t.Fatalf("expected 13 GDPR rules, got %d", len(rules)) } } + +func TestGDPRWrapperRulesAndEvidence(t *testing.T) { + q := newMockQuerier() + q.nodes["ProcessingActivity"] = NodeList{ + {ID: "n:PA:1", Label: "ProcessingActivity", Properties: map[string]any{"retentionPeriod": "30d", "riskLevel": "high"}}, + {ID: "n:PA:2", Label: "ProcessingActivity", Properties: map[string]any{}}, + } + q.nodes["DataSubjectRequest"] = NodeList{ + {ID: "n:DSR:1", Properties: map[string]any{"status": "completed", "responseDetails": "emailed data export"}}, + {ID: "n:DSR:2", Properties: map[string]any{"status": "completed"}}, + } + q.nodes["DPIA"] = NodeList{ + {ID: "n:DPIA:1", Properties: map[string]any{}}, + {ID: "n:DPIA:2", Properties: map[string]any{}}, + } + q.nodes["ChecklistItem"] = NodeList{ + {ID: "n:CL:1", Properties: map[string]any{"status": "compliant"}}, + {ID: "n:CL:2", Properties: map[string]any{"status": "compliant"}}, + } + q.edges["n:PA:1"] = EdgeList{ + {Label: "PROCESSES_CATEGORY", To: "n:Cat:1"}, + {Label: "PROTECTED_BY", To: "n:SM:1"}, + } + q.edges["n:DPIA:1"] = EdgeList{{Label: "DPIA_FOR", To: "n:PA:1"}} + q.edges["n:DPIA:2"] = EdgeList{{Label: "HAS_RISK", To: "n:Risk:1"}} + q.edges["n:CL:1"] = EdgeList{{Label: "EVIDENCED_BY", To: "n:EV:1"}} + + tests := []struct { + name string + rule Rule + wantCount int + statuses []EvalStatus + }{ + {"ropa002", &gdprRopa002{}, 2, []EvalStatus{EvalPass, EvalFail}}, + {"ropa003", &gdprRopa003{}, 2, []EvalStatus{EvalPass, EvalFail}}, + {"ropa004", &gdprRopa004{}, 2, []EvalStatus{EvalPass, EvalFail}}, + {"dsr002", &gdprDSR002{}, 2, []EvalStatus{EvalPass, EvalFail}}, + {"dpia001", &gdprDPIA001{}, 1, []EvalStatus{EvalPass}}, + {"dpia002", &gdprDPIA002{}, 2, []EvalStatus{EvalFail, EvalPass}}, + {"evidence001", &gdprEvidence001{}, 2, []EvalStatus{EvalPass, EvalFail}}, + } + + for _, tt := range tests { + results, err := tt.rule.Evaluate(q) + if err != nil { + t.Fatalf("%s Evaluate: %v", tt.name, err) + } + if len(results) != tt.wantCount { + t.Fatalf("%s expected %d results, got %d", tt.name, tt.wantCount, len(results)) + } + for i, status := range tt.statuses { + if results[i].Status != status { + t.Fatalf("%s result[%d] = %s, want %s", tt.name, i, results[i].Status, status) + } + } + } +} + +func TestGDPRBreach002AndFlow004Evaluate(t *testing.T) { + q := newMockQuerier() + q.nodes["DataBreach"] = NodeList{ + {ID: "n:Breach:1", Properties: map[string]any{}}, + {ID: "n:Breach:2", Properties: map[string]any{"subjectsNotifiedAt": time.Now().UTC().Format(time.RFC3339)}}, + } + q.edges["n:Breach:2"] = EdgeList{{Label: "BREACH_INVOLVES", To: "n:Cat:1"}} + + breachResults, err := (&gdprBreach002{}).Evaluate(q) + if err != nil { + t.Fatalf("gdprBreach002 Evaluate: %v", err) + } + if len(breachResults) != 2 { + t.Fatalf("expected 2 breach results, got %d", len(breachResults)) + } + if breachResults[0].Status != EvalPass || breachResults[1].Status != EvalPass { + t.Fatalf("unexpected breach statuses: %+v", breachResults) + } + + q.nodes["DataFlow"] = NodeList{ + {ID: "n:Flow:1", Properties: map[string]any{"legalBasis": "legitimate_interest"}}, + } + q.edges["n:Flow:1"] = EdgeList{{Label: "CARRIES", To: "n:Cat:2"}} + + flowResults, err := (&gdprFlow004{}).Evaluate(q) + if err != nil { + t.Fatalf("gdprFlow004 Evaluate: %v", err) + } + if len(flowResults) != 0 { + t.Fatalf("expected no flow004 results, got %d", len(flowResults)) + } +} diff --git a/internal/compliance/inspection.go b/internal/compliance/inspection.go index 0dc6223..741fe25 100644 --- a/internal/compliance/inspection.go +++ b/internal/compliance/inspection.go @@ -25,6 +25,7 @@ import ( // InspectionStatus tracks the lifecycle of an inspection. type InspectionStatus string +// Inspection lifecycle states. const ( InspectionDraft InspectionStatus = "draft" InspectionInProgress InspectionStatus = "in_progress" @@ -36,6 +37,7 @@ const ( // FindingStatus tracks the lifecycle of an inspection finding. type FindingStatus string +// Finding lifecycle states. const ( FindingOpen FindingStatus = "open" FindingRemediated FindingStatus = "remediated" @@ -46,6 +48,7 @@ const ( // RemediationStatus tracks the lifecycle of a remediation action. type RemediationStatus string +// Remediation lifecycle states. const ( RemediationPending RemediationStatus = "pending" RemediationInProgress RemediationStatus = "in_progress" @@ -111,8 +114,8 @@ func SignOffInspection(g *graph.Graph, inspectionID graph.NodeID, approverID str } _, err = g.UpsertNode(inspectionID, node.Label, graph.Properties{ - "status": string(InspectionSignedOff), - "approverId": approverID, + "status": string(InspectionSignedOff), + "approverId": approverID, "signedOffAt": time.Now().UTC().Format(time.RFC3339), }) if err != nil { diff --git a/internal/compliance/rule_metadata_test.go b/internal/compliance/rule_metadata_test.go new file mode 100644 index 0000000..c6a2d03 --- /dev/null +++ b/internal/compliance/rule_metadata_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 Scalytics, Inc. +// Copyright 2026 Mirko Kämpf +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compliance + +import "testing" + +func TestRegisterRuleSets(t *testing.T) { + e := NewEngine(nil) + RegisterGDPRRules(e) + RegisterDataFlowRules(e) + + rules := e.Rules() + if len(rules) != 20 { + t.Fatalf("expected 20 registered rules, got %d", len(rules)) + } + + if got := len(e.RulesByFramework(FrameworkGDPR)); got != 20 { + t.Fatalf("expected 20 GDPR rules, got %d", got) + } +} + +func TestBuiltInRuleMetadata(t *testing.T) { + tests := []struct { + rule Rule + id string + framework Framework + module string + article string + title string + severity Severity + }{ + {&gdprSetup001{}, "GDPR-SETUP-001", FrameworkGDPR, "setup", "Art. 37", "DPO designation required", SeverityCritical}, + {&gdprRopa001{}, "GDPR-ROPA-001", FrameworkGDPR, "ropa", "Art. 6", "Legal basis required for all processing activities", SeverityCritical}, + {&gdprRopa002{}, "GDPR-ROPA-002", FrameworkGDPR, "ropa", "Art. 30", "Retention period required for processing activities", SeverityHigh}, + {&gdprRopa003{}, "GDPR-ROPA-003", FrameworkGDPR, "ropa", "Art. 30", "Data categories must be documented per activity", SeverityMedium}, + {&gdprRopa004{}, "GDPR-ROPA-004", FrameworkGDPR, "ropa", "Art. 32", "Technical/organizational measures required", SeverityHigh}, + {&gdprDSR001{}, "GDPR-DSR-001", FrameworkGDPR, "dsr", "Art. 12", "No DSR requests overdue", SeverityCritical}, + {&gdprDSR002{}, "GDPR-DSR-002", FrameworkGDPR, "dsr", "Art. 15-22", "Completed DSRs must have response details", SeverityHigh}, + {&gdprBreach001{}, "GDPR-BREACH-001", FrameworkGDPR, "breach", "Art. 33", "Authority notification within 72 hours", SeverityCritical}, + {&gdprBreach002{}, "GDPR-BREACH-002", FrameworkGDPR, "breach", "Art. 34", "Data subjects notified for special category breaches", SeverityCritical}, + {&gdprDPIA001{}, "GDPR-DPIA-001", FrameworkGDPR, "dpia", "Art. 35", "DPIA required for high-risk processing", SeverityCritical}, + {&gdprDPIA002{}, "GDPR-DPIA-002", FrameworkGDPR, "dpia", "Art. 35", "Every DPIA must identify risks", SeverityHigh}, + {&gdprProc001{}, "GDPR-PROC-001", FrameworkGDPR, "processor", "Art. 28", "Active processors must have signed contracts", SeverityHigh}, + {&gdprEvidence001{}, "GDPR-EVIDENCE-001", FrameworkGDPR, "evidence", "Art. 5(2)", "Compliant checklist items must have evidence", SeverityMedium}, + {&gdprFlow001{}, "GDPR-FLOW-001", FrameworkGDPR, "dataflow", "Art. 30", "Data categories must be documented per data flow", SeverityHigh}, + {&gdprFlow002{}, "GDPR-FLOW-002", FrameworkGDPR, "dataflow", "Art. 6", "Legal basis required for every data flow", SeverityCritical}, + {&gdprFlow003{}, "GDPR-FLOW-003", FrameworkGDPR, "dataflow", "Art. 44-49", "International transfers require adequate safeguards", SeverityCritical}, + {&gdprFlow004{}, "GDPR-FLOW-004", FrameworkGDPR, "dataflow", "Art. 9", "Special category data flows require explicit consent", SeverityCritical}, + {&gdprFlow005{}, "GDPR-FLOW-005", FrameworkGDPR, "dataflow", "Art. 30", "Active processing activities should have data flows defined", SeverityMedium}, + {&gdprInsp001{}, "GDPR-INSP-001", FrameworkGDPR, "inspection", "Art. 5(2)", "No inspection findings overdue", SeverityHigh}, + {&gdprInsp002{}, "GDPR-INSP-002", FrameworkGDPR, "inspection", "Art. 5(2)", "Completed remediations must be verified", SeverityMedium}, + } + + for _, tt := range tests { + if got := tt.rule.ID(); got != tt.id { + t.Fatalf("%T ID() = %q, want %q", tt.rule, got, tt.id) + } + if got := tt.rule.Framework(); got != tt.framework { + t.Fatalf("%T Framework() = %q, want %q", tt.rule, got, tt.framework) + } + if got := tt.rule.Module(); got != tt.module { + t.Fatalf("%T Module() = %q, want %q", tt.rule, got, tt.module) + } + if got := tt.rule.Article(); got != tt.article { + t.Fatalf("%T Article() = %q, want %q", tt.rule, got, tt.article) + } + if got := tt.rule.Title(); got != tt.title { + t.Fatalf("%T Title() = %q, want %q", tt.rule, got, tt.title) + } + if got := tt.rule.Severity(); got != tt.severity { + t.Fatalf("%T Severity() = %q, want %q", tt.rule, got, tt.severity) + } + } +} + +func TestYAMLRuleMetadataAndSeverityFallback(t *testing.T) { + def := &YAMLRuleDef{ + RuleID: "YAML-001", + FrameworkStr: "gdpr", + ModuleStr: "yaml", + ArticleStr: "Art. 99", + TitleStr: "YAML title", + SeverityStr: "unknown", + } + + rule := def.ToRule() + if rule.ID() != "YAML-001" || rule.Framework() != FrameworkGDPR || rule.Module() != "yaml" { + t.Fatalf("unexpected yaml rule metadata: %+v", rule) + } + if rule.Article() != "Art. 99" || rule.Title() != "YAML title" { + t.Fatalf("unexpected yaml article/title: %q %q", rule.Article(), rule.Title()) + } + if rule.Severity() != SeverityMedium { + t.Fatalf("unexpected default severity: %q", rule.Severity()) + } +} diff --git a/internal/compliance/scheduler.go b/internal/compliance/scheduler.go index a91a28f..194c4e7 100644 --- a/internal/compliance/scheduler.go +++ b/internal/compliance/scheduler.go @@ -37,7 +37,7 @@ func NewScheduler(engine *Engine, interval time.Duration, autoScan bool) *Schedu } } -// Run starts the scheduled scan loop. Blocks until ctx is cancelled. +// Run starts the scheduled scan loop. Blocks until ctx is canceled. func (s *Scheduler) Run(ctx context.Context) error { if !s.autoScan || s.interval <= 0 { <-ctx.Done() diff --git a/internal/compliance/scoring_test.go b/internal/compliance/scoring_test.go index d4c7e1f..f91085a 100644 --- a/internal/compliance/scoring_test.go +++ b/internal/compliance/scoring_test.go @@ -52,7 +52,7 @@ func TestCalculateScore_AllFail(t *testing.T) { func TestCalculateScore_Mixed(t *testing.T) { results := []RuleResult{ {RuleID: "R1", Status: EvalPass, Severity: SeverityCritical}, // weight 3 - {RuleID: "R2", Status: EvalFail, Severity: SeverityLow}, // weight 0.5 + {RuleID: "R2", Status: EvalFail, Severity: SeverityLow}, // weight 0.5 } // Expected: 3 / 3.5 * 100 = 85.7 score := CalculateScore(results) diff --git a/internal/compliance/types.go b/internal/compliance/types.go index abdde8a..a02c2ad 100644 --- a/internal/compliance/types.go +++ b/internal/compliance/types.go @@ -20,6 +20,7 @@ import "time" // Framework identifies a compliance framework. type Framework string +// Supported compliance frameworks. const ( FrameworkGDPR Framework = "gdpr" FrameworkSOC2 Framework = "soc2" @@ -29,6 +30,7 @@ const ( // Severity levels for compliance rules. type Severity string +// Severity tiers from highest to lowest impact. const ( SeverityCritical Severity = "critical" SeverityHigh Severity = "high" @@ -36,7 +38,7 @@ const ( SeverityLow Severity = "low" ) -// SeverityWeight returns the numeric weight for score calculation. +// Weight returns the numeric weight for score calculation. func (s Severity) Weight() float64 { switch s { case SeverityCritical: @@ -55,6 +57,7 @@ func (s Severity) Weight() float64 { // EvalStatus is the result status of a rule evaluation. type EvalStatus string +// Possible rule evaluation outcomes. const ( EvalPass EvalStatus = "pass" EvalFail EvalStatus = "fail" @@ -117,27 +120,27 @@ type ScanRequest struct { // ScanResult is the aggregated result of a compliance scan. type ScanResult struct { - ScanID string `json:"scanId"` - Framework Framework `json:"framework"` - TriggeredBy string `json:"triggeredBy"` - StartedAt time.Time `json:"startedAt"` - CompletedAt time.Time `json:"completedAt"` - PassCount int `json:"passCount"` - FailCount int `json:"failCount"` - WarningCount int `json:"warningCount"` - NACount int `json:"naCount"` - Score float64 `json:"score"` - Evaluations []RuleResult `json:"evaluations"` + ScanID string `json:"scanId"` + Framework Framework `json:"framework"` + TriggeredBy string `json:"triggeredBy"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt"` + PassCount int `json:"passCount"` + FailCount int `json:"failCount"` + WarningCount int `json:"warningCount"` + NACount int `json:"naCount"` + Score float64 `json:"score"` + Evaluations []RuleResult `json:"evaluations"` } // ScoreSummary holds per-framework compliance scores. type ScoreSummary struct { - Framework Framework `json:"framework"` - Score float64 `json:"score"` - PassCount int `json:"passCount"` - FailCount int `json:"failCount"` - TotalRules int `json:"totalRules"` - LastScanAt string `json:"lastScanAt,omitempty"` + Framework Framework `json:"framework"` + Score float64 `json:"score"` + PassCount int `json:"passCount"` + FailCount int `json:"failCount"` + TotalRules int `json:"totalRules"` + LastScanAt string `json:"lastScanAt,omitempty"` } // DashboardData aggregates data for the compliance dashboard. diff --git a/internal/compliance/yaml_loader.go b/internal/compliance/yaml_loader.go index 5619d36..c0e8d44 100644 --- a/internal/compliance/yaml_loader.go +++ b/internal/compliance/yaml_loader.go @@ -57,7 +57,7 @@ func (e *Engine) LoadYAMLRules(dir string) error { } func (e *Engine) loadYAMLFile(path string) error { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // path comes from filepath.WalkDir over operator-configured rules dir if err != nil { return fmt.Errorf("read %s: %w", path, err) } diff --git a/internal/compliance/yaml_rule.go b/internal/compliance/yaml_rule.go index e276ea9..50db912 100644 --- a/internal/compliance/yaml_rule.go +++ b/internal/compliance/yaml_rule.go @@ -42,11 +42,11 @@ type yamlRule struct { def *YAMLRuleDef } -func (r *yamlRule) ID() string { return r.def.RuleID } +func (r *yamlRule) ID() string { return r.def.RuleID } func (r *yamlRule) Framework() Framework { return Framework(r.def.FrameworkStr) } -func (r *yamlRule) Module() string { return r.def.ModuleStr } -func (r *yamlRule) Article() string { return r.def.ArticleStr } -func (r *yamlRule) Title() string { return r.def.TitleStr } +func (r *yamlRule) Module() string { return r.def.ModuleStr } +func (r *yamlRule) Article() string { return r.def.ArticleStr } +func (r *yamlRule) Title() string { return r.def.TitleStr } func (r *yamlRule) Severity() Severity { switch Severity(r.def.SeverityStr) { diff --git a/internal/demo/compliance_scenario.go b/internal/demo/compliance_scenario.go index 19309f9..ac39117 100644 --- a/internal/demo/compliance_scenario.go +++ b/internal/demo/compliance_scenario.go @@ -206,8 +206,8 @@ func SeedComplianceScenario(g *graph.Graph) (*ComplianceResult, error) { deadline time.Time completed bool }{ - {"access", "in_progress", now.Add(20 * 24 * time.Hour), false}, // Active, within SLA - {"erasure", "pending", now.Add(-5 * 24 * time.Hour), false}, // Overdue! + {"access", "in_progress", now.Add(20 * 24 * time.Hour), false}, // Active, within SLA + {"erasure", "pending", now.Add(-5 * 24 * time.Hour), false}, // Overdue! } for _, d := range dsrs { props := graph.Properties{ @@ -372,11 +372,11 @@ func SeedComplianceScenario(g *graph.Graph) (*ComplianceResult, error) { transferType string safeguard string legalBasis string - fromPA int // index into paNodes - toPA int // -1 if to processor - toProc int // index into procNodes; -1 if to activity - catIdxs []int // data category indices - lbIdx int // legal basis index + fromPA int // index into paNodes + toPA int // -1 if to processor + toProc int // index into procNodes; -1 if to activity + catIdxs []int // data category indices + lbIdx int // legal basis index }{ { name: "Internal Analytics Pipeline", transferType: "internal", @@ -444,11 +444,11 @@ func SeedComplianceScenario(g *graph.Graph) (*ComplianceResult, error) { // Finding 1: remediated finding1, err := g.CreateNode("InspectionFinding", graph.Properties{ - "title": "Missing retention period for Employee Health Monitoring", - "severity": "high", - "status": "remediated", - "targetDate": now.Add(30 * 24 * time.Hour).Format(time.RFC3339), - "createdAt": now.Format(time.RFC3339), + "title": "Missing retention period for Employee Health Monitoring", + "severity": "high", + "status": "remediated", + "targetDate": now.Add(30 * 24 * time.Hour).Format(time.RFC3339), + "createdAt": now.Format(time.RFC3339), }) if err != nil { return nil, fmt.Errorf("create finding 1: %w", err) diff --git a/internal/demo/scenario_test.go b/internal/demo/scenario_test.go index d97ecb7..295d2d8 100644 --- a/internal/demo/scenario_test.go +++ b/internal/demo/scenario_test.go @@ -77,10 +77,10 @@ func TestBlogTeamScenarioIngestion(t *testing.T) { // Verify node counts by label. assertNodeCount(t, g, "Agent", 4) assertNodeCount(t, g, "Conversation", 1) - assertNodeCount(t, g, "Skill", 9) // web_search, summarize, deep_search, rewrite, tone_check, citation_check, ascii_doc, proofread, format_html - assertNodeCount(t, g, "SharedMemory", 3) // research-findings, editorial-notes, final-blog - assertNodeCount(t, g, "AuditEvent", 4) // 3 task_completed + 1 pipeline_completed - assertMinNodeCount(t, g, "Message", 15) // requests + responses + skill_requests + skill_responses + assertNodeCount(t, g, "Skill", 9) // web_search, summarize, deep_search, rewrite, tone_check, citation_check, ascii_doc, proofread, format_html + assertNodeCount(t, g, "SharedMemory", 3) // research-findings, editorial-notes, final-blog + assertNodeCount(t, g, "AuditEvent", 4) // 3 task_completed + 1 pipeline_completed + assertMinNodeCount(t, g, "Message", 15) // requests + responses + skill_requests + skill_responses } func TestRunReflections(t *testing.T) { diff --git a/internal/reflect/analyzer.go b/internal/reflect/analyzer.go index 933ae65..c6864af 100644 --- a/internal/reflect/analyzer.go +++ b/internal/reflect/analyzer.go @@ -42,6 +42,7 @@ type AnalysisResult struct { // EntityType classifies a recognized entity. type EntityType string +// Recognized entity classifications. const ( EntityAgent EntityType = "agent" EntitySkill EntityType = "skill" diff --git a/internal/server/compliance_dataflow.go b/internal/server/compliance_dataflow.go index de90dab..a9fd8f2 100644 --- a/internal/server/compliance_dataflow.go +++ b/internal/server/compliance_dataflow.go @@ -81,12 +81,12 @@ func handleDataFlowList(g *graph.Graph) http.HandlerFunc { func handleDataFlowCreate(g *graph.Graph) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var body struct { - Props map[string]any `json:"properties"` - FromActivity string `json:"fromActivityId"` - ToActivity string `json:"toActivityId"` - ToProcessor string `json:"toProcessorId"` - CategoryIDs []string `json:"categoryIds"` - LegalBasisID string `json:"legalBasisId"` + Props map[string]any `json:"properties"` + FromActivity string `json:"fromActivityId"` + ToActivity string `json:"toActivityId"` + ToProcessor string `json:"toProcessorId"` + CategoryIDs []string `json:"categoryIds"` + LegalBasisID string `json:"legalBasisId"` } if err := readJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, err.Error()) diff --git a/internal/server/compliance_dataflow_test.go b/internal/server/compliance_dataflow_test.go new file mode 100644 index 0000000..c32b9bb --- /dev/null +++ b/internal/server/compliance_dataflow_test.go @@ -0,0 +1,149 @@ +// Copyright 2026 Scalytics, Inc. +// Copyright 2026 Mirko Kämpf +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/scalytics/kafgraph/internal/graph" +) + +func complianceRequest(t *testing.T, tsURL, method, path, body string) *http.Response { + t.Helper() + var reqBody *strings.Reader + if body == "" { + reqBody = strings.NewReader("") + } else { + reqBody = strings.NewReader(body) + } + req, err := http.NewRequest(method, tsURL+path, reqBody) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +func decodeBody(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + defer func() { _ = resp.Body.Close() }() + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + return body +} + +func TestComplianceDataFlowCRUDAndValidate(t *testing.T) { + ts, _, g := setupComplianceTestServer(t) + + src, err := g.CreateNode("ProcessingActivity", graph.Properties{"name": "HR Intake", "status": "active"}) + require.NoError(t, err) + dst, err := g.CreateNode("ProcessingActivity", graph.Properties{"name": "Payroll", "status": "active"}) + require.NoError(t, err) + proc, err := g.CreateNode("DataProcessor", graph.Properties{"name": "Vendor X"}) + require.NoError(t, err) + cat, err := g.CreateNode("DataCategory", graph.Properties{"name": "Employee Data", "isSpecial": false}) + require.NoError(t, err) + basis, err := g.CreateNode("LegalBasis", graph.Properties{"name": "Contract"}) + require.NoError(t, err) + _, err = g.CreateNode("ProcessingActivity", graph.Properties{"name": "Orphan Activity", "status": "active"}) + require.NoError(t, err) + + createResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/gdpr/data-flows", `{ + "properties":{"name":"HR -> Payroll","transferType":"internal","legalBasis":"contract"}, + "fromActivityId":"`+string(src.ID)+`", + "toActivityId":"`+string(dst.ID)+`", + "toProcessorId":"`+string(proc.ID)+`", + "categoryIds":["`+string(cat.ID)+`"], + "legalBasisId":"`+string(basis.ID)+`" + }`) + require.Equal(t, http.StatusCreated, createResp.StatusCode) + created := decodeBody(t, createResp) + flowID, ok := created["id"].(string) + require.True(t, ok) + assert.Equal(t, "DataFlow", created["label"]) + require.NotEmpty(t, created["properties"].(map[string]any)["createdAt"]) + + listResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/gdpr/data-flows", "") + require.Equal(t, http.StatusOK, listResp.StatusCode) + listBody := decodeBody(t, listResp) + assert.Equal(t, float64(1), listBody["total"]) + items := listBody["items"].([]any) + item := items[0].(map[string]any) + assert.Equal(t, "HR -> Payroll", item["properties"].(map[string]any)["name"]) + assert.Contains(t, item["fromNames"], "HR Intake") + assert.Contains(t, item["toNames"], "Payroll") + assert.Contains(t, item["toNames"], "Vendor X") + assert.Contains(t, item["categoryNames"], "Employee Data") + + detailResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/gdpr/data-flows/"+flowID, "") + require.Equal(t, http.StatusOK, detailResp.StatusCode) + detailBody := decodeBody(t, detailResp) + assert.Len(t, detailBody["edges"], 6) + + updateResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/gdpr/data-flows/"+flowID, `{"name":"HR -> Payroll v2","legalBasis":"contract"}`) + require.Equal(t, http.StatusOK, updateResp.StatusCode) + updateBody := decodeBody(t, updateResp) + assert.Equal(t, "HR -> Payroll v2", updateBody["properties"].(map[string]any)["name"]) + require.NotEmpty(t, updateBody["properties"].(map[string]any)["updatedAt"]) + + mapResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/gdpr/data-flows/map", "") + require.Equal(t, http.StatusOK, mapResp.StatusCode) + mapBody := decodeBody(t, mapResp) + assert.NotEmpty(t, mapBody["nodes"]) + assert.NotEmpty(t, mapBody["edges"]) + + validateResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/gdpr/data-flows/validate", `{"inspectionId":"insp-1"}`) + require.Equal(t, http.StatusOK, validateResp.StatusCode) + validateBody := decodeBody(t, validateResp) + assert.Equal(t, float64(3), validateBody["total"]) + assert.Equal(t, float64(1), validateBody["pass"]) + assert.Equal(t, float64(2), validateBody["warnings"]) + assert.Equal(t, float64(0), validateBody["fail"]) + + validations, err := g.NodesByLabel("DataFlowValidation") + require.NoError(t, err) + assert.Len(t, validations, 1) + + deleteResp := complianceRequest(t, ts.URL, http.MethodDelete, "/api/v2/compliance/gdpr/data-flows/"+flowID, "") + require.Equal(t, http.StatusOK, deleteResp.StatusCode) + deleteBody := decodeBody(t, deleteResp) + assert.Equal(t, "deleted", deleteBody["status"]) + + missingResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/gdpr/data-flows/"+flowID, "") + require.Equal(t, http.StatusNotFound, missingResp.StatusCode) +} + +func TestComplianceDataFlowErrors(t *testing.T) { + ts, _, _ := setupComplianceTestServer(t) + + createResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/gdpr/data-flows", `{bad}`) + require.Equal(t, http.StatusBadRequest, createResp.StatusCode) + _ = decodeBody(t, createResp) + + updateResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/gdpr/data-flows/", `{"name":"broken"}`) + require.Equal(t, http.StatusBadRequest, updateResp.StatusCode) + _ = decodeBody(t, updateResp) + + deleteResp := complianceRequest(t, ts.URL, http.MethodDelete, "/api/v2/compliance/gdpr/data-flows/", "") + require.Equal(t, http.StatusBadRequest, deleteResp.StatusCode) + _ = decodeBody(t, deleteResp) +} diff --git a/internal/server/compliance_gdpr_test.go b/internal/server/compliance_gdpr_test.go index 87fd164..f85bb8c 100644 --- a/internal/server/compliance_gdpr_test.go +++ b/internal/server/compliance_gdpr_test.go @@ -20,6 +20,8 @@ import ( "net/http" "strings" "testing" + + "github.com/scalytics/kafgraph/internal/graph" ) func TestGDPRSetupCRUD(t *testing.T) { @@ -112,3 +114,88 @@ func TestGDPRDSRSLA(t *testing.T) { t.Fatalf("expected 1 SLA item, got %v", total) } } + +func TestGDPRDetailUpdateAndDelete(t *testing.T) { + ts, _, g := setupComplianceTestServer(t) + + lb, err := g.CreateNode("LegalBasis", graph.Properties{"name": "Contract"}) + if err != nil { + t.Fatal(err) + } + sm, err := g.CreateNode("SecurityMeasure", graph.Properties{"name": "Encryption"}) + if err != nil { + t.Fatal(err) + } + cat, err := g.CreateNode("DataCategory", graph.Properties{"name": "Employee Data"}) + if err != nil { + t.Fatal(err) + } + + ropaBody := `{"name":"Analytics","purpose":"Insights","legalBasisId":"` + string(lb.ID) + `","securityMeasureId":"` + string(sm.ID) + `","categoryIds":["` + string(cat.ID) + `"]}` + resp, err := http.Post(ts.URL+"/api/v2/compliance/gdpr/ropa", "application/json", strings.NewReader(ropaBody)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + var created map[string]any + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatal(err) + } + ropaID, _ := created["id"].(string) + if ropaID == "" { + t.Fatal("expected ropa ID") + } + + detailResp, err := http.Get(ts.URL + "/api/v2/compliance/gdpr/ropa/" + ropaID) + if err != nil { + t.Fatal(err) + } + defer func() { _ = detailResp.Body.Close() }() + var detail map[string]any + if err := json.NewDecoder(detailResp.Body).Decode(&detail); err != nil { + t.Fatal(err) + } + if len(detail["edges"].([]any)) != 3 { + t.Fatalf("expected 3 edges, got %d", len(detail["edges"].([]any))) + } + + req, _ := http.NewRequest("PUT", ts.URL+"/api/v2/compliance/gdpr/ropa/"+ropaID, strings.NewReader(`{"name":"Analytics v2"}`)) + req.Header.Set("Content-Type", "application/json") + updateResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = updateResp.Body.Close() }() + var updated map[string]any + if err := json.NewDecoder(updateResp.Body).Decode(&updated); err != nil { + t.Fatal(err) + } + props, _ := updated["properties"].(map[string]any) + if props["name"] != "Analytics v2" { + t.Fatalf("expected updated name, got %v", props["name"]) + } + + evidenceResp, err := http.Post(ts.URL+"/api/v2/compliance/gdpr/evidence", "application/json", strings.NewReader(`{"title":"Audit Trail"}`)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = evidenceResp.Body.Close() }() + var evidence map[string]any + if err := json.NewDecoder(evidenceResp.Body).Decode(&evidence); err != nil { + t.Fatal(err) + } + evidenceID, _ := evidence["id"].(string) + if evidenceID == "" { + t.Fatal("expected evidence ID") + } + + delReq, _ := http.NewRequest("DELETE", ts.URL+"/api/v2/compliance/gdpr/evidence/"+evidenceID, nil) + delResp, err := http.DefaultClient.Do(delReq) + if err != nil { + t.Fatal(err) + } + defer func() { _ = delResp.Body.Close() }() + if delResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 on delete, got %d", delResp.StatusCode) + } +} diff --git a/internal/server/compliance_inspection_test.go b/internal/server/compliance_inspection_test.go new file mode 100644 index 0000000..a5ab8cd --- /dev/null +++ b/internal/server/compliance_inspection_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 Scalytics, Inc. +// Copyright 2026 Mirko Kämpf +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/scalytics/kafgraph/internal/graph" +) + +func TestComplianceInspectionCRUD(t *testing.T) { + ts, _, g := setupComplianceTestServer(t) + + scope, err := g.CreateNode("ProcessingActivity", graph.Properties{"name": "Payroll", "status": "active"}) + require.NoError(t, err) + scan, err := g.CreateNode("ComplianceScan", graph.Properties{"scanId": "scan-1"}) + require.NoError(t, err) + + createResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/inspections", `{ + "properties":{"title":"Quarterly Review","inspectorId":"ins-1"}, + "scopeNodeIds":["`+string(scope.ID)+`"], + "scanId":"scan-1" + }`) + require.Equal(t, http.StatusCreated, createResp.StatusCode) + inspection := decodeBody(t, createResp) + inspectionID := inspection["id"].(string) + + listResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/inspections?status=draft", "") + require.Equal(t, http.StatusOK, listResp.StatusCode) + listBody := decodeBody(t, listResp) + assert.Equal(t, float64(1), listBody["total"]) + + findingResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/inspections/"+inspectionID+"/findings", `{ + "properties":{"title":"Missing evidence"}, + "affectedNodeIds":["`+string(scope.ID)+`"] + }`) + require.Equal(t, http.StatusCreated, findingResp.StatusCode) + finding := decodeBody(t, findingResp) + findingID := finding["id"].(string) + + remediationResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/findings/"+findingID+"/remediation", `{"title":"Attach evidence"}`) + require.Equal(t, http.StatusCreated, remediationResp.StatusCode) + remediation := decodeBody(t, remediationResp) + remediationID := remediation["id"].(string) + + updateFindingResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/findings/"+findingID, `{"status":"remediated","title":"Resolved"}`) + require.Equal(t, http.StatusOK, updateFindingResp.StatusCode) + _ = decodeBody(t, updateFindingResp) + + updateRemediationResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/remediation/"+remediationID, `{"status":"completed","verifiedBy":"lead-1"}`) + require.Equal(t, http.StatusOK, updateRemediationResp.StatusCode) + _ = decodeBody(t, updateRemediationResp) + + detailResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/inspections/"+inspectionID, "") + require.Equal(t, http.StatusOK, detailResp.StatusCode) + detail := decodeBody(t, detailResp) + assert.Len(t, detail["findings"], 1) + assert.Len(t, detail["scope"], 1) + assert.NotNil(t, detail["basedOnScan"]) + + findingDetailResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/findings/"+findingID, "") + require.Equal(t, http.StatusOK, findingDetailResp.StatusCode) + findingDetail := decodeBody(t, findingDetailResp) + assert.Len(t, findingDetail["remediations"], 1) + assert.Len(t, findingDetail["affected"], 1) + + updateInspectionResp := complianceRequest(t, ts.URL, http.MethodPut, "/api/v2/compliance/inspections/"+inspectionID, `{"status":"review"}`) + require.Equal(t, http.StatusOK, updateInspectionResp.StatusCode) + _ = decodeBody(t, updateInspectionResp) + + signOffResp := complianceRequest(t, ts.URL, http.MethodPost, "/api/v2/compliance/inspections/"+inspectionID+"/sign-off", `{"approverId":"lead-1"}`) + require.Equal(t, http.StatusOK, signOffResp.StatusCode) + signOffBody := decodeBody(t, signOffResp) + assert.Equal(t, "signed_off", signOffBody["status"]) + + eventsResp := complianceRequest(t, ts.URL, http.MethodGet, "/api/v2/compliance/events?limit=2", "") + require.Equal(t, http.StatusOK, eventsResp.StatusCode) + events := decodeBody(t, eventsResp) + assert.Len(t, events["events"], 2) + assert.True(t, events["total"].(float64) >= 2) + + _, err = g.GetNode(scan.ID) + require.NoError(t, err) +} diff --git a/internal/server/compliance_test.go b/internal/server/compliance_test.go index 13d19d4..6d1269f 100644 --- a/internal/server/compliance_test.go +++ b/internal/server/compliance_test.go @@ -114,6 +114,10 @@ func TestComplianceScan(t *testing.T) { func TestComplianceDashboard(t *testing.T) { ts, _, _ := setupComplianceTestServer(t) + _, err := http.Post(ts.URL+"/api/v2/compliance/scan", "application/json", strings.NewReader(`{"framework":"gdpr","module":"setup"}`)) + if err != nil { + t.Fatal(err) + } resp, err := http.Get(ts.URL + "/api/v2/compliance/dashboard") if err != nil { t.Fatal(err) @@ -122,4 +126,89 @@ func TestComplianceDashboard(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["latestScan"] == nil { + t.Fatal("expected latestScan") + } + if body["moduleScores"] == nil { + t.Fatal("expected moduleScores") + } +} + +func TestComplianceScanListingDetailAndScore(t *testing.T) { + ts, _, _ := setupComplianceTestServer(t) + + resp, err := http.Post(ts.URL+"/api/v2/compliance/scan", "application/json", strings.NewReader(`{"framework":"gdpr","module":"setup"}`)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var scan map[string]any + if err := json.NewDecoder(resp.Body).Decode(&scan); err != nil { + t.Fatal(err) + } + scanID, _ := scan["scanId"].(string) + if scanID == "" { + t.Fatal("expected scanId") + } + + listResp, err := http.Get(ts.URL + "/api/v2/compliance/scans") + if err != nil { + t.Fatal(err) + } + defer func() { _ = listResp.Body.Close() }() + var list map[string]any + if err := json.NewDecoder(listResp.Body).Decode(&list); err != nil { + t.Fatal(err) + } + if total, _ := list["total"].(float64); total < 1 { + t.Fatalf("expected at least one scan, got %v", total) + } + + detailResp, err := http.Get(ts.URL + "/api/v2/compliance/scans/" + scanID) + if err != nil { + t.Fatal(err) + } + defer func() { _ = detailResp.Body.Close() }() + var detail map[string]any + if err := json.NewDecoder(detailResp.Body).Decode(&detail); err != nil { + t.Fatal(err) + } + props, _ := detail["properties"].(map[string]any) + if props["scanId"] != scanID { + t.Fatalf("expected detail for %s, got %v", scanID, props["scanId"]) + } + if _, ok := detail["evaluations"].([]any); !ok { + t.Fatal("expected evaluations array") + } + + scoreResp, err := http.Get(ts.URL + "/api/v2/compliance/score") + if err != nil { + t.Fatal(err) + } + defer func() { _ = scoreResp.Body.Close() }() + var score map[string]any + if err := json.NewDecoder(scoreResp.Body).Decode(&score); err != nil { + t.Fatal(err) + } + scores, ok := score["scores"].([]any) + if !ok || len(scores) == 0 { + t.Fatal("expected score entries") + } +} + +func TestModuleFromRuleID(t *testing.T) { + if got := moduleFromRuleID("GDPR-ROPA-001"); got != "ropa" { + t.Fatalf("expected ropa, got %s", got) + } + if got := moduleFromRuleID("BROKEN"); got != "unknown" { + t.Fatalf("expected unknown, got %s", got) + } } diff --git a/internal/server/http_test.go b/internal/server/http_test.go index 94a2fa9..3d7b1ab 100644 --- a/internal/server/http_test.go +++ b/internal/server/http_test.go @@ -25,6 +25,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/scalytics/kafgraph/internal/cluster" + "github.com/scalytics/kafgraph/internal/compliance" "github.com/scalytics/kafgraph/internal/config" "github.com/scalytics/kafgraph/internal/graph" ) @@ -173,3 +175,31 @@ func TestHTTPServerHandler(t *testing.T) { defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) } + +func TestServerOptionsAndReadJSON(t *testing.T) { + opts := &serverOpts{} + var exec cluster.QueryExecutor + var membership *cluster.Membership + var partMap *cluster.PartitionMap + var compEngine *compliance.Engine + + WithExecutor(exec)(opts) + WithBrain(nil)(opts) + WithConfig(nil)(opts) + WithMembership(membership)(opts) + WithPartitionMap(partMap)(opts) + WithCompliance(compEngine)(opts) + + assert.Nil(t, opts.exec) + assert.Nil(t, opts.brain) + assert.Nil(t, opts.cfg) + assert.Nil(t, opts.membership) + assert.Nil(t, opts.partMap) + assert.Nil(t, opts.compEngine) + + req := httptest.NewRequest(http.MethodPost, "/unused", nil) + req.Body = nil + err := readJSON(req, &map[string]any{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty request body") +} diff --git a/internal/server/management.go b/internal/server/management.go index 4b7cc7b..e00b9b5 100644 --- a/internal/server/management.go +++ b/internal/server/management.go @@ -451,17 +451,6 @@ func handleMgmtConfigDetailed(opts *serverOpts) http.HandlerFunc { return } - // Define all settings grouped by section. - type sectionDef struct { - Name string - Settings []struct { - Key string - Default any - GetValue func(*config.Config) any - } - } - - // Build the detailed config by section. sections := buildConfigSections(opts.cfg) writeJSON(w, http.StatusOK, sections) }