@doccloak/core 0.11.0 → 0.12.1

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +148 -2
  2. package/README.md +252 -19
  3. package/dist/doc.d.ts +68 -1
  4. package/dist/doc.js +447 -62
  5. package/dist/dom/docx.d.ts +135 -7
  6. package/dist/dom/docx.js +752 -125
  7. package/dist/dom/drawingml.d.ts +59 -0
  8. package/dist/dom/drawingml.js +176 -0
  9. package/dist/dom/errors.d.ts +35 -0
  10. package/dist/dom/errors.js +24 -0
  11. package/dist/dom/index.d.ts +3 -0
  12. package/dist/dom/index.js +6 -0
  13. package/dist/dom/office.d.ts +56 -0
  14. package/dist/dom/office.js +76 -23
  15. package/dist/dom/opc.d.ts +135 -7
  16. package/dist/dom/opc.js +407 -14
  17. package/dist/dom/package-policy.d.ts +137 -0
  18. package/dist/dom/package-policy.js +355 -0
  19. package/dist/dom/xlsx.d.ts +64 -6
  20. package/dist/dom/xlsx.js +130 -20
  21. package/dist/engine.d.ts +18 -0
  22. package/dist/engine.js +37 -13
  23. package/dist/env.d.ts +21 -1
  24. package/dist/index.d.ts +8 -6
  25. package/dist/index.js +10 -5
  26. package/dist/model-loader.d.ts +61 -6
  27. package/dist/model-loader.js +122 -17
  28. package/dist/pipeline.d.ts +87 -8
  29. package/dist/pipeline.js +249 -50
  30. package/dist/providers/bardsai.d.ts +8 -1
  31. package/dist/providers/bardsai.js +22 -2
  32. package/dist/providers/gliner-base.d.ts +8 -1
  33. package/dist/providers/gliner-base.js +22 -2
  34. package/dist/providers/gliner.d.ts +8 -1
  35. package/dist/providers/gliner.js +22 -2
  36. package/dist/regex/loader.d.ts +2 -0
  37. package/dist/regex/loader.js +2 -0
  38. package/dist/regex/rules.data.js +15 -14
  39. package/dist/regex/types.d.ts +6 -0
  40. package/dist/session.d.ts +101 -3
  41. package/dist/session.js +397 -43
  42. package/dist/surrogates.js +57 -45
  43. package/dist/worker-protocol.d.ts +13 -1
  44. package/dist/worker-protocol.js +164 -12
  45. package/package.json +1 -1
  46. package/rules/at.json +1 -1
  47. package/rules/be.json +1 -1
  48. package/rules/ch.json +1 -1
  49. package/rules/de.json +1 -1
  50. package/rules/dk.json +1 -1
  51. package/rules/es.json +1 -1
  52. package/rules/fi.json +1 -1
  53. package/rules/fr.json +1 -1
  54. package/rules/it.json +1 -1
  55. package/rules/no.json +1 -1
  56. package/rules/pl.json +1 -1
  57. package/rules/pt.json +1 -1
  58. package/rules/schema.json +4 -0
  59. package/rules/se.json +1 -1
  60. 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
+ }
@@ -11,3 +11,6 @@ export * from './docx.js';
11
11
  export * from './ocr.js';
12
12
  export * from './xlsx.js';
13
13
  export * from './office.js';
14
+ export * from './errors.js';
15
+ export * from './drawingml.js';
16
+ export * from './package-policy.js';
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";
@@ -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>;
@@ -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, writeAnonymizedDocx } from "./docx.js";
14
- import { readXlsx, writeAnonymizedXlsx } from "./xlsx.js";
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 plainText = kind === 'docx'
40
- ? (await readDocx(file)).plainText
41
- : (await readXlsx(file)).plainText;
42
- const entities = await detect(plainText);
43
- return { kind, plainText, entities };
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
- // Drop entities whose offsets do not match this extraction (defensive: a
74
- // stale analysis of a different file must not splice garbage), then map
75
- // through the session in reading order. Surrogate mode maps PERSON values
76
- // first, mirroring AnonymizationSession.anonymizeText, so derived
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.start >= 0 && e.end <= plainText.length && e.start < e.end
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 redacted in an earlier prompt is
95
- // also caught when it hides in a hyperlink target of this file.
96
- const valueReplacements = session.getEntries()
97
- .filter((entry) => entry.replacement !== '________')
98
- .map((entry) => ({ value: entry.original, replacement: entry.replacement }));
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 blob = kind === 'docx'
104
- ? await writeAnonymizedDocx(extraction, replacements, valueReplacements, {
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 writeAnonymizedXlsx(extraction, replacements, valueReplacements);
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
  }