@privacyscrubber/sdk 2.2.2 → 2.2.3

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/vision.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * TypeScript Definitions for @privacyscrubber/sdk/vision
3
+ */
4
+
5
+ export interface ExifStripResult {
6
+ /** The sanitized binary buffer without EXIF/XMP/IPTC/GPS metadata. */
7
+ cleanBuffer: Uint8Array | Buffer;
8
+ /** Whether any metadata segment was found and stripped. */
9
+ metadataStripped: boolean;
10
+ /** Image format detected: 'jpeg' | 'png' | 'webp' | 'unknown'. */
11
+ format: string;
12
+ /** Array of removed segment identifiers. */
13
+ removedSegments: string[];
14
+ /** Number of bytes saved by removing metadata. */
15
+ bytesSaved: number;
16
+ }
17
+
18
+ export interface VisionBoundingBox {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ label?: string;
24
+ }
25
+
26
+ export interface VisionRedactOptions {
27
+ /** Fill color for blackout box (Default: '#000000'). */
28
+ fillColor?: string;
29
+ /** Border color (Default: 'rgba(0, 240, 255, 0.4)'). */
30
+ borderColor?: string;
31
+ /** Whether to render token label badges (Default: true). */
32
+ showLabels?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Strips EXIF, XMP, GPS, and sensitive metadata chunks from image buffers (JPEG, PNG, WebP).
37
+ */
38
+ export function stripExifMetadata(inputBuffer: Buffer | Uint8Array): ExifStripResult;
39
+
40
+ /**
41
+ * Redacts bounding boxes on an HTML Canvas or 2D Canvas context.
42
+ */
43
+ export function redactImageBoundingBoxes(
44
+ canvasOrCtx: any,
45
+ boxes?: VisionBoundingBox[],
46
+ options?: VisionRedactOptions
47
+ ): number;
48
+
49
+ /**
50
+ * Generates an air-gapped SVG redaction mask overlay for headless environments.
51
+ */
52
+ export function generateSvgRedactionOverlay(
53
+ dimensions?: { width: number; height: number },
54
+ boxes?: VisionBoundingBox[],
55
+ options?: VisionRedactOptions
56
+ ): string;
package/vision.js ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * PrivacyScrubber Developer SDK — Multimodal Vision Submodule (@privacyscrubber/sdk/vision)
3
+ *
4
+ * Sub-millisecond in-memory binary metadata sanitization & bounding box redaction.
5
+ * Zero external dependencies. Zero network calls. 100% local RAM execution.
6
+ *
7
+ * https://privacyscrubber.com/features/developer-sdk/
8
+ */
9
+
10
+ /**
11
+ * Strips EXIF, XMP, GPS, and sensitive metadata chunks from image buffers (JPEG, PNG, WebP).
12
+ * Operates purely in-memory on typed arrays with sub-millisecond execution.
13
+ *
14
+ * @param {Buffer|Uint8Array} inputBuffer - Raw image buffer
15
+ * @returns {{ cleanBuffer: Uint8Array|Buffer, metadataStripped: boolean, format: string, removedSegments: string[], bytesSaved: number }}
16
+ */
17
+ export function stripExifMetadata(inputBuffer) {
18
+ if (!inputBuffer || inputBuffer.length < 8) {
19
+ return {
20
+ cleanBuffer: inputBuffer,
21
+ metadataStripped: false,
22
+ format: 'unknown',
23
+ removedSegments: [],
24
+ bytesSaved: 0
25
+ };
26
+ }
27
+
28
+ const u8 = inputBuffer instanceof Uint8Array ? inputBuffer : new Uint8Array(inputBuffer);
29
+ const originalSize = u8.length;
30
+ const removedSegments = [];
31
+
32
+ // 1. JPEG Detection & Stripping (SOI: 0xFF, 0xD8)
33
+ if (u8[0] === 0xFF && u8[1] === 0xD8) {
34
+ const chunks = [];
35
+ chunks.push(new Uint8Array([0xFF, 0xD8])); // Keep SOI
36
+ let offset = 2;
37
+ let stripped = false;
38
+
39
+ while (offset < u8.length - 1) {
40
+ if (u8[offset] !== 0xFF) {
41
+ // Stray byte or corrupted segment, push rest and break
42
+ chunks.push(u8.subarray(offset));
43
+ break;
44
+ }
45
+
46
+ const marker = u8[offset + 1];
47
+
48
+ // Standalone markers without length: RST0-RST7 (0xD0-0xD7), SOI (0xD8), EOI (0xD9)
49
+ if (marker === 0xD9) { // EOI
50
+ chunks.push(new Uint8Array([0xFF, 0xD9]));
51
+ break;
52
+ }
53
+ if (marker === 0x00 || (marker >= 0xD0 && marker <= 0xD7)) {
54
+ chunks.push(new Uint8Array([0xFF, marker]));
55
+ offset += 2;
56
+ continue;
57
+ }
58
+
59
+ // Check boundary for 16-bit big-endian length
60
+ if (offset + 3 >= u8.length) {
61
+ chunks.push(u8.subarray(offset));
62
+ break;
63
+ }
64
+
65
+ const length = (u8[offset + 2] << 8) | u8[offset + 3];
66
+ const segmentEnd = offset + 2 + length;
67
+ if (segmentEnd > u8.length) {
68
+ chunks.push(u8.subarray(offset));
69
+ break;
70
+ }
71
+
72
+ // Markers to strip:
73
+ // 0xE1 = APP1 (EXIF, XMP)
74
+ // 0xED = APP13 (Photoshop IRB, IPTC)
75
+ // 0xFE = COM (Comment)
76
+ const isExif = marker === 0xE1;
77
+ const isIptc = marker === 0xED;
78
+ const isComment = marker === 0xFE;
79
+
80
+ if (isExif || isIptc || isComment) {
81
+ stripped = true;
82
+ const segmentName = isExif ? 'APP1 (Exif/XMP)' : isIptc ? 'APP13 (IPTC)' : 'COM (Comment)';
83
+ removedSegments.push(segmentName);
84
+ offset = segmentEnd;
85
+ } else if (marker === 0xDA) { // SOS (Start of Scan) - compressed bitstream follows until EOI
86
+ chunks.push(u8.subarray(offset));
87
+ break;
88
+ } else {
89
+ // Keep segment (e.g. SOF, DHT, DQT, APP0 JFIF, etc.)
90
+ chunks.push(u8.subarray(offset, segmentEnd));
91
+ offset = segmentEnd;
92
+ }
93
+ }
94
+
95
+ if (stripped) {
96
+ let totalLen = 0;
97
+ for (const c of chunks) totalLen += c.length;
98
+ const clean = new Uint8Array(totalLen);
99
+ let cur = 0;
100
+ for (const c of chunks) {
101
+ clean.set(c, cur);
102
+ cur += c.length;
103
+ }
104
+ const isBuffer = typeof Buffer !== 'undefined' && Buffer.isBuffer(inputBuffer);
105
+ return {
106
+ cleanBuffer: isBuffer ? Buffer.from(clean.buffer, clean.byteOffset, clean.byteLength) : clean,
107
+ metadataStripped: true,
108
+ format: 'jpeg',
109
+ removedSegments,
110
+ bytesSaved: originalSize - totalLen
111
+ };
112
+ }
113
+
114
+ return {
115
+ cleanBuffer: inputBuffer,
116
+ metadataStripped: false,
117
+ format: 'jpeg',
118
+ removedSegments: [],
119
+ bytesSaved: 0
120
+ };
121
+ }
122
+
123
+ // 2. PNG Detection & Stripping (\x89PNG\r\n\x1a\n)
124
+ if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4E && u8[3] === 0x47 &&
125
+ u8[4] === 0x0D && u8[5] === 0x0A && u8[6] === 0x1A && u8[7] === 0x0A) {
126
+ const chunks = [];
127
+ chunks.push(u8.subarray(0, 8)); // Keep 8-byte PNG header
128
+ let offset = 8;
129
+ let stripped = false;
130
+
131
+ // Ancillary chunk types to strip: eXIf, tEXt, zTXt, iTXt, tIME, pHYs
132
+ const ANCILLARY_TYPES = new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME', 'pHYs']);
133
+
134
+ while (offset + 8 <= u8.length) {
135
+ const length = (u8[offset] << 24) | (u8[offset + 1] << 16) | (u8[offset + 2] << 8) | u8[offset + 3];
136
+ const type = String.fromCharCode(u8[offset + 4], u8[offset + 5], u8[offset + 6], u8[offset + 7]);
137
+ const chunkTotalLen = 12 + length; // 4 len + 4 type + data + 4 crc
138
+
139
+ if (offset + chunkTotalLen > u8.length) {
140
+ chunks.push(u8.subarray(offset));
141
+ break;
142
+ }
143
+
144
+ if (ANCILLARY_TYPES.has(type)) {
145
+ stripped = true;
146
+ removedSegments.push(`PNG_${type}`);
147
+ offset += chunkTotalLen;
148
+ } else {
149
+ chunks.push(u8.subarray(offset, offset + chunkTotalLen));
150
+ offset += chunkTotalLen;
151
+ if (type === 'IEND') break;
152
+ }
153
+ }
154
+
155
+ if (stripped) {
156
+ let totalLen = 0;
157
+ for (const c of chunks) totalLen += c.length;
158
+ const clean = new Uint8Array(totalLen);
159
+ let cur = 0;
160
+ for (const c of chunks) {
161
+ clean.set(c, cur);
162
+ cur += c.length;
163
+ }
164
+ const isBuffer = typeof Buffer !== 'undefined' && Buffer.isBuffer(inputBuffer);
165
+ return {
166
+ cleanBuffer: isBuffer ? Buffer.from(clean.buffer, clean.byteOffset, clean.byteLength) : clean,
167
+ metadataStripped: true,
168
+ format: 'png',
169
+ removedSegments,
170
+ bytesSaved: originalSize - totalLen
171
+ };
172
+ }
173
+
174
+ return {
175
+ cleanBuffer: inputBuffer,
176
+ metadataStripped: false,
177
+ format: 'png',
178
+ removedSegments: [],
179
+ bytesSaved: 0
180
+ };
181
+ }
182
+
183
+ // 3. WebP Detection & Stripping (RIFF....WEBP)
184
+ if (u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46 &&
185
+ u8[8] === 0x57 && u8[9] === 0x45 && u8[10] === 0x42 && u8[11] === 0x50) {
186
+ const chunks = [];
187
+ chunks.push(u8.subarray(0, 12)); // Keep RIFF header (length will be patched)
188
+ let offset = 12;
189
+ let stripped = false;
190
+
191
+ while (offset + 8 <= u8.length) {
192
+ const fourCC = String.fromCharCode(u8[offset], u8[offset + 1], u8[offset + 2], u8[offset + 3]);
193
+ const chunkSize = u8[offset + 4] | (u8[offset + 5] << 8) | (u8[offset + 6] << 16) | (u8[offset + 7] << 24);
194
+ const paddedSize = chunkSize + (chunkSize % 2); // WebP chunks are 2-byte aligned
195
+ const totalChunkSize = 8 + paddedSize;
196
+
197
+ if (offset + totalChunkSize > u8.length) {
198
+ chunks.push(u8.subarray(offset));
199
+ break;
200
+ }
201
+
202
+ if (fourCC === 'EXIF' || fourCC === 'XMP ') {
203
+ stripped = true;
204
+ removedSegments.push(`WEBP_${fourCC.trim()}`);
205
+ offset += totalChunkSize;
206
+ } else {
207
+ chunks.push(u8.subarray(offset, offset + totalChunkSize));
208
+ offset += totalChunkSize;
209
+ }
210
+ }
211
+
212
+ if (stripped) {
213
+ let totalLen = 0;
214
+ for (const c of chunks) totalLen += c.length;
215
+ const clean = new Uint8Array(totalLen);
216
+ let cur = 0;
217
+ for (const c of chunks) {
218
+ clean.set(c, cur);
219
+ cur += c.length;
220
+ }
221
+ // Patch RIFF file size (totalLen - 8) in little-endian at bytes 4..7
222
+ const newRiffSize = totalLen - 8;
223
+ clean[4] = newRiffSize & 0xFF;
224
+ clean[5] = (newRiffSize >> 8) & 0xFF;
225
+ clean[6] = (newRiffSize >> 16) & 0xFF;
226
+ clean[7] = (newRiffSize >> 24) & 0xFF;
227
+
228
+ const isBuffer = typeof Buffer !== 'undefined' && Buffer.isBuffer(inputBuffer);
229
+ return {
230
+ cleanBuffer: isBuffer ? Buffer.from(clean.buffer, clean.byteOffset, clean.byteLength) : clean,
231
+ metadataStripped: true,
232
+ format: 'webp',
233
+ removedSegments,
234
+ bytesSaved: originalSize - totalLen
235
+ };
236
+ }
237
+
238
+ return {
239
+ cleanBuffer: inputBuffer,
240
+ metadataStripped: false,
241
+ format: 'webp',
242
+ removedSegments: [],
243
+ bytesSaved: 0
244
+ };
245
+ }
246
+
247
+ // Unsupported or raw format
248
+ return {
249
+ cleanBuffer: inputBuffer,
250
+ metadataStripped: false,
251
+ format: 'unknown',
252
+ removedSegments: [],
253
+ bytesSaved: 0
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Redacts bounding boxes on an HTML Canvas or 2D Canvas context.
259
+ * Solid blackouts or placeholder privacy badges are drawn directly in memory.
260
+ *
261
+ * @param {HTMLCanvasElement|CanvasRenderingContext2D} canvasOrCtx
262
+ * @param {Array<{x: number, y: number, width: number, height: number, label?: string}>} boxes
263
+ * @param {Object} [options]
264
+ * @param {string} [options.fillColor='#000000'] - Fill color for the redaction box
265
+ * @param {string} [options.borderColor='#00f0ff'] - Border highlight color
266
+ * @param {boolean} [options.showLabels=true] - Whether to render synthetic token label badges
267
+ * @returns {number} Count of redacted boxes
268
+ */
269
+ export function redactImageBoundingBoxes(canvasOrCtx, boxes = [], options = {}) {
270
+ if (!canvasOrCtx || !Array.isArray(boxes) || boxes.length === 0) return 0;
271
+
272
+ const ctx = typeof canvasOrCtx.getContext === 'function'
273
+ ? canvasOrCtx.getContext('2d')
274
+ : canvasOrCtx;
275
+
276
+ if (!ctx || typeof ctx.fillRect !== 'function') return 0;
277
+
278
+ const fillColor = options.fillColor || '#000000';
279
+ const borderColor = options.borderColor || 'rgba(0, 240, 255, 0.4)';
280
+ const showLabels = options.showLabels !== false;
281
+
282
+ ctx.save();
283
+
284
+ let count = 0;
285
+ for (const box of boxes) {
286
+ if (typeof box.x !== 'number' || typeof box.y !== 'number' ||
287
+ typeof box.width !== 'number' || typeof box.height !== 'number') {
288
+ continue;
289
+ }
290
+
291
+ // 1. Draw solid blackout box
292
+ ctx.fillStyle = fillColor;
293
+ ctx.fillRect(box.x, box.y, box.width, box.height);
294
+
295
+ // 2. Draw subtle border
296
+ ctx.strokeStyle = borderColor;
297
+ ctx.lineWidth = 1.5;
298
+ ctx.strokeRect(box.x, box.y, box.width, box.height);
299
+
300
+ // 3. Render token label badge if provided
301
+ if (showLabels && box.label) {
302
+ const labelText = String(box.label);
303
+ ctx.font = 'bold 11px monospace';
304
+ const textMetrics = ctx.measureText(labelText);
305
+ const badgeWidth = textMetrics.width + 10;
306
+ const badgeHeight = 16;
307
+ const badgeX = box.x + 2;
308
+ const badgeY = box.y + 2;
309
+
310
+ ctx.fillStyle = 'rgba(10, 15, 29, 0.85)';
311
+ ctx.fillRect(badgeX, badgeY, badgeWidth, badgeHeight);
312
+
313
+ ctx.fillStyle = '#00f0ff';
314
+ ctx.fillText(labelText, badgeX + 5, badgeY + 12);
315
+ }
316
+
317
+ count++;
318
+ }
319
+
320
+ ctx.restore();
321
+ return count;
322
+ }
323
+
324
+ /**
325
+ * Generates an air-gapped SVG redaction mask overlay for headless environments.
326
+ *
327
+ * @param {{ width: number, height: number }} dimensions
328
+ * @param {Array<{x: number, y: number, width: number, height: number, label?: string}>} boxes
329
+ * @param {Object} [options]
330
+ * @returns {string} Standalone SVG markup
331
+ */
332
+ export function generateSvgRedactionOverlay(dimensions = { width: 800, height: 600 }, boxes = [], options = {}) {
333
+ const width = dimensions.width || 800;
334
+ const height = dimensions.height || 600;
335
+ const fillColor = options.fillColor || '#000000';
336
+ const strokeColor = options.borderColor || '#00f0ff';
337
+
338
+ const rects = (boxes || []).map((b, idx) => {
339
+ const label = b.label ? `<text x="${b.x + 5}" y="${b.y + 14}" font-family="monospace" font-size="11" font-weight="bold" fill="#00f0ff">${escapeXml(b.label)}</text>` : '';
340
+ return `
341
+ <g class="ps-vision-mask" id="mask-${idx}">
342
+ <rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" fill="${fillColor}" stroke="${strokeColor}" stroke-width="1.5" />
343
+ ${label}
344
+ </g>`;
345
+ }).join('');
346
+
347
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
348
+ ${rects}
349
+ </svg>`;
350
+ }
351
+
352
+ function escapeXml(unsafe) {
353
+ return String(unsafe).replace(/[<>&'"]/g, c => {
354
+ switch (c) {
355
+ case '<': return '&lt;';
356
+ case '>': return '&gt;';
357
+ case '&': return '&amp;';
358
+ case '\'': return '&apos;';
359
+ case '"': return '&quot;';
360
+ }
361
+ });
362
+ }