stegdoc 4.0.0 → 5.0.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.
@@ -1,416 +1,597 @@
1
- const ExcelJS = require('exceljs');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const AdmZip = require('adm-zip');
5
- const { generateDecoyHeaders, generateDecoyData, calculateDecoyRowCount, rowToArray, resetTimeWindow } = require('./decoy-generator');
6
- const { parseXmlFromZip, ensureArray, extractTextContent } = require('./xml-utils');
7
-
8
- // Constants for data storage
9
- const HIDDEN_SHEET_NAME = 'Data';
10
- const VISIBLE_SHEET_NAME = 'Server Metrics';
11
- const CELL_CHUNK_SIZE = 32000; // Max characters per cell (~32KB)
12
-
13
- /**
14
- * Create an XLSX file using streaming WorkbookWriter (v4 format).
15
- * Memory-efficient: rows are freed after commit.
16
- * @param {object} options
17
- * @param {string} options.base64Content - Base64 content to store in this part
18
- * @param {string} options.encryptionMeta - Packed encryption metadata (iv:salt:authTag) or ''
19
- * @param {string} options.metadataJson - Serialized metadata JSON string
20
- * @param {string} options.outputPath - Output file path
21
- * @returns {Promise<string>} Path to created file
22
- */
23
- async function createXlsxPartStreaming(options) {
24
- const { base64Content, encryptionMeta, metadataJson, outputPath } = options;
25
-
26
- // Ensure output directory exists
27
- const outputDir = path.dirname(outputPath);
28
- if (!fs.existsSync(outputDir)) {
29
- fs.mkdirSync(outputDir, { recursive: true });
30
- }
31
-
32
- const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
33
- filename: outputPath,
34
- useSharedStrings: false, // Inline strings for easier post-processing
35
- });
36
-
37
- workbook.creator = 'Microsoft Excel';
38
- workbook.lastModifiedBy = 'Microsoft Excel';
39
- workbook.created = new Date();
40
- workbook.modified = new Date();
41
-
42
- // === Sheet 1: Visible decoy data (server metrics) ===
43
- const visibleSheet = workbook.addWorksheet(VISIBLE_SHEET_NAME, {
44
- properties: { tabColor: { argb: '4472C4' } },
45
- });
46
-
47
- // Set column widths before adding rows
48
- visibleSheet.columns = [
49
- { width: 20 }, // Timestamp
50
- { width: 16 }, // Server ID
51
- { width: 12 }, // Status
52
- { width: 8 }, // CPU %
53
- { width: 10 }, // Memory %
54
- { width: 8 }, // Disk %
55
- { width: 14 }, // Network (MB/s)
56
- { width: 10 }, // Requests
57
- { width: 14 }, // Resp Time (ms)
58
- { width: 12 }, // Uptime (hrs)
59
- ];
60
-
61
- // Add headers
62
- const headers = generateDecoyHeaders();
63
- const headerRow = visibleSheet.addRow(headers);
64
-
65
- const headerCount = headers.length;
66
- for (let col = 1; col <= headerCount; col++) {
67
- const cell = headerRow.getCell(col);
68
- cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
69
- cell.fill = {
70
- type: 'pattern',
71
- pattern: 'solid',
72
- fgColor: { argb: '2E7D32' },
73
- };
74
- }
75
- headerRow.commit();
76
-
77
- // Generate and write decoy data rows, committing each to free memory
78
- const payloadSize = base64Content.length;
79
- const rowCount = calculateDecoyRowCount(payloadSize);
80
- const decoyData = generateDecoyData(rowCount);
81
-
82
- for (const row of decoyData) {
83
- const dataRow = visibleSheet.addRow(rowToArray(row));
84
- dataRow.commit();
85
- }
86
-
87
- // Add filters
88
- visibleSheet.autoFilter = {
89
- from: 'A1',
90
- to: `J${rowCount + 1}`,
91
- };
92
-
93
- await visibleSheet.commit();
94
-
95
- // === Sheet 2: Hidden payload (veryHidden) ===
96
- const hiddenSheet = workbook.addWorksheet(HIDDEN_SHEET_NAME, {
97
- state: 'veryHidden',
98
- });
99
-
100
- // Split base64 content into cell-sized chunks
101
- const chunks = splitIntoChunks(base64Content, CELL_CHUNK_SIZE);
102
-
103
- // Row 1: metadata
104
- const metaRow = hiddenSheet.getRow(1);
105
- metaRow.getCell(1).value = encryptionMeta;
106
- metaRow.getCell(2).value = metadataJson;
107
- metaRow.getCell(3).value = chunks.length.toString();
108
- metaRow.commit();
109
-
110
- // Write cell chunks in rows starting from row 2, columns A-Z
111
- const totalChunks = chunks.length;
112
- const totalRows = Math.ceil(totalChunks / 26);
113
-
114
- for (let rowIdx = 0; rowIdx < totalRows; rowIdx++) {
115
- const sheetRow = hiddenSheet.getRow(rowIdx + 2);
116
- const startChunk = rowIdx * 26;
117
- const endChunk = Math.min(startChunk + 26, totalChunks);
118
-
119
- for (let i = startChunk; i < endChunk; i++) {
120
- const col = (i % 26) + 1;
121
- sheetRow.getCell(col).value = chunks[i];
122
- }
123
- sheetRow.commit();
124
- }
125
-
126
- await hiddenSheet.commit();
127
- await workbook.commit();
128
-
129
- // WorkbookWriter natively supports veryHidden state — no post-processing needed.
130
- return outputPath;
131
- }
132
-
133
- /**
134
- * Create an XLSX file with encrypted base64 content hidden in a veryHidden sheet (legacy v3)
135
- * @param {object} options - Options for creating the XLSX
136
- * @param {string} options.base64Content - Encrypted base64 content to store
137
- * @param {string} options.encryptionMeta - Packed encryption metadata (iv:salt:authTag)
138
- * @param {object} options.metadata - Metadata object (serialized JSON)
139
- * @param {string} options.outputPath - Output file path
140
- * @returns {Promise<string>} Path to created file
141
- */
142
- async function createXlsxWithBase64(options) {
143
- const { base64Content, encryptionMeta, metadata, outputPath } = options;
144
-
145
- const workbook = new ExcelJS.Workbook();
146
-
147
- // Set workbook properties to look legitimate
148
- workbook.creator = 'Microsoft Excel';
149
- workbook.lastModifiedBy = 'Microsoft Excel';
150
- workbook.created = new Date();
151
- workbook.modified = new Date();
152
-
153
- // === Sheet 1: Visible decoy data (server metrics) ===
154
- const visibleSheet = workbook.addWorksheet(VISIBLE_SHEET_NAME, {
155
- properties: { tabColor: { argb: '4472C4' } },
156
- });
157
-
158
- // Add headers
159
- const headers = generateDecoyHeaders();
160
- const headerRow = visibleSheet.addRow(headers);
161
-
162
- // Style header columns (10 columns for metrics data)
163
- const headerCount = headers.length;
164
- for (let col = 1; col <= headerCount; col++) {
165
- const cell = headerRow.getCell(col);
166
- cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
167
- cell.fill = {
168
- type: 'pattern',
169
- pattern: 'solid',
170
- fgColor: { argb: '2E7D32' }, // Green for metrics/monitoring theme
171
- };
172
- }
173
-
174
- // Calculate decoy row count based on payload size
175
- const payloadSize = base64Content.length;
176
- const rowCount = calculateDecoyRowCount(payloadSize);
177
-
178
- // Generate and add decoy data
179
- const decoyData = generateDecoyData(rowCount);
180
- decoyData.forEach((row) => {
181
- visibleSheet.addRow(rowToArray(row));
182
- });
183
-
184
- // Auto-fit columns for metrics data
185
- visibleSheet.columns = [
186
- { width: 20 }, // Timestamp
187
- { width: 16 }, // Server ID
188
- { width: 12 }, // Status
189
- { width: 8 }, // CPU %
190
- { width: 10 }, // Memory %
191
- { width: 8 }, // Disk %
192
- { width: 14 }, // Network (MB/s)
193
- { width: 10 }, // Requests
194
- { width: 14 }, // Resp Time (ms)
195
- { width: 12 }, // Uptime (hrs)
196
- ];
197
-
198
- // Add filters to look more professional
199
- visibleSheet.autoFilter = {
200
- from: 'A1',
201
- to: `J${rowCount + 1}`,
202
- };
203
-
204
- // === Sheet 2: Hidden payload (veryHidden) ===
205
- const hiddenSheet = workbook.addWorksheet(HIDDEN_SHEET_NAME, {
206
- state: 'veryHidden', // Not visible in Excel UI at all
207
- });
208
-
209
- // Store encryption metadata in cell A1
210
- hiddenSheet.getCell('A1').value = encryptionMeta;
211
-
212
- // Store serialized metadata in cell B1
213
- hiddenSheet.getCell('B1').value = metadata;
214
-
215
- // Split base64 content into chunks and store in cells
216
- const chunks = splitIntoChunks(base64Content, CELL_CHUNK_SIZE);
217
-
218
- // Store chunk count in C1 for reconstruction
219
- hiddenSheet.getCell('C1').value = chunks.length.toString();
220
-
221
- // Store chunks starting from A2
222
- chunks.forEach((chunk, index) => {
223
- const row = Math.floor(index / 26) + 2; // Start from row 2
224
- const col = (index % 26) + 1; // Columns A-Z
225
- hiddenSheet.getCell(row, col).value = chunk;
226
- });
227
-
228
- // Ensure output directory exists
229
- const outputDir = path.dirname(outputPath);
230
- if (!fs.existsSync(outputDir)) {
231
- fs.mkdirSync(outputDir, { recursive: true });
232
- }
233
-
234
- // Write workbook to file
235
- await workbook.xlsx.writeFile(outputPath);
236
-
237
- // Post-process: ensure veryHidden state is set correctly
238
- // ExcelJS sometimes has issues with veryHidden, so we fix it via XML
239
- await ensureVeryHidden(outputPath);
240
-
241
- return outputPath;
242
- }
243
-
244
- /**
245
- * Read an XLSX file and extract encrypted base64 content and metadata.
246
- * Always uses direct XML extraction for reliability and memory efficiency.
247
- * @param {string} xlsxPath - Path to XLSX file
248
- * @returns {Promise<object>} Object containing base64Content, encryptionMeta, and metadata
249
- */
250
- async function readXlsxBase64(xlsxPath) {
251
- if (!fs.existsSync(xlsxPath)) {
252
- throw new Error(`XLSX file not found: ${xlsxPath}`);
253
- }
254
-
255
- return await extractFromXml(xlsxPath);
256
- }
257
-
258
- /**
259
- * Extract data directly from XLSX XML using proper XML parser
260
- * Handles any namespace prefix (default, ns0:, ns1:, etc.)
261
- * @param {string} xlsxPath - Path to XLSX file
262
- * @returns {Promise<object>} Extracted data
263
- */
264
- async function extractFromXml(xlsxPath) {
265
- // Parse shared strings using namespace-agnostic parser
266
- let sharedStrings = [];
267
- const ssParsed = parseXmlFromZip(xlsxPath, 'xl/sharedStrings.xml');
268
-
269
- if (ssParsed && ssParsed.sst && ssParsed.sst.si) {
270
- const siArray = ensureArray(ssParsed.sst.si);
271
- sharedStrings = siArray.map((si) => extractTextContent(si.t));
272
- }
273
-
274
- // Parse the hidden sheet (sheet2.xml is our default location)
275
- const sheetParsed = parseXmlFromZip(xlsxPath, 'xl/worksheets/sheet2.xml');
276
-
277
- if (!sheetParsed) {
278
- throw new Error('Hidden sheet not found in XLSX file. This may not be a stegdoc-encoded file.');
279
- }
280
-
281
- // Build cell map from worksheet > sheetData > row > c
282
- const cellValues = new Map();
283
- const sheetData = sheetParsed.worksheet?.sheetData;
284
-
285
- if (sheetData && sheetData.row) {
286
- const rows = ensureArray(sheetData.row);
287
-
288
- for (const row of rows) {
289
- if (!row.c) continue;
290
- const cells = ensureArray(row.c);
291
-
292
- for (const cell of cells) {
293
- const cellRef = cell['@_r']; // e.g., "A1", "B1"
294
- const cellType = cell['@_t']; // "s" for shared string, "inlineStr" for inline
295
- const cellValue = cell.v;
296
-
297
- if (cellRef === undefined) continue;
298
-
299
- if (cellType === 's' && cellValue !== undefined) {
300
- // Shared string reference
301
- const ssIndex = parseInt(cellValue, 10);
302
- if (ssIndex < sharedStrings.length) {
303
- cellValues.set(cellRef, sharedStrings[ssIndex]);
304
- }
305
- } else if (cellType === 'inlineStr' && cell.is) {
306
- // Inline string (from WorkbookWriter with useSharedStrings: false)
307
- const text = extractTextContent(cell.is.t);
308
- if (text !== undefined) {
309
- cellValues.set(cellRef, text);
310
- }
311
- } else if (cellValue !== undefined) {
312
- cellValues.set(cellRef, String(cellValue));
313
- }
314
- }
315
- }
316
- }
317
-
318
- // Extract our data
319
- const encryptionMeta = cellValues.get('A1') || ''; // May be empty if unencrypted
320
- const metadata = cellValues.get('B1');
321
- const chunkCountStr = cellValues.get('C1');
322
-
323
- if (!metadata) {
324
- throw new Error('No metadata found in XLSX file.');
325
- }
326
-
327
- const chunkCount = parseInt(chunkCountStr, 10);
328
- if (isNaN(chunkCount) || chunkCount <= 0) {
329
- throw new Error('Invalid chunk count in XLSX file.');
330
- }
331
-
332
- // Reconstruct chunks
333
- const chunks = [];
334
- for (let i = 0; i < chunkCount; i++) {
335
- const row = Math.floor(i / 26) + 2;
336
- const col = (i % 26) + 1;
337
- const cellRef = `${columnToLetter(col)}${row}`;
338
- const chunk = cellValues.get(cellRef);
339
- if (chunk) {
340
- chunks.push(chunk);
341
- }
342
- }
343
-
344
- return {
345
- base64Content: chunks.join(''),
346
- encryptionMeta,
347
- metadata,
348
- };
349
- }
350
-
351
- /**
352
- * Ensure the hidden sheet is truly veryHidden by modifying the workbook.xml
353
- * @param {string} xlsxPath - Path to XLSX file
354
- */
355
- async function ensureVeryHidden(xlsxPath) {
356
- const zip = new AdmZip(xlsxPath);
357
-
358
- // Modify workbook.xml to ensure veryHidden state
359
- const workbookEntry = zip.getEntry('xl/workbook.xml');
360
- if (workbookEntry) {
361
- let workbookXml = workbookEntry.getData().toString('utf8');
362
-
363
- // Find the Data sheet and fix its state to veryHidden
364
- // First, remove any existing state attribute from the Data sheet
365
- workbookXml = workbookXml.replace(
366
- /(<sheet[^>]*name="Data"[^>]*)\s+state="[^"]*"([^>]*\/>)/gi,
367
- '$1 state="veryHidden"$2'
368
- );
369
-
370
- // If no state attribute was present, add one
371
- if (!workbookXml.match(/<sheet[^>]*name="Data"[^>]*state="/i)) {
372
- workbookXml = workbookXml.replace(
373
- /(<sheet[^>]*name="Data")([^>]*\/>)/gi,
374
- '$1 state="veryHidden"$2'
375
- );
376
- }
377
-
378
- zip.updateFile('xl/workbook.xml', Buffer.from(workbookXml, 'utf8'));
379
- zip.writeZip(xlsxPath);
380
- }
381
- }
382
-
383
- /**
384
- * Split a string into chunks of specified size
385
- * @param {string} str - String to split
386
- * @param {number} size - Max chunk size
387
- * @returns {Array<string>} Array of chunks
388
- */
389
- function splitIntoChunks(str, size) {
390
- const chunks = [];
391
- for (let i = 0; i < str.length; i += size) {
392
- chunks.push(str.slice(i, i + size));
393
- }
394
- return chunks;
395
- }
396
-
397
- /**
398
- * Convert column number to letter (1=A, 2=B, ..., 27=AA)
399
- * @param {number} col - Column number (1-based)
400
- * @returns {string} Column letter
401
- */
402
- function columnToLetter(col) {
403
- let letter = '';
404
- while (col > 0) {
405
- const mod = (col - 1) % 26;
406
- letter = String.fromCharCode(65 + mod) + letter;
407
- col = Math.floor((col - 1) / 26);
408
- }
409
- return letter;
410
- }
411
-
412
- module.exports = {
413
- createXlsxPartStreaming,
414
- createXlsxWithBase64,
415
- readXlsxBase64,
416
- };
1
+ const ExcelJS = require('exceljs');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const AdmZip = require('adm-zip');
5
+ const { generateDecoyHeaders, generateDecoyData, calculateDecoyRowCount, rowToArray, resetTimeWindow } = require('./decoy-generator');
6
+ const { generateLogHeaders, encodePayloadToLogLines, decodeLogLines, resetTimeState } = require('./log-generator');
7
+ const { parseXmlFromZip, ensureArray, extractTextContent } = require('./xml-utils');
8
+
9
+ // Constants for data storage
10
+ const HIDDEN_SHEET_NAME = 'Data';
11
+ const VISIBLE_SHEET_NAME = 'Server Metrics';
12
+ const V5_SHEET_NAME = 'Access Logs';
13
+ const CELL_CHUNK_SIZE = 32000; // Max characters per cell (~32KB)
14
+
15
+ // ─── v5 Log-Embed Format ───────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Create an XLSX file using v5 log-embed format (streaming).
19
+ * Single sheet "Access Logs" with payload embedded in log line fields.
20
+ * No hidden sheets.
21
+ *
22
+ * @param {object} options
23
+ * @param {Buffer} options.payloadBuffer - Encrypted binary payload
24
+ * @param {string} options.encryptionMeta - Packed encryption metadata (iv:salt:authTag) or ''
25
+ * @param {string} options.metadataJson - Serialized metadata JSON string
26
+ * @param {string} options.outputPath - Output file path
27
+ * @returns {Promise<string>} Path to created file
28
+ */
29
+ async function createXlsxPartV5(options) {
30
+ const { payloadBuffer, encryptionMeta, metadataJson, outputPath } = options;
31
+
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
+ // Generate all log lines (header + data + filler)
82
+ const { headerRows, dataRows, fillerRows } = encodePayloadToLogLines(
83
+ payloadBuffer, metadataJson, encryptionMeta
84
+ );
85
+
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();
114
+
115
+ return outputPath;
116
+ }
117
+
118
+ /**
119
+ * Read a v5 log-embed XLSX file and extract payload.
120
+ * @param {string} xlsxPath - Path to XLSX file
121
+ * @returns {object} { payloadBuffer, metadataJson, encryptionMeta, metadata }
122
+ */
123
+ async function readXlsxV5(xlsxPath) {
124
+ if (!fs.existsSync(xlsxPath)) {
125
+ throw new Error(`XLSX file not found: ${xlsxPath}`);
126
+ }
127
+
128
+ // Parse shared strings
129
+ let sharedStrings = [];
130
+ const ssParsed = parseXmlFromZip(xlsxPath, 'xl/sharedStrings.xml');
131
+ if (ssParsed && ssParsed.sst && ssParsed.sst.si) {
132
+ const siArray = ensureArray(ssParsed.sst.si);
133
+ sharedStrings = siArray.map((si) => extractTextContent(si.t));
134
+ }
135
+
136
+ // Parse sheet1 (the only sheet in v5)
137
+ const sheetParsed = parseXmlFromZip(xlsxPath, 'xl/worksheets/sheet1.xml');
138
+ if (!sheetParsed) {
139
+ throw new Error('Sheet not found in XLSX file.');
140
+ }
141
+
142
+ // Extract all rows as arrays of cell values
143
+ const allRows = [];
144
+ const sheetData = sheetParsed.worksheet?.sheetData;
145
+
146
+ if (sheetData && sheetData.row) {
147
+ const rows = ensureArray(sheetData.row);
148
+
149
+ for (const row of rows) {
150
+ if (!row.c) continue;
151
+ const cells = ensureArray(row.c);
152
+
153
+ // Build a sparse array for this row
154
+ const rowValues = [];
155
+ for (const cell of cells) {
156
+ const cellRef = cell['@_r'];
157
+ if (!cellRef) continue;
158
+
159
+ // Parse column index from cell reference (e.g., "A2" -> col 0, "J2" -> col 9)
160
+ const colMatch = cellRef.match(/^([A-Z]+)/);
161
+ if (!colMatch) continue;
162
+ const colIdx = colLetterToIndex(colMatch[1]);
163
+
164
+ const cellType = cell['@_t'];
165
+ const cellValue = cell.v;
166
+
167
+ let value;
168
+ if (cellType === 's' && cellValue !== undefined) {
169
+ const ssIndex = parseInt(cellValue, 10);
170
+ value = ssIndex < sharedStrings.length ? sharedStrings[ssIndex] : '';
171
+ } else if (cellType === 'inlineStr' && cell.is) {
172
+ value = extractTextContent(cell.is.t);
173
+ } else if (cellValue !== undefined) {
174
+ value = String(cellValue);
175
+ } else {
176
+ value = '';
177
+ }
178
+
179
+ rowValues[colIdx] = value;
180
+ }
181
+
182
+ allRows.push(rowValues);
183
+ }
184
+ }
185
+
186
+ // Skip the first row (column headers)
187
+ if (allRows.length < 2) {
188
+ throw new Error('Not enough rows in XLSX file for v5 format.');
189
+ }
190
+
191
+ const dataRows = allRows.slice(1); // Skip header row
192
+
193
+ // Decode log lines
194
+ return decodeLogLines(dataRows);
195
+ }
196
+
197
+ /**
198
+ * Convert column letter to 0-based index (A=0, B=1, ..., Z=25, AA=26)
199
+ */
200
+ function colLetterToIndex(letters) {
201
+ let index = 0;
202
+ for (let i = 0; i < letters.length; i++) {
203
+ index = index * 26 + (letters.charCodeAt(i) - 64);
204
+ }
205
+ return index - 1; // 0-based
206
+ }
207
+
208
+ /**
209
+ * Detect whether an XLSX file is v5 (log-embed) or v3/v4 (hidden sheet) format.
210
+ * @param {string} xlsxPath - Path to XLSX file
211
+ * @returns {string} 'v5' or 'legacy'
212
+ */
213
+ function detectXlsxVersion(xlsxPath) {
214
+ const zip = new AdmZip(xlsxPath);
215
+
216
+ // v5 files have only sheet1.xml, v3/v4 have sheet2.xml (hidden data sheet)
217
+ const sheet2 = zip.getEntry('xl/worksheets/sheet2.xml');
218
+ if (sheet2) {
219
+ return 'legacy';
220
+ }
221
+
222
+ // Double-check: look at sheet name in workbook.xml
223
+ const wbEntry = zip.getEntry('xl/workbook.xml');
224
+ if (wbEntry) {
225
+ const wbXml = wbEntry.getData().toString('utf8');
226
+ if (wbXml.includes('Access Logs') || wbXml.includes('access_log')) {
227
+ return 'v5';
228
+ }
229
+ }
230
+
231
+ // Default: try v5 if only one sheet exists
232
+ return 'v5';
233
+ }
234
+
235
+ // ─── v3/v4 Legacy Format ────────────────────────────────────────────────────
236
+
237
+ /**
238
+ * Create an XLSX file using streaming WorkbookWriter (v4 format).
239
+ * Memory-efficient: rows are freed after commit.
240
+ * @param {object} options
241
+ * @param {string} options.base64Content - Base64 content to store in this part
242
+ * @param {string} options.encryptionMeta - Packed encryption metadata (iv:salt:authTag) or ''
243
+ * @param {string} options.metadataJson - Serialized metadata JSON string
244
+ * @param {string} options.outputPath - Output file path
245
+ * @returns {Promise<string>} Path to created file
246
+ */
247
+ async function createXlsxPartStreaming(options) {
248
+ const { base64Content, encryptionMeta, metadataJson, outputPath } = options;
249
+
250
+ // Ensure output directory exists
251
+ const outputDir = path.dirname(outputPath);
252
+ if (!fs.existsSync(outputDir)) {
253
+ fs.mkdirSync(outputDir, { recursive: true });
254
+ }
255
+
256
+ const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
257
+ filename: outputPath,
258
+ useSharedStrings: false, // Inline strings for easier post-processing
259
+ });
260
+
261
+ workbook.creator = 'Microsoft Excel';
262
+ workbook.lastModifiedBy = 'Microsoft Excel';
263
+ workbook.created = new Date();
264
+ workbook.modified = new Date();
265
+
266
+ // === Sheet 1: Visible decoy data (server metrics) ===
267
+ const visibleSheet = workbook.addWorksheet(VISIBLE_SHEET_NAME, {
268
+ properties: { tabColor: { argb: '4472C4' } },
269
+ });
270
+
271
+ // Set column widths before adding rows
272
+ visibleSheet.columns = [
273
+ { width: 20 }, // Timestamp
274
+ { width: 16 }, // Server ID
275
+ { width: 12 }, // Status
276
+ { width: 8 }, // CPU %
277
+ { width: 10 }, // Memory %
278
+ { width: 8 }, // Disk %
279
+ { width: 14 }, // Network (MB/s)
280
+ { width: 10 }, // Requests
281
+ { width: 14 }, // Resp Time (ms)
282
+ { width: 12 }, // Uptime (hrs)
283
+ ];
284
+
285
+ // Add headers
286
+ const headers = generateDecoyHeaders();
287
+ const headerRow = visibleSheet.addRow(headers);
288
+
289
+ const headerCount = headers.length;
290
+ for (let col = 1; col <= headerCount; col++) {
291
+ const cell = headerRow.getCell(col);
292
+ cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
293
+ cell.fill = {
294
+ type: 'pattern',
295
+ pattern: 'solid',
296
+ fgColor: { argb: '2E7D32' },
297
+ };
298
+ }
299
+ headerRow.commit();
300
+
301
+ // Generate and write decoy data rows, committing each to free memory
302
+ const payloadSize = base64Content.length;
303
+ const rowCount = calculateDecoyRowCount(payloadSize);
304
+ const decoyData = generateDecoyData(rowCount);
305
+
306
+ for (const row of decoyData) {
307
+ const dataRow = visibleSheet.addRow(rowToArray(row));
308
+ dataRow.commit();
309
+ }
310
+
311
+ // Add filters
312
+ visibleSheet.autoFilter = {
313
+ from: 'A1',
314
+ to: `J${rowCount + 1}`,
315
+ };
316
+
317
+ await visibleSheet.commit();
318
+
319
+ // === Sheet 2: Hidden payload (veryHidden) ===
320
+ const hiddenSheet = workbook.addWorksheet(HIDDEN_SHEET_NAME, {
321
+ state: 'veryHidden',
322
+ });
323
+
324
+ // Split base64 content into cell-sized chunks
325
+ const chunks = splitIntoChunks(base64Content, CELL_CHUNK_SIZE);
326
+
327
+ // Row 1: metadata
328
+ const metaRow = hiddenSheet.getRow(1);
329
+ metaRow.getCell(1).value = encryptionMeta;
330
+ metaRow.getCell(2).value = metadataJson;
331
+ metaRow.getCell(3).value = chunks.length.toString();
332
+ metaRow.commit();
333
+
334
+ // Write cell chunks in rows starting from row 2, columns A-Z
335
+ const totalChunks = chunks.length;
336
+ const totalRows = Math.ceil(totalChunks / 26);
337
+
338
+ for (let rowIdx = 0; rowIdx < totalRows; rowIdx++) {
339
+ const sheetRow = hiddenSheet.getRow(rowIdx + 2);
340
+ const startChunk = rowIdx * 26;
341
+ const endChunk = Math.min(startChunk + 26, totalChunks);
342
+
343
+ for (let i = startChunk; i < endChunk; i++) {
344
+ const col = (i % 26) + 1;
345
+ sheetRow.getCell(col).value = chunks[i];
346
+ }
347
+ sheetRow.commit();
348
+ }
349
+
350
+ await hiddenSheet.commit();
351
+ await workbook.commit();
352
+
353
+ // WorkbookWriter natively supports veryHidden state no post-processing needed.
354
+ return outputPath;
355
+ }
356
+
357
+ /**
358
+ * Create an XLSX file with encrypted base64 content hidden in a veryHidden sheet (legacy v3)
359
+ */
360
+ async function createXlsxWithBase64(options) {
361
+ const { base64Content, encryptionMeta, metadata, outputPath } = options;
362
+
363
+ const workbook = new ExcelJS.Workbook();
364
+
365
+ workbook.creator = 'Microsoft Excel';
366
+ workbook.lastModifiedBy = 'Microsoft Excel';
367
+ workbook.created = new Date();
368
+ workbook.modified = new Date();
369
+
370
+ const visibleSheet = workbook.addWorksheet(VISIBLE_SHEET_NAME, {
371
+ properties: { tabColor: { argb: '4472C4' } },
372
+ });
373
+
374
+ const headers = generateDecoyHeaders();
375
+ const headerRow = visibleSheet.addRow(headers);
376
+
377
+ const headerCount = headers.length;
378
+ for (let col = 1; col <= headerCount; col++) {
379
+ const cell = headerRow.getCell(col);
380
+ cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
381
+ cell.fill = {
382
+ type: 'pattern',
383
+ pattern: 'solid',
384
+ fgColor: { argb: '2E7D32' },
385
+ };
386
+ }
387
+
388
+ const payloadSize = base64Content.length;
389
+ const rowCount = calculateDecoyRowCount(payloadSize);
390
+
391
+ const decoyData = generateDecoyData(rowCount);
392
+ decoyData.forEach((row) => {
393
+ visibleSheet.addRow(rowToArray(row));
394
+ });
395
+
396
+ visibleSheet.columns = [
397
+ { width: 20 }, { width: 16 }, { width: 12 }, { width: 8 },
398
+ { width: 10 }, { width: 8 }, { width: 14 }, { width: 10 },
399
+ { width: 14 }, { width: 12 },
400
+ ];
401
+
402
+ visibleSheet.autoFilter = { from: 'A1', to: `J${rowCount + 1}` };
403
+
404
+ const hiddenSheet = workbook.addWorksheet(HIDDEN_SHEET_NAME, {
405
+ state: 'veryHidden',
406
+ });
407
+
408
+ hiddenSheet.getCell('A1').value = encryptionMeta;
409
+ hiddenSheet.getCell('B1').value = metadata;
410
+
411
+ const chunks = splitIntoChunks(base64Content, CELL_CHUNK_SIZE);
412
+ hiddenSheet.getCell('C1').value = chunks.length.toString();
413
+
414
+ chunks.forEach((chunk, index) => {
415
+ const row = Math.floor(index / 26) + 2;
416
+ const col = (index % 26) + 1;
417
+ hiddenSheet.getCell(row, col).value = chunk;
418
+ });
419
+
420
+ const outputDir = path.dirname(outputPath);
421
+ if (!fs.existsSync(outputDir)) {
422
+ fs.mkdirSync(outputDir, { recursive: true });
423
+ }
424
+
425
+ await workbook.xlsx.writeFile(outputPath);
426
+ await ensureVeryHidden(outputPath);
427
+
428
+ return outputPath;
429
+ }
430
+
431
+ // ─── Unified Reader ─────────────────────────────────────────────────────────
432
+
433
+ /**
434
+ * Read an XLSX file and extract content. Auto-detects v5 vs v3/v4 format.
435
+ * @param {string} xlsxPath - Path to XLSX file
436
+ * @returns {Promise<object>} For v5: { payloadBuffer, metadataJson, encryptionMeta, metadata, formatVersion: 'v5' }
437
+ * For legacy: { base64Content, encryptionMeta, metadata, formatVersion: 'legacy' }
438
+ */
439
+ async function readXlsxBase64(xlsxPath) {
440
+ if (!fs.existsSync(xlsxPath)) {
441
+ throw new Error(`XLSX file not found: ${xlsxPath}`);
442
+ }
443
+
444
+ const version = detectXlsxVersion(xlsxPath);
445
+
446
+ if (version === 'v5') {
447
+ const result = await readXlsxV5(xlsxPath);
448
+ return {
449
+ ...result,
450
+ formatVersion: 'v5',
451
+ };
452
+ }
453
+
454
+ const result = await extractFromXml(xlsxPath);
455
+ return {
456
+ ...result,
457
+ formatVersion: 'legacy',
458
+ };
459
+ }
460
+
461
+ // ─── Legacy XML Extraction ──────────────────────────────────────────────────
462
+
463
+ async function extractFromXml(xlsxPath) {
464
+ let sharedStrings = [];
465
+ const ssParsed = parseXmlFromZip(xlsxPath, 'xl/sharedStrings.xml');
466
+
467
+ if (ssParsed && ssParsed.sst && ssParsed.sst.si) {
468
+ const siArray = ensureArray(ssParsed.sst.si);
469
+ sharedStrings = siArray.map((si) => extractTextContent(si.t));
470
+ }
471
+
472
+ const sheetParsed = parseXmlFromZip(xlsxPath, 'xl/worksheets/sheet2.xml');
473
+
474
+ if (!sheetParsed) {
475
+ throw new Error('Hidden sheet not found in XLSX file. This may not be a stegdoc-encoded file.');
476
+ }
477
+
478
+ const cellValues = new Map();
479
+ const sheetData = sheetParsed.worksheet?.sheetData;
480
+
481
+ if (sheetData && sheetData.row) {
482
+ const rows = ensureArray(sheetData.row);
483
+
484
+ for (const row of rows) {
485
+ if (!row.c) continue;
486
+ const cells = ensureArray(row.c);
487
+
488
+ for (const cell of cells) {
489
+ const cellRef = cell['@_r'];
490
+ const cellType = cell['@_t'];
491
+ const cellValue = cell.v;
492
+
493
+ if (cellRef === undefined) continue;
494
+
495
+ if (cellType === 's' && cellValue !== undefined) {
496
+ const ssIndex = parseInt(cellValue, 10);
497
+ if (ssIndex < sharedStrings.length) {
498
+ cellValues.set(cellRef, sharedStrings[ssIndex]);
499
+ }
500
+ } else if (cellType === 'inlineStr' && cell.is) {
501
+ const text = extractTextContent(cell.is.t);
502
+ if (text !== undefined) {
503
+ cellValues.set(cellRef, text);
504
+ }
505
+ } else if (cellValue !== undefined) {
506
+ cellValues.set(cellRef, String(cellValue));
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ const encryptionMeta = cellValues.get('A1') || '';
513
+ const metadata = cellValues.get('B1');
514
+ const chunkCountStr = cellValues.get('C1');
515
+
516
+ if (!metadata) {
517
+ throw new Error('No metadata found in XLSX file.');
518
+ }
519
+
520
+ const chunkCount = parseInt(chunkCountStr, 10);
521
+ if (isNaN(chunkCount) || chunkCount <= 0) {
522
+ throw new Error('Invalid chunk count in XLSX file.');
523
+ }
524
+
525
+ const chunks = [];
526
+ for (let i = 0; i < chunkCount; i++) {
527
+ const row = Math.floor(i / 26) + 2;
528
+ const col = (i % 26) + 1;
529
+ const cellRef = `${columnToLetter(col)}${row}`;
530
+ const chunk = cellValues.get(cellRef);
531
+ if (chunk) {
532
+ chunks.push(chunk);
533
+ }
534
+ }
535
+
536
+ return {
537
+ base64Content: chunks.join(''),
538
+ encryptionMeta,
539
+ metadata,
540
+ };
541
+ }
542
+
543
+ // ─── Helpers ────────────────────────────────────────────────────────────────
544
+
545
+ async function ensureVeryHidden(xlsxPath) {
546
+ const zip = new AdmZip(xlsxPath);
547
+
548
+ const workbookEntry = zip.getEntry('xl/workbook.xml');
549
+ if (workbookEntry) {
550
+ let workbookXml = workbookEntry.getData().toString('utf8');
551
+
552
+ workbookXml = workbookXml.replace(
553
+ /(<sheet[^>]*name="Data"[^>]*)\s+state="[^"]*"([^>]*\/>)/gi,
554
+ '$1 state="veryHidden"$2'
555
+ );
556
+
557
+ if (!workbookXml.match(/<sheet[^>]*name="Data"[^>]*state="/i)) {
558
+ workbookXml = workbookXml.replace(
559
+ /(<sheet[^>]*name="Data")([^>]*\/>)/gi,
560
+ '$1 state="veryHidden"$2'
561
+ );
562
+ }
563
+
564
+ zip.updateFile('xl/workbook.xml', Buffer.from(workbookXml, 'utf8'));
565
+ zip.writeZip(xlsxPath);
566
+ }
567
+ }
568
+
569
+ function splitIntoChunks(str, size) {
570
+ const chunks = [];
571
+ for (let i = 0; i < str.length; i += size) {
572
+ chunks.push(str.slice(i, i + size));
573
+ }
574
+ return chunks;
575
+ }
576
+
577
+ function columnToLetter(col) {
578
+ let letter = '';
579
+ while (col > 0) {
580
+ const mod = (col - 1) % 26;
581
+ letter = String.fromCharCode(65 + mod) + letter;
582
+ col = Math.floor((col - 1) / 26);
583
+ }
584
+ return letter;
585
+ }
586
+
587
+ module.exports = {
588
+ // v5
589
+ createXlsxPartV5,
590
+ readXlsxV5,
591
+ detectXlsxVersion,
592
+ // v3/v4 legacy
593
+ createXlsxPartStreaming,
594
+ createXlsxWithBase64,
595
+ // Unified reader
596
+ readXlsxBase64,
597
+ };