@doccloak/core 0.11.0 → 0.12.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/CHANGELOG.md +121 -2
- package/README.md +252 -19
- package/dist/doc.d.ts +68 -1
- package/dist/doc.js +447 -62
- package/dist/dom/docx.d.ts +135 -7
- package/dist/dom/docx.js +752 -125
- package/dist/dom/drawingml.d.ts +59 -0
- package/dist/dom/drawingml.js +176 -0
- package/dist/dom/errors.d.ts +35 -0
- package/dist/dom/errors.js +24 -0
- package/dist/dom/index.d.ts +3 -0
- package/dist/dom/index.js +6 -0
- package/dist/dom/office.d.ts +56 -0
- package/dist/dom/office.js +76 -23
- package/dist/dom/opc.d.ts +135 -7
- package/dist/dom/opc.js +407 -14
- package/dist/dom/package-policy.d.ts +137 -0
- package/dist/dom/package-policy.js +355 -0
- package/dist/dom/xlsx.d.ts +64 -6
- package/dist/dom/xlsx.js +75 -20
- package/dist/engine.d.ts +18 -0
- package/dist/engine.js +37 -13
- package/dist/env.d.ts +21 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +10 -5
- package/dist/model-loader.d.ts +61 -6
- package/dist/model-loader.js +122 -17
- package/dist/pipeline.d.ts +87 -8
- package/dist/pipeline.js +249 -50
- package/dist/providers/bardsai.d.ts +8 -1
- package/dist/providers/bardsai.js +22 -2
- package/dist/providers/gliner-base.d.ts +8 -1
- package/dist/providers/gliner-base.js +22 -2
- package/dist/providers/gliner.d.ts +8 -1
- package/dist/providers/gliner.js +22 -2
- package/dist/regex/loader.d.ts +2 -0
- package/dist/regex/loader.js +2 -0
- package/dist/regex/rules.data.js +15 -14
- package/dist/regex/types.d.ts +6 -0
- package/dist/session.d.ts +101 -3
- package/dist/session.js +397 -43
- package/dist/surrogates.js +57 -45
- package/dist/worker-protocol.d.ts +13 -1
- package/dist/worker-protocol.js +164 -12
- package/package.json +1 -1
- package/rules/at.json +1 -1
- package/rules/be.json +1 -1
- package/rules/ch.json +1 -1
- package/rules/de.json +1 -1
- package/rules/dk.json +1 -1
- package/rules/es.json +1 -1
- package/rules/fi.json +1 -1
- package/rules/fr.json +1 -1
- package/rules/it.json +1 -1
- package/rules/no.json +1 -1
- package/rules/pl.json +1 -1
- package/rules/pt.json +1 -1
- package/rules/schema.json +4 -0
- package/rules/se.json +1 -1
- package/rules/universal.json +2 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - DrawingML, chart, SmartArt and custom XML text units
|
|
3
|
+
* (T175, security remediation 2026-09, finding H3).
|
|
4
|
+
*
|
|
5
|
+
* PII that appears for the first time in a chart (title, category cache,
|
|
6
|
+
* series name), a SmartArt node, a chart user shape or a custom XML data
|
|
7
|
+
* store was previously invisible to the detector: only the layer-zero value
|
|
8
|
+
* scrub (T172) could remove it, and only when the same value had been
|
|
9
|
+
* detected elsewhere. These helpers turn those parts into text units the
|
|
10
|
+
* docx and xlsx readers register as content parts, so the detector sees the
|
|
11
|
+
* text and the writer applies offset replacements to it.
|
|
12
|
+
*
|
|
13
|
+
* A unit is the list of leaf elements whose concatenated text forms one
|
|
14
|
+
* independent string. Units are separated by newlines in the flat text and
|
|
15
|
+
* every separator is a paragraph break, so a replacement never crosses two
|
|
16
|
+
* units (the T172 rule).
|
|
17
|
+
*/
|
|
18
|
+
import type { TextNodeMapping, ParagraphBreak } from './docx.js';
|
|
19
|
+
/** Leaf elements whose concatenated text is one independent string. */
|
|
20
|
+
export type TextUnit = Element[];
|
|
21
|
+
/**
|
|
22
|
+
* Text units of a DrawingML part, in document order:
|
|
23
|
+
* - every a:p (DrawingML paragraph) as one unit of its a:t leaves: chart
|
|
24
|
+
* titles, axis titles, data labels, text boxes, SmartArt node text
|
|
25
|
+
* (dgm:t wraps a:p), chart user shapes;
|
|
26
|
+
* - every c:v inside c:strCache / c:multiLvlStrCache (category and series
|
|
27
|
+
* name caches) or c:tx (literal series names) as its own unit; numeric
|
|
28
|
+
* caches (c:numCache) are left alone so a chart's numbers stay numbers;
|
|
29
|
+
* - every chartEx cx:pt (string dimension point) and cx:v (text data).
|
|
30
|
+
* Applies to word/charts, word/diagrams, word/drawings, xl/charts and
|
|
31
|
+
* xl/drawings parts.
|
|
32
|
+
*/
|
|
33
|
+
export declare function drawingUnits(doc: Document): TextUnit[];
|
|
34
|
+
/**
|
|
35
|
+
* Every non-empty leaf text node of an arbitrary XML part as its own unit
|
|
36
|
+
* (customXml/item*.xml: cover page properties, bibliography sources,
|
|
37
|
+
* SharePoint document properties). Element and attribute names are never
|
|
38
|
+
* units; attribute values are covered by layer zero.
|
|
39
|
+
*/
|
|
40
|
+
export declare function leafTextUnits(doc: Document): TextUnit[];
|
|
41
|
+
/**
|
|
42
|
+
* Flatten units into the reader's shape: one line per unit, a
|
|
43
|
+
* TextNodeMapping per non-empty leaf, and a ParagraphBreak at every
|
|
44
|
+
* separating newline so no replacement can span two units.
|
|
45
|
+
*/
|
|
46
|
+
export declare function flattenUnits(units: TextUnit[]): {
|
|
47
|
+
plainText: string;
|
|
48
|
+
textNodes: TextNodeMapping[];
|
|
49
|
+
paragraphBreaks: ParagraphBreak[];
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Remove every c:externalData element (with its c:autoUpdate child) from a
|
|
53
|
+
* chart part so Word never tries to open the embedded workbook the chart
|
|
54
|
+
* was built from. The string and number caches stay, so the chart still
|
|
55
|
+
* renders, and they are redacted like any other unit. Returns the number of
|
|
56
|
+
* elements removed. The embedded workbook itself is reported by the package
|
|
57
|
+
* policy (T177) and removed by T199.
|
|
58
|
+
*/
|
|
59
|
+
export declare function stripChartExternalData(doc: Document): number;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - DrawingML, chart, SmartArt and custom XML text units
|
|
3
|
+
* (T175, security remediation 2026-09, finding H3).
|
|
4
|
+
*
|
|
5
|
+
* PII that appears for the first time in a chart (title, category cache,
|
|
6
|
+
* series name), a SmartArt node, a chart user shape or a custom XML data
|
|
7
|
+
* store was previously invisible to the detector: only the layer-zero value
|
|
8
|
+
* scrub (T172) could remove it, and only when the same value had been
|
|
9
|
+
* detected elsewhere. These helpers turn those parts into text units the
|
|
10
|
+
* docx and xlsx readers register as content parts, so the detector sees the
|
|
11
|
+
* text and the writer applies offset replacements to it.
|
|
12
|
+
*
|
|
13
|
+
* A unit is the list of leaf elements whose concatenated text forms one
|
|
14
|
+
* independent string. Units are separated by newlines in the flat text and
|
|
15
|
+
* every separator is a paragraph break, so a replacement never crosses two
|
|
16
|
+
* units (the T172 rule).
|
|
17
|
+
*/
|
|
18
|
+
import { OOXML_NS, isElementIn } from "./opc.js";
|
|
19
|
+
/** chartEx (Office 2016+ chart types: waterfall, treemap, sunburst, funnel). */
|
|
20
|
+
const CHARTEX_NS = new Set([
|
|
21
|
+
'http://schemas.microsoft.com/office/drawing/2014/chartex',
|
|
22
|
+
]);
|
|
23
|
+
/** Chart containers whose c:v children hold strings, not numbers. */
|
|
24
|
+
const CHART_STRING_CONTAINERS = new Set(['strCache', 'multiLvlStrCache', 'tx']);
|
|
25
|
+
/** True for a c:v that holds a string: inside a string cache or a c:tx literal. */
|
|
26
|
+
function isChartStringValue(el) {
|
|
27
|
+
if (!isElementIn(el, OOXML_NS.c, 'v'))
|
|
28
|
+
return false;
|
|
29
|
+
for (let p = el.parentElement; p; p = p.parentElement) {
|
|
30
|
+
if (p.namespaceURI === null || !OOXML_NS.c.has(p.namespaceURI))
|
|
31
|
+
continue;
|
|
32
|
+
if (CHART_STRING_CONTAINERS.has(p.localName))
|
|
33
|
+
return true;
|
|
34
|
+
if (p.localName === 'numCache' || p.localName === 'numLit')
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/** Every a:t under a DrawingML paragraph, in document order, non-empty. */
|
|
40
|
+
function paragraphLeaves(p) {
|
|
41
|
+
const leaves = [];
|
|
42
|
+
const all = p.getElementsByTagName('*');
|
|
43
|
+
for (let i = 0; i < all.length; i++) {
|
|
44
|
+
const el = all[i];
|
|
45
|
+
if (isElementIn(el, OOXML_NS.a, 't') && (el.textContent ?? '').length > 0)
|
|
46
|
+
leaves.push(el);
|
|
47
|
+
}
|
|
48
|
+
return leaves;
|
|
49
|
+
}
|
|
50
|
+
function hasText(el) {
|
|
51
|
+
return (el.textContent ?? '').trim().length > 0;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Text units of a DrawingML part, in document order:
|
|
55
|
+
* - every a:p (DrawingML paragraph) as one unit of its a:t leaves: chart
|
|
56
|
+
* titles, axis titles, data labels, text boxes, SmartArt node text
|
|
57
|
+
* (dgm:t wraps a:p), chart user shapes;
|
|
58
|
+
* - every c:v inside c:strCache / c:multiLvlStrCache (category and series
|
|
59
|
+
* name caches) or c:tx (literal series names) as its own unit; numeric
|
|
60
|
+
* caches (c:numCache) are left alone so a chart's numbers stay numbers;
|
|
61
|
+
* - every chartEx cx:pt (string dimension point) and cx:v (text data).
|
|
62
|
+
* Applies to word/charts, word/diagrams, word/drawings, xl/charts and
|
|
63
|
+
* xl/drawings parts.
|
|
64
|
+
*/
|
|
65
|
+
export function drawingUnits(doc) {
|
|
66
|
+
const units = [];
|
|
67
|
+
const walk = (parent) => {
|
|
68
|
+
for (let child = parent.firstElementChild; child; child = child.nextElementSibling) {
|
|
69
|
+
if (isElementIn(child, OOXML_NS.a, 'p')) {
|
|
70
|
+
const leaves = paragraphLeaves(child);
|
|
71
|
+
if (leaves.length > 0)
|
|
72
|
+
units.push(leaves);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (isChartStringValue(child)) {
|
|
76
|
+
if (hasText(child))
|
|
77
|
+
units.push([child]);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (isElementIn(child, CHARTEX_NS, 'pt') || isElementIn(child, CHARTEX_NS, 'v')) {
|
|
81
|
+
if (child.childElementCount === 0 && hasText(child))
|
|
82
|
+
units.push([child]);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
walk(child);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
if (doc.documentElement)
|
|
89
|
+
walk(doc.documentElement);
|
|
90
|
+
return units;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Leaf text this long with only base64 characters is an encoded payload
|
|
94
|
+
* (Power Query mashup, serialized add-in state), not user text: it would
|
|
95
|
+
* flood the editor and the detector without ever matching an entity. The
|
|
96
|
+
* layer-zero value scrub still visits it.
|
|
97
|
+
*/
|
|
98
|
+
const BLOB_MIN_LENGTH = 256;
|
|
99
|
+
const BLOB_PATTERN = /^[A-Za-z0-9+/=\r\n]+$/;
|
|
100
|
+
function looksLikeBlob(text) {
|
|
101
|
+
return text.length >= BLOB_MIN_LENGTH && BLOB_PATTERN.test(text);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Every non-empty leaf text node of an arbitrary XML part as its own unit
|
|
105
|
+
* (customXml/item*.xml: cover page properties, bibliography sources,
|
|
106
|
+
* SharePoint document properties). Element and attribute names are never
|
|
107
|
+
* units; attribute values are covered by layer zero.
|
|
108
|
+
*/
|
|
109
|
+
export function leafTextUnits(doc) {
|
|
110
|
+
const units = [];
|
|
111
|
+
const all = doc.getElementsByTagName('*');
|
|
112
|
+
for (let i = 0; i < all.length; i++) {
|
|
113
|
+
const el = all[i];
|
|
114
|
+
if (el.childElementCount !== 0)
|
|
115
|
+
continue;
|
|
116
|
+
const text = el.textContent ?? '';
|
|
117
|
+
if (text.trim().length === 0 || looksLikeBlob(text))
|
|
118
|
+
continue;
|
|
119
|
+
units.push([el]);
|
|
120
|
+
}
|
|
121
|
+
return units;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Flatten units into the reader's shape: one line per unit, a
|
|
125
|
+
* TextNodeMapping per non-empty leaf, and a ParagraphBreak at every
|
|
126
|
+
* separating newline so no replacement can span two units.
|
|
127
|
+
*/
|
|
128
|
+
export function flattenUnits(units) {
|
|
129
|
+
const textNodes = [];
|
|
130
|
+
const paragraphBreaks = [];
|
|
131
|
+
let plainText = '';
|
|
132
|
+
for (const unit of units) {
|
|
133
|
+
const nodes = [];
|
|
134
|
+
let unitLength = 0;
|
|
135
|
+
for (const element of unit) {
|
|
136
|
+
const text = element.textContent ?? '';
|
|
137
|
+
if (text.length === 0)
|
|
138
|
+
continue;
|
|
139
|
+
nodes.push({ element, flatStart: unitLength, flatEnd: unitLength + text.length });
|
|
140
|
+
unitLength += text.length;
|
|
141
|
+
}
|
|
142
|
+
if (unitLength === 0)
|
|
143
|
+
continue;
|
|
144
|
+
if (plainText.length > 0) {
|
|
145
|
+
paragraphBreaks.push({ flatIndex: plainText.length });
|
|
146
|
+
plainText += '\n';
|
|
147
|
+
}
|
|
148
|
+
const base = plainText.length;
|
|
149
|
+
for (const node of nodes) {
|
|
150
|
+
node.flatStart += base;
|
|
151
|
+
node.flatEnd += base;
|
|
152
|
+
plainText += node.element.textContent ?? '';
|
|
153
|
+
textNodes.push(node);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { plainText, textNodes, paragraphBreaks };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Remove every c:externalData element (with its c:autoUpdate child) from a
|
|
160
|
+
* chart part so Word never tries to open the embedded workbook the chart
|
|
161
|
+
* was built from. The string and number caches stay, so the chart still
|
|
162
|
+
* renders, and they are redacted like any other unit. Returns the number of
|
|
163
|
+
* elements removed. The embedded workbook itself is reported by the package
|
|
164
|
+
* policy (T177) and removed by T199.
|
|
165
|
+
*/
|
|
166
|
+
export function stripChartExternalData(doc) {
|
|
167
|
+
const doomed = [];
|
|
168
|
+
const all = doc.getElementsByTagName('*');
|
|
169
|
+
for (let i = 0; i < all.length; i++) {
|
|
170
|
+
if (isElementIn(all[i], OOXML_NS.c, 'externalData'))
|
|
171
|
+
doomed.push(all[i]);
|
|
172
|
+
}
|
|
173
|
+
for (const el of doomed)
|
|
174
|
+
el.parentNode?.removeChild(el);
|
|
175
|
+
return doomed.length;
|
|
176
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - typed refusal errors (T172, security remediation 2026-09).
|
|
3
|
+
*
|
|
4
|
+
* Fail-closed principle: a document the reader cannot interpret is refused
|
|
5
|
+
* with a stable machine-readable `code` instead of being copied through as a
|
|
6
|
+
* "redacted" file. Hosts map `code` to their own localized messages; `details`
|
|
7
|
+
* carries the specifics (offending namespaces, part names) for diagnostics.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Stable refusal codes. T172 raises 'unrecognized-namespace' and
|
|
11
|
+
* 'invalid-package'; the remaining codes are reserved for T177 (package
|
|
12
|
+
* policy, size limit) and the legacy .doc reader so hosts can switch on one
|
|
13
|
+
* union from day one.
|
|
14
|
+
*/
|
|
15
|
+
export type UnsupportedDocumentCode = 'unrecognized-namespace' | 'invalid-package' | 'unredactable-parts' | 'too-large' | 'fast-saved' | 'encrypted';
|
|
16
|
+
export declare class UnsupportedDocumentError extends Error {
|
|
17
|
+
readonly code: UnsupportedDocumentCode;
|
|
18
|
+
/** Free-form specifics: namespaces seen, part names, sizes. */
|
|
19
|
+
readonly details: string[];
|
|
20
|
+
constructor(code: UnsupportedDocumentCode, message?: string, details?: string[]);
|
|
21
|
+
}
|
|
22
|
+
/** True when `err` is an UnsupportedDocumentError (also across realms). */
|
|
23
|
+
export declare function isUnsupportedDocumentError(err: unknown): err is UnsupportedDocumentError;
|
|
24
|
+
/**
|
|
25
|
+
* A package part the writer cannot redact (embedded workbook, macros, HTML
|
|
26
|
+
* chunk). T172 only declares the shape and always reports an empty list;
|
|
27
|
+
* T177 fills it from classifyPart and gates the writer on allowUnredactable.
|
|
28
|
+
*/
|
|
29
|
+
export interface UnredactablePart {
|
|
30
|
+
/** Zip path of the part, e.g. 'word/embeddings/oleObject1.bin' */
|
|
31
|
+
part: string;
|
|
32
|
+
kind: 'embedded-object' | 'macros' | 'html-chunk' | 'external-data' | 'printer-settings' | 'unknown';
|
|
33
|
+
/** Human-readable label for host UIs, e.g. 'embedded Excel sheet' */
|
|
34
|
+
label: string;
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - typed refusal errors (T172, security remediation 2026-09).
|
|
3
|
+
*
|
|
4
|
+
* Fail-closed principle: a document the reader cannot interpret is refused
|
|
5
|
+
* with a stable machine-readable `code` instead of being copied through as a
|
|
6
|
+
* "redacted" file. Hosts map `code` to their own localized messages; `details`
|
|
7
|
+
* carries the specifics (offending namespaces, part names) for diagnostics.
|
|
8
|
+
*/
|
|
9
|
+
export class UnsupportedDocumentError extends Error {
|
|
10
|
+
constructor(code, message, details = []) {
|
|
11
|
+
super(message ?? `Unsupported document (${code})`);
|
|
12
|
+
this.name = 'UnsupportedDocumentError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.details = details;
|
|
15
|
+
// Keep instanceof working when the package is compiled to ES5-style
|
|
16
|
+
// classes by a downstream bundler.
|
|
17
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** True when `err` is an UnsupportedDocumentError (also across realms). */
|
|
21
|
+
export function isUnsupportedDocumentError(err) {
|
|
22
|
+
return err instanceof UnsupportedDocumentError
|
|
23
|
+
|| (typeof err === 'object' && err !== null && err.name === 'UnsupportedDocumentError');
|
|
24
|
+
}
|
package/dist/dom/index.d.ts
CHANGED
package/dist/dom/index.js
CHANGED
|
@@ -12,3 +12,9 @@ export * from "./ocr.js";
|
|
|
12
12
|
// T110: xlsx + detection-driven office file redaction
|
|
13
13
|
export * from "./xlsx.js";
|
|
14
14
|
export * from "./office.js";
|
|
15
|
+
// T172: typed refusal errors (fail-closed readers)
|
|
16
|
+
export * from "./errors.js";
|
|
17
|
+
// T175: DrawingML / chart / SmartArt / custom XML text units
|
|
18
|
+
export * from "./drawingml.js";
|
|
19
|
+
// T177: package part policy, unredactable parts, unpacked-size guard
|
|
20
|
+
export * from "./package-policy.js";
|
package/dist/dom/office.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { DetectedEntity } from '../types.js';
|
|
14
14
|
import type { AnonymizationSession } from '../session.js';
|
|
15
|
+
import type { UnredactablePart } from './errors.js';
|
|
15
16
|
/** File kinds the office redaction flow can regenerate. */
|
|
16
17
|
export type OfficeFileKind = 'docx' | 'xlsx';
|
|
17
18
|
/**
|
|
@@ -22,12 +23,37 @@ export type OfficeFileKind = 'docx' | 'xlsx';
|
|
|
22
23
|
export declare function officeFileKind(filename: string): OfficeFileKind | null;
|
|
23
24
|
/** A detect function, typically DocCloakEngine.detect or detectEntities. */
|
|
24
25
|
export type DetectFn = (text: string) => Promise<DetectedEntity[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Raised by redactOfficeFile (T179, finding M4) when supplied entities do
|
|
28
|
+
* not match the file's freshly extracted text: the analysis is stale or came
|
|
29
|
+
* from a different file. Before T179 such entities were dropped silently and
|
|
30
|
+
* the host received a "successful" copy that still held the value.
|
|
31
|
+
*
|
|
32
|
+
* `entities` lists every mismatch (value, type, offsets); the message only
|
|
33
|
+
* counts them and names their types and offsets so it can be logged without
|
|
34
|
+
* repeating the values. The session is not touched when this is thrown.
|
|
35
|
+
*/
|
|
36
|
+
export declare class StaleAnalysisError extends Error {
|
|
37
|
+
readonly entities: DetectedEntity[];
|
|
38
|
+
constructor(entities: DetectedEntity[], total?: number);
|
|
39
|
+
}
|
|
40
|
+
/** True when `err` is a StaleAnalysisError (also across realms). */
|
|
41
|
+
export declare function isStaleAnalysisError(err: unknown): err is StaleAnalysisError;
|
|
25
42
|
export interface OfficeAnalysis {
|
|
26
43
|
kind: OfficeFileKind;
|
|
27
44
|
/** Flat text extracted from every text-bearing part of the package */
|
|
28
45
|
plainText: string;
|
|
29
46
|
/** Entities detected in the flat text (offsets refer to plainText) */
|
|
30
47
|
entities: DetectedEntity[];
|
|
48
|
+
/**
|
|
49
|
+
* Parts DocCloak cannot redact (embedded objects, macros, HTML chunks,
|
|
50
|
+
* printer settings, unknown parts; T177). Non-empty means redactOfficeFile
|
|
51
|
+
* refuses the file unless allowUnredactable is passed; hosts show the list
|
|
52
|
+
* and ask before doing so.
|
|
53
|
+
*/
|
|
54
|
+
unredactable: UnredactablePart[];
|
|
55
|
+
/** Reader notes: parts skipped by the extractor (malformed XML). */
|
|
56
|
+
warnings: string[];
|
|
31
57
|
}
|
|
32
58
|
/**
|
|
33
59
|
* Extract a file's text and run detection WITHOUT touching the session. Hosts
|
|
@@ -49,6 +75,23 @@ export interface RedactOfficeFileOptions {
|
|
|
49
75
|
detect?: DetectFn;
|
|
50
76
|
/** Accept and strip tracked changes in docx output. Default: true. */
|
|
51
77
|
acceptTrackedChanges?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* What to do with a supplied entity whose offsets do not match the freshly
|
|
80
|
+
* extracted text (T179, M4). 'throw' (default): raise StaleAnalysisError
|
|
81
|
+
* listing every mismatch before the session is touched, so the host never
|
|
82
|
+
* gets a "redacted" file that still holds the value. 'drop': the historical
|
|
83
|
+
* behaviour, mismatches are filtered out and absent from result.entities.
|
|
84
|
+
*/
|
|
85
|
+
onMismatch?: 'throw' | 'drop';
|
|
86
|
+
/**
|
|
87
|
+
* Export even though the package holds parts DocCloak cannot redact
|
|
88
|
+
* (T177). Default false: the writer throws
|
|
89
|
+
* UnsupportedDocumentError('unredactable-parts') with the part names in
|
|
90
|
+
* `details`. With true the parts are copied verbatim and named in
|
|
91
|
+
* result.warnings; hosts pass it only after the user saw the list from
|
|
92
|
+
* analyzeOfficeFile.
|
|
93
|
+
*/
|
|
94
|
+
allowUnredactable?: boolean;
|
|
52
95
|
}
|
|
53
96
|
export interface OfficeRedactionResult {
|
|
54
97
|
kind: OfficeFileKind;
|
|
@@ -62,6 +105,14 @@ export interface OfficeRedactionResult {
|
|
|
62
105
|
plainText: string;
|
|
63
106
|
/** The redacted flat text (placeholders applied), for preview UIs */
|
|
64
107
|
redactedText: string;
|
|
108
|
+
/**
|
|
109
|
+
* Writer notes the host must show: one line per unredactable part copied
|
|
110
|
+
* verbatim (allowUnredactable) and per part the layer-zero scrub could not
|
|
111
|
+
* parse. Empty on a clean run.
|
|
112
|
+
*/
|
|
113
|
+
warnings: string[];
|
|
114
|
+
/** Parts copied verbatim because allowUnredactable was passed (T177). */
|
|
115
|
+
unredactable: UnredactablePart[];
|
|
65
116
|
}
|
|
66
117
|
/** "report.docx" -> "report.redacted.docx"; extensionless names get a suffix. */
|
|
67
118
|
export declare function redactedFileName(filename: string): string;
|
|
@@ -71,5 +122,10 @@ export declare function redactedFileName(filename: string): string;
|
|
|
71
122
|
* with placeholders spliced into the XML, formatting preserved and metadata
|
|
72
123
|
* stripped. Placeholder issuing goes through session.anonymize, so a value
|
|
73
124
|
* already seen in a prompt reuses its existing placeholder and vice versa.
|
|
125
|
+
*
|
|
126
|
+
* Fail-closed (T179): supplied entities that do not match this extraction
|
|
127
|
+
* raise StaleAnalysisError unless onMismatch is 'drop'; parts that cannot be
|
|
128
|
+
* redacted raise UnsupportedDocumentError('unredactable-parts') unless
|
|
129
|
+
* allowUnredactable is passed. Both are thrown before the session changes.
|
|
74
130
|
*/
|
|
75
131
|
export declare function redactOfficeFile(file: File, options: RedactOfficeFileOptions): Promise<OfficeRedactionResult>;
|
package/dist/dom/office.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* PDF is intentionally NOT handled here: layout-preserving PDF redaction is
|
|
11
11
|
* out of scope for this module.
|
|
12
12
|
*/
|
|
13
|
-
import { getFileExtension, readDocx,
|
|
14
|
-
import { readXlsx,
|
|
13
|
+
import { getFileExtension, readDocx, writeAnonymizedDocxWithReport } from "./docx.js";
|
|
14
|
+
import { readXlsx, writeAnonymizedXlsxWithReport } from "./xlsx.js";
|
|
15
|
+
import { layerZeroValueReplacements } from "./opc.js";
|
|
15
16
|
/**
|
|
16
17
|
* Classify a filename into a supported office kind, or null when the format
|
|
17
18
|
* cannot be regenerated by this module (.doc has its own binary writer in
|
|
@@ -25,6 +26,31 @@ export function officeFileKind(filename) {
|
|
|
25
26
|
return 'xlsx';
|
|
26
27
|
return null;
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Raised by redactOfficeFile (T179, finding M4) when supplied entities do
|
|
31
|
+
* not match the file's freshly extracted text: the analysis is stale or came
|
|
32
|
+
* from a different file. Before T179 such entities were dropped silently and
|
|
33
|
+
* the host received a "successful" copy that still held the value.
|
|
34
|
+
*
|
|
35
|
+
* `entities` lists every mismatch (value, type, offsets); the message only
|
|
36
|
+
* counts them and names their types and offsets so it can be logged without
|
|
37
|
+
* repeating the values. The session is not touched when this is thrown.
|
|
38
|
+
*/
|
|
39
|
+
export class StaleAnalysisError extends Error {
|
|
40
|
+
constructor(entities, total = entities.length) {
|
|
41
|
+
const where = entities.map((e) => `${e.type}@${e.start}-${e.end}`).join(', ');
|
|
42
|
+
super(`${entities.length} of ${total} supplied entities do not match the file's text `
|
|
43
|
+
+ `(stale analysis or a different file): ${where}. Re-run analyzeOfficeFile on this file.`);
|
|
44
|
+
this.name = 'StaleAnalysisError';
|
|
45
|
+
this.entities = entities;
|
|
46
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** True when `err` is a StaleAnalysisError (also across realms). */
|
|
50
|
+
export function isStaleAnalysisError(err) {
|
|
51
|
+
return err instanceof StaleAnalysisError
|
|
52
|
+
|| (typeof err === 'object' && err !== null && err.name === 'StaleAnalysisError');
|
|
53
|
+
}
|
|
28
54
|
/**
|
|
29
55
|
* Extract a file's text and run detection WITHOUT touching the session. Hosts
|
|
30
56
|
* use this to show the user what was found before asking for consent to
|
|
@@ -36,11 +62,15 @@ export async function analyzeOfficeFile(file, detect) {
|
|
|
36
62
|
const kind = officeFileKind(file.name);
|
|
37
63
|
if (!kind)
|
|
38
64
|
throw new Error(`Unsupported office file: ${file.name}`);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
const extraction = kind === 'docx' ? await readDocx(file) : await readXlsx(file);
|
|
66
|
+
const entities = await detect(extraction.plainText);
|
|
67
|
+
return {
|
|
68
|
+
kind,
|
|
69
|
+
plainText: extraction.plainText,
|
|
70
|
+
entities,
|
|
71
|
+
unredactable: extraction.unredactable,
|
|
72
|
+
warnings: extraction.warnings,
|
|
73
|
+
};
|
|
44
74
|
}
|
|
45
75
|
/** "report.docx" -> "report.redacted.docx"; extensionless names get a suffix. */
|
|
46
76
|
export function redactedFileName(filename) {
|
|
@@ -49,12 +79,24 @@ export function redactedFileName(filename) {
|
|
|
49
79
|
return `${filename}.redacted`;
|
|
50
80
|
return `${filename.slice(0, dot)}.redacted${filename.slice(dot)}`;
|
|
51
81
|
}
|
|
82
|
+
/** True when the entity's offsets land exactly on its value in the text. */
|
|
83
|
+
function matchesText(entity, plainText) {
|
|
84
|
+
return entity.start >= 0
|
|
85
|
+
&& entity.end <= plainText.length
|
|
86
|
+
&& entity.start < entity.end
|
|
87
|
+
&& plainText.slice(entity.start, entity.end) === entity.value;
|
|
88
|
+
}
|
|
52
89
|
/**
|
|
53
90
|
* Redact a DOCX or XLSX file: extract text, detect (or reuse supplied
|
|
54
91
|
* entities), map every entity through the session, and regenerate the file
|
|
55
92
|
* with placeholders spliced into the XML, formatting preserved and metadata
|
|
56
93
|
* stripped. Placeholder issuing goes through session.anonymize, so a value
|
|
57
94
|
* already seen in a prompt reuses its existing placeholder and vice versa.
|
|
95
|
+
*
|
|
96
|
+
* Fail-closed (T179): supplied entities that do not match this extraction
|
|
97
|
+
* raise StaleAnalysisError unless onMismatch is 'drop'; parts that cannot be
|
|
98
|
+
* redacted raise UnsupportedDocumentError('unredactable-parts') unless
|
|
99
|
+
* allowUnredactable is passed. Both are thrown before the session changes.
|
|
58
100
|
*/
|
|
59
101
|
export async function redactOfficeFile(file, options) {
|
|
60
102
|
const kind = officeFileKind(file.name);
|
|
@@ -70,14 +112,19 @@ export async function redactOfficeFile(file, options) {
|
|
|
70
112
|
entities = await options.detect(plainText);
|
|
71
113
|
}
|
|
72
114
|
const { session } = options;
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
115
|
+
// Entities whose offsets do not match this extraction come from a stale
|
|
116
|
+
// analysis (or a different file). Splicing them would corrupt the text and
|
|
117
|
+
// skipping them silently would ship the value (M4), so the default is to
|
|
118
|
+
// refuse; 'drop' keeps the historical filter for hosts that re-detect.
|
|
119
|
+
const mismatched = entities.filter((e) => !matchesText(e, plainText));
|
|
120
|
+
if (mismatched.length > 0 && (options.onMismatch ?? 'throw') === 'throw') {
|
|
121
|
+
throw new StaleAnalysisError(mismatched, entities.length);
|
|
122
|
+
}
|
|
123
|
+
// Map through the session in reading order. Surrogate mode maps PERSON
|
|
124
|
+
// values first, mirroring AnonymizationSession.anonymizeText, so derived
|
|
77
125
|
// surrogates (emails from owner names) stay consistent with prompts.
|
|
78
126
|
const valid = entities
|
|
79
|
-
.filter((e) => e
|
|
80
|
-
&& plainText.slice(e.start, e.end) === e.value)
|
|
127
|
+
.filter((e) => matchesText(e, plainText))
|
|
81
128
|
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
82
129
|
if (session.getMode() === 'surrogate') {
|
|
83
130
|
for (const entity of valid) {
|
|
@@ -90,27 +137,33 @@ export async function redactOfficeFile(file, options) {
|
|
|
90
137
|
end: entity.end,
|
|
91
138
|
replacement: session.anonymize(entity.value, entity.type),
|
|
92
139
|
}));
|
|
93
|
-
// Value-level scrubbing (relationship targets, formulas, field instructions
|
|
94
|
-
// uses the whole session map, so a value
|
|
95
|
-
// also caught when it hides in a hyperlink
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
140
|
+
// Value-level scrubbing (relationship targets, formulas, field instructions
|
|
141
|
+
// and the package-wide layer zero) uses the whole session map, so a value
|
|
142
|
+
// redacted in an earlier prompt is also caught when it hides in a hyperlink
|
|
143
|
+
// target of this file. layerZeroValueReplacements (T172) adds the PERSON
|
|
144
|
+
// name tokens, so a bare surname in a chart cache or custom XML part is
|
|
145
|
+
// caught too. Blanked entries ('________') stay out of the value list.
|
|
146
|
+
const valueReplacements = layerZeroValueReplacements(session.getEntries().filter((entry) => entry.replacement !== '________'));
|
|
99
147
|
let redactedText = plainText;
|
|
100
148
|
for (const repl of [...replacements].sort((a, b) => b.start - a.start)) {
|
|
101
149
|
redactedText = redactedText.slice(0, repl.start) + repl.replacement + redactedText.slice(repl.end);
|
|
102
150
|
}
|
|
103
|
-
const
|
|
104
|
-
? await
|
|
151
|
+
const written = kind === 'docx'
|
|
152
|
+
? await writeAnonymizedDocxWithReport(extraction, replacements, valueReplacements, {
|
|
105
153
|
acceptTrackedChanges: options.acceptTrackedChanges ?? true,
|
|
154
|
+
allowUnredactable: options.allowUnredactable ?? false,
|
|
106
155
|
})
|
|
107
|
-
: await
|
|
156
|
+
: await writeAnonymizedXlsxWithReport(extraction, replacements, valueReplacements, {
|
|
157
|
+
allowUnredactable: options.allowUnredactable ?? false,
|
|
158
|
+
});
|
|
108
159
|
return {
|
|
109
160
|
kind,
|
|
110
|
-
blob,
|
|
161
|
+
blob: written.blob,
|
|
111
162
|
suggestedName: redactedFileName(file.name),
|
|
112
163
|
entities: valid,
|
|
113
164
|
plainText,
|
|
114
165
|
redactedText,
|
|
166
|
+
warnings: written.warnings,
|
|
167
|
+
unredactable: extraction.unredactable,
|
|
115
168
|
};
|
|
116
169
|
}
|