diff --git a/lib/entry-points.js b/lib/entry-points.js index 60bc56d157..6539a35ff8 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -144621,7 +144621,11 @@ function initializeEnvironment(version) { function getEnv(env = process.env) { return { getRequired: (name) => getRequiredEnvVar(env, name), - getOptional: (name) => getOptionalEnvVarFrom(env, name) + getOptional: (name) => getOptionalEnvVarFrom(env, name), + entries: () => Object.entries(env), + set: (name, value) => { + env[name] = value; + } }; } function getRequiredEnvVar(env, paramName) { @@ -144993,31 +144997,31 @@ var getOptionalInput = function(name) { const value = core3.getInput(name); return value.length > 0 ? value : void 0; }; -function getTemporaryDirectory() { - const value = process.env["CODEQL_ACTION_TEMP"]; - return value !== void 0 && value !== "" ? value : getRequiredEnvParam("RUNNER_TEMP" /* RUNNER_TEMP */); +function getTemporaryDirectory(env = getEnv()) { + const value = env.getOptional("CODEQL_ACTION_TEMP" /* TEMP */); + return value !== void 0 && value !== "" ? value : env.getRequired("RUNNER_TEMP" /* RUNNER_TEMP */); } var PR_DIFF_RANGE_JSON_FILENAME = "pr-diff-range.json"; -function getDiffRangesJsonFilePath() { - return path2.join(getTemporaryDirectory(), PR_DIFF_RANGE_JSON_FILENAME); +function getDiffRangesJsonFilePath(env = getEnv()) { + return path2.join(getTemporaryDirectory(env), PR_DIFF_RANGE_JSON_FILENAME); } function getActionVersion() { return "4.36.4"; } -function getWorkflowEventName() { - return getRequiredEnvParam("GITHUB_EVENT_NAME" /* GITHUB_EVENT_NAME */); +function getWorkflowEventName(env = getEnv()) { + return env.getRequired("GITHUB_EVENT_NAME" /* GITHUB_EVENT_NAME */); } -function isRunningLocalAction() { - const relativeScriptPath = getRelativeScriptPath(); +function isRunningLocalAction(env = getEnv()) { + const relativeScriptPath = getRelativeScriptPath(env); return relativeScriptPath.startsWith("..") || path2.isAbsolute(relativeScriptPath); } -function getRelativeScriptPath() { - const runnerTemp = getRequiredEnvParam("RUNNER_TEMP" /* RUNNER_TEMP */); +function getRelativeScriptPath(env) { + const runnerTemp = env.getRequired("RUNNER_TEMP" /* RUNNER_TEMP */); const actionsDirectory = path2.join(path2.dirname(runnerTemp), "_actions"); return path2.relative(actionsDirectory, __filename); } -function getWorkflowEvent() { - const eventJsonFile = getRequiredEnvParam("GITHUB_EVENT_PATH" /* GITHUB_EVENT_PATH */); +function getWorkflowEvent(env = getEnv()) { + const eventJsonFile = env.getRequired("GITHUB_EVENT_PATH" /* GITHUB_EVENT_PATH */); try { return JSON.parse(fs2.readFileSync(eventJsonFile, "utf-8")); } catch (e) { @@ -145073,8 +145077,8 @@ function getUploadValue(input) { return "always"; } } -function getWorkflowRunID() { - const workflowRunIdString = getRequiredEnvParam("GITHUB_RUN_ID" /* GITHUB_RUN_ID */); +function getWorkflowRunID(env = getEnv()) { + const workflowRunIdString = env.getRequired("GITHUB_RUN_ID" /* GITHUB_RUN_ID */); const workflowRunID = parseInt(workflowRunIdString, 10); if (Number.isNaN(workflowRunID)) { throw new Error( @@ -145088,8 +145092,8 @@ function getWorkflowRunID() { } return workflowRunID; } -function getWorkflowRunAttempt() { - const workflowRunAttemptString = getRequiredEnvParam( +function getWorkflowRunAttempt(env = getEnv()) { + const workflowRunAttemptString = env.getRequired( "GITHUB_RUN_ATTEMPT" /* GITHUB_RUN_ATTEMPT */ ); const workflowRunAttempt = parseInt(workflowRunAttemptString, 10); @@ -145142,14 +145146,14 @@ var getFileType = async (filePath) => { throw e; } }; -function isSelfHostedRunner() { - return process.env.RUNNER_ENVIRONMENT === "self-hosted"; +function isSelfHostedRunner(env = getEnv()) { + return env.getOptional("RUNNER_ENVIRONMENT") === "self-hosted"; } -function isDynamicWorkflow() { - return getWorkflowEventName() === "dynamic"; +function isDynamicWorkflow(env = getEnv()) { + return getWorkflowEventName(env) === "dynamic"; } -function isDefaultSetup() { - return isDynamicWorkflow(); +function isDefaultSetup(env = getEnv()) { + return isDynamicWorkflow(env); } function prettyPrintInvocation(cmd, args) { return [cmd, ...args].map((x) => x.includes(" ") ? `'${x}'` : x).join(" "); @@ -145213,8 +145217,9 @@ async function runTool(cmd, args = [], opts = {}) { return stdout; } var persistedInputsKey = "persisted_inputs"; -var persistInputs = function() { - const inputEnvironmentVariables = Object.entries(process.env).filter( +var persistInputs = function(env = getEnv()) { + const entries = env.entries(); + const inputEnvironmentVariables = entries.filter( ([name]) => name.startsWith("INPUT_") ); core3.saveState(persistedInputsKey, JSON.stringify(inputEnvironmentVariables)); @@ -145227,7 +145232,7 @@ var restoreInputs = function() { } } }; -function getPullRequestBranches() { +function getPullRequestBranches(env = getEnv()) { const pullRequest = github.context.payload.pull_request; if (pullRequest) { return { @@ -145238,8 +145243,8 @@ function getPullRequestBranches() { head: pullRequest.head.label }; } - const codeScanningRef = process.env.CODE_SCANNING_REF; - const codeScanningBaseBranch = process.env.CODE_SCANNING_BASE_BRANCH; + const codeScanningRef = env.getOptional("CODE_SCANNING_REF"); + const codeScanningBaseBranch = env.getOptional("CODE_SCANNING_BASE_BRANCH"); if (codeScanningRef && codeScanningBaseBranch) { return { base: codeScanningBaseBranch, @@ -145250,8 +145255,8 @@ function getPullRequestBranches() { } return void 0; } -function isAnalyzingPullRequest() { - return getPullRequestBranches() !== void 0; +function isAnalyzingPullRequest(env = getEnv()) { + return getPullRequestBranches(env) !== void 0; } var qualityCategoryMapping = { "c#": "csharp", @@ -145263,8 +145268,8 @@ var qualityCategoryMapping = { typescript: "javascript-typescript", kotlin: "java-kotlin" }; -function fixCodeQualityCategory(logger, category) { - if (category !== void 0 && isDefaultSetup() && category.startsWith("/language:")) { +function fixCodeQualityCategory(logger, category, env = getEnv()) { + if (category !== void 0 && isDefaultSetup(env) && category.startsWith("/language:")) { const language = category.substring("/language:".length); const mappedLanguage = qualityCategoryMapping[language]; if (mappedLanguage) { @@ -146027,6 +146032,11 @@ var LINKED_CODEQL_VERSION = { tagName: bundleVersion }; var featureConfig = { + ["allow_merge_config_files" /* AllowMergeConfigFiles */]: { + defaultValue: false, + envVar: "CODEQL_ACTION_ALLOW_MERGE_CONFIG_FILES", + minimumVersion: void 0 + }, ["allow_multiple_analysis_kinds" /* AllowMultipleAnalysisKinds */]: { defaultValue: false, envVar: "CODEQL_ACTION_ALLOW_MULTIPLE_ANALYSIS_KINDS", @@ -146942,6 +146952,30 @@ function isKnownPropertyName(name) { } // src/config/db-config.ts +function mergeUserConfigs(logger, fromConfigInput, fromConfigFile) { + logger.debug( + "Combining configuration files from 'config' and 'config-file' inputs" + ); + const threatModels = new Set(fromConfigInput["threat-models"] || []); + for (const configFileThreatModel of fromConfigFile["threat-models"] || []) { + threatModels.add(configFileThreatModel); + } + if (fromConfigFile["default-setup"]) { + logger.warning( + `The 'default-setup' configuration key is not supported in user-supplied configuration files and will be ignored.` + ); + } + const result = { ...fromConfigFile }; + delete result["threat-models"]; + delete result["default-setup"]; + if (fromConfigInput["default-setup"]) { + result["default-setup"] = fromConfigInput["default-setup"]; + } + if (threatModels.size > 0) { + result["threat-models"] = Array.from(threatModels); + } + return result; +} function shouldCombine(inputValue) { return !!inputValue?.trim().startsWith("+"); } @@ -148409,25 +148443,54 @@ async function applyIncrementalAnalysisSettings(config, hasDiffRanges, codeql, l }); } } -async function initConfig(features, inputs) { - const { logger, tempDir } = inputs; +async function determineUserConfig(logger, env, features, tempDir, inputs) { + const validateConfig = await features.getValue("validate_db_config" /* ValidateDbConfig */); if (inputs.configInput) { - if (inputs.configFile) { - logger.warning( - `Both a config file and config input were provided. Ignoring config file.` + const computedConfigPath = userConfigFromActionPath(tempDir); + const allowMergeConfigs = () => features.getValue("allow_merge_config_files" /* AllowMergeConfigFiles */); + if (inputs.configFile && isDefaultSetup(env) && await allowMergeConfigs()) { + const fromConfigInput = parseUserConfig( + logger, + "`config` input", + inputs.configInput, + validateConfig ); + const fromConfigFile = await loadUserConfig( + logger, + inputs.configFile, + inputs.workspacePath, + inputs.apiDetails, + tempDir, + validateConfig + ); + const mergedConfig = mergeUserConfigs( + logger, + fromConfigInput, + fromConfigFile + ); + fs9.writeFileSync(computedConfigPath, dump(mergedConfig)); + logger.debug( + `Using merged configurations from 'config' input with configuration from '${inputs.configFile}': ${computedConfigPath}` + ); + inputs.configFile = computedConfigPath; + return mergedConfig; + } else { + if (inputs.configFile) { + logger.warning( + `Both a config file and config input were provided. Ignoring config file.` + ); + } + fs9.writeFileSync(computedConfigPath, inputs.configInput); + inputs.configFile = computedConfigPath; + logger.debug(`Using config from action input: ${inputs.configFile}`); } - inputs.configFile = userConfigFromActionPath(tempDir); - fs9.writeFileSync(inputs.configFile, inputs.configInput); - logger.debug(`Using config from action input: ${inputs.configFile}`); } - let userConfig = {}; if (!inputs.configFile) { logger.debug("No configuration file was provided"); + return {}; } else { logger.debug(`Using configuration file: ${inputs.configFile}`); - const validateConfig = await features.getValue("validate_db_config" /* ValidateDbConfig */); - userConfig = await loadUserConfig( + return await loadUserConfig( logger, inputs.configFile, inputs.workspacePath, @@ -148436,6 +148499,16 @@ async function initConfig(features, inputs) { validateConfig ); } +} +async function initConfig(features, inputs) { + const { logger, tempDir } = inputs; + const userConfig = await determineUserConfig( + logger, + getEnv(), + features, + tempDir, + inputs + ); const config = await initActionState(inputs, userConfig); if (config.analysisKinds.length === 1 && isCodeQualityEnabled(config)) { if (hasQueryCustomisation(config.computedConfig)) { diff --git a/src/actions-util.ts b/src/actions-util.ts index d7fbacbf3e..251e804e70 100644 --- a/src/actions-util.ts +++ b/src/actions-util.ts @@ -7,12 +7,13 @@ import * as github from "@actions/github"; import * as io from "@actions/io"; import type { Config } from "./config-utils"; +import { Env, EnvVar } from "./environment"; import { Logger } from "./logging"; import { doesDirectoryExist, getCodeQLDatabasePath, - getRequiredEnvParam, ConfigurationError, + getEnv, } from "./util"; /** @@ -83,17 +84,22 @@ export const getOptionalInput = function (name: string): string | undefined { return value.length > 0 ? value : undefined; }; -export function getTemporaryDirectory(): string { - const value = process.env["CODEQL_ACTION_TEMP"]; +/** + * Gets the temporary directory used by the CodeQL Action. This will either be the temporary + * directory that has been set in `CODEQL_ACTION_TEMP` by e.g. a previous step, or the + * value of `RUNNER_TEMP` otherwise. + */ +export function getTemporaryDirectory(env: Env = getEnv()): string { + const value = env.getOptional(EnvVar.TEMP); return value !== undefined && value !== "" ? value - : getRequiredEnvParam(ActionsEnvVars.RUNNER_TEMP); + : env.getRequired(ActionsEnvVars.RUNNER_TEMP); } const PR_DIFF_RANGE_JSON_FILENAME = "pr-diff-range.json"; -export function getDiffRangesJsonFilePath(): string { - return path.join(getTemporaryDirectory(), PR_DIFF_RANGE_JSON_FILENAME); +export function getDiffRangesJsonFilePath(env: Env = getEnv()): string { + return path.join(getTemporaryDirectory(env), PR_DIFF_RANGE_JSON_FILENAME); } export function getActionVersion(): string { @@ -105,16 +111,16 @@ export function getActionVersion(): string { * * This will be "dynamic" for default setup workflow runs. */ -export function getWorkflowEventName() { - return getRequiredEnvParam(ActionsEnvVars.GITHUB_EVENT_NAME); +export function getWorkflowEventName(env: Env = getEnv()) { + return env.getRequired(ActionsEnvVars.GITHUB_EVENT_NAME); } /** * Returns whether the current workflow is executing a local copy of the Action, e.g. we're running * a workflow on the codeql-action repo itself. */ -export function isRunningLocalAction(): boolean { - const relativeScriptPath = getRelativeScriptPath(); +export function isRunningLocalAction(env: Env = getEnv()): boolean { + const relativeScriptPath = getRelativeScriptPath(env); return ( relativeScriptPath.startsWith("..") || path.isAbsolute(relativeScriptPath) ); @@ -125,15 +131,15 @@ export function isRunningLocalAction(): boolean { * * This can be used to get the Action's name or tell if we're running a local Action. */ -function getRelativeScriptPath(): string { - const runnerTemp = getRequiredEnvParam(ActionsEnvVars.RUNNER_TEMP); +function getRelativeScriptPath(env: Env): string { + const runnerTemp = env.getRequired(ActionsEnvVars.RUNNER_TEMP); const actionsDirectory = path.join(path.dirname(runnerTemp), "_actions"); return path.relative(actionsDirectory, __filename); } /** Returns the contents of `GITHUB_EVENT_PATH` as a JSON object. */ -export function getWorkflowEvent(): any { - const eventJsonFile = getRequiredEnvParam(ActionsEnvVars.GITHUB_EVENT_PATH); +export function getWorkflowEvent(env: Env = getEnv()): any { + const eventJsonFile = env.getRequired(ActionsEnvVars.GITHUB_EVENT_PATH); try { return JSON.parse(fs.readFileSync(eventJsonFile, "utf-8")); } catch (e) { @@ -202,8 +208,8 @@ export function getUploadValue(input: string | undefined): UploadKind { /** * Get the workflow run ID. */ -export function getWorkflowRunID(): number { - const workflowRunIdString = getRequiredEnvParam(ActionsEnvVars.GITHUB_RUN_ID); +export function getWorkflowRunID(env: Env = getEnv()): number { + const workflowRunIdString = env.getRequired(ActionsEnvVars.GITHUB_RUN_ID); const workflowRunID = parseInt(workflowRunIdString, 10); if (Number.isNaN(workflowRunID)) { throw new Error( @@ -221,8 +227,8 @@ export function getWorkflowRunID(): number { /** * Get the workflow run attempt number. */ -export function getWorkflowRunAttempt(): number { - const workflowRunAttemptString = getRequiredEnvParam( +export function getWorkflowRunAttempt(env: Env = getEnv()): number { + const workflowRunAttemptString = env.getRequired( ActionsEnvVars.GITHUB_RUN_ATTEMPT, ); const workflowRunAttempt = parseInt(workflowRunAttemptString, 10); @@ -290,18 +296,18 @@ export const getFileType = async (filePath: string): Promise => { } }; -export function isSelfHostedRunner() { - return process.env.RUNNER_ENVIRONMENT === "self-hosted"; +export function isSelfHostedRunner(env: Env = getEnv()) { + return env.getOptional("RUNNER_ENVIRONMENT") === "self-hosted"; } /** Determines whether the workflow trigger is `dynamic`. */ -export function isDynamicWorkflow(): boolean { - return getWorkflowEventName() === "dynamic"; +export function isDynamicWorkflow(env: Env = getEnv()): boolean { + return getWorkflowEventName(env) === "dynamic"; } /** Determines whether we are running in default setup. */ -export function isDefaultSetup(): boolean { - return isDynamicWorkflow(); +export function isDefaultSetup(env: Env = getEnv()): boolean { + return isDynamicWorkflow(env); } export function prettyPrintInvocation(cmd: string, args: string[]): string { @@ -399,9 +405,10 @@ const persistedInputsKey = "persisted_inputs"; * This would be simplified if actions/runner#3514 is addressed. * https://github.com/actions/runner/issues/3514 */ -export const persistInputs = function () { - const inputEnvironmentVariables = Object.entries(process.env).filter( - ([name]) => name.startsWith("INPUT_"), +export const persistInputs = function (env: Env = getEnv()) { + const entries = env.entries(); + const inputEnvironmentVariables = entries.filter(([name]) => + name.startsWith("INPUT_"), ); core.saveState(persistedInputsKey, JSON.stringify(inputEnvironmentVariables)); }; @@ -429,7 +436,9 @@ export interface PullRequestBranches { * @returns the base and head branches of the pull request, or undefined if * we are not analyzing a pull request. */ -export function getPullRequestBranches(): PullRequestBranches | undefined { +export function getPullRequestBranches( + env: Env = getEnv(), +): PullRequestBranches | undefined { const pullRequest = github.context.payload.pull_request; if (pullRequest) { return { @@ -443,8 +452,8 @@ export function getPullRequestBranches(): PullRequestBranches | undefined { // PR analysis under Default Setup does not have the pull_request context, // but it should set CODE_SCANNING_REF and CODE_SCANNING_BASE_BRANCH. - const codeScanningRef = process.env.CODE_SCANNING_REF; - const codeScanningBaseBranch = process.env.CODE_SCANNING_BASE_BRANCH; + const codeScanningRef = env.getOptional("CODE_SCANNING_REF"); + const codeScanningBaseBranch = env.getOptional("CODE_SCANNING_BASE_BRANCH"); if (codeScanningRef && codeScanningBaseBranch) { return { base: codeScanningBaseBranch, @@ -459,8 +468,8 @@ export function getPullRequestBranches(): PullRequestBranches | undefined { /** * Returns whether we are analyzing a pull request. */ -export function isAnalyzingPullRequest(): boolean { - return getPullRequestBranches() !== undefined; +export function isAnalyzingPullRequest(env: Env = getEnv()): boolean { + return getPullRequestBranches(env) !== undefined; } /** @@ -484,13 +493,14 @@ const qualityCategoryMapping: Record = { export function fixCodeQualityCategory( logger: Logger, category?: string, + env: Env = getEnv(), ): string | undefined { // The `category` should always be set by Default Setup. We perform this check // to avoid potential issues if Code Quality supports Advanced Setup in the future // and before this workaround is removed. if ( category !== undefined && - isDefaultSetup() && + isDefaultSetup(env) && category.startsWith("/language:") ) { const language = category.substring("/language:".length); diff --git a/src/config-utils.test.ts b/src/config-utils.test.ts index 27de780ad5..f6eaa155af 100644 --- a/src/config-utils.test.ts +++ b/src/config-utils.test.ts @@ -36,6 +36,8 @@ import { mockCodeQLVersion, createTestConfig, makeMacro, + RecordingLogger, + DEFAULT_ACTIONS_VARS, } from "./testing-utils"; import { GitHubVariant, @@ -482,6 +484,24 @@ test.serial("load non-existent input", async (t) => { }); }); +/** A non-empty, but fairly minimal configuration file. */ +const simpleConfigFileContents = ` + name: my config + queries: + - uses: ./foo_file`; + +/** A less minimal configuration file. */ +const otherConfigFileContents = ` + name: my config + disable-default-queries: true + queries: + - uses: ./foo + paths-ignore: + - a + - b + paths: + - c/d`; + test.serial("load non-empty input", async (t) => { return await withTmpDir(async (tempDir) => { setupActionsVars(tempDir, tempDir); @@ -496,18 +516,6 @@ test.serial("load non-empty input", async (t) => { }, }); - // Just create a generic config object with non-default values for all fields - const inputFileContents = ` - name: my config - disable-default-queries: true - queries: - - uses: ./foo - paths-ignore: - - a - - b - paths: - - c/d`; - fs.mkdirSync(path.join(tempDir, "foo")); const userConfig: UserConfig = { @@ -534,7 +542,7 @@ test.serial("load non-empty input", async (t) => { }); const languagesInput = "javascript"; - const configFilePath = createConfigFile(inputFileContents, tempDir); + const configFilePath = createConfigFile(otherConfigFileContents, tempDir); const actualConfig = await configUtils.initConfig( createFeatures([]), @@ -559,14 +567,12 @@ test.serial( "Using config input and file together, config input should be used.", async (t) => { return await withTmpDir(async (tempDir) => { - process.env["RUNNER_TEMP"] = tempDir; - process.env["GITHUB_WORKSPACE"] = tempDir; + setupActionsVars(tempDir, tempDir); - const inputFileContents = ` - name: my config - queries: - - uses: ./foo_file`; - const configFilePath = createConfigFile(inputFileContents, tempDir); + const configFilePath = createConfigFile( + simpleConfigFileContents, + tempDir, + ); const configInput = ` name: my config @@ -2272,3 +2278,317 @@ test("applyIncrementalAnalysisSettings: adds exclusions for diff-informed-only r { exclude: { tags: "exclude-from-incremental" } }, ]); }); + +test("determineUserConfig - empty config when neither input is specified", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = new RecordingLogger(); + const env = util.getEnv(DEFAULT_ACTIONS_VARS); + + const result = await configUtils.determineUserConfig( + logger, + env, + createFeatures([]), + tmpDir, + createTestInitConfigInputs({ + configInput: undefined, + configFile: undefined, + workspacePath: tmpDir, + }), + ); + + // The returned configuration should be empty. + t.deepEqual(result, {}); + // And the fact that no configuration was provided should have been logged, + // but not the messages for the two input sources. + t.true(logger.hasMessage("No configuration file was provided")); + t.false(logger.hasMessage("Using config from action input:")); + t.false(logger.hasMessage("Using configuration file:")); + // And the warning about both inputs should not have been logged. + t.false( + logger.hasMessage( + "Both a config file and config input were provided. Ignoring config file.", + ), + ); + }); +}); + +test("determineUserConfig - loads config file", async (t) => { + await withTmpDir(async (tmpDir) => { + const configFilePath = createConfigFile(simpleConfigFileContents, tmpDir); + const logger = new RecordingLogger(); + const env = util.getEnv(DEFAULT_ACTIONS_VARS); + + const inputs = createTestInitConfigInputs({ + configInput: undefined, + configFile: configFilePath, + workspacePath: tmpDir, + }); + const result = await configUtils.determineUserConfig( + logger, + env, + createFeatures([]), + tmpDir, + inputs, + ); + + // The loaded configuration should match `simpleConfigFileContents`. + t.deepEqual(result, { + name: "my config", + queries: [{ uses: "./foo_file" }], + }); + // The `configFile` input should not have changed. + t.is(inputs.configFile, configFilePath); + // And the path of the input config file should have been logged, while the + // other two origin messages should not have been logged. + t.true(logger.hasMessage(`Using configuration file: ${configFilePath}`)); + t.false(logger.hasMessage("No configuration file was provided")); + t.false(logger.hasMessage("Using config from action input:")); + // But the warning about both inputs should not have been logged. + t.false( + logger.hasMessage( + "Both a config file and config input were provided. Ignoring config file.", + ), + ); + }); +}); + +test("determineUserConfig - loads config input", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = new RecordingLogger(); + const env = util.getEnv(DEFAULT_ACTIONS_VARS); + const expectedConfigPath = configUtils.userConfigFromActionPath(tmpDir); + + const inputs = createTestInitConfigInputs({ + configInput: simpleConfigFileContents, + configFile: undefined, + workspacePath: tmpDir, + }); + const result = await configUtils.determineUserConfig( + logger, + env, + createFeatures([]), + tmpDir, + inputs, + ); + + // The loaded configuration should match `simpleConfigFileContents`. + t.deepEqual(result, { + name: "my config", + queries: [{ uses: "./foo_file" }], + }); + // The `configFile` input should have been mutated to the generated path. + t.is(inputs.configFile, expectedConfigPath); + // And the input source and path of the generated config file should have been logged, + // while the message about no configuration input should not have been logged. + t.true(logger.hasMessage("Using config from action input:")); + t.true( + logger.hasMessage(`Using configuration file: ${expectedConfigPath}`), + ); + t.false(logger.hasMessage("No configuration file was provided")); + // But the warning about both inputs should not have been logged. + t.false( + logger.hasMessage( + "Both a config file and config input were provided. Ignoring config file.", + ), + ); + }); +}); + +test("determineUserConfig - ignores config file input when both specified", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = new RecordingLogger(); + const env = util.getEnv(DEFAULT_ACTIONS_VARS); + const configFilePath = createConfigFile(otherConfigFileContents, tmpDir); + const expectedConfigPath = configUtils.userConfigFromActionPath(tmpDir); + + const inputs = createTestInitConfigInputs({ + configInput: simpleConfigFileContents, + configFile: configFilePath, + workspacePath: tmpDir, + }); + const result = await configUtils.determineUserConfig( + logger, + env, + createFeatures([]), + tmpDir, + inputs, + ); + + // The loaded configuration should match `simpleConfigFileContents`. + t.deepEqual(result, { + name: "my config", + queries: [{ uses: "./foo_file" }], + }); + // The `configFile` input should have been mutated to the generated path. + t.is(inputs.configFile, expectedConfigPath); + // And the path of the generated config file should have been logged. + t.true( + logger.hasMessage( + `Using config from action input: ${expectedConfigPath}`, + ), + ); + t.true( + logger.hasMessage(`Using configuration file: ${expectedConfigPath}`), + ); + t.false(logger.hasMessage("No configuration file was provided")); + // And the warning about both inputs should have been logged. + t.true( + logger.hasMessage( + "Both a config file and config input were provided. Ignoring config file.", + ), + ); + }); +}); + +/** A `config` input that we might get from Default Setup. */ +const defaultSetupConfigInput = ` + threat-models: [local, remote] + default-setup: + org: + model-packs: [foo, bar]`; + +test("determineUserConfig - merges configs if FF is enabled in Default Setup", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = new RecordingLogger(true); + const env = util.getEnv({ + ...DEFAULT_ACTIONS_VARS, + [actionsUtil.ActionsEnvVars.GITHUB_EVENT_NAME]: "dynamic", + }); + const configFilePath = createConfigFile(simpleConfigFileContents, tmpDir); + const expectedConfigPath = configUtils.userConfigFromActionPath(tmpDir); + + const result = await configUtils.determineUserConfig( + logger, + env, + createFeatures([Feature.AllowMergeConfigFiles]), + tmpDir, + createTestInitConfigInputs({ + configInput: defaultSetupConfigInput, + configFile: configFilePath, + workspacePath: tmpDir, + }), + ); + + // The loaded configuration should match the result of merging + // `defaultSetupConfigInput` and `simpleConfigFileContents`. + t.deepEqual(result, { + name: "my config", + queries: [{ uses: "./foo_file" }], + "threat-models": ["local", "remote"], + "default-setup": { + org: { + "model-packs": ["foo", "bar"], + }, + }, + } satisfies UserConfig); + // And the appropriate origin messages should have been logged. + t.true( + logger.hasMessage( + `Using merged configurations from 'config' input with configuration from '${configFilePath}': ${expectedConfigPath}`, + ), + ); + t.false( + logger.hasMessage(`Using configuration file: ${expectedConfigPath}`), + ); + t.false(logger.hasMessage("No configuration file was provided")); + t.false( + logger.hasMessage( + `Using config from action input: ${expectedConfigPath}`, + ), + ); + t.false( + logger.hasMessage( + "Both a config file and config input were provided. Ignoring config file.", + ), + ); + }); +}); + +test("determineUserConfig - ignores config file input in Default Setup if FF is off", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = new RecordingLogger(true); + const env = util.getEnv({ + ...DEFAULT_ACTIONS_VARS, + [actionsUtil.ActionsEnvVars.GITHUB_EVENT_NAME]: "dynamic", + }); + const configFilePath = createConfigFile(otherConfigFileContents, tmpDir); + const expectedConfigPath = configUtils.userConfigFromActionPath(tmpDir); + + const result = await configUtils.determineUserConfig( + logger, + env, + createFeatures([]), + tmpDir, + createTestInitConfigInputs({ + configInput: simpleConfigFileContents, + configFile: configFilePath, + workspacePath: tmpDir, + }), + ); + + // The loaded configuration should match `simpleConfigFileContents`. + t.deepEqual(result, { + name: "my config", + queries: [{ uses: "./foo_file" }], + }); + // And the path of the generated config file should have been logged. + t.true( + logger.hasMessage( + `Using config from action input: ${expectedConfigPath}`, + ), + ); + t.true( + logger.hasMessage(`Using configuration file: ${expectedConfigPath}`), + ); + t.false(logger.hasMessage("No configuration file was provided")); + // And the warning about both inputs should have been logged. + t.true( + logger.hasMessage( + "Both a config file and config input were provided. Ignoring config file.", + ), + ); + }); +}); + +test("determineUserConfig - ignores config file input outside Default Setup if FF is on", async (t) => { + await withTmpDir(async (tmpDir) => { + const logger = new RecordingLogger(true); + const env = util.getEnv(DEFAULT_ACTIONS_VARS); + const configFilePath = createConfigFile(otherConfigFileContents, tmpDir); + const expectedConfigPath = configUtils.userConfigFromActionPath(tmpDir); + + const result = await configUtils.determineUserConfig( + logger, + env, + createFeatures([Feature.AllowMergeConfigFiles]), + tmpDir, + createTestInitConfigInputs({ + configInput: simpleConfigFileContents, + configFile: configFilePath, + workspacePath: tmpDir, + }), + ); + + // The loaded configuration should match `simpleConfigFileContents`. + t.deepEqual(result, { + name: "my config", + queries: [{ uses: "./foo_file" }], + }); + // And the path of the generated config file should have been logged. + t.true( + logger.hasMessage( + `Using config from action input: ${expectedConfigPath}`, + ), + ); + t.true( + logger.hasMessage(`Using configuration file: ${expectedConfigPath}`), + ); + t.false(logger.hasMessage("No configuration file was provided")); + // And the warning about both inputs should have been logged. + t.true( + logger.hasMessage( + "Both a config file and config input were provided. Ignoring config file.", + ), + ); + }); +}); diff --git a/src/config-utils.ts b/src/config-utils.ts index 972734877a..9d7d89a579 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -9,6 +9,7 @@ import { getActionVersion, getOptionalInput, isAnalyzingPullRequest, + isDefaultSetup, isDynamicWorkflow, } from "./actions-util"; import { @@ -24,6 +25,7 @@ import { calculateAugmentation, ExcludeQueryFilter, generateCodeScanningConfig, + mergeUserConfigs, parseUserConfig, UserConfig, } from "./config/db-config"; @@ -32,7 +34,7 @@ import { makeTelemetryDiagnostic, } from "./diagnostics"; import { prepareDiffInformedAnalysis } from "./diff-informed-analysis-utils"; -import { EnvVar } from "./environment"; +import { Env, EnvVar } from "./environment"; import * as errorMessages from "./error-messages"; import { Feature, FeatureEnablement } from "./feature-flags"; import { @@ -77,6 +79,7 @@ import { Success, Failure, isHostedRunner, + getEnv, } from "./util"; /** @@ -598,6 +601,17 @@ async function downloadCacheWithTime( return { trapCaches, trapCacheDownloadTime }; } +/** + * Loads a CLI configuration file from `configFile`. + * + * @param logger The logger to use. + * @param configFile The address of the configuration file. + * @param workspacePath The workspace path, used to check that the configuration file exists relative to it. + * @param apiDetails Information for how to access the API to fetch remote files. + * @param tempDir The temporary directory which may contain a CodeQL Action-generated configuration file. + * @param validateConfig Whether to validate the configuration file. + * @returns The loaded configuration file, if successful. + */ async function loadUserConfig( logger: Logger, configFile: string, @@ -1071,7 +1085,11 @@ function dbLocationOrDefault( return dbLocation || path.resolve(tempDir, "codeql_databases"); } -function userConfigFromActionPath(tempDir: string): string { +/** + * Gets the path for the CodeQL Action-generated configuration file, + * which is used to store the `config` input. + */ +export function userConfigFromActionPath(tempDir: string): string { return path.resolve(tempDir, "user-config-from-action.yml"); } @@ -1132,36 +1150,98 @@ export async function applyIncrementalAnalysisSettings( } /** - * Load and return the config. + * Determines where to load the `UserConfig` for the CLI from and loads it. * - * This will parse the config from the user input if present, or generate - * a default config. The parsed config is then stored to a known location. + * @param inputs The Action inputs. The `configFile` value will be mutated + * if a CodeQL Action-generated file should be used. + * + * @returns The loaded `UserConfig`, which might be empty if no configuration + * was specified. */ -export async function initConfig( +export async function determineUserConfig( + logger: Logger, + env: Env, features: FeatureEnablement, + tempDir: string, inputs: InitConfigInputs, -): Promise { - const { logger, tempDir } = inputs; +): Promise { + const validateConfig = await features.getValue(Feature.ValidateDbConfig); - // if configInput is set, it takes precedence over configFile + // We have the following cases: + // 1. A `config` or `config-file` input is provided, but not both: use the provided one. + // 2. Both are provided and we are in an advanced workflow: ignore the `config-file` input. + // 3. Both are provided and we are in Default Setup: the `config` input uses a limited + // set of options, which are supported by `mergeUserConfigs`, and we merge the two configs. if (inputs.configInput) { - if (inputs.configFile) { - logger.warning( - `Both a config file and config input were provided. Ignoring config file.`, + const computedConfigPath = userConfigFromActionPath(tempDir); + + // Get a function which enables us to determine whether the FF that allows us to + // merge supported configuration file properties is enabled. We only execute + // this lazily if the other checks pass. + const allowMergeConfigs = () => + features.getValue(Feature.AllowMergeConfigFiles); + + // Check whether we also have a `config-file` input and decide what to do. + if ( + inputs.configFile && + isDefaultSetup(env) && + (await allowMergeConfigs()) + ) { + // If the FF is enabled and we are in Default Setup, combine the supported + // configuration file properties and write the result to disk. + const fromConfigInput = parseUserConfig( + logger, + "`config` input", + inputs.configInput, + validateConfig, + ); + const fromConfigFile = await loadUserConfig( + logger, + inputs.configFile, + inputs.workspacePath, + inputs.apiDetails, + tempDir, + validateConfig, + ); + + // Write the merged configuration to disk so that it can be loaded subsequently by + // the CLI or other CodeQL Action steps. + const mergedConfig = mergeUserConfigs( + logger, + fromConfigInput, + fromConfigFile, + ); + fs.writeFileSync(computedConfigPath, yaml.dump(mergedConfig)); + logger.debug( + `Using merged configurations from 'config' input with configuration from '${inputs.configFile}': ${computedConfigPath}`, ); + + inputs.configFile = computedConfigPath; + return mergedConfig; + } else { + // If we are in this branch and there is a `config-file` input, then it means + // we didn't meet the conditions for merging the configurations. Warn the user + // that the configuration file will be ignored. + if (inputs.configFile) { + logger.warning( + `Both a config file and config input were provided. Ignoring config file.`, + ); + } + + // Write the `config` input straight to disk. + fs.writeFileSync(computedConfigPath, inputs.configInput); + inputs.configFile = computedConfigPath; + logger.debug(`Using config from action input: ${inputs.configFile}`); } - inputs.configFile = userConfigFromActionPath(tempDir); - fs.writeFileSync(inputs.configFile, inputs.configInput); - logger.debug(`Using config from action input: ${inputs.configFile}`); } - let userConfig: UserConfig = {}; + // Load whatever configuration file we have, if any. if (!inputs.configFile) { logger.debug("No configuration file was provided"); + return {}; } else { logger.debug(`Using configuration file: ${inputs.configFile}`); - const validateConfig = await features.getValue(Feature.ValidateDbConfig); - userConfig = await loadUserConfig( + return await loadUserConfig( logger, inputs.configFile, inputs.workspacePath, @@ -1170,6 +1250,27 @@ export async function initConfig( validateConfig, ); } +} + +/** + * Load and return the config. + * + * This will parse the config from the user input if present, or generate + * a default config. The parsed config is then stored to a known location. + */ +export async function initConfig( + features: FeatureEnablement, + inputs: InitConfigInputs, +): Promise { + const { logger, tempDir } = inputs; + + const userConfig = await determineUserConfig( + logger, + getEnv(), + features, + tempDir, + inputs, + ); const config = await initActionState(inputs, userConfig); diff --git a/src/config/db-config.test.ts b/src/config/db-config.test.ts index ca0061e136..4d523ad0a9 100644 --- a/src/config/db-config.test.ts +++ b/src/config/db-config.test.ts @@ -8,6 +8,7 @@ import { getRecordingLogger, LoggedMessage, makeMacro, + RecordingLogger, } from "../testing-utils"; import { ConfigurationError, prettyPrintPack } from "../util"; @@ -488,3 +489,67 @@ test("parseUserConfig - throws no ConfigurationError if validation should fail, ), ); }); + +test("mergeUserConfigs - combines threat models", async (t) => { + const logger = new RecordingLogger(); + const result = dbConfig.mergeUserConfigs( + logger, + { "threat-models": ["a", "b"] }, + { "threat-models": ["local", "remote"] }, + ); + + const threatModels = result["threat-models"]; + + if (t.truthy(threatModels)) { + t.deepEqual(threatModels, ["a", "b", "local", "remote"]); + } +}); + +test("mergeUserConfigs - warns if user-supplied config contains default setup key", async (t) => { + const logger = new RecordingLogger(); + const result = dbConfig.mergeUserConfigs(logger, {}, { "default-setup": {} }); + + // User-supplied value is ignored. + t.deepEqual(result, {}); + + // Warning is logged. + t.true( + logger.hasMessage( + "The 'default-setup' configuration key is not supported in user-supplied configuration files", + ), + ); +}); + +test("mergeUserConfigs - keeps default setup key from 'config' input", async (t) => { + const logger = new RecordingLogger(); + const expected: dbConfig.DefaultSetupConfig = { + org: { "model-packs": ["some-pack"] }, + }; + const result = dbConfig.mergeUserConfigs( + logger, + { "default-setup": expected }, + {}, + ); + + // Result matches the input. + t.deepEqual(result["default-setup"], expected); + + // No warning is logged. + t.false( + logger.hasMessage( + "The 'default-setup' configuration key is not supported in user-supplied configuration files", + ), + ); +}); + +test("mergeUserConfigs - keeps other properties from user-supplied configuration", async (t) => { + const logger = new RecordingLogger(); + const configFile: dbConfig.UserConfig = { + "query-filters": [{ exclude: { a: "b" } }], + "paths-ignore": ["path"], + }; + + const result = dbConfig.mergeUserConfigs(logger, {}, configFile); + + t.deepEqual(result, configFile); +}); diff --git a/src/config/db-config.ts b/src/config/db-config.ts index a84a20f247..c24994476f 100644 --- a/src/config/db-config.ts +++ b/src/config/db-config.ts @@ -28,6 +28,14 @@ export interface QuerySpec { uses: string; } +/** Not intended to be provided directly by a user. */ +export interface DefaultSetupConfig { + org?: { + /** An array of model pack names. */ + "model-packs"?: string[]; + }; +} + /** * Format of the config file supplied by the user. */ @@ -46,6 +54,65 @@ export interface UserConfig { // Set of query filters to include and exclude extra queries based on // codeql query suite `include` and `exclude` properties "query-filters"?: QueryFilter[]; + + /** An array (possibly empty or absent) of threat models to use. */ + "threat-models"?: string[]; + + /** + * Configuration options that are reserved for us in Default Setup and + * not intended to be supplied directly by users. + */ + "default-setup"?: DefaultSetupConfig; +} + +/** + * Merges supported properties from two configuration files. This is intended only for + * use with merging the `config` input provided by Default Setup with a potentially + * richer configuration file provided by a user. + * + * @param logger The logger to use. + * @param fromConfigInput The configuration from Default Setup. + * @param fromConfigFile The user-supplied configuration. + * @returns The combination of both configuration files. + */ +export function mergeUserConfigs( + logger: Logger, + fromConfigInput: UserConfig, + fromConfigFile: UserConfig, +): UserConfig { + logger.debug( + "Combining configuration files from 'config' and 'config-file' inputs", + ); + + // Combine all specified threat models from both sources. + const threatModels = new Set(fromConfigInput["threat-models"] || []); + for (const configFileThreatModel of fromConfigFile["threat-models"] || []) { + threatModels.add(configFileThreatModel); + } + + // Warn if there is a 'default-setup' configuration key in the user-supplied configuration, + // since it is not meant to be used and we therefore ignore it here. + if (fromConfigFile["default-setup"]) { + logger.warning( + `The 'default-setup' configuration key is not supported in user-supplied configuration files and will be ignored.`, + ); + } + + // Since we expect the `fromConfigInput` configuration to be provided by Default Setup, + // we expect a limited set of options. Therefore, we base the overall configuration on + // the one provided via the `config-file` input, which may be richer. + const result = { ...fromConfigFile }; + delete result["threat-models"]; + delete result["default-setup"]; + + if (fromConfigInput["default-setup"]) { + result["default-setup"] = fromConfigInput["default-setup"]; + } + if (threatModels.size > 0) { + result["threat-models"] = Array.from(threatModels); + } + + return result; } /** diff --git a/src/environment.ts b/src/environment.ts index c0ca050b03..03d4870be1 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -83,6 +83,9 @@ export enum EnvVar { /** Whether to suppress the warning if the current CLI will soon be unsupported. */ SUPPRESS_DEPRECATED_SOON_WARNING = "CODEQL_ACTION_SUPPRESS_DEPRECATED_SOON_WARNING", + /** Used to dictate or persist the temporary directory used by the CodeQL Action. */ + TEMP = "CODEQL_ACTION_TEMP", + /** Whether to disable uploading SARIF results or status reports to the GitHub API */ TEST_MODE = "CODEQL_ACTION_TEST_MODE", @@ -167,4 +170,8 @@ export interface Env { getRequired(name: string): string; /** Gets the value for `name`, or `undefined` if it isn't set or empty. */ getOptional(name: string): string | undefined; + /** Gets the entries of the underlying `ProcessEnv`. */ + entries(): Array<[string, string | undefined]>; + /** Sets an environment variable. */ + set(name: string, value: string): void; } diff --git a/src/feature-flags.ts b/src/feature-flags.ts index f71ecab57b..5b6d623a7a 100644 --- a/src/feature-flags.ts +++ b/src/feature-flags.ts @@ -70,6 +70,8 @@ export interface CodeQLDefaultVersionInfo { * Legacy features should end with `_enabled`. */ export enum Feature { + /** Allows supported properties of configuration files to be merged. */ + AllowMergeConfigFiles = "allow_merge_config_files", /** Controls whether we allow multiple values for the `analysis-kinds` input. */ AllowMultipleAnalysisKinds = "allow_multiple_analysis_kinds", AllowToolcacheInput = "allow_toolcache_input", @@ -171,6 +173,11 @@ export type FeatureConfig = { }; export const featureConfig = { + [Feature.AllowMergeConfigFiles]: { + defaultValue: false, + envVar: "CODEQL_ACTION_ALLOW_MERGE_CONFIG_FILES", + minimumVersion: undefined, + }, [Feature.AllowMultipleAnalysisKinds]: { defaultValue: false, envVar: "CODEQL_ACTION_ALLOW_MULTIPLE_ANALYSIS_KINDS", diff --git a/src/testing-utils.ts b/src/testing-utils.ts index f7589ca08a..bcbfa1c817 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -323,11 +323,19 @@ export type ActionVarOverrides = Partial< * excluding some that are expected to be set to paths. See `setupActionsVars`. * * @param overrides Overrides for the defaults. + * @param env The environment to set the variables for. */ -export function setupBaseActionsVars(overrides?: ActionVarOverrides) { +export function setupBaseActionsVars( + overrides?: ActionVarOverrides, + env?: Env, +) { const vars = { ...DEFAULT_ACTIONS_VARS, ...overrides }; for (const [key, value] of Object.entries(vars)) { - process.env[key] = value; + if (env) { + env.set(key, value); + } else { + process.env[key] = value; + } } } diff --git a/src/util.ts b/src/util.ts index 7f52456608..36f0ce04b0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -571,6 +571,10 @@ export function getEnv(env: NodeJS.ProcessEnv = process.env): Env { return { getRequired: (name) => getRequiredEnvVar(env, name), getOptional: (name) => getOptionalEnvVarFrom(env, name), + entries: () => Object.entries(env), + set: (name, value) => { + env[name] = value; + }, }; }