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..4b8eb541 100644 --- a/test/unit-test/pac-path-validation.test.ts +++ b/test/unit-test/pac-path-validation.test.ts @@ -1,81 +1,517 @@ // 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 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"); + } - 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 rejects(candidate: string, reason: RegExp, moduleDir = trustedModuleDir): void { + 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); + assert.notProperty(error, "code", "policy rejection must not be an incidental filesystem error"); + } + + function rejectsWithCode(candidate: string, code: string): void { + assert.propertyVal(captureError(() => 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); + }); + } + } - 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/); + 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 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(captureError(() => 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(captureError(() => 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); + }); + } + } + }); });