Files
codeql-action/src/config/db-config.ts
T
2025-11-09 10:26:36 +00:00

525 lines
17 KiB
TypeScript

import * as path from "path";
import * as yaml from "js-yaml";
import * as jsonschema from "jsonschema";
import * as semver from "semver";
import * as errorMessages from "../error-messages";
import {
RepositoryProperties,
RepositoryPropertyName,
} from "../feature-flags/properties";
import { Language } from "../languages";
import { Logger } from "../logging";
import { cloneObject, ConfigurationError, prettyPrintPack } from "../util";
export interface ExcludeQueryFilter {
exclude: Record<string, string[] | string>;
}
export interface IncludeQueryFilter {
include: Record<string, string[] | string>;
}
export type QueryFilter = ExcludeQueryFilter | IncludeQueryFilter;
export interface QuerySpec {
name?: string;
uses: string;
}
/**
* Format of the config file supplied by the user.
*/
export interface UserConfig {
name?: string;
"disable-default-queries"?: boolean;
queries?: QuerySpec[];
"paths-ignore"?: string[];
paths?: string[];
// If this is a multi-language analysis, then the packages must be split by
// language. If this is a single language analysis, then no split by
// language is necessary.
packs?: Record<string, string[]> | string[];
// Set of query filters to include and exclude extra queries based on
// codeql query suite `include` and `exclude` properties
"query-filters"?: QueryFilter[];
}
/**
* Represents additional configuration data from a source other than
* a configuration file.
*/
interface Augmentation<T> {
/** Whether or not the `input` combines with data in the base config. */
combines: boolean;
/** The additional input data. */
input?: T;
}
/**
* Describes how to augment the user config with inputs from the action.
*
* When running a CodeQL analysis, the user can supply a config file. When
* running a CodeQL analysis from a GitHub action, the user can supply a
* config file _and_ a set of inputs.
*
* The inputs from the action are used to augment the user config before
* passing the user config to the CodeQL CLI invocation.
*/
export interface AugmentationProperties {
/**
* Whether or not the queries input combines with the queries in the config.
*/
queriesInputCombines: boolean;
/**
* The queries input from the `with` block of the action declaration
*/
queriesInput?: QuerySpec[];
/**
* Whether or not the packs input combines with the packs in the config.
*/
packsInputCombines: boolean;
/**
* The packs input from the `with` block of the action declaration
*/
packsInput?: string[];
/**
* Extra queries from the corresponding repository property.
*/
repoPropertyQueries: Augmentation<QuerySpec[]>;
}
/**
* The default, empty augmentation properties. This is most useful
* for tests.
*/
export const defaultAugmentationProperties: AugmentationProperties = {
queriesInputCombines: false,
packsInputCombines: false,
packsInput: undefined,
queriesInput: undefined,
repoPropertyQueries: {
combines: false,
input: undefined,
},
};
/**
* The convention in this action is that an input value that is prefixed with a '+' will
* be combined with the corresponding value in the config file.
*
* Without a '+', an input value will override the corresponding value in the config file.
*
* @param inputValue The input value to process.
* @returns true if the input value should replace the corresponding value in the config file,
* false if it should be appended.
*/
function shouldCombine(inputValue?: string): boolean {
return !!inputValue?.trim().startsWith("+");
}
export type Packs = Partial<Record<Language, string[]>>;
export interface Pack {
name: string;
version?: string;
path?: string;
}
/**
* Pack names must be in the form of `scope/name`, with only alpha-numeric characters,
* and `-` allowed as long as not the first or last char.
**/
const PACK_IDENTIFIER_PATTERN = (function () {
const alphaNumeric = "[a-z0-9]";
const alphaNumericDash = "[a-z0-9-]";
const component = `${alphaNumeric}(${alphaNumericDash}*${alphaNumeric})?`;
return new RegExp(`^${component}/${component}$`);
})();
/**
* Validates that this package specification is syntactically correct.
* It may not point to any real package, but after this function returns
* without throwing, we are guaranteed that the package specification
* is roughly correct.
*
* The CLI itself will do a more thorough validation of the package
* specification.
*
* A package specification looks like this:
*
* `scope/name@version:path`
*
* Version and path are optional.
*
* @param packStr the package specification to verify.
*/
export function parsePacksSpecification(packStr: string): Pack {
if (typeof packStr !== "string") {
throw new ConfigurationError(errorMessages.getPacksStrInvalid(packStr));
}
packStr = packStr.trim();
const atIndex = packStr.indexOf("@");
const colonIndex = packStr.indexOf(":", atIndex);
const packStart = 0;
const versionStart = atIndex + 1 || undefined;
const pathStart = colonIndex + 1 || undefined;
const packEnd = Math.min(
atIndex > 0 ? atIndex : Infinity,
colonIndex > 0 ? colonIndex : Infinity,
packStr.length,
);
const versionEnd = versionStart
? Math.min(colonIndex > 0 ? colonIndex : Infinity, packStr.length)
: undefined;
const pathEnd = pathStart ? packStr.length : undefined;
const packName = packStr.slice(packStart, packEnd).trim();
const version = versionStart
? packStr.slice(versionStart, versionEnd).trim()
: undefined;
const packPath = pathStart
? packStr.slice(pathStart, pathEnd).trim()
: undefined;
if (!PACK_IDENTIFIER_PATTERN.test(packName)) {
throw new ConfigurationError(errorMessages.getPacksStrInvalid(packStr));
}
if (version) {
try {
new semver.Range(version);
} catch {
// The range string is invalid. OK to ignore the caught error
throw new ConfigurationError(errorMessages.getPacksStrInvalid(packStr));
}
}
if (
packPath &&
(path.isAbsolute(packPath) ||
// Permit using "/" instead of "\" on Windows
// Use `x.split(y).join(z)` as a polyfill for `x.replaceAll(y, z)` since
// if we used a regex we'd need to escape the path separator on Windows
// which seems more awkward.
path.normalize(packPath).split(path.sep).join("/") !==
packPath.split(path.sep).join("/"))
) {
throw new ConfigurationError(errorMessages.getPacksStrInvalid(packStr));
}
if (!packPath && pathStart) {
// 0 length path
throw new ConfigurationError(errorMessages.getPacksStrInvalid(packStr));
}
return {
name: packName,
version,
path: packPath,
};
}
export function validatePackSpecification(pack: string) {
return prettyPrintPack(parsePacksSpecification(pack));
}
// Exported for testing
export function parsePacksFromInput(
rawPacksInput: string | undefined,
languages: Language[],
packsInputCombines: boolean,
): Packs | undefined {
if (!rawPacksInput?.trim()) {
return undefined;
}
if (languages.length > 1) {
throw new ConfigurationError(
"Cannot specify a 'packs' input in a multi-language analysis. Use a codeql-config.yml file instead and specify packs by language.",
);
} else if (languages.length === 0) {
throw new ConfigurationError(
"No languages specified. Cannot process the packs input.",
);
}
rawPacksInput = rawPacksInput.trim();
if (packsInputCombines) {
rawPacksInput = rawPacksInput.trim().substring(1).trim();
if (!rawPacksInput) {
throw new ConfigurationError(
errorMessages.getConfigFilePropertyError(
undefined,
"packs",
"A '+' was used in the 'packs' input to specify that you wished to add some packs to your CodeQL analysis. However, no packs were specified. Please either remove the '+' or specify some packs.",
),
);
}
}
return {
[languages[0]]: rawPacksInput.split(",").reduce((packs, pack) => {
packs.push(validatePackSpecification(pack));
return packs;
}, [] as string[]),
};
}
/**
* Calculates how the codeql config file needs to be augmented before passing
* it to the CLI. The reason this is necessary is the codeql-action can be called
* with extra inputs from the workflow. These inputs are not part of the config
* and the CLI does not know about these inputs so we need to inject them into
* the config file sent to the CLI.
*
* @param rawPacksInput The packs input from the action configuration.
* @param rawQueriesInput The queries input from the action configuration.
* @param repositoryProperties The dictionary of repository properties.
* @param languages The languages that the config file is for. If the packs input
* is non-empty, then there must be exactly one language. Otherwise, an
* error is thrown.
*
* @returns The properties that need to be augmented in the config file.
*
* @throws An error if the packs input is non-empty and the languages input does
* not have exactly one language.
*/
export async function calculateAugmentation(
rawPacksInput: string | undefined,
rawQueriesInput: string | undefined,
repositoryProperties: RepositoryProperties,
languages: Language[],
): Promise<AugmentationProperties> {
const packsInputCombines = shouldCombine(rawPacksInput);
const packsInput = parsePacksFromInput(
rawPacksInput,
languages,
packsInputCombines,
);
const queriesInputCombines = shouldCombine(rawQueriesInput);
const queriesInput = parseQueriesFromInput(
rawQueriesInput,
queriesInputCombines,
);
const repoExtraQueries =
repositoryProperties[RepositoryPropertyName.EXTRA_QUERIES];
const repoExtraQueriesCombines = shouldCombine(repoExtraQueries);
const repoPropertyQueries = {
combines: repoExtraQueriesCombines,
input: parseQueriesFromInput(
repoExtraQueries,
repoExtraQueriesCombines,
new ConfigurationError(
errorMessages.getRepoPropertyError(
RepositoryPropertyName.EXTRA_QUERIES,
errorMessages.getEmptyCombinesError(),
),
),
),
};
return {
packsInputCombines,
packsInput: packsInput?.[languages[0]],
queriesInput,
queriesInputCombines,
repoPropertyQueries,
};
}
function parseQueriesFromInput(
rawQueriesInput: string | undefined,
queriesInputCombines: boolean,
errorToThrow?: ConfigurationError,
) {
if (!rawQueriesInput) {
return undefined;
}
const trimmedInput = queriesInputCombines
? rawQueriesInput.trim().slice(1).trim()
: (rawQueriesInput?.trim() ?? "");
if (queriesInputCombines && trimmedInput.length === 0) {
if (errorToThrow) {
throw errorToThrow;
}
throw new ConfigurationError(
errorMessages.getConfigFilePropertyError(
undefined,
"queries",
"A '+' was used in the 'queries' input to specify that you wished to add some packs to your CodeQL analysis. However, no packs were specified. Please either remove the '+' or specify some packs.",
),
);
}
return trimmedInput.split(",").map((query) => ({ uses: query.trim() }));
}
/**
* Combines queries from various configuration sources.
*
* @param logger The logger to use.
* @param config The loaded configuration file (either `config-file` or `config` input).
* @param augmentationProperties Additional configuration data from other sources.
* @returns Returns `augmentedConfig` with `queries` set to the computed array of queries.
*/
function combineQueries(
logger: Logger,
config: UserConfig,
augmentationProperties: AugmentationProperties,
): QuerySpec[] {
const result: QuerySpec[] = [];
// Query settings obtained from the repository properties have the highest precedence.
if (augmentationProperties.repoPropertyQueries?.input) {
logger.info(
`Found query configuration in the repository properties (${RepositoryPropertyName.EXTRA_QUERIES}): ` +
`${augmentationProperties.repoPropertyQueries.input.map((q) => q.uses).join(", ")}`,
);
// If there are queries configured as a repository property, these may be organisational
// settings. If they don't allow combining with other query configurations, return just the
// ones configured in the repository properties.
if (!augmentationProperties.repoPropertyQueries.combines) {
logger.info(
`The queries configured in the repository properties don't allow combining with other query settings. ` +
`Any queries configured elsewhere will be ignored.`,
);
return augmentationProperties.repoPropertyQueries.input;
} else {
// Otherwise, add them to the query array and continue.
result.push(...augmentationProperties.repoPropertyQueries.input);
}
}
// If there is a `queries` input to the Action, it has the next highest precedence.
if (augmentationProperties.queriesInput) {
// If there is a `queries` input and `queriesInputCombines` is `false`, then we don't
// combine it with the queries configured in the configuration file (if any). That is the
// original behaviour of this property. However, we DO combine it with any queries that
// we obtained from the repository properties, since that may be enforced by the organisation.
if (!augmentationProperties.queriesInputCombines) {
return result.concat(augmentationProperties.queriesInput);
} else {
// If they combine, add them to the query array and continue.
result.push(...augmentationProperties.queriesInput);
}
}
// If we get to this point, we either don't have any extra configuration inputs or all of them
// allow themselves to be combined with the settings from the configuration file.
if (config.queries) {
result.push(...config.queries);
}
return result;
}
export function generateCodeScanningConfig(
logger: Logger,
originalUserInput: UserConfig,
augmentationProperties: AugmentationProperties,
): UserConfig {
// make a copy so we can modify it
const augmentedConfig = cloneObject(originalUserInput);
// Inject the queries from the input
augmentedConfig.queries = combineQueries(
logger,
augmentedConfig,
augmentationProperties,
);
logger.debug(
`Combined queries: ${augmentedConfig.queries?.map((q) => q.uses).join(",")}`,
);
if (augmentedConfig.queries?.length === 0) {
delete augmentedConfig.queries;
}
// Inject the packs from the input
if (augmentationProperties.packsInput) {
if (augmentationProperties.packsInputCombines) {
// At this point, we already know that this is a single-language analysis
if (Array.isArray(augmentedConfig.packs)) {
augmentedConfig.packs = (augmentedConfig.packs || []).concat(
augmentationProperties.packsInput,
);
} else if (!augmentedConfig.packs) {
augmentedConfig.packs = augmentationProperties.packsInput;
} else {
// At this point, we know there is only one language.
// If there were more than one language, an error would already have been thrown.
const language = Object.keys(augmentedConfig.packs)[0];
augmentedConfig.packs[language] = augmentedConfig.packs[
language
].concat(augmentationProperties.packsInput);
}
} else {
augmentedConfig.packs = augmentationProperties.packsInput;
}
}
if (Array.isArray(augmentedConfig.packs) && !augmentedConfig.packs.length) {
delete augmentedConfig.packs;
}
return augmentedConfig;
}
/**
* Attempts to parse `contents` into a `UserConfig` value.
*
* @param logger The logger to use.
* @param pathInput The path to the file where `contents` was obtained from, for use in error messages.
* @param contents The string contents of a YAML file to try and parse as a `UserConfig`.
* @param validateConfig Whether to validate the configuration file against the schema.
* @returns The `UserConfig` corresponding to `contents`, if parsing was successful.
* @throws A `ConfigurationError` if parsing failed.
*/
export function parseUserConfig(
logger: Logger,
pathInput: string,
contents: string,
validateConfig: boolean,
): UserConfig {
try {
const schema =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("../../src/db-config-schema.json") as jsonschema.Schema;
const doc = yaml.load(contents);
if (validateConfig) {
const result = new jsonschema.Validator().validate(doc, schema);
if (result.errors.length > 0) {
for (const error of result.errors) {
logger.error(error.stack);
}
throw new ConfigurationError(
errorMessages.getInvalidConfigFileMessage(
pathInput,
result.errors.map((e) => e.stack),
),
);
}
}
return doc as UserConfig;
} catch (error) {
if (error instanceof yaml.YAMLException) {
throw new ConfigurationError(
errorMessages.getConfigFileParseErrorMessage(pathInput, error.message),
);
}
throw error;
}
}