@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
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
|
|
72
|
+
export declare function replaceSensitiveValues(input: string, valueReplacements: ValueReplacement[], urlEncode: boolean, options?: {
|
|
73
|
+
wordBoundary?: boolean;
|
|
74
|
+
}): string;
|
|
30
75
|
/**
|
|
31
|
-
*
|
|
32
|
-
* -
|
|
33
|
-
*
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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]
|
|
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
|
|
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
|
-
*
|
|
64
|
-
* -
|
|
65
|
-
*
|
|
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,
|
|
72
|
-
|
|
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,
|
|
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
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
+
}
|