stegdoc 5.6.0 → 6.0.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/README.md +231 -106
- package/package.json +25 -3
- package/src/commands/decode.js +97 -149
- package/src/commands/encode.js +152 -173
- package/src/commands/info.js +49 -0
- package/src/commands/verify.js +66 -0
- package/src/index.js +9 -5
- package/src/lib/bindings.js +99 -0
- package/src/lib/log-generator.js +5 -2
- package/src/lib/native.js +68 -0
- package/src/lib/streams.js +0 -107
- package/src/lib/utils.js +20 -0
- package/src/lib/xlsx-handler.js +22 -321
- package/src/lib/xlsx-writer.js +308 -0
- package/src/lib/file-handler.js +0 -113
package/src/lib/log-generator.js
CHANGED
|
@@ -657,9 +657,12 @@ function decodeLogLines(allRows) {
|
|
|
657
657
|
const headerStr = headerPayload.toString('utf8');
|
|
658
658
|
|
|
659
659
|
// Parse header: "STGD05|<metaLen>|<encLen>|{metadataJson}{encryptionMeta}"
|
|
660
|
-
const markerIdx = headerStr.
|
|
660
|
+
const markerIdx = headerStr.search(/STGD0[56]\|/);
|
|
661
661
|
if (markerIdx === -1) {
|
|
662
|
-
throw new Error('Invalid
|
|
662
|
+
throw new Error('Invalid log-embed format: magic marker not found. This may not be a stegdoc file.');
|
|
663
|
+
}
|
|
664
|
+
if (headerStr.startsWith('STGD06|', markerIdx)) {
|
|
665
|
+
throw new Error('This file uses the v6 format, which the JavaScript decoder cannot read. The native engine is required.');
|
|
663
666
|
}
|
|
664
667
|
|
|
665
668
|
const afterMarker = headerStr.slice(markerIdx + 7);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const { hostTarget, packageName } = require('./bindings');
|
|
6
|
+
|
|
7
|
+
const NATIVE_DIR = path.join(__dirname, '..', '..', 'native');
|
|
8
|
+
|
|
9
|
+
let binding;
|
|
10
|
+
let resolved = false;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load the native format engine, or return null when it is not available.
|
|
14
|
+
*
|
|
15
|
+
* Resolution order:
|
|
16
|
+
* 1. `STEGDOC_NATIVE_PATH`, an explicit `.node` file.
|
|
17
|
+
* 2. The platform package (`@stegdoc/binding-<suffix>`) from the published
|
|
18
|
+
* install's optionalDependencies.
|
|
19
|
+
* 3. `native/stegdoc.node`, the local `pnpm build:native` output.
|
|
20
|
+
*
|
|
21
|
+
* The binding is required for log-embed (v5/v6) decode and is the preferred
|
|
22
|
+
* encoder. The CommonJS implementation remains only for the legacy v3/v4
|
|
23
|
+
* formats, which the native engine does not cover. Set
|
|
24
|
+
* `STEGDOC_DISABLE_NATIVE=1` to force the JS paths.
|
|
25
|
+
*
|
|
26
|
+
* @returns {object|null} The binding, or null when unavailable.
|
|
27
|
+
*/
|
|
28
|
+
function loadNative() {
|
|
29
|
+
if (resolved) return binding;
|
|
30
|
+
resolved = true;
|
|
31
|
+
|
|
32
|
+
if (process.env.STEGDOC_DISABLE_NATIVE === '1') {
|
|
33
|
+
binding = null;
|
|
34
|
+
return binding;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const explicit = process.env.STEGDOC_NATIVE_PATH;
|
|
38
|
+
if (explicit) {
|
|
39
|
+
binding = require(explicit);
|
|
40
|
+
return binding;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const target = hostTarget();
|
|
44
|
+
if (target) {
|
|
45
|
+
try {
|
|
46
|
+
binding = require(packageName(target));
|
|
47
|
+
return binding;
|
|
48
|
+
} catch {
|
|
49
|
+
// Not installed; fall through to the development build.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
binding = require(path.join(NATIVE_DIR, 'stegdoc.node'));
|
|
55
|
+
} catch {
|
|
56
|
+
binding = null;
|
|
57
|
+
}
|
|
58
|
+
return binding;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @returns {boolean} Whether the native engine is loaded.
|
|
63
|
+
*/
|
|
64
|
+
function nativeAvailable() {
|
|
65
|
+
return loadNative() !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { loadNative, nativeAvailable };
|
package/src/lib/streams.js
CHANGED
|
@@ -1,65 +1,6 @@
|
|
|
1
1
|
const { Transform, Writable } = require('stream');
|
|
2
2
|
const crypto = require('crypto');
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Transform stream that converts binary input to base64 text output.
|
|
6
|
-
* Buffers incomplete 3-byte groups across chunk boundaries.
|
|
7
|
-
*/
|
|
8
|
-
class Base64EncodeTransform extends Transform {
|
|
9
|
-
constructor() {
|
|
10
|
-
super();
|
|
11
|
-
this._remainder = Buffer.alloc(0);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
_transform(chunk, encoding, callback) {
|
|
15
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
16
|
-
const combined = this._remainder.length > 0 ? Buffer.concat([this._remainder, buf]) : buf;
|
|
17
|
-
const usable = combined.length - (combined.length % 3);
|
|
18
|
-
if (usable > 0) {
|
|
19
|
-
this.push(combined.slice(0, usable).toString('base64'));
|
|
20
|
-
}
|
|
21
|
-
this._remainder = usable < combined.length ? combined.slice(usable) : Buffer.alloc(0);
|
|
22
|
-
callback();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
_flush(callback) {
|
|
26
|
-
if (this._remainder.length > 0) {
|
|
27
|
-
this.push(this._remainder.toString('base64'));
|
|
28
|
-
this._remainder = Buffer.alloc(0);
|
|
29
|
-
}
|
|
30
|
-
callback();
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Transform stream that converts base64 text input to binary output.
|
|
36
|
-
* Buffers incomplete 4-char groups across chunk boundaries.
|
|
37
|
-
*/
|
|
38
|
-
class Base64DecodeTransform extends Transform {
|
|
39
|
-
constructor() {
|
|
40
|
-
super();
|
|
41
|
-
this._remainder = '';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
_transform(chunk, encoding, callback) {
|
|
45
|
-
const str = this._remainder + (Buffer.isBuffer(chunk) ? chunk.toString() : chunk);
|
|
46
|
-
const usable = str.length - (str.length % 4);
|
|
47
|
-
if (usable > 0) {
|
|
48
|
-
this.push(Buffer.from(str.slice(0, usable), 'base64'));
|
|
49
|
-
}
|
|
50
|
-
this._remainder = usable < str.length ? str.slice(usable) : '';
|
|
51
|
-
callback();
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
_flush(callback) {
|
|
55
|
-
if (this._remainder.length > 0) {
|
|
56
|
-
this.push(Buffer.from(this._remainder, 'base64'));
|
|
57
|
-
this._remainder = '';
|
|
58
|
-
}
|
|
59
|
-
callback();
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
4
|
/**
|
|
64
5
|
* Transform stream that passes data through unchanged while computing SHA-256 hash.
|
|
65
6
|
* Access the hex hash via .digest after the stream has ended.
|
|
@@ -90,51 +31,6 @@ class HashPassthrough extends Transform {
|
|
|
90
31
|
}
|
|
91
32
|
}
|
|
92
33
|
|
|
93
|
-
/**
|
|
94
|
-
* Writable stream that collects string output up to maxBytes.
|
|
95
|
-
* Calls an async onChunkReady callback when a chunk is full, applying
|
|
96
|
-
* backpressure to pause upstream until the callback resolves.
|
|
97
|
-
*/
|
|
98
|
-
class ChunkCollector extends Writable {
|
|
99
|
-
constructor(maxBytes, onChunkReady) {
|
|
100
|
-
super({ decodeStrings: false });
|
|
101
|
-
this._maxBytes = maxBytes;
|
|
102
|
-
this._buffer = '';
|
|
103
|
-
this._chunkIndex = 0;
|
|
104
|
-
this._onChunkReady = onChunkReady;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async _write(chunk, encoding, callback) {
|
|
108
|
-
try {
|
|
109
|
-
this._buffer += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
110
|
-
while (this._buffer.length >= this._maxBytes) {
|
|
111
|
-
const piece = this._buffer.slice(0, this._maxBytes);
|
|
112
|
-
this._buffer = this._buffer.slice(this._maxBytes);
|
|
113
|
-
await this._onChunkReady(piece, this._chunkIndex++);
|
|
114
|
-
}
|
|
115
|
-
callback();
|
|
116
|
-
} catch (err) {
|
|
117
|
-
callback(err);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async _final(callback) {
|
|
122
|
-
try {
|
|
123
|
-
if (this._buffer.length > 0) {
|
|
124
|
-
await this._onChunkReady(this._buffer, this._chunkIndex++);
|
|
125
|
-
this._buffer = '';
|
|
126
|
-
}
|
|
127
|
-
callback();
|
|
128
|
-
} catch (err) {
|
|
129
|
-
callback(err);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
get totalChunks() {
|
|
134
|
-
return this._chunkIndex;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
34
|
/**
|
|
139
35
|
* Writable stream that collects binary Buffer output up to maxBytes.
|
|
140
36
|
* Calls an async onChunkReady callback with a Buffer when full.
|
|
@@ -221,10 +117,7 @@ class ProgressTransform extends Transform {
|
|
|
221
117
|
}
|
|
222
118
|
|
|
223
119
|
module.exports = {
|
|
224
|
-
Base64EncodeTransform,
|
|
225
|
-
Base64DecodeTransform,
|
|
226
120
|
HashPassthrough,
|
|
227
|
-
ChunkCollector,
|
|
228
121
|
BinaryChunkCollector,
|
|
229
122
|
ProgressTransform,
|
|
230
123
|
};
|
package/src/lib/utils.js
CHANGED
|
@@ -213,6 +213,25 @@ function detectFormat(filename) {
|
|
|
213
213
|
return null;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Reduce a metadata-supplied name to a bare, safe filename.
|
|
218
|
+
*
|
|
219
|
+
* `originalFilename` is unauthenticated, so it is never joined onto a directory
|
|
220
|
+
* unchecked (FORMAT.md 11.1). Any directory component is discarded and the
|
|
221
|
+
* remainder must not be empty, `.`, `..`, or contain NUL.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} name - Metadata-supplied filename
|
|
224
|
+
* @returns {string} A bare filename safe to join onto a directory
|
|
225
|
+
* @throws {Error} If the name has no usable filename component
|
|
226
|
+
*/
|
|
227
|
+
function safeFilename(name) {
|
|
228
|
+
const last = String(name == null ? '' : name).split(/[/\\]/).pop();
|
|
229
|
+
if (!last || last === '.' || last === '..' || last.includes('\0')) {
|
|
230
|
+
throw new Error(`Unsafe output filename in metadata: ${JSON.stringify(name)}`);
|
|
231
|
+
}
|
|
232
|
+
return last;
|
|
233
|
+
}
|
|
234
|
+
|
|
216
235
|
module.exports = {
|
|
217
236
|
generateHash,
|
|
218
237
|
generateContentHash,
|
|
@@ -221,6 +240,7 @@ module.exports = {
|
|
|
221
240
|
generateFilename,
|
|
222
241
|
parseFilename,
|
|
223
242
|
detectFormat,
|
|
243
|
+
safeFilename,
|
|
224
244
|
// Legacy aliases for backward compatibility
|
|
225
245
|
generateDocxFilename,
|
|
226
246
|
parseDocxFilename,
|
package/src/lib/xlsx-handler.js
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
//
|
|
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 —
|
|
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
|
|
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
|
-
//
|
|
612
|
-
createXlsxPartStreaming,
|
|
613
|
-
createXlsxWithBase64,
|
|
614
|
-
// Unified reader
|
|
315
|
+
// Unified reader (auto-detects v5 vs legacy)
|
|
615
316
|
readXlsxBase64,
|
|
616
317
|
};
|