Skip to main content

build-audit-logs

Build or review audit trails in TypeScript/JavaScript apps using evlog (pipelines, typed actions, denials, retention, compliance-style reviews). For application code, not for extending the evlog package.

跳到安装

来源信息

仓库
evloghq/evlog
最近来源活动
2026年9月8日 17:53
检测到的 SKILL.md 语言
英语
星标
1,855
分支
63

安装方式

默认使用会先检查来源的 Prompt;你也可以切换为直接命令,或下载本地副本。

检查来源文件

决定是否安装前,请先阅读 SKILL.md,以及 SkillsMP 当前展示的配套文件。

文件资源管理器
2 个文件

正在显示 SKILL.md

SKILL.md
来源说明 · 只读预览
name
build-audit-logs
description
Build or review audit trails in TypeScript/JavaScript apps using evlog (pipelines, typed actions, denials, retention, compliance-style reviews). For application code, not for extending the evlog package.
# Build or Review an Audit System with evlog For **application developers** who either need to add an audit trail to their product, or who already have one and want it reviewed. Walks through the design calls, the end-to-end implementation, and a review checklist for an existing setup. This skill assumes the audit lives in **your app**. To extend the evlog package itself (new audit helper, new drain wrapper), see the contributor skills under `.agents/skills/`. ## Quick reference: call-site cheat sheet When you already know the system is wired and just need to remember the API: | Situation | Helper | |---|---| | Inside a request handler, action succeeded | `log.audit({ action, actor, target, outcome: 'success' })` | | Inside a request handler, AuthZ denial | `log.audit.deny('reason', { action, actor, target })` | | Standalone job / script / CLI (no request) | `audit({ action, actor, target, outcome })` | | Auto-record success / failure / denied for a function | `withAudit({ action, target }, fn)` | | Recording a state change | add `changes: auditDiff(before, after)` | | Centralised typed action vocabulary | `defineAuditCatalog('billing', { INVOICE_REFUND: { target: 'invoice' } })` — or `defineAuditAction('invoice.refund', { target: 'invoice' })` for one-offs | | Asserting audits in tests | `mockAudit()` — `assertAudit()` or `toIncludeAuditOf()` | `AuditFields` schema (always provide `action`, `actor`, `outcome`; `target` strongly recommended; the rest is filled in for you): ```ts interface AuditFields { action: string // 'invoice.refund' actor: { type: 'user' | 'system' | 'api' | 'agent', id: string, email?, displayName?, model?, tools?, reason?, promptId? } outcome: 'success' | 'failure' | 'denied' target?: { type: string, id: string, [k: string]: unknown } reason?: string changes?: { before?: unknown, after?: unknown, patch?: AuditPatchOp[] } causationId?: string correlationId?: string version?: number // defaults to AUDIT_SCHEMA_VERSION idempotencyKey?: string // auto-derived from action+actor+target+timestamp context?: { requestId?, traceId?, ip?, userAgent?, tenantId?, ... } // filled by auditEnricher signature?: string // added by signed(drain, { strategy: 'hmac' }) prevHash?: string // added by signed(drain, { strategy: 'hash-chain' }) hash?: string // added by signed(drain, { strategy: 'hash-chain' }) } ``` ## What "audit logging" actually means An audit log answers a forensic question: **who did what, on which resource, when, from where, with which outcome.** That's a different shape from observability logs, which is why the operational rules differ: | | Audit log | Observability log | | -------------- | ----------------------------------------------- | ---------------------------------- | | Question | "Who tried to do what, was it allowed?" | "How did this request behave?" | | Sampling | Never (force-keep) | Often (head + tail) | | Retention | 1 – 7 years (compliance) | 30 – 90 days | | Mutability | Append-only, tamper-evident | Mutable, lossy | | Audience | Auditors, security, legal | Engineers | | Storage | Often dedicated (separate dataset / DB) | Shared with telemetry | evlog ships the audit layer as a thin extension of its wide-event pipeline (a typed `audit` field on `BaseWideEvent` plus a few helpers and drain wrappers). The point is that you compose with the primitives the app already uses: same drains, same enrichers, same redact, same framework integration. There is no parallel system to maintain. ## Mental model ```text log.audit(...) ──► sets event.audit ──► force-keep ──► auditEnricher ──► redact ──► every drain └─► auditOnly(signed(fsDrain)) ``` | Building block | Role | Required? | | ------------------------------------------- | ------------------------------------------------------------------- | ----------------------- | | `log.audit()` / `audit()` / `withAudit()` | Sets `event.audit` and force-keeps the event | Yes | | `auditEnricher()` | Auto-fills `event.audit.context` (req / trace / ip / ua / tenantId) | Recommended | | `auditOnly(drain)` | Filters the drain to events with `event.audit` set | Recommended | | `signed(drain, ...)` | Adds tamper-evident integrity (HMAC or hash-chain) | Optional (compliance) | | `auditRedactPreset` | Strict PII preset for audit events | Recommended | | `mockAudit()` | Captures audit events in tests | Yes (in tests) | ## Design calls before writing code Make these explicit and write them down somewhere a security reviewer can find. Without a written rule, the system can't be audited. Auditors look for the policy first, then the enforcement. ### 1. Where do audits live? | Drain | Use when | Trade-offs | | --------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------- | | **FS** (`evlog/fs` + `signed`) | Self-hosted, simple, you control the disk | Manual rotation/backup; single-process unless you persist hash-chain `state` externally | | **Dedicated Axiom dataset** | You already use Axiom | Easy queries, separate retention/billing; cost scales with volume | | **Postgres / Neon / Aurora** | You want SQL queries, joins with app data | Need a schema, indexes, retention job; idempotency key prevents duplicates | | **S3 + Object Lock** | Append-only WORM compliance (HIPAA / FINRA) | Read latency; pair with a queryable mirror (Athena) | | **Multiple drains** | Different audiences (engineers ↔ legal) | Use `auditOnly` per drain; drains fail in isolation by design | > **Rule of thumb.** Pick at least two: a queryable one (Axiom / Postgres) for day-to-day forensics + an append-only one (FS journal with hash-chain, or S3 Object Lock) as the compliance artefact. The two-drain pattern protects against vendor outages and admin mistakes on the queryable side. ### 2. Do you need integrity (`signed`)? Yes if any of: - A compliance framework requires tamper-evidence (SOC2 CC7, HIPAA §164.312(c)(1), PCI 10.5). - The drain is mutable by engineers / admins. - You may need to prove to a regulator that no events were modified after the fact. Skip if: - Drain is already WORM (S3 Object Lock, BigQuery append-only, Postgres with row-level immutability + monitored DDL). - You're prototyping. Strategies: - `'hmac'`: per-event signature; quick to verify; rotate `secret` annually and embed a key id (extend `AuditFields`). - `'hash-chain'`: sequence integrity; deleting a row breaks the chain forward; persist `state.{load,save}` if you run multiple processes (Redis is the typical store). ### 3. Multi-tenancy? If the app is multi-tenant, **tenant isolation on every audit event is non-negotiable.** A query that mixes tenants is a privacy incident. Wire it once in the enricher: ```ts auditEnricher({ tenantId: ctx => resolveTenant(ctx) }) ``` If the app uses Better Auth, `auditEnricher` can also bridge the authenticated session into `audit.context`. See the Better Auth integration (`evlog/better-auth`, https://www.evlog.dev/use-cases/better-auth/overview) for wiring `identifyUser` alongside the audit pipeline. Then either (a) partition the audit dataset by `audit.context.tenantId`, or (b) one drain per tenant if hard isolation is required. Never query audits without a tenant filter. ### 4. Retention Pick a window per drain and document it. Enforce at the drain layer, not in app code, because the drain already has audited mechanisms for it (lifecycle policies, `DELETE` jobs, dataset retention). | Framework | Typical retention | | --------- | ------------------------------------------------------------------- | | SOC2 | 1 year minimum, 7 years recommended | | HIPAA | 6 years | | PCI DSS | 1 year (3 months immediately accessible) | | GDPR | "As long as necessary" — see "GDPR vs append-only" below | How to enforce per drain: - **FS**: `createFsDrain({ maxFiles })` + a daily compactor. - **Postgres**: `DELETE FROM audit_events WHERE timestamp < now() - interval '7 years'` on a cron. - **Axiom / Datadog / Loki**: dataset-level retention policy. ### 5. GDPR vs append-only The right to be forgotten collides with audit immutability. Recommended pattern: 1. Keep audit rows immutable and chain-verified. 2. Encrypt PII fields (email, name, IP) with a per-actor key held outside the audit store. 3. To "forget" a user, delete their key. The audit row stays, the chain stays valid, the PII becomes unreadable (crypto-shredding). A built-in `cryptoShredding` helper is on the roadmap; until then, encrypt in a custom enricher. ## Step-by-step buildout ### Step 1: Wire the pipeline (one-time) The wiring shape is the same in every framework: register `auditEnricher()` so `event.audit.context` gets `requestId`, `traceId`, `ip`, `userAgent`, and (if configured) `tenantId` automatically, then add a main drain plus an audit-only drain. The minimal Nuxt / Nitro setup looks like this: ```ts // server/plugins/evlog.ts import { auditEnricher, auditOnly, signed } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createFsDrain } from 'evlog/fs' export default defineNitroPlugin((nitroApp) => { const auditSink = auditOnly( signed(createFsDrain({ dir: '.audit/' }), { strategy: 'hash-chain' }), { await: true }, ) const main = createAxiomDrain({ dataset: 'logs' }) nitroApp.hooks.hook('evlog:enrich', auditEnricher({ tenantId: ctx => ctx.headers?.['x-tenant-id'], })) nitroApp.hooks.hook('evlog:drain', async (ctx) => { await Promise.all([main(ctx), auditSink(ctx)]) }) }) ``` For Hono, Express, Next.js, or standalone scripts / workers, see [`references/framework-wiring.md`](references/framework-wiring.md). The pattern is identical. Only the framework integration helper changes. ### Step 2: Define the action vocabulary Audits get queried and alerted on by `audit.action`. A typo is a missing alert, so centralise the list. For a bounded context with several actions, prefer a catalog: one prefix, typed keys, autocomplete on the wire format `${prefix}.${KEY}`: ```ts // app/audit/billing.ts import { defineAuditCatalog } from 'evlog' export const billingAudit = defineAuditCatalog('billing', { INVOICE_REFUND: { target: 'invoice' }, PLAN_CHANGE: { target: 'subscription' }, }) ``` At the call site: `log.audit(billingAudit.INVOICE_REFUND({ actor, target, outcome: 'success' }))`. Add the opt-in `declare module 'evlog' { interface RegisteredAuditCatalogs { billing: typeof billingAudit } }` augmentation to get autocomplete everywhere. For one-off actions that don't fit a catalog, `defineAuditAction` still works: ```ts // app/audit/actions.ts import { defineAuditAction } from 'evlog' export const InvoiceRefund = defineAuditAction('invoice.refund', { target: 'invoice' }) export const ApiKeyRevoke = defineAuditAction('apiKey.revoke', { target: 'apiKey' }) ``` Catalog conventions and scaling recipes (folder per domain, npm packages per bounded context): https://www.evlog.dev/learn/catalogs Naming conventions: - `noun.verb` (`invoice.refund`, not `refundInvoice`). - Past tense if the audit is logged after the fact (`invoice.refunded`); present tense when wrapped by `withAudit()` (which resolves the outcome itself). - Lowercase, dot-delimited, no spaces: for hand-written action ids (`defineAuditAction`, inline `log.audit`). Catalog entries follow the catalog convention instead: UPPER_SNAKE_CASE keys under a lowercase prefix, producing wire actions like `billing.INVOICE_REFUND`. That is intentional. Do not lowercase the keys. ### Step 3: Instrument call sites
在 GitHub 查看
这个 SKILL.md 很大,SkillsMP 这里只预览前一段内容。 在 GitHub 查看