SQSCANGHA-140 Add GPG signature verification for scanner downloads

Implemented OpenPGP signature verification to ensure the integrity and authenticity of downloaded SonarQube scanner packages. This security enhancement protects against supply chain attacks.

Key implementation decisions:
  - GPG verification runs by default for all scanner downloads, with an optional skipSignatureVerification flag for environments where GPG is unavailable
  - Dual keyserver strategy: attempts primary keyserver (keyserver.ubuntu.com) with automatic fallback to keys.openpgp.org if the primary fails, improving reliability across different network environments
  - Platform-specific path handling: converts Windows paths to Unix-style format for GPG compatibility, as GPG from Git for Windows expects Unix-style paths even on Windows systems
  - Isolated verification: uses temporary GPG home directories to avoid polluting user keyring, with guaranteed cleanup in finally blocks to prevent temp file leakage even on verification failures
  - Security-first error handling: throws clear errors when GPG is absent or signatures fail, preventing silent security bypasses

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 e1c6b579ce
17 changed files with 33778 additions and 41 deletions

22
.sonarcloud.properties Normal file
View file

@ -0,0 +1,22 @@
# Path to sources
sonar.sources=src
sonar.exclusions=src/**/__tests__/*
# sonar.inclusions=
# Path to tests
sonar.tests=test,src
# sonar.test.exclusions=
sonar.test.inclusions=src/**/__tests__/*
# Source encoding
# sonar.sourceEncoding=
# Exclusions for copy-paste detection
# sonar.cpd.exclusions=
# Python version (for python projects only)
# sonar.python.version=
# C++ standard version (for C++ projects only)
# If not specified, it defaults to the latest supported standard
# sonar.cfamily.reportingCppStandardOverride=c++98|c++11|c++14|c++17|c++20

View file

@ -24,6 +24,10 @@ inputs:
description: URL to download the Sonar Scanner CLI binaries from
required: false
default: https://binaries.sonarsource.com/Distribution/sonar-scanner-cli
skipSignatureVerification:
description: Skip GPG signature verification (not recommended for security)
required: false
default: "false"
runs:
using: node24
main: dist/index.js

31833
dist/exec-zlpfwmpH.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/exec-zlpfwmpH.js.map vendored Normal file

File diff suppressed because one or more lines are too long

916
dist/index.js vendored

File diff suppressed because it is too large Load diff

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
import { h as getExecOutput, b as addPath, i as info, j as setOutput, s as setFailed, e as exec, k as startGroup, l as endGroup } from './core-DpWEmnbG.js';
import { f as execExports, h as addPath, a as info, n as setOutput, s as setFailed, o as startGroup, p as endGroup } from './exec-zlpfwmpH.js';
import * as fs from 'fs';
import * as path from 'path';
import 'os';
@ -124,7 +124,7 @@ function getSuffixAndName(runnerOS, runnerArch) {
async function getRealPath(filePath, runnerOS) {
switch (runnerOS) {
case "Windows": {
const windowsResult = await getExecOutput("cygpath", [
const windowsResult = await execExports.getExecOutput("cygpath", [
"--absolute",
"--windows",
filePath,
@ -132,14 +132,14 @@ async function getRealPath(filePath, runnerOS) {
return windowsResult.stdout.trim();
}
case "Linux": {
const linuxResult = await getExecOutput("readlink", [
const linuxResult = await execExports.getExecOutput("readlink", [
"-f",
filePath,
]);
return linuxResult.stdout.trim();
}
case "macOS": {
const macResult = await getExecOutput("greadlink", ["-f", filePath]);
const macResult = await execExports.getExecOutput("greadlink", ["-f", filePath]);
return macResult.stdout.trim();
}
default:
@ -169,7 +169,7 @@ async function getRealPath(filePath, runnerOS) {
async function installMacOSPackages() {
if (process.platform === "darwin") {
info("Installing required packages for macOS");
await exec("brew", ["install", "coreutils"]);
await execExports.exec("brew", ["install", "coreutils"]);
}
}
@ -207,10 +207,10 @@ async function downloadAndInstallBuildWrapper(downloadUrl, runnerEnv) {
fs.mkdirSync(runnerTemp, { recursive: true });
}
await exec("curl", ["-sSLo", tmpZipPath, downloadUrl]);
await execExports.exec("curl", ["-sSLo", tmpZipPath, downloadUrl]);
info("Decompressing");
await exec("unzip", ["-o", "-d", runnerTemp, tmpZipPath]);
await execExports.exec("unzip", ["-o", "-d", runnerTemp, tmpZipPath]);
endGroup();
}

File diff suppressed because one or more lines are too long

27
package-lock.json generated
View file

@ -10,6 +10,7 @@
"license": "LGPL-3.0-only",
"dependencies": {
"@actions/core": "3.0.0",
"@actions/exec": "2.0.0",
"@actions/github": "9.0.0",
"@actions/tool-cache": "4.0.0",
"string-argv": "0.3.2"
@ -31,7 +32,7 @@
"@actions/http-client": "^4.0.0"
}
},
"node_modules/@actions/exec": {
"node_modules/@actions/core/node_modules/@actions/exec": {
"version": "3.0.0",
"resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
@ -40,6 +41,21 @@
"@actions/io": "^3.0.2"
}
},
"node_modules/@actions/exec": {
"version": "2.0.0",
"resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/@actions/exec/-/exec-2.0.0.tgz",
"integrity": "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw==",
"license": "MIT",
"dependencies": {
"@actions/io": "^2.0.0"
}
},
"node_modules/@actions/exec/node_modules/@actions/io": {
"version": "2.0.0",
"resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/@actions/io/-/io-2.0.0.tgz",
"integrity": "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg==",
"license": "MIT"
},
"node_modules/@actions/github": {
"version": "9.0.0",
"resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/@actions/github/-/github-9.0.0.tgz",
@ -94,6 +110,15 @@
"semver": "^7.7.3"
}
},
"node_modules/@actions/tool-cache/node_modules/@actions/exec": {
"version": "3.0.0",
"resolved": "https://repox.jfrog.io/artifactory/api/npm/npm/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"license": "MIT",
"dependencies": {
"@actions/io": "^3.0.2"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",

View file

@ -6,11 +6,12 @@
"main": "src/main/index.js",
"scripts": {
"build": "rollup --config rollup.config.js",
"test": "node --test"
"test": "node --experimental-test-module-mocks --test"
},
"license": "LGPL-3.0-only",
"dependencies": {
"@actions/core": "3.0.0",
"@actions/exec": "2.0.0",
"@actions/github": "9.0.0",
"@actions/tool-cache": "4.0.0",
"string-argv": "0.3.2"

View file

@ -0,0 +1,486 @@
/*
* 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 assert from "node:assert/strict";
import * as fs from "node:fs";
import {afterEach, describe, it, mock} from "node:test";
import {setupGpgHome,} from "../gpg-verification.js";
/**
* Helper function to create a temporary GPG home directory for testing.
* @param {Array} tempDirs - Array to track temp directories for cleanup
* @returns {string} Path to the created GPG home directory
*/
function createTrackedGpgHome(tempDirs) {
const gpgHome = setupGpgHome();
tempDirs.push(gpgHome);
assert.ok(fs.existsSync(gpgHome));
return gpgHome;
}
describe("gpg-verification with mocked exec", () => {
let tempDirs = [];
afterEach(() => {
// Clean up temporary directories
tempDirs.forEach((dir) => {
try {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
} catch (error) {
// Ignore cleanup errors
}
});
tempDirs = [];
});
describe("isGpgAvailable", () => {
it("should return true when GPG is available", async (t) => {
const execFn = mock.fn(async () => 0);
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { isGpgAvailable } = await import("../gpg-verification.js?test=gpg-available");
const result = await isGpgAvailable();
assert.equal(result, true);
assert.equal(execFn.mock.calls.length, 1);
assert.equal(execFn.mock.calls[0].arguments[0], "gpg");
assert.deepEqual(execFn.mock.calls[0].arguments[1], ["--version"]);
});
it("should return false when GPG is not available", async (t) => {
const execFn = mock.fn(async () => {
throw new Error("GPG not found");
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { isGpgAvailable } = await import("../gpg-verification.js?test=gpg-unavailable");
const result = await isGpgAvailable();
assert.equal(result, false);
});
});
describe("runGpgVerify", () => {
it("should successfully verify valid signature", async (t) => {
const execCalls = [];
const execFn = mock.fn(async (command, args) => {
execCalls.push({ command, args });
return 0;
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { runGpgVerify } = await import("../gpg-verification.js?test=verify-success");
const gpgHome = createTrackedGpgHome(tempDirs);
const zipPath = "/tmp/scanner.zip";
const signaturePath = "/tmp/scanner.zip.asc";
await runGpgVerify(zipPath, signaturePath, gpgHome);
assert.equal(execCalls.length, 1);
assert.equal(execCalls[0].command, "gpg");
assert.ok(execCalls[0].args.includes("--verify"));
assert.ok(execCalls[0].args.includes(signaturePath));
assert.ok(execCalls[0].args.includes(zipPath));
});
it("should throw error when signature verification fails", async (t) => {
const execFn = mock.fn(async () => {
throw new Error("BAD signature");
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { runGpgVerify } = await import("../gpg-verification.js?test=verify-fail");
const gpgHome = createTrackedGpgHome(tempDirs);
const zipPath = "/tmp/scanner.zip";
const signaturePath = "/tmp/scanner.zip.asc";
await assert.rejects(
() => runGpgVerify(zipPath, signaturePath, gpgHome),
{
message: /GPG signature verification failed - file may be corrupted or tampered/
}
);
});
it("should convert Windows paths for GPG", async (t) => {
const execCalls = [];
const execFn = mock.fn(async (command, args) => {
execCalls.push({ command, args });
return 0;
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { runGpgVerify } = await import("../gpg-verification.js?test=verify-windows");
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: "win32",
writable: true,
configurable: true,
});
try {
const gpgHome = createTrackedGpgHome(tempDirs);
const zipPath = String.raw`C:\temp\scanner.zip`;
const signaturePath = String.raw`C:\temp\scanner.zip.asc`;
await runGpgVerify(zipPath, signaturePath, gpgHome);
// Verify paths were converted to Unix format
const args = execCalls[0].args;
const homeDirIndex = args.indexOf("--homedir");
const zipIndex = args.indexOf("--verify") + 1;
// Check that Windows paths are converted (should start with /c/ instead of C:\)
assert.ok(!args[homeDirIndex + 1].includes("\\"));
assert.ok(!args[zipIndex].includes("\\"));
assert.ok(!args[zipIndex + 1].includes("\\"));
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
configurable: true,
});
}
});
});
describe("verifySignature", () => {
it("should successfully verify signature with GPG available", async (t) => {
const execCalls = [];
const execFn = mock.fn(async (command, args) => {
execCalls.push({ command, args });
return 0;
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { verifySignature } = await import("../gpg-verification.js?test=full-verify-success");
const zipPath = "/tmp/scanner.zip";
const signaturePath = "/tmp/scanner.zip.asc";
await verifySignature(zipPath, signaturePath);
assert.equal(execCalls.length, 3);
assert.deepEqual(execCalls[0].args, ["--version"]);
assert.ok(execCalls[1].args.includes("--recv-keys"));
assert.ok(execCalls[2].args.includes("--verify"));
});
it("should throw error when GPG is not available", async (t) => {
const execFn = mock.fn(async (command, args) => {
if (args.includes("--version")) {
throw new Error("GPG not found");
}
return 0;
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { verifySignature } = await import("../gpg-verification.js?test=no-gpg");
const zipPath = "/tmp/scanner.zip";
const signaturePath = "/tmp/scanner.zip.asc";
await assert.rejects(
() => verifySignature(zipPath, signaturePath),
{
message: /GPG is not available/
}
);
});
it("should throw error when signature verification fails", async (t) => {
let callCount = 0;
const execFn = mock.fn(async () => {
callCount++;
// First call: gpg --version (success)
if (callCount === 1) {
return 0;
}
// Second call: recv-keys (success)
if (callCount === 2) {
return 0;
}
// Third call: verify (failure - bad signature)
throw new Error("BAD signature from 679F1EE92B19609DE816FDE81DB198F93525EC1A");
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { verifySignature } = await import("../gpg-verification.js?test=bad-signature");
const zipPath = "/tmp/scanner.zip";
const signaturePath = "/tmp/scanner.zip.asc";
await assert.rejects(
() => verifySignature(zipPath, signaturePath),
{
message: /GPG signature verification failed - file may be corrupted or tampered/
}
);
});
it("should cleanup GPG home directory even on failure", async (t) => {
let createdGpgHome;
let callCount = 0;
const execFn = mock.fn(async (command, args) => {
callCount++;
// First call: gpg --version (success)
if (callCount === 1) {
return 0;
}
// Second call: recv-keys (success)
if (callCount === 2) {
// Capture the GPG home directory from the args
const homeDirIndex = args.indexOf("--homedir");
if (homeDirIndex !== -1) {
createdGpgHome = args[homeDirIndex + 1];
}
return 0;
}
// Third call: verify (failure)
throw new Error("BAD signature");
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { verifySignature } = await import("../gpg-verification.js?test=cleanup-on-fail");
const zipPath = "/tmp/scanner.zip";
const signaturePath = "/tmp/scanner.zip.asc";
await assert.rejects(
() => verifySignature(zipPath, signaturePath),
{
message: /GPG signature verification failed/
}
);
assert.ok(!fs.existsSync(createdGpgHome), "GPG home should have been deleted after failure");
});
it("should use custom keyserver and fingerprint when provided", async (t) => {
const execCalls = [];
const execFn = mock.fn(async (command, args) => {
execCalls.push({ command, args });
return 0;
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { verifySignature } = await import("../gpg-verification.js?test=custom-options");
const zipPath = "/tmp/scanner.zip";
const signaturePath = "/tmp/scanner.zip.asc";
const customKeyserver = "hkps://custom.keyserver.example.com";
const customFingerprint = "ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234";
await verifySignature(zipPath, signaturePath, {
keyserver: customKeyserver,
keyFingerprint: customFingerprint,
});
const recvKeysCall = execCalls.find(call => call.args.includes("--recv-keys"));
assert.ok(recvKeysCall, "Should have recv-keys call");
assert.ok(recvKeysCall.args.includes(customKeyserver));
assert.ok(recvKeysCall.args.includes(customFingerprint));
});
it("should use default keyserver and fingerprint when not provided", async (t) => {
const execCalls = [];
const execFn = mock.fn(async (command, args) => {
execCalls.push({ command, args });
return 0;
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { verifySignature } = await import("../gpg-verification.js?test=default-options");
const zipPath = "/tmp/scanner.zip";
const signaturePath = "/tmp/scanner.zip.asc";
await verifySignature(zipPath, signaturePath);
const recvKeysCall = execCalls.find(call => call.args.includes("--recv-keys"));
assert.ok(recvKeysCall, "Should have recv-keys call");
assert.ok(recvKeysCall.args.includes("hkps://keyserver.ubuntu.com"));
assert.ok(recvKeysCall.args.includes("679F1EE92B19609DE816FDE81DB198F93525EC1A"));
});
});
describe("importSonarSourceKey", () => {
it("should use fallback keyserver when primary fails", async (t) => {
const execCalls = [];
const execFn = mock.fn(async (command, args) => {
execCalls.push({ command, args });
const argsString = args.join(" ");
if (argsString.includes("invalid.keyserver.that.does.not.exist.example.com")) {
throw new Error("Failed to import key from invalid keyserver");
}
if (argsString.includes("keys.openpgp.org")) {
return 0;
}
return 0;
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { importSonarSourceKey } = await import("../gpg-verification.js?test=fallback");
const gpgHome = createTrackedGpgHome(tempDirs);
const invalidKeyserver = "hkps://invalid.keyserver.that.does.not.exist.example.com";
const keyFingerprint = "679F1EE92B19609DE816FDE81DB198F93525EC1A";
await importSonarSourceKey(gpgHome, keyFingerprint, invalidKeyserver);
assert.equal(execCalls.length, 2, "Should attempt two keyservers");
// Verify primary keyserver call
assert.equal(execCalls[0].command, "gpg");
assert.ok(execCalls[0].args.includes(invalidKeyserver));
assert.ok(execCalls[0].args.includes(keyFingerprint));
// Verify fallback keyserver call
assert.equal(execCalls[1].command, "gpg");
assert.ok(execCalls[1].args.includes("hkps://keys.openpgp.org"));
assert.ok(execCalls[1].args.includes(keyFingerprint));
});
it("should succeed with valid keyserver", async (t) => {
const execCalls = [];
const execFn = mock.fn(async (command, args) => {
execCalls.push({ command, args });
return 0;
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { importSonarSourceKey } = await import("../gpg-verification.js?test=valid");
const gpgHome = createTrackedGpgHome(tempDirs);
const keyserver = "hkps://keyserver.ubuntu.com";
const keyFingerprint = "679F1EE92B19609DE816FDE81DB198F93525EC1A";
await importSonarSourceKey(gpgHome, keyFingerprint, keyserver);
assert.equal(execCalls.length, 1);
assert.equal(execCalls[0].command, "gpg");
assert.ok(execCalls[0].args.includes(keyserver));
assert.ok(execCalls[0].args.includes(keyFingerprint));
assert.ok(execCalls[0].args.includes("--recv-keys"));
});
it("should throw error when both keyservers fail", async (t) => {
const execFn = mock.fn(async () => {
throw new Error("Connection failed");
});
t.mock.module("@actions/exec", {
namedExports: {
exec: execFn,
},
});
const { importSonarSourceKey } = await import("../gpg-verification.js?test=both-fail");
const gpgHome = createTrackedGpgHome(tempDirs);
const keyserver = "hkps://keyserver.ubuntu.com";
const keyFingerprint = "679F1EE92B19609DE816FDE81DB198F93525EC1A";
await assert.rejects(
() => importSonarSourceKey(gpgHome, keyFingerprint, keyserver),
{
message: /Failed to import SonarSource public key from all keyservers/
}
);
});
});
});

View file

@ -0,0 +1,228 @@
/*
* 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 "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
getGpgCommand,
setupGpgHome,
cleanupGpgHome,
convertToUnixPath,
} from "../gpg-verification.js";
/**
* Helper function to temporarily mock process.platform for a test.
* Automatically restores the original platform value after the test.
* @param {string} platform - The platform to mock (e.g., "win32", "linux")
* @param {Function} testFn - The test function to run with the mocked platform
*/
function withMockedPlatform(platform, testFn) {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: platform,
writable: true,
configurable: true,
});
try {
testFn();
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
configurable: true,
});
}
}
/**
* Helper function to create a GPG home directory and track it for cleanup.
* @param {Array} tempDirs - Array to track temporary directories for cleanup
* @returns {string} The path to the created GPG home directory
*/
function createTrackedGpgHome(tempDirs) {
const gpgHome = setupGpgHome();
tempDirs.push(gpgHome);
assert.ok(fs.existsSync(gpgHome));
return gpgHome;
}
/**
* Helper function to temporarily mock environment variables for a test.
* Automatically restores or deletes environment variables after the test.
* @param {Object} envVars - Object with environment variable names as keys and values as values
* @param {Function} testFn - The async test function to run with the mocked environment
*/
async function withMockedEnv(envVars, testFn) {
const originalValues = {};
// Save original values and set new ones
for (const [key, value] of Object.entries(envVars)) {
originalValues[key] = process.env[key];
process.env[key] = value;
}
try {
await testFn();
} finally {
// Restore or delete environment variables
for (const [key, originalValue] of Object.entries(originalValues)) {
if (originalValue === undefined) {
delete process.env[key];
} else {
process.env[key] = originalValue;
}
}
}
}
/**
* Helper function to create a temporary directory.
* @returns {string} The path to the created temporary directory
*/
function createTempDir() {
const tempDir = path.join(os.tmpdir(), `test-runner-temp-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
return tempDir;
}
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("convertToUnixPath", () => {
it("should convert Windows path with drive letter to Unix path", () => {
withMockedPlatform("win32", () => {
assert.equal(
convertToUnixPath(String.raw`C:\a\_temp\gpg-home`),
"/c/a/_temp/gpg-home"
);
assert.equal(
convertToUnixPath(String.raw`D:\Users\test\file.txt`),
"/d/Users/test/file.txt"
);
});
});
it("should handle mixed slashes on Windows", () => {
withMockedPlatform("win32", () => {
assert.equal(
convertToUnixPath(String.raw`C:\a/_temp\gpg-home`),
"/c/a/_temp/gpg-home"
);
});
});
it("should return path unchanged on non-Windows platforms", () => {
withMockedPlatform("linux", () => {
assert.equal(
convertToUnixPath("/tmp/gpg-home"),
"/tmp/gpg-home"
);
});
});
});
describe("setupGpgHome", () => {
it("should create a temporary GPG home directory", () => {
const gpgHome = createTrackedGpgHome(tempDirs);
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 & Number.parseInt("777", 8);
assert.equal(mode, Number.parseInt("700", 8));
}
});
it("should create unique directories on multiple calls", async () => {
const gpgHome1 = createTrackedGpgHome(tempDirs);
// Small delay to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const gpgHome2 = createTrackedGpgHome(tempDirs);
assert.notEqual(gpgHome1, gpgHome2);
});
it("should use RUNNER_TEMP if available", async () => {
const testTemp = createTempDir();
await withMockedEnv({ RUNNER_TEMP: testTemp }, async () => {
const gpgHome = createTrackedGpgHome(tempDirs);
assert.ok(gpgHome.startsWith(testTemp));
});
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,240 @@
/*
* 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 "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const SONARSOURCE_KEY_FINGERPRINT = "679F1EE92B19609DE816FDE81DB198F93525EC1A";
const DEFAULT_KEYSERVER = "hkps://keyserver.ubuntu.com";
const FALLBACK_KEYSERVER = "hkps://keys.openpgp.org";
/**
* 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 - Primary keyserver URL (default: keyserver.ubuntu.com, with fallback to keys.openpgp.org)
* @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;
if (!(await isGpgAvailable())) {
throw new Error(
"GPG is not available. Install GPG or set skipSignatureVerification: true"
);
}
let gpgHome;
try {
gpgHome = setupGpgHome();
core.debug(`Created temporary GPG home: ${gpgHome}`);
await importSonarSourceKey(gpgHome, keyFingerprint, keyserver);
core.info("✓ SonarSource public key imported successfully");
await runGpgVerify(zipPath, signaturePath, gpgHome);
core.info("✓ GPG signature verification passed");
} finally {
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";
}
/**
* Converts a Windows path to Unix-style path for GPG
* GPG on Windows (from Git for Windows) expects Unix-style paths
* @param {string} windowsPath - Windows path (e.g., C:\a\_temp\gpg-home)
* @returns {string} Unix-style path (e.g., /c/a/_temp/gpg-home)
*/
export function convertToUnixPath(windowsPath) {
if (process.platform !== "win32") {
return windowsPath;
}
let unixPath = windowsPath.replaceAll('\\', "/");
unixPath = unixPath.replace(/^([A-Za-z]):/, (match, drive) => {
return `/${drive.toLowerCase()}`;
});
return unixPath;
}
/**
* 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;
}
/**
* Attempts to import a public key from a specific 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
*/
async function tryImportKey(gpgHome, keyFingerprint, keyserver) {
const gpgCommand = getGpgCommand();
const gpgHomePath = convertToUnixPath(gpgHome);
await exec.exec(
gpgCommand,
[
"--homedir",
gpgHomePath,
"--batch",
"--keyserver",
keyserver,
"--recv-keys",
keyFingerprint,
],
{
silent: false,
}
);
}
/**
* 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) {
let primaryError;
try {
core.info(`Importing SonarSource public key from ${keyserver}...`);
await tryImportKey(gpgHome, keyFingerprint, keyserver);
core.info(`Successfully imported key from ${keyserver}`);
return;
} catch (error) {
primaryError = error;
core.warning(
`Failed to import key from ${keyserver}: ${error.message}`
);
}
try {
core.info(`Attempting fallback keyserver ${FALLBACK_KEYSERVER}...`);
await tryImportKey(gpgHome, keyFingerprint, FALLBACK_KEYSERVER);
core.info(`Successfully imported key from fallback keyserver ${FALLBACK_KEYSERVER}`);
} catch (fallbackError) {
throw new Error(
`Failed to import SonarSource public key from all keyservers. ` +
`Primary (${keyserver}): ${primaryError.message}. ` +
`Fallback (${FALLBACK_KEYSERVER}): ${fallbackError.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",
convertToUnixPath(gpgHome),
"--batch",
"--verify",
convertToUnixPath(signaturePath),
convertToUnixPath(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) {
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

@ -18,13 +18,14 @@
import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
import * as os from "os";
import * as path from "path";
import * as os from "node:os";
import * as path from "node:path";
import {
getPlatformFlavor,
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,25 @@ export async function installSonarScanner({
core.info(`Downloading from: ${downloadUrl}`);
const downloadPath = await tc.downloadTool(downloadUrl);
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

View file

@ -18,9 +18,9 @@
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";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { parseArgsStringToArgv } from "string-argv";
const KEYTOOL_MAIN_CLASS = "sun.security.tools.keytool.Main";

View file

@ -16,8 +16,8 @@
// along with this program; if not, write to the Free Software Foundation,
// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import fs from "fs";
import { join } from "path";
import fs from "node:fs";
import { join } from "node:path";
export function validateScannerVersion(version) {
if (!version) {