import * as path from "path"; import * as core from "@actions/core"; import * as toolcache from "@actions/tool-cache"; import { getApiClient, getApiDetails, getAuthorizationHeaderFor, getGitHubVersion, } from "./api-client"; import * as artifactScanner from "./artifact-scanner"; import { Config } from "./config-utils"; import * as defaults from "./defaults.json"; import { CodeQLDefaultVersionInfo, Feature, FeatureEnablement, } from "./feature-flags"; import * as json from "./json"; import * as knownLanguageAliases from "./known-language-aliases.json"; import { KnownLanguage } from "./languages"; import { Logger } from "./logging"; import { Address, Registry, Credential, AuthConfig, isToken, isAzureConfig, Token, UsernamePassword, AzureConfig, isAWSConfig, AWSConfig, isJFrogConfig, JFrogConfig, isUsernamePassword, hasUsername, RawCredential, } from "./start-proxy/types"; import { ActionName, createStatusReportBase, getActionsStatus, sendStatusReport, StatusReportBase, } from "./status-report"; import * as util from "./util"; import { ConfigurationError, getErrorMessage, isDefined } from "./util"; export * from "./start-proxy/types"; /** * Enumerates specific error types for which we have corresponding error messages that * are safe to include in status reports. */ export enum StartProxyErrorType { DownloadFailed, ExtractionFailed, CacheFailed, } /** * @returns The error message corresponding to the error type. */ export function getStartProxyErrorMessage( errorType: StartProxyErrorType, ): string { switch (errorType) { case StartProxyErrorType.DownloadFailed: return "Failed to download proxy archive."; case StartProxyErrorType.ExtractionFailed: return "Failed to extract proxy archive."; case StartProxyErrorType.CacheFailed: return "Failed to add proxy to toolcache"; } } /** * We want to avoid accidentally leaking secrets that may be contained in exception * messages in the `start-proxy` action. Consequently, we don't report the messages * of arbitrary exceptions. This type of error ensures that the message is one from * `StartProxyErrorType` and therefore safe to include in a status report. */ export class StartProxyError extends Error { public readonly errorType: StartProxyErrorType; constructor(errorType: StartProxyErrorType) { super(); this.errorType = errorType; } } interface StartProxyStatus extends StatusReportBase { // A comma-separated list of registry types which are configured for CodeQL. // This only includes registry types we support, not all that are configured. registry_types: string; } /** * Sends a status report for the `start-proxy` action indicating a successful outcome. * * @param startedAt When the action was started. * @param config The configuration used. * @param registry_types The types of registries that are configured. * @param logger The logger to use. */ export async function sendSuccessStatusReport( startedAt: Date, config: Partial, registry_types: string[], logger: Logger, ) { const statusReportBase = await createStatusReportBase( ActionName.StartProxy, "success", startedAt, config, await util.checkDiskUsage(logger), logger, ); if (statusReportBase !== undefined) { const statusReport: StartProxyStatus = { ...statusReportBase, registry_types: registry_types.join(","), }; await sendStatusReport(statusReport); } } /** * Returns an error message for `error` that can safely be reported in a status report, * i.e. that does not contain sensitive information. * * @param error The error for which to get an error message. */ export function getSafeErrorMessage(error: Error): string { // If the error is a `StartProxyError`, resolve the error type to the corresponding // error message. if (error instanceof StartProxyError) { return getStartProxyErrorMessage(error.errorType); } // Otherwise, omit the actual error message. return `Error from start-proxy Action omitted (${error.constructor.name}).`; } /** * Sends a status report for the `start-proxy` action indicating a failure. * * @param logger The logger to use. * @param startedAt When the action was started. * @param language The language provided as input, if any. * @param unwrappedError The exception that was thrown. */ export async function sendFailedStatusReport( logger: Logger, startedAt: Date, language: KnownLanguage | undefined, unwrappedError: unknown, ) { const error = util.wrapError(unwrappedError); core.setFailed(`start-proxy action failed: ${error.message}`); // To avoid the possibility of leaking sensitive information into the telemetry, // we don't include arbitrary error messages. Instead, `getSafeErrorMessage` will // return a generic message that includes the type of the error, unless it can decide // that the message is safe to include. const statusReportMessage = getSafeErrorMessage(error); const errorStatusReportBase = await createStatusReportBase( ActionName.StartProxy, getActionsStatus(error), startedAt, { languages: language && [language], }, await util.checkDiskUsage(logger), logger, statusReportMessage, ); if (errorStatusReportBase !== undefined) { await sendStatusReport(errorStatusReportBase); } } export const UPDATEJOB_PROXY = "update-job-proxy"; export const UPDATEJOB_PROXY_VERSION = "v2.0.20250624110901"; const UPDATEJOB_PROXY_URL_PREFIX = "https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.22.0/"; /** * Parse the start-proxy language input into its canonical CodeQL language name. * * This uses the language aliases shipped with the Action and will not be able to resolve aliases * added by versions of the CodeQL CLI newer than the one mentioned in `defaults.json`. However, * this is sufficient for the start-proxy Action since we are already specifying proxy * configurations on a per-language basis. */ export function parseLanguage(language: string): KnownLanguage | undefined { // Normalize to lower case language = language.trim().toLowerCase(); // See if it's an exact match if (Object.hasOwn(KnownLanguage, language)) { return language as KnownLanguage; } // Check language aliases if (Object.hasOwn(knownLanguageAliases, language)) { language = knownLanguageAliases[language as keyof typeof knownLanguageAliases]; if (Object.hasOwn(KnownLanguage, language)) { return language as KnownLanguage; } } return undefined; } function isPAT(value: string) { return artifactScanner.isAuthToken(value, [ artifactScanner.GITHUB_PAT_CLASSIC_PATTERN, artifactScanner.GITHUB_PAT_FINE_GRAINED_PATTERN, ]); } type RegistryMapping = Partial>; const LANGUAGE_TO_REGISTRY_TYPE: RegistryMapping = { java: ["maven_repository"], csharp: ["nuget_feed"], javascript: ["npm_registry"], python: ["python_index"], ruby: ["rubygems_server"], rust: ["cargo_registry"], go: ["goproxy_server", "git_source"], } as const; const NEW_LANGUAGE_TO_REGISTRY_TYPE: Required = { actions: [], cpp: [], java: ["maven_repository"], csharp: ["nuget_feed"], javascript: [], python: [], ruby: [], rust: [], swift: [], go: ["goproxy_server", "git_source"], } as const; /** * Extracts an `Address` value from the given `Registry` value by determining whether it has * a `url` value, or no `url` value but a `host` value. * * @throws A `ConfigurationError` if the `Registry` value contains neither a `url` or `host` field. */ function getRegistryAddress( registry: json.UnvalidatedObject, ): Address { if ( isDefined(registry.url) && json.isString(registry.url) && json.isStringOrUndefined(registry.host) ) { return { url: registry.url, host: registry.host, }; } else if (isDefined(registry.host) && json.isString(registry.host)) { return { url: undefined, host: registry.host, }; } else { // The proxy needs one of these to work. If both are defined, the url has the precedence. throw new ConfigurationError( "Invalid credentials - must specify host or url", ); } } /** Extracts an `AuthConfig` value from `config`. */ export function getAuthConfig( config: json.UnvalidatedObject, ): AuthConfig { // Start by checking for the OIDC configurations, since they have required properties // which we can use to identify them. if (isAzureConfig(config)) { return { tenant_id: config.tenant_id, client_id: config.client_id, } satisfies AzureConfig; } else if (isAWSConfig(config)) { return { aws_region: config.aws_region, account_id: config.account_id, role_name: config.role_name, domain: config.domain, domain_owner: config.domain_owner, audience: config.audience, } satisfies AWSConfig; } else if (isJFrogConfig(config)) { return { jfrog_oidc_provider_name: config.jfrog_oidc_provider_name, identity_mapping_name: config.identity_mapping_name, audience: config.audience, } satisfies JFrogConfig; } else if (isToken(config)) { // There are three scenarios for non-OIDC authentication based on the registry type: // // 1. `username`+`token` // 2. A `token` that combines the username and actual token, separated by ':'. // 3. `username`+`password` // // In all three cases, all fields are optional. If the `token` field is present, // we accept the configuration as a `Token` typed configuration, with the `token` // value and an optional `username`. Otherwise, we accept the configuration // typed as `UsernamePassword` (in the `else` clause below) with optional // username and password. I.e. a private registry type that uses 1. or 2., // but has no `token` configured, will get accepted as `UsernamePassword` here. if (isDefined(config.token)) { // Mask token to reduce chance of accidental leakage in logs, if we have one. core.setSecret(config.token); } return { username: config.username, token: config.token } satisfies Token; } else { let username: string | undefined = undefined; let password: string | undefined = undefined; // Both "username" and "password" are optional. If we have reached this point, we need // to validate which of them are present and that they have the correct type if so. if ("password" in config && json.isString(config.password)) { // Mask password to reduce chance of accidental leakage in logs, if we have one. core.setSecret(config.password); password = config.password; } if ("username" in config && json.isString(config.username)) { username = config.username; } // Return the `UsernamePassword` object. Both username and password may be undefined. return { username, password, } satisfies UsernamePassword; } } // getCredentials returns registry credentials from action inputs. // It prefers `registries_credentials` over `registry_secrets`. // If neither is set, it returns an empty array. export function getCredentials( logger: Logger, registrySecrets: string | undefined, registriesCredentials: string | undefined, language: KnownLanguage | undefined, skipUnusedRegistries: boolean = false, ): Credential[] { const registryMapping = skipUnusedRegistries ? NEW_LANGUAGE_TO_REGISTRY_TYPE : LANGUAGE_TO_REGISTRY_TYPE; const registryTypeForLanguage = language ? registryMapping[language] : undefined; let credentialsStr: string; if (registriesCredentials !== undefined) { logger.info(`Using registries_credentials input.`); credentialsStr = Buffer.from(registriesCredentials, "base64").toString(); } else if (registrySecrets !== undefined) { logger.info(`Using registry_secrets input.`); credentialsStr = registrySecrets; } else { logger.info(`No credentials defined.`); return []; } // Parse and validate the credentials let parsed: unknown; try { parsed = json.parseString(credentialsStr); } catch { // Don't log the error since it might contain sensitive information. logger.error("Failed to parse the credentials data."); throw new ConfigurationError("Invalid credentials format."); } // Check that the parsed data is indeed an array. if (!json.isArray(parsed)) { throw new ConfigurationError( "Expected credentials data to be an array of configurations, but it is not.", ); } const out: Credential[] = []; for (const e of parsed) { if (e === null || !json.isObject(e)) { throw new ConfigurationError("Invalid credentials - must be an object"); } // The configuration must have a type. if (!isDefined(e.type) || !json.isString(e.type)) { throw new ConfigurationError("Invalid credentials - must have a type"); } // Mask credentials to reduce chance of accidental leakage in logs. const authConfig = getAuthConfig(e); const address = getRegistryAddress(e); // Filter credentials based on language if specified. `type` is the registry type. // E.g., "maven_feed" for Java/Kotlin, "nuget_repository" for C#. if ( registryTypeForLanguage && !registryTypeForLanguage.some((t) => t === e.type) ) { continue; } const isPrintable = (str: string | undefined): boolean => { return str ? /^[\x20-\x7E]*$/.test(str) : true; }; // Ensure that all string fields only contain printable characters. for (const key of Object.keys(e)) { const val = e[key]; if (typeof val === "string" && !isPrintable(val)) { throw new ConfigurationError( "Invalid credentials - fields must contain only printable characters", ); } } // If the password or token looks like a GitHub PAT, warn if no username is configured. const noUsername = !hasUsername(authConfig) || !isDefined(authConfig.username); const passwordIsPAT = isUsernamePassword(authConfig) && isDefined(authConfig.password) && isPAT(authConfig.password); const tokenIsPAT = isToken(authConfig) && isDefined(authConfig.token) && isPAT(authConfig.token); if (noUsername && (passwordIsPAT || tokenIsPAT)) { logger.warning( `A ${e.type} private registry is configured for ${e.host || e.url} using a GitHub Personal Access Token (PAT), but no username was provided. ` + `This may not work correctly. When configuring a private registry using a PAT, select "Username and password" and enter the username of the user ` + `who generated the PAT.`, ); } out.push({ type: e.type, ...authConfig, ...address, }); } return out; } /** * Gets the name of the proxy release asset for the current platform. */ export function getProxyPackage(): string { const platform = process.platform === "win32" ? "win64" : process.platform === "darwin" ? "osx64" : "linux64"; return `${UPDATEJOB_PROXY}-${platform}.tar.gz`; } /** * Gets the fallback URL for downloading the proxy release asset. * * @param proxyPackage The asset name. * @returns The full URL to download the specified asset from the fallback release. */ export function getFallbackUrl(proxyPackage: string): string { return `${UPDATEJOB_PROXY_URL_PREFIX}${proxyPackage}`; } /** * Uses the GitHub API to obtain information about the CodeQL CLI bundle release * that is tagged by `version`. * * @returns The response from the GitHub API. */ async function getReleaseByVersion(version: string) { return getApiClient().rest.repos.getReleaseByTag({ owner: "github", repo: "codeql-action", tag: version, }); } /** Uses `features` to determine the default CLI version. */ async function getCliVersionFromFeatures( features: FeatureEnablement, ): Promise { const gitHubVersion = await getGitHubVersion(); return await features.getDefaultCliVersion(gitHubVersion.type); } /** * Determines the URL of the proxy release asset that we should download if its not * already in the toolcache, and its version. * * @param logger The logger to use. * @param features Information about enabled features. * @returns Returns the download URL and version of the proxy package we plan to use. */ export async function getDownloadUrl( logger: Logger, features: FeatureEnablement, ): Promise<{ url: string; version: string }> { const proxyPackage = getProxyPackage(); try { const useFeaturesToDetermineCLI = await features.getValue( Feature.StartProxyUseFeaturesRelease, ); // Retrieve information about the CLI version we should use. This will be either the linked // version, or the one enabled by FFs. const versionInfo = useFeaturesToDetermineCLI ? await getCliVersionFromFeatures(features) : { cliVersion: defaults.cliVersion, tagName: defaults.bundleVersion, }; // Try to retrieve information about the CLI bundle release identified by `versionInfo`. const cliRelease = await getReleaseByVersion(versionInfo.tagName); // Search the release's assets to find the one we are looking for. for (const asset of cliRelease.data.assets) { if (asset.name === proxyPackage) { logger.info( `Found '${proxyPackage}' in release '${versionInfo.tagName}' at '${asset.url}'`, ); return { url: asset.url, // The `update-job-proxy` doesn't have a version as such. Since we now bundle it // with CodeQL CLI bundle releases, we use the corresponding CLI version to // differentiate between (potentially) different versions of `update-job-proxy`. version: versionInfo.cliVersion, }; } } } catch (ex) { logger.warning( `Failed to retrieve information about the linked release: ${getErrorMessage(ex)}`, ); } // Fallback to the hard-coded URL. logger.info( `Did not find '${proxyPackage}' in the linked release, falling back to hard-coded version.`, ); return { url: getFallbackUrl(proxyPackage), version: UPDATEJOB_PROXY_VERSION, }; } /** * Attempts to download a file from `url` into the toolcache. * * @param logger The logger to use. * @param url The URL to download the proxy binary from. * @param authorization The authorization information to use. * @returns If successful, the path to the downloaded file. */ export async function downloadProxy( logger: Logger, url: string, authorization: string | undefined, ) { try { // Download the proxy archive from `url`. We let `downloadTool` choose where // to store it. The path to the downloaded file will be returned if successful. return toolcache.downloadTool(url, /* dest: */ undefined, authorization, { accept: "application/octet-stream", }); } catch (error) { logger.error( `Failed to download proxy archive from ${url}: ${getErrorMessage(error)}`, ); throw new StartProxyError(StartProxyErrorType.DownloadFailed); } } /** * Attempts to extract the proxy binary from the `archive`. * * @param logger The logger to use. * @param archive The archive to extract. * @returns The path to the extracted file(s). */ export async function extractProxy(logger: Logger, archive: string) { try { return await toolcache.extractTar(archive); } catch (error) { logger.error( `Failed to extract proxy archive from ${archive}: ${getErrorMessage(error)}`, ); throw new StartProxyError(StartProxyErrorType.ExtractionFailed); } } /** * Attempts to store the proxy in the toolcache. * * @param logger The logger to use. * @param source The source path to add to the toolcache. * @param filename The filename of the proxy binary. * @param version The version of the proxy. * @returns The path to the directory in the toolcache. */ export async function cacheProxy( logger: Logger, source: string, filename: string, version: string, ) { try { return await toolcache.cacheDir(source, filename, version); } catch (error) { logger.error( `Failed to add proxy archive from ${source} to toolcache: ${getErrorMessage(error)}`, ); throw new StartProxyError(StartProxyErrorType.CacheFailed); } } /** * Returns the platform-specific filename of the proxy binary. */ export function getProxyFilename() { return process.platform === "win32" ? `${UPDATEJOB_PROXY}.exe` : UPDATEJOB_PROXY; } /** * Gets a path to the proxy binary. If possible, this function will find the proxy in the * runner's tool cache. Otherwise, it downloads and extracts the proxy binary, * and stores it in the tool cache. * * @param logger The logger to use. * @returns The path to the proxy binary. */ export async function getProxyBinaryPath( logger: Logger, features: FeatureEnablement, ): Promise { const proxyFileName = getProxyFilename(); const proxyInfo = await getDownloadUrl(logger, features); let proxyBin = toolcache.find(proxyFileName, proxyInfo.version); if (!proxyBin) { const apiDetails = getApiDetails(); const authorization = getAuthorizationHeaderFor( logger, apiDetails, proxyInfo.url, ); const temp = await downloadProxy(logger, proxyInfo.url, authorization); const extracted = await extractProxy(logger, temp); proxyBin = await cacheProxy( logger, extracted, proxyFileName, proxyInfo.version, ); } return path.join(proxyBin, proxyFileName); }