sundaydev-arch

audit-security

Use when a new write needs an audit, when returning API errors, when handling passwords/tokens/uploads, or when doing a security self-review. Hash chain is append-only; action code + params; IP/UA capture; error codes; upload checks; company seals never sent to clients.

sundaydev-arch 0 Updated 4w ago
GitHub

Install

npx skillscat add sundaydev-arch/hrsign/audit-security

Install via the SkillsCat registry.

SKILL.md

Skill 08: Audit log and security baseline

When to use

  • Any write operation (create/approve/sign/publish/delete/change permissions/login)
  • Designing a new error response
  • Handling credentials, tokens, verification codes, uploads, downloads
  • Security self-check or review

Key files

Hard rules

Audit

  1. Every critical write, success or failure, calls recordAudit: action (action code, e.g. task.approve, auth.login, template.publish), targetType/targetId, result, ip, userAgent; document operations include documentSha256.
  2. detail stores params, not copy: via AuditDetail zod schema; never put UI Chinese/English sentences, passwords, token plaintext, or verification codes in it.
  3. Audit is append-only; the application must not update/delete AuditLog; the hash-chain payload depends on canonicalJson (sorted keys); keep that normalization when changing detail shape.
  4. recordAudit failure must not roll back/block the main business (current design), but failures must be observable (logs).
  5. Security events (failed login, permission denied, code brute-force) also get failure audits.

Error responses

  1. APIs throw new ApiError(status, semantic message) and handleApiError is the single sink; the target shape is error code + params (VALIDATION_ERROR / AUTH_INVALID_CREDENTIALS / SEAL_APPROVAL_REQUIRED); the frontend renders via i18n. Transition still has Chinese messages; new code should define error-code constants and not spread more Chinese strings.
  2. Do not leak stacks/SQL/internal addresses to the client; unknown errors are a generic 500; details stay in server logs.

Credentials and access

  1. Passwords use bcrypt only (hash on register/change, compare on login); never log, never return, never put in URLs.
  2. Tokens and codes stored hashed only; compare secrets with constant-time compare (timingSafeEqual, see email-code); send-code is anti-enumeration (unknown emails still succeed).
  3. Official seal images are read only on the backend; no list/detail API may return a directly accessible seal URL to unrelated roles; stamping loads the image by sealId on the server.
  4. Downloads go only through /api/files/[...key], authorized by prefix (session ownership or token hash); used external tokens stay read-only and cannot sign again.
  5. PDF upload: enforce size cap + magic bytes (%PDF-), and reject documents with JavaScript / embedded files / Launch actions / encryption (currently size + parse only; magic/dangerous structure is outstanding P0 — new upload entry points must implement this, do not copy the old loose checks). Image uploads at least check magic (PNG/JPEG).
  6. User input in redirects/links/SQL: redirects only to in-site allowlisted paths; queries always Prisma-parameterized (no string-built SQL); React escapes by default; no dangerouslySetInnerHTML.

Current security gaps (prefer filling these when you touch the area; do not open a large refactor branch)

No CSP/security headers, no rate limit, no Origin check (CSRF defense in depth), no dependency scanning, no DB triggers on the audit table, no env zod at startup. When touching login/send-code/upload, add the matching rate limit or check in the smallest way and document it.

Standard steps (add audit to a write)

const ip = getClientIp(req);
const userAgent = getUserAgent(req);
try {
  // …business transaction
  await recordAudit({
    userId: user.id,
    action: "task.approve",
    targetType: "SigningTask",
    targetId: id,
    result: "success",
    ip,
    userAgent,
    documentSha256: version?.sha256 ?? null,
    detail: { fromStatus: "PENDING", toStatus: "APPROVED" },
  });
} catch (err) {
  await recordAudit({
    userId: user.id,
    action: "task.approve",
    targetType: "SigningTask",
    targetId: id,
    result: "failure",
    ip,
    userAgent,
    detail: { reason: "…" },
  });
  throw err;
}

Do not

  • ❌ Store localized sentences or secrets in AuditLog
  • ❌ Log, query, or return verification codes / plaintext tokens
  • ❌ Add unauthenticated file/export paths
  • ❌ After catch, send err.message (English stack-like) to the client

Done when

  • Writes have success/failure audit + IP/UA
  • detail passes zod, no copy, no secrets
  • Errors go through ApiError/handleApiError; uploads have magic and dangerous-structure checks
  • New APIs rechecked for auth and data scope