@pacamelo/core 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/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/index.d.mts +1669 -0
- package/dist/index.d.ts +1669 -0
- package/dist/index.js +4999 -0
- package/dist/index.mjs +4966 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4999 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
BaseProcessor: () => BaseProcessor,
|
|
34
|
+
OfflineQuotaStore: () => OfflineQuotaStore,
|
|
35
|
+
PLATFORM_INSTRUCTIONS: () => PLATFORM_INSTRUCTIONS,
|
|
36
|
+
QuotaExhaustedError: () => QuotaExhaustedError,
|
|
37
|
+
QuotaExhaustedErrorClass: () => QuotaExhaustedError,
|
|
38
|
+
RegexDetectionEngine: () => RegexDetectionEngine,
|
|
39
|
+
XlsxProcessor: () => XlsxProcessor,
|
|
40
|
+
adversarialVerifier: () => adversarialVerifier,
|
|
41
|
+
categories: () => categories,
|
|
42
|
+
categoryMap: () => categoryMap,
|
|
43
|
+
detectPlatform: () => detectPlatform2,
|
|
44
|
+
downloadFile: () => downloadFile,
|
|
45
|
+
downloadFilesAsZip: () => downloadFilesAsZip,
|
|
46
|
+
downloadSessionSummary: () => downloadSessionSummary,
|
|
47
|
+
generateDeviceFingerprint: () => generateDeviceFingerprint,
|
|
48
|
+
generatePrefixedId: () => generatePrefixedId,
|
|
49
|
+
generateSectionId: () => generateSectionId,
|
|
50
|
+
generateSecureId: () => generateSecureId,
|
|
51
|
+
generateSessionId: () => generateSessionId,
|
|
52
|
+
generateSessionSummaryPDF: () => generateSessionSummaryPDF,
|
|
53
|
+
getCategoryIcon: () => getCategoryIcon,
|
|
54
|
+
getCategoryIconFromCategories: () => getCategoryIcon2,
|
|
55
|
+
getCategoryLabel: () => getCategoryLabel,
|
|
56
|
+
getCategoryName: () => getCategoryName,
|
|
57
|
+
getFileTypeFromExtension: () => getFileTypeFromExtension,
|
|
58
|
+
getOptimalGridSize: () => getOptimalGridSize,
|
|
59
|
+
getPartialMask: () => getPartialMask,
|
|
60
|
+
getPatternsForCategories: () => getPatternsForCategories,
|
|
61
|
+
getProcessor: () => getProcessor,
|
|
62
|
+
getProcessorForFile: () => getProcessorForFile,
|
|
63
|
+
getSensitivityThreshold: () => getSensitivityThreshold,
|
|
64
|
+
hasValidExtension: () => hasValidExtension,
|
|
65
|
+
hilbertD2XY: () => hilbertD2XY,
|
|
66
|
+
hilbertXY2D: () => hilbertXY2D,
|
|
67
|
+
maskPII: () => maskPII,
|
|
68
|
+
offlineQuotaStore: () => offlineQuotaStore,
|
|
69
|
+
regexDetectionEngine: () => regexDetectionEngine,
|
|
70
|
+
requestQuotaRefresh: () => requestQuotaRefresh,
|
|
71
|
+
safeCompileRegex: () => safeCompileRegex,
|
|
72
|
+
safeFilename: () => safeFilename,
|
|
73
|
+
secureError: () => secureError,
|
|
74
|
+
secureLog: () => secureLog,
|
|
75
|
+
secureWarn: () => secureWarn,
|
|
76
|
+
truncateHash: () => truncateHash,
|
|
77
|
+
useAdversarialAnalysis: () => useAdversarialAnalysis,
|
|
78
|
+
useAirplaneMode: () => useAirplaneMode,
|
|
79
|
+
useDocumentProcessor: () => useDocumentProcessor,
|
|
80
|
+
useEnhancedNetworkProof: () => useEnhancedNetworkProof,
|
|
81
|
+
useFileEntropy: () => useFileEntropy,
|
|
82
|
+
useFileHash: () => useFileHash,
|
|
83
|
+
useFirstTimeUser: () => useFirstTimeUser,
|
|
84
|
+
useNetworkProof: () => useNetworkProof,
|
|
85
|
+
useOfflineEnforcement: () => useOfflineEnforcement,
|
|
86
|
+
useOfflineQuota: () => useOfflineQuota,
|
|
87
|
+
usePurgeStore: () => usePurgeStore,
|
|
88
|
+
useSpreadsheetMetadata: () => useSpreadsheetMetadata,
|
|
89
|
+
useStorageProof: () => useStorageProof,
|
|
90
|
+
validateFileType: () => validateFileType,
|
|
91
|
+
verifyDeviceFingerprint: () => verifyDeviceFingerprint,
|
|
92
|
+
xlsxProcessor: () => xlsxProcessor
|
|
93
|
+
});
|
|
94
|
+
module.exports = __toCommonJS(index_exports);
|
|
95
|
+
|
|
96
|
+
// src/store/usePurgeStore.ts
|
|
97
|
+
var import_zustand = require("zustand");
|
|
98
|
+
var getSensitivityThreshold = (sensitivity) => {
|
|
99
|
+
switch (sensitivity) {
|
|
100
|
+
case "low":
|
|
101
|
+
return 0.9;
|
|
102
|
+
case "medium":
|
|
103
|
+
return 0.7;
|
|
104
|
+
case "high":
|
|
105
|
+
return 0.5;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var defaultConfig = {
|
|
109
|
+
categories: {
|
|
110
|
+
person_name: true,
|
|
111
|
+
email: true,
|
|
112
|
+
phone: true,
|
|
113
|
+
address: true,
|
|
114
|
+
ssn: true,
|
|
115
|
+
credit_card: true,
|
|
116
|
+
ip_address: false,
|
|
117
|
+
date_of_birth: true,
|
|
118
|
+
custom: false
|
|
119
|
+
},
|
|
120
|
+
redactionStyle: "replacement",
|
|
121
|
+
replacementText: "[REDACTED]",
|
|
122
|
+
customPatterns: [],
|
|
123
|
+
sensitivity: "medium"
|
|
124
|
+
};
|
|
125
|
+
var defaultAdversarialConfig = {
|
|
126
|
+
enabled: true,
|
|
127
|
+
riskThreshold: 30,
|
|
128
|
+
maxIterations: 3,
|
|
129
|
+
autoApplyLowRisk: false,
|
|
130
|
+
analysisDepth: "standard",
|
|
131
|
+
enabledAnalyses: {
|
|
132
|
+
attributeLeakage: true,
|
|
133
|
+
semanticFingerprinting: true,
|
|
134
|
+
crossReferenceCheck: true
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
var initialState = {
|
|
138
|
+
state: "idle",
|
|
139
|
+
queuedFiles: [],
|
|
140
|
+
processedFiles: [],
|
|
141
|
+
detections: [],
|
|
142
|
+
selectedDetections: /* @__PURE__ */ new Set(),
|
|
143
|
+
config: defaultConfig,
|
|
144
|
+
progress: null,
|
|
145
|
+
// Column selection
|
|
146
|
+
columnConfig: null,
|
|
147
|
+
spreadsheetMetadata: null,
|
|
148
|
+
columnAccessProof: null,
|
|
149
|
+
// Adversarial verification
|
|
150
|
+
adversarialResult: null,
|
|
151
|
+
adversarialConfig: defaultAdversarialConfig,
|
|
152
|
+
isAnalyzingAdversarial: false,
|
|
153
|
+
// Privacy mode
|
|
154
|
+
paranoidMode: false
|
|
155
|
+
};
|
|
156
|
+
var usePurgeStore = (0, import_zustand.create)()((set) => ({
|
|
157
|
+
...initialState,
|
|
158
|
+
// State management
|
|
159
|
+
setState: (state) => set({ state }),
|
|
160
|
+
// File management
|
|
161
|
+
feedDocuments: (files) => set((s) => ({
|
|
162
|
+
queuedFiles: [...s.queuedFiles, ...files],
|
|
163
|
+
state: "loaded"
|
|
164
|
+
})),
|
|
165
|
+
removeDocument: (id) => set((s) => {
|
|
166
|
+
const queuedFiles = s.queuedFiles.filter((f) => f.id !== id);
|
|
167
|
+
return {
|
|
168
|
+
queuedFiles,
|
|
169
|
+
state: queuedFiles.length === 0 ? "idle" : s.state
|
|
170
|
+
};
|
|
171
|
+
}),
|
|
172
|
+
updateFileStatus: (id, status, error) => set((s) => ({
|
|
173
|
+
queuedFiles: s.queuedFiles.map(
|
|
174
|
+
(f) => f.id === id ? { ...f, status, error } : f
|
|
175
|
+
)
|
|
176
|
+
})),
|
|
177
|
+
addProcessedFile: (file) => set((s) => ({
|
|
178
|
+
processedFiles: [...s.processedFiles, file]
|
|
179
|
+
})),
|
|
180
|
+
clearProcessedFiles: () => set({ processedFiles: [] }),
|
|
181
|
+
// Detection management
|
|
182
|
+
setDetections: (detections) => set({
|
|
183
|
+
detections,
|
|
184
|
+
selectedDetections: new Set(detections.map((d) => d.id))
|
|
185
|
+
}),
|
|
186
|
+
toggleDetection: (id) => set((s) => {
|
|
187
|
+
const selected = new Set(s.selectedDetections);
|
|
188
|
+
if (selected.has(id)) {
|
|
189
|
+
selected.delete(id);
|
|
190
|
+
} else {
|
|
191
|
+
selected.add(id);
|
|
192
|
+
}
|
|
193
|
+
return { selectedDetections: selected };
|
|
194
|
+
}),
|
|
195
|
+
selectAllDetections: (visibleIds) => set((s) => ({
|
|
196
|
+
selectedDetections: new Set(
|
|
197
|
+
visibleIds ?? s.detections.map((d) => d.id)
|
|
198
|
+
)
|
|
199
|
+
})),
|
|
200
|
+
deselectAllDetections: () => set({ selectedDetections: /* @__PURE__ */ new Set() }),
|
|
201
|
+
selectDetectionsByCategory: (category, selected) => set((s) => {
|
|
202
|
+
const newSelected = new Set(s.selectedDetections);
|
|
203
|
+
s.detections.filter((d) => d.category === category).forEach((d) => {
|
|
204
|
+
if (selected) {
|
|
205
|
+
newSelected.add(d.id);
|
|
206
|
+
} else {
|
|
207
|
+
newSelected.delete(d.id);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
return { selectedDetections: newSelected };
|
|
211
|
+
}),
|
|
212
|
+
/**
|
|
213
|
+
* M-5 SECURITY FIX: Clear detections entirely after processing.
|
|
214
|
+
* Previously just masked values with '[CLEARED]' which left PII in memory.
|
|
215
|
+
* Now removes all detection data completely.
|
|
216
|
+
*/
|
|
217
|
+
clearDetectionValues: () => set({
|
|
218
|
+
detections: [],
|
|
219
|
+
selectedDetections: /* @__PURE__ */ new Set()
|
|
220
|
+
}),
|
|
221
|
+
/**
|
|
222
|
+
* M-6 SECURITY FIX: Aggressively clear all sensitive data.
|
|
223
|
+
* Completely removes detections rather than just replacing values.
|
|
224
|
+
* Also clears queued files and spreadsheet metadata.
|
|
225
|
+
*/
|
|
226
|
+
secureClear: () => set({
|
|
227
|
+
detections: [],
|
|
228
|
+
selectedDetections: /* @__PURE__ */ new Set(),
|
|
229
|
+
queuedFiles: [],
|
|
230
|
+
spreadsheetMetadata: null,
|
|
231
|
+
columnConfig: null,
|
|
232
|
+
columnAccessProof: null
|
|
233
|
+
}),
|
|
234
|
+
// Configuration management
|
|
235
|
+
updateConfig: (config) => set((s) => ({
|
|
236
|
+
config: { ...s.config, ...config }
|
|
237
|
+
})),
|
|
238
|
+
toggleCategory: (category) => set((s) => ({
|
|
239
|
+
config: {
|
|
240
|
+
...s.config,
|
|
241
|
+
categories: {
|
|
242
|
+
...s.config.categories,
|
|
243
|
+
[category]: !s.config.categories[category]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
})),
|
|
247
|
+
setRedactionStyle: (style) => set((s) => ({
|
|
248
|
+
config: { ...s.config, redactionStyle: style }
|
|
249
|
+
})),
|
|
250
|
+
// Processing management
|
|
251
|
+
setProgress: (progress) => set({ progress }),
|
|
252
|
+
// Column selection management
|
|
253
|
+
setColumnConfig: (columnConfig) => set({ columnConfig }),
|
|
254
|
+
setSpreadsheetMetadata: (spreadsheetMetadata) => set({ spreadsheetMetadata }),
|
|
255
|
+
setColumnAccessProof: (columnAccessProof) => set({ columnAccessProof }),
|
|
256
|
+
// Adversarial verification management
|
|
257
|
+
setAdversarialResult: (adversarialResult) => set({ adversarialResult }),
|
|
258
|
+
setAdversarialConfig: (config) => set((s) => ({
|
|
259
|
+
adversarialConfig: { ...s.adversarialConfig, ...config }
|
|
260
|
+
})),
|
|
261
|
+
setIsAnalyzingAdversarial: (isAnalyzingAdversarial) => set({ isAnalyzingAdversarial }),
|
|
262
|
+
applySuggestion: (suggestion) => set((s) => {
|
|
263
|
+
if (!s.adversarialResult) return s;
|
|
264
|
+
const updatedSuggestions = s.adversarialResult.suggestions.map(
|
|
265
|
+
(sug) => sug.id === suggestion.id ? { ...sug, accepted: true } : sug
|
|
266
|
+
);
|
|
267
|
+
return {
|
|
268
|
+
adversarialResult: {
|
|
269
|
+
...s.adversarialResult,
|
|
270
|
+
suggestions: updatedSuggestions
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
clearAdversarialState: () => set({
|
|
275
|
+
adversarialResult: null,
|
|
276
|
+
isAnalyzingAdversarial: false
|
|
277
|
+
}),
|
|
278
|
+
// Privacy mode
|
|
279
|
+
setParanoidMode: (paranoidMode) => set({ paranoidMode }),
|
|
280
|
+
// Full reset
|
|
281
|
+
reset: () => set({
|
|
282
|
+
...initialState,
|
|
283
|
+
selectedDetections: /* @__PURE__ */ new Set(),
|
|
284
|
+
columnConfig: null,
|
|
285
|
+
spreadsheetMetadata: null,
|
|
286
|
+
columnAccessProof: null
|
|
287
|
+
}),
|
|
288
|
+
// Soft reset - preserve config for multi-run workflow
|
|
289
|
+
softReset: () => set((s) => ({
|
|
290
|
+
state: "idle",
|
|
291
|
+
queuedFiles: [],
|
|
292
|
+
processedFiles: [],
|
|
293
|
+
detections: [],
|
|
294
|
+
selectedDetections: /* @__PURE__ */ new Set(),
|
|
295
|
+
progress: null,
|
|
296
|
+
columnConfig: null,
|
|
297
|
+
spreadsheetMetadata: null,
|
|
298
|
+
columnAccessProof: null,
|
|
299
|
+
adversarialResult: null,
|
|
300
|
+
isAnalyzingAdversarial: false,
|
|
301
|
+
// Preserve config settings between runs
|
|
302
|
+
config: s.config,
|
|
303
|
+
adversarialConfig: s.adversarialConfig
|
|
304
|
+
}))
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
// src/services/detection/patterns.ts
|
|
308
|
+
var emailPattern = {
|
|
309
|
+
category: "email",
|
|
310
|
+
pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
|
311
|
+
priority: 100
|
|
312
|
+
};
|
|
313
|
+
var phonePattern = {
|
|
314
|
+
category: "phone",
|
|
315
|
+
pattern: /(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}/g,
|
|
316
|
+
priority: 90,
|
|
317
|
+
validator: (match) => {
|
|
318
|
+
const digits = match.replace(/\D/g, "");
|
|
319
|
+
return digits.length >= 10 && digits.length <= 11;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
var ssnPattern = {
|
|
323
|
+
category: "ssn",
|
|
324
|
+
pattern: /\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b/g,
|
|
325
|
+
priority: 100,
|
|
326
|
+
validator: (match) => {
|
|
327
|
+
const digits = match.replace(/\D/g, "");
|
|
328
|
+
if (digits.length !== 9) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
const area = digits.slice(0, 3);
|
|
332
|
+
const group = digits.slice(3, 5);
|
|
333
|
+
const serial = digits.slice(5, 9);
|
|
334
|
+
if (area === "000") {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
if (area === "666") {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (area >= "900") {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
if (group === "00") {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
if (serial === "0000") {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
if (area === "987" && group === "65") {
|
|
350
|
+
const serialNum = parseInt(serial, 10);
|
|
351
|
+
if (serialNum >= 4320 && serialNum <= 4329) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (digits === "078051120") {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
var creditCardPattern = {
|
|
362
|
+
category: "credit_card",
|
|
363
|
+
pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b|\b\d{15,16}\b/g,
|
|
364
|
+
priority: 95,
|
|
365
|
+
validator: (match) => {
|
|
366
|
+
const digits = match.replace(/\D/g, "");
|
|
367
|
+
if (digits.length < 15 || digits.length > 16) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
return luhnCheck(digits);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
var ipAddressPattern = {
|
|
374
|
+
category: "ip_address",
|
|
375
|
+
pattern: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g,
|
|
376
|
+
priority: 80
|
|
377
|
+
};
|
|
378
|
+
var dateOfBirthPattern = {
|
|
379
|
+
category: "date_of_birth",
|
|
380
|
+
pattern: /\b(?:0?[1-9]|1[0-2])[-/](?:0?[1-9]|[12][0-9]|3[01])[-/](?:19|20)\d{2}\b|\b(?:19|20)\d{2}[-/](?:0?[1-9]|1[0-2])[-/](?:0?[1-9]|[12][0-9]|3[01])\b/g,
|
|
381
|
+
priority: 70
|
|
382
|
+
};
|
|
383
|
+
var addressPattern = {
|
|
384
|
+
category: "address",
|
|
385
|
+
pattern: /\b\d{1,5}\s+[A-Za-z]+(?:\s+[A-Za-z]+){0,3}\s+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Circle|Cir|Place|Pl)\.?\b/gi,
|
|
386
|
+
priority: 60
|
|
387
|
+
};
|
|
388
|
+
var personNameWithTitlePattern = {
|
|
389
|
+
category: "person_name",
|
|
390
|
+
pattern: /\b(?:Mr|Mrs|Ms|Miss|Dr|Prof)\.?\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b/g,
|
|
391
|
+
priority: 50
|
|
392
|
+
};
|
|
393
|
+
var personNameBroadPattern = {
|
|
394
|
+
category: "person_name",
|
|
395
|
+
pattern: /\b[A-Z][a-z]{2,15}\s+[A-Z][a-z]{2,20}\b/g,
|
|
396
|
+
priority: 30,
|
|
397
|
+
validator: (match) => {
|
|
398
|
+
const commonPhrases = [
|
|
399
|
+
// Place names
|
|
400
|
+
"New York",
|
|
401
|
+
"Los Angeles",
|
|
402
|
+
"San Francisco",
|
|
403
|
+
"San Diego",
|
|
404
|
+
"Las Vegas",
|
|
405
|
+
"United States",
|
|
406
|
+
"United Kingdom",
|
|
407
|
+
"North America",
|
|
408
|
+
"South America",
|
|
409
|
+
"North Carolina",
|
|
410
|
+
"South Carolina",
|
|
411
|
+
"New Jersey",
|
|
412
|
+
"New Mexico",
|
|
413
|
+
"New Hampshire",
|
|
414
|
+
"New Zealand",
|
|
415
|
+
// Company names
|
|
416
|
+
"General Electric",
|
|
417
|
+
"American Express",
|
|
418
|
+
// Common column headers / field labels (spreadsheet noise)
|
|
419
|
+
"Full Name",
|
|
420
|
+
"First Name",
|
|
421
|
+
"Last Name",
|
|
422
|
+
"Middle Name",
|
|
423
|
+
"Given Name",
|
|
424
|
+
"Family Name",
|
|
425
|
+
"Company Name",
|
|
426
|
+
"Business Name",
|
|
427
|
+
"Account Name",
|
|
428
|
+
"Customer Name",
|
|
429
|
+
"Employee Name",
|
|
430
|
+
"Contact Name",
|
|
431
|
+
"Display Name",
|
|
432
|
+
"Legal Name",
|
|
433
|
+
"Billing Name",
|
|
434
|
+
"Shipping Name",
|
|
435
|
+
"Primary Contact",
|
|
436
|
+
"Emergency Contact",
|
|
437
|
+
"Phone Number",
|
|
438
|
+
"Email Address",
|
|
439
|
+
"Street Address",
|
|
440
|
+
"Mailing Address",
|
|
441
|
+
"Billing Address",
|
|
442
|
+
"Shipping Address",
|
|
443
|
+
"Home Address",
|
|
444
|
+
"Work Address",
|
|
445
|
+
"Office Address",
|
|
446
|
+
"Physical Address",
|
|
447
|
+
"Postal Code",
|
|
448
|
+
"Zip Code",
|
|
449
|
+
"Area Code",
|
|
450
|
+
"Country Code",
|
|
451
|
+
"Report Date",
|
|
452
|
+
"Start Date",
|
|
453
|
+
"End Date",
|
|
454
|
+
"Due Date",
|
|
455
|
+
"Birth Date",
|
|
456
|
+
"Hire Date",
|
|
457
|
+
"Created Date",
|
|
458
|
+
"Modified Date",
|
|
459
|
+
"Last Modified",
|
|
460
|
+
"Date Created",
|
|
461
|
+
"Customer Service",
|
|
462
|
+
"Technical Support",
|
|
463
|
+
"Human Resources",
|
|
464
|
+
"Account Number",
|
|
465
|
+
"Reference Number",
|
|
466
|
+
"Order Number",
|
|
467
|
+
"Invoice Number",
|
|
468
|
+
"Serial Number",
|
|
469
|
+
"Tracking Number",
|
|
470
|
+
"Case Number",
|
|
471
|
+
"Report Number",
|
|
472
|
+
"Total Amount",
|
|
473
|
+
"Grand Total",
|
|
474
|
+
"Net Amount",
|
|
475
|
+
"Tax Amount"
|
|
476
|
+
];
|
|
477
|
+
return !commonPhrases.includes(match);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
var intlPhonePattern = {
|
|
481
|
+
category: "phone",
|
|
482
|
+
pattern: /\+[1-9]\d{6,14}\b/g,
|
|
483
|
+
priority: 85
|
|
484
|
+
};
|
|
485
|
+
var ukPostcodePattern = {
|
|
486
|
+
category: "address",
|
|
487
|
+
pattern: /\b[A-Z]{1,2}\d{1,2}[A-Z]?\s*\d[A-Z]{2}\b/gi,
|
|
488
|
+
priority: 70
|
|
489
|
+
};
|
|
490
|
+
var patterns = [
|
|
491
|
+
emailPattern,
|
|
492
|
+
ssnPattern,
|
|
493
|
+
creditCardPattern,
|
|
494
|
+
phonePattern,
|
|
495
|
+
intlPhonePattern,
|
|
496
|
+
ipAddressPattern,
|
|
497
|
+
ukPostcodePattern,
|
|
498
|
+
dateOfBirthPattern,
|
|
499
|
+
addressPattern,
|
|
500
|
+
personNameWithTitlePattern,
|
|
501
|
+
personNameBroadPattern
|
|
502
|
+
].sort((a, b) => b.priority - a.priority);
|
|
503
|
+
var patternSetCache = /* @__PURE__ */ new Map();
|
|
504
|
+
function getPatternsForCategories(enabledCategories) {
|
|
505
|
+
const key = [...enabledCategories].sort().join("|");
|
|
506
|
+
let cached = patternSetCache.get(key);
|
|
507
|
+
if (cached) {
|
|
508
|
+
return cached;
|
|
509
|
+
}
|
|
510
|
+
cached = patterns.filter((p) => enabledCategories.includes(p.category));
|
|
511
|
+
patternSetCache.set(key, cached);
|
|
512
|
+
return cached;
|
|
513
|
+
}
|
|
514
|
+
function luhnCheck(cardNumber) {
|
|
515
|
+
let sum = 0;
|
|
516
|
+
let isEven = false;
|
|
517
|
+
for (let i = cardNumber.length - 1; i >= 0; i--) {
|
|
518
|
+
let digit = parseInt(cardNumber[i], 10);
|
|
519
|
+
if (isEven) {
|
|
520
|
+
digit *= 2;
|
|
521
|
+
if (digit > 9) {
|
|
522
|
+
digit -= 9;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
sum += digit;
|
|
526
|
+
isEven = !isEven;
|
|
527
|
+
}
|
|
528
|
+
return sum % 10 === 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/utils/secureRandom.ts
|
|
532
|
+
function generateSecureId() {
|
|
533
|
+
const array = new Uint8Array(12);
|
|
534
|
+
crypto.getRandomValues(array);
|
|
535
|
+
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
536
|
+
}
|
|
537
|
+
function generatePrefixedId(prefix) {
|
|
538
|
+
return `${prefix}-${generateSecureId()}`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// src/utils/validateRegex.ts
|
|
542
|
+
var MAX_PATTERN_LENGTH = 500;
|
|
543
|
+
var DANGEROUS_PATTERNS = [
|
|
544
|
+
// Nested quantifiers: (a+)+ or (a*)*
|
|
545
|
+
/\([^)]*[+*]\)[+*]/,
|
|
546
|
+
// Overlapping alternation with quantifiers: (a|a)+
|
|
547
|
+
/\([^)]*\|[^)]*\)[+*]/,
|
|
548
|
+
// Quantifier on quantified group: (?:...){n}{m}
|
|
549
|
+
/\{[0-9,]+\}\s*\{/,
|
|
550
|
+
// (.*)+ or (.*)* - catastrophic backtracking
|
|
551
|
+
/\(\.\*\)[+*]/,
|
|
552
|
+
// (.+)+ or (.+)* - catastrophic backtracking
|
|
553
|
+
/\(\.\+\)[+*]/,
|
|
554
|
+
// Nested groups with + or * on inner and outer
|
|
555
|
+
/\([^)]*\([^)]*[+*][^)]*\)[^)]*\)[+*]/
|
|
556
|
+
];
|
|
557
|
+
function validateRegex(pattern) {
|
|
558
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
559
|
+
return {
|
|
560
|
+
valid: false,
|
|
561
|
+
error: `Pattern too long (max ${MAX_PATTERN_LENGTH} characters, got ${pattern.length})`
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
if (pattern.trim().length === 0) {
|
|
565
|
+
return {
|
|
566
|
+
valid: false,
|
|
567
|
+
error: "Pattern cannot be empty"
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
for (const dangerous of DANGEROUS_PATTERNS) {
|
|
571
|
+
if (dangerous.test(pattern)) {
|
|
572
|
+
return {
|
|
573
|
+
valid: false,
|
|
574
|
+
error: "Pattern contains constructs that could cause slow matching. Avoid nested quantifiers like (a+)+ or (.*)+"
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
new RegExp(pattern);
|
|
580
|
+
} catch (e) {
|
|
581
|
+
const message = e instanceof Error ? e.message : "Unknown error";
|
|
582
|
+
return {
|
|
583
|
+
valid: false,
|
|
584
|
+
error: `Invalid regex syntax: ${message}`
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
return { valid: true };
|
|
588
|
+
}
|
|
589
|
+
function safeCompileRegex(pattern, flags = "g") {
|
|
590
|
+
const validation = validateRegex(pattern);
|
|
591
|
+
if (!validation.valid) {
|
|
592
|
+
return { regex: null, error: validation.error };
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
return { regex: new RegExp(pattern, flags) };
|
|
596
|
+
} catch (e) {
|
|
597
|
+
const message = e instanceof Error ? e.message : "Unknown error";
|
|
598
|
+
return { regex: null, error: `Failed to compile regex: ${message}` };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/utils/secureLogger.ts
|
|
603
|
+
function sanitizeError(error) {
|
|
604
|
+
if (error instanceof Error) {
|
|
605
|
+
const message = error.message.split("\n")[0].slice(0, 100);
|
|
606
|
+
return {
|
|
607
|
+
type: error.constructor.name,
|
|
608
|
+
message
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
if (typeof error === "string") {
|
|
612
|
+
return {
|
|
613
|
+
type: "String",
|
|
614
|
+
message: error.slice(0, 100)
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
type: "Unknown",
|
|
619
|
+
message: "An error occurred"
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
function secureWarn(context, error) {
|
|
623
|
+
if (error !== void 0) {
|
|
624
|
+
const safe = sanitizeError(error);
|
|
625
|
+
console.warn(`PURGE: ${context} [${safe.type}]: ${safe.message}`);
|
|
626
|
+
} else {
|
|
627
|
+
console.warn(`PURGE: ${context}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function secureError(context, error) {
|
|
631
|
+
if (error !== void 0) {
|
|
632
|
+
const safe = sanitizeError(error);
|
|
633
|
+
console.error(`PURGE: ${context} [${safe.type}]: ${safe.message}`);
|
|
634
|
+
} else {
|
|
635
|
+
console.error(`PURGE: ${context}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function secureLog(message) {
|
|
639
|
+
console.log(`PURGE: ${message}`);
|
|
640
|
+
}
|
|
641
|
+
function safeFilename(filename) {
|
|
642
|
+
const name = filename.split("/").pop() || filename;
|
|
643
|
+
return name.slice(0, 50);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// src/workers/regexWorker.ts
|
|
647
|
+
var regexWorkerCode = `
|
|
648
|
+
self.onmessage = function(e) {
|
|
649
|
+
const { id, type, pattern, flags, text } = e.data;
|
|
650
|
+
|
|
651
|
+
if (type !== 'matchAll') {
|
|
652
|
+
self.postMessage({ id, success: false, error: 'Unknown message type' });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
const regex = new RegExp(pattern, flags);
|
|
658
|
+
const matches = [];
|
|
659
|
+
|
|
660
|
+
// Reset lastIndex for global patterns
|
|
661
|
+
regex.lastIndex = 0;
|
|
662
|
+
|
|
663
|
+
let match;
|
|
664
|
+
let maxMatches = 10000; // Safety limit to prevent infinite loops
|
|
665
|
+
let count = 0;
|
|
666
|
+
|
|
667
|
+
while ((match = regex.exec(text)) !== null && count < maxMatches) {
|
|
668
|
+
matches.push({
|
|
669
|
+
match: match[0],
|
|
670
|
+
index: match.index,
|
|
671
|
+
groups: match.groups || undefined
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Prevent infinite loops for zero-length matches
|
|
675
|
+
if (match[0].length === 0) {
|
|
676
|
+
regex.lastIndex++;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
count++;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
self.postMessage({ id, success: true, matches });
|
|
683
|
+
} catch (error) {
|
|
684
|
+
self.postMessage({
|
|
685
|
+
id,
|
|
686
|
+
success: false,
|
|
687
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
`;
|
|
692
|
+
function createRegexWorkerUrl() {
|
|
693
|
+
const blob = new Blob([regexWorkerCode], { type: "application/javascript" });
|
|
694
|
+
return URL.createObjectURL(blob);
|
|
695
|
+
}
|
|
696
|
+
async function executeRegexInWorker(pattern, text, timeoutMs = 100) {
|
|
697
|
+
return new Promise((resolve) => {
|
|
698
|
+
let worker = null;
|
|
699
|
+
let timeoutId = null;
|
|
700
|
+
let workerUrl = null;
|
|
701
|
+
let resolved = false;
|
|
702
|
+
const cleanup = () => {
|
|
703
|
+
if (timeoutId) {
|
|
704
|
+
clearTimeout(timeoutId);
|
|
705
|
+
timeoutId = null;
|
|
706
|
+
}
|
|
707
|
+
if (worker) {
|
|
708
|
+
worker.terminate();
|
|
709
|
+
worker = null;
|
|
710
|
+
}
|
|
711
|
+
if (workerUrl) {
|
|
712
|
+
URL.revokeObjectURL(workerUrl);
|
|
713
|
+
workerUrl = null;
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
const resolveOnce = (result) => {
|
|
717
|
+
if (!resolved) {
|
|
718
|
+
resolved = true;
|
|
719
|
+
cleanup();
|
|
720
|
+
resolve(result);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
try {
|
|
724
|
+
workerUrl = createRegexWorkerUrl();
|
|
725
|
+
worker = new Worker(workerUrl);
|
|
726
|
+
const messageId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36);
|
|
727
|
+
timeoutId = setTimeout(() => {
|
|
728
|
+
resolveOnce([]);
|
|
729
|
+
}, timeoutMs);
|
|
730
|
+
worker.onmessage = (e) => {
|
|
731
|
+
if (e.data.id !== messageId) return;
|
|
732
|
+
if (e.data.success && e.data.matches) {
|
|
733
|
+
const results = e.data.matches.map((m) => {
|
|
734
|
+
const arr = [m.match];
|
|
735
|
+
arr.index = m.index;
|
|
736
|
+
arr.input = text;
|
|
737
|
+
arr.groups = m.groups;
|
|
738
|
+
return arr;
|
|
739
|
+
});
|
|
740
|
+
resolveOnce(results);
|
|
741
|
+
} else {
|
|
742
|
+
resolveOnce([]);
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
worker.onerror = () => {
|
|
746
|
+
resolveOnce([]);
|
|
747
|
+
};
|
|
748
|
+
const message = {
|
|
749
|
+
id: messageId,
|
|
750
|
+
type: "matchAll",
|
|
751
|
+
pattern: pattern.source,
|
|
752
|
+
flags: pattern.flags,
|
|
753
|
+
text
|
|
754
|
+
};
|
|
755
|
+
worker.postMessage(message);
|
|
756
|
+
} catch {
|
|
757
|
+
resolveOnce([]);
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/services/detection/RegexDetectionEngine.ts
|
|
763
|
+
var REGEX_TIMEOUT_MS = 100;
|
|
764
|
+
var USE_WORKER_REGEX = typeof Worker !== "undefined";
|
|
765
|
+
function generateId() {
|
|
766
|
+
return generatePrefixedId("det");
|
|
767
|
+
}
|
|
768
|
+
async function safeRegexMatchAll(pattern, text, timeoutMs = REGEX_TIMEOUT_MS) {
|
|
769
|
+
if (USE_WORKER_REGEX) {
|
|
770
|
+
try {
|
|
771
|
+
return await executeRegexInWorker(pattern, text, timeoutMs);
|
|
772
|
+
} catch {
|
|
773
|
+
secureWarn("Worker regex failed, falling back to synchronous");
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return new Promise((resolve) => {
|
|
777
|
+
const timeout = setTimeout(() => {
|
|
778
|
+
secureWarn(`Regex pattern timed out after ${timeoutMs}ms (fallback mode)`);
|
|
779
|
+
resolve([]);
|
|
780
|
+
}, timeoutMs);
|
|
781
|
+
try {
|
|
782
|
+
pattern.lastIndex = 0;
|
|
783
|
+
const matches = Array.from(text.matchAll(pattern));
|
|
784
|
+
clearTimeout(timeout);
|
|
785
|
+
resolve(matches);
|
|
786
|
+
} catch (e) {
|
|
787
|
+
clearTimeout(timeout);
|
|
788
|
+
secureWarn("Regex execution error", e);
|
|
789
|
+
resolve([]);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
function extractContext(text, startOffset, endOffset, contextChars = 25) {
|
|
794
|
+
const start = Math.max(0, startOffset - contextChars);
|
|
795
|
+
const end = Math.min(text.length, endOffset + contextChars);
|
|
796
|
+
let context = "";
|
|
797
|
+
if (start > 0) {
|
|
798
|
+
context += "...";
|
|
799
|
+
}
|
|
800
|
+
context += text.slice(start, end);
|
|
801
|
+
if (end < text.length) {
|
|
802
|
+
context += "...";
|
|
803
|
+
}
|
|
804
|
+
return context;
|
|
805
|
+
}
|
|
806
|
+
var RegexDetectionEngine = class {
|
|
807
|
+
constructor() {
|
|
808
|
+
this.version = "regex-v1.0.0";
|
|
809
|
+
}
|
|
810
|
+
async detect(content, config) {
|
|
811
|
+
const startTime = performance.now();
|
|
812
|
+
const detections = [];
|
|
813
|
+
const enabledCategories = Object.entries(config.categories).filter(([, enabled]) => enabled).map(([category]) => category);
|
|
814
|
+
const activePatterns = getPatternsForCategories(enabledCategories);
|
|
815
|
+
for (const section of content.sections) {
|
|
816
|
+
for (const patternDef of activePatterns) {
|
|
817
|
+
const matches = await safeRegexMatchAll(patternDef.pattern, section.text);
|
|
818
|
+
for (const match of matches) {
|
|
819
|
+
const value = match[0];
|
|
820
|
+
if (patternDef.validator && !patternDef.validator(value)) {
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
const confidence = this.calculateConfidence(
|
|
824
|
+
patternDef.category,
|
|
825
|
+
value,
|
|
826
|
+
config.sensitivity
|
|
827
|
+
);
|
|
828
|
+
const threshold = this.getSensitivityThreshold(config.sensitivity);
|
|
829
|
+
if (confidence < threshold) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
detections.push({
|
|
833
|
+
id: generateId(),
|
|
834
|
+
fileId: content.fileId,
|
|
835
|
+
sectionId: section.id,
|
|
836
|
+
category: patternDef.category,
|
|
837
|
+
value,
|
|
838
|
+
startOffset: match.index ?? 0,
|
|
839
|
+
endOffset: (match.index ?? 0) + value.length,
|
|
840
|
+
confidence,
|
|
841
|
+
context: extractContext(
|
|
842
|
+
section.text,
|
|
843
|
+
match.index ?? 0,
|
|
844
|
+
(match.index ?? 0) + value.length
|
|
845
|
+
),
|
|
846
|
+
source: "regex",
|
|
847
|
+
location: section.location
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (config.categories.custom) {
|
|
852
|
+
for (const customPattern of config.customPatterns) {
|
|
853
|
+
if (!customPattern.enabled) continue;
|
|
854
|
+
const { regex, error } = safeCompileRegex(customPattern.regex, "g");
|
|
855
|
+
if (!regex) {
|
|
856
|
+
secureWarn(`Custom pattern rejected: ${error}`);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
const matches = await safeRegexMatchAll(regex, section.text);
|
|
860
|
+
for (const match of matches) {
|
|
861
|
+
detections.push({
|
|
862
|
+
id: generateId(),
|
|
863
|
+
fileId: content.fileId,
|
|
864
|
+
sectionId: section.id,
|
|
865
|
+
category: "custom",
|
|
866
|
+
value: match[0],
|
|
867
|
+
startOffset: match.index ?? 0,
|
|
868
|
+
endOffset: (match.index ?? 0) + match[0].length,
|
|
869
|
+
confidence: 0.9,
|
|
870
|
+
// Custom patterns get high confidence
|
|
871
|
+
context: extractContext(
|
|
872
|
+
section.text,
|
|
873
|
+
match.index ?? 0,
|
|
874
|
+
(match.index ?? 0) + match[0].length
|
|
875
|
+
),
|
|
876
|
+
source: "regex",
|
|
877
|
+
location: section.location
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const uniqueDetections = this.deduplicateDetections(detections);
|
|
884
|
+
const endTime = performance.now();
|
|
885
|
+
return {
|
|
886
|
+
detections: uniqueDetections,
|
|
887
|
+
processingTimeMs: Math.round(endTime - startTime),
|
|
888
|
+
engineVersion: this.version
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
async isAvailable() {
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
getCapabilities() {
|
|
895
|
+
return {
|
|
896
|
+
supportedCategories: [
|
|
897
|
+
"person_name",
|
|
898
|
+
"email",
|
|
899
|
+
"phone",
|
|
900
|
+
"address",
|
|
901
|
+
"ssn",
|
|
902
|
+
"credit_card",
|
|
903
|
+
"ip_address",
|
|
904
|
+
"date_of_birth",
|
|
905
|
+
"custom"
|
|
906
|
+
],
|
|
907
|
+
supportsCustomPatterns: true,
|
|
908
|
+
supportsContextualDetection: false,
|
|
909
|
+
// AI engine would support this
|
|
910
|
+
maxFileSizeMB: 50
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Calculate confidence score based on pattern and context
|
|
915
|
+
*/
|
|
916
|
+
calculateConfidence(category, value, sensitivity) {
|
|
917
|
+
const baseConfidence = {
|
|
918
|
+
email: 0.95,
|
|
919
|
+
ssn: 0.95,
|
|
920
|
+
credit_card: 0.9,
|
|
921
|
+
phone: 0.85,
|
|
922
|
+
ip_address: 0.9,
|
|
923
|
+
date_of_birth: 0.75,
|
|
924
|
+
address: 0.75,
|
|
925
|
+
person_name: 0.75,
|
|
926
|
+
custom: 0.9
|
|
927
|
+
};
|
|
928
|
+
let confidence = baseConfidence[category] ?? 0.5;
|
|
929
|
+
if (category === "email" && value.includes("+")) {
|
|
930
|
+
confidence += 0.02;
|
|
931
|
+
}
|
|
932
|
+
if (category === "phone") {
|
|
933
|
+
const digits = value.replace(/\D/g, "");
|
|
934
|
+
if (digits.length === 10) confidence += 0.05;
|
|
935
|
+
if (value.includes("(") && value.includes(")")) confidence += 0.03;
|
|
936
|
+
}
|
|
937
|
+
if (sensitivity === "high") {
|
|
938
|
+
confidence += 0.1;
|
|
939
|
+
} else if (sensitivity === "low") {
|
|
940
|
+
confidence -= 0.1;
|
|
941
|
+
}
|
|
942
|
+
return Math.min(1, Math.max(0, confidence));
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Get minimum confidence threshold for sensitivity level
|
|
946
|
+
*/
|
|
947
|
+
getSensitivityThreshold(sensitivity) {
|
|
948
|
+
switch (sensitivity) {
|
|
949
|
+
case "low":
|
|
950
|
+
return 0.9;
|
|
951
|
+
case "medium":
|
|
952
|
+
return 0.7;
|
|
953
|
+
case "high":
|
|
954
|
+
return 0.5;
|
|
955
|
+
default:
|
|
956
|
+
return 0.7;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Remove duplicate detections at the same position
|
|
961
|
+
*/
|
|
962
|
+
deduplicateDetections(detections) {
|
|
963
|
+
const seen = /* @__PURE__ */ new Set();
|
|
964
|
+
return detections.filter((d) => {
|
|
965
|
+
const key = `${d.sectionId}:${d.startOffset}:${d.endOffset}:${d.category}`;
|
|
966
|
+
if (seen.has(key)) {
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
seen.add(key);
|
|
970
|
+
return true;
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
var regexDetectionEngine = new RegexDetectionEngine();
|
|
975
|
+
|
|
976
|
+
// src/services/processors/BaseProcessor.ts
|
|
977
|
+
function generateSectionId() {
|
|
978
|
+
return generatePrefixedId("section");
|
|
979
|
+
}
|
|
980
|
+
var BaseProcessor = class {
|
|
981
|
+
/**
|
|
982
|
+
* Check if this processor can handle the file
|
|
983
|
+
*/
|
|
984
|
+
canProcess(file) {
|
|
985
|
+
const ext = "." + file.name.split(".").pop()?.toLowerCase();
|
|
986
|
+
return this.extensions.includes(ext) || this.mimeTypes.includes(file.type);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Get supported MIME types
|
|
990
|
+
*/
|
|
991
|
+
getSupportedMimeTypes() {
|
|
992
|
+
return this.mimeTypes;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Helper to create a content section
|
|
996
|
+
*/
|
|
997
|
+
createSection(text, type, location) {
|
|
998
|
+
return {
|
|
999
|
+
id: generateSectionId(),
|
|
1000
|
+
text,
|
|
1001
|
+
type,
|
|
1002
|
+
location
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Apply text replacements to a string
|
|
1007
|
+
*/
|
|
1008
|
+
applyTextRedactions(text, redactions, sectionId) {
|
|
1009
|
+
const sectionRedactions = redactions.filter((r) => r.sectionId === sectionId).sort((a, b) => b.startOffset - a.startOffset);
|
|
1010
|
+
let result = text;
|
|
1011
|
+
for (const redaction of sectionRedactions) {
|
|
1012
|
+
result = result.slice(0, redaction.startOffset) + redaction.replacement + result.slice(redaction.endOffset);
|
|
1013
|
+
}
|
|
1014
|
+
return result;
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
// src/services/processors/XlsxProcessor.ts
|
|
1019
|
+
var XLSX = __toESM(require("xlsx"));
|
|
1020
|
+
var XlsxProcessor = class extends BaseProcessor {
|
|
1021
|
+
constructor() {
|
|
1022
|
+
super(...arguments);
|
|
1023
|
+
this.fileType = "xlsx";
|
|
1024
|
+
this.mimeTypes = [
|
|
1025
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1026
|
+
"application/vnd.ms-excel"
|
|
1027
|
+
];
|
|
1028
|
+
this.extensions = [".xlsx", ".xls"];
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Parse Excel file and extract text from cells.
|
|
1032
|
+
* Supports selective column parsing with attestation generation.
|
|
1033
|
+
*
|
|
1034
|
+
* @param file - The Excel file to parse
|
|
1035
|
+
* @param options - Optional parsing options for column selection
|
|
1036
|
+
*/
|
|
1037
|
+
async parse(file, options = {}) {
|
|
1038
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
1039
|
+
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
|
1040
|
+
const sections = [];
|
|
1041
|
+
const sheets = /* @__PURE__ */ new Map();
|
|
1042
|
+
const attestations = [];
|
|
1043
|
+
let formulaCellCount = 0;
|
|
1044
|
+
const { columnSelection, generateAttestation = false } = options;
|
|
1045
|
+
const processAllColumns = !columnSelection || columnSelection.mode === "all";
|
|
1046
|
+
for (const sheetName of workbook.SheetNames) {
|
|
1047
|
+
if (columnSelection?.mode === "selected" && columnSelection.selectedSheets.size > 0 && !columnSelection.selectedSheets.has(sheetName)) {
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
const sheet = workbook.Sheets[sheetName];
|
|
1051
|
+
sheets.set(sheetName, sheet);
|
|
1052
|
+
const range = XLSX.utils.decode_range(sheet["!ref"] || "A1");
|
|
1053
|
+
for (let col = range.s.c; col <= range.e.c; col++) {
|
|
1054
|
+
const colLetter = XLSX.utils.encode_col(col);
|
|
1055
|
+
const qualifiedCol = `${sheetName}:${colLetter}`;
|
|
1056
|
+
const shouldProcess = processAllColumns || columnSelection.selectedColumns.has(qualifiedCol) || columnSelection.selectedColumns.has(colLetter);
|
|
1057
|
+
if (!shouldProcess) {
|
|
1058
|
+
if (generateAttestation) {
|
|
1059
|
+
const attestation = await this.generateColumnAttestation(
|
|
1060
|
+
sheet,
|
|
1061
|
+
sheetName,
|
|
1062
|
+
col,
|
|
1063
|
+
colLetter,
|
|
1064
|
+
range
|
|
1065
|
+
);
|
|
1066
|
+
attestations.push(attestation);
|
|
1067
|
+
}
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
for (let row = range.s.r; row <= range.e.r; row++) {
|
|
1071
|
+
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
|
|
1072
|
+
const cell = sheet[cellAddress];
|
|
1073
|
+
if (cell && cell.v !== void 0 && cell.v !== null) {
|
|
1074
|
+
const text = String(cell.v);
|
|
1075
|
+
if (cell.f) {
|
|
1076
|
+
formulaCellCount++;
|
|
1077
|
+
}
|
|
1078
|
+
if (text.trim()) {
|
|
1079
|
+
sections.push({
|
|
1080
|
+
id: generateSectionId(),
|
|
1081
|
+
text,
|
|
1082
|
+
type: "cell",
|
|
1083
|
+
location: {
|
|
1084
|
+
sheet: sheetName,
|
|
1085
|
+
cell: cellAddress
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const content = {
|
|
1094
|
+
fileId: `xlsx-${generateSecureId()}`,
|
|
1095
|
+
fileName: file.name,
|
|
1096
|
+
fileType: "xlsx",
|
|
1097
|
+
sections
|
|
1098
|
+
};
|
|
1099
|
+
const formulaWarning = formulaCellCount > 0 ? `Found ${formulaCellCount} formula cell(s). Formula values may contain PII from referenced cells. Review carefully.` : void 0;
|
|
1100
|
+
return {
|
|
1101
|
+
content,
|
|
1102
|
+
metadata: {
|
|
1103
|
+
sheetCount: workbook.SheetNames.length,
|
|
1104
|
+
sheetNames: workbook.SheetNames,
|
|
1105
|
+
columnAttestations: attestations.length > 0 ? attestations : void 0,
|
|
1106
|
+
formulaCellCount: formulaCellCount > 0 ? formulaCellCount : void 0,
|
|
1107
|
+
formulaWarning
|
|
1108
|
+
},
|
|
1109
|
+
rawData: {
|
|
1110
|
+
workbook,
|
|
1111
|
+
sheets,
|
|
1112
|
+
columnAttestations: attestations.length > 0 ? attestations : void 0,
|
|
1113
|
+
formulaCellCount,
|
|
1114
|
+
formulaWarning
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Generate metadata record for a skipped column.
|
|
1120
|
+
*
|
|
1121
|
+
* NOTE: This does NOT cryptographically prove anything meaningful.
|
|
1122
|
+
* It just records column metadata (position, cell count). The hash
|
|
1123
|
+
* is of this metadata string, not the actual cell values. This is
|
|
1124
|
+
* for internal tracking only - we removed the misleading
|
|
1125
|
+
* "cryptographic attestation" claim from the UI.
|
|
1126
|
+
*/
|
|
1127
|
+
async generateColumnAttestation(sheet, sheetName, colIndex, colLetter, range) {
|
|
1128
|
+
let cellCount = 0;
|
|
1129
|
+
for (let row = range.s.r; row <= range.e.r; row++) {
|
|
1130
|
+
const cellAddress = XLSX.utils.encode_cell({ r: row, c: colIndex });
|
|
1131
|
+
if (sheet[cellAddress]) cellCount++;
|
|
1132
|
+
}
|
|
1133
|
+
const columnFingerprint = `${sheetName}:${colLetter}:${cellCount}:${range.e.r - range.s.r + 1}:${Date.now()}`;
|
|
1134
|
+
const hashBuffer = await crypto.subtle.digest(
|
|
1135
|
+
"SHA-256",
|
|
1136
|
+
new TextEncoder().encode(columnFingerprint)
|
|
1137
|
+
);
|
|
1138
|
+
const rawBytesHash = Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1139
|
+
return {
|
|
1140
|
+
column: colLetter,
|
|
1141
|
+
sheet: sheetName,
|
|
1142
|
+
cellCount,
|
|
1143
|
+
rawBytesHash,
|
|
1144
|
+
wasAccessed: false
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Apply redactions and generate new Excel file
|
|
1149
|
+
*
|
|
1150
|
+
* SECURITY: This method rebuilds the workbook from scratch to strip metadata.
|
|
1151
|
+
* Only cell values are copied - no formulas, comments, properties, or hidden sheets.
|
|
1152
|
+
*/
|
|
1153
|
+
async applyRedactions(document2, redactions) {
|
|
1154
|
+
const { workbook } = document2.rawData;
|
|
1155
|
+
const redactionMap = /* @__PURE__ */ new Map();
|
|
1156
|
+
for (const redaction of redactions) {
|
|
1157
|
+
const existing = redactionMap.get(redaction.sectionId) || [];
|
|
1158
|
+
existing.push(redaction);
|
|
1159
|
+
redactionMap.set(redaction.sectionId, existing);
|
|
1160
|
+
}
|
|
1161
|
+
const cellRedactions = /* @__PURE__ */ new Map();
|
|
1162
|
+
for (const section of document2.content.sections) {
|
|
1163
|
+
const sectionRedactions = redactionMap.get(section.id);
|
|
1164
|
+
if (sectionRedactions && sectionRedactions.length > 0 && section.location.sheet && section.location.cell) {
|
|
1165
|
+
const key = `${section.location.sheet}!${section.location.cell}`;
|
|
1166
|
+
const newText = this.applyTextRedactions(
|
|
1167
|
+
section.text,
|
|
1168
|
+
sectionRedactions,
|
|
1169
|
+
section.id
|
|
1170
|
+
);
|
|
1171
|
+
cellRedactions.set(key, newText);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const newWorkbook = XLSX.utils.book_new();
|
|
1175
|
+
for (const sheetName of workbook.SheetNames) {
|
|
1176
|
+
const originalSheet = workbook.Sheets[sheetName];
|
|
1177
|
+
const sheetState = originalSheet["!state"];
|
|
1178
|
+
if (sheetState === "hidden" || sheetState === "veryHidden") {
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
const newSheet = {};
|
|
1182
|
+
const ref = originalSheet["!ref"];
|
|
1183
|
+
if (!ref) continue;
|
|
1184
|
+
const range = XLSX.utils.decode_range(ref);
|
|
1185
|
+
for (let row = range.s.r; row <= range.e.r; row++) {
|
|
1186
|
+
for (let col = range.s.c; col <= range.e.c; col++) {
|
|
1187
|
+
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
|
|
1188
|
+
const originalCell = originalSheet[cellAddress];
|
|
1189
|
+
if (originalCell && originalCell.v !== void 0 && originalCell.v !== null) {
|
|
1190
|
+
const redactionKey = `${sheetName}!${cellAddress}`;
|
|
1191
|
+
const redactedValue = cellRedactions.get(redactionKey);
|
|
1192
|
+
const value = redactedValue !== void 0 ? redactedValue : String(originalCell.v);
|
|
1193
|
+
newSheet[cellAddress] = {
|
|
1194
|
+
t: "s",
|
|
1195
|
+
// String type
|
|
1196
|
+
v: value
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
newSheet["!ref"] = ref;
|
|
1202
|
+
if (originalSheet["!cols"]) {
|
|
1203
|
+
newSheet["!cols"] = originalSheet["!cols"].map((col) => ({
|
|
1204
|
+
wch: col?.wch,
|
|
1205
|
+
wpx: col?.wpx
|
|
1206
|
+
}));
|
|
1207
|
+
}
|
|
1208
|
+
XLSX.utils.book_append_sheet(newWorkbook, newSheet, sheetName);
|
|
1209
|
+
}
|
|
1210
|
+
newWorkbook.Props = {
|
|
1211
|
+
Title: document2.content.fileName.replace(/\.[^/.]+$/, "") + " (PURGED)",
|
|
1212
|
+
Author: "PURGE - purgedata.app",
|
|
1213
|
+
Comments: `Processed locally by PURGE on ${(/* @__PURE__ */ new Date()).toISOString()}. No data was transmitted.`
|
|
1214
|
+
};
|
|
1215
|
+
const output = XLSX.write(newWorkbook, {
|
|
1216
|
+
type: "array",
|
|
1217
|
+
bookType: "xlsx"
|
|
1218
|
+
});
|
|
1219
|
+
return new Blob([output], {
|
|
1220
|
+
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
var xlsxProcessor = new XlsxProcessor();
|
|
1225
|
+
|
|
1226
|
+
// src/services/processors/index.ts
|
|
1227
|
+
var processors = {
|
|
1228
|
+
xlsx: xlsxProcessor,
|
|
1229
|
+
docx: null,
|
|
1230
|
+
// TODO: Implement DocxProcessor
|
|
1231
|
+
pptx: null,
|
|
1232
|
+
// TODO: Implement PptxProcessor
|
|
1233
|
+
pdf: null
|
|
1234
|
+
// TODO: Implement PdfProcessor
|
|
1235
|
+
};
|
|
1236
|
+
function getProcessor(fileType) {
|
|
1237
|
+
return processors[fileType];
|
|
1238
|
+
}
|
|
1239
|
+
function getProcessorForFile(file) {
|
|
1240
|
+
for (const processor of Object.values(processors)) {
|
|
1241
|
+
if (processor?.canProcess(file)) {
|
|
1242
|
+
return processor;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// src/services/adversarial/AdversarialVerifier.ts
|
|
1249
|
+
var ATTRIBUTE_PATTERNS = [
|
|
1250
|
+
// Professions - highly identifying
|
|
1251
|
+
{
|
|
1252
|
+
type: "profession",
|
|
1253
|
+
patterns: [
|
|
1254
|
+
/\b(?:CEO|CFO|CTO|COO|CMO|CIO|CISO)\b/gi,
|
|
1255
|
+
/\b(?:chief|head|director|vp|vice president)\s+(?:of\s+)?[\w\s]+(?:officer|executive)?\b/gi,
|
|
1256
|
+
/\b(?:surgeon|doctor|physician|attorney|lawyer|judge|professor|dean)\b/gi,
|
|
1257
|
+
/\b(?:architect|engineer|scientist|researcher)\s+(?:at|for|of)\s+[\w\s]+\b/gi,
|
|
1258
|
+
/\bfounder\s+(?:of|and\s+CEO\s+of)\s+[\w\s]+\b/gi,
|
|
1259
|
+
/\b(?:partner|principal|managing\s+director)\s+at\s+[\w\s]+\b/gi
|
|
1260
|
+
],
|
|
1261
|
+
narrowingFactor: 1e-4,
|
|
1262
|
+
// Very few people have specific C-suite titles
|
|
1263
|
+
explanationTemplate: 'Job title "{phrase}" significantly narrows identification',
|
|
1264
|
+
suggestionTemplate: "a senior executive"
|
|
1265
|
+
},
|
|
1266
|
+
// Affiliations - companies, universities, organizations
|
|
1267
|
+
{
|
|
1268
|
+
type: "affiliation",
|
|
1269
|
+
patterns: [
|
|
1270
|
+
/\bat\s+(?:Google|Apple|Microsoft|Amazon|Meta|Facebook|Netflix|Tesla|SpaceX)\b/gi,
|
|
1271
|
+
/\bat\s+(?:Harvard|Stanford|MIT|Yale|Princeton|Oxford|Cambridge)\b/gi,
|
|
1272
|
+
/\b(?:works?|worked|employed)\s+(?:at|for|with)\s+[\w\s]+(?:Inc|Corp|LLC|Ltd|University|College|Hospital)?\b/gi,
|
|
1273
|
+
/\b(?:member|fellow|associate)\s+of\s+(?:the\s+)?[\w\s]+(?:Association|Society|Institute|Academy)\b/gi
|
|
1274
|
+
],
|
|
1275
|
+
narrowingFactor: 1e-3,
|
|
1276
|
+
explanationTemplate: 'Affiliation with "{phrase}" narrows potential matches',
|
|
1277
|
+
suggestionTemplate: "a technology company"
|
|
1278
|
+
},
|
|
1279
|
+
// Temporal markers - specific dates/events
|
|
1280
|
+
{
|
|
1281
|
+
type: "temporal_marker",
|
|
1282
|
+
patterns: [
|
|
1283
|
+
/\bin\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+(?:of\s+)?(?:19|20)\d{2}\b/gi,
|
|
1284
|
+
/\bon\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2}(?:st|nd|rd|th)?,?\s*(?:19|20)\d{2}\b/gi,
|
|
1285
|
+
/\bduring\s+(?:the\s+)?(?:19|20)\d{2}\s+[\w\s]+\b/gi,
|
|
1286
|
+
/\b(?:class|batch|cohort)\s+of\s+(?:19|20)\d{2}\b/gi,
|
|
1287
|
+
/\bQ[1-4]\s+(?:19|20)\d{2}\b/gi
|
|
1288
|
+
],
|
|
1289
|
+
narrowingFactor: 0.1,
|
|
1290
|
+
explanationTemplate: 'Specific time reference "{phrase}" helps narrow search',
|
|
1291
|
+
suggestionTemplate: "during that period"
|
|
1292
|
+
},
|
|
1293
|
+
// Geographic signals
|
|
1294
|
+
{
|
|
1295
|
+
type: "geographic_signal",
|
|
1296
|
+
patterns: [
|
|
1297
|
+
/\b(?:based|located|headquartered)\s+in\s+[\w\s]+(?:,\s*[A-Z]{2})?\b/gi,
|
|
1298
|
+
/\b(?:downtown|midtown|uptown)\s+[\w\s]+\b/gi,
|
|
1299
|
+
/\b[\w\s]+(?:office|campus|facility|branch)\b/gi,
|
|
1300
|
+
/\bnative\s+of\s+[\w\s]+\b/gi,
|
|
1301
|
+
/\bfrom\s+(?:the\s+)?[\w\s]+(?:area|region|district|neighborhood)\b/gi
|
|
1302
|
+
],
|
|
1303
|
+
narrowingFactor: 0.01,
|
|
1304
|
+
explanationTemplate: 'Location "{phrase}" limits geographic scope',
|
|
1305
|
+
suggestionTemplate: "a major metropolitan area"
|
|
1306
|
+
},
|
|
1307
|
+
// Relational context - family, colleagues
|
|
1308
|
+
{
|
|
1309
|
+
type: "relational_context",
|
|
1310
|
+
patterns: [
|
|
1311
|
+
/\b(?:his|her|their)\s+(?:wife|husband|spouse|partner|son|daughter|father|mother|brother|sister)\b/gi,
|
|
1312
|
+
/\b(?:married|divorced|widowed)\s+(?:to|from)\s+[\w\s]+\b/gi,
|
|
1313
|
+
/\b(?:colleague|mentor|mentee|protégé|advisor)\s+(?:of|to)\s+[\w\s]+\b/gi,
|
|
1314
|
+
/\bco-founder\s+with\s+[\w\s]+\b/gi
|
|
1315
|
+
],
|
|
1316
|
+
narrowingFactor: 0.1,
|
|
1317
|
+
explanationTemplate: 'Relationship context "{phrase}" provides additional linkage',
|
|
1318
|
+
suggestionTemplate: "a family member"
|
|
1319
|
+
},
|
|
1320
|
+
// Unique events - testimony, awards, notable occurrences
|
|
1321
|
+
{
|
|
1322
|
+
type: "unique_event",
|
|
1323
|
+
patterns: [
|
|
1324
|
+
/\btestified\s+(?:before|at|to)\s+(?:the\s+)?[\w\s]+(?:Congress|Senate|Committee|Court)\b/gi,
|
|
1325
|
+
/\bwon\s+(?:the\s+)?[\w\s]+(?:Award|Prize|Medal|Trophy)\b/gi,
|
|
1326
|
+
/\bnamed\s+(?:to|in)\s+(?:the\s+)?[\w\s]+(?:list|ranking|100)\b/gi,
|
|
1327
|
+
/\bfirst\s+(?:woman|man|person|African.American|Asian)\s+to\b/gi,
|
|
1328
|
+
/\bonly\s+(?:person|individual|one)\s+to\s+(?:have\s+)?[\w\s]+\b/gi,
|
|
1329
|
+
/\brecord.setting\b/gi,
|
|
1330
|
+
/\bpioneer(?:ed|ing)?\s+[\w\s]+\b/gi
|
|
1331
|
+
],
|
|
1332
|
+
narrowingFactor: 1e-5,
|
|
1333
|
+
// Unique events are extremely identifying
|
|
1334
|
+
explanationTemplate: 'Unique event "{phrase}" likely identifies a single individual',
|
|
1335
|
+
suggestionTemplate: "received recognition"
|
|
1336
|
+
},
|
|
1337
|
+
// Demographics - age, gender, ethnicity indicators
|
|
1338
|
+
{
|
|
1339
|
+
type: "demographic",
|
|
1340
|
+
patterns: [
|
|
1341
|
+
/\b(?:youngest|oldest|first)\s+(?:female|male|woman|man)\b/gi,
|
|
1342
|
+
/\b\d{2}.year.old\b/gi,
|
|
1343
|
+
/\bborn\s+in\s+(?:19|20)\d{2}\b/gi,
|
|
1344
|
+
/\bgeneration\s+(?:X|Y|Z|Alpha|Boomer|Millennial)\b/gi
|
|
1345
|
+
],
|
|
1346
|
+
narrowingFactor: 0.05,
|
|
1347
|
+
explanationTemplate: 'Demographic detail "{phrase}" narrows population',
|
|
1348
|
+
suggestionTemplate: "an individual"
|
|
1349
|
+
},
|
|
1350
|
+
// Achievements - degrees, certifications, publications
|
|
1351
|
+
{
|
|
1352
|
+
type: "achievement",
|
|
1353
|
+
patterns: [
|
|
1354
|
+
/\bPhD\s+(?:in|from)\s+[\w\s]+\b/gi,
|
|
1355
|
+
/\bauthor(?:ed)?\s+(?:of\s+)?["']?[\w\s]+["']?\b/gi,
|
|
1356
|
+
/\bpublished\s+(?:in|by)\s+[\w\s]+\b/gi,
|
|
1357
|
+
/\bpatent(?:ed|s)?\s+(?:for|on)\s+[\w\s]+\b/gi,
|
|
1358
|
+
/\bfounded\s+[\w\s]+\b/gi,
|
|
1359
|
+
/\binvented\s+[\w\s]+\b/gi
|
|
1360
|
+
],
|
|
1361
|
+
narrowingFactor: 1e-3,
|
|
1362
|
+
explanationTemplate: 'Achievement "{phrase}" is potentially searchable',
|
|
1363
|
+
suggestionTemplate: "has relevant credentials"
|
|
1364
|
+
},
|
|
1365
|
+
// Public roles - elected officials, public figures
|
|
1366
|
+
{
|
|
1367
|
+
type: "public_role",
|
|
1368
|
+
patterns: [
|
|
1369
|
+
/\b(?:senator|congressman|congresswoman|representative|mayor|governor|president)\b/gi,
|
|
1370
|
+
/\b(?:elected|appointed|nominated)\s+(?:to|as)\s+[\w\s]+\b/gi,
|
|
1371
|
+
/\bserved\s+(?:on|as|in)\s+(?:the\s+)?[\w\s]+(?:board|committee|council|commission)\b/gi,
|
|
1372
|
+
/\bformer\s+[\w\s]+(?:secretary|minister|ambassador|director)\b/gi
|
|
1373
|
+
],
|
|
1374
|
+
narrowingFactor: 1e-4,
|
|
1375
|
+
explanationTemplate: 'Public role "{phrase}" is easily searchable',
|
|
1376
|
+
suggestionTemplate: "held a public position"
|
|
1377
|
+
}
|
|
1378
|
+
];
|
|
1379
|
+
var SEARCHABILITY_PATTERNS = [
|
|
1380
|
+
// Trivially searchable - direct quotes, exact events
|
|
1381
|
+
{
|
|
1382
|
+
pattern: /["'][\w\s]{10,}["']/g,
|
|
1383
|
+
searchability: "trivial",
|
|
1384
|
+
reason: "Exact quote can be searched directly"
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
pattern: /testified\s+(?:before|at|to)\s+[\w\s]+(?:on|about|regarding)\s+[\w\s]+/gi,
|
|
1388
|
+
searchability: "trivial",
|
|
1389
|
+
reason: "Congressional testimony is public record"
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
pattern: /(?:filed|settled|won|lost)\s+(?:a\s+)?(?:lawsuit|case|suit)\s+(?:against|with)\s+[\w\s]+/gi,
|
|
1393
|
+
searchability: "trivial",
|
|
1394
|
+
reason: "Legal proceedings are searchable"
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
pattern: /(?:awarded|received|won)\s+(?:the\s+)?[\w\s]+(?:Award|Prize|Medal)/gi,
|
|
1398
|
+
searchability: "trivial",
|
|
1399
|
+
reason: "Awards are typically announced publicly"
|
|
1400
|
+
},
|
|
1401
|
+
// Moderately searchable - combinations of attributes
|
|
1402
|
+
{
|
|
1403
|
+
pattern: /(?:CEO|CFO|CTO)\s+(?:of|at)\s+[\w\s]+/gi,
|
|
1404
|
+
searchability: "moderate",
|
|
1405
|
+
reason: "Executive titles at named companies are findable"
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
pattern: /(?:professor|researcher)\s+(?:of|at)\s+[\w\s]+University/gi,
|
|
1409
|
+
searchability: "moderate",
|
|
1410
|
+
reason: "Academic positions are often listed online"
|
|
1411
|
+
},
|
|
1412
|
+
// Difficult but possible
|
|
1413
|
+
{
|
|
1414
|
+
pattern: /(?:worked|employed)\s+(?:at|for)\s+[\w\s]+\s+(?:from|during|in)\s+[\w\s]+/gi,
|
|
1415
|
+
searchability: "difficult",
|
|
1416
|
+
reason: "Employment history with dates may appear in professional profiles"
|
|
1417
|
+
}
|
|
1418
|
+
];
|
|
1419
|
+
var VULNERABLE_SOURCES = [
|
|
1420
|
+
{
|
|
1421
|
+
name: "LinkedIn",
|
|
1422
|
+
dataTypes: ["employment history", "education", "skills", "connections"],
|
|
1423
|
+
matchPatterns: [
|
|
1424
|
+
/\bworked?\s+(?:at|for)\b/gi,
|
|
1425
|
+
/\bemployed\b/gi,
|
|
1426
|
+
/\b(?:CEO|CTO|CFO|Director|Manager|Engineer)\b/gi
|
|
1427
|
+
]
|
|
1428
|
+
},
|
|
1429
|
+
{
|
|
1430
|
+
name: "Public Records",
|
|
1431
|
+
dataTypes: ["property ownership", "court records", "voter registration"],
|
|
1432
|
+
matchPatterns: [
|
|
1433
|
+
/\bowned\s+property\b/gi,
|
|
1434
|
+
/\bfiled\s+(?:lawsuit|bankruptcy)\b/gi,
|
|
1435
|
+
/\bregistered\s+voter\b/gi
|
|
1436
|
+
]
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
name: "News Archives",
|
|
1440
|
+
dataTypes: ["news mentions", "press releases", "interviews"],
|
|
1441
|
+
matchPatterns: [
|
|
1442
|
+
/\bannounced\b/gi,
|
|
1443
|
+
/\bquoted\b/gi,
|
|
1444
|
+
/\binterview(?:ed)?\b/gi,
|
|
1445
|
+
/\btestified\b/gi
|
|
1446
|
+
]
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
name: "Academic Databases",
|
|
1450
|
+
dataTypes: ["publications", "citations", "institutional affiliations"],
|
|
1451
|
+
matchPatterns: [
|
|
1452
|
+
/\bpublished\b/gi,
|
|
1453
|
+
/\bprofessor\b/gi,
|
|
1454
|
+
/\bresearcher\b/gi,
|
|
1455
|
+
/\bPhD\b/gi
|
|
1456
|
+
]
|
|
1457
|
+
},
|
|
1458
|
+
{
|
|
1459
|
+
name: "Corporate Filings",
|
|
1460
|
+
dataTypes: ["SEC filings", "board memberships", "executive compensation"],
|
|
1461
|
+
matchPatterns: [
|
|
1462
|
+
/\b(?:CEO|CFO|CTO|COO|board)\b/gi,
|
|
1463
|
+
/\bfiled\s+with\b/gi,
|
|
1464
|
+
/\bpublic(?:ly)?\s+traded\b/gi
|
|
1465
|
+
]
|
|
1466
|
+
}
|
|
1467
|
+
];
|
|
1468
|
+
var WORLD_POPULATION = 8e9;
|
|
1469
|
+
var POPULATION_MULTIPLIERS = {
|
|
1470
|
+
profession: 1e-4,
|
|
1471
|
+
// ~800,000 for a specific job title
|
|
1472
|
+
affiliation: 1e-3,
|
|
1473
|
+
// ~8,000,000 for a major company
|
|
1474
|
+
temporal_marker: 0.1,
|
|
1475
|
+
// Narrows to a year or period
|
|
1476
|
+
geographic_signal: 0.01,
|
|
1477
|
+
// ~80,000,000 for a city
|
|
1478
|
+
relational_context: 0.1,
|
|
1479
|
+
// Provides linkage, not direct ID
|
|
1480
|
+
unique_event: 1e-5,
|
|
1481
|
+
// ~80,000 or less for unique achievements
|
|
1482
|
+
demographic: 0.05,
|
|
1483
|
+
// Age/gender combo
|
|
1484
|
+
achievement: 1e-3,
|
|
1485
|
+
// Specific degrees/publications
|
|
1486
|
+
public_role: 1e-4
|
|
1487
|
+
// Public figures are findable
|
|
1488
|
+
};
|
|
1489
|
+
var AdversarialVerifier = class {
|
|
1490
|
+
constructor(config) {
|
|
1491
|
+
this.config = {
|
|
1492
|
+
enabled: true,
|
|
1493
|
+
riskThreshold: 30,
|
|
1494
|
+
maxIterations: 3,
|
|
1495
|
+
autoApplyLowRisk: false,
|
|
1496
|
+
analysisDepth: "standard",
|
|
1497
|
+
enabledAnalyses: {
|
|
1498
|
+
attributeLeakage: true,
|
|
1499
|
+
semanticFingerprinting: true,
|
|
1500
|
+
crossReferenceCheck: true
|
|
1501
|
+
},
|
|
1502
|
+
...config
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Main entry point: analyze redacted content for re-identification risk
|
|
1507
|
+
*/
|
|
1508
|
+
async analyze(sections, appliedRedactions) {
|
|
1509
|
+
const startTime = performance.now();
|
|
1510
|
+
const redactedText = this.simulateRedactedOutput(sections, appliedRedactions);
|
|
1511
|
+
const leakedAttributes = this.config.enabledAnalyses.attributeLeakage ? this.extractLeakedAttributes(redactedText, sections) : [];
|
|
1512
|
+
const semanticFingerprint = this.config.enabledAnalyses.semanticFingerprinting ? this.calculateSemanticFingerprint(redactedText, leakedAttributes) : this.emptySemanticFingerprint();
|
|
1513
|
+
const crossReferenceRisk = this.config.enabledAnalyses.crossReferenceCheck ? this.assessCrossReferenceRisk(redactedText) : this.emptyCrossReferenceRisk();
|
|
1514
|
+
const reidentificationConfidence = this.calculateOverallConfidence(
|
|
1515
|
+
leakedAttributes,
|
|
1516
|
+
semanticFingerprint,
|
|
1517
|
+
crossReferenceRisk
|
|
1518
|
+
);
|
|
1519
|
+
const riskLevel = this.classifyRiskLevel(reidentificationConfidence);
|
|
1520
|
+
const analysis = {
|
|
1521
|
+
id: generateSecureId(),
|
|
1522
|
+
timestamp: Date.now(),
|
|
1523
|
+
reidentificationConfidence,
|
|
1524
|
+
riskLevel,
|
|
1525
|
+
leakedAttributes,
|
|
1526
|
+
semanticFingerprint,
|
|
1527
|
+
crossReferenceRisk,
|
|
1528
|
+
sectionsAnalyzed: sections.map((s) => s.id),
|
|
1529
|
+
processingTimeMs: performance.now() - startTime
|
|
1530
|
+
};
|
|
1531
|
+
const suggestions = this.generateSuggestions(analysis);
|
|
1532
|
+
return {
|
|
1533
|
+
analysis,
|
|
1534
|
+
suggestions,
|
|
1535
|
+
passesThreshold: reidentificationConfidence <= this.config.riskThreshold,
|
|
1536
|
+
riskThreshold: this.config.riskThreshold,
|
|
1537
|
+
iteration: 1
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Simulate what the document looks like after redaction
|
|
1542
|
+
*/
|
|
1543
|
+
simulateRedactedOutput(sections, redactions) {
|
|
1544
|
+
const result = /* @__PURE__ */ new Map();
|
|
1545
|
+
for (const section of sections) {
|
|
1546
|
+
let text = section.text;
|
|
1547
|
+
const sectionRedactions = redactions.filter((r) => r.sectionId === section.id).sort((a, b) => b.startOffset - a.startOffset);
|
|
1548
|
+
for (const redaction of sectionRedactions) {
|
|
1549
|
+
text = text.slice(0, redaction.startOffset) + "[REDACTED]" + text.slice(redaction.endOffset);
|
|
1550
|
+
}
|
|
1551
|
+
result.set(section.id, text);
|
|
1552
|
+
}
|
|
1553
|
+
return result;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Extract attributes that leak identity from redacted text
|
|
1557
|
+
*/
|
|
1558
|
+
extractLeakedAttributes(redactedText, _sections) {
|
|
1559
|
+
const attributes = [];
|
|
1560
|
+
for (const [sectionId, text] of redactedText) {
|
|
1561
|
+
for (const pattern of ATTRIBUTE_PATTERNS) {
|
|
1562
|
+
for (const regex of pattern.patterns) {
|
|
1563
|
+
regex.lastIndex = 0;
|
|
1564
|
+
let match;
|
|
1565
|
+
while ((match = regex.exec(text)) !== null) {
|
|
1566
|
+
if (this.isInsideRedaction(text, match.index)) {
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
const phrase = match[0].trim();
|
|
1570
|
+
if (phrase.length < 5) {
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
attributes.push({
|
|
1574
|
+
type: pattern.type,
|
|
1575
|
+
phrase,
|
|
1576
|
+
narrowingFactor: pattern.narrowingFactor,
|
|
1577
|
+
explanation: pattern.explanationTemplate.replace("{phrase}", phrase),
|
|
1578
|
+
suggestion: pattern.suggestionTemplate,
|
|
1579
|
+
location: {
|
|
1580
|
+
sectionId,
|
|
1581
|
+
startOffset: match.index,
|
|
1582
|
+
endOffset: match.index + match[0].length
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1590
|
+
return attributes.filter((attr) => {
|
|
1591
|
+
const key = `${attr.type}:${attr.phrase.toLowerCase()}`;
|
|
1592
|
+
if (seen.has(key)) return false;
|
|
1593
|
+
seen.add(key);
|
|
1594
|
+
return true;
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Check if a position is inside a [REDACTED] marker
|
|
1599
|
+
*/
|
|
1600
|
+
isInsideRedaction(text, position) {
|
|
1601
|
+
const redactionPattern = /\[REDACTED\]/g;
|
|
1602
|
+
let match;
|
|
1603
|
+
while ((match = redactionPattern.exec(text)) !== null) {
|
|
1604
|
+
if (position >= match.index && position < match.index + match[0].length) {
|
|
1605
|
+
return true;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
return false;
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Calculate semantic fingerprint - how unique is this description?
|
|
1612
|
+
*/
|
|
1613
|
+
calculateSemanticFingerprint(_redactedText, leakedAttributes) {
|
|
1614
|
+
let populationSize = WORLD_POPULATION;
|
|
1615
|
+
const drivers = [];
|
|
1616
|
+
for (const attr of leakedAttributes) {
|
|
1617
|
+
const narrowingFactor = POPULATION_MULTIPLIERS[attr.type] || attr.narrowingFactor;
|
|
1618
|
+
const newPopulation = populationSize * narrowingFactor;
|
|
1619
|
+
drivers.push({
|
|
1620
|
+
phrase: attr.phrase,
|
|
1621
|
+
impact: this.categorizeImpact(narrowingFactor),
|
|
1622
|
+
narrowingFactor,
|
|
1623
|
+
suggestion: attr.suggestion || "Consider generalizing this phrase"
|
|
1624
|
+
});
|
|
1625
|
+
populationSize = newPopulation;
|
|
1626
|
+
}
|
|
1627
|
+
drivers.sort((a, b) => {
|
|
1628
|
+
const impactOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
1629
|
+
return impactOrder[a.impact] - impactOrder[b.impact];
|
|
1630
|
+
});
|
|
1631
|
+
const riskScore = Math.min(
|
|
1632
|
+
100,
|
|
1633
|
+
Math.max(0, 100 - Math.log10(Math.max(1, populationSize)) * 10)
|
|
1634
|
+
);
|
|
1635
|
+
return {
|
|
1636
|
+
estimatedPopulationSize: Math.max(1, Math.round(populationSize)),
|
|
1637
|
+
populationDescription: this.describePopulation(populationSize),
|
|
1638
|
+
uniquenessDrivers: drivers.slice(0, 10),
|
|
1639
|
+
// Top 10
|
|
1640
|
+
riskScore,
|
|
1641
|
+
riskLevel: this.classifyRiskLevel(riskScore)
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Categorize the impact of a narrowing factor
|
|
1646
|
+
*/
|
|
1647
|
+
categorizeImpact(factor) {
|
|
1648
|
+
if (factor <= 1e-4) return "critical";
|
|
1649
|
+
if (factor <= 1e-3) return "high";
|
|
1650
|
+
if (factor <= 0.01) return "medium";
|
|
1651
|
+
return "low";
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Create human-readable population description
|
|
1655
|
+
*/
|
|
1656
|
+
describePopulation(size) {
|
|
1657
|
+
if (size <= 1) return "Single individual identifiable";
|
|
1658
|
+
if (size <= 10) return "Fewer than 10 people match";
|
|
1659
|
+
if (size <= 100) return "Fewer than 100 people match";
|
|
1660
|
+
if (size <= 1e3) return "Approximately 1,000 people match";
|
|
1661
|
+
if (size <= 1e4) return "Approximately 10,000 people match";
|
|
1662
|
+
if (size <= 1e5) return "Approximately 100,000 people match";
|
|
1663
|
+
if (size <= 1e6) return "Approximately 1 million people match";
|
|
1664
|
+
return "Large population matches (lower risk)";
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Assess cross-reference vulnerability
|
|
1668
|
+
*/
|
|
1669
|
+
assessCrossReferenceRisk(redactedText) {
|
|
1670
|
+
const fullText = Array.from(redactedText.values()).join(" ");
|
|
1671
|
+
const searchableFragments = [];
|
|
1672
|
+
const vulnerableSources = [];
|
|
1673
|
+
for (const pattern of SEARCHABILITY_PATTERNS) {
|
|
1674
|
+
pattern.pattern.lastIndex = 0;
|
|
1675
|
+
let match;
|
|
1676
|
+
while ((match = pattern.pattern.exec(fullText)) !== null) {
|
|
1677
|
+
if (!this.isInsideRedaction(fullText, match.index)) {
|
|
1678
|
+
searchableFragments.push({
|
|
1679
|
+
fragment: match[0].trim(),
|
|
1680
|
+
searchability: pattern.searchability,
|
|
1681
|
+
reason: pattern.reason,
|
|
1682
|
+
predictedResults: this.predictSearchResults(
|
|
1683
|
+
match[0],
|
|
1684
|
+
pattern.searchability
|
|
1685
|
+
)
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
for (const source of VULNERABLE_SOURCES) {
|
|
1691
|
+
const matchingDataPoints = [];
|
|
1692
|
+
for (const pattern of source.matchPatterns) {
|
|
1693
|
+
pattern.lastIndex = 0;
|
|
1694
|
+
if (pattern.test(fullText)) {
|
|
1695
|
+
matchingDataPoints.push(
|
|
1696
|
+
...source.dataTypes.filter(
|
|
1697
|
+
(_dt) => source.matchPatterns.some((p) => {
|
|
1698
|
+
p.lastIndex = 0;
|
|
1699
|
+
return p.test(fullText);
|
|
1700
|
+
})
|
|
1701
|
+
)
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
if (matchingDataPoints.length > 0) {
|
|
1706
|
+
vulnerableSources.push({
|
|
1707
|
+
source: source.name,
|
|
1708
|
+
matchLikelihood: this.assessMatchLikelihood(matchingDataPoints.length),
|
|
1709
|
+
dataPoints: [...new Set(matchingDataPoints)]
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
const trivialCount = searchableFragments.filter(
|
|
1714
|
+
(f) => f.searchability === "trivial"
|
|
1715
|
+
).length;
|
|
1716
|
+
const moderateCount = searchableFragments.filter(
|
|
1717
|
+
(f) => f.searchability === "moderate"
|
|
1718
|
+
).length;
|
|
1719
|
+
const riskScore = Math.min(
|
|
1720
|
+
100,
|
|
1721
|
+
trivialCount * 30 + moderateCount * 15 + vulnerableSources.length * 10
|
|
1722
|
+
);
|
|
1723
|
+
return {
|
|
1724
|
+
searchableFragments: searchableFragments.slice(0, 10),
|
|
1725
|
+
vulnerableSources,
|
|
1726
|
+
riskScore
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Predict what a search might return
|
|
1731
|
+
*/
|
|
1732
|
+
predictSearchResults(_fragment, searchability) {
|
|
1733
|
+
switch (searchability) {
|
|
1734
|
+
case "trivial":
|
|
1735
|
+
return "Direct search likely returns exact match";
|
|
1736
|
+
case "moderate":
|
|
1737
|
+
return "Search combined with other context may identify";
|
|
1738
|
+
case "difficult":
|
|
1739
|
+
return "Requires additional context to narrow results";
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Assess likelihood of database match
|
|
1744
|
+
*/
|
|
1745
|
+
assessMatchLikelihood(dataPointCount) {
|
|
1746
|
+
if (dataPointCount >= 3) return "certain";
|
|
1747
|
+
if (dataPointCount === 2) return "likely";
|
|
1748
|
+
if (dataPointCount === 1) return "possible";
|
|
1749
|
+
return "unlikely";
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Calculate overall re-identification confidence
|
|
1753
|
+
*/
|
|
1754
|
+
calculateOverallConfidence(leakedAttributes, semanticFingerprint, crossReferenceRisk) {
|
|
1755
|
+
const weights = {
|
|
1756
|
+
semantic: 0.5,
|
|
1757
|
+
// How unique is the description?
|
|
1758
|
+
crossRef: 0.3,
|
|
1759
|
+
// How searchable?
|
|
1760
|
+
attributeCount: 0.2
|
|
1761
|
+
// Raw attribute count factor
|
|
1762
|
+
};
|
|
1763
|
+
const attributeRisk = Math.min(100, leakedAttributes.length * 10);
|
|
1764
|
+
return Math.round(
|
|
1765
|
+
weights.semantic * semanticFingerprint.riskScore + weights.crossRef * crossReferenceRisk.riskScore + weights.attributeCount * attributeRisk
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Classify risk level based on confidence score
|
|
1770
|
+
*/
|
|
1771
|
+
classifyRiskLevel(confidence) {
|
|
1772
|
+
if (confidence >= 80) return "critical";
|
|
1773
|
+
if (confidence >= 60) return "high";
|
|
1774
|
+
if (confidence >= 40) return "medium";
|
|
1775
|
+
if (confidence >= 20) return "low";
|
|
1776
|
+
return "minimal";
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Generate suggestions for reducing risk
|
|
1780
|
+
*/
|
|
1781
|
+
generateSuggestions(analysis) {
|
|
1782
|
+
const suggestions = [];
|
|
1783
|
+
let priority = 1;
|
|
1784
|
+
for (const driver of analysis.semanticFingerprint.uniquenessDrivers) {
|
|
1785
|
+
if (driver.impact === "critical" || driver.impact === "high") {
|
|
1786
|
+
const attr = analysis.leakedAttributes.find(
|
|
1787
|
+
(a) => a.phrase === driver.phrase
|
|
1788
|
+
);
|
|
1789
|
+
if (attr) {
|
|
1790
|
+
suggestions.push({
|
|
1791
|
+
id: generateSecureId(),
|
|
1792
|
+
type: "generalize",
|
|
1793
|
+
priority: priority++,
|
|
1794
|
+
originalPhrase: driver.phrase,
|
|
1795
|
+
suggestedReplacement: driver.suggestion,
|
|
1796
|
+
expectedRiskReduction: this.estimateRiskReduction(driver.impact),
|
|
1797
|
+
location: attr.location,
|
|
1798
|
+
rationale: `This phrase narrows identification to approximately ${Math.round(
|
|
1799
|
+
analysis.semanticFingerprint.estimatedPopulationSize * driver.narrowingFactor
|
|
1800
|
+
).toLocaleString()} people`,
|
|
1801
|
+
accepted: false
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
for (const fragment of analysis.crossReferenceRisk.searchableFragments) {
|
|
1807
|
+
if (fragment.searchability === "trivial") {
|
|
1808
|
+
suggestions.push({
|
|
1809
|
+
id: generateSecureId(),
|
|
1810
|
+
type: "redact",
|
|
1811
|
+
priority: priority++,
|
|
1812
|
+
originalPhrase: fragment.fragment,
|
|
1813
|
+
suggestedReplacement: "[CONTEXT REDACTED]",
|
|
1814
|
+
expectedRiskReduction: 15,
|
|
1815
|
+
location: {
|
|
1816
|
+
sectionId: "",
|
|
1817
|
+
// Would need to track this
|
|
1818
|
+
startOffset: 0,
|
|
1819
|
+
endOffset: 0
|
|
1820
|
+
},
|
|
1821
|
+
rationale: fragment.reason,
|
|
1822
|
+
accepted: false
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return suggestions.sort((a, b) => a.priority - b.priority).slice(0, 10);
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Estimate risk reduction from applying a suggestion
|
|
1830
|
+
*/
|
|
1831
|
+
estimateRiskReduction(impact) {
|
|
1832
|
+
switch (impact) {
|
|
1833
|
+
case "critical":
|
|
1834
|
+
return 25;
|
|
1835
|
+
case "high":
|
|
1836
|
+
return 15;
|
|
1837
|
+
case "medium":
|
|
1838
|
+
return 8;
|
|
1839
|
+
case "low":
|
|
1840
|
+
return 3;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Empty semantic fingerprint for when analysis is disabled
|
|
1845
|
+
*/
|
|
1846
|
+
emptySemanticFingerprint() {
|
|
1847
|
+
return {
|
|
1848
|
+
estimatedPopulationSize: WORLD_POPULATION,
|
|
1849
|
+
populationDescription: "Analysis disabled",
|
|
1850
|
+
uniquenessDrivers: [],
|
|
1851
|
+
riskScore: 0,
|
|
1852
|
+
riskLevel: "minimal"
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Empty cross-reference risk for when analysis is disabled
|
|
1857
|
+
*/
|
|
1858
|
+
emptyCrossReferenceRisk() {
|
|
1859
|
+
return {
|
|
1860
|
+
searchableFragments: [],
|
|
1861
|
+
vulnerableSources: [],
|
|
1862
|
+
riskScore: 0
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Update configuration
|
|
1867
|
+
*/
|
|
1868
|
+
setConfig(config) {
|
|
1869
|
+
this.config = { ...this.config, ...config };
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Get current configuration
|
|
1873
|
+
*/
|
|
1874
|
+
getConfig() {
|
|
1875
|
+
return { ...this.config };
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
var adversarialVerifier = new AdversarialVerifier();
|
|
1879
|
+
|
|
1880
|
+
// src/hooks/useDocumentProcessor.ts
|
|
1881
|
+
var import_react3 = require("react");
|
|
1882
|
+
|
|
1883
|
+
// src/hooks/useFileEntropy.ts
|
|
1884
|
+
var import_react = require("react");
|
|
1885
|
+
var DEFAULT_BLOCK_SIZE = 256;
|
|
1886
|
+
var MAX_ENTROPY = 8;
|
|
1887
|
+
function calculateBlockEntropy(bytes) {
|
|
1888
|
+
if (bytes.length === 0) return 0;
|
|
1889
|
+
const freq = /* @__PURE__ */ new Map();
|
|
1890
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1891
|
+
const byte = bytes[i];
|
|
1892
|
+
freq.set(byte, (freq.get(byte) || 0) + 1);
|
|
1893
|
+
}
|
|
1894
|
+
let entropy = 0;
|
|
1895
|
+
const len = bytes.length;
|
|
1896
|
+
for (const count of freq.values()) {
|
|
1897
|
+
const p = count / len;
|
|
1898
|
+
entropy -= p * Math.log2(p);
|
|
1899
|
+
}
|
|
1900
|
+
return entropy;
|
|
1901
|
+
}
|
|
1902
|
+
function normalizeEntropy(entropy) {
|
|
1903
|
+
return Math.min(1, Math.max(0, entropy / MAX_ENTROPY));
|
|
1904
|
+
}
|
|
1905
|
+
function blockContainsPII(blockOffset, blockSize, detections) {
|
|
1906
|
+
const blockEnd = blockOffset + blockSize;
|
|
1907
|
+
for (const detection of detections) {
|
|
1908
|
+
if (detection.startOffset < blockEnd && detection.endOffset > blockOffset) {
|
|
1909
|
+
return true;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
return false;
|
|
1913
|
+
}
|
|
1914
|
+
function useFileEntropy(options = {}) {
|
|
1915
|
+
const { blockSize = DEFAULT_BLOCK_SIZE } = options;
|
|
1916
|
+
const [isCalculating, setIsCalculating] = (0, import_react.useState)(false);
|
|
1917
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
1918
|
+
const calculateEntropy = (0, import_react.useCallback)(
|
|
1919
|
+
(bytes, detections = []) => {
|
|
1920
|
+
if (bytes.length === 0) {
|
|
1921
|
+
return {
|
|
1922
|
+
blocks: [],
|
|
1923
|
+
globalEntropy: 0,
|
|
1924
|
+
maxEntropy: 0,
|
|
1925
|
+
minEntropy: 0,
|
|
1926
|
+
totalBytes: 0,
|
|
1927
|
+
blockSize
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
const blocks = [];
|
|
1931
|
+
let minEntropy = MAX_ENTROPY;
|
|
1932
|
+
let maxEntropy = 0;
|
|
1933
|
+
const numBlocks = Math.ceil(bytes.length / blockSize);
|
|
1934
|
+
for (let i = 0; i < numBlocks; i++) {
|
|
1935
|
+
const offset = i * blockSize;
|
|
1936
|
+
const end = Math.min(offset + blockSize, bytes.length);
|
|
1937
|
+
const blockBytes = bytes.slice(offset, end);
|
|
1938
|
+
const entropy = calculateBlockEntropy(blockBytes);
|
|
1939
|
+
const normalizedEntropy = normalizeEntropy(entropy);
|
|
1940
|
+
minEntropy = Math.min(minEntropy, entropy);
|
|
1941
|
+
maxEntropy = Math.max(maxEntropy, entropy);
|
|
1942
|
+
blocks.push({
|
|
1943
|
+
index: i,
|
|
1944
|
+
entropy,
|
|
1945
|
+
normalizedEntropy,
|
|
1946
|
+
offset,
|
|
1947
|
+
size: end - offset,
|
|
1948
|
+
containsPII: blockContainsPII(offset, end - offset, detections)
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
const globalEntropy = calculateBlockEntropy(bytes);
|
|
1952
|
+
return {
|
|
1953
|
+
blocks,
|
|
1954
|
+
globalEntropy,
|
|
1955
|
+
maxEntropy,
|
|
1956
|
+
minEntropy: blocks.length > 0 ? minEntropy : 0,
|
|
1957
|
+
totalBytes: bytes.length,
|
|
1958
|
+
blockSize
|
|
1959
|
+
};
|
|
1960
|
+
},
|
|
1961
|
+
[blockSize]
|
|
1962
|
+
);
|
|
1963
|
+
const calculateFileEntropy = (0, import_react.useCallback)(
|
|
1964
|
+
async (file, detections = []) => {
|
|
1965
|
+
setIsCalculating(true);
|
|
1966
|
+
setError(null);
|
|
1967
|
+
try {
|
|
1968
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
1969
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
1970
|
+
return calculateEntropy(bytes, detections);
|
|
1971
|
+
} catch (err) {
|
|
1972
|
+
const error2 = err instanceof Error ? err : new Error("Failed to read file");
|
|
1973
|
+
setError(error2);
|
|
1974
|
+
throw error2;
|
|
1975
|
+
} finally {
|
|
1976
|
+
setIsCalculating(false);
|
|
1977
|
+
}
|
|
1978
|
+
},
|
|
1979
|
+
[calculateEntropy]
|
|
1980
|
+
);
|
|
1981
|
+
const calculateTextEntropy = (0, import_react.useCallback)(
|
|
1982
|
+
(text, detections = []) => {
|
|
1983
|
+
const encoder = new TextEncoder();
|
|
1984
|
+
const bytes = encoder.encode(text);
|
|
1985
|
+
return calculateEntropy(bytes, detections);
|
|
1986
|
+
},
|
|
1987
|
+
[calculateEntropy]
|
|
1988
|
+
);
|
|
1989
|
+
const compareEntropy = (0, import_react.useCallback)(
|
|
1990
|
+
(before, after) => {
|
|
1991
|
+
const changedRegions = [];
|
|
1992
|
+
if (before && after) {
|
|
1993
|
+
const minBlocks = Math.min(before.blocks.length, after.blocks.length);
|
|
1994
|
+
let regionStart = null;
|
|
1995
|
+
let regionDrop = 0;
|
|
1996
|
+
for (let i = 0; i < minBlocks; i++) {
|
|
1997
|
+
const entropyDrop = before.blocks[i].entropy - after.blocks[i].entropy;
|
|
1998
|
+
if (entropyDrop > 1) {
|
|
1999
|
+
if (regionStart === null) {
|
|
2000
|
+
regionStart = i;
|
|
2001
|
+
regionDrop = entropyDrop;
|
|
2002
|
+
} else {
|
|
2003
|
+
regionDrop = Math.max(regionDrop, entropyDrop);
|
|
2004
|
+
}
|
|
2005
|
+
} else if (regionStart !== null) {
|
|
2006
|
+
changedRegions.push({
|
|
2007
|
+
startBlock: regionStart,
|
|
2008
|
+
endBlock: i - 1,
|
|
2009
|
+
entropyDrop: regionDrop
|
|
2010
|
+
});
|
|
2011
|
+
regionStart = null;
|
|
2012
|
+
regionDrop = 0;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
if (regionStart !== null) {
|
|
2016
|
+
changedRegions.push({
|
|
2017
|
+
startBlock: regionStart,
|
|
2018
|
+
endBlock: minBlocks - 1,
|
|
2019
|
+
entropyDrop: regionDrop
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return {
|
|
2024
|
+
before,
|
|
2025
|
+
after,
|
|
2026
|
+
changedRegions
|
|
2027
|
+
};
|
|
2028
|
+
},
|
|
2029
|
+
[]
|
|
2030
|
+
);
|
|
2031
|
+
return (0, import_react.useMemo)(
|
|
2032
|
+
() => ({
|
|
2033
|
+
calculateEntropy,
|
|
2034
|
+
calculateFileEntropy,
|
|
2035
|
+
calculateTextEntropy,
|
|
2036
|
+
compareEntropy,
|
|
2037
|
+
isCalculating,
|
|
2038
|
+
error
|
|
2039
|
+
}),
|
|
2040
|
+
[
|
|
2041
|
+
calculateEntropy,
|
|
2042
|
+
calculateFileEntropy,
|
|
2043
|
+
calculateTextEntropy,
|
|
2044
|
+
compareEntropy,
|
|
2045
|
+
isCalculating,
|
|
2046
|
+
error
|
|
2047
|
+
]
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// src/hooks/useOfflineQuota.ts
|
|
2052
|
+
var import_react2 = require("react");
|
|
2053
|
+
|
|
2054
|
+
// src/services/quota/DeviceFingerprint.ts
|
|
2055
|
+
async function generateDeviceFingerprint() {
|
|
2056
|
+
const components = [
|
|
2057
|
+
// User agent (browser + OS)
|
|
2058
|
+
navigator.userAgent,
|
|
2059
|
+
// Language preference
|
|
2060
|
+
navigator.language,
|
|
2061
|
+
// Screen dimensions (stable across sessions)
|
|
2062
|
+
`${screen.width}x${screen.height}x${screen.colorDepth}`,
|
|
2063
|
+
// Timezone
|
|
2064
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
2065
|
+
// Platform
|
|
2066
|
+
navigator.platform,
|
|
2067
|
+
// Hardware concurrency (CPU cores)
|
|
2068
|
+
navigator.hardwareConcurrency?.toString() || "unknown",
|
|
2069
|
+
// Device memory (if available)
|
|
2070
|
+
navigator.deviceMemory?.toString() || "unknown"
|
|
2071
|
+
];
|
|
2072
|
+
const canvasFingerprint = await getCanvasFingerprint();
|
|
2073
|
+
if (canvasFingerprint) {
|
|
2074
|
+
components.push(canvasFingerprint);
|
|
2075
|
+
}
|
|
2076
|
+
const combined = components.join("|");
|
|
2077
|
+
const hash = await sha256(combined);
|
|
2078
|
+
return hash.substring(0, 16);
|
|
2079
|
+
}
|
|
2080
|
+
async function getCanvasFingerprint() {
|
|
2081
|
+
try {
|
|
2082
|
+
const canvas = document.createElement("canvas");
|
|
2083
|
+
canvas.width = 200;
|
|
2084
|
+
canvas.height = 50;
|
|
2085
|
+
const ctx = canvas.getContext("2d");
|
|
2086
|
+
if (!ctx) return null;
|
|
2087
|
+
ctx.textBaseline = "top";
|
|
2088
|
+
ctx.font = "14px Arial";
|
|
2089
|
+
ctx.fillStyle = "#f60";
|
|
2090
|
+
ctx.fillRect(10, 1, 62, 20);
|
|
2091
|
+
ctx.fillStyle = "#069";
|
|
2092
|
+
ctx.fillText("PURGE", 15, 5);
|
|
2093
|
+
ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
|
|
2094
|
+
ctx.fillText("quota", 60, 5);
|
|
2095
|
+
const dataUrl = canvas.toDataURL();
|
|
2096
|
+
return await sha256(dataUrl);
|
|
2097
|
+
} catch {
|
|
2098
|
+
return null;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async function sha256(message) {
|
|
2102
|
+
const encoder = new TextEncoder();
|
|
2103
|
+
const data = encoder.encode(message);
|
|
2104
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
2105
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
2106
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2107
|
+
}
|
|
2108
|
+
async function verifyDeviceFingerprint(storedFingerprint) {
|
|
2109
|
+
const currentFingerprint = await generateDeviceFingerprint();
|
|
2110
|
+
if (currentFingerprint === storedFingerprint) {
|
|
2111
|
+
return { matches: true, similarity: 1 };
|
|
2112
|
+
}
|
|
2113
|
+
let matching = 0;
|
|
2114
|
+
for (let i = 0; i < Math.min(currentFingerprint.length, storedFingerprint.length); i++) {
|
|
2115
|
+
if (currentFingerprint[i] === storedFingerprint[i]) {
|
|
2116
|
+
matching++;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
const similarity = matching / Math.max(currentFingerprint.length, storedFingerprint.length);
|
|
2120
|
+
return {
|
|
2121
|
+
matches: similarity >= 0.75,
|
|
2122
|
+
similarity
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// src/services/quota/OfflineQuotaStore.ts
|
|
2127
|
+
var LS_KEY = "purge_offline_quota";
|
|
2128
|
+
var IDB_NAME = "purge_quota_db";
|
|
2129
|
+
var IDB_STORE = "quota";
|
|
2130
|
+
var IDB_KEY = "current";
|
|
2131
|
+
var SCHEMA_VERSION = 1;
|
|
2132
|
+
var DEFAULT_TOKENS = 50;
|
|
2133
|
+
var HMAC_SALT = "purge_quota_v1_";
|
|
2134
|
+
async function deriveKey(fingerprint) {
|
|
2135
|
+
const encoder = new TextEncoder();
|
|
2136
|
+
const keyMaterial = encoder.encode(HMAC_SALT + fingerprint);
|
|
2137
|
+
const rawKey = await crypto.subtle.importKey(
|
|
2138
|
+
"raw",
|
|
2139
|
+
keyMaterial,
|
|
2140
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
2141
|
+
false,
|
|
2142
|
+
["sign", "verify"]
|
|
2143
|
+
);
|
|
2144
|
+
return rawKey;
|
|
2145
|
+
}
|
|
2146
|
+
async function signState(state, fingerprint) {
|
|
2147
|
+
const key = await deriveKey(fingerprint);
|
|
2148
|
+
const encoder = new TextEncoder();
|
|
2149
|
+
const data = encoder.encode(JSON.stringify(state));
|
|
2150
|
+
const signature = await crypto.subtle.sign("HMAC", key, data);
|
|
2151
|
+
const signatureArray = Array.from(new Uint8Array(signature));
|
|
2152
|
+
return signatureArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2153
|
+
}
|
|
2154
|
+
async function verifySignature(signed, fingerprint) {
|
|
2155
|
+
try {
|
|
2156
|
+
const expectedSignature = await signState(signed.data, fingerprint);
|
|
2157
|
+
return expectedSignature === signed.signature;
|
|
2158
|
+
} catch {
|
|
2159
|
+
return false;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
function openDatabase() {
|
|
2163
|
+
return new Promise((resolve, reject) => {
|
|
2164
|
+
const request = indexedDB.open(IDB_NAME, 1);
|
|
2165
|
+
request.onerror = () => reject(request.error);
|
|
2166
|
+
request.onsuccess = () => resolve(request.result);
|
|
2167
|
+
request.onupgradeneeded = (event) => {
|
|
2168
|
+
const db = event.target.result;
|
|
2169
|
+
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
|
2170
|
+
db.createObjectStore(IDB_STORE);
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
async function readFromIDB() {
|
|
2176
|
+
try {
|
|
2177
|
+
const db = await openDatabase();
|
|
2178
|
+
return new Promise((resolve, reject) => {
|
|
2179
|
+
const tx = db.transaction(IDB_STORE, "readonly");
|
|
2180
|
+
const store = tx.objectStore(IDB_STORE);
|
|
2181
|
+
const request = store.get(IDB_KEY);
|
|
2182
|
+
request.onerror = () => reject(request.error);
|
|
2183
|
+
request.onsuccess = () => resolve(request.result || null);
|
|
2184
|
+
tx.oncomplete = () => db.close();
|
|
2185
|
+
});
|
|
2186
|
+
} catch {
|
|
2187
|
+
return null;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
async function writeToIDB(signed) {
|
|
2191
|
+
try {
|
|
2192
|
+
const db = await openDatabase();
|
|
2193
|
+
return new Promise((resolve, reject) => {
|
|
2194
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
2195
|
+
const store = tx.objectStore(IDB_STORE);
|
|
2196
|
+
const request = store.put(signed, IDB_KEY);
|
|
2197
|
+
request.onerror = () => reject(request.error);
|
|
2198
|
+
tx.oncomplete = () => {
|
|
2199
|
+
db.close();
|
|
2200
|
+
resolve();
|
|
2201
|
+
};
|
|
2202
|
+
});
|
|
2203
|
+
} catch {
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
async function clearIDB() {
|
|
2207
|
+
try {
|
|
2208
|
+
const db = await openDatabase();
|
|
2209
|
+
return new Promise((resolve, reject) => {
|
|
2210
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
2211
|
+
const store = tx.objectStore(IDB_STORE);
|
|
2212
|
+
const request = store.delete(IDB_KEY);
|
|
2213
|
+
request.onerror = () => reject(request.error);
|
|
2214
|
+
tx.oncomplete = () => {
|
|
2215
|
+
db.close();
|
|
2216
|
+
resolve();
|
|
2217
|
+
};
|
|
2218
|
+
});
|
|
2219
|
+
} catch {
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
var OfflineQuotaStore = class {
|
|
2223
|
+
constructor() {
|
|
2224
|
+
this.cachedState = null;
|
|
2225
|
+
this.fingerprint = null;
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Initializes the store, loading existing state or creating new
|
|
2229
|
+
*/
|
|
2230
|
+
async initialize() {
|
|
2231
|
+
this.fingerprint = await generateDeviceFingerprint();
|
|
2232
|
+
const state = await this.load();
|
|
2233
|
+
if (state) {
|
|
2234
|
+
this.cachedState = state;
|
|
2235
|
+
return state;
|
|
2236
|
+
}
|
|
2237
|
+
const newState = await this.createInitialState();
|
|
2238
|
+
this.cachedState = newState;
|
|
2239
|
+
return newState;
|
|
2240
|
+
}
|
|
2241
|
+
/**
|
|
2242
|
+
* Gets current tokens remaining (cached for performance)
|
|
2243
|
+
*/
|
|
2244
|
+
getTokens() {
|
|
2245
|
+
return this.cachedState?.tokensRemaining ?? 0;
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* Checks if processing is allowed
|
|
2249
|
+
*/
|
|
2250
|
+
canProcess() {
|
|
2251
|
+
return (this.cachedState?.tokensRemaining ?? 0) > 0;
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Decrements quota by 1 token. Returns new remaining count.
|
|
2255
|
+
* Throws if quota is exhausted.
|
|
2256
|
+
*/
|
|
2257
|
+
async decrement() {
|
|
2258
|
+
if (!this.cachedState || !this.fingerprint) {
|
|
2259
|
+
throw new Error("QuotaStore not initialized");
|
|
2260
|
+
}
|
|
2261
|
+
if (this.cachedState.tokensRemaining <= 0) {
|
|
2262
|
+
throw new QuotaExhaustedError("No tokens remaining");
|
|
2263
|
+
}
|
|
2264
|
+
const newState = {
|
|
2265
|
+
...this.cachedState,
|
|
2266
|
+
tokensRemaining: this.cachedState.tokensRemaining - 1
|
|
2267
|
+
};
|
|
2268
|
+
await this.save(newState);
|
|
2269
|
+
this.cachedState = newState;
|
|
2270
|
+
return newState.tokensRemaining;
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Resets quota to specified token count (used by refresh API)
|
|
2274
|
+
*/
|
|
2275
|
+
async reset(tokens) {
|
|
2276
|
+
if (!this.fingerprint) {
|
|
2277
|
+
this.fingerprint = await generateDeviceFingerprint();
|
|
2278
|
+
}
|
|
2279
|
+
const newState = {
|
|
2280
|
+
tokensRemaining: Math.min(tokens, DEFAULT_TOKENS),
|
|
2281
|
+
lastRefresh: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2282
|
+
deviceFingerprint: this.fingerprint,
|
|
2283
|
+
version: SCHEMA_VERSION
|
|
2284
|
+
};
|
|
2285
|
+
await this.save(newState);
|
|
2286
|
+
this.cachedState = newState;
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Loads and validates quota state from storage
|
|
2290
|
+
*/
|
|
2291
|
+
async load() {
|
|
2292
|
+
if (!this.fingerprint) {
|
|
2293
|
+
this.fingerprint = await generateDeviceFingerprint();
|
|
2294
|
+
}
|
|
2295
|
+
const lsData = this.loadFromLocalStorage();
|
|
2296
|
+
const idbData = await readFromIDB();
|
|
2297
|
+
if (!lsData && !idbData) {
|
|
2298
|
+
return null;
|
|
2299
|
+
}
|
|
2300
|
+
if (lsData && !idbData) {
|
|
2301
|
+
const valid = await this.validateState(lsData);
|
|
2302
|
+
if (valid) {
|
|
2303
|
+
await writeToIDB(lsData);
|
|
2304
|
+
return lsData.data;
|
|
2305
|
+
}
|
|
2306
|
+
await this.handleTampering("localStorage signature invalid");
|
|
2307
|
+
return null;
|
|
2308
|
+
}
|
|
2309
|
+
if (!lsData && idbData) {
|
|
2310
|
+
const valid = await this.validateState(idbData);
|
|
2311
|
+
if (valid) {
|
|
2312
|
+
this.saveToLocalStorage(idbData);
|
|
2313
|
+
return idbData.data;
|
|
2314
|
+
}
|
|
2315
|
+
await this.handleTampering("IndexedDB signature invalid");
|
|
2316
|
+
return null;
|
|
2317
|
+
}
|
|
2318
|
+
if (lsData && idbData) {
|
|
2319
|
+
const lsValid = await this.validateState(lsData);
|
|
2320
|
+
const idbValid = await this.validateState(idbData);
|
|
2321
|
+
if (!lsValid && !idbValid) {
|
|
2322
|
+
await this.handleTampering("Both storage locations tampered");
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
2325
|
+
if (lsValid && !idbValid) {
|
|
2326
|
+
await writeToIDB(lsData);
|
|
2327
|
+
return lsData.data;
|
|
2328
|
+
}
|
|
2329
|
+
if (!lsValid && idbValid) {
|
|
2330
|
+
this.saveToLocalStorage(idbData);
|
|
2331
|
+
return idbData.data;
|
|
2332
|
+
}
|
|
2333
|
+
if (lsData.data.tokensRemaining !== idbData.data.tokensRemaining) {
|
|
2334
|
+
const trustedState = lsData.data.tokensRemaining < idbData.data.tokensRemaining ? lsData : idbData;
|
|
2335
|
+
this.saveToLocalStorage(trustedState);
|
|
2336
|
+
await writeToIDB(trustedState);
|
|
2337
|
+
return trustedState.data;
|
|
2338
|
+
}
|
|
2339
|
+
return lsData.data;
|
|
2340
|
+
}
|
|
2341
|
+
return null;
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Saves quota state to both storage locations
|
|
2345
|
+
*/
|
|
2346
|
+
async save(state) {
|
|
2347
|
+
if (!this.fingerprint) {
|
|
2348
|
+
throw new Error("Fingerprint not initialized");
|
|
2349
|
+
}
|
|
2350
|
+
const signature = await signState(state, this.fingerprint);
|
|
2351
|
+
const signed = { data: state, signature };
|
|
2352
|
+
this.saveToLocalStorage(signed);
|
|
2353
|
+
await writeToIDB(signed);
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Creates initial state for new users
|
|
2357
|
+
*/
|
|
2358
|
+
async createInitialState() {
|
|
2359
|
+
if (!this.fingerprint) {
|
|
2360
|
+
this.fingerprint = await generateDeviceFingerprint();
|
|
2361
|
+
}
|
|
2362
|
+
const state = {
|
|
2363
|
+
tokensRemaining: DEFAULT_TOKENS,
|
|
2364
|
+
lastRefresh: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2365
|
+
deviceFingerprint: this.fingerprint,
|
|
2366
|
+
version: SCHEMA_VERSION
|
|
2367
|
+
};
|
|
2368
|
+
await this.save(state);
|
|
2369
|
+
return state;
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Validates a signed state
|
|
2373
|
+
*/
|
|
2374
|
+
async validateState(signed) {
|
|
2375
|
+
if (!this.fingerprint) return false;
|
|
2376
|
+
const signatureValid = await verifySignature(signed, this.fingerprint);
|
|
2377
|
+
if (!signatureValid) return false;
|
|
2378
|
+
const fpResult = await verifyDeviceFingerprint(signed.data.deviceFingerprint);
|
|
2379
|
+
if (!fpResult.matches) return false;
|
|
2380
|
+
if (signed.data.version !== SCHEMA_VERSION) {
|
|
2381
|
+
return false;
|
|
2382
|
+
}
|
|
2383
|
+
if (signed.data.tokensRemaining < 0 || signed.data.tokensRemaining > DEFAULT_TOKENS) {
|
|
2384
|
+
return false;
|
|
2385
|
+
}
|
|
2386
|
+
return true;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Handles detected tampering by resetting quota to 0
|
|
2390
|
+
*/
|
|
2391
|
+
async handleTampering(reason) {
|
|
2392
|
+
secureWarn(`Quota tampering detected: ${reason}`);
|
|
2393
|
+
const zeroState = {
|
|
2394
|
+
tokensRemaining: 0,
|
|
2395
|
+
lastRefresh: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2396
|
+
deviceFingerprint: this.fingerprint || await generateDeviceFingerprint(),
|
|
2397
|
+
version: SCHEMA_VERSION
|
|
2398
|
+
};
|
|
2399
|
+
await this.save(zeroState);
|
|
2400
|
+
this.cachedState = zeroState;
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* LocalStorage helpers
|
|
2404
|
+
*/
|
|
2405
|
+
loadFromLocalStorage() {
|
|
2406
|
+
try {
|
|
2407
|
+
const raw = localStorage.getItem(LS_KEY);
|
|
2408
|
+
if (!raw) return null;
|
|
2409
|
+
return JSON.parse(raw);
|
|
2410
|
+
} catch {
|
|
2411
|
+
return null;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
saveToLocalStorage(signed) {
|
|
2415
|
+
try {
|
|
2416
|
+
localStorage.setItem(LS_KEY, JSON.stringify(signed));
|
|
2417
|
+
} catch {
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
/**
|
|
2421
|
+
* Clears all quota data (for testing/debugging)
|
|
2422
|
+
*/
|
|
2423
|
+
async clear() {
|
|
2424
|
+
localStorage.removeItem(LS_KEY);
|
|
2425
|
+
await clearIDB();
|
|
2426
|
+
this.cachedState = null;
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
var QuotaExhaustedError = class extends Error {
|
|
2430
|
+
constructor(message) {
|
|
2431
|
+
super(message);
|
|
2432
|
+
this.name = "QuotaExhaustedError";
|
|
2433
|
+
}
|
|
2434
|
+
};
|
|
2435
|
+
var offlineQuotaStore = new OfflineQuotaStore();
|
|
2436
|
+
|
|
2437
|
+
// src/services/quota/QuotaRefreshAPI.ts
|
|
2438
|
+
async function checkOnlineStatus() {
|
|
2439
|
+
if (!navigator.onLine) {
|
|
2440
|
+
return false;
|
|
2441
|
+
}
|
|
2442
|
+
return true;
|
|
2443
|
+
}
|
|
2444
|
+
async function requestQuotaRefresh() {
|
|
2445
|
+
const isOnline = await checkOnlineStatus();
|
|
2446
|
+
if (!isOnline) {
|
|
2447
|
+
return {
|
|
2448
|
+
success: false,
|
|
2449
|
+
reason: "offline"
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
return {
|
|
2453
|
+
success: false,
|
|
2454
|
+
reason: "not_authenticated"
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// src/hooks/useOfflineQuota.ts
|
|
2459
|
+
function useOfflineQuota() {
|
|
2460
|
+
const [tokensRemaining, setTokensRemaining] = (0, import_react2.useState)(0);
|
|
2461
|
+
const [isInitialized, setIsInitialized] = (0, import_react2.useState)(false);
|
|
2462
|
+
const [quotaError, setQuotaError] = (0, import_react2.useState)(null);
|
|
2463
|
+
const [lastRefresh, setLastRefresh] = (0, import_react2.useState)(null);
|
|
2464
|
+
(0, import_react2.useEffect)(() => {
|
|
2465
|
+
let mounted = true;
|
|
2466
|
+
async function init() {
|
|
2467
|
+
try {
|
|
2468
|
+
const state = await offlineQuotaStore.initialize();
|
|
2469
|
+
if (mounted) {
|
|
2470
|
+
setTokensRemaining(state.tokensRemaining);
|
|
2471
|
+
setLastRefresh(state.lastRefresh);
|
|
2472
|
+
setIsInitialized(true);
|
|
2473
|
+
setQuotaError(null);
|
|
2474
|
+
}
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
if (mounted) {
|
|
2477
|
+
setQuotaError(
|
|
2478
|
+
error instanceof Error ? error.message : "Failed to initialize quota"
|
|
2479
|
+
);
|
|
2480
|
+
setIsInitialized(true);
|
|
2481
|
+
setTokensRemaining(0);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
init();
|
|
2486
|
+
return () => {
|
|
2487
|
+
mounted = false;
|
|
2488
|
+
};
|
|
2489
|
+
}, []);
|
|
2490
|
+
const canProcessFile = (0, import_react2.useCallback)(() => {
|
|
2491
|
+
if (!isInitialized) return false;
|
|
2492
|
+
return offlineQuotaStore.canProcess();
|
|
2493
|
+
}, [isInitialized]);
|
|
2494
|
+
const consumeToken = (0, import_react2.useCallback)(async () => {
|
|
2495
|
+
try {
|
|
2496
|
+
const remaining = await offlineQuotaStore.decrement();
|
|
2497
|
+
setTokensRemaining(remaining);
|
|
2498
|
+
setQuotaError(null);
|
|
2499
|
+
} catch (error) {
|
|
2500
|
+
if (error instanceof QuotaExhaustedError) {
|
|
2501
|
+
setTokensRemaining(0);
|
|
2502
|
+
setQuotaError("Offline quota exhausted. Go online to continue.");
|
|
2503
|
+
} else {
|
|
2504
|
+
setQuotaError(
|
|
2505
|
+
error instanceof Error ? error.message : "Failed to update quota"
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
throw error;
|
|
2509
|
+
}
|
|
2510
|
+
}, []);
|
|
2511
|
+
const requestRefresh = (0, import_react2.useCallback)(async () => {
|
|
2512
|
+
try {
|
|
2513
|
+
const result = await requestQuotaRefresh();
|
|
2514
|
+
if (result.success) {
|
|
2515
|
+
await offlineQuotaStore.reset(result.tokens);
|
|
2516
|
+
setTokensRemaining(result.tokens);
|
|
2517
|
+
setLastRefresh((/* @__PURE__ */ new Date()).toISOString());
|
|
2518
|
+
setQuotaError(null);
|
|
2519
|
+
}
|
|
2520
|
+
return result;
|
|
2521
|
+
} catch (error) {
|
|
2522
|
+
const errorResult = {
|
|
2523
|
+
success: false,
|
|
2524
|
+
reason: "api_error"
|
|
2525
|
+
};
|
|
2526
|
+
setQuotaError(
|
|
2527
|
+
error instanceof Error ? error.message : "Failed to refresh quota"
|
|
2528
|
+
);
|
|
2529
|
+
return errorResult;
|
|
2530
|
+
}
|
|
2531
|
+
}, []);
|
|
2532
|
+
return {
|
|
2533
|
+
tokensRemaining,
|
|
2534
|
+
isExhausted: tokensRemaining <= 0,
|
|
2535
|
+
isInitialized,
|
|
2536
|
+
canProcessFile,
|
|
2537
|
+
consumeToken,
|
|
2538
|
+
requestRefresh,
|
|
2539
|
+
quotaError,
|
|
2540
|
+
lastRefresh
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// src/utils/partialMask.ts
|
|
2545
|
+
var MASK_CHAR = "*";
|
|
2546
|
+
function maskSSN(value) {
|
|
2547
|
+
const digits = value.replace(/\D/g, "");
|
|
2548
|
+
if (digits.length !== 9) {
|
|
2549
|
+
return MASK_CHAR.repeat(value.length);
|
|
2550
|
+
}
|
|
2551
|
+
const last4 = digits.slice(-4);
|
|
2552
|
+
return `${MASK_CHAR}${MASK_CHAR}${MASK_CHAR}-${MASK_CHAR}${MASK_CHAR}-${last4}`;
|
|
2553
|
+
}
|
|
2554
|
+
function maskCreditCard(value) {
|
|
2555
|
+
const digits = value.replace(/\D/g, "");
|
|
2556
|
+
if (digits.length < 15 || digits.length > 16) {
|
|
2557
|
+
return MASK_CHAR.repeat(value.length);
|
|
2558
|
+
}
|
|
2559
|
+
const last4 = digits.slice(-4);
|
|
2560
|
+
if (value.includes("-")) {
|
|
2561
|
+
if (digits.length === 16) {
|
|
2562
|
+
return `${MASK_CHAR.repeat(4)}-${MASK_CHAR.repeat(4)}-${MASK_CHAR.repeat(4)}-${last4}`;
|
|
2563
|
+
}
|
|
2564
|
+
return `${MASK_CHAR.repeat(4)}-${MASK_CHAR.repeat(6)}-${MASK_CHAR}${last4}`;
|
|
2565
|
+
} else if (value.includes(" ")) {
|
|
2566
|
+
if (digits.length === 16) {
|
|
2567
|
+
return `${MASK_CHAR.repeat(4)} ${MASK_CHAR.repeat(4)} ${MASK_CHAR.repeat(4)} ${last4}`;
|
|
2568
|
+
}
|
|
2569
|
+
return `${MASK_CHAR.repeat(4)} ${MASK_CHAR.repeat(6)} ${MASK_CHAR}${last4}`;
|
|
2570
|
+
}
|
|
2571
|
+
return MASK_CHAR.repeat(digits.length - 4) + last4;
|
|
2572
|
+
}
|
|
2573
|
+
function maskEmail(value) {
|
|
2574
|
+
const atIndex = value.indexOf("@");
|
|
2575
|
+
if (atIndex < 1) {
|
|
2576
|
+
return MASK_CHAR.repeat(value.length);
|
|
2577
|
+
}
|
|
2578
|
+
const localPart = value.slice(0, atIndex);
|
|
2579
|
+
const domain = value.slice(atIndex);
|
|
2580
|
+
if (localPart.length <= 1) {
|
|
2581
|
+
return localPart + MASK_CHAR.repeat(3) + domain;
|
|
2582
|
+
}
|
|
2583
|
+
const firstChar = localPart[0];
|
|
2584
|
+
const maskLength = Math.min(localPart.length - 1, 5);
|
|
2585
|
+
return firstChar + MASK_CHAR.repeat(maskLength) + domain;
|
|
2586
|
+
}
|
|
2587
|
+
function maskPhone(value) {
|
|
2588
|
+
const digits = value.replace(/\D/g, "");
|
|
2589
|
+
if (digits.length < 10) {
|
|
2590
|
+
return MASK_CHAR.repeat(value.length);
|
|
2591
|
+
}
|
|
2592
|
+
const last4 = digits.slice(-4);
|
|
2593
|
+
if (value.includes("(") && value.includes(")")) {
|
|
2594
|
+
return `(${MASK_CHAR.repeat(3)}) ${MASK_CHAR.repeat(3)}-${last4}`;
|
|
2595
|
+
} else if (value.startsWith("+")) {
|
|
2596
|
+
const countryCodeMatch = value.match(/^\+\d+/);
|
|
2597
|
+
const countryCode = countryCodeMatch ? countryCodeMatch[0] : "+1";
|
|
2598
|
+
return `${countryCode}-${MASK_CHAR.repeat(3)}-${MASK_CHAR.repeat(3)}-${last4}`;
|
|
2599
|
+
} else if (value.includes("-")) {
|
|
2600
|
+
return `${MASK_CHAR.repeat(3)}-${MASK_CHAR.repeat(3)}-${last4}`;
|
|
2601
|
+
} else if (value.includes(".")) {
|
|
2602
|
+
return `${MASK_CHAR.repeat(3)}.${MASK_CHAR.repeat(3)}.${last4}`;
|
|
2603
|
+
}
|
|
2604
|
+
return MASK_CHAR.repeat(digits.length - 4) + last4;
|
|
2605
|
+
}
|
|
2606
|
+
function maskAddress(value) {
|
|
2607
|
+
const match = value.match(/^(\d+)\s+(.+)$/);
|
|
2608
|
+
if (match) {
|
|
2609
|
+
const [, houseNum, streetPart] = match;
|
|
2610
|
+
return MASK_CHAR.repeat(houseNum.length) + " " + streetPart;
|
|
2611
|
+
}
|
|
2612
|
+
const words = value.split(/\s+/);
|
|
2613
|
+
if (words.length > 1) {
|
|
2614
|
+
words[0] = MASK_CHAR.repeat(words[0].length);
|
|
2615
|
+
return words.join(" ");
|
|
2616
|
+
}
|
|
2617
|
+
return MASK_CHAR.repeat(value.length);
|
|
2618
|
+
}
|
|
2619
|
+
function maskPersonName(value) {
|
|
2620
|
+
const parts = value.split(/\s+/);
|
|
2621
|
+
return parts.map((part) => {
|
|
2622
|
+
if (part.length === 0) return "";
|
|
2623
|
+
if (/^(Mr|Mrs|Ms|Miss|Dr|Prof)\.?$/i.test(part)) {
|
|
2624
|
+
return part;
|
|
2625
|
+
}
|
|
2626
|
+
if (part.length === 1) return part;
|
|
2627
|
+
const firstChar = part[0];
|
|
2628
|
+
const maskLength = Math.min(part.length - 1, 5);
|
|
2629
|
+
return firstChar + MASK_CHAR.repeat(maskLength);
|
|
2630
|
+
}).join(" ");
|
|
2631
|
+
}
|
|
2632
|
+
function maskIPAddress(value) {
|
|
2633
|
+
const octets = value.split(".");
|
|
2634
|
+
if (octets.length !== 4) {
|
|
2635
|
+
return MASK_CHAR.repeat(value.length);
|
|
2636
|
+
}
|
|
2637
|
+
return `${MASK_CHAR.repeat(3)}.${MASK_CHAR.repeat(3)}.${MASK_CHAR.repeat(3)}.${octets[3]}`;
|
|
2638
|
+
}
|
|
2639
|
+
function maskDateOfBirth(value) {
|
|
2640
|
+
if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test(value)) {
|
|
2641
|
+
const year = value.slice(0, 4);
|
|
2642
|
+
const sep = value.includes("-") ? "-" : "/";
|
|
2643
|
+
return `${year}${sep}${MASK_CHAR}${MASK_CHAR}${sep}${MASK_CHAR}${MASK_CHAR}`;
|
|
2644
|
+
}
|
|
2645
|
+
const match = value.match(/^(\d{1,2})([-/])(\d{1,2})\2(\d{4})$/);
|
|
2646
|
+
if (match) {
|
|
2647
|
+
const [, , sep, , year] = match;
|
|
2648
|
+
return `${MASK_CHAR}${MASK_CHAR}${sep}${MASK_CHAR}${MASK_CHAR}${sep}${year}`;
|
|
2649
|
+
}
|
|
2650
|
+
if (value.length >= 4) {
|
|
2651
|
+
return MASK_CHAR.repeat(value.length - 4) + value.slice(-4);
|
|
2652
|
+
}
|
|
2653
|
+
return MASK_CHAR.repeat(value.length);
|
|
2654
|
+
}
|
|
2655
|
+
function maskCustom(value) {
|
|
2656
|
+
if (value.length <= 4) {
|
|
2657
|
+
return MASK_CHAR.repeat(value.length);
|
|
2658
|
+
}
|
|
2659
|
+
const visibleCount = Math.min(2, Math.floor(value.length / 4));
|
|
2660
|
+
const start = value.slice(0, visibleCount);
|
|
2661
|
+
const end = value.slice(-visibleCount);
|
|
2662
|
+
const middleLength = value.length - visibleCount * 2;
|
|
2663
|
+
return start + MASK_CHAR.repeat(middleLength) + end;
|
|
2664
|
+
}
|
|
2665
|
+
function getPartialMask(category, value) {
|
|
2666
|
+
switch (category) {
|
|
2667
|
+
case "ssn":
|
|
2668
|
+
return maskSSN(value);
|
|
2669
|
+
case "credit_card":
|
|
2670
|
+
return maskCreditCard(value);
|
|
2671
|
+
case "email":
|
|
2672
|
+
return maskEmail(value);
|
|
2673
|
+
case "phone":
|
|
2674
|
+
return maskPhone(value);
|
|
2675
|
+
case "address":
|
|
2676
|
+
return maskAddress(value);
|
|
2677
|
+
case "person_name":
|
|
2678
|
+
return maskPersonName(value);
|
|
2679
|
+
case "ip_address":
|
|
2680
|
+
return maskIPAddress(value);
|
|
2681
|
+
case "date_of_birth":
|
|
2682
|
+
return maskDateOfBirth(value);
|
|
2683
|
+
case "custom":
|
|
2684
|
+
return maskCustom(value);
|
|
2685
|
+
default:
|
|
2686
|
+
if (value.length <= 2) {
|
|
2687
|
+
return MASK_CHAR.repeat(value.length);
|
|
2688
|
+
}
|
|
2689
|
+
return value[0] + MASK_CHAR.repeat(value.length - 2) + value[value.length - 1];
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// src/hooks/useDocumentProcessor.ts
|
|
2694
|
+
function getPurgedFilename(originalName) {
|
|
2695
|
+
const safeName = originalName.replace(/[/\\]/g, "_");
|
|
2696
|
+
const lastDot = safeName.lastIndexOf(".");
|
|
2697
|
+
if (lastDot === -1) return `${safeName}_purged`;
|
|
2698
|
+
return `${safeName.slice(0, lastDot)}_purged${safeName.slice(lastDot)}`;
|
|
2699
|
+
}
|
|
2700
|
+
function useDocumentProcessor() {
|
|
2701
|
+
const [isProcessing, setIsProcessing] = (0, import_react3.useState)(false);
|
|
2702
|
+
const [progress, setProgress] = (0, import_react3.useState)(null);
|
|
2703
|
+
const [detections, setDetections] = (0, import_react3.useState)([]);
|
|
2704
|
+
const [memoryStats, setMemoryStats] = (0, import_react3.useState)({
|
|
2705
|
+
allocated: 0,
|
|
2706
|
+
wiped: 0,
|
|
2707
|
+
buffersCleared: 0,
|
|
2708
|
+
totalBuffers: 0
|
|
2709
|
+
});
|
|
2710
|
+
const [entropyComparison, setEntropyComparison] = (0, import_react3.useState)({
|
|
2711
|
+
before: null,
|
|
2712
|
+
after: null,
|
|
2713
|
+
changedRegions: []
|
|
2714
|
+
});
|
|
2715
|
+
const [isCalculatingEntropy, setIsCalculatingEntropy] = (0, import_react3.useState)(false);
|
|
2716
|
+
const [contentSections, setContentSections] = (0, import_react3.useState)([]);
|
|
2717
|
+
const buffersRef = (0, import_react3.useRef)([]);
|
|
2718
|
+
const originalBytesRef = (0, import_react3.useRef)(null);
|
|
2719
|
+
const parsedDocsRef = (0, import_react3.useRef)(/* @__PURE__ */ new Map());
|
|
2720
|
+
const updateFileStatus = usePurgeStore((s) => s.updateFileStatus);
|
|
2721
|
+
const storeSetDetections = usePurgeStore((s) => s.setDetections);
|
|
2722
|
+
const { calculateEntropy, compareEntropy } = useFileEntropy();
|
|
2723
|
+
const { canProcessFile, consumeToken, tokensRemaining } = useOfflineQuota();
|
|
2724
|
+
(0, import_react3.useEffect)(() => {
|
|
2725
|
+
return () => {
|
|
2726
|
+
if (originalBytesRef.current) {
|
|
2727
|
+
originalBytesRef.current.fill(0);
|
|
2728
|
+
originalBytesRef.current = null;
|
|
2729
|
+
}
|
|
2730
|
+
buffersRef.current.forEach((buffer) => {
|
|
2731
|
+
new Uint8Array(buffer).fill(0);
|
|
2732
|
+
});
|
|
2733
|
+
buffersRef.current = [];
|
|
2734
|
+
parsedDocsRef.current.clear();
|
|
2735
|
+
secureLog("Memory cleanup on unmount");
|
|
2736
|
+
};
|
|
2737
|
+
}, []);
|
|
2738
|
+
const scanFiles = (0, import_react3.useCallback)(
|
|
2739
|
+
async (files, config, bypassQuota = false) => {
|
|
2740
|
+
if (!bypassQuota) {
|
|
2741
|
+
if (!canProcessFile()) {
|
|
2742
|
+
throw new QuotaExhaustedError(
|
|
2743
|
+
`Offline quota exhausted (${tokensRemaining} tokens remaining). Go online to refresh.`
|
|
2744
|
+
);
|
|
2745
|
+
}
|
|
2746
|
+
if (tokensRemaining < files.length) {
|
|
2747
|
+
throw new QuotaExhaustedError(
|
|
2748
|
+
`Insufficient quota: ${tokensRemaining} tokens remaining, ${files.length} files queued. Go online to refresh.`
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
setIsProcessing(true);
|
|
2753
|
+
setIsCalculatingEntropy(true);
|
|
2754
|
+
const allDetections = [];
|
|
2755
|
+
parsedDocsRef.current.clear();
|
|
2756
|
+
setContentSections([]);
|
|
2757
|
+
try {
|
|
2758
|
+
for (let i = 0; i < files.length; i++) {
|
|
2759
|
+
const file = files[i];
|
|
2760
|
+
if (!canProcessFile()) {
|
|
2761
|
+
updateFileStatus(file.id, "error", "Quota exhausted");
|
|
2762
|
+
break;
|
|
2763
|
+
}
|
|
2764
|
+
setProgress({
|
|
2765
|
+
currentFileIndex: i,
|
|
2766
|
+
totalFiles: files.length,
|
|
2767
|
+
currentFileName: file.name,
|
|
2768
|
+
phase: "detecting",
|
|
2769
|
+
percent: Math.round(i / files.length * 100)
|
|
2770
|
+
});
|
|
2771
|
+
updateFileStatus(file.id, "scanning");
|
|
2772
|
+
const processor = getProcessorForFile(file.file);
|
|
2773
|
+
if (!processor) {
|
|
2774
|
+
updateFileStatus(file.id, "error", "Unsupported file format");
|
|
2775
|
+
continue;
|
|
2776
|
+
}
|
|
2777
|
+
try {
|
|
2778
|
+
const parsed = await processor.parse(file.file);
|
|
2779
|
+
parsedDocsRef.current.set(file.id, parsed);
|
|
2780
|
+
setContentSections((prev) => [...prev, ...parsed.content.sections]);
|
|
2781
|
+
buffersRef.current.push(new ArrayBuffer(file.size));
|
|
2782
|
+
setMemoryStats((s) => ({
|
|
2783
|
+
...s,
|
|
2784
|
+
allocated: s.allocated + file.size,
|
|
2785
|
+
totalBuffers: s.totalBuffers + 1
|
|
2786
|
+
}));
|
|
2787
|
+
if (i === 0) {
|
|
2788
|
+
try {
|
|
2789
|
+
const originalBytes = await file.file.arrayBuffer();
|
|
2790
|
+
originalBytesRef.current = new Uint8Array(originalBytes);
|
|
2791
|
+
const beforeEntropy = calculateEntropy(originalBytesRef.current, []);
|
|
2792
|
+
setEntropyComparison((prev) => ({
|
|
2793
|
+
...prev,
|
|
2794
|
+
before: beforeEntropy
|
|
2795
|
+
}));
|
|
2796
|
+
} catch (entropyError) {
|
|
2797
|
+
secureWarn("Failed to calculate original entropy", entropyError);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
const scanConfig = { ...config, sensitivity: "high" };
|
|
2801
|
+
const result = await regexDetectionEngine.detect(parsed.content, scanConfig);
|
|
2802
|
+
allDetections.push(...result.detections);
|
|
2803
|
+
storeSetDetections([...allDetections]);
|
|
2804
|
+
updateFileStatus(file.id, "detected");
|
|
2805
|
+
if (!bypassQuota) {
|
|
2806
|
+
await consumeToken();
|
|
2807
|
+
}
|
|
2808
|
+
if (i === 0 && originalBytesRef.current) {
|
|
2809
|
+
const beforeEntropyWithPII = calculateEntropy(
|
|
2810
|
+
originalBytesRef.current,
|
|
2811
|
+
result.detections
|
|
2812
|
+
);
|
|
2813
|
+
setEntropyComparison((prev) => ({
|
|
2814
|
+
...prev,
|
|
2815
|
+
before: beforeEntropyWithPII
|
|
2816
|
+
}));
|
|
2817
|
+
originalBytesRef.current.fill(0);
|
|
2818
|
+
originalBytesRef.current = null;
|
|
2819
|
+
}
|
|
2820
|
+
setProgress(
|
|
2821
|
+
(p) => p ? {
|
|
2822
|
+
...p,
|
|
2823
|
+
percent: Math.round((i + 1) / files.length * 100)
|
|
2824
|
+
} : null
|
|
2825
|
+
);
|
|
2826
|
+
} catch (error) {
|
|
2827
|
+
secureWarn(`Error scanning file ${safeFilename(file.name)}`, error);
|
|
2828
|
+
updateFileStatus(
|
|
2829
|
+
file.id,
|
|
2830
|
+
"error",
|
|
2831
|
+
error instanceof Error ? error.message : "Processing failed"
|
|
2832
|
+
);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
setDetections(allDetections);
|
|
2836
|
+
return allDetections;
|
|
2837
|
+
} finally {
|
|
2838
|
+
setIsProcessing(false);
|
|
2839
|
+
setIsCalculatingEntropy(false);
|
|
2840
|
+
setProgress(null);
|
|
2841
|
+
}
|
|
2842
|
+
},
|
|
2843
|
+
[updateFileStatus, storeSetDetections, calculateEntropy, canProcessFile, consumeToken, tokensRemaining]
|
|
2844
|
+
);
|
|
2845
|
+
const processFiles = (0, import_react3.useCallback)(
|
|
2846
|
+
async (files, selectedDetections, config) => {
|
|
2847
|
+
setIsProcessing(true);
|
|
2848
|
+
const processedFiles = [];
|
|
2849
|
+
try {
|
|
2850
|
+
for (let i = 0; i < files.length; i++) {
|
|
2851
|
+
const file = files[i];
|
|
2852
|
+
setProgress({
|
|
2853
|
+
currentFileIndex: i,
|
|
2854
|
+
totalFiles: files.length,
|
|
2855
|
+
currentFileName: file.name,
|
|
2856
|
+
phase: "redacting",
|
|
2857
|
+
percent: Math.round(i / files.length * 50)
|
|
2858
|
+
});
|
|
2859
|
+
updateFileStatus(file.id, "purging");
|
|
2860
|
+
const processor = getProcessorForFile(file.file);
|
|
2861
|
+
if (!processor) {
|
|
2862
|
+
updateFileStatus(file.id, "error", "Unsupported file format");
|
|
2863
|
+
continue;
|
|
2864
|
+
}
|
|
2865
|
+
try {
|
|
2866
|
+
let parsed = parsedDocsRef.current.get(file.id);
|
|
2867
|
+
if (!parsed) {
|
|
2868
|
+
secureWarn(`No cached parse for file ${safeFilename(file.name)}, re-parsing`);
|
|
2869
|
+
parsed = await processor.parse(file.file);
|
|
2870
|
+
}
|
|
2871
|
+
const fileDetections = detections.filter(
|
|
2872
|
+
(d) => d.fileId === parsed.content.fileId && selectedDetections.has(d.id)
|
|
2873
|
+
);
|
|
2874
|
+
const redactions = fileDetections.map((d) => ({
|
|
2875
|
+
detectionId: d.id,
|
|
2876
|
+
sectionId: d.sectionId,
|
|
2877
|
+
startOffset: d.startOffset,
|
|
2878
|
+
endOffset: d.endOffset,
|
|
2879
|
+
replacement: getReplacementText(d, config)
|
|
2880
|
+
}));
|
|
2881
|
+
setProgress(
|
|
2882
|
+
(p) => p ? {
|
|
2883
|
+
...p,
|
|
2884
|
+
phase: "redacting",
|
|
2885
|
+
percent: Math.round(50 + (i + 0.5) / files.length * 25)
|
|
2886
|
+
} : null
|
|
2887
|
+
);
|
|
2888
|
+
const blob = await processor.applyRedactions(parsed, redactions);
|
|
2889
|
+
setProgress(
|
|
2890
|
+
(p) => p ? {
|
|
2891
|
+
...p,
|
|
2892
|
+
phase: "finalizing",
|
|
2893
|
+
percent: Math.round(75 + (i + 1) / files.length * 25)
|
|
2894
|
+
} : null
|
|
2895
|
+
);
|
|
2896
|
+
const processedFile = {
|
|
2897
|
+
id: generateSecureId(),
|
|
2898
|
+
originalName: file.name,
|
|
2899
|
+
purgedName: getPurgedFilename(file.name),
|
|
2900
|
+
originalSize: file.size,
|
|
2901
|
+
purgedSize: blob.size,
|
|
2902
|
+
type: file.type,
|
|
2903
|
+
blob,
|
|
2904
|
+
detectionsRemoved: redactions.length,
|
|
2905
|
+
timestamp: Date.now()
|
|
2906
|
+
};
|
|
2907
|
+
processedFiles.push(processedFile);
|
|
2908
|
+
updateFileStatus(file.id, "complete");
|
|
2909
|
+
if (i === 0) {
|
|
2910
|
+
try {
|
|
2911
|
+
setIsCalculatingEntropy(true);
|
|
2912
|
+
const processedBytes = await blob.arrayBuffer();
|
|
2913
|
+
const afterEntropy = calculateEntropy(new Uint8Array(processedBytes), []);
|
|
2914
|
+
setEntropyComparison((prev) => {
|
|
2915
|
+
const comparison = compareEntropy(prev.before, afterEntropy);
|
|
2916
|
+
return comparison;
|
|
2917
|
+
});
|
|
2918
|
+
} catch (entropyError) {
|
|
2919
|
+
secureWarn("Failed to calculate processed entropy", entropyError);
|
|
2920
|
+
} finally {
|
|
2921
|
+
setIsCalculatingEntropy(false);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
} catch (error) {
|
|
2925
|
+
secureWarn(`Error processing file ${safeFilename(file.name)}`, error);
|
|
2926
|
+
updateFileStatus(
|
|
2927
|
+
file.id,
|
|
2928
|
+
"error",
|
|
2929
|
+
error instanceof Error ? error.message : "Processing failed"
|
|
2930
|
+
);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
return processedFiles;
|
|
2934
|
+
} finally {
|
|
2935
|
+
setIsProcessing(false);
|
|
2936
|
+
setProgress(null);
|
|
2937
|
+
}
|
|
2938
|
+
},
|
|
2939
|
+
[detections, updateFileStatus, calculateEntropy, compareEntropy]
|
|
2940
|
+
);
|
|
2941
|
+
const wipeMemory = (0, import_react3.useCallback)(async () => {
|
|
2942
|
+
const buffers = buffersRef.current;
|
|
2943
|
+
const totalSize = memoryStats.allocated;
|
|
2944
|
+
if (originalBytesRef.current) {
|
|
2945
|
+
originalBytesRef.current.fill(0);
|
|
2946
|
+
originalBytesRef.current = null;
|
|
2947
|
+
}
|
|
2948
|
+
if (buffers.length === 0) return;
|
|
2949
|
+
let wiped = 0;
|
|
2950
|
+
for (let i = 0; i < buffers.length; i++) {
|
|
2951
|
+
const buffer = buffers[i];
|
|
2952
|
+
const view = new Uint8Array(buffer);
|
|
2953
|
+
view.fill(0);
|
|
2954
|
+
wiped += buffer.byteLength;
|
|
2955
|
+
setMemoryStats((s) => ({
|
|
2956
|
+
...s,
|
|
2957
|
+
wiped,
|
|
2958
|
+
buffersCleared: i + 1
|
|
2959
|
+
}));
|
|
2960
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2961
|
+
}
|
|
2962
|
+
buffersRef.current = [];
|
|
2963
|
+
setMemoryStats({
|
|
2964
|
+
allocated: totalSize,
|
|
2965
|
+
wiped: totalSize,
|
|
2966
|
+
buffersCleared: buffers.length,
|
|
2967
|
+
totalBuffers: buffers.length
|
|
2968
|
+
});
|
|
2969
|
+
}, [memoryStats.allocated]);
|
|
2970
|
+
return {
|
|
2971
|
+
isProcessing,
|
|
2972
|
+
progress,
|
|
2973
|
+
detections,
|
|
2974
|
+
memoryStats,
|
|
2975
|
+
entropyComparison,
|
|
2976
|
+
isCalculatingEntropy,
|
|
2977
|
+
contentSections,
|
|
2978
|
+
scanFiles,
|
|
2979
|
+
processFiles,
|
|
2980
|
+
wipeMemory
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
function getReplacementText(detection, config) {
|
|
2984
|
+
switch (config.redactionStyle) {
|
|
2985
|
+
case "blackout":
|
|
2986
|
+
return "\u2588".repeat(Math.min(detection.value.length, 20));
|
|
2987
|
+
case "replacement":
|
|
2988
|
+
return config.replacementText || "[REDACTED]";
|
|
2989
|
+
case "pseudonym":
|
|
2990
|
+
return generatePseudonym(detection.category);
|
|
2991
|
+
case "partial":
|
|
2992
|
+
return getPartialMask(detection.category, detection.value);
|
|
2993
|
+
default:
|
|
2994
|
+
return "[REDACTED]";
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
function generatePseudonym(category) {
|
|
2998
|
+
const pseudonyms = {
|
|
2999
|
+
person_name: ["John Doe", "Jane Smith", "Bob Johnson", "Alice Williams"],
|
|
3000
|
+
email: ["user@example.com", "contact@company.org", "info@domain.net"],
|
|
3001
|
+
phone: ["(555) 000-0000", "555-123-4567", "+1-555-EXAMPLE"],
|
|
3002
|
+
address: ["123 Example St, Anytown, USA 12345"],
|
|
3003
|
+
ssn: ["XXX-XX-XXXX"],
|
|
3004
|
+
credit_card: ["XXXX-XXXX-XXXX-XXXX"],
|
|
3005
|
+
ip_address: ["0.0.0.0", "127.0.0.1"],
|
|
3006
|
+
date_of_birth: ["01/01/1900"],
|
|
3007
|
+
custom: ["[CUSTOM DATA]"]
|
|
3008
|
+
};
|
|
3009
|
+
const options = pseudonyms[category] || ["[REDACTED]"];
|
|
3010
|
+
return options[Math.floor(Math.random() * options.length)];
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// src/hooks/useOfflineEnforcement.ts
|
|
3014
|
+
var import_react4 = require("react");
|
|
3015
|
+
var EXTENSION_SECONDS = 30;
|
|
3016
|
+
var MAX_EXTENSIONS = 2;
|
|
3017
|
+
var WARNING_COUNTDOWN_SECONDS = 5;
|
|
3018
|
+
var ONLINE_CHECK_INTERVAL = 100;
|
|
3019
|
+
function forceCloseTab() {
|
|
3020
|
+
if (typeof window !== "undefined") {
|
|
3021
|
+
window.location.href = "about:blank";
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
function getInitialOnlineState() {
|
|
3025
|
+
if (typeof navigator !== "undefined") {
|
|
3026
|
+
return navigator.onLine;
|
|
3027
|
+
}
|
|
3028
|
+
return true;
|
|
3029
|
+
}
|
|
3030
|
+
function useOfflineEnforcement() {
|
|
3031
|
+
const {
|
|
3032
|
+
tokensRemaining,
|
|
3033
|
+
isExhausted: quotaExhausted,
|
|
3034
|
+
requestRefresh,
|
|
3035
|
+
isInitialized: quotaInitialized
|
|
3036
|
+
} = useOfflineQuota();
|
|
3037
|
+
const [status, setStatus] = (0, import_react4.useState)(
|
|
3038
|
+
getInitialOnlineState() ? "online_blocked" : "offline_ready"
|
|
3039
|
+
);
|
|
3040
|
+
const [isOnline, setIsOnline] = (0, import_react4.useState)(getInitialOnlineState());
|
|
3041
|
+
const [countdownSeconds, setCountdownSeconds] = (0, import_react4.useState)(null);
|
|
3042
|
+
const [hasDownloaded, setHasDownloaded] = (0, import_react4.useState)(false);
|
|
3043
|
+
const [extensionsUsed, setExtensionsUsed] = (0, import_react4.useState)(0);
|
|
3044
|
+
const [startedOnline, setStartedOnline] = (0, import_react4.useState)(false);
|
|
3045
|
+
const countdownIntervalRef = (0, import_react4.useRef)(null);
|
|
3046
|
+
const onlineCheckIntervalRef = (0, import_react4.useRef)(null);
|
|
3047
|
+
const isStartingProcessingRef = (0, import_react4.useRef)(false);
|
|
3048
|
+
(0, import_react4.useEffect)(() => {
|
|
3049
|
+
if (quotaInitialized && quotaExhausted && !isOnline && status === "offline_ready") {
|
|
3050
|
+
setStatus("quota_exhausted");
|
|
3051
|
+
}
|
|
3052
|
+
}, [quotaInitialized, quotaExhausted, isOnline, status]);
|
|
3053
|
+
(0, import_react4.useEffect)(() => {
|
|
3054
|
+
let mounted = true;
|
|
3055
|
+
if (isOnline && quotaExhausted && quotaInitialized && (status === "online_blocked" || status === "quota_exhausted")) {
|
|
3056
|
+
secureLog("Online with exhausted quota, attempting refresh...");
|
|
3057
|
+
requestRefresh().then((result) => {
|
|
3058
|
+
if (!mounted) {
|
|
3059
|
+
secureLog("[CLEANUP] Quota refresh completed after unmount, ignoring");
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
if (result.success) {
|
|
3063
|
+
secureLog(`Quota refreshed: ${result.tokens} tokens`);
|
|
3064
|
+
setStatus((currentStatus) => {
|
|
3065
|
+
if (currentStatus === "online_acknowledged") {
|
|
3066
|
+
secureLog("[RACE PREVENTED] Quota refreshed but user acknowledged risk - preserving online_acknowledged");
|
|
3067
|
+
return currentStatus;
|
|
3068
|
+
}
|
|
3069
|
+
if (currentStatus !== "online_blocked" && currentStatus !== "quota_exhausted") {
|
|
3070
|
+
secureLog(`[RACE PREVENTED] Quota refreshed but state is ${currentStatus} - not updating to online_blocked`);
|
|
3071
|
+
return currentStatus;
|
|
3072
|
+
}
|
|
3073
|
+
return "online_blocked";
|
|
3074
|
+
});
|
|
3075
|
+
} else {
|
|
3076
|
+
secureLog(`Quota refresh failed: ${result.reason}`);
|
|
3077
|
+
}
|
|
3078
|
+
}).catch((error) => {
|
|
3079
|
+
if (!mounted) return;
|
|
3080
|
+
secureError("Quota refresh error:", error);
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
return () => {
|
|
3084
|
+
mounted = false;
|
|
3085
|
+
};
|
|
3086
|
+
}, [isOnline, quotaExhausted, quotaInitialized, requestRefresh, status]);
|
|
3087
|
+
const canProcess = status === "online_acknowledged" || // Admin bypass - ignore quota
|
|
3088
|
+
status === "processing" || // Currently processing
|
|
3089
|
+
status === "offline_ready" && !quotaExhausted;
|
|
3090
|
+
const clearCountdownInterval = (0, import_react4.useCallback)(() => {
|
|
3091
|
+
if (countdownIntervalRef.current) {
|
|
3092
|
+
clearInterval(countdownIntervalRef.current);
|
|
3093
|
+
countdownIntervalRef.current = null;
|
|
3094
|
+
}
|
|
3095
|
+
}, []);
|
|
3096
|
+
const startCountdown = (0, import_react4.useCallback)((seconds) => {
|
|
3097
|
+
clearCountdownInterval();
|
|
3098
|
+
setCountdownSeconds(seconds);
|
|
3099
|
+
countdownIntervalRef.current = setInterval(() => {
|
|
3100
|
+
setCountdownSeconds((prev) => {
|
|
3101
|
+
if (prev === null || prev <= 1) {
|
|
3102
|
+
clearCountdownInterval();
|
|
3103
|
+
forceCloseTab();
|
|
3104
|
+
return null;
|
|
3105
|
+
}
|
|
3106
|
+
return prev - 1;
|
|
3107
|
+
});
|
|
3108
|
+
}, 1e3);
|
|
3109
|
+
}, [clearCountdownInterval]);
|
|
3110
|
+
const handleOnlineChange = (0, import_react4.useCallback)((online) => {
|
|
3111
|
+
setIsOnline(online);
|
|
3112
|
+
if (online) {
|
|
3113
|
+
switch (status) {
|
|
3114
|
+
case "offline_ready":
|
|
3115
|
+
setStatus("online_blocked");
|
|
3116
|
+
break;
|
|
3117
|
+
case "processing":
|
|
3118
|
+
if (!startedOnline) {
|
|
3119
|
+
setStatus("reconnected_abort");
|
|
3120
|
+
clearCountdownInterval();
|
|
3121
|
+
}
|
|
3122
|
+
break;
|
|
3123
|
+
case "awaiting_download":
|
|
3124
|
+
if (!startedOnline) {
|
|
3125
|
+
setStatus("reconnected_warning");
|
|
3126
|
+
startCountdown(WARNING_COUNTDOWN_SECONDS);
|
|
3127
|
+
}
|
|
3128
|
+
break;
|
|
3129
|
+
case "complete":
|
|
3130
|
+
setStatus("reconnected_warning");
|
|
3131
|
+
startCountdown(WARNING_COUNTDOWN_SECONDS);
|
|
3132
|
+
break;
|
|
3133
|
+
default:
|
|
3134
|
+
break;
|
|
3135
|
+
}
|
|
3136
|
+
} else {
|
|
3137
|
+
switch (status) {
|
|
3138
|
+
case "online_blocked":
|
|
3139
|
+
setStatus("offline_ready");
|
|
3140
|
+
break;
|
|
3141
|
+
case "reconnected_warning":
|
|
3142
|
+
setStatus("awaiting_download");
|
|
3143
|
+
clearCountdownInterval();
|
|
3144
|
+
setCountdownSeconds(null);
|
|
3145
|
+
break;
|
|
3146
|
+
default:
|
|
3147
|
+
break;
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
}, [status, startedOnline, clearCountdownInterval, startCountdown]);
|
|
3151
|
+
(0, import_react4.useEffect)(() => {
|
|
3152
|
+
const handleOnline = () => handleOnlineChange(true);
|
|
3153
|
+
const handleOffline = () => handleOnlineChange(false);
|
|
3154
|
+
window.addEventListener("online", handleOnline);
|
|
3155
|
+
window.addEventListener("offline", handleOffline);
|
|
3156
|
+
onlineCheckIntervalRef.current = setInterval(() => {
|
|
3157
|
+
const currentOnline = navigator.onLine;
|
|
3158
|
+
if (currentOnline !== isOnline) {
|
|
3159
|
+
handleOnlineChange(currentOnline);
|
|
3160
|
+
}
|
|
3161
|
+
}, ONLINE_CHECK_INTERVAL);
|
|
3162
|
+
return () => {
|
|
3163
|
+
window.removeEventListener("online", handleOnline);
|
|
3164
|
+
window.removeEventListener("offline", handleOffline);
|
|
3165
|
+
if (onlineCheckIntervalRef.current) {
|
|
3166
|
+
clearInterval(onlineCheckIntervalRef.current);
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
}, [isOnline, handleOnlineChange]);
|
|
3170
|
+
(0, import_react4.useEffect)(() => {
|
|
3171
|
+
return () => {
|
|
3172
|
+
clearCountdownInterval();
|
|
3173
|
+
if (onlineCheckIntervalRef.current) {
|
|
3174
|
+
clearInterval(onlineCheckIntervalRef.current);
|
|
3175
|
+
}
|
|
3176
|
+
};
|
|
3177
|
+
}, [clearCountdownInterval]);
|
|
3178
|
+
const actions = {
|
|
3179
|
+
startProcessing: (0, import_react4.useCallback)(async () => {
|
|
3180
|
+
if (isStartingProcessingRef.current) {
|
|
3181
|
+
secureWarn("startProcessing already in progress, ignoring duplicate call");
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
isStartingProcessingRef.current = true;
|
|
3185
|
+
try {
|
|
3186
|
+
if (status === "offline_ready" && !isOnline) {
|
|
3187
|
+
if ("serviceWorker" in navigator) {
|
|
3188
|
+
try {
|
|
3189
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
3190
|
+
if (registrations.length > 0) {
|
|
3191
|
+
secureError("Service workers detected - blocking processing for privacy");
|
|
3192
|
+
setStatus("sw_blocked");
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
} catch (e) {
|
|
3196
|
+
secureWarn("Could not check service workers", e);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
setStartedOnline(false);
|
|
3200
|
+
setStatus("processing");
|
|
3201
|
+
} else if (status === "online_acknowledged") {
|
|
3202
|
+
setStartedOnline(true);
|
|
3203
|
+
setStatus("processing");
|
|
3204
|
+
}
|
|
3205
|
+
} finally {
|
|
3206
|
+
isStartingProcessingRef.current = false;
|
|
3207
|
+
}
|
|
3208
|
+
}, [status, isOnline]),
|
|
3209
|
+
completeProcessing: (0, import_react4.useCallback)(() => {
|
|
3210
|
+
if (status === "processing") {
|
|
3211
|
+
setStatus("awaiting_download");
|
|
3212
|
+
}
|
|
3213
|
+
}, [status]),
|
|
3214
|
+
markDownloaded: (0, import_react4.useCallback)(() => {
|
|
3215
|
+
setHasDownloaded(true);
|
|
3216
|
+
if (!startedOnline) {
|
|
3217
|
+
setStatus("complete");
|
|
3218
|
+
setTimeout(() => {
|
|
3219
|
+
forceCloseTab();
|
|
3220
|
+
}, 500);
|
|
3221
|
+
}
|
|
3222
|
+
}, [startedOnline]),
|
|
3223
|
+
extendCountdown: (0, import_react4.useCallback)(() => {
|
|
3224
|
+
if (extensionsUsed >= MAX_EXTENSIONS) {
|
|
3225
|
+
return false;
|
|
3226
|
+
}
|
|
3227
|
+
setExtensionsUsed((prev) => prev + 1);
|
|
3228
|
+
clearCountdownInterval();
|
|
3229
|
+
startCountdown(EXTENSION_SECONDS);
|
|
3230
|
+
return true;
|
|
3231
|
+
}, [extensionsUsed, clearCountdownInterval, startCountdown]),
|
|
3232
|
+
forceClose: (0, import_react4.useCallback)(() => {
|
|
3233
|
+
forceCloseTab();
|
|
3234
|
+
}, []),
|
|
3235
|
+
reset: (0, import_react4.useCallback)(() => {
|
|
3236
|
+
clearCountdownInterval();
|
|
3237
|
+
setStatus(navigator.onLine ? "online_blocked" : "offline_ready");
|
|
3238
|
+
setCountdownSeconds(null);
|
|
3239
|
+
setHasDownloaded(false);
|
|
3240
|
+
setExtensionsUsed(0);
|
|
3241
|
+
setStartedOnline(false);
|
|
3242
|
+
}, [clearCountdownInterval]),
|
|
3243
|
+
acknowledgeOnlineRisk: (0, import_react4.useCallback)(() => {
|
|
3244
|
+
console.log("[ADMIN BYPASS] acknowledgeOnlineRisk called", { status, isOnline });
|
|
3245
|
+
if (isOnline) {
|
|
3246
|
+
console.log("[ADMIN BYPASS] \u2705 Acknowledging online risk, transitioning to online_acknowledged");
|
|
3247
|
+
setStatus("online_acknowledged");
|
|
3248
|
+
} else {
|
|
3249
|
+
console.warn("[ADMIN BYPASS] \u274C Cannot acknowledge - browser reports offline", { status, isOnline });
|
|
3250
|
+
}
|
|
3251
|
+
}, [status, isOnline])
|
|
3252
|
+
};
|
|
3253
|
+
return {
|
|
3254
|
+
state: {
|
|
3255
|
+
status,
|
|
3256
|
+
isOnline,
|
|
3257
|
+
canProcess,
|
|
3258
|
+
countdownSeconds,
|
|
3259
|
+
hasDownloaded,
|
|
3260
|
+
extensionsUsed,
|
|
3261
|
+
maxExtensions: MAX_EXTENSIONS,
|
|
3262
|
+
startedOnline,
|
|
3263
|
+
quotaRemaining: tokensRemaining,
|
|
3264
|
+
quotaExhausted
|
|
3265
|
+
},
|
|
3266
|
+
actions
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// src/hooks/useFileHash.ts
|
|
3271
|
+
var import_react5 = require("react");
|
|
3272
|
+
function arrayBufferToHex(buffer) {
|
|
3273
|
+
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3274
|
+
}
|
|
3275
|
+
async function calculateSHA256(buffer) {
|
|
3276
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
|
3277
|
+
return arrayBufferToHex(hashBuffer);
|
|
3278
|
+
}
|
|
3279
|
+
function truncateHash(hash) {
|
|
3280
|
+
if (hash.length <= 20) return hash;
|
|
3281
|
+
return `${hash.slice(0, 8)}...${hash.slice(-8)}`;
|
|
3282
|
+
}
|
|
3283
|
+
function useFileHash() {
|
|
3284
|
+
const [state, setState] = (0, import_react5.useState)({
|
|
3285
|
+
originalHash: null,
|
|
3286
|
+
processedHash: null,
|
|
3287
|
+
timestamp: null,
|
|
3288
|
+
isHashing: false,
|
|
3289
|
+
error: null
|
|
3290
|
+
});
|
|
3291
|
+
const hashOriginalFile = (0, import_react5.useCallback)(async (file) => {
|
|
3292
|
+
setState((prev) => ({ ...prev, isHashing: true, error: null }));
|
|
3293
|
+
try {
|
|
3294
|
+
const buffer = await file.arrayBuffer();
|
|
3295
|
+
const hash = await calculateSHA256(buffer);
|
|
3296
|
+
setState((prev) => ({
|
|
3297
|
+
...prev,
|
|
3298
|
+
originalHash: hash,
|
|
3299
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3300
|
+
isHashing: false
|
|
3301
|
+
}));
|
|
3302
|
+
return hash;
|
|
3303
|
+
} catch (error) {
|
|
3304
|
+
const errorMessage = error instanceof Error ? error.message : "Failed to hash file";
|
|
3305
|
+
setState((prev) => ({
|
|
3306
|
+
...prev,
|
|
3307
|
+
isHashing: false,
|
|
3308
|
+
error: errorMessage
|
|
3309
|
+
}));
|
|
3310
|
+
throw error;
|
|
3311
|
+
}
|
|
3312
|
+
}, []);
|
|
3313
|
+
const hashProcessedBlob = (0, import_react5.useCallback)(async (blob) => {
|
|
3314
|
+
setState((prev) => ({ ...prev, isHashing: true, error: null }));
|
|
3315
|
+
try {
|
|
3316
|
+
const buffer = await blob.arrayBuffer();
|
|
3317
|
+
const hash = await calculateSHA256(buffer);
|
|
3318
|
+
setState((prev) => ({
|
|
3319
|
+
...prev,
|
|
3320
|
+
processedHash: hash,
|
|
3321
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3322
|
+
isHashing: false
|
|
3323
|
+
}));
|
|
3324
|
+
return hash;
|
|
3325
|
+
} catch (error) {
|
|
3326
|
+
const errorMessage = error instanceof Error ? error.message : "Failed to hash blob";
|
|
3327
|
+
setState((prev) => ({
|
|
3328
|
+
...prev,
|
|
3329
|
+
isHashing: false,
|
|
3330
|
+
error: errorMessage
|
|
3331
|
+
}));
|
|
3332
|
+
throw error;
|
|
3333
|
+
}
|
|
3334
|
+
}, []);
|
|
3335
|
+
const reset = (0, import_react5.useCallback)(() => {
|
|
3336
|
+
setState({
|
|
3337
|
+
originalHash: null,
|
|
3338
|
+
processedHash: null,
|
|
3339
|
+
timestamp: null,
|
|
3340
|
+
isHashing: false,
|
|
3341
|
+
error: null
|
|
3342
|
+
});
|
|
3343
|
+
}, []);
|
|
3344
|
+
const getComparisonResult = (0, import_react5.useCallback)(() => {
|
|
3345
|
+
const { originalHash, processedHash } = state;
|
|
3346
|
+
if (!originalHash && !processedHash) {
|
|
3347
|
+
return {
|
|
3348
|
+
hasBothHashes: false,
|
|
3349
|
+
hashesMatch: false,
|
|
3350
|
+
message: "No files have been hashed yet"
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3353
|
+
if (!originalHash) {
|
|
3354
|
+
return {
|
|
3355
|
+
hasBothHashes: false,
|
|
3356
|
+
hashesMatch: false,
|
|
3357
|
+
message: "Original file hash not calculated"
|
|
3358
|
+
};
|
|
3359
|
+
}
|
|
3360
|
+
if (!processedHash) {
|
|
3361
|
+
return {
|
|
3362
|
+
hasBothHashes: false,
|
|
3363
|
+
hashesMatch: false,
|
|
3364
|
+
message: "Processed file hash not calculated"
|
|
3365
|
+
};
|
|
3366
|
+
}
|
|
3367
|
+
const hashesMatch = originalHash === processedHash;
|
|
3368
|
+
return {
|
|
3369
|
+
hasBothHashes: true,
|
|
3370
|
+
hashesMatch,
|
|
3371
|
+
message: hashesMatch ? "File was not modified during processing" : "File was modified during processing (redactions applied)"
|
|
3372
|
+
};
|
|
3373
|
+
}, [state]);
|
|
3374
|
+
return {
|
|
3375
|
+
state,
|
|
3376
|
+
hashOriginalFile,
|
|
3377
|
+
hashProcessedBlob,
|
|
3378
|
+
reset,
|
|
3379
|
+
getComparisonResult
|
|
3380
|
+
};
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
// src/hooks/useAdversarialAnalysis.ts
|
|
3384
|
+
var import_react6 = require("react");
|
|
3385
|
+
function useAdversarialAnalysis(options = {}) {
|
|
3386
|
+
const { autoAnalyze = false, debounceMs = 500 } = options;
|
|
3387
|
+
const result = usePurgeStore((s) => s.adversarialResult);
|
|
3388
|
+
const isAnalyzing = usePurgeStore((s) => s.isAnalyzingAdversarial);
|
|
3389
|
+
const config = usePurgeStore((s) => s.adversarialConfig);
|
|
3390
|
+
const detections = usePurgeStore((s) => s.detections);
|
|
3391
|
+
const selectedDetections = usePurgeStore((s) => s.selectedDetections);
|
|
3392
|
+
const setResult = usePurgeStore((s) => s.setAdversarialResult);
|
|
3393
|
+
const setIsAnalyzing = usePurgeStore((s) => s.setIsAnalyzingAdversarial);
|
|
3394
|
+
const setConfig = usePurgeStore((s) => s.setAdversarialConfig);
|
|
3395
|
+
const storeSuggestion = usePurgeStore((s) => s.applySuggestion);
|
|
3396
|
+
const clearState = usePurgeStore((s) => s.clearAdversarialState);
|
|
3397
|
+
const debounceRef = (0, import_react6.useRef)(null);
|
|
3398
|
+
const analyze = (0, import_react6.useCallback)(
|
|
3399
|
+
async (sections, selected) => {
|
|
3400
|
+
if (!config.enabled) {
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
setIsAnalyzing(true);
|
|
3404
|
+
try {
|
|
3405
|
+
adversarialVerifier.setConfig(config);
|
|
3406
|
+
const analysisResult = await adversarialVerifier.analyze(sections, selected);
|
|
3407
|
+
if (result) {
|
|
3408
|
+
analysisResult.previousConfidence = result.analysis.reidentificationConfidence;
|
|
3409
|
+
analysisResult.iteration = result.iteration + 1;
|
|
3410
|
+
}
|
|
3411
|
+
setResult(analysisResult);
|
|
3412
|
+
} catch (error) {
|
|
3413
|
+
console.error("[Adversarial] Analysis failed:", error);
|
|
3414
|
+
} finally {
|
|
3415
|
+
setIsAnalyzing(false);
|
|
3416
|
+
}
|
|
3417
|
+
},
|
|
3418
|
+
[config, result, setIsAnalyzing, setResult]
|
|
3419
|
+
);
|
|
3420
|
+
const applySuggestion = (0, import_react6.useCallback)(
|
|
3421
|
+
(suggestion) => {
|
|
3422
|
+
storeSuggestion(suggestion);
|
|
3423
|
+
},
|
|
3424
|
+
[storeSuggestion]
|
|
3425
|
+
);
|
|
3426
|
+
const applyAllSuggestions = (0, import_react6.useCallback)(() => {
|
|
3427
|
+
if (!result) return;
|
|
3428
|
+
for (const suggestion of result.suggestions) {
|
|
3429
|
+
if (!suggestion.accepted) {
|
|
3430
|
+
storeSuggestion(suggestion);
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
}, [result, storeSuggestion]);
|
|
3434
|
+
const updateConfig = (0, import_react6.useCallback)(
|
|
3435
|
+
(newConfig) => {
|
|
3436
|
+
setConfig(newConfig);
|
|
3437
|
+
adversarialVerifier.setConfig(newConfig);
|
|
3438
|
+
},
|
|
3439
|
+
[setConfig]
|
|
3440
|
+
);
|
|
3441
|
+
const clear = (0, import_react6.useCallback)(() => {
|
|
3442
|
+
clearState();
|
|
3443
|
+
}, [clearState]);
|
|
3444
|
+
const dismiss = (0, import_react6.useCallback)(() => {
|
|
3445
|
+
clearState();
|
|
3446
|
+
}, [clearState]);
|
|
3447
|
+
(0, import_react6.useEffect)(() => {
|
|
3448
|
+
if (!autoAnalyze || !config.enabled) {
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
if (debounceRef.current) {
|
|
3452
|
+
clearTimeout(debounceRef.current);
|
|
3453
|
+
}
|
|
3454
|
+
const selected = detections.filter((d) => selectedDetections.has(d.id));
|
|
3455
|
+
if (selected.length === 0) {
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
debounceRef.current = setTimeout(() => {
|
|
3459
|
+
console.log("[Adversarial] Auto-analyze triggered with", selected.length, "detections");
|
|
3460
|
+
}, debounceMs);
|
|
3461
|
+
return () => {
|
|
3462
|
+
if (debounceRef.current) {
|
|
3463
|
+
clearTimeout(debounceRef.current);
|
|
3464
|
+
}
|
|
3465
|
+
};
|
|
3466
|
+
}, [autoAnalyze, config.enabled, debounceMs, detections, selectedDetections]);
|
|
3467
|
+
return {
|
|
3468
|
+
result,
|
|
3469
|
+
isAnalyzing,
|
|
3470
|
+
config,
|
|
3471
|
+
analyze,
|
|
3472
|
+
applySuggestion,
|
|
3473
|
+
applyAllSuggestions,
|
|
3474
|
+
updateConfig,
|
|
3475
|
+
clear,
|
|
3476
|
+
dismiss
|
|
3477
|
+
};
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
// src/hooks/useSpreadsheetMetadata.ts
|
|
3481
|
+
var import_react7 = require("react");
|
|
3482
|
+
var XLSX2 = __toESM(require("xlsx"));
|
|
3483
|
+
function useSpreadsheetMetadata() {
|
|
3484
|
+
const extractMetadata = (0, import_react7.useCallback)(
|
|
3485
|
+
async (file, includeHeaders = true) => {
|
|
3486
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
3487
|
+
const workbook = XLSX2.read(arrayBuffer, {
|
|
3488
|
+
type: "array",
|
|
3489
|
+
// Only read first row if headers needed, otherwise just structure
|
|
3490
|
+
sheetRows: includeHeaders ? 1 : 0
|
|
3491
|
+
});
|
|
3492
|
+
const sheets = [];
|
|
3493
|
+
let totalCells = 0;
|
|
3494
|
+
let globalMinCol = "Z";
|
|
3495
|
+
let globalMaxCol = "A";
|
|
3496
|
+
for (const sheetName of workbook.SheetNames) {
|
|
3497
|
+
const sheet = workbook.Sheets[sheetName];
|
|
3498
|
+
if (!sheet["!ref"]) {
|
|
3499
|
+
sheets.push({
|
|
3500
|
+
name: sheetName,
|
|
3501
|
+
columns: [],
|
|
3502
|
+
rowCount: 0,
|
|
3503
|
+
columnCount: 0
|
|
3504
|
+
});
|
|
3505
|
+
continue;
|
|
3506
|
+
}
|
|
3507
|
+
const range = XLSX2.utils.decode_range(sheet["!ref"]);
|
|
3508
|
+
const rowCount = range.e.r - range.s.r + 1;
|
|
3509
|
+
const columnCount = range.e.c - range.s.c + 1;
|
|
3510
|
+
const columns = [];
|
|
3511
|
+
for (let col = range.s.c; col <= range.e.c; col++) {
|
|
3512
|
+
const colLetter = XLSX2.utils.encode_col(col);
|
|
3513
|
+
columns.push(colLetter);
|
|
3514
|
+
if (colLetter < globalMinCol) globalMinCol = colLetter;
|
|
3515
|
+
if (colLetter > globalMaxCol) globalMaxCol = colLetter;
|
|
3516
|
+
}
|
|
3517
|
+
let sampleHeaders;
|
|
3518
|
+
if (includeHeaders && range.s.r <= range.e.r) {
|
|
3519
|
+
sampleHeaders = [];
|
|
3520
|
+
for (let col = range.s.c; col <= range.e.c; col++) {
|
|
3521
|
+
const cellAddr = XLSX2.utils.encode_cell({ r: range.s.r, c: col });
|
|
3522
|
+
const cell = sheet[cellAddr];
|
|
3523
|
+
sampleHeaders.push(
|
|
3524
|
+
cell?.v !== void 0 ? String(cell.v) : ""
|
|
3525
|
+
);
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
totalCells += rowCount * columnCount;
|
|
3529
|
+
sheets.push({
|
|
3530
|
+
name: sheetName,
|
|
3531
|
+
columns,
|
|
3532
|
+
rowCount,
|
|
3533
|
+
columnCount,
|
|
3534
|
+
sampleHeaders
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
return {
|
|
3538
|
+
sheets,
|
|
3539
|
+
totalCells,
|
|
3540
|
+
columnRange: {
|
|
3541
|
+
min: globalMinCol,
|
|
3542
|
+
max: globalMaxCol
|
|
3543
|
+
}
|
|
3544
|
+
};
|
|
3545
|
+
},
|
|
3546
|
+
[]
|
|
3547
|
+
);
|
|
3548
|
+
return { extractMetadata };
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
// src/hooks/useNetworkProof.ts
|
|
3552
|
+
var import_react8 = require("react");
|
|
3553
|
+
function generateId2() {
|
|
3554
|
+
return generatePrefixedId("req");
|
|
3555
|
+
}
|
|
3556
|
+
function useNetworkProof() {
|
|
3557
|
+
const [requests, setRequests] = (0, import_react8.useState)([]);
|
|
3558
|
+
const [isRecording, setIsRecording] = (0, import_react8.useState)(false);
|
|
3559
|
+
const observerRef = (0, import_react8.useRef)(null);
|
|
3560
|
+
const originalFetchRef = (0, import_react8.useRef)(null);
|
|
3561
|
+
const originalXHROpenRef = (0, import_react8.useRef)(null);
|
|
3562
|
+
const originalBeaconRef = (0, import_react8.useRef)(null);
|
|
3563
|
+
const originalWebSocketRef = (0, import_react8.useRef)(null);
|
|
3564
|
+
const originalWorkerRef = (0, import_react8.useRef)(null);
|
|
3565
|
+
const originalSharedWorkerRef = (0, import_react8.useRef)(null);
|
|
3566
|
+
const originalRTCRef = (0, import_react8.useRef)(null);
|
|
3567
|
+
const startRecording = (0, import_react8.useCallback)(() => {
|
|
3568
|
+
setRequests([]);
|
|
3569
|
+
setIsRecording(true);
|
|
3570
|
+
try {
|
|
3571
|
+
const observer = new PerformanceObserver((list) => {
|
|
3572
|
+
const entries = list.getEntries();
|
|
3573
|
+
entries.forEach((entry) => {
|
|
3574
|
+
if (entry.name.includes("chrome-extension://")) return;
|
|
3575
|
+
setRequests((prev) => [
|
|
3576
|
+
...prev,
|
|
3577
|
+
{
|
|
3578
|
+
id: generateId2(),
|
|
3579
|
+
method: "GET",
|
|
3580
|
+
// PerformanceObserver doesn't provide method
|
|
3581
|
+
url: entry.name,
|
|
3582
|
+
size: entry.transferSize || 0,
|
|
3583
|
+
status: 200,
|
|
3584
|
+
// Approximate
|
|
3585
|
+
timestamp: entry.startTime
|
|
3586
|
+
}
|
|
3587
|
+
]);
|
|
3588
|
+
});
|
|
3589
|
+
});
|
|
3590
|
+
observer.observe({ entryTypes: ["resource"] });
|
|
3591
|
+
observerRef.current = observer;
|
|
3592
|
+
} catch {
|
|
3593
|
+
console.warn("PerformanceObserver not supported");
|
|
3594
|
+
}
|
|
3595
|
+
if (!originalFetchRef.current) {
|
|
3596
|
+
originalFetchRef.current = window.fetch;
|
|
3597
|
+
window.fetch = async (input, init) => {
|
|
3598
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
3599
|
+
const method = init?.method || "GET";
|
|
3600
|
+
const startTime = performance.now();
|
|
3601
|
+
try {
|
|
3602
|
+
const response = await originalFetchRef.current(input, init);
|
|
3603
|
+
const clone = response.clone();
|
|
3604
|
+
const body = await clone.blob();
|
|
3605
|
+
setRequests((prev) => [
|
|
3606
|
+
...prev,
|
|
3607
|
+
{
|
|
3608
|
+
id: generateId2(),
|
|
3609
|
+
method: method.toUpperCase(),
|
|
3610
|
+
url,
|
|
3611
|
+
size: body.size,
|
|
3612
|
+
status: response.status,
|
|
3613
|
+
timestamp: startTime
|
|
3614
|
+
}
|
|
3615
|
+
]);
|
|
3616
|
+
return response;
|
|
3617
|
+
} catch (error) {
|
|
3618
|
+
setRequests((prev) => [
|
|
3619
|
+
...prev,
|
|
3620
|
+
{
|
|
3621
|
+
id: generateId2(),
|
|
3622
|
+
method: method.toUpperCase(),
|
|
3623
|
+
url,
|
|
3624
|
+
size: 0,
|
|
3625
|
+
status: 0,
|
|
3626
|
+
timestamp: startTime
|
|
3627
|
+
}
|
|
3628
|
+
]);
|
|
3629
|
+
throw error;
|
|
3630
|
+
}
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
if (!originalXHROpenRef.current) {
|
|
3634
|
+
originalXHROpenRef.current = XMLHttpRequest.prototype.open;
|
|
3635
|
+
XMLHttpRequest.prototype.open = function(method, url, async, username, password) {
|
|
3636
|
+
const startTime = performance.now();
|
|
3637
|
+
this.addEventListener("load", () => {
|
|
3638
|
+
setRequests((prev) => [
|
|
3639
|
+
...prev,
|
|
3640
|
+
{
|
|
3641
|
+
id: generateId2(),
|
|
3642
|
+
method: method.toUpperCase(),
|
|
3643
|
+
url: url.toString(),
|
|
3644
|
+
size: this.response?.length || 0,
|
|
3645
|
+
status: this.status,
|
|
3646
|
+
timestamp: startTime
|
|
3647
|
+
}
|
|
3648
|
+
]);
|
|
3649
|
+
});
|
|
3650
|
+
return originalXHROpenRef.current.call(this, method, url, async ?? true, username, password);
|
|
3651
|
+
};
|
|
3652
|
+
}
|
|
3653
|
+
if (!originalBeaconRef.current && typeof navigator.sendBeacon === "function") {
|
|
3654
|
+
originalBeaconRef.current = navigator.sendBeacon.bind(navigator);
|
|
3655
|
+
navigator.sendBeacon = (url, data) => {
|
|
3656
|
+
const size = data ? typeof data === "string" ? data.length : data.size || 0 : 0;
|
|
3657
|
+
setRequests((prev) => [
|
|
3658
|
+
...prev,
|
|
3659
|
+
{
|
|
3660
|
+
id: generateId2(),
|
|
3661
|
+
method: "BEACON",
|
|
3662
|
+
url,
|
|
3663
|
+
size,
|
|
3664
|
+
status: 0,
|
|
3665
|
+
// Beacon doesn't return status
|
|
3666
|
+
timestamp: performance.now()
|
|
3667
|
+
}
|
|
3668
|
+
]);
|
|
3669
|
+
return originalBeaconRef.current(url, data);
|
|
3670
|
+
};
|
|
3671
|
+
}
|
|
3672
|
+
if (!originalWebSocketRef.current) {
|
|
3673
|
+
originalWebSocketRef.current = window.WebSocket;
|
|
3674
|
+
const OriginalWS = window.WebSocket;
|
|
3675
|
+
const PatchedWebSocket = class extends OriginalWS {
|
|
3676
|
+
constructor(url, protocols) {
|
|
3677
|
+
super(url, protocols);
|
|
3678
|
+
setRequests((prev) => [
|
|
3679
|
+
...prev,
|
|
3680
|
+
{
|
|
3681
|
+
id: generateId2(),
|
|
3682
|
+
method: "WEBSOCKET",
|
|
3683
|
+
url: url.toString(),
|
|
3684
|
+
size: 0,
|
|
3685
|
+
status: 0,
|
|
3686
|
+
timestamp: performance.now()
|
|
3687
|
+
}
|
|
3688
|
+
]);
|
|
3689
|
+
}
|
|
3690
|
+
};
|
|
3691
|
+
window.WebSocket = PatchedWebSocket;
|
|
3692
|
+
}
|
|
3693
|
+
if (!originalWorkerRef.current && typeof Worker !== "undefined") {
|
|
3694
|
+
originalWorkerRef.current = window.Worker;
|
|
3695
|
+
const OriginalWorker = window.Worker;
|
|
3696
|
+
const PatchedWorker = class extends OriginalWorker {
|
|
3697
|
+
constructor(scriptURL, options) {
|
|
3698
|
+
super(scriptURL, options);
|
|
3699
|
+
setRequests((prev) => [
|
|
3700
|
+
...prev,
|
|
3701
|
+
{
|
|
3702
|
+
id: generateId2(),
|
|
3703
|
+
method: "WORKER",
|
|
3704
|
+
url: scriptURL.toString(),
|
|
3705
|
+
size: 0,
|
|
3706
|
+
status: 0,
|
|
3707
|
+
timestamp: performance.now()
|
|
3708
|
+
}
|
|
3709
|
+
]);
|
|
3710
|
+
}
|
|
3711
|
+
};
|
|
3712
|
+
window.Worker = PatchedWorker;
|
|
3713
|
+
}
|
|
3714
|
+
if (!originalSharedWorkerRef.current && typeof SharedWorker !== "undefined") {
|
|
3715
|
+
originalSharedWorkerRef.current = window.SharedWorker;
|
|
3716
|
+
const OriginalSharedWorker = window.SharedWorker;
|
|
3717
|
+
const PatchedSharedWorker = class extends OriginalSharedWorker {
|
|
3718
|
+
constructor(scriptURL, options) {
|
|
3719
|
+
super(scriptURL, options);
|
|
3720
|
+
setRequests((prev) => [
|
|
3721
|
+
...prev,
|
|
3722
|
+
{
|
|
3723
|
+
id: generateId2(),
|
|
3724
|
+
method: "SHAREDWORKER",
|
|
3725
|
+
url: scriptURL.toString(),
|
|
3726
|
+
size: 0,
|
|
3727
|
+
status: 0,
|
|
3728
|
+
timestamp: performance.now()
|
|
3729
|
+
}
|
|
3730
|
+
]);
|
|
3731
|
+
}
|
|
3732
|
+
};
|
|
3733
|
+
window.SharedWorker = PatchedSharedWorker;
|
|
3734
|
+
}
|
|
3735
|
+
if (!originalRTCRef.current && typeof RTCPeerConnection !== "undefined") {
|
|
3736
|
+
originalRTCRef.current = window.RTCPeerConnection;
|
|
3737
|
+
const OriginalRTC = window.RTCPeerConnection;
|
|
3738
|
+
const PatchedRTC = class extends OriginalRTC {
|
|
3739
|
+
constructor(config) {
|
|
3740
|
+
super(config);
|
|
3741
|
+
const iceServers = config?.iceServers?.map(
|
|
3742
|
+
(s) => Array.isArray(s.urls) ? s.urls.join(", ") : s.urls
|
|
3743
|
+
).join("; ") || "default";
|
|
3744
|
+
setRequests((prev) => [
|
|
3745
|
+
...prev,
|
|
3746
|
+
{
|
|
3747
|
+
id: generateId2(),
|
|
3748
|
+
method: "WEBRTC",
|
|
3749
|
+
url: `ICE: ${iceServers}`,
|
|
3750
|
+
size: 0,
|
|
3751
|
+
status: 0,
|
|
3752
|
+
timestamp: performance.now()
|
|
3753
|
+
}
|
|
3754
|
+
]);
|
|
3755
|
+
}
|
|
3756
|
+
};
|
|
3757
|
+
window.RTCPeerConnection = PatchedRTC;
|
|
3758
|
+
}
|
|
3759
|
+
}, []);
|
|
3760
|
+
const stopRecording = (0, import_react8.useCallback)(() => {
|
|
3761
|
+
setIsRecording(false);
|
|
3762
|
+
if (observerRef.current) {
|
|
3763
|
+
observerRef.current.disconnect();
|
|
3764
|
+
observerRef.current = null;
|
|
3765
|
+
}
|
|
3766
|
+
if (originalFetchRef.current) {
|
|
3767
|
+
window.fetch = originalFetchRef.current;
|
|
3768
|
+
originalFetchRef.current = null;
|
|
3769
|
+
}
|
|
3770
|
+
if (originalXHROpenRef.current) {
|
|
3771
|
+
XMLHttpRequest.prototype.open = originalXHROpenRef.current;
|
|
3772
|
+
originalXHROpenRef.current = null;
|
|
3773
|
+
}
|
|
3774
|
+
if (originalBeaconRef.current) {
|
|
3775
|
+
navigator.sendBeacon = originalBeaconRef.current;
|
|
3776
|
+
originalBeaconRef.current = null;
|
|
3777
|
+
}
|
|
3778
|
+
if (originalWebSocketRef.current) {
|
|
3779
|
+
window.WebSocket = originalWebSocketRef.current;
|
|
3780
|
+
originalWebSocketRef.current = null;
|
|
3781
|
+
}
|
|
3782
|
+
if (originalWorkerRef.current) {
|
|
3783
|
+
window.Worker = originalWorkerRef.current;
|
|
3784
|
+
originalWorkerRef.current = null;
|
|
3785
|
+
}
|
|
3786
|
+
if (originalSharedWorkerRef.current) {
|
|
3787
|
+
window.SharedWorker = originalSharedWorkerRef.current;
|
|
3788
|
+
originalSharedWorkerRef.current = null;
|
|
3789
|
+
}
|
|
3790
|
+
if (originalRTCRef.current) {
|
|
3791
|
+
window.RTCPeerConnection = originalRTCRef.current;
|
|
3792
|
+
originalRTCRef.current = null;
|
|
3793
|
+
}
|
|
3794
|
+
}, []);
|
|
3795
|
+
const clearRequests = (0, import_react8.useCallback)(() => {
|
|
3796
|
+
setRequests([]);
|
|
3797
|
+
}, []);
|
|
3798
|
+
(0, import_react8.useEffect)(() => {
|
|
3799
|
+
return () => {
|
|
3800
|
+
stopRecording();
|
|
3801
|
+
};
|
|
3802
|
+
}, [stopRecording]);
|
|
3803
|
+
const totalBytes = requests.reduce((sum, r) => sum + r.size, 0);
|
|
3804
|
+
const requestCount = requests.length;
|
|
3805
|
+
return {
|
|
3806
|
+
requests,
|
|
3807
|
+
isRecording,
|
|
3808
|
+
startRecording,
|
|
3809
|
+
stopRecording,
|
|
3810
|
+
clearRequests,
|
|
3811
|
+
totalBytes,
|
|
3812
|
+
requestCount
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
// src/hooks/useEnhancedNetworkProof.ts
|
|
3817
|
+
var import_react9 = require("react");
|
|
3818
|
+
function generateId3() {
|
|
3819
|
+
return generateSecureId();
|
|
3820
|
+
}
|
|
3821
|
+
function useEnhancedNetworkProof() {
|
|
3822
|
+
const [state, setState] = (0, import_react9.useState)({
|
|
3823
|
+
webSocketConnections: [],
|
|
3824
|
+
webRTCConnections: [],
|
|
3825
|
+
serviceWorkers: [],
|
|
3826
|
+
beaconRequests: [],
|
|
3827
|
+
hasConcerningActivity: false,
|
|
3828
|
+
summary: "Not monitoring"
|
|
3829
|
+
});
|
|
3830
|
+
const [isMonitoring, setIsMonitoring] = (0, import_react9.useState)(false);
|
|
3831
|
+
const originalWebSocket = (0, import_react9.useRef)(null);
|
|
3832
|
+
const originalRTCPeerConnection = (0, import_react9.useRef)(null);
|
|
3833
|
+
const originalSendBeacon = (0, import_react9.useRef)(null);
|
|
3834
|
+
const updateSummary = (0, import_react9.useCallback)((currentState) => {
|
|
3835
|
+
const issues = [];
|
|
3836
|
+
if (currentState.webSocketConnections.length > 0) {
|
|
3837
|
+
issues.push(`${currentState.webSocketConnections.length} WebSocket connection(s)`);
|
|
3838
|
+
}
|
|
3839
|
+
if (currentState.webRTCConnections.length > 0) {
|
|
3840
|
+
issues.push(`${currentState.webRTCConnections.length} WebRTC connection(s)`);
|
|
3841
|
+
}
|
|
3842
|
+
if (currentState.serviceWorkers.length > 0) {
|
|
3843
|
+
issues.push(`${currentState.serviceWorkers.length} Service Worker(s) registered`);
|
|
3844
|
+
}
|
|
3845
|
+
if (currentState.beaconRequests.length > 0) {
|
|
3846
|
+
issues.push(`${currentState.beaconRequests.length} Beacon request(s)`);
|
|
3847
|
+
}
|
|
3848
|
+
if (issues.length === 0) {
|
|
3849
|
+
return "No WebSocket, WebRTC, Service Workers, or Beacons detected";
|
|
3850
|
+
}
|
|
3851
|
+
return `Detected: ${issues.join(", ")}`;
|
|
3852
|
+
}, []);
|
|
3853
|
+
const startMonitoring = (0, import_react9.useCallback)(() => {
|
|
3854
|
+
setIsMonitoring(true);
|
|
3855
|
+
if (!originalWebSocket.current && typeof WebSocket !== "undefined") {
|
|
3856
|
+
originalWebSocket.current = WebSocket;
|
|
3857
|
+
const OriginalWebSocket = WebSocket;
|
|
3858
|
+
window.WebSocket = function(url, protocols) {
|
|
3859
|
+
const ws = new OriginalWebSocket(url, protocols);
|
|
3860
|
+
setState((prev) => {
|
|
3861
|
+
const newConnections = [
|
|
3862
|
+
...prev.webSocketConnections,
|
|
3863
|
+
{
|
|
3864
|
+
id: generateId3(),
|
|
3865
|
+
url,
|
|
3866
|
+
timestamp: Date.now(),
|
|
3867
|
+
state: "connecting"
|
|
3868
|
+
}
|
|
3869
|
+
];
|
|
3870
|
+
return {
|
|
3871
|
+
...prev,
|
|
3872
|
+
webSocketConnections: newConnections,
|
|
3873
|
+
hasConcerningActivity: true,
|
|
3874
|
+
summary: updateSummary({ ...prev, webSocketConnections: newConnections })
|
|
3875
|
+
};
|
|
3876
|
+
});
|
|
3877
|
+
return ws;
|
|
3878
|
+
};
|
|
3879
|
+
Object.assign(window.WebSocket, OriginalWebSocket);
|
|
3880
|
+
}
|
|
3881
|
+
if (!originalRTCPeerConnection.current && typeof RTCPeerConnection !== "undefined") {
|
|
3882
|
+
originalRTCPeerConnection.current = RTCPeerConnection;
|
|
3883
|
+
const OriginalRTCPeerConnection = RTCPeerConnection;
|
|
3884
|
+
window.RTCPeerConnection = function(config) {
|
|
3885
|
+
const pc = new OriginalRTCPeerConnection(config);
|
|
3886
|
+
setState((prev) => {
|
|
3887
|
+
const newConnections = [
|
|
3888
|
+
...prev.webRTCConnections,
|
|
3889
|
+
{
|
|
3890
|
+
id: generateId3(),
|
|
3891
|
+
timestamp: Date.now(),
|
|
3892
|
+
type: "offer"
|
|
3893
|
+
}
|
|
3894
|
+
];
|
|
3895
|
+
return {
|
|
3896
|
+
...prev,
|
|
3897
|
+
webRTCConnections: newConnections,
|
|
3898
|
+
hasConcerningActivity: true,
|
|
3899
|
+
summary: updateSummary({ ...prev, webRTCConnections: newConnections })
|
|
3900
|
+
};
|
|
3901
|
+
});
|
|
3902
|
+
return pc;
|
|
3903
|
+
};
|
|
3904
|
+
window.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
|
3905
|
+
Object.assign(window.RTCPeerConnection, OriginalRTCPeerConnection);
|
|
3906
|
+
}
|
|
3907
|
+
if (!originalSendBeacon.current && typeof navigator.sendBeacon !== "undefined") {
|
|
3908
|
+
originalSendBeacon.current = navigator.sendBeacon.bind(navigator);
|
|
3909
|
+
navigator.sendBeacon = (url, data) => {
|
|
3910
|
+
setState((prev) => {
|
|
3911
|
+
const newBeacons = [
|
|
3912
|
+
...prev.beaconRequests,
|
|
3913
|
+
{
|
|
3914
|
+
id: generateId3(),
|
|
3915
|
+
url,
|
|
3916
|
+
timestamp: Date.now()
|
|
3917
|
+
}
|
|
3918
|
+
];
|
|
3919
|
+
return {
|
|
3920
|
+
...prev,
|
|
3921
|
+
beaconRequests: newBeacons,
|
|
3922
|
+
hasConcerningActivity: true,
|
|
3923
|
+
summary: updateSummary({ ...prev, beaconRequests: newBeacons })
|
|
3924
|
+
};
|
|
3925
|
+
});
|
|
3926
|
+
return originalSendBeacon.current(url, data);
|
|
3927
|
+
};
|
|
3928
|
+
}
|
|
3929
|
+
setState((prev) => ({
|
|
3930
|
+
...prev,
|
|
3931
|
+
summary: "Monitoring WebSocket, WebRTC, Beacons..."
|
|
3932
|
+
}));
|
|
3933
|
+
}, [updateSummary]);
|
|
3934
|
+
const stopMonitoring = (0, import_react9.useCallback)(() => {
|
|
3935
|
+
setIsMonitoring(false);
|
|
3936
|
+
if (originalWebSocket.current) {
|
|
3937
|
+
window.WebSocket = originalWebSocket.current;
|
|
3938
|
+
originalWebSocket.current = null;
|
|
3939
|
+
}
|
|
3940
|
+
if (originalRTCPeerConnection.current) {
|
|
3941
|
+
window.RTCPeerConnection = originalRTCPeerConnection.current;
|
|
3942
|
+
originalRTCPeerConnection.current = null;
|
|
3943
|
+
}
|
|
3944
|
+
if (originalSendBeacon.current) {
|
|
3945
|
+
navigator.sendBeacon = originalSendBeacon.current;
|
|
3946
|
+
originalSendBeacon.current = null;
|
|
3947
|
+
}
|
|
3948
|
+
setState((prev) => ({
|
|
3949
|
+
...prev,
|
|
3950
|
+
summary: updateSummary(prev)
|
|
3951
|
+
}));
|
|
3952
|
+
}, [updateSummary]);
|
|
3953
|
+
const checkServiceWorkers = (0, import_react9.useCallback)(async () => {
|
|
3954
|
+
if (!("serviceWorker" in navigator)) {
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
try {
|
|
3958
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
3959
|
+
const swInfo = registrations.map((reg) => ({
|
|
3960
|
+
scriptURL: reg.active?.scriptURL || reg.installing?.scriptURL || reg.waiting?.scriptURL || "unknown",
|
|
3961
|
+
state: reg.active?.state || reg.installing?.state || reg.waiting?.state || "unknown",
|
|
3962
|
+
timestamp: Date.now()
|
|
3963
|
+
}));
|
|
3964
|
+
setState((prev) => {
|
|
3965
|
+
const hasSW = swInfo.length > 0;
|
|
3966
|
+
return {
|
|
3967
|
+
...prev,
|
|
3968
|
+
serviceWorkers: swInfo,
|
|
3969
|
+
hasConcerningActivity: prev.hasConcerningActivity || hasSW,
|
|
3970
|
+
summary: updateSummary({ ...prev, serviceWorkers: swInfo })
|
|
3971
|
+
};
|
|
3972
|
+
});
|
|
3973
|
+
} catch (error) {
|
|
3974
|
+
console.warn("Could not check service workers:", error);
|
|
3975
|
+
}
|
|
3976
|
+
}, [updateSummary]);
|
|
3977
|
+
const reset = (0, import_react9.useCallback)(() => {
|
|
3978
|
+
stopMonitoring();
|
|
3979
|
+
setState({
|
|
3980
|
+
webSocketConnections: [],
|
|
3981
|
+
webRTCConnections: [],
|
|
3982
|
+
serviceWorkers: [],
|
|
3983
|
+
beaconRequests: [],
|
|
3984
|
+
hasConcerningActivity: false,
|
|
3985
|
+
summary: "Not monitoring"
|
|
3986
|
+
});
|
|
3987
|
+
}, [stopMonitoring]);
|
|
3988
|
+
(0, import_react9.useEffect)(() => {
|
|
3989
|
+
return () => {
|
|
3990
|
+
stopMonitoring();
|
|
3991
|
+
};
|
|
3992
|
+
}, [stopMonitoring]);
|
|
3993
|
+
return {
|
|
3994
|
+
state,
|
|
3995
|
+
startMonitoring,
|
|
3996
|
+
stopMonitoring,
|
|
3997
|
+
checkServiceWorkers,
|
|
3998
|
+
reset,
|
|
3999
|
+
isMonitoring
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
// src/hooks/useStorageProof.ts
|
|
4004
|
+
var import_react10 = require("react");
|
|
4005
|
+
var WATERMARK_KEY = "purge_watermark_test";
|
|
4006
|
+
function countLocalStorage() {
|
|
4007
|
+
try {
|
|
4008
|
+
return localStorage.length;
|
|
4009
|
+
} catch {
|
|
4010
|
+
return 0;
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
function countSessionStorage() {
|
|
4014
|
+
try {
|
|
4015
|
+
return sessionStorage.length;
|
|
4016
|
+
} catch {
|
|
4017
|
+
return 0;
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
async function countIndexedDB() {
|
|
4021
|
+
try {
|
|
4022
|
+
if ("indexedDB" in window && "databases" in indexedDB) {
|
|
4023
|
+
const databases = await indexedDB.databases();
|
|
4024
|
+
return databases.length;
|
|
4025
|
+
}
|
|
4026
|
+
} catch {
|
|
4027
|
+
}
|
|
4028
|
+
return 0;
|
|
4029
|
+
}
|
|
4030
|
+
function countCookies() {
|
|
4031
|
+
try {
|
|
4032
|
+
const cookies = document.cookie;
|
|
4033
|
+
if (!cookies) return 0;
|
|
4034
|
+
return cookies.split(";").filter((c) => c.trim()).length;
|
|
4035
|
+
} catch {
|
|
4036
|
+
return 0;
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
async function countCacheAPI() {
|
|
4040
|
+
if (!("caches" in window)) return 0;
|
|
4041
|
+
try {
|
|
4042
|
+
const cacheNames = await caches.keys();
|
|
4043
|
+
return cacheNames.length;
|
|
4044
|
+
} catch {
|
|
4045
|
+
return 0;
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
async function takeSnapshot() {
|
|
4049
|
+
const [indexedDBCount, cacheAPICount] = await Promise.all([
|
|
4050
|
+
countIndexedDB(),
|
|
4051
|
+
countCacheAPI()
|
|
4052
|
+
]);
|
|
4053
|
+
return {
|
|
4054
|
+
localStorage: countLocalStorage(),
|
|
4055
|
+
sessionStorage: countSessionStorage(),
|
|
4056
|
+
indexedDB: indexedDBCount,
|
|
4057
|
+
cookies: countCookies(),
|
|
4058
|
+
cacheAPI: cacheAPICount,
|
|
4059
|
+
watermarkPlanted: false,
|
|
4060
|
+
watermarkVerified: false
|
|
4061
|
+
};
|
|
4062
|
+
}
|
|
4063
|
+
function useStorageProof() {
|
|
4064
|
+
const [beforeSnapshot, setBeforeSnapshot] = (0, import_react10.useState)(null);
|
|
4065
|
+
const [afterSnapshot, setAfterSnapshot] = (0, import_react10.useState)(null);
|
|
4066
|
+
const [watermarkStatus, setWatermarkStatus] = (0, import_react10.useState)("not_planted");
|
|
4067
|
+
const watermarkValueRef = (0, import_react10.useRef)("");
|
|
4068
|
+
const takeBeforeSnapshot = (0, import_react10.useCallback)(async () => {
|
|
4069
|
+
const snapshot = await takeSnapshot();
|
|
4070
|
+
setBeforeSnapshot(snapshot);
|
|
4071
|
+
setAfterSnapshot(null);
|
|
4072
|
+
setWatermarkStatus("not_planted");
|
|
4073
|
+
}, []);
|
|
4074
|
+
const takeAfterSnapshot = (0, import_react10.useCallback)(async () => {
|
|
4075
|
+
const snapshot = await takeSnapshot();
|
|
4076
|
+
setAfterSnapshot(snapshot);
|
|
4077
|
+
}, []);
|
|
4078
|
+
const plantWatermark = (0, import_react10.useCallback)(() => {
|
|
4079
|
+
const value = `purge_test_${generateSecureId()}`;
|
|
4080
|
+
watermarkValueRef.current = value;
|
|
4081
|
+
try {
|
|
4082
|
+
sessionStorage.setItem(WATERMARK_KEY, value);
|
|
4083
|
+
setWatermarkStatus("planted");
|
|
4084
|
+
setBeforeSnapshot(
|
|
4085
|
+
(prev) => prev ? {
|
|
4086
|
+
...prev,
|
|
4087
|
+
sessionStorage: prev.sessionStorage + 1,
|
|
4088
|
+
watermarkPlanted: true
|
|
4089
|
+
} : null
|
|
4090
|
+
);
|
|
4091
|
+
} catch {
|
|
4092
|
+
setWatermarkStatus("failed");
|
|
4093
|
+
}
|
|
4094
|
+
}, []);
|
|
4095
|
+
const verifyWatermark = (0, import_react10.useCallback)(() => {
|
|
4096
|
+
try {
|
|
4097
|
+
const storedValue = sessionStorage.getItem(WATERMARK_KEY);
|
|
4098
|
+
if (storedValue === watermarkValueRef.current) {
|
|
4099
|
+
sessionStorage.removeItem(WATERMARK_KEY);
|
|
4100
|
+
setWatermarkStatus("verified");
|
|
4101
|
+
setAfterSnapshot(
|
|
4102
|
+
(prev) => prev ? {
|
|
4103
|
+
...prev,
|
|
4104
|
+
watermarkVerified: true
|
|
4105
|
+
} : null
|
|
4106
|
+
);
|
|
4107
|
+
return true;
|
|
4108
|
+
} else {
|
|
4109
|
+
setWatermarkStatus("failed");
|
|
4110
|
+
return false;
|
|
4111
|
+
}
|
|
4112
|
+
} catch {
|
|
4113
|
+
setWatermarkStatus("failed");
|
|
4114
|
+
return false;
|
|
4115
|
+
}
|
|
4116
|
+
}, []);
|
|
4117
|
+
const isDifferent = (() => {
|
|
4118
|
+
if (!beforeSnapshot || !afterSnapshot) return false;
|
|
4119
|
+
const expectedLocalStorage = beforeSnapshot.localStorage;
|
|
4120
|
+
const expectedSessionStorage = beforeSnapshot.watermarkPlanted ? beforeSnapshot.sessionStorage - 1 : beforeSnapshot.sessionStorage;
|
|
4121
|
+
const expectedIndexedDB = beforeSnapshot.indexedDB;
|
|
4122
|
+
const expectedCookies = beforeSnapshot.cookies;
|
|
4123
|
+
const expectedCacheAPI = beforeSnapshot.cacheAPI;
|
|
4124
|
+
return afterSnapshot.localStorage !== expectedLocalStorage || afterSnapshot.sessionStorage !== expectedSessionStorage || afterSnapshot.indexedDB !== expectedIndexedDB || afterSnapshot.cookies !== expectedCookies || afterSnapshot.cacheAPI !== expectedCacheAPI;
|
|
4125
|
+
})();
|
|
4126
|
+
return {
|
|
4127
|
+
beforeSnapshot,
|
|
4128
|
+
afterSnapshot,
|
|
4129
|
+
isDifferent,
|
|
4130
|
+
watermarkStatus,
|
|
4131
|
+
takeBeforeSnapshot,
|
|
4132
|
+
takeAfterSnapshot,
|
|
4133
|
+
plantWatermark,
|
|
4134
|
+
verifyWatermark
|
|
4135
|
+
};
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
// src/hooks/useAirplaneMode.ts
|
|
4139
|
+
var import_react11 = require("react");
|
|
4140
|
+
function detectPlatform() {
|
|
4141
|
+
const ua = navigator.userAgent.toLowerCase();
|
|
4142
|
+
const platform = navigator.platform?.toLowerCase() || "";
|
|
4143
|
+
if (/iphone|ipad|android/i.test(ua)) return "mobile";
|
|
4144
|
+
if (platform.includes("mac") || ua.includes("mac")) return "mac";
|
|
4145
|
+
if (platform.includes("win") || ua.includes("win")) return "windows";
|
|
4146
|
+
if (platform.includes("linux") || ua.includes("linux")) return "linux";
|
|
4147
|
+
return "unknown";
|
|
4148
|
+
}
|
|
4149
|
+
function getOfflineInstructionsForPlatform(platform) {
|
|
4150
|
+
switch (platform) {
|
|
4151
|
+
case "mac":
|
|
4152
|
+
return "Click the WiFi icon in your menu bar \u2192 Turn Wi-Fi Off, or press Option+click WiFi \u2192 Disconnect";
|
|
4153
|
+
case "windows":
|
|
4154
|
+
return "Click the WiFi icon in your taskbar \u2192 Click the connection \u2192 Disconnect, or enable Airplane Mode";
|
|
4155
|
+
case "linux":
|
|
4156
|
+
return "Click the network icon \u2192 Disable Wi-Fi, or run: nmcli radio wifi off";
|
|
4157
|
+
case "mobile":
|
|
4158
|
+
return "Swipe down for Control Center/Quick Settings \u2192 Enable Airplane Mode";
|
|
4159
|
+
default:
|
|
4160
|
+
return "Disable your WiFi or enable Airplane Mode in your system settings";
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
function useAirplaneMode() {
|
|
4164
|
+
const [state, setState] = (0, import_react11.useState)({
|
|
4165
|
+
isOnline: typeof navigator !== "undefined" ? navigator.onLine : true,
|
|
4166
|
+
processingStartedOffline: false,
|
|
4167
|
+
stayedOfflineDuringProcessing: false,
|
|
4168
|
+
challengeAccepted: false,
|
|
4169
|
+
isProcessing: false,
|
|
4170
|
+
connectionLog: [],
|
|
4171
|
+
proofInvalidated: false
|
|
4172
|
+
});
|
|
4173
|
+
const platform = (0, import_react11.useRef)(detectPlatform());
|
|
4174
|
+
const logEvent = (0, import_react11.useCallback)((status, event) => {
|
|
4175
|
+
setState((prev) => ({
|
|
4176
|
+
...prev,
|
|
4177
|
+
connectionLog: [
|
|
4178
|
+
...prev.connectionLog,
|
|
4179
|
+
{
|
|
4180
|
+
timestamp: Date.now(),
|
|
4181
|
+
status,
|
|
4182
|
+
event
|
|
4183
|
+
}
|
|
4184
|
+
]
|
|
4185
|
+
}));
|
|
4186
|
+
}, []);
|
|
4187
|
+
const prevOnlineRef = (0, import_react11.useRef)(null);
|
|
4188
|
+
(0, import_react11.useEffect)(() => {
|
|
4189
|
+
const initialOnline = navigator.onLine;
|
|
4190
|
+
prevOnlineRef.current = initialOnline;
|
|
4191
|
+
logEvent(initialOnline ? "online" : "offline", "initial");
|
|
4192
|
+
const handleOnline = () => {
|
|
4193
|
+
if (prevOnlineRef.current === true) return;
|
|
4194
|
+
prevOnlineRef.current = true;
|
|
4195
|
+
setState((prev) => {
|
|
4196
|
+
const newState = { ...prev, isOnline: true };
|
|
4197
|
+
if (prev.isProcessing && prev.processingStartedOffline) {
|
|
4198
|
+
newState.proofInvalidated = true;
|
|
4199
|
+
newState.stayedOfflineDuringProcessing = false;
|
|
4200
|
+
}
|
|
4201
|
+
return newState;
|
|
4202
|
+
});
|
|
4203
|
+
logEvent("online", "change");
|
|
4204
|
+
};
|
|
4205
|
+
const handleOffline = () => {
|
|
4206
|
+
if (prevOnlineRef.current === false) return;
|
|
4207
|
+
prevOnlineRef.current = false;
|
|
4208
|
+
setState((prev) => ({ ...prev, isOnline: false }));
|
|
4209
|
+
logEvent("offline", "change");
|
|
4210
|
+
};
|
|
4211
|
+
window.addEventListener("online", handleOnline);
|
|
4212
|
+
window.addEventListener("offline", handleOffline);
|
|
4213
|
+
const pollInterval = setInterval(() => {
|
|
4214
|
+
const currentOnline = navigator.onLine;
|
|
4215
|
+
if (currentOnline !== prevOnlineRef.current) {
|
|
4216
|
+
if (currentOnline) {
|
|
4217
|
+
handleOnline();
|
|
4218
|
+
} else {
|
|
4219
|
+
handleOffline();
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
}, 2e3);
|
|
4223
|
+
return () => {
|
|
4224
|
+
window.removeEventListener("online", handleOnline);
|
|
4225
|
+
window.removeEventListener("offline", handleOffline);
|
|
4226
|
+
clearInterval(pollInterval);
|
|
4227
|
+
};
|
|
4228
|
+
}, [logEvent]);
|
|
4229
|
+
const acceptChallenge = (0, import_react11.useCallback)(() => {
|
|
4230
|
+
setState((prev) => ({ ...prev, challengeAccepted: true }));
|
|
4231
|
+
}, []);
|
|
4232
|
+
const startProcessing = (0, import_react11.useCallback)(() => {
|
|
4233
|
+
const isCurrentlyOffline = !navigator.onLine;
|
|
4234
|
+
setState((prev) => ({
|
|
4235
|
+
...prev,
|
|
4236
|
+
isProcessing: true,
|
|
4237
|
+
processingStartedOffline: isCurrentlyOffline,
|
|
4238
|
+
stayedOfflineDuringProcessing: isCurrentlyOffline,
|
|
4239
|
+
proofInvalidated: false
|
|
4240
|
+
}));
|
|
4241
|
+
logEvent(isCurrentlyOffline ? "offline" : "online", "processing_start");
|
|
4242
|
+
}, [logEvent]);
|
|
4243
|
+
const endProcessing = (0, import_react11.useCallback)(() => {
|
|
4244
|
+
const isCurrentlyOffline = !navigator.onLine;
|
|
4245
|
+
setState((prev) => ({
|
|
4246
|
+
...prev,
|
|
4247
|
+
isProcessing: false,
|
|
4248
|
+
// Only valid if started offline AND still offline
|
|
4249
|
+
stayedOfflineDuringProcessing: prev.processingStartedOffline && isCurrentlyOffline && !prev.proofInvalidated
|
|
4250
|
+
}));
|
|
4251
|
+
logEvent(isCurrentlyOffline ? "offline" : "online", "processing_end");
|
|
4252
|
+
}, [logEvent]);
|
|
4253
|
+
const reset = (0, import_react11.useCallback)(() => {
|
|
4254
|
+
setState({
|
|
4255
|
+
isOnline: navigator.onLine,
|
|
4256
|
+
processingStartedOffline: false,
|
|
4257
|
+
stayedOfflineDuringProcessing: false,
|
|
4258
|
+
challengeAccepted: false,
|
|
4259
|
+
isProcessing: false,
|
|
4260
|
+
connectionLog: [],
|
|
4261
|
+
proofInvalidated: false
|
|
4262
|
+
});
|
|
4263
|
+
}, []);
|
|
4264
|
+
const getStatusMessage = (0, import_react11.useCallback)(() => {
|
|
4265
|
+
if (state.proofInvalidated) {
|
|
4266
|
+
return "Connection was restored during processing - offline proof invalidated";
|
|
4267
|
+
}
|
|
4268
|
+
if (state.stayedOfflineDuringProcessing) {
|
|
4269
|
+
return "Processed with NO INTERNET CONNECTION - strong privacy assurance";
|
|
4270
|
+
}
|
|
4271
|
+
if (state.processingStartedOffline && state.isProcessing) {
|
|
4272
|
+
return "Processing offline - stay disconnected for valid proof";
|
|
4273
|
+
}
|
|
4274
|
+
if (!state.isOnline) {
|
|
4275
|
+
return "You're offline - drop your file now for maximum privacy";
|
|
4276
|
+
}
|
|
4277
|
+
if (state.challengeAccepted) {
|
|
4278
|
+
return "Go offline to prove files stay local";
|
|
4279
|
+
}
|
|
4280
|
+
return "Currently online - consider going offline for proof";
|
|
4281
|
+
}, [state]);
|
|
4282
|
+
const getOfflineInstructions = (0, import_react11.useCallback)(() => {
|
|
4283
|
+
return getOfflineInstructionsForPlatform(platform.current);
|
|
4284
|
+
}, []);
|
|
4285
|
+
return {
|
|
4286
|
+
state,
|
|
4287
|
+
acceptChallenge,
|
|
4288
|
+
startProcessing,
|
|
4289
|
+
endProcessing,
|
|
4290
|
+
reset,
|
|
4291
|
+
getStatusMessage,
|
|
4292
|
+
getOfflineInstructions
|
|
4293
|
+
};
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
// src/hooks/useFirstTimeUser.ts
|
|
4297
|
+
var import_react12 = require("react");
|
|
4298
|
+
var STORAGE_KEY = "purge_onboarding_completed";
|
|
4299
|
+
function useFirstTimeUser() {
|
|
4300
|
+
const [isFirstTime, setIsFirstTime] = (0, import_react12.useState)(false);
|
|
4301
|
+
const [isLoading, setIsLoading] = (0, import_react12.useState)(true);
|
|
4302
|
+
(0, import_react12.useEffect)(() => {
|
|
4303
|
+
try {
|
|
4304
|
+
const hasCompleted = localStorage.getItem(STORAGE_KEY);
|
|
4305
|
+
setIsFirstTime(hasCompleted !== "true");
|
|
4306
|
+
} catch {
|
|
4307
|
+
setIsFirstTime(false);
|
|
4308
|
+
}
|
|
4309
|
+
setIsLoading(false);
|
|
4310
|
+
}, []);
|
|
4311
|
+
const markOnboardingComplete = (0, import_react12.useCallback)(() => {
|
|
4312
|
+
try {
|
|
4313
|
+
localStorage.setItem(STORAGE_KEY, "true");
|
|
4314
|
+
} catch {
|
|
4315
|
+
}
|
|
4316
|
+
setIsFirstTime(false);
|
|
4317
|
+
}, []);
|
|
4318
|
+
const resetOnboarding = (0, import_react12.useCallback)(() => {
|
|
4319
|
+
try {
|
|
4320
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
4321
|
+
} catch {
|
|
4322
|
+
}
|
|
4323
|
+
setIsFirstTime(true);
|
|
4324
|
+
}, []);
|
|
4325
|
+
return {
|
|
4326
|
+
isFirstTime,
|
|
4327
|
+
isLoading,
|
|
4328
|
+
markOnboardingComplete,
|
|
4329
|
+
resetOnboarding
|
|
4330
|
+
};
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4333
|
+
// src/utils/maskPII.ts
|
|
4334
|
+
function maskPII(category, value) {
|
|
4335
|
+
switch (category) {
|
|
4336
|
+
case "email": {
|
|
4337
|
+
const atIndex = value.indexOf("@");
|
|
4338
|
+
if (atIndex === -1) return maskGeneric(value);
|
|
4339
|
+
const local = value.slice(0, atIndex);
|
|
4340
|
+
const domain = value.slice(atIndex + 1);
|
|
4341
|
+
const maskedLocal = local.slice(0, 2) + "**";
|
|
4342
|
+
const dotIndex = domain.lastIndexOf(".");
|
|
4343
|
+
if (dotIndex === -1) return `${maskedLocal}@**`;
|
|
4344
|
+
const ext = domain.slice(dotIndex);
|
|
4345
|
+
const domainName = domain.slice(0, dotIndex);
|
|
4346
|
+
const maskedDomain = "**" + domainName.slice(-2) + ext;
|
|
4347
|
+
return `${maskedLocal}@${maskedDomain}`;
|
|
4348
|
+
}
|
|
4349
|
+
case "phone": {
|
|
4350
|
+
const digits = value.replace(/\D/g, "");
|
|
4351
|
+
if (digits.length < 4) return maskGeneric(value);
|
|
4352
|
+
const lastFour = digits.slice(-4);
|
|
4353
|
+
return `***-***-${lastFour.slice(0, 2)}${lastFour.slice(2)}`;
|
|
4354
|
+
}
|
|
4355
|
+
case "ssn": {
|
|
4356
|
+
const digits = value.replace(/\D/g, "");
|
|
4357
|
+
if (digits.length < 4) return maskGeneric(value);
|
|
4358
|
+
return `***-**-**${digits.slice(-2)}`;
|
|
4359
|
+
}
|
|
4360
|
+
case "credit_card": {
|
|
4361
|
+
const digits = value.replace(/\D/g, "");
|
|
4362
|
+
if (digits.length < 4) return maskGeneric(value);
|
|
4363
|
+
return `****-****-****-${digits.slice(-4)}`;
|
|
4364
|
+
}
|
|
4365
|
+
case "person_name": {
|
|
4366
|
+
return value.split(/\s+/).map((part) => part.length > 0 ? part[0] + "***" : "").join(" ");
|
|
4367
|
+
}
|
|
4368
|
+
case "address": {
|
|
4369
|
+
const words = value.split(/\s+/);
|
|
4370
|
+
if (words.length < 2) return maskGeneric(value);
|
|
4371
|
+
return words.map((w, i) => {
|
|
4372
|
+
if (/^\d+$/.test(w)) return "***";
|
|
4373
|
+
if (i === 0) return "***";
|
|
4374
|
+
if (w.length > 4) return w.slice(0, 4) + "***";
|
|
4375
|
+
return w;
|
|
4376
|
+
}).join(" ");
|
|
4377
|
+
}
|
|
4378
|
+
case "date_of_birth": {
|
|
4379
|
+
if (value.includes("/")) {
|
|
4380
|
+
const parts = value.split("/");
|
|
4381
|
+
if (parts.length === 3) {
|
|
4382
|
+
return `**/${parts[1]}/**${parts[2].slice(-2)}`;
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
if (value.includes("-")) {
|
|
4386
|
+
const parts = value.split("-");
|
|
4387
|
+
if (parts.length === 3) {
|
|
4388
|
+
return `****-${parts[1]}-**`;
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
return maskGeneric(value);
|
|
4392
|
+
}
|
|
4393
|
+
case "ip_address": {
|
|
4394
|
+
const parts = value.split(".");
|
|
4395
|
+
if (parts.length === 4) {
|
|
4396
|
+
return `${parts[0]}.${parts[1]}.***.***`;
|
|
4397
|
+
}
|
|
4398
|
+
return maskGeneric(value);
|
|
4399
|
+
}
|
|
4400
|
+
case "custom":
|
|
4401
|
+
default:
|
|
4402
|
+
return maskGeneric(value);
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
function maskGeneric(value) {
|
|
4406
|
+
const len = value.length;
|
|
4407
|
+
if (len <= 4) return "****";
|
|
4408
|
+
return value.slice(0, 2) + "***" + value.slice(-2);
|
|
4409
|
+
}
|
|
4410
|
+
function getCategoryLabel(category) {
|
|
4411
|
+
const labels = {
|
|
4412
|
+
person_name: "NAME",
|
|
4413
|
+
email: "EMAIL",
|
|
4414
|
+
phone: "PHONE",
|
|
4415
|
+
address: "ADDRESS",
|
|
4416
|
+
ssn: "SSN",
|
|
4417
|
+
credit_card: "CARD",
|
|
4418
|
+
ip_address: "IP",
|
|
4419
|
+
date_of_birth: "DOB",
|
|
4420
|
+
custom: "CUSTOM"
|
|
4421
|
+
};
|
|
4422
|
+
return labels[category] || "UNKNOWN";
|
|
4423
|
+
}
|
|
4424
|
+
function getCategoryIcon(category) {
|
|
4425
|
+
const icons = {
|
|
4426
|
+
person_name: "[N]",
|
|
4427
|
+
email: "[@]",
|
|
4428
|
+
phone: "[#]",
|
|
4429
|
+
address: "[A]",
|
|
4430
|
+
ssn: "[S]",
|
|
4431
|
+
credit_card: "[$]",
|
|
4432
|
+
ip_address: "[I]",
|
|
4433
|
+
date_of_birth: "[D]",
|
|
4434
|
+
custom: "[*]"
|
|
4435
|
+
};
|
|
4436
|
+
return icons[category] || "[?]";
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
// src/utils/fileMagic.ts
|
|
4440
|
+
var MAGIC_SIGNATURES = {
|
|
4441
|
+
// Office Open XML formats (DOCX, XLSX, PPTX) are ZIP archives
|
|
4442
|
+
// They all start with PK (0x50 0x4B 0x03 0x04)
|
|
4443
|
+
docx: [[80, 75, 3, 4]],
|
|
4444
|
+
xlsx: [[80, 75, 3, 4]],
|
|
4445
|
+
pptx: [[80, 75, 3, 4]],
|
|
4446
|
+
// PDF starts with %PDF-
|
|
4447
|
+
pdf: [[37, 80, 68, 70, 45]]
|
|
4448
|
+
// %PDF-
|
|
4449
|
+
};
|
|
4450
|
+
var EXPECTED_MIMES = {
|
|
4451
|
+
docx: [
|
|
4452
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
4453
|
+
"application/octet-stream"
|
|
4454
|
+
// Some browsers report this
|
|
4455
|
+
],
|
|
4456
|
+
xlsx: [
|
|
4457
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
4458
|
+
"application/vnd.ms-excel",
|
|
4459
|
+
"application/octet-stream"
|
|
4460
|
+
],
|
|
4461
|
+
pptx: [
|
|
4462
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
4463
|
+
"application/octet-stream"
|
|
4464
|
+
],
|
|
4465
|
+
pdf: [
|
|
4466
|
+
"application/pdf",
|
|
4467
|
+
"application/octet-stream"
|
|
4468
|
+
]
|
|
4469
|
+
};
|
|
4470
|
+
function matchesMagicBytes(bytes, signatures) {
|
|
4471
|
+
for (const signature of signatures) {
|
|
4472
|
+
if (bytes.length < signature.length) continue;
|
|
4473
|
+
let matches = true;
|
|
4474
|
+
for (let i = 0; i < signature.length; i++) {
|
|
4475
|
+
if (bytes[i] !== signature[i]) {
|
|
4476
|
+
matches = false;
|
|
4477
|
+
break;
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
if (matches) return true;
|
|
4481
|
+
}
|
|
4482
|
+
return false;
|
|
4483
|
+
}
|
|
4484
|
+
function getExtension(filename) {
|
|
4485
|
+
const lastDot = filename.lastIndexOf(".");
|
|
4486
|
+
if (lastDot === -1) return "";
|
|
4487
|
+
return filename.slice(lastDot).toLowerCase();
|
|
4488
|
+
}
|
|
4489
|
+
var EXTENSION_MAP = {
|
|
4490
|
+
".docx": "docx",
|
|
4491
|
+
".xlsx": "xlsx",
|
|
4492
|
+
".pptx": "pptx",
|
|
4493
|
+
".pdf": "pdf"
|
|
4494
|
+
};
|
|
4495
|
+
async function validateFileType(file) {
|
|
4496
|
+
const warnings = [];
|
|
4497
|
+
const ext = getExtension(file.name);
|
|
4498
|
+
const typeByExt = EXTENSION_MAP[ext];
|
|
4499
|
+
if (!typeByExt) {
|
|
4500
|
+
return {
|
|
4501
|
+
valid: false,
|
|
4502
|
+
fileType: null,
|
|
4503
|
+
error: `Unsupported file extension: ${ext}`,
|
|
4504
|
+
warnings
|
|
4505
|
+
};
|
|
4506
|
+
}
|
|
4507
|
+
const expectedMimes = EXPECTED_MIMES[typeByExt];
|
|
4508
|
+
if (!expectedMimes.includes(file.type) && file.type !== "") {
|
|
4509
|
+
warnings.push(`Unexpected MIME type: ${file.type} (expected ${expectedMimes[0]})`);
|
|
4510
|
+
}
|
|
4511
|
+
try {
|
|
4512
|
+
const headerSize = Math.max(...MAGIC_SIGNATURES[typeByExt].map((s) => s.length));
|
|
4513
|
+
const header = await file.slice(0, headerSize).arrayBuffer();
|
|
4514
|
+
const bytes = new Uint8Array(header);
|
|
4515
|
+
const expectedSignatures = MAGIC_SIGNATURES[typeByExt];
|
|
4516
|
+
if (!matchesMagicBytes(bytes, expectedSignatures)) {
|
|
4517
|
+
const officeTypes = ["docx", "xlsx", "pptx"];
|
|
4518
|
+
if (officeTypes.includes(typeByExt)) {
|
|
4519
|
+
const isZip = matchesMagicBytes(bytes, [[80, 75, 3, 4]]);
|
|
4520
|
+
if (!isZip) {
|
|
4521
|
+
return {
|
|
4522
|
+
valid: false,
|
|
4523
|
+
fileType: null,
|
|
4524
|
+
error: `File header does not match ${ext.toUpperCase()} format. File may be corrupted or misnamed.`,
|
|
4525
|
+
warnings
|
|
4526
|
+
};
|
|
4527
|
+
}
|
|
4528
|
+
warnings.push("File is a valid ZIP archive but specific Office format cannot be verified without parsing");
|
|
4529
|
+
} else {
|
|
4530
|
+
return {
|
|
4531
|
+
valid: false,
|
|
4532
|
+
fileType: null,
|
|
4533
|
+
error: `File header does not match ${ext.toUpperCase()} format. File may be corrupted or misnamed.`,
|
|
4534
|
+
warnings
|
|
4535
|
+
};
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
} catch (error) {
|
|
4539
|
+
warnings.push("Could not verify file header (file may be too small or corrupted)");
|
|
4540
|
+
}
|
|
4541
|
+
return {
|
|
4542
|
+
valid: true,
|
|
4543
|
+
fileType: typeByExt,
|
|
4544
|
+
warnings
|
|
4545
|
+
};
|
|
4546
|
+
}
|
|
4547
|
+
function hasValidExtension(filename) {
|
|
4548
|
+
const ext = getExtension(filename);
|
|
4549
|
+
return ext in EXTENSION_MAP;
|
|
4550
|
+
}
|
|
4551
|
+
function getFileTypeFromExtension(filename) {
|
|
4552
|
+
const ext = getExtension(filename);
|
|
4553
|
+
return EXTENSION_MAP[ext] || null;
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
// src/utils/download.ts
|
|
4557
|
+
function downloadFile(file) {
|
|
4558
|
+
const url = URL.createObjectURL(file.blob);
|
|
4559
|
+
const a = document.createElement("a");
|
|
4560
|
+
a.href = url;
|
|
4561
|
+
a.download = file.purgedName;
|
|
4562
|
+
document.body.appendChild(a);
|
|
4563
|
+
a.click();
|
|
4564
|
+
document.body.removeChild(a);
|
|
4565
|
+
URL.revokeObjectURL(url);
|
|
4566
|
+
}
|
|
4567
|
+
async function downloadFilesAsZip(files, zipName = "purged_files.zip") {
|
|
4568
|
+
if (files.length === 0) return;
|
|
4569
|
+
if (files.length === 1) {
|
|
4570
|
+
downloadFile(files[0]);
|
|
4571
|
+
return;
|
|
4572
|
+
}
|
|
4573
|
+
try {
|
|
4574
|
+
const { zipSync, strToU8 } = await import("fflate");
|
|
4575
|
+
const zipContents = {};
|
|
4576
|
+
for (const file of files) {
|
|
4577
|
+
const arrayBuffer = await file.blob.arrayBuffer();
|
|
4578
|
+
zipContents[file.purgedName] = new Uint8Array(arrayBuffer);
|
|
4579
|
+
}
|
|
4580
|
+
const manifest = {
|
|
4581
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4582
|
+
files: files.map((f) => ({
|
|
4583
|
+
originalName: f.originalName,
|
|
4584
|
+
purgedName: f.purgedName,
|
|
4585
|
+
originalSize: f.originalSize,
|
|
4586
|
+
purgedSize: f.purgedSize,
|
|
4587
|
+
detectionsRemoved: f.detectionsRemoved
|
|
4588
|
+
})),
|
|
4589
|
+
notice: "Files processed locally with PURGE. No data was transmitted to any server."
|
|
4590
|
+
};
|
|
4591
|
+
zipContents["_purge_manifest.json"] = strToU8(
|
|
4592
|
+
JSON.stringify(manifest, null, 2)
|
|
4593
|
+
);
|
|
4594
|
+
const zipped = zipSync(zipContents, {
|
|
4595
|
+
level: 6
|
|
4596
|
+
// Balanced compression
|
|
4597
|
+
});
|
|
4598
|
+
const blob = new Blob([new Uint8Array(zipped)], { type: "application/zip" });
|
|
4599
|
+
const url = URL.createObjectURL(blob);
|
|
4600
|
+
const a = document.createElement("a");
|
|
4601
|
+
a.href = url;
|
|
4602
|
+
a.download = zipName;
|
|
4603
|
+
document.body.appendChild(a);
|
|
4604
|
+
a.click();
|
|
4605
|
+
document.body.removeChild(a);
|
|
4606
|
+
URL.revokeObjectURL(url);
|
|
4607
|
+
} catch (error) {
|
|
4608
|
+
secureWarn("ZIP creation failed, falling back to individual downloads", error);
|
|
4609
|
+
await downloadFilesSequentially(files);
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
async function downloadFilesSequentially(files, delayMs = 500) {
|
|
4613
|
+
for (const file of files) {
|
|
4614
|
+
downloadFile(file);
|
|
4615
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
// src/utils/hilbertCurve.ts
|
|
4620
|
+
function hilbertD2XY(n, d) {
|
|
4621
|
+
if (n <= 0 || (n & n - 1) !== 0) {
|
|
4622
|
+
throw new Error("Grid size n must be a positive power of 2");
|
|
4623
|
+
}
|
|
4624
|
+
if (d < 0 || d >= n * n) {
|
|
4625
|
+
throw new Error(`Index d must be between 0 and ${n * n - 1}`);
|
|
4626
|
+
}
|
|
4627
|
+
let x = 0;
|
|
4628
|
+
let y = 0;
|
|
4629
|
+
let s = 1;
|
|
4630
|
+
let rx;
|
|
4631
|
+
let ry;
|
|
4632
|
+
let t = d;
|
|
4633
|
+
while (s < n) {
|
|
4634
|
+
rx = 1 & t >> 1;
|
|
4635
|
+
ry = 1 & (t ^ rx);
|
|
4636
|
+
if (ry === 0) {
|
|
4637
|
+
if (rx === 1) {
|
|
4638
|
+
x = s - 1 - x;
|
|
4639
|
+
y = s - 1 - y;
|
|
4640
|
+
}
|
|
4641
|
+
[x, y] = [y, x];
|
|
4642
|
+
}
|
|
4643
|
+
x += s * rx;
|
|
4644
|
+
y += s * ry;
|
|
4645
|
+
t = Math.floor(t / 4);
|
|
4646
|
+
s *= 2;
|
|
4647
|
+
}
|
|
4648
|
+
return { x, y };
|
|
4649
|
+
}
|
|
4650
|
+
function hilbertXY2D(n, x, y) {
|
|
4651
|
+
if (n <= 0 || (n & n - 1) !== 0) {
|
|
4652
|
+
throw new Error("Grid size n must be a positive power of 2");
|
|
4653
|
+
}
|
|
4654
|
+
if (x < 0 || x >= n || y < 0 || y >= n) {
|
|
4655
|
+
throw new Error(`Coordinates must be between 0 and ${n - 1}`);
|
|
4656
|
+
}
|
|
4657
|
+
let d = 0;
|
|
4658
|
+
let s = n >> 1;
|
|
4659
|
+
let rx;
|
|
4660
|
+
let ry;
|
|
4661
|
+
let tempX = x;
|
|
4662
|
+
let tempY = y;
|
|
4663
|
+
while (s > 0) {
|
|
4664
|
+
rx = (tempX & s) > 0 ? 1 : 0;
|
|
4665
|
+
ry = (tempY & s) > 0 ? 1 : 0;
|
|
4666
|
+
d += s * s * (3 * rx ^ ry);
|
|
4667
|
+
if (ry === 0) {
|
|
4668
|
+
if (rx === 1) {
|
|
4669
|
+
tempX = s - 1 - tempX;
|
|
4670
|
+
tempY = s - 1 - tempY;
|
|
4671
|
+
}
|
|
4672
|
+
[tempX, tempY] = [tempY, tempX];
|
|
4673
|
+
}
|
|
4674
|
+
s >>= 1;
|
|
4675
|
+
}
|
|
4676
|
+
return d;
|
|
4677
|
+
}
|
|
4678
|
+
function getOptimalGridSize(blockCount) {
|
|
4679
|
+
if (blockCount <= 0) return 1;
|
|
4680
|
+
let n = 1;
|
|
4681
|
+
while (n * n < blockCount) {
|
|
4682
|
+
n *= 2;
|
|
4683
|
+
}
|
|
4684
|
+
return n;
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
// src/constants/categories.ts
|
|
4688
|
+
var categories = [
|
|
4689
|
+
{
|
|
4690
|
+
id: "person_name",
|
|
4691
|
+
name: "Names",
|
|
4692
|
+
description: "Personal names, first/last names",
|
|
4693
|
+
icon: "N",
|
|
4694
|
+
examples: ["John Smith", "Jane Doe", "Dr. Robert Johnson"],
|
|
4695
|
+
defaultEnabled: true
|
|
4696
|
+
},
|
|
4697
|
+
{
|
|
4698
|
+
id: "email",
|
|
4699
|
+
name: "Email",
|
|
4700
|
+
description: "Email addresses",
|
|
4701
|
+
icon: "@",
|
|
4702
|
+
examples: ["john@example.com", "jane.doe@company.org"],
|
|
4703
|
+
defaultEnabled: true
|
|
4704
|
+
},
|
|
4705
|
+
{
|
|
4706
|
+
id: "phone",
|
|
4707
|
+
name: "Phone",
|
|
4708
|
+
description: "Phone numbers in various formats",
|
|
4709
|
+
icon: "#",
|
|
4710
|
+
examples: ["(555) 123-4567", "+1-555-123-4567", "555.123.4567"],
|
|
4711
|
+
defaultEnabled: true
|
|
4712
|
+
},
|
|
4713
|
+
{
|
|
4714
|
+
id: "address",
|
|
4715
|
+
name: "Address",
|
|
4716
|
+
description: "Physical addresses, street addresses",
|
|
4717
|
+
icon: "A",
|
|
4718
|
+
examples: ["123 Main St, Anytown, USA 12345"],
|
|
4719
|
+
defaultEnabled: true
|
|
4720
|
+
},
|
|
4721
|
+
{
|
|
4722
|
+
id: "ssn",
|
|
4723
|
+
name: "SSN",
|
|
4724
|
+
description: "Social Security Numbers",
|
|
4725
|
+
icon: "S",
|
|
4726
|
+
examples: ["123-45-6789", "123456789"],
|
|
4727
|
+
defaultEnabled: true
|
|
4728
|
+
},
|
|
4729
|
+
{
|
|
4730
|
+
id: "credit_card",
|
|
4731
|
+
name: "Credit Card",
|
|
4732
|
+
description: "Credit/debit card numbers",
|
|
4733
|
+
icon: "$",
|
|
4734
|
+
examples: ["4111-1111-1111-1111", "4111111111111111"],
|
|
4735
|
+
defaultEnabled: true
|
|
4736
|
+
},
|
|
4737
|
+
{
|
|
4738
|
+
id: "ip_address",
|
|
4739
|
+
name: "IP Address",
|
|
4740
|
+
description: "IPv4 and IPv6 addresses",
|
|
4741
|
+
icon: "IP",
|
|
4742
|
+
examples: ["192.168.1.1", "10.0.0.1"],
|
|
4743
|
+
defaultEnabled: false
|
|
4744
|
+
},
|
|
4745
|
+
{
|
|
4746
|
+
id: "date_of_birth",
|
|
4747
|
+
name: "Date of Birth",
|
|
4748
|
+
description: "Birth dates in various formats",
|
|
4749
|
+
icon: "D",
|
|
4750
|
+
examples: ["01/15/1990", "January 15, 1990", "1990-01-15"],
|
|
4751
|
+
defaultEnabled: true
|
|
4752
|
+
}
|
|
4753
|
+
];
|
|
4754
|
+
var categoryMap = new Map(categories.map((c) => [c.id, c]));
|
|
4755
|
+
function getCategoryName(id) {
|
|
4756
|
+
return categoryMap.get(id)?.name ?? id;
|
|
4757
|
+
}
|
|
4758
|
+
function getCategoryIcon2(id) {
|
|
4759
|
+
return categoryMap.get(id)?.icon ?? "?";
|
|
4760
|
+
}
|
|
4761
|
+
|
|
4762
|
+
// src/constants/platformInstructions.ts
|
|
4763
|
+
function detectPlatform2() {
|
|
4764
|
+
if (typeof navigator === "undefined") return "unknown";
|
|
4765
|
+
const ua = navigator.userAgent.toLowerCase();
|
|
4766
|
+
if (/iphone|ipad|ipod/.test(ua)) return "ios";
|
|
4767
|
+
if (/android/.test(ua)) return "android";
|
|
4768
|
+
if (/macintosh|mac os x/.test(ua)) return "mac";
|
|
4769
|
+
if (/windows/.test(ua)) return "windows";
|
|
4770
|
+
return "unknown";
|
|
4771
|
+
}
|
|
4772
|
+
var PLATFORM_INSTRUCTIONS = {
|
|
4773
|
+
mac: {
|
|
4774
|
+
icon: "",
|
|
4775
|
+
steps: [
|
|
4776
|
+
"Click the WiFi icon in the menu bar",
|
|
4777
|
+
'Select "Turn Wi-Fi Off"',
|
|
4778
|
+
"Or press Option + click WiFi icon for quick toggle"
|
|
4779
|
+
]
|
|
4780
|
+
},
|
|
4781
|
+
windows: {
|
|
4782
|
+
icon: "",
|
|
4783
|
+
steps: [
|
|
4784
|
+
"Click the network icon in the taskbar",
|
|
4785
|
+
'Toggle "Wi-Fi" to Off',
|
|
4786
|
+
'Or enable "Airplane mode"'
|
|
4787
|
+
]
|
|
4788
|
+
},
|
|
4789
|
+
ios: {
|
|
4790
|
+
icon: "",
|
|
4791
|
+
steps: [
|
|
4792
|
+
"Swipe down from top-right for Control Center",
|
|
4793
|
+
"Tap the airplane icon",
|
|
4794
|
+
"Or go to Settings > Airplane Mode"
|
|
4795
|
+
]
|
|
4796
|
+
},
|
|
4797
|
+
android: {
|
|
4798
|
+
icon: "",
|
|
4799
|
+
steps: [
|
|
4800
|
+
"Swipe down from the top of the screen",
|
|
4801
|
+
'Tap "Airplane mode" in Quick Settings',
|
|
4802
|
+
"Or go to Settings > Network > Airplane mode"
|
|
4803
|
+
]
|
|
4804
|
+
},
|
|
4805
|
+
unknown: {
|
|
4806
|
+
icon: "",
|
|
4807
|
+
steps: [
|
|
4808
|
+
"Disconnect from WiFi",
|
|
4809
|
+
"Or enable Airplane Mode",
|
|
4810
|
+
"Or unplug your ethernet cable"
|
|
4811
|
+
]
|
|
4812
|
+
}
|
|
4813
|
+
};
|
|
4814
|
+
|
|
4815
|
+
// src/services/sessionSummary.ts
|
|
4816
|
+
var import_jspdf = require("jspdf");
|
|
4817
|
+
var DISCLAIMER = `
|
|
4818
|
+
IMPORTANT DISCLAIMER
|
|
4819
|
+
|
|
4820
|
+
This document is a SESSION SUMMARY, NOT a proof certificate.
|
|
4821
|
+
All metrics shown are SELF-REPORTED by the PURGE application.
|
|
4822
|
+
|
|
4823
|
+
For INDEPENDENT VERIFICATION, please:
|
|
4824
|
+
1. Enable Airplane Mode before processing files (strongest privacy indicator)
|
|
4825
|
+
2. Check your browser's Developer Tools > Network tab during processing
|
|
4826
|
+
3. Review the open-source code at github.com/Pacamelo/forge
|
|
4827
|
+
|
|
4828
|
+
This summary cannot prove that files were not copied or uploaded.
|
|
4829
|
+
It only records what our monitoring detected during the session.
|
|
4830
|
+
`;
|
|
4831
|
+
function generateSessionId() {
|
|
4832
|
+
return generatePrefixedId("PURGE");
|
|
4833
|
+
}
|
|
4834
|
+
function formatBytes(bytes) {
|
|
4835
|
+
if (bytes === 0) return "0 bytes";
|
|
4836
|
+
if (bytes < 1024) return `${bytes} bytes`;
|
|
4837
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
4838
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
4839
|
+
}
|
|
4840
|
+
function truncateHash2(hash) {
|
|
4841
|
+
if (!hash || hash.length <= 20) return hash || "N/A";
|
|
4842
|
+
return `${hash.slice(0, 12)}...${hash.slice(-12)}`;
|
|
4843
|
+
}
|
|
4844
|
+
function generateSessionSummaryPDF(data) {
|
|
4845
|
+
const doc = new import_jspdf.jsPDF();
|
|
4846
|
+
const pageWidth = doc.internal.pageSize.getWidth();
|
|
4847
|
+
let yPos = 20;
|
|
4848
|
+
const margin = 15;
|
|
4849
|
+
const lineHeight = 6;
|
|
4850
|
+
const addText = (text, fontSize = 10, bold = false) => {
|
|
4851
|
+
doc.setFontSize(fontSize);
|
|
4852
|
+
if (bold) {
|
|
4853
|
+
doc.setFont("helvetica", "bold");
|
|
4854
|
+
} else {
|
|
4855
|
+
doc.setFont("helvetica", "normal");
|
|
4856
|
+
}
|
|
4857
|
+
doc.text(text, margin, yPos);
|
|
4858
|
+
yPos += lineHeight;
|
|
4859
|
+
};
|
|
4860
|
+
const addSection = (title) => {
|
|
4861
|
+
yPos += 4;
|
|
4862
|
+
doc.setDrawColor(0, 255, 0);
|
|
4863
|
+
doc.line(margin, yPos, pageWidth - margin, yPos);
|
|
4864
|
+
yPos += 6;
|
|
4865
|
+
addText(title, 12, true);
|
|
4866
|
+
yPos += 2;
|
|
4867
|
+
};
|
|
4868
|
+
const checkPage = () => {
|
|
4869
|
+
if (yPos > 270) {
|
|
4870
|
+
doc.addPage();
|
|
4871
|
+
yPos = 20;
|
|
4872
|
+
}
|
|
4873
|
+
};
|
|
4874
|
+
doc.setTextColor(0, 255, 0);
|
|
4875
|
+
doc.setFontSize(18);
|
|
4876
|
+
doc.setFont("helvetica", "bold");
|
|
4877
|
+
doc.text("PURGE SESSION SUMMARY", margin, yPos);
|
|
4878
|
+
yPos += 8;
|
|
4879
|
+
doc.setFillColor(255, 200, 0);
|
|
4880
|
+
doc.rect(margin, yPos, pageWidth - margin * 2, 12, "F");
|
|
4881
|
+
doc.setTextColor(0, 0, 0);
|
|
4882
|
+
doc.setFontSize(10);
|
|
4883
|
+
doc.setFont("helvetica", "bold");
|
|
4884
|
+
doc.text("NOT A PROOF CERTIFICATE - SELF-REPORTED DATA ONLY", pageWidth / 2, yPos + 8, {
|
|
4885
|
+
align: "center"
|
|
4886
|
+
});
|
|
4887
|
+
yPos += 18;
|
|
4888
|
+
doc.setTextColor(50, 50, 50);
|
|
4889
|
+
addSection("SESSION INFORMATION");
|
|
4890
|
+
addText(`Session ID: ${data.sessionId}`);
|
|
4891
|
+
addText(`Timestamp: ${new Date(data.timestamp).toLocaleString()}`);
|
|
4892
|
+
addText(`Browser: ${data.environment.browser}`);
|
|
4893
|
+
addText(`Platform: ${data.environment.platform}`);
|
|
4894
|
+
checkPage();
|
|
4895
|
+
addSection("FILES PROCESSED");
|
|
4896
|
+
if (data.files.length === 0) {
|
|
4897
|
+
addText("No files were processed in this session.");
|
|
4898
|
+
} else {
|
|
4899
|
+
data.files.forEach((file, index) => {
|
|
4900
|
+
checkPage();
|
|
4901
|
+
addText(`${index + 1}. ${file.name}`, 10, true);
|
|
4902
|
+
addText(` Original Size: ${formatBytes(file.originalSize)}`);
|
|
4903
|
+
addText(` Processed Size: ${formatBytes(file.processedSize)}`);
|
|
4904
|
+
addText(` Detections Removed: ${file.detectionsRemoved}`);
|
|
4905
|
+
addText(` Original Hash: ${truncateHash2(file.originalHash)}`);
|
|
4906
|
+
addText(` Processed Hash: ${truncateHash2(file.processedHash)}`);
|
|
4907
|
+
yPos += 2;
|
|
4908
|
+
});
|
|
4909
|
+
}
|
|
4910
|
+
checkPage();
|
|
4911
|
+
addSection("SELF-REPORTED METRICS");
|
|
4912
|
+
const { selfReportedMetrics } = data;
|
|
4913
|
+
const networkStatus = selfReportedMetrics.networkRequestsDetected === 0 ? "\u2713" : "\u26A0";
|
|
4914
|
+
addText(
|
|
4915
|
+
`${networkStatus} Network Requests Detected: ${selfReportedMetrics.networkRequestsDetected}`
|
|
4916
|
+
);
|
|
4917
|
+
const storageStatus = !selfReportedMetrics.storageChangesDetected ? "\u2713" : "\u26A0";
|
|
4918
|
+
addText(
|
|
4919
|
+
`${storageStatus} Storage Changes: ${selfReportedMetrics.storageChangesDetected ? "Detected" : "None detected"}`
|
|
4920
|
+
);
|
|
4921
|
+
const memoryStatus = selfReportedMetrics.memoryWipeCompleted ? "\u2713" : "\u25CB";
|
|
4922
|
+
addText(
|
|
4923
|
+
`${memoryStatus} Buffer Cleanup: ${selfReportedMetrics.memoryWipeCompleted ? "Completed (visual only)" : "Not completed"}`
|
|
4924
|
+
);
|
|
4925
|
+
const offlineStatus = selfReportedMetrics.wasOfflineDuringProcessing ? "\u2713" : "\u25CB";
|
|
4926
|
+
addText(
|
|
4927
|
+
`${offlineStatus} Offline Processing: ${selfReportedMetrics.wasOfflineDuringProcessing ? "YES - Processed with no internet" : "No - Was online during processing"}`
|
|
4928
|
+
);
|
|
4929
|
+
if (selfReportedMetrics.wasOfflineDuringProcessing) {
|
|
4930
|
+
yPos += 2;
|
|
4931
|
+
doc.setTextColor(0, 150, 0);
|
|
4932
|
+
addText(" \u2192 Offline processing provides the strongest privacy indication.", 9);
|
|
4933
|
+
doc.setTextColor(50, 50, 50);
|
|
4934
|
+
}
|
|
4935
|
+
checkPage();
|
|
4936
|
+
addSection("HOW TO VERIFY INDEPENDENTLY");
|
|
4937
|
+
addText("For stronger assurance that files never left your browser:", 10, true);
|
|
4938
|
+
yPos += 2;
|
|
4939
|
+
addText("1. AIRPLANE MODE CHALLENGE");
|
|
4940
|
+
addText(" Enable Airplane Mode before dropping files into PURGE.");
|
|
4941
|
+
addText(" Offline processing provides the strongest privacy assurance.");
|
|
4942
|
+
yPos += 2;
|
|
4943
|
+
addText("2. BROWSER DEVTOOLS");
|
|
4944
|
+
addText(" Open Developer Tools (F12) \u2192 Network tab before processing.");
|
|
4945
|
+
addText(" Watch for any requests containing your file data.");
|
|
4946
|
+
yPos += 2;
|
|
4947
|
+
addText("3. SOURCE CODE AUDIT");
|
|
4948
|
+
addText(" Review the open-source code at github.com/Pacamelo/forge");
|
|
4949
|
+
addText(" Verify there are no hidden network calls in the processing logic.");
|
|
4950
|
+
checkPage();
|
|
4951
|
+
addSection("DISCLAIMER");
|
|
4952
|
+
doc.setFontSize(8);
|
|
4953
|
+
const disclaimerLines = DISCLAIMER.trim().split("\n");
|
|
4954
|
+
disclaimerLines.forEach((line) => {
|
|
4955
|
+
if (yPos > 280) {
|
|
4956
|
+
doc.addPage();
|
|
4957
|
+
yPos = 20;
|
|
4958
|
+
}
|
|
4959
|
+
doc.text(line.trim(), margin, yPos);
|
|
4960
|
+
yPos += 4;
|
|
4961
|
+
});
|
|
4962
|
+
const pageCount = doc.getNumberOfPages();
|
|
4963
|
+
for (let i = 1; i <= pageCount; i++) {
|
|
4964
|
+
doc.setPage(i);
|
|
4965
|
+
doc.setFontSize(8);
|
|
4966
|
+
doc.setTextColor(150, 150, 150);
|
|
4967
|
+
doc.text(`Generated by PURGE | Page ${i} of ${pageCount}`, pageWidth / 2, 290, {
|
|
4968
|
+
align: "center"
|
|
4969
|
+
});
|
|
4970
|
+
}
|
|
4971
|
+
return doc.output("blob");
|
|
4972
|
+
}
|
|
4973
|
+
function downloadSessionSummary(data) {
|
|
4974
|
+
const blob = generateSessionSummaryPDF(data);
|
|
4975
|
+
const url = URL.createObjectURL(blob);
|
|
4976
|
+
const a = document.createElement("a");
|
|
4977
|
+
a.href = url;
|
|
4978
|
+
a.download = `PURGE-Session-${data.sessionId}.pdf`;
|
|
4979
|
+
document.body.appendChild(a);
|
|
4980
|
+
a.click();
|
|
4981
|
+
document.body.removeChild(a);
|
|
4982
|
+
URL.revokeObjectURL(url);
|
|
4983
|
+
}
|
|
4984
|
+
/**
|
|
4985
|
+
* @pacamelo/core
|
|
4986
|
+
* Core PII detection and redaction engine for PURGE
|
|
4987
|
+
*
|
|
4988
|
+
* This package contains the data-processing logic for PURGE:
|
|
4989
|
+
* - PII detection patterns and engine
|
|
4990
|
+
* - Document parsers (XLSX, etc.)
|
|
4991
|
+
* - Masking and redaction utilities
|
|
4992
|
+
* - Security infrastructure
|
|
4993
|
+
*
|
|
4994
|
+
* Open source for transparency - users can verify exactly
|
|
4995
|
+
* how their data is processed.
|
|
4996
|
+
*
|
|
4997
|
+
* @see https://github.com/Pacamelo/purge-core
|
|
4998
|
+
* @license MIT
|
|
4999
|
+
*/
|