mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
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>
111 lines
4.1 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|