feat(observer): PII filter — phone/email/Sentry/OpenAI/Bearer masking
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user