diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4963a87 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + pull_request_review: + types: [submitted] + +jobs: + test: + if: github.event_name != 'pull_request_review' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dAppCore/build/actions/build/core@dev + with: + go-version: "1.26" + run-vet: "true" + + auto-fix: + if: > + github.event_name == 'pull_request_review' && + github.event.review.user.login == 'coderabbitai' && + github.event.review.state == 'changes_requested' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + - uses: dAppCore/build/actions/fix@dev + with: + go-version: "1.26" + + auto-merge: + if: > + github.event_name == 'pull_request_review' && + github.event.review.user.login == 'coderabbitai' && + github.event.review.state == 'approved' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - name: Merge PR + run: gh pr merge ${{ github.event.pull_request.number }} --merge --delete-branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 004b238..1c7eb40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -SQLite key-value store with TTL, namespace isolation, and reactive events. Pure Go (no CGO). Module: `forge.lthn.ai/core/go-store` +SQLite key-value store with TTL, namespace isolation, and reactive events. Pure Go (no CGO). Module: `dappco.re/go/core/store` ## Getting Started diff --git a/README.md b/README.md index 70d1379..05399b2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Go Reference](https://pkg.go.dev/badge/forge.lthn.ai/core/go-store.svg)](https://pkg.go.dev/forge.lthn.ai/core/go-store) +[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/core/store.svg)](https://pkg.go.dev/dappco.re/go/core/store) [![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md) [![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod) @@ -6,14 +6,14 @@ Group-namespaced SQLite key-value store with TTL expiry, namespace isolation, quota enforcement, and a reactive event system. Backed by a pure-Go SQLite driver (no CGO), uses WAL mode for concurrent reads, and enforces a single connection to ensure pragma consistency. Supports scoped stores for multi-tenant use, Watch/Unwatch subscriptions, and OnChange callbacks — the designed integration point for go-ws real-time streaming. -**Module**: `forge.lthn.ai/core/go-store` +**Module**: `dappco.re/go/core/store` **Licence**: EUPL-1.2 **Language**: Go 1.25 ## Quick Start ```go -import "forge.lthn.ai/core/go-store" +import "dappco.re/go/core/store" st, err := store.New("/path/to/store.db") // or store.New(":memory:") defer st.Close() diff --git a/docs/index.md b/docs/index.md index 5d9f6da..7365623 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ description: Group-namespaced SQLite key-value store with TTL expiry, namespace The package has a single runtime dependency -- a pure-Go SQLite driver (`modernc.org/sqlite`). No CGO is required. It compiles and runs on all platforms that Go supports. -**Module path:** `forge.lthn.ai/core/go-store` +**Module path:** `dappco.re/go/core/store` **Go version:** 1.26+ **Licence:** EUPL-1.2 @@ -22,7 +22,7 @@ import ( "fmt" "time" - "forge.lthn.ai/core/go-store" + "dappco.re/go/core/store" ) func main() { diff --git a/docs/security-attack-vector-mapping.md b/docs/security-attack-vector-mapping.md new file mode 100644 index 0000000..c6c3544 --- /dev/null +++ b/docs/security-attack-vector-mapping.md @@ -0,0 +1,58 @@ +# Security Attack Vector Mapping + +Scope: exported API entry points that accept caller-controlled input. Methods with no caller-controlled parameters (`(*Store).Close`, `(*Store).PurgeExpired`, `(*ScopedStore).Namespace`, `EventType.String`) and unexported helpers are omitted. + +Verified against the current source tree on 2026-03-23. `CODEX.md` is not present under `/workspace`; `CLAUDE.md` is the only repo-local guidance file. + +Cross-cutting observations: + +- SQL writes and reads use placeholders throughout; no direct SQL injection sink was found in `store.go`. +- `CountAll` and `Groups` prefix searches escape `^`, `%`, and `_` before using `LIKE`, which blocks wildcard injection at that sink. +- Outside namespace construction, there are no length limits or character restrictions on `group`, `key`, `value`, template strings, watcher filters, or callback registration. +- Event payloads carry raw `group`, `key`, and `value` strings into watchers and callbacks without sanitisation. + +## Store API + +| Entry point | File:line | Input source | What it flows into | Current validation | Potential attack vector | +| --- | --- | --- | --- | --- | --- | +| `New(dbPath string)` | `store.go:37` | Caller-supplied SQLite path / DSN string | `sql.Open("sqlite", dbPath)` followed by `PRAGMA` execution, schema creation, and migration queries | No path or DSN allowlist; relies on driver/open errors | Arbitrary database file selection or creation if the caller controls the path; attacker-owned or malformed DB files can force schema/migration work and denial of service on open. | +| `(*Store).Get(group, key string)` | `store.go:89` | Caller-supplied `group` and `key` | Parameterised `SELECT` on `(grp, key)`; if expired, parameterised `DELETE`; raw `group+"/"+key` is included in the not-found error context | No syntax or length checks; SQL placeholders prevent direct injection | Key existence oracle; reads of expired keys become writes, which can be abused for write amplification; raw `group/key` strings can leak into logs or error surfaces. | +| `(*Store).Set(group, key, value string)` | `store.go:116` | Caller-supplied `group`, `key`, and `value` | Parameterised `INSERT ... ON CONFLICT DO UPDATE`; persisted in SQLite; mirrored into `notify(EventSet{...})` | No syntax, size, or character validation; SQL placeholders used | Disk and memory exhaustion from unbounded values or high key churn; raw payload fan-out to watchers/callbacks can poison downstream logs or protocols; repeated same-value writes can be used to flood events. | +| `(*Store).SetWithTTL(group, key, value string, ttl time.Duration)` | `store.go:132` | Caller-supplied `group`, `key`, `value`, and `ttl` | `time.Now().Add(ttl).UnixMilli()`; parameterised upsert; mirrored into `notify(EventSet{...})` | No TTL bounds, size limits, or character validation; SQL placeholders used | Negative or zero TTL produces immediately expired rows while still emitting `EventSet`; this enables event-driven side effects for data that is effectively unreadable and can create purge churn or temporary storage bloat. | +| `(*Store).Delete(group, key string)` | `store.go:147` | Caller-supplied `group` and `key` | Parameterised `DELETE`; unconditional `notify(EventDelete{...})` after the exec call | No existence, syntax, or length checks | No-op deletes still emit delete events, so callers can spoof delete notifications for arbitrary keys; if exposed without authorisation, this is also a direct destructive surface. | +| `(*Store).Count(group string)` | `store.go:157` | Caller-supplied `group` | Parameterised `COUNT(*)` query on the selected group | No syntax or length checks | Group existence and cardinality oracle; brute-force probing can reveal tenant structure and consume DB time. | +| `(*Store).DeleteGroup(group string)` | `store.go:170` | Caller-supplied `group` | Parameterised `DELETE FROM kv WHERE grp = ?`; unconditional `notify(EventDeleteGroup{...})` | No existence, syntax, or length checks | Mass-deletion surface for any reachable group; no-op deletes still emit `delete_group` events, which can mislead subscribers or audit trails. | +| `(*Store).GetAll(group string)` | `store.go:185` | Caller-supplied `group` | `All(group)` iterator results are collected into an in-memory `map[string]string` | No syntax or length checks; expired rows are filtered | Unbounded memory usage if the target group has many keys or large values; bulk exfiltration surface for the entire group. | +| `(*Store).All(group string)` | `store.go:197` | Caller-supplied `group` | Parameterised `SELECT key, value ... WHERE grp = ?` when the iterator is consumed | No syntax or length checks; placeholders used; streaming avoids one big map allocation | Full-group enumeration and sustained scan cost; attacker-controlled group population can drive long-running DB and CPU work. | +| `(*Store).GetSplit(group, key, sep string)` | `store.go:229` | Caller-supplied `group`, `key`, and `sep` | `Get(group, key)` result into `strings.SplitSeq(val, sep)` | Same validation as `Get`; no separator validation | Same key existence oracle as `Get`; attacker-chosen separators can amplify token count and CPU on large stored values. | +| `(*Store).GetFields(group, key string)` | `store.go:239` | Caller-supplied `group` and `key` | `Get(group, key)` result into `strings.FieldsSeq(val)` | Same validation as `Get` | Same key existence oracle as `Get`; large stored values can produce high CPU and large token streams. | +| `(*Store).Render(tmplStr, group string)` | `store.go:249` | Caller-supplied template string and `group`; stored keys/values in the target group | `All(group)` into `map[string]string`, then `template.New("render").Parse(tmplStr)` and `Execute` | No template allowlist, output size limit, or escaping mode; parse and exec errors are returned | Untrusted templates can enumerate or exfiltrate all values in the chosen group and generate unbounded output or CPU load; output is raw `text/template`, so downstream HTML, shell, JSON, or config injection remains possible if callers reuse it unsafely. | +| `(*Store).CountAll(prefix string)` | `store.go:271` | Caller-supplied group prefix | Parameterised `LIKE ? ESCAPE '^'` query using `escapeLike(prefix) + "%"`, or a full-table count when `prefix == ""` | `escapeLike` escapes `^`, `%`, and `_`; SQL placeholders used | Direct SQL wildcard injection is blocked, but empty or broad prefixes still enable whole-database counting and cross-namespace enumeration. | +| `(*Store).Groups(prefix string)` | `store.go:293` | Caller-supplied group prefix | `GroupsSeq(prefix)` results are collected into an in-memory `[]string` | Same escaping as `CountAll` | Group-name disclosure across namespaces; unbounded memory use if there are many groups; empty prefix enumerates the whole store. | +| `(*Store).GroupsSeq(prefix string)` | `store.go:306` | Caller-supplied group prefix | Parameterised `SELECT DISTINCT grp ... LIKE ? ESCAPE '^'` when the iterator is consumed | `escapeLike` escapes `^`, `%`, and `_`; SQL placeholders used | Group-name disclosure and sustained scan cost across the whole database; empty prefix scans all groups. | + +## Event API + +| Entry point | File:line | Input source | What it flows into | Current validation | Potential attack vector | +| --- | --- | --- | --- | --- | --- | +| `(*Store).Watch(group, key string)` | `events.go:73` | Caller-supplied watcher filters | Stored in `Watcher{group, key}` and evaluated by `watcherMatches`; matched events are queued on a buffered channel | No validation; `"*"` is treated as wildcard by convention | Wildcard subscriptions can observe every mutation and value; each watch allocates a buffer and adds O(n) work to every write, so unbounded watch creation is a memory and CPU denial-of-service surface; literal `*` names cannot be distinguished from wildcard filters. | +| `(*Store).Unwatch(w *Watcher)` | `events.go:92` | Caller-supplied watcher pointer | `existing.id == w.id` match inside the store's watcher slice, then `close(w.ch)` | No nil check and no store-ownership check | `nil` causes a panic; passing a watcher from another `Store` with a colliding `id` can unregister a local watcher while closing the caller-supplied channel instead, causing cross-store subscription denial of service and leaving the removed watcher's channel open. | +| `(*Store).OnChange(fn func(Event))` | `events.go:116` | Caller-supplied callback function | Stored in `callbacks`; invoked synchronously by `notify()` while `s.mu` is held for reading | No nil check, timeout, sandboxing, or re-entrancy guard | Nil callback panics on the first mutation; slow or blocking callbacks stall writers; calling `Watch`, `Unwatch`, or `OnChange` from inside the callback deadlocks; callback registration is also a raw event exfiltration point. | + +## ScopedStore API + +| Entry point | File:line | Input source | What it flows into | Current validation | Potential attack vector | +| --- | --- | --- | --- | --- | --- | +| `NewScoped(store *Store, namespace string)` | `scope.go:34` | Caller-supplied store pointer and namespace | Namespace is stored and later used by `prefix()` to build `namespace + ":" + group` names | Namespace must match `^[a-zA-Z0-9-]+$`; no nil-store or length check | Namespace validation blocks delimiter and wildcard breakout, but a nil store still yields later panics; attacker-chosen long namespaces inflate persisted group names and event payloads. | +| `NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig)` | `scope.go:44` | Caller-supplied store pointer, namespace, and quota config | `NewScoped(...)`, then quota values are stored and later consumed by `checkQuota()` | Same namespace regex as `NewScoped`; no nil-store check; negative quota values are not rejected | Negative `MaxKeys` or `MaxGroups` effectively disable enforcement because checks only run for values `> 0`; nil stores still panic later if used. | +| `(*ScopedStore).Get(group, key string)` | `scope.go:64` | Caller-supplied `group` and `key` within a namespace | `s.prefix(group)` then delegated to `Store.Get(...)` | Namespace was validated at construction; `group` and `key` are otherwise unvalidated | Same existence oracle and expired-read write amplification as `Store.Get`, but confined to the prefixed namespace. | +| `(*ScopedStore).Set(group, key, value string)` | `scope.go:70` | Caller-supplied `group`, `key`, and `value` within a namespace | `checkQuota(group, key)` then `Store.Set(s.prefix(group), key, value)` | Namespace regex at construction; quota checks only if limits are positive; underlying SQL is parameterised | Same storage and event-fan-out issues as `Store.Set`; quota enforcement is non-transactional, so concurrent writers can pass `checkQuota()` and then exceed `MaxKeys` or `MaxGroups`. | +| `(*ScopedStore).SetWithTTL(group, key, value string, ttl time.Duration)` | `scope.go:79` | Caller-supplied `group`, `key`, `value`, and `ttl` within a namespace | `checkQuota(group, key)` then `Store.SetWithTTL(s.prefix(group), key, value, ttl)` | Same as `Set`; no TTL bounds | Same as `Store.SetWithTTL`; concurrent quota bypass remains possible, and negative or zero TTL can create immediately expired rows that fall out of future quota counts until purge, enabling bursty quota evasion and storage churn. | +| `(*ScopedStore).Delete(group, key string)` | `scope.go:87` | Caller-supplied `group` and `key` within a namespace | `Store.Delete(s.prefix(group), key)` | Namespace regex at construction only | Same delete-event spoofing and destructive surface as `Store.Delete`, limited to the namespace prefix. | +| `(*ScopedStore).DeleteGroup(group string)` | `scope.go:92` | Caller-supplied `group` within a namespace | `Store.DeleteGroup(s.prefix(group))` | Namespace regex at construction only | Same mass-delete and no-op event spoofing as `Store.DeleteGroup`, limited to the namespace prefix. | +| `(*ScopedStore).GetAll(group string)` | `scope.go:98` | Caller-supplied `group` within a namespace | `Store.GetAll(s.prefix(group))` | Namespace regex at construction only | Same bulk-read memory pressure and group-wide data exposure as `Store.GetAll`, limited to the namespace prefix. | +| `(*ScopedStore).All(group string)` | `scope.go:104` | Caller-supplied `group` within a namespace | `Store.All(s.prefix(group))` | Namespace regex at construction only | Same streaming enumeration and scan-cost surface as `Store.All`, limited to the namespace prefix. | +| `(*ScopedStore).Count(group string)` | `scope.go:109` | Caller-supplied `group` within a namespace | `Store.Count(s.prefix(group))` | Namespace regex at construction only | Same group-cardinality oracle as `Store.Count`, limited to the namespace prefix. | +| `(*ScopedStore).Render(tmplStr, group string)` | `scope.go:115` | Caller-supplied template string and `group` within a namespace | `Store.Render(tmplStr, s.prefix(group))` | Namespace regex at construction only; same template handling as `Store.Render` | Same template-driven disclosure, output injection, and resource-amplification surface as `Store.Render`, limited to the namespace prefix. | + +No additional exported callables with caller-controlled inputs were found beyond the rows above. diff --git a/go.mod b/go.mod index 990e9ba..853eac3 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ -module forge.lthn.ai/core/go-store +module dappco.re/go/core/store go 1.26.0 require ( - forge.lthn.ai/core/go-log v0.0.4 + dappco.re/go/core/log v0.1.0 github.com/stretchr/testify v1.11.1 - modernc.org/sqlite v1.46.2 + modernc.org/sqlite v1.47.0 ) require ( diff --git a/go.sum b/go.sum index 9209e57..1cf12e5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -63,8 +61,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE= -modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/scope.go b/scope.go index f53ebd0..3c529a9 100644 --- a/scope.go +++ b/scope.go @@ -7,7 +7,7 @@ import ( "regexp" "time" - coreerr "forge.lthn.ai/core/go-log" + coreerr "dappco.re/go/core/log" ) // validNamespace matches alphanumeric characters and hyphens (non-empty). diff --git a/store.go b/store.go index d2e0abf..c1f977f 100644 --- a/store.go +++ b/store.go @@ -9,7 +9,7 @@ import ( "text/template" "time" - coreerr "forge.lthn.ai/core/go-log" + coreerr "dappco.re/go/core/log" _ "modernc.org/sqlite" )