Permalink
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
codeql-action/src/config-utils.ts
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Sam Partington
Allow "additive" queries in workflow by prefixing with "+"
See discussion on https://github.com/github/code-scanning/issues/1446
794 lines (687 sloc)
27.2 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as core from '@actions/core'; | |
import * as fs from 'fs'; | |
import * as yaml from 'js-yaml'; | |
import * as path from 'path'; | |
import * as api from './api-client'; | |
import { CodeQL, ResolveQueriesOutput } from './codeql'; | |
import * as externalQueries from "./external-queries"; | |
import { Language, parseLanguage } from "./languages"; | |
import * as util from './util'; | |
// Property names from the user-supplied config file. | |
const NAME_PROPERTY = 'name'; | |
const DISABLE_DEFAULT_QUERIES_PROPERTY = 'disable-default-queries'; | |
const QUERIES_PROPERTY = 'queries'; | |
const QUERIES_USES_PROPERTY = 'uses'; | |
const PATHS_IGNORE_PROPERTY = 'paths-ignore'; | |
const PATHS_PROPERTY = 'paths'; | |
/** | |
* Format of the config file supplied by the user. | |
*/ | |
export interface UserConfig { | |
name?: string; | |
'disable-default-queries'?: boolean; | |
queries?: { | |
name?: string; | |
uses: string; | |
}[]; | |
'paths-ignore'?: string[]; | |
paths?: string[]; | |
} | |
/** | |
* Format of the parsed config file. | |
*/ | |
export interface Config { | |
/** | |
* Set of languages to run analysis for. | |
*/ | |
languages: Language[]; | |
/** | |
* Map from language to query files. | |
* Will only contain .ql files and not other kinds of files, | |
* and all file paths will be absolute. | |
*/ | |
queries: { [language: string]: string[] }; | |
/** | |
* List of paths to ignore from analysis. | |
*/ | |
pathsIgnore: string[]; | |
/** | |
* List of paths to include in analysis. | |
*/ | |
paths: string[]; | |
/** | |
* A unaltered copy of the original user input. | |
* Mainly intended to be used for status reporting. | |
* If any field is useful for the actual processing | |
* of the action then consider pulling it out to a | |
* top-level field above. | |
*/ | |
originalUserInput: UserConfig; | |
/** | |
* Directory to use for temporary files that should be | |
* deleted at the end of the job. | |
*/ | |
tempDir: string; | |
/** | |
* Directory to use for the tool cache. | |
* This may be persisted between jobs but this is not guaranteed. | |
*/ | |
toolCacheDir: string; | |
/** | |
* Path of the CodeQL executable. | |
*/ | |
codeQLCmd: string; | |
} | |
/** | |
* A list of queries from https://github.com/github/codeql that | |
* we don't want to run. Disabling them here is a quicker alternative to | |
* disabling them in the code scanning query suites. Queries should also | |
* be disabled in the suites, and removed from this list here once the | |
* bundle is updated to make those suite changes live. | |
* | |
* Format is a map from language to an array of path suffixes of .ql files. | |
*/ | |
const DISABLED_BUILTIN_QUERIES: {[language: string]: string[]} = { | |
'csharp': [ | |
'ql/src/Security Features/CWE-937/VulnerablePackage.ql', | |
'ql/src/Security Features/CWE-451/MissingXFrameOptions.ql', | |
] | |
}; | |
function queryIsDisabled(language, query): boolean { | |
return (DISABLED_BUILTIN_QUERIES[language] || []) | |
.some(disabledQuery => query.endsWith(disabledQuery)); | |
} | |
/** | |
* Asserts that the noDeclaredLanguage and multipleDeclaredLanguages fields are | |
* both empty and errors if they are not. | |
*/ | |
function validateQueries(resolvedQueries: ResolveQueriesOutput) { | |
const noDeclaredLanguage = resolvedQueries.noDeclaredLanguage; | |
const noDeclaredLanguageQueries = Object.keys(noDeclaredLanguage); | |
if (noDeclaredLanguageQueries.length !== 0) { | |
throw new Error('The following queries do not declare a language. ' + | |
'Their qlpack.yml files are either missing or is invalid.\n' + | |
noDeclaredLanguageQueries.join('\n')); | |
} | |
const multipleDeclaredLanguages = resolvedQueries.multipleDeclaredLanguages; | |
const multipleDeclaredLanguagesQueries = Object.keys(multipleDeclaredLanguages); | |
if (multipleDeclaredLanguagesQueries.length !== 0) { | |
throw new Error('The following queries declare multiple languages. ' + | |
'Their qlpack.yml files are either missing or is invalid.\n' + | |
multipleDeclaredLanguagesQueries.join('\n')); | |
} | |
} | |
/** | |
* Run 'codeql resolve queries' and add the results to resultMap | |
*/ | |
async function runResolveQueries( | |
codeQL: CodeQL, | |
resultMap: { [language: string]: string[] }, | |
toResolve: string[], | |
extraSearchPath: string | undefined, | |
errorOnInvalidQueries: boolean) { | |
const resolvedQueries = await codeQL.resolveQueries(toResolve, extraSearchPath); | |
for (const [language, queries] of Object.entries(resolvedQueries.byLanguage)) { | |
if (resultMap[language] === undefined) { | |
resultMap[language] = []; | |
} | |
resultMap[language].push(...Object.keys(queries).filter(q => !queryIsDisabled(language, q))); | |
} | |
if (errorOnInvalidQueries) { | |
validateQueries(resolvedQueries); | |
} | |
} | |
/** | |
* Get the set of queries included by default. | |
*/ | |
async function addDefaultQueries(codeQL: CodeQL, languages: string[], resultMap: { [language: string]: string[] }) { | |
const suites = languages.map(l => l + '-code-scanning.qls'); | |
await runResolveQueries(codeQL, resultMap, suites, undefined, false); | |
} | |
// The set of acceptable values for built-in suites from the codeql bundle | |
const builtinSuites = ['security-extended', 'security-and-quality'] as const; | |
/** | |
* Determine the set of queries associated with suiteName's suites and add them to resultMap. | |
* Throws an error if suiteName is not a valid builtin suite. | |
*/ | |
async function addBuiltinSuiteQueries( | |
languages: string[], | |
codeQL: CodeQL, | |
resultMap: { [language: string]: string[] }, | |
suiteName: string, | |
configFile?: string) { | |
const suite = builtinSuites.find((suite) => suite === suiteName); | |
if (!suite) { | |
throw new Error(getQueryUsesInvalid(configFile, suiteName)); | |
} | |
const suites = languages.map(l => l + '-' + suiteName + '.qls'); | |
await runResolveQueries(codeQL, resultMap, suites, undefined, false); | |
} | |
/** | |
* Retrieve the set of queries at localQueryPath and add them to resultMap. | |
*/ | |
async function addLocalQueries( | |
codeQL: CodeQL, | |
resultMap: { [language: string]: string[] }, | |
localQueryPath: string, | |
configFile?: string) { | |
// Resolve the local path against the workspace so that when this is | |
// passed to codeql it resolves to exactly the path we expect it to resolve to. | |
const workspacePath = fs.realpathSync(util.getRequiredEnvParam('GITHUB_WORKSPACE')); | |
let absoluteQueryPath = path.join(workspacePath, localQueryPath); | |
// Check the file exists | |
if (!fs.existsSync(absoluteQueryPath)) { | |
throw new Error(getLocalPathDoesNotExist(configFile, localQueryPath)); | |
} | |
// Call this after checking file exists, because it'll fail if file doesn't exist | |
absoluteQueryPath = fs.realpathSync(absoluteQueryPath); | |
// Check the local path doesn't jump outside the repo using '..' or symlinks | |
if (!(absoluteQueryPath + path.sep).startsWith(workspacePath + path.sep)) { | |
throw new Error(getLocalPathOutsideOfRepository(configFile, localQueryPath)); | |
} | |
// Get the root of the current repo to use when resolving query dependencies | |
const rootOfRepo = util.getRequiredEnvParam('GITHUB_WORKSPACE'); | |
await runResolveQueries(codeQL, resultMap, [absoluteQueryPath], rootOfRepo, true); | |
} | |
/** | |
* Retrieve the set of queries at the referenced remote repo and add them to resultMap. | |
*/ | |
async function addRemoteQueries( | |
codeQL: CodeQL, | |
resultMap: { [language: string]: string[] }, | |
queryUses: string, | |
tempDir: string, | |
configFile?: string) { | |
let tok = queryUses.split('@'); | |
if (tok.length !== 2) { | |
throw new Error(getQueryUsesInvalid(configFile, queryUses)); | |
} | |
const ref = tok[1]; | |
tok = tok[0].split('/'); | |
// The first token is the owner | |
// The second token is the repo | |
// The rest is a path, if there is more than one token combine them to form the full path | |
if (tok.length < 2) { | |
throw new Error(getQueryUsesInvalid(configFile, queryUses)); | |
} | |
// Check none of the parts of the repository name are empty | |
if (tok[0].trim() === '' || tok[1].trim() === '') { | |
throw new Error(getQueryUsesInvalid(configFile, queryUses)); | |
} | |
const nwo = tok[0] + '/' + tok[1]; | |
// Checkout the external repository | |
const rootOfRepo = await externalQueries.checkoutExternalRepository(nwo, ref, tempDir); | |
const queryPath = tok.length > 2 | |
? path.join(rootOfRepo, tok.slice(2).join('/')) | |
: rootOfRepo; | |
await runResolveQueries(codeQL, resultMap, [queryPath], rootOfRepo, true); | |
} | |
/** | |
* Parse a query 'uses' field to a discrete set of query files and update resultMap. | |
* | |
* The logic for parsing the string is based on what actions does for | |
* parsing the 'uses' actions in the workflow file. So it can handle | |
* local paths starting with './', or references to remote repos, or | |
* a finite set of hardcoded terms for builtin suites. | |
*/ | |
async function parseQueryUses( | |
languages: string[], | |
codeQL: CodeQL, | |
resultMap: { [language: string]: string[] }, | |
queryUses: string, | |
tempDir: string, | |
configFile?: string) { | |
queryUses = queryUses.trim(); | |
if (queryUses === "") { | |
throw new Error(getQueryUsesInvalid(configFile)); | |
} | |
// Check for the local path case before we start trying to parse the repository name | |
if (queryUses.startsWith("./")) { | |
await addLocalQueries(codeQL, resultMap, queryUses.slice(2), configFile); | |
return; | |
} | |
// Check for one of the builtin suites | |
if (queryUses.indexOf('/') === -1 && queryUses.indexOf('@') === -1) { | |
await addBuiltinSuiteQueries(languages, codeQL, resultMap, queryUses, configFile); | |
return; | |
} | |
// Otherwise, must be a reference to another repo | |
await addRemoteQueries(codeQL, resultMap, queryUses, tempDir, configFile); | |
} | |
// Regex validating stars in paths or paths-ignore entries. | |
// The intention is to only allow ** to appear when immediately | |
// preceded and followed by a slash. | |
const pathStarsRegex = /.*(?:\*\*[^/].*|\*\*$|[^/]\*\*.*)/; | |
// Characters that are supported by filters in workflows, but not by us. | |
// See https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet | |
const filterPatternCharactersRegex = /.*[\?\+\[\]!].*/; | |
// Checks that a paths of paths-ignore entry is valid, possibly modifying it | |
// to make it valid, or if not possible then throws an error. | |
export function validateAndSanitisePath( | |
originalPath: string, | |
propertyName: string, | |
configFile: string): string { | |
// Take a copy so we don't modify the original path, so we can still construct error messages | |
let path = originalPath; | |
// All paths are relative to the src root, so strip off leading slashes. | |
while (path.charAt(0) === '/') { | |
path = path.substring(1); | |
} | |
// Trailing ** are redundant, so strip them off | |
if (path.endsWith('/**')) { | |
path = path.substring(0, path.length - 2); | |
} | |
// An empty path is not allowed as it's meaningless | |
if (path === '') { | |
throw new Error(getConfigFilePropertyError( | |
configFile, | |
propertyName, | |
'"' + originalPath + '" is not an invalid path. ' + | |
'It is not necessary to include it, and it is not allowed to exclude it.')); | |
} | |
// Check for illegal uses of ** | |
if (path.match(pathStarsRegex)) { | |
throw new Error(getConfigFilePropertyError( | |
configFile, | |
propertyName, | |
'"' + originalPath + '" contains an invalid "**" wildcard. ' + | |
'They must be immediately preceeded and followed by a slash as in "/**/", or come at the start or end.')); | |
} | |
// Check for other regex characters that we don't support. | |
// Output a warning so the user knows, but otherwise continue normally. | |
if (path.match(filterPatternCharactersRegex)) { | |
core.warning(getConfigFilePropertyError( | |
configFile, | |
propertyName, | |
'"' + originalPath + '" contains an unsupported character. ' + | |
'The filter pattern characters ?, +, [, ], ! are not supported and will be matched literally.')); | |
} | |
// Ban any uses of backslash for now. | |
// This may not play nicely with project layouts. | |
// This restriction can be lifted later if we determine they are ok. | |
if (path.indexOf('\\') !== -1) { | |
throw new Error(getConfigFilePropertyError( | |
configFile, | |
propertyName, | |
'"' + originalPath + '" contains an "\\" character. These are not allowed in filters. ' + | |
'If running on windows we recommend using "/" instead for path filters.')); | |
} | |
return path; | |
} | |
// An undefined configFile in some of these functions indicates that | |
// the property was in a workflow file, not a config file | |
export function getNameInvalid(configFile: string): string { | |
return getConfigFilePropertyError(configFile, NAME_PROPERTY, 'must be a non-empty string'); | |
} | |
export function getDisableDefaultQueriesInvalid(configFile: string): string { | |
return getConfigFilePropertyError(configFile, DISABLE_DEFAULT_QUERIES_PROPERTY, 'must be a boolean'); | |
} | |
export function getQueriesInvalid(configFile: string): string { | |
return getConfigFilePropertyError(configFile, QUERIES_PROPERTY, 'must be an array'); | |
} | |
export function getQueryUsesInvalid(configFile: string | undefined, queryUses?: string): string { | |
return getConfigFilePropertyError( | |
configFile, | |
QUERIES_PROPERTY + '.' + QUERIES_USES_PROPERTY, | |
'must be a built-in suite (' + builtinSuites.join(' or ') + | |
'), a relative path, or be of the form "owner/repo[/path]@ref"' + | |
(queryUses !== undefined ? '\n Found: ' + queryUses : '')); | |
} | |
export function getPathsIgnoreInvalid(configFile: string): string { | |
return getConfigFilePropertyError(configFile, PATHS_IGNORE_PROPERTY, 'must be an array of non-empty strings'); | |
} | |
export function getPathsInvalid(configFile: string): string { | |
return getConfigFilePropertyError(configFile, PATHS_PROPERTY, 'must be an array of non-empty strings'); | |
} | |
export function getLocalPathOutsideOfRepository(configFile: string | undefined, localPath: string): string { | |
return getConfigFilePropertyError( | |
configFile, | |
QUERIES_PROPERTY + '.' + QUERIES_USES_PROPERTY, | |
'is invalid as the local path "' + localPath + '" is outside of the repository'); | |
} | |
export function getLocalPathDoesNotExist(configFile: string | undefined, localPath: string): string { | |
return getConfigFilePropertyError( | |
configFile, | |
QUERIES_PROPERTY + '.' + QUERIES_USES_PROPERTY, | |
'is invalid as the local path "' + localPath + '" does not exist in the repository'); | |
} | |
export function getConfigFileOutsideWorkspaceErrorMessage(configFile: string): string { | |
return 'The configuration file "' + configFile + '" is outside of the workspace'; | |
} | |
export function getConfigFileDoesNotExistErrorMessage(configFile: string): string { | |
return 'The configuration file "' + configFile + '" does not exist'; | |
} | |
export function getConfigFileRepoFormatInvalidMessage(configFile: string): string { | |
let error = 'The configuration file "' + configFile + '" is not a supported remote file reference.'; | |
error += ' Expected format <owner>/<repository>/<file-path>@<ref>'; | |
return error; | |
} | |
export function getConfigFileFormatInvalidMessage(configFile: string): string { | |
return 'The configuration file "' + configFile + '" could not be read'; | |
} | |
export function getConfigFileDirectoryGivenMessage(configFile: string): string { | |
return 'The configuration file "' + configFile + '" looks like a directory, not a file'; | |
} | |
function getConfigFilePropertyError(configFile: string | undefined, property: string, error: string): string { | |
if (configFile === undefined) { | |
return 'The workflow property "' + property + '" is invalid: ' + error; | |
} else { | |
return 'The configuration file "' + configFile + '" is invalid: property "' + property + '" ' + error; | |
} | |
} | |
export function getNoLanguagesError(): string { | |
return "Did not detect any languages to analyze. " + | |
"Please update input in workflow or check that GitHub detects the correct languages in your repository."; | |
} | |
export function getUnknownLanguagesError(languages: string[]): string { | |
return "Did not recognise the following languages: " + languages.join(', '); | |
} | |
/** | |
* Gets the set of languages in the current repository | |
*/ | |
async function getLanguagesInRepo(): Promise<Language[]> { | |
let repo_nwo = process.env['GITHUB_REPOSITORY']?.split("/"); | |
if (repo_nwo) { | |
let owner = repo_nwo[0]; | |
let repo = repo_nwo[1]; | |
core.debug(`GitHub repo ${owner} ${repo}`); | |
const response = await api.getActionsApiClient(true).repos.listLanguages({ | |
owner, | |
repo | |
}); | |
core.debug("Languages API response: " + JSON.stringify(response)); | |
// The GitHub API is going to return languages in order of popularity, | |
// When we pick a language to autobuild we want to pick the most popular traced language | |
// Since sets in javascript maintain insertion order, using a set here and then splatting it | |
// into an array gives us an array of languages ordered by popularity | |
let languages: Set<Language> = new Set(); | |
for (let lang of Object.keys(response.data)) { | |
let parsedLang = parseLanguage(lang); | |
if (parsedLang !== undefined) { | |
languages.add(parsedLang); | |
} | |
} | |
return [...languages]; | |
} else { | |
return []; | |
} | |
} | |
/** | |
* Get the languages to analyse. | |
* | |
* The result is obtained from the action input parameter 'languages' if that | |
* has been set, otherwise it is deduced as all languages in the repo that | |
* can be analysed. | |
* | |
* If no languages could be detected from either the workflow or the repository | |
* then throw an error. | |
*/ | |
async function getLanguages(): Promise<Language[]> { | |
// Obtain from action input 'languages' if set | |
let languages = core.getInput('languages', { required: false }) | |
.split(',') | |
.map(x => x.trim()) | |
.filter(x => x.length > 0); | |
core.info("Languages from configuration: " + JSON.stringify(languages)); | |
if (languages.length === 0) { | |
// Obtain languages as all languages in the repo that can be analysed | |
languages = await getLanguagesInRepo(); | |
core.info("Automatically detected languages: " + JSON.stringify(languages)); | |
} | |
// If the languages parameter was not given and no languages were | |
// detected then fail here as this is a workflow configuration error. | |
if (languages.length === 0) { | |
throw new Error(getNoLanguagesError()); | |
} | |
// Make sure they are supported | |
const parsedLanguages: Language[] = []; | |
const unknownLanguages: string[] = []; | |
for (let language of languages) { | |
const parsedLanguage = parseLanguage(language); | |
if (parsedLanguage === undefined) { | |
unknownLanguages.push(language); | |
} else if (parsedLanguages.indexOf(parsedLanguage) === -1) { | |
parsedLanguages.push(parsedLanguage); | |
} | |
} | |
if (unknownLanguages.length > 0) { | |
throw new Error(getUnknownLanguagesError(unknownLanguages)); | |
} | |
return parsedLanguages; | |
} | |
async function addQueriesFromWorkflowIfRequired( | |
codeQL: CodeQL, | |
languages: string[], | |
resultMap: { [language: string]: string[] }, | |
tempDir: string | |
) { | |
let queryUses = core.getInput('queries'); | |
if (queryUses) { | |
queryUses = queryUses.trim(); | |
queryUses = queryUses.replace(/^\+/, ''); // "+" means "don't override config file" - see shouldAddConfigFileQueries | |
for (const query of queryUses.split(',')) { | |
await parseQueryUses(languages, codeQL, resultMap, query, tempDir); | |
} | |
} | |
} | |
// Returns true if either no queries were provided in the workflow. | |
// or if the queries in the workflow were provided in "additive" mode, | |
// indicating that they shouldn't override the config queries but | |
// should instead be added in addition | |
function shouldAddConfigFileQueries(): boolean { | |
const queryUses = core.getInput('queries'); | |
if (queryUses) { | |
return queryUses.trimStart().substr(0, 1) === '+'; | |
} | |
return true; | |
} | |
/** | |
* Get the default config for when the user has not supplied one. | |
*/ | |
export async function getDefaultConfig(tempDir: string, toolCacheDir: string, codeQL: CodeQL): Promise<Config> { | |
const languages = await getLanguages(); | |
const queries = {}; | |
await addDefaultQueries(codeQL, languages, queries); | |
await addQueriesFromWorkflowIfRequired(codeQL, languages, queries, tempDir); | |
return { | |
languages: languages, | |
queries: queries, | |
pathsIgnore: [], | |
paths: [], | |
originalUserInput: {}, | |
tempDir, | |
toolCacheDir, | |
codeQLCmd: codeQL.getPath(), | |
}; | |
} | |
/** | |
* Load the config from the given file. | |
*/ | |
async function loadConfig(configFile: string, tempDir: string, toolCacheDir: string, codeQL: CodeQL): Promise<Config> { | |
let parsedYAML: UserConfig; | |
if (isLocal(configFile)) { | |
// Treat the config file as relative to the workspace | |
const workspacePath = util.getRequiredEnvParam('GITHUB_WORKSPACE'); | |
configFile = path.resolve(workspacePath, configFile); | |
parsedYAML = getLocalConfig(configFile, workspacePath); | |
} else { | |
parsedYAML = await getRemoteConfig(configFile); | |
} | |
// Validate that the 'name' property is syntactically correct, | |
// even though we don't use the value yet. | |
if (NAME_PROPERTY in parsedYAML) { | |
if (typeof parsedYAML[NAME_PROPERTY] !== "string") { | |
throw new Error(getNameInvalid(configFile)); | |
} | |
if (parsedYAML[NAME_PROPERTY]!.length === 0) { | |
throw new Error(getNameInvalid(configFile)); | |
} | |
} | |
const languages = await getLanguages(); | |
const queries = {}; | |
const pathsIgnore: string[] = []; | |
const paths: string[] = []; | |
let disableDefaultQueries = false; | |
if (DISABLE_DEFAULT_QUERIES_PROPERTY in parsedYAML) { | |
if (typeof parsedYAML[DISABLE_DEFAULT_QUERIES_PROPERTY] !== "boolean") { | |
throw new Error(getDisableDefaultQueriesInvalid(configFile)); | |
} | |
disableDefaultQueries = parsedYAML[DISABLE_DEFAULT_QUERIES_PROPERTY]!; | |
} | |
if (!disableDefaultQueries) { | |
await addDefaultQueries(codeQL, languages, queries); | |
} | |
// If queries were provided using `with` in the action configuration, | |
// they should take precedence over the queries in the config file | |
// unless they're prefixed with "+", in which case they supplement those | |
// in the config file. | |
await addQueriesFromWorkflowIfRequired(codeQL, languages, queries, tempDir); | |
if (shouldAddConfigFileQueries() && QUERIES_PROPERTY in parsedYAML) { | |
if (!(parsedYAML[QUERIES_PROPERTY] instanceof Array)) { | |
throw new Error(getQueriesInvalid(configFile)); | |
} | |
for (const query of parsedYAML[QUERIES_PROPERTY]!) { | |
if (!(QUERIES_USES_PROPERTY in query) || typeof query[QUERIES_USES_PROPERTY] !== "string") { | |
throw new Error(getQueryUsesInvalid(configFile)); | |
} | |
await parseQueryUses(languages, codeQL, queries, query[QUERIES_USES_PROPERTY], tempDir, configFile); | |
} | |
} | |
if (PATHS_IGNORE_PROPERTY in parsedYAML) { | |
if (!(parsedYAML[PATHS_IGNORE_PROPERTY] instanceof Array)) { | |
throw new Error(getPathsIgnoreInvalid(configFile)); | |
} | |
parsedYAML[PATHS_IGNORE_PROPERTY]!.forEach(path => { | |
if (typeof path !== "string" || path === '') { | |
throw new Error(getPathsIgnoreInvalid(configFile)); | |
} | |
pathsIgnore.push(validateAndSanitisePath(path, PATHS_IGNORE_PROPERTY, configFile)); | |
}); | |
} | |
if (PATHS_PROPERTY in parsedYAML) { | |
if (!(parsedYAML[PATHS_PROPERTY] instanceof Array)) { | |
throw new Error(getPathsInvalid(configFile)); | |
} | |
parsedYAML[PATHS_PROPERTY]!.forEach(path => { | |
if (typeof path !== "string" || path === '') { | |
throw new Error(getPathsInvalid(configFile)); | |
} | |
paths.push(validateAndSanitisePath(path, PATHS_PROPERTY, configFile)); | |
}); | |
} | |
// The list of queries should not be empty for any language. If it is then | |
// it is a user configuration error. | |
for (const language of languages) { | |
if (queries[language] === undefined || queries[language].length === 0) { | |
throw new Error(`Did not detect any queries to run for ${language}. ` + | |
"Please make sure that the default queries are enabled, or you are specifying queries to run."); | |
} | |
} | |
return { | |
languages, | |
queries, | |
pathsIgnore, | |
paths, | |
originalUserInput: parsedYAML, | |
tempDir, | |
toolCacheDir, | |
codeQLCmd: codeQL.getPath(), | |
}; | |
} | |
/** | |
* 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(tempDir: string, toolCacheDir: string, codeQL: CodeQL): Promise<Config> { | |
const configFile = core.getInput('config-file'); | |
let config: Config; | |
// If no config file was provided create an empty one | |
if (configFile === '') { | |
core.debug('No configuration file was provided'); | |
config = await getDefaultConfig(tempDir, toolCacheDir, codeQL); | |
} else { | |
config = await loadConfig(configFile, tempDir, toolCacheDir, codeQL); | |
} | |
// Save the config so we can easily access it again in the future | |
await saveConfig(config); | |
return config; | |
} | |
function isLocal(configPath: string): boolean { | |
// If the path starts with ./, look locally | |
if (configPath.indexOf("./") === 0) { | |
return true; | |
} | |
return (configPath.indexOf("@") === -1); | |
} | |
function getLocalConfig(configFile: string, workspacePath: string): UserConfig { | |
// Error if the config file is now outside of the workspace | |
if (!(configFile + path.sep).startsWith(workspacePath + path.sep)) { | |
throw new Error(getConfigFileOutsideWorkspaceErrorMessage(configFile)); | |
} | |
// Error if the file does not exist | |
if (!fs.existsSync(configFile)) { | |
throw new Error(getConfigFileDoesNotExistErrorMessage(configFile)); | |
} | |
return yaml.safeLoad(fs.readFileSync(configFile, 'utf8')); | |
} | |
async function getRemoteConfig(configFile: string): Promise<UserConfig> { | |
// retrieve the various parts of the config location, and ensure they're present | |
const format = new RegExp('(?<owner>[^/]+)/(?<repo>[^/]+)/(?<path>[^@]+)@(?<ref>.*)'); | |
const pieces = format.exec(configFile); | |
// 5 = 4 groups + the whole expression | |
if (pieces === null || pieces.groups === undefined || pieces.length < 5) { | |
throw new Error(getConfigFileRepoFormatInvalidMessage(configFile)); | |
} | |
const response = await api.getActionsApiClient(true).repos.getContents({ | |
owner: pieces.groups.owner, | |
repo: pieces.groups.repo, | |
path: pieces.groups.path, | |
ref: pieces.groups.ref, | |
}); | |
let fileContents: string; | |
if ("content" in response.data && response.data.content !== undefined) { | |
fileContents = response.data.content; | |
} else if (Array.isArray(response.data)) { | |
throw new Error(getConfigFileDirectoryGivenMessage(configFile)); | |
} else { | |
throw new Error(getConfigFileFormatInvalidMessage(configFile)); | |
} | |
return yaml.safeLoad(Buffer.from(fileContents, 'base64').toString('binary')); | |
} | |
/** | |
* Get the file path where the parsed config will be stored. | |
*/ | |
export function getPathToParsedConfigFile(tempDir: string): string { | |
return path.join(tempDir, 'config'); | |
} | |
/** | |
* Store the given config to the path returned from getPathToParsedConfigFile. | |
*/ | |
async function saveConfig(config: Config) { | |
const configString = JSON.stringify(config); | |
const configFile = getPathToParsedConfigFile(config.tempDir); | |
fs.mkdirSync(path.dirname(configFile), { recursive: true }); | |
fs.writeFileSync(configFile, configString, 'utf8'); | |
core.debug('Saved config:'); | |
core.debug(configString); | |
} | |
/** | |
* Get the config. | |
* | |
* If this is the first time in a workflow that this is being called then | |
* this will parse the config from the user input. The parsed config is then | |
* stored to a known location. On the second and further calls, this will | |
* return the contents of the parsed config from the known location. | |
*/ | |
export async function getConfig(tempDir: string): Promise<Config> { | |
const configFile = getPathToParsedConfigFile(tempDir); | |
if (!fs.existsSync(configFile)) { | |
throw new Error("Config file could not be found at expected location. Has the 'init' action been called?"); | |
} | |
const configString = fs.readFileSync(configFile, 'utf8'); | |
core.debug('Loaded config:'); | |
core.debug(configString); | |
return JSON.parse(configString); | |
} |