stegdoc 1.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/LICENSE +21 -0
- package/README.md +214 -0
- package/bootstrap.js +33 -0
- package/package.json +60 -0
- package/src/commands/decode.js +201 -0
- package/src/commands/encode.js +346 -0
- package/src/commands/info.js +113 -0
- package/src/commands/verify.js +169 -0
- package/src/index.js +87 -0
- package/src/lib/compression.js +97 -0
- package/src/lib/crypto.js +118 -0
- package/src/lib/decoy-generator.js +306 -0
- package/src/lib/docx-handler.js +161 -0
- package/src/lib/file-handler.js +113 -0
- package/src/lib/file-utils.js +150 -0
- package/src/lib/interactive.js +190 -0
- package/src/lib/metadata.js +111 -0
- package/src/lib/utils.js +227 -0
- package/src/lib/xlsx-handler.js +359 -0
- package/src/lib/xml-utils.js +115 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create metadata object for encoding
|
|
3
|
+
* @param {object} options - Metadata options
|
|
4
|
+
* @param {string} options.originalFilename - Original filename
|
|
5
|
+
* @param {string} options.originalExtension - Original file extension
|
|
6
|
+
* @param {string} options.hash - Hash identifier for this file
|
|
7
|
+
* @param {number} options.partNumber - Part number (for split files)
|
|
8
|
+
* @param {number} options.totalParts - Total number of parts
|
|
9
|
+
* @param {number} options.originalSize - Original file size in bytes
|
|
10
|
+
* @param {string} options.format - Output format ('xlsx' or 'docx')
|
|
11
|
+
* @param {boolean} options.encrypted - Whether the content is encrypted
|
|
12
|
+
* @param {boolean} options.compressed - Whether the content is compressed
|
|
13
|
+
* @param {string} options.contentHash - SHA-256 hash of original file for integrity verification
|
|
14
|
+
* @returns {object} Metadata object
|
|
15
|
+
*/
|
|
16
|
+
function createMetadata({
|
|
17
|
+
originalFilename,
|
|
18
|
+
originalExtension,
|
|
19
|
+
hash,
|
|
20
|
+
partNumber = null,
|
|
21
|
+
totalParts = null,
|
|
22
|
+
originalSize = 0,
|
|
23
|
+
format = 'xlsx',
|
|
24
|
+
encrypted = true,
|
|
25
|
+
compressed = false,
|
|
26
|
+
contentHash = null,
|
|
27
|
+
}) {
|
|
28
|
+
return {
|
|
29
|
+
originalFilename,
|
|
30
|
+
originalExtension,
|
|
31
|
+
hash,
|
|
32
|
+
partNumber,
|
|
33
|
+
totalParts,
|
|
34
|
+
originalSize,
|
|
35
|
+
format,
|
|
36
|
+
encrypted,
|
|
37
|
+
compressed,
|
|
38
|
+
contentHash,
|
|
39
|
+
encodingDate: new Date().toISOString(),
|
|
40
|
+
version: '1.0.0',
|
|
41
|
+
tool: 'stegdoc',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Serialize metadata to string format for storage in DOCX
|
|
47
|
+
* @param {object} metadata - Metadata object
|
|
48
|
+
* @returns {string} JSON string
|
|
49
|
+
*/
|
|
50
|
+
function serializeMetadata(metadata) {
|
|
51
|
+
return JSON.stringify(metadata);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse metadata from string
|
|
56
|
+
* @param {string} metadataStr - JSON string
|
|
57
|
+
* @returns {object} Metadata object
|
|
58
|
+
*/
|
|
59
|
+
function parseMetadata(metadataStr) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(metadataStr);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(`Failed to parse metadata: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate metadata object
|
|
69
|
+
* @param {object} metadata - Metadata to validate
|
|
70
|
+
* @returns {boolean} True if valid
|
|
71
|
+
* @throws {Error} If metadata is invalid
|
|
72
|
+
*/
|
|
73
|
+
function validateMetadata(metadata) {
|
|
74
|
+
const required = ['originalFilename', 'originalExtension', 'hash', 'tool'];
|
|
75
|
+
|
|
76
|
+
for (const field of required) {
|
|
77
|
+
if (!metadata[field]) {
|
|
78
|
+
throw new Error(`Missing required metadata field: ${field}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (metadata.tool !== 'stegdoc' && metadata.tool !== 'docstash' && metadata.tool !== 'whitener') {
|
|
83
|
+
throw new Error('Invalid tool identifier in metadata');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If it's a multi-part file, validate part info
|
|
87
|
+
if (metadata.totalParts !== null && metadata.totalParts > 1) {
|
|
88
|
+
if (metadata.partNumber === null || metadata.partNumber < 1 || metadata.partNumber > metadata.totalParts) {
|
|
89
|
+
throw new Error('Invalid part number in metadata');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if metadata indicates a multi-part file
|
|
98
|
+
* @param {object} metadata - Metadata object
|
|
99
|
+
* @returns {boolean} True if multi-part
|
|
100
|
+
*/
|
|
101
|
+
function isMultiPart(metadata) {
|
|
102
|
+
return metadata.totalParts !== null && metadata.totalParts > 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
createMetadata,
|
|
107
|
+
serializeMetadata,
|
|
108
|
+
parseMetadata,
|
|
109
|
+
validateMetadata,
|
|
110
|
+
isMultiPart,
|
|
111
|
+
};
|
package/src/lib/utils.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a random hash for file naming
|
|
5
|
+
* @param {number} length - Length of hash in bytes (default 8)
|
|
6
|
+
* @returns {string} Hexadecimal hash string
|
|
7
|
+
*/
|
|
8
|
+
function generateHash(length = 8) {
|
|
9
|
+
return crypto.randomBytes(length).toString('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate SHA-256 hash of a buffer for integrity verification
|
|
14
|
+
* @param {Buffer} buffer - Data to hash
|
|
15
|
+
* @returns {string} Hexadecimal SHA-256 hash
|
|
16
|
+
*/
|
|
17
|
+
function generateContentHash(buffer) {
|
|
18
|
+
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse size string to bytes
|
|
23
|
+
* @param {string} sizeStr - Size string (e.g., "5MB", "100KB", "1GB")
|
|
24
|
+
* @returns {number} Size in bytes
|
|
25
|
+
*/
|
|
26
|
+
function parseSizeToBytes(sizeStr) {
|
|
27
|
+
if (typeof sizeStr === 'number') {
|
|
28
|
+
return sizeStr;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const units = {
|
|
32
|
+
B: 1,
|
|
33
|
+
KB: 1024,
|
|
34
|
+
MB: 1024 * 1024,
|
|
35
|
+
GB: 1024 * 1024 * 1024,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const match = sizeStr.trim().toUpperCase().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/);
|
|
39
|
+
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(`Invalid size format: ${sizeStr}. Use format like "5MB", "100KB", etc.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const value = parseFloat(match[1]);
|
|
45
|
+
const unit = match[2] || 'B';
|
|
46
|
+
|
|
47
|
+
return Math.floor(value * units[unit]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format bytes to human-readable string
|
|
52
|
+
* @param {number} bytes - Number of bytes
|
|
53
|
+
* @param {number} decimals - Number of decimal places (default 2)
|
|
54
|
+
* @returns {string} Formatted size string
|
|
55
|
+
*/
|
|
56
|
+
function formatBytes(bytes, decimals = 2) {
|
|
57
|
+
if (bytes === 0) return '0 B';
|
|
58
|
+
|
|
59
|
+
const k = 1024;
|
|
60
|
+
const dm = decimals < 0 ? 0 : decimals;
|
|
61
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
62
|
+
|
|
63
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
64
|
+
|
|
65
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate a realistic-looking filename that matches the decoy theme
|
|
70
|
+
* Decoy data contains ~4 hours of server logs, so filenames reflect periodic reports
|
|
71
|
+
*
|
|
72
|
+
* @param {string} hash - Hash identifier (used internally for matching parts)
|
|
73
|
+
* @param {number} partNumber - Part number (optional, for split files)
|
|
74
|
+
* @param {number} totalParts - Total number of parts (optional)
|
|
75
|
+
* @param {string} format - Output format ('xlsx' or 'docx'), default 'xlsx'
|
|
76
|
+
* @returns {string} Realistic filename WITH extension
|
|
77
|
+
*/
|
|
78
|
+
function generateFilename(hash, partNumber = null, totalParts = null, format = 'xlsx') {
|
|
79
|
+
const ext = format.toLowerCase() === 'docx' ? 'docx' : 'xlsx';
|
|
80
|
+
|
|
81
|
+
// Generate deterministic timestamp based on hash (so all parts match)
|
|
82
|
+
// Use different parts of hash for day and hour to get better distribution
|
|
83
|
+
const hashNumLow = parseInt(hash.slice(0, 8), 16) >>> 0;
|
|
84
|
+
const hashNumHigh = parseInt(hash.slice(8, 16), 16) >>> 0;
|
|
85
|
+
const daysAgo = hashNumLow % 7; // 0-6 days ago
|
|
86
|
+
const hourBlock = (hashNumHigh % 6) * 4; // 0, 4, 8, 12, 16, 20
|
|
87
|
+
|
|
88
|
+
const now = new Date();
|
|
89
|
+
const date = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
|
|
90
|
+
|
|
91
|
+
// Format date in local timezone (YYYYMMDD)
|
|
92
|
+
const year = date.getFullYear();
|
|
93
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
94
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
95
|
+
const dateStr = `${year}${month}${day}`;
|
|
96
|
+
const timeStr = String(hourBlock).padStart(2, '0') + '00'; // HH00
|
|
97
|
+
|
|
98
|
+
// Use last 4 chars of hash as a "report ID" to keep files linked
|
|
99
|
+
const reportId = hash.slice(-4).toUpperCase();
|
|
100
|
+
|
|
101
|
+
if (format === 'xlsx') {
|
|
102
|
+
// Server metrics theme - looks like periodic 4-hour monitoring exports
|
|
103
|
+
if (partNumber !== null && totalParts !== null) {
|
|
104
|
+
return `server_metrics_${dateStr}_${timeStr}_${reportId}_part${partNumber}.${ext}`;
|
|
105
|
+
}
|
|
106
|
+
return `server_metrics_${dateStr}_${timeStr}_${reportId}.${ext}`;
|
|
107
|
+
} else {
|
|
108
|
+
// DOCX - use a different theme
|
|
109
|
+
if (partNumber !== null && totalParts !== null) {
|
|
110
|
+
return `system_report_${dateStr}_${timeStr}_${reportId}_part${partNumber}.${ext}`;
|
|
111
|
+
}
|
|
112
|
+
return `system_report_${dateStr}_${timeStr}_${reportId}.${ext}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Legacy alias for backward compatibility
|
|
118
|
+
*/
|
|
119
|
+
function generateDocxFilename(hash, partNumber = null, totalParts = null) {
|
|
120
|
+
return generateFilename(hash, partNumber, totalParts, 'docx').replace(/\.docx$/, '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse filename to extract hash, part information, and format
|
|
125
|
+
* @param {string} filename - Filename to parse
|
|
126
|
+
* @returns {object} Object with hash, partNumber, totalParts, baseHash, format
|
|
127
|
+
*/
|
|
128
|
+
function parseFilename(filename) {
|
|
129
|
+
// Detect format from extension
|
|
130
|
+
let format = 'xlsx';
|
|
131
|
+
let name = filename;
|
|
132
|
+
|
|
133
|
+
if (/\.docx$/i.test(filename)) {
|
|
134
|
+
format = 'docx';
|
|
135
|
+
name = filename.replace(/\.docx$/i, '');
|
|
136
|
+
} else if (/\.xlsx$/i.test(filename)) {
|
|
137
|
+
format = 'xlsx';
|
|
138
|
+
name = filename.replace(/\.xlsx$/i, '');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Try new realistic filename format first
|
|
142
|
+
// Pattern: server_metrics_YYYYMMDD_HH00_XXXX[_partN]
|
|
143
|
+
// Or: system_report_YYYYMMDD_HH00_XXXX[_partN]
|
|
144
|
+
const realisticPattern = /^(server_metrics|system_report)_(\d{8})_(\d{4})_([A-F0-9]{4})(?:_part(\d+))?$/i;
|
|
145
|
+
const realisticMatch = name.match(realisticPattern);
|
|
146
|
+
|
|
147
|
+
if (realisticMatch) {
|
|
148
|
+
const [, prefix, dateStr, timeStr, reportId, partStr] = realisticMatch;
|
|
149
|
+
// Create a pseudo-hash from the components for matching purposes
|
|
150
|
+
// The reportId is the last 4 chars of the original hash
|
|
151
|
+
const baseIdentifier = `${dateStr}_${timeStr}_${reportId}`;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
hash: name,
|
|
155
|
+
baseHash: baseIdentifier,
|
|
156
|
+
partNumber: partStr ? parseInt(partStr, 10) : null,
|
|
157
|
+
totalParts: null, // Will be determined from metadata
|
|
158
|
+
format,
|
|
159
|
+
// Extra info for matching
|
|
160
|
+
reportId: reportId.toUpperCase(),
|
|
161
|
+
dateStr,
|
|
162
|
+
timeStr,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Legacy: Check if it's a hex string (old format)
|
|
167
|
+
if (/^[a-f0-9]+$/i.test(name)) {
|
|
168
|
+
// Could be either single file or multi-part
|
|
169
|
+
// Single files are 16 chars (8 bytes), multi-part are 18+ chars (16 + 2 digit part number)
|
|
170
|
+
|
|
171
|
+
if (name.length === 16) {
|
|
172
|
+
// Single file
|
|
173
|
+
return {
|
|
174
|
+
hash: name,
|
|
175
|
+
baseHash: name,
|
|
176
|
+
partNumber: null,
|
|
177
|
+
totalParts: null,
|
|
178
|
+
format,
|
|
179
|
+
};
|
|
180
|
+
} else if (name.length >= 18) {
|
|
181
|
+
// Multi-part: last 2 chars are the part number
|
|
182
|
+
const baseHash = name.slice(0, -2);
|
|
183
|
+
const partNumber = parseInt(name.slice(-2), 10);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
hash: name,
|
|
187
|
+
baseHash: baseHash,
|
|
188
|
+
partNumber: partNumber,
|
|
189
|
+
totalParts: null, // Will be determined from metadata
|
|
190
|
+
format,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Legacy alias for backward compatibility
|
|
200
|
+
*/
|
|
201
|
+
function parseDocxFilename(filename) {
|
|
202
|
+
return parseFilename(filename);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Detect file format from extension
|
|
207
|
+
* @param {string} filename - Filename to check
|
|
208
|
+
* @returns {string} 'xlsx', 'docx', or null if unknown
|
|
209
|
+
*/
|
|
210
|
+
function detectFormat(filename) {
|
|
211
|
+
if (/\.xlsx$/i.test(filename)) return 'xlsx';
|
|
212
|
+
if (/\.docx$/i.test(filename)) return 'docx';
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
generateHash,
|
|
218
|
+
generateContentHash,
|
|
219
|
+
parseSizeToBytes,
|
|
220
|
+
formatBytes,
|
|
221
|
+
generateFilename,
|
|
222
|
+
parseFilename,
|
|
223
|
+
detectFormat,
|
|
224
|
+
// Legacy aliases for backward compatibility
|
|
225
|
+
generateDocxFilename,
|
|
226
|
+
parseDocxFilename,
|
|
227
|
+
};
|
|
@@ -0,0 +1,359 @@
|
|
|
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 with encrypted base64 content hidden in a veryHidden sheet
|
|
15
|
+
* @param {object} options - Options for creating the XLSX
|
|
16
|
+
* @param {string} options.base64Content - Encrypted base64 content to store
|
|
17
|
+
* @param {string} options.encryptionMeta - Packed encryption metadata (iv:salt:authTag)
|
|
18
|
+
* @param {object} options.metadata - Metadata object (serialized JSON)
|
|
19
|
+
* @param {string} options.outputPath - Output file path
|
|
20
|
+
* @returns {Promise<string>} Path to created file
|
|
21
|
+
*/
|
|
22
|
+
async function createXlsxWithBase64(options) {
|
|
23
|
+
const { base64Content, encryptionMeta, metadata, outputPath } = options;
|
|
24
|
+
|
|
25
|
+
const workbook = new ExcelJS.Workbook();
|
|
26
|
+
|
|
27
|
+
// Set workbook properties to look legitimate
|
|
28
|
+
workbook.creator = 'Microsoft Excel';
|
|
29
|
+
workbook.lastModifiedBy = 'Microsoft Excel';
|
|
30
|
+
workbook.created = new Date();
|
|
31
|
+
workbook.modified = new Date();
|
|
32
|
+
|
|
33
|
+
// === Sheet 1: Visible decoy data (server metrics) ===
|
|
34
|
+
const visibleSheet = workbook.addWorksheet(VISIBLE_SHEET_NAME, {
|
|
35
|
+
properties: { tabColor: { argb: '4472C4' } },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Add headers
|
|
39
|
+
const headers = generateDecoyHeaders();
|
|
40
|
+
const headerRow = visibleSheet.addRow(headers);
|
|
41
|
+
|
|
42
|
+
// Style header columns (10 columns for metrics data)
|
|
43
|
+
const headerCount = headers.length;
|
|
44
|
+
for (let col = 1; col <= headerCount; col++) {
|
|
45
|
+
const cell = headerRow.getCell(col);
|
|
46
|
+
cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } };
|
|
47
|
+
cell.fill = {
|
|
48
|
+
type: 'pattern',
|
|
49
|
+
pattern: 'solid',
|
|
50
|
+
fgColor: { argb: '2E7D32' }, // Green for metrics/monitoring theme
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Calculate decoy row count based on payload size
|
|
55
|
+
const payloadSize = base64Content.length;
|
|
56
|
+
const rowCount = calculateDecoyRowCount(payloadSize);
|
|
57
|
+
|
|
58
|
+
// Generate and add decoy data
|
|
59
|
+
const decoyData = generateDecoyData(rowCount);
|
|
60
|
+
decoyData.forEach((row) => {
|
|
61
|
+
visibleSheet.addRow(rowToArray(row));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Auto-fit columns for metrics data
|
|
65
|
+
visibleSheet.columns = [
|
|
66
|
+
{ width: 20 }, // Timestamp
|
|
67
|
+
{ width: 16 }, // Server ID
|
|
68
|
+
{ width: 12 }, // Status
|
|
69
|
+
{ width: 8 }, // CPU %
|
|
70
|
+
{ width: 10 }, // Memory %
|
|
71
|
+
{ width: 8 }, // Disk %
|
|
72
|
+
{ width: 14 }, // Network (MB/s)
|
|
73
|
+
{ width: 10 }, // Requests
|
|
74
|
+
{ width: 14 }, // Resp Time (ms)
|
|
75
|
+
{ width: 12 }, // Uptime (hrs)
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// Add filters to look more professional
|
|
79
|
+
visibleSheet.autoFilter = {
|
|
80
|
+
from: 'A1',
|
|
81
|
+
to: `J${rowCount + 1}`,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// === Sheet 2: Hidden payload (veryHidden) ===
|
|
85
|
+
const hiddenSheet = workbook.addWorksheet(HIDDEN_SHEET_NAME, {
|
|
86
|
+
state: 'veryHidden', // Not visible in Excel UI at all
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Store encryption metadata in cell A1
|
|
90
|
+
hiddenSheet.getCell('A1').value = encryptionMeta;
|
|
91
|
+
|
|
92
|
+
// Store serialized metadata in cell B1
|
|
93
|
+
hiddenSheet.getCell('B1').value = metadata;
|
|
94
|
+
|
|
95
|
+
// Split base64 content into chunks and store in cells
|
|
96
|
+
const chunks = splitIntoChunks(base64Content, CELL_CHUNK_SIZE);
|
|
97
|
+
|
|
98
|
+
// Store chunk count in C1 for reconstruction
|
|
99
|
+
hiddenSheet.getCell('C1').value = chunks.length.toString();
|
|
100
|
+
|
|
101
|
+
// Store chunks starting from A2
|
|
102
|
+
chunks.forEach((chunk, index) => {
|
|
103
|
+
const row = Math.floor(index / 26) + 2; // Start from row 2
|
|
104
|
+
const col = (index % 26) + 1; // Columns A-Z
|
|
105
|
+
hiddenSheet.getCell(row, col).value = chunk;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Ensure output directory exists
|
|
109
|
+
const outputDir = path.dirname(outputPath);
|
|
110
|
+
if (!fs.existsSync(outputDir)) {
|
|
111
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Write workbook to file
|
|
115
|
+
await workbook.xlsx.writeFile(outputPath);
|
|
116
|
+
|
|
117
|
+
// Post-process: ensure veryHidden state is set correctly
|
|
118
|
+
// ExcelJS sometimes has issues with veryHidden, so we fix it via XML
|
|
119
|
+
await ensureVeryHidden(outputPath);
|
|
120
|
+
|
|
121
|
+
return outputPath;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read an XLSX file and extract encrypted base64 content and metadata
|
|
126
|
+
* @param {string} xlsxPath - Path to XLSX file
|
|
127
|
+
* @returns {Promise<object>} Object containing base64Content, encryptionMeta, and metadata
|
|
128
|
+
*/
|
|
129
|
+
async function readXlsxBase64(xlsxPath) {
|
|
130
|
+
if (!fs.existsSync(xlsxPath)) {
|
|
131
|
+
throw new Error(`XLSX file not found: ${xlsxPath}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Try ExcelJS first, fall back to direct XML extraction if it fails
|
|
135
|
+
// (ExcelJS fails when namespace prefixes are changed, e.g., ns0:)
|
|
136
|
+
let hiddenSheet = null;
|
|
137
|
+
try {
|
|
138
|
+
const workbook = new ExcelJS.Workbook();
|
|
139
|
+
await workbook.xlsx.readFile(xlsxPath);
|
|
140
|
+
|
|
141
|
+
// Find the hidden sheet
|
|
142
|
+
workbook.eachSheet((sheet) => {
|
|
143
|
+
if (sheet.name === HIDDEN_SHEET_NAME || sheet.state === 'veryHidden') {
|
|
144
|
+
hiddenSheet = sheet;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
} catch (excelError) {
|
|
148
|
+
// ExcelJS failed (likely namespace issue), use direct XML extraction
|
|
149
|
+
return await extractFromXml(xlsxPath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// If not found via ExcelJS (veryHidden might be invisible), try direct XML extraction
|
|
153
|
+
if (!hiddenSheet) {
|
|
154
|
+
return await extractFromXml(xlsxPath);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Extract encryption metadata from A1 (may be empty if unencrypted)
|
|
158
|
+
const encryptionMeta = hiddenSheet.getCell('A1').value || '';
|
|
159
|
+
|
|
160
|
+
// Extract serialized metadata from B1
|
|
161
|
+
const metadata = hiddenSheet.getCell('B1').value;
|
|
162
|
+
if (!metadata) {
|
|
163
|
+
throw new Error('No metadata found in XLSX file.');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Get chunk count from C1
|
|
167
|
+
const chunkCountStr = hiddenSheet.getCell('C1').value;
|
|
168
|
+
const chunkCount = parseInt(chunkCountStr, 10);
|
|
169
|
+
|
|
170
|
+
if (isNaN(chunkCount) || chunkCount <= 0) {
|
|
171
|
+
throw new Error('Invalid chunk count in XLSX file.');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Reconstruct base64 content from chunks
|
|
175
|
+
const chunks = [];
|
|
176
|
+
for (let i = 0; i < chunkCount; i++) {
|
|
177
|
+
const row = Math.floor(i / 26) + 2;
|
|
178
|
+
const col = (i % 26) + 1;
|
|
179
|
+
const chunk = hiddenSheet.getCell(row, col).value;
|
|
180
|
+
if (chunk) {
|
|
181
|
+
chunks.push(chunk);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const base64Content = chunks.join('');
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
base64Content,
|
|
189
|
+
encryptionMeta,
|
|
190
|
+
metadata,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Extract data directly from XLSX XML using proper XML parser
|
|
196
|
+
* Handles any namespace prefix (default, ns0:, ns1:, etc.)
|
|
197
|
+
* @param {string} xlsxPath - Path to XLSX file
|
|
198
|
+
* @returns {Promise<object>} Extracted data
|
|
199
|
+
*/
|
|
200
|
+
async function extractFromXml(xlsxPath) {
|
|
201
|
+
// Parse shared strings using namespace-agnostic parser
|
|
202
|
+
let sharedStrings = [];
|
|
203
|
+
const ssParsed = parseXmlFromZip(xlsxPath, 'xl/sharedStrings.xml');
|
|
204
|
+
|
|
205
|
+
if (ssParsed && ssParsed.sst && ssParsed.sst.si) {
|
|
206
|
+
const siArray = ensureArray(ssParsed.sst.si);
|
|
207
|
+
sharedStrings = siArray.map((si) => extractTextContent(si.t));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Parse the hidden sheet (sheet2.xml is our default location)
|
|
211
|
+
const sheetParsed = parseXmlFromZip(xlsxPath, 'xl/worksheets/sheet2.xml');
|
|
212
|
+
|
|
213
|
+
if (!sheetParsed) {
|
|
214
|
+
throw new Error('Hidden sheet not found in XLSX file. This may not be a whitener-encoded file.');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Build cell map from worksheet > sheetData > row > c
|
|
218
|
+
const cellValues = new Map();
|
|
219
|
+
const sheetData = sheetParsed.worksheet?.sheetData;
|
|
220
|
+
|
|
221
|
+
if (sheetData && sheetData.row) {
|
|
222
|
+
const rows = ensureArray(sheetData.row);
|
|
223
|
+
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
if (!row.c) continue;
|
|
226
|
+
const cells = ensureArray(row.c);
|
|
227
|
+
|
|
228
|
+
for (const cell of cells) {
|
|
229
|
+
const cellRef = cell['@_r']; // e.g., "A1", "B1"
|
|
230
|
+
const cellType = cell['@_t']; // "s" for shared string
|
|
231
|
+
const cellValue = cell.v;
|
|
232
|
+
|
|
233
|
+
if (cellRef === undefined) continue;
|
|
234
|
+
|
|
235
|
+
if (cellType === 's' && cellValue !== undefined) {
|
|
236
|
+
// Shared string reference
|
|
237
|
+
const ssIndex = parseInt(cellValue, 10);
|
|
238
|
+
if (ssIndex < sharedStrings.length) {
|
|
239
|
+
cellValues.set(cellRef, sharedStrings[ssIndex]);
|
|
240
|
+
}
|
|
241
|
+
} else if (cellValue !== undefined) {
|
|
242
|
+
cellValues.set(cellRef, String(cellValue));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Extract our data
|
|
249
|
+
const encryptionMeta = cellValues.get('A1') || ''; // May be empty if unencrypted
|
|
250
|
+
const metadata = cellValues.get('B1');
|
|
251
|
+
const chunkCountStr = cellValues.get('C1');
|
|
252
|
+
|
|
253
|
+
if (!metadata) {
|
|
254
|
+
throw new Error('No metadata found in XLSX file.');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const chunkCount = parseInt(chunkCountStr, 10);
|
|
258
|
+
if (isNaN(chunkCount) || chunkCount <= 0) {
|
|
259
|
+
throw new Error('Invalid chunk count in XLSX file.');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Reconstruct chunks
|
|
263
|
+
const chunks = [];
|
|
264
|
+
for (let i = 0; i < chunkCount; i++) {
|
|
265
|
+
const row = Math.floor(i / 26) + 2;
|
|
266
|
+
const col = (i % 26) + 1;
|
|
267
|
+
const cellRef = `${columnToLetter(col)}${row}`;
|
|
268
|
+
const chunk = cellValues.get(cellRef);
|
|
269
|
+
if (chunk) {
|
|
270
|
+
chunks.push(chunk);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
base64Content: chunks.join(''),
|
|
276
|
+
encryptionMeta,
|
|
277
|
+
metadata,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Ensure the hidden sheet is truly veryHidden by modifying the workbook.xml
|
|
283
|
+
* @param {string} xlsxPath - Path to XLSX file
|
|
284
|
+
*/
|
|
285
|
+
async function ensureVeryHidden(xlsxPath) {
|
|
286
|
+
const zip = new AdmZip(xlsxPath);
|
|
287
|
+
|
|
288
|
+
// Modify workbook.xml to ensure veryHidden state
|
|
289
|
+
const workbookEntry = zip.getEntry('xl/workbook.xml');
|
|
290
|
+
if (workbookEntry) {
|
|
291
|
+
let workbookXml = workbookEntry.getData().toString('utf8');
|
|
292
|
+
|
|
293
|
+
// Find the Data sheet and fix its state to veryHidden
|
|
294
|
+
// First, remove any existing state attribute from the Data sheet
|
|
295
|
+
workbookXml = workbookXml.replace(
|
|
296
|
+
/(<sheet[^>]*name="Data"[^>]*)\s+state="[^"]*"([^>]*\/>)/gi,
|
|
297
|
+
'$1 state="veryHidden"$2'
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// If no state attribute was present, add one
|
|
301
|
+
if (!workbookXml.match(/<sheet[^>]*name="Data"[^>]*state="/i)) {
|
|
302
|
+
workbookXml = workbookXml.replace(
|
|
303
|
+
/(<sheet[^>]*name="Data")([^>]*\/>)/gi,
|
|
304
|
+
'$1 state="veryHidden"$2'
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
zip.updateFile('xl/workbook.xml', Buffer.from(workbookXml, 'utf8'));
|
|
309
|
+
zip.writeZip(xlsxPath);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Split a string into chunks of specified size
|
|
315
|
+
* @param {string} str - String to split
|
|
316
|
+
* @param {number} size - Max chunk size
|
|
317
|
+
* @returns {Array<string>} Array of chunks
|
|
318
|
+
*/
|
|
319
|
+
function splitIntoChunks(str, size) {
|
|
320
|
+
const chunks = [];
|
|
321
|
+
for (let i = 0; i < str.length; i += size) {
|
|
322
|
+
chunks.push(str.slice(i, i + size));
|
|
323
|
+
}
|
|
324
|
+
return chunks;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Convert column number to letter (1=A, 2=B, ..., 27=AA)
|
|
329
|
+
* @param {number} col - Column number (1-based)
|
|
330
|
+
* @returns {string} Column letter
|
|
331
|
+
*/
|
|
332
|
+
function columnToLetter(col) {
|
|
333
|
+
let letter = '';
|
|
334
|
+
while (col > 0) {
|
|
335
|
+
const mod = (col - 1) % 26;
|
|
336
|
+
letter = String.fromCharCode(65 + mod) + letter;
|
|
337
|
+
col = Math.floor((col - 1) / 26);
|
|
338
|
+
}
|
|
339
|
+
return letter;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Decode XML entities
|
|
344
|
+
* @param {string} str - String with XML entities
|
|
345
|
+
* @returns {string} Decoded string
|
|
346
|
+
*/
|
|
347
|
+
function decodeXmlEntities(str) {
|
|
348
|
+
return str
|
|
349
|
+
.replace(/"/g, '"')
|
|
350
|
+
.replace(/'/g, "'")
|
|
351
|
+
.replace(/</g, '<')
|
|
352
|
+
.replace(/>/g, '>')
|
|
353
|
+
.replace(/&/g, '&');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
createXlsxWithBase64,
|
|
358
|
+
readXlsxBase64,
|
|
359
|
+
};
|