From 46163084026554162903fcb4b0cbd7fee4a860f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 19 May 2026 06:11:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(observer):=20PII=20filter=20=E2=80=94=20ph?= =?UTF-8?q?one/email/Sentry/OpenAI/Bearer=20masking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Used by Stop-hook before JSONL write. 6 Vitest cases including idempotence and recursive object sanitization. Per Pravila §16.2 + ADR-011 + spec §5.4. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/vitest.config.tools.mjs | 12 +++++++++ tools/observer-pii-filter.mjs | 41 ++++++++++++++++++++++++++++++ tools/observer-pii-filter.test.mjs | 36 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 app/vitest.config.tools.mjs create mode 100644 tools/observer-pii-filter.mjs create mode 100644 tools/observer-pii-filter.test.mjs diff --git a/app/vitest.config.tools.mjs b/app/vitest.config.tools.mjs new file mode 100644 index 00000000..796ba059 --- /dev/null +++ b/app/vitest.config.tools.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +// Minimal Vitest config for tools/*.test.mjs (Node environment, no Vue/DOM). +// Separate from vitest.config.ts which targets tests/Frontend/**/*.ts. +// Run from repo root: node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['../tools/observer-pii-filter.test.mjs'], + }, +}); diff --git a/tools/observer-pii-filter.mjs b/tools/observer-pii-filter.mjs new file mode 100644 index 00000000..9205287e --- /dev/null +++ b/tools/observer-pii-filter.mjs @@ -0,0 +1,41 @@ +/** + * PII filter for brain governance observer (B2). + * Used by Stop-hook before JSONL write — per Pravila §16.2 + ADR-011 + spec §5.4. + * + * Patterns covered: + * RU_PHONE — +7XXXXXXXXXX (10 digits after +7) + * EMAIL — any user@domain.tld + * SENTRY_TOKEN — sntrys?_<12+ alphanum> + * OPENAI_TOKEN — sk-<20+ alphanum> + * GENERIC_BEARER — Bearer <20+ token chars> + * + * Security Guidance #40: pure regex — no exec/execSync. + */ + +const RU_PHONE = /\+7\d{10}/g; +const EMAIL = /[\w.+-]+@[\w-]+\.[\w.-]+/g; +const SENTRY_TOKEN = /sntrys?_[A-Za-z0-9]{12,}/g; +const OPENAI_TOKEN = /sk-[A-Za-z0-9]{20,}/g; +const GENERIC_BEARER = /Bearer\s+[A-Za-z0-9._-]{20,}/g; + +function sanitizeString(s) { + if (typeof s !== 'string') return s; + return s + .replace(RU_PHONE, '+7XXXXXXXXXX') + .replace(EMAIL, '***@***') + .replace(SENTRY_TOKEN, '[REDACTED:sentry]') + .replace(OPENAI_TOKEN, '[REDACTED:openai]') + .replace(GENERIC_BEARER, '[REDACTED:bearer]'); +} + +export function sanitize(input) { + if (typeof input === 'string') return sanitizeString(input); + if (input === null || input === undefined) return input; + if (Array.isArray(input)) return input.map(sanitize); + if (typeof input === 'object') { + const out = {}; + for (const [k, v] of Object.entries(input)) out[k] = sanitize(v); + return out; + } + return input; +} diff --git a/tools/observer-pii-filter.test.mjs b/tools/observer-pii-filter.test.mjs new file mode 100644 index 00000000..edb00c9b --- /dev/null +++ b/tools/observer-pii-filter.test.mjs @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { sanitize } from './observer-pii-filter.mjs'; + +describe('observer-pii-filter sanitize', () => { + it('masks Russian phone numbers', () => { + const input = 'Контакт: +79991234567 — позвонить'; + expect(sanitize(input)).toBe('Контакт: +7XXXXXXXXXX — позвонить'); + }); + + it('masks email addresses', () => { + const input = 'Mail: kpd9363@gmail.com'; + expect(sanitize(input)).toBe('Mail: ***@***'); + }); + + it('masks Sentry-style tokens', () => { + const input = 'token sntrys_abc123def456ghi789'; + expect(sanitize(input)).toContain('[REDACTED'); + expect(sanitize(input)).not.toContain('sntrys_abc123def456ghi789'); + }); + + it('is idempotent on already-sanitized strings', () => { + const sanitized = 'Контакт: +7XXXXXXXXXX, ***@***'; + expect(sanitize(sanitized)).toBe(sanitized); + }); + + it('handles empty string', () => { + expect(sanitize('')).toBe(''); + }); + + it('handles object input by sanitizing string fields recursively', () => { + const input = { task_id: 'x', note: 'call +79991234567' }; + const out = sanitize(input); + expect(out.note).toBe('call +7XXXXXXXXXX'); + expect(out.task_id).toBe('x'); + }); +});