Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version> (commit <sha>, built <iso8601>)`.
- 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

Expand Down
3 changes: 3 additions & 0 deletions cmd/mcp-audit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
112 changes: 112 additions & 0 deletions cmd/mcp-audit/verify.go
Original file line number Diff line number Diff line change
@@ -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 <audit.jsonl|audit.db> [--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
}
125 changes: 125 additions & 0 deletions cmd/mcp-audit/verify_test.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions internal/audit/verify/jsonl.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading