SQSCANGHA-140 Implement OpenPGP signature verification for scanner downloads

Add GPG signature verification to ensure downloaded Sonar Scanner CLI binaries
are authentic and haven't been tampered with. This implements supply chain
security by verifying signatures against SonarSource's public key.

Changes:
- Add gpg-verification.js module with signature verification logic
- Download and verify .asc signature files alongside scanner ZIPs
- Import SonarSource public key from keyserver.ubuntu.com
- Add skipSignatureVerification input parameter (default: false)
- Add @actions/exec dependency for cross-platform GPG execution
- Add comprehensive unit tests for verification functions
- Update dist with bundled changes

Verification is enabled by default and uses an isolated temporary GPG home
directory to avoid polluting user's keyring. All temporary files are cleaned
up properly, even on errors.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claire Villard 2026-04-27 17:54:16 +02:00
parent 30dbe5c9ee
commit e8b2382915
13 changed files with 33049 additions and 21 deletions

View file

@ -0,0 +1,130 @@
/*
* sonarqube-scan-action
* Copyright (C) 2025 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { describe, it, afterEach } from "node:test";
import assert from "node:assert/strict";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import {
getGpgCommand,
setupGpgHome,
cleanupGpgHome,
} from "../gpg-verification.js";
describe("gpg-verification", () => {
let tempDirs = [];
afterEach(() => {
// Clean up any temporary directories created during tests
tempDirs.forEach((dir) => {
try {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
} catch (error) {
// Ignore cleanup errors
}
});
tempDirs = [];
});
describe("getGpgCommand", () => {
it("should return 'gpg' as the command", () => {
const command = getGpgCommand();
assert.equal(command, "gpg");
});
});
describe("setupGpgHome", () => {
it("should create a temporary GPG home directory", () => {
const gpgHome = setupGpgHome();
tempDirs.push(gpgHome);
assert.ok(fs.existsSync(gpgHome));
assert.ok(fs.statSync(gpgHome).isDirectory());
// Check directory permissions (on Unix systems)
if (process.platform !== "win32") {
const stats = fs.statSync(gpgHome);
const mode = stats.mode & parseInt("777", 8);
assert.equal(mode, parseInt("700", 8));
}
});
it("should create unique directories on multiple calls", async () => {
const gpgHome1 = setupGpgHome();
// Small delay to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const gpgHome2 = setupGpgHome();
tempDirs.push(gpgHome1, gpgHome2);
assert.notEqual(gpgHome1, gpgHome2);
assert.ok(fs.existsSync(gpgHome1));
assert.ok(fs.existsSync(gpgHome2));
});
it("should use RUNNER_TEMP if available", () => {
const originalRunnerTemp = process.env.RUNNER_TEMP;
const testTemp = path.join(os.tmpdir(), `test-runner-temp-${Date.now()}`);
try {
fs.mkdirSync(testTemp, { recursive: true });
process.env.RUNNER_TEMP = testTemp;
const gpgHome = setupGpgHome();
tempDirs.push(gpgHome);
assert.ok(gpgHome.startsWith(testTemp));
assert.ok(fs.existsSync(gpgHome));
} finally {
process.env.RUNNER_TEMP = originalRunnerTemp;
if (fs.existsSync(testTemp)) {
fs.rmSync(testTemp, { recursive: true, force: true });
}
}
});
});
describe("cleanupGpgHome", () => {
it("should remove the GPG home directory", () => {
const gpgHome = setupGpgHome();
assert.ok(fs.existsSync(gpgHome));
cleanupGpgHome(gpgHome);
assert.ok(!fs.existsSync(gpgHome));
});
it("should not throw if directory does not exist", () => {
const nonExistentDir = path.join(os.tmpdir(), `non-existent-${Date.now()}`);
assert.doesNotThrow(() => {
cleanupGpgHome(nonExistentDir);
});
});
it("should handle cleanup errors gracefully", () => {
// This test verifies the function doesn't throw on permission errors
// In practice, permission errors are rare in test environments
assert.doesNotThrow(() => {
cleanupGpgHome("/invalid/path/that/does/not/exist");
});
});
});
});

View file

@ -0,0 +1,189 @@
/*
* sonarqube-scan-action
* Copyright (C) 2025 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
// SonarSource public key fingerprint for verifying scanner signatures
const SONARSOURCE_KEY_FINGERPRINT = "679F1EE92B19609DE816FDE81DB198F93525EC1A";
const DEFAULT_KEYSERVER = "hkps://keyserver.ubuntu.com";
/**
* Verifies the GPG signature of a downloaded file
* @param {string} zipPath - Path to the downloaded ZIP file
* @param {string} signaturePath - Path to the .asc signature file
* @param {object} options - Verification options
* @param {string} options.keyFingerprint - GPG key fingerprint (default: SonarSource key)
* @param {string} options.keyserver - Keyserver URL (default: keyserver.ubuntu.com)
* @returns {Promise<void>}
* @throws {Error} If GPG is unavailable or verification fails
*/
export async function verifySignature(zipPath, signaturePath, options = {}) {
const keyFingerprint = options.keyFingerprint || SONARSOURCE_KEY_FINGERPRINT;
const keyserver = options.keyserver || DEFAULT_KEYSERVER;
// Check GPG availability
if (!(await isGpgAvailable())) {
throw new Error(
"GPG is not available. Install GPG or set skipSignatureVerification: true"
);
}
let gpgHome;
try {
// Create temporary GPG home directory
gpgHome = setupGpgHome();
core.debug(`Created temporary GPG home: ${gpgHome}`);
// Import SonarSource public key
await importSonarSourceKey(gpgHome, keyFingerprint, keyserver);
core.info("✓ SonarSource public key imported successfully");
// Run GPG verification
await runGpgVerify(zipPath, signaturePath, gpgHome);
core.info("✓ GPG signature verification passed");
} finally {
// Cleanup temporary GPG home directory
if (gpgHome) {
cleanupGpgHome(gpgHome);
}
}
}
/**
* Checks if GPG is available on the system
* @returns {Promise<boolean>} True if GPG is available
*/
export async function isGpgAvailable() {
try {
const gpgCommand = getGpgCommand();
await exec.exec(gpgCommand, ["--version"], {
silent: true,
ignoreReturnCode: false,
});
return true;
} catch (error) {
core.debug(`GPG not available: ${error.message}`);
return false;
}
}
/**
* Gets the GPG command for the current platform
* @returns {string} GPG command name
*/
export function getGpgCommand() {
// GPG is available as 'gpg' on all GitHub-hosted runners
return "gpg";
}
/**
* Creates a temporary GPG home directory
* @returns {string} Path to the temporary GPG home directory
*/
export function setupGpgHome() {
const tempDir = process.env.RUNNER_TEMP || os.tmpdir();
const gpgHome = path.join(tempDir, `gpg-home-${Date.now()}-${process.pid}`);
fs.mkdirSync(gpgHome, { recursive: true, mode: 0o700 });
return gpgHome;
}
/**
* Imports the SonarSource public key from a keyserver
* @param {string} gpgHome - Path to GPG home directory
* @param {string} keyFingerprint - Public key fingerprint
* @param {string} keyserver - Keyserver URL
* @returns {Promise<void>}
* @throws {Error} If key import fails
*/
export async function importSonarSourceKey(gpgHome, keyFingerprint, keyserver) {
const gpgCommand = getGpgCommand();
try {
core.info(`Importing SonarSource public key from ${keyserver}...`);
await exec.exec(
gpgCommand,
[
"--homedir",
gpgHome,
"--batch",
"--keyserver",
keyserver,
"--recv-keys",
keyFingerprint,
],
{
silent: false,
}
);
} catch (error) {
throw new Error(
`Failed to import SonarSource public key from keyserver: ${error.message}`
);
}
}
/**
* Runs GPG verification on the downloaded file
* @param {string} zipPath - Path to the ZIP file
* @param {string} signaturePath - Path to the signature file
* @param {string} gpgHome - Path to GPG home directory
* @returns {Promise<void>}
* @throws {Error} If verification fails
*/
export async function runGpgVerify(zipPath, signaturePath, gpgHome) {
const gpgCommand = getGpgCommand();
try {
core.info("Verifying GPG signature...");
await exec.exec(
gpgCommand,
["--homedir", gpgHome, "--batch", "--verify", signaturePath, zipPath],
{
silent: false,
}
);
} catch (error) {
throw new Error(
`GPG signature verification failed - file may be corrupted or tampered: ${error.message}`
);
}
}
/**
* Cleans up the temporary GPG home directory
* @param {string} gpgHome - Path to GPG home directory
*/
export function cleanupGpgHome(gpgHome) {
try {
if (fs.existsSync(gpgHome)) {
fs.rmSync(gpgHome, { recursive: true, force: true });
core.debug(`Cleaned up temporary GPG home: ${gpgHome}`);
}
} catch (error) {
// Non-critical error, just log a warning
core.warning(`Failed to cleanup temporary GPG home: ${error.message}`);
}
}

View file

@ -34,8 +34,9 @@ function getInputs() {
const projectBaseDir = core.getInput("projectBaseDir");
const scannerBinariesUrl = core.getInput("scannerBinariesUrl");
const scannerVersion = core.getInput("scannerVersion");
const skipSignatureVerification = core.getBooleanInput("skipSignatureVerification");
return { args, projectBaseDir, scannerBinariesUrl, scannerVersion };
return { args, projectBaseDir, scannerBinariesUrl, scannerVersion, skipSignatureVerification };
}
/**
@ -71,7 +72,7 @@ function runSanityChecks(inputs) {
async function run() {
try {
const { args, projectBaseDir, scannerVersion, scannerBinariesUrl } =
const { args, projectBaseDir, scannerVersion, scannerBinariesUrl, skipSignatureVerification } =
getInputs();
const runnerEnv = getEnvVariables();
const { sonarToken } = runnerEnv;
@ -81,6 +82,7 @@ async function run() {
const scannerDir = await installSonarScanner({
scannerVersion,
scannerBinariesUrl,
skipSignatureVerification,
});
await runSonarScanner(args, projectBaseDir, scannerDir, runnerEnv);

View file

@ -25,6 +25,7 @@ import {
getScannerDownloadURL,
scannerDirName,
} from "./utils";
import { verifySignature } from "./gpg-verification";
const TOOLNAME = "sonar-scanner-cli";
@ -34,6 +35,7 @@ const TOOLNAME = "sonar-scanner-cli";
export async function installSonarScanner({
scannerVersion,
scannerBinariesUrl,
skipSignatureVerification = false,
}) {
const flavor = getPlatformFlavor(os.platform(), os.arch());
@ -54,6 +56,26 @@ export async function installSonarScanner({
core.info(`Downloading from: ${downloadUrl}`);
const downloadPath = await tc.downloadTool(downloadUrl);
// Download and verify signature (unless skipped)
if (skipSignatureVerification) {
core.warning("⚠ Skipping GPG signature verification (not recommended)");
} else {
const signatureUrl = `${downloadUrl}.asc`;
core.info(`Downloading signature from: ${signatureUrl}`);
let signaturePath;
try {
signaturePath = await tc.downloadTool(signatureUrl);
} catch (error) {
throw new Error(
`Failed to download signature file from ${signatureUrl}: ${error.message}`
);
}
await verifySignature(downloadPath, signaturePath);
}
const extractedPath = await tc.extractZip(downloadPath);
// Find the actual scanner directory inside the extracted folder