sotashimozono_obsidian-remo.../plugin/tests/Redact.test.ts
Souta b641e4f1c4 feat(plugin): Phase D-β / F20 — structured JSONL log file + secret redactor (0.4.50)
Replaces the ad-hoc `[ISO] [level] msg` text format on the file
sink with **JSONL** (one JSON object per line) and adds a secret
redactor so token-shaped substrings + credential-keyed fields
never reach the log file. Foundation for Phase D's debug story:
post-mortem analysis pipes through `jq` instead of grep, every
line carries structured context the existing plain-text format
couldn't.

What ships:

- `plugin/src/util/redact.ts` (new, pure)

  Two-layer redaction:

  1. **Field-name keyed** — values whose key matches one of the
     credential hints (`password / passphrase / token / secret /
     apikey / authorization / privatekey / accesstoken /
     refreshtoken / cookie / session`) get replaced with
     `<REDACTED>` (no length leak — the *existence* of a
     `password` field is fine to log; the value is not). Match
     is case-insensitive and substring-based, so `authToken`,
     `API_KEY`, `refresh_token` all hit.

  2. **String content scan** — long token-shaped substrings in
     free-text messages get replaced with `<REDACTED:Nb>` where
     N is the original length (length leak is acceptable here —
     "we wrote a 36-char hex string" is much less sensitive than
     the string itself, and N gives debug breadcrumbs). Patterns:
       - long hex (≥ 32 chars): SSH session tokens, server.deploy
         tokens, sha256 sums, host-key fingerprints.
       - JWT-shaped: three base64url segments separated by dots,
         each ≥ 16 chars.
       - long base64 (≥ 64 chars): private key bodies, ssh agent
         identities. The 64-char floor avoids false-positives
         on short fixture base64 like `aGVsbG8=`.

  Cycle-safe: a `WeakSet` tracks visited objects so a self-
  referential field becomes `<CYCLE>` instead of blowing the
  recursion stack. Top-level fields object is seeded into
  `visited` at entry so a 1st-level cycle is caught immediately.

  Pure / no I/O / no global state. `redactString` short-circuits
  to identity when no token pattern matches — keeps the hot path
  allocation-free for the common case (most log lines don't
  contain secrets).

- `plugin/src/util/logger.ts` (extended)

  - `emit(level, message, fields?)` overload — every emit method
    (`debug_/info/warn/error`) now accepts an optional structured
    fields object. Old single-arg call sites stay green (37 of
    them across the codebase) — no churn at callers.
  - **Redaction at the boundary**: redactString + redactFields
    fire before any listener / file sink / console echo sees the
    payload. No secret leaks even if a downstream sink starts
    persisting `LogLine` directly later.
  - **JSONL file sink**: writes `{"ts": ISO8601, "level":, "msg":,
    "fields"?: {...}}\n` per emit. Replaces the old
    `[ts] [level] msg` text format. `tail -f` still works (still
    newline-delimited); `cat <log> | jq '. | select(.level=="error")'`
    is now the post-mortem pattern.
  - **Console echo unchanged in spirit**: dev devtools still see
    `[RemoteSSH] msg` (with `{fields...}` JSON suffix when
    present) so the in-IDE dev loop reads the same.
  - **`uninstallFileSink()` returns Promise<void>** so callers
    (and tests) can `await` the underlying stream's actual flush
    instead of racing the buffered write. Production callers
    that ignored the void return ignore the Promise — no
    behaviour change there.
  - **JSON serialise fallback**: a circular-reference field that
    survived the redactor (e.g. via a non-plain-object wrapper)
    still lands in the log as a single line with
    `_serialiseError` instead of vanishing.

- `plugin/src/types.ts`

  - `LogLine.fields?: Record<string, unknown>` — optional. Old
    consumers that destructure `{level, timestamp, message}`
    keep working; new consumers can read the structured payload.

- `plugin/tests/Redact.test.ts` (new, 18 tests)

    redactString:
      - clean message untouched
      - 32-char hex redacted
      - 64-char hex (sha256) redacted
      - short hex left alone (no false positive on `mtime=…`)
      - JWT-shaped string redacted
      - long base64 redacted; short base64 left alone
      - multiple tokens in one message all redacted

    redactFields:
      - secret-keyed values become `<REDACTED>`
      - case-insensitive matching (Token / TOKEN / authToken)
      - snake_case keys (api_key / refresh_token)
      - token scan runs on non-secret string values too
      - non-string values preserved
      - nested object recursion + arrays
      - returns a NEW object even when nothing was redacted

    SECRET_KEY_HINTS frozen + headline credential names present.

- `plugin/tests/Logger.test.ts` (new, 13 tests)

    in-memory ring + listener (backward compat):
      - captures info/warn/error to ring with existing fields
      - listener subscribe / unsubscribe

    fields overload:
      - threads optional fields through ring
      - omits `fields` key entirely when no fields passed (back
        compat for any consumer that does `'fields' in line`)
      - secret redaction visible through listener
      - token redaction in message body

    JSONL file sink:
      - `{ts, level, msg}` shape with no `fields` when absent
      - `fields` key when present
      - secret-keyed values redacted in the on-disk JSONL
      - circular-reference field becomes `<CYCLE>` (cycle-safe)
      - 200-line burst all serialise cleanly

    debug-level gating still works (drops debug when off, lets
    through when on).

Verification:

- `npx vitest run` — 39 files / 532 tests green (was 501, +13
  Logger + +18 Redact = +31 new)
- `npx tsc --noEmit -p tsconfig.json` — clean
- 37 existing `logger.{info,warn,error,debug_}` call sites across
  src/ — none touched, all use the single-arg overload that
  remains backward-compatible.

manifest/package/versions bumped 0.4.49 → 0.4.50.

Roadmap context: D-β unblocks D-γ (Error toast taxonomy can attach
RpcError code + path + retry-attempt fields to its log emits) and
the eventual telemetry opt-in (F22 Phase G) which hangs on the
same JSONL pipeline. D-δ (security: F23/F24) is up next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:06:16 +09:00

111 lines
4.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { redactString, redactFields, SECRET_KEY_HINTS } from '../src/util/redact';
describe('redactString — token-shape detection', () => {
it('passes a clean message through untouched', () => {
expect(redactString('connected to host')).toBe('connected to host');
});
it('redacts a 32-char hex token (e.g. server.deploy session token)', () => {
const tok = 'deadbeefcafef00d1234567890abcdef';
const got = redactString(`token=${tok} accepted`);
expect(got).toBe('token=<REDACTED:32b> accepted');
});
it('redacts a 64-char hex (e.g. sha256 sum)', () => {
const sha = 'a'.repeat(64);
expect(redactString(sha)).toBe('<REDACTED:64b>');
});
it('does NOT redact a short hex (under 32 chars)', () => {
expect(redactString('mtime=1700000000ab')).toBe('mtime=1700000000ab');
});
it('redacts a JWT-shaped token in a sentence', () => {
// Three base64url segments, each ≥16 chars, separated by dots.
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEyMzQ1Njc4OTB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(redactString(`bearer ${jwt} ok`)).toMatch(/^bearer <REDACTED:\d+b> ok$/);
});
it('redacts a long base64-shaped string (e.g. private key body)', () => {
const blob = 'A'.repeat(80);
expect(redactString(`key: ${blob}`)).toBe('key: <REDACTED:80b>');
});
it('does NOT redact a short base64 (e.g. content payload fixture)', () => {
expect(redactString('content=aGVsbG8=')).toBe('content=aGVsbG8=');
});
it('redacts MULTIPLE token-shaped substrings independently', () => {
const a = 'a'.repeat(40);
const b = 'b'.repeat(40);
expect(redactString(`first ${a} then ${b}`)).toBe(`first <REDACTED:40b> then <REDACTED:40b>`);
});
});
describe('redactFields — key-name + value scan', () => {
it('redacts values whose key name contains a secret-hint word', () => {
const out = redactFields({ password: 'p@ss', userName: 'alice' });
expect(out).toEqual({ password: '<REDACTED>', userName: 'alice' });
});
it('matches secret hints case-insensitively (Token / TOKEN / authToken)', () => {
const out = redactFields({ Token: 'a', authToken: 'b', TOKEN: 'c' });
expect(out).toEqual({ Token: '<REDACTED>', authToken: '<REDACTED>', TOKEN: '<REDACTED>' });
});
it('matches snake_case keys (api_key, refresh_token)', () => {
const out = redactFields({ api_key: 'a', refresh_token: 'b' });
expect(out).toEqual({ api_key: '<REDACTED>', refresh_token: '<REDACTED>' });
});
it('runs redactString over non-secret string values too', () => {
const tok = 'deadbeefcafe1234deadbeefcafe1234';
const out = redactFields({ url: `https://x/?t=${tok}` });
expect(out.url).toBe(`https://x/?t=<REDACTED:32b>`);
});
it('preserves non-string values for non-secret keys', () => {
const out = redactFields({ count: 42, ok: true, nothing: null });
expect(out).toEqual({ count: 42, ok: true, nothing: null });
});
it('recurses into nested plain objects and applies key-name redaction at each level', () => {
const out = redactFields({
profile: { host: '157.x.y.z', token: 'sekrit' },
meta: { ok: true },
});
expect(out).toEqual({
profile: { host: '157.x.y.z', token: '<REDACTED>' },
meta: { ok: true },
});
});
it('walks arrays element-wise', () => {
const out = redactFields({
events: [{ name: 'connect', token: 'sekrit' }, { name: 'idle' }],
});
expect(out).toEqual({
events: [{ name: 'connect', token: '<REDACTED>' }, { name: 'idle' }],
});
});
it('returns a new object even when nothing was redacted', () => {
const input = { a: 1, b: 'two' };
const out = redactFields(input);
expect(out).toEqual(input);
expect(out).not.toBe(input);
});
});
describe('SECRET_KEY_HINTS', () => {
it('is frozen to prevent silent mutation', () => {
expect(Object.isFrozen(SECRET_KEY_HINTS)).toBe(true);
});
it('includes the headline credential names', () => {
for (const name of ['password', 'token', 'secret', 'private_key']) {
expect(SECRET_KEY_HINTS).toContain(name);
}
});
});