From 69cdd4a4e73faa08c61667bed791fb141ae79114 Mon Sep 17 00:00:00 2001 From: P4ST4S Date: Mon, 24 Aug 2026 08:47:29 +0200 Subject: [PATCH] feat(audit): add verification CLI --- CHANGELOG.md | 2 + README.md | 20 +++ STABILITY.md | 3 + cmd/mcp-audit/main.go | 3 + cmd/mcp-audit/verify.go | 112 ++++++++++++++ cmd/mcp-audit/verify_test.go | 125 +++++++++++++++ internal/audit/verify/jsonl.go | 48 ++++++ internal/audit/verify/sqlite.go | 154 ++++++++++++++++++ internal/audit/verify/verify.go | 150 ++++++++++++++++++ internal/audit/verify/verify_test.go | 223 +++++++++++++++++++++++++++ 10 files changed, 840 insertions(+) create mode 100644 cmd/mcp-audit/verify.go create mode 100644 cmd/mcp-audit/verify_test.go create mode 100644 internal/audit/verify/jsonl.go create mode 100644 internal/audit/verify/sqlite.go create mode 100644 internal/audit/verify/verify.go create mode 100644 internal/audit/verify/verify_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 890a78b..e290481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to mcp-audit are documented in this file. - Additive Integrity v2 metadata using RFC 8785 JCS and HMAC-SHA256 to protect the complete critical audit record while preserving legacy signatures. - `audit.signing.key_id` for identifying the Integrity v2 verification key. +- `mcp-audit verify` for streaming verification of Integrity v2 and legacy + signatures in JSONL and SQLite audit artifacts, with text and JSON output. - Security invariants and release gates for v1.2.0. - Installation cookbook with platform-specific notes in `INSTALL.md`. - VS Code stdio configuration example under `examples/vscode/`. diff --git a/README.md b/README.md index b6d016a..a5262d1 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,26 @@ CLI flags: --log-level debug | info | warn | error ``` +### Verify audit evidence + +Verify every Integrity v2 or legacy signature in a JSONL or SQLite audit artifact: + +```bash +MCP_AUDIT_SIGNING_SECRET="your-signing-secret" mcp-audit verify audit.jsonl +MCP_AUDIT_SIGNING_SECRET="your-signing-secret" mcp-audit verify audit.db --json +``` + +The verifier detects JSONL and SQLite automatically. Use `--format jsonl` or +`--format sqlite` to override detection, and `--key-id ID` when the Integrity v2 +records were produced with a non-default key ID. `AUDIT_SECRET` remains a +compatible fallback for existing deployments. + +Text output reports `verified`, `invalid`, `legacy`, and `unsigned` record +counts. Exit status `0` means every record has a valid Integrity v2 or legacy +signature, `1` means at least one record is invalid or unsigned, and `2` means +the command or artifact could not be read. `--json` emits the same result with +the total count and first verification error for automation. + ## Claude Desktop Configure Claude Desktop to spawn `mcp-audit` instead of the upstream MCP server: diff --git a/STABILITY.md b/STABILITY.md index ffe29d0..a74bd3d 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -30,6 +30,9 @@ The following surfaces are covered by the stability policy starting at `v1.0.0`: - Existing flags keep their meaning and accepted values. - New flags are additive. - The `--version` output format is documented and stable: `mcp-audit (commit , built )`. +- The `verify` command accepts JSONL and SQLite audit artifacts. Its text counter + names, JSON result fields, and exit statuses (`0` verified, `1` invalid or + unsigned evidence, `2` usage or input failure) are stable. ### Audit entry JSON schema diff --git a/cmd/mcp-audit/main.go b/cmd/mcp-audit/main.go index 5aee58e..1f6a2e9 100644 --- a/cmd/mcp-audit/main.go +++ b/cmd/mcp-audit/main.go @@ -145,6 +145,9 @@ type cliFlags struct { } func main() { + if len(os.Args) > 1 && os.Args[1] == "verify" { + os.Exit(runVerifyCommand(os.Args[2:], os.Stdout, os.Stderr)) + } flags := parseFlags() if flags.version { fmt.Println(versionString()) diff --git a/cmd/mcp-audit/verify.go b/cmd/mcp-audit/verify.go new file mode 100644 index 0000000..650b335 --- /dev/null +++ b/cmd/mcp-audit/verify.go @@ -0,0 +1,112 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/P4ST4S/mcp-audit/internal/audit/integrity" + auditverify "github.com/P4ST4S/mcp-audit/internal/audit/verify" +) + +const verifyUsage = `usage: mcp-audit verify [--format auto|jsonl|sqlite] [--key-id ID] [--json]` + +type verifyOptions struct { + path string + format auditverify.Format + keyID string + json bool + help bool +} + +func runVerifyCommand(args []string, stdout, stderr io.Writer) int { + options, err := parseVerifyOptions(args) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n%s\n", err, verifyUsage) + return 2 + } + if options.help { + fmt.Fprintln(stdout, verifyUsage) + return 0 + } + secret := os.Getenv("MCP_AUDIT_SIGNING_SECRET") + if secret == "" { + secret = os.Getenv("AUDIT_SECRET") + } + keys := make(map[string]string) + if secret != "" { + keys[options.keyID] = secret + } + result, err := auditverify.VerifyPath(options.path, auditverify.Config{ + Format: options.format, + Keys: keys, + }) + if err != nil { + fmt.Fprintf(stderr, "verification failed: %v\n", err) + return 2 + } + if options.json { + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(result); err != nil { + fmt.Fprintf(stderr, "write verification result: %v\n", err) + return 2 + } + } else { + fmt.Fprintf(stdout, "verified: %d\ninvalid: %d\nlegacy: %d\nunsigned: %d\n", + result.Verified, result.Invalid, result.Legacy, result.Unsigned) + } + if result.FirstError != "" { + fmt.Fprintf(stderr, "first error: %s\n", result.FirstError) + } + if !result.Clean() { + return 1 + } + return 0 +} + +func parseVerifyOptions(args []string) (verifyOptions, error) { + options := verifyOptions{format: auditverify.FormatAuto, keyID: integrity.DefaultKeyID} + for index := 0; index < len(args); index++ { + argument := args[index] + switch { + case argument == "--help" || argument == "-h": + options.help = true + case argument == "--json": + options.json = true + case argument == "--format" || argument == "--key-id": + if index+1 >= len(args) { + return verifyOptions{}, fmt.Errorf("%s requires a value", argument) + } + index++ + if argument == "--format" { + options.format = auditverify.Format(args[index]) + } else { + options.keyID = args[index] + } + case strings.HasPrefix(argument, "--format="): + options.format = auditverify.Format(strings.TrimPrefix(argument, "--format=")) + case strings.HasPrefix(argument, "--key-id="): + options.keyID = strings.TrimPrefix(argument, "--key-id=") + case strings.HasPrefix(argument, "-"): + return verifyOptions{}, fmt.Errorf("unknown option %q", argument) + default: + if options.path != "" { + return verifyOptions{}, fmt.Errorf("expected one audit path, got %q and %q", options.path, argument) + } + options.path = argument + } + } + if options.help { + return options, nil + } + if options.path == "" { + return verifyOptions{}, fmt.Errorf("audit path is required") + } + if options.keyID == "" { + return verifyOptions{}, fmt.Errorf("key ID must not be empty") + } + return options, nil +} diff --git a/cmd/mcp-audit/verify_test.go b/cmd/mcp-audit/verify_test.go new file mode 100644 index 0000000..cc0c911 --- /dev/null +++ b/cmd/mcp-audit/verify_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/P4ST4S/mcp-audit/internal/audit" + "github.com/P4ST4S/mcp-audit/internal/audit/integrity" +) + +func TestRunVerifyCommandJSONAfterPath(t *testing.T) { + const secret = "command verification secret" + t.Setenv("MCP_AUDIT_SIGNING_SECRET", secret) + t.Setenv("AUDIT_SECRET", "wrong fallback secret") + entry := commandTestEntry("signed") + metadata, err := integrity.NewSigner(secret, "production").Sign(audit.IntegrityEntryV2(entry)) + if err != nil { + t.Fatalf("sign entry: %v", err) + } + entry.Integrity = metadata + path := writeCommandTestJSONL(t, entry) + + var stdout, stderr bytes.Buffer + exitCode := runVerifyCommand([]string{path, "--json", "--key-id", "production"}, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr.String()) + } + var result map[string]any + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode JSON output: %v", err) + } + if result["verified"] != float64(1) || result["total"] != float64(1) { + t.Fatalf("result = %v, want one verified entry", result) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRunVerifyCommandUsesLegacyAuditSecret(t *testing.T) { + const secret = "legacy environment secret" + t.Setenv("MCP_AUDIT_SIGNING_SECRET", "") + t.Setenv("AUDIT_SECRET", secret) + entry := commandTestEntry("legacy") + entry.Signature = audit.NewSigner(secret).Sign(entry) + path := writeCommandTestJSONL(t, entry) + + var stdout, stderr bytes.Buffer + exitCode := runVerifyCommand([]string{path}, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "legacy: 1") { + t.Fatalf("stdout = %q, want legacy count", stdout.String()) + } +} + +func TestRunVerifyCommandReturnsOneForUnsignedEvidence(t *testing.T) { + t.Setenv("MCP_AUDIT_SIGNING_SECRET", "") + t.Setenv("AUDIT_SECRET", "") + path := writeCommandTestJSONL(t, commandTestEntry("unsigned")) + + var stdout, stderr bytes.Buffer + exitCode := runVerifyCommand([]string{path}, &stdout, &stderr) + if exitCode != 1 { + t.Fatalf("exit code = %d, want 1", exitCode) + } + if !strings.Contains(stdout.String(), "unsigned: 1") { + t.Fatalf("stdout = %q, want unsigned count", stdout.String()) + } + if !strings.Contains(stderr.String(), "first error: entry unsigned: unsigned") { + t.Fatalf("stderr = %q, want first error", stderr.String()) + } +} + +func TestRunVerifyCommandReturnsTwoForUsageAndInputErrors(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {name: "missing path"}, + {name: "unknown option", args: []string{"--unknown"}}, + {name: "missing file", args: []string{filepath.Join(t.TempDir(), "missing.jsonl")}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if exitCode := runVerifyCommand(tc.args, &stdout, &stderr); exitCode != 2 { + t.Fatalf("exit code = %d, want 2; stderr = %q", exitCode, stderr.String()) + } + }) + } +} + +func commandTestEntry(id string) audit.Entry { + return audit.Entry{ + ID: id, + Timestamp: time.Date(2026, 8, 24, 10, 0, 0, 0, time.UTC), + Direction: audit.DirectionClientToServer, + Transport: "stdio", + Method: "ping", + Params: json.RawMessage(`{}`), + DurationMs: 1, + ClientID: "client", + ServerID: "server", + } +} + +func writeCommandTestJSONL(t *testing.T, entry audit.Entry) string { + t.Helper() + raw, err := json.Marshal(entry) + if err != nil { + t.Fatalf("marshal entry: %v", err) + } + path := filepath.Join(t.TempDir(), "audit.jsonl") + if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil { + t.Fatalf("write JSONL: %v", err) + } + return path +} diff --git a/internal/audit/verify/jsonl.go b/internal/audit/verify/jsonl.go new file mode 100644 index 0000000..bef9ef8 --- /dev/null +++ b/internal/audit/verify/jsonl.go @@ -0,0 +1,48 @@ +package verify + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/P4ST4S/mcp-audit/internal/audit" + "github.com/gowebpki/jcs" +) + +const maxJSONLRecordBytes = 32 * 1024 * 1024 + +func verifyJSONLPath(path string, verifier entryVerifier) (Result, error) { + file, err := os.Open(path) + if err != nil { + return Result{}, fmt.Errorf("audit: verify: open JSONL: %w", err) + } + defer file.Close() + + var result Result + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), maxJSONLRecordBytes) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + raw := bytes.TrimSpace(scanner.Bytes()) + if len(raw) == 0 { + continue + } + if _, err := jcs.Transform(raw); err != nil { + result.malformed(lineNumber, fmt.Errorf("invalid or ambiguous JSON: %w", err)) + continue + } + var entry audit.Entry + if err := json.Unmarshal(raw, &entry); err != nil { + result.malformed(lineNumber, fmt.Errorf("decode audit entry: %w", err)) + continue + } + result.record(entry, verifier) + } + if err := scanner.Err(); err != nil { + return result, fmt.Errorf("audit: verify: scan JSONL: %w", err) + } + return result, nil +} diff --git a/internal/audit/verify/sqlite.go b/internal/audit/verify/sqlite.go new file mode 100644 index 0000000..d3fc3ba --- /dev/null +++ b/internal/audit/verify/sqlite.go @@ -0,0 +1,154 @@ +package verify + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/P4ST4S/mcp-audit/internal/audit" + "github.com/P4ST4S/mcp-audit/internal/audit/integrity" + _ "modernc.org/sqlite" +) + +var sqliteEntryColumns = []string{ + "id", "timestamp", "audit_operation_id", "outcome", "direction", "transport", "method", + "request_id", "tool_name", "params", "result", "error", "duration_ms", "client_id", "server_id", + "signature", "integrity", +} + +func verifySQLitePath(path string, verifier entryVerifier) (Result, error) { + if _, err := os.Stat(path); err != nil { + return Result{}, fmt.Errorf("audit: verify: stat SQLite: %w", err) + } + db, err := sql.Open("sqlite", path) + if err != nil { + return Result{}, fmt.Errorf("audit: verify: open SQLite: %w", err) + } + defer db.Close() + if _, err := db.Exec(`PRAGMA query_only = ON`); err != nil { + return Result{}, fmt.Errorf("audit: verify: enable SQLite query-only mode: %w", err) + } + available, err := sqliteColumns(db) + if err != nil { + return Result{}, err + } + for _, required := range []string{"id", "timestamp", "direction", "transport", "method", "duration_ms", "client_id", "server_id", "signature"} { + if !available[required] { + return Result{}, fmt.Errorf("audit: verify: SQLite audit_entries is missing required column %q", required) + } + } + selects := make([]string, len(sqliteEntryColumns)) + for index, column := range sqliteEntryColumns { + if available[column] { + selects[index] = column + } else { + selects[index] = "NULL AS " + column + } + } + rows, err := db.Query(`SELECT ` + strings.Join(selects, ", ") + ` FROM audit_entries ORDER BY rowid`) + if err != nil { + return Result{}, fmt.Errorf("audit: verify: query SQLite: %w", err) + } + defer rows.Close() + + var result Result + rowNumber := 0 + for rows.Next() { + rowNumber++ + entry, err := scanSQLiteEntry(rows) + if err != nil { + result.malformed(rowNumber, err) + continue + } + result.record(entry, verifier) + } + if err := rows.Err(); err != nil { + return result, fmt.Errorf("audit: verify: iterate SQLite: %w", err) + } + return result, nil +} + +func sqliteColumns(db *sql.DB) (map[string]bool, error) { + rows, err := db.Query(`PRAGMA table_info(audit_entries)`) + if err != nil { + return nil, fmt.Errorf("audit: verify: inspect SQLite schema: %w", err) + } + defer rows.Close() + columns := make(map[string]bool) + for rows.Next() { + var cid, notNull, primaryKey int + var name, columnType string + var defaultValue sql.NullString + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + return nil, fmt.Errorf("audit: verify: scan SQLite schema: %w", err) + } + columns[name] = true + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("audit: verify: iterate SQLite schema: %w", err) + } + if len(columns) == 0 { + return nil, fmt.Errorf("audit: verify: SQLite table audit_entries does not exist") + } + return columns, nil +} + +func scanSQLiteEntry(rows *sql.Rows) (audit.Entry, error) { + values := make([]sql.NullString, len(sqliteEntryColumns)) + destinations := make([]any, len(values)) + for index := range values { + destinations[index] = &values[index] + } + if err := rows.Scan(destinations...); err != nil { + return audit.Entry{}, fmt.Errorf("scan SQLite entry: %w", err) + } + value := func(column string) string { + for index, candidate := range sqliteEntryColumns { + if candidate == column && values[index].Valid { + return values[index].String + } + } + return "" + } + timestamp, err := time.Parse(time.RFC3339Nano, value("timestamp")) + if err != nil { + return audit.Entry{}, fmt.Errorf("parse timestamp: %w", err) + } + durationMS, err := strconv.ParseInt(value("duration_ms"), 10, 64) + if err != nil { + return audit.Entry{}, fmt.Errorf("parse duration_ms: %w", err) + } + entry := audit.Entry{ + ID: value("id"), + Timestamp: timestamp, + AuditOperationID: value("audit_operation_id"), + Outcome: audit.Outcome(value("outcome")), + Direction: value("direction"), + Transport: value("transport"), + Method: value("method"), + RequestID: value("request_id"), + ToolName: value("tool_name"), + Params: json.RawMessage(value("params")), + Result: json.RawMessage(value("result")), + DurationMs: durationMS, + ClientID: value("client_id"), + ServerID: value("server_id"), + Signature: value("signature"), + } + if raw := value("error"); raw != "" && raw != "null" { + if err := json.Unmarshal([]byte(raw), &entry.Error); err != nil { + return audit.Entry{}, fmt.Errorf("decode error field: %w", err) + } + } + if raw := value("integrity"); raw != "" && raw != "null" { + entry.Integrity = &integrity.Metadata{} + if err := json.Unmarshal([]byte(raw), entry.Integrity); err != nil { + return audit.Entry{}, fmt.Errorf("decode integrity field: %w", err) + } + } + return entry, nil +} diff --git a/internal/audit/verify/verify.go b/internal/audit/verify/verify.go new file mode 100644 index 0000000..2d683c1 --- /dev/null +++ b/internal/audit/verify/verify.go @@ -0,0 +1,150 @@ +// Package verify validates stored audit evidence without modifying it. +package verify + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/P4ST4S/mcp-audit/internal/audit" + "github.com/P4ST4S/mcp-audit/internal/audit/integrity" +) + +const ( + FormatAuto = "auto" + FormatJSONL = "jsonl" + FormatSQLite = "sqlite" +) + +// Result summarizes verification outcomes for one evidence artifact. +type Result struct { + Verified int `json:"verified"` + Invalid int `json:"invalid"` + Legacy int `json:"legacy"` + Unsigned int `json:"unsigned"` + Total int `json:"total"` + FirstError string `json:"first_error,omitempty"` +} + +// Clean reports whether every record was cryptographically verified. +func (r Result) Clean() bool { + return r.Invalid == 0 && r.Unsigned == 0 +} + +// Config supplies verification keys and input format. +type Config struct { + Format Format + Keys map[string]string +} + +// Format is an evidence storage format. +type Format string + +// VerifyPath verifies every record in path. +func VerifyPath(path string, config Config) (Result, error) { + format := config.Format + if format == "" || format == FormatAuto { + detected, err := detectFormat(path) + if err != nil { + return Result{}, err + } + format = detected + } + verifier := newEntryVerifier(config.Keys) + switch format { + case FormatJSONL: + return verifyJSONLPath(path, verifier) + case FormatSQLite: + return verifySQLitePath(path, verifier) + default: + return Result{}, fmt.Errorf("audit: verify: unsupported format %q", format) + } +} + +type entryVerifier struct { + v2 *integrity.Verifier + legacy map[string]*audit.Signer +} + +func newEntryVerifier(keys map[string]string) entryVerifier { + legacy := make(map[string]*audit.Signer, len(keys)) + for keyID, secret := range keys { + legacy[keyID] = audit.NewSigner(secret) + } + return entryVerifier{v2: integrity.NewVerifier(keys), legacy: legacy} +} + +func (v entryVerifier) verify(entry audit.Entry) (string, error) { + if entry.Integrity != nil { + if err := v.v2.Verify(audit.IntegrityEntryV2(entry), entry.Integrity); err != nil { + return "invalid", err + } + return "verified", nil + } + if entry.Signature == "" { + return "unsigned", nil + } + for _, signer := range v.legacy { + if signer.Verify(entry) { + return "legacy", nil + } + } + return "invalid", errors.New("legacy signature does not match any configured key") +} + +func (r *Result) record(entry audit.Entry, verifier entryVerifier) { + r.Total++ + status, err := verifier.verify(entry) + switch status { + case "verified": + r.Verified++ + case "legacy": + r.Legacy++ + case "unsigned": + r.Unsigned++ + if r.FirstError == "" { + r.FirstError = entryLabel(entry.ID, r.Total) + ": unsigned" + } + default: + r.Invalid++ + if r.FirstError == "" { + r.FirstError = entryLabel(entry.ID, r.Total) + ": " + err.Error() + } + } +} + +func (r *Result) malformed(index int, err error) { + r.Total++ + r.Invalid++ + if r.FirstError == "" { + r.FirstError = fmt.Sprintf("record %d: %v", index, err) + } +} + +func entryLabel(id string, index int) string { + if id != "" { + return fmt.Sprintf("entry %s", id) + } + return fmt.Sprintf("record %d", index) +} + +func detectFormat(path string) (Format, error) { + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("audit: verify: open input: %w", err) + } + defer file.Close() + header := make([]byte, 16) + read, err := file.Read(header) + if err == io.EOF && read == 0 { + return FormatJSONL, nil + } + if err != nil && read == 0 { + return "", fmt.Errorf("audit: verify: read input header: %w", err) + } + if string(header[:read]) == "SQLite format 3\x00" { + return FormatSQLite, nil + } + return FormatJSONL, nil +} diff --git a/internal/audit/verify/verify_test.go b/internal/audit/verify/verify_test.go new file mode 100644 index 0000000..014901d --- /dev/null +++ b/internal/audit/verify/verify_test.go @@ -0,0 +1,223 @@ +package verify_test + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/P4ST4S/mcp-audit/internal/audit" + "github.com/P4ST4S/mcp-audit/internal/audit/integrity" + "github.com/P4ST4S/mcp-audit/internal/audit/storage" + auditverify "github.com/P4ST4S/mcp-audit/internal/audit/verify" + _ "modernc.org/sqlite" +) + +const testSecret = "a sufficiently long audit verification secret" + +func TestVerifyJSONLReportsV2LegacyUnsignedAndInvalid(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.jsonl") + entries := []audit.Entry{ + signedV2Entry(t, "v2"), + legacyEntry("legacy"), + baseEntry("unsigned"), + signedV2Entry(t, "tampered"), + } + entries[3].Result = json.RawMessage(`{"ok":false}`) + + var lines []string + for _, entry := range entries { + raw, err := json.Marshal(entry) + if err != nil { + t.Fatalf("marshal entry: %v", err) + } + lines = append(lines, string(raw)) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatalf("write JSONL: %v", err) + } + + result, err := auditverify.VerifyPath(path, auditverify.Config{ + Keys: map[string]string{integrity.DefaultKeyID: testSecret}, + }) + if err != nil { + t.Fatalf("verify JSONL: %v", err) + } + assertResult(t, result, auditverify.Result{ + Verified: 1, + Invalid: 1, + Legacy: 1, + Unsigned: 1, + Total: 4, + }) + if result.Clean() { + t.Fatal("result should not be clean") + } + if !strings.Contains(result.FirstError, "entry unsigned: unsigned") { + t.Fatalf("first error = %q, want unsigned entry", result.FirstError) + } +} + +func TestVerifyJSONLRejectsAmbiguousJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.jsonl") + raw := `{"id":"first","id":"second","timestamp":"2026-08-24T09:10:11Z","direction":"client→server","transport":"stdio","method":"ping","duration_ms":1,"client_id":"client","server_id":"server","signature":""}` + if err := os.WriteFile(path, []byte(raw+"\n"), 0o600); err != nil { + t.Fatalf("write JSONL: %v", err) + } + + result, err := auditverify.VerifyPath(path, auditverify.Config{Format: auditverify.FormatJSONL}) + if err != nil { + t.Fatalf("verify JSONL: %v", err) + } + if result.Invalid != 1 || result.Total != 1 { + t.Fatalf("result = %+v, want one invalid record", result) + } + if !strings.Contains(result.FirstError, "ambiguous JSON") { + t.Fatalf("first error = %q, want ambiguous JSON", result.FirstError) + } +} + +func TestVerifySQLiteAutoDetectionReadsEveryEntry(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.db") + store, err := storage.NewSQLiteStore(path) + if err != nil { + t.Fatalf("open SQLite store: %v", err) + } + entries := []audit.Entry{signedV2Entry(t, "v2"), legacyEntry("legacy")} + for _, entry := range entries { + if err := store.Append(entry); err != nil { + t.Fatalf("append entry: %v", err) + } + } + if err := store.Close(); err != nil { + t.Fatalf("close SQLite store: %v", err) + } + + result, err := auditverify.VerifyPath(path, auditverify.Config{ + Keys: map[string]string{integrity.DefaultKeyID: testSecret}, + }) + if err != nil { + t.Fatalf("verify SQLite: %v", err) + } + assertResult(t, result, auditverify.Result{Verified: 1, Legacy: 1, Total: 2}) + if !result.Clean() { + t.Fatalf("result should be clean: %+v", result) + } +} + +func TestVerifySQLiteSupportsLegacySchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open SQLite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + _, err = db.ExecContext(context.Background(), `CREATE TABLE audit_entries ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + direction TEXT NOT NULL, + transport TEXT NOT NULL, + method TEXT NOT NULL, + request_id TEXT, + tool_name TEXT, + params TEXT, + result TEXT, + error TEXT, + duration_ms INTEGER NOT NULL, + client_id TEXT NOT NULL, + server_id TEXT NOT NULL, + signature TEXT + )`) + if err != nil { + t.Fatalf("create legacy schema: %v", err) + } + entry := legacyEntry("legacy") + _, err = db.ExecContext(context.Background(), `INSERT INTO audit_entries ( + id, timestamp, direction, transport, method, request_id, tool_name, params, + result, error, duration_ms, client_id, server_id, signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + entry.ID, entry.Timestamp.Format(time.RFC3339Nano), entry.Direction, entry.Transport, + entry.Method, entry.RequestID, entry.ToolName, string(entry.Params), string(entry.Result), + nil, entry.DurationMs, entry.ClientID, entry.ServerID, entry.Signature, + ) + if err != nil { + t.Fatalf("insert legacy entry: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close SQLite: %v", err) + } + + result, err := auditverify.VerifyPath(path, auditverify.Config{ + Format: auditverify.FormatSQLite, + Keys: map[string]string{integrity.DefaultKeyID: testSecret}, + }) + if err != nil { + t.Fatalf("verify legacy SQLite: %v", err) + } + assertResult(t, result, auditverify.Result{Legacy: 1, Total: 1}) +} + +func TestVerifyPathErrorsForUnsupportedFormatAndMissingFile(t *testing.T) { + t.Run("unsupported format", func(t *testing.T) { + _, err := auditverify.VerifyPath("unused", auditverify.Config{Format: "csv"}) + if err == nil || !strings.Contains(err.Error(), "unsupported format") { + t.Fatalf("error = %v, want unsupported format", err) + } + }) + t.Run("missing file", func(t *testing.T) { + _, err := auditverify.VerifyPath(filepath.Join(t.TempDir(), "missing.jsonl"), auditverify.Config{}) + if err == nil || !strings.Contains(err.Error(), "open input") { + t.Fatalf("error = %v, want open input", err) + } + }) +} + +func baseEntry(id string) audit.Entry { + return audit.Entry{ + ID: id, + Timestamp: time.Date(2026, 8, 24, 9, 10, 11, 123456789, time.UTC), + AuditOperationID: "019d2f6e-47ad-75ad-b506-b7990d8c11ba", + Outcome: audit.OutcomeSuccess, + Direction: audit.DirectionClientToServer, + Transport: "http", + Method: "tools/call", + RequestID: "42", + ToolName: "read_file", + Params: json.RawMessage(`{"name":"read_file","arguments":{"path":"/tmp/file"}}`), + Result: json.RawMessage(`{"ok":true}`), + DurationMs: 17, + ClientID: "client", + ServerID: "server", + } +} + +func signedV2Entry(t *testing.T, id string) audit.Entry { + t.Helper() + entry := baseEntry(id) + metadata, err := integrity.NewSigner(testSecret, integrity.DefaultKeyID).Sign(audit.IntegrityEntryV2(entry)) + if err != nil { + t.Fatalf("sign Integrity v2 entry: %v", err) + } + entry.Integrity = metadata + return entry +} + +func legacyEntry(id string) audit.Entry { + entry := baseEntry(id) + entry.AuditOperationID = "" + entry.Outcome = "" + entry.Signature = audit.NewSigner(testSecret).Sign(entry) + return entry +} + +func assertResult(t *testing.T, got, want auditverify.Result) { + t.Helper() + got.FirstError = "" + if got != want { + t.Fatalf("result = %+v, want %+v", got, want) + } +}