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.
- package/README.md +152 -123
- package/nopii-cli.js +89 -0
- package/nopii.js +164 -123
- package/package.json +29 -31
package/README.md
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
1
|
# no-pii
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/no-pii)
|
|
6
|
-
[](https://github.com/littlejustnode/no-pii/blob/main/LICENSE)
|
|
7
|
-
[](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
|
-
- 🔒 **
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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
|
-
//
|
|
33
|
-
const pii = nopii(
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
39
|
+
'Contact me at john@example.com or 5123-4567'
|
|
42
40
|
);
|
|
43
41
|
|
|
44
42
|
console.log(safeText);
|
|
45
|
-
//
|
|
43
|
+
// Contact me at [EMAIL_1] or [HK_PHONE_1]
|
|
46
44
|
|
|
47
|
-
// Restore original
|
|
45
|
+
// Restore original data
|
|
48
46
|
const restored = pii.restore(safeText, vault);
|
|
49
47
|
console.log(restored);
|
|
50
|
-
//
|
|
48
|
+
// Contact me at john@example.com or 5123-4567
|
|
51
49
|
```
|
|
52
50
|
|
|
53
|
-
|
|
51
|
+
### CLI Usage
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
```bash
|
|
54
|
+
# Redact from stdin
|
|
55
|
+
cat log.txt | no-pii --common --hk
|
|
58
56
|
|
|
59
|
-
|
|
57
|
+
# Add custom rules
|
|
58
|
+
no-pii --add=\"NAMES:Alice,Bob,Charlie\" --db=rules.json --key=secret123
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
64
|
+
# List current rules
|
|
65
|
+
no-pii --list --db=rules.json --key=secret123
|
|
66
|
+
```
|
|
70
67
|
|
|
71
|
-
|
|
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
|
-
###
|
|
70
|
+
### Rules
|
|
77
71
|
|
|
78
|
-
|
|
72
|
+
Rules can be enabled via presets or custom patterns:
|
|
79
73
|
|
|
80
74
|
```javascript
|
|
81
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
100
|
+
### Storage Options
|
|
87
101
|
|
|
102
|
+
**In-Memory (default):**
|
|
88
103
|
```javascript
|
|
89
|
-
|
|
104
|
+
const pii = nopii(); // Rules not persisted
|
|
90
105
|
```
|
|
91
106
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
await pii.load('./config.json');
|
|
126
|
+
## API Reference
|
|
102
127
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
###
|
|
138
|
+
### Instance Methods
|
|
111
139
|
|
|
112
|
-
|
|
140
|
+
#### `addRule(key, value)`
|
|
141
|
+
|
|
142
|
+
Add a rule dynamically. Returns Promise (resolves to instance).
|
|
113
143
|
|
|
114
144
|
```javascript
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
149
|
+
#### `list()`
|
|
121
150
|
|
|
122
|
-
|
|
151
|
+
Returns current rules as plain object.
|
|
123
152
|
|
|
124
153
|
```javascript
|
|
125
|
-
const
|
|
154
|
+
const rules = pii.list();
|
|
155
|
+
// { COMMON: true, HK: true, EMAIL: /.../ }
|
|
126
156
|
```
|
|
127
157
|
|
|
128
|
-
|
|
158
|
+
#### `redact(text)`
|
|
129
159
|
|
|
130
|
-
|
|
160
|
+
Redacts PII from input text.
|
|
131
161
|
|
|
132
|
-
|
|
133
|
-
const pii = nopii({ memory: 'os', service: 'myapp', account: 'pii' });
|
|
162
|
+
**Returns:** `{ safeText: string, vault: Map }`
|
|
134
163
|
|
|
135
|
-
|
|
136
|
-
|
|
164
|
+
- `safeText` — Text with placeholders
|
|
165
|
+
- `vault` — Map of `placeholder → original_value`
|
|
137
166
|
|
|
138
|
-
|
|
139
|
-
await pii.load();
|
|
140
|
-
```
|
|
167
|
+
#### `restore(redactedText, vault)`
|
|
141
168
|
|
|
142
|
-
|
|
169
|
+
Restores original text from vault.
|
|
143
170
|
|
|
144
171
|
```javascript
|
|
145
|
-
const
|
|
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
|
-
|
|
175
|
+
#### `load()`
|
|
155
176
|
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
const pii = nopii({
|
|
162
|
-
memory: 'os',
|
|
163
|
-
service: 'my-cli-tool',
|
|
164
|
-
account: 'redaction-config'
|
|
165
|
-
});
|
|
179
|
+
## CLI Reference
|
|
166
180
|
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
212
|
+
# Use the ruleset
|
|
213
|
+
journalctl -u myapp | no-pii --db=production.json --key=...
|
|
214
|
+
```
|
|
182
215
|
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
221
|
+
# Everyone uses same rules
|
|
222
|
+
npm run logs | no-pii --os --service=team-redactor
|
|
188
223
|
```
|
|
189
224
|
|
|
190
|
-
## Security
|
|
225
|
+
## Security Notes
|
|
191
226
|
|
|
192
|
-
- **
|
|
193
|
-
- **
|
|
194
|
-
- **OS Keychain**: Relies on
|
|
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 ©
|
|
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
|
-
#
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
36
|
+
list() {
|
|
37
|
+
return { ...this.#currentRules };
|
|
38
|
+
}
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
94
|
-
let
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
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
|
-
|
|
80
|
+
pattern = regex;
|
|
102
81
|
}
|
|
103
82
|
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
89
|
+
this.#compile();
|
|
108
90
|
}
|
|
109
91
|
|
|
110
|
-
|
|
111
|
-
if (this.#
|
|
112
|
-
|
|
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 #
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
109
|
+
await this.#storage.set('rules', serializable);
|
|
128
110
|
}
|
|
129
111
|
|
|
130
|
-
async
|
|
131
|
-
|
|
132
|
-
this.#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
161
|
-
|
|
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
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
4
|
-
"description": "PII redaction
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Production-grade PII redaction library with CLI support",
|
|
6
5
|
"main": "nopii.js",
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
"
|
|
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": "
|
|
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
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
21
|
+
"cli",
|
|
22
|
+
"hong-kong",
|
|
23
|
+
"hkid",
|
|
24
|
+
"email",
|
|
25
|
+
"credit-card"
|
|
29
26
|
],
|
|
30
27
|
"author": "littlejustnode",
|
|
31
28
|
"license": "MIT",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
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
|
-
"
|
|
43
|
-
"
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"cross-keychain": "^1.0.0"
|
|
44
34
|
},
|
|
45
|
-
"
|
|
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
|
+
}
|