mirror of
https://github.com/github/codeql-action.git
synced 2026-04-29 02:18:47 +00:00
525 lines
17 KiB
TypeScript
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;
|
|
}
|
|
}
|