Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions __tests__/main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'node:path';
import { gunzipSync } from 'node:zlib';

const mockInfo = jest.fn();
const mockDebug = jest.fn();
const mockSetFailed = jest.fn();
const mockWarning = jest.fn();
const mockGetInput = jest.fn();
Expand All @@ -21,6 +22,7 @@ function decodeCoverageContent(encoded) {

jest.unstable_mockModule('@actions/core', () => ({
info: mockInfo,
debug: mockDebug,
setFailed: mockSetFailed,
warning: mockWarning,
getInput: mockGetInput,
Expand Down Expand Up @@ -60,6 +62,7 @@ describe('main.js security - single file path validation', () => {

// Reset all mocks
mockInfo.mockClear();
mockDebug.mockClear();
mockSetFailed.mockClear();
mockWarning.mockClear();
mockGetInput.mockClear();
Expand Down Expand Up @@ -385,6 +388,54 @@ describe('main.js security - single file path validation', () => {
});
});

describe('glob support', () => {
it('expands a glob pattern and merges the matched reports', async () => {
const previousCwd = process.cwd();
process.chdir(tmpDir);

try {
await fs.mkdir('packages/a/coverage', { recursive: true });
await fs.mkdir('packages/b/coverage', { recursive: true });
await fs.writeFile(
'packages/a/coverage/lcov.info',
'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n',
);
await fs.writeFile(
'packages/b/coverage/lcov.info',
'TN:\nSF:src/b.js\nDA:1,3\nend_of_record\n',
);

mockGetInput.mockReturnValue('packages/*/coverage/lcov.info');

await run();

expect(mockSetFailed).not.toHaveBeenCalled();
expect(mockPost).toHaveBeenCalled();
expect(mockInfo).toHaveBeenCalledWith('Upload succeeded.');
} finally {
process.chdir(previousCwd);
}
});

it('fails when a glob pattern matches no files', async () => {
const previousCwd = process.cwd();
process.chdir(tmpDir);

try {
mockGetInput.mockReturnValue('packages/*/coverage/lcov.info');

await run();

expect(mockSetFailed).toHaveBeenCalledWith(
expect.stringContaining('No file(s) found matching'),
);
expect(mockPost).not.toHaveBeenCalled();
} finally {
process.chdir(previousCwd);
}
});
});

describe('fail-on-error behavior', () => {
it('uses warning instead of setFailed when fail-on-error is false', async () => {
const previousCwd = process.cwd();
Expand Down
5 changes: 0 additions & 5 deletions __tests__/mergeLcov.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,6 @@ end_of_record
await expect(readMerged(['abs.lcov'])).rejects.toThrow(/Invalid source path/);
});

it('throws on absolute or parent input paths', async () => {
await expect(mergeLcov(['/tmp/a.lcov'])).rejects.toThrow(/Invalid file path/);
await expect(mergeLcov(['../a.lcov'])).rejects.toThrow(/Invalid file path/);
});

it('skips empty coverage inputs when aligning path roots', async () => {
const job1 = `TN:empty
`;
Expand Down
85 changes: 85 additions & 0 deletions __tests__/resolveLcovFilePaths.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveLcovFilePaths } from '../src/resolveLcovFilePaths.js';

describe('resolveLcovFilePaths', () => {
let tmpDir;
let previousCwd;

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'resolve-lcov-'));
previousCwd = process.cwd();
process.chdir(tmpDir);
});

afterEach(async () => {
process.chdir(previousCwd);
await fs.rm(tmpDir, { recursive: true, force: true });
});

it('resolves a literal path', async () => {
await fs.writeFile('lcov.info', '');

const resolved = await resolveLcovFilePaths(['lcov.info']);

expect(resolved).toEqual([path.join(tmpDir, 'lcov.info')]);
});

it('expands a glob pattern to every matching file', async () => {
await fs.mkdir('packages/a/coverage', { recursive: true });
await fs.mkdir('packages/b/coverage', { recursive: true });
await fs.writeFile('packages/a/coverage/lcov.info', '');
await fs.writeFile('packages/b/coverage/lcov.info', '');

const resolved = await resolveLcovFilePaths(['packages/*/coverage/lcov.info']);

expect(resolved.sort()).toEqual(
[
path.join(tmpDir, 'packages/a/coverage/lcov.info'),
path.join(tmpDir, 'packages/b/coverage/lcov.info'),
].sort(),
);
});

it('deduplicates files matched by overlapping patterns', async () => {
await fs.mkdir('coverage', { recursive: true });
await fs.writeFile('coverage/lcov.info', '');

const resolved = await resolveLcovFilePaths(['coverage/lcov.info', 'coverage/*.info']);

expect(resolved).toEqual([path.join(tmpDir, 'coverage/lcov.info')]);
});

it('throws when a pattern matches no files', async () => {
await expect(resolveLcovFilePaths(['packages/*/coverage/lcov.info'])).rejects.toThrow(
/No file\(s\) found matching "packages\/\*\/coverage\/lcov.info"/,
);
});

it('rejects an absolute pattern', async () => {
await expect(resolveLcovFilePaths(['/etc/passwd'])).rejects.toThrow(
/Invalid file path: absolute paths and "\.\." segments are not allowed/,
);
});

it('rejects a pattern containing ".." segments', async () => {
await expect(resolveLcovFilePaths(['../etc/passwd'])).rejects.toThrow(
/Invalid file path: absolute paths and "\.\." segments are not allowed/,
);
});

it('rejects a matched file that is a symlink outside the workspace', async () => {
const secret = await fs.mkdtemp(path.join(os.tmpdir(), 'resolve-lcov-secret-'));
await fs.writeFile(path.join(secret, 'passwd'), 'root:x:0:0');
await fs.symlink(path.join(secret, 'passwd'), 'coverage.info');

try {
await expect(resolveLcovFilePaths(['coverage.info'])).rejects.toThrow(
/matched a symlink, which is not allowed/,
);
} finally {
await fs.rm(secret, { recursive: true, force: true });
}
});
});
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ branding:

inputs:
lcov-file-paths:
description: 'Path(s) to the LCOV coverage report(s). Separate multiple entries with newlines'
description: 'Path(s) or glob pattern(s) to the LCOV coverage report(s). Separate multiple entries with newlines'
required: true
fail-on-error:
description: 'Fail the action if discovery or upload fails. Set to false to warn instead.'
Expand Down
57 changes: 57 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
},
"dependencies": {
"@actions/core": "3.0.1",
"@actions/glob": "^0.7.0",
"@actions/http-client": "4.0.1",
"ignore": "^7.0.8"
},
Expand Down
29 changes: 8 additions & 21 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import * as core from '@actions/core';
import { readInputs } from './inputs.js';
import { normalizeLcovSourcePaths } from './lcovPaths.js';
import { resolveLcovFilePaths } from './resolveLcovFilePaths.js';
import { mergeLcov } from './mergeLcov.js';
import { uploadCoverage } from './aikido.js';

/**
* Validate that a file path is safe to read.
* Rejects absolute paths and paths containing '..' segments to prevent
* directory traversal and arbitrary file access.
*/
function validateFilePath(filePath) {
if (filePath.includes('..') || path.isAbsolute(filePath)) {
throw new Error('Invalid file path: absolute paths and ".." segments are not allowed');
}
}

async function run() {
let failOnError = true;

Expand All @@ -28,20 +17,18 @@ async function run() {
throw new Error(`No lcov file(s) provided. Specify at least one path.`);
}

core.info(`Found ${inputs.lcovFilePaths.length} coverage file(s):`);
const lcovFilePaths = await resolveLcovFilePaths(inputs.lcovFilePaths);

core.info(`Found ${lcovFilePaths.length} coverage file(s):`);

let codeCoverageFileContent = null;

if (inputs.lcovFilePaths.length > 1) {
core.info(`Merging ${inputs.lcovFilePaths.length} coverage file(s) into a single file...`);
const mergedLcovFilePath = await mergeLcov(inputs.lcovFilePaths);
if (lcovFilePaths.length > 1) {
core.info(`Merging ${lcovFilePaths.length} coverage file(s) into a single file...`);
const mergedLcovFilePath = await mergeLcov(lcovFilePaths);
codeCoverageFileContent = await fs.readFile(mergedLcovFilePath, 'utf8');
} else {
// Validate single path to prevent arbitrary file access
const lcovFilePath = inputs.lcovFilePaths[0];
validateFilePath(lcovFilePath);

const content = await fs.readFile(path.resolve(lcovFilePath), 'utf8');
const content = await fs.readFile(lcovFilePaths[0], 'utf8');
const repositoryRoot = process.env.GITHUB_WORKSPACE ?? process.cwd();
codeCoverageFileContent = normalizeLcovSourcePaths(content, repositoryRoot);
}
Expand Down
4 changes: 0 additions & 4 deletions src/mergeLcov.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ export async function mergeLcov(paths) {
const contents = [];

for (const inputPath of paths) {
if (inputPath.includes('..') || path.isAbsolute(inputPath)) {
throw new Error('Invalid file path');
}

contents.push(await fs.readFile(path.resolve(inputPath), 'utf8'));
Comment thread
aikido-autofix[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential file inclusion attack via reading file - medium severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.

Show fix

Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

}

Expand Down
Loading