stegdoc 6.0.0 → 6.1.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/src/index.js CHANGED
@@ -24,7 +24,6 @@ program
24
24
  .option('-f, --format <format>', 'Output format: xlsx (default) or docx', 'xlsx')
25
25
  .option('-p, --password <password>', 'Encryption password (optional, but recommended)')
26
26
  .option('--force', 'Overwrite existing files without asking')
27
- .option('--legacy', 'Use v4 format (hidden sheet + gzip) for backward compatibility')
28
27
  .option('--v5', 'Emit the v5 format (PBKDF2) instead of the v6 default')
29
28
  .option('--v6', 'Emit the v6 format (default; Argon2id, authenticated metadata)')
30
29
  .option('--no-limit', 'Bypass DOCX 1 MB size limit (large files will produce huge documents)')
@@ -9,40 +9,6 @@ const zlib = require('zlib');
9
9
  */
10
10
  const BROTLI_DEFAULT_QUALITY = 6;
11
11
 
12
- /**
13
- * Compress data using Brotli (used in v5+)
14
- * @param {Buffer} buffer - Data to compress
15
- * @param {number} [quality] - Compression quality (0-11, default 6)
16
- * @returns {Promise<Buffer>} Compressed data
17
- */
18
- function compressBrotli(buffer, quality) {
19
- const q = quality !== undefined ? quality : BROTLI_DEFAULT_QUALITY;
20
- return new Promise((resolve, reject) => {
21
- zlib.brotliCompress(buffer, {
22
- params: {
23
- [zlib.constants.BROTLI_PARAM_QUALITY]: q,
24
- },
25
- }, (err, result) => {
26
- if (err) reject(err);
27
- else resolve(result);
28
- });
29
- });
30
- }
31
-
32
- /**
33
- * Decompress Brotli data
34
- * @param {Buffer} buffer - Compressed data
35
- * @returns {Promise<Buffer>} Decompressed data
36
- */
37
- function decompressBrotli(buffer) {
38
- return new Promise((resolve, reject) => {
39
- zlib.brotliDecompress(buffer, (err, result) => {
40
- if (err) reject(err);
41
- else resolve(result);
42
- });
43
- });
44
- }
45
-
46
12
  /**
47
13
  * Create a streaming Brotli compression transform
48
14
  * @param {number} [quality] - Compression quality (0-11, default 6)
@@ -57,130 +23,4 @@ function createBrotliCompressStream(quality) {
57
23
  });
58
24
  }
59
25
 
60
- /**
61
- * Create a streaming Brotli decompression transform
62
- * @returns {zlib.BrotliDecompress} Brotli transform stream
63
- */
64
- function createBrotliDecompressStream() {
65
- return zlib.createBrotliDecompress();
66
- }
67
-
68
- // ─── Gzip Compression (v3/v4 legacy) ───────────────────────────────────────
69
-
70
- /**
71
- * MIME types that are already compressed - no benefit from additional compression
72
- */
73
- const COMPRESSED_MIMES = new Set([
74
- // Archives
75
- 'application/zip',
76
- 'application/x-7z-compressed',
77
- 'application/x-rar-compressed',
78
- 'application/gzip',
79
- 'application/x-gzip',
80
- 'application/x-bzip2',
81
- 'application/x-xz',
82
- 'application/x-tar',
83
- 'application/x-lzip',
84
- 'application/x-lzma',
85
- 'application/zstd',
86
-
87
- // Images (lossy compressed)
88
- 'image/jpeg',
89
- 'image/png',
90
- 'image/gif',
91
- 'image/webp',
92
- 'image/avif',
93
- 'image/heic',
94
- 'image/heif',
95
- 'image/jxl',
96
-
97
- // Audio
98
- 'audio/mpeg', // mp3
99
- 'audio/ogg',
100
- 'audio/flac',
101
- 'audio/aac',
102
- 'audio/mp4',
103
- 'audio/x-m4a',
104
- 'audio/opus',
105
-
106
- // Video
107
- 'video/mp4',
108
- 'video/webm',
109
- 'video/x-matroska', // mkv
110
- 'video/quicktime', // mov
111
- 'video/x-msvideo', // avi
112
- 'video/mpeg',
113
-
114
- // Documents (OOXML are zip-based)
115
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
116
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
117
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // pptx
118
- 'application/pdf',
119
- 'application/epub+zip',
120
- ]);
121
-
122
- /**
123
- * Check if a file type is already compressed
124
- * @param {string|null} mime - MIME type from file-type detection
125
- * @returns {boolean}
126
- */
127
- function isCompressedMime(mime) {
128
- return mime ? COMPRESSED_MIMES.has(mime) : false;
129
- }
130
-
131
- /**
132
- * Compress data using gzip
133
- * @param {Buffer} buffer - Data to compress
134
- * @returns {Promise<Buffer>} Compressed data
135
- */
136
- function compress(buffer) {
137
- return new Promise((resolve, reject) => {
138
- zlib.gzip(buffer, { level: 9 }, (err, result) => {
139
- if (err) reject(err);
140
- else resolve(result);
141
- });
142
- });
143
- }
144
-
145
- /**
146
- * Decompress gzip data
147
- * @param {Buffer} buffer - Compressed data
148
- * @returns {Promise<Buffer>} Decompressed data
149
- */
150
- function decompress(buffer) {
151
- return new Promise((resolve, reject) => {
152
- zlib.gunzip(buffer, (err, result) => {
153
- if (err) reject(err);
154
- else resolve(result);
155
- });
156
- });
157
- }
158
-
159
- /**
160
- * Create a streaming gzip compression transform
161
- * @returns {zlib.Gzip} Gzip transform stream
162
- */
163
- function createCompressStream() {
164
- return zlib.createGzip({ level: 9 });
165
- }
166
-
167
- /**
168
- * Create a streaming gunzip decompression transform
169
- * @returns {zlib.Gunzip} Gunzip transform stream
170
- */
171
- function createDecompressStream() {
172
- return zlib.createGunzip();
173
- }
174
-
175
- module.exports = {
176
- isCompressedMime,
177
- compress,
178
- decompress,
179
- createCompressStream,
180
- createDecompressStream,
181
- compressBrotli,
182
- decompressBrotli,
183
- createBrotliCompressStream,
184
- createBrotliDecompressStream,
185
- COMPRESSED_MIMES,
186
- };
26
+ module.exports = { createBrotliCompressStream };
package/src/lib/crypto.js CHANGED
@@ -18,75 +18,6 @@ function deriveKey(password, salt) {
18
18
  return crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
19
19
  }
20
20
 
21
- /**
22
- * Encrypt plaintext using AES-256-GCM
23
- * @param {string} plaintext - Data to encrypt
24
- * @param {string} password - User password
25
- * @returns {object} { ciphertext, iv, salt, authTag } - all as base64 strings
26
- */
27
- function encrypt(plaintext, password) {
28
- // Generate random salt and IV
29
- const salt = crypto.randomBytes(SALT_LENGTH);
30
- const iv = crypto.randomBytes(IV_LENGTH);
31
-
32
- // Derive key from password
33
- const key = deriveKey(password, salt);
34
-
35
- // Create cipher and encrypt
36
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
37
- authTagLength: AUTH_TAG_LENGTH,
38
- });
39
-
40
- let ciphertext = cipher.update(plaintext, 'utf8', 'base64');
41
- ciphertext += cipher.final('base64');
42
-
43
- // Get authentication tag
44
- const authTag = cipher.getAuthTag();
45
-
46
- return {
47
- ciphertext,
48
- iv: iv.toString('base64'),
49
- salt: salt.toString('base64'),
50
- authTag: authTag.toString('base64'),
51
- };
52
- }
53
-
54
- /**
55
- * Decrypt ciphertext using AES-256-GCM
56
- * @param {string} ciphertext - Encrypted data (base64)
57
- * @param {string} password - User password
58
- * @param {string} ivBase64 - Initialization vector (base64)
59
- * @param {string} saltBase64 - Salt used for key derivation (base64)
60
- * @param {string} authTagBase64 - Authentication tag (base64)
61
- * @returns {string} Decrypted plaintext
62
- * @throws {Error} If decryption fails (wrong password or tampered data)
63
- */
64
- function decrypt(ciphertext, password, ivBase64, saltBase64, authTagBase64) {
65
- // Convert from base64
66
- const iv = Buffer.from(ivBase64, 'base64');
67
- const salt = Buffer.from(saltBase64, 'base64');
68
- const authTag = Buffer.from(authTagBase64, 'base64');
69
-
70
- // Derive key from password
71
- const key = deriveKey(password, salt);
72
-
73
- // Create decipher
74
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
75
- authTagLength: AUTH_TAG_LENGTH,
76
- });
77
-
78
- // Set auth tag for verification
79
- decipher.setAuthTag(authTag);
80
-
81
- try {
82
- let plaintext = decipher.update(ciphertext, 'base64', 'utf8');
83
- plaintext += decipher.final('utf8');
84
- return plaintext;
85
- } catch (error) {
86
- throw new Error('Decryption failed: Invalid password or corrupted data');
87
- }
88
- }
89
-
90
21
  /**
91
22
  * Pack encryption metadata into a single string for storage
92
23
  * @param {object} encryptionData - { iv, salt, authTag }
@@ -96,19 +27,6 @@ function packEncryptionMeta(encryptionData) {
96
27
  return `${encryptionData.iv}:${encryptionData.salt}:${encryptionData.authTag}`;
97
28
  }
98
29
 
99
- /**
100
- * Unpack encryption metadata from storage string
101
- * @param {string} packed - Packed string (iv:salt:authTag)
102
- * @returns {object} { iv, salt, authTag }
103
- */
104
- function unpackEncryptionMeta(packed) {
105
- const [iv, salt, authTag] = packed.split(':');
106
- if (!iv || !salt || !authTag) {
107
- throw new Error('Invalid encryption metadata format');
108
- }
109
- return { iv, salt, authTag };
110
- }
111
-
112
30
  /**
113
31
  * Generate a random salt for key derivation (one per encode session)
114
32
  * @returns {Buffer} Random salt
@@ -139,34 +57,9 @@ function createEncryptStream(password, salt) {
139
57
  };
140
58
  }
141
59
 
142
- /**
143
- * Create a streaming decrypt decipher for per-part decryption.
144
- * Auth tag is set immediately and verified on .final().
145
- * @param {string} password - User password
146
- * @param {string} ivBase64 - IV (base64)
147
- * @param {string} saltBase64 - Salt (base64)
148
- * @param {string} authTagBase64 - Auth tag (base64)
149
- * @returns {crypto.Decipher} Decipher transform stream
150
- */
151
- function createDecryptStream(password, ivBase64, saltBase64, authTagBase64) {
152
- const iv = Buffer.from(ivBase64, 'base64');
153
- const salt = Buffer.from(saltBase64, 'base64');
154
- const authTag = Buffer.from(authTagBase64, 'base64');
155
- const key = deriveKey(password, salt);
156
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
157
- authTagLength: AUTH_TAG_LENGTH,
158
- });
159
- decipher.setAuthTag(authTag);
160
- return decipher;
161
- }
162
-
163
60
  module.exports = {
164
- encrypt,
165
- decrypt,
166
61
  deriveKey,
167
62
  packEncryptionMeta,
168
- unpackEncryptionMeta,
169
63
  generateSalt,
170
64
  createEncryptStream,
171
- createDecryptStream,
172
65
  };
@@ -1,16 +1,11 @@
1
1
  const {
2
2
  Document, Paragraph, TextRun, Packer, Table, TableRow, TableCell,
3
- AlignmentType, HeadingLevel, WidthType, ShadingType, BorderStyle, PageBreak,
3
+ AlignmentType, HeadingLevel, WidthType, ShadingType,
4
4
  } = require('docx');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { serializeMetadata, parseMetadata } = require('./metadata');
8
- const { parseXmlFromZip, ensureArray, extractTextContent } = require('./xml-utils');
9
7
  const { generateIncident, generateHebrewDate } = require('./docx-templates');
10
- const {
11
- encodePayloadToLogLines, decodeLogLines, generateLogHeaders, resetTimeState,
12
- BYTES_PER_DATA_LINE, calculateDataLineCount,
13
- } = require('./log-generator');
8
+ const { encodePayloadToLogLines, resetTimeState } = require('./log-generator');
14
9
 
15
10
  // ─── Shared Styles ──────────────────────────────────────────────────────────
16
11
 
@@ -311,277 +306,6 @@ function createHebrewCell(text, isHeader, widthPct) {
311
306
  });
312
307
  }
313
308
 
314
- // ─── v5 DOCX Reader ─────────────────────────────────────────────────────────
315
-
316
- /**
317
- * Read a v5 log-embed DOCX file and extract payload.
318
- * Scans for monospace paragraphs that contain log lines.
319
- * @param {string} docxPath - Path to DOCX file
320
- * @returns {object} { payloadBuffer, metadataJson, encryptionMeta, metadata, formatVersion }
321
- */
322
- async function readDocxV5(docxPath) {
323
- const docParsed = parseXmlFromZip(docxPath, 'word/document.xml');
324
- if (!docParsed) {
325
- throw new Error('Could not find document.xml in DOCX file');
326
- }
327
-
328
- // Also parse styles to identify monospace/code runs
329
- const stylesParsed = parseXmlFromZip(docxPath, 'word/styles.xml');
330
-
331
- // Extract all paragraphs with their formatting info
332
- const body = docParsed?.document?.body;
333
- if (!body) throw new Error('Empty document body');
334
-
335
- const paragraphs = ensureArray(body.p);
336
- const logLines = [];
337
-
338
- for (const para of paragraphs) {
339
- const runs = ensureArray(para.r);
340
- let paraText = '';
341
- let isCode = false;
342
-
343
- for (const run of runs) {
344
- if (run.t !== undefined) {
345
- paraText += extractTextContent(run.t);
346
- }
347
-
348
- // Check if run uses monospace font (Consolas/Courier)
349
- const rPr = run.rPr;
350
- if (rPr) {
351
- const fonts = rPr.rFonts;
352
- if (fonts) {
353
- const fontName = fonts['@_w:ascii'] || fonts['@_ascii'] || '';
354
- if (/consolas|courier/i.test(fontName)) {
355
- isCode = true;
356
- }
357
- }
358
- }
359
- }
360
-
361
- // Also check paragraph-level shading as indicator of code block
362
- const pPr = para.pPr;
363
- if (pPr && pPr.shd) {
364
- const fill = pPr.shd['@_w:fill'] || pPr.shd['@_fill'] || '';
365
- if (fill === 'F2F2F2' || fill === 'f2f2f2') {
366
- isCode = true;
367
- }
368
- }
369
-
370
- // Collect code paragraphs that look like log lines
371
- if (isCode && paraText.trim().length > 0) {
372
- // Skip the comment line
373
- if (paraText.startsWith('//')) continue;
374
- logLines.push(paraText.trim());
375
- }
376
- }
377
-
378
- if (logLines.length === 0) {
379
- throw new Error('No log lines found in DOCX file. This may not be a v5 stegdoc file.');
380
- }
381
-
382
- // Parse log lines back into row arrays
383
- const rows = logLines.map(parseLogLine);
384
-
385
- // Decode using the same engine as XLSX
386
- return decodeLogLines(rows);
387
- }
388
-
389
- /**
390
- * Parse a formatted log line string back into a row array.
391
- * Input format: `IP - - [timestamp] "request" status bytes "referer" "ua" "requestId" "traceId"`
392
- */
393
- function parseLogLine(line) {
394
- // Regex to parse nginx combined log format with extra fields
395
- const regex = /^(\S+)\s+-\s+-\s+(\[[^\]]+\])\s+"([^"]+)"\s+(\d+)\s+(\d+)\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"$/;
396
- const match = line.match(regex);
397
-
398
- if (!match) {
399
- // Fallback: try to extract what we can
400
- return ['', '', '', line, '', '', '', '', '', ''];
401
- }
402
-
403
- const [, ip, timestamp, request, status, bytes, referer, ua, requestId, traceId] = match;
404
-
405
- // Extract method from request
406
- const methodMatch = request.match(/^(\w+)\s/);
407
- const method = methodMatch ? methodMatch[1] : '';
408
-
409
- return [ip, timestamp, method, request, status, bytes, referer, ua, requestId, traceId];
410
- }
411
-
412
- /**
413
- * Detect if a DOCX file is v5 (log-embed) format.
414
- * Checks for the presence of log-formatted monospace content.
415
- */
416
- function detectDocxVersion(docxPath) {
417
- try {
418
- const docParsed = parseXmlFromZip(docxPath, 'word/document.xml');
419
- if (!docParsed) return 'legacy';
420
-
421
- const body = docParsed?.document?.body;
422
- if (!body) return 'legacy';
423
-
424
- // Quick check: look for the STGD05 marker in raw text
425
- const paragraphs = ensureArray(body.p);
426
- for (const para of paragraphs) {
427
- const runs = ensureArray(para.r);
428
- for (const run of runs) {
429
- const text = extractTextContent(run.t || '');
430
- if (text.includes('/api/v1/health/')) {
431
- return 'v5';
432
- }
433
- }
434
- }
435
-
436
- // Check for WHITENER_METADATA (legacy)
437
- for (const para of paragraphs) {
438
- const runs = ensureArray(para.r);
439
- for (const run of runs) {
440
- const text = extractTextContent(run.t || '');
441
- if (text.includes('WHITENER_METADATA:')) {
442
- return 'legacy';
443
- }
444
- }
445
- }
446
-
447
- return 'legacy';
448
- } catch {
449
- return 'legacy';
450
- }
451
- }
452
-
453
- // ─── Legacy DOCX (v3/v4) ───────────────────────────────────────────────────
454
-
455
- /**
456
- * Create a legacy DOCX file with base64 content (v3/v4 format)
457
- */
458
- async function createDocxWithBase64(options) {
459
- const { base64Content, metadata, outputPath } = options;
460
-
461
- const metadataStr = serializeMetadata(metadata);
462
-
463
- const doc = new Document({
464
- sections: [{
465
- properties: {},
466
- children: [
467
- new Paragraph({
468
- children: [
469
- new TextRun({
470
- text: `WHITENER_METADATA:${metadataStr}`,
471
- size: 1,
472
- }),
473
- ],
474
- }),
475
- new Paragraph({
476
- children: [
477
- new TextRun({ text: '---', break: 1 }),
478
- ],
479
- }),
480
- new Paragraph({
481
- children: [
482
- new TextRun({
483
- text: base64Content,
484
- font: 'Courier New',
485
- size: 16,
486
- }),
487
- ],
488
- }),
489
- ],
490
- }],
491
- });
492
-
493
- const buffer = await Packer.toBuffer(doc);
494
-
495
- const outputDir = path.dirname(outputPath);
496
- if (!fs.existsSync(outputDir)) {
497
- fs.mkdirSync(outputDir, { recursive: true });
498
- }
499
-
500
- fs.writeFileSync(outputPath, buffer);
501
- return outputPath;
502
- }
503
-
504
- // ─── Unified Reader ─────────────────────────────────────────────────────────
505
-
506
- /**
507
- * Read a DOCX file, auto-detecting v5 vs legacy format.
508
- */
509
- async function readDocxBase64(docxPath) {
510
- if (!fs.existsSync(docxPath)) {
511
- throw new Error(`DOCX file not found: ${docxPath}`);
512
- }
513
-
514
- const version = detectDocxVersion(docxPath);
515
-
516
- if (version === 'v5') {
517
- const result = await readDocxV5(docxPath);
518
- return {
519
- ...result,
520
- formatVersion: 'v5',
521
- };
522
- }
523
-
524
- // Legacy path
525
- try {
526
- const docParsed = parseXmlFromZip(docxPath, 'word/document.xml');
527
- if (!docParsed) {
528
- throw new Error('Could not find document.xml in DOCX file');
529
- }
530
-
531
- const fullText = extractAllText(docParsed);
532
- const metadataMarker = 'WHITENER_METADATA:';
533
- const metadataStart = fullText.indexOf(metadataMarker);
534
-
535
- if (metadataStart === -1) {
536
- throw new Error('No metadata found in DOCX file. This may not be a stegdoc-encoded file.');
537
- }
538
-
539
- const separatorIndex = fullText.indexOf('---', metadataStart);
540
- if (separatorIndex === -1) {
541
- throw new Error('Invalid file format: separator not found');
542
- }
543
-
544
- const metadataStr = fullText.substring(metadataStart + metadataMarker.length, separatorIndex).trim();
545
- const metadata = parseMetadata(metadataStr);
546
- const base64Content = fullText.substring(separatorIndex + 3).trim();
547
-
548
- return {
549
- base64Content,
550
- metadata,
551
- formatVersion: 'legacy',
552
- };
553
- } catch (error) {
554
- throw new Error(`Failed to read DOCX file: ${error.message}`);
555
- }
556
- }
557
-
558
- /**
559
- * Extract all text content from parsed DOCX document (legacy)
560
- */
561
- function extractAllText(docParsed) {
562
- let fullText = '';
563
- const body = docParsed?.document?.body;
564
- if (!body) return fullText;
565
-
566
- const paragraphs = ensureArray(body.p);
567
- for (const para of paragraphs) {
568
- const runs = ensureArray(para.r);
569
- for (const run of runs) {
570
- if (run.t !== undefined) {
571
- fullText += extractTextContent(run.t);
572
- }
573
- }
574
- }
575
- return fullText;
576
- }
577
-
578
309
  module.exports = {
579
- // v5
580
310
  createDocxV5,
581
- readDocxV5,
582
- detectDocxVersion,
583
- // Legacy
584
- createDocxWithBase64,
585
- // Unified
586
- readDocxBase64,
587
311
  };