Operations

Audit replication

F3.11 — audit log replication · Last updated: 2026-04-29

Audit log replication

F3.11 — Replicate the F0.2 hash-chained audit log to immutable storage. Closes the residual Tampering risk in THREAT-MODEL.md: a host-compromised attacker today can rewrite the audit chain plus the matching signatures (private key on the same machine). With a copy in immutable storage, the attacker would also need to rewrite the storage tier's record.

What ships

What does NOT ship

Cloud-specific replicators (S3, GCS, Azure Blob) are intentionally not bundled. Each cloud has a distinct SDK surface, auth model, and credential conventions. fastpace stays zero-dep so we provide the contract and let the org wire the SDK. The contract is small enough that an S3 replicator is ~30 lines.

The contract

const replicator = {
  name: 'my-s3-replicator',
  config: { bucket: 'audit-logs', region: 'us-east-1' },
  async replicate(record) {
    // record: { cwd, full_path, basename, size, sha256, body_buffer }
    // Return: { ok: bool, location?: string, error?: string }
  },
  // Optional: enables fastpace replicate verify
  async read(historyEntry) {
    // historyEntry comes from the replication manifest's history[] array
    // and includes .location, .sha256, .ts, .lines
    // Return: Buffer with the original bytes that were replicated
  },
};

Example: AWS S3 replicator

'use strict';
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });

module.exports = function s3Replicator({ bucket, prefix = 'fastpace/audit/' }) {
  return {
    name: 's3',
    config: { bucket, prefix },
    async replicate(record) {
      const Key = prefix + record.basename;
      try {
        await s3.send(new PutObjectCommand({
          Bucket: bucket,
          Key,
          Body: record.body_buffer,
          ContentType: 'application/x-ndjson',
          // S3 Object Lock + WORM bucket settings recommended at the
          // bucket level so an attacker with valid creds can't delete.
          Metadata: { 'fastpace-sha256': record.sha256, 'fastpace-size': String(record.size) },
        }));
        return { ok: true, location: `s3://${bucket}/${Key}` };
      } catch (e) {
        return { ok: false, error: String(e.message) };
      }
    },
    async read(historyEntry) {
      const Key = (historyEntry.location || '').split(`s3://${bucket}/`)[1];
      const r = await s3.send(new GetObjectCommand({ Bucket: bucket, Key }));
      const chunks = [];
      for await (const c of r.Body) chunks.push(c);
      return Buffer.concat(chunks);
    },
  };
};

To use:

const repl = require('@fastpace-ai/fp/src/audit-replication');
const s3Replicator = require('./my-s3-replicator')({ bucket: 'acme-fastpace-audit' });
await repl.replicate(process.cwd(), s3Replicator);

Hardening the bucket

Replication moves the integrity tier from the developer machine to the storage tier. Get value out of that move:

The replication manifest at fastpace/replication/manifest.json records each chunk's sha256. fastpace replicate verify (when the replicator provides read()) reads the chunks back and compares against the live audit log up to the last-replicated offset; drift is a tampering signal.

Schedule

fastpace replicate to-file/to-http is idempotent — re-running picks up from last_offset. Wire it on a cron / systemd timer / CI cadence:

*/5 * * * *  cd /path/to/repo && /usr/local/bin/fastpace replicate to-http \
   --url https://siem.example.com/ingest/fastpace \
   --headers '{"Authorization":"Bearer XXXX"}' \
   >> /var/log/fastpace-replication.log 2>&1

Or have the F3.10 webhook event bus fire audit.broken to your incident response channel if fastpace verify ever fails — that's the canary that something tried to rewrite history.

Privacy

The audit log carries sha256 digests of prompts/responses, never the plaintext. Replicating the log to storage doesn't leak content; it only extends the integrity perimeter.