@patrickdk77/office-defuse 0.1.1 → 0.2.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 (82) hide show
  1. package/README.md +211 -43
  2. package/dist/cfb-writer.d.ts +25 -0
  3. package/dist/cfb-writer.js +314 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +357 -0
  6. package/dist/contained.d.ts +46 -0
  7. package/dist/contained.js +152 -0
  8. package/dist/context.d.ts +107 -12
  9. package/dist/context.js +101 -24
  10. package/dist/detect.d.ts +13 -0
  11. package/dist/detect.js +83 -0
  12. package/dist/engine.d.ts +66 -1
  13. package/dist/engine.js +290 -35
  14. package/dist/errors.d.ts +17 -0
  15. package/dist/errors.js +27 -1
  16. package/dist/fields.d.ts +20 -13
  17. package/dist/fields.js +65 -23
  18. package/dist/findings.d.ts +2 -1
  19. package/dist/findings.js +17 -1
  20. package/dist/flat.d.ts +33 -0
  21. package/dist/flat.js +555 -0
  22. package/dist/formats.d.ts +9 -0
  23. package/dist/formats.js +7 -1
  24. package/dist/formulas.d.ts +27 -0
  25. package/dist/formulas.js +157 -1
  26. package/dist/index.d.ts +4 -2
  27. package/dist/index.js +11 -1
  28. package/dist/index.mjs +1 -1
  29. package/dist/io.d.ts +62 -1
  30. package/dist/io.js +245 -1
  31. package/dist/links.d.ts +27 -0
  32. package/dist/links.js +106 -1
  33. package/dist/msg.d.ts +3 -2
  34. package/dist/msg.js +172 -18
  35. package/dist/objects.d.ts +8 -5
  36. package/dist/objects.js +90 -20
  37. package/dist/odf.d.ts +87 -1
  38. package/dist/odf.js +204 -76
  39. package/dist/offcrypto.d.ts +24 -1
  40. package/dist/offcrypto.js +308 -69
  41. package/dist/ole.d.ts +19 -2
  42. package/dist/ole.js +98 -58
  43. package/dist/oleobj.d.ts +11 -0
  44. package/dist/oleobj.js +20 -3
  45. package/dist/ooxml.d.ts +108 -2
  46. package/dist/ooxml.js +392 -109
  47. package/dist/package.d.ts +14 -0
  48. package/dist/package.js +23 -7
  49. package/dist/plan.d.ts +104 -0
  50. package/dist/plan.js +73 -0
  51. package/dist/plugins.d.ts +8 -0
  52. package/dist/plugins.js +35 -0
  53. package/dist/ppt.js +45 -5
  54. package/dist/rewrite-cfb.d.ts +14 -0
  55. package/dist/rewrite-cfb.js +52 -0
  56. package/dist/rewrite-rtf.d.ts +9 -0
  57. package/dist/rewrite-rtf.js +45 -0
  58. package/dist/rewrite-xml.d.ts +36 -0
  59. package/dist/rewrite-xml.js +341 -0
  60. package/dist/rewrite-zip.d.ts +8 -0
  61. package/dist/rewrite-zip.js +498 -0
  62. package/dist/rtf.js +216 -42
  63. package/dist/rtfcomp.d.ts +7 -0
  64. package/dist/rtfcomp.js +80 -0
  65. package/dist/score.js +1 -1
  66. package/dist/sniff.d.ts +5 -0
  67. package/dist/sniff.js +38 -0
  68. package/dist/types.d.ts +66 -6
  69. package/dist/types.js +14 -0
  70. package/dist/vsd.d.ts +13 -0
  71. package/dist/vsd.js +689 -0
  72. package/dist/word.d.ts +4 -4
  73. package/dist/word.js +70 -23
  74. package/dist/xls.d.ts +10 -4
  75. package/dist/xls.js +58 -10
  76. package/dist/xml.d.ts +30 -0
  77. package/dist/xml.js +45 -23
  78. package/dist/zip-writer.d.ts +4 -0
  79. package/dist/zip-writer.js +20 -7
  80. package/dist/zip.d.ts +11 -2
  81. package/dist/zip.js +67 -6
  82. package/package.json +4 -1
@@ -0,0 +1,46 @@
1
+ import type { RunContext, RunState } from './context';
2
+ import { SpillSink } from './io';
3
+ import { type DefuseFinding, type OfficeOptions, type PluginContext } from './types';
4
+ /** The key under which office-defuse hands its run's options to a plugin. No other package can name it. */
5
+ export declare const RUN_OPTIONS: unique symbol;
6
+ /** A plugin context as office-defuse makes it. */
7
+ export type RunPluginContext = PluginContext & {
8
+ [RUN_OPTIONS]?: OfficeOptions;
9
+ };
10
+ /** What the plugins decided about a file inside the file. Undefined from `decideContained` means no plugin took it. */
11
+ export interface ContainedDecision {
12
+ keep: boolean;
13
+ /** The bytes a plugin kept in place of the file's own. */
14
+ replacement?: SpillSink;
15
+ }
16
+ /** A file found inside the file, as the format rules hand it over. */
17
+ export interface InnerFile {
18
+ name?: string;
19
+ /** The MIME type the container gives the file, such as a part's content type. */
20
+ declaredType?: string;
21
+ location: string;
22
+ /** The file's bytes, in order. */
23
+ chunks(): AsyncIterable<Uint8Array>;
24
+ }
25
+ /**
26
+ * Adds the findings a plugin reported from inside a file, labeled with the file's name and place. A reject inside the
27
+ * file means the file goes, not the one that holds it, so it is recorded here as strip.
28
+ */
29
+ export declare function pushContained(ctx: RunContext, found: DefuseFinding[] | undefined, location: string, name: string | undefined): void;
30
+ /**
31
+ * Shows a file found inside the file to the plugins, as pdf-defuse shows an attached file. The first plugin that
32
+ * accepts it decides: 'passed' and 'scrubbed' keep it, with the bytes the plugin wrote when it wrote any, and
33
+ * 'removed', a failure or a scrub that wrote nothing remove it. Its findings come into this run's report. When no
34
+ * plugin accepts the file, this returns undefined and the rules the file gets without plugins decide.
35
+ *
36
+ * In the run that checks an output, the plugins are not asked again. A file whose MD5 matches what a plugin kept in
37
+ * the upload's run is taken as kept, with the findings the plugin reported for its output.
38
+ */
39
+ export declare function decideContained(ctx: RunContext, inner: InnerFile): Promise<ContainedDecision | undefined>;
40
+ /**
41
+ * The rule a file gets for being there, such as EMBEDDED/OLE_OBJECT, applies when no plugin took the file. A plugin
42
+ * that kept the file turns those findings to info, since the content stays.
43
+ */
44
+ export declare function presence(decision: ContainedDecision | undefined): 'info' | undefined;
45
+ /** Bytes in a spill sink the run holds until it ends, as the rewrite takes a plugin's output. */
46
+ export declare function spillOf(run: RunState, bytes: Uint8Array): Promise<SpillSink>;
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RUN_OPTIONS = void 0;
4
+ exports.pushContained = pushContained;
5
+ exports.decideContained = decideContained;
6
+ exports.presence = presence;
7
+ exports.spillOf = spillOf;
8
+ const errors_1 = require("./errors");
9
+ const io_1 = require("./io");
10
+ const sniff_1 = require("./sniff");
11
+ const types_1 = require("./types");
12
+ /** The key under which office-defuse hands its run's options to a plugin. No other package can name it. */
13
+ exports.RUN_OPTIONS = Symbol('office-defuse run options');
14
+ /**
15
+ * Throws on an error a plugin threw that stops the whole tree. An I/O error fails this run as one of its own would,
16
+ * so its cause goes up.
17
+ */
18
+ function passOn(run, e) {
19
+ const code = (0, errors_1.stopsTree)(e);
20
+ if (code === 'DEFUSE_IO') {
21
+ const cause = e.cause ?? e;
22
+ run.temp.ioError ??= cause;
23
+ throw cause;
24
+ }
25
+ if (code === 'DEFUSE_LIMIT')
26
+ throw e;
27
+ }
28
+ /**
29
+ * Adds the findings a plugin reported from inside a file, labeled with the file's name and place. A reject inside the
30
+ * file means the file goes, not the one that holds it, so it is recorded here as strip.
31
+ */
32
+ function pushContained(ctx, found, location, name) {
33
+ const label = name || 'unnamed file';
34
+ ctx.push((found ?? []).map(f => ({
35
+ ...f,
36
+ action: f.action === 'reject' ? 'strip' : f.action,
37
+ location: f.location ? `${location} > ${f.location}` : location,
38
+ data: f.data && { ...f.data },
39
+ attachment: f.attachment ? `${label} > ${f.attachment}` : label,
40
+ })));
41
+ }
42
+ /**
43
+ * Shows a file found inside the file to the plugins, as pdf-defuse shows an attached file. The first plugin that
44
+ * accepts it decides: 'passed' and 'scrubbed' keep it, with the bytes the plugin wrote when it wrote any, and
45
+ * 'removed', a failure or a scrub that wrote nothing remove it. Its findings come into this run's report. When no
46
+ * plugin accepts the file, this returns undefined and the rules the file gets without plugins decide.
47
+ *
48
+ * In the run that checks an output, the plugins are not asked again. A file whose MD5 matches what a plugin kept in
49
+ * the upload's run is taken as kept, with the findings the plugin reported for its output.
50
+ */
51
+ async function decideContained(ctx, inner) {
52
+ const run = ctx.run;
53
+ const plugins = run.options.filePlugins ?? [];
54
+ if (!plugins.length && !run.verify?.size)
55
+ return undefined;
56
+ const decoded = new io_1.SpillSink(run.temp, run.threshold);
57
+ try {
58
+ for await (const c of inner.chunks())
59
+ await decoded.write(c);
60
+ await decoded.close();
61
+ const hash = decoded.digest();
62
+ const name = inner.name;
63
+ const expected = run.verify?.get(hash);
64
+ if (expected) {
65
+ ctx.report(types_1.OfficeCategory.Embedded, expected.result === 'passed' ? types_1.OfficeDetail.PluginPassed : types_1.OfficeDetail.PluginScrubbed, inner.location, { plugin: expected.plugin, name: name ?? '' });
66
+ pushContained(ctx, expected.findings, inner.location, name);
67
+ return { keep: true };
68
+ }
69
+ if (!plugins.length)
70
+ return undefined;
71
+ const source = decoded.source();
72
+ try {
73
+ const head = await source.read(0, Math.min(1024, decoded.length));
74
+ const file = { name, declaredType: inner.declaredType, sniffedType: (0, sniff_1.sniffType)(head), size: decoded.length, location: inner.location, depth: run.depth + 1, source };
75
+ const context = { depth: file.depth, deadline: run.deadline, [exports.RUN_OPTIONS]: run.options };
76
+ for (const plugin of plugins) {
77
+ try {
78
+ if (!(await plugin.accepts(file)))
79
+ continue;
80
+ }
81
+ catch (e) {
82
+ passOn(run, e);
83
+ continue;
84
+ }
85
+ // Copies a plugin keeps wait in memory until the rewrite, so all of them together stay under one threshold.
86
+ const out = new io_1.SpillSink(run.temp, Math.max(0, run.threshold - run.retained));
87
+ const data = { plugin: plugin.name, name: name ?? '' };
88
+ try {
89
+ const answer = await plugin.process(file, out, context);
90
+ const r = typeof answer === 'string' ? { result: answer } : (answer ?? {});
91
+ await out.close();
92
+ let keep = false;
93
+ if (r.result === 'passed') {
94
+ ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginPassed, inner.location, data);
95
+ // Bytes the plugin wrote are the ones kept, such as a document rewritten with nothing removed. A container
96
+ // the rewrite cannot change keeps the file as it was, which the plugin passed too.
97
+ const passed = { result: 'passed', plugin: plugin.name, findings: r.outputFindings };
98
+ run.kept.set(hash, passed);
99
+ if (out.length > 0)
100
+ run.kept.set(out.digest(), passed);
101
+ keep = true;
102
+ }
103
+ else if (r.result === 'scrubbed' && out.length > 0) {
104
+ ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginScrubbed, inner.location, data);
105
+ run.kept.set(out.digest(), { result: 'scrubbed', plugin: plugin.name, findings: r.outputFindings });
106
+ keep = true;
107
+ }
108
+ else
109
+ ctx.removal(types_1.OfficeCategory.Embedded, r.result === 'removed' ? types_1.OfficeDetail.PluginRemoved : types_1.OfficeDetail.PluginFailed, inner.location, data);
110
+ pushContained(ctx, r.findings, inner.location, name);
111
+ const replacement = keep && out.length > 0 ? out : undefined;
112
+ if (replacement) {
113
+ if (!replacement.spilled)
114
+ run.retained += replacement.length;
115
+ run.held.push(replacement);
116
+ }
117
+ else
118
+ await out.dispose();
119
+ return { keep, replacement };
120
+ }
121
+ catch (e) {
122
+ await out.dispose();
123
+ passOn(run, e);
124
+ ctx.removal(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginFailed, inner.location, data);
125
+ return { keep: false };
126
+ }
127
+ }
128
+ return undefined;
129
+ }
130
+ finally {
131
+ await source.close?.();
132
+ }
133
+ }
134
+ finally {
135
+ await decoded.dispose();
136
+ }
137
+ }
138
+ /**
139
+ * The rule a file gets for being there, such as EMBEDDED/OLE_OBJECT, applies when no plugin took the file. A plugin
140
+ * that kept the file turns those findings to info, since the content stays.
141
+ */
142
+ function presence(decision) {
143
+ return decision?.keep ? 'info' : undefined;
144
+ }
145
+ /** Bytes in a spill sink the run holds until it ends, as the rewrite takes a plugin's output. */
146
+ async function spillOf(run, bytes) {
147
+ const sink = new io_1.SpillSink(run.temp, run.threshold);
148
+ run.held.push(sink);
149
+ await sink.write(bytes);
150
+ await sink.close();
151
+ return sink;
152
+ }
package/dist/context.d.ts CHANGED
@@ -1,6 +1,9 @@
1
+ import type { CompoundFile } from './cfb';
1
2
  import type { FindingFactory } from './findings';
2
3
  import type { Format } from './formats';
3
- import { OfficeCategory as C, OfficeDetail as D, type DefuseFinding } from './types';
4
+ import type { SpillSink, TempDir } from './io';
5
+ import { type CompoundPlan, type FlatOpcLayout, PackagePlan, type RtfCut } from './plan';
6
+ import { type ByteSource, OfficeCategory as C, OfficeDetail as D, type DefuseFinding, type OfficeFinding, type OfficeFindingAction, type OfficeOptions } from './types';
4
7
  import { type EntityBudget, type XmlHandler, type XmlLimits } from './xml';
5
8
  import type { ZipArchive, ZipEntry } from './zip';
6
9
  /** Cuts text from the file to a length that fits a finding's data. A reference to an external entity shows as `&name;`. */
@@ -13,8 +16,72 @@ export interface RunSettings {
13
16
  /** Group nesting in an RTF file, and destinations open at once. */
14
17
  rtfDepth: number;
15
18
  deadline?: number;
16
- /** Tried first on an encrypted file, before the password the format opens with by default. */
17
- password?: string;
19
+ /** Tried in order on an encrypted file, before the password the format opens with by default. */
20
+ passwords: readonly string[];
21
+ }
22
+ /** What a plugin returned for a file, which the output check expects to find again by the MD5 of its bytes. */
23
+ export interface Expected {
24
+ result: 'passed' | 'scrubbed';
25
+ plugin: string;
26
+ findings?: DefuseFinding[];
27
+ }
28
+ /** What the rewrite starts from: the container the run read, and what its findings remove. */
29
+ export type Output = {
30
+ kind: 'zip';
31
+ zip: ZipArchive;
32
+ plan: PackagePlan;
33
+ format: Format;
34
+ } | {
35
+ kind: 'cfb';
36
+ cfb: CompoundFile;
37
+ plan: CompoundPlan;
38
+ } | {
39
+ kind: 'rtf';
40
+ size: number;
41
+ cuts: RtfCut[];
42
+ } | {
43
+ kind: 'flatodf';
44
+ plan: PackagePlan;
45
+ }
46
+ /** A Flat OPC package, unpacked into `zip`, whose OOXML format is `format`. */
47
+ | {
48
+ kind: 'flatopc';
49
+ zip: ZipArchive;
50
+ plan: PackagePlan;
51
+ format: Format;
52
+ layout: FlatOpcLayout;
53
+ };
54
+ /** What one run of the engine holds besides its findings: the caller's options, temporary files and plugin results. */
55
+ export interface RunState {
56
+ options: OfficeOptions;
57
+ /** The depth of the file in the upload: 0 for the upload itself. */
58
+ depth: number;
59
+ deadline?: number;
60
+ temp: TempDir;
61
+ /** memoryThreshold, in bytes. */
62
+ threshold: number;
63
+ /** Bytes of plugin output held in memory until the rewrite. */
64
+ retained: number;
65
+ /** Set when the run checks an output: what the plugins returned for the upload's files. */
66
+ verify?: Map<string, Expected>;
67
+ /** What the plugins returned in this run, by the MD5 of the bytes kept. */
68
+ kept: Map<string, Expected>;
69
+ /** Plugin output that waits for the rewrite. The run disposes of it when it ends. */
70
+ held: SpillSink[];
71
+ /**
72
+ * Findings whose content a plugin kept, such as EMBEDDED/PACKAGE for a package a plugin passed. They stay in the
73
+ * report as info and leave the score to what the plugin found, as pdf-defuse leaves out a kept attached file.
74
+ */
75
+ superseded: WeakSet<DefuseFinding>;
76
+ /** Sources the rewrite reads from, such as a compound file a plugin returned, which the run closes when it ends. */
77
+ sources: ByteSource[];
78
+ /** The container, once the format rules have read it. */
79
+ output?: Output;
80
+ }
81
+ /** A finding the rules made, with its action, which the rewrite reads. Past 200 of one kind, `finding` is absent. */
82
+ export interface Reported {
83
+ action: OfficeFindingAction;
84
+ finding?: OfficeFinding;
18
85
  }
19
86
  /** What every context of one inspection shares, so a package inside the file reports into the same list. */
20
87
  interface Shared {
@@ -24,6 +91,9 @@ interface Shared {
24
91
  /** Entity expansion in every part, which the entity limits bound. */
25
92
  budget: EntityBudget;
26
93
  format?: Format;
94
+ run: RunState;
95
+ /** Strip findings made so far, counted past the 200 kept of each kind too. */
96
+ strips: number;
27
97
  }
28
98
  /** What one inspection holds while the format rules run. */
29
99
  export declare class RunContext {
@@ -31,33 +101,58 @@ export declare class RunContext {
31
101
  readonly xmlLimits: XmlLimits;
32
102
  readonly settings: RunSettings;
33
103
  protected readonly shared: Shared;
34
- constructor(factory: FindingFactory, xmlLimits: XmlLimits, settings: RunSettings, shared?: Shared);
104
+ constructor(factory: FindingFactory, xmlLimits: XmlLimits, settings: RunSettings, shared: Shared | RunState);
35
105
  get findings(): DefuseFinding[];
106
+ get run(): RunState;
107
+ /** How many strip findings the run has made, so a rule can tell whether anything it read made one. */
108
+ get strips(): number;
36
109
  /** Set as soon as the rules know it, so a run that stops later still names the format. */
37
110
  get format(): Format | undefined;
38
111
  set format(f: Format | undefined);
39
- /** A context for a ZIP archive the rules read, reporting into the same findings. */
112
+ /** A context for a ZIP archive the rules read, reporting into the same findings, with a plan of its own. */
40
113
  withZip(zip: ZipArchive): ZipContext;
41
- /** Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. */
42
- report(category: C, detail: D, location?: string, data?: Record<string, string | number>): void;
114
+ /** A context for rules that fill in a plan of their own without an archive, as for a flat XML file. */
115
+ withPlan(): PlanContext;
116
+ /**
117
+ * Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. `action` replaces the
118
+ * action the overrides give, for content whose fate a plugin decided.
119
+ */
120
+ report(category: C, detail: D, location?: string, data?: Record<string, string | number>, action?: OfficeFindingAction): Reported;
121
+ /** Reports content that goes whatever the overrides say: its action is strip, or reject when the caller asked for that. */
122
+ removal(category: C, detail: D, location?: string, data?: Record<string, string | number>): Reported;
123
+ /** Adds findings from another package as they are, such as those a plugin made inside a file. */
124
+ push(findings: DefuseFinding[]): void;
43
125
  /** Reports a link, classified by pdf-defuse's checks of its target. */
44
- link(target: string, location: string, data?: Record<string, string>): void;
126
+ link(target: string, location: string, data?: Record<string, string>): Reported;
127
+ /**
128
+ * Reports a safe link whose text or tooltip names another site than the one it opens, as LINK/TEXT_MISMATCH. Returns
129
+ * undefined when no label names a site, or every site a label names is the target's.
130
+ */
131
+ labelMismatch(target: string, labels: ReadonlyArray<string | undefined>, location: string, data?: Record<string, string>): Reported | undefined;
45
132
  /**
46
133
  * Reports a reference that loads from outside the file on its own. It is EXTERNAL/LOCAL_FILE, with `kind` saying
47
134
  * what refers, when the target is on this machine, and `detail` otherwise.
48
135
  */
49
- external(detail: D, target: string, location: string, data: Record<string, string>, kind: string): void;
136
+ external(detail: D, target: string, location: string, data: Record<string, string>, kind: string): Reported;
50
137
  /**
51
138
  * Tokenizes XML text, or Office's VML dialect, handing the events to `handler`. A document type declaration is
52
139
  * XML/DOCTYPE without an internal subset and XML/INTERNAL_SUBSET with one. A reference to an external entity in
53
140
  * content is XML/EXTERNAL_ENTITY.
54
141
  */
55
- parseXml(part: string, chunks: AsyncIterable<Uint8Array> | Iterable<Uint8Array>, handler: XmlHandler, vml?: boolean): Promise<void>;
142
+ parseXml(part: string, chunks: AsyncIterable<Uint8Array> | Iterable<Uint8Array>, handler: XmlHandler, vml?: boolean, plan?: PackagePlan, after?: () => Promise<void>): Promise<void>;
143
+ }
144
+ /** The context of rules that fill in a rewrite plan: a ZIP package's, or a flat XML file's. */
145
+ export declare class PlanContext extends RunContext {
146
+ /** What the rewrite removes, which the rules fill in as they report. */
147
+ readonly plan: PackagePlan;
148
+ constructor(
149
+ /** What the rewrite removes, which the rules fill in as they report. */
150
+ plan: PackagePlan, factory: FindingFactory, xmlLimits: XmlLimits, settings: RunSettings, shared: Shared);
56
151
  }
57
152
  /** The context of the rules that read a ZIP archive. */
58
- export declare class ZipContext extends RunContext {
153
+ export declare class ZipContext extends PlanContext {
59
154
  readonly zip: ZipArchive;
60
- constructor(zip: ZipArchive, factory: FindingFactory, xmlLimits: XmlLimits, settings: RunSettings, shared: Shared);
155
+ constructor(zip: ZipArchive, plan: PackagePlan, factory: FindingFactory, xmlLimits: XmlLimits, settings: RunSettings, shared: Shared);
61
156
  /** Inflates an entry and tokenizes it as XML, or as Office's VML dialect, handing the events to `handler`. */
62
157
  parse(entry: ZipEntry, handler: XmlHandler, vml?: boolean): Promise<void>;
63
158
  /** Inflates an entry, which checks it, hands each chunk to `visit`, and tells whether its bytes are an OLE file. */
package/dist/context.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ZipContext = exports.RunContext = exports.clip = void 0;
3
+ exports.ZipContext = exports.PlanContext = exports.RunContext = exports.clip = void 0;
4
4
  const links_1 = require("./links");
5
+ const plan_1 = require("./plan");
5
6
  const types_1 = require("./types");
6
7
  const xml_1 = require("./xml");
7
8
  const OLE_SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
@@ -21,11 +22,18 @@ class RunContext {
21
22
  this.factory = factory;
22
23
  this.xmlLimits = xmlLimits;
23
24
  this.settings = settings;
24
- this.shared = shared ?? { findings: [], perKind: new Map(), overflow: new Map(), budget: { expansions: 0, size: 0 } };
25
+ this.shared = 'run' in shared ? shared : { findings: [], perKind: new Map(), overflow: new Map(), budget: { expansions: 0, size: 0 }, run: shared, strips: 0 };
25
26
  }
26
27
  get findings() {
27
28
  return this.shared.findings;
28
29
  }
30
+ get run() {
31
+ return this.shared.run;
32
+ }
33
+ /** How many strip findings the run has made, so a rule can tell whether anything it read made one. */
34
+ get strips() {
35
+ return this.shared.strips;
36
+ }
29
37
  /** Set as soon as the rules know it, so a run that stops later still names the format. */
30
38
  get format() {
31
39
  return this.shared.format;
@@ -33,32 +41,71 @@ class RunContext {
33
41
  set format(f) {
34
42
  this.shared.format = f;
35
43
  }
36
- /** A context for a ZIP archive the rules read, reporting into the same findings. */
44
+ /** A context for a ZIP archive the rules read, reporting into the same findings, with a plan of its own. */
37
45
  withZip(zip) {
38
- return new ZipContext(zip, this.factory, this.xmlLimits, this.settings, this.shared);
46
+ return new ZipContext(zip, new plan_1.PackagePlan(), this.factory, this.xmlLimits, this.settings, this.shared);
47
+ }
48
+ /** A context for rules that fill in a plan of their own without an archive, as for a flat XML file. */
49
+ withPlan() {
50
+ return new PlanContext(new plan_1.PackagePlan(), this.factory, this.xmlLimits, this.settings, this.shared);
39
51
  }
40
- /** Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. */
41
- report(category, detail, location, data) {
42
- const k = `${category}/${detail}`;
52
+ /**
53
+ * Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. `action` replaces the
54
+ * action the overrides give, for content whose fate a plugin decided.
55
+ */
56
+ report(category, detail, location, data, action) {
57
+ const act = action ?? this.factory.actionFor(category, detail);
58
+ if (act === 'strip')
59
+ this.shared.strips++;
60
+ const k = `${category}/${detail}/${act}`;
43
61
  const n = (this.shared.perKind.get(k) ?? 0) + 1;
44
62
  this.shared.perKind.set(k, n);
45
63
  if (n <= 200) {
46
- this.shared.findings.push(this.factory.make(category, detail, location, data));
47
- return;
64
+ const finding = this.factory.make(category, detail, location, data);
65
+ finding.action = act;
66
+ if (action === 'info')
67
+ this.run.superseded.add(finding);
68
+ this.shared.findings.push(finding);
69
+ return { action: act, finding };
48
70
  }
49
71
  let counted = this.shared.overflow.get(k);
50
72
  if (!counted) {
51
73
  // The first overflow fills the description's placeholders.
52
74
  counted = { ...data, count: 0 };
53
75
  this.shared.overflow.set(k, counted);
54
- this.shared.findings.push(this.factory.make(category, detail, 'additional occurrences', counted));
76
+ const finding = this.factory.make(category, detail, 'additional occurrences', counted);
77
+ finding.action = act;
78
+ if (action === 'info')
79
+ this.run.superseded.add(finding);
80
+ this.shared.findings.push(finding);
55
81
  }
56
82
  counted.count = Number(counted.count) + 1;
83
+ return { action: act };
84
+ }
85
+ /** Reports content that goes whatever the overrides say: its action is strip, or reject when the caller asked for that. */
86
+ removal(category, detail, location, data) {
87
+ const act = this.factory.actionFor(category, detail);
88
+ return this.report(category, detail, location, data, act === 'info' ? 'strip' : act);
89
+ }
90
+ /** Adds findings from another package as they are, such as those a plugin made inside a file. */
91
+ push(findings) {
92
+ this.shared.findings.push(...findings);
93
+ this.shared.strips += findings.filter(f => f.action === 'strip').length;
57
94
  }
58
95
  /** Reports a link, classified by pdf-defuse's checks of its target. */
59
96
  link(target, location, data = {}) {
60
97
  const v = (0, links_1.checkLink)(target);
61
- this.report(v.category, v.detail, location, { ...data, target: (0, exports.clip)(target, 2000), ...(v.host ? { host: (0, exports.clip)(v.host) } : {}) });
98
+ return this.report(v.category, v.detail, location, { ...data, target: (0, exports.clip)(target, 2000), ...(v.host ? { host: (0, exports.clip)(v.host) } : {}) });
99
+ }
100
+ /**
101
+ * Reports a safe link whose text or tooltip names another site than the one it opens, as LINK/TEXT_MISMATCH. Returns
102
+ * undefined when no label names a site, or every site a label names is the target's.
103
+ */
104
+ labelMismatch(target, labels, location, data = {}) {
105
+ const label = (0, links_1.mismatchedLabel)(target, labels);
106
+ if (label === undefined)
107
+ return undefined;
108
+ return this.report(types_1.OfficeCategory.Link, types_1.OfficeDetail.TextMismatch, location, { ...data, target: (0, exports.clip)(target, 2000), label: (0, exports.clip)(label) });
62
109
  }
63
110
  /**
64
111
  * Reports a reference that loads from outside the file on its own. It is EXTERNAL/LOCAL_FILE, with `kind` saying
@@ -67,45 +114,75 @@ class RunContext {
67
114
  external(detail, target, location, data, kind) {
68
115
  const d = { ...data, target: (0, exports.clip)(target, 2000) };
69
116
  if ((0, links_1.isLocalTarget)(target))
70
- this.report(types_1.OfficeCategory.External, types_1.OfficeDetail.LocalFile, location, { ...d, kind });
71
- else
72
- this.report(types_1.OfficeCategory.External, detail, location, d);
117
+ return this.report(types_1.OfficeCategory.External, types_1.OfficeDetail.LocalFile, location, { ...d, kind });
118
+ return this.report(types_1.OfficeCategory.External, detail, location, d);
73
119
  }
74
120
  /**
75
121
  * Tokenizes XML text, or Office's VML dialect, handing the events to `handler`. A document type declaration is
76
122
  * XML/DOCTYPE without an internal subset and XML/INTERNAL_SUBSET with one. A reference to an external entity in
77
123
  * content is XML/EXTERNAL_ENTITY.
78
124
  */
79
- async parseXml(part, chunks, handler, vml = false) {
125
+ async parseXml(part, chunks, handler, vml = false, plan, after) {
80
126
  const notes = {
81
127
  doctype: d => {
82
128
  const ids = { ...(d.publicId === undefined ? {} : { publicId: (0, exports.clip)(d.publicId) }), ...(d.systemId === undefined ? {} : { systemId: (0, exports.clip)(d.systemId) }) };
83
- if (!d.internalSubset)
84
- this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.Doctype, part, { part, name: (0, exports.clip)(d.name), ...ids });
129
+ if (!d.internalSubset) {
130
+ if (this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.Doctype, part, { part, name: (0, exports.clip)(d.name), ...ids }).action === 'strip' && plan)
131
+ plan.part(part).doctype = true;
132
+ }
85
133
  else {
86
134
  const counts = { entities: d.entities, externalEntities: d.externalEntities, attributeDefaults: d.attributeDefaults, skipped: d.skipped };
87
- this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.InternalSubset, part, { part, name: (0, exports.clip)(d.name), ...ids, ...counts });
135
+ if (this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.InternalSubset, part, { part, name: (0, exports.clip)(d.name), ...ids, ...counts }).action === 'strip' && plan)
136
+ plan.part(part).subset = true;
88
137
  }
89
138
  },
90
- externalEntity: r => this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.ExternalEntity, part, { part, entity: (0, exports.clip)(r.name), target: (0, exports.clip)(r.systemId, 2000), ...(r.publicId === undefined ? {} : { publicId: (0, exports.clip)(r.publicId) }) }),
139
+ externalEntity: r => {
140
+ const reported = this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.ExternalEntity, part, {
141
+ part,
142
+ entity: (0, exports.clip)(r.name),
143
+ target: (0, exports.clip)(r.systemId, 2000),
144
+ ...(r.publicId === undefined ? {} : { publicId: (0, exports.clip)(r.publicId) }),
145
+ });
146
+ if (!plan)
147
+ return;
148
+ const p = plan.part(part);
149
+ p.hasExternal = true;
150
+ if (reported.action === 'strip')
151
+ p.external = true;
152
+ },
91
153
  };
92
154
  const t = new xml_1.XmlTokenizer(handler, this.xmlLimits, part, { vml, notes, budget: this.shared.budget });
93
- for await (const chunk of chunks)
155
+ // `after` runs between chunks, so a handler can hand what it gathered to something that waits.
156
+ for await (const chunk of chunks) {
94
157
  t.write(chunk);
158
+ await after?.();
159
+ }
95
160
  t.end();
161
+ await after?.();
96
162
  }
97
163
  }
98
164
  exports.RunContext = RunContext;
165
+ /** The context of rules that fill in a rewrite plan: a ZIP package's, or a flat XML file's. */
166
+ class PlanContext extends RunContext {
167
+ plan;
168
+ constructor(
169
+ /** What the rewrite removes, which the rules fill in as they report. */
170
+ plan, factory, xmlLimits, settings, shared) {
171
+ super(factory, xmlLimits, settings, shared);
172
+ this.plan = plan;
173
+ }
174
+ }
175
+ exports.PlanContext = PlanContext;
99
176
  /** The context of the rules that read a ZIP archive. */
100
- class ZipContext extends RunContext {
177
+ class ZipContext extends PlanContext {
101
178
  zip;
102
- constructor(zip, factory, xmlLimits, settings, shared) {
103
- super(factory, xmlLimits, settings, shared);
179
+ constructor(zip, plan, factory, xmlLimits, settings, shared) {
180
+ super(plan, factory, xmlLimits, settings, shared);
104
181
  this.zip = zip;
105
182
  }
106
183
  /** Inflates an entry and tokenizes it as XML, or as Office's VML dialect, handing the events to `handler`. */
107
184
  parse(entry, handler, vml = false) {
108
- return this.parseXml(entry.name, this.zip.read(entry), handler, vml);
185
+ return this.parseXml(entry.name, this.zip.read(entry), handler, vml, this.plan);
109
186
  }
110
187
  /** Inflates an entry, which checks it, hands each chunk to `visit`, and tells whether its bytes are an OLE file. */
111
188
  async drain(entry, visit) {
@@ -0,0 +1,13 @@
1
+ import { type ByteSource, type DetectOptions, type FormatDetection } from './types';
2
+ /**
3
+ * The format office-defuse reads `source` as, when it can name it from the content: the format and MIME type an
4
+ * inspection reports, read the way the inspection reads them, without the rules, the plugins or the rewrite. A ZIP
5
+ * archive, compound file or XML file whose format it cannot name, such as a plain archive, an installer or a package
6
+ * of another kind, is not claimed. A file it names but cannot read past that point is claimed, and an inspection then
7
+ * rejects it. So is an encrypted Office Open XML package no password at hand opens, as application/x-ole-storage with
8
+ * no extension, since only the password says which format is inside. The offset is that of the ZIP data in an archive with data in front, and 0 otherwise. The name and the
9
+ * declared type play no part. The source stays open.
10
+ */
11
+ export declare function detectOfficeSource(source: ByteSource, options?: DetectOptions): Promise<FormatDetection | undefined>;
12
+ /** `detectOfficeSource` over bytes in memory. */
13
+ export declare function detectOffice(bytes: Uint8Array, options?: DetectOptions): Promise<FormatDetection | undefined>;
package/dist/detect.js ADDED
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectOfficeSource = detectOfficeSource;
4
+ exports.detectOffice = detectOffice;
5
+ const engine_1 = require("./engine");
6
+ const errors_1 = require("./errors");
7
+ const flat_1 = require("./flat");
8
+ const formats_1 = require("./formats");
9
+ const io_1 = require("./io");
10
+ const ole_1 = require("./ole");
11
+ const package_1 = require("./package");
12
+ const types_1 = require("./types");
13
+ /**
14
+ * The format of a file whose container `openContainer` read, named by the steps the inspection names it by, without
15
+ * the rules: the root of a compound file, and for Word and Excel whether it is a template or an add-in; the markers,
16
+ * [Content_Types].xml and package relationships of a package; the root of a flat file; or RTF. An encrypted package
17
+ * names its format when the passwords the run tries, which without a password is VelvetSweatshop alone, open it.
18
+ */
19
+ async function nameContainer(ctx, container, src, size, limits) {
20
+ if (container.kind === 'rtf')
21
+ return formats_1.RTF_FORMAT;
22
+ if (container.kind === 'xml')
23
+ return (0, flat_1.nameFlat)(ctx, src, size, limits);
24
+ if (container.kind === 'zip')
25
+ return (0, package_1.namePackage)(ctx.withZip(container.zip));
26
+ const cfb = container.cfb;
27
+ const family = (0, ole_1.mainFamily)(cfb);
28
+ const format = await (0, ole_1.compoundFormat)(cfb, family, ctx.settings.deadline);
29
+ if (format)
30
+ return format;
31
+ try {
32
+ return await (0, package_1.namePackage)(ctx.withZip(await (0, ole_1.openEncryptedPackage)(ctx, cfb)));
33
+ }
34
+ catch (e) {
35
+ // An encrypted package that no password at hand opens is still office-defuse's. Only the password says which
36
+ // format is inside, and the inspection decrypts it with the run's password or rejects it as encrypted.
37
+ if (family === 'encrypted' && e instanceof errors_1.StopError && e.category === types_1.OfficeCategory.Encrypted)
38
+ return ENCRYPTED_PACKAGE;
39
+ throw e;
40
+ }
41
+ }
42
+ /** An encrypted Office Open XML package whose format no password at hand reveals: a compound file, with no extension of its own. */
43
+ const ENCRYPTED_PACKAGE = { format: '', mimeType: 'application/x-ole-storage' };
44
+ /**
45
+ * The format office-defuse reads `source` as, when it can name it from the content: the format and MIME type an
46
+ * inspection reports, read the way the inspection reads them, without the rules, the plugins or the rewrite. A ZIP
47
+ * archive, compound file or XML file whose format it cannot name, such as a plain archive, an installer or a package
48
+ * of another kind, is not claimed. A file it names but cannot read past that point is claimed, and an inspection then
49
+ * rejects it. So is an encrypted Office Open XML package no password at hand opens, as application/x-ole-storage with
50
+ * no extension, since only the password says which format is inside. The offset is that of the ZIP data in an archive with data in front, and 0 otherwise. The name and the
51
+ * declared type play no part. The source stays open.
52
+ */
53
+ async function detectOfficeSource(source, options = {}) {
54
+ const run = (0, engine_1.newRun)({ tempDir: options.tempDir, memoryThreshold: options.memoryThreshold }, 0, options.deadline);
55
+ const src = (0, engine_1.guarded)(source);
56
+ try {
57
+ const { limits, settings, ctx } = (0, engine_1.runContext)(run);
58
+ const size = await src.size();
59
+ (0, errors_1.checkTime)(run.deadline);
60
+ const container = await (0, engine_1.openContainer)(src, size, limits, settings);
61
+ const format = await nameContainer(ctx, container, src, size, limits);
62
+ if (run.temp.ioError !== undefined)
63
+ throw run.temp.ioError;
64
+ return { mimeType: format.mimeType, extension: format.format, offset: container.kind === 'zip' ? container.zip.start : 0 };
65
+ }
66
+ catch (e) {
67
+ if (src.failure !== undefined)
68
+ throw src.failure;
69
+ if (run.temp.ioError !== undefined)
70
+ throw run.temp.ioError;
71
+ if ((0, errors_1.stopsTree)(e) !== undefined || (0, errors_1.isIoError)(e))
72
+ throw e;
73
+ // Any other failure, as in an inspection, means office-defuse cannot name the file's format.
74
+ return undefined;
75
+ }
76
+ finally {
77
+ await (0, engine_1.endRun)(run);
78
+ }
79
+ }
80
+ /** `detectOfficeSource` over bytes in memory. */
81
+ function detectOffice(bytes, options = {}) {
82
+ return detectOfficeSource((0, io_1.bufferSource)(bytes), options);
83
+ }