Files
renovate/lib/workers/repository/init/inherited.ts
Jamie Tanna 238e15a498 feat(config): add inheritConfigTrusted to trust inherited-config hostRules
Renovate deployments that are deployed across multiple organisations may
take advantage of Inherited Config as a means to decentralise some
decisions and/or preferences that their organisations would like to use
for some self-hosted configuration.

Similarly, the decision of which hosts to allowlist may be something
that a self-hosted administrator /may/ want to allow their organisation
admins to decide.

We can make this an opt-in capability to indicate that the self-hosted
administrator trusts the Inherited Config moreso, and allows someone who
can write to it the ability to allowlist access to internal hosts that
may not be intended.

If a self-hosted administrator sets `allowInternal: false`, this takes
precedence, allowing a "veto", and a self-hosted admin's `headers` are
still preferred, too.

It's not possible for Inherited Config to set grants for `extends`, as
config resolution has already occurred, so this still needs to be
performed at the self-hosted config level.

This trust is transitive: `hostRules` defined in presets the
inherited config `extends` are registered at the same inherited
tier once resolved, so this option must only be enabled when those
preset sources are trusted too.

Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
2026-09-10 14:52:01 +01:00

218 lines
7.7 KiB
TypeScript

import { isNonEmptyArray, isNullOrUndefined, isString } from '@sindresorhus/is';
import { dequal } from 'dequal';
import { setUserConfigFileNames } from '../../../config/app-strings.ts';
import { decryptConfig } from '../../../config/decrypt.ts';
import { GlobalConfig } from '../../../config/global.ts';
import { mergeChildConfig, removeGlobalConfig } from '../../../config/index.ts';
import { InheritConfig } from '../../../config/inherit.ts';
import { parseFileConfig } from '../../../config/parse.ts';
import { resolveConfigPresets } from '../../../config/presets/index.ts';
import { applySecretsAndVariablesToConfig } from '../../../config/secrets.ts';
import type {
RenovateConfig,
ValidationMessage,
} from '../../../config/types.ts';
import { validateConfig } from '../../../config/validation.ts';
import {
CONFIG_INHERIT_NOT_FOUND,
CONFIG_INHERIT_PARSE_ERROR,
CONFIG_VALIDATION,
} from '../../../constants/error-messages.ts';
import { logger } from '../../../logger/index.ts';
import { platform } from '../../../modules/platform/index.ts';
import type { AddHostRuleOptions } from '../../../util/host-rules.ts';
import { coerceObject } from '../../../util/object.ts';
import * as template from '../../../util/template/index.ts';
import { applyHostRules } from './merge.ts';
const inheritedConfigValidationError =
'The inherited config contains some invalid settings';
/**
* Report an inherited config validation failure against the inherited config, rather than the repository being processed.
*
* The inherited config repository is managed by the organization's administrators, so a fault there is not one the repository's owners have introduced - and they may not even be able to read the file to see it.
*/
function throwInheritedConfigValidationError(
validationSource: string,
errors: ValidationMessage[],
): never {
const error = new Error(CONFIG_VALIDATION);
error.validationSource = validationSource;
error.validationError = inheritedConfigValidationError;
error.validationMessage = errors.map((err) => err.message).join(', ');
throw error;
}
/**
* How the inherited config's `hostRules` are registered.
*
* The inherited config repository is controlled by the organization's administrators rather than by the self-hosted administrator, so its rules are untrusted unless the administrator has opted into trusting them.
*/
function inheritedHostRuleOptions(): AddHostRuleOptions | undefined {
if (!GlobalConfig.get('inheritConfigTrusted')) {
return undefined;
}
return { inherited: true };
}
export async function mergeInheritedConfig(
config: RenovateConfig,
): Promise<RenovateConfig> {
// typescript doesn't know that repo is defined
if (!config.repository || !config.inheritConfig) {
return config;
}
if (
!isString(config.inheritConfigRepoName) ||
!isString(config.inheritConfigFileName)
) {
// Config validation should prevent this error
logger.error(
{
inheritConfigRepoName: config.inheritConfigRepoName,
inheritConfigFileName: config.inheritConfigFileName,
},
'Invalid inherited config.',
);
return config;
}
const templateConfig = {
topLevelOrg: config.topLevelOrg,
parentOrg: config.parentOrg,
repository: config.repository,
};
const inheritConfigRepoName = template.compile(
config.inheritConfigRepoName,
templateConfig,
false,
);
logger.trace(
{ templateConfig, inheritConfigRepoName },
'Compiled inheritConfigRepoName result.',
);
logger.debug(
`Checking for inherited config file ${config.inheritConfigFileName} in repo ${inheritConfigRepoName}.`,
);
let configFileRaw: string | null = null;
try {
configFileRaw = await platform.getRawFile(
config.inheritConfigFileName,
inheritConfigRepoName,
);
} catch (err) {
if (config.inheritConfigStrict) {
logger.debug({ err }, 'Error getting inherited config.');
throw new Error(CONFIG_INHERIT_NOT_FOUND);
}
logger.trace({ err }, `Error getting inherited config.`);
}
if (!configFileRaw) {
logger.debug(`No inherited config found in ${inheritConfigRepoName}.`);
return config;
}
const parseResult = parseFileConfig(
config.inheritConfigFileName,
configFileRaw,
);
if (!parseResult.success) {
logger.debug({ parseResult }, 'Error parsing inherited config.');
throw new Error(CONFIG_INHERIT_PARSE_ERROR);
}
const inheritedConfigSource = `Inherited config (\`${config.inheritConfigFileName}\` in \`${inheritConfigRepoName}\`)`;
const inheritedConfig = parseResult.parsedContents as RenovateConfig;
logger.debug({ config: inheritedConfig }, `Inherited config`);
const res = await validateConfig('inherit', inheritedConfig);
if (res.errors.length) {
logger.warn(
{ errors: res.errors },
'Found errors in inherited configuration.',
);
throwInheritedConfigValidationError(inheritedConfigSource, res.errors);
}
if (res.warnings.length) {
logger.warn(
{ warnings: res.warnings },
'Found warnings in inherited configuration.',
);
}
// set user config file name here
if (isNonEmptyArray(inheritedConfig.configFileNames)) {
logger.debug(
{ configFileNames: inheritedConfig.configFileNames },
'Updated the config filenames list',
);
setUserConfigFileNames(inheritedConfig.configFileNames);
delete config.configFileNames;
}
let decryptedConfig = await decryptConfig(inheritedConfig, config.repository);
let filteredConfig = removeGlobalConfig(decryptedConfig, true);
if (!dequal(decryptedConfig, filteredConfig)) {
logger.debug(
{ inheritedConfig: decryptedConfig, filteredConfig },
'Removed global config from inherited config.',
);
}
if (isNullOrUndefined(filteredConfig.extends)) {
filteredConfig = applySecretsAndVariablesToConfig({
config: filteredConfig,
secrets: coerceObject(config.secrets),
variables: coerceObject(config.variables),
});
applyHostRules(filteredConfig, inheritedHostRuleOptions());
filteredConfig = InheritConfig.set(filteredConfig);
return mergeChildConfig(config, filteredConfig);
}
logger.debug('Resolving presets found in inherited config');
const { config: resolvedConfig } = await resolveConfigPresets(
filteredConfig,
config,
config.ignorePresets,
);
logger.trace({ config: resolvedConfig }, 'Resolved inherited config');
const validationRes = await validateConfig('inherit', resolvedConfig);
if (validationRes.errors.length) {
logger.warn(
{ errors: validationRes.errors },
'Found errors in presets inside the inherited configuration.',
);
throwInheritedConfigValidationError(
inheritedConfigSource,
validationRes.errors,
);
}
if (validationRes.warnings.length) {
logger.warn(
{ warnings: validationRes.warnings },
'Found warnings in presets inside the inherited configuration.',
);
}
// decrypt again, as resolved presets could contain encrypted values
decryptedConfig = await decryptConfig(resolvedConfig, config.repository);
// remove global config options once again, as resolved presets could have added some
filteredConfig = removeGlobalConfig(decryptedConfig, true);
if (!dequal(decryptedConfig, filteredConfig)) {
logger.debug(
{ inheritedConfig: decryptedConfig, filteredConfig },
'Removed global config from inherited config presets.',
);
}
filteredConfig = applySecretsAndVariablesToConfig({
config: filteredConfig,
secrets: coerceObject(config.secrets),
variables: coerceObject(config.variables),
});
applyHostRules(filteredConfig, inheritedHostRuleOptions());
filteredConfig = InheritConfig.set(filteredConfig);
return mergeChildConfig(config, filteredConfig);
}