mirror of
https://github.com/github/codeql-action.git
synced 2026-08-06 13:13:45 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e36708483 | |||
| fccc166683 | |||
| f8f91fb336 | |||
| 58f39a7d42 | |||
| 40b3f0c1bc | |||
| 6e32e882a9 |
@@ -54,16 +54,15 @@ runs:
|
||||
env:
|
||||
CODEQL_ACTION_TEST_MODE: 'true'
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm install --location=global ts-node js-yaml
|
||||
|
||||
- name: Check config
|
||||
working-directory: ${{ github.action_path }}
|
||||
shell: bash
|
||||
env:
|
||||
EXPECTED_CONFIG_FILE_CONTENTS: '${{ inputs.expected-config-file-contents }}'
|
||||
run: ts-node ./index.ts "$RUNNER_TEMP/user-config.yaml" "$EXPECTED_CONFIG_FILE_CONTENTS"
|
||||
run: |
|
||||
npx tsx ../action/pr-checks/check-cs-config.ts \
|
||||
--file "$RUNNER_TEMP/user-config.yaml" \
|
||||
--expected-contents "$EXPECTED_CONFIG_FILE_CONTENTS"
|
||||
|
||||
- name: Clean up
|
||||
shell: bash
|
||||
if: always()
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
|
||||
import * as core from '@actions/core'
|
||||
import * as yaml from 'js-yaml'
|
||||
import * as fs from 'fs'
|
||||
import * as assert from 'assert'
|
||||
|
||||
const actualConfig = loadActualConfig()
|
||||
|
||||
function sortConfigArrays(config) {
|
||||
for (const key of Object.keys(config)) {
|
||||
const value = config[key];
|
||||
if (key === 'queries' && Array.isArray(value)) {
|
||||
config[key] = value.sort();
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
const rawExpectedConfig = process.argv[3].trim()
|
||||
if (!rawExpectedConfig) {
|
||||
core.setFailed('No expected configuration provided')
|
||||
} else {
|
||||
core.startGroup('Expected generated user config')
|
||||
core.info(yaml.dump(JSON.parse(rawExpectedConfig)))
|
||||
core.endGroup()
|
||||
}
|
||||
|
||||
const expectedConfig = rawExpectedConfig ? JSON.parse(rawExpectedConfig) : undefined;
|
||||
|
||||
assert.deepStrictEqual(
|
||||
sortConfigArrays(actualConfig),
|
||||
sortConfigArrays(expectedConfig),
|
||||
'Expected configuration does not match actual configuration'
|
||||
);
|
||||
|
||||
|
||||
function loadActualConfig() {
|
||||
if (!fs.existsSync(process.argv[2])) {
|
||||
core.info('No configuration file found')
|
||||
return undefined
|
||||
} else {
|
||||
const rawActualConfig = fs.readFileSync(process.argv[2], 'utf8')
|
||||
core.startGroup('Actual generated user config')
|
||||
core.info(rawActualConfig)
|
||||
core.endGroup()
|
||||
|
||||
return yaml.load(rawActualConfig)
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,23 @@ inputs:
|
||||
Comma separated list of query ids that should NOT be included in this SARIF file.
|
||||
|
||||
runs:
|
||||
using: node24
|
||||
main: index.js
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Run `check-sarif.ts`
|
||||
shell: bash
|
||||
env:
|
||||
SARIF_FILE: ${{ inputs.sarif-file }}
|
||||
QUERIES_RUN: ${{ inputs.queries-run }}
|
||||
QUERIES_NOT_RUN: ${{ inputs.queries-not-run }}
|
||||
run: |
|
||||
if [[ -d pr-checks ]]; then
|
||||
npx tsx ./pr-checks/check-sarif.ts \
|
||||
--sarif-file "$SARIF_FILE" \
|
||||
--queries-run "$QUERIES_RUN" \
|
||||
--queries-not-run "$QUERIES_NOT_RUN"
|
||||
else
|
||||
npx tsx ../action/pr-checks/check-sarif.ts \
|
||||
--sarif-file "$SARIF_FILE" \
|
||||
--queries-run "$QUERIES_RUN" \
|
||||
--queries-not-run "$QUERIES_NOT_RUN"
|
||||
fi
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const core = require('@actions/core')
|
||||
const fs = require('fs')
|
||||
|
||||
const sarif = JSON.parse(fs.readFileSync(core.getInput('sarif-file'), 'utf8'))
|
||||
const rules = sarif.runs[0].tool.extensions.flatMap(ext => ext.rules || [])
|
||||
const ruleIds = rules.map(rule => rule.id)
|
||||
|
||||
// Check that all the expected queries ran
|
||||
const expectedQueriesRun = getQueryIdsInput('queries-run')
|
||||
const queriesThatShouldHaveRunButDidNot = expectedQueriesRun.filter(queryId => !ruleIds.includes(queryId))
|
||||
|
||||
if (queriesThatShouldHaveRunButDidNot.length > 0) {
|
||||
core.setFailed(`The following queries were expected to run but did not: ${queriesThatShouldHaveRunButDidNot.join(', ')}`)
|
||||
}
|
||||
|
||||
// Check that all the unexpected queries did not run
|
||||
const expectedQueriesNotRun = getQueryIdsInput('queries-not-run')
|
||||
|
||||
const queriesThatShouldNotHaveRunButDid = expectedQueriesNotRun.filter(queryId => ruleIds.includes(queryId))
|
||||
|
||||
if (queriesThatShouldNotHaveRunButDid.length > 0) {
|
||||
core.setFailed(`The following queries were NOT expected to have run but did: ${queriesThatShouldNotHaveRunButDid.join(', ')}`)
|
||||
}
|
||||
|
||||
|
||||
core.startGroup('All queries run')
|
||||
rules.forEach(rule => {
|
||||
core.info(`${rule.id}: ${(rule.properties && rule.properties.name) || rule.name}`)
|
||||
})
|
||||
core.endGroup()
|
||||
|
||||
core.startGroup('Full SARIF')
|
||||
core.info(JSON.stringify(sarif, null, 2))
|
||||
core.endGroup()
|
||||
|
||||
function getQueryIdsInput(name) {
|
||||
return core.getInput(name)
|
||||
.split(',')
|
||||
.map(q => q.trim())
|
||||
.filter(q => q.length > 0)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
name: Update default CodeQL bundle
|
||||
description: Updates 'src/defaults.json' to point to a new CodeQL bundle release.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install ts-node
|
||||
shell: bash
|
||||
run: npm install -g ts-node
|
||||
|
||||
- name: Run update script
|
||||
working-directory: ${{ github.action_path }}
|
||||
shell: bash
|
||||
run: ts-node ./index.ts
|
||||
@@ -1,67 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as github from '@actions/github';
|
||||
|
||||
interface BundleInfo {
|
||||
bundleVersion: string;
|
||||
cliVersion: string;
|
||||
}
|
||||
|
||||
interface Defaults {
|
||||
bundleVersion: string;
|
||||
cliVersion: string;
|
||||
priorBundleVersion: string;
|
||||
priorCliVersion: string;
|
||||
}
|
||||
|
||||
function getCodeQLCliVersionForRelease(release): string {
|
||||
// We do not currently tag CodeQL bundles based on the CLI version they contain.
|
||||
// Instead, we use a marker file `cli-version-<version>.txt` to record the CLI version.
|
||||
// This marker file is uploaded as a release asset for all new CodeQL bundles.
|
||||
const cliVersionsFromMarkerFiles = release.assets
|
||||
.map((asset) => asset.name.match(/cli-version-(.*)\.txt/)?.[1])
|
||||
.filter((v) => v)
|
||||
.map((v) => v as string);
|
||||
if (cliVersionsFromMarkerFiles.length > 1) {
|
||||
throw new Error(
|
||||
`Release ${release.tag_name} has multiple CLI version marker files.`
|
||||
);
|
||||
} else if (cliVersionsFromMarkerFiles.length === 0) {
|
||||
throw new Error(
|
||||
`Failed to find the CodeQL CLI version for release ${release.tag_name}.`
|
||||
);
|
||||
}
|
||||
return cliVersionsFromMarkerFiles[0];
|
||||
}
|
||||
|
||||
async function getBundleInfoFromRelease(release): Promise<BundleInfo> {
|
||||
return {
|
||||
bundleVersion: release.tag_name,
|
||||
cliVersion: getCodeQLCliVersionForRelease(release)
|
||||
};
|
||||
}
|
||||
|
||||
async function getNewDefaults(currentDefaults: Defaults): Promise<Defaults> {
|
||||
const release = github.context.payload.release;
|
||||
console.log('Updating default bundle as a result of the following release: ' +
|
||||
`${JSON.stringify(release)}.`)
|
||||
|
||||
const bundleInfo = await getBundleInfoFromRelease(release);
|
||||
return {
|
||||
bundleVersion: bundleInfo.bundleVersion,
|
||||
cliVersion: bundleInfo.cliVersion,
|
||||
priorBundleVersion: currentDefaults.bundleVersion,
|
||||
priorCliVersion: currentDefaults.cliVersion
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const previousDefaults: Defaults = JSON.parse(fs.readFileSync('../../../src/defaults.json', 'utf8'));
|
||||
const newDefaults = await getNewDefaults(previousDefaults);
|
||||
// Update the source file in the repository. Calling workflows should subsequently rebuild
|
||||
// the Action to update `lib/defaults.json`.
|
||||
fs.writeFileSync('../../../src/defaults.json', JSON.stringify(newDefaults, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// Ideally, we'd await main() here, but that doesn't work well with `ts-node`.
|
||||
// So instead we rely on the fact that Node won't exit until the event loop is empty.
|
||||
main();
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
npm ci
|
||||
|
||||
- name: Verify compiled JS up to date
|
||||
run: .github/workflows/script/check-js.sh
|
||||
run: npx tsx pr-checks/check-js.ts
|
||||
|
||||
- name: Run unit tests
|
||||
if: always()
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# Sanity check that repo is clean to start with
|
||||
if [ ! -z "$(git status --porcelain)" ]; then
|
||||
# If we get a fail here then this workflow needs attention...
|
||||
>&2 echo "Failed: Repo should be clean before testing!"
|
||||
exit 1
|
||||
fi
|
||||
# Wipe the lib directory in case there are extra unnecessary files in there
|
||||
rm -rf lib
|
||||
# Generate the JavaScript files
|
||||
npm run-script build
|
||||
# Check that repo is still clean
|
||||
if [ ! -z "$(git status --porcelain)" ]; then
|
||||
# If we get a fail here then the PR needs attention
|
||||
>&2 echo "Failed: JavaScript files are not up to date. Run 'rm -rf lib && npm run-script build' to update"
|
||||
git status
|
||||
|
||||
echo "### Transpiled JS diff" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```diff' >> $GITHUB_STEP_SUMMARY
|
||||
git diff --output="$RUNNER_TEMP/js.diff"
|
||||
cat "$RUNNER_TEMP/js.diff" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Reset bundled files to allow other checks to test for changes
|
||||
git checkout lib
|
||||
|
||||
# Fail this check
|
||||
exit 1
|
||||
fi
|
||||
echo "Success: JavaScript files are up to date"
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Update bundle
|
||||
uses: ./.github/actions/update-bundle
|
||||
run: npx tsx pr-checks/update-bundle.ts
|
||||
|
||||
- name: Set up CodeQL CLI from new bundle
|
||||
id: setup-codeql
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Tests for `check-cs-config.ts`.
|
||||
*/
|
||||
|
||||
import * as assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { UserConfig } from "../src/config/db-config";
|
||||
|
||||
import { checkConfiguration } from "./check-cs-config";
|
||||
|
||||
describe("checkConfiguration", async () => {
|
||||
await it("passes when actual and expected configs match", () => {
|
||||
const actual: UserConfig = { name: "test-config", paths: ["src"] };
|
||||
const expected = JSON.stringify(actual);
|
||||
assert.doesNotThrow(() => checkConfiguration(actual, expected));
|
||||
});
|
||||
|
||||
await it("passes when queries arrays match after sorting", () => {
|
||||
const actual: UserConfig = { paths: ["b", "a", "c"] };
|
||||
const expected = JSON.stringify(actual);
|
||||
assert.doesNotThrow(() => checkConfiguration(actual, expected));
|
||||
});
|
||||
|
||||
await it("throws when actual config does not match expected", () => {
|
||||
const actual: UserConfig = { name: "actual-name" };
|
||||
const expected = JSON.stringify({
|
||||
name: "expected-name",
|
||||
} satisfies UserConfig);
|
||||
assert.throws(() => checkConfiguration(actual, expected), {
|
||||
message: /Expected configuration does not match actual configuration/,
|
||||
});
|
||||
});
|
||||
|
||||
await it("throws when expected contents are empty", () => {
|
||||
assert.throws(() => checkConfiguration({}, ""), {
|
||||
message: /No expected configuration provided/,
|
||||
});
|
||||
});
|
||||
|
||||
await it("throws when expected contents are only whitespace", () => {
|
||||
assert.throws(() => checkConfiguration({}, " "), {
|
||||
message: /No expected configuration provided/,
|
||||
});
|
||||
});
|
||||
|
||||
await it("passes with complex config", () => {
|
||||
const actual: UserConfig = {
|
||||
name: "complex",
|
||||
"disable-default-queries": true,
|
||||
paths: ["src", "lib"],
|
||||
"paths-ignore": ["test"],
|
||||
"threat-models": ["remote"],
|
||||
};
|
||||
const expected = JSON.stringify(actual);
|
||||
assert.doesNotThrow(() => checkConfiguration(actual, expected));
|
||||
});
|
||||
|
||||
await it("trims whitespace from expected contents before parsing", () => {
|
||||
const actual: UserConfig = { name: "trimmed" };
|
||||
const expected = ` ${JSON.stringify(actual)} `;
|
||||
assert.doesNotThrow(() => checkConfiguration(actual, expected));
|
||||
});
|
||||
|
||||
await it("passes when both configs are empty objects", () => {
|
||||
assert.doesNotThrow(() => checkConfiguration({}, "{}"));
|
||||
});
|
||||
});
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Checks the code scanning configuration file generated by the
|
||||
* action to ensure it contains the expected contents
|
||||
*/
|
||||
|
||||
import * as assert from "node:assert";
|
||||
import * as fs from "node:fs";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import * as yaml from "yaml";
|
||||
|
||||
import type { UserConfig } from "../src/config/db-config";
|
||||
|
||||
import { getErrorMessage } from "./util";
|
||||
|
||||
function sortConfigArrays(config: UserConfig) {
|
||||
for (const key of Object.keys(config)) {
|
||||
const value = config[key];
|
||||
if (key === "queries" && Array.isArray(value)) {
|
||||
config[key] = value.sort();
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function loadActualConfig(configPath: string) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error("No configuration file found");
|
||||
} else {
|
||||
const rawActualConfig = fs.readFileSync(configPath, "utf8");
|
||||
core.startGroup("Actual generated user config");
|
||||
core.info(rawActualConfig);
|
||||
core.endGroup();
|
||||
|
||||
return yaml.parse(rawActualConfig) as UserConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export function checkConfiguration(
|
||||
actualConfig: UserConfig,
|
||||
expectedContents: string,
|
||||
) {
|
||||
const rawExpectedConfig = expectedContents.trim();
|
||||
if (!rawExpectedConfig) {
|
||||
throw new Error("No expected configuration provided");
|
||||
}
|
||||
|
||||
const expectedConfig = JSON.parse(rawExpectedConfig) as UserConfig;
|
||||
|
||||
core.startGroup("Expected generated user config");
|
||||
core.info(yaml.stringify(expectedConfig));
|
||||
core.endGroup();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
sortConfigArrays(actualConfig),
|
||||
sortConfigArrays(expectedConfig),
|
||||
"Expected configuration does not match actual configuration",
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
// The path of the configuration file to check.
|
||||
file: {
|
||||
type: "string",
|
||||
},
|
||||
// The expected contents of the file.
|
||||
"expected-contents": {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (values.file === undefined) {
|
||||
throw new Error("The '--file' input is required.");
|
||||
}
|
||||
if (values["expected-contents"] === undefined) {
|
||||
throw new Error("The '--expected-contents' input is required.");
|
||||
}
|
||||
|
||||
const actualConfig = loadActualConfig(values.file);
|
||||
|
||||
try {
|
||||
checkConfiguration(actualConfig, values["expected-contents"]);
|
||||
} catch (err) {
|
||||
core.error(getErrorMessage(err));
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.exit(main());
|
||||
}
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { runCommand, runGit } from "./command";
|
||||
import { LIB_ROOT, PR_CHECKS_DIR, REPO_ROOT } from "./config";
|
||||
import { getErrorMessage } from "./util";
|
||||
|
||||
function main() {
|
||||
// Sanity check that repo is clean to start with
|
||||
try {
|
||||
runGit(["diff", "--exit-code"], { allowNonZeroExitCode: false });
|
||||
console.info("Repository is clean.");
|
||||
} catch (err) {
|
||||
// If we get a fail here then this workflow needs attention...
|
||||
console.error(getErrorMessage(err));
|
||||
console.error("Failed: Repo should be clean before testing!");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Wipe the lib directory in case there are extra unnecessary files in there
|
||||
console.info(`Removing ${LIB_ROOT}...`);
|
||||
fs.rmSync(LIB_ROOT, { recursive: true, force: true });
|
||||
|
||||
// Generate the JavaScript files
|
||||
runCommand("npm", ["run", "build"], { execOptions: { shell: true } });
|
||||
|
||||
// Check that repo is still clean
|
||||
try {
|
||||
runGit(["diff", "--exit-code"], { allowNonZeroExitCode: false });
|
||||
console.info("Repository is clean.");
|
||||
} catch (err) {
|
||||
// If we get a fail here then the PR needs attention
|
||||
console.error(getErrorMessage(err));
|
||||
console.error("Failed: JavaScript files are not up to date.");
|
||||
console.error("Run 'rm -rf lib && npm run build' to update.");
|
||||
|
||||
const diffFile = path.join(
|
||||
process.env["RUNNER_TEMP"] ?? PR_CHECKS_DIR,
|
||||
"js.diff",
|
||||
);
|
||||
runCommand("git", ["status"]);
|
||||
runCommand("git", ["diff", `--output=${diffFile}`], {
|
||||
execOptions: { cwd: REPO_ROOT },
|
||||
});
|
||||
|
||||
core.summary.addHeading("Transpiled JS diff", 3);
|
||||
core.summary.addCodeBlock(fs.readFileSync(diffFile, "utf-8"), "diff");
|
||||
|
||||
fs.rmSync(diffFile);
|
||||
|
||||
// Reset bundled files to allow other checks to test for changes
|
||||
runCommand("git", ["checkout", "lib"]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.info("Success: JavaScript files are up to date");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Tests for `check-sarif.ts`.
|
||||
*/
|
||||
|
||||
import * as assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { Log } from "sarif";
|
||||
|
||||
import { checkSarif } from "./check-sarif";
|
||||
|
||||
/** Builds a minimal SARIF Log with the given rule IDs spread across extensions. */
|
||||
function buildSarifLog(ruleIds: string[]): Log {
|
||||
return {
|
||||
version: "2.1.0",
|
||||
$schema:
|
||||
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
||||
runs: [
|
||||
{
|
||||
tool: {
|
||||
driver: { name: "CodeQL" },
|
||||
extensions: [
|
||||
{
|
||||
name: "test-pack",
|
||||
rules: ruleIds.map((id) => ({ id })),
|
||||
},
|
||||
],
|
||||
},
|
||||
results: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkSarif", async () => {
|
||||
await it("returns 0 when all expected queries ran and no unexpected queries ran", () => {
|
||||
const sarif = buildSarifLog(["js/sql-injection", "js/xss"]);
|
||||
const exitCode = checkSarif(sarif, {
|
||||
sarifFile: "test.sarif",
|
||||
queriesRun: "js/sql-injection, js/xss",
|
||||
queriesNotRun: "js/hardcoded-credentials",
|
||||
});
|
||||
assert.equal(exitCode, 0);
|
||||
});
|
||||
|
||||
await it("returns -2 when an expected query did not run", () => {
|
||||
const sarif = buildSarifLog(["js/sql-injection"]);
|
||||
const exitCode = checkSarif(sarif, {
|
||||
sarifFile: "test.sarif",
|
||||
queriesRun: "js/sql-injection, js/xss",
|
||||
queriesNotRun: "",
|
||||
});
|
||||
assert.equal(exitCode, -2);
|
||||
});
|
||||
|
||||
await it("returns -2 when an unexpected query ran", () => {
|
||||
const sarif = buildSarifLog(["js/sql-injection", "js/xss"]);
|
||||
const exitCode = checkSarif(sarif, {
|
||||
sarifFile: "test.sarif",
|
||||
queriesRun: "js/sql-injection",
|
||||
queriesNotRun: "js/xss",
|
||||
});
|
||||
assert.equal(exitCode, -2);
|
||||
});
|
||||
|
||||
await it("handles empty queries-run and queries-not-run inputs", () => {
|
||||
const sarif = buildSarifLog(["js/sql-injection"]);
|
||||
const exitCode = checkSarif(sarif, {
|
||||
sarifFile: "test.sarif",
|
||||
queriesRun: "",
|
||||
queriesNotRun: "",
|
||||
});
|
||||
assert.equal(exitCode, 0);
|
||||
});
|
||||
|
||||
await it("handles multiple extensions with rules", () => {
|
||||
const sarif: Log = {
|
||||
version: "2.1.0",
|
||||
$schema:
|
||||
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
||||
runs: [
|
||||
{
|
||||
tool: {
|
||||
driver: { name: "CodeQL" },
|
||||
extensions: [
|
||||
{
|
||||
name: "pack-a",
|
||||
rules: [{ id: "js/sql-injection" }],
|
||||
},
|
||||
{
|
||||
name: "pack-b",
|
||||
rules: [{ id: "js/xss" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
results: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const exitCode = checkSarif(sarif, {
|
||||
sarifFile: "test.sarif",
|
||||
queriesRun: "js/sql-injection, js/xss",
|
||||
queriesNotRun: "",
|
||||
});
|
||||
assert.equal(exitCode, 0);
|
||||
});
|
||||
|
||||
await it("handles extensions with no rules", () => {
|
||||
const sarif: Log = {
|
||||
version: "2.1.0",
|
||||
$schema:
|
||||
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
||||
runs: [
|
||||
{
|
||||
tool: {
|
||||
driver: { name: "CodeQL" },
|
||||
extensions: [
|
||||
{ name: "empty-pack" },
|
||||
{
|
||||
name: "pack-with-rules",
|
||||
rules: [{ id: "js/xss" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
results: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const exitCode = checkSarif(sarif, {
|
||||
sarifFile: "test.sarif",
|
||||
queriesRun: "js/xss",
|
||||
queriesNotRun: "js/sql-injection",
|
||||
});
|
||||
assert.equal(exitCode, 0);
|
||||
});
|
||||
|
||||
await it("throws when tool extensions are undefined", () => {
|
||||
const sarif: Log = {
|
||||
version: "2.1.0",
|
||||
$schema:
|
||||
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
||||
runs: [
|
||||
{
|
||||
tool: { driver: { name: "CodeQL" } },
|
||||
results: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
checkSarif(sarif, {
|
||||
sarifFile: "test.sarif",
|
||||
queriesRun: "js/xss",
|
||||
queriesNotRun: "",
|
||||
}),
|
||||
{ message: /Couldn't find tool extensions/ },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/** Checks a SARIF file to see if certain queries were run and others were not run. */
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import type { ReportingDescriptor, Log } from "sarif";
|
||||
|
||||
import { getErrorMessage } from "./util";
|
||||
|
||||
type Options = { sarifFile: string; queriesRun: string; queriesNotRun: string };
|
||||
|
||||
function getOptions(): Options {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
// The path of the SARIF file to check.
|
||||
"sarif-file": {
|
||||
type: "string",
|
||||
},
|
||||
// The query ids to check are present.
|
||||
"queries-run": {
|
||||
type: "string",
|
||||
},
|
||||
// The query ids to check are absent.
|
||||
"queries-not-run": {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (values["sarif-file"] === undefined) {
|
||||
throw new Error("The '--sarif-file' input is required.");
|
||||
}
|
||||
if (values["queries-run"] === undefined) {
|
||||
throw new Error("The '--queries-run' input is required.");
|
||||
}
|
||||
if (values["queries-not-run"] === undefined) {
|
||||
throw new Error("The '--queries-not-run' input is required.");
|
||||
}
|
||||
|
||||
return {
|
||||
sarifFile: values["sarif-file"],
|
||||
queriesRun: values["queries-run"],
|
||||
queriesNotRun: values["queries-not-run"],
|
||||
};
|
||||
}
|
||||
|
||||
function parseQueryIdsInput(queriesRun: string): string[] {
|
||||
return queriesRun
|
||||
.split(",")
|
||||
.map((q) => q.trim())
|
||||
.filter((q) => q.length > 0);
|
||||
}
|
||||
|
||||
export function checkSarif(sarif: Log, options: Options) {
|
||||
if (sarif.runs[0].tool.extensions === undefined) {
|
||||
throw new Error(`Couldn't find tool extensions in the SARIF file.`);
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
|
||||
// Extract the rule ids from the SARIF file.
|
||||
const rules: ReportingDescriptor[] = sarif.runs[0].tool.extensions.flatMap(
|
||||
(ext) => ext.rules || [],
|
||||
);
|
||||
const ruleIds: string[] = rules.map((rule) => rule.id);
|
||||
|
||||
// Check that all the expected queries ran
|
||||
const expectedQueriesRun = parseQueryIdsInput(options.queriesRun);
|
||||
const queriesThatShouldHaveRunButDidNot = expectedQueriesRun.filter(
|
||||
(queryId) => !ruleIds.includes(queryId),
|
||||
);
|
||||
|
||||
if (queriesThatShouldHaveRunButDidNot.length > 0) {
|
||||
core.error(
|
||||
`The following queries were expected to run but did not: ${queriesThatShouldHaveRunButDidNot.join(", ")}`,
|
||||
);
|
||||
exitCode = -2;
|
||||
}
|
||||
|
||||
// Check that all the unexpected queries did not run
|
||||
const expectedQueriesNotRun = parseQueryIdsInput(options.queriesNotRun);
|
||||
|
||||
const queriesThatShouldNotHaveRunButDid = expectedQueriesNotRun.filter(
|
||||
(queryId) => ruleIds.includes(queryId),
|
||||
);
|
||||
|
||||
if (queriesThatShouldNotHaveRunButDid.length > 0) {
|
||||
core.error(
|
||||
`The following queries were NOT expected to have run but did: ${queriesThatShouldNotHaveRunButDid.join(", ")}`,
|
||||
);
|
||||
exitCode = -2;
|
||||
}
|
||||
|
||||
core.startGroup("All queries that ran");
|
||||
for (const rule of rules) {
|
||||
core.info(`${rule.id}: ${rule.properties?.name || rule.name}`);
|
||||
}
|
||||
core.endGroup();
|
||||
|
||||
core.startGroup("Full SARIF");
|
||||
core.info(JSON.stringify(sarif, null, 2));
|
||||
core.endGroup();
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const options = getOptions();
|
||||
const sarif: Log = JSON.parse(fs.readFileSync(options.sarifFile, "utf8"));
|
||||
|
||||
return checkSarif(sarif, options);
|
||||
} catch (err) {
|
||||
core.error(`Failed to check SARIF file: ${getErrorMessage(err)}`);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { execFileSync, ExecFileSyncOptions } from "node:child_process";
|
||||
|
||||
import { DryRunOption, REPO_ROOT } from "./config";
|
||||
|
||||
/** Options for {@link runCommand}. */
|
||||
export interface RunCommandOptions extends DryRunOption {
|
||||
/** Options for `execFileSync`. */
|
||||
execOptions?: ExecFileSyncOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command, streaming output to the console by default.
|
||||
*
|
||||
* @param command The name of the command to run.
|
||||
* @param args The arguments for the command.
|
||||
* @throws When the process exits with a non-zero exit code.
|
||||
* @param options How to run the command.
|
||||
*/
|
||||
export function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: RunCommandOptions,
|
||||
) {
|
||||
if (!options?.dryRun) {
|
||||
console.log(`Running \`${command} ${args.join(" ")}\`.`);
|
||||
return execFileSync(command, args, {
|
||||
stdio: "inherit",
|
||||
cwd: REPO_ROOT,
|
||||
...options?.execOptions,
|
||||
});
|
||||
} else {
|
||||
console.info(
|
||||
`[DRY RUN] Would have executed '${command} ${args.join(" ")}'`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Options for {@link runGit}. */
|
||||
export interface RunGitOptions extends DryRunOption {
|
||||
/** When true, non-zero exit codes will not throw. */
|
||||
allowNonZeroExitCode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `git` with the given `args` and returns the stdout.
|
||||
*
|
||||
* @param args - Arguments to pass to `git`.
|
||||
* @param options - Optional settings.
|
||||
* @throws If `git` does not exit successfully, unless
|
||||
* `options.allowNonZeroExitCode` is `true`.
|
||||
* @returns The trimmed stdout output.
|
||||
*/
|
||||
export function runGit(args: string[], options?: RunGitOptions): string {
|
||||
const execOptions: ExecFileSyncOptions = {
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
try {
|
||||
const result = runCommand("git", args, {
|
||||
dryRun: options?.dryRun,
|
||||
execOptions,
|
||||
}) as string;
|
||||
return result.trimEnd();
|
||||
} catch (error: unknown) {
|
||||
if (options?.allowNonZeroExitCode) {
|
||||
// execFileSync throws an object with `stdout` when the process exits
|
||||
// with a non-zero code.
|
||||
const execError = error as { stdout?: Buffer | string };
|
||||
if (typeof execError.stdout === "string") {
|
||||
return execError.stdout.trimEnd();
|
||||
}
|
||||
if (Buffer.isBuffer(execError.stdout)) {
|
||||
return execError.stdout.toString("utf8").trimEnd();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -22,7 +22,13 @@ export const CHANGELOG_FILE = path.join(REPO_ROOT, "CHANGELOG.md");
|
||||
export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json");
|
||||
|
||||
/** The `src` directory. */
|
||||
const SOURCE_ROOT = path.join(REPO_ROOT, "src");
|
||||
export const SOURCE_ROOT = path.join(REPO_ROOT, "src");
|
||||
|
||||
/** The `src` directory. */
|
||||
export const LIB_ROOT = path.join(REPO_ROOT, "lib");
|
||||
|
||||
/** The path to `defaults.json`. */
|
||||
export const DEFAULTS_FILE = path.join(SOURCE_ROOT, "defaults.json");
|
||||
|
||||
/** The path to the built-in languages file. */
|
||||
export const BUILTIN_LANGUAGES_FILE = path.join(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Tests for the update-bundle.ts script.
|
||||
*/
|
||||
|
||||
import * as assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { Defaults, getNewDefaults } from "./update-bundle";
|
||||
|
||||
const testDefaults: Defaults = {
|
||||
bundleVersion: "codeql-bundle-v2.26.2",
|
||||
cliVersion: "2.26.2",
|
||||
priorBundleVersion: "codeql-bundle-v2.26.1",
|
||||
priorCliVersion: "2.26.1",
|
||||
};
|
||||
|
||||
describe("getNewDefaults", async () => {
|
||||
await it("throws if there is no cli-version-*.txt asset", async () => {
|
||||
assert.throws(
|
||||
() => getNewDefaults({ tag_name: "foo", assets: [] }, testDefaults),
|
||||
{ message: "Failed to find the CodeQL CLI version for release foo." },
|
||||
);
|
||||
});
|
||||
|
||||
await it("throws if there are multiple cli-version-*.txt assets", async () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
getNewDefaults(
|
||||
{
|
||||
tag_name: "foo",
|
||||
assets: [
|
||||
{ name: "cli-version-foo.txt" },
|
||||
{ name: "cli-version-bar.txt" },
|
||||
],
|
||||
},
|
||||
testDefaults,
|
||||
),
|
||||
{ message: "Release foo has multiple CLI version marker files." },
|
||||
);
|
||||
});
|
||||
|
||||
await it("finds the new bundle info", async () => {
|
||||
const newDefaults = getNewDefaults(
|
||||
{
|
||||
tag_name: "foo",
|
||||
assets: [{ name: "cli-version-1.2.3.txt" }],
|
||||
},
|
||||
testDefaults,
|
||||
);
|
||||
|
||||
assert.deepEqual(newDefaults, {
|
||||
bundleVersion: "foo",
|
||||
cliVersion: "1.2.3",
|
||||
priorBundleVersion: testDefaults.bundleVersion,
|
||||
priorCliVersion: testDefaults.cliVersion,
|
||||
} satisfies Defaults);
|
||||
});
|
||||
});
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/** Updates 'src/defaults.json' to point to a new CodeQL bundle release. */
|
||||
|
||||
import * as fs from "fs";
|
||||
|
||||
import * as github from "@actions/github";
|
||||
|
||||
import * as defaults from "../src/defaults.json";
|
||||
|
||||
import { DEFAULTS_FILE } from "./config";
|
||||
|
||||
interface BundleInfo {
|
||||
bundleVersion: string;
|
||||
cliVersion: string;
|
||||
}
|
||||
|
||||
export type Defaults = typeof defaults;
|
||||
|
||||
interface Release {
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function getCodeQLCliVersionForRelease(release: Release): string {
|
||||
// We do not currently tag CodeQL bundles based on the CLI version they contain.
|
||||
// Instead, we use a marker file `cli-version-<version>.txt` to record the CLI version.
|
||||
// This marker file is uploaded as a release asset for all new CodeQL bundles.
|
||||
const cliVersionsFromMarkerFiles = release.assets
|
||||
.map((asset) => asset.name.match(/cli-version-(.*)\.txt/)?.[1])
|
||||
.filter((v) => v)
|
||||
.map((v) => v as string);
|
||||
if (cliVersionsFromMarkerFiles.length > 1) {
|
||||
throw new Error(
|
||||
`Release ${release.tag_name} has multiple CLI version marker files.`,
|
||||
);
|
||||
} else if (cliVersionsFromMarkerFiles.length === 0) {
|
||||
throw new Error(
|
||||
`Failed to find the CodeQL CLI version for release ${release.tag_name}.`,
|
||||
);
|
||||
}
|
||||
return cliVersionsFromMarkerFiles[0];
|
||||
}
|
||||
|
||||
function getBundleInfoFromRelease(release: Release): BundleInfo {
|
||||
return {
|
||||
bundleVersion: release.tag_name,
|
||||
cliVersion: getCodeQLCliVersionForRelease(release),
|
||||
};
|
||||
}
|
||||
|
||||
export function getNewDefaults(
|
||||
release: Release,
|
||||
currentDefaults: Defaults,
|
||||
): Defaults {
|
||||
console.log(
|
||||
"Updating default bundle as a result of the following release: " +
|
||||
`${JSON.stringify(release)}.`,
|
||||
);
|
||||
|
||||
const bundleInfo = getBundleInfoFromRelease(release);
|
||||
|
||||
return {
|
||||
bundleVersion: bundleInfo.bundleVersion,
|
||||
cliVersion: bundleInfo.cliVersion,
|
||||
priorBundleVersion: currentDefaults.bundleVersion,
|
||||
priorCliVersion: currentDefaults.cliVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const release: Release = github.context.payload.release;
|
||||
|
||||
if (release === undefined) {
|
||||
console.error(`Release payload is undefined.`);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const previousDefaults = defaults;
|
||||
const newDefaults = getNewDefaults(release, previousDefaults);
|
||||
|
||||
// Update the source file in the repository. Calling workflows should subsequently rebuild
|
||||
// the Action to update `lib/defaults.json`.
|
||||
fs.writeFileSync(DEFAULTS_FILE, `${JSON.stringify(newDefaults, null, 2)}\n`);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.exit(main());
|
||||
}
|
||||
@@ -18,12 +18,12 @@
|
||||
* [--dry-run]
|
||||
*/
|
||||
|
||||
import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { type ApiClient, getApiClient } from "./api-client";
|
||||
import * as changelog from "./changelog";
|
||||
import { DryRunOption, REPO_ROOT } from "./config";
|
||||
import { runCommand, runGit } from "./command";
|
||||
import {
|
||||
getCurrentVersion,
|
||||
replaceVersionInPackageJson,
|
||||
@@ -65,84 +65,6 @@ export function getGitHubToken(): string {
|
||||
throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN.");
|
||||
}
|
||||
|
||||
/** Options for {@link runCommand}. */
|
||||
export interface RunCommandOptions extends DryRunOption {
|
||||
/** Options for `execFileSync`. */
|
||||
execOptions?: ExecFileSyncOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command, streaming output to the console by default.
|
||||
*
|
||||
* @param command The name of the command to run.
|
||||
* @param args The arguments for the command.
|
||||
* @throws When the process exits with a non-zero exit code.
|
||||
* @param options How to run the command.
|
||||
*/
|
||||
export function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: RunCommandOptions,
|
||||
) {
|
||||
if (!options?.dryRun) {
|
||||
console.log(`Running \`${command} ${args.join(" ")}\`.`);
|
||||
return execFileSync(command, args, {
|
||||
stdio: "inherit",
|
||||
cwd: REPO_ROOT,
|
||||
...options?.execOptions,
|
||||
});
|
||||
} else {
|
||||
console.info(
|
||||
`[DRY RUN] Would have executed '${command} ${args.join(" ")}'`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Options for {@link runGit}. */
|
||||
export interface RunGitOptions extends DryRunOption {
|
||||
/** When true, non-zero exit codes will not throw. */
|
||||
allowNonZeroExitCode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `git` with the given `args` and returns the stdout.
|
||||
*
|
||||
* @param args - Arguments to pass to `git`.
|
||||
* @param options - Optional settings.
|
||||
* @throws If `git` does not exit successfully, unless
|
||||
* `options.allowNonZeroExitCode` is `true`.
|
||||
* @returns The trimmed stdout output.
|
||||
*/
|
||||
export function runGit(args: string[], options?: RunGitOptions): string {
|
||||
const execOptions: ExecFileSyncOptions = {
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
try {
|
||||
const result = runCommand("git", args, {
|
||||
dryRun: options?.dryRun,
|
||||
execOptions,
|
||||
}) as string;
|
||||
return result.trimEnd();
|
||||
} catch (error: unknown) {
|
||||
if (options?.allowNonZeroExitCode) {
|
||||
// execFileSync throws an object with `stdout` when the process exits
|
||||
// with a non-zero code.
|
||||
const execError = error as { stdout?: Buffer | string };
|
||||
if (typeof execError.stdout === "string") {
|
||||
return execError.stdout.trimEnd();
|
||||
}
|
||||
if (Buffer.isBuffer(execError.stdout)) {
|
||||
return execError.stdout.toString("utf8").trimEnd();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the given branch exists on the origin remote. */
|
||||
export function branchExistsOnRemote(branchName: string): boolean {
|
||||
const result = runGit(["ls-remote", "--heads", ORIGIN, branchName]);
|
||||
|
||||
Reference in New Issue
Block a user