Files
Joey Perrott 3093edcad0 fix(docs-infra): secure update-assets script against RCE and SSRF
- Validate storedSha and storedBranch from _build-info.json.
- Validate latestSha returned from GitHub API.
- Validate branch in GithubClient.getShaForBranch and baseSha/headSha in GithubClient.getAffectedFiles.
- Use execFileSync instead of execSync to avoid shell execution.

TAG=agy
CONV=4e3e69ba-3f3d-416b-9ce4-9ef75486d2f3
2026-06-02 11:21:56 +02:00

151 lines
4.7 KiB
JavaScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
//tslint:disable:no-console
import assert from 'node:assert';
import {execFileSync} from 'node:child_process';
import {existsSync, constants as fsConstants} from 'node:fs';
import {
copyFile,
mkdtemp,
readdir,
readFile,
realpath,
rm,
unlink,
writeFile,
} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {GithubClient} from './github-client.mjs';
export async function updateAssets({repo, assetsPath, destPath}) {
console.log('\n-----------------------------------------------');
console.log(`Processing: ${repo}`);
console.log('-----------------------------------------------\n');
const buildInfoPath = join(destPath, '_build-info.json');
if (!existsSync(buildInfoPath)) {
throw new Error(`${buildInfoPath} does not exist.`);
}
assert(process.env.GITHUB_REF);
const currentBranch = process.env.GITHUB_REF;
const {sha: storedSha, branchName: storedBranch} = JSON.parse(
await readFile(buildInfoPath, 'utf-8'),
);
const shaRegex = /^[0-9a-f]{40}$/i;
const branchRegex = /^(?!.*\.\.)[a-zA-Z0-9/_.-]+$/;
if (!shaRegex.test(storedSha)) {
throw new Error(`Invalid SHA in build info: ${storedSha}`);
}
if (!branchRegex.test(storedBranch)) {
throw new Error(`Invalid branch name in build info: ${storedBranch}`);
}
assert(process.env.ANGULAR_READONLY_GITHUB_TOKEN);
const githubApi = new GithubClient(
repo,
process.env.ANGULAR_READONLY_GITHUB_TOKEN,
'ADEV_Cross_Repo_Docs_Update',
);
let downstreamBranch = currentBranch;
let latestSha = await githubApi.getShaForBranch(currentBranch);
if (
latestSha.includes('No commit') &&
currentBranch !== 'refs/heads/main' &&
currentBranch !== storedBranch
) {
// In some cases, such as when a new branch is created for a feature,
// the branch may not exist in the downstream repo. For example, during an
// exceptional minor release (e.g. FW 20.3.x and Components: 20.2.x).
// In such scenarios, we fallback to the last known branch.
latestSha = await githubApi.getShaForBranch(storedBranch);
downstreamBranch = storedBranch;
}
if (!shaRegex.test(latestSha)) {
throw new Error(`Invalid SHA resolved: ${latestSha}`);
}
console.log(`Comparing ${storedSha}...${latestSha}.`);
const affectedFiles = await githubApi.getAffectedFiles(storedSha, latestSha);
const changedFiles = affectedFiles.filter((file) => file.startsWith(`${assetsPath}/`));
let shaWhenFilesChanged;
if (changedFiles.length > 0) {
console.log(
`The below files changed between ${storedSha} and ${latestSha}:\n` +
changedFiles.map((f) => '* ' + f).join('\n'),
);
const temporaryDir = await realpath(await mkdtemp(join(tmpdir(), 'update-assets-')));
try {
const execOptions = {cwd: temporaryDir, stdio: 'inherit'};
execFileSync('git', ['init'], execOptions);
execFileSync(
'git',
['remote', 'add', 'origin', `https://github.com/${repo}.git`],
execOptions,
);
// fetch a commit
execFileSync('git', ['fetch', 'origin', latestSha], execOptions);
// reset this repository's main branch to the commit of interest
execFileSync('git', ['reset', '--hard', 'FETCH_HEAD'], execOptions);
// get sha when files where changed
shaWhenFilesChanged = execFileSync('git', ['rev-list', '-1', latestSha, `${assetsPath}/`], {
encoding: 'utf8',
cwd: temporaryDir,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
// Delete existing asset files.
const apiFilesUnlink = (await readdir(destPath))
.filter((f) => f.endsWith('.json'))
.map((f) => unlink(join(destPath, f)));
await Promise.allSettled(apiFilesUnlink);
// Copy new asset files
const tempAssetsDir = join(temporaryDir, assetsPath);
const assetFilesCopy = (await readdir(tempAssetsDir)).map((f) => {
const src = join(tempAssetsDir, f);
const dest = join(destPath, f);
return copyFile(src, dest, fsConstants.COPYFILE_FICLONE);
});
await Promise.allSettled(assetFilesCopy);
} finally {
await rm(temporaryDir, {force: true, recursive: true});
}
console.log(`Successfully updated asset files in '${destPath}'.\n`);
} else {
console.log(`No '${assetsPath}/**' files changed between ${storedSha} and ${latestSha}.`);
}
// Write SHA to file.
await writeFile(
buildInfoPath,
JSON.stringify(
{
branchName: downstreamBranch,
sha: shaWhenFilesChanged ?? storedSha,
},
undefined,
2,
),
);
}