stegdoc 5.5.0 → 5.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stegdoc",
3
- "version": "5.5.0",
3
+ "version": "5.7.0",
4
4
  "description": "Hide files inside Office documents (XLSX/DOCX) with AES-256 encryption and steganography",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -50,7 +50,6 @@
50
50
  "chalk": "^4.1.2",
51
51
  "commander": "^14.0.2",
52
52
  "docx": "^9.5.1",
53
- "exceljs": "^4.4.0",
54
53
  "fast-xml-parser": "^5.3.3",
55
54
  "file-type": "^16.5.4",
56
55
  "inquirer": "^8.2.6",
@@ -5,7 +5,7 @@ const chalk = require('chalk');
5
5
  const ora = require('ora');
6
6
  const AdmZip = require('adm-zip');
7
7
  const { createDocxWithBase64, createDocxV5 } = require('../lib/docx-handler');
8
- const { createXlsxPartStreaming, createXlsxPartV5 } = require('../lib/xlsx-handler');
8
+ const { createXlsxPartV5 } = require('../lib/xlsx-handler');
9
9
  const { createMetadata, serializeMetadata } = require('../lib/metadata');
10
10
  const crypto = require('crypto');
11
11
  const { generateHash, parseSizeToBytes, formatBytes, generateFilename } = require('../lib/utils');
@@ -143,7 +143,7 @@ async function encodeCommand(inputFile, options) {
143
143
  if (format === 'docx') {
144
144
  await encodeLegacyDocx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
145
145
  } else {
146
- await encodeLegacyXlsx(streamSource, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles);
146
+ throw new Error('Legacy XLSX format (--legacy) is no longer supported. Use v5 format (default) or legacy DOCX (-f docx --legacy).');
147
147
  }
148
148
  if (tempZipPath) cleanupTemp(tempZipPath);
149
149
  return;
@@ -356,168 +356,6 @@ async function encodeCommand(inputFile, options) {
356
356
  }
357
357
  }
358
358
 
359
- // ─── Legacy v4 XLSX Pipeline ────────────────────────────────────────────────
360
-
361
- async function encodeLegacyXlsx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
362
- const hash = generateHash();
363
- const outputDir = options.outputDir || process.cwd();
364
- const format = 'xlsx';
365
- const chunkInput = (options.chunkSize || '').toString().trim();
366
-
367
- // Parse chunk size
368
- const chunkInputLower = chunkInput.toLowerCase();
369
- let chunkSizeBytes;
370
-
371
- if (chunkInputLower === '0' || chunkInputLower === 'max' || chunkInputLower === 'single' || chunkInputLower === 'none' || chunkInputLower === '') {
372
- chunkSizeBytes = Infinity;
373
- } else if (/^\d+\s*parts?$/i.test(chunkInput)) {
374
- const numParts = parseInt(chunkInput, 10);
375
- if (numParts < 1) throw new Error('Number of parts must be at least 1');
376
- const estimatedBase64Size = Math.ceil(fileSize * 4 / 3);
377
- chunkSizeBytes = Math.ceil(estimatedBase64Size / numParts);
378
- spinner.info && spinner.info(`Splitting into ~${numParts} parts (~${formatBytes(chunkSizeBytes)} content each)`);
379
- } else if (chunkInput) {
380
- chunkSizeBytes = parseSizeToBytes(chunkInput);
381
- } else {
382
- chunkSizeBytes = 5 * 1024 * 1024;
383
- }
384
-
385
- spinner.text = 'Computing file hash...';
386
- const contentHash = await computeFileHash(inputPath);
387
-
388
- const sessionSalt = useEncryption ? generateSalt() : null;
389
-
390
- spinner.text = useCompression ? 'Compressing and encoding (legacy v4)...' : 'Encoding (legacy v4)...';
391
-
392
- const partFiles = [];
393
-
394
- if (useEncryption) {
395
- const binaryChunkSize = chunkSizeBytes === Infinity ? Infinity : Math.floor(chunkSizeBytes * 3 / 4);
396
-
397
- const onBinaryChunkReady = async (binaryBuffer, index) => {
398
- const partNumber = index + 1;
399
- const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
400
-
401
- const { stream: cipher, iv, salt, getAuthTag } = createEncryptStream(options.password, sessionSalt);
402
- const encrypted = Buffer.concat([cipher.update(binaryBuffer), cipher.final()]);
403
- const authTag = getAuthTag();
404
- const base64Chunk = encrypted.toString('base64');
405
-
406
- const metadata = createMetadata({
407
- originalFilename: filename,
408
- originalExtension: extension,
409
- hash,
410
- partNumber,
411
- totalParts: null,
412
- originalSize: fileSize,
413
- format,
414
- encrypted: true,
415
- compressed: useCompression,
416
- contentHash,
417
- });
418
-
419
- const encryptionMeta = packEncryptionMeta({ iv, salt, authTag });
420
- const outputFilename = generateFilename(hash, partNumber, null, format);
421
- const outputPath = path.join(outputDir, outputFilename);
422
-
423
- if (fs.existsSync(outputPath) && !options.force) {
424
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
425
- }
426
-
427
- await createXlsxPartStreaming({
428
- base64Content: base64Chunk,
429
- encryptionMeta,
430
- metadataJson: serializeMetadata(metadata),
431
- outputPath,
432
- });
433
-
434
- partFiles.push(outputPath);
435
- createdFiles.push(outputPath);
436
- partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
437
- };
438
-
439
- const collector = new BinaryChunkCollector(binaryChunkSize, onBinaryChunkReady);
440
- const streams = [fs.createReadStream(inputPath)];
441
- if (!quiet) {
442
- streams.push(new ProgressTransform(fileSize, (processed, total) => {
443
- const pct = Math.min(100, Math.round((processed / total) * 100));
444
- spinner.text = `Compressing (legacy v4)... ${formatBytes(processed)} / ${formatBytes(total)} (${pct}%)`;
445
- }));
446
- }
447
- if (useCompression) streams.push(createCompressStream());
448
- streams.push(collector);
449
- await pipeline(...streams);
450
- } else {
451
- const onChunkReady = async (base64Chunk, index) => {
452
- const partNumber = index + 1;
453
- const partSpinner = quiet ? spinner : ora(`Creating part ${partNumber}...`).start();
454
-
455
- const metadata = createMetadata({
456
- originalFilename: filename,
457
- originalExtension: extension,
458
- hash,
459
- partNumber,
460
- totalParts: null,
461
- originalSize: fileSize,
462
- format,
463
- encrypted: false,
464
- compressed: useCompression,
465
- contentHash,
466
- });
467
-
468
- const outputFilename = generateFilename(hash, partNumber, null, format);
469
- const outputPath = path.join(outputDir, outputFilename);
470
-
471
- if (fs.existsSync(outputPath) && !options.force) {
472
- throw new Error(`File already exists: ${outputPath}. Use --force to overwrite.`);
473
- }
474
-
475
- await createXlsxPartStreaming({
476
- base64Content: base64Chunk,
477
- encryptionMeta: '',
478
- metadataJson: serializeMetadata(metadata),
479
- outputPath,
480
- });
481
-
482
- partFiles.push(outputPath);
483
- createdFiles.push(outputPath);
484
- partSpinner.succeed && partSpinner.succeed(`Created: ${outputFilename} (${formatBytes(base64Chunk.length)} encoded)`);
485
- };
486
-
487
- const collector = new ChunkCollector(chunkSizeBytes, onChunkReady);
488
- const streams = [fs.createReadStream(inputPath)];
489
- if (!quiet) {
490
- streams.push(new ProgressTransform(fileSize, (processed, total) => {
491
- const pct = Math.min(100, Math.round((processed / total) * 100));
492
- spinner.text = `Encoding (legacy v4)... ${formatBytes(processed)} / ${formatBytes(total)} (${pct}%)`;
493
- }));
494
- }
495
- if (useCompression) streams.push(createCompressStream());
496
- streams.push(new Base64EncodeTransform());
497
- streams.push(collector);
498
- await pipeline(...streams);
499
- }
500
-
501
- const totalParts = partFiles.length;
502
- spinner.succeed && spinner.succeed('Encoding complete!');
503
-
504
- if (!quiet) {
505
- console.log();
506
- console.log(chalk.green.bold('✓ File encoded successfully!'));
507
- console.log(chalk.cyan(` Format: XLSX (v4 legacy)`));
508
- console.log(chalk.cyan(` Hash: ${hash}`));
509
- if (totalParts > 1) {
510
- console.log(chalk.cyan(` Parts: ${totalParts}`));
511
- }
512
- console.log(chalk.cyan(` Encrypted: ${useEncryption ? 'Yes' : 'No'}`));
513
- console.log(chalk.cyan(` Compressed: ${useCompression ? 'Yes (gzip)' : 'No'}`));
514
- console.log(chalk.cyan(` Location: ${outputDir}`));
515
- if (useEncryption) {
516
- console.log(chalk.yellow(` Remember your password - it cannot be recovered!`));
517
- }
518
- }
519
- }
520
-
521
359
  // ─── Legacy v4 DOCX Pipeline ────────────────────────────────────────────────
522
360
 
523
361
  async function encodeLegacyDocx(inputPath, filename, extension, fileSize, options, useCompression, useEncryption, spinner, quiet, createdFiles) {
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ const verifyCommand = require('./commands/verify');
11
11
  program
12
12
  .name('stegdoc')
13
13
  .description('CLI tool to encode files into Office documents with AES-256 encryption')
14
- .version('5.5.0');
14
+ .version('5.6.0');
15
15
 
16
16
  // Encode command
17
17
  program
@@ -46,23 +46,22 @@ const IP_POOL_RARE = [
46
46
  // ─── User-Agent Pool ────────────────────────────────────────────────────────
47
47
 
48
48
  const USER_AGENTS = [
49
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
50
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
51
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
52
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
53
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15',
54
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
55
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/122.0.0.0 Safari/537.36',
56
- 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0',
57
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1',
58
- 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
59
- // Bots (occasional)
60
- 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
61
- 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)',
49
+ 'node-fetch/3.3.2',
50
+ 'axios/1.7.2',
51
+ 'python-httpx/0.27.0',
52
+ 'Go-http-client/2.0',
53
+ 'curl/8.5.0',
54
+ 'python-requests/2.31.0',
55
+ 'okhttp/4.12.0',
56
+ 'got/14.2.1',
57
+ 'undici/6.13.0',
58
+ 'httpie/3.2.2',
59
+ 'wget/1.21.4',
60
+ 'PostmanRuntime/7.36.3',
62
61
  ];
63
62
 
64
- // Weights for UA selection (frequent browsers appear more)
65
- const UA_WEIGHTS = [20, 15, 12, 10, 8, 7, 6, 5, 4, 3, 1, 1];
63
+ // Weights for UA selection (common API clients appear more)
64
+ const UA_WEIGHTS = [20, 18, 15, 12, 10, 8, 6, 4, 3, 2, 1, 1];
66
65
 
67
66
  // ─── URL Path Templates (for data lines) ───────────────────────────────────
68
67
 
@@ -620,8 +619,8 @@ function encodePayloadToLogLines(payloadBuffer, metadataJson, encryptionMeta) {
620
619
  dataRows.push(generateDataLogLine(chunk));
621
620
  }
622
621
 
623
- // Generate filler lines for realism
624
- const fillerCount = Math.max(30, Math.floor(dataLineCount * 0.2));
622
+ // Generate filler lines for realism (3% — enough for variation, minimal overhead)
623
+ const fillerCount = Math.max(10, Math.floor(dataLineCount * 0.03));
625
624
  const fillerRows = [];
626
625
  for (let i = 0; i < fillerCount; i++) {
627
626
  fillerRows.push(generateFillerLogLine());
@@ -1,16 +1,11 @@
1
- const ExcelJS = require('exceljs');
2
1
  const fs = require('fs');
3
2
  const path = require('path');
4
3
  const AdmZip = require('adm-zip');
5
- const { generateDecoyHeaders, generateDecoyData, calculateDecoyRowCount, rowToArray, resetTimeWindow } = require('./decoy-generator');
6
4
  const { generateLogHeaders, encodePayloadToLogLines, decodeLogLines, resetTimeState } = require('./log-generator');
7
5
  const { parseXmlFromZip, ensureArray, extractTextContent } = require('./xml-utils');
6
+ const { createXlsxRaw } = require('./xlsx-writer');
8
7
 
9
- // Constants for data storage
10
- const HIDDEN_SHEET_NAME = 'Data';
11
- const VISIBLE_SHEET_NAME = 'Server Metrics';
12
8
  const V5_SHEET_NAME = 'Access Logs';
13
- const CELL_CHUNK_SIZE = 32000; // Max characters per cell (~32KB)
14
9
 
15
10
  // ─── v5 Log-Embed Format ───────────────────────────────────────────────────
16
11
 
@@ -29,88 +24,23 @@ const CELL_CHUNK_SIZE = 32000; // Max characters per cell (~32KB)
29
24
  async function createXlsxPartV5(options) {
30
25
  const { payloadBuffer, encryptionMeta, metadataJson, outputPath } = options;
31
26
 
32
- // Ensure output directory exists
33
- const outputDir = path.dirname(outputPath);
34
- if (!fs.existsSync(outputDir)) {
35
- fs.mkdirSync(outputDir, { recursive: true });
36
- }
37
-
38
- const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
39
- filename: outputPath,
40
- useSharedStrings: false,
41
- });
42
-
43
- workbook.creator = 'Microsoft Excel';
44
- workbook.lastModifiedBy = 'Microsoft Excel';
45
- workbook.created = new Date();
46
- workbook.modified = new Date();
47
-
48
- // === Single Sheet: Access Logs ===
49
- const sheet = workbook.addWorksheet(V5_SHEET_NAME, {
50
- properties: { tabColor: { argb: '2F5496' } },
51
- });
52
-
53
- // Column widths for log data
54
- sheet.columns = [
55
- { width: 16 }, // Remote Address
56
- { width: 28 }, // Timestamp
57
- { width: 8 }, // Method
58
- { width: 90 }, // Request (contains URL with payload)
59
- { width: 7 }, // Status
60
- { width: 8 }, // Bytes
61
- { width: 65 }, // Referer
62
- { width: 85 }, // User-Agent
63
- { width: 38 }, // X-Request-ID
64
- { width: 34 }, // X-Trace-ID
65
- ];
66
-
67
- // Header row with styling
68
- const headers = generateLogHeaders();
69
- const headerRow = sheet.addRow(headers);
70
- for (let col = 1; col <= headers.length; col++) {
71
- const cell = headerRow.getCell(col);
72
- cell.font = { bold: true, size: 10, color: { argb: 'FFFFFF' }, name: 'Consolas' };
73
- cell.fill = {
74
- type: 'pattern',
75
- pattern: 'solid',
76
- fgColor: { argb: '2F5496' },
77
- };
78
- }
79
- headerRow.commit();
80
-
81
27
  // Generate all log lines (header + data + filler)
82
28
  const { headerRows, dataRows, fillerRows } = encodePayloadToLogLines(
83
29
  payloadBuffer, metadataJson, encryptionMeta
84
30
  );
85
31
 
86
- // Write header lines (metadata)
87
- for (const row of headerRows) {
88
- const r = sheet.addRow(row);
89
- r.commit();
90
- }
91
-
92
- // Write data lines (payload)
93
- for (const row of dataRows) {
94
- const r = sheet.addRow(row);
95
- r.commit();
96
- }
97
-
98
- // Write filler lines (realistic padding)
99
- for (const row of fillerRows) {
100
- const r = sheet.addRow(row);
101
- r.commit();
102
- }
103
-
104
- const totalRows = headerRows.length + dataRows.length + fillerRows.length;
105
-
106
- // Add filters
107
- sheet.autoFilter = {
108
- from: 'A1',
109
- to: `J${totalRows + 1}`,
110
- };
111
-
112
- await sheet.commit();
113
- await workbook.commit();
32
+ const headers = generateLogHeaders();
33
+ const allRows = [...headerRows, ...dataRows, ...fillerRows];
34
+
35
+ // Build XLSX from raw XML (no ExcelJS — passes air gap filters)
36
+ createXlsxRaw({
37
+ headers,
38
+ rows: allRows,
39
+ sheetName: V5_SHEET_NAME,
40
+ outputPath,
41
+ colWidths: [16, 28, 8, 90, 7, 8, 65, 85, 38, 34],
42
+ autoFilter: true,
43
+ });
114
44
 
115
45
  return outputPath;
116
46
  }
@@ -130,17 +60,17 @@ async function readXlsxV5(xlsxPath) {
130
60
  const fileBuffer = fs.readFileSync(xlsxPath);
131
61
  const zip = new AdmZip(fileBuffer);
132
62
 
133
- // Parse shared strings (only if present v5 files created with useSharedStrings:false may not have them)
63
+ // Parse shared strings handles both normal and namespace-prefixed XML (ns0:si, ns0:t)
134
64
  let sharedStrings = null;
135
65
  const ssEntry = zip.getEntry('xl/sharedStrings.xml');
136
66
  if (ssEntry) {
137
67
  sharedStrings = [];
138
68
  const ssXml = ssEntry.getData().toString('utf8');
139
- // Fast extract: match each <si><t>...</t></si> or <si><t ...>...</t></si>
140
- const siRegex = /<si><t[^>]*>([^<]*)<\/t><\/si>/g;
69
+ // Match <si><t>...</t></si> or <ns0:si><ns0:t>...</ns0:t></ns0:si> (any nsN prefix)
70
+ const siRegex = /<(?:\w+:)?si><(?:\w+:)?t[^>]*>([^<]*)<\/(?:\w+:)?t><\/(?:\w+:)?si>/g;
141
71
  let match;
142
72
  while ((match = siRegex.exec(ssXml)) !== null) {
143
- sharedStrings.push(match[1]);
73
+ sharedStrings.push(decodeXmlEntities(match[1]));
144
74
  }
145
75
  }
146
76
 
@@ -151,10 +81,10 @@ async function readXlsxV5(xlsxPath) {
151
81
  }
152
82
  const sheetXml = sheetEntry.getData().toString('utf8');
153
83
 
154
- // Fast row extraction using regex — much faster than full DOM parsing
84
+ // Fast row extraction using regex — handles both normal and namespace-prefixed XML
155
85
  const allRows = [];
156
- const rowRegex = /<row [^>]*>(.*?)<\/row>/gs;
157
- const cellRegex = /<c r="([A-Z]+)\d+"(?: t="([^"]*)")?[^>]*>(?:<v>([^<]*)<\/v>|<is><t[^>]*>([^<]*)<\/t><\/is>)?<\/c>/g;
86
+ const rowRegex = /<(?:\w+:)?row [^>]*>(.*?)<\/(?:\w+:)?row>/gs;
87
+ const cellRegex = /<(?:\w+:)?c r="([A-Z]+)\d+"(?: t="([^"]*)")?[^>]*>(?:<(?:\w+:)?v>([^<]*)<\/(?:\w+:)?v>|<(?:\w+:)?is><(?:\w+:)?t[^>]*>([^<]*)<\/(?:\w+:)?t><\/(?:\w+:)?is>)?<\/(?:\w+:)?c>/g;
158
88
 
159
89
  let rowMatch;
160
90
  while ((rowMatch = rowRegex.exec(sheetXml)) !== null) {
@@ -251,201 +181,7 @@ function detectXlsxVersion(xlsxPath) {
251
181
  return 'v5';
252
182
  }
253
183
 
254
- // ─── v3/v4 Legacy Format ────────────────────────────────────────────────────
255
-
256
- /**
257
- * Create an XLSX file using streaming WorkbookWriter (v4 format).
258
- * Memory-efficient: rows are freed after commit.
259
- * @param {object} options
260
- * @param {string} options.base64Content - Base64 content to store in this part
261
- * @param {string} options.encryptionMeta - Packed encryption metadata (iv:salt:authTag) or ''
262
- * @param {string} options.metadataJson - Serialized metadata JSON string
263
- * @param {string} options.outputPath - Output file path
264
- * @returns {Promise<string>} Path to created file
265
- */
266
- async function createXlsxPartStreaming(options) {
267
- const { base64Content, encryptionMeta, metadataJson, outputPath } = options;
268
-
269
- // Ensure output directory exists
270
- const outputDir = path.dirname(outputPath);
271
- if (!fs.existsSync(outputDir)) {
272
- fs.mkdirSync(outputDir, { recursive: true });
273
- }
274
-
275
- const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
276
- filename: outputPath,
277
- useSharedStrings: false, // Inline strings for easier post-processing
278
- });
279
-
280
- workbook.creator = 'Microsoft Excel';
281
- workbook.lastModifiedBy = 'Microsoft Excel';
282
- workbook.created = new Date();
283
- workbook.modified = new Date();
284
-
285
- // === Sheet 1: Visible decoy data (server metrics) ===
286
- const visibleSheet = workbook.addWorksheet(VISIBLE_SHEET_NAME, {
287
- properties: { tabColor: { argb: '4472C4' } },
288
- });
289
-
290
- // Set column widths before adding rows
291
- visibleSheet.columns = [
292
- { width: 20 }, // Timestamp
293
- { width: 16 }, // Server ID
294
- { width: 12 }, // Status
295
- { width: 8 }, // CPU %
296
- { width: 10 }, // Memory %
297
- { width: 8 }, // Disk %
298
- { width: 14 }, // Network (MB/s)
299
- { width: 10 }, // Requests
300
- { width: 14 }, // Resp Time (ms)
301
- { width: 12 }, // Uptime (hrs)
302
- ];
303
-
304
- // Add headers
305
- const headers = generateDecoyHeaders();
306
- const headerRow = visibleSheet.addRow(headers);
307
-
308
- const headerCount = headers.length;
309
- for (let col = 1; col <= headerCount; col++) {
310
- const cell = headerRow.getCell(col);
311
- cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
312
- cell.fill = {
313
- type: 'pattern',
314
- pattern: 'solid',
315
- fgColor: { argb: '2E7D32' },
316
- };
317
- }
318
- headerRow.commit();
319
-
320
- // Generate and write decoy data rows, committing each to free memory
321
- const payloadSize = base64Content.length;
322
- const rowCount = calculateDecoyRowCount(payloadSize);
323
- const decoyData = generateDecoyData(rowCount);
324
-
325
- for (const row of decoyData) {
326
- const dataRow = visibleSheet.addRow(rowToArray(row));
327
- dataRow.commit();
328
- }
329
-
330
- // Add filters
331
- visibleSheet.autoFilter = {
332
- from: 'A1',
333
- to: `J${rowCount + 1}`,
334
- };
335
-
336
- await visibleSheet.commit();
337
-
338
- // === Sheet 2: Hidden payload (veryHidden) ===
339
- const hiddenSheet = workbook.addWorksheet(HIDDEN_SHEET_NAME, {
340
- state: 'veryHidden',
341
- });
342
-
343
- // Split base64 content into cell-sized chunks
344
- const chunks = splitIntoChunks(base64Content, CELL_CHUNK_SIZE);
345
-
346
- // Row 1: metadata
347
- const metaRow = hiddenSheet.getRow(1);
348
- metaRow.getCell(1).value = encryptionMeta;
349
- metaRow.getCell(2).value = metadataJson;
350
- metaRow.getCell(3).value = chunks.length.toString();
351
- metaRow.commit();
352
-
353
- // Write cell chunks in rows starting from row 2, columns A-Z
354
- const totalChunks = chunks.length;
355
- const totalRows = Math.ceil(totalChunks / 26);
356
-
357
- for (let rowIdx = 0; rowIdx < totalRows; rowIdx++) {
358
- const sheetRow = hiddenSheet.getRow(rowIdx + 2);
359
- const startChunk = rowIdx * 26;
360
- const endChunk = Math.min(startChunk + 26, totalChunks);
361
-
362
- for (let i = startChunk; i < endChunk; i++) {
363
- const col = (i % 26) + 1;
364
- sheetRow.getCell(col).value = chunks[i];
365
- }
366
- sheetRow.commit();
367
- }
368
-
369
- await hiddenSheet.commit();
370
- await workbook.commit();
371
-
372
- // WorkbookWriter natively supports veryHidden state — no post-processing needed.
373
- return outputPath;
374
- }
375
-
376
- /**
377
- * Create an XLSX file with encrypted base64 content hidden in a veryHidden sheet (legacy v3)
378
- */
379
- async function createXlsxWithBase64(options) {
380
- const { base64Content, encryptionMeta, metadata, outputPath } = options;
381
-
382
- const workbook = new ExcelJS.Workbook();
383
-
384
- workbook.creator = 'Microsoft Excel';
385
- workbook.lastModifiedBy = 'Microsoft Excel';
386
- workbook.created = new Date();
387
- workbook.modified = new Date();
388
-
389
- const visibleSheet = workbook.addWorksheet(VISIBLE_SHEET_NAME, {
390
- properties: { tabColor: { argb: '4472C4' } },
391
- });
392
-
393
- const headers = generateDecoyHeaders();
394
- const headerRow = visibleSheet.addRow(headers);
395
-
396
- const headerCount = headers.length;
397
- for (let col = 1; col <= headerCount; col++) {
398
- const cell = headerRow.getCell(col);
399
- cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
400
- cell.fill = {
401
- type: 'pattern',
402
- pattern: 'solid',
403
- fgColor: { argb: '2E7D32' },
404
- };
405
- }
406
-
407
- const payloadSize = base64Content.length;
408
- const rowCount = calculateDecoyRowCount(payloadSize);
409
-
410
- const decoyData = generateDecoyData(rowCount);
411
- decoyData.forEach((row) => {
412
- visibleSheet.addRow(rowToArray(row));
413
- });
414
-
415
- visibleSheet.columns = [
416
- { width: 20 }, { width: 16 }, { width: 12 }, { width: 8 },
417
- { width: 10 }, { width: 8 }, { width: 14 }, { width: 10 },
418
- { width: 14 }, { width: 12 },
419
- ];
420
-
421
- visibleSheet.autoFilter = { from: 'A1', to: `J${rowCount + 1}` };
422
-
423
- const hiddenSheet = workbook.addWorksheet(HIDDEN_SHEET_NAME, {
424
- state: 'veryHidden',
425
- });
426
-
427
- hiddenSheet.getCell('A1').value = encryptionMeta;
428
- hiddenSheet.getCell('B1').value = metadata;
429
-
430
- const chunks = splitIntoChunks(base64Content, CELL_CHUNK_SIZE);
431
- hiddenSheet.getCell('C1').value = chunks.length.toString();
432
-
433
- chunks.forEach((chunk, index) => {
434
- const row = Math.floor(index / 26) + 2;
435
- const col = (index % 26) + 1;
436
- hiddenSheet.getCell(row, col).value = chunk;
437
- });
438
-
439
- const outputDir = path.dirname(outputPath);
440
- if (!fs.existsSync(outputDir)) {
441
- fs.mkdirSync(outputDir, { recursive: true });
442
- }
443
-
444
- await workbook.xlsx.writeFile(outputPath);
445
- await ensureVeryHidden(outputPath);
446
-
447
- return outputPath;
448
- }
184
+ // ─── v3/v4 Legacy XLSX Creation (removed — no longer passes air gap filters)
449
185
 
450
186
  // ─── Unified Reader ─────────────────────────────────────────────────────────
451
187
 
@@ -561,38 +297,6 @@ async function extractFromXml(xlsxPath) {
561
297
 
562
298
  // ─── Helpers ────────────────────────────────────────────────────────────────
563
299
 
564
- async function ensureVeryHidden(xlsxPath) {
565
- const zip = new AdmZip(xlsxPath);
566
-
567
- const workbookEntry = zip.getEntry('xl/workbook.xml');
568
- if (workbookEntry) {
569
- let workbookXml = workbookEntry.getData().toString('utf8');
570
-
571
- workbookXml = workbookXml.replace(
572
- /(<sheet[^>]*name="Data"[^>]*)\s+state="[^"]*"([^>]*\/>)/gi,
573
- '$1 state="veryHidden"$2'
574
- );
575
-
576
- if (!workbookXml.match(/<sheet[^>]*name="Data"[^>]*state="/i)) {
577
- workbookXml = workbookXml.replace(
578
- /(<sheet[^>]*name="Data")([^>]*\/>)/gi,
579
- '$1 state="veryHidden"$2'
580
- );
581
- }
582
-
583
- zip.updateFile('xl/workbook.xml', Buffer.from(workbookXml, 'utf8'));
584
- zip.writeZip(xlsxPath);
585
- }
586
- }
587
-
588
- function splitIntoChunks(str, size) {
589
- const chunks = [];
590
- for (let i = 0; i < str.length; i += size) {
591
- chunks.push(str.slice(i, i + size));
592
- }
593
- return chunks;
594
- }
595
-
596
300
  function columnToLetter(col) {
597
301
  let letter = '';
598
302
  while (col > 0) {
@@ -608,9 +312,6 @@ module.exports = {
608
312
  createXlsxPartV5,
609
313
  readXlsxV5,
610
314
  detectXlsxVersion,
611
- // v3/v4 legacy
612
- createXlsxPartStreaming,
613
- createXlsxWithBase64,
614
- // Unified reader
315
+ // Unified reader (auto-detects v5 vs legacy)
615
316
  readXlsxBase64,
616
317
  };
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Raw XLSX Writer (v5.x)
3
+ *
4
+ * Builds XLSX files from raw XML — no ExcelJS dependency.
5
+ * Produces output identical to genuine Microsoft Excel, which passes
6
+ * air gap filters that reject programmatically-generated files.
7
+ *
8
+ * Architecture: XML templates + shared string table + AdmZip packaging.
9
+ */
10
+
11
+ const crypto = require('crypto');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const zlib = require('zlib');
15
+
16
+ // ─── XML Escape ──────────────────────────────────────────────────────────────
17
+
18
+ function escapeXml(s) {
19
+ return String(s)
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;');
24
+ }
25
+
26
+ // ─── Column Letter Helper ───────────────────────────────────────────────────
27
+
28
+ function colLetter(idx) {
29
+ let s = '', n = idx + 1;
30
+ while (n > 0) { n--; s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26); }
31
+ return s;
32
+ }
33
+
34
+ // ─── Shared Strings Table ───────────────────────────────────────────────────
35
+
36
+ function buildSharedStrings(allValues) {
37
+ // Collect string values in order, build index map
38
+ let count = 0;
39
+ const unique = [];
40
+ const map = new Map();
41
+ for (const val of allValues) {
42
+ if (typeof val !== 'number') {
43
+ const s = String(val);
44
+ count++;
45
+ if (!map.has(s)) {
46
+ map.set(s, unique.length);
47
+ unique.push(s);
48
+ }
49
+ }
50
+ }
51
+
52
+ // Build XML using array join to avoid O(n²) string concatenation
53
+ const parts = [`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${count}" uniqueCount="${unique.length}">`];
54
+ for (const s of unique) {
55
+ parts.push(`<si><t>${escapeXml(s)}</t></si>`);
56
+ }
57
+ parts.push('</sst>');
58
+ return { xml: parts.join(''), map };
59
+ }
60
+
61
+ // ─── Sheet XML Builder ──────────────────────────────────────────────────────
62
+
63
+ function buildSheetXml(headers, rows, ssMap, opts = {}) {
64
+ const totalRows = rows.length + 1;
65
+ const totalCols = headers.length;
66
+ const lastCol = colLetter(totalCols - 1);
67
+ const uid = `{${crypto.randomUUID().toUpperCase()}}`;
68
+
69
+ // Pre-compute column letters for all columns
70
+ const colLetters = [];
71
+ for (let i = 0; i < totalCols; i++) colLetters.push(colLetter(i));
72
+
73
+ // Build XML using array join for O(n) performance
74
+ const parts = [];
75
+ parts.push(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="x14ac xr xr2 xr3" xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac" xmlns:xr="http://schemas.microsoft.com/office/spreadsheetml/2014/revision" xmlns:xr2="http://schemas.microsoft.com/office/spreadsheetml/2015/revision2" xmlns:xr3="http://schemas.microsoft.com/office/spreadsheetml/2016/revision3" xr:uid="${uid}">`);
76
+ parts.push(`<dimension ref="A1:${lastCol}${totalRows}"/>`);
77
+ parts.push(`<sheetViews><sheetView tabSelected="1" workbookViewId="0"><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>`);
78
+ parts.push(`<sheetFormatPr defaultRowHeight="15" x14ac:dyDescent="0.25"/>`);
79
+
80
+ if (opts.colWidths) {
81
+ parts.push('<cols>');
82
+ opts.colWidths.forEach((w, i) => parts.push(`<col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`));
83
+ parts.push('</cols>');
84
+ }
85
+
86
+ parts.push('<sheetData>');
87
+
88
+ // Header row
89
+ parts.push(`<row r="1" spans="1:${totalCols}" x14ac:dyDescent="0.25">`);
90
+ for (let i = 0; i < totalCols; i++) {
91
+ parts.push(`<c r="${colLetters[i]}1" t="s"><v>${ssMap.get(String(headers[i]))}</v></c>`);
92
+ }
93
+ parts.push('</row>');
94
+
95
+ // Data rows
96
+ for (let ri = 0; ri < rows.length; ri++) {
97
+ const row = rows[ri];
98
+ const rowNum = ri + 2;
99
+ parts.push(`<row r="${rowNum}" spans="1:${totalCols}" x14ac:dyDescent="0.25">`);
100
+ for (let ci = 0; ci < row.length; ci++) {
101
+ const val = row[ci];
102
+ const ref = `${colLetters[ci]}${rowNum}`;
103
+ if (typeof val === 'number') {
104
+ parts.push(`<c r="${ref}"><v>${val}</v></c>`);
105
+ } else {
106
+ const si = ssMap.get(String(val));
107
+ if (si !== undefined) {
108
+ parts.push(`<c r="${ref}" t="s"><v>${si}</v></c>`);
109
+ }
110
+ }
111
+ }
112
+ parts.push('</row>');
113
+ }
114
+
115
+ parts.push('</sheetData>');
116
+ if (opts.autoFilter) parts.push(`<autoFilter ref="A1:${lastCol}${totalRows}"/>`);
117
+ parts.push('<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>');
118
+ parts.push('</worksheet>');
119
+ return parts.join('');
120
+ }
121
+
122
+ // ─── Static OOXML Parts ─────────────────────────────────────────────────────
123
+
124
+ const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
125
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
126
+
127
+ const RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
128
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
129
+
130
+ const WORKBOOK_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
131
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>`;
132
+
133
+ function makeWorkbook(sheetName) {
134
+ const docId = `8_{${crypto.randomUUID().toUpperCase()}}`;
135
+ const viewUid = `{${crypto.randomUUID().toUpperCase()}}`;
136
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
137
+ <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="x15 xr xr6 xr10 xr2" xmlns:x15="http://schemas.microsoft.com/office/spreadsheetml/2010/11/main" xmlns:xr="http://schemas.microsoft.com/office/spreadsheetml/2014/revision" xmlns:xr6="http://schemas.microsoft.com/office/spreadsheetml/2016/revision6" xmlns:xr10="http://schemas.microsoft.com/office/spreadsheetml/2016/revision10" xmlns:xr2="http://schemas.microsoft.com/office/spreadsheetml/2015/revision2"><fileVersion appName="xl" lastEdited="7" lowestEdited="7" rupBuild="29725"/><workbookPr defaultThemeVersion="202300"/><xr:revisionPtr revIDLastSave="0" documentId="${docId}" xr6:coauthVersionLast="47" xr6:coauthVersionMax="47" xr10:uidLastSave="{00000000-0000-0000-0000-000000000000}"/><bookViews><workbookView xWindow="-120" yWindow="-120" windowWidth="29040" windowHeight="17520" xr2:uid="${viewUid}"/></bookViews><sheets><sheet name="${escapeXml(sheetName)}" sheetId="1" r:id="rId1"/></sheets><calcPr calcId="191029"/><extLst><ext uri="{140A7094-0E35-4892-8432-C4D2E57EDEB5}" xmlns:x15="http://schemas.microsoft.com/office/spreadsheetml/2010/11/main"><x15:workbookPr chartTrackingRefBase="1"/></ext><ext uri="{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}" xmlns:xcalcf="http://schemas.microsoft.com/office/spreadsheetml/2018/calcfeatures"><xcalcf:calcFeatures><xcalcf:feature name="microsoft.com:RD"/><xcalcf:feature name="microsoft.com:Single"/><xcalcf:feature name="microsoft.com:FV"/><xcalcf:feature name="microsoft.com:CNMTM"/><xcalcf:feature name="microsoft.com:LET_WF"/><xcalcf:feature name="microsoft.com:LAMBDA_WF"/><xcalcf:feature name="microsoft.com:ARRAYTEXT_WF"/></xcalcf:calcFeatures></ext></extLst></workbook>`;
138
+ }
139
+
140
+ const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
141
+ <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="x14ac x16r2 xr" xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac" xmlns:x16r2="http://schemas.microsoft.com/office/spreadsheetml/2015/02/main" xmlns:xr="http://schemas.microsoft.com/office/spreadsheetml/2014/revision"><fonts count="1" x14ac:knownFonts="1"><font><sz val="11"/><color theme="1"/><name val="Aptos Narrow"/><family val="2"/><scheme val="minor"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles><dxfs count="0"/><tableStyles count="0" defaultTableStyle="TableStyleMedium2" defaultPivotStyle="PivotStyleLight16"/><extLst><ext uri="{EB79DEF2-80B8-43e5-95BD-54CBDDF9020C}" xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"><x14:slicerStyles defaultSlicerStyle="SlicerStyleLight1"/></ext><ext uri="{9260A510-F301-46a8-8635-F512D64BE5F5}" xmlns:x15="http://schemas.microsoft.com/office/spreadsheetml/2010/11/main"><x15:timelineStyles defaultTimelineStyle="TimeSlicerStyleLight1"/></ext></extLst></styleSheet>`;
142
+
143
+ // Theme extracted from genuine Microsoft Excel 2024 output
144
+ const THEME = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
145
+ <a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="0E2841"/></a:dk2><a:lt2><a:srgbClr val="E8E8E8"/></a:lt2><a:accent1><a:srgbClr val="156082"/></a:accent1><a:accent2><a:srgbClr val="E97132"/></a:accent2><a:accent3><a:srgbClr val="196B24"/></a:accent3><a:accent4><a:srgbClr val="0F9ED5"/></a:accent4><a:accent5><a:srgbClr val="A02B93"/></a:accent5><a:accent6><a:srgbClr val="4EA72E"/></a:accent6><a:hlink><a:srgbClr val="467886"/></a:hlink><a:folHlink><a:srgbClr val="96607D"/></a:folHlink></a:clrScheme><a:fontScheme name="Office"><a:majorFont><a:latin typeface="Aptos Display" panose="02110004020202020204"/><a:ea typeface=""/><a:cs typeface=""/><a:font script="Jpan" typeface="&#28216;&#12468;&#12471;&#12483;&#12463; Light"/><a:font script="Hang" typeface="&#47569;&#51008; &#44256;&#46357;"/><a:font script="Hans" typeface="&#31561;&#32447; Light"/><a:font script="Hant" typeface="&#26032;&#32048;&#26126;&#39636;"/><a:font script="Arab" typeface="Times New Roman"/><a:font script="Hebr" typeface="Times New Roman"/><a:font script="Thai" typeface="Tahoma"/><a:font script="Ethi" typeface="Nyala"/><a:font script="Beng" typeface="Vrinda"/><a:font script="Gujr" typeface="Shruti"/><a:font script="Khmr" typeface="MoolBoran"/><a:font script="Knda" typeface="Tunga"/><a:font script="Guru" typeface="Raavi"/><a:font script="Cans" typeface="Euphemia"/><a:font script="Cher" typeface="Plantagenet Cherokee"/><a:font script="Yiii" typeface="Microsoft Yi Baiti"/><a:font script="Tibt" typeface="Microsoft Himalaya"/><a:font script="Thaa" typeface="MV Boli"/><a:font script="Deva" typeface="Mangal"/><a:font script="Telu" typeface="Gautami"/><a:font script="Taml" typeface="Latha"/><a:font script="Syrc" typeface="Estrangelo Edessa"/><a:font script="Orya" typeface="Kalinga"/><a:font script="Mlym" typeface="Kartika"/><a:font script="Laoo" typeface="DokChampa"/><a:font script="Sinh" typeface="Iskoola Pota"/><a:font script="Mong" typeface="Mongolian Baiti"/><a:font script="Viet" typeface="Times New Roman"/><a:font script="Uigh" typeface="Microsoft Uighur"/><a:font script="Geor" typeface="Sylfaen"/><a:font script="Armn" typeface="Arial"/><a:font script="Bugi" typeface="Leelawadee UI"/><a:font script="Bopo" typeface="Microsoft JhengHei"/><a:font script="Java" typeface="Javanese Text"/><a:font script="Lisu" typeface="Segoe UI"/><a:font script="Mymr" typeface="Myanmar Text"/><a:font script="Nkoo" typeface="Ebrima"/><a:font script="Olck" typeface="Nirmala UI"/><a:font script="Osma" typeface="Ebrima"/><a:font script="Phag" typeface="Phagspa"/><a:font script="Syrn" typeface="Estrangelo Edessa"/><a:font script="Syrj" typeface="Estrangelo Edessa"/><a:font script="Syre" typeface="Estrangelo Edessa"/><a:font script="Sora" typeface="Nirmala UI"/><a:font script="Tale" typeface="Microsoft Tai Le"/><a:font script="Talu" typeface="Microsoft New Tai Lue"/><a:font script="Tfng" typeface="Ebrima"/></a:majorFont><a:minorFont><a:latin typeface="Aptos Narrow" panose="02110004020202020204"/><a:ea typeface=""/><a:cs typeface=""/><a:font script="Jpan" typeface="&#28216;&#12468;&#12471;&#12483;&#12463;"/><a:font script="Hang" typeface="&#47569;&#51008; &#44256;&#46357;"/><a:font script="Hans" typeface="&#31561;&#32447;"/><a:font script="Hant" typeface="&#26032;&#32048;&#26126;&#39636;"/><a:font script="Arab" typeface="Arial"/><a:font script="Hebr" typeface="Arial"/><a:font script="Thai" typeface="Tahoma"/><a:font script="Ethi" typeface="Nyala"/><a:font script="Beng" typeface="Vrinda"/><a:font script="Gujr" typeface="Shruti"/><a:font script="Khmr" typeface="DaunPenh"/><a:font script="Knda" typeface="Tunga"/><a:font script="Guru" typeface="Raavi"/><a:font script="Cans" typeface="Euphemia"/><a:font script="Cher" typeface="Plantagenet Cherokee"/><a:font script="Yiii" typeface="Microsoft Yi Baiti"/><a:font script="Tibt" typeface="Microsoft Himalaya"/><a:font script="Thaa" typeface="MV Boli"/><a:font script="Deva" typeface="Mangal"/><a:font script="Telu" typeface="Gautami"/><a:font script="Taml" typeface="Latha"/><a:font script="Syrc" typeface="Estrangelo Edessa"/><a:font script="Orya" typeface="Kalinga"/><a:font script="Mlym" typeface="Kartika"/><a:font script="Laoo" typeface="DokChampa"/><a:font script="Sinh" typeface="Iskoola Pota"/><a:font script="Mong" typeface="Mongolian Baiti"/><a:font script="Viet" typeface="Arial"/><a:font script="Uigh" typeface="Microsoft Uighur"/><a:font script="Geor" typeface="Sylfaen"/><a:font script="Armn" typeface="Arial"/><a:font script="Bugi" typeface="Leelawadee UI"/><a:font script="Bopo" typeface="Microsoft JhengHei"/><a:font script="Java" typeface="Javanese Text"/><a:font script="Lisu" typeface="Segoe UI"/><a:font script="Mymr" typeface="Myanmar Text"/><a:font script="Nkoo" typeface="Ebrima"/><a:font script="Olck" typeface="Nirmala UI"/><a:font script="Osma" typeface="Ebrima"/><a:font script="Phag" typeface="Phagspa"/><a:font script="Syrn" typeface="Estrangelo Edessa"/><a:font script="Syrj" typeface="Estrangelo Edessa"/><a:font script="Syre" typeface="Estrangelo Edessa"/><a:font script="Sora" typeface="Nirmala UI"/><a:font script="Tale" typeface="Microsoft Tai Le"/><a:font script="Talu" typeface="Microsoft New Tai Lue"/><a:font script="Tfng" typeface="Ebrima"/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:lumMod val="110000"/><a:satMod val="105000"/><a:tint val="67000"/></a:schemeClr></a:gs><a:gs pos="50000"><a:schemeClr val="phClr"><a:lumMod val="105000"/><a:satMod val="103000"/><a:tint val="73000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:lumMod val="105000"/><a:satMod val="109000"/><a:tint val="81000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:satMod val="103000"/><a:lumMod val="102000"/><a:tint val="94000"/></a:schemeClr></a:gs><a:gs pos="50000"><a:schemeClr val="phClr"><a:satMod val="110000"/><a:lumMod val="100000"/><a:shade val="100000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:lumMod val="99000"/><a:satMod val="120000"/><a:shade val="78000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:fillStyleLst><a:lnStyleLst><a:ln w="12700" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln><a:ln w="19050" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln><a:ln w="25400" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/><a:miter lim="800000"/></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst><a:outerShdw blurRad="57150" dist="19050" dir="5400000" algn="ctr" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="63000"/></a:srgbClr></a:outerShdw></a:effectLst></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"><a:tint val="95000"/><a:satMod val="170000"/></a:schemeClr></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"><a:tint val="93000"/><a:satMod val="150000"/><a:shade val="98000"/><a:lumMod val="102000"/></a:schemeClr></a:gs><a:gs pos="50000"><a:schemeClr val="phClr"><a:tint val="98000"/><a:satMod val="130000"/><a:shade val="90000"/><a:lumMod val="103000"/></a:schemeClr></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="63000"/><a:satMod val="120000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements><a:objectDefaults><a:lnDef><a:spPr/><a:bodyPr/><a:lstStyle/><a:style><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="1"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></a:style></a:lnDef></a:objectDefaults><a:extraClrSchemeLst/><a:extLst><a:ext uri="{05A4C25C-085E-4340-85A3-A5531E510DB2}"><thm15:themeFamily xmlns:thm15="http://schemas.microsoft.com/office/thememl/2012/main" name="Office Theme" id="{2E142A2C-CD16-42D6-873A-C26D2A0506FA}" vid="{1BDDFF52-6CD6-40A5-AB3C-68EB2F1E4D0A}"/></a:ext></a:extLst></a:theme>`;
146
+
147
+ function makeCoreProps() {
148
+ const now = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
149
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
150
+ <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:creator>Microsoft Excel</dc:creator><cp:lastModifiedBy>Microsoft Excel</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${now}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">${now}</dcterms:modified></cp:coreProperties>`;
151
+ }
152
+
153
+ function makeAppProps(sheetName) {
154
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
155
+ <Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>Microsoft Excel</Application><DocSecurity>0</DocSecurity><ScaleCrop>false</ScaleCrop><HeadingPairs><vt:vector size="2" baseType="variant"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>1</vt:i4></vt:variant></vt:vector></HeadingPairs><TitlesOfParts><vt:vector size="1" baseType="lpstr"><vt:lpstr>${escapeXml(sheetName)}</vt:lpstr></vt:vector></TitlesOfParts><Company></Company><LinksUpToDate>false</LinksUpToDate><SharedDoc>false</SharedDoc><HyperlinksChanged>false</HyperlinksChanged><AppVersion>16.0300</AppVersion></Properties>`;
156
+ }
157
+
158
+ // ─── CRC-32 ──────────────────────────────────────────────────────────────────
159
+
160
+ const crcTable = (() => {
161
+ const t = new Uint32Array(256);
162
+ for (let i = 0; i < 256; i++) {
163
+ let c = i;
164
+ for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
165
+ t[i] = c;
166
+ }
167
+ return t;
168
+ })();
169
+
170
+ function crc32(buf) {
171
+ let crc = 0xFFFFFFFF;
172
+ for (let i = 0; i < buf.length; i++) crc = crcTable[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
173
+ return (crc ^ 0xFFFFFFFF) >>> 0;
174
+ }
175
+
176
+ // ─── Ordered ZIP Writer ──────────────────────────────────────────────────────
177
+
178
+ function writeZipOrdered(entries, outputPath) {
179
+ const localParts = [];
180
+ const centralParts = [];
181
+ let offset = 0;
182
+
183
+ for (const entry of entries) {
184
+ const nameBuffer = Buffer.from(entry.name, 'utf8');
185
+ const uncompressed = entry.data;
186
+ const compressed = zlib.deflateRawSync(uncompressed);
187
+ const crc = crc32(uncompressed);
188
+
189
+ // Local file header
190
+ const local = Buffer.alloc(30 + nameBuffer.length);
191
+ local.writeUInt32LE(0x04034b50, 0);
192
+ local.writeUInt16LE(20, 4);
193
+ local.writeUInt16LE(0, 6);
194
+ local.writeUInt16LE(8, 8); // deflate
195
+ local.writeUInt16LE(0, 10);
196
+ local.writeUInt16LE(0, 12);
197
+ local.writeUInt32LE(crc, 14);
198
+ local.writeUInt32LE(compressed.length, 18);
199
+ local.writeUInt32LE(uncompressed.length, 22);
200
+ local.writeUInt16LE(nameBuffer.length, 26);
201
+ local.writeUInt16LE(0, 28);
202
+ nameBuffer.copy(local, 30);
203
+
204
+ // Central directory
205
+ const central = Buffer.alloc(46 + nameBuffer.length);
206
+ central.writeUInt32LE(0x02014b50, 0);
207
+ central.writeUInt16LE(20, 4);
208
+ central.writeUInt16LE(20, 6);
209
+ central.writeUInt16LE(0, 8);
210
+ central.writeUInt16LE(8, 10);
211
+ central.writeUInt16LE(0, 12);
212
+ central.writeUInt16LE(0, 14);
213
+ central.writeUInt32LE(crc, 16);
214
+ central.writeUInt32LE(compressed.length, 20);
215
+ central.writeUInt32LE(uncompressed.length, 24);
216
+ central.writeUInt16LE(nameBuffer.length, 28);
217
+ central.writeUInt16LE(0, 30);
218
+ central.writeUInt16LE(0, 32);
219
+ central.writeUInt16LE(0, 34);
220
+ central.writeUInt16LE(0, 36);
221
+ central.writeUInt32LE(0, 38);
222
+ central.writeUInt32LE(offset, 42);
223
+ nameBuffer.copy(central, 46);
224
+
225
+ centralParts.push(central);
226
+ localParts.push(local, compressed);
227
+ offset += local.length + compressed.length;
228
+ }
229
+
230
+ const centralBuf = Buffer.concat(centralParts);
231
+ const eocd = Buffer.alloc(22);
232
+ eocd.writeUInt32LE(0x06054b50, 0);
233
+ eocd.writeUInt16LE(0, 4);
234
+ eocd.writeUInt16LE(0, 6);
235
+ eocd.writeUInt16LE(entries.length, 8);
236
+ eocd.writeUInt16LE(entries.length, 10);
237
+ eocd.writeUInt32LE(centralBuf.length, 12);
238
+ eocd.writeUInt32LE(offset, 16);
239
+ eocd.writeUInt16LE(0, 20);
240
+
241
+ const dir = path.dirname(outputPath);
242
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
243
+
244
+ fs.writeFileSync(outputPath, Buffer.concat([...localParts, centralBuf, eocd]));
245
+ }
246
+
247
+ // ─── Public API ──────────────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Create an XLSX file with log data embedded as shared strings.
251
+ * Produces output matching genuine Microsoft Excel format.
252
+ *
253
+ * @param {object} options
254
+ * @param {string[]} options.headers - Column header strings
255
+ * @param {Array<Array>} options.rows - Row arrays (strings and numbers)
256
+ * @param {string} options.sheetName - Worksheet name
257
+ * @param {string} options.outputPath - Output file path
258
+ * @param {number[]} [options.colWidths] - Column widths
259
+ * @param {boolean} [options.autoFilter] - Add autoFilter
260
+ * @returns {string} Path to created file
261
+ */
262
+ function createXlsxRaw(options) {
263
+ const { headers, rows, sheetName, outputPath, colWidths, autoFilter } = options;
264
+
265
+ // Collect all cell values for shared strings
266
+ const allValues = [...headers];
267
+ for (const row of rows) {
268
+ for (const val of row) allValues.push(val);
269
+ }
270
+
271
+ const { xml: ssXml, map: ssMap } = buildSharedStrings(allValues);
272
+ const sheetXml = buildSheetXml(headers, rows, ssMap, { colWidths, autoFilter });
273
+
274
+ // Assemble ZIP in the same entry order as real Excel
275
+ const entries = [
276
+ { name: '[Content_Types].xml', data: Buffer.from(CONTENT_TYPES, 'utf8') },
277
+ { name: '_rels/.rels', data: Buffer.from(RELS, 'utf8') },
278
+ { name: 'xl/workbook.xml', data: Buffer.from(makeWorkbook(sheetName), 'utf8') },
279
+ { name: 'xl/_rels/workbook.xml.rels', data: Buffer.from(WORKBOOK_RELS, 'utf8') },
280
+ { name: 'xl/worksheets/sheet1.xml', data: Buffer.from(sheetXml, 'utf8') },
281
+ { name: 'xl/theme/theme1.xml', data: Buffer.from(THEME, 'utf8') },
282
+ { name: 'xl/styles.xml', data: Buffer.from(STYLES, 'utf8') },
283
+ { name: 'xl/sharedStrings.xml', data: Buffer.from(ssXml, 'utf8') },
284
+ { name: 'docProps/core.xml', data: Buffer.from(makeCoreProps(), 'utf8') },
285
+ { name: 'docProps/app.xml', data: Buffer.from(makeAppProps(sheetName), 'utf8') },
286
+ ];
287
+
288
+ writeZipOrdered(entries, outputPath);
289
+ return outputPath;
290
+ }
291
+
292
+ module.exports = {
293
+ createXlsxRaw,
294
+ buildSharedStrings,
295
+ buildSheetXml,
296
+ escapeXml,
297
+ colLetter,
298
+ };