This commit is contained in:
Jason Ginchereau 2026-05-20 12:28:27 -10:00 committed by GitHub
commit 2b49858bbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 201060 additions and 164352 deletions

View file

@ -0,0 +1,282 @@
name: Path Validation E2E
# Manually triggered only — this matrix runs 39 jobs with multiple cache
# saves/restores per job, which is too expensive to run on every PR. Maintainers
# should dispatch it from the Actions tab against a PR branch whenever changes
# touch the path-validation codepath (src/restoreImpl.ts, src/utils/actionUtils.ts,
# or the bundled @actions/cache version).
on:
workflow_dispatch:
permissions:
contents: read
# This workflow validates client-side path validation for action/cache restores.
# It is intentionally structured as two phases:
# 1. The "good cache" phase saves a legitimate cache, then restores it under
# each strict-paths value and asserts extraction succeeded.
# 2. The "poisoned cache" phase manufactures a tar archive that contains an
# entry whose path resolves outside the declared `path` input. It uploads
# that archive directly to the cache backend via the toolkit's internal
# APIs (not via this action), then attempts to restore it via this action
# under each strict-paths value and asserts the expected behavior:
# - off: the malicious entry is extracted (validation disabled).
# - warn: the malicious entry is extracted but a workflow warning is logged.
# - error: the malicious entry is rejected (no extraction).
#
# NOTE: The poisoned-cache phase relies on a small Node.js helper script
# (__tests__/e2e/save-poisoned-cache.mjs) that the workflow invokes. Rather
# than fabricating a tar archive by hand, the helper calls the toolkit's
# `@actions/cache.saveCache()` with the declared `path` AND one or more extra
# paths that escape it; the toolkit packs everything into a normal cache
# archive. The action's later restore step declares only the legitimate
# `path`, so the extra entries become "escape" entries that the client-side
# validation should reject (or warn about) per the configured strict-paths
# mode.
jobs:
good-cache:
name: 'Restore legitimate cache (strict-paths=${{ matrix.strict-paths }})'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022]
strict-paths: [off, warn, error]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: 24.x
- name: Install dependencies and build
run: |
npm ci
npm run build
- name: Generate legitimate cache files
shell: bash
run: |
mkdir -p path-validation-cache
for i in 1 2 3 4 5; do
echo "file-${i}-${{ matrix.os }}-${{ matrix.strict-paths }}" > "path-validation-cache/file-${i}.txt"
done
- name: Save legitimate cache
uses: ./
with:
key: path-validation-good-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
path: path-validation-cache
- name: Remove local files
shell: bash
run: rm -rf path-validation-cache
- name: Restore legitimate cache (should succeed under all modes)
id: restore
uses: ./
with:
key: path-validation-good-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
path: path-validation-cache
strict-paths: ${{ matrix.strict-paths }}
fail-on-cache-invalid: true
- name: Verify legitimate cache extracted
shell: bash
run: |
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
echo "::error::Expected cache-hit=true but got '${{ steps.restore.outputs.cache-hit }}'"
exit 1
fi
for i in 1 2 3 4 5; do
if [ ! -f "path-validation-cache/file-${i}.txt" ]; then
echo "::error::Missing expected file path-validation-cache/file-${i}.txt"
exit 1
fi
done
echo "Legitimate cache restored successfully under strict-paths=${{ matrix.strict-paths }}"
poisoned-cache:
name: 'Restore poisoned cache (strict-paths=${{ matrix.strict-paths }})'
needs: good-cache
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022]
strict-paths: [off, warn, error]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: 24.x
- name: Install dependencies and build
run: |
npm ci
npm run build
# Build a tar.zst archive containing one legitimate entry plus one entry
# whose path escapes the declared `path` input. We then save it to the
# cache backend using the toolkit's saveCache() with the same path the
# restore step uses, so the archive header roots and declared paths match
# what the action expects.
- name: Generate poisoned archive locally
shell: bash
run: |
mkdir -p path-validation-cache
echo "legitimate" > path-validation-cache/legit.txt
# The escape file is created at the working-dir level (one above the
# declared path), simulating an attacker who built the cache from a
# workspace where the relative path "../escape.txt" pointed outside
# the declared `path: path-validation-cache` input.
echo "should-be-rejected" > escape.txt
- name: Save poisoned cache via toolkit helper
shell: bash
env:
POISONED_KEY: path-validation-bad-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
run: |
node __tests__/e2e/save-poisoned-cache.mjs \
"$POISONED_KEY" \
path-validation-cache \
../escape.txt
- name: Remove staged files (force restore to re-extract)
shell: bash
run: |
rm -rf path-validation-cache escape.txt
- name: Restore poisoned cache
id: restore
continue-on-error: true
uses: ./
with:
key: path-validation-bad-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
path: path-validation-cache
strict-paths: ${{ matrix.strict-paths }}
# Always treat integrity failures as misses for this E2E so we can
# assert on outputs rather than job failure.
fail-on-cache-invalid: false
- name: Assert behavior for strict-paths=off (no validation)
if: matrix.strict-paths == 'off'
shell: bash
run: |
# In off mode the malicious entry IS extracted.
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
echo "::error::Expected cache-hit=true for strict-paths=off"
exit 1
fi
if [ ! -f "escape.txt" ]; then
echo "::error::Expected the malicious entry 'escape.txt' to be extracted in 'off' mode"
exit 1
fi
echo "OK: strict-paths=off extracted the cache without validation (expected legacy behavior)."
- name: Assert behavior for strict-paths=warn (warn but extract)
if: matrix.strict-paths == 'warn'
shell: bash
run: |
# In warn mode the cache IS extracted but a warning should be logged.
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
echo "::error::Expected cache-hit=true for strict-paths=warn"
exit 1
fi
if [ ! -f "escape.txt" ]; then
echo "::error::Expected the malicious entry 'escape.txt' to be extracted in 'warn' mode (warn does not reject)"
exit 1
fi
echo "OK: strict-paths=warn extracted the cache (a workflow warning should be visible in the action log above)."
- name: Assert behavior for strict-paths=error (reject)
if: matrix.strict-paths == 'error'
shell: bash
run: |
# In error mode the action should treat the cache as a miss
# (because fail-on-cache-invalid: false).
if [ -f "escape.txt" ]; then
echo "::error::Malicious entry 'escape.txt' was extracted in 'error' mode (validation failed open)"
exit 1
fi
if [ -f "path-validation-cache/legit.txt" ]; then
echo "::error::Cache was extracted in 'error' mode (validation should have rejected the entire archive)"
exit 1
fi
# The discarded cache must look identical to a regular cache miss
# to downstream `if:` checks (see issue #1466), so `cache-hit` is
# intentionally NOT set (empty string), NOT 'false'.
if [ -n "${{ steps.restore.outputs.cache-hit }}" ]; then
echo "::error::Expected cache-hit to be unset for rejected cache in 'error' mode (got '${{ steps.restore.outputs.cache-hit }}')"
exit 1
fi
echo "OK: strict-paths=error rejected the poisoned cache and treated it as a miss."
poisoned-cache-fail-on-invalid:
name: 'Reject poisoned cache (fail-on-cache-invalid=true)'
needs: poisoned-cache
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: 24.x
- name: Install dependencies and build
run: |
npm ci
npm run build
- name: Generate poisoned archive locally
shell: bash
run: |
mkdir -p path-validation-cache
echo "legitimate" > path-validation-cache/legit.txt
echo "should-be-rejected" > escape.txt
- name: Save poisoned cache via toolkit helper
shell: bash
env:
POISONED_KEY: path-validation-fail-${{ matrix.os }}-${{ github.run_id }}
run: |
node __tests__/e2e/save-poisoned-cache.mjs \
"$POISONED_KEY" \
path-validation-cache \
../escape.txt
- name: Remove staged files
shell: bash
run: |
rm -rf path-validation-cache escape.txt
- name: Attempt restore (expected to fail the workflow)
id: restore
continue-on-error: true
uses: ./
with:
key: path-validation-fail-${{ matrix.os }}-${{ github.run_id }}
path: path-validation-cache
strict-paths: error
fail-on-cache-invalid: true
- name: Assert the restore step failed
shell: bash
run: |
if [ "${{ steps.restore.outcome }}" != "failure" ]; then
echo "::error::Expected the restore step to fail when fail-on-cache-invalid=true and the cache is rejected (got outcome='${{ steps.restore.outcome }}')"
exit 1
fi
echo "OK: restore step failed as expected when fail-on-cache-invalid=true."

View file

@ -90,6 +90,11 @@ If you are using a `self-hosted` Windows runner, `GNU tar` and `zstd` are requir
* `enableCrossOsArchive` - An optional boolean when enabled, allows Windows runners to save or restore caches that can be restored or saved respectively on other platforms. Default: `false`
* `fail-on-cache-miss` - Fail the workflow if cache entry is not found. Default: `false`
* `lookup-only` - If true, only checks if cache entry exists and skips download. Does not change save cache behavior. Default: `false`
* `strict-paths` - Client-side path-validation strictness applied when extracting a restored cache. Helps protect against some forms of cache poisoning attacks. Valid values:
* `off` - Disable path validation entirely (legacy behavior). Skipping validation may slightly improve performance for very large cache archives, but is not recommended for best security.
* `warn` *(current default)* - Pre-scan the archive and emit a workflow warning if any entry would resolve outside the declared `path` inputs. The cache is still extracted.
* `error` *(future default)* - Pre-scan the archive and reject it (without extracting) if any entry would resolve outside the declared `path` inputs.
* `fail-on-cache-invalid` - Fail the workflow when a restored cache is rejected by client-side validation (entries that escape the declared paths, or an archive that cannot be parsed). Only applies when `strict-paths: error` is set; the `off` and `warn` modes never reject a cache. When `false` (default) the rejected cache is treated as a cache miss.
#### Environment Variables

View file

@ -25,6 +25,13 @@
## Changelog
### 5.1.0
- Add path validation for restored caches.
- New `strict-paths` input (`off`, `warn` *(default)*, `error`) pre-scans the archive entry list before extraction and reports or rejects entries that would resolve outside the declared `path` inputs.
- New `fail-on-cache-invalid` input controls whether a rejected cache archive fails the workflow (`true`) or is treated as a cache miss (`false`, default).
- Bump `@actions/cache` toolkit dependency to `^6.1.0` (introduces the validation surface and bumps `tar` to v7.5.15).
### 5.0.4
- Bump `minimatch` to v3.1.5 (fixes ReDoS via globstar patterns)

View file

@ -0,0 +1,119 @@
/**
* Local CommonJS stub for the `@actions/cache` toolkit package.
*
* The published toolkit is an ESM-only package, which Jest's CJS resolver
* cannot load directly. The action's runtime is bundled by `@vercel/ncc`
* (which handles ESM deps), but Jest tests run uncompiled and therefore
* need a CJS-compatible surface to import.
*
* This file re-implements just the public surface that the action's source
* code imports, with no-op implementations. Tests use `jest.spyOn` or
* `jest.mock("@actions/cache")` to override the implementations as needed.
*
* Wired up via `moduleNameMapper` in `jest.config.js`.
*
* Types are pulled from the real `@actions/cache` package via type-only
* imports, so a TypeScript build (via `tsc --noEmit` or ts-jest) verifies
* that the stub's runtime surface still satisfies the real package's
* signatures a signature drift (renamed parameter, added property,
* changed return type) will surface here as a compile error rather than
* as a silent test-only behavior change. `import type` is fully erased at
* compile time, so the Jest `moduleNameMapper` redirect for this file is
* not affected at runtime (no self-referential require loop).
*/
import type * as Cache from "@actions/cache";
// Re-export the toolkit's types so consumers of this stub and consumers of
// the real package see identical types — there is no second source of truth.
export type {
CacheIntegrityErrorCode,
DownloadOptions,
PathValidationMode,
PathValidationViolation,
UploadOptions
} from "@actions/cache";
// Each `typeof Cache.X` annotation forces the local implementation to be
// assignable to the real package's exported signature.
export const ValidationError: typeof Cache.ValidationError = class extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
};
export const ReserveCacheError: typeof Cache.ReserveCacheError = class extends Error {
constructor(message: string) {
super(message);
this.name = "ReserveCacheError";
}
};
export const FinalizeCacheError: typeof Cache.FinalizeCacheError = class extends Error {
constructor(message: string) {
super(message);
this.name = "FinalizeCacheError";
}
};
export const CacheIntegrityError: typeof Cache.CacheIntegrityError = class extends Error {
readonly code: Cache.CacheIntegrityErrorCode;
readonly violations?: Cache.PathValidationViolation[];
constructor(
code: Cache.CacheIntegrityErrorCode,
message: string,
violations?: Cache.PathValidationViolation[]
) {
super(message);
this.name = "CacheIntegrityError";
this.code = code;
this.violations = violations;
}
};
export const isFeatureAvailable: typeof Cache.isFeatureAvailable = () => true;
function checkKey(key: string): void {
if (key.length > 512) {
throw new ValidationError(
`Key Validation Error: ${key} cannot be larger than 512 characters.`
);
}
const regex = /^[^,]*$/;
if (!regex.test(key)) {
throw new ValidationError(
`Key Validation Error: ${key} cannot contain commas.`
);
}
}
export const restoreCache: typeof Cache.restoreCache = async (
_paths,
primaryKey,
restoreKeys,
_options,
_enableCrossOsArchive
) => {
const keys = [primaryKey, ...(restoreKeys ?? [])];
if (keys.length > 10) {
throw new ValidationError(
`Key Validation Error: Keys are limited to a maximum of 10.`
);
}
for (const key of keys) {
checkKey(key);
}
return undefined;
};
export const saveCache: typeof Cache.saveCache = async (
_paths,
key,
_options,
_enableCrossOsArchive
) => {
checkKey(key);
return -1;
};

View file

@ -1,7 +1,7 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import { Events, RefKey } from "../src/constants";
import { Events, Inputs, RefKey } from "../src/constants";
import * as actionUtils from "../src/utils/actionUtils";
import * as testUtils from "../src/utils/testUtils";
@ -265,3 +265,58 @@ test("isGhes returns true when the GITHUB_SERVER_URL environment variable is set
process.env["GITHUB_SERVER_URL"] = "https://src.onpremise.fabrikam.com";
expect(actionUtils.isGhes()).toBeTruthy();
});
describe("getPathValidationInput", () => {
const inputEnv = `INPUT_${Inputs.StrictPaths.toUpperCase()}`;
beforeEach(() => {
delete process.env[inputEnv];
// Re-mock getInput so the each-test environment reads the input env var
jest.spyOn(core, "getInput").mockImplementation((name, options) => {
return jest.requireActual("@actions/core").getInput(name, options);
});
});
afterEach(() => {
delete process.env[inputEnv];
});
test("returns 'warn' when input is unset", () => {
expect(actionUtils.getPathValidationInput()).toBe("warn");
});
test.each([
["off", "off"],
["warn", "warn"],
["error", "error"],
["OFF", "off"],
["Warn", "warn"],
["ERROR", "error"]
])("normalizes %s to %s", (input, expected) => {
process.env[inputEnv] = input;
expect(actionUtils.getPathValidationInput()).toBe(expected);
});
test("falls back to 'warn' for unrecognized values and emits a workflow warning", () => {
process.env[inputEnv] = "strict";
// Suppress the real implementation so the warning does not pollute
// the Jest log, and assert it was emitted via core.warning so it
// surfaces as a real `::warning::` workflow annotation.
const warningSpy = jest
.spyOn(core, "warning")
.mockImplementation(() => undefined);
try {
expect(actionUtils.getPathValidationInput()).toBe("warn");
expect(warningSpy).toHaveBeenCalledWith(
expect.stringContaining("Unrecognized value for strict-paths")
);
} finally {
warningSpy.mockRestore();
}
});
test("treats empty string as default 'warn'", () => {
process.env[inputEnv] = "";
expect(actionUtils.getPathValidationInput()).toBe("warn");
});
});

View file

@ -0,0 +1,45 @@
// @ts-check
/**
* save-poisoned-cache.mjs
*
* Helper script used by the path-validation E2E workflow to upload a cache
* archive that contains entries outside the declared `path` inputs. This
* simulates a poisoned cache that would have been produced by a build job
* that had write access to the workspace's parent directory (the canonical
* cache-poisoning scenario being defended against).
*
* Usage:
* node save-poisoned-cache.mjs <cache-key> <declared-path> [extra-path ...]
*
* The script invokes `@actions/cache.saveCache()` with the declared path(s)
* AND extra paths that escape the workspace. The toolkit's saveCache packs
* everything into the archive, so the resulting cache entry will contain
* "escape" entries that resolve outside the declared `path` when the action's
* `restore` step later extracts it (because the restore step only declares the
* legitimate `path`).
*
* Important: this script is NOT shipped to users. It is purely a test fixture
* generator used by the E2E workflow to validate that the action's client-side
* validation correctly rejects (or warns about) such caches.
*/
import * as cache from '@actions/cache';
const [, , key, ...paths] = process.argv;
if (!key || paths.length === 0) {
console.error(
'Usage: node save-poisoned-cache.mjs <cache-key> <path> [extra-path ...]'
);
process.exit(2);
}
console.log(`Saving poisoned cache with key="${key}" paths=${JSON.stringify(paths)}`);
try {
const cacheId = await cache.saveCache(paths, key);
console.log(`Saved poisoned cache (cacheId=${cacheId})`);
} catch (err) {
console.error(`Failed to save poisoned cache: ${err?.message ?? err}`);
process.exit(1);
}

View file

@ -34,6 +34,11 @@ beforeAll(() => {
return actualUtils.getInputAsBool(name, options);
}
);
jest.spyOn(actionUtils, "getPathValidationInput").mockImplementation(() => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.getPathValidationInput();
});
});
beforeEach(() => {
@ -79,7 +84,8 @@ test("restore with no cache found", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -122,7 +128,8 @@ test("restore with restore keys and no cache found", async () => {
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -164,7 +171,8 @@ test("restore with cache found for key", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -209,7 +217,8 @@ test("restore with cache found for restore key", async () => {
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -254,7 +263,8 @@ test("Fail restore when fail on cache miss is enabled and primary + restore keys
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -297,7 +307,8 @@ test("restore when fail on cache miss is enabled and primary key doesn't match r
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -343,7 +354,8 @@ test("restore with fail on cache miss disabled and no cache found", async () =>
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);

View file

@ -35,6 +35,16 @@ beforeAll(() => {
return actualUtils.getInputAsBool(name, options);
}
);
jest.spyOn(actionUtils, "getPathValidationInput").mockImplementation(() => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.getPathValidationInput();
});
jest.spyOn(actionUtils, "logWarning").mockImplementation(message => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.logWarning(message);
});
});
beforeEach(() => {
@ -127,7 +137,8 @@ test("restore on GHES with AC available ", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -181,7 +192,8 @@ test("restore with too many keys should fail", async () => {
key,
restoreKeys,
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -207,7 +219,8 @@ test("restore with large key should fail", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -233,7 +246,8 @@ test("restore with invalid key should fail", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -268,7 +282,8 @@ test("restore with no cache found", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -309,7 +324,8 @@ test("restore with restore keys and no cache found", async () => {
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -349,7 +365,8 @@ test("restore with cache found for key", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -391,7 +408,8 @@ test("restore with cache found for restore key", async () => {
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -432,7 +450,8 @@ test("restore with lookup-only set", async () => {
key,
[],
{
lookupOnly: true
lookupOnly: true,
pathValidation: "warn"
},
false
);
@ -465,3 +484,246 @@ test("restore failure with earlyExit should call process exit", async () => {
);
expect(processExitMock).toHaveBeenCalledWith(1);
});
// ---------------------------------------------------------------------------
// Path validation tests
//
// These tests verify that the action correctly forwards the `strict-paths`
// input to the @actions/cache toolkit and handles `CacheIntegrityError`
// rejections according to the `fail-on-cache-invalid` input.
// ---------------------------------------------------------------------------
test("restore defaults strict-paths to 'warn' and forwards it to restoreCache", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key });
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockResolvedValueOnce(key);
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{ lookupOnly: false, pathValidation: "warn" },
false
);
});
test.each(["off", "warn", "error"])(
"restore forwards strict-paths value '%s' to restoreCache",
async value => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: value });
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockResolvedValueOnce(key);
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{ lookupOnly: false, pathValidation: value },
false
);
}
);
test("restore falls back to 'warn' when strict-paths input is unrecognized", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: "STRICT" });
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockResolvedValueOnce(key);
// getPathValidationInput() emits the misconfiguration notice via
// core.warning() so it surfaces as a real `::warning::` workflow
// annotation. Suppress the real implementation to keep the Jest log
// clean while asserting it was called.
const warningMock = jest
.spyOn(core, "warning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledWith(
[path],
key,
[],
{ lookupOnly: false, pathValidation: "warn" },
false
);
expect(warningMock).toHaveBeenCalledWith(
expect.stringContaining("Unrecognized value for strict-paths")
);
});
test("restore treats CacheIntegrityError as a cache miss by default", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: "error" });
const integrityError = new Error("entries escape declared paths");
integrityError.name = "CacheIntegrityError";
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
const restoreCacheMock = jest
.spyOn(cache, "restoreCache")
.mockRejectedValueOnce(integrityError);
const setOutputMock = jest.spyOn(core, "setOutput");
const failedMock = jest.spyOn(core, "setFailed");
// Suppress the real logWarning so the discarded-cache warning does not
// pollute test output. beforeEach's jest.restoreAllMocks() handles
// cross-test cleanup.
const logWarningMock = jest
.spyOn(actionUtils, "logWarning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
// Intentionally NOT set: a discarded cache must look identical to a
// regular cache miss to downstream `if:` checks (see issue #1466).
const cacheHitCalls = setOutputMock.mock.calls.filter(
c => c[0] === "cache-hit"
);
expect(cacheHitCalls).toHaveLength(0);
expect(failedMock).not.toHaveBeenCalled();
expect(logWarningMock).toHaveBeenCalledWith(
expect.stringContaining("PATH_VIOLATION")
);
});
test("restore fails when CacheIntegrityError is raised and fail-on-cache-invalid is true", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({
path,
key,
strictPaths: "error",
failOnCacheInvalid: true
});
const integrityError = new Error("entry escapes workspace");
integrityError.name = "CacheIntegrityError";
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
const failedMock = jest.spyOn(core, "setFailed");
await restoreImpl(new StateProvider());
expect(failedMock).toHaveBeenCalledTimes(1);
expect(failedMock.mock.calls[0][0]).toContain("integrity validation");
expect(failedMock.mock.calls[0][0]).toContain("PATH_VIOLATION");
});
test("restore propagates non-integrity errors normally", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key });
const networkError = new Error("Network timeout");
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(networkError);
const failedMock = jest.spyOn(core, "setFailed");
const logWarningMock = jest
.spyOn(actionUtils, "logWarning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
expect(failedMock).toHaveBeenCalledWith("Network timeout");
expect(logWarningMock).not.toHaveBeenCalledWith(
expect.stringContaining("integrity")
);
});
test("restore parse-error integrity failure also treated as miss by default", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: "error" });
const parseError = new Error("malformed gzip header");
parseError.name = "CacheIntegrityError";
(parseError as Error & { code?: string }).code = "PARSE_ERROR";
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(parseError);
const setOutputMock = jest.spyOn(core, "setOutput");
const failedMock = jest.spyOn(core, "setFailed");
const logWarningMock = jest
.spyOn(actionUtils, "logWarning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
const cacheHitCalls = setOutputMock.mock.calls.filter(
c => c[0] === "cache-hit"
);
expect(cacheHitCalls).toHaveLength(0);
expect(failedMock).not.toHaveBeenCalled();
expect(logWarningMock).toHaveBeenCalledWith(
expect.stringContaining("PARSE_ERROR")
);
});
test("restore tolerates CacheIntegrityError without explicit code", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({ path, key, strictPaths: "error" });
const integrityError = new Error("bad archive");
integrityError.name = "CacheIntegrityError";
// intentionally no .code property
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
const setOutputMock = jest.spyOn(core, "setOutput");
const logWarningMock = jest
.spyOn(actionUtils, "logWarning")
.mockImplementation(() => undefined);
await restoreImpl(new StateProvider());
const cacheHitCalls = setOutputMock.mock.calls.filter(
c => c[0] === "cache-hit"
);
expect(cacheHitCalls).toHaveLength(0);
expect(logWarningMock).toHaveBeenCalledWith(
expect.stringContaining("unknown")
);
});
test("restore does not set cache-hit output when integrity error is rethrown", async () => {
const path = "node_modules";
const key = "node-test";
testUtils.setInputs({
path,
key,
strictPaths: "error",
failOnCacheInvalid: true
});
const integrityError = new Error("rejected");
integrityError.name = "CacheIntegrityError";
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
const setOutputMock = jest.spyOn(core, "setOutput");
await restoreImpl(new StateProvider());
// setOutput should NOT have been called with cache-hit at all in this path
const cacheHitCalls = setOutputMock.mock.calls.filter(
c => c[0] === "cache-hit"
);
expect(cacheHitCalls).toHaveLength(0);
});

View file

@ -35,6 +35,11 @@ beforeAll(() => {
.getInputAsBool(name, options);
}
);
jest.spyOn(actionUtils, "getPathValidationInput").mockImplementation(() => {
const actualUtils = jest.requireActual("../src/utils/actionUtils");
return actualUtils.getPathValidationInput();
});
});
beforeEach(() => {
@ -80,7 +85,8 @@ test("restore with no cache found", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -122,7 +128,8 @@ test("restore with restore keys and no cache found", async () => {
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -161,7 +168,8 @@ test("restore with cache found for key", async () => {
key,
[],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);
@ -204,7 +212,8 @@ test("restore with cache found for restore key", async () => {
key,
[restoreKey],
{
lookupOnly: false
lookupOnly: false,
pathValidation: "warn"
},
false
);

View file

@ -34,6 +34,23 @@ inputs:
save-always does not work as intended and will be removed in a future release.
A separate `actions/cache/restore` step should be used instead.
See https://github.com/actions/cache/tree/main/save#always-save-cache for more details.
strict-paths:
description: |
Controls client-side validation of cache archive entry paths before extraction.
'off' disables validation (legacy behavior). 'warn' logs a single warning when any
entry would resolve outside the declared `path` inputs and still extracts the cache.
'error' rejects the cache with a CacheIntegrityError and skips extraction entirely.
Default is 'warn'.
default: 'warn'
required: false
fail-on-cache-invalid:
description: |
Fail the workflow if the restored cache is rejected by client-side path validation
(entries that escape the declared paths, or an archive that cannot be parsed).
Only applies when `strict-paths` is 'error'; the 'off' and 'warn' modes never
reject a cache. When 'false' (default), a rejected cache is treated as a cache miss.
default: 'false'
required: false
outputs:
cache-hit:
description: 'A boolean value to indicate an exact match was found for the primary key'

File diff suppressed because one or more lines are too long

90961
dist/restore/index.js vendored

File diff suppressed because one or more lines are too long

90935
dist/save-only/index.js vendored

File diff suppressed because one or more lines are too long

90935
dist/save/index.js vendored

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,239 @@
# Path Validation Test Plan (`actions/cache@v5.1+`)
This document describes the test coverage for the client-side path-validation
feature added to `actions/cache` v5.1.0. The validation feature itself lives
in the `@actions/cache` toolkit (v6.1.0+); this action wires the new
`strict-paths` and `fail-on-cache-invalid` inputs through to the toolkit and
handles the `CacheIntegrityError` it can throw.
The companion document in the toolkit repo
([`packages/cache/docs/path-validation-test-plan.md`](../../actions-toolkit/packages/cache/docs/path-validation-test-plan.md))
covers the **behavioral** tests for the validation engine itself. This document
focuses on the **action-layer** wiring tests.
## Summary
| Layer | Test file | Tests | What it validates |
| ------------------------------------ | -------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------ |
| Input parsing | `__tests__/actionUtils.test.ts` | 9 new | `getPathValidationInput()` normalizes input values, defaults to `warn`, warns on unknown values |
| restoreImpl integration | `__tests__/restoreImpl.test.ts` | 9 new | Forwards `strict-paths` to `cache.restoreCache`, handles `CacheIntegrityError` per `fail-on-cache-invalid` |
| Regression — existing forward tests | `__tests__/restoreImpl.test.ts` (updated) | 11 | Existing tests now assert the `pathValidation` field is present in the options object passed to `cache.restoreCache`|
| End-to-end (good cache) | `.github/workflows/path-validation-e2e.yml` | 18 | A legitimate cache restores successfully under all three `strict-paths` modes on every supported OS |
| End-to-end (poisoned cache) | `.github/workflows/path-validation-e2e.yml` | 18 | A poisoned cache is treated correctly per mode (extracted in `off`/`warn`, rejected in `error`) |
| End-to-end (fail-on-cache-invalid) | `.github/workflows/path-validation-e2e.yml` | 3 | When `fail-on-cache-invalid: true` and the cache is rejected, the restore step itself fails the workflow |
Total: **77 new/updated tests** at the action layer (29 unit + 39 E2E job runs + 9 input-parsing).
## Unit tests
### `__tests__/actionUtils.test.ts``getPathValidationInput()` (9 new)
Verifies the input-parsing helper that the action's `restoreImpl` uses to
translate the `strict-paths` workflow input into the literal type expected by
the toolkit.
| Test | Asserts |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| returns `'warn'` when input is unset | Default behavior matches the action.yml default |
| normalizes `off` / `warn` / `error` / `OFF` / `Warn` / `ERROR` | Case-insensitive parsing of the three valid values |
| falls back to `'warn'` for unrecognized values and logs a warning | Typos don't silently disable validation; user gets a workflow warning via `core.info` |
| treats empty string as default `'warn'` | Defensive default in case the workflow runner passes an empty string |
### `__tests__/restoreImpl.test.ts` — Path-validation wiring (9 new)
Verifies `restoreImpl` forwards the input to the toolkit and handles its
errors per the `fail-on-cache-invalid` input.
| Test | Asserts |
| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| defaults strict-paths to `'warn'` and forwards it to `restoreCache` | Default option object contains `pathValidation: 'warn'` |
| `test.each(['off', 'warn', 'error'])` forwards each value to `restoreCache` | All three valid values reach the toolkit unchanged |
| falls back to `'warn'` when strict-paths input is unrecognized | Unknown values are coerced to `'warn'` and a warning is logged |
| treats `CacheIntegrityError` as a cache miss by default | When the toolkit throws `CacheIntegrityError` and `fail-on-cache-invalid: false`, action logs the rejection and returns without setting the `cache-hit` output (intentionally unset to match regular cache-miss semantics — see issue #1466) |
| fails when `CacheIntegrityError` is raised and `fail-on-cache-invalid: true` | When `fail-on-cache-invalid: true`, `core.setFailed()` is called with a message containing `integrity validation` and the code |
| propagates non-integrity errors normally | Network/auth errors still surface via `core.setFailed()` rather than being mis-classified as integrity failures |
| `PARSE_ERROR` integrity failure also treated as miss by default | Validation handles both `PATH_VIOLATION` and `PARSE_ERROR` codes identically |
| tolerates `CacheIntegrityError` without explicit `.code` | If the toolkit ever omits a code, the action still degrades gracefully (logs `unknown`) |
| does not set `cache-hit` output when integrity error is rethrown | When `fail-on-cache-invalid: true`, no `cache-hit` output is set (preserves existing miss semantics for downstream `if:` checks) |
### Detection strategy for `CacheIntegrityError`
The action detects integrity errors by **name** (`err.name === 'CacheIntegrityError'`) rather than `instanceof`. This is intentional:
* The `@actions/cache` toolkit is shipped as ESM, while the action's runtime is
bundled by `ncc` into CJS. Cross-module-system `instanceof` checks are
fragile (different module realms, two copies of the class).
* The toolkit guarantees `name === 'CacheIntegrityError'` via the class
constructor.
* The toolkit also attaches a stable `code` property whose value is one of
`'PARSE_ERROR' | 'PATH_VIOLATION' | 'CHECKSUM_MISMATCH'`. The action surfaces
this code in the workflow log so users can diagnose rejections.
This approach is covered by the **tolerates `CacheIntegrityError` without
explicit `.code`** test — even if the toolkit changes the shape of its error,
the action continues to recognize and handle it.
## End-to-end workflow
[`.github/workflows/path-validation-e2e.yml`](../.github/workflows/path-validation-e2e.yml)
runs three jobs:
### 1. `good-cache` — legitimate cache restores correctly under all modes (18 runs)
Matrix: `[ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022]` × `[off, warn, error]`.
For each combination:
1. Generates a directory `path-validation-cache/` with 5 small files.
2. Saves it using `actions/cache@./` with a unique key.
3. Removes the local directory.
4. Restores using `actions/cache@./` with `strict-paths: ${{ matrix.strict-paths }}` and `fail-on-cache-invalid: true`.
5. Asserts `cache-hit == 'true'` and all 5 files are present.
This validates that **enabling path validation does not regress legitimate
caches** — a critical false-positive check on every supported platform and
both archive formats (gnu-tar on Linux/macOS, bsdtar on Windows).
### 2. `poisoned-cache` — poisoned cache is handled per mode (18 runs)
Same matrix. For each combination:
1. Generates a directory `path-validation-cache/` with one legitimate file.
2. Generates an `escape.txt` file **outside** the declared `path` (in the workspace root, one level up from `path-validation-cache/`).
3. Uses the toolkit's `saveCache()` directly (via `__tests__/e2e/save-poisoned-cache.mjs`) to upload a cache that includes BOTH paths. This produces an archive whose entries, when extracted relative to the declared `path` on restore, would write `escape.txt` outside the declared path.
4. Removes local files.
5. Restores via `actions/cache@./` declaring **only** the legitimate `path` (this is what an unsuspecting downstream consumer would do).
6. Asserts per-mode:
* **`off`**: `cache-hit == 'true'`, `escape.txt` IS present (validation disabled = legacy behavior).
* **`warn`**: `cache-hit == 'true'`, `escape.txt` IS present (validation does not block extraction in warn mode), and a workflow warning should be visible in the log.
* **`error`**: `cache-hit == 'false'`, NEITHER `escape.txt` NOR `path-validation-cache/legit.txt` exists (the archive was rejected before any extraction).
This validates the **false-negative** axis — the path-validation logic
correctly identifies a real cross-path entry on every supported OS.
### 3. `poisoned-cache-fail-on-invalid` — rejected cache fails the workflow when configured to (3 runs)
Matrix: `[ubuntu-latest, macos-latest, windows-latest]`.
Same setup as job 2, but with `fail-on-cache-invalid: true`. The restore step
itself is expected to **fail** (step `outcome == 'failure'`). The job uses
`continue-on-error: true` on the restore step so we can assert on its outcome
rather than the job failing.
This validates the workflow-fail path that strict security-conscious users
will enable in production pipelines.
## Manual tests
Before tagging a release, run the following manual sanity checks:
### Manual test 1: existing workflow regressions
In a workflow file that uses `actions/cache@<branch>` (no path-validation
inputs set), run a normal cache save + restore cycle. Verify:
* The action behaves identically to v5.0.x.
* No new warnings appear in the log for a clean cache.
* `cache-hit` output is set correctly.
### Manual test 2: warn-mode visibility
Configure a workflow that uses `actions/cache@<branch>` with
`strict-paths: warn` (the default in v5.1+). Confirm that:
* For a clean cache, no warning is logged.
* For a poisoned cache (use the E2E helper to create one), a single `::warning::`
annotation appears in the workflow summary, mentioning the path violation
and the offending entry.
### Manual test 3: error mode with fail-on-cache-invalid
Configure a workflow:
```yaml
- uses: actions/cache@<branch>
with:
key: ...
path: ./build
strict-paths: error
fail-on-cache-invalid: true
```
Trigger a restore of a poisoned cache. Confirm:
* The workflow fails at the cache restore step.
* The failure annotation includes the code (e.g., `PATH_VIOLATION`) and a
short message describing the violation.
### Manual test 4: cross-OS cache restore (`enableCrossOsArchive: true`)
Save a cache on Linux with one path, restore it on Windows with the same
path under each `strict-paths` value. Confirm validation behaves the same
way across the OS boundary (this exercises both `gnu-tar` archive
production and `bsdtar`-on-Windows extraction).
### Manual test 5: large real-world cache
Restore a realistic, multi-gigabyte cache (e.g., a `node_modules`
deep-dependency tree) with `strict-paths: warn`. Measure:
* No measurable regression in restore wall time.
* No false-positive warnings for paths containing `..` segments that resolve
to valid in-workspace locations (e.g., symlinks inside the cache).
## What's NOT tested here
* **The validation algorithm itself.** Comprehensive tests for path
resolution, glob expansion, env-var substitution, and parse-error
classification live in the toolkit repo
([`packages/cache/__tests__/pathValidation.test.ts`](../../actions-toolkit/packages/cache/__tests__/pathValidation.test.ts),
[`listAndValidate.test.ts`](../../actions-toolkit/packages/cache/__tests__/listAndValidate.test.ts), and
[`tarPathValidation.test.ts`](../../actions-toolkit/packages/cache/__tests__/tarPathValidation.test.ts)).
* **`saveCache` validation.** This change adds restore-side validation only.
The save path does not validate the entries it creates (and does not need
to — saves operate on paths the user explicitly declared in this action's
inputs).
* **Server-side scanning.** This is a client-side defence-in-depth control;
it does not replace server-side cache-poisoning mitigations.
## How to run
```bash
# unit tests (Jest + ts-jest)
npm test
# rebuild distribution bundles
npm run build
# full lint + format + tests + build
npm run format-check && npm run lint && npm test && npm run build
```
For the E2E workflow, push the changes to a branch and trigger the
`Path Validation E2E` workflow. All 39 matrix entries should pass; any
matrix-entry failure indicates the validation logic disagrees with this
plan on a specific OS/archive-format combination.
## Local development note
The `@actions/cache` toolkit v6.1.0 used by this action is currently
unpublished. For local development:
1. From the toolkit repo: `cd packages/cache && npm pack`
2. From this repo: `npm install /path/to/actions-toolkit/packages/cache/actions-cache-6.1.0.tgz`
3. After running `npm install`, restore `package.json`'s
`"@actions/cache": "^6.1.0"` specifier (npm rewrites it to a `file:` URL
when installing from a tarball).
Once the toolkit is published to npm, `npm install` will resolve `^6.1.0`
directly from the registry and step 1-3 are no longer needed.
Jest cannot load the ESM-only toolkit directly. The `jest.config.js` file
includes a `moduleNameMapper` that redirects `@actions/cache` imports during
tests to a CJS stub at
[`__tests__/__mocks__/actions-cache.ts`](../__tests__/__mocks__/actions-cache.ts).
The stub re-implements just the surface the action consumes (with the same
validation behavior for keys and paths) so tests can spy/mock on it. The
production bundle (built by `ncc`) uses the real ESM module — verified by
grepping for `pathValidation` and `CacheIntegrityError` symbols in the
bundled `dist/restore/index.js`.

View file

@ -9,6 +9,13 @@ module.exports = {
transform: {
"^.+\\.ts$": "ts-jest"
},
// The @actions/cache toolkit (v6+) is ESM-only and cannot be loaded by
// Jest's CommonJS resolver. For unit tests we redirect imports to a
// local CJS-compatible stub that exposes the same surface; production
// builds (tsc + ncc) use the real ESM package directly.
moduleNameMapper: {
"^@actions/cache$": "<rootDir>/__tests__/__mocks__/actions-cache.ts"
},
verbose: true
};

398
package-lock.json generated
View file

@ -1,15 +1,15 @@
{
"name": "cache",
"version": "5.0.4",
"version": "5.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cache",
"version": "5.0.4",
"version": "5.1.0",
"license": "MIT",
"dependencies": {
"@actions/cache": "^5.0.5",
"@actions/cache": "file:../actions-toolkit/packages/cache/actions-cache-6.1.0.tgz",
"@actions/core": "^2.0.3",
"@actions/exec": "^2.0.0",
"@actions/io": "^2.0.0"
@ -39,21 +39,68 @@
}
},
"node_modules/@actions/cache": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@actions/cache/-/cache-5.0.5.tgz",
"integrity": "sha512-jiQSg0gfd+C2KPgcmdCOq7dCuCIQQWQ4b1YfGIRaaA9w7PJbRwTOcCz4LiFEUnqZGf0ha/8OKL3BeNwetHzYsQ==",
"version": "6.1.0",
"resolved": "file:../actions-toolkit/packages/cache/actions-cache-6.1.0.tgz",
"integrity": "sha512-nty8KpfavtFXlCdR27I1kp3XNE1oKol1m74qEz6uBZDdPkU2MeG89bKnQEtCCrYdMkaa2e2iIobqYuCwpqFI/A==",
"license": "MIT",
"dependencies": {
"@actions/core": "^2.0.0",
"@actions/exec": "^2.0.0",
"@actions/glob": "^0.5.1",
"@actions/http-client": "^3.0.2",
"@actions/io": "^2.0.0",
"@azure/abort-controller": "^1.1.0",
"@azure/core-rest-pipeline": "^1.22.0",
"@azure/storage-blob": "^12.29.1",
"@actions/core": "^3.0.1",
"@actions/exec": "^3.0.0",
"@actions/glob": "^0.6.1",
"@actions/http-client": "^4.0.1",
"@actions/io": "^3.0.2",
"@azure/core-rest-pipeline": "^1.23.0",
"@azure/storage-blob": "^12.31.0",
"@protobuf-ts/runtime-rpc": "^2.11.1",
"semver": "^6.3.1"
"semver": "^7.7.4",
"tar": "^7.5.15"
}
},
"node_modules/@actions/cache/node_modules/@actions/core": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz",
"integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==",
"license": "MIT",
"dependencies": {
"@actions/exec": "^3.0.0",
"@actions/http-client": "^4.0.0"
}
},
"node_modules/@actions/cache/node_modules/@actions/exec": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"license": "MIT",
"dependencies": {
"@actions/io": "^3.0.2"
}
},
"node_modules/@actions/cache/node_modules/@actions/http-client": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz",
"integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==",
"license": "MIT",
"dependencies": {
"tunnel": "^0.0.6",
"undici": "^6.23.0"
}
},
"node_modules/@actions/cache/node_modules/@actions/io": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
"license": "MIT"
},
"node_modules/@actions/cache/node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@actions/core": {
@ -76,15 +123,50 @@
}
},
"node_modules/@actions/glob": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.5.1.tgz",
"integrity": "sha512-+dv/t2aKQdKp9WWSp+1yIXVJzH5Q38M0Mta26pzIbeec14EcIleMB7UU6N7sNgbEuYfyuVGpE5pOKjl6j1WXkA==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.6.1.tgz",
"integrity": "sha512-K4+2Ac5ILcf2ySdJCha+Pop9NcKjxqCL4xL4zI50dgB2PbXgC0+AcP011xfH4Of6b4QEJJg8dyZYv7zl4byTsw==",
"license": "MIT",
"dependencies": {
"@actions/core": "^2.0.3",
"@actions/core": "^3.0.0",
"minimatch": "^3.0.4"
}
},
"node_modules/@actions/glob/node_modules/@actions/core": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz",
"integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==",
"license": "MIT",
"dependencies": {
"@actions/exec": "^3.0.0",
"@actions/http-client": "^4.0.0"
}
},
"node_modules/@actions/glob/node_modules/@actions/exec": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"license": "MIT",
"dependencies": {
"@actions/io": "^3.0.2"
}
},
"node_modules/@actions/glob/node_modules/@actions/http-client": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz",
"integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==",
"license": "MIT",
"dependencies": {
"tunnel": "^0.0.6",
"undici": "^6.23.0"
}
},
"node_modules/@actions/glob/node_modules/@actions/io": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
"license": "MIT"
},
"node_modules/@actions/http-client": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz",
@ -102,15 +184,15 @@
"license": "MIT"
},
"node_modules/@azure/abort-controller": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz",
"integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.2.0"
"tslib": "^2.6.2"
},
"engines": {
"node": ">=12.0.0"
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-auth": {
@ -127,18 +209,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-auth/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-client": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
@ -157,42 +227,20 @@
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-client/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-http-compat": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz",
"integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz",
"integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-client": "^1.10.0",
"@azure/core-rest-pipeline": "^1.22.0"
"@azure/abort-controller": "^2.1.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-http-compat/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
"peerDependencies": {
"@azure/core-client": "^1.10.0",
"@azure/core-rest-pipeline": "^1.22.0"
}
},
"node_modules/@azure/core-lro": {
@ -210,18 +258,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-lro/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-paging": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz",
@ -235,9 +271,9 @@
}
},
"node_modules/@azure/core-rest-pipeline": {
"version": "1.22.2",
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz",
"integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
@ -245,25 +281,13 @@
"@azure/core-tracing": "^1.3.0",
"@azure/core-util": "^1.13.0",
"@azure/logger": "^1.3.0",
"@typespec/ts-http-runtime": "^0.3.0",
"@typespec/ts-http-runtime": "^0.3.4",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-rest-pipeline/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-tracing": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
@ -290,25 +314,13 @@
"node": ">=20.0.0"
}
},
"node_modules/@azure/core-util/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/core-xml": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.0.tgz",
"integrity": "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.1.tgz",
"integrity": "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.0.7",
"fast-xml-parser": "^5.5.9",
"tslib": "^2.8.1"
},
"engines": {
@ -329,9 +341,9 @@
}
},
"node_modules/@azure/storage-blob": {
"version": "12.30.0",
"resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.30.0.tgz",
"integrity": "sha512-peDCR8blSqhsAKDbpSP/o55S4sheNwSrblvCaHUZ5xUI73XA7ieUGGwrONgD/Fng0EoDe1VOa3fAQ7+WGB3Ocg==",
"version": "12.31.0",
"resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.31.0.tgz",
"integrity": "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
@ -345,7 +357,7 @@
"@azure/core-util": "^1.11.0",
"@azure/core-xml": "^1.4.5",
"@azure/logger": "^1.1.4",
"@azure/storage-common": "^12.2.0",
"@azure/storage-common": "^12.3.0",
"events": "^3.0.0",
"tslib": "^2.8.1"
},
@ -353,22 +365,10 @@
"node": ">=20.0.0"
}
},
"node_modules/@azure/storage-blob/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@azure/storage-common": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.2.0.tgz",
"integrity": "sha512-YZLxiJ3vBAAnFbG3TFuAMUlxZRexjQX5JDQxOkFGb6e2TpoxH3xyHI6idsMe/QrWtj41U/KoqBxlayzhS+LlwA==",
"version": "12.3.0",
"resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.3.0.tgz",
"integrity": "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
@ -385,18 +385,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@azure/storage-common/node_modules/@azure/abort-controller": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@ -994,6 +982,18 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@ -1453,6 +1453,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -2595,6 +2607,15 @@
"node": ">=10"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
@ -3742,9 +3763,9 @@
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
@ -3753,13 +3774,14 @@
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz",
"integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==",
"funding": [
{
"type": "github",
@ -3768,9 +3790,11 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2"
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.2.0",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.3.0",
"xml-naming": "^0.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@ -5843,6 +5867,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -6159,9 +6204,9 @@
}
},
"node_modules/path-expression-matcher": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
@ -6694,6 +6739,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -7071,9 +7117,9 @@
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
"funding": [
{
"type": "github",
@ -7124,6 +7170,31 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tar": {
"version": "7.5.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tar/node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@ -7736,6 +7807,21 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "cache",
"version": "5.0.4",
"version": "5.1.0",
"private": true,
"description": "Cache dependencies and build outputs",
"main": "dist/restore/index.js",
@ -23,7 +23,7 @@
"author": "GitHub",
"license": "MIT",
"dependencies": {
"@actions/cache": "^5.0.5",
"@actions/cache": "^6.1.0",
"@actions/core": "^2.0.3",
"@actions/exec": "^2.0.0",
"@actions/io": "^2.0.0"
@ -51,4 +51,4 @@
"engines": {
"node": ">=24"
}
}
}

View file

@ -11,6 +11,11 @@ The restore action restores a cache. It works similarly to the `cache` action ex
* `restore-keys` - An ordered list of prefix-matched keys to use for restoring stale cache if no cache hit occurred for key.
* `fail-on-cache-miss` - Fail the workflow if cache entry is not found. Default: `false`
* `lookup-only` - If true, only checks if cache entry exists and skips download. Default: `false`
* `strict-paths` - Client-side path-validation strictness applied when extracting a restored cache. Helps protect against some forms of cache poisoning attacks. Valid values:
* `off` - Disable path validation entirely (legacy behavior). Skipping validation may slightly improve performance for very large cache archives, but is not recommended for best security.
* `warn` *(current default)* - Pre-scan the archive and emit a workflow warning if any entry would resolve outside the declared `path` inputs. The cache is still extracted.
* `error` *(future default)* - Pre-scan the archive and reject it (without extracting) if any entry would resolve outside the declared `path` inputs.
* `fail-on-cache-invalid` - Fail the workflow when a restored cache is rejected by client-side validation (entries that escape the declared paths, or an archive that cannot be parsed). Only applies when `strict-paths: error` is set; the `off` and `warn` modes never reject a cache. When `false` (default) the rejected cache is treated as a cache miss.
### Outputs

View file

@ -23,6 +23,23 @@ inputs:
description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache'
default: 'false'
required: false
strict-paths:
description: |
Controls client-side validation of cache archive entry paths before extraction.
'off' disables validation (legacy behavior). 'warn' logs a single warning when any
entry would resolve outside the declared `path` inputs and still extracts the cache.
'error' rejects the cache with a CacheIntegrityError and skips extraction entirely.
Default is 'warn'.
default: 'warn'
required: false
fail-on-cache-invalid:
description: |
Fail the workflow if the restored cache is rejected by client-side path validation
(entries that escape the declared paths, or an archive that cannot be parsed).
Only applies when `strict-paths` is 'error'; the 'off' and 'warn' modes never
reject a cache. When 'false' (default), a rejected cache is treated as a cache miss.
default: 'false'
required: false
outputs:
cache-hit:
description: 'A boolean value to indicate an exact match was found for the primary key'

View file

@ -5,7 +5,9 @@ export enum Inputs {
UploadChunkSize = "upload-chunk-size", // Input for cache, save action
EnableCrossOsArchive = "enableCrossOsArchive", // Input for cache, restore, save action
FailOnCacheMiss = "fail-on-cache-miss", // Input for cache, restore action
LookupOnly = "lookup-only" // Input for cache, restore action
LookupOnly = "lookup-only", // Input for cache, restore action
StrictPaths = "strict-paths", // Input for cache, restore action
FailOnCacheInvalid = "fail-on-cache-invalid" // Input for cache, restore action
}
export enum Outputs {

View file

@ -41,15 +41,53 @@ export async function restoreImpl(
);
const failOnCacheMiss = utils.getInputAsBool(Inputs.FailOnCacheMiss);
const lookupOnly = utils.getInputAsBool(Inputs.LookupOnly);
const cacheKey = await cache.restoreCache(
cachePaths,
primaryKey,
restoreKeys,
{ lookupOnly: lookupOnly },
enableCrossOsArchive
const pathValidation = utils.getPathValidationInput();
const failOnCacheInvalid = utils.getInputAsBool(
Inputs.FailOnCacheInvalid
);
let cacheKey: string | undefined;
try {
cacheKey = await cache.restoreCache(
cachePaths,
primaryKey,
restoreKeys,
{ lookupOnly: lookupOnly, pathValidation: pathValidation },
enableCrossOsArchive
);
} catch (err: unknown) {
// The toolkit throws CacheIntegrityError when client-side path
// validation rejects the archive (in 'error' mode) or when the
// archive cannot be parsed. Detect by name/code so we don't have
// to take a hard dependency on the class identity (which may not
// round-trip across module boundaries in all bundlers).
if (err instanceof Error && err.name === "CacheIntegrityError") {
const code = (err as Error & { code?: string }).code;
if (failOnCacheInvalid) {
// Preserve the toolkit's original error via `Error.cause`.
// (Assigned after construction because this project's
// tsconfig targets ES6.)
const failure = new Error(
`Restored cache failed integrity validation (${
code ?? "unknown"
}): ${err.message}`
);
(failure as Error & { cause?: unknown }).cause = err;
throw failure;
}
// Treat as a cache miss. Intentionally do NOT set the
// `cache-hit` output here, to preserve the same downstream
// semantics as a regular miss (see issue #1466).
utils.logWarning(
`Restored cache failed integrity validation (${
code ?? "unknown"
}) and was discarded: ${err.message}`
);
return;
}
throw err;
}
if (!cacheKey) {
// `cache-hit` is intentionally not set to `false` here to preserve existing behavior
// See https://github.com/actions/cache/issues/1466

View file

@ -1,7 +1,7 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import { RefKey } from "../constants";
import { Inputs, RefKey } from "../constants";
export function isGhes(): boolean {
const ghUrl = new URL(
@ -66,6 +66,28 @@ export function getInputAsBool(
return result.toLowerCase() === "true";
}
/**
* Read the `strict-paths` input and coerce it to a value the `@actions/cache`
* toolkit understands. Unknown values default to `'warn'` (a best-effort
* recovery we don't want a typo to silently disable client-side validation)
* and a workflow warning annotation is emitted so the user notices.
*
* Uses `core.warning()` directly (rather than this module's `logWarning()`
* helper, which routes through `core.info()`) so an input misconfiguration
* surfaces as a real `::warning::` annotation in the run summary.
*/
export function getPathValidationInput(): "off" | "warn" | "error" {
const raw = core.getInput(Inputs.StrictPaths) || "warn";
const normalized = raw.toLowerCase();
if (normalized === "off" || normalized === "warn" || normalized === "error") {
return normalized;
}
core.warning(
`Unrecognized value for strict-paths: "${raw}". Falling back to "warn". Valid values are: off, warn, error.`
);
return "warn";
}
export function isCacheFeatureAvailable(): boolean {
if (cache.isFeatureAvailable()) {
return true;

View file

@ -16,6 +16,8 @@ interface CacheInput {
enableCrossOsArchive?: boolean;
failOnCacheMiss?: boolean;
lookupOnly?: boolean;
strictPaths?: string;
failOnCacheInvalid?: boolean;
}
export function setInputs(input: CacheInput): void {
@ -32,6 +34,13 @@ export function setInputs(input: CacheInput): void {
setInput(Inputs.FailOnCacheMiss, input.failOnCacheMiss.toString());
input.lookupOnly !== undefined &&
setInput(Inputs.LookupOnly, input.lookupOnly.toString());
input.strictPaths !== undefined &&
setInput(Inputs.StrictPaths, input.strictPaths);
input.failOnCacheInvalid !== undefined &&
setInput(
Inputs.FailOnCacheInvalid,
input.failOnCacheInvalid.toString()
);
}
export function clearInputs(): void {
@ -42,4 +51,6 @@ export function clearInputs(): void {
delete process.env[getInputName(Inputs.EnableCrossOsArchive)];
delete process.env[getInputName(Inputs.FailOnCacheMiss)];
delete process.env[getInputName(Inputs.LookupOnly)];
delete process.env[getInputName(Inputs.StrictPaths)];
delete process.env[getInputName(Inputs.FailOnCacheInvalid)];
}

View file

@ -59,5 +59,5 @@
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
},
"exclude": ["node_modules", "**/*.test.ts"]
"exclude": ["node_modules", "**/*.test.ts", "__tests__"]
}