no-pii 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +152 -123
  2. package/nopii-cli.js +89 -0
  3. package/nopii.js +164 -123
  4. package/package.json +29 -31
package/README.md CHANGED
@@ -1,22 +1,15 @@
1
1
  # no-pii
2
2
 
3
- > **No PII** PII detection and redaction with secure vault storage
4
-
5
- [![npm version](https://img.shields.io/npm/v/no-pii.svg)](https://www.npmjs.com/package/no-pii)
6
- [![license](https://img.shields.io/npm/l/no-pii.svg)](https://github.com/littlejustnode/no-pii/blob/main/LICENSE)
7
- [![node](https://img.shields.io/node/v/no-pii.svg)](https://nodejs.org/)
8
-
9
- Zero-dependency PII (Personally Identifiable Information) detection and redaction library with pluggable strategies, secure vault storage, and state persistence.
3
+ PII (Personally Identifiable Information) redaction library for Node.js with built-in CLI support.
10
4
 
11
5
  ## Features
12
6
 
13
- - 🔒 **PII Redaction** — Automatically detect and replace sensitive data with safe placeholders
14
- - 🗄️ **Secure Vault** — Store redacted values in an encrypted vault for later restoration
15
- - 🔧 **Pluggable Strategies** — Define custom regex patterns or word-based masking rules
16
- - 💾 **Persistent Storage** — Save/load configurations to OS keychain, encrypted JSON, or memory
17
- - 🚀 **Zero Dependencies** — Uses only Node.js built-in modules (except optional `cross-keychain` for OS storage)
18
- - 📦 **ESM Native** — Modern ES module syntax with named exports
19
- - 🔄 **Bidirectional** — Redact and restore text without data loss
7
+ - 🔒 **Secure Redaction** — Replace sensitive data with placeholders
8
+ - 🔄 **Reversible** — Restore original data from vault when needed
9
+ - 🏷️ **Named Groups** — Each redacted item gets a labeled placeholder
10
+ - 📦 **Presets** — Built-in patterns for common PII (email, credit card, Hong Kong ID, etc.)
11
+ - 🔐 **Encrypted Storage** — Persist rules to OS keychain or AES-256-GCM encrypted JSON
12
+ - 🖥️ **CLI Ready** — Pipe data through stdin or manage rules from command line
20
13
 
21
14
  ## Installation
22
15
 
@@ -24,185 +17,221 @@ Zero-dependency PII (Personally Identifiable Information) detection and redactio
24
17
  npm install no-pii
25
18
  ```
26
19
 
20
+ For global CLI access:
21
+
22
+ ```bash
23
+ npm install -g no-pii
24
+ ```
25
+
27
26
  ## Quick Start
28
27
 
28
+ ### Library Usage
29
+
29
30
  ```javascript
30
31
  import { nopii } from 'no-pii';
31
32
 
32
- // Create instance with in-memory storage
33
- const pii = nopii();
34
-
35
- // Add masking strategies
36
- await pii.addMasking('email', 'user@example.com', 'admin@company.org');
37
- await pii.addStrategy('ssn', /\\b\\d{3}-\\d{2}-\\d{4}\\b/g);
33
+ // Basic usage with presets
34
+ const pii = nopii({
35
+ rules: { COMMON: true, HK: true }
36
+ });
38
37
 
39
- // Redact sensitive text
40
38
  const { safeText, vault } = pii.redact(
41
- 'Contact user@example.com or admin@company.org. SSN: 123-45-6789'
39
+ 'Contact me at john@example.com or 5123-4567'
42
40
  );
43
41
 
44
42
  console.log(safeText);
45
- // 'Contact _P1_ or _P2_. SSN: _P3_'
43
+ // Contact me at [EMAIL_1] or [HK_PHONE_1]
46
44
 
47
- // Restore original text
45
+ // Restore original data
48
46
  const restored = pii.restore(safeText, vault);
49
47
  console.log(restored);
50
- // 'Contact user@example.com or admin@company.org. SSN: 123-45-6789'
48
+ // Contact me at john@example.com or 5123-4567
51
49
  ```
52
50
 
53
- ## API Reference
51
+ ### CLI Usage
54
52
 
55
- ### `nopii(options)`
56
-
57
- Factory function to create a new no-pii instance.
53
+ ```bash
54
+ # Redact from stdin
55
+ cat log.txt | no-pii --common --hk
58
56
 
59
- **Options:**
57
+ # Add custom rules
58
+ no-pii --add=\"NAMES:Alice,Bob,Charlie\" --db=rules.json --key=secret123
60
59
 
61
- | Option | Type | Default | Description |
62
- |--------|------|---------|-------------|
63
- | `verbose` | `boolean` | `false` | Enable verbose logging |
64
- | `service` | `string` | `'no-pii'` | Service name for OS keychain storage |
65
- | `account` | `string` | `'default'` | Account name for OS keychain storage |
66
- | `memory` | `string\\|object` | `'memory'` | Storage backend: `'os'`, `'memory'`, or custom object |
67
- | `aesKey` | `string` | — | Required for encrypted JSON file storage |
60
+ # Use OS keychain for storage
61
+ no-pii --add=\"PROJECT:AcmeCorp\" --os --service=myapp
62
+ cat data.txt | no-pii --os --service=myapp
68
63
 
69
- **Storage Backends:**
64
+ # List current rules
65
+ no-pii --list --db=rules.json --key=secret123
66
+ ```
70
67
 
71
- - `'memory'` (default) — In-memory Map storage
72
- - `'os'` — OS native keychain (Windows Credential Manager, macOS Keychain, Linux Secret Service) via `cross-keychain`
73
- - `'./path/to/file.json'` — Encrypted JSON file storage (requires `aesKey`)
74
- - Custom object — Must implement `{ get(key), set(key, value) }`
68
+ ## Configuration
75
69
 
76
- ### `instance.addMasking(label, ...words)`
70
+ ### Rules
77
71
 
78
- Add word-based masking strategy. Escapes special regex characters automatically.
72
+ Rules can be enabled via presets or custom patterns:
79
73
 
80
74
  ```javascript
81
- await pii.addMasking('names', 'John Doe', 'Jane Smith');
75
+ const pii = nopii({
76
+ rules: {
77
+ // Enable all common PII patterns
78
+ COMMON: true,
79
+ // Enable Hong Kong-specific patterns
80
+ HK: true,
81
+ // Custom keyword list (array becomes OR regex)
82
+ INTERNAL_CODES: ['PROJ-123', 'PROJ-456', 'SECRET'],
83
+ // Custom regex
84
+ API_KEY: /sk-[a-zA-Z0-9]{48}/
85
+ }
86
+ });
82
87
  ```
83
88
 
84
- ### `instance.addStrategy(name, regex)`
89
+ ### Built-in Presets
90
+
91
+ **COMMON:**
92
+ - `EMAIL` — RFC 5322 compliant email addresses
93
+ - `CREDIT_CARD` — Major card networks (Visa, Mastercard, Amex, etc.)
94
+
95
+ **HK (Hong Kong):**
96
+ - `HK_PHONE` — Local phone numbers (2/3/5/6/7/8/9 prefixes)
97
+ - `HKID` — Hong Kong Identity Card numbers
98
+ - `HK_OCTOPUS` — Octopus card numbers
85
99
 
86
- Add custom regex strategy.
100
+ ### Storage Options
87
101
 
102
+ **In-Memory (default):**
88
103
  ```javascript
89
- await pii.addStrategy('credit-card', /\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b/g);
104
+ const pii = nopii(); // Rules not persisted
90
105
  ```
91
106
 
92
- ### `instance.load(input)`
93
-
94
- Load configuration from file, string, or storage.
107
+ **OS Keychain:**
108
+ ```javascript
109
+ const pii = nopii({
110
+ storage: { method: 'os', service: 'myapp' }
111
+ });
112
+ await pii.load(); // Load saved rules
113
+ ```
95
114
 
115
+ **Encrypted JSON File:**
96
116
  ```javascript
97
- // Load from default storage
117
+ const pii = nopii({
118
+ storage: {
119
+ method: './rules.db.json',
120
+ aes: 'your-32-char-secret-key-here!!' // 32 bytes for AES-256
121
+ }
122
+ });
98
123
  await pii.load();
124
+ ```
99
125
 
100
- // Load from JSON file
101
- await pii.load('./config.json');
126
+ ## API Reference
102
127
 
103
- // Load from object
104
- await pii.load({
105
- masking: { names: ['Alice', 'Bob'] },
106
- strategies: { phone: '^\\\\d{3}-\\\\d{3}-\\\\d{4}$' }
107
- });
108
- ```
128
+ ### `nopii(options)`
129
+
130
+ Creates a new NoPii instance.
131
+
132
+ | Option | Type | Description |
133
+ |--------|------|-------------|
134
+ | `rules` | `Object` | Key-value pairs of rules to register |
135
+ | `storage` | `Object` | Storage configuration for persistence |
136
+ | `verbose` | `boolean` | Enable verbose logging (default: false) |
109
137
 
110
- ### `instance.redact(text)`
138
+ ### Instance Methods
111
139
 
112
- Redact PII from text. Returns `{ safeText, vault }`.
140
+ #### `addRule(key, value)`
141
+
142
+ Add a rule dynamically. Returns Promise (resolves to instance).
113
143
 
114
144
  ```javascript
115
- const { safeText, vault } = pii.redact('Sensitive data here');
116
- // safeText: Redacted text with placeholders
117
- // vault: Map of placeholder → original value
145
+ await pii.addRule('EMPLOYEE_ID', /EMP-\\d{5}/);
146
+ await pii.addRule('DEPARTMENTS', ['HR', 'Engineering', 'Sales']);
118
147
  ```
119
148
 
120
- ### `instance.restore(redactedText, vault)`
149
+ #### `list()`
121
150
 
122
- Restore original text from redacted version using vault.
151
+ Returns current rules as plain object.
123
152
 
124
153
  ```javascript
125
- const original = pii.restore(safeText, vault);
154
+ const rules = pii.list();
155
+ // { COMMON: true, HK: true, EMAIL: /.../ }
126
156
  ```
127
157
 
128
- ## Configuration Persistence
158
+ #### `redact(text)`
129
159
 
130
- ### OS Keychain Storage
160
+ Redacts PII from input text.
131
161
 
132
- ```javascript
133
- const pii = nopii({ memory: 'os', service: 'myapp', account: 'pii' });
162
+ **Returns:** `{ safeText: string, vault: Map }`
134
163
 
135
- // Strategies are persisted automatically
136
- await pii.addMasking('api-keys', 'sk-abc123', 'sk-xyz789');
164
+ - `safeText` Text with placeholders
165
+ - `vault` — Map of `placeholder → original_value`
137
166
 
138
- // Load on next run
139
- await pii.load();
140
- ```
167
+ #### `restore(redactedText, vault)`
141
168
 
142
- ### Encrypted JSON Storage
169
+ Restores original text from vault.
143
170
 
144
171
  ```javascript
145
- const pii = nopii({
146
- memory: './secrets/pii-store.json',
147
- aesKey: 'your-32-char-secret-key-here!!' // Must be 32+ chars for AES-256-GCM
148
- });
149
-
150
- await pii.addStrategy('token', /Bearer\\s+[a-zA-Z0-9]+/g);
151
- // Automatically encrypted and saved to ./secrets/pii-store.json
172
+ const original = pii.restore(safeText, vault);
152
173
  ```
153
174
 
154
- ## Advanced Example
175
+ #### `load()`
155
176
 
156
- ```javascript
157
- import { nopii } from 'no-pii';
158
- import fs from 'node:fs/promises';
177
+ Loads rules from configured storage. Returns Promise (resolves to instance).
159
178
 
160
- // Initialize with OS keychain storage
161
- const pii = nopii({
162
- memory: 'os',
163
- service: 'my-cli-tool',
164
- account: 'redaction-config'
165
- });
179
+ ## CLI Reference
166
180
 
167
- // Load existing configuration
168
- await pii.load();
181
+ ```
182
+ no-pii [options]
183
+
184
+ Options:
185
+ --add=\"KEY:VAL\" Add rule. Use commas for multiple keywords.
186
+ --list List all currently registered rules
187
+ --os Use OS Keychain (with --service)
188
+ --db=path.json Use encrypted JSON file (requires --key)
189
+ --key=str 32-character AES key for --db
190
+ --service=name Custom service identity (default: nopii)
191
+ --hk Enable Hong Kong PII presets
192
+ --common Enable Common PII presets
193
+ --help, -h Show help
194
+ ```
195
+
196
+ ### CLI Examples
197
+
198
+ **Redact a log file:**
199
+ ```bash
200
+ no-pii --common --hk < application.log > redacted.log
201
+ ```
169
202
 
170
- // Add comprehensive PII patterns
171
- await pii.addStrategy('email', /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/g);
172
- await pii.addStrategy('phone', /\\b\\(?\\d{3}\\)?[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b/g);
173
- await pii.addStrategy('ip', /\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b/g);
174
- await pii.addMasking('internal-codes', 'PROJ-1234', 'SECRET-5678');
203
+ **Build a ruleset incrementally:**
204
+ ```bash
205
+ # Initialize with presets
206
+ no-pii --common --db=production.json --key=$(openssl rand -base64 24)
175
207
 
176
- // Process log file
177
- const logs = await fs.readFile('./app.log', 'utf8');
178
- const { safeText, vault } = pii.redact(logs);
208
+ # Add company-specific terms
209
+ no-pii --add=\"PRODUCTS:WidgetPro,MegaTool\" --db=production.json --key=...
210
+ no-pii --add=\"STAFF:jdoe,asmith\" --db=production.json --key=...
179
211
 
180
- // Save redacted logs (safe for sharing)
181
- await fs.writeFile('./app-redacted.log', safeText);
212
+ # Use the ruleset
213
+ journalctl -u myapp | no-pii --db=production.json --key=...
214
+ ```
182
215
 
183
- // Save vault separately (keep secure!)
184
- await fs.writeFile('./vault.json', JSON.stringify([...vault]));
216
+ **Team-shared rules via keychain:**
217
+ ```bash
218
+ # One-time setup per machine
219
+ no-pii --common --hk --os --service=team-redactor
185
220
 
186
- // Later: restore original
187
- const restored = pii.restore(safeText, new Map(JSON.parse(await fs.readFile('./vault.json', 'utf8'))));
221
+ # Everyone uses same rules
222
+ npm run logs | no-pii --os --service=team-redactor
188
223
  ```
189
224
 
190
- ## Security Considerations
225
+ ## Security Notes
191
226
 
192
- - **Vault Security**: The vault contains the actual PII. Store it encrypted or in a secure location.
193
- - **AES Key Management**: When using JSON storage, protect your `aesKey` it decrypts the entire store.
194
- - **OS Keychain**: Relies on system security. On shared machines, ensure proper user isolation.
195
- - **Memory Storage**: Not persisted; cleared on process exit. Suitable for one-time operations.
227
+ - **AES Key**: When using encrypted JSON storage, the key must be exactly 32 bytes. The library will pad/truncate shorter/longer strings.
228
+ - **Vault Handling**: The `vault` Map contains sensitive data. Do not log or serialize it unintentionally.
229
+ - **OS Keychain**: Relies on `cross-keychain` for cross-platform keychain access (macOS Keychain, Windows Credential Locker, Linux Secret Service).
196
230
 
197
231
  ## Requirements
198
232
 
199
- - Node.js ≥ 18.0.0
200
- - For OS storage: `cross-keychain` peer dependency (auto-installed)
233
+ - Node.js ≥ 18.0.0 (for native `node:` prefixed modules)
201
234
 
202
235
  ## License
203
236
 
204
- MIT © [littlejustnode](https://github.com/littlejustnode)
205
-
206
- ---
207
-
208
- **Note**: This package is designed for data processing pipelines, log sanitization, and GDPR/privacy compliance workflows. Always follow your organization's data handling policies when processing PII.
237
+ MIT © littlejustnode
package/nopii-cli.js ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ import { stdin, stdout } from 'node:process';
3
+ import { nopii } from './nopii.js';
4
+
5
+ const args = process.argv.slice(2);
6
+ const help = args.includes('--help') || args.includes('-h');
7
+ const listRules = args.includes('--list');
8
+ const addRuleStr = args.find(a => a.startsWith('--add='))?.split('=')[1];
9
+ const useHK = args.includes('--hk');
10
+ const useCommon = args.includes('--common');
11
+ const useOS = args.includes('--os');
12
+
13
+ const getVal = (flag) => args.find(a => a.startsWith(flag))?.split('=')[1];
14
+
15
+ const aesKey = getVal('--key=');
16
+ const dbPath = getVal('--db=');
17
+ const service = getVal('--service=') || 'nopii';
18
+
19
+ if (help || (args.length === 0 && stdin.isTTY && !listRules && !addRuleStr)) {
20
+ console.log(`
21
+ nopii CLI - Production-grade PII Redaction
22
+
23
+ Usage:
24
+ cat log.txt | nopii [options]
25
+ nopii --add="NAMES:peter,amy,danny" --db=v.json --key=secret
26
+
27
+ Options:
28
+ --add="KEY:VAL" Add rule. Use commas for multiple keywords.
29
+ --list List all currently registered rules
30
+ --os Use OS Keychain
31
+ --db=path.json Use encrypted JSON file (requires --key)
32
+ --key=str 32-character AES key
33
+ --service=name Custom service identity (default: nopii)
34
+ --hk Enable Hong Kong PII presets
35
+ --common Enable Common PII presets
36
+ `);
37
+ process.exit(0);
38
+ }
39
+
40
+ // Ensure service/key are nested inside storage object for nopii.js
41
+ const storageConfig = useOS
42
+ ? { method: 'os', service: service }
43
+ : dbPath ? { method: dbPath, aes: aesKey } : null;
44
+
45
+ const pii = nopii({
46
+ rules: { HK: useHK, COMMON: useCommon },
47
+ storage: storageConfig
48
+ });
49
+
50
+ async function run() {
51
+ if (storageConfig) {
52
+ try { await pii.load(); } catch (err) {
53
+ console.error(`[nopii] Load failed: ${err.message}`);
54
+ process.exit(1);
55
+ }
56
+ }
57
+
58
+ if (addRuleStr) {
59
+ const [key, ...valParts] = addRuleStr.split(':');
60
+ let value = valParts.join(':');
61
+
62
+ // Split by comma if multiple values are provided
63
+ if (value.includes(',')) {
64
+ value = value.split(',').map(v => v.trim());
65
+ }
66
+
67
+ try {
68
+ await pii.addRule(key, value); //
69
+ console.log(`[nopii] Added ${key}: ${Array.isArray(value) ? value.join(', ') : value}`);
70
+ if (stdin.isTTY) process.exit(0);
71
+ } catch (err) {
72
+ console.error(`[nopii] Add failed: ${err.message}`);
73
+ process.exit(1);
74
+ }
75
+ }
76
+
77
+ if (listRules) {
78
+ console.log(JSON.stringify(pii.list(), null, 2));
79
+ process.exit(0);
80
+ }
81
+
82
+ stdin.on('data', (chunk) => {
83
+ const { safeText } = pii.redact(chunk.toString()); //
84
+ stdout.write(safeText);
85
+ });
86
+ stdin.on('end', () => process.exit(0));
87
+ }
88
+
89
+ run();
package/nopii.js CHANGED
@@ -8,148 +8,118 @@ export class NoPii {
8
8
  #combinedRegex = null;
9
9
  #storage = null;
10
10
  #config = {};
11
- #currentConfig = { masking: {}, strategies: {} };
11
+ #currentRules = {};
12
+
13
+ static PRESETS = {
14
+ COMMON: {
15
+ EMAIL: /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*/u,
16
+ CREDIT_CARD: /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})\b/u,
17
+ },
18
+ HK: {
19
+ HK_PHONE: /\b(852)?[2356789]\d{3}[\s-]?\d{4}\b/u,
20
+ HKID: /\b[A-Z]{1,2}\d{6}\(?[\dA]\)?\b/u,
21
+ HK_OCTOPUS: /\b\d{8,9}\(?\d\)?\b/u,
22
+ }
23
+ };
12
24
 
13
25
  constructor(options = {}) {
14
- this.#config = {
15
- verbose: false,
16
- service: 'nopii',
17
- account: 'default',
18
- ...options
19
- };
20
- this.#storage = this.#initStorage(this.#config.memory);
26
+ this.#config = { verbose: false, ...options };
27
+ this.#storage = this.#initStorage(options.storage);
28
+
29
+ if (options.rules) {
30
+ for (const [key, val] of Object.entries(options.rules)) {
31
+ this.#applyRuleLogic(key, val);
32
+ }
33
+ }
21
34
  }
22
35
 
23
- #initStorage(memoryOption) {
24
- if (memoryOption && typeof memoryOption === 'object' && typeof memoryOption.get === 'function') {
25
- return memoryOption;
26
- }
36
+ list() {
37
+ return { ...this.#currentRules };
38
+ }
27
39
 
28
- if (memoryOption === 'os') {
29
- const { service, account } = this.#config;
30
- return {
31
- get: async (key) => {
32
- // Use underscore instead of colon for Windows compatibility
33
- const val = await getPassword(service, `${account}_${key}`);
34
- return val ? JSON.parse(val) : null;
35
- },
36
- set: async (key, val) => {
37
- await setPassword(service, `${account}_${key}`, JSON.stringify(val));
38
- }
39
- };
40
- }
40
+ async addRule(key, value) {
41
+ this.#applyRuleLogic(key, value);
42
+ await this.#persist();
43
+ return this;
44
+ }
41
45
 
42
- if (typeof memoryOption === 'string' && memoryOption.endsWith('.json')) {
43
- const aesKey = this.#config.aesKey;
44
- if (!aesKey) throw new Error("nopii: aesKey required for JSON storage.");
46
+ #applyRuleLogic(key, value) {
47
+ const upperKey = key.toUpperCase();
48
+ const allPresets = { ...NoPii.PRESETS.COMMON, ...NoPii.PRESETS.HK };
49
+ this.#currentRules[key] = value;
45
50
 
46
- return {
47
- path: memoryOption,
48
- key: Buffer.alloc(32, aesKey),
49
- async get() {
50
- try {
51
- const raw = await fs.readFile(this.path, 'utf8');
52
- return JSON.parse(this.decrypt(raw));
53
- } catch (e) {
54
- // Rethrow decryption/auth errors, ignore "file not found"
55
- if (e.message.includes('Decryption failed')) throw e;
56
- return null;
57
- }
58
- },
59
- async set(key, val) {
60
- const encrypted = this.encrypt(JSON.stringify(val));
61
- await fs.writeFile(this.path, encrypted);
62
- },
63
- encrypt(text) {
64
- const iv = crypto.randomBytes(12);
65
- const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv);
66
- const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
67
- return Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64');
68
- },
69
- decrypt(data) {
70
- try {
71
- const buf = Buffer.from(data, 'base64');
72
- const iv = buf.subarray(0, 12);
73
- const tag = buf.subarray(12, 28);
74
- const enc = buf.subarray(28);
75
- const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv);
76
- decipher.setAuthTag(tag);
77
- // .final() throws if the tag (GCM auth) is invalid
78
- return decipher.update(enc, 'utf8') + decipher.final('utf8');
79
- } catch (err) {
80
- throw new Error(`Decryption failed: ${err.message}`);
81
- }
82
- }
83
- };
51
+ if (upperKey === 'HK' && value === true) {
52
+ Object.entries(NoPii.PRESETS.HK).forEach(([k, v]) => this.#register(k, v));
53
+ }
54
+ else if (upperKey === 'COMMON' && value === true) {
55
+ Object.entries(NoPii.PRESETS.COMMON).forEach(([k, v]) => this.#register(k, v));
56
+ }
57
+ else if (allPresets[upperKey] && value === true) {
58
+ this.#register(upperKey, allPresets[upperKey]);
59
+ }
60
+ else if (Array.isArray(value)) {
61
+ const escaped = value.map(v => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
62
+ this.#register(key, new RegExp(`\\b(${escaped})\\b`, 'gu'));
63
+ }
64
+ else {
65
+ this.#register(key, value);
84
66
  }
85
-
86
- const _mem = new Map();
87
- return {
88
- get: async (key) => _mem.get(key),
89
- set: async (key, val) => _mem.set(key, val)
90
- };
91
67
  }
92
68
 
93
- async load(input) {
94
- let configData = null;
95
- if (!input && this.#config.memory) {
96
- configData = await this.#storage.get('config');
97
- } else if (typeof input === 'string') {
98
- const raw = input.endsWith('.json') ? await fs.readFile(input, 'utf8') : input;
99
- configData = JSON.parse(raw);
69
+ #register(name, regex) {
70
+ let pattern;
71
+ // FIX: Handle string-serialized regex like "/pattern/flags"
72
+ if (typeof regex === 'string' && regex.startsWith('/') && regex.lastIndexOf('/') > 0) {
73
+ const lastSlash = regex.lastIndexOf('/');
74
+ const source = regex.slice(1, lastSlash);
75
+ const flags = regex.slice(lastSlash + 1);
76
+ pattern = new RegExp(source, flags || 'u');
77
+ } else if (typeof regex === 'string') {
78
+ pattern = new RegExp(regex, 'u');
100
79
  } else {
101
- configData = input;
80
+ pattern = regex;
102
81
  }
103
82
 
104
- if (configData) {
105
- await this.#applyConfig(configData, false);
83
+ const existingIdx = this.#strategies.findIndex(s => s.name === name);
84
+ if (existingIdx > -1) {
85
+ this.#strategies[existingIdx].regex = pattern;
86
+ } else {
87
+ this.#strategies.push({ name, regex: pattern });
106
88
  }
107
- return this;
89
+ this.#compile();
108
90
  }
109
91
 
110
- async #persist() {
111
- if (this.#config.memory) {
112
- await this.#storage.set('config', this.#currentConfig);
92
+ #compile() {
93
+ if (this.#strategies.length === 0) {
94
+ this.#combinedRegex = null;
95
+ return;
113
96
  }
97
+ const source = this.#strategies.map(s => `(?<${s.name}>${s.regex.source})`).join('|');
98
+ this.#combinedRegex = new RegExp(source, 'gu');
114
99
  }
115
100
 
116
- async #applyConfig(data, shouldPersist = true) {
117
- if (data.masking) {
118
- for (const [label, words] of Object.entries(data.masking)) {
119
- await this.addMasking(label, ...words);
120
- }
121
- }
122
- if (data.strategies) {
123
- for (const [name, pattern] of Object.entries(data.strategies)) {
124
- await this.addStrategy(name, new RegExp(pattern, 'u'));
125
- }
101
+ async #persist() {
102
+ if (!this.#storage) return;
103
+
104
+ // FIX: Serialize RegExp objects to strings so they don't become {} in JSON
105
+ const serializable = {};
106
+ for (const [k, v] of Object.entries(this.#currentRules)) {
107
+ serializable[k] = (v instanceof RegExp) ? `/${v.source}/${v.flags}` : v;
126
108
  }
127
- if (shouldPersist) await this.#persist();
109
+ await this.#storage.set('rules', serializable);
128
110
  }
129
111
 
130
- async addMasking(label, ...words) {
131
- const pattern = words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
132
- this.#currentConfig.masking[label] = words;
133
- this.#strategies.push({ name: label, regex: new RegExp(`(${pattern})`, 'u') });
134
- this.#compile();
135
- await this.#persist();
136
- return this;
137
- }
138
-
139
- async addStrategy(name, regex) {
140
- this.#currentConfig.strategies[name] = regex.source;
141
- this.#strategies.push({ name, regex });
142
- this.#compile();
143
- await this.#persist();
112
+ async load() {
113
+ if (!this.#storage) return this;
114
+ const savedRules = await this.#storage.get('rules');
115
+ if (savedRules) {
116
+ for (const [key, val] of Object.entries(savedRules)) {
117
+ this.#applyRuleLogic(key, val);
118
+ }
119
+ }
144
120
  return this;
145
121
  }
146
122
 
147
- #compile() {
148
- if (this.#strategies.length === 0) return;
149
- const source = this.#strategies.map(s => `(?<${s.name}>${s.regex.source})`).join('|');
150
- this.#combinedRegex = new RegExp(source, 'gu');
151
- }
152
-
153
123
  redact(text) {
154
124
  if (!this.#combinedRegex || !text) return { safeText: text, vault: new Map() };
155
125
  const vault = new Map();
@@ -157,18 +127,89 @@ export class NoPii {
157
127
  const safeText = text.replace(this.#combinedRegex, (...args) => {
158
128
  const groups = args.at(-1);
159
129
  const label = Object.keys(groups).find(key => groups[key] !== undefined);
160
- const placeholder = `_P${++count}_`;
161
- vault.set(placeholder, groups[label]);
130
+ const value = groups[label];
131
+ const placeholder = `[${label}_${++count}]`;
132
+ vault.set(placeholder, value);
162
133
  return placeholder;
163
134
  });
164
135
  return { safeText, vault };
165
136
  }
166
137
 
167
138
  restore(redactedText, vault) {
168
- if (!redactedText || !vault || vault.size === 0) return redactedText;
169
- const keys = Array.from(vault.keys()).sort((a, b) => b.length - a.length);
170
- const restoreRegex = new RegExp(keys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g');
171
- return redactedText.replace(restoreRegex, (m) => vault.get(m));
139
+ if (!redactedText || !vault) return redactedText;
140
+ const vaultEntries = vault instanceof Map ? Array.from(vault.entries()) : Object.entries(vault);
141
+ if (vaultEntries.length === 0) return redactedText;
142
+
143
+ const sortedKeys = vaultEntries
144
+ .map(([k]) => k)
145
+ .sort((a, b) => b.length - a.length);
146
+
147
+ const restoreRegex = new RegExp(sortedKeys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g');
148
+ const lookup = (m) => (vault instanceof Map ? vault.get(m) : vault[m]);
149
+
150
+ return redactedText.replace(restoreRegex, (m) => lookup(m));
151
+ }
152
+
153
+ #initStorage(storage) {
154
+ if (!storage) {
155
+ const _mem = new Map();
156
+ return { get: async (k) => _mem.get(k), set: async (k, v) => _mem.set(k, v) };
157
+ }
158
+
159
+ if (storage.method === 'os') {
160
+ const { service = 'nopii' } = storage;
161
+ const account = service;
162
+ return {
163
+ get: async (key) => {
164
+ try {
165
+ const val = await getPassword(service, `${account}_${key}`);
166
+ return val ? JSON.parse(val) : null;
167
+ } catch { return null; }
168
+ },
169
+ set: async (key, val) => {
170
+ await setPassword(service, `${account}_${key}`, JSON.stringify(val));
171
+ }
172
+ };
173
+ }
174
+
175
+ if (typeof storage.method === 'string' && storage.method.endsWith('.json')) {
176
+ const aesKey = Buffer.alloc(32, storage.aes || 'default-secret-key');
177
+ const filePath = storage.method;
178
+
179
+ const encrypt = (text) => {
180
+ const iv = crypto.randomBytes(12);
181
+ const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
182
+ const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
183
+ return Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64');
184
+ };
185
+
186
+ const decrypt = (data) => {
187
+ const buf = Buffer.from(data, 'base64');
188
+ const iv = buf.subarray(0, 12), tag = buf.subarray(12, 28), enc = buf.subarray(28);
189
+ const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv);
190
+ decipher.setAuthTag(tag);
191
+ return decipher.update(enc, 'utf8') + decipher.final('utf8');
192
+ };
193
+
194
+ return {
195
+ async get(lookupKey) {
196
+ try {
197
+ const raw = await fs.readFile(filePath, 'utf8');
198
+ const data = JSON.parse(decrypt(raw));
199
+ return lookupKey ? data[lookupKey] : data;
200
+ } catch { return null; }
201
+ },
202
+ async set(lookupKey, val) {
203
+ let data = {};
204
+ try {
205
+ const raw = await fs.readFile(filePath, 'utf8');
206
+ data = JSON.parse(decrypt(raw));
207
+ } catch (e) {}
208
+ data[lookupKey] = val;
209
+ await fs.writeFile(filePath, encrypt(JSON.stringify(data)));
210
+ }
211
+ };
212
+ }
172
213
  }
173
214
  }
174
215
 
package/package.json CHANGED
@@ -1,46 +1,44 @@
1
1
  {
2
2
  "name": "no-pii",
3
- "version": "1.0.1",
4
- "description": "PII redaction utility with encrypted local persistence and OS keychain support.",
5
- "type": "module",
3
+ "version": "1.2.0",
4
+ "description": "Production-grade PII redaction library with CLI support",
6
5
  "main": "nopii.js",
7
- "files": [
8
- "nopii.js",
9
- "LICENSE",
10
- "README.md"
11
- ],
12
- "engines": {
13
- "node": ">=18.0.0"
6
+ "type": "module",
7
+ "bin": {
8
+ "nopii": "./nopii-cli.js"
14
9
  },
15
10
  "scripts": {
16
- "test": "node --test test.js",
17
- "lint": "eslint .",
18
- "example": "node examples/basic-usage.js"
11
+ "test": "echo \"Error: no test specified\" && exit 1"
19
12
  },
20
13
  "keywords": [
21
- "nopii",
22
14
  "pii",
15
+ "nopii",
16
+ "no-pii",
23
17
  "redaction",
18
+ "privacy",
19
+ "gdpr",
24
20
  "security",
25
- "keychain",
26
- "encryption",
27
- "node-js",
28
- "esm"
21
+ "cli",
22
+ "hong-kong",
23
+ "hkid",
24
+ "email",
25
+ "credit-card"
29
26
  ],
30
27
  "author": "littlejustnode",
31
28
  "license": "MIT",
32
- "dependencies": {
33
- "cross-keychain": "^1.1.0"
34
- },
35
- "devDependencies": {
36
- "eslint": "^9.0.0"
37
- },
38
- "repository": {
39
- "type": "git",
40
- "url": "git+https://github.com/littlejustnode/nopii.git"
29
+ "engines": {
30
+ "node": ">=18.0.0"
41
31
  },
42
- "bugs": {
43
- "url": "https://github.com/littlejustnode/nopii/issues"
32
+ "dependencies": {
33
+ "cross-keychain": "^1.0.0"
44
34
  },
45
- "homepage": "https://github.com/littlejustnode/nopii#readme"
46
- }
35
+ "files": [
36
+ "nopii.js",
37
+ "nopii-cli.js",
38
+ "README.md"
39
+ ],
40
+ "exports": {
41
+ ".": "./nopii.js",
42
+ "./cli": "./nopii-cli.js"
43
+ }
44
+ }