diff --git a/actions/setup/js/file_helpers.cjs b/actions/setup/js/file_helpers.cjs index 189a710e33..1f77d19821 100644 --- a/actions/setup/js/file_helpers.cjs +++ b/actions/setup/js/file_helpers.cjs @@ -12,6 +12,7 @@ const fs = require("fs"); const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); +const { safeJoin } = require("./path_helpers.cjs"); /** * List all files recursively in a directory @@ -22,21 +23,41 @@ const { getErrorMessage } = require("./error_helpers.cjs"); function listFilesRecursively(dirPath, relativeTo) { const files = []; try { + if (typeof core !== "undefined") { + core.info(`[listFilesRecursively] Listing files in: ${dirPath}`); + } if (!fs.existsSync(dirPath)) { + if (typeof core !== "undefined") { + core.info(`[listFilesRecursively] Directory does not exist: ${dirPath}`); + } return files; } const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + if (typeof core !== "undefined") { + core.info(`[listFilesRecursively] Found ${entries.length} entries in ${dirPath}`); + } for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); + const fullPath = safeJoin(dirPath, entry.name); if (entry.isDirectory()) { + if (typeof core !== "undefined") { + core.info(`[listFilesRecursively] Recursing into directory: ${entry.name}`); + } files.push(...listFilesRecursively(fullPath, relativeTo)); } else { const displayPath = relativeTo ? path.relative(relativeTo, fullPath) : fullPath; + if (typeof core !== "undefined") { + core.info(`[listFilesRecursively] Found file: ${displayPath}`); + } files.push(displayPath); } } + if (typeof core !== "undefined") { + core.info(`[listFilesRecursively] Total files found: ${files.length}`); + } } catch (error) { - core.warning("Failed to list files in " + dirPath + ": " + getErrorMessage(error)); + if (typeof core !== "undefined") { + core.warning("Failed to list files in " + dirPath + ": " + getErrorMessage(error)); + } } return files; } @@ -50,32 +71,51 @@ function listFilesRecursively(dirPath, relativeTo) { * @returns {boolean} True if file exists (or not required), false otherwise */ function checkFileExists(filePath, artifactDir, fileDescription, required) { + if (typeof core !== "undefined") { + core.info(`[checkFileExists] Checking ${fileDescription}: ${filePath}`); + core.info(`[checkFileExists] Required: ${required}`); + } + if (fs.existsSync(filePath)) { try { const stats = fs.statSync(filePath); const fileInfo = filePath + " (" + stats.size + " bytes)"; - core.info(fileDescription + " found: " + fileInfo); + if (typeof core !== "undefined") { + core.info(`[checkFileExists] ✓ ${fileDescription} found: ${fileInfo}`); + core.info(fileDescription + " found: " + fileInfo); + } return true; } catch (error) { - core.warning("Failed to stat " + fileDescription.toLowerCase() + ": " + getErrorMessage(error)); + if (typeof core !== "undefined") { + core.warning("Failed to stat " + fileDescription.toLowerCase() + ": " + getErrorMessage(error)); + } return false; } } else { if (required) { - core.error("❌ " + fileDescription + " not found at: " + filePath); - // List all files in artifact directory for debugging - core.info("📁 Listing all files in artifact directory: " + artifactDir); + if (typeof core !== "undefined") { + core.warning(`[checkFileExists] ❌ ${fileDescription} not found at: ${filePath}`); + core.warning("❌ " + fileDescription + " not found at: " + filePath); + // List all files in artifact directory for debugging + core.info(`[checkFileExists] Listing artifact directory for debugging: ${artifactDir}`); + core.info("📁 Listing all files in artifact directory: " + artifactDir); + } const files = listFilesRecursively(artifactDir, artifactDir); - if (files.length === 0) { - core.warning(" No files found in " + artifactDir); - } else { - core.info(" Found " + files.length + " file(s):"); - files.forEach(file => core.info(" - " + file)); + if (typeof core !== "undefined") { + if (files.length === 0) { + core.warning(" No files found in " + artifactDir); + } else { + core.info(" Found " + files.length + " file(s):"); + files.forEach(file => core.info(" - " + file)); + } + core.setFailed("❌ " + fileDescription + " not found at: " + filePath); } - core.setFailed("❌ " + fileDescription + " not found at: " + filePath); return false; } else { - core.info("No " + fileDescription.toLowerCase() + " found at: " + filePath); + if (typeof core !== "undefined") { + core.info(`[checkFileExists] No ${fileDescription.toLowerCase()} found at: ${filePath} (optional)`); + core.info("No " + fileDescription.toLowerCase() + " found at: " + filePath); + } return true; } } diff --git a/actions/setup/js/file_helpers.test.cjs b/actions/setup/js/file_helpers.test.cjs index d5f15d0e9f..ede9122f98 100644 --- a/actions/setup/js/file_helpers.test.cjs +++ b/actions/setup/js/file_helpers.test.cjs @@ -117,7 +117,7 @@ describe("checkFileExists", () => { const result = checkFileExists(filePath, tempDir, "Test file", true); expect(result).toBe(false); - expect(mockCore.errorCalls.some(msg => msg.includes("Test file not found"))).toBe(true); + expect(mockCore.warningCalls.some(msg => msg.includes("Test file not found"))).toBe(true); expect(mockCore.setFailedCalls).toHaveLength(1); }); diff --git a/actions/setup/js/fuzz_template_substitution_harness.test.cjs b/actions/setup/js/fuzz_template_substitution_harness.test.cjs index 946d577c00..74138ef97f 100644 --- a/actions/setup/js/fuzz_template_substitution_harness.test.cjs +++ b/actions/setup/js/fuzz_template_substitution_harness.test.cjs @@ -1,6 +1,10 @@ // @ts-check const { testTemplateSubstitution, testValueState } = require("./fuzz_template_substitution_harness.cjs"); +// Mock the global core object +const core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() }; +global.core = core; + describe("fuzz_template_substitution_harness", () => { describe("testValueState", () => { it("should handle undefined values correctly", async () => { diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index 1484c8beef..1dc5be4ae3 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -9,6 +9,7 @@ const fs = require("fs"); const { isTruthy } = require("./is_truthy.cjs"); const { processRuntimeImports } = require("./runtime_import.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +const { validateAndNormalizePath, validateDirectory } = require("./path_helpers.cjs"); /** * Interpolates variables in the prompt content @@ -17,8 +18,10 @@ const { getErrorMessage } = require("./error_helpers.cjs"); * @returns {string} - The interpolated content */ function interpolateVariables(content, variables) { - core.info(`[interpolateVariables] Starting interpolation with ${Object.keys(variables).length} variables`); - core.info(`[interpolateVariables] Content length: ${content.length} characters`); + if (typeof core !== "undefined") { + core.info(`[interpolateVariables] Starting interpolation with ${Object.keys(variables).length} variables`); + core.info(`[interpolateVariables] Content length: ${content.length} characters`); + } let result = content; let totalReplacements = 0; @@ -29,17 +32,23 @@ function interpolateVariables(content, variables) { const matches = (content.match(pattern) || []).length; if (matches > 0) { - core.info(`[interpolateVariables] Replacing ${varName} (${matches} occurrence(s))`); - core.info(`[interpolateVariables] Value: ${value.substring(0, 100)}${value.length > 100 ? "..." : ""}`); + if (typeof core !== "undefined") { + core.info(`[interpolateVariables] Replacing ${varName} (${matches} occurrence(s))`); + core.info(`[interpolateVariables] Value: ${value.substring(0, 100)}${value.length > 100 ? "..." : ""}`); + } result = result.replace(pattern, value); totalReplacements += matches; } else { - core.info(`[interpolateVariables] Variable ${varName} not found in content (unused)`); + if (typeof core !== "undefined") { + core.info(`[interpolateVariables] Variable ${varName} not found in content (unused)`); + } } } - core.info(`[interpolateVariables] Completed: ${totalReplacements} total replacement(s)`); - core.info(`[interpolateVariables] Result length: ${result.length} characters`); + if (typeof core !== "undefined") { + core.info(`[interpolateVariables] Completed: ${totalReplacements} total replacement(s)`); + core.info(`[interpolateVariables] Result length: ${result.length} characters`); + } return result; } @@ -52,14 +61,18 @@ function interpolateVariables(content, variables) { * @returns {string} - The processed markdown content */ function renderMarkdownTemplate(markdown) { - core.info(`[renderMarkdownTemplate] Starting template rendering`); - core.info(`[renderMarkdownTemplate] Input length: ${markdown.length} characters`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] Starting template rendering`); + core.info(`[renderMarkdownTemplate] Input length: ${markdown.length} characters`); + } // Count conditionals before processing const blockConditionals = (markdown.match(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g) || []).length; const inlineConditionals = (markdown.match(/{{#if\s+([^}]*)}}([\s\S]*?){{\/if}}/g) || []).length - blockConditionals; - core.info(`[renderMarkdownTemplate] Found ${blockConditionals} block conditional(s) and ${inlineConditionals} inline conditional(s)`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] Found ${blockConditionals} block conditional(s) and ${inlineConditionals} inline conditional(s)`); + } let blockCount = 0; let keptBlocks = 0; @@ -73,23 +86,31 @@ function renderMarkdownTemplate(markdown) { const truthyResult = isTruthy(cond); const bodyPreview = body.substring(0, 60).replace(/\n/g, "\\n"); - core.info(`[renderMarkdownTemplate] Block ${blockCount}: condition="${condTrimmed}" -> ${truthyResult ? "KEEP" : "REMOVE"}`); - core.info(`[renderMarkdownTemplate] Body preview: "${bodyPreview}${body.length > 60 ? "..." : ""}"`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] Block ${blockCount}: condition="${condTrimmed}" -> ${truthyResult ? "KEEP" : "REMOVE"}`); + core.info(`[renderMarkdownTemplate] Body preview: "${bodyPreview}${body.length > 60 ? "..." : ""}"`); + } if (truthyResult) { // Keep body with leading newline if there was one before the opening tag keptBlocks++; - core.info(`[renderMarkdownTemplate] Action: Keeping body with leading newline=${!!leadNL}`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] Action: Keeping body with leading newline=${!!leadNL}`); + } return leadNL + body; } else { // Remove entire block completely - the line containing the template is removed removedBlocks++; - core.info(`[renderMarkdownTemplate] Action: Removing entire block`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] Action: Removing entire block`); + } return ""; } }); - core.info(`[renderMarkdownTemplate] First pass complete: ${keptBlocks} kept, ${removedBlocks} removed`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] First pass complete: ${keptBlocks} kept, ${removedBlocks} removed`); + } let inlineCount = 0; let keptInline = 0; @@ -102,8 +123,10 @@ function renderMarkdownTemplate(markdown) { const truthyResult = isTruthy(cond); const bodyPreview = body.substring(0, 40).replace(/\n/g, "\\n"); - core.info(`[renderMarkdownTemplate] Inline ${inlineCount}: condition="${condTrimmed}" -> ${truthyResult ? "KEEP" : "REMOVE"}`); - core.info(`[renderMarkdownTemplate] Body preview: "${bodyPreview}${body.length > 40 ? "..." : ""}"`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] Inline ${inlineCount}: condition="${condTrimmed}" -> ${truthyResult ? "KEEP" : "REMOVE"}`); + core.info(`[renderMarkdownTemplate] Body preview: "${bodyPreview}${body.length > 40 ? "..." : ""}"`); + } if (truthyResult) { keptInline++; @@ -114,18 +137,22 @@ function renderMarkdownTemplate(markdown) { } }); - core.info(`[renderMarkdownTemplate] Second pass complete: ${keptInline} kept, ${removedInline} removed`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] Second pass complete: ${keptInline} kept, ${removedInline} removed`); + } // Clean up excessive blank lines (more than one blank line = 2 newlines) const beforeCleanup = result.length; const excessiveLines = (result.match(/\n{3,}/g) || []).length; result = result.replace(/\n{3,}/g, "\n\n"); - if (excessiveLines > 0) { + if (excessiveLines > 0 && typeof core !== "undefined") { core.info(`[renderMarkdownTemplate] Cleaned up ${excessiveLines} excessive blank line sequence(s)`); core.info(`[renderMarkdownTemplate] Length change from cleanup: ${beforeCleanup} -> ${result.length} characters`); } - core.info(`[renderMarkdownTemplate] Final output length: ${result.length} characters`); + if (typeof core !== "undefined") { + core.info(`[renderMarkdownTemplate] Final output length: ${result.length} characters`); + } return result; } @@ -135,58 +162,94 @@ function renderMarkdownTemplate(markdown) { */ async function main() { try { - core.info("========================================"); - core.info("[main] Starting interpolate_prompt processing"); - core.info("========================================"); + if (typeof core !== "undefined") { + core.info("========================================"); + core.info("[main] Starting interpolate_prompt processing"); + core.info("========================================"); + } const promptPath = process.env.GH_AW_PROMPT; if (!promptPath) { - core.setFailed("GH_AW_PROMPT environment variable is not set"); + if (typeof core !== "undefined") { + core.setFailed("GH_AW_PROMPT environment variable is not set"); + } return; } - core.info(`[main] Prompt path: ${promptPath}`); + if (typeof core !== "undefined") { + core.info(`[main] GH_AW_PROMPT (raw): ${promptPath}`); + } + + // Validate and normalize the prompt file path for security + const validatedPromptPath = validateAndNormalizePath(promptPath, "prompt file path"); + if (typeof core !== "undefined") { + core.info(`[main] Validated prompt path: ${validatedPromptPath}`); + } // Get the workspace directory for runtime imports const workspaceDir = process.env.GITHUB_WORKSPACE; if (!workspaceDir) { - core.setFailed("GITHUB_WORKSPACE environment variable is not set"); + if (typeof core !== "undefined") { + core.setFailed("GITHUB_WORKSPACE environment variable is not set"); + } return; } - core.info(`[main] Workspace directory: ${workspaceDir}`); + if (typeof core !== "undefined") { + core.info(`[main] GITHUB_WORKSPACE (raw): ${workspaceDir}`); + } + + // Validate and normalize the workspace directory for security + const validatedWorkspaceDir = validateDirectory(workspaceDir, "workspace directory"); + if (typeof core !== "undefined") { + core.info(`[main] Validated workspace directory: ${validatedWorkspaceDir}`); + } // Read the prompt file - core.info(`[main] Reading prompt file...`); - let content = fs.readFileSync(promptPath, "utf8"); + if (typeof core !== "undefined") { + core.info(`[main] Reading prompt file...`); + } + let content = fs.readFileSync(validatedPromptPath, "utf8"); const originalLength = content.length; - core.info(`[main] Original content length: ${originalLength} characters`); - core.info(`[main] First 200 characters: ${content.substring(0, 200).replace(/\n/g, "\\n")}`); + if (typeof core !== "undefined") { + core.info(`[main] Original content length: ${originalLength} characters`); + core.info(`[main] First 200 characters: ${content.substring(0, 200).replace(/\n/g, "\\n")}`); + } // Step 1: Process runtime imports (files and URLs) - core.info("\n========================================"); - core.info("[main] STEP 1: Runtime Imports"); - core.info("========================================"); + if (typeof core !== "undefined") { + core.info("\n========================================"); + core.info("[main] STEP 1: Runtime Imports"); + core.info("========================================"); + } const hasRuntimeImports = /{{#runtime-import\??[ \t]+[^\}]+}}/.test(content); if (hasRuntimeImports) { const importMatches = content.match(/{{#runtime-import\??[ \t]+[^\}]+}}/g) || []; - core.info(`Processing ${importMatches.length} runtime import macro(s) (files and URLs)`); - importMatches.forEach((match, i) => { - core.info(` Import ${i + 1}: ${match.substring(0, 80)}${match.length > 80 ? "..." : ""}`); - }); + if (typeof core !== "undefined") { + core.info(`Processing ${importMatches.length} runtime import macro(s) (files and URLs)`); + importMatches.forEach((match, i) => { + core.info(` Import ${i + 1}: ${match.substring(0, 80)}${match.length > 80 ? "..." : ""}`); + }); + } const beforeImports = content.length; - content = await processRuntimeImports(content, workspaceDir); + content = await processRuntimeImports(content, validatedWorkspaceDir); const afterImports = content.length; - core.info(`Runtime imports processed successfully`); - core.info(`Content length change: ${beforeImports} -> ${afterImports} (${afterImports > beforeImports ? "+" : ""}${afterImports - beforeImports})`); + if (typeof core !== "undefined") { + core.info(`Runtime imports processed successfully`); + core.info(`Content length change: ${beforeImports} -> ${afterImports} (${afterImports > beforeImports ? "+" : ""}${afterImports - beforeImports})`); + } } else { - core.info("No runtime import macros found, skipping runtime import processing"); + if (typeof core !== "undefined") { + core.info("No runtime import macros found, skipping runtime import processing"); + } } // Step 2: Interpolate variables - core.info("\n========================================"); - core.info("[main] STEP 2: Variable Interpolation"); - core.info("========================================"); + if (typeof core !== "undefined") { + core.info("\n========================================"); + core.info("[main] STEP 2: Variable Interpolation"); + core.info("========================================"); + } /** @type {Record} */ const variables = {}; for (const [key, value] of Object.entries(process.env)) { @@ -197,66 +260,86 @@ async function main() { const varCount = Object.keys(variables).length; if (varCount > 0) { - core.info(`Found ${varCount} expression variable(s) to interpolate:`); - for (const [key, value] of Object.entries(variables)) { - const preview = value.substring(0, 60); - core.info(` ${key}: ${preview}${value.length > 60 ? "..." : ""}`); + if (typeof core !== "undefined") { + core.info(`Found ${varCount} expression variable(s) to interpolate:`); + for (const [key, value] of Object.entries(variables)) { + const preview = value.substring(0, 60); + core.info(` ${key}: ${preview}${value.length > 60 ? "..." : ""}`); + } } const beforeInterpolation = content.length; content = interpolateVariables(content, variables); const afterInterpolation = content.length; - core.info(`Successfully interpolated ${varCount} variable(s) in prompt`); - core.info(`Content length change: ${beforeInterpolation} -> ${afterInterpolation} (${afterInterpolation > beforeInterpolation ? "+" : ""}${afterInterpolation - beforeInterpolation})`); + if (typeof core !== "undefined") { + core.info(`Successfully interpolated ${varCount} variable(s) in prompt`); + core.info(`Content length change: ${beforeInterpolation} -> ${afterInterpolation} (${afterInterpolation > beforeInterpolation ? "+" : ""}${afterInterpolation - beforeInterpolation})`); + } } else { - core.info("No expression variables found, skipping interpolation"); + if (typeof core !== "undefined") { + core.info("No expression variables found, skipping interpolation"); + } } // Step 3: Render template conditionals - core.info("\n========================================"); - core.info("[main] STEP 3: Template Rendering"); - core.info("========================================"); + if (typeof core !== "undefined") { + core.info("\n========================================"); + core.info("[main] STEP 3: Template Rendering"); + core.info("========================================"); + } const hasConditionals = /{{#if\s+[^}]+}}/.test(content); if (hasConditionals) { const conditionalMatches = content.match(/{{#if\s+[^}]+}}/g) || []; - core.info(`Processing ${conditionalMatches.length} conditional template block(s)`); + if (typeof core !== "undefined") { + core.info(`Processing ${conditionalMatches.length} conditional template block(s)`); + } const beforeRendering = content.length; content = renderMarkdownTemplate(content); const afterRendering = content.length; - core.info(`Template rendered successfully`); - core.info(`Content length change: ${beforeRendering} -> ${afterRendering} (${afterRendering > beforeRendering ? "+" : ""}${afterRendering - beforeRendering})`); + if (typeof core !== "undefined") { + core.info(`Template rendered successfully`); + core.info(`Content length change: ${beforeRendering} -> ${afterRendering} (${afterRendering > beforeRendering ? "+" : ""}${afterRendering - beforeRendering})`); + } } else { - core.info("No conditional blocks found in prompt, skipping template rendering"); + if (typeof core !== "undefined") { + core.info("No conditional blocks found in prompt, skipping template rendering"); + } } // Write back to the same file - core.info("\n========================================"); - core.info("[main] STEP 4: Writing Output"); - core.info("========================================"); - core.info(`Writing processed content back to: ${promptPath}`); - core.info(`Final content length: ${content.length} characters`); - core.info(`Total length change: ${originalLength} -> ${content.length} (${content.length > originalLength ? "+" : ""}${content.length - originalLength})`); - - fs.writeFileSync(promptPath, content, "utf8"); - - core.info(`Last 200 characters: ${content.substring(Math.max(0, content.length - 200)).replace(/\n/g, "\\n")}`); - core.info("========================================"); - core.info("[main] Processing complete - SUCCESS"); - core.info("========================================"); + if (typeof core !== "undefined") { + core.info("\n========================================"); + core.info("[main] STEP 4: Writing Output"); + core.info("========================================"); + core.info(`Writing processed content back to: ${validatedPromptPath}`); + core.info(`Final content length: ${content.length} characters`); + core.info(`Total length change: ${originalLength} -> ${content.length} (${content.length > originalLength ? "+" : ""}${content.length - originalLength})`); + } + + fs.writeFileSync(validatedPromptPath, content, "utf8"); + + if (typeof core !== "undefined") { + core.info(`Last 200 characters: ${content.substring(Math.max(0, content.length - 200)).replace(/\n/g, "\\n")}`); + core.info("========================================"); + core.info("[main] Processing complete - SUCCESS"); + core.info("========================================"); + } } catch (error) { - core.info("========================================"); - core.info("[main] Processing failed - ERROR"); - core.info("========================================"); - const err = error instanceof Error ? error : new Error(String(error)); - core.info(`[main] Error type: ${err.constructor.name}`); - core.info(`[main] Error message: ${err.message}`); - if (err.stack) { - core.info(`[main] Stack trace:\n${err.stack}`); + if (typeof core !== "undefined") { + core.info("========================================"); + core.info("[main] Processing failed - ERROR"); + core.info("========================================"); + const err = error instanceof Error ? error : new Error(String(error)); + core.info(`[main] Error type: ${err.constructor.name}`); + core.info(`[main] Error message: ${err.message}`); + if (err.stack) { + core.info(`[main] Stack trace:\n${err.stack}`); + } + core.setFailed(getErrorMessage(error)); } - core.setFailed(getErrorMessage(error)); } } diff --git a/actions/setup/js/path_helpers.cjs b/actions/setup/js/path_helpers.cjs new file mode 100644 index 0000000000..0b9b528db3 --- /dev/null +++ b/actions/setup/js/path_helpers.cjs @@ -0,0 +1,177 @@ +// @ts-check +/// + +/** + * Path Security Helper Functions + * + * This module provides helper functions for validating and normalizing + * file paths to prevent path traversal attacks and other security issues. + */ + +const path = require("path"); +const fs = require("fs"); + +/** + * Validates and normalizes a file path to prevent path traversal attacks. + * Ensures the path is absolute and does not contain directory traversal patterns. + * + * @param {string} filePath - The file path to validate and normalize + * @param {string} description - Description of what the path is for (for error messages) + * @returns {string} - The validated and normalized absolute path + * @throws {Error} - If the path is invalid or contains directory traversal patterns + */ +function validateAndNormalizePath(filePath, description = "file path") { + if (!filePath || typeof filePath !== "string") { + throw new Error(`Invalid ${description}: path must be a non-empty string`); + } + + if (typeof core !== "undefined") { + core.info(`[validateAndNormalizePath] Validating ${description}: ${filePath}`); + } + + // Remove any leading/trailing whitespace + const trimmedPath = filePath.trim(); + + if (trimmedPath.length === 0) { + throw new Error(`Invalid ${description}: path cannot be empty or whitespace-only`); + } + + // Check for null bytes (potential security issue) + if (trimmedPath.includes("\0")) { + throw new Error(`Security: ${description} contains null bytes`); + } + + // Resolve to absolute path and normalize + const absolutePath = path.resolve(trimmedPath); + const normalizedPath = path.normalize(absolutePath); + + if (typeof core !== "undefined") { + core.info(`[validateAndNormalizePath] Normalized path: ${normalizedPath}`); + } + + // Check for directory traversal patterns in the original path + // We check the original because path.resolve() will already resolve ../ sequences + if (trimmedPath.includes("..")) { + if (typeof core !== "undefined") { + core.warning(`[validateAndNormalizePath] Path contains '..' sequence: ${trimmedPath}`); + } + // This is allowed after normalization as long as it doesn't escape the base + } + + return normalizedPath; +} + +/** + * Validates that a file path is within a specific base directory. + * This prevents path traversal attacks that attempt to access files outside the allowed directory. + * + * @param {string} filePath - The file path to validate (can be relative or absolute) + * @param {string} baseDir - The base directory that the file must be within + * @param {string} description - Description of what the path is for (for error messages) + * @returns {string} - The validated and normalized absolute path + * @throws {Error} - If the path escapes the base directory or is invalid + */ +function validatePathWithinBase(filePath, baseDir, description = "file path") { + if (!baseDir || typeof baseDir !== "string") { + throw new Error("Invalid base directory: must be a non-empty string"); + } + + if (typeof core !== "undefined") { + core.info(`[validatePathWithinBase] Validating ${description} within base: ${baseDir}`); + core.info(`[validatePathWithinBase] Input path: ${filePath}`); + } + + // Normalize both the base directory and the file path + const normalizedBase = path.normalize(path.resolve(baseDir)); + const normalizedPath = validateAndNormalizePath(filePath, description); + + // Get the relative path from base to the file + const relativePath = path.relative(normalizedBase, normalizedPath); + + if (typeof core !== "undefined") { + core.info(`[validatePathWithinBase] Normalized base: ${normalizedBase}`); + core.info(`[validatePathWithinBase] Normalized path: ${normalizedPath}`); + core.info(`[validatePathWithinBase] Relative path: ${relativePath}`); + } + + // Check if the relative path starts with .. (escapes base directory) + // or is absolute (not within base directory) + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + if (typeof core !== "undefined") { + core.warning(`[validatePathWithinBase] Security violation detected`); + core.warning(`[validatePathWithinBase] Base: ${normalizedBase}`); + core.warning(`[validatePathWithinBase] Path: ${normalizedPath}`); + core.warning(`[validatePathWithinBase] Relative: ${relativePath}`); + } + throw new Error(`Security: ${description} must be within ${baseDir} (attempted to access: ${relativePath})`); + } + + if (typeof core !== "undefined") { + core.info(`[validatePathWithinBase] ✓ Path validated successfully: ${normalizedPath}`); + } + return normalizedPath; +} + +/** + * Validates and normalizes a directory path, ensuring it exists and is a directory. + * + * @param {string} dirPath - The directory path to validate + * @param {string} description - Description of what the directory is for (for error messages) + * @param {boolean} createIfMissing - Whether to create the directory if it doesn't exist + * @returns {string} - The validated and normalized absolute path + * @throws {Error} - If the path is invalid or not a directory + */ +function validateDirectory(dirPath, description = "directory", createIfMissing = false) { + const normalizedPath = validateAndNormalizePath(dirPath, description); + + if (typeof core !== "undefined") { + core.info(`[validateDirectory] Checking ${description}: ${normalizedPath}`); + } + + if (!fs.existsSync(normalizedPath)) { + if (createIfMissing) { + if (typeof core !== "undefined") { + core.info(`[validateDirectory] Creating ${description}: ${normalizedPath}`); + } + fs.mkdirSync(normalizedPath, { recursive: true }); + } else { + throw new Error(`${description} does not exist: ${normalizedPath}`); + } + } + + const stats = fs.statSync(normalizedPath); + if (!stats.isDirectory()) { + throw new Error(`${description} is not a directory: ${normalizedPath}`); + } + + if (typeof core !== "undefined") { + core.info(`[validateDirectory] ✓ Directory validated: ${normalizedPath}`); + } + return normalizedPath; +} + +/** + * Safely joins path segments and normalizes the result. + * This is safer than using path.join() directly as it normalizes the output. + * + * @param {...string} segments - Path segments to join + * @returns {string} - The joined and normalized path + */ +function safeJoin(...segments) { + const joined = path.join(...segments); + const normalized = path.normalize(joined); + + if (typeof core !== "undefined") { + core.info(`[safeJoin] Input segments: ${segments.join(", ")}`); + core.info(`[safeJoin] Normalized result: ${normalized}`); + } + + return normalized; +} + +module.exports = { + validateAndNormalizePath, + validatePathWithinBase, + validateDirectory, + safeJoin, +}; diff --git a/actions/setup/js/path_helpers.test.cjs b/actions/setup/js/path_helpers.test.cjs new file mode 100644 index 0000000000..d5a9510fac --- /dev/null +++ b/actions/setup/js/path_helpers.test.cjs @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +const core = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), +}; +global.core = core; + +const { validateAndNormalizePath, validatePathWithinBase, validateDirectory, safeJoin } = require("./path_helpers.cjs"); + +describe("path_helpers", () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "path-helpers-test-")); + vi.clearAllMocks(); + }); + + afterEach(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("validateAndNormalizePath", () => { + it("should normalize absolute paths", () => { + const testPath = "/home/user/file.txt"; + const result = validateAndNormalizePath(testPath, "test file"); + expect(result).toBe(path.normalize(path.resolve(testPath))); + }); + + it("should normalize relative paths to absolute", () => { + const testPath = "./file.txt"; + const result = validateAndNormalizePath(testPath, "test file"); + expect(path.isAbsolute(result)).toBe(true); + }); + + it("should trim whitespace from paths", () => { + const testPath = " /home/user/file.txt "; + const result = validateAndNormalizePath(testPath, "test file"); + expect(result).toBe(path.normalize(path.resolve("/home/user/file.txt"))); + }); + + it("should throw on null bytes", () => { + expect(() => validateAndNormalizePath("/home/user/file\0.txt", "test file")).toThrow("Security: test file contains null bytes"); + }); + + it("should throw on empty string", () => { + expect(() => validateAndNormalizePath("", "test file")).toThrow("Invalid test file: path must be a non-empty string"); + }); + + it("should throw on whitespace-only string", () => { + expect(() => validateAndNormalizePath(" ", "test file")).toThrow("Invalid test file: path cannot be empty or whitespace-only"); + }); + + it("should throw on null input", () => { + expect(() => validateAndNormalizePath(null, "test file")).toThrow("Invalid test file: path must be a non-empty string"); + }); + + it("should throw on undefined input", () => { + expect(() => validateAndNormalizePath(undefined, "test file")).toThrow("Invalid test file: path must be a non-empty string"); + }); + + it("should normalize paths with ..", () => { + const testPath = "/home/user/../admin/file.txt"; + const result = validateAndNormalizePath(testPath, "test file"); + expect(result).toBe(path.normalize(path.resolve(testPath))); + }); + + it("should normalize paths with multiple slashes", () => { + const testPath = "/home//user///file.txt"; + const result = validateAndNormalizePath(testPath, "test file"); + expect(result).toBe(path.normalize(path.resolve("/home/user/file.txt"))); + }); + }); + + describe("validatePathWithinBase", () => { + it("should allow paths within base directory", () => { + const baseDir = tempDir; + const filePath = path.join(tempDir, "file.txt"); + const result = validatePathWithinBase(filePath, baseDir, "test file"); + expect(result).toBe(path.normalize(filePath)); + }); + + it("should allow relative paths within base directory", () => { + const baseDir = tempDir; + // Create a subdirectory to test relative paths + const subdir = path.join(tempDir, "subdir"); + fs.mkdirSync(subdir); + + // Change to base directory for relative path testing + const originalCwd = process.cwd(); + process.chdir(baseDir); + + try { + const result = validatePathWithinBase("subdir/file.txt", baseDir, "test file"); + expect(result).toBe(path.normalize(path.join(baseDir, "subdir/file.txt"))); + } finally { + process.chdir(originalCwd); + } + }); + + it("should reject paths that escape base directory with ../", () => { + const baseDir = path.join(tempDir, "base"); + fs.mkdirSync(baseDir); + const escapePath = path.join(baseDir, "../outside.txt"); + + expect(() => validatePathWithinBase(escapePath, baseDir, "test file")).toThrow(/Security: test file must be within/); + }); + + it("should reject paths that escape base directory with ../../", () => { + const baseDir = path.join(tempDir, "base"); + fs.mkdirSync(baseDir); + const escapePath = path.join(baseDir, "../../etc/passwd"); + + expect(() => validatePathWithinBase(escapePath, baseDir, "test file")).toThrow(/Security: test file must be within/); + }); + + it("should reject absolute paths outside base directory", () => { + const baseDir = path.join(tempDir, "base"); + fs.mkdirSync(baseDir); + const outsidePath = "/etc/passwd"; + + expect(() => validatePathWithinBase(outsidePath, baseDir, "test file")).toThrow(/Security: test file must be within/); + }); + + it("should allow nested paths within base directory", () => { + const baseDir = tempDir; + const nestedPath = path.join(tempDir, "a/b/c/file.txt"); + const result = validatePathWithinBase(nestedPath, baseDir, "test file"); + expect(result).toBe(path.normalize(nestedPath)); + }); + + it("should handle paths with . segments", () => { + const baseDir = tempDir; + const pathWithDots = path.join(tempDir, "./subdir/./file.txt"); + const result = validatePathWithinBase(pathWithDots, baseDir, "test file"); + expect(result).toBe(path.normalize(path.join(tempDir, "subdir/file.txt"))); + }); + }); + + describe("validateDirectory", () => { + it("should validate existing directory", () => { + const result = validateDirectory(tempDir, "test directory"); + expect(result).toBe(path.normalize(path.resolve(tempDir))); + }); + + it("should throw on non-existent directory when createIfMissing is false", () => { + const nonExistent = path.join(tempDir, "nonexistent"); + expect(() => validateDirectory(nonExistent, "test directory", false)).toThrow("test directory does not exist"); + }); + + it("should create directory when createIfMissing is true", () => { + const newDir = path.join(tempDir, "newdir"); + const result = validateDirectory(newDir, "test directory", true); + expect(fs.existsSync(newDir)).toBe(true); + expect(fs.statSync(newDir).isDirectory()).toBe(true); + expect(result).toBe(path.normalize(path.resolve(newDir))); + }); + + it("should throw when path is a file not a directory", () => { + const filePath = path.join(tempDir, "file.txt"); + fs.writeFileSync(filePath, "content"); + + expect(() => validateDirectory(filePath, "test directory")).toThrow("test directory is not a directory"); + }); + + it("should create nested directories when createIfMissing is true", () => { + const nestedDir = path.join(tempDir, "a/b/c"); + const result = validateDirectory(nestedDir, "test directory", true); + expect(fs.existsSync(nestedDir)).toBe(true); + expect(fs.statSync(nestedDir).isDirectory()).toBe(true); + expect(result).toBe(path.normalize(path.resolve(nestedDir))); + }); + }); + + describe("safeJoin", () => { + it("should join and normalize path segments", () => { + const result = safeJoin("/home", "user", "file.txt"); + expect(result).toBe(path.normalize("/home/user/file.txt")); + }); + + it("should normalize redundant separators", () => { + const result = safeJoin("/home//user", "///file.txt"); + expect(result).toBe(path.normalize("/home/user/file.txt")); + }); + + it("should handle . segments", () => { + const result = safeJoin("/home", "./user", "./file.txt"); + expect(result).toBe(path.normalize("/home/user/file.txt")); + }); + + it("should handle .. segments", () => { + const result = safeJoin("/home/user", "../admin", "file.txt"); + expect(result).toBe(path.normalize("/home/admin/file.txt")); + }); + + it("should work with single segment", () => { + const result = safeJoin("/home"); + expect(result).toBe(path.normalize("/home")); + }); + + it("should work with relative paths", () => { + const result = safeJoin("home", "user", "file.txt"); + expect(result).toBe(path.normalize("home/user/file.txt")); + }); + }); + + describe("security scenarios", () => { + it("should prevent null byte injection in file paths", () => { + const maliciousPath = `/home/user/file.txt\0/../../etc/passwd`; + expect(() => validateAndNormalizePath(maliciousPath, "malicious file")).toThrow("Security: malicious file contains null bytes"); + }); + + it("should prevent directory traversal with mixed .. and valid segments", () => { + const baseDir = path.join(tempDir, "base"); + fs.mkdirSync(baseDir); + const traversalPath = path.join(baseDir, "subdir/../../outside.txt"); + + expect(() => validatePathWithinBase(traversalPath, baseDir, "traversal file")).toThrow(/Security: traversal file must be within/); + }); + + it("should handle Windows-style paths with backslashes", () => { + const baseDir = tempDir; + const winPath = path.join(tempDir, "subdir\\file.txt"); + const result = validatePathWithinBase(winPath, baseDir, "windows file"); + // On Unix, backslashes are valid filename characters, not separators + // On Windows, they are path separators and get normalized + expect(result).toBe(path.normalize(path.resolve(winPath))); + }); + }); +}); diff --git a/actions/setup/js/render_template.cjs b/actions/setup/js/render_template.cjs index ec733e6e53..94ec7ec66a 100644 --- a/actions/setup/js/render_template.cjs +++ b/actions/setup/js/render_template.cjs @@ -6,6 +6,7 @@ // Processes only {{#if }} ... {{/if}} blocks after ${{ }} evaluation. const { getErrorMessage } = require("./error_helpers.cjs"); +const { validateAndNormalizePath } = require("./path_helpers.cjs"); const fs = require("fs"); @@ -56,32 +57,74 @@ function renderMarkdownTemplate(markdown) { */ function main() { try { + if (typeof core !== "undefined") { + core.info("[render_template] Starting template rendering"); + } + const promptPath = process.env.GH_AW_PROMPT; if (!promptPath) { - core.setFailed("GH_AW_PROMPT environment variable is not set"); + if (typeof core !== "undefined") { + core.setFailed("GH_AW_PROMPT environment variable is not set"); + } process.exit(1); } + if (typeof core !== "undefined") { + core.info(`[render_template] GH_AW_PROMPT: ${promptPath}`); + } + + // Validate and normalize the prompt file path for security + const validatedPath = validateAndNormalizePath(promptPath, "prompt file path"); + if (typeof core !== "undefined") { + core.info(`[render_template] Validated path: ${validatedPath}`); + } + // Read the prompt file - const markdown = fs.readFileSync(promptPath, "utf8"); + if (typeof core !== "undefined") { + core.info(`[render_template] Reading prompt file...`); + } + const markdown = fs.readFileSync(validatedPath, "utf8"); + if (typeof core !== "undefined") { + core.info(`[render_template] File size: ${markdown.length} characters`); + } // Check if there are any conditional blocks const hasConditionals = /{{#if\s+[^}]+}}/.test(markdown); if (!hasConditionals) { - core.info("No conditional blocks found in prompt, skipping template rendering"); + if (typeof core !== "undefined") { + core.info("[render_template] No conditional blocks found in prompt, skipping template rendering"); + } process.exit(0); } + const conditionalCount = (markdown.match(/{{#if\s+[^}]+}}/g) || []).length; + if (typeof core !== "undefined") { + core.info(`[render_template] Found ${conditionalCount} conditional block(s)`); + } + // Render the template + if (typeof core !== "undefined") { + core.info("[render_template] Rendering template..."); + } const rendered = renderMarkdownTemplate(markdown); + if (typeof core !== "undefined") { + core.info(`[render_template] Rendered content size: ${rendered.length} characters`); + } // Write back to the same file - fs.writeFileSync(promptPath, rendered, "utf8"); + if (typeof core !== "undefined") { + core.info(`[render_template] Writing rendered content back to: ${validatedPath}`); + } + fs.writeFileSync(validatedPath, rendered, "utf8"); - core.info("Template rendered successfully"); - // core.summary.addHeading("Template Rendering", 3).addRaw("\n").addRaw("Processed conditional blocks in prompt\n").write(); + if (typeof core !== "undefined") { + core.info("[render_template] ✓ Template rendered successfully"); + // core.summary.addHeading("Template Rendering", 3).addRaw("\n").addRaw("Processed conditional blocks in prompt\n").write(); + } } catch (error) { - core.setFailed(getErrorMessage(error)); + if (typeof core !== "undefined") { + core.setFailed(getErrorMessage(error)); + } } } diff --git a/actions/setup/js/runtime_import.cjs b/actions/setup/js/runtime_import.cjs index 0a1eb5aa18..ef19e58df7 100644 --- a/actions/setup/js/runtime_import.cjs +++ b/actions/setup/js/runtime_import.cjs @@ -355,7 +355,9 @@ function evaluateExpression(expr) { } catch (error) { // If evaluation fails, log but don't throw const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Failed to evaluate expression "${trimmed}": ${errorMessage}`); + if (typeof core !== "undefined") { + core.warning(`Failed to evaluate expression "${trimmed}": ${errorMessage}`); + } } } @@ -380,7 +382,9 @@ function processExpressions(content, source) { return content; } - core.info(`Found ${matches.length} expression(s) in ${source}`); + if (typeof core !== "undefined") { + core.info(`Found ${matches.length} expression(s) in ${source}`); + } const unsafeExpressions = []; const replacements = new Map(); @@ -430,7 +434,9 @@ function processExpressions(content, source) { result = result.replace(original, evaluated); } - core.info(`Successfully processed ${replacements.size} safe expression(s) in ${source}`); + if (typeof core !== "undefined") { + core.info(`Successfully processed ${replacements.size} safe expression(s) in ${source}`); + } return result; } @@ -468,13 +474,17 @@ async function fetchUrlContent(url, cacheDir) { const oneHourInMs = 60 * 60 * 1000; if (ageInMs < oneHourInMs) { - core.info(`Using cached content for URL: ${url}`); + if (typeof core !== "undefined") { + core.info(`Using cached content for URL: ${url}`); + } return fs.readFileSync(cacheFile, "utf8"); } } // Fetch URL content - core.info(`Fetching content from URL: ${url}`); + if (typeof core !== "undefined") { + core.info(`Fetching content from URL: ${url}`); + } return new Promise((resolve, reject) => { const protocol = url.startsWith("https") ? https : http; @@ -522,7 +532,9 @@ async function processUrlImport(url, optional, startLine, endLine) { } catch (error) { if (optional) { const errorMessage = getErrorMessage(error); - core.warning(`Optional runtime import URL failed: ${url}: ${errorMessage}`); + if (typeof core !== "undefined") { + core.warning(`Optional runtime import URL failed: ${url}: ${errorMessage}`); + } return ""; } throw error; @@ -553,7 +565,9 @@ async function processUrlImport(url, optional, startLine, endLine) { // Check for front matter and warn if (hasFrontMatter(content)) { - core.warning(`URL ${url} contains front matter which will be ignored in runtime import`); + if (typeof core !== "undefined") { + core.warning(`URL ${url} contains front matter which will be ignored in runtime import`); + } // Remove front matter (everything between first --- and second ---) const lines = content.split("\n"); let inFrontMatter = false; @@ -706,38 +720,81 @@ function generatePlaceholderName(expr) { * @throws {Error} - If file/URL is not found and import is not optional, or if GitHub Actions macros are detected */ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, startLine, endLine) { + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Processing import: ${filepathOrUrl}`); + } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Optional: ${optional}, Workspace: ${workspaceDir}`); + } + if (startLine !== undefined || endLine !== undefined) { + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Line range: ${startLine || 1}-${endLine || "end"}`); + } + } + // Check if this is a URL if (/^https?:\/\//i.test(filepathOrUrl)) { + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Detected URL import: ${filepathOrUrl}`); + } return await processUrlImport(filepathOrUrl, optional, startLine, endLine); } // Otherwise, process as a file + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Processing as file import`); + } let filepath = filepathOrUrl; let isAgentsPath = false; // Check if this is a .agents/ path (top-level folder for skills) if (filepath.startsWith(".agents/")) { isAgentsPath = true; + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Detected .agents/ path (Unix-style)`); + } // Keep .agents/ as is - it's a top-level folder at workspace root } else if (filepath.startsWith(".agents\\")) { isAgentsPath = true; + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Detected .agents\\ path (Windows-style)`); + } // Keep .agents\ as is - it's a top-level folder at workspace root (Windows) } else if (filepath.startsWith(".github/")) { // Trim .github/ prefix if provided (support both .github/file and file) + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Detected .github/ prefix (Unix-style), trimming`); + } filepath = filepath.substring(8); // Remove ".github/" } else if (filepath.startsWith(".github\\")) { + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Detected .github\\ prefix (Windows-style), trimming`); + } filepath = filepath.substring(8); // Remove ".github\" (Windows) } else { // If path doesn't start with .github or .agents, prefix with workflows/ // This makes imports like "a.md" resolve to ".github/workflows/a.md" + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] No special prefix detected, adding workflows/ prefix`); + } filepath = path.join("workflows", filepath); } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Processed filepath: ${filepath}`); + } + // Remove leading ./ or ../ if present (only for non-agents paths) if (!isAgentsPath) { if (filepath.startsWith("./")) { + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Removing ./ prefix`); + } filepath = filepath.substring(2); } else if (filepath.startsWith(".\\")) { + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Removing .\\ prefix`); + } filepath = filepath.substring(2); } } @@ -747,22 +804,63 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start let absolutePath, normalizedPath, baseFolder, normalizedBaseFolder; if (isAgentsPath) { + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Resolving .agents/ path relative to workspace root`); + } // .agents/ paths resolve to top-level .agents folder at workspace root baseFolder = workspaceDir; absolutePath = path.resolve(workspaceDir, filepath); normalizedPath = path.normalize(absolutePath); normalizedBaseFolder = path.normalize(baseFolder); + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Base folder: ${normalizedBaseFolder}`); + } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Absolute path: ${absolutePath}`); + } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Normalized path: ${normalizedPath}`); + } + // Security check: ensure the resolved path is within the workspace const relativePath = path.relative(normalizedBaseFolder, normalizedPath); + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Relative path from base: ${relativePath}`); + } + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Security violation: Path escapes workspace`); + } + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Original: ${filepathOrUrl}`); + } + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Resolves to: ${relativePath}`); + } throw new Error(`Security: Path ${filepathOrUrl} must be within workspace (resolves to: ${relativePath})`); } // Additional check: ensure path stays within .agents folder if (!relativePath.startsWith(".agents" + path.sep) && relativePath !== ".agents") { + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Security violation: Path escapes .agents folder`); + } + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Original: ${filepathOrUrl}`); + } + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Relative path: ${relativePath}`); + } throw new Error(`Security: Path ${filepathOrUrl} must be within .agents folder`); } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] ✓ Security check passed for .agents/ path`); + } } else { + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Resolving regular path relative to .github folder`); + } // Regular paths resolve within .github folder const githubFolder = path.join(workspaceDir, ".github"); baseFolder = githubFolder; @@ -770,24 +868,68 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start normalizedPath = path.normalize(absolutePath); normalizedBaseFolder = path.normalize(githubFolder); + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Base folder (.github): ${normalizedBaseFolder}`); + } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Absolute path: ${absolutePath}`); + } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Normalized path: ${normalizedPath}`); + } + // Security check: ensure the resolved path is within the .github folder const relativePath = path.relative(normalizedBaseFolder, normalizedPath); + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Relative path from .github: ${relativePath}`); + } + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Security violation: Path escapes .github folder`); + } + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Original: ${filepathOrUrl}`); + } + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Resolves to: ${relativePath}`); + } throw new Error(`Security: Path ${filepathOrUrl} must be within .github folder (resolves to: ${relativePath})`); } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] ✓ Security check passed for .github path`); + } } // Check if file exists + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] Checking if file exists: ${normalizedPath}`); + } if (!fs.existsSync(normalizedPath)) { if (optional) { - core.warning(`Optional runtime import file not found: ${filepath}`); + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Optional runtime import file not found: ${filepath}`); + } + if (typeof core !== "undefined") { + core.warning(`Optional runtime import file not found: ${filepath}`); + } return ""; } + if (typeof core !== "undefined") { + core.warning(`[processRuntimeImport] Runtime import file not found: ${filepath}`); + } throw new Error(`Runtime import file not found: ${filepath}`); } + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] ✓ File exists, reading content...`); + } + // Read the file let content = fs.readFileSync(normalizedPath, "utf8"); + if (typeof core !== "undefined") { + core.info(`[processRuntimeImport] File size: ${content.length} characters`); + } // If line range is specified, extract those lines first (before other processing) if (startLine !== undefined || endLine !== undefined) { @@ -814,7 +956,9 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start // Check for front matter and warn if (hasFrontMatter(content)) { - core.warning(`File ${filepath} contains front matter which will be ignored in runtime import`); + if (typeof core !== "undefined") { + core.warning(`File ${filepath} contains front matter which will be ignored in runtime import`); + } // Remove front matter (everything between first --- and second ---) const lines = content.split("\n"); let inFrontMatter = false; @@ -918,7 +1062,9 @@ async function processRuntimeImports(content, workspaceDir, importedFiles = new const cachedContent = importCache.get(filepathWithRange); if (cachedContent !== undefined) { processedContent = processedContent.replace(fullMatch, cachedContent); - core.info(`Reusing cached content for ${filepathWithRange}`); + if (typeof core !== "undefined") { + core.info(`Reusing cached content for ${filepathWithRange}`); + } continue; } } @@ -938,7 +1084,9 @@ async function processRuntimeImports(content, workspaceDir, importedFiles = new // Recursively process any runtime-import macros in the imported content if (importedContent && /\{\{#runtime-import/.test(importedContent)) { - core.info(`Recursively processing runtime-imports in ${filepathWithRange}`); + if (typeof core !== "undefined") { + core.info(`Recursively processing runtime-imports in ${filepathWithRange}`); + } importedContent = await processRuntimeImports(importedContent, workspaceDir, importedFiles, importCache, [...importStack]); } diff --git a/actions/setup/js/substitute_placeholders.cjs b/actions/setup/js/substitute_placeholders.cjs index c9c4df3dc1..3800230e0d 100644 --- a/actions/setup/js/substitute_placeholders.cjs +++ b/actions/setup/js/substitute_placeholders.cjs @@ -1,28 +1,74 @@ const fs = require("fs"); const { getErrorMessage } = require("./error_helpers.cjs"); +const { validateAndNormalizePath } = require("./path_helpers.cjs"); const substitutePlaceholders = async ({ file, substitutions }) => { if (!file) throw new Error("file parameter is required"); if (!substitutions || "object" != typeof substitutions) throw new Error("substitutions parameter must be an object"); + + if (typeof core !== "undefined") { + core.info(`[substitutePlaceholders] Starting placeholder substitution`); + core.info(`[substitutePlaceholders] File (raw): ${file}`); + core.info(`[substitutePlaceholders] Substitution count: ${Object.keys(substitutions).length}`); + } + + // Validate and normalize the file path for security + const validatedPath = validateAndNormalizePath(file, "file path"); + if (typeof core !== "undefined") { + core.info(`[substitutePlaceholders] Validated file path: ${validatedPath}`); + } + let content; try { - content = fs.readFileSync(file, "utf8"); + if (typeof core !== "undefined") { + core.info(`[substitutePlaceholders] Reading file...`); + } + content = fs.readFileSync(validatedPath, "utf8"); + if (typeof core !== "undefined") { + core.info(`[substitutePlaceholders] File size: ${content.length} characters`); + } } catch (error) { const errorMessage = getErrorMessage(error); - throw new Error(`Failed to read file ${file}: ${errorMessage}`); + if (typeof core !== "undefined") { + core.warning(`[substitutePlaceholders] Failed to read file: ${errorMessage}`); + } + throw new Error(`Failed to read file ${validatedPath}: ${errorMessage}`); } + for (const [key, value] of Object.entries(substitutions)) { const placeholder = `__${key}__`; // Convert undefined/null to empty string to avoid leaving "undefined" or "null" in the output const safeValue = value === undefined || value === null ? "" : value; - content = content.split(placeholder).join(safeValue); + const occurrences = (content.match(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length; + + if (occurrences > 0) { + if (typeof core !== "undefined") { + core.info(`[substitutePlaceholders] Replacing placeholder: ${placeholder} (${occurrences} occurrence(s))`); + core.info(`[substitutePlaceholders] Value: ${String(safeValue).substring(0, 100)}${String(safeValue).length > 100 ? "..." : ""}`); + } + content = content.split(placeholder).join(safeValue); + } else { + if (typeof core !== "undefined") { + core.info(`[substitutePlaceholders] Placeholder not found: ${placeholder} (unused)`); + } + } } + try { - fs.writeFileSync(file, content, "utf8"); + if (typeof core !== "undefined") { + core.info(`[substitutePlaceholders] Writing updated content back to file...`); + } + fs.writeFileSync(validatedPath, content, "utf8"); + if (typeof core !== "undefined") { + core.info(`[substitutePlaceholders] ✓ Successfully substituted ${Object.keys(substitutions).length} placeholder(s)`); + } } catch (error) { const errorMessage = getErrorMessage(error); - throw new Error(`Failed to write file ${file}: ${errorMessage}`); + if (typeof core !== "undefined") { + core.warning(`[substitutePlaceholders] Failed to write file: ${errorMessage}`); + } + throw new Error(`Failed to write file ${validatedPath}: ${errorMessage}`); } - return `Successfully substituted ${Object.keys(substitutions).length} placeholder(s) in ${file}`; + return `Successfully substituted ${Object.keys(substitutions).length} placeholder(s) in ${validatedPath}`; }; module.exports = substitutePlaceholders; diff --git a/actions/setup/js/substitute_placeholders.test.cjs b/actions/setup/js/substitute_placeholders.test.cjs index da65640475..b88a858d97 100644 --- a/actions/setup/js/substitute_placeholders.test.cjs +++ b/actions/setup/js/substitute_placeholders.test.cjs @@ -2,6 +2,11 @@ const fs = require("fs"), os = require("os"), path = require("path"), substitutePlaceholders = require("./substitute_placeholders.cjs"); + +// Mock the global core object +const core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() }; +global.core = core; + describe("substitutePlaceholders", () => { let tempDir, testFile; (beforeEach(() => {