2
mirror of https://github.com/pnpm/action-setup.git synced 2026-08-14 00:11:30 +00:00

fix: upload the verification log right after the install writes it

Saving in the post step left the whole job between the install and the upload.
Anything running in that window — the job's tests, its build, a dependency's
own install scripts — can rewrite the log on disk, and the job's own cache
write would then publish a record claiming some other lockfile passed
verification, for every later job to restore and trust. No cache credentials
needed: the attacker rides the write the job performs anyway.

The log is complete the moment the install finishes, so it is uploaded there.
The post step still covers a job that installs in a step of its own, where
that is the first point the log is known to be final; the save is idempotent
across the two, and the process-local flags exist because main and post do not
share state within a run.
This commit is contained in:
Zoltan Kochan
2026-08-13 17:03:37 +02:00
parent b543421fa5
commit e6cb65ab2f
4 changed files with 156 additions and 128 deletions
+28 -5
View File
@@ -15,7 +15,18 @@ const VERIFICATION_CACHE_FILE = 'lockfile-verified.jsonl'
const PATH_STATE = 'lockfile_verification_cache_path'
const KEY_STATE = 'lockfile_verification_cache_key'
const RESTORED_STATE = 'lockfile_verification_cache_restored'
const STORED_STATE = 'lockfile_verification_cache_stored'
/**
* Where the log lives and under which key it belongs in the cache. Held in
* memory as well as in the action's state because the main and post steps run
* as separate processes, and state written by one is only readable by the
* other.
*/
let target: { cacheFilePath: string, key: string } | undefined
/** Whether this process already restored or saved the log. */
let stored = false
/**
* The verdict is only valid for the exact lockfile content it was recorded
@@ -26,6 +37,7 @@ export async function restoreVerificationCache(lockfileHash: string): Promise<vo
try {
const cacheFilePath = path.join(await getPnpmCacheDirectory(), VERIFICATION_CACHE_FILE)
const key = `pnpm-lockfile-verified-${process.env.RUNNER_OS}-${os.arch()}-${lockfileHash}`
target = { cacheFilePath, key }
saveState(PATH_STATE, cacheFilePath)
saveState(KEY_STATE, key)
debug(`Lockfile verification cache path is ${cacheFilePath}, key is ${key}`)
@@ -36,7 +48,8 @@ export async function restoreVerificationCache(lockfileHash: string): Promise<vo
return
}
saveState(RESTORED_STATE, 'true')
stored = true
saveState(STORED_STATE, 'true')
info(`Lockfile verification cache restored from key: ${restoredKey}`)
} catch (error) {
// The gate only costs time, never correctness — a job that cannot reuse
@@ -45,16 +58,26 @@ export async function restoreVerificationCache(lockfileHash: string): Promise<vo
}
}
/**
* Uploaded as soon as the install that produced the log finishes, rather than
* at the end of the job: whatever a job runs after installing — its tests, its
* build, a dependency's own scripts — can rewrite the log on disk, and the
* job's own cache write would then publish that for later jobs to trust.
*
* Safe to call more than once; the second call is a no-op.
*/
export async function saveVerificationCache(): Promise<void> {
if (getState(RESTORED_STATE) === 'true') return
if (stored || getState(STORED_STATE) === 'true') return
const cacheFilePath = getState(PATH_STATE)
const key = getState(KEY_STATE)
const cacheFilePath = target?.cacheFilePath ?? getState(PATH_STATE)
const key = target?.key ?? getState(KEY_STATE)
if (!cacheFilePath || !key || !existsSync(cacheFilePath)) return
try {
const cacheId = await saveCache([cacheFilePath], key)
if (cacheId === -1) return
stored = true
saveState(STORED_STATE, 'true')
info(`Lockfile verification cache saved with the key: ${key}`)
} catch (error) {
warning(`Failed to save the lockfile verification cache: ${(error as Error).message}`)