diff --git a/packages/glob/README.md b/packages/glob/README.md index 9575beaa1d..84256e2c17 100644 --- a/packages/glob/README.md +++ b/packages/glob/README.md @@ -76,6 +76,36 @@ for await (const file of globber.globGenerator()) { } ``` +## Hashing files (`hashFiles`) + +`hashFiles` computes a hash of files matched by glob patterns. + +By default, only files under the workspace (`GITHUB_WORKSPACE`) are eligible to be hashed. + +To improve security, file eligibility is evaluated using each file's resolved (real) path to prevent symbolic link traversal outside the allowed root path(s). + +### Options + +- `roots?: string[]` — Allowlist of root paths. Only files that resolve under (or equal) one of these roots are hashed. Defaults to `[GITHUB_WORKSPACE]` (or `currentWorkspace` if provided). +- `allowFilesOutsideWorkspace?: boolean` — Explicit opt-in to include files outside the specified root path(s). Defaults to `false`. +- `exclude?: string[]` — Glob patterns to exclude from hashing. Defaults to `[]`. + +If files match your patterns but are outside the allowed roots and `allowFilesOutsideWorkspace` is not enabled, those files are skipped and a warning is emitted. If no eligible files remain after filtering, `hashFiles` returns an empty string (`''`). + +### Example + +```js +const glob = require('@actions/glob') + +const hash = await glob.hashFiles('**/*.json', process.env.GITHUB_WORKSPACE || '', { + roots: [process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH].filter(Boolean), + allowFilesOutsideWorkspace: true, + exclude: ['**/node_modules/**'] +}) + +console.log(hash) +``` + ## Patterns ### Glob behavior diff --git a/packages/glob/__tests__/hash-files.test.ts b/packages/glob/__tests__/hash-files.test.ts index de42be3a29..d7885561a4 100644 --- a/packages/glob/__tests__/hash-files.test.ts +++ b/packages/glob/__tests__/hash-files.test.ts @@ -123,6 +123,114 @@ describe('globber', () => { '4e911ea5824830b6a3ec096c7833d5af8381c189ffaa825c3503a5333a73eadc' ) }) + + it('hashes files in allowed roots only', async () => { + const root = path.join(getTestTemp(), 'roots-hashfiles') + const dir1 = path.join(root, 'dir1') + const dir2 = path.join(root, 'dir2') + await fs.mkdir(dir1, {recursive: true}) + await fs.mkdir(dir2, {recursive: true}) + await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content') + await fs.writeFile(path.join(dir2, 'file2.txt'), 'test 2 file content') + + const broadPattern = `${root}/**` + + const hashDir1Only = await hashFiles(broadPattern, '', {roots: [dir1]}) + expect(hashDir1Only).not.toEqual('') + + const hashDir2Only = await hashFiles(broadPattern, '', {roots: [dir2]}) + expect(hashDir2Only).not.toEqual('') + + expect(hashDir1Only).not.toEqual(hashDir2Only) + + const hashBoth = await hashFiles(broadPattern, '', {roots: [dir1, dir2]}) + expect(hashBoth).not.toEqual(hashDir1Only) + expect(hashBoth).not.toEqual(hashDir2Only) + + const hashDir1Again = await hashFiles(broadPattern, '', {roots: [dir1]}) + expect(hashDir1Again).toEqual(hashDir1Only) + }) + + it('skips outside-root matches by default (hash unchanged)', async () => { + const root = path.join(getTestTemp(), 'default-skip-outside-roots') + const dir1 = path.join(root, 'dir1') + const outsideDir = path.join(root, 'outsideDir') + + await fs.mkdir(dir1, {recursive: true}) + await fs.mkdir(outsideDir, {recursive: true}) + + await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content') + await fs.writeFile( + path.join(outsideDir, 'fileOut.txt'), + 'test outside file content' + ) + + const insideOnly = await hashFiles(`${dir1}/*`, '', {roots: [dir1]}) + expect(insideOnly).not.toEqual('') + + const patterns = `${dir1}/*\n${outsideDir}/*` + const defaultSkip = await hashFiles(patterns, '', {roots: [dir1]}) + + expect(defaultSkip).toEqual(insideOnly) + }) + + it('allows files outside roots if opted-in (hash changes)', async () => { + const root = path.join(getTestTemp(), 'allow-outside-roots') + const dir1 = path.join(root, 'dir1') + const outsideDir = path.join(root, 'outsideDir') + await fs.mkdir(dir1, {recursive: true}) + await fs.mkdir(outsideDir, {recursive: true}) + await fs.writeFile(path.join(dir1, 'file1.txt'), 'test 1 file content') + await fs.writeFile( + path.join(outsideDir, 'fileOut.txt'), + 'test outside file content' + ) + + const insideOnly = await hashFiles(`${dir1}/*`, '', {roots: [dir1]}) + expect(insideOnly).not.toEqual('') + + const patterns = `${dir1}/*\n${outsideDir}/*` + const withOptIn1 = await hashFiles(patterns, '', { + roots: [dir1], + allowFilesOutsideWorkspace: true + }) + expect(withOptIn1).not.toEqual('') + expect(withOptIn1).not.toEqual(insideOnly) + + const withOptIn2 = await hashFiles(patterns, '', { + roots: [dir1], + allowFilesOutsideWorkspace: true + }) + expect(withOptIn2).toEqual(withOptIn1) + }) + + it('excludes files matching exclude patterns', async () => { + const root = path.join(getTestTemp(), 'exclude-hashfiles') + await fs.mkdir(root, {recursive: true}) + await fs.writeFile(path.join(root, 'file1.txt'), 'test 1 file content') + await fs.writeFile(path.join(root, 'file2.log'), 'test 2 file content') + + const all = await hashFiles(`${root}/*`, '', {roots: [root]}) + expect(all).not.toEqual('') + + // Exclude by exact filename and extension + const excluded = await hashFiles(`${root}/*`, '', { + roots: [root], + exclude: ['file2.log', '*.log'] + }) + expect(excluded).not.toEqual('') + + const justIncluded = await hashFiles( + `${path.join(root, 'file1.txt')}`, + '', + { + roots: [root] + } + ) + + expect(excluded).toEqual(justIncluded) + expect(excluded).not.toEqual(all) + }) }) function getTestTemp(): string { diff --git a/packages/glob/src/glob.ts b/packages/glob/src/glob.ts index b229ed766e..eab4ab1b3e 100644 --- a/packages/glob/src/glob.ts +++ b/packages/glob/src/glob.ts @@ -37,5 +37,5 @@ export async function hashFiles( followSymbolicLinks = options.followSymbolicLinks } const globber = await create(patterns, {followSymbolicLinks}) - return _hashFiles(globber, currentWorkspace, verbose) + return _hashFiles(globber, currentWorkspace, options, verbose) } diff --git a/packages/glob/src/internal-hash-file-options.ts b/packages/glob/src/internal-hash-file-options.ts index 00f9bb5f77..34cb76a4a4 100644 --- a/packages/glob/src/internal-hash-file-options.ts +++ b/packages/glob/src/internal-hash-file-options.ts @@ -9,4 +9,27 @@ export interface HashFileOptions { * @default true */ followSymbolicLinks?: boolean + + /** + * Array of allowed root directories. Only files that resolve under one of + * these roots will be included in the hash. + * + * @default [GITHUB_WORKSPACE] + */ + roots?: string[] + + /** + * Indicates whether files outside the allowed roots should be included. + * If false, outside-root files are skipped with a warning. + * + * @default false + */ + allowFilesOutsideWorkspace?: boolean + + /** + * Array of glob patterns for files to exclude from hashing. + * + * @default [] + */ + exclude?: string[] } diff --git a/packages/glob/src/internal-hash-files.ts b/packages/glob/src/internal-hash-files.ts index 88093dc12e..e442d4a290 100644 --- a/packages/glob/src/internal-hash-files.ts +++ b/packages/glob/src/internal-hash-files.ts @@ -4,41 +4,223 @@ import * as fs from 'fs' import * as stream from 'stream' import * as util from 'util' import * as path from 'path' +import minimatch from 'minimatch' import {Globber} from './glob.js' +import {HashFileOptions} from './internal-hash-file-options.js' + +type IMinimatch = minimatch.IMinimatch +type IMinimatchOptions = minimatch.IOptions +const {Minimatch} = minimatch + +const IS_WINDOWS = process.platform === 'win32' +const MAX_WARNED_FILES = 10 + +const MINIMATCH_OPTIONS: IMinimatchOptions = { + dot: true, + nobrace: true, + nocase: IS_WINDOWS, + nocomment: true, + noext: true, + nonegate: true +} + +type ExcludeMatcher = { + absolutePathMatcher: IMinimatch + workspaceRelativeMatcher: IMinimatch +} + +type OutsideRootFile = { + matched: string + resolved: string +} + +// Checks if resolvedFile is inside any of resolvedRoots. +function isInResolvedRoots( + resolvedFile: string, + resolvedRoots: string[] +): boolean { + const normalizedFile = IS_WINDOWS ? resolvedFile.toLowerCase() : resolvedFile + return resolvedRoots.some(root => { + const normalizedRoot = IS_WINDOWS ? root.toLowerCase() : root + if (normalizedFile === normalizedRoot) return true + const rel = path.relative(normalizedRoot, normalizedFile) + return ( + !path.isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${path.sep}`) + ) + }) +} + +function normalizeForMatch(p: string): string { + return p.split(path.sep).join('/') +} + +function buildExcludeMatchers(excludePatterns: string[]): ExcludeMatcher[] { + return excludePatterns.map(pattern => { + const normalizedPattern = normalizeForMatch(pattern) + // basename-only pattern (no "/") uses matchBase so "*.log" matches anywhere + const isBasenamePattern = !normalizedPattern.includes('/') + return { + absolutePathMatcher: new Minimatch(normalizedPattern, { + ...MINIMATCH_OPTIONS, + matchBase: false + } as IMinimatchOptions), + workspaceRelativeMatcher: new Minimatch(normalizedPattern, { + ...MINIMATCH_OPTIONS, + matchBase: isBasenamePattern + } as IMinimatchOptions) + } + }) +} + +function isExcluded( + resolvedFile: string, + excludeMatchers: ExcludeMatcher[], + workspaceForRelativeMatch: string +): boolean { + if (excludeMatchers.length === 0) return false + const absolutePath = path.resolve(resolvedFile) + const absolutePathForMatch = normalizeForMatch(absolutePath) + const workspaceRelativePathForMatch = normalizeForMatch( + path.relative(workspaceForRelativeMatch, absolutePath) + ) + return excludeMatchers.some( + m => + m.absolutePathMatcher.match(absolutePathForMatch) || + m.workspaceRelativeMatcher.match(workspaceRelativePathForMatch) + ) +} export async function hashFiles( globber: Globber, currentWorkspace: string, + options?: HashFileOptions, verbose: Boolean = false ): Promise { const writeDelegate = verbose ? core.info : core.debug - let hasMatch = false const githubWorkspace = currentWorkspace ? currentWorkspace : (process.env['GITHUB_WORKSPACE'] ?? process.cwd()) + + // Resolve the workspace so workspace-relative exclude matching is consistent. + // This avoids mismatches when resolvedFile is a realpath but the workspace path contains symlinks. + let resolvedWorkspace = githubWorkspace + try { + resolvedWorkspace = fs.realpathSync(githubWorkspace) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + writeDelegate( + `Could not resolve workspace '${githubWorkspace}', falling back to original path. Details: ${msg}` + ) + } + + const allowOutside = options?.allowFilesOutsideWorkspace ?? false + const excludeMatchers = buildExcludeMatchers(options?.exclude ?? []) + + // Resolve roots up front; warn and skip any that fail to resolve. + // If allowFilesOutsideWorkspace is not enabled, roots are restricted to the resolved workspace. + const resolvedRootsSet = new Set() + const roots = options?.roots ?? [resolvedWorkspace] + + for (const root of roots) { + try { + const resolvedRoot = + root === resolvedWorkspace ? root : fs.realpathSync(root) + + if ( + !allowOutside && + !isInResolvedRoots(resolvedRoot, [resolvedWorkspace]) + ) { + writeDelegate(`Skipping root outside workspace: ${resolvedRoot}`) + continue + } + + resolvedRootsSet.add(resolvedRoot) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + writeDelegate(`Skipping unresolved root '${root}'. Details: ${msg}`) + } + } + + const resolvedRoots = Array.from(resolvedRootsSet) + if (resolvedRoots.length === 0) { + core.warning( + `Could not resolve any allowed root(s); no files will be considered for hashing.` + ) + return '' + } + + const outsideRootFiles: OutsideRootFile[] = [] const result = crypto.createHash('sha256') + const pipeline = util.promisify(stream.pipeline) + let hasMatch = false let count = 0 + for await (const file of globber.globGenerator()) { writeDelegate(file) - if (!file.startsWith(`${githubWorkspace}${path.sep}`)) { - writeDelegate(`Ignore '${file}' since it is not under GITHUB_WORKSPACE.`) + + // Resolve real path of the file for symlink-safe exclude + root checking + let resolvedFile: string + try { + resolvedFile = fs.realpathSync(file) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + core.warning( + `Could not read "${file}". Please check symlinks and file access. Details: ${msg}` + ) continue } - if (fs.statSync(file).isDirectory()) { + + // Exclude matching patterns (apply to resolved path for symlink-safety) + if (isExcluded(resolvedFile, excludeMatchers, resolvedWorkspace)) { + writeDelegate(`Exclude '${file}' (exclude pattern match).`) + continue + } + + // Check if in resolved roots + if (!isInResolvedRoots(resolvedFile, resolvedRoots)) { + outsideRootFiles.push({matched: file, resolved: resolvedFile}) + if (allowOutside) { + writeDelegate( + `Including '${file}' since it is outside the allowed root(s) and 'allowFilesOutsideWorkspace' is enabled.` + ) + } else { + writeDelegate(`Skip '${file}' since it is not under allowed root(s).`) + continue + } + } + + if (fs.statSync(resolvedFile).isDirectory()) { writeDelegate(`Skip directory '${file}'.`) continue } + const hash = crypto.createHash('sha256') - const pipeline = util.promisify(stream.pipeline) - await pipeline(fs.createReadStream(file), hash) + await pipeline(fs.createReadStream(resolvedFile), hash) result.write(hash.digest()) count++ - if (!hasMatch) { - hasMatch = true - } + hasMatch = true } result.end() + // Warn if any files outside root were found without opt-in. + if (!allowOutside && outsideRootFiles.length > 0) { + const shown = outsideRootFiles.slice(0, MAX_WARNED_FILES) + const remaining = outsideRootFiles.length - shown.length + const fileList = shown + .map(f => `- ${f.matched} -> ${f.resolved}`) + .join('\n') + + const suffix = + remaining > 0 + ? `\n ...and ${remaining} more file(s). Enable debug logging to see all.` + : '' + + core.warning( + `Some matched files are outside the allowed root(s) and were skipped:\n${fileList}${suffix}\n` + + `To include them, set 'allowFilesOutsideWorkspace: true' in your options.` + ) + } + if (hasMatch) { writeDelegate(`Found ${count} files to hash.`) return result.digest('hex')