1
0
Fork 0
mirror of https://github.com/imjasonh/govulncheck-action synced 2026-07-21 06:17:10 +00:00

Add support for comma-separated and space-delimited working directories

- Parse multiple directories from working-directory input
- Support both comma and space delimiters
- Filter duplicate directories automatically
- Skip non-existent directories with warnings
- Aggregate and deduplicate vulnerabilities across all directories
- Add comprehensive test coverage (79 tests total, all passing)
- Update documentation in README and action.yml

Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-13 17:57:14 +00:00
parent d375553216
commit 9dacd0a386
6 changed files with 698 additions and 95 deletions

View file

@ -70,14 +70,20 @@ describe('GitHub Action Integration', () => {
});
it('should complete successfully with no vulnerabilities', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
const result = await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
});
expect(core.info).toHaveBeenCalledWith('Running govulncheck...');
expect(core.info).toHaveBeenCalledWith('Raw govulncheck output length: 0 characters');
expect(core.info).toHaveBeenCalledWith('Processing 1 working directory: .');
expect(core.info).toHaveBeenCalledWith('Running govulncheck in ....');
expect(core.info).toHaveBeenCalledWith('Raw govulncheck output length in .: 0 characters');
expect(core.info).toHaveBeenCalledWith('Raw output: ');
expect(mockGovulncheck.install).toHaveBeenCalled();
@ -93,6 +99,10 @@ describe('GitHub Action Integration', () => {
});
it('should handle vulnerabilities found', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
const mockVulnerabilities = [
{
finding: {
@ -113,19 +123,14 @@ describe('GitHub Action Integration', () => {
const result = await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
});
expect(core.warning).toHaveBeenCalledWith('Found 2 vulnerabilities');
expect(core.warning).toHaveBeenCalledWith('Found 2 unique vulnerabilities across all directories');
expect(core.setOutput).toHaveBeenCalledWith('vulnerabilities-found', 'true');
expect(core.setOutput).toHaveBeenCalledWith('vulnerability-count', '2');
expect(mockAnnotator.createAnnotations).toHaveBeenCalledWith(
mockVulnerabilities,
mockParser,
'.'
);
expect(result).toEqual({
vulnerabilities: mockVulnerabilities,
hasVulnerabilities: true
@ -133,30 +138,51 @@ describe('GitHub Action Integration', () => {
});
it('should change to working directory if specified', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
core.getInput.mockReturnValue('./subfolder');
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
});
expect(process.chdir).toHaveBeenCalledWith('./subfolder');
// Should change to the subfolder absolute path
expect(process.chdir).toHaveBeenCalled();
const chdirCalls = process.chdir.mock.calls;
expect(chdirCalls.some(call => call[0].includes('subfolder'))).toBe(true);
});
it('should not change directory for default value', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
core.getInput.mockReturnValue('.');
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
});
expect(process.chdir).not.toHaveBeenCalled();
// Should still call chdir for the original directory restoration
// but not for changing to '.'
const chdirCalls = process.chdir.mock.calls;
const changeToNewDir = chdirCalls.some(call => call[0] !== originalChdir && call[0] !== process.cwd());
expect(changeToNewDir).toBe(false);
});
it('should handle stderr warnings', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
mockGovulncheck.run.mockResolvedValue({
output: '',
errorOutput: 'warning: using database from 2023-01-01',
@ -166,15 +192,20 @@ describe('GitHub Action Integration', () => {
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
});
expect(core.warning).toHaveBeenCalledWith(
'govulncheck stderr: warning: using database from 2023-01-01'
'govulncheck stderr in .: warning: using database from 2023-01-01'
);
});
it('should fail when go.sum is missing', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
mockGovulncheck.run.mockResolvedValue({
output: '{"config": {}}',
errorOutput: 'missing go.sum entry for module providing package golang.org/x/net/html',
@ -184,11 +215,16 @@ describe('GitHub Action Integration', () => {
await expect(run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
})).rejects.toThrow('govulncheck failed due to missing dependencies');
annotator: mockAnnotator,
fs: mockFs
})).rejects.toThrow('govulncheck failed in . due to missing dependencies');
});
it('should fail when imports cannot be resolved', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
mockGovulncheck.run.mockResolvedValue({
output: '{"config": {}}',
errorOutput: 'could not import golang.org/x/net/html (invalid package name: "")',
@ -198,24 +234,34 @@ describe('GitHub Action Integration', () => {
await expect(run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
})).rejects.toThrow('govulncheck failed due to missing dependencies');
annotator: mockAnnotator,
fs: mockFs
})).rejects.toThrow('govulncheck failed in . due to missing dependencies');
});
it('should handle errors and set action as failed', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
const error = new Error('Failed to install govulncheck');
mockGovulncheck.install.mockRejectedValue(error);
await expect(run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
})).rejects.toThrow('Failed to install govulncheck');
expect(core.setFailed).toHaveBeenCalledWith('Failed to install govulncheck');
});
it('should handle parsing errors', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
const error = new Error('Invalid JSON output');
mockParser.parse.mockImplementation(() => {
throw error;
@ -224,13 +270,18 @@ describe('GitHub Action Integration', () => {
await expect(run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
})).rejects.toThrow('Invalid JSON output');
expect(core.setFailed).toHaveBeenCalledWith('Invalid JSON output');
});
it('should handle annotation errors gracefully', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
const mockVulnerabilities = [{ finding: { osv: 'GO-2023-1234' } }];
mockParser.parse.mockReturnValue(mockVulnerabilities);
mockAnnotator.createAnnotations.mockRejectedValue(new Error('Annotation failed'));
@ -238,13 +289,18 @@ describe('GitHub Action Integration', () => {
await expect(run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
})).rejects.toThrow('Annotation failed');
expect(core.setFailed).toHaveBeenCalledWith('Annotation failed');
});
it('should truncate output logging when output is large', async () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
const largeOutput = 'x'.repeat(6000);
mockGovulncheck.run.mockResolvedValue({
output: largeOutput,
@ -255,10 +311,341 @@ describe('GitHub Action Integration', () => {
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator
annotator: mockAnnotator,
fs: mockFs
});
expect(core.info).toHaveBeenCalledWith('Raw govulncheck output length: 6000 characters');
expect(core.info).toHaveBeenCalledWith('Raw govulncheck output length in .: 6000 characters');
expect(core.info).toHaveBeenCalledWith(`Raw output (first 1000 chars): ${'x'.repeat(1000)}...`);
});
describe('Multiple Working Directories', () => {
let mockFs;
let originalCwd;
beforeEach(() => {
originalCwd = process.cwd();
mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
});
it('should process comma-separated directories', async () => {
core.getInput.mockReturnValue('dir1,dir2,dir3');
const mockVulns1 = [{ finding: { osv: 'GO-2023-1111' } }];
const mockVulns2 = [{ finding: { osv: 'GO-2023-2222' } }];
const mockVulns3 = [{ finding: { osv: 'GO-2023-3333' } }];
mockParser.parse
.mockReturnValueOnce(mockVulns1)
.mockReturnValueOnce(mockVulns2)
.mockReturnValueOnce(mockVulns3);
const result = await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
expect(core.info).toHaveBeenCalledWith('Processing 3 working directories: dir1, dir2, dir3');
expect(mockGovulncheck.run).toHaveBeenCalledTimes(3);
expect(result.vulnerabilities).toHaveLength(3);
expect(result.hasVulnerabilities).toBe(true);
});
it('should process space-delimited directories', async () => {
core.getInput.mockReturnValue('dir1 dir2 dir3');
mockParser.parse.mockReturnValue([]);
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
expect(core.info).toHaveBeenCalledWith('Processing 3 working directories: dir1, dir2, dir3');
expect(mockGovulncheck.run).toHaveBeenCalledTimes(3);
});
it('should process mixed comma and space delimited directories', async () => {
core.getInput.mockReturnValue('dir1, dir2 dir3,dir4');
mockParser.parse.mockReturnValue([]);
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
expect(core.info).toHaveBeenCalledWith('Processing 4 working directories: dir1, dir2, dir3, dir4');
expect(mockGovulncheck.run).toHaveBeenCalledTimes(4);
});
it('should filter out duplicate directories', async () => {
core.getInput.mockReturnValue('dir1,dir2,dir1,dir3,dir2');
mockParser.parse.mockReturnValue([]);
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
expect(core.info).toHaveBeenCalledWith('Processing 3 working directories: dir1, dir2, dir3');
expect(mockGovulncheck.run).toHaveBeenCalledTimes(3);
});
it('should skip non-existent directories with a warning', async () => {
core.getInput.mockReturnValue('existing-dir,non-existent-dir');
mockFs.statSync.mockImplementation((dir) => {
if (dir.includes('non-existent')) {
throw new Error('ENOENT: no such file or directory');
}
return { isDirectory: () => true };
});
mockParser.parse.mockReturnValue([]);
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
expect(core.warning).toHaveBeenCalledWith('Directory not found, skipping: non-existent-dir');
expect(mockGovulncheck.run).toHaveBeenCalledTimes(1);
});
it('should filter duplicate vulnerabilities across directories', async () => {
core.getInput.mockReturnValue('dir1,dir2');
const vuln1 = { finding: { osv: 'GO-2023-1111' } };
const vuln2 = { finding: { osv: 'GO-2023-2222' } };
const vuln1Duplicate = { finding: { osv: 'GO-2023-1111' } };
mockParser.parse
.mockReturnValueOnce([vuln1, vuln2])
.mockReturnValueOnce([vuln1Duplicate]);
const result = await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
expect(result.vulnerabilities).toHaveLength(2);
expect(core.info).toHaveBeenCalledWith('Total unique vulnerabilities across all directories: 2');
});
it('should add workingDirectory context to vulnerabilities', async () => {
core.getInput.mockReturnValue('dir1,dir2');
const vuln1 = { finding: { osv: 'GO-2023-1111' } };
const vuln2 = { finding: { osv: 'GO-2023-2222' } };
mockParser.parse
.mockReturnValueOnce([vuln1])
.mockReturnValueOnce([vuln2]);
const result = await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
expect(result.vulnerabilities[0].workingDirectory).toBe('dir1');
expect(result.vulnerabilities[1].workingDirectory).toBe('dir2');
});
it('should return to original directory after processing each directory', async () => {
core.getInput.mockReturnValue('dir1,dir2');
const originalDir = process.cwd();
mockParser.parse.mockReturnValue([]);
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
// Should return to original directory after each iteration
expect(process.chdir).toHaveBeenCalledWith(originalDir);
// Called once per directory (2 times)
const chdirCalls = process.chdir.mock.calls.filter(call => call[0] !== originalDir);
expect(chdirCalls.length).toBe(2);
});
it('should install govulncheck only once for multiple directories', async () => {
core.getInput.mockReturnValue('dir1,dir2,dir3');
mockParser.parse.mockReturnValue([]);
await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
expect(mockGovulncheck.install).toHaveBeenCalledTimes(1);
expect(mockGovulncheck.run).toHaveBeenCalledTimes(3);
});
it('should handle errors in one directory and continue with others', async () => {
core.getInput.mockReturnValue('dir1,dir2');
let callCount = 0;
mockGovulncheck.run.mockImplementation(() => {
callCount++;
if (callCount === 1) {
throw new Error('Error in dir1');
}
return Promise.resolve({
output: '',
errorOutput: '',
exitCode: 0
});
});
await expect(run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
})).rejects.toThrow('Error in dir1');
expect(core.setFailed).toHaveBeenCalledWith('Error in dir1');
});
it('should handle vulnerabilities without OSV IDs', async () => {
core.getInput.mockReturnValue('dir1,dir2');
const vuln1 = { finding: { osv: 'GO-2023-1111' } };
const vuln2 = { finding: {} }; // No OSV ID
const vuln3 = { finding: { osv: 'GO-2023-3333' } };
mockParser.parse
.mockReturnValueOnce([vuln1, vuln2])
.mockReturnValueOnce([vuln3]);
const result = await run({
govulncheck: mockGovulncheck,
parser: mockParser,
annotator: mockAnnotator,
fs: mockFs
});
// Should include all vulnerabilities since one doesn't have OSV ID
expect(result.vulnerabilities).toHaveLength(3);
});
});
});
describe('parseWorkingDirectories', () => {
const { parseWorkingDirectories } = require('../index');
it('should parse comma-separated directories', () => {
const result = parseWorkingDirectories('dir1,dir2,dir3');
expect(result).toEqual(['dir1', 'dir2', 'dir3']);
});
it('should parse space-delimited directories', () => {
const result = parseWorkingDirectories('dir1 dir2 dir3');
expect(result).toEqual(['dir1', 'dir2', 'dir3']);
});
it('should parse mixed comma and space delimiters', () => {
const result = parseWorkingDirectories('dir1, dir2 dir3,dir4');
expect(result).toEqual(['dir1', 'dir2', 'dir3', 'dir4']);
});
it('should filter out empty strings', () => {
const result = parseWorkingDirectories('dir1,,dir2, ,dir3');
expect(result).toEqual(['dir1', 'dir2', 'dir3']);
});
it('should remove duplicates', () => {
const result = parseWorkingDirectories('dir1,dir2,dir1,dir3,dir2');
expect(result).toEqual(['dir1', 'dir2', 'dir3']);
});
it('should trim whitespace from directory names', () => {
const result = parseWorkingDirectories(' dir1 , dir2 , dir3 ');
expect(result).toEqual(['dir1', 'dir2', 'dir3']);
});
it('should return ["."] for empty input', () => {
expect(parseWorkingDirectories('')).toEqual(['.']);
expect(parseWorkingDirectories(null)).toEqual(['.']);
expect(parseWorkingDirectories(undefined)).toEqual(['.']);
});
it('should return ["."] for input with only whitespace', () => {
const result = parseWorkingDirectories(' , , ');
expect(result).toEqual(['.']);
});
it('should handle a single directory', () => {
const result = parseWorkingDirectories('single-dir');
expect(result).toEqual(['single-dir']);
});
});
describe('directoryExists', () => {
const { directoryExists } = require('../index');
it('should return true for existing directories', () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => true })
};
const result = directoryExists('/some/path', mockFs);
expect(result).toBe(true);
expect(mockFs.statSync).toHaveBeenCalledWith('/some/path');
});
it('should return false for files', () => {
const mockFs = {
statSync: jest.fn().mockReturnValue({ isDirectory: () => false })
};
const result = directoryExists('/some/file.txt', mockFs);
expect(result).toBe(false);
});
it('should return false for non-existent paths', () => {
const mockFs = {
statSync: jest.fn().mockImplementation(() => {
throw new Error('ENOENT: no such file or directory');
})
};
const result = directoryExists('/non/existent', mockFs);
expect(result).toBe(false);
});
it('should handle any error and return false', () => {
const mockFs = {
statSync: jest.fn().mockImplementation(() => {
throw new Error('Permission denied');
})
};
const result = directoryExists('/no/access', mockFs);
expect(result).toBe(false);
});
});