temperDocs
Menu

Concepts

Audit log

A tamper-evident record of what each environment's agent did on the network, with credentials and in the browser.

Every environment has a security audit log. It records what the agent did that touched the outside world: requests that left the VM, requests where a credential stand-in was swapped for a real token, decisions on actions that needed an approval, and actions in a leased browser. You can list the records, export them, and check that nobody has altered them.

What is recorded

Records are written on the host, outside the VM, so the agent cannot skip or edit them. Each record has a source:

SourceWritten byWhat a record describes
egressThe VM's egress proxyA connection or request leaving the environment, and whether the egress allowlist let it through.
gatewayThe credential gatewayA request that used a credential: host, method, path, which credential, the action category, the decision and, if the action needed one, the approval id.
browserThe browser brokerAn action in a leased browser: the lease, the page URL, the action, and the decision (allow, deny:<reason>, ask, or approved:<approval id>).

Approval decisions show up on the record of the action they gated: the record carries the decision and, when an approval was involved, its id. The approval itself (who decided, when, and how) is on the approval object.

Metadata only

Records hold metadata: time, domain, method, path, which credential was used, the action category and the decision. They never hold real tokens, request or response bodies, or query strings. Browser records drop the query string and fragment from URLs, and passwords the end user types during a takeover are not recorded.

Retention

Records are kept for 90 days. You can still read the log of an environment after it has been destroyed, until its records age out.

Hash chains

The log is append-only and hash-chained. Each VM that runs the environment writes its own chain, so an environment that has moved to a new VM has more than one chain. Every line in a chain carries two fields:

  • seq: the line's position in the chain, starting at 1.
  • prev: the SHA-256 (hex) of the previous line's exact bytes, without the trailing newline. The first line of a chain uses 64 zeros.

Changing, deleting or reordering a line breaks the link to the line after it. The control plane recomputes the hashes as lines arrive. If a chain does not link up, it still stores the lines but marks the chain as broken from that seq on and records an audit_chain_broken environment event. Temper also archives the records and the chain heads daily to write-once storage, so records cut from the end of a chain can be detected too.

Checking chain status

The chains endpoint returns one entry per chain: chain, first_seq, last_seq, head (the hash of the last line received), broken_at_seq (null when the chain is intact) and updated_at. The top-level intact field is true only when every chain links up.

import { Temper } from "@temper-hq/sdk";

const temper = new Temper();

const { data: chains, intact } = await temper.audit.chains(envId);
if (!intact) {
  for (const c of chains.filter((c) => c.broken_at_seq !== null)) {
    console.warn(`chain ${c.chain} is broken from seq ${c.broken_at_seq}`);
  }
}
from temper_hq import Temper

temper = Temper()

chains = temper.audit.chains(env_id)
if not chains.intact:
    for c in chains.data:
        if c.broken_at_seq is not None:
            print(f"chain {c.chain} is broken from seq {c.broken_at_seq}")
chains, err := client.Audit.Chains(ctx, envID)
if err != nil {
	log.Fatal(err)
}
if !chains.Intact {
	for _, c := range chains.Data {
		if c.BrokenAtSeq != nil {
			fmt.Printf("chain %s is broken from seq %d\n", c.Chain, *c.BrokenAtSeq)
		}
	}
}

Listing records

List audit records returns parsed records in time order. Each item has chain, seq, at, source and record (the original line as JSON). Filters:

ParameterMeaning
sourceOnly records from this source, for example egress.
chainOnly records from this chain (see the chains endpoint).
fromRecords at or after this time.
toRecords before this time (exclusive).
limitPage size. Defaults to 100; values outside 1–1000 are clamped.
afterThe next value from the previous page.

next is an opaque string. It is set only when the page is full; pass it back unchanged as after to get the next page. The SDKs have iterators that page for you.

Exporting and verifying offline

Export returns the raw lines as NDJSON (application/x-ndjson), one line per record, ordered by chain and then by seq, exactly as the host wrote them. Because the bytes are unchanged, you can recompute the hashes yourself. One export returns at most 100,000 lines. If the range holds more, the request fails with export_too_large instead of truncating; narrow it with from and to and export in parts. An empty range returns an empty body.

import { createWriteStream } from "node:fs";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { Temper } from "@temper-hq/sdk";

const temper = new Temper();

// Page through egress records from the last day.
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
for await (const r of temper.audit.iterate(envId, { source: "egress", from: since })) {
  console.log(r.at, r.chain, r.seq, JSON.stringify(r.record));
}

// Export raw lines. The response body is not read yet, so large exports can be streamed to disk.
const res = await temper.audit.export(envId, { from: since });
await pipeline(Readable.fromWeb(res.body!), createWriteStream(`audit-${envId}.jsonl`));
from datetime import datetime, timedelta, timezone

from temper_hq import Temper

temper = Temper()

# Page through egress records from the last day.
since = datetime.now(timezone.utc) - timedelta(days=1)
for r in temper.audit.iter(env_id, source="egress", from_=since):
    print(r.at, r.chain, r.seq, r.record)

# Export raw lines, streamed one at a time (without the newline).
with open(f"audit-{env_id}.jsonl", "w") as f:
    for line in temper.audit.iter_export(env_id, from_=since):
        f.write(line + "\n")
// Page through egress records from the last day.
since := time.Now().Add(-24 * time.Hour)
it := client.Audit.Iter(envID, temper.ListAuditParams{Source: "egress", From: since})
for it.Next(ctx) {
	r := it.Value()
	fmt.Println(r.At, r.Chain, r.Seq, string(r.Record))
}
if err := it.Err(); err != nil {
	log.Fatal(err)
}

// Export raw lines. The caller closes the stream; a zero time means no bound.
export, err := client.Audit.Export(ctx, envID, since, time.Time{})
if err != nil {
	log.Fatal(err)
}
defer export.Close()
f, err := os.Create("audit-" + envID + ".jsonl")
if err != nil {
	log.Fatal(err)
}
defer f.Close()
if _, err := io.Copy(f, export); err != nil {
	log.Fatal(err)
}

In Python, audit.export(...) returns the same lines as a list, read into memory; use iter_export for large ranges.

To verify an export, walk the lines in order. Where seq follows the previous line's seq by one, prev must equal the SHA-256 of the previous line's bytes. Where it does not, a new chain starts (lines are grouped by chain). A chain whose first line has seq 1 must have the all-zero prev; a chain that starts later (because of from or retention) can only be checked from its second line on. This standalone script does that:

Python
import hashlib
import json
import sys

GENESIS = "0" * 64
heads = []
prev_line, prev_seq = None, None

with open(sys.argv[1], "rb") as f:
    for n, raw in enumerate(f, 1):
        line = raw.rstrip(b"\n")
        record = json.loads(line)
        seq, prev = record["seq"], record["prev"]
        if prev_seq is not None and seq == prev_seq + 1:
            if prev != hashlib.sha256(prev_line).hexdigest():
                sys.exit(f"line {n}: prev does not match the hash of the line before it")
        else:
            if prev_line is not None:
                heads.append(hashlib.sha256(prev_line).hexdigest())
            if seq == 1 and prev != GENESIS:
                sys.exit(f"line {n}: a chain's first line must have the all-zero prev")
        prev_line, prev_seq = line, seq

if prev_line is not None:
    heads.append(hashlib.sha256(prev_line).hexdigest())
print(f"links ok across {len(heads)} chain(s)")
for h in heads:
    print("head", h)

If the export runs up to the present, each printed head should match the head of one entry from the chains endpoint. A mismatch means records are missing from the end of a chain.