no-pii 1.0.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 +208 -0
- package/nopii.js +175 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# nopii
|
|
2
|
+
|
|
3
|
+
> **No PII** — PII detection and redaction with secure vault storage
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/nopii)
|
|
6
|
+
[](https://github.com/littlejustnode/nopii/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.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
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
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install nopii
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```javascript
|
|
30
|
+
import { nopii } from 'nopii';
|
|
31
|
+
|
|
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);
|
|
38
|
+
|
|
39
|
+
// Redact sensitive text
|
|
40
|
+
const { safeText, vault } = pii.redact(
|
|
41
|
+
'Contact user@example.com or admin@company.org. SSN: 123-45-6789'
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
console.log(safeText);
|
|
45
|
+
// 'Contact _P1_ or _P2_. SSN: _P3_'
|
|
46
|
+
|
|
47
|
+
// Restore original text
|
|
48
|
+
const restored = pii.restore(safeText, vault);
|
|
49
|
+
console.log(restored);
|
|
50
|
+
// 'Contact user@example.com or admin@company.org. SSN: 123-45-6789'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API Reference
|
|
54
|
+
|
|
55
|
+
### `nopii(options)`
|
|
56
|
+
|
|
57
|
+
Factory function to create a new NoPii instance.
|
|
58
|
+
|
|
59
|
+
**Options:**
|
|
60
|
+
|
|
61
|
+
| Option | Type | Default | Description |
|
|
62
|
+
|--------|------|---------|-------------|
|
|
63
|
+
| `verbose` | `boolean` | `false` | Enable verbose logging |
|
|
64
|
+
| `service` | `string` | `'nopii'` | 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 |
|
|
68
|
+
|
|
69
|
+
**Storage Backends:**
|
|
70
|
+
|
|
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) }`
|
|
75
|
+
|
|
76
|
+
### `instance.addMasking(label, ...words)`
|
|
77
|
+
|
|
78
|
+
Add word-based masking strategy. Escapes special regex characters automatically.
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
await pii.addMasking('names', 'John Doe', 'Jane Smith');
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### `instance.addStrategy(name, regex)`
|
|
85
|
+
|
|
86
|
+
Add custom regex strategy.
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
await pii.addStrategy('credit-card', /\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b/g);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `instance.load(input)`
|
|
93
|
+
|
|
94
|
+
Load configuration from file, string, or storage.
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
// Load from default storage
|
|
98
|
+
await pii.load();
|
|
99
|
+
|
|
100
|
+
// Load from JSON file
|
|
101
|
+
await pii.load('./config.json');
|
|
102
|
+
|
|
103
|
+
// Load from object
|
|
104
|
+
await pii.load({
|
|
105
|
+
masking: { names: ['Alice', 'Bob'] },
|
|
106
|
+
strategies: { phone: '^\\\\d{3}-\\\\d{3}-\\\\d{4}$' }
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### `instance.redact(text)`
|
|
111
|
+
|
|
112
|
+
Redact PII from text. Returns `{ safeText, vault }`.
|
|
113
|
+
|
|
114
|
+
```javascript
|
|
115
|
+
const { safeText, vault } = pii.redact('Sensitive data here');
|
|
116
|
+
// safeText: Redacted text with placeholders
|
|
117
|
+
// vault: Map of placeholder → original value
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `instance.restore(redactedText, vault)`
|
|
121
|
+
|
|
122
|
+
Restore original text from redacted version using vault.
|
|
123
|
+
|
|
124
|
+
```javascript
|
|
125
|
+
const original = pii.restore(safeText, vault);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Configuration Persistence
|
|
129
|
+
|
|
130
|
+
### OS Keychain Storage
|
|
131
|
+
|
|
132
|
+
```javascript
|
|
133
|
+
const pii = nopii({ memory: 'os', service: 'myapp', account: 'pii' });
|
|
134
|
+
|
|
135
|
+
// Strategies are persisted automatically
|
|
136
|
+
await pii.addMasking('api-keys', 'sk-abc123', 'sk-xyz789');
|
|
137
|
+
|
|
138
|
+
// Load on next run
|
|
139
|
+
await pii.load();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Encrypted JSON Storage
|
|
143
|
+
|
|
144
|
+
```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
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Advanced Example
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
import { nopii } from 'nopii';
|
|
158
|
+
import fs from 'node:fs/promises';
|
|
159
|
+
|
|
160
|
+
// Initialize with OS keychain storage
|
|
161
|
+
const pii = nopii({
|
|
162
|
+
memory: 'os',
|
|
163
|
+
service: 'my-cli-tool',
|
|
164
|
+
account: 'redaction-config'
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Load existing configuration
|
|
168
|
+
await pii.load();
|
|
169
|
+
|
|
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');
|
|
175
|
+
|
|
176
|
+
// Process log file
|
|
177
|
+
const logs = await fs.readFile('./app.log', 'utf8');
|
|
178
|
+
const { safeText, vault } = pii.redact(logs);
|
|
179
|
+
|
|
180
|
+
// Save redacted logs (safe for sharing)
|
|
181
|
+
await fs.writeFile('./app-redacted.log', safeText);
|
|
182
|
+
|
|
183
|
+
// Save vault separately (keep secure!)
|
|
184
|
+
await fs.writeFile('./vault.json', JSON.stringify([...vault]));
|
|
185
|
+
|
|
186
|
+
// Later: restore original
|
|
187
|
+
const restored = pii.restore(safeText, new Map(JSON.parse(await fs.readFile('./vault.json', 'utf8'))));
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Security Considerations
|
|
191
|
+
|
|
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.
|
|
196
|
+
|
|
197
|
+
## Requirements
|
|
198
|
+
|
|
199
|
+
- Node.js ≥ 18.0.0
|
|
200
|
+
- For OS storage: `cross-keychain` peer dependency (auto-installed)
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
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.
|
package/nopii.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { Buffer } from 'node:buffer';
|
|
4
|
+
import { getPassword, setPassword } from 'cross-keychain';
|
|
5
|
+
|
|
6
|
+
export class NoPii {
|
|
7
|
+
#strategies = [];
|
|
8
|
+
#combinedRegex = null;
|
|
9
|
+
#storage = null;
|
|
10
|
+
#config = {};
|
|
11
|
+
#currentConfig = { masking: {}, strategies: {} };
|
|
12
|
+
|
|
13
|
+
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);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#initStorage(memoryOption) {
|
|
24
|
+
if (memoryOption && typeof memoryOption === 'object' && typeof memoryOption.get === 'function') {
|
|
25
|
+
return memoryOption;
|
|
26
|
+
}
|
|
27
|
+
|
|
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
|
+
}
|
|
41
|
+
|
|
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.");
|
|
45
|
+
|
|
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
|
+
};
|
|
84
|
+
}
|
|
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
|
+
}
|
|
92
|
+
|
|
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);
|
|
100
|
+
} else {
|
|
101
|
+
configData = input;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (configData) {
|
|
105
|
+
await this.#applyConfig(configData, false);
|
|
106
|
+
}
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async #persist() {
|
|
111
|
+
if (this.#config.memory) {
|
|
112
|
+
await this.#storage.set('config', this.#currentConfig);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
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
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (shouldPersist) await this.#persist();
|
|
128
|
+
}
|
|
129
|
+
|
|
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();
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
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
|
+
redact(text) {
|
|
154
|
+
if (!this.#combinedRegex || !text) return { safeText: text, vault: new Map() };
|
|
155
|
+
const vault = new Map();
|
|
156
|
+
let count = 0;
|
|
157
|
+
const safeText = text.replace(this.#combinedRegex, (...args) => {
|
|
158
|
+
const groups = args.at(-1);
|
|
159
|
+
const label = Object.keys(groups).find(key => groups[key] !== undefined);
|
|
160
|
+
const placeholder = `_P${++count}_`;
|
|
161
|
+
vault.set(placeholder, groups[label]);
|
|
162
|
+
return placeholder;
|
|
163
|
+
});
|
|
164
|
+
return { safeText, vault };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
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));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const nopii = (options) => new NoPii(options);
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "no-pii",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "PII redaction utility with encrypted local persistence and OS keychain support.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "nopii.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"nopii.js",
|
|
9
|
+
"LICENSE",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18.0.0"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test.js",
|
|
17
|
+
"lint": "eslint .",
|
|
18
|
+
"example": "node examples/basic-usage.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"nopii",
|
|
22
|
+
"pii",
|
|
23
|
+
"redaction",
|
|
24
|
+
"security",
|
|
25
|
+
"keychain",
|
|
26
|
+
"encryption",
|
|
27
|
+
"node-js",
|
|
28
|
+
"esm"
|
|
29
|
+
],
|
|
30
|
+
"author": "littlejustnode",
|
|
31
|
+
"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"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/littlejustnode/nopii/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/littlejustnode/nopii#readme"
|
|
46
|
+
}
|