From e7923cb9285a9f8de1432d8a1bdfb8641039b01f Mon Sep 17 00:00:00 2001 From: Gene Chu Date: Tue, 8 Sep 2026 17:29:44 -0700 Subject: [PATCH 1/2] fix: anchor PAC paths to the trusted task cache Resolve the PAC installation from the executing task's cache, validate filesystem containment, and retain the canonical runner directory. Add native filesystem regressions for path imitations, links, and directory identity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/host/BuildToolsRunnerParams.ts | 170 +++++-- test/unit-test/pac-path-validation.test.ts | 524 +++++++++++++++++++-- 2 files changed, 614 insertions(+), 80 deletions(-) diff --git a/src/host/BuildToolsRunnerParams.ts b/src/host/BuildToolsRunnerParams.ts index c07bcc0a..7c27a7ec 100644 --- a/src/host/BuildToolsRunnerParams.ts +++ b/src/host/BuildToolsRunnerParams.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import * as tl from 'azure-pipelines-task-lib/task'; +import fs = require('fs'); import path = require('path'); import { Logger, RunnerParameters } from "@microsoft/powerplatform-cli-wrapper"; import { cwd } from "process"; @@ -11,8 +12,7 @@ const EnvVarPrefix = 'POWERPLATFORMTOOLS_'; export const PacPathEnvVarName = `${EnvVarPrefix}PACCLIPATH`; // Known task GUIDs for PowerPlatformToolInstaller across all release stages. -// The PAC CLI binary is only installed by this task, so a valid PAC path must -// reside under a directory named with one of these GUIDs. +// These names select direct children of the executing bundle's trusted task cache. const ToolInstallerTaskGuids: ReadonlyArray = [ '8015465b-f367-4ec4-8215-8edf682574d3', // LIVE 'a4243e47-8809-429e-bda4-624757b874b5', // BETA @@ -21,33 +21,146 @@ const ToolInstallerTaskGuids: ReadonlyArray = [ ]; /** - * Validates that a PAC CLI path originates from the official ToolInstaller task directory. - * This prevents a low-trust build step from redirecting protected tasks to an - * attacker-controlled PAC binary by overwriting the job-scoped PACCLIPATH variable. + * Returns the canonical PAC directory from the executing bundle's task cache. + * The module directory is injectable for filesystem fixtures, never from job variables. + * Task-cache contents must already be trusted: this does not prevent same-user + * replacement of validated files or their support files. */ -export function validatePacPath(pacPath: string): void { - const normalizedPath = path.resolve(pacPath).toLowerCase().replace(/\\/g, '/'); - - // The path must be under the agent's _tasks directory - if (!normalizedPath.includes('/_tasks/')) { - throw new Error( - `Security validation failed: PAC CLI path "${pacPath}" is not under the agent's _tasks directory. ` + - `The PAC CLI must be resolved from the official PowerPlatformToolInstaller task. ` + - `Ensure that PowerPlatformToolInstaller@2 runs before this task and that ` + - `the ${PacPathEnvVarName} variable has not been modified by other pipeline steps.` - ); - } - - // The path must contain one of the known ToolInstaller task GUIDs - const hasValidGuid = ToolInstallerTaskGuids.some(guid => - normalizedPath.includes(`/powerplatformtoolinstaller_${guid}`) +export function validatePacPath(pacPath: string, trustedModuleDir: string = __dirname): string { + if (process.platform !== 'win32' && process.platform !== 'linux') { + throw pacPathValidationError(`unsupported operating system: ${process.platform}`); + } + + requireFullyQualifiedPath(pacPath); + requireFullyQualifiedPath(trustedModuleDir); + const consumerTaskName = path.basename(path.dirname(trustedModuleDir)); + const consumerTaskMatch = /^[a-z][a-z0-9_]*_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.exec(consumerTaskName); + if (!consumerTaskMatch || consumerTaskMatch[0] !== consumerTaskName || + !isV2TaskVersion(path.basename(trustedModuleDir))) { + throw pacPathValidationError('the executing bundle is not in a taskName_GUID/v2-version directory'); + } + + // Deployed index.js is exactly two directories below the task-cache authority. + const trustedCacheRoot = canonicalDirectory(path.dirname(path.dirname(trustedModuleDir))); + const versionDirectory = path.dirname(pacPath); + const installerDirectory = path.dirname(versionDirectory); + const installerName = path.basename(installerDirectory); + const version = path.basename(versionDirectory); + const binName = path.basename(pacPath); + const expectedInstallerName = ToolInstallerTaskGuids + .map(guid => `PowerPlatformToolInstaller_${guid}`) + .find(name => componentMatches(name, installerName)); + + if (!expectedInstallerName || !isV2TaskVersion(version) || !componentMatches('bin', binName)) { + throw pacPathValidationError('expected a known PowerPlatformToolInstaller_GUID/2.minor.patch/bin path'); + } + + // Resolve aliases before comparing roots, including drive/share aliases on Windows. + const candidateCacheRoot = canonicalDirectory(path.dirname(installerDirectory)); + if (!sameDirectory(trustedCacheRoot, candidateCacheRoot)) { + throw pacPathValidationError('the PAC directory is outside the executing bundle\'s task cache'); + } + + const installer = installationDirectory(trustedCacheRoot, expectedInstallerName); + if (installerName !== expectedInstallerName && + !sameDirectory(installer, installationDirectory(trustedCacheRoot, installerName))) { + throw pacPathValidationError('the installer name resolves to a different filesystem directory'); + } + const installation = installationDirectory(installer, version); + const bin = canonicalDirectory(path.join(installation, 'bin')); + if (binName !== 'bin' && !sameDirectory(bin, canonicalDirectory(path.join(installation, binName)))) { + throw pacPathValidationError('the bin name resolves to a different filesystem directory'); + } + requireWithinInstallation(bin, installation); + + const platformDirectory = canonicalDirectory(path.join(bin, process.platform === 'win32' ? 'pac' : 'pac_linux')); + requireWithinInstallation(platformDirectory, installation); + const toolsDirectory = canonicalDirectory(path.join(platformDirectory, 'tools')); + requireWithinInstallation(toolsDirectory, installation); + const executable = fs.realpathSync.native( + path.join(toolsDirectory, process.platform === 'win32' ? 'pac.exe' : 'pac') ); - if (!hasValidGuid) { - throw new Error( - `Security validation failed: PAC CLI path "${pacPath}" does not reference a known ` + - `PowerPlatformToolInstaller task GUID. The PAC CLI must be executed from the official ` + - `ToolInstaller task directory. Ensure that ${PacPathEnvVarName} has not been tampered with.` - ); + requireWithinInstallation(path.dirname(executable), installation); + if (!fs.statSync(executable).isFile()) { + throw pacPathValidationError('the PAC executable is not a regular file'); + } + if (process.platform === 'linux') { + fs.accessSync(executable, fs.constants.X_OK); + } + return bin; +} + +function pacPathValidationError(reason: string): Error { + return new Error( + `Security validation failed: ${reason}. Ensure PowerPlatformToolInstaller@2 runs before this task ` + + `and ${PacPathEnvVarName} has not been modified by other pipeline steps.` + ); +} + +function requireFullyQualifiedPath(value: string): void { + const windows = process.platform === 'win32'; + const fullyQualified = windows + ? /^[a-z]:[\\/]/i.test(value) || /^[/\\]{2}[^/\\]+[/\\][^/\\]+(?:[/\\]|$)/.test(value) + : path.isAbsolute(value); + const components = value.split(windows ? /[/\\]/ : /\//); + if (!fullyQualified || value.indexOf('\0') !== -1 || + components.some(component => component === '.' || component === '..') || + (windows && (/^[/\\]{2}[?.][/\\]/.test(value) || + components.some(component => /[. ]$/.test(component))))) { + throw pacPathValidationError('paths must be fully qualified platform paths without traversal or device namespaces'); + } +} + +function isV2TaskVersion(value: string): boolean { + const components = value.split('.'); + return components.length === 3 && components[0] === '2' && + components.slice(1).every(component => component.length > 0 && !/[^0-9]/.test(component)); +} + +function componentMatches(expected: string, supplied: string): boolean { + // Case folding only selects an expected name; sameDirectory establishes identity. + return process.platform === 'win32' + ? expected.toLowerCase() === supplied.toLowerCase() + : expected === supplied; +} + +function canonicalDirectory(directory: string): string { + const canonical = fs.realpathSync.native(directory); + if (!fs.statSync(canonical).isDirectory()) { + throw pacPathValidationError('a required task-cache or PAC directory is not a directory'); + } + return canonical; +} + +function installationDirectory(parent: string, name: string): string { + const directory = path.join(parent, name); + // Only cache-root aliases are authoritative; task/version links cannot select an installation. + if (!fs.lstatSync(directory).isDirectory()) { + throw pacPathValidationError('installer task and version entries must be directories, not links'); + } + return canonicalDirectory(directory); +} + +function sameDirectory(first: string, second: string): boolean { + // BigInt preserves Windows file IDs; case-insensitive string comparison would + // conflate distinct siblings when per-directory case sensitivity is enabled. + const firstStat = fs.statSync(first, { bigint: true }); + const secondStat = fs.statSync(second, { bigint: true }); + if (firstStat.ino.toString() === '0' || secondStat.ino.toString() === '0') { + throw pacPathValidationError('the filesystem does not expose a usable directory identity'); + } + return firstStat.isDirectory() && secondStat.isDirectory() && + firstStat.dev === secondStat.dev && firstStat.ino === secondStat.ino; +} + +function requireWithinInstallation(directory: string, installation: string): void { + let ancestor = directory; + while (!sameDirectory(ancestor, installation)) { + const parent = path.dirname(ancestor); + if (parent === ancestor) { + throw pacPathValidationError('a PAC path redirects outside the selected installer version'); + } + ancestor = parent; } } @@ -79,8 +192,7 @@ export class BuildToolsRunnerParams implements RunnerParameters { throw new Error(`Cannot find required pac CLI, Tool-Installer task was not called before this task!`); } } - validatePacPath(pacPath); - this._runnersDir = pacPath; + this._runnersDir = validatePacPath(pacPath); } return this._runnersDir; } diff --git a/test/unit-test/pac-path-validation.test.ts b/test/unit-test/pac-path-validation.test.ts index 239b7325..385838d2 100644 --- a/test/unit-test/pac-path-validation.test.ts +++ b/test/unit-test/pac-path-validation.test.ts @@ -1,81 +1,503 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { assert, should, use } from "chai"; -import * as sinonChai from "sinon-chai"; -import { validatePacPath, PacPathEnvVarName } from "../../src/host/BuildToolsRunnerParams"; +import { assert } from "chai"; +import fs = require("fs"); +import os = require("os"); +import path = require("path"); +import { createSandbox } from "sinon"; +import rewiremock from "../rewiremock"; -should(); -use(sinonChai); +type RunnerModule = typeof import("../../src/host/BuildToolsRunnerParams"); +interface Installation { + task: string; + version: string; + bin: string; + platform: string; + tools: string; + executable: string; +} -describe("PAC CLI path validation", () => { +const channels = [ + { name: "LIVE", guid: "8015465b-f367-4ec4-8215-8edf682574d3" }, + { name: "BETA", guid: "a4243e47-8809-429e-bda4-624757b874b5" }, + { name: "DEV", guid: "bbb104f9-1acc-4584-8b09-93b8e2373659" }, + { name: "EXPERIMENTAL", guid: "133b55b8-c51f-4ceb-8270-6d68c0cac6e4" } +]; +const installerName = `PowerPlatformToolInstaller_${channels[0].guid}`; +const consumerName = "ConsumerTask_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const windows = process.platform === "win32"; +const nativeSuite = windows || process.platform === "linux" ? describe : describe.skip; - describe("validatePacPath - valid paths", () => { - it("accepts Linux LIVE task path", () => { - const validPath = "/home/vsts/work/_tasks/PowerPlatformToolInstaller_8015465b-f367-4ec4-8215-8edf682574d3/2.0.137/bin"; - assert.doesNotThrow(() => validatePacPath(validPath)); - }); +// Suppress task-lib initialization, not validation: these fixtures never inspect agent +// credentials, publish telemetry, construct a CLI wrapper, or execute the inert files. +async function loadRunnerModule(getVariable: () => never): Promise { + return rewiremock.around( + () => import("../../src/host/BuildToolsRunnerParams"), + mock => mock(() => import("azure-pipelines-task-lib/task")).with({ getVariable }) + ); +} - it("accepts Windows LIVE task path", () => { - const validPath = "C:\\agent\\_work\\_tasks\\PowerPlatformToolInstaller_8015465b-f367-4ec4-8215-8edf682574d3\\2.0.137\\bin"; - assert.doesNotThrow(() => validatePacPath(validPath)); - }); +nativeSuite(`PAC CLI path validation (native ${process.platform})`, () => { + const sandbox = createSandbox(); + let runnerModule: RunnerModule; + let tempRoot: string; + let cacheRoot: string; + let trustedModuleDir: string; - it("accepts BETA task GUID path", () => { - const validPath = "/home/vsts/work/_tasks/PowerPlatformToolInstaller_a4243e47-8809-429e-bda4-624757b874b5/2.0.137/bin"; - assert.doesNotThrow(() => validatePacPath(validPath)); + before(async () => { + runnerModule = await loadRunnerModule(() => { + throw new Error("Pure path validation must not read pipeline variables"); }); + }); - it("accepts DEV task GUID path", () => { - const validPath = "/home/vsts/work/_tasks/PowerPlatformToolInstaller_bbb104f9-1acc-4584-8b09-93b8e2373659/2.0.137/bin"; - assert.doesNotThrow(() => validatePacPath(validPath)); - }); + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ppbt-pac-validation-")); + cacheRoot = path.join(tempRoot, "agent", "_tasks"); + trustedModuleDir = path.join(cacheRoot, consumerName, "2.0.155"); + fs.mkdirSync(trustedModuleDir, { recursive: true }); + }); + + afterEach(() => { + try { + sandbox.restore(); + } finally { + if (tempRoot) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + } + }); + + function populateBin(bin: string): Installation { + const platform = path.join(bin, windows ? "pac" : "pac_linux"); + const tools = path.join(platform, "tools"); + const executable = path.join(tools, windows ? "pac.exe" : "pac"); + fs.mkdirSync(tools, { recursive: true }); + fs.writeFileSync(executable, "Inert PAC path-validation fixture; never execute.\n", { flag: "wx" }); + if (process.platform === "linux") { + fs.chmodSync(executable, 0o755); + } + return { task: path.dirname(path.dirname(bin)), version: path.dirname(bin), bin, platform, tools, executable }; + } + + function install(root = cacheRoot, task = installerName, version = "2.0.137"): Installation { + return populateBin(path.join(root, task, version, "bin")); + } + + function validate(candidate: string, moduleDir = trustedModuleDir): string { + return runnerModule.validatePacPath(candidate, moduleDir); + } + + function rejects(candidate: string, reason: RegExp, moduleDir = trustedModuleDir): void { + const error = assert.throws(() => validate(candidate, moduleDir), reason); + assert.match(error.message, /^Security validation failed:/); + assert.include(error.message, "PowerPlatformToolInstaller@2"); + assert.include(error.message, runnerModule.PacPathEnvVarName); + assert.notProperty(error, "code", "policy rejection must not be an incidental filesystem error"); + } - it("accepts EXPERIMENTAL task GUID path", () => { - const validPath = "/home/vsts/work/_tasks/PowerPlatformToolInstaller_133b55b8-c51f-4ceb-8270-6d68c0cac6e4/2.0.137/bin"; - assert.doesNotThrow(() => validatePacPath(validPath)); + function rejectsWithCode(candidate: string, code: string): void { + assert.propertyVal(assert.throws(() => validate(candidate)), "code", code); + } + + function directoryLink(target: string, link: string): void { + // Junctions exercise native Windows redirection without requiring symlink privilege. + fs.symlinkSync(target, link, windows ? "junction" : "dir"); + } + + function skipWithReason(context: Mocha.Context, reason: string): never { + if (context.test) { + context.test.title += ` [skipped: ${reason}]`; + } + return context.skip(); + } + + function executableLink(context: Mocha.Context, target: string, link: string): void { + try { + fs.symlinkSync(target, link, "file"); + } catch (error: unknown) { + if (windows && error instanceof Error && + "code" in error && error.code === "EPERM" && + "syscall" in error && error.syscall === "symlink") { + skipWithReason(context, "Windows file-symlink privilege unavailable (EPERM)"); + } + throw error; + } + } + + function sameNativeDirectory(first: string, second: string): boolean { + const a = fs.statSync(first, { bigint: true }); + const b = fs.statSync(second, { bigint: true }); + return a.isDirectory() && b.isDirectory() && a.ino !== BigInt(0) && + a.dev === b.dev && a.ino === b.ino; + } + + it("imports before PAC discovery without resolving validation paths", async () => { + const discovery = sandbox.stub<[], never>().throws(new Error("Unexpected pipeline-variable read")); + const canonicalize = sandbox.spy(fs.realpathSync, "native"); + const imported = await loadRunnerModule(discovery); + assert.isFunction(imported.validatePacPath); + assert.isFalse(discovery.called); + assert.isFalse(canonicalize.called); + }); + + describe("legitimate installations", () => { + for (const channel of channels) { + for (const version of ["2.0.137", "2.12.456"]) { + it(`accepts ${channel.name} ${version} with consumer 2.0.155 and returns canonical bin`, () => { + const fixture = install(cacheRoot, `PowerPlatformToolInstaller_${channel.guid}`, version); + assert.strictEqual(validate(fixture.bin), fs.realpathSync.native(fixture.bin)); + }); + } + } + + it("accepts a configured cache location without relying on an _tasks name", () => { + const configuredRoot = path.join(tempRoot, "configured-task-cache"); + const moduleDir = path.join(configuredRoot, consumerName, "2.0.155"); + fs.mkdirSync(moduleDir, { recursive: true }); + const fixture = install(configuredRoot); + assert.strictEqual(validate(fixture.bin, moduleDir), fs.realpathSync.native(fixture.bin)); }); - it("accepts path with mixed case in ToolInstaller directory name", () => { - const validPath = "/home/vsts/work/_tasks/powerplatformtoolinstaller_8015465b-f367-4ec4-8215-8edf682574d3/2.0.137/bin"; - assert.doesNotThrow(() => validatePacPath(validPath)); + for (const aliased of ["candidate", "consumer", "both"]) { + it(`accepts a trusted root alias for ${aliased} and returns the real bin`, () => { + const fixture = install(); + const alias = path.join(tempRoot, "cache-alias"); + directoryLink(cacheRoot, alias); + const candidate = aliased === "consumer" ? fixture.bin : + path.join(alias, path.relative(cacheRoot, fixture.bin)); + const moduleDir = aliased === "candidate" ? trustedModuleDir : + path.join(alias, path.relative(cacheRoot, trustedModuleDir)); + assert.strictEqual(validate(candidate, moduleDir), fs.realpathSync.native(fixture.bin)); + }); + } + + for (const entry of ["bin", "platform", "tools"] as const) { + it(`allows a contained ${entry} directory link within the selected installation`, () => { + const fixture = install(); + const target = path.join(fixture.version, `contained-${entry}`); + fs.renameSync(fixture[entry], target); + directoryLink(target, fixture[entry]); + const canonicalBin = fs.realpathSync.native(fixture.bin); + assert.strictEqual(validate(fixture.bin), canonicalBin); + if (entry === "bin") { + assert.notStrictEqual(canonicalBin, fixture.bin); + } + }); + } + + it("allows an executable symlink contained within the selected installation", function () { + const fixture = install(); + const target = path.join(fixture.version, "contained-pac"); + fs.renameSync(fixture.executable, target); + executableLink(this, target, fixture.executable); + assert.strictEqual(validate(fixture.bin), fs.realpathSync.native(fixture.bin)); }); }); - describe("validatePacPath - rejects attacker paths", () => { - it("rejects /tmp path (attacker-controlled)", () => { - const attackerPath = "/tmp/ppbt-pac-root"; - assert.throws(() => validatePacPath(attackerPath), /Security validation failed.*not under the agent's _tasks directory/); - }); + describe("authority and exact layout", () => { + for (const root of [ + ["repository", "_tasks"], + ["other-agent", "_tasks"], + ["agent", "_tasks-sibling"], + ["agent", "_tasks", "extra", "_tasks"] + ]) { + it(`rejects a fully existing imitation under ${root.join("/")} for wrong authority`, () => { + const fixture = install(path.join(tempRoot, ...root)); + assert.isTrue(fs.statSync(fixture.executable).isFile()); + rejects(fixture.bin, /outside the executing bundle's task cache/); + }); + } - it("rejects source directory path", () => { - const attackerPath = "/home/vsts/work/1/s/fake-pac"; - assert.throws(() => validatePacPath(attackerPath), /Security validation failed.*not under the agent's _tasks directory/); + it("rejects the reporter's repository imitation without a version/bin suffix", () => { + const imitation = path.join(tempRoot, "repository", "_tasks", installerName); + populateBin(imitation); + rejects(imitation, /expected a known PowerPlatformToolInstaller_GUID\/2.minor.patch\/bin path/); }); - it("rejects pipeline workspace path", () => { - const attackerPath = "/home/vsts/work/1/a/malicious-pac"; - assert.throws(() => validatePacPath(attackerPath), /Security validation failed.*not under the agent's _tasks directory/); + const malformedLayouts = [ + { name: "GUID suffix", parts: [`${installerName}_suffix`, "2.0.137", "bin"] }, + { name: "unknown GUID", parts: ["PowerPlatformToolInstaller_00000000-0000-0000-0000-000000000000", "2.0.137", "bin"] }, + { name: "wrong task name", parts: [`OtherTask_${channels[0].guid}`, "2.0.137", "bin"] }, + { name: "task/cache markers in reversed order", parts: [installerName, "_tasks", "2.0.137", "bin"] }, + { name: "extra directory before version", parts: [installerName, "extra", "2.0.137", "bin"] }, + { name: "extra directory before bin", parts: [installerName, "2.0.137", "extra", "bin"] }, + { name: "extra directory after bin", parts: [installerName, "2.0.137", "bin", "extra"] }, + { name: "missing version", parts: [installerName, "bin"] }, + { name: "v1 version", parts: [installerName, "1.0.137", "bin"] }, + { name: "v3 version", parts: [installerName, "3.0.137", "bin"] }, + { name: "short version", parts: [installerName, "2.137", "bin"] }, + { name: "empty version component", parts: [installerName, "2..137", "bin"] }, + { name: "version suffix", parts: [installerName, "2.0.137-beta", "bin"] }, + { name: "extra version component", parts: [installerName, "2.0.137.1", "bin"] }, + { name: "bin suffix", parts: [installerName, "2.0.137", "bin-extra"] } + ]; + for (const scenario of malformedLayouts) { + it(`rejects ${scenario.name} even when the candidate and executable exist`, () => { + const fixture = populateBin(path.join(cacheRoot, ...scenario.parts)); + assert.isTrue(fs.statSync(fixture.executable).isFile()); + rejects(fixture.bin, /expected a known PowerPlatformToolInstaller_GUID\/2.minor.patch\/bin path/); + }); + } + + it("ignores unrelated CWD and spoofed agent/PAC variables when choosing the supplied trusted root", () => { + const legitimate = install(); + const forged = install(path.join(tempRoot, "repository", "_tasks")); + const environmentNames = [ + "AGENT_WORKFOLDER", "AGENT_HOMEDIRECTORY", "AGENT_BUILDDIRECTORY", + "BUILD_SOURCESDIRECTORY", "SYSTEM_DEFAULTWORKINGDIRECTORY", runnerModule.PacPathEnvVarName + ]; + const saved = environmentNames.map(name => ({ name, value: process.env[name] })); + const originalCwd = process.cwd(); + try { + for (const name of environmentNames) { + process.env[name] = name === runnerModule.PacPathEnvVarName ? forged.bin : path.dirname(path.dirname(forged.task)); + } + process.chdir(path.join(tempRoot, "repository")); + assert.strictEqual(validate(legitimate.bin), fs.realpathSync.native(legitimate.bin)); + rejects(forged.bin, /outside the executing bundle's task cache/); + } finally { + try { + process.chdir(originalCwd); + } finally { + for (const { name, value } of saved) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } + } }); + }); + + describe("lexical inputs and trusted module layout", () => { + const invalidInputs: { name: string; change: (absolute: string) => string }[] = [ + { name: "empty argument", change: () => "" }, + { name: "relative argument", change: absolute => path.relative(tempRoot, absolute) }, + { name: "dot traversal", change: absolute => `${path.dirname(absolute)}${path.sep}.${path.sep}${path.basename(absolute)}` }, + { name: "parent traversal", change: absolute => `${path.dirname(absolute)}${path.sep}child${path.sep}..${path.sep}${path.basename(absolute)}` }, + { name: "NUL argument", change: absolute => `${absolute}\0` } + ]; + for (const input of invalidInputs) { + for (const argument of ["PAC path", "trusted module"]) { + it(`rejects ${input.name} in ${argument} with setup guidance`, () => { + const fixture = install(); + const candidate = argument === "PAC path" ? input.change(fixture.bin) : fixture.bin; + const moduleDir = argument === "trusted module" ? input.change(trustedModuleDir) : trustedModuleDir; + rejects(candidate, /paths must be fully qualified platform paths without traversal or device namespaces/, moduleDir); + }); + } + } + + for (const parts of [ + ["ConsumerTask", "2.0.155"], + [`${consumerName}_suffix`, "2.0.155"], + [consumerName, "1.0.155"], + [consumerName, "2.0.155-beta"], + [consumerName, "2.0.155", "extra"] + ]) { + it(`rejects an existing malformed trusted module layout: ${parts.join("/")}`, () => { + const fixture = install(); + const moduleDir = path.join(cacheRoot, ...parts); + fs.mkdirSync(moduleDir, { recursive: true }); + rejects(fixture.bin, /executing bundle is not in a taskName_GUID\/v2-version directory/, moduleDir); + }); + } + }); + + describe("filesystem failures", () => { + for (const entry of ["task", "version", "bin", "platform", "tools", "executable"] as const) { + it(`propagates ENOENT for missing ${entry}`, () => { + const fixture = install(); + fs.rmSync(fixture[entry], { recursive: true }); + rejectsWithCode(fixture.bin, "ENOENT"); + }); + } + + for (const entry of ["bin", "platform", "tools"] as const) { + it(`rejects a file in place of the ${entry} directory`, () => { + const fixture = install(); + fs.rmSync(fixture[entry], { recursive: true }); + fs.writeFileSync(fixture[entry], "Not a directory"); + rejects(fixture.bin, /required task-cache or PAC directory is not a directory/); + }); + } - it("rejects path under _tasks but with wrong task GUID", () => { - const attackerPath = "/home/vsts/work/_tasks/SomeOtherTask_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/1.0.0/bin"; - assert.throws(() => validatePacPath(attackerPath), /Security validation failed.*does not reference a known/); + it("rejects an executable that is a directory", () => { + const fixture = install(); + fs.unlinkSync(fixture.executable); + fs.mkdirSync(fixture.executable); + rejects(fixture.bin, /PAC executable is not a regular file/); }); - it("rejects path that contains _tasks as a substring but not as a directory", () => { - const attackerPath = "/tmp/fake_tasks_dir/pac"; - assert.throws(() => validatePacPath(attackerPath), /Security validation failed/); + for (const call of [0, 1]) { + it(`fails closed when root identity ${call + 1} has a zero inode`, () => { + const fixture = install(); + const canonicalRoot = fs.realpathSync.native(cacheRoot); + const unavailable = fs.statSync(canonicalRoot, { bigint: true }); + unavailable.ino = BigInt(0); + const stat = sandbox.stub(fs, "statSync").callThrough(); + stat.withArgs(canonicalRoot, { bigint: true }).onCall(call).returns(unavailable); + rejects(fixture.bin, /filesystem does not expose a usable directory identity/); + }); + } + + it("propagates an identity stat failure unchanged", () => { + const fixture = install(); + const canonicalRoot = fs.realpathSync.native(cacheRoot); + const failure = Object.assign(new Error("Fixture directory identity access denied"), { code: "EACCES" }); + sandbox.stub(fs, "statSync").callThrough() + .withArgs(canonicalRoot, { bigint: true }).throws(failure); + assert.strictEqual(assert.throws(() => validate(fixture.bin)), failure); }); - it("rejects Windows temp path", () => { - const attackerPath = "C:\\Users\\vsts\\AppData\\Local\\Temp\\ppbt-pac-root"; - assert.throws(() => validatePacPath(attackerPath), /Security validation failed.*not under the agent's _tasks directory/); + it("propagates a canonicalization failure unchanged", () => { + const fixture = install(); + const canonicalVersion = fs.realpathSync.native(fixture.version); + const failure = Object.assign(new Error("Fixture bin access denied"), { code: "EACCES" }); + sandbox.stub(fs.realpathSync, "native").callThrough() + .withArgs(path.join(canonicalVersion, "bin")).throws(failure); + assert.strictEqual(assert.throws(() => validate(fixture.bin)), failure); }); + }); - it("rejects path with _tasks but fabricated GUID mimicking installer", () => { - const attackerPath = "/home/vsts/work/_tasks/PowerPlatformToolInstaller_00000000-0000-0000-0000-000000000000/1.0.0/bin"; - assert.throws(() => validatePacPath(attackerPath), /Security validation failed.*does not reference a known/); + describe("installation links and descendant escapes", () => { + for (const entry of ["task", "version"] as const) { + for (const location of ["outside cache", "inside cache"]) { + it(`rejects a linked ${entry} even with a real target ${location}`, () => { + const fixture = install(); + const target = location === "outside cache" ? install(path.join(tempRoot, "foreign", "_tasks")) : + entry === "task" ? install(cacheRoot, `OtherTask_${channels[1].guid}`) : + install(cacheRoot, installerName, "2.0.136"); + fs.rmSync(fixture[entry], { recursive: true }); + directoryLink(target[entry], fixture[entry]); + rejects(fixture.bin, /installer task and version entries must be directories, not links/); + }); + } + } + + for (const entry of ["bin", "platform", "tools", "executable"] as const) { + for (const location of ["outside cache", "another cached task", "another version"]) { + it(`rejects ${entry} redirection to ${location}`, function () { + const fixture = install(); + const target = location === "outside cache" ? install(path.join(tempRoot, "foreign", "_tasks")) : + location === "another cached task" ? install(cacheRoot, `PowerPlatformToolInstaller_${channels[1].guid}`) : + install(cacheRoot, installerName, "2.0.136"); + fs.rmSync(fixture[entry], { recursive: true }); + if (entry === "executable") { + executableLink(this, target.executable, fixture.executable); + } else { + directoryLink(target[entry], fixture[entry]); + } + rejects(fixture.bin, /PAC path redirects outside the selected installer version/); + }); + } + + it(`propagates ENOENT for a dangling ${entry} link`, function () { + const fixture = install(); + const missing = path.join(tempRoot, `missing-${entry}`); + fs.rmSync(fixture[entry], { recursive: true }); + if (entry === "executable") { + executableLink(this, missing, fixture.executable); + } else { + directoryLink(missing, fixture[entry]); + } + rejectsWithCode(fixture.bin, "ENOENT"); + }); + } + }); + + it("rejects distinct case-sensitive cache siblings (native or labeled identity fallback)", function () { + const sibling = path.join(path.dirname(cacheRoot), "_TASKS"); + const fixture = install(sibling); + if (sameNativeDirectory(cacheRoot, sibling)) { + // Without a case-sensitive native directory, model only the two canonical + // root identities. All installation paths and PAC contents remain real. + if (this.test) { + this.test.title += " [simulated root identities: native filesystem is case-insensitive]"; + } + const first = fs.statSync(cacheRoot, { bigint: true }); + const second = fs.statSync(sibling, { bigint: true }); + first.ino = BigInt("9007199254740992"); + second.ino = first.ino + BigInt(1); + const canonicalize = sandbox.stub(fs.realpathSync, "native").callThrough(); + canonicalize.withArgs(cacheRoot).returns(cacheRoot); + canonicalize.withArgs(sibling).returns(sibling); + const stat = sandbox.stub(fs, "statSync").callThrough(); + stat.withArgs(cacheRoot, { bigint: true }).returns(first); + stat.withArgs(sibling, { bigint: true }).returns(second); + } + rejects(fixture.bin, /outside the executing bundle's task cache/); + }); + + (process.platform === "linux" ? describe : describe.skip)("Linux exact-case and executable permissions", () => { + for (const entry of ["task", "bin", "platform", "tools", "executable"] as const) { + it(`does not case-fold the ${entry} component`, () => { + const fixture = install(); + const changed = path.join(path.dirname(fixture[entry]), path.basename(fixture[entry]).toUpperCase()); + fs.renameSync(fixture[entry], changed); + if (entry === "task") { + rejects(path.join(changed, "2.0.137", "bin"), /expected a known PowerPlatformToolInstaller_GUID/); + } else if (entry === "bin") { + rejects(changed, /expected a known PowerPlatformToolInstaller_GUID/); + } else { + rejectsWithCode(fixture.bin, "ENOENT"); + } + }); + } + + it("propagates EACCES when the regular PAC file lacks X_OK", () => { + const fixture = install(); + fs.chmodSync(fixture.executable, 0o644); + rejectsWithCode(fixture.bin, "EACCES"); }); }); + + (windows ? describe : describe.skip)("Windows native aliases and lexical restrictions", () => { + const aliases: { name: string; change: (fixture: Installation) => string }[] = [ + { name: "installer case", change: fixture => path.join(cacheRoot, installerName.toLowerCase(), path.basename(fixture.version), "bin") }, + { name: "bin case", change: fixture => path.join(fixture.version, "BIN") }, + { + name: "drive case", + change: fixture => fixture.bin.replace(/^[a-z](?=:)/i, + drive => drive === drive.toUpperCase() ? drive.toLowerCase() : drive.toUpperCase()) + } + ]; + for (const alias of aliases) { + it(`accepts ${alias.name} only when it resolves to the same native object`, function () { + const fixture = install(); + const candidate = alias.change(fixture); + if (candidate === fixture.bin) { + skipWithReason(this, "native path has no drive-letter case alias"); + } + if (!fs.existsSync(candidate) || !sameNativeDirectory(fixture.bin, candidate)) { + skipWithReason(this, "case alias does not identify the same native directory"); + } + assert.strictEqual(validate(candidate), fs.realpathSync.native(fixture.bin)); + }); + } + + const invalidWindowsPaths: { name: string; change: (absolute: string) => string }[] = [ + { name: "root-relative", change: absolute => `\\${absolute.slice(path.parse(absolute).root.length)}` }, + { name: "drive-relative", change: absolute => `${path.parse(absolute).root.slice(0, 2)}${absolute.slice(path.parse(absolute).root.length)}` }, + { name: "extended device namespace", change: absolute => `\\\\?\\${absolute}` }, + { name: "device namespace", change: absolute => `\\\\.\\${absolute}` }, + { name: "trailing dot", change: absolute => `${absolute}.` }, + { name: "trailing space", change: absolute => `${absolute} ` }, + { name: "intermediate trailing dot", change: absolute => `${path.dirname(absolute)}.\\${path.basename(absolute)}` }, + { name: "intermediate trailing space", change: absolute => `${path.dirname(absolute)} \\${path.basename(absolute)}` } + ]; + for (const input of invalidWindowsPaths) { + for (const argument of ["PAC path", "trusted module"]) { + it(`rejects ${input.name} in ${argument} before filesystem normalization`, () => { + const fixture = install(); + const candidate = argument === "PAC path" ? input.change(fixture.bin) : fixture.bin; + const moduleDir = argument === "trusted module" ? input.change(trustedModuleDir) : trustedModuleDir; + rejects(candidate, /paths must be fully qualified platform paths without traversal or device namespaces/, moduleDir); + }); + } + } + }); }); From 43f0c65470b37302743e8960bb73cbf08dab6d8b Mon Sep 17 00:00:00 2001 From: Gene Chu Date: Thu, 10 Sep 2026 10:56:04 -0700 Subject: [PATCH 2/2] test: fix PAC path error assertions for Chai typings Capture and narrow thrown errors explicitly instead of reading assert.throws' void-typed result. Preserve message, filesystem-code, and error-identity assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/unit-test/pac-path-validation.test.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/test/unit-test/pac-path-validation.test.ts b/test/unit-test/pac-path-validation.test.ts index 385838d2..4b8eb541 100644 --- a/test/unit-test/pac-path-validation.test.ts +++ b/test/unit-test/pac-path-validation.test.ts @@ -88,8 +88,22 @@ nativeSuite(`PAC CLI path validation (native ${process.platform})`, () => { return runnerModule.validatePacPath(candidate, moduleDir); } + function captureError(action: () => void): Error { + let thrown: unknown; + try { + action(); + } catch (error: unknown) { + thrown = error; + } + if (thrown instanceof Error) { + return thrown; + } + throw new Error("Expected validation to throw an Error"); + } + function rejects(candidate: string, reason: RegExp, moduleDir = trustedModuleDir): void { - const error = assert.throws(() => validate(candidate, moduleDir), reason); + const error = captureError(() => validate(candidate, moduleDir)); + assert.match(error.message, reason); assert.match(error.message, /^Security validation failed:/); assert.include(error.message, "PowerPlatformToolInstaller@2"); assert.include(error.message, runnerModule.PacPathEnvVarName); @@ -97,7 +111,7 @@ nativeSuite(`PAC CLI path validation (native ${process.platform})`, () => { } function rejectsWithCode(candidate: string, code: string): void { - assert.propertyVal(assert.throws(() => validate(candidate)), "code", code); + assert.propertyVal(captureError(() => validate(candidate)), "code", code); } function directoryLink(target: string, link: string): void { @@ -350,7 +364,7 @@ nativeSuite(`PAC CLI path validation (native ${process.platform})`, () => { const failure = Object.assign(new Error("Fixture directory identity access denied"), { code: "EACCES" }); sandbox.stub(fs, "statSync").callThrough() .withArgs(canonicalRoot, { bigint: true }).throws(failure); - assert.strictEqual(assert.throws(() => validate(fixture.bin)), failure); + assert.strictEqual(captureError(() => validate(fixture.bin)), failure); }); it("propagates a canonicalization failure unchanged", () => { @@ -359,7 +373,7 @@ nativeSuite(`PAC CLI path validation (native ${process.platform})`, () => { const failure = Object.assign(new Error("Fixture bin access denied"), { code: "EACCES" }); sandbox.stub(fs.realpathSync, "native").callThrough() .withArgs(path.join(canonicalVersion, "bin")).throws(failure); - assert.strictEqual(assert.throws(() => validate(fixture.bin)), failure); + assert.strictEqual(captureError(() => validate(fixture.bin)), failure); }); });