Files

273 lines
7.6 KiB
Go

package audit
import (
"encoding/json"
"fmt"
"net"
"net/url"
"os"
osuser "os/user"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"hauler.dev/go/hauler/v2/internal/flags"
"hauler.dev/go/hauler/v2/pkg/consts"
)
var auditID string
// auditID is generated once per process to group all entries from a single invocation
func init() {
if id, err := uuid.NewV7(); err == nil {
auditID = id.String()
} else {
auditID = uuid.New().String()
}
}
// auditID returns the audit ID for the current process invocation
func ID() string { return auditID }
// SystemEntry captures OS level context and only records at verbose level to the global log
type SystemEntry struct {
User string `json:"user,omitempty"`
Hostname string `json:"hostname,omitempty"`
IPAddress string `json:"ip-address,omitempty"`
}
// GlobalEntry captures hauler flag values and only records at verbose audit level
type GlobalEntry struct {
HaulerDir string `json:"haulerdir,omitempty"`
IgnoreErrors bool `json:"ignore-errors,omitempty"`
LogLevel string `json:"log-level,omitempty"`
AuditLevel string `json:"audit-level,omitempty"`
Retries int `json:"retries,omitempty"`
StoreDir string `json:"store,omitempty"`
TempDir string `json:"tempdir,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
// Entry records a single auditable operation on the store
type Entry struct {
AuditID string `json:"audit-id,omitempty"`
StoreID string `json:"store-id,omitempty"`
Timestamp string `json:"timestamp"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Type string `json:"type,omitempty"`
Reference string `json:"reference,omitempty"`
Digest string `json:"digest,omitempty"`
Store string `json:"store,omitempty"`
System *SystemEntry `json:"system,omitempty"`
Global *GlobalEntry `json:"global,omitempty"`
Flags map[string]any `json:"flags,omitempty"`
// PortableReference replaces Reference in the store audit log
PortableReference string `json:"-"`
}
// portableEntry is the machine-agnostic subset written to the store audit log
type portableEntry struct {
AuditID string `json:"audit-id"`
StoreID string `json:"store-id,omitempty"`
Timestamp string `json:"timestamp"`
Command string `json:"command"`
Type string `json:"type,omitempty"`
Reference string `json:"reference,omitempty"`
Digest string `json:"digest,omitempty"`
}
// ShortFileRef returns a portable-safe short name for a local path or URL
func ShortFileRef(raw string) string {
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
if u, err := url.Parse(raw); err == nil {
if base := path.Base(u.Path); base != "." && base != "/" {
return base
}
}
}
return filepath.Base(raw)
}
// SanitizeURL strips userinfo and the query string from an http(s) URL so
// embedded credentials (presigned signatures, tokens, API keys) never reach
// the audit log, at any audit level. Non-URL values (local paths, image or
// chart references) are returned unchanged.
func SanitizeURL(raw string) string {
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
return raw
}
u, err := url.Parse(raw)
if err != nil {
return raw
}
u.User = nil
u.RawQuery = ""
u.Fragment = ""
return u.String()
}
// BuildSystem returns OS level context for verbose audit entries
func BuildSystem() SystemEntry {
s := SystemEntry{}
if u, err := osuser.Current(); err == nil {
s.User = u.Username
}
s.Hostname, _ = os.Hostname()
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
s.IPAddress = ipnet.IP.String()
break
}
}
}
return s
}
// BuildGlobal returns hauler flag values for verbose audit entries
func BuildGlobal(ro *flags.CliRootOpts, rso *flags.StoreRootOpts) GlobalEntry {
g := GlobalEntry{}
if ro != nil {
g.HaulerDir = resolveDir(ro.HaulerDir)
g.IgnoreErrors = flags.ShouldIgnoreErrors(ro)
g.LogLevel = ro.LogLevel
g.AuditLevel = ro.AuditLevel
}
if rso != nil {
g.Retries = rso.Retries
g.StoreDir = rso.StoreDir
g.TempDir = rso.TempOverride
}
env := map[string]string{}
for _, key := range []string{
consts.HaulerDir,
consts.HaulerTempDir,
consts.HaulerStoreDir,
consts.HaulerIgnoreErrors,
consts.HaulerRetries,
consts.HaulerLogLevel,
consts.HaulerAuditLevel,
consts.HaulerConcurrency,
consts.HaulerBlobConcurrency,
} {
if v := os.Getenv(key); v != "" {
env[key] = v
}
}
if len(env) > 0 {
g.Env = env
}
return g
}
// Append records a full log entry to <haulerDir>/audit.log
// When e.Store is set... a portable subset to <storeDir>/audit.log
func Append(haulerDir string, e Entry) error {
e.AuditID = auditID
e.Timestamp = time.Now().UTC().Format(time.RFC3339)
// global write... full entry including system/global/flags
var globalErr error
if err := appendLine(resolveDir(haulerDir), e); err != nil {
globalErr = fmt.Errorf("audit: global write: %w", err)
}
// store write... portable subset only, attempted even if the global write above failed
if e.Store != "" {
reference := e.Reference
if e.PortableReference != "" {
reference = e.PortableReference
}
pe := portableEntry{
AuditID: e.AuditID,
StoreID: e.StoreID,
Timestamp: e.Timestamp,
Command: e.Command,
Type: e.Type,
Reference: reference,
Digest: e.Digest,
}
if err := appendLine(e.Store, pe); err != nil {
if globalErr != nil {
return fmt.Errorf("%v: audit: store write: %w", globalErr, err)
}
return fmt.Errorf("audit: store write: %w", err)
}
}
return globalErr
}
// LogFileName is the audit log's filename, under both haulerDir and a store's Root.
const LogFileName = "audit.log"
// appendMu serializes appendLine/MergeStoreLog calls: os.OpenFile with
// O_APPEND is only atomic for a single write() syscall on POSIX, and
// concurrent `store sync` image jobs (runImageJobs) can each call this at
// once without it.
var appendMu sync.Mutex
func appendLine(dir string, v any) error {
appendMu.Lock()
defer appendMu.Unlock()
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("audit: ensure dir: %w", err)
}
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("audit: marshal: %w", err)
}
f, err := os.OpenFile(filepath.Join(dir, LogFileName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("audit: open log: %w", err)
}
defer f.Close()
_, err = fmt.Fprintf(f, "%s\n", data)
return err
}
// MergeStoreLog appends tempDir's staged audit.log onto destDir's. No-op if tempDir has none.
func MergeStoreLog(tempDir, destDir string) error {
data, err := os.ReadFile(filepath.Join(tempDir, LogFileName))
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("audit: read staged log: %w", err)
}
if len(data) == 0 {
return nil
}
appendMu.Lock()
defer appendMu.Unlock()
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("audit: ensure dir: %w", err)
}
f, err := os.OpenFile(filepath.Join(destDir, LogFileName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("audit: open log: %w", err)
}
defer f.Close()
_, err = f.Write(data)
return err
}
func resolveDir(haulerDir string) string {
if haulerDir != "" {
return haulerDir
}
if d := os.Getenv(consts.HaulerDir); d != "" {
return d
}
home, _ := os.UserHomeDir()
return filepath.Join(home, consts.DefaultHaulerDirName)
}