@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
package/dist/dom/opc.d.ts CHANGED
@@ -10,12 +10,55 @@
10
10
  */
11
11
  import type JSZip from 'jszip';
12
12
  import type { ValueReplacement } from '../docx.js';
13
+ /**
14
+ * OOXML namespace families. Every family holds the ECMA-376 transitional URI
15
+ * (what Word/Excel write by default) and the ISO/IEC 29500 strict URI
16
+ * (purl.oclc.org, written by "Strict Open XML Document"). Matching only the
17
+ * transitional form let a strict file through with zero extracted text and
18
+ * an unredacted copy as the "result" (SECURITY_REPORT-2026-09 H1).
19
+ */
20
+ export declare const OOXML_NS: {
21
+ /** WordprocessingML main */
22
+ readonly w: Set<string>;
23
+ /** SpreadsheetML main */
24
+ readonly ss: Set<string>;
25
+ /** DrawingML main (a:) */
26
+ readonly a: Set<string>;
27
+ /** DrawingML chart (c:) */
28
+ readonly c: Set<string>;
29
+ /** DrawingML diagram / SmartArt (dgm:) */
30
+ readonly dgm: Set<string>;
31
+ /** Office Math (m:) */
32
+ readonly m: Set<string>;
33
+ /** Markup compatibility (mc:); the strict profile keeps the same URI */
34
+ readonly mc: Set<string>;
35
+ /** Package relationships references (r:), attribute values are rIds */
36
+ readonly r: Set<string>;
37
+ /** WordprocessingML drawing (wp:), carries wp:docPr alt text (T174) */
38
+ readonly wp: Set<string>;
39
+ };
40
+ /** True when the element has the local name and one of the namespaces. */
41
+ export declare function isElementIn(el: Element, namespaces: ReadonlySet<string>, localName: string): boolean;
42
+ /**
43
+ * getElementsByTagNameNS across a namespace family, in document order. The
44
+ * result is a static array, so callers may remove elements while iterating.
45
+ */
46
+ export declare function elementsByLocalName(root: Document | Element, namespaces: ReadonlySet<string>, localName: string): Element[];
47
+ /** Distinct namespace URIs of elements that belong to no known OOXML family. */
48
+ export declare function foreignNamespaces(root: Document | Element): string[];
13
49
  /** Parse one XML part from the zip; null when absent or malformed. */
14
50
  export declare function readXmlPart(zip: JSZip, path: string): Promise<Document | null>;
15
51
  /** Serialize an XML document back into the zip at the given path. */
16
52
  export declare function writeXmlPart(zip: JSZip, path: string, doc: Document): void;
17
53
  /** Blank the text content of every element whose local name is in the set. */
18
54
  export declare function blankElementsByLocalName(doc: Document, names: Set<string>): void;
55
+ /**
56
+ * The timestamp written wherever a date would fingerprint the original
57
+ * (T179, M6): docProps created/modified/lastPrinted and w:date on comments,
58
+ * revisions and property-change records. A fixed value instead of removal
59
+ * keeps the elements schema-valid (dcterms:* carry xsi:type="dcterms:W3CDTF").
60
+ */
61
+ export declare const NORMALISED_DATE = "2000-01-01T00:00:00Z";
19
62
  /**
20
63
  * Replace every occurrence of each sensitive value (case-insensitive) in a
21
64
  * string. A replacer function keeps '$' sequences in values/placeholders inert.
@@ -26,11 +69,36 @@ export declare function blankElementsByLocalName(doc: Document, names: Set<strin
26
69
  * of each value is matched too, so "Jan%20Kowalski" in a hyperlink target is
27
70
  * scrubbed as a whole.
28
71
  */
29
- export declare function replaceSensitiveValues(input: string, valueReplacements: ValueReplacement[], urlEncode: boolean): string;
72
+ export declare function replaceSensitiveValues(input: string, valueReplacements: ValueReplacement[], urlEncode: boolean, options?: {
73
+ wordBoundary?: boolean;
74
+ }): string;
30
75
  /**
31
- * Scrub the shared docProps parts:
32
- * - core.xml: author identities and content-describing fields
33
- * - app.xml: organization and template/path hints
76
+ * Value list for the layer-zero scrub (T172): every session entry plus, for
77
+ * PERSON entries, each whitespace-separated token of at least four characters
78
+ * that contains a letter, mapped to the entry's placeholder. A surname that
79
+ * appears alone in a chart cache or a custom XML part is then caught even if
80
+ * it never got its own session entry. Tokens already present as entries keep
81
+ * their own replacement; replaceSensitiveValues applies longer values first,
82
+ * so the full name always wins over a token inside it.
83
+ *
84
+ * office.ts (redactOfficeFile) is the intended caller; hosts that call the
85
+ * writers directly can build their list through this helper too.
86
+ */
87
+ export declare function layerZeroValueReplacements(entries: ReadonlyArray<{
88
+ original: string;
89
+ replacement: string;
90
+ entityType?: string;
91
+ }>): ValueReplacement[];
92
+ /**
93
+ * Scrub the shared docProps parts (T179 extends the list, finding M6):
94
+ * - core.xml: author identities, content-describing fields, content status
95
+ * and identifier blanked; created/modified/lastPrinted set to
96
+ * NORMALISED_DATE; revision and version removed
97
+ * - app.xml: organization and template/path hints blanked; page, word,
98
+ * character, line, paragraph and editing-time statistics set to 0;
99
+ * TitlesOfParts and HeadingPairs keep their vt:vector size and element
100
+ * count while every vt:lpstr (heading text, sheet name, defined name) is
101
+ * blanked
34
102
  * - custom.xml: every leaf value (property names/structure kept)
35
103
  */
36
104
  export declare function scrubDocPropsParts(zip: JSZip): Promise<void>;
@@ -40,9 +108,69 @@ export declare function scrubDocPropsParts(zip: JSZip): Promise<void>;
40
108
  * override.
41
109
  */
42
110
  export declare function removePackageThumbnail(zip: JSZip): Promise<void>;
111
+ /** The date every entry of an exported package carries (T179, L1). */
112
+ export declare const ZIP_ENTRY_DATE: Date;
113
+ /**
114
+ * Normalise the archive container before generation (T179, finding L1):
115
+ * every entry, structural ones and directories included, gets ZIP_ENTRY_DATE
116
+ * and an empty comment, and the archive comment is cleared. JSZip otherwise
117
+ * keeps the original mtime of untouched entries and stamps rewritten ones
118
+ * with "now", which fingerprints both the source file and the parts DocCloak
119
+ * touched; entry and archive comments are copied through verbatim.
120
+ *
121
+ * generateAsync must still receive { comment: '' }; JSZip falls back to the
122
+ * loaded archive comment when the option is empty, which is why the archive
123
+ * comment is cleared on the instance here as well.
124
+ */
125
+ export declare function normalizeZipEntries(zip: JSZip): void;
126
+ /**
127
+ * What every mailto: relationship target becomes (T174, M1). A mail address
128
+ * in a hyperlink target is personal data by definition, and the detector
129
+ * never sees relationship files, so the rule is unconditional: no known
130
+ * value list is needed for it to fire.
131
+ */
132
+ export declare const MAILTO_REDACTED_TARGET = "mailto:redacted@example.invalid";
133
+ /**
134
+ * Always-scrub rule for external relationship targets: a mailto: target is
135
+ * replaced wholesale (address, query string and all); anything else is
136
+ * returned unchanged so the caller can apply the value-based scrub.
137
+ */
138
+ export declare function scrubMailtoTarget(target: string): string;
43
139
  /**
44
- * Scrub external relationship targets (mailto:/https: hyperlinks keep the raw
45
- * sensitive value even when the display text was redacted) in every .rels file
46
- * matched by the given pattern.
140
+ * Scrub external relationship targets in every .rels file matched by the
141
+ * given pattern: mailto: targets are always replaced (T174), and every other
142
+ * external target has the known values removed (https: hyperlinks keep the
143
+ * raw sensitive value even when the display text was redacted).
47
144
  */
48
145
  export declare function scrubExternalRelTargets(zip: JSZip, relsPattern: RegExp, valueReplacements: ValueReplacement[]): Promise<void>;
146
+ /**
147
+ * Scrub every known value inside one parsed XML part: text and CDATA node
148
+ * values get every value; attribute values follow the safety rule (lettered
149
+ * values, or purely numeric values of at least nine digits at word
150
+ * boundaries; never identifiers, relationship ids or layout numbers).
151
+ * Element names and rels Type/Id/internal Target are never touched.
152
+ * Returns true when anything changed.
153
+ */
154
+ export declare function scrubXmlValues(doc: Document, valueReplacements: ValueReplacement[], isRels: boolean): boolean;
155
+ export interface PackageScrubOptions {
156
+ /** Parts to leave alone (content parts already written by the caller). */
157
+ skip?: ReadonlySet<string>;
158
+ }
159
+ export interface PackageScrubResult {
160
+ /** Parts that were rewritten. */
161
+ touched: string[];
162
+ /** Human-readable notes: parts that could not be parsed and were left as-is. */
163
+ warnings: string[];
164
+ }
165
+ /** Every XML-like part of the package except the content-types manifest. */
166
+ export declare function isLayerZeroPart(path: string): boolean;
167
+ /**
168
+ * Layer zero (plan 3.1): walk every *.xml, *.rels and *.vml part of the zip,
169
+ * including customXml/, word/charts/, word/diagrams/, word/glossary/,
170
+ * docProps/ and xl/pivotCache/, and scrub every known value from text nodes
171
+ * and attribute values. Runs after the content replacements and before the
172
+ * zip is generated, so nothing the content extractors did not understand can
173
+ * carry a known value out of the package. Parts that do not parse are left
174
+ * untouched and listed in warnings; the caller decides how to surface them.
175
+ */
176
+ export declare function scrubPackageValues(zip: JSZip, valueReplacements: ValueReplacement[], options?: PackageScrubOptions): Promise<PackageScrubResult>;
package/dist/dom/opc.js CHANGED
@@ -8,6 +8,105 @@
8
8
  * implementation untouched (behavior-preservation for existing callers); new
9
9
  * code paths use these helpers.
10
10
  */
11
+ // ---------------------------------------------------------------------------
12
+ // Namespaces (T172, H1)
13
+ // ---------------------------------------------------------------------------
14
+ /**
15
+ * OOXML namespace families. Every family holds the ECMA-376 transitional URI
16
+ * (what Word/Excel write by default) and the ISO/IEC 29500 strict URI
17
+ * (purl.oclc.org, written by "Strict Open XML Document"). Matching only the
18
+ * transitional form let a strict file through with zero extracted text and
19
+ * an unredacted copy as the "result" (SECURITY_REPORT-2026-09 H1).
20
+ */
21
+ export const OOXML_NS = {
22
+ /** WordprocessingML main */
23
+ w: new Set([
24
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
25
+ 'http://purl.oclc.org/ooxml/wordprocessingml/main',
26
+ ]),
27
+ /** SpreadsheetML main */
28
+ ss: new Set([
29
+ 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
30
+ 'http://purl.oclc.org/ooxml/spreadsheetml/main',
31
+ ]),
32
+ /** DrawingML main (a:) */
33
+ a: new Set([
34
+ 'http://schemas.openxmlformats.org/drawingml/2006/main',
35
+ 'http://purl.oclc.org/ooxml/drawingml/main',
36
+ ]),
37
+ /** DrawingML chart (c:) */
38
+ c: new Set([
39
+ 'http://schemas.openxmlformats.org/drawingml/2006/chart',
40
+ 'http://purl.oclc.org/ooxml/drawingml/chart',
41
+ ]),
42
+ /** DrawingML diagram / SmartArt (dgm:) */
43
+ dgm: new Set([
44
+ 'http://schemas.openxmlformats.org/drawingml/2006/diagram',
45
+ 'http://purl.oclc.org/ooxml/drawingml/diagram',
46
+ ]),
47
+ /** Office Math (m:) */
48
+ m: new Set([
49
+ 'http://schemas.openxmlformats.org/officeDocument/2006/math',
50
+ 'http://purl.oclc.org/ooxml/officeDocument/math',
51
+ ]),
52
+ /** Markup compatibility (mc:); the strict profile keeps the same URI */
53
+ mc: new Set([
54
+ 'http://schemas.openxmlformats.org/markup-compatibility/2006',
55
+ ]),
56
+ /** Package relationships references (r:), attribute values are rIds */
57
+ r: new Set([
58
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
59
+ 'http://purl.oclc.org/ooxml/officeDocument/relationships',
60
+ ]),
61
+ /** WordprocessingML drawing (wp:), carries wp:docPr alt text (T174) */
62
+ wp: new Set([
63
+ 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing',
64
+ 'http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing',
65
+ ]),
66
+ };
67
+ /** True when the element has the local name and one of the namespaces. */
68
+ export function isElementIn(el, namespaces, localName) {
69
+ return el.localName === localName && el.namespaceURI !== null && namespaces.has(el.namespaceURI);
70
+ }
71
+ /**
72
+ * getElementsByTagNameNS across a namespace family, in document order. The
73
+ * result is a static array, so callers may remove elements while iterating.
74
+ */
75
+ export function elementsByLocalName(root, namespaces, localName) {
76
+ const result = [];
77
+ const all = root.getElementsByTagName('*');
78
+ for (let i = 0; i < all.length; i++) {
79
+ if (isElementIn(all[i], namespaces, localName))
80
+ result.push(all[i]);
81
+ }
82
+ return result;
83
+ }
84
+ /**
85
+ * Namespace prefixes the office readers understand as "one of ours". An
86
+ * element outside every family here is foreign: when the main part yields
87
+ * no paragraph (or no sheetData) while foreign elements are present, the
88
+ * reader refuses the file instead of exporting an unredacted copy.
89
+ */
90
+ const KNOWN_NAMESPACE_PREFIXES = [
91
+ 'http://schemas.openxmlformats.org/',
92
+ 'http://purl.oclc.org/ooxml/',
93
+ 'http://schemas.microsoft.com/office/',
94
+ 'urn:schemas-microsoft-com:',
95
+ 'http://www.w3.org/XML/1998/namespace',
96
+ 'http://www.w3.org/2001/XMLSchema',
97
+ ];
98
+ /** Distinct namespace URIs of elements that belong to no known OOXML family. */
99
+ export function foreignNamespaces(root) {
100
+ const seen = new Set();
101
+ const all = root.getElementsByTagName('*');
102
+ for (let i = 0; i < all.length; i++) {
103
+ const ns = all[i].namespaceURI ?? '';
104
+ if (KNOWN_NAMESPACE_PREFIXES.some((prefix) => ns.startsWith(prefix)))
105
+ continue;
106
+ seen.add(ns === '' ? '(no namespace)' : ns);
107
+ }
108
+ return [...seen];
109
+ }
11
110
  /** Parse one XML part from the zip; null when absent or malformed. */
12
111
  export async function readXmlPart(zip, path) {
13
112
  const file = zip.file(path);
@@ -25,12 +124,34 @@ export function writeXmlPart(zip, path, doc) {
25
124
  }
26
125
  /** Blank the text content of every element whose local name is in the set. */
27
126
  export function blankElementsByLocalName(doc, names) {
127
+ setElementsTextByLocalName(doc, names, '');
128
+ }
129
+ /** Set the text content of every element whose local name is in the set. */
130
+ function setElementsTextByLocalName(root, names, value) {
131
+ const all = root.getElementsByTagName('*');
132
+ for (let i = 0; i < all.length; i++) {
133
+ if (names.has(all[i].localName))
134
+ all[i].textContent = value;
135
+ }
136
+ }
137
+ /** Remove every element whose local name is in the set (static snapshot first). */
138
+ function removeElementsByLocalName(doc, names) {
28
139
  const all = doc.getElementsByTagName('*');
140
+ const doomed = [];
29
141
  for (let i = 0; i < all.length; i++) {
30
142
  if (names.has(all[i].localName))
31
- all[i].textContent = '';
143
+ doomed.push(all[i]);
32
144
  }
145
+ for (const el of doomed)
146
+ el.parentNode?.removeChild(el);
33
147
  }
148
+ /**
149
+ * The timestamp written wherever a date would fingerprint the original
150
+ * (T179, M6): docProps created/modified/lastPrinted and w:date on comments,
151
+ * revisions and property-change records. A fixed value instead of removal
152
+ * keeps the elements schema-valid (dcterms:* carry xsi:type="dcterms:W3CDTF").
153
+ */
154
+ export const NORMALISED_DATE = '2000-01-01T00:00:00Z';
34
155
  /**
35
156
  * Replace every occurrence of each sensitive value (case-insensitive) in a
36
157
  * string. A replacer function keeps '$' sequences in values/placeholders inert.
@@ -41,7 +162,7 @@ export function blankElementsByLocalName(doc, names) {
41
162
  * of each value is matched too, so "Jan%20Kowalski" in a hyperlink target is
42
163
  * scrubbed as a whole.
43
164
  */
44
- export function replaceSensitiveValues(input, valueReplacements, urlEncode) {
165
+ export function replaceSensitiveValues(input, valueReplacements, urlEncode, options = {}) {
45
166
  let result = input;
46
167
  const ordered = [...valueReplacements]
47
168
  .filter(({ value }) => value)
@@ -53,29 +174,99 @@ export function replaceSensitiveValues(input, valueReplacements, urlEncode) {
53
174
  if (encoded !== value)
54
175
  forms.push(encoded);
55
176
  }
56
- const pattern = forms.map((form) => form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
177
+ const alternatives = forms.map((form) => form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
178
+ // Boundaries are only meaningful for values that begin and end with a
179
+ // word character; \b would otherwise make a value like "(c)" unmatchable.
180
+ const bounded = options.wordBoundary && /^\w[\s\S]*\w$|^\w$/.test(value);
181
+ const pattern = bounded ? `\\b(?:${alternatives})\\b` : alternatives;
57
182
  const substitute = urlEncode ? encodeURIComponent(replacement) : replacement;
58
183
  result = result.replace(new RegExp(pattern, 'gi'), () => substitute);
59
184
  }
60
185
  return result;
61
186
  }
62
187
  /**
63
- * Scrub the shared docProps parts:
64
- * - core.xml: author identities and content-describing fields
65
- * - app.xml: organization and template/path hints
188
+ * Value list for the layer-zero scrub (T172): every session entry plus, for
189
+ * PERSON entries, each whitespace-separated token of at least four characters
190
+ * that contains a letter, mapped to the entry's placeholder. A surname that
191
+ * appears alone in a chart cache or a custom XML part is then caught even if
192
+ * it never got its own session entry. Tokens already present as entries keep
193
+ * their own replacement; replaceSensitiveValues applies longer values first,
194
+ * so the full name always wins over a token inside it.
195
+ *
196
+ * office.ts (redactOfficeFile) is the intended caller; hosts that call the
197
+ * writers directly can build their list through this helper too.
198
+ */
199
+ export function layerZeroValueReplacements(entries) {
200
+ const result = [];
201
+ const seen = new Set();
202
+ const add = (value, replacement) => {
203
+ const key = value.toLowerCase();
204
+ if (value.length === 0 || seen.has(key))
205
+ return;
206
+ seen.add(key);
207
+ result.push({ value, replacement });
208
+ };
209
+ for (const entry of entries)
210
+ add(entry.original, entry.replacement);
211
+ for (const entry of entries) {
212
+ if (entry.entityType !== 'PERSON')
213
+ continue;
214
+ for (const raw of entry.original.split(/\s+/)) {
215
+ const token = raw.replace(/^[.,;:!?()"'\u201E\u201C\u201D]+|[.,;:!?()"'\u201E\u201C\u201D]+$/g, '');
216
+ if (token.length >= 4 && /\p{L}/u.test(token))
217
+ add(token, entry.replacement);
218
+ }
219
+ }
220
+ return result;
221
+ }
222
+ /** core.xml elements blanked: identities and content-describing text. */
223
+ const CORE_BLANKED = new Set([
224
+ 'creator', 'lastModifiedBy', 'title', 'subject', 'description', 'keywords', 'category',
225
+ 'contentStatus', 'identifier',
226
+ ]);
227
+ /** core.xml timestamps set to NORMALISED_DATE (schema-typed W3CDTF, never blanked). */
228
+ const CORE_DATES = new Set(['created', 'modified', 'lastPrinted']);
229
+ /** core.xml elements removed outright (optional in the schema, numeric fingerprints). */
230
+ const CORE_REMOVED = new Set(['revision', 'version']);
231
+ /** app.xml elements blanked: organisation and template/path hints. */
232
+ const APP_BLANKED = new Set(['Company', 'Manager', 'HyperlinkBase', 'Template']);
233
+ /** app.xml statistics set to '0': they fingerprint the original's length and edit time. */
234
+ const APP_ZEROED = new Set([
235
+ 'Pages', 'Words', 'Characters', 'CharactersWithSpaces', 'Lines', 'Paragraphs', 'TotalTime',
236
+ 'Slides', 'Notes', 'HiddenSlides', 'MMClips',
237
+ ]);
238
+ /** app.xml vectors whose vt:lpstr entries name headings, sheets and defined names. */
239
+ const APP_PART_VECTORS = new Set(['TitlesOfParts', 'HeadingPairs']);
240
+ /**
241
+ * Scrub the shared docProps parts (T179 extends the list, finding M6):
242
+ * - core.xml: author identities, content-describing fields, content status
243
+ * and identifier blanked; created/modified/lastPrinted set to
244
+ * NORMALISED_DATE; revision and version removed
245
+ * - app.xml: organization and template/path hints blanked; page, word,
246
+ * character, line, paragraph and editing-time statistics set to 0;
247
+ * TitlesOfParts and HeadingPairs keep their vt:vector size and element
248
+ * count while every vt:lpstr (heading text, sheet name, defined name) is
249
+ * blanked
66
250
  * - custom.xml: every leaf value (property names/structure kept)
67
251
  */
68
252
  export async function scrubDocPropsParts(zip) {
69
253
  const core = await readXmlPart(zip, 'docProps/core.xml');
70
254
  if (core) {
71
- blankElementsByLocalName(core, new Set([
72
- 'creator', 'lastModifiedBy', 'title', 'subject', 'description', 'keywords', 'category',
73
- ]));
255
+ blankElementsByLocalName(core, CORE_BLANKED);
256
+ setElementsTextByLocalName(core, CORE_DATES, NORMALISED_DATE);
257
+ removeElementsByLocalName(core, CORE_REMOVED);
74
258
  writeXmlPart(zip, 'docProps/core.xml', core);
75
259
  }
76
260
  const app = await readXmlPart(zip, 'docProps/app.xml');
77
261
  if (app) {
78
- blankElementsByLocalName(app, new Set(['Company', 'Manager', 'HyperlinkBase', 'Template']));
262
+ blankElementsByLocalName(app, APP_BLANKED);
263
+ setElementsTextByLocalName(app, APP_ZEROED, '0');
264
+ const all = app.getElementsByTagName('*');
265
+ for (let i = 0; i < all.length; i++) {
266
+ if (APP_PART_VECTORS.has(all[i].localName)) {
267
+ setElementsTextByLocalName(all[i], new Set(['lpstr']), '');
268
+ }
269
+ }
79
270
  writeXmlPart(zip, 'docProps/app.xml', app);
80
271
  }
81
272
  const custom = await readXmlPart(zip, 'docProps/custom.xml');
@@ -129,10 +320,47 @@ export async function removePackageThumbnail(zip) {
129
320
  writeXmlPart(zip, '[Content_Types].xml', contentTypes);
130
321
  }
131
322
  }
323
+ /** The date every entry of an exported package carries (T179, L1). */
324
+ export const ZIP_ENTRY_DATE = new Date(Date.UTC(1980, 0, 1));
325
+ /**
326
+ * Normalise the archive container before generation (T179, finding L1):
327
+ * every entry, structural ones and directories included, gets ZIP_ENTRY_DATE
328
+ * and an empty comment, and the archive comment is cleared. JSZip otherwise
329
+ * keeps the original mtime of untouched entries and stamps rewritten ones
330
+ * with "now", which fingerprints both the source file and the parts DocCloak
331
+ * touched; entry and archive comments are copied through verbatim.
332
+ *
333
+ * generateAsync must still receive { comment: '' }; JSZip falls back to the
334
+ * loaded archive comment when the option is empty, which is why the archive
335
+ * comment is cleared on the instance here as well.
336
+ */
337
+ export function normalizeZipEntries(zip) {
338
+ for (const entry of Object.values(zip.files)) {
339
+ entry.date = new Date(ZIP_ENTRY_DATE.getTime());
340
+ entry.comment = '';
341
+ }
342
+ zip.comment = '';
343
+ }
132
344
  /**
133
- * Scrub external relationship targets (mailto:/https: hyperlinks keep the raw
134
- * sensitive value even when the display text was redacted) in every .rels file
135
- * matched by the given pattern.
345
+ * What every mailto: relationship target becomes (T174, M1). A mail address
346
+ * in a hyperlink target is personal data by definition, and the detector
347
+ * never sees relationship files, so the rule is unconditional: no known
348
+ * value list is needed for it to fire.
349
+ */
350
+ export const MAILTO_REDACTED_TARGET = 'mailto:redacted@example.invalid';
351
+ /**
352
+ * Always-scrub rule for external relationship targets: a mailto: target is
353
+ * replaced wholesale (address, query string and all); anything else is
354
+ * returned unchanged so the caller can apply the value-based scrub.
355
+ */
356
+ export function scrubMailtoTarget(target) {
357
+ return /^\s*mailto:/i.test(target) ? MAILTO_REDACTED_TARGET : target;
358
+ }
359
+ /**
360
+ * Scrub external relationship targets in every .rels file matched by the
361
+ * given pattern: mailto: targets are always replaced (T174), and every other
362
+ * external target has the known values removed (https: hyperlinks keep the
363
+ * raw sensitive value even when the display text was redacted).
136
364
  */
137
365
  export async function scrubExternalRelTargets(zip, relsPattern, valueReplacements) {
138
366
  const relsPaths = [];
@@ -149,7 +377,7 @@ export async function scrubExternalRelTargets(zip, relsPattern, valueReplacement
149
377
  const rel = relationships[i];
150
378
  if (rel.getAttribute('TargetMode') === 'External') {
151
379
  const target = rel.getAttribute('Target') ?? '';
152
- const scrubbed = replaceSensitiveValues(target, valueReplacements, true);
380
+ const scrubbed = replaceSensitiveValues(scrubMailtoTarget(target), valueReplacements, true);
153
381
  if (scrubbed !== target)
154
382
  rel.setAttribute('Target', scrubbed);
155
383
  }
@@ -157,3 +385,168 @@ export async function scrubExternalRelTargets(zip, relsPattern, valueReplacement
157
385
  writeXmlPart(zip, relsPath, rels);
158
386
  }
159
387
  }
388
+ // ---------------------------------------------------------------------------
389
+ // Layer zero: package-wide value scrub (T172, H3/H4)
390
+ // ---------------------------------------------------------------------------
391
+ /**
392
+ * Attribute local names that carry identifiers or numeric layout and are
393
+ * never rewritten: a placeholder inside one of these corrupts the file.
394
+ */
395
+ const NEVER_SCRUB_ATTRS = new Set([
396
+ 'id', 'paraid', 'textid', 'durableid', 'embed', 'link', 'pict', 'cx', 'cy', 'x', 'y',
397
+ 'l', 't', 'r', 'b', 'w', 'h', 'sz', 'szcs', 'dxaorig', 'dyaorig', 'type', 'partname',
398
+ 'extension', 'contenttype', 'requires', 'ignorable',
399
+ ]);
400
+ /**
401
+ * Elements whose every attribute is geometry or positioning (DrawingML and
402
+ * WordprocessingML layout): skipped wholesale.
403
+ */
404
+ const LAYOUT_ELEMENTS = new Set([
405
+ 'ext', 'off', 'extent', 'effectExtent', 'chOff', 'chExt', 'positionH', 'positionV',
406
+ 'posOffset', 'simplePos', 'srcRect', 'tblW', 'tcW', 'tblInd', 'ind', 'spacing',
407
+ 'pgSz', 'pgMar', 'gridCol', 'trHeight', 'tblCellMar', 'framePr', 'anchor', 'inline',
408
+ 'wrapSquare', 'wrapTight', 'wrapThrough', 'wrapTopAndBottom', 'lnTo', 'pt',
409
+ 'cubicBezTo', 'quadBezTo', 'arcTo', 'path', 'gd', 'pos',
410
+ ]);
411
+ const XML_NS = 'http://www.w3.org/XML/1998/namespace';
412
+ const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
413
+ /** Attribute safety rule from the T172 spec, applied per attribute. */
414
+ function isScrubbableAttribute(el, attr, isRels) {
415
+ if (attr.namespaceURI === XMLNS_NS || attr.name.startsWith('xmlns'))
416
+ return false;
417
+ if (attr.namespaceURI === XML_NS)
418
+ return false;
419
+ if (attr.namespaceURI !== null && OOXML_NS.r.has(attr.namespaceURI))
420
+ return false;
421
+ const local = attr.localName.toLowerCase();
422
+ if (NEVER_SCRUB_ATTRS.has(local) || local.startsWith('rsid'))
423
+ return false;
424
+ if (LAYOUT_ELEMENTS.has(el.localName))
425
+ return false;
426
+ if (isRels && el.localName === 'Relationship') {
427
+ // Internal targets are part names; only external ones carry user data.
428
+ return attr.localName === 'Target' && el.getAttribute('TargetMode') === 'External';
429
+ }
430
+ return true;
431
+ }
432
+ /** Split the value list per the safety rule: lettered values, long numeric values. */
433
+ function partitionForAttributes(valueReplacements) {
434
+ const lettered = [];
435
+ const longNumeric = [];
436
+ for (const pair of valueReplacements) {
437
+ if (/\p{L}/u.test(pair.value))
438
+ lettered.push(pair);
439
+ else if (/^\d{9,}$/.test(pair.value))
440
+ longNumeric.push(pair);
441
+ }
442
+ return { lettered, longNumeric };
443
+ }
444
+ function scrubAttributeValue(value, urlEncode, lettered, longNumeric) {
445
+ let result = value;
446
+ if (lettered.length > 0)
447
+ result = replaceSensitiveValues(result, lettered, urlEncode);
448
+ if (longNumeric.length > 0)
449
+ result = replaceSensitiveValues(result, longNumeric, urlEncode, { wordBoundary: true });
450
+ return result;
451
+ }
452
+ const TEXT_NODE = 3;
453
+ const CDATA_SECTION_NODE = 4;
454
+ const ELEMENT_NODE = 1;
455
+ /**
456
+ * Scrub every known value inside one parsed XML part: text and CDATA node
457
+ * values get every value; attribute values follow the safety rule (lettered
458
+ * values, or purely numeric values of at least nine digits at word
459
+ * boundaries; never identifiers, relationship ids or layout numbers).
460
+ * Element names and rels Type/Id/internal Target are never touched.
461
+ * Returns true when anything changed.
462
+ */
463
+ export function scrubXmlValues(doc, valueReplacements, isRels) {
464
+ const { lettered, longNumeric } = partitionForAttributes(valueReplacements);
465
+ let changed = false;
466
+ const visit = (node) => {
467
+ if (node.nodeType === TEXT_NODE || node.nodeType === CDATA_SECTION_NODE) {
468
+ const value = node.nodeValue ?? '';
469
+ if (value.trim().length === 0)
470
+ return;
471
+ const scrubbed = replaceSensitiveValues(value, valueReplacements, false);
472
+ if (scrubbed !== value) {
473
+ node.nodeValue = scrubbed;
474
+ changed = true;
475
+ }
476
+ return;
477
+ }
478
+ if (node.nodeType !== ELEMENT_NODE)
479
+ return;
480
+ const el = node;
481
+ const attrs = el.attributes;
482
+ for (let a = 0; a < attrs.length; a++) {
483
+ const attr = attrs[a];
484
+ if (!isScrubbableAttribute(el, attr, isRels))
485
+ continue;
486
+ const urlEncode = isRels || /^(?:https?:|mailto:|file:)/i.test(attr.value);
487
+ // Second layer for the mailto rule (T174): the writers apply it to
488
+ // their own rels first; any rels part they did not visit gets it here.
489
+ const base = isRels ? scrubMailtoTarget(attr.value) : attr.value;
490
+ const scrubbed = scrubAttributeValue(base, urlEncode, lettered, longNumeric);
491
+ if (scrubbed !== attr.value) {
492
+ attr.value = scrubbed;
493
+ changed = true;
494
+ }
495
+ }
496
+ for (let child = el.firstChild; child; child = child.nextSibling)
497
+ visit(child);
498
+ };
499
+ // The Document node itself is neither an element nor text: start at its children.
500
+ for (let child = doc.firstChild; child; child = child.nextSibling)
501
+ visit(child);
502
+ return changed;
503
+ }
504
+ /** Every XML-like part of the package except the content-types manifest. */
505
+ export function isLayerZeroPart(path) {
506
+ if (path === '[Content_Types].xml')
507
+ return false;
508
+ return /\.(?:xml|rels|vml)$/i.test(path);
509
+ }
510
+ /**
511
+ * Layer zero (plan 3.1): walk every *.xml, *.rels and *.vml part of the zip,
512
+ * including customXml/, word/charts/, word/diagrams/, word/glossary/,
513
+ * docProps/ and xl/pivotCache/, and scrub every known value from text nodes
514
+ * and attribute values. Runs after the content replacements and before the
515
+ * zip is generated, so nothing the content extractors did not understand can
516
+ * carry a known value out of the package. Parts that do not parse are left
517
+ * untouched and listed in warnings; the caller decides how to surface them.
518
+ */
519
+ export async function scrubPackageValues(zip, valueReplacements, options = {}) {
520
+ const result = { touched: [], warnings: [] };
521
+ const active = valueReplacements.filter((pair) => pair.value.length > 0);
522
+ if (active.length === 0)
523
+ return result;
524
+ const paths = [];
525
+ zip.forEach((relativePath, entry) => {
526
+ if (entry.dir)
527
+ return;
528
+ if (options.skip?.has(relativePath))
529
+ return;
530
+ if (isLayerZeroPart(relativePath))
531
+ paths.push(relativePath);
532
+ });
533
+ paths.sort();
534
+ const parser = new DOMParser();
535
+ for (const path of paths) {
536
+ const file = zip.file(path);
537
+ if (!file)
538
+ continue;
539
+ const xmlStr = await file.async('string');
540
+ const doc = parser.parseFromString(xmlStr, 'application/xml');
541
+ if (doc.getElementsByTagName('parsererror').length > 0) {
542
+ result.warnings.push(`${path}: not well-formed XML, left untouched by the value scrub`);
543
+ continue;
544
+ }
545
+ const isRels = /\.rels$/i.test(path);
546
+ if (scrubXmlValues(doc, active, isRels)) {
547
+ writeXmlPart(zip, path, doc);
548
+ result.touched.push(path);
549
+ }
550
+ }
551
+ return result;
552
+ }