Files
renovate/lib/config-validator.ts
Jamie Tanna af055eba22 feat(config): allow self-hosted admins to bypass allowedHeaders (#45654)
In a similar way to how `allowedEnv` works, we should allow a
self-hosted admin to specify whichever headers they wish in their
`hostRules` - whether in their global config, a `repositories[]` entry,
or a preset a `repositories[]` entry extends.

Previously, this would require a self-hosted admin to add any header
they wished to set to `allowedHeaders`, which would also allow any of
their users' repositories to set that same header.

We can align this behaviour with `allowedEnv`, which makes sure that
the admin's own `headers` are always applied regardless of
`allowedHeaders`, while a repository's own `hostRules` - and those of
any preset it extends - remain constrained by it as before.

We make sure to use an explicit `[]` to make it clear when there are no
headers to allow, opposed to using something less clear like
`undefined`.

We do not currently allow an `extends` (without a `repositories[]`
entry) to bypass, and they'll be followed-up with in #45670.

Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
2026-09-29 10:37:15 +00:00

319 lines
11 KiB
JavaScript

#!/usr/bin/env node
import 'source-map-support/register.js';
import './punycode.cjs';
import { styleText } from 'node:util';
import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is';
import { Command, CommanderError } from 'commander';
import { dequal } from 'dequal';
import { diffLines } from 'diff';
import fs from 'fs-extra';
import { getConfigFileNames } from './config/app-strings.ts';
import { GlobalConfig } from './config/global.ts';
import { massageConfig } from './config/massage.ts';
import { migrateConfig } from './config/migration.ts';
import type { RenovateConfig } from './config/types.ts';
import { validateConfig } from './config/validation.ts';
import { pkg } from './expose.ts';
import { init, logger } from './logger/index.ts';
import { getEnv } from './util/env.ts';
import { add as addHostRule } from './util/host-rules.ts';
import { regEx, regexEngineStatus } from './util/regex.ts';
import { getConfig as getFileConfig } from './workers/global/config/parse/file.ts';
import { parseConfigs } from './workers/global/config/parse/index.ts';
import { getParsedContent } from './workers/global/config/parse/util.ts';
await init();
const { pathExists, readFile } = fs;
let returnVal = 0;
/**
* Make sure that we've resolved configuration from the different places that Renovate users would expect them to be specified
*
* This then allows a `configType=repo` config to i.e. be validated alongside a `config.js` or `env RENOVATE_ALLOWED_COMMANDS=...`
*
* Note that we intentionally don't fully initialize Renovate and its modules, as we're not fully running, and it would require a Platform to be configured
* */
async function partiallyGlobalInitialize(): Promise<void> {
// NOTE that this doesn't allow command-line arguments
const globalConfig = await parseConfigs(getEnv(), []);
GlobalConfig.set(globalConfig);
if (globalConfig.hostRules) {
// this is the self-hosted admin's own config, so its `headers` are exempt from `allowedHeaders` altogether - see `hostRules.add()`
for (const hostRule of globalConfig.hostRules) {
addHostRule(hostRule, { trusted: true });
}
}
}
async function validate(
configType: 'global' | 'repo',
desc: string,
config: RenovateConfig,
strict: boolean,
isPreset = false,
): Promise<void> {
if (config.hostRules) {
// a `global` config is the self-hosted administrator's own, so its `headers` are exempt from `allowedHeaders` altogether - see `hostRules.add()`; a `repo` config's `hostRules` are still constrained by this instance's `allowedHeaders`
for (const hostRule of config.hostRules) {
addHostRule(hostRule, { trusted: configType === 'global' });
}
}
const { isMigrated, migratedConfig } = migrateConfig(config);
if (isMigrated) {
logger.warn(
{
oldConfig: config,
newConfig: migratedConfig,
},
'Config migration necessary',
);
const changedObjects = diffLines(
JSON.stringify(config, null, 2),
JSON.stringify(migratedConfig, null, 2),
{ ignoreWhitespace: false, newlineIsToken: false },
);
const added = styleText('green', '+ ');
const removed = styleText('red', '- ');
const msg = changedObjects
.flatMap((part) => {
let linePrefix: string;
if (part.added) {
linePrefix = added;
} else if (part.removed) {
linePrefix = removed;
} else {
linePrefix = ' ';
}
return part.value
.split('\n')
.filter(isNonEmptyStringAndNotWhitespace)
.map((line) =>
line.replace(regEx(/^(?<ws> *)/), `${linePrefix}$<ws>`),
);
})
.join('\n');
// oxlint-disable-next-line renovate/logger-static-message -- the multi-line, colorized diff must be interpolated to stay readable; as a metadata field it is JSON-escaped onto one line
logger.warn(`Config migration diff:\n${msg}`);
if (strict) {
returnVal = 1;
}
}
const massagedConfig = massageConfig(migratedConfig);
const res = await validateConfig(configType, massagedConfig, isPreset);
if (res.errors.length) {
logger.error(
{ file: desc, errors: res.errors },
'Found errors in configuration',
);
returnVal = 1;
}
if (res.warnings.length) {
logger.warn(
{ file: desc, warnings: res.warnings },
'Found errors in configuration',
);
returnVal = 1;
}
}
interface PackageJson {
renovate?: RenovateConfig;
'renovate-config'?: Record<string, RenovateConfig>;
}
(async () => {
await partiallyGlobalInitialize();
const program = new Command('renovate-config-validator')
.summary('Validate Renovate configuration files')
.description(
`Validate your Renovate configuration (repo config, shared presets or global configuration) files\n` +
// oxlint-disable-next-line renovate/no-hardcoded-docs-url -- CLI help text, no config available
'If no [config-files...] are given, renovate-config-validator will look at the default config file locations (https://docs.renovatebot.com/configuration-options/)',
)
.addHelpText(
'after',
// oxlint-disable-next-line renovate/no-hardcoded-docs-url -- CLI help text, no config available
`
When specifying [config-files...], Renovate will treat them as global self-hosted configuration files. You can disable this behaviour with --no-global
Examples:
$ renovate-config-validator
$ renovate-config-validator --strict
$ renovate-config-validator first_config.json
$ renovate-config-validator --strict config.js
$ renovate-config-validator --no-global renovate.json5
$ env RENOVATE_CONFIG_FILE=obscure-name.json renovate-config-validator
Global configuration:
If you have specified global self-hosted configuration (https://docs.renovatebot.com/self-hosted-configuration/) in environment variables or in a \`config.js\`, this will be detected:
$ env RENOVATE_ALLOWED_ENV='["GO*"]' renovate-config-validator
# if passing the filename, make sure it's not validating as a global config
$ env RENOVATE_ALLOWED_ENV='["GO*"]' renovate-config-validator --no-global renovate.json`,
)
.argument('[config-files...]')
.version(pkg.version, '-v, --version')
.option(
'--strict',
'Fail command if any configuration warnings, errors, or a migration is needed',
)
.option(
'--no-global',
'When specifying [config-files], do not treat them as global self-hosted configuration file(s)',
true,
)
// allow us to manage the exit code
.exitOverride();
program.action(async (files, opts) => {
const strict = opts.strict ?? false;
let filesValidated = 0;
// without RE2, patterns are checked by RegExp, which accepts syntax RE2 rejects (e.g. lookahead)
if (regexEngineStatus.type === 'unavailable') {
logger.warn(
{ err: regexEngineStatus.err },
'RE2 not usable, falling back to RegExp: regex validation may be inaccurate',
);
} else if (regexEngineStatus.type === 'ignored') {
logger.debug(
'RE2 ignored via RENOVATE_X_IGNORE_RE2: regex validation may be inaccurate',
);
}
if (files.length) {
let isGlobalConfig = true;
if (opts.global === false) {
isGlobalConfig = false;
}
const configType = isGlobalConfig ? 'global' : 'repo';
for (const file of files) {
try {
if (!(await pathExists(file))) {
returnVal = 1;
logger.error({ file }, 'File does not exist');
break;
}
const parsedContent = await getParsedContent(file);
try {
logger.info(`Validating ${file} as ${configType} config`);
await validate(configType, file, parsedContent, strict);
} catch (err) {
logger.warn({ file, err }, 'File is not valid Renovate config');
returnVal = 1;
}
} catch (err) {
logger.warn({ file, err }, 'File could not be parsed');
returnVal = 1;
}
filesValidated++;
}
} else {
for (const file of getConfigFileNames().filter(
(name) => name !== 'package.json',
)) {
try {
if (!(await pathExists(file))) {
continue;
}
const parsedContent = await getParsedContent(file);
try {
logger.info(`Validating ${file}`);
await validate('repo', file, parsedContent, strict);
} catch (err) {
logger.warn({ file, err }, 'File is not valid Renovate config');
returnVal = 1;
}
} catch (err) {
logger.warn({ file, err }, 'File could not be parsed');
returnVal = 1;
}
filesValidated++;
}
try {
const pkgJson = JSON.parse(
await readFile('package.json', 'utf8'),
) as PackageJson;
if (pkgJson.renovate) {
logger.info(`Validating package.json > renovate`);
await validate(
'repo',
'package.json > renovate',
pkgJson.renovate,
strict,
);
filesValidated++;
}
if (pkgJson['renovate-config']) {
logger.info(`Validating package.json > renovate-config`);
for (const presetConfig of Object.values(
pkgJson['renovate-config'],
)) {
await validate(
'repo',
'package.json > renovate-config',
presetConfig,
strict,
true,
);
filesValidated++;
}
}
} catch {
// ignore
}
try {
const env = getEnv();
const fileConfig = await getFileConfig(env);
if (!dequal(fileConfig, {})) {
const file = env.RENOVATE_CONFIG_FILE ?? 'config.js';
logger.info(`Validating ${file}`);
try {
await validate('global', file, fileConfig, strict);
} catch (err) {
logger.error({ file, err }, 'File is not valid Renovate config');
returnVal = 1;
}
filesValidated++;
}
} catch {
// ignore
}
}
if (returnVal === 0 && filesValidated) {
logger.info(
`Config validated successfully against ${filesValidated} file(s)`,
);
} else if (!filesValidated) {
logger.warn(`No files to perform configuration validation against`);
}
// Use exitCode (not process.exit) so async log streams can flush
process.exitCode = returnVal;
});
await program.parseAsync();
})().catch((e) => {
// Commander throws an error at the end of Action execution i.e. as part of the `help` and `version` commands, and so we don't want to return an error code in this case
if (
e instanceof CommanderError &&
(e.code === 'commander.helpDisplayed' || e.code === 'commander.version')
) {
return;
}
// oxlint-disable-next-line no-console -- intentional: display critical error on CLI
console.error(e);
process.exit(99);
});