mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-07-13 04:57:11 +00:00
e39d6a0f0d
Multi-agent audit + fix pass covering bugs, security, dead config wiring,
and missing features. All quality gates green: build/vet/test -race/govulncheck/
golangci-lint. Frontend tests 47/47.
SECURITY & CORRECTNESS
- Scanner pipeline fail-closed: cache.go scan timeout now 503 (was goto servePkg);
scanner.go all-scanners-fail saves ScanStatusError; CheckVulnerabilities blocks
on missing/error result instead of allowing.
- Scanner argv corrections: govulncheck source-mode + go.mod discovery;
npm-audit generates lockfile via npm install --package-lock-only --ignore-scripts;
pip-audit per-extension dispatch (wheel direct / sdist extract+pyproject).
- GHSA version-range filtering implemented (was always-include); url.QueryEscape
on package name.
- pypi SSRF closed via host allowlist on original_url; url.QueryEscape on
rewriteURL output.
- Path traversal: cache temp file uses os.CreateTemp; smb keyToPath sanitized;
filesystem keyToPath returns error + filepath.Abs prefix-verify.
- Goroutine leaks plugged: cache.cleanupWorker stop channel; auth.ValidationCache
Stop() with sync.Once; ws.unregister buffered with non-blocking send.
- WebSocket double-close panic fixed via sync.Once Client.closeSend().
- Race conditions: gormstore.registryCache sync.RWMutex (was concurrent map
panic); auth.LastUsedAt no longer mutated under RLock; analytics strict
lock-ordering invariant (statsMu before downloadsMu).
- Auth validator fails CLOSED on transport/unknown-status errors (was returning
true,err 'allow cache fallback').
- Credential cache hash full SHA256 (was 8-byte truncate; eliminates 2^32
collision risk).
- filesystem.fs.used incremented after successful rename (no quota inflation
race).
- gormstore aggregation events deleted in same tx as insert (no double-counting).
- gormstore.SavePackage uses Updates(map) so zero-value security flags
(RequiresAuth=false) can be cleared.
- partition_manager validates partition name regex before DROP TABLE.
- vcs/git: version regex validation rejects --option-injection; checkout uses
--detach -- separator; removed TrimPrefix(repo,'v') that corrupted vault/
vitess/vim-go.
- WebSocket CheckOrigin allowlist (was return true; CSWSH closed); same-origin
default + ServerConfig.AllowedOrigins.
- fiber CVE GO-2026-4543 patched (v2.52.10 -> v2.52.12).
FUNCTIONAL FEATURES
- API key DB persistence: APIKeyModel + migration 202604280002, full CRUD,
async LastUsedAt updates with WaitGroup-tracked goroutines drained on Close.
- Admin bootstrap via GOHOARDER_BOOTSTRAP_ADMIN_KEY env var; idempotent
(skipped when non-revoked admin already exists).
- Auth middleware factory in pkg/app: RequireAuth, RequireRole, RequirePermission,
OptionalAuth. Mounted on proxy + read APIs when Auth.Enabled=true.
DELETE /api/packages/* requires admin role. /health public for k8s probes.
- NFS storage backend: pkg/storage/nfs wraps filesystem with /proc/mounts
detection (Linux), post-write fsync, read-after-write health probe.
- WebSocket real-time pipeline: pkg/events Broadcaster interface; cache + scanner
managers emit EventPackageCached / EventPackageDownloaded / EventScanComplete;
app.go runs 30s EventStatsUpdate ticker. Frontend has full WS client (reconnect
with exponential backoff 1s->30s, 25s heartbeat, subscriber pattern), Pinia
realtime store, Dashboard live indicator + activity feed + reactive stats
override.
- TLS termination: fiber.ListenTLS when Server.TLS.Enabled.
- Pre-warming: Prewarming.Enabled flag wired (was hardcoded false); Interval,
MaxConcurrent, TopPackages plumbed.
- NetworkConfig wired (timeouts, retry, rate-limit, circuit-breaker).
- AuthConfig.BcryptCost wired.
- Security.Scanners.Static.MaxPackageSize plumbed to cache.io.LimitReader.
- Handlers.{Go,NPM,PyPI}.Enabled gates route mounting.
- Graceful shutdown: authManager.Close drains async writes.
LINT/HYGIENE
- 80 golangci-lint issues resolved: errcheck (Close/Write/RemoveAll wrapped),
gofmt, gosec G104/G306, govet shadow renames + fieldalignment reorders,
staticcheck ST1000 pkg comments + QF1003 tagged switches + QF1008 embedded
field selectors + QF1001 De Morgan's law.
BEHAVIOR CHANGES
- Scanner now fail-closed: deployments without scanner binaries
(trivy/govulncheck/npm-audit/pip-audit/grype) BLOCK packages instead of
serving unscanned. Add binaries or plan a future allow-on-scan-error flag.
- WS origin defaults to same-origin; cross-origin dashboards must set
server.allowed_origins.
- Validator no longer fails open; private packages won't serve from cache when
validation can't be performed.
- download_events retention dropped from 24h to ~5min (deleted in agg tx).
Migration 202604280001 purges pre-upgrade events.
- DELETE /api/packages/* requires admin role when auth enabled.
NEW PACKAGES
- pkg/events - Broadcaster interface
- pkg/storage/nfs - NFS-aware filesystem wrapper
DEFERRED (out of scope this pass)
- Static scanner implementation (config defines AllowedLicenses/MaxPackageSize/
BlockSuspicious but pkg/scanner/static/ does not exist).
- AuthConfig.AuditLog wiring (no audit log infra yet).
- CacheConfig.TTLOverrides passthrough (would touch cache.Config beyond
integrator scope).
395 lines
12 KiB
Go
395 lines
12 KiB
Go
package gormstore
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Whitelist regexes for partition table names — defense in depth before
|
|
// we interpolate them into raw DDL.
|
|
var (
|
|
downloadEventPartitionRe = regexp.MustCompile(`^download_events_\d{4}_\d{2}$`)
|
|
auditLogPartitionRe = regexp.MustCompile(`^audit_log_\d{4}_\d{2}$`)
|
|
)
|
|
|
|
// PartitionManager handles automatic partition creation and cleanup for PostgreSQL
|
|
type PartitionManager struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewPartitionManager creates a new partition manager
|
|
func NewPartitionManager(db *gorm.DB) *PartitionManager {
|
|
return &PartitionManager{db: db}
|
|
}
|
|
|
|
// EnsurePartitions ensures required partitions exist for current and future months
|
|
func (pm *PartitionManager) EnsurePartitions() error {
|
|
// Check if we're using PostgreSQL
|
|
if pm.db.Name() != "postgres" {
|
|
log.Debug().Msg("Partitioning only supported on PostgreSQL, skipping")
|
|
return nil
|
|
}
|
|
|
|
log.Info().Msg("Ensuring partitions exist")
|
|
|
|
// Create partitions for download_events
|
|
if err := pm.ensureDownloadEventPartitions(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create partitions for audit_log
|
|
if err := pm.ensureAuditLogPartitions(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set up automatic partition creation
|
|
if err := pm.createPartitionFunction(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to create partition function (may already exist)")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureDownloadEventPartitions creates download_events partitions
|
|
func (pm *PartitionManager) ensureDownloadEventPartitions() error {
|
|
// Check if table is already partitioned
|
|
var isPartitioned bool
|
|
err := pm.db.Raw(`
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM pg_partitioned_table
|
|
WHERE partrelid = 'download_events'::regclass
|
|
)
|
|
`).Scan(&isPartitioned).Error
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !isPartitioned {
|
|
log.Info().Msg("Converting download_events to partitioned table")
|
|
|
|
// Rename existing table
|
|
if err := pm.db.Exec("ALTER TABLE IF EXISTS download_events RENAME TO download_events_old").Error; err != nil {
|
|
log.Warn().Err(err).Msg("Could not rename old table (may not exist)")
|
|
}
|
|
|
|
// Create partitioned table
|
|
createTableSQL := `
|
|
CREATE TABLE IF NOT EXISTS download_events (
|
|
id BIGSERIAL,
|
|
package_id BIGINT NOT NULL,
|
|
registry_id INTEGER NOT NULL,
|
|
downloaded_at TIMESTAMP NOT NULL,
|
|
user_agent VARCHAR(512),
|
|
ip_address VARCHAR(45),
|
|
authenticated BOOLEAN NOT NULL DEFAULT FALSE,
|
|
username VARCHAR(255)
|
|
) PARTITION BY RANGE (downloaded_at)
|
|
`
|
|
|
|
if err := pm.db.Exec(createTableSQL).Error; err != nil {
|
|
return fmt.Errorf("failed to create partitioned table: %w", err)
|
|
}
|
|
|
|
log.Info().Msg("Created partitioned download_events table")
|
|
}
|
|
|
|
// Create partitions for past 3 months, current month, and next 3 months
|
|
now := time.Now()
|
|
for i := -3; i <= 3; i++ {
|
|
month := now.AddDate(0, i, 0)
|
|
if err := pm.createDownloadEventPartition(month); err != nil {
|
|
log.Error().Err(err).Time("month", month).Msg("Failed to create partition")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createDownloadEventPartition creates a partition for a specific month
|
|
func (pm *PartitionManager) createDownloadEventPartition(month time.Time) error {
|
|
// Truncate to start of month
|
|
startOfMonth := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
endOfMonth := startOfMonth.AddDate(0, 1, 0)
|
|
|
|
partitionName := fmt.Sprintf("download_events_%d_%02d", month.Year(), month.Month())
|
|
|
|
// Check if partition already exists
|
|
var exists bool
|
|
err := pm.db.Raw("SELECT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = ?)", partitionName).Scan(&exists).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if exists {
|
|
log.Debug().Str("partition", partitionName).Msg("Partition already exists")
|
|
return nil
|
|
}
|
|
|
|
// Create partition
|
|
createPartitionSQL := fmt.Sprintf(`
|
|
CREATE TABLE %s PARTITION OF download_events
|
|
FOR VALUES FROM ('%s') TO ('%s')
|
|
`, partitionName, startOfMonth.Format("2006-01-02"), endOfMonth.Format("2006-01-02"))
|
|
|
|
if err := pm.db.Exec(createPartitionSQL).Error; err != nil {
|
|
return fmt.Errorf("failed to create partition %s: %w", partitionName, err)
|
|
}
|
|
|
|
// Create indexes on partition
|
|
indexSQL := []string{
|
|
fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s_package_idx ON %s(package_id, downloaded_at)", partitionName, partitionName),
|
|
fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s_registry_idx ON %s(registry_id)", partitionName, partitionName),
|
|
fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s_time_idx ON %s(downloaded_at)", partitionName, partitionName),
|
|
}
|
|
|
|
for _, sql := range indexSQL {
|
|
if err := pm.db.Exec(sql).Error; err != nil {
|
|
log.Warn().Err(err).Str("sql", sql).Msg("Failed to create index")
|
|
}
|
|
}
|
|
|
|
log.Info().Str("partition", partitionName).Msg("Created partition")
|
|
return nil
|
|
}
|
|
|
|
// ensureAuditLogPartitions creates audit_log partitions
|
|
func (pm *PartitionManager) ensureAuditLogPartitions() error {
|
|
// Check if table exists
|
|
var exists bool
|
|
err := pm.db.Raw("SELECT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'audit_log')").Scan(&exists).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !exists {
|
|
// Create partitioned table
|
|
createTableSQL := `
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id BIGSERIAL,
|
|
entity_type VARCHAR(50) NOT NULL,
|
|
entity_id BIGINT NOT NULL,
|
|
action VARCHAR(20) NOT NULL,
|
|
username VARCHAR(255) NOT NULL,
|
|
timestamp TIMESTAMP NOT NULL,
|
|
changes JSONB,
|
|
ip_address VARCHAR(45),
|
|
user_agent VARCHAR(512)
|
|
) PARTITION BY RANGE (timestamp)
|
|
`
|
|
|
|
if err := pm.db.Exec(createTableSQL).Error; err != nil {
|
|
return fmt.Errorf("failed to create audit_log table: %w", err)
|
|
}
|
|
|
|
log.Info().Msg("Created partitioned audit_log table")
|
|
}
|
|
|
|
// Create partitions for past month, current month, and next 2 months
|
|
now := time.Now()
|
|
for i := -1; i <= 2; i++ {
|
|
month := now.AddDate(0, i, 0)
|
|
if err := pm.createAuditLogPartition(month); err != nil {
|
|
log.Error().Err(err).Time("month", month).Msg("Failed to create audit partition")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createAuditLogPartition creates a partition for a specific month
|
|
func (pm *PartitionManager) createAuditLogPartition(month time.Time) error {
|
|
startOfMonth := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
endOfMonth := startOfMonth.AddDate(0, 1, 0)
|
|
|
|
partitionName := fmt.Sprintf("audit_log_%d_%02d", month.Year(), month.Month())
|
|
|
|
// Check if partition already exists
|
|
var exists bool
|
|
err := pm.db.Raw("SELECT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = ?)", partitionName).Scan(&exists).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if exists {
|
|
return nil
|
|
}
|
|
|
|
// Create partition
|
|
createPartitionSQL := fmt.Sprintf(`
|
|
CREATE TABLE %s PARTITION OF audit_log
|
|
FOR VALUES FROM ('%s') TO ('%s')
|
|
`, partitionName, startOfMonth.Format("2006-01-02"), endOfMonth.Format("2006-01-02"))
|
|
|
|
if err := pm.db.Exec(createPartitionSQL).Error; err != nil {
|
|
return fmt.Errorf("failed to create partition %s: %w", partitionName, err)
|
|
}
|
|
|
|
// Create indexes
|
|
indexSQL := []string{
|
|
fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s_entity_idx ON %s(entity_type, entity_id)", partitionName, partitionName),
|
|
fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s_user_idx ON %s(username)", partitionName, partitionName),
|
|
fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s_time_idx ON %s(timestamp)", partitionName, partitionName),
|
|
}
|
|
|
|
for _, sql := range indexSQL {
|
|
if err := pm.db.Exec(sql).Error; err != nil {
|
|
log.Warn().Err(err).Msg("Failed to create audit index")
|
|
}
|
|
}
|
|
|
|
log.Info().Str("partition", partitionName).Msg("Created audit partition")
|
|
return nil
|
|
}
|
|
|
|
// createPartitionFunction creates a PostgreSQL function for automatic partition creation
|
|
func (pm *PartitionManager) createPartitionFunction() error {
|
|
functionSQL := `
|
|
CREATE OR REPLACE FUNCTION create_next_month_partitions()
|
|
RETURNS void AS $$
|
|
DECLARE
|
|
next_month DATE := date_trunc('month', NOW() + INTERVAL '2 months');
|
|
partition_name TEXT;
|
|
start_date TEXT;
|
|
end_date TEXT;
|
|
BEGIN
|
|
-- Create download_events partition
|
|
partition_name := 'download_events_' || to_char(next_month, 'YYYY_MM');
|
|
start_date := to_char(next_month, 'YYYY-MM-DD');
|
|
end_date := to_char(next_month + INTERVAL '1 month', 'YYYY-MM-DD');
|
|
|
|
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF download_events FOR VALUES FROM (%L) TO (%L)',
|
|
partition_name, start_date, end_date);
|
|
|
|
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I(package_id, downloaded_at)',
|
|
partition_name || '_package_idx', partition_name);
|
|
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I(registry_id)',
|
|
partition_name || '_registry_idx', partition_name);
|
|
|
|
-- Create audit_log partition
|
|
partition_name := 'audit_log_' || to_char(next_month, 'YYYY_MM');
|
|
|
|
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_log FOR VALUES FROM (%L) TO (%L)',
|
|
partition_name, start_date, end_date);
|
|
|
|
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I(entity_type, entity_id)',
|
|
partition_name || '_entity_idx', partition_name);
|
|
|
|
RAISE NOTICE 'Created partitions for %', to_char(next_month, 'YYYY-MM');
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
`
|
|
|
|
if err := pm.db.Exec(functionSQL).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Info().Msg("Created partition management function")
|
|
return nil
|
|
}
|
|
|
|
// CleanupOldPartitions drops partitions older than the retention period
|
|
func (pm *PartitionManager) CleanupOldPartitions(retentionMonths int) error {
|
|
if pm.db.Name() != "postgres" {
|
|
return nil
|
|
}
|
|
|
|
cutoffDate := time.Now().AddDate(0, -retentionMonths, 0)
|
|
cutoffPartition := fmt.Sprintf("%d_%02d", cutoffDate.Year(), cutoffDate.Month())
|
|
|
|
log.Info().
|
|
Str("cutoff", cutoffPartition).
|
|
Int("retention_months", retentionMonths).
|
|
Msg("Cleaning up old partitions")
|
|
|
|
// Find and drop old download_events partitions
|
|
var downloadPartitions []string
|
|
if err := pm.db.Raw(`
|
|
SELECT tablename FROM pg_tables
|
|
WHERE tablename LIKE 'download_events_%'
|
|
AND tablename < 'download_events_' || ?
|
|
`, cutoffPartition).Scan(&downloadPartitions).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, partition := range downloadPartitions {
|
|
// Defense in depth: even though tablename came from pg_tables, verify it
|
|
// matches the expected partition naming scheme before interpolating into DDL.
|
|
if !downloadEventPartitionRe.MatchString(partition) {
|
|
log.Warn().Str("partition", partition).Msg("Skipping partition with unexpected name")
|
|
continue
|
|
}
|
|
log.Info().Str("partition", partition).Msg("Dropping old partition")
|
|
if dropErr := pm.db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", partition)).Error; dropErr != nil {
|
|
log.Error().Err(dropErr).Str("partition", partition).Msg("Failed to drop partition")
|
|
}
|
|
}
|
|
|
|
// Find and drop old audit_log partitions
|
|
var auditPartitions []string
|
|
if err := pm.db.Raw(`
|
|
SELECT tablename FROM pg_tables
|
|
WHERE tablename LIKE 'audit_log_%'
|
|
AND tablename < 'audit_log_' || ?
|
|
`, cutoffPartition).Scan(&auditPartitions).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, partition := range auditPartitions {
|
|
if !auditLogPartitionRe.MatchString(partition) {
|
|
log.Warn().Str("partition", partition).Msg("Skipping audit partition with unexpected name")
|
|
continue
|
|
}
|
|
log.Info().Str("partition", partition).Msg("Dropping old audit partition")
|
|
if dropErr := pm.db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", partition)).Error; dropErr != nil {
|
|
log.Error().Err(dropErr).Str("partition", partition).Msg("Failed to drop audit partition")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetPartitionInfo returns information about current partitions
|
|
func (pm *PartitionManager) GetPartitionInfo() (map[string]interface{}, error) {
|
|
if pm.db.Name() != "postgres" {
|
|
return map[string]interface{}{"status": "not_applicable"}, nil
|
|
}
|
|
|
|
info := make(map[string]interface{})
|
|
|
|
// Count download_events partitions
|
|
var downloadCount int64
|
|
pm.db.Raw("SELECT COUNT(*) FROM pg_tables WHERE tablename LIKE 'download_events_%'").Scan(&downloadCount)
|
|
info["download_events_partitions"] = downloadCount
|
|
|
|
// Count audit_log partitions
|
|
var auditCount int64
|
|
pm.db.Raw("SELECT COUNT(*) FROM pg_tables WHERE tablename LIKE 'audit_log_%'").Scan(&auditCount)
|
|
info["audit_log_partitions"] = auditCount
|
|
|
|
// Get partition sizes
|
|
type PartitionSize struct {
|
|
TableName string
|
|
SizeMB float64
|
|
}
|
|
|
|
var partitionSizes []PartitionSize
|
|
pm.db.Raw(`
|
|
SELECT
|
|
tablename AS table_name,
|
|
pg_total_relation_size(tablename::regclass) / 1024.0 / 1024.0 AS size_mb
|
|
FROM pg_tables
|
|
WHERE tablename LIKE 'download_events_%' OR tablename LIKE 'audit_log_%'
|
|
ORDER BY size_mb DESC
|
|
LIMIT 10
|
|
`).Scan(&partitionSizes)
|
|
|
|
info["largest_partitions"] = partitionSizes
|
|
|
|
return info, nil
|
|
}
|