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'); + }); +});