Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
go.work
go.work.sum


# SQLite history store (local data, contains redacted query text)
go/data/
*.db
*.db-wal
*.db-shm

# Node / Vite (web/)
web/node_modules/
web/dist/
Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ Please include:
- The lack of a full user/role system — this is a deliberate, documented design choice for a single-operator tool (see [go/README.md](go/README.md)'s security model section), not an oversight
- Behavior when pgscope is run without a reverse proxy / without HTTPS — the docs are explicit that this is required for the `Secure` session cookie to work correctly, and that responsibility sits with the deployer, not the application

## History store and query redaction

pgscope persists a rolling window of session snapshots to a local SQLite file (`internal/infrastructure/history/sqlite_store.go`) so past activity can be replayed. Since `pg_stat_activity` reports query text with its actual bound parameter values, every session's query is passed through a best-effort redaction pass (`redactQueryLiterals`) before it's ever written to disk — string and standalone numeric literals are replaced with `***`.

This redaction is **not a formal guarantee**. It's a character-level scanner, not a SQL parser, and known gaps include dollar-quoted strings (`$$...$$`) and some less common literal syntaxes. Treat the history file with the same sensitivity as the monitored database itself: it lives on the pgscope host's local disk, is not exposed over any network protocol, and should be protected by normal filesystem permissions and host-level access control, same as any other credential-adjacent artifact.

## Response time

This is a single-maintainer open-source project, not a company with an SLA. I'll do my best to acknowledge a report within a few days and follow up with a fix or a clear explanation of why something isn't a vulnerability.
8 changes: 7 additions & 1 deletion docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ services:
PGSCOPE_API_KEY: ${PGSCOPE_API_KEY}
PGSCOPE_HTTP_PORT: 8090
PGSCOPE_POLL_INTERVAL_SECONDS: ${PGSCOPE_POLL_INTERVAL_SECONDS:-1}
PGSCOPE_HISTORY_DB_PATH: /data/pgscope.db
volumes:
- pgscope_history_local:/data
ports:
- "8090:8090"
mem_limit: 128m
Expand Down Expand Up @@ -49,4 +52,7 @@ services:
ports:
- "8080:80"
mem_limit: 32m
mem_reservation: 16m
mem_reservation: 16m

volumes:
pgscope_history_local:
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ services:
PGSCOPE_API_KEY: ${PGSCOPE_API_KEY}
PGSCOPE_HTTP_PORT: 8090
PGSCOPE_POLL_INTERVAL_SECONDS: ${PGSCOPE_POLL_INTERVAL_SECONDS:-1}
PGSCOPE_HISTORY_DB_PATH: /data/pgscope.db
volumes:
- pgscope_history:/data
# LOCAL TEST ONLY: same reasoning as postgres above. On a server,
# remove this port mapping, only your own nginx (running outside this
# compose file, already installed on the server) should be able to
Expand Down Expand Up @@ -88,4 +91,5 @@ services:
mem_reservation: 64m

volumes:
postgres_data:
postgres_data:
pgscope_history:
3 changes: 3 additions & 0 deletions go/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ COPY . .
# nothing beyond what's strictly needed to run the binary.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /pgscope ./cmd/pgscope

RUN mkdir -p /data

# distroless/static includes CA certificates (needed if PGSCOPE_DATABASE_URL
# ever uses sslmode=verify-full against a cert-issuing Postgres) and nothing
# else: no shell, no package manager, drastically smaller attack surface
Expand All @@ -22,6 +24,7 @@ FROM gcr.io/distroless/static-debian12:nonroot AS runtime

WORKDIR /app
COPY --from=builder /pgscope /app/pgscope
COPY --chown=nonroot:nonroot --from=builder /data /data

USER nonroot:nonroot

Expand Down
55 changes: 51 additions & 4 deletions go/cmd/pgscope/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
Expand Down Expand Up @@ -43,25 +44,39 @@ func run() error {
}
defer pool.Close()

historyStore, err := history.NewSQLiteStore(cfg.HistoryDBPath)
if err != nil {
return fmt.Errorf("open history store: %w", err)
}
defer func() { _ = historyStore.Close() }()

broadcaster := sse.NewBroadcaster()
poller := buildPoller(pool, broadcaster, cfg)
poller := buildPoller(pool, broadcaster, historyStore, cfg)
insightsService := buildInsightsService(pool)
server := buildServer(broadcaster, poller, insightsService, cfg)

go poller.Run(ctx)
go runHistoryPruner(ctx, historyStore, cfg.HistoryRetention, cfg.HistoryMaxDBSizeBytes)

return runServer(ctx, server)
}

func buildPoller(pool *pgxpool.Pool, broadcaster *sse.Broadcaster, cfg config.Config) *service.Poller {
func buildPoller(pool *pgxpool.Pool, broadcaster *sse.Broadcaster, historyStore *history.SQLiteStore, cfg config.Config) *service.Poller {
collector := postgres.NewCollector(pool)
monitoringService := service.NewMonitoringService(collector)

dbStatsCollector := postgres.NewDatabaseStatsCollector(pool)
publisher := sse.NewSessionPublisher(broadcaster)
historyStore := history.NewRingBufferStore()

return service.NewPoller(monitoringService, dbStatsCollector, publisher, historyStore, cfg.PollInterval)
return service.NewPoller(
monitoringService,
dbStatsCollector,
publisher,
historyStore,
cfg.PollInterval,
cfg.HistoryRecordInterval,
cfg.HistoryMaxSessionsPerSnapshot,
)
}

func buildInsightsService(pool *pgxpool.Pool) *service.InsightsService {
Expand Down Expand Up @@ -99,3 +114,35 @@ func runServer(ctx context.Context, server *http.Server) error {
return err
}
}

const historyPruneInterval = 6 * time.Hour

// runHistoryPruner periodically deletes periodic snapshots older than the
// configured retention window, in small batches (see PruneOlderThan) so it
// never causes a noticeable CPU or lock spike. It also caps the number of
// incident snapshots kept (see PruneExcessIncidents), since those are never
// pruned by age, and enforces a hard disk-size backstop (EnforceMaxSize) on
// top of the age-based retention, in case an unusually busy monitored
// database outpaces it.
func runHistoryPruner(ctx context.Context, store *history.SQLiteStore, retention time.Duration, maxDBSizeBytes int64) {
ticker := time.NewTicker(historyPruneInterval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cutoff := time.Now().Add(-retention)
if err := store.PruneOlderThan(ctx, cutoff); err != nil {
slog.Error("history prune failed", "error", err)
}
if err := store.PruneExcessIncidents(ctx, history.MaxIncidentRows); err != nil {
slog.Error("incident prune failed", "error", err)
}
if err := store.EnforceMaxSize(ctx, maxDBSizeBytes); err != nil {
slog.Error("history size enforcement failed", "error", err)
}
}
}
}
12 changes: 11 additions & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@ go 1.25.0
require (
github.com/jackc/pgx/v5 v5.10.0
golang.org/x/time v0.15.0
modernc.org/sqlite v1.54.0
)

require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
53 changes: 51 additions & 2 deletions go/go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
Expand All @@ -9,20 +17,61 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/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.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
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=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
7 changes: 6 additions & 1 deletion go/internal/application/port/output/history_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package output

import (
"context"
"time"

"github.com/fayupable/pgscope/internal/domain"
)
Expand All @@ -13,6 +14,10 @@ import (
// stored (in-memory ring buffer, database, ...).
type IHistoryStorePort interface {
Append(ctx context.Context, snapshot domain.Snapshot) error
Recent(ctx context.Context) ([]domain.Snapshot, error)
// Recent returns snapshots captured at or after since, in chronological
// order. Incident snapshots within the window are always returned in
// full; implementations may downsample periodic snapshots to keep the
// result bounded for wide windows.
Recent(ctx context.Context, since time.Time) ([]domain.Snapshot, error)
Incidents(ctx context.Context) ([]domain.Snapshot, error)
}
Loading
Loading