Fix ECDH zeroization, add audit logging, and remediate high findings
- Fix #61: handleRotateKey and handleDeleteUser now zeroize stored privBytes instead of calling Bytes() (which returns a copy). New state populates privBytes; old references nil'd for GC. - Add audit logging subsystem (internal/audit) with structured event recording for cryptographic operations. - Add audit log engine spec (engines/auditlog.md). - Add ValidateName checks across all engines for path traversal (#48). - Update AUDIT.md: all High findings resolved (0 open). - Add REMEDIATION.md with detailed remediation tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
79
internal/audit/audit.go
Normal file
79
internal/audit/audit.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Package audit provides structured audit event logging for Metacrypt.
|
||||
// Audit events record security-relevant operations (who did what, when,
|
||||
// and whether it succeeded) as one JSON object per line.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// LevelAudit is a custom slog level for audit events. It sits above Warn
|
||||
// so that audit events are never suppressed by log level filtering.
|
||||
const LevelAudit = slog.Level(12)
|
||||
|
||||
func init() {
|
||||
// Replace the level name so JSON output shows "AUDIT" instead of "ERROR+4".
|
||||
slog.SetLogLoggerLevel(LevelAudit)
|
||||
}
|
||||
|
||||
// Logger writes structured audit events. A nil *Logger is safe to use;
|
||||
// all methods are no-ops.
|
||||
type Logger struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates an audit logger writing to the given handler. Pass nil to
|
||||
// create a disabled logger (equivalent to using a nil *Logger).
|
||||
func New(h slog.Handler) *Logger {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return &Logger{logger: slog.New(h)}
|
||||
}
|
||||
|
||||
// Event represents a single audit event.
|
||||
type Event struct {
|
||||
Caller string // MCIAS username, or "operator" for unauthenticated ops
|
||||
Roles []string // caller's MCIAS roles
|
||||
Operation string // engine operation name (e.g., "issue", "sign-user")
|
||||
Engine string // engine type (e.g., "ca", "sshca", "transit", "user")
|
||||
Mount string // mount name
|
||||
Resource string // policy resource path evaluated
|
||||
Outcome string // "success", "denied", or "error"
|
||||
Error string // error message (on "error" or "denied" outcomes)
|
||||
Detail map[string]interface{} // operation-specific metadata
|
||||
}
|
||||
|
||||
// Log writes an audit event. Safe to call on a nil receiver.
|
||||
func (l *Logger) Log(ctx context.Context, e Event) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
attrs := []slog.Attr{
|
||||
slog.String("caller", e.Caller),
|
||||
slog.String("operation", e.Operation),
|
||||
slog.String("outcome", e.Outcome),
|
||||
}
|
||||
if len(e.Roles) > 0 {
|
||||
attrs = append(attrs, slog.Any("roles", e.Roles))
|
||||
}
|
||||
if e.Engine != "" {
|
||||
attrs = append(attrs, slog.String("engine", e.Engine))
|
||||
}
|
||||
if e.Mount != "" {
|
||||
attrs = append(attrs, slog.String("mount", e.Mount))
|
||||
}
|
||||
if e.Resource != "" {
|
||||
attrs = append(attrs, slog.String("resource", e.Resource))
|
||||
}
|
||||
if e.Error != "" {
|
||||
attrs = append(attrs, slog.String("error", e.Error))
|
||||
}
|
||||
if len(e.Detail) > 0 {
|
||||
attrs = append(attrs, slog.Any("detail", e.Detail))
|
||||
}
|
||||
|
||||
l.logger.LogAttrs(ctx, LevelAudit, "audit", attrs...)
|
||||
}
|
||||
124
internal/audit/audit_test.go
Normal file
124
internal/audit/audit_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNilLoggerIsSafe(t *testing.T) {
|
||||
var l *Logger
|
||||
// Must not panic.
|
||||
l.Log(context.Background(), Event{
|
||||
Caller: "test",
|
||||
Operation: "issue",
|
||||
Outcome: "success",
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogWritesJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.Level(-10), // accept all levels
|
||||
})
|
||||
l := New(h)
|
||||
|
||||
l.Log(context.Background(), Event{
|
||||
Caller: "kyle",
|
||||
Roles: []string{"admin"},
|
||||
Operation: "issue",
|
||||
Engine: "ca",
|
||||
Mount: "pki",
|
||||
Outcome: "success",
|
||||
Detail: map[string]interface{}{"serial": "01:02:03"},
|
||||
})
|
||||
|
||||
var entry map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\nbody: %s", err, buf.String())
|
||||
}
|
||||
|
||||
checks := map[string]string{
|
||||
"caller": "kyle",
|
||||
"operation": "issue",
|
||||
"engine": "ca",
|
||||
"mount": "pki",
|
||||
"outcome": "success",
|
||||
}
|
||||
for k, want := range checks {
|
||||
got, ok := entry[k].(string)
|
||||
if !ok || got != want {
|
||||
t.Errorf("field %q = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
detail, ok := entry["detail"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %T", entry["detail"])
|
||||
}
|
||||
if serial, _ := detail["serial"].(string); serial != "01:02:03" {
|
||||
t.Errorf("detail.serial = %q, want %q", serial, "01:02:03")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogOmitsEmptyFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.Level(-10),
|
||||
})
|
||||
l := New(h)
|
||||
|
||||
l.Log(context.Background(), Event{
|
||||
Caller: "kyle",
|
||||
Operation: "unseal",
|
||||
Outcome: "success",
|
||||
})
|
||||
|
||||
var entry map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
for _, key := range []string{"roles", "engine", "mount", "resource", "error", "detail"} {
|
||||
if _, ok := entry[key]; ok {
|
||||
t.Errorf("field %q should be omitted for empty value", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogIncludesError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.Level(-10),
|
||||
})
|
||||
l := New(h)
|
||||
|
||||
l.Log(context.Background(), Event{
|
||||
Caller: "operator",
|
||||
Operation: "unseal",
|
||||
Outcome: "denied",
|
||||
Error: "invalid password",
|
||||
})
|
||||
|
||||
var entry map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if got, _ := entry["error"].(string); got != "invalid password" {
|
||||
t.Errorf("error = %q, want %q", got, "invalid password")
|
||||
}
|
||||
if got, _ := entry["outcome"].(string); got != "denied" {
|
||||
t.Errorf("outcome = %q, want %q", got, "denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithNilHandlerReturnsNil(t *testing.T) {
|
||||
l := New(nil)
|
||||
if l != nil {
|
||||
t.Errorf("New(nil) = %v, want nil", l)
|
||||
}
|
||||
// Must not panic.
|
||||
l.Log(context.Background(), Event{Caller: "test", Operation: "test", Outcome: "success"})
|
||||
}
|
||||
Reference in New Issue
Block a user