From 8aae584f49ffaca07d4e83748f9c17fea8f6ac32 Mon Sep 17 00:00:00 2001 From: hilram7 Date: Mon, 5 Jan 2026 14:34:36 -0500 Subject: [PATCH 01/11] Implement KB sidebar persistence (Phase 1: Access Analyzer) Solution 2 - Approach C: Build Script - Add scripts/copy-kb-to-versions.js - Update package.json with KB copy scripts - Configure Access Analyzer 12.0 & 11.6 sidebars - Add gitignore rule for generated KB folders Target: Access Analyzer 12.0 and 11.6 Ready for testing --- .gitignore | 3 + package.json | 8 +- scripts/copy-kb-to-versions.js | 220 ++++++++++++++++++++++++++++++++ sidebars/accessanalyzer/11.6.js | 8 -- sidebars/accessanalyzer/12.0.js | 8 -- 5 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 scripts/copy-kb-to-versions.js diff --git a/.gitignore b/.gitignore index e2324deeff..68b761404b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ build claude_logs packages +# Copied KB content (generated by scripts/copy-kb-to-versions.js) +docs/accessanalyzer/*/kb/ + # Misc .DS_Store .env.local diff --git a/package.json b/package.json index 346d3387c5..f329d129df 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,21 @@ "main": "docusaurus.config.js", "scripts": { "docusaurus": "npx docusaurus", + "prestart": "node scripts/copy-kb-to-versions.js", "start": "cross-env NODE_OPTIONS=--max-old-space-size=16384 CHOKIDAR_USEPOLLING=false npx docusaurus start --port=4500 --no-open", "start-chok": "cross-env NODE_OPTIONS=--max-old-space-size=16384 CHOKIDAR_USEPOLLING=true npx docusaurus start --port=4500 --no-open", + "prebuild": "node scripts/copy-kb-to-versions.js", "build": "cross-env NODE_OPTIONS=--max-old-space-size=16384 npx docusaurus build", "ci": "npx docusaurus build", "swizzle": "npx docusaurus swizzle", "clear": "npx docusaurus clear", "serve": "npx serve -s build -l 8080", "write-translations": "npx docusaurus write-translations", - "write-heading-ids": "npx docusaurus write-heading-ids" + "write-heading-ids": "npx docusaurus write-heading-ids", + "kb:clean": "node scripts/copy-kb-to-versions.js --clean", + "kb:dry": "node scripts/copy-kb-to-versions.js --dry", + "kb:test:aa": "npm run kb:clean && npm run prestart && npm run start", + "kb:prodtest:aa": "npm run kb:clean && npm run prebuild && npm run build && npm run serve" }, "dependencies": { "@docusaurus/babel": "^3.8.1", diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js new file mode 100644 index 0000000000..c796985325 --- /dev/null +++ b/scripts/copy-kb-to-versions.js @@ -0,0 +1,220 @@ +#!/usr/bin/env node + +/** + * Copy KB content into versioned product docs folders + * Solution 2 - Approach C: Build Script + * + * Usage: + * node scripts/copy-kb-to-versions.js # Copy all configured products/versions + * node scripts/copy-kb-to-versions.js --dry # Preview without copying + * node scripts/copy-kb-to-versions.js --clean # Remove copied KB folders + * + * Environment variables: + * COPY_KB_PRODUCTS=accessanalyzer # Filter by product + * COPY_KB_VERSIONS=12.0,11.6 # Filter by versions + */ + +const fs = require('fs'); +const path = require('path'); + +// ============================================================================ +// Configuration +// ============================================================================ + +const CONFIG = { + accessanalyzer: { + versions: ['12.0', '11.6'], + source: 'docs/kb/accessanalyzer', + destinationPattern: 'docs/accessanalyzer/{version}/kb' + } +}; + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Convert version string to URL-safe format (12.0 โ†’ 12_0) + */ +function versionToUrl(version) { + return version.replace(/\./g, '_'); +} + +/** + * Recursively copy directory + */ +function copyDirectorySync(src, dest) { + // Create destination directory + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + // Read all files/folders in source + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + // Recursively copy subdirectory + copyDirectorySync(srcPath, destPath); + } else { + // Copy file + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Recursively remove directory + */ +function removeDirectorySync(dir) { + if (!fs.existsSync(dir)) { + return; + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + removeDirectorySync(fullPath); + } else { + fs.unlinkSync(fullPath); + } + } + + fs.rmdirSync(dir); +} + +// ============================================================================ +// Main Logic +// ============================================================================ + +function main() { + const args = process.argv.slice(2); + const isDryRun = args.includes('--dry'); + const isClean = args.includes('--clean'); + + // Read environment filters + const filterProducts = process.env.COPY_KB_PRODUCTS + ? process.env.COPY_KB_PRODUCTS.split(',').map(p => p.trim()) + : null; + + const filterVersions = process.env.COPY_KB_VERSIONS + ? process.env.COPY_KB_VERSIONS.split(',').map(v => v.trim()) + : null; + + console.log('='.repeat(60)); + console.log('KB Copy Script - Solution 2 (Approach C)'); + console.log('='.repeat(60)); + + if (isDryRun) { + console.log('๐Ÿ” DRY RUN MODE - No files will be modified'); + } + + if (isClean) { + console.log('๐Ÿงน CLEAN MODE - Removing copied KB folders'); + } + + if (filterProducts) { + console.log(`๐Ÿ“ฆ Filtering products: ${filterProducts.join(', ')}`); + } + + if (filterVersions) { + console.log(`๐Ÿ“Œ Filtering versions: ${filterVersions.join(', ')}`); + } + + console.log(''); + + // Process each product + for (const [product, config] of Object.entries(CONFIG)) { + // Skip if filtered out + if (filterProducts && !filterProducts.includes(product)) { + continue; + } + + console.log(`\n๐Ÿ“š Product: ${product}`); + console.log('-'.repeat(60)); + + // Check if source exists + if (!fs.existsSync(config.source)) { + console.log(`โš ๏ธ Source not found: ${config.source}`); + continue; + } + + // Process each version + for (const version of config.versions) { + // Skip if filtered out + if (filterVersions && !filterVersions.includes(version)) { + continue; + } + + const versionUrl = versionToUrl(version); + const destination = config.destinationPattern.replace('{version}', versionUrl); + + console.log(`\n ๐Ÿ“– Version: ${version} (${versionUrl})`); + console.log(` Source: ${config.source}`); + console.log(` Dest: ${destination}`); + + if (isClean) { + // Remove copied KB folder + if (fs.existsSync(destination)) { + if (!isDryRun) { + removeDirectorySync(destination); + console.log(` โœ… Removed`); + } else { + console.log(` ๐Ÿ” Would remove`); + } + } else { + console.log(` โ„น๏ธ Does not exist (nothing to remove)`); + } + } else { + // Copy KB content + + // Remove existing destination first (avoid stale files) + if (fs.existsSync(destination)) { + if (!isDryRun) { + removeDirectorySync(destination); + console.log(` ๐Ÿ—‘๏ธ Removed old KB folder`); + } else { + console.log(` ๐Ÿ” Would remove old KB folder`); + } + } + + // Copy source to destination + if (!isDryRun) { + copyDirectorySync(config.source, destination); + + // Count copied files + const countFiles = (dir) => { + let count = 0; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + count += countFiles(path.join(dir, entry.name)); + } else { + count++; + } + } + return count; + }; + + const fileCount = countFiles(destination); + console.log(` โœ… Copied ${fileCount} files`); + } else { + console.log(` ๐Ÿ” Would copy KB content`); + } + } + } + } + + console.log('\n' + '='.repeat(60)); + console.log(isClean ? '๐Ÿงน Clean complete' : 'โœ… Copy complete'); + console.log('='.repeat(60)); +} + +// Run +main(); diff --git a/sidebars/accessanalyzer/11.6.js b/sidebars/accessanalyzer/11.6.js index d537b87433..f4e8941a40 100644 --- a/sidebars/accessanalyzer/11.6.js +++ b/sidebars/accessanalyzer/11.6.js @@ -1,16 +1,8 @@ -const generateKBSidebar = require('../../src/utils/generateKBSidebar'); - module.exports = { sidebar: [ { type: 'autogenerated', dirName: '.', }, - { - type: 'category', - label: 'Knowledge Base', - collapsed: true, - items: generateKBSidebar('accessanalyzer') - }, ], }; diff --git a/sidebars/accessanalyzer/12.0.js b/sidebars/accessanalyzer/12.0.js index d537b87433..f4e8941a40 100644 --- a/sidebars/accessanalyzer/12.0.js +++ b/sidebars/accessanalyzer/12.0.js @@ -1,16 +1,8 @@ -const generateKBSidebar = require('../../src/utils/generateKBSidebar'); - module.exports = { sidebar: [ { type: 'autogenerated', dirName: '.', }, - { - type: 'category', - label: 'Knowledge Base', - collapsed: true, - items: generateKBSidebar('accessanalyzer') - }, ], }; From 0b4846d15c1bac20958718f3e30cf1adf329d31e Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Mon, 5 Jan 2026 22:39:27 -0500 Subject: [PATCH 02/11] Fix KB copy script to use correct version path format Changed the copy script to use actual version strings (e.g., "12.0") instead of URL-safe format (e.g., "12_0") for file system paths. Docusaurus expects docs folders to use the original version format with dots. This fix enables KB articles to be correctly copied into versioned docs folders and resolved by Docusaurus for sidebar generation. Phase 1 complete: KB sidebar persistence now working for Access Analyzer 12.0 and 11.6. Sidebar remains visible when navigating KB articles under versioned routes. Root cause: Line 156 used versionToUrl() result for destination path, but Docusaurus expects version folders to match the original format with dots. Impact before fix: - KB files copied to wrong locations (docs/accessanalyzer/12_0/kb/) - Docusaurus couldn't find KB content - Sidebar configuration failed with "No docs found in kb" errors Impact after fix: - KB files correctly copied to docs/accessanalyzer/12.0/kb/ - Sidebar autogeneration works correctly - Phase 1 validation successful: sidebar persists across KB navigation Testing: Verified with Access Analyzer 12.0 and 11.6 on macOS dev server. Related to commit 8aae584f49 (Initial Phase 1 implementation). --- scripts/copy-kb-to-versions.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index c796985325..56ac905f0b 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -153,9 +153,10 @@ function main() { } const versionUrl = versionToUrl(version); - const destination = config.destinationPattern.replace('{version}', versionUrl); + // Use the actual version (with dots) for file system path, not the URL version + const destination = config.destinationPattern.replace('{version}', version); - console.log(`\n ๐Ÿ“– Version: ${version} (${versionUrl})`); + console.log(`\n ๐Ÿ“– Version: ${version} (URL: ${versionUrl})`); console.log(` Source: ${config.source}`); console.log(` Dest: ${destination}`); From 16d2b28fdee90771664d1b417c93c842ab0f145b Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Tue, 6 Jan 2026 01:13:37 -0500 Subject: [PATCH 03/11] Add link rewriting to KB copy script (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented automatic link rewriting during KB content copy to fix 28 broken cross-reference links. The script now converts absolute KB links to relative paths and removes .md extensions. Changes: - Added rewriteKbLinks() function to convert absolute paths to relative - Added rewriteAndCopyMarkdownFile() to process markdown files during copy - Modified copyDirectorySync() to handle markdown files with link rewriting - Updated script documentation to describe link rewriting feature Link transformation examples: - Same-directory: /docs/kb/.../article.md โ†’ ./article - Cross-directory: /docs/kb/.../other/article.md โ†’ ../other/article - Removes .md extensions (Docusaurus best practice) - Preserves external links, images, and product doc links unchanged Testing: - Verified with Access Analyzer 12.0 and 11.6 - Dev server compiles successfully with zero link errors (was 28 before) - Manual testing confirms cross-reference links navigate correctly - Sidebar persistence maintained --- scripts/copy-kb-to-versions.js | 65 +++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index 56ac905f0b..dbf7bae769 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -4,6 +4,12 @@ * Copy KB content into versioned product docs folders * Solution 2 - Approach C: Build Script * + * Features: + * - Copies KB articles from central location to versioned docs folders + * - Rewrites absolute KB links to relative paths during copy + * - Removes .md extensions from links (Docusaurus best practice) + * - Preserves external links and images unchanged + * * Usage: * node scripts/copy-kb-to-versions.js # Copy all configured products/versions * node scripts/copy-kb-to-versions.js --dry # Preview without copying @@ -40,10 +46,54 @@ function versionToUrl(version) { return version.replace(/\./g, '_'); } +/** + * Rewrite KB links from absolute to relative paths + * Converts: /docs/kb/accessanalyzer/category/article.md + * To: ../category/article (relative, no .md extension) + */ +function rewriteKbLinks(content, sourceFilePath, kbSourceRoot) { + const kbLinkRegex = /\[([^\]]+)\]\(\/docs\/kb\/accessanalyzer\/([^)]+\.md)\)/g; + + return content.replace(kbLinkRegex, (match, linkText, targetPath) => { + // Calculate relative path from source file to target file + const sourceDir = path.dirname(sourceFilePath); + const absoluteTargetPath = path.join(kbSourceRoot, targetPath); + let relativePath = path.relative(sourceDir, absoluteTargetPath); + + // Remove .md extension (Docusaurus best practice) + relativePath = relativePath.replace(/\.md$/, ''); + + // Normalize path separators (Windows compatibility) + relativePath = relativePath.replace(/\\/g, '/'); + + // Add ./ prefix for same-directory links + if (!relativePath.startsWith('../') && !relativePath.startsWith('./')) { + relativePath = './' + relativePath; + } + + // Reconstruct markdown link with relative path + return `[${linkText}](${relativePath})`; + }); +} + +/** + * Read markdown file, rewrite KB links, and write to destination + */ +function rewriteAndCopyMarkdownFile(srcPath, destPath, kbSourceRoot) { + // Read source file + const content = fs.readFileSync(srcPath, 'utf8'); + + // Rewrite KB links to relative paths + const transformedContent = rewriteKbLinks(content, srcPath, kbSourceRoot); + + // Write to destination + fs.writeFileSync(destPath, transformedContent, 'utf8'); +} + /** * Recursively copy directory */ -function copyDirectorySync(src, dest) { +function copyDirectorySync(src, dest, kbSourceRoot) { // Create destination directory if (!fs.existsSync(dest)) { fs.mkdirSync(dest, { recursive: true }); @@ -58,10 +108,15 @@ function copyDirectorySync(src, dest) { if (entry.isDirectory()) { // Recursively copy subdirectory - copyDirectorySync(srcPath, destPath); + copyDirectorySync(srcPath, destPath, kbSourceRoot); } else { - // Copy file - fs.copyFileSync(srcPath, destPath); + // Process markdown files with link rewriting + if (entry.name.endsWith('.md')) { + rewriteAndCopyMarkdownFile(srcPath, destPath, kbSourceRoot); + } else { + // Copy non-markdown files as-is (images, JSON, etc.) + fs.copyFileSync(srcPath, destPath); + } } } } @@ -187,7 +242,7 @@ function main() { // Copy source to destination if (!isDryRun) { - copyDirectorySync(config.source, destination); + copyDirectorySync(config.source, destination, config.source); // Count copied files const countFiles = (dir) => { From 26e6a4477558eff5d0a94f30186b47619f372171 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Tue, 6 Jan 2026 02:23:05 -0500 Subject: [PATCH 04/11] Generate _category_.json files for proper category labels Phase 3: Fix category folder labels to display as Title Case instead of lowercase-hyphenated format. Changes: - Added CATEGORY_LABELS mapping with 12 category folder names - Created generateCategoryFile() function to generate _category_.json files with proper Title Case labels - Modified copyDirectorySync() to generate category files after copying subdirectories (excludes 0-images folder) - Updated script documentation to include category generation feature Result: - Category folders now display as "Active Directory Auditing" instead of "active-directory-auditing" - 13 _category_.json files generated per version (root + 12 categories) - All category labels render consistently in Title Case Testing: - Dev server: Category labels display correctly in Title Case format - Production build: Build completes successfully with no errors or warnings# --- scripts/copy-kb-to-versions.js | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index dbf7bae769..b957a20cad 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -8,6 +8,7 @@ * - Copies KB articles from central location to versioned docs folders * - Rewrites absolute KB links to relative paths during copy * - Removes .md extensions from links (Docusaurus best practice) + * - Generates _category_.json files for proper category labeling * - Preserves external links and images unchanged * * Usage: @@ -35,6 +36,22 @@ const CONFIG = { } }; +// Category folder name to display label mapping +const CATEGORY_LABELS = { + 'active-directory-auditing': 'Active Directory Auditing', + 'connection-profiles-and-credentials': 'Connection Profiles and Credentials', + 'database-auditing-and-configuration': 'Database Auditing and Configuration', + 'entra-id-and-azure-integration': 'Entra ID and Azure Integration', + 'exchange-online-integration': 'Exchange Online Integration', + 'file-system-and-sensitive-data-discovery': 'File System and Sensitive Data Discovery', + 'installation-and-upgrades': 'Installation and Upgrades', + 'job-management-and-scheduling': 'Job Management and Scheduling', + 'reference-and-technical-specifications': 'Reference and Technical Specifications', + 'reports-and-web-console': 'Reports and Web Console', + 'sharepoint-online-integration': 'SharePoint Online Integration', + 'troubleshooting-and-errors': 'Troubleshooting and Errors' +}; + // ============================================================================ // Utilities // ============================================================================ @@ -90,6 +107,26 @@ function rewriteAndCopyMarkdownFile(srcPath, destPath, kbSourceRoot) { fs.writeFileSync(destPath, transformedContent, 'utf8'); } +/** + * Generate _category_.json file for a category folder + */ +function generateCategoryFile(destPath, folderName) { + const label = CATEGORY_LABELS[folderName]; + + if (!label) { + return; // Skip if no label mapping exists + } + + const categoryConfig = { + label: label, + collapsed: true, + collapsible: true + }; + + const categoryFilePath = path.join(destPath, '_category_.json'); + fs.writeFileSync(categoryFilePath, JSON.stringify(categoryConfig, null, 2) + '\n', 'utf8'); +} + /** * Recursively copy directory */ @@ -109,6 +146,11 @@ function copyDirectorySync(src, dest, kbSourceRoot) { if (entry.isDirectory()) { // Recursively copy subdirectory copyDirectorySync(srcPath, destPath, kbSourceRoot); + + // Generate _category_.json for category folders (skip root and 0-images) + if (entry.name !== '0-images') { + generateCategoryFile(destPath, entry.name); + } } else { // Process markdown files with link rewriting if (entry.name.endsWith('.md')) { From a15fa5e651f6ab000790bd583a4e25e81c0f8f57 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Wed, 7 Jan 2026 17:28:04 -0500 Subject: [PATCH 05/11] Production-ready rewrite of KB copy script Comprehensive enhancement of copy-kb-to-versions.js to address code review feedback and implement production-grade robustness, security, and error handling. Changes: - Added lockfile management to prevent concurrent runs - Implemented comprehensive validation (version format, path traversal, environment variables) - Split error tracking into file-level and version-level counters - Enhanced removeDirectorySync to return boolean and handle failures - Hardened validateDestinationPath with path.relative checks - Dynamic link rewriting (no hardcoded product names) - Whitelist-based category generation (true/false/null semantics) - Per-version error isolation (one failure doesn't abort entire run) - Removed dead code (versionToUrl, countFiles, special-case logic) - Single process.exit with proper exit codes - Added preci script to package.json for CI integration - Updated .gitignore pattern to docs/*/*/kb/** for any product/version Testing: - Dev server: 242 files, 12 categories copied per version, 0 errors - Production build: Completes successfully with 0 errors - Cross-reference links functional (same-directory and cross-directory) - Category labels display in Title Case - Sidebar persistence maintained --- .gitignore | 2 +- package.json | 1 + scripts/copy-kb-to-versions.js | 514 ++++++++++++++++++++++----------- 3 files changed, 352 insertions(+), 165 deletions(-) diff --git a/.gitignore b/.gitignore index 68b761404b..648fae2cb9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ claude_logs packages # Copied KB content (generated by scripts/copy-kb-to-versions.js) -docs/accessanalyzer/*/kb/ +docs/*/*/kb/** # Misc .DS_Store diff --git a/package.json b/package.json index f329d129df..9701a0cd8b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start-chok": "cross-env NODE_OPTIONS=--max-old-space-size=16384 CHOKIDAR_USEPOLLING=true npx docusaurus start --port=4500 --no-open", "prebuild": "node scripts/copy-kb-to-versions.js", "build": "cross-env NODE_OPTIONS=--max-old-space-size=16384 npx docusaurus build", + "preci": "node scripts/copy-kb-to-versions.js", "ci": "npx docusaurus build", "swizzle": "npx docusaurus swizzle", "clear": "npx docusaurus clear", diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index b957a20cad..308de30679 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -10,6 +10,8 @@ * - Removes .md extensions from links (Docusaurus best practice) * - Generates _category_.json files for proper category labeling * - Preserves external links and images unchanged + * - Lockfile management to prevent concurrent runs + * - Comprehensive validation and error handling * * Usage: * node scripts/copy-kb-to-versions.js # Copy all configured products/versions @@ -25,9 +27,12 @@ const fs = require('fs'); const path = require('path'); // ============================================================================ -// Configuration +// Global Constants // ============================================================================ +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const LOCKFILE = path.join(PROJECT_ROOT, '.kb-copy.lock'); + const CONFIG = { accessanalyzer: { versions: ['12.0', '11.6'], @@ -36,7 +41,6 @@ const CONFIG = { } }; -// Category folder name to display label mapping const CATEGORY_LABELS = { 'active-directory-auditing': 'Active Directory Auditing', 'connection-profiles-and-credentials': 'Connection Profiles and Credentials', @@ -53,34 +57,127 @@ const CATEGORY_LABELS = { }; // ============================================================================ -// Utilities +// Lockfile Management // ============================================================================ -/** - * Convert version string to URL-safe format (12.0 โ†’ 12_0) - */ -function versionToUrl(version) { - return version.replace(/\./g, '_'); +function acquireLock(isDryRun) { + if (isDryRun) return; // Skip locking in dry-run mode + + // Check for stale locks (>10 minutes) + if (fs.existsSync(LOCKFILE)) { + const stats = fs.statSync(LOCKFILE); + const lockAge = Date.now() - stats.mtimeMs; + const tenMinutes = 10 * 60 * 1000; + + if (lockAge > tenMinutes) { + console.log('โš ๏ธ Removing stale lock file'); + try { + fs.unlinkSync(LOCKFILE); + } catch (err) { + throw new Error(`Cannot remove stale lock file: ${err.message}`); + } + } else { + throw new Error('KB copy script is already running. If this is incorrect, delete .kb-copy.lock'); + } + } + + // Create lock file + fs.writeFileSync(LOCKFILE, JSON.stringify({ timestamp: Date.now() }), 'utf8'); } -/** - * Rewrite KB links from absolute to relative paths - * Converts: /docs/kb/accessanalyzer/category/article.md - * To: ../category/article (relative, no .md extension) - */ -function rewriteKbLinks(content, sourceFilePath, kbSourceRoot) { - const kbLinkRegex = /\[([^\]]+)\]\(\/docs\/kb\/accessanalyzer\/([^)]+\.md)\)/g; +function releaseLock(isDryRun) { + if (isDryRun) return; + if (fs.existsSync(LOCKFILE)) { + try { + fs.unlinkSync(LOCKFILE); + } catch (err) { + console.warn(`โš ๏ธ Warning: Could not remove lock file: ${err.message}`); + } + } +} + +// ============================================================================ +// Validation Functions +// ============================================================================ + +function validateVersionFormat(version) { + const versionRegex = /^\d+\.\d+(\.\d+)?$/; // X.Y or X.Y.Z + if (!versionRegex.test(version)) { + throw new Error(`Invalid version format: ${version}. Expected X.Y or X.Y.Z`); + } +} + +function validateDestinationPath(destPath) { + // Destination must be relative + if (path.isAbsolute(destPath)) { + throw new Error(`Destination path must be relative: ${destPath}`); + } + + // Resolve and check it stays under PROJECT_ROOT + const abs = path.resolve(PROJECT_ROOT, destPath); + const rel = path.relative(PROJECT_ROOT, abs); + + // Reject if resolves to project root itself + if (rel === '') { + throw new Error(`Destination cannot be project root: ${destPath}`); + } + + // Reject if escapes project root + if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) { + throw new Error(`Path traversal detected: ${destPath} resolves outside project root`); + } +} + +function validateEnvironment(filterProducts, filterVersions, CONFIG) { + // Validate COPY_KB_PRODUCTS + if (filterProducts) { + const configKeys = Object.keys(CONFIG); + for (const product of filterProducts) { + if (!configKeys.includes(product)) { + throw new Error(`Invalid product: ${product}. Available: ${configKeys.join(', ')}`); + } + } + } + + // Validate COPY_KB_VERSIONS + if (filterVersions) { + for (const version of filterVersions) { + validateVersionFormat(version); + } + + // Check versions exist in selected products + const selectedProducts = filterProducts || Object.keys(CONFIG); + for (const product of selectedProducts) { + const availableVersions = CONFIG[product].versions; + for (const version of filterVersions) { + if (!availableVersions.includes(version)) { + throw new Error(`Version ${version} not found in ${product}. Available: ${availableVersions.join(', ')}`); + } + } + } + } +} + +// ============================================================================ +// Link Rewriting (Dynamic Product) +// ============================================================================ + +function rewriteKbLinks(content, sourceFilePath, kbSourceRoot, productName) { + // Dynamic regex based on product name + const kbLinkRegex = new RegExp(`\\[([^\\]]+)\\]\\(\\/docs\\/kb\\/${productName}\\/([^)]+\\.md)\\)`, 'g'); return content.replace(kbLinkRegex, (match, linkText, targetPath) => { - // Calculate relative path from source file to target file - const sourceDir = path.dirname(sourceFilePath); - const absoluteTargetPath = path.join(kbSourceRoot, targetPath); + // Use absolute paths anchored to PROJECT_ROOT + const absoluteSourcePath = path.resolve(PROJECT_ROOT, sourceFilePath); + const absoluteTargetPath = path.resolve(PROJECT_ROOT, kbSourceRoot, targetPath); + + const sourceDir = path.dirname(absoluteSourcePath); let relativePath = path.relative(sourceDir, absoluteTargetPath); - // Remove .md extension (Docusaurus best practice) + // Remove .md extension relativePath = relativePath.replace(/\.md$/, ''); - // Normalize path separators (Windows compatibility) + // Normalize path separators relativePath = relativePath.replace(/\\/g, '/'); // Add ./ prefix for same-directory links @@ -88,102 +185,150 @@ function rewriteKbLinks(content, sourceFilePath, kbSourceRoot) { relativePath = './' + relativePath; } - // Reconstruct markdown link with relative path return `[${linkText}](${relativePath})`; }); } -/** - * Read markdown file, rewrite KB links, and write to destination - */ -function rewriteAndCopyMarkdownFile(srcPath, destPath, kbSourceRoot) { - // Read source file - const content = fs.readFileSync(srcPath, 'utf8'); - - // Rewrite KB links to relative paths - const transformedContent = rewriteKbLinks(content, srcPath, kbSourceRoot); - - // Write to destination - fs.writeFileSync(destPath, transformedContent, 'utf8'); +function rewriteAndCopyMarkdownFile(srcPath, destPath, kbSourceRoot, productName, errorCount) { + try { + const content = fs.readFileSync(srcPath, 'utf8'); + const transformedContent = rewriteKbLinks(content, srcPath, kbSourceRoot, productName); + fs.writeFileSync(destPath, transformedContent, 'utf8'); + return true; + } catch (err) { + console.log(` โš ๏ธ Failed to copy ${path.basename(srcPath)}: ${err.message}`); + errorCount.count++; + return false; + } } -/** - * Generate _category_.json file for a category folder - */ +// ============================================================================ +// Category File Generation (Whitelist) +// ============================================================================ + function generateCategoryFile(destPath, folderName) { const label = CATEGORY_LABELS[folderName]; if (!label) { - return; // Skip if no label mapping exists + return null; // Not in whitelist } - const categoryConfig = { - label: label, - collapsed: true, - collapsible: true - }; - - const categoryFilePath = path.join(destPath, '_category_.json'); - fs.writeFileSync(categoryFilePath, JSON.stringify(categoryConfig, null, 2) + '\n', 'utf8'); + try { + const categoryConfig = { + label: label, + collapsed: true, + collapsible: true + }; + + const categoryFilePath = path.join(destPath, '_category_.json'); + fs.writeFileSync(categoryFilePath, JSON.stringify(categoryConfig, null, 2) + '\n', 'utf8'); + return true; // Created successfully + } catch (err) { + console.log(` โš ๏ธ Failed to generate category file for ${folderName}: ${err.message}`); + return false; // Failed + } } -/** - * Recursively copy directory - */ -function copyDirectorySync(src, dest, kbSourceRoot) { - // Create destination directory - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true }); +// ============================================================================ +// Copy Logic with Success Tracking +// ============================================================================ + +function copyDirectorySync(src, dest, kbSourceRoot, productName, errorCount) { + let filesCount = 0; + let categoriesCount = 0; + + // Create destination directory (fatal if fails) + try { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + } catch (err) { + throw new Error(`Fatal: Cannot create directory ${dest}: ${err.message}`); } - // Read all files/folders in source - const entries = fs.readdirSync(src, { withFileTypes: true }); + // Read source directory (fatal if fails) + let entries; + try { + entries = fs.readdirSync(src, { withFileTypes: true }); + } catch (err) { + throw new Error(`Fatal: Cannot read directory ${src}: ${err.message}`); + } for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { - // Recursively copy subdirectory - copyDirectorySync(srcPath, destPath, kbSourceRoot); - - // Generate _category_.json for category folders (skip root and 0-images) - if (entry.name !== '0-images') { - generateCategoryFile(destPath, entry.name); + // Recursively copy subdirectory (fatal errors throw up to per-version handler) + const subCounts = copyDirectorySync(srcPath, destPath, kbSourceRoot, productName, errorCount); + filesCount += subCounts.filesCount; + categoriesCount += subCounts.categoriesCount; + + // Generate category file (always call, whitelist inside) + const categoryResult = generateCategoryFile(destPath, entry.name); + if (categoryResult === true) { + categoriesCount++; + } else if (categoryResult === false) { + errorCount.count++; } + // null = not whitelisted, ignore } else { - // Process markdown files with link rewriting + // Copy files (non-fatal failures, log and continue) if (entry.name.endsWith('.md')) { - rewriteAndCopyMarkdownFile(srcPath, destPath, kbSourceRoot); + if (rewriteAndCopyMarkdownFile(srcPath, destPath, kbSourceRoot, productName, errorCount)) { + filesCount++; + } } else { - // Copy non-markdown files as-is (images, JSON, etc.) - fs.copyFileSync(srcPath, destPath); + try { + fs.copyFileSync(srcPath, destPath); + filesCount++; + } catch (err) { + console.log(` โš ๏ธ Failed to copy ${entry.name}: ${err.message}`); + errorCount.count++; + } } } } + + return { filesCount, categoriesCount }; } -/** - * Recursively remove directory - */ +// ============================================================================ +// Remove Directory +// ============================================================================ + function removeDirectorySync(dir) { if (!fs.existsSync(dir)) { - return; + return true; // Nothing to remove, success } - const entries = fs.readdirSync(dir, { withFileTypes: true }); + try { + // Prefer fs.rmSync if available (Node 14.14+) + if (fs.rmSync) { + fs.rmSync(dir, { recursive: true, force: true }); + return true; + } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); + // Fallback: manual recursive removal + const entries = fs.readdirSync(dir, { withFileTypes: true }); - if (entry.isDirectory()) { - removeDirectorySync(fullPath); - } else { - fs.unlinkSync(fullPath); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + const success = removeDirectorySync(fullPath); + if (!success) return false; + } else { + fs.unlinkSync(fullPath); + } } - } - fs.rmdirSync(dir); + fs.rmdirSync(dir); + return true; + } catch (err) { + console.log(` โš ๏ธ Failed to remove directory ${dir}: ${err.message}`); + return false; + } } // ============================================================================ @@ -195,123 +340,164 @@ function main() { const isDryRun = args.includes('--dry'); const isClean = args.includes('--clean'); - // Read environment filters - const filterProducts = process.env.COPY_KB_PRODUCTS - ? process.env.COPY_KB_PRODUCTS.split(',').map(p => p.trim()) - : null; + let totalFileErrors = 0; + let totalVersionErrors = 0; + let totalSuccess = 0; - const filterVersions = process.env.COPY_KB_VERSIONS - ? process.env.COPY_KB_VERSIONS.split(',').map(v => v.trim()) - : null; + try { + // Acquire lock + acquireLock(isDryRun); - console.log('='.repeat(60)); - console.log('KB Copy Script - Solution 2 (Approach C)'); - console.log('='.repeat(60)); + // Read environment filters + const filterProducts = process.env.COPY_KB_PRODUCTS + ? process.env.COPY_KB_PRODUCTS.split(',').map(p => p.trim()) + : null; - if (isDryRun) { - console.log('๐Ÿ” DRY RUN MODE - No files will be modified'); - } + const filterVersions = process.env.COPY_KB_VERSIONS + ? process.env.COPY_KB_VERSIONS.split(',').map(v => v.trim()) + : null; - if (isClean) { - console.log('๐Ÿงน CLEAN MODE - Removing copied KB folders'); - } + // Validate environment + validateEnvironment(filterProducts, filterVersions, CONFIG); - if (filterProducts) { - console.log(`๐Ÿ“ฆ Filtering products: ${filterProducts.join(', ')}`); - } - - if (filterVersions) { - console.log(`๐Ÿ“Œ Filtering versions: ${filterVersions.join(', ')}`); - } + console.log('='.repeat(60)); + console.log('KB Copy Script - Solution 2 (Approach C)'); + console.log('='.repeat(60)); - console.log(''); + if (isDryRun) { + console.log('๐Ÿ” DRY RUN MODE - No files will be modified'); + } - // Process each product - for (const [product, config] of Object.entries(CONFIG)) { - // Skip if filtered out - if (filterProducts && !filterProducts.includes(product)) { - continue; + if (isClean) { + console.log('๐Ÿงน CLEAN MODE - Removing copied KB folders'); } - console.log(`\n๐Ÿ“š Product: ${product}`); - console.log('-'.repeat(60)); + if (filterProducts) { + console.log(`๐Ÿ“ฆ Filtering products: ${filterProducts.join(', ')}`); + } - // Check if source exists - if (!fs.existsSync(config.source)) { - console.log(`โš ๏ธ Source not found: ${config.source}`); - continue; + if (filterVersions) { + console.log(`๐Ÿ“Œ Filtering versions: ${filterVersions.join(', ')}`); } - // Process each version - for (const version of config.versions) { + console.log(''); + + // Process each product + for (const [product, config] of Object.entries(CONFIG)) { // Skip if filtered out - if (filterVersions && !filterVersions.includes(version)) { + if (filterProducts && !filterProducts.includes(product)) { continue; } - const versionUrl = versionToUrl(version); - // Use the actual version (with dots) for file system path, not the URL version - const destination = config.destinationPattern.replace('{version}', version); + console.log(`\n๐Ÿ“š Product: ${product}`); + console.log('-'.repeat(60)); - console.log(`\n ๐Ÿ“– Version: ${version} (URL: ${versionUrl})`); - console.log(` Source: ${config.source}`); - console.log(` Dest: ${destination}`); + // Check if source exists + if (!fs.existsSync(config.source)) { + console.log(`โš ๏ธ Source not found: ${config.source}`); + totalVersionErrors++; + continue; + } - if (isClean) { - // Remove copied KB folder - if (fs.existsSync(destination)) { - if (!isDryRun) { - removeDirectorySync(destination); - console.log(` โœ… Removed`); - } else { - console.log(` ๐Ÿ” Would remove`); - } - } else { - console.log(` โ„น๏ธ Does not exist (nothing to remove)`); + // Process each version (isolated) + for (const version of config.versions) { + // Skip if filtered out + if (filterVersions && !filterVersions.includes(version)) { + continue; } - } else { - // Copy KB content - // Remove existing destination first (avoid stale files) - if (fs.existsSync(destination)) { - if (!isDryRun) { - removeDirectorySync(destination); - console.log(` ๐Ÿ—‘๏ธ Removed old KB folder`); + try { + // Validate version format + validateVersionFormat(version); + + const destination = config.destinationPattern.replace('{version}', version); + + // Validate destination path + validateDestinationPath(destination); + + console.log(`\n ๐Ÿ“– Version: ${version}`); + console.log(` Source: ${config.source}`); + console.log(` Dest: ${destination}`); + + if (isClean) { + // Remove copied KB folder + if (fs.existsSync(destination)) { + if (!isDryRun) { + const success = removeDirectorySync(destination); + if (success) { + console.log(` โœ… Removed`); + totalSuccess++; + } else { + totalVersionErrors++; + } + } else { + console.log(` ๐Ÿ” Would remove`); + totalSuccess++; + } + } else { + console.log(` โ„น๏ธ Does not exist (nothing to remove)`); + totalSuccess++; + } } else { - console.log(` ๐Ÿ” Would remove old KB folder`); - } - } - - // Copy source to destination - if (!isDryRun) { - copyDirectorySync(config.source, destination, config.source); - - // Count copied files - const countFiles = (dir) => { - let count = 0; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - count += countFiles(path.join(dir, entry.name)); + // Copy KB content + + // Remove existing destination first + if (fs.existsSync(destination)) { + if (!isDryRun) { + const removeSuccess = removeDirectorySync(destination); + if (removeSuccess) { + console.log(` ๐Ÿ—‘๏ธ Removed old KB folder`); + } else { + throw new Error('Failed to remove old KB folder'); + } } else { - count++; + console.log(` ๐Ÿ” Would remove old KB folder`); } } - return count; - }; - const fileCount = countFiles(destination); - console.log(` โœ… Copied ${fileCount} files`); - } else { - console.log(` ๐Ÿ” Would copy KB content`); + // Copy source to destination + if (!isDryRun) { + const errorCount = { count: 0 }; + const result = copyDirectorySync(config.source, destination, config.source, product, errorCount); + + if (errorCount.count > 0) { + console.log(` โš ๏ธ Copied with ${errorCount.count} file errors`); + totalFileErrors += errorCount.count; + totalSuccess++; // Version partially succeeded + } else { + console.log(` โœ… Copied ${result.filesCount} files, ${result.categoriesCount} categories`); + totalSuccess++; + } + } else { + console.log(` ๐Ÿ” Would copy KB content`); + totalSuccess++; + } + } + } catch (err) { + // Per-version error isolation + console.log(` โŒ Error: ${err.message}`); + totalVersionErrors++; + // Continue to next version } } } + + console.log('\n' + '='.repeat(60)); + console.log(isClean ? '๐Ÿงน Clean complete' : 'โœ… Copy complete'); + console.log(`Total: ${totalSuccess} successful, ${totalFileErrors} file errors, ${totalVersionErrors} version errors`); + console.log('='.repeat(60)); + + } catch (err) { + console.error(`\nโŒ Fatal error: ${err.message}`); + totalVersionErrors++; + } finally { + // Always release lock + releaseLock(isDryRun); } - console.log('\n' + '='.repeat(60)); - console.log(isClean ? '๐Ÿงน Clean complete' : 'โœ… Copy complete'); - console.log('='.repeat(60)); + // Single exit point + const totalErrors = totalFileErrors + totalVersionErrors; + process.exit(totalErrors > 0 ? 1 : 0); } // Run From 659c60fa3ca169155deb57236ae4812cc9669c1e Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Wed, 7 Jan 2026 18:40:09 -0500 Subject: [PATCH 06/11] Address CodeQL alert and path resolution issues - Fix TOCTOU race condition in acquireLock() by using atomic file descriptor operations (O_CREAT | O_EXCL) - Fix rewriteKbLinks() path resolution to handle absolute paths correctly - Add .kb-copy.lock to .gitignore - Clarify defensive check comment in validateDestinationPath Resolves CodeQL js/file-system-race alert (high severity) --- .gitignore | 3 +++ scripts/copy-kb-to-versions.js | 34 +++++++++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 648fae2cb9..e94f80fb5d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ packages # Copied KB content (generated by scripts/copy-kb-to-versions.js) docs/*/*/kb/** +# KB copy script lockfile +.kb-copy.lock + # Misc .DS_Store .env.local diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index 308de30679..d62659476f 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -81,8 +81,25 @@ function acquireLock(isDryRun) { } } - // Create lock file - fs.writeFileSync(LOCKFILE, JSON.stringify({ timestamp: Date.now() }), 'utf8'); + // Create lock file atomically (prevents TOCTOU race conditions) + try { + const fd = fs.openSync( + LOCKFILE, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 0o600 + ); + + try { + fs.writeFileSync(fd, JSON.stringify({ timestamp: Date.now() }), 'utf8'); + } finally { + fs.closeSync(fd); + } + } catch (err) { + if (err.code === 'EEXIST') { + throw new Error('KB copy script is already running. If this is incorrect, delete .kb-copy.lock'); + } + throw new Error(`Cannot create lock file: ${err.message}`); + } } function releaseLock(isDryRun) { @@ -117,7 +134,7 @@ function validateDestinationPath(destPath) { const abs = path.resolve(PROJECT_ROOT, destPath); const rel = path.relative(PROJECT_ROOT, abs); - // Reject if resolves to project root itself + // Defensive: block destPath that resolves exactly to the repo root if (rel === '') { throw new Error(`Destination cannot be project root: ${destPath}`); } @@ -168,8 +185,15 @@ function rewriteKbLinks(content, sourceFilePath, kbSourceRoot, productName) { return content.replace(kbLinkRegex, (match, linkText, targetPath) => { // Use absolute paths anchored to PROJECT_ROOT - const absoluteSourcePath = path.resolve(PROJECT_ROOT, sourceFilePath); - const absoluteTargetPath = path.resolve(PROJECT_ROOT, kbSourceRoot, targetPath); + const absoluteSourcePath = path.isAbsolute(sourceFilePath) + ? sourceFilePath + : path.resolve(PROJECT_ROOT, sourceFilePath); + + const absoluteKbRoot = path.isAbsolute(kbSourceRoot) + ? kbSourceRoot + : path.resolve(PROJECT_ROOT, kbSourceRoot); + + const absoluteTargetPath = path.resolve(absoluteKbRoot, targetPath); const sourceDir = path.dirname(absoluteSourcePath); let relativePath = path.relative(sourceDir, absoluteTargetPath); From fd15efe6fd1cb69b453b52e46161ae4bda46a444 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Thu, 8 Jan 2026 11:01:30 -0500 Subject: [PATCH 07/11] Eliminate TOCTOU pattern in lock acquisition Refactor acquireLock() to attempt atomic lock creation first, then check for stale locks only on EEXIST. This eliminates the existsSync pre-check pattern that triggered CodeQL js/file-system-race alert. Changes: - Try O_CREAT|O_EXCL first (atomic, no pre-check) - Check mtime only if lock exists (EEXIST) - Retry atomic create after stale lock removal - Handle lock-disappeared race with recursive retry Resolves CodeQL js/file-system-race alert --- scripts/copy-kb-to-versions.js | 60 +++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index d62659476f..b1c60af5af 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -63,25 +63,53 @@ const CATEGORY_LABELS = { function acquireLock(isDryRun) { if (isDryRun) return; // Skip locking in dry-run mode - // Check for stale locks (>10 minutes) - if (fs.existsSync(LOCKFILE)) { - const stats = fs.statSync(LOCKFILE); - const lockAge = Date.now() - stats.mtimeMs; - const tenMinutes = 10 * 60 * 1000; - - if (lockAge > tenMinutes) { - console.log('โš ๏ธ Removing stale lock file'); - try { - fs.unlinkSync(LOCKFILE); - } catch (err) { - throw new Error(`Cannot remove stale lock file: ${err.message}`); - } - } else { - throw new Error('KB copy script is already running. If this is incorrect, delete .kb-copy.lock'); + const tenMinutes = 10 * 60 * 1000; + + // Try to create lock atomically first. If it exists, decide stale vs active. + try { + const fd = fs.openSync( + LOCKFILE, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 0o600 + ); + + try { + fs.writeFileSync(fd, JSON.stringify({ timestamp: Date.now() }), 'utf8'); + } finally { + fs.closeSync(fd); + } + + return; // Lock acquired successfully + } catch (err) { + if (err.code !== 'EEXIST') { + throw new Error(`Cannot create lock file: ${err.message}`); } } - // Create lock file atomically (prevents TOCTOU race conditions) + // Lock exists: check if it is stale + let stats; + try { + stats = fs.statSync(LOCKFILE); + } catch (err) { + // Race: lock disappeared between EEXIST and stat; retry once + return acquireLock(isDryRun); + } + + const lockAge = Date.now() - stats.mtimeMs; + + if (lockAge <= tenMinutes) { + throw new Error('KB copy script is already running. If this is incorrect, delete .kb-copy.lock'); + } + + // Stale: remove and retry atomic create + console.log('โš ๏ธ Removing stale lock file'); + try { + fs.unlinkSync(LOCKFILE); + } catch (err) { + throw new Error(`Cannot remove stale lock file: ${err.message}`); + } + + // Retry once after stale removal try { const fd = fs.openSync( LOCKFILE, From 895c7e482777b76752e86b4e580bca30b9b61d3e Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Thu, 8 Jan 2026 13:06:22 -0500 Subject: [PATCH 08/11] Replace statSync with fstatSync in lock acquisition Use file descriptor-based fstatSync instead of path-based statSync to check lock file staleness. This eliminates the remaining TOCTOU pattern that triggered CodeQL js/file-system-race alert. Implementation: - Open existing lock file with O_RDONLY to get file descriptor - Use fstatSync(fd) instead of statSync(path) to read metadata - Handle race condition if lock disappears between EEXIST and open - Limit retry attempts to 2 (initial + one stale removal) Resolves CodeQL js/file-system-race alert --- scripts/copy-kb-to-versions.js | 96 ++++++++++++++++------------------ 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index b1c60af5af..03a90985be 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -65,69 +65,63 @@ function acquireLock(isDryRun) { const tenMinutes = 10 * 60 * 1000; - // Try to create lock atomically first. If it exists, decide stale vs active. - try { - const fd = fs.openSync( - LOCKFILE, - fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, - 0o600 - ); + // Try at most twice: initial attempt + one retry after stale deletion + for (let attempt = 0; attempt < 2; attempt++) { + const now = Date.now(); + // 1) Attempt atomic create (no pre-checks) try { - fs.writeFileSync(fd, JSON.stringify({ timestamp: Date.now() }), 'utf8'); - } finally { - fs.closeSync(fd); - } + const fd = fs.openSync( + LOCKFILE, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 0o600 + ); + + try { + fs.writeFileSync(fd, JSON.stringify({ timestamp: now }), 'utf8'); + } finally { + fs.closeSync(fd); + } - return; // Lock acquired successfully - } catch (err) { - if (err.code !== 'EEXIST') { - throw new Error(`Cannot create lock file: ${err.message}`); + return; // Lock acquired + } catch (err) { + if (err.code !== 'EEXIST') { + throw new Error(`Cannot create lock file: ${err.message}`); + } } - } - // Lock exists: check if it is stale - let stats; - try { - stats = fs.statSync(LOCKFILE); - } catch (err) { - // Race: lock disappeared between EEXIST and stat; retry once - return acquireLock(isDryRun); - } - - const lockAge = Date.now() - stats.mtimeMs; - - if (lockAge <= tenMinutes) { - throw new Error('KB copy script is already running. If this is incorrect, delete .kb-copy.lock'); - } - - // Stale: remove and retry atomic create - console.log('โš ๏ธ Removing stale lock file'); - try { - fs.unlinkSync(LOCKFILE); - } catch (err) { - throw new Error(`Cannot remove stale lock file: ${err.message}`); - } - - // Retry once after stale removal - try { - const fd = fs.openSync( - LOCKFILE, - fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, - 0o600 - ); + // 2) Lock exists: determine staleness using a file descriptor (avoid statSync(path)) + let readFd; + try { + readFd = fs.openSync(LOCKFILE, fs.constants.O_RDONLY); + } catch (err) { + // Race: lock disappeared between EEXIST and open; retry loop + continue; + } + let lockAge; try { - fs.writeFileSync(fd, JSON.stringify({ timestamp: Date.now() }), 'utf8'); + const stats = fs.fstatSync(readFd); + lockAge = now - stats.mtimeMs; } finally { - fs.closeSync(fd); + fs.closeSync(readFd); } - } catch (err) { - if (err.code === 'EEXIST') { + + if (lockAge <= tenMinutes) { throw new Error('KB copy script is already running. If this is incorrect, delete .kb-copy.lock'); } - throw new Error(`Cannot create lock file: ${err.message}`); + + // 3) Stale lock: remove and retry once + console.log('โš ๏ธ Removing stale lock file'); + try { + fs.unlinkSync(LOCKFILE); + } catch (err) { + throw new Error(`Cannot remove stale lock file: ${err.message}`); + } } + + // If we get here, another process is racing us + throw new Error('KB copy script is already running. If this is incorrect, delete .kb-copy.lock'); } function releaseLock(isDryRun) { From 477af5ccac032158e682f0e7284ff64c5cd106be Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Thu, 8 Jan 2026 13:58:09 -0500 Subject: [PATCH 09/11] Escape productName in regex construction to prevent injection Add escapeRegExp() helper to sanitize productName before using it in RegExp construction within rewriteKbLinks(). This prevents potential regex injection or ReDoS attacks from malicious product names. --- scripts/copy-kb-to-versions.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index 03a90985be..470dbdbd7d 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -201,9 +201,14 @@ function validateEnvironment(filterProducts, filterVersions, CONFIG) { // Link Rewriting (Dynamic Product) // ============================================================================ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function rewriteKbLinks(content, sourceFilePath, kbSourceRoot, productName) { // Dynamic regex based on product name - const kbLinkRegex = new RegExp(`\\[([^\\]]+)\\]\\(\\/docs\\/kb\\/${productName}\\/([^)]+\\.md)\\)`, 'g'); + const escapedProduct = escapeRegExp(productName); + const kbLinkRegex = new RegExp(`\\[([^\\]]+)\\]\\(\\/docs\\/kb\\/${escapedProduct}\\/([^)]+\\.md)\\)`, 'g'); return content.replace(kbLinkRegex, (match, linkText, targetPath) => { // Use absolute paths anchored to PROJECT_ROOT From 29c7689f3e83535fe9c4bd1b1e36aa083e0ec134 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Thu, 8 Jan 2026 14:52:24 -0500 Subject: [PATCH 10/11] Add CONFIG validation to prevent path traversal in source/destination paths Introduce validateConfig() function that validates CONFIG object structure and ensures source and destination patterns stay within repository root. Called early in main() to catch misconfiguration before processing begins. --- scripts/copy-kb-to-versions.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index 470dbdbd7d..7e1d8fe8a5 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -197,6 +197,33 @@ function validateEnvironment(filterProducts, filterVersions, CONFIG) { } } +function validateConfig(CONFIG) { + for (const [product, productConfig] of Object.entries(CONFIG)) { + if (!productConfig || typeof productConfig !== 'object') { + throw new Error(`Invalid config for product ${product}: expected object`); + } + + if (!Array.isArray(productConfig.versions)) { + throw new Error(`Invalid config for product ${product}: versions must be an array`); + } + + if (typeof productConfig.source !== 'string') { + throw new Error(`Invalid config for product ${product}: source must be a string`); + } + + if (typeof productConfig.destinationPattern !== 'string') { + throw new Error(`Invalid config for product ${product}: destinationPattern must be a string`); + } + + // Validate source path stays under repo root + validateDestinationPath(productConfig.source); + + // Validate destination pattern stays under repo root (sample substitution) + const testDest = productConfig.destinationPattern.replace('{version}', '1.0'); + validateDestinationPath(testDest); + } +} + // ============================================================================ // Link Rewriting (Dynamic Product) // ============================================================================ @@ -410,6 +437,7 @@ function main() { // Validate environment validateEnvironment(filterProducts, filterVersions, CONFIG); + validateConfig(CONFIG); console.log('='.repeat(60)); console.log('KB Copy Script - Solution 2 (Approach C)'); From e67f142cb64ff409e1af668365ec2b2fd18115b1 Mon Sep 17 00:00:00 2001 From: Hilary Ramirez Date: Thu, 8 Jan 2026 16:02:13 -0500 Subject: [PATCH 11/11] Support extensionless links and preserve anchors in link rewriting: - Update rewriteKbLinks() to handle links with or without .md extension and preserve anchor (#) and query (?) suffixes when rewriting absolute KB links to relative paths. - Add filter(Boolean) to environment variable parsing to handle empty string edge case where COPY_KB_PRODUCTS= would produce [''] array. - Verified the updated regex against real KB content and confirmed it correctly handles all observed link formats, including .md, anchors, and query strings, while not affecting relative, external, or image links. - Testing: Dev server and production build completed successfully with no errors. --- scripts/copy-kb-to-versions.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/copy-kb-to-versions.js b/scripts/copy-kb-to-versions.js index 7e1d8fe8a5..a2614a100a 100644 --- a/scripts/copy-kb-to-versions.js +++ b/scripts/copy-kb-to-versions.js @@ -235,9 +235,12 @@ function escapeRegExp(str) { function rewriteKbLinks(content, sourceFilePath, kbSourceRoot, productName) { // Dynamic regex based on product name const escapedProduct = escapeRegExp(productName); - const kbLinkRegex = new RegExp(`\\[([^\\]]+)\\]\\(\\/docs\\/kb\\/${escapedProduct}\\/([^)]+\\.md)\\)`, 'g'); + const kbLinkRegex = new RegExp( + `\\[([^\\]]+)\\]\\(\\/docs\\/kb\\/${escapedProduct}\\/([^)#?]+?)(?:\\.md)?([#?][^)]*)?\\)`, + 'g' + ); - return content.replace(kbLinkRegex, (match, linkText, targetPath) => { + return content.replace(kbLinkRegex, (match, linkText, targetPath, suffix = '') => { // Use absolute paths anchored to PROJECT_ROOT const absoluteSourcePath = path.isAbsolute(sourceFilePath) ? sourceFilePath @@ -263,7 +266,7 @@ function rewriteKbLinks(content, sourceFilePath, kbSourceRoot, productName) { relativePath = './' + relativePath; } - return `[${linkText}](${relativePath})`; + return `[${linkText}](${relativePath}${suffix})`; }); } @@ -428,11 +431,11 @@ function main() { // Read environment filters const filterProducts = process.env.COPY_KB_PRODUCTS - ? process.env.COPY_KB_PRODUCTS.split(',').map(p => p.trim()) + ? process.env.COPY_KB_PRODUCTS.split(',').map(p => p.trim()).filter(Boolean) : null; const filterVersions = process.env.COPY_KB_VERSIONS - ? process.env.COPY_KB_VERSIONS.split(',').map(v => v.trim()) + ? process.env.COPY_KB_VERSIONS.split(',').map(v => v.trim()).filter(Boolean) : null; // Validate environment