mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 12:31:59 +00:00
Replace try-lock/sleep scheme with blocking locks, add ErrModelNotFound/ ErrModelExists/ErrInvalidModel sentinels, make RegisterModelWithRules atomic, snapshot in IterateModels, guard defaultRegistry access, cap the pointer-unwrap depth, and recover panics in callbacks and reflection. Security hooks now allow-by-default only on ErrModelNotFound. Add tests.
286 lines
15 KiB
Markdown
286 lines
15 KiB
Markdown
# Audit — `pkg/logger`
|
||
|
||
- **Date:** 2026-09-29
|
||
- **Scope:** `pkg/logger/logger.go` (211 LOC, 1 file, no tests)
|
||
- **Axes:** thread locking/waiting · slowness · security · panic handling & logging
|
||
- **Threat model:** hostile internet client; request bodies, headers, params and identifiers are attacker-controlled.
|
||
|
||
## Summary
|
||
|
||
`pkg/logger` is a thin package-global wrapper over `zap.SugaredLogger` plus a fan-out to
|
||
`pkg/errortracking`. It is the single most widely imported package in the repo, so its defects
|
||
are systemic. Two classes of problem dominate: **unsynchronised global mutable state** (a real
|
||
data race between logger re-initialisation and request-path logging), and **unbounded,
|
||
unsampled, unscrubbed egress of formatted messages to a third-party error tracker** on every
|
||
`Warn`/`Error` call — which under hostile input is both a data-leak and a cost/latency
|
||
amplification channel.
|
||
|
||
There are **zero tests** in this package.
|
||
|
||
| # | Severity | Axis | Finding |
|
||
|---|----------|------|---------|
|
||
| 1 | **High** | Locking | Unsynchronised writes to `Logger` / `errorTracker` globals race with every log call |
|
||
| 2 | **High** | Security | Every `Warn`/`Error` message is shipped verbatim to Sentry — no scrubbing, no allowlist |
|
||
| 3 | **High** | Slowness | No rate limit, sampling or dedup on error-tracker fan-out; attacker-triggerable |
|
||
| 4 | **High** | Panic | `CatchPanic` swallows panics unconditionally — and both call sites are security enforcement functions (fail-open) |
|
||
| 5 | Medium | Slowness | `debug.Stack()` + full stack stringification on every recovered panic |
|
||
| 6 | Medium | Security | `log.Printf(template, args...)` fallback is a format-string sink for caller-supplied text |
|
||
| 7 | Medium | Security | No CRLF/control-char sanitisation on the stdlib fallback path → log injection |
|
||
| 8 | Medium | Correctness | `Info`/`Debug` do not strip `context.Context` args; `Warn`/`Error` do |
|
||
| 9 | Low | Correctness | `UpdateLogger` leaks the previous zap logger / file descriptor |
|
||
| 10 | Low | Correctness | No `Sync()` exported → buffered log lines lost on exit |
|
||
| 11 | Low | Slowness | `os.Getpid()` called on every log line |
|
||
| 12 | Low | Observability | `UpdateLogger` build failure degrades silently to stdlib `log` |
|
||
|
||
## Resolution status (2026-09-30)
|
||
|
||
- **#1** — Fixed (earlier race work): `stateMu` RWMutex with `getLogger`/`swapLogger`/`getErrorTracker`; the exported `Logger` var is kept for compatibility
|
||
- **#2** — Fixed: messages are scrubbed before `CaptureMessage` (URL credentials, `password=`/`token=`/`secret=`/`api_key=` values, `Bearer`/`Basic` tokens). Local logs are unchanged. Sentry `BeforeSend` and structured-field allowlisting are not done
|
||
- **#3** — Partly fixed: global token bucket (burst 50, 20/s) plus per-severity/template dedup (1s, 1024 keys). Panics are not limited. The `error_tracking.sample_rate` default (Sentry maps 0 to 1.0) is still unset in `config/manager.go`
|
||
- **#4** — Partly fixed: `CatchPanicRethrow` added. `pkg/security/provider.go:302` and `:443` still use the swallowing `CatchPanic`; left for the security audit pass
|
||
- **#5** — Fixed: stack captured with `runtime.Stack` into a 16 KiB buffer. Per-fingerprint panic rate limiting not done
|
||
- **#6** — Fixed: `Info`/`Debug` format first and fall back with `log.Printf("%s", ...)`. `gosec` was enabled separately
|
||
- **#7** — Fixed: CR/LF and other control characters are escaped on the stdlib fallback path
|
||
- **#8** — Fixed: `Info`/`Debug` strip `context.Context` args
|
||
- **#9** — Fixed: the replaced logger is synced on `UpdateLogger`
|
||
- **#10** — Fixed: `logger.Sync()` added. Not yet called from the server shutdown path
|
||
- **#11** — Fixed: PID cached in a package var
|
||
- **#12** — Partly fixed: `UpdateLoggerE` returns the build error and a failed build keeps the previous logger. `Init` still returns nothing
|
||
- Tests: `pkg/logger/logger_test.go` (run with `-race`).
|
||
|
||
---
|
||
|
||
## Findings
|
||
|
||
### 1. Unsynchronised global mutable state — data race (High, Locking)
|
||
|
||
`logger.go:14-15`
|
||
|
||
```go
|
||
var Logger *zap.SugaredLogger
|
||
var errorTracker errortracking.Provider
|
||
```
|
||
|
||
`Logger` is written by `Init` → `UpdateLogger` (`logger.go:51`) and by `UpdateLoggerPath`
|
||
(`logger.go:29`). `errorTracker` is written by `InitErrorTracking` (`logger.go:57`) and read by
|
||
`GetErrorTracker`, `CloseErrorTracking`, `Warn`, `Error`, `CatchPanicCallback`, `HandlePanic`.
|
||
|
||
Every read site (`logger.go:100`, `108`, `123`, `139`, `156`, `199`) is unguarded. There is no
|
||
mutex, no `atomic.Value`, no `sync.Once`.
|
||
|
||
- **Benign case:** everything is initialised once in `main` before goroutines start. Then it's fine.
|
||
- **Real case:** `UpdateLoggerPath` is an exported, runtime-callable API. A config reload, a
|
||
log-rotation hook, or a test helper calling it while HTTP handlers log concurrently is an
|
||
unsynchronised write to an interface value and a pointer, concurrent with reads. Under the Go
|
||
memory model this is undefined behaviour; in practice a torn interface read (type word from the
|
||
new value, data word from the old) faults.
|
||
- `CloseErrorTracking` (`logger.go:69`) does a read-check-then-use on `errorTracker` with no
|
||
guard, so a concurrent `InitErrorTracking(nil)` yields a nil-interface dereference inside
|
||
`Flush`.
|
||
|
||
**Recommendation:** store both behind `atomic.Pointer`/`atomic.Value` (or an `sync.RWMutex`),
|
||
and gate first-time init behind `sync.Once`. Run the test suite with `-race` — see finding 12 of
|
||
`audit/pkg/config.audit.md` for the same pattern in the config singleton.
|
||
|
||
### 2. Unscrubbed message egress to third-party error tracker (High, Security)
|
||
|
||
`logger.go:110-118` and `logger.go:126-134`
|
||
|
||
```go
|
||
message := fmt.Sprintf(template, remainingArgs...)
|
||
...
|
||
errorTracker.CaptureMessage(ctx, message, errortracking.SeverityError, ...)
|
||
```
|
||
|
||
*Every* `Warn` and `Error` call in the entire codebase has its fully-formatted message sent to
|
||
the configured provider (Sentry, in practice). There is no allowlist, no redaction hook, and
|
||
`pkg/errortracking/sentry.go` configures no `BeforeSend` scrubber.
|
||
|
||
Concretely, formatted error strings across `pkg/` embed: SQL fragments and bound values, DB
|
||
connection strings, schema/table/column identifiers, filter expressions built from request
|
||
input, and raw request bodies in a few handlers. Under the hostile-client threat model this is
|
||
two problems at once:
|
||
|
||
- **Outbound data leak:** secrets that appear in wrapped driver errors (DSNs, credentials from
|
||
`pq`/`pgx` connect failures) leave the trust boundary to a SaaS endpoint.
|
||
- **Attacker-controlled exfil channel:** an attacker who can shape a value that ends up in an
|
||
error message gets that value written to a third-party system — useful for exfiltrating data
|
||
read out of the DB via an induced error.
|
||
|
||
**Recommendation:** add a redaction step before `CaptureMessage`/`CapturePanic` (regex-strip
|
||
DSN/`password=`/bearer-token shapes at minimum), and set Sentry's `BeforeSend` as a second
|
||
layer. Prefer passing structured fields with an explicit allowlist over shipping the rendered
|
||
string.
|
||
|
||
### 3. No rate limiting or sampling on error-tracker fan-out (High, Slowness)
|
||
|
||
`logger.go:113`, `logger.go:129`
|
||
|
||
An unauthenticated request that reliably produces one `Error` log (a malformed filter, an unknown
|
||
column, a bad JSON body — all of which the spec handlers log at error level) becomes one Sentry
|
||
event. At even modest request rates this means:
|
||
|
||
- Sentry quota burn → a direct billing-DoS.
|
||
- `sentry-go` enqueues onto a bounded worker queue; once saturated events are dropped, so the
|
||
*real* errors are the ones lost.
|
||
- `pkg/errortracking/sentry.go:34` passes `SampleRate` straight through from config, and
|
||
`config/manager.go` sets **no default** for it. `sentry-go@v0.46.2` `client.go:339` maps
|
||
`SampleRate == 0.0` → `1.0`, so the out-of-the-box behaviour is *send 100% of events*.
|
||
|
||
**Recommendation:** default `error_tracking.sample_rate` to something < 1.0 for the message path,
|
||
and put a token-bucket or a fingerprint-dedup in front of `CaptureMessage`. Keep panics at 100%.
|
||
|
||
### 4. `CatchPanic` swallows panics unconditionally, fail-open at both call sites (High, Panic handling)
|
||
|
||
`logger.go:145-176`
|
||
|
||
```go
|
||
func CatchPanicCallback(location string, cb func(err any), args ...interface{}) func() {
|
||
...
|
||
if err := recover(); err != nil { ... if cb != nil { cb(err) } }
|
||
}
|
||
```
|
||
|
||
The recovered value is logged and then discarded. There is no variant that logs-and-re-panics
|
||
and no way for the caller to signal "this panic means state is corrupt, take the process down".
|
||
|
||
This is the right default for an HTTP handler boundary. The two current call sites are **not**
|
||
handler boundaries:
|
||
|
||
- `pkg/security/provider.go:302` — `defer logger.CatchPanic("ApplyColumnSecurity")()`
|
||
- `pkg/security/provider.go:443` — `defer logger.CatchPanic("GetRowSecurityTemplate")()`
|
||
|
||
Both are *security enforcement* functions. Swallowing a panic there means the column-security
|
||
filter or row-security template silently does not get applied, and the caller — which has no way
|
||
to learn a panic occurred, since `CatchPanic` returns nothing and sets no error — proceeds as if
|
||
security was applied. That is a fail-open security control; see
|
||
`audit/pkg/security.audit.md` for the full write-up of those two sites.
|
||
|
||
Separately: a panic while a mutex is held does not release that mutex unless an intervening
|
||
`defer Unlock` exists, so swallowing converts a crash into a permanent deadlock at any
|
||
lock-holding call site.
|
||
|
||
**Recommendation:** add `CatchPanicRethrow(location string)` for internal use and reserve the
|
||
swallowing form for the outermost request/goroutine boundary. Document which is which.
|
||
|
||
### 5. Full stack capture on every recovered panic (Medium, Slowness)
|
||
|
||
`logger.go:158` and `logger.go:197`
|
||
|
||
```go
|
||
callstack := debug.Stack()
|
||
```
|
||
|
||
`debug.Stack()` stops the world briefly and allocates; `HandlePanic` then formats the whole trace
|
||
into a string *and* ships it to Sentry. Because panics on the request path are recovered rather
|
||
than fatal (finding 4), an attacker who finds one reliably-panicking input turns each request
|
||
into a stack capture + string build + network event. That is a solid amplification factor over a
|
||
normal request.
|
||
|
||
**Recommendation:** cap the captured stack (`runtime.Stack` into a fixed 8–16 KiB buffer rather
|
||
than `debug.Stack()`'s grow-until-it-fits loop), and rate-limit identical panic fingerprints.
|
||
|
||
### 6. Format-string sink in the stdlib fallback (Medium, Security)
|
||
|
||
`logger.go:100`, `logger.go:142` (and `108`/`123` with `"%s"`, correctly)
|
||
|
||
```go
|
||
func Info(template string, args ...interface{}) {
|
||
if Logger == nil {
|
||
log.Printf(template, args...) // template is the caller's, args may be empty
|
||
```
|
||
|
||
`Info` and `Debug` pass `template` directly to `log.Printf`. If any caller ever does
|
||
`logger.Info(someUserString)` — the idiomatic-looking single-argument call — a `%s` or `%n` in
|
||
that string is interpreted as a verb, producing `%!s(MISSING)` garbage and mangled logs. Note
|
||
`Warn`/`Error` already avoid this on the fallback path by using `log.Printf("%s", message)`;
|
||
`Info`/`Debug` do not.
|
||
|
||
A grep of `pkg/` found **no** current single-argument call sites, so this is a latent API footgun
|
||
rather than a live bug — but it is one that costs one line to close.
|
||
|
||
**Recommendation:** mirror `Warn`'s shape: format first, then `log.Printf("%s", message)`.
|
||
`govet` runs by default under golangci-lint v2's standard set, and its `printf` analyser infers
|
||
wrappers like these — so once the fallback is fixed, call sites are checked at build time for
|
||
free. (Note `gosec` is *not* in `.golangci.json`'s `linters.enable` list; it appears only in the
|
||
exclusion rules. Worth enabling repo-wide.)
|
||
|
||
### 7. No log-injection sanitisation on the fallback path (Medium, Security)
|
||
|
||
On the zap path, the JSON encoder escapes newlines and control characters, so injected content
|
||
can't forge a log record. On the `Logger == nil` fallback path, `log.Printf` writes raw bytes: a
|
||
value containing `\n2026-09-29 ... level=info authorized=true` forges a plausible second log
|
||
line. Combined with finding 12 (silent degradation to the fallback path) this is reachable
|
||
without the operator noticing the encoder changed.
|
||
|
||
**Recommendation:** strip/escape `\r`, `\n` and other C0 control characters from formatted
|
||
messages before the stdlib write.
|
||
|
||
### 8. `Info`/`Debug` don't strip `context.Context` arguments (Medium, Correctness)
|
||
|
||
`extractContext` (`logger.go:79-98`) exists precisely so callers can pass a `ctx` as a trailing
|
||
variadic arg. `Warn` (`logger.go:106`) and `Error` (`logger.go:121`) call it. `Info`
|
||
(`logger.go:99`) and `Debug` (`logger.go:137`) **do not** — they pass every arg to `Sprintf`.
|
||
|
||
So `logger.Info("saved %s", name, ctx)` renders as
|
||
`saved widget%!(EXTRA *context.valueCtx=context.Background...)`, dumping the context's contents
|
||
(which in this codebase carry auth/tenant values) into the log line. That is both noise and a
|
||
minor disclosure.
|
||
|
||
**Recommendation:** call `extractContext` in all four level functions for uniform behaviour.
|
||
|
||
### 9. `UpdateLogger` leaks the previous logger (Low)
|
||
|
||
`logger.go:37-53` builds a new zap logger and overwrites `Logger` without calling `Sync()`/close
|
||
on the old one. `UpdateLoggerPath` opens a new file sink each call; repeated calls leak a file
|
||
descriptor each time and buffered lines in the old logger are lost.
|
||
|
||
### 10. No `Sync()` on shutdown (Low)
|
||
|
||
Nothing in the package exposes `Logger.Sync()`, and `CloseErrorTracking` (`logger.go:69`) flushes
|
||
only the error tracker. zap buffers writes to file sinks, so the last lines before exit — often
|
||
the interesting ones — are dropped. Add `func Sync() error` and call it from the server's
|
||
shutdown path alongside `CloseErrorTracking`.
|
||
|
||
### 11. `os.Getpid()` per log line (Low, Slowness)
|
||
|
||
`logger.go:102`, `111`, `127`, `140`, `165`, `202`. On Linux `getpid` is cached by the runtime so
|
||
this is cheap, but the PID cannot change for the life of the process — cache it in a package var
|
||
and drop six calls from the hot path.
|
||
|
||
### 12. Silent degradation when the logger fails to build (Low, Observability)
|
||
|
||
`logger.go:45-49`
|
||
|
||
```go
|
||
logger, err := config.Build()
|
||
if err != nil { log.Print(err); return }
|
||
```
|
||
|
||
`Logger` stays `nil`, so the whole process silently falls back to unstructured stdlib logging
|
||
(and thereby onto the format-string and log-injection paths of findings 6 and 7) with a single
|
||
line of warning that itself goes to stderr. A bad `logger.path` in config (unwritable directory)
|
||
triggers exactly this.
|
||
|
||
**Recommendation:** return the error from `Init`/`UpdateLogger` and let the caller decide whether
|
||
to fail startup.
|
||
|
||
---
|
||
|
||
## What looks right
|
||
|
||
- `extractContext` correctly ignores second and subsequent contexts rather than fighting over them.
|
||
- `Warn`/`Error` use `log.Printf("%s", message)` on the fallback path — the safe form.
|
||
- `HandlePanic` returns an `error` rather than swallowing, which lets callers convert a panic into
|
||
a normal error return. This is the better of the two panic idioms in the package.
|
||
- The `errortracking.Provider` indirection means a nil/noop provider is always safe to call.
|
||
|
||
## Suggested follow-up
|
||
|
||
1. Guard the two globals (finding 1) — prerequisite for running the suite under `-race`.
|
||
2. Add redaction + sampling in front of the error-tracker fan-out (findings 2, 3).
|
||
3. Split `CatchPanic` into swallow/rethrow variants and re-audit the ~60 `recover()` sites
|
||
listed in the other package audits against the split (finding 4).
|
||
4. Add a test file. Minimum: concurrent `UpdateLogger` + `Error` under `-race`, `Info` with a
|
||
`%`-bearing message, and nil-provider paths.
|