mirror of
https://github.com/github/codeql-action.git
synced 2025-12-26 17:20:10 +08:00
441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import zlib from "zlib";
|
|
|
|
import * as core from "@actions/core";
|
|
import * as yaml from "js-yaml";
|
|
|
|
import * as api from "./api-client";
|
|
import { CodeQL } from "./codeql";
|
|
import { EnvVar } from "./environment";
|
|
import { Logger } from "./logging";
|
|
import { getRequiredEnvParam, isInTestMode } from "./util";
|
|
|
|
export interface WorkflowJobStep {
|
|
name?: string;
|
|
run?: any;
|
|
uses?: string;
|
|
with?: { [key: string]: boolean | number | string };
|
|
}
|
|
|
|
interface WorkflowJob {
|
|
name?: string;
|
|
"runs-on"?: string;
|
|
steps?: WorkflowJobStep[];
|
|
strategy?: { matrix: { [key: string]: string[] } };
|
|
uses?: string;
|
|
}
|
|
|
|
interface WorkflowTrigger {
|
|
branches?: string[] | string;
|
|
paths?: string[];
|
|
}
|
|
|
|
// on: {} then push/pull_request are undefined
|
|
// on:
|
|
// push:
|
|
// pull_request:
|
|
// then push/pull_request are null
|
|
interface WorkflowTriggers {
|
|
push?: WorkflowTrigger | null;
|
|
pull_request?: WorkflowTrigger | null;
|
|
}
|
|
|
|
export interface Workflow {
|
|
name?: string;
|
|
jobs?: { [key: string]: WorkflowJob };
|
|
on?: string | string[] | WorkflowTriggers;
|
|
}
|
|
|
|
export interface CodedError {
|
|
message: string;
|
|
code: string;
|
|
}
|
|
|
|
function toCodedErrors(errors: {
|
|
[code: string]: string;
|
|
}): Record<string, CodedError> {
|
|
return Object.entries(errors).reduce(
|
|
(acc, [code, message]) => {
|
|
acc[code] = { message, code };
|
|
return acc;
|
|
},
|
|
{} as Record<string, CodedError>,
|
|
);
|
|
}
|
|
|
|
// code to send back via status report
|
|
// message to add as a warning annotation to the run
|
|
export const WorkflowErrors = toCodedErrors({
|
|
MissingPushHook: `Please specify an on.push hook to analyze and see code scanning alerts from the default branch on the Security tab.`,
|
|
CheckoutWrongHead: `git checkout HEAD^2 is no longer necessary. Please remove this step as Code Scanning recommends analyzing the merge commit for best results.`,
|
|
});
|
|
|
|
/**
|
|
* Groups the given list of CodeQL languages by their extractor name.
|
|
*
|
|
* Resolves to `undefined` if the CodeQL version does not support language aliasing.
|
|
*/
|
|
async function groupLanguagesByExtractor(
|
|
languages: string[],
|
|
codeql: CodeQL,
|
|
): Promise<{ [extractorName: string]: string[] } | undefined> {
|
|
const resolveResult = await codeql.betterResolveLanguages();
|
|
if (!resolveResult.aliases) {
|
|
return undefined;
|
|
}
|
|
const aliases = resolveResult.aliases;
|
|
const languagesByExtractor: {
|
|
[extractorName: string]: string[];
|
|
} = {};
|
|
for (const language of languages) {
|
|
const extractorName = aliases[language] || language;
|
|
if (!languagesByExtractor[extractorName]) {
|
|
languagesByExtractor[extractorName] = [];
|
|
}
|
|
languagesByExtractor[extractorName].push(language);
|
|
}
|
|
return languagesByExtractor;
|
|
}
|
|
|
|
export async function getWorkflowErrors(
|
|
doc: Workflow,
|
|
codeql: CodeQL,
|
|
): Promise<CodedError[]> {
|
|
const errors: CodedError[] = [];
|
|
|
|
const jobName = process.env.GITHUB_JOB;
|
|
|
|
if (jobName) {
|
|
const job = doc?.jobs?.[jobName];
|
|
|
|
if (job?.strategy?.matrix?.language) {
|
|
const matrixLanguages = job.strategy.matrix.language;
|
|
if (Array.isArray(matrixLanguages)) {
|
|
// Map extractors to entries in the `language` matrix parameter. This will allow us to
|
|
// detect languages which are analyzed in more than one job.
|
|
const matrixLanguagesByExtractor = await groupLanguagesByExtractor(
|
|
matrixLanguages,
|
|
codeql,
|
|
);
|
|
// If the CodeQL version does not support language aliasing, then `matrixLanguagesByExtractor`
|
|
// will be `undefined`. In this case, we cannot detect duplicate languages in the matrix.
|
|
if (matrixLanguagesByExtractor !== undefined) {
|
|
// Check for duplicate languages in the matrix
|
|
for (const [extractor, languages] of Object.entries(
|
|
matrixLanguagesByExtractor,
|
|
)) {
|
|
if (languages.length > 1) {
|
|
errors.push({
|
|
message:
|
|
`CodeQL language '${extractor}' is referenced by more than one entry in the ` +
|
|
`'language' matrix parameter for job '${jobName}'. This may result in duplicate alerts. ` +
|
|
`Please edit the 'language' matrix parameter to keep only one of the following: ${languages
|
|
.map((language) => `'${language}'`)
|
|
.join(", ")}.`,
|
|
code: "DuplicateLanguageInMatrix",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const steps = job?.steps;
|
|
|
|
if (Array.isArray(steps)) {
|
|
for (const step of steps) {
|
|
// this was advice that we used to give in the README
|
|
// we actually want to run the analysis on the merge commit
|
|
// to produce results that are more inline with expectations
|
|
// (i.e: this is what will happen if you merge this PR)
|
|
// and avoid some race conditions
|
|
if (step?.run === "git checkout HEAD^2") {
|
|
errors.push(WorkflowErrors.CheckoutWrongHead);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there is no push trigger, we will not be able to analyze the default branch.
|
|
// So add a warning to the user to add a push trigger.
|
|
// If there is a workflow_call trigger, we don't need a push trigger since we assume
|
|
// that the workflow_call trigger is called from a workflow that has a push trigger.
|
|
const hasPushTrigger = hasWorkflowTrigger("push", doc);
|
|
const hasPullRequestTrigger = hasWorkflowTrigger("pull_request", doc);
|
|
const hasWorkflowCallTrigger = hasWorkflowTrigger("workflow_call", doc);
|
|
|
|
if (hasPullRequestTrigger && !hasPushTrigger && !hasWorkflowCallTrigger) {
|
|
errors.push(WorkflowErrors.MissingPushHook);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function hasWorkflowTrigger(triggerName: string, doc: Workflow): boolean {
|
|
if (!doc.on) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof doc.on === "string") {
|
|
return doc.on === triggerName;
|
|
}
|
|
|
|
if (Array.isArray(doc.on)) {
|
|
return doc.on.includes(triggerName);
|
|
}
|
|
|
|
return Object.prototype.hasOwnProperty.call(doc.on, triggerName);
|
|
}
|
|
|
|
export async function validateWorkflow(
|
|
codeql: CodeQL,
|
|
logger: Logger,
|
|
): Promise<undefined | string> {
|
|
let workflow: Workflow;
|
|
try {
|
|
workflow = await getWorkflow(logger);
|
|
} catch (e) {
|
|
return `error: getWorkflow() failed: ${String(e)}`;
|
|
}
|
|
let workflowErrors: CodedError[];
|
|
try {
|
|
workflowErrors = await getWorkflowErrors(workflow, codeql);
|
|
} catch (e) {
|
|
return `error: getWorkflowErrors() failed: ${String(e)}`;
|
|
}
|
|
|
|
if (workflowErrors.length > 0) {
|
|
let message: string;
|
|
try {
|
|
message = formatWorkflowErrors(workflowErrors);
|
|
} catch (e) {
|
|
return `error: formatWorkflowErrors() failed: ${String(e)}`;
|
|
}
|
|
core.warning(message);
|
|
}
|
|
|
|
return formatWorkflowCause(workflowErrors);
|
|
}
|
|
|
|
export function formatWorkflowErrors(errors: CodedError[]): string {
|
|
const issuesWere = errors.length === 1 ? "issue was" : "issues were";
|
|
|
|
const errorsList = errors.map((e) => e.message).join(" ");
|
|
|
|
return `${errors.length} ${issuesWere} detected with this workflow: ${errorsList}`;
|
|
}
|
|
|
|
export function formatWorkflowCause(errors: CodedError[]): undefined | string {
|
|
if (errors.length === 0) {
|
|
return undefined;
|
|
}
|
|
return errors.map((e) => e.code).join(",");
|
|
}
|
|
|
|
export async function getWorkflow(logger: Logger): Promise<Workflow> {
|
|
// In default setup, the currently executing workflow is not checked into the repository.
|
|
// Instead, a gzipped then base64 encoded version of the workflow file is provided via the
|
|
// `CODE_SCANNING_WORKFLOW_FILE` environment variable.
|
|
const maybeWorkflow = process.env["CODE_SCANNING_WORKFLOW_FILE"];
|
|
if (maybeWorkflow) {
|
|
logger.debug(
|
|
"Using the workflow specified by the CODE_SCANNING_WORKFLOW_FILE environment variable.",
|
|
);
|
|
return yaml.load(
|
|
zlib.gunzipSync(Buffer.from(maybeWorkflow, "base64")).toString(),
|
|
) as Workflow;
|
|
}
|
|
|
|
const workflowPath = await getWorkflowAbsolutePath(logger);
|
|
return yaml.load(fs.readFileSync(workflowPath, "utf-8")) as Workflow;
|
|
}
|
|
|
|
/**
|
|
* Get the absolute path of the currently executing workflow.
|
|
*/
|
|
async function getWorkflowAbsolutePath(logger: Logger): Promise<string> {
|
|
const relativePath = await api.getWorkflowRelativePath();
|
|
const absolutePath = path.join(
|
|
getRequiredEnvParam("GITHUB_WORKSPACE"),
|
|
relativePath,
|
|
);
|
|
|
|
if (fs.existsSync(absolutePath)) {
|
|
logger.debug(
|
|
`Derived the following absolute path for the currently executing workflow: ${absolutePath}.`,
|
|
);
|
|
return absolutePath;
|
|
}
|
|
|
|
throw new Error(
|
|
`Expected to find a code scanning workflow file at ${absolutePath}, but no such file existed. ` +
|
|
"This can happen if the currently running workflow checks out a branch that doesn't contain " +
|
|
"the corresponding workflow file.",
|
|
);
|
|
}
|
|
|
|
function getStepsCallingAction(
|
|
job: WorkflowJob,
|
|
actionName: string,
|
|
): WorkflowJobStep[] {
|
|
if (job.uses) {
|
|
throw new Error(
|
|
`Could not get steps calling ${actionName} since the job calls a reusable workflow.`,
|
|
);
|
|
}
|
|
const steps = job.steps;
|
|
if (!Array.isArray(steps)) {
|
|
throw new Error(
|
|
`Could not get steps calling ${actionName} since job.steps was not an array.`,
|
|
);
|
|
}
|
|
return steps.filter((step) => step.uses?.includes(actionName));
|
|
}
|
|
|
|
/**
|
|
* Makes a best effort attempt to retrieve the value of a particular input with which
|
|
* an Action in the workflow would be invoked.
|
|
*
|
|
* Typically you'll want to wrap this function in a try/catch block and handle the error.
|
|
*
|
|
* @returns the value of the input, or undefined if no such input is passed to the Action
|
|
* @throws an error if the value of the input could not be determined, or we could not
|
|
* determine that no such input is passed to the Action.
|
|
*/
|
|
function getInputOrThrow(
|
|
workflow: Workflow,
|
|
jobName: string,
|
|
actionName: string,
|
|
inputName: string,
|
|
matrixVars: { [key: string]: string } | undefined,
|
|
) {
|
|
const preamble = `Could not get ${inputName} input to ${actionName} since`;
|
|
if (!workflow.jobs) {
|
|
throw new Error(`${preamble} the workflow has no jobs.`);
|
|
}
|
|
if (!workflow.jobs[jobName]) {
|
|
throw new Error(`${preamble} the workflow has no job named ${jobName}.`);
|
|
}
|
|
|
|
const stepsCallingAction = getStepsCallingAction(
|
|
workflow.jobs[jobName],
|
|
actionName,
|
|
);
|
|
|
|
if (stepsCallingAction.length === 0) {
|
|
throw new Error(
|
|
`${preamble} the ${jobName} job does not call ${actionName}.`,
|
|
);
|
|
} else if (stepsCallingAction.length > 1) {
|
|
throw new Error(
|
|
`${preamble} the ${jobName} job calls ${actionName} multiple times.`,
|
|
);
|
|
}
|
|
|
|
let input = stepsCallingAction[0].with?.[inputName]?.toString();
|
|
|
|
if (input !== undefined && matrixVars !== undefined) {
|
|
// Normalize by removing whitespace
|
|
input = input.replace(/\${{\s+/, "${{").replace(/\s+}}/, "}}");
|
|
// Make a basic attempt to substitute matrix variables
|
|
for (const [key, value] of Object.entries(matrixVars)) {
|
|
input = input.replace(`\${{matrix.${key}}}`, value);
|
|
}
|
|
}
|
|
if (input !== undefined && input.includes("${{")) {
|
|
throw new Error(
|
|
`Could not get ${inputName} input to ${actionName} since it contained an unrecognized dynamic value.`,
|
|
);
|
|
}
|
|
return input;
|
|
}
|
|
|
|
/**
|
|
* Get the expected name of the analyze Action.
|
|
*
|
|
* This allows us to test workflow parsing functionality as a CodeQL Action PR check.
|
|
*/
|
|
function getAnalyzeActionName() {
|
|
if (
|
|
isInTestMode() ||
|
|
process.env[EnvVar.TESTING_ENVIRONMENT] === "codeql-action-pr-checks"
|
|
) {
|
|
return "./analyze";
|
|
} else {
|
|
return "github/codeql-action/analyze";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes a best effort attempt to retrieve the category input for the particular job,
|
|
* given a set of matrix variables.
|
|
*
|
|
* Typically you'll want to wrap this function in a try/catch block and handle the error.
|
|
*
|
|
* @returns the category input, or undefined if the category input is not defined
|
|
* @throws an error if the category input could not be determined
|
|
*/
|
|
export function getCategoryInputOrThrow(
|
|
workflow: Workflow,
|
|
jobName: string,
|
|
matrixVars: { [key: string]: string } | undefined,
|
|
): string | undefined {
|
|
return getInputOrThrow(
|
|
workflow,
|
|
jobName,
|
|
getAnalyzeActionName(),
|
|
"category",
|
|
matrixVars,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Makes a best effort attempt to retrieve the upload input for the particular job,
|
|
* given a set of matrix variables.
|
|
*
|
|
* Typically you'll want to wrap this function in a try/catch block and handle the error.
|
|
*
|
|
* @returns the user input to upload, or undefined if input was unspecified
|
|
* @throws an error if the upload input could not be determined
|
|
*/
|
|
export function getUploadInputOrThrow(
|
|
workflow: Workflow,
|
|
jobName: string,
|
|
matrixVars: { [key: string]: string } | undefined,
|
|
): string | undefined {
|
|
return getInputOrThrow(
|
|
workflow,
|
|
jobName,
|
|
getAnalyzeActionName(),
|
|
"upload",
|
|
matrixVars,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Makes a best effort attempt to retrieve the checkout_path input for the
|
|
* particular job, given a set of matrix variables.
|
|
*
|
|
* Typically you'll want to wrap this function in a try/catch block and handle the error.
|
|
*
|
|
* @returns the checkout_path input
|
|
* @throws an error if the checkout_path input could not be determined
|
|
*/
|
|
export function getCheckoutPathInputOrThrow(
|
|
workflow: Workflow,
|
|
jobName: string,
|
|
matrixVars: { [key: string]: string } | undefined,
|
|
): string {
|
|
return (
|
|
getInputOrThrow(
|
|
workflow,
|
|
jobName,
|
|
getAnalyzeActionName(),
|
|
"checkout_path",
|
|
matrixVars,
|
|
) || getRequiredEnvParam("GITHUB_WORKSPACE") // if unspecified, checkout_path defaults to ${{ github.workspace }}
|
|
);
|
|
}
|