diff --git a/package.json b/package.json index 8637b36..fef3d6a 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,10 @@ ], "coverageThreshold": { "global": { - "branches": 50, - "functions": 75, - "lines": 75, - "statements": 75 + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 } } } diff --git a/test/annotator.test.js b/test/annotator.test.js index b31f064..55598fc 100644 --- a/test/annotator.test.js +++ b/test/annotator.test.js @@ -191,5 +191,208 @@ require ( annotator.annotateCallSites([]); expect(mockCore.warning).not.toHaveBeenCalled(); }); + + it('should include OSV details with CVE aliases in call site annotations', () => { + const callSites = [ + { + filename: 'main.go', + line: 42, + vulnerableFunction: 'vulnerable.Function', + osv: 'GO-2023-1234', + osvDetails: { + summary: 'Critical vulnerability in package', + aliases: ['CVE-2023-1234', 'GHSA-xxxx-yyyy', 'CVE-2023-5678'], + details: 'This vulnerability allows remote code execution' + }, + fixedVersion: 'v1.2.3' + } + ]; + + annotator.annotateCallSites(callSites, '.'); + + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining('CVE: CVE-2023-1234, CVE-2023-5678'), + expect.anything() + ); + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining('Details: This vulnerability allows remote code execution'), + expect.anything() + ); + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining('✅ Fix available: Update the dependency to v1.2.3 or later'), + expect.anything() + ); + }); + + it('should handle call sites without OSV details', () => { + const callSites = [ + { + filename: 'main.go', + line: 42, + vulnerableFunction: 'vulnerable.Function', + osv: 'GO-2023-1234', + osvDetails: null, + fixedVersion: null + } + ]; + + annotator.annotateCallSites(callSites, '.'); + + expect(mockCore.warning).toHaveBeenCalledWith( + expect.not.stringContaining('CVE:'), + expect.anything() + ); + expect(mockCore.warning).toHaveBeenCalledWith( + expect.not.stringContaining('✅ Fix available:'), + expect.anything() + ); + }); + }); + + describe('annotateGoMod with OSV details', () => { + it('should include OSV details without aliases', async () => { + const modules = ['example.com/vulnerable']; + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable' }], + fixed_version: 'v1.2.3' + }, + osvDetails: { + summary: 'Critical vulnerability' + } + } + ]; + + const goModContent = 'module example.com/app\n\nrequire example.com/vulnerable v1.0.0'; + mockFs.readFile.mockResolvedValue(goModContent); + + await annotator.annotateGoMod(modules, vulnerabilities, '.', new Map()); + + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining('GO-2023-1234: Critical vulnerability'), + expect.anything() + ); + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining('✅ Fixed in: v1.2.3'), + expect.anything() + ); + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining('💡 Recommended action: Update to v1.2.3 or later'), + expect.anything() + ); + }); + + it('should include OSV details with CVE aliases', async () => { + const modules = ['example.com/vulnerable']; + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable' }] + }, + osvDetails: { + summary: 'Critical vulnerability', + aliases: ['CVE-2023-1234', 'GHSA-xxxx-yyyy', 'CVE-2023-5678'] + } + } + ]; + + const goModContent = 'module example.com/app\n\nrequire example.com/vulnerable v1.0.0'; + mockFs.readFile.mockResolvedValue(goModContent); + + await annotator.annotateGoMod(modules, vulnerabilities, '.', new Map()); + + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining('(CVE-2023-1234, CVE-2023-5678)'), + expect.anything() + ); + }); + + it('should create suggested edits for modules with call sites', async () => { + mockCore.notice = jest.fn(); + + const modules = ['example.com/vulnerable']; + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }], + fixed_version: 'v1.2.3' + } + } + ]; + + const goModContent = 'module example.com/app\n\nrequire example.com/vulnerable v1.0.0'; + mockFs.readFile.mockResolvedValue(goModContent); + + const modulesWithCallSites = new Map([ + ['example.com/vulnerable', { + currentVersion: 'v1.0.0', + fixedVersion: 'v1.2.3', + osvs: ['GO-2023-1234', 'GO-2023-5678'] + }] + ]); + + await annotator.annotateGoMod(modules, vulnerabilities, '.', modulesWithCallSites); + + expect(mockCore.notice).toHaveBeenCalledWith( + expect.stringContaining('🔧 Suggested fix:'), + expect.objectContaining({ + title: 'Fix available for example.com/vulnerable', + file: 'go.mod', + startLine: 3, + endLine: 3 + }) + ); + expect(mockCore.notice).toHaveBeenCalledWith( + expect.stringContaining('Current: require example.com/vulnerable v1.0.0'), + expect.anything() + ); + expect(mockCore.notice).toHaveBeenCalledWith( + expect.stringContaining('Suggested: require example.com/vulnerable v1.2.3'), + expect.anything() + ); + expect(mockCore.notice).toHaveBeenCalledWith( + expect.stringContaining('• GO-2023-1234'), + expect.anything() + ); + }); + + it('should handle vulnerabilities without OSV IDs when sorting', async () => { + const modules = ['example.com/vulnerable']; + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-5678', + trace: [{ module: 'example.com/vulnerable' }] + } + }, + { + finding: { + // No OSV ID + trace: [{ module: 'example.com/vulnerable' }] + } + }, + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable' }] + } + } + ]; + + const goModContent = 'module example.com/app\n\nrequire example.com/vulnerable v1.0.0'; + mockFs.readFile.mockResolvedValue(goModContent); + + await annotator.annotateGoMod(modules, vulnerabilities, '.', new Map()); + + // The vulnerabilities should be sorted with empty OSV IDs first + expect(mockCore.warning).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Security vulnerabilities found in example.com/vulnerable'), + expect.anything() + ); + }); }); }); \ No newline at end of file diff --git a/test/index-main.test.js b/test/index-main.test.js new file mode 100644 index 0000000..cc82a2d --- /dev/null +++ b/test/index-main.test.js @@ -0,0 +1,83 @@ +// This test file specifically tests the main module execution path +describe('Main module execution', () => { + it('should execute run() when module is main', () => { + // Mock @actions/core first + jest.mock('@actions/core', () => ({ + getInput: jest.fn().mockReturnValue('.'), + info: jest.fn(), + warning: jest.fn(), + setOutput: jest.fn(), + setFailed: jest.fn(), + summary: { + addHeading: jest.fn().mockReturnThis(), + addEOL: jest.fn().mockReturnThis(), + addRaw: jest.fn().mockReturnThis(), + addList: jest.fn().mockReturnThis(), + addTable: jest.fn().mockReturnThis(), + addLink: jest.fn().mockReturnThis(), + addSeparator: jest.fn().mockReturnThis(), + write: jest.fn().mockResolvedValue() + } + })); + + // Clear require cache to ensure fresh module load + delete require.cache[require.resolve('../index')]; + delete require.cache[require.resolve('../lib/govulncheck')]; + delete require.cache[require.resolve('../lib/parser')]; + delete require.cache[require.resolve('../lib/annotator')]; + delete require.cache[require.resolve('../lib/summary')]; + + // Save original require.main + const originalMain = require.main; + + // Mock the dependencies to avoid actual execution + jest.doMock('../lib/govulncheck', () => { + return jest.fn().mockImplementation(() => ({ + install: jest.fn().mockResolvedValue(), + run: jest.fn().mockResolvedValue({ output: '', errorOutput: '', exitCode: 0 }) + })); + }); + + jest.doMock('../lib/parser', () => { + return jest.fn().mockImplementation(() => ({ + parse: jest.fn().mockReturnValue([]), + extractUniqueModules: jest.fn().mockReturnValue([]), + extractCallSites: jest.fn().mockReturnValue([]) + })); + }); + + jest.doMock('../lib/annotator', () => { + return jest.fn().mockImplementation(() => ({ + createAnnotations: jest.fn().mockResolvedValue() + })); + }); + + jest.doMock('../lib/summary', () => { + return jest.fn().mockImplementation(() => ({ + generateSummary: jest.fn().mockResolvedValue() + })); + }); + + // Isolate and require the module + jest.isolateModules(() => { + // Set require.main to simulate running as main module + const indexPath = require.resolve('../index'); + require.main = { filename: indexPath }; + + // This should trigger the main module execution + require('../index'); + }); + + // Restore original require.main + require.main = originalMain; + + // Clean up mocks + jest.dontMock('../lib/govulncheck'); + jest.dontMock('../lib/parser'); + jest.dontMock('../lib/annotator'); + jest.dontMock('../lib/summary'); + + // The test passes if no errors are thrown + expect(true).toBe(true); + }); +}); \ No newline at end of file diff --git a/test/index.test.js b/test/index.test.js index 6ff18e9..5d3ee04 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -215,4 +215,22 @@ describe('GitHub Action Integration', () => { expect(core.setFailed).toHaveBeenCalledWith('Annotation failed'); }); + + it('should truncate output logging when output is large', async () => { + const largeOutput = 'x'.repeat(6000); + mockGovulncheck.run.mockResolvedValue({ + output: largeOutput, + errorOutput: '', + exitCode: 0 + }); + + await run({ + govulncheck: mockGovulncheck, + parser: mockParser, + annotator: mockAnnotator + }); + + expect(core.info).toHaveBeenCalledWith('Raw govulncheck output length: 6000 characters'); + expect(core.info).toHaveBeenCalledWith(`Raw output (first 1000 chars): ${'x'.repeat(1000)}...`); + }); }); \ No newline at end of file diff --git a/test/parser.test.js b/test/parser.test.js index d7886fc..f7c685b 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -44,6 +44,89 @@ Another invalid line`; const vulnerabilities = parser.parse(output); expect(vulnerabilities).toHaveLength(1); }); + + it('should parse multi-line JSON objects', () => { + const output = `{ + "finding": { + "osv": "GO-2023-1234", + "trace": [{"module": "example.com/vulnerable"}] + } +} +{ + "finding": { + "osv": "GO-2023-5678", + "trace": [{"module": "another.com/package"}] + } +}`; + + const vulnerabilities = parser.parse(output); + expect(vulnerabilities).toHaveLength(2); + expect(vulnerabilities[0].finding.osv).toBe('GO-2023-1234'); + expect(vulnerabilities[1].finding.osv).toBe('GO-2023-5678'); + }); + + it('should store and attach OSV details to vulnerabilities', () => { + const output = ` + {"osv":{"id":"GO-2023-1234","summary":"Critical vulnerability","aliases":["CVE-2023-1234"]}} + {"finding":{"osv":"GO-2023-1234","trace":[{"module":"example.com/vulnerable"}]}} + {"osv":{"id":"GO-2023-5678","summary":"Another vulnerability"}} + {"finding":{"osv":"GO-2023-5678","trace":[{"module":"another.com/package"}]}} + `; + + const vulnerabilities = parser.parse(output); + + expect(vulnerabilities).toHaveLength(2); + expect(vulnerabilities[0].osvDetails).toEqual({ + id: 'GO-2023-1234', + summary: 'Critical vulnerability', + aliases: ['CVE-2023-1234'] + }); + expect(vulnerabilities[1].osvDetails).toEqual({ + id: 'GO-2023-5678', + summary: 'Another vulnerability' + }); + }); + + it('should handle invalid JSON in multi-line format', () => { + const output = `{ + "finding": { + "osv": "GO-2023-1234", + "trace": [{"module": "example.com/vulnerable"}] + } +} +{ invalid json +{ + "finding": { + "osv": "GO-2023-5678", + "trace": [{"module": "another.com/package"}] + } +}`; + + const vulnerabilities = parser.parse(output); + expect(vulnerabilities).toHaveLength(2); + }); + + it('should parse OSV details in multi-line format', () => { + const output = `{ + "osv": { + "id": "GO-2023-1234", + "summary": "Critical vulnerability" + } +} +{ + "finding": { + "osv": "GO-2023-1234", + "trace": [{"module": "example.com/vulnerable"}] + } +}`; + + const vulnerabilities = parser.parse(output); + expect(vulnerabilities).toHaveLength(1); + expect(vulnerabilities[0].osvDetails).toEqual({ + id: 'GO-2023-1234', + summary: 'Critical vulnerability' + }); + }); }); describe('extractUniqueModules', () => { diff --git a/test/summary.test.js b/test/summary.test.js new file mode 100644 index 0000000..139a18a --- /dev/null +++ b/test/summary.test.js @@ -0,0 +1,238 @@ +const SummaryGenerator = require('../lib/summary'); + +describe('SummaryGenerator', () => { + let summaryGenerator; + let mockCore; + let mockParser; + let mockSummary; + + beforeEach(() => { + mockSummary = { + addHeading: jest.fn().mockReturnThis(), + addEOL: jest.fn().mockReturnThis(), + addRaw: jest.fn().mockReturnThis(), + addList: jest.fn().mockReturnThis(), + addTable: jest.fn().mockReturnThis(), + addLink: jest.fn().mockReturnThis(), + addSeparator: jest.fn().mockReturnThis(), + write: jest.fn().mockResolvedValue() + }; + + mockCore = { + summary: mockSummary + }; + + mockParser = { + extractUniqueModules: jest.fn(), + extractCallSites: jest.fn() + }; + + summaryGenerator = new SummaryGenerator(mockCore); + }); + + describe('generateSummary', () => { + it('should generate summary with no vulnerabilities', async () => { + mockParser.extractUniqueModules.mockReturnValue([]); + mockParser.extractCallSites.mockReturnValue([]); + + await summaryGenerator.generateSummary([], mockParser, '.'); + + expect(mockSummary.addHeading).toHaveBeenCalledWith('🔍 Govulncheck Security Report', 1); + expect(mockSummary.addRaw).toHaveBeenCalledWith('✅ No vulnerabilities found!'); + expect(mockSummary.write).toHaveBeenCalled(); + }); + + it('should generate summary with vulnerabilities sorted by OSV ID', async () => { + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-5678', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }], + fixed_version: 'v1.2.3' + }, + osvDetails: { + summary: 'Second vulnerability' + } + }, + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }], + fixed_version: 'v1.2.3' + }, + osvDetails: { + summary: 'First vulnerability' + } + } + ]; + + mockParser.extractUniqueModules.mockReturnValue(['example.com/vulnerable']); + mockParser.extractCallSites.mockReturnValue([]); + + await summaryGenerator.generateSummary(vulnerabilities, mockParser, '.'); + + // Check that table was called + const tableCall = mockSummary.addTable.mock.calls[0][0]; + expect(tableCall[1][0]).toBe('GO-2023-1234'); // First row should be sorted first + expect(tableCall[2][0]).toBe('GO-2023-5678'); // Second row should be sorted second + }); + + it('should include CVE aliases in vulnerability table', async () => { + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }], + fixed_version: 'v1.2.3' + }, + osvDetails: { + summary: 'Critical vulnerability', + aliases: ['CVE-2023-1234', 'GHSA-xxxx-yyyy', 'CVE-2023-5678'] + } + } + ]; + + mockParser.extractUniqueModules.mockReturnValue(['example.com/vulnerable']); + mockParser.extractCallSites.mockReturnValue([]); + + await summaryGenerator.generateSummary(vulnerabilities, mockParser, '.'); + + const tableCall = mockSummary.addTable.mock.calls[0][0]; + expect(tableCall[1][3]).toBe('CVE: CVE-2023-1234, CVE-2023-5678'); + }); + + it('should generate vulnerable code locations section', async () => { + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }] + } + } + ]; + + const callSites = [ + { + filename: 'main.go', + line: 42, + vulnerableFunction: 'vulnerable.Function', + osv: 'GO-2023-1234' + }, + { + filename: 'utils.go', + line: 10, + vulnerableFunction: 'helper.Process', + osv: 'GO-2023-5678' + } + ]; + + mockParser.extractUniqueModules.mockReturnValue(['example.com/vulnerable']); + mockParser.extractCallSites.mockReturnValue(callSites); + + await summaryGenerator.generateSummary(vulnerabilities, mockParser, '.'); + + expect(mockSummary.addHeading).toHaveBeenCalledWith('🚨 Vulnerable Code Locations', 2); + expect(mockSummary.addHeading).toHaveBeenCalledWith('📄 main.go', 3); + expect(mockSummary.addRaw).toHaveBeenCalledWith('• Line 42: calls vulnerable.Function'); + expect(mockSummary.addLink).toHaveBeenCalledWith('GO-2023-1234', 'https://pkg.go.dev/vuln/GO-2023-1234'); + }); + + it('should handle call sites in subdirectory', async () => { + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }] + } + } + ]; + + const callSites = [ + { + filename: 'main.go', + line: 42, + vulnerableFunction: 'vulnerable.Function', + osv: 'GO-2023-1234' + } + ]; + + mockParser.extractUniqueModules.mockReturnValue(['example.com/vulnerable']); + mockParser.extractCallSites.mockReturnValue(callSites); + + await summaryGenerator.generateSummary(vulnerabilities, mockParser, 'subdir'); + + expect(mockSummary.addHeading).toHaveBeenCalledWith('📄 subdir/main.go', 3); + }); + + it('should generate recommendations with latest fixed versions', async () => { + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }], + fixed_version: 'v1.2.0' + } + }, + { + finding: { + osv: 'GO-2023-5678', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }], + fixed_version: 'v1.2.3' + } + }, + { + finding: { + osv: 'GO-2023-9999', + trace: [{ module: 'another.com/package', version: 'v2.0.0' }], + fixed_version: 'v2.1.0' + } + } + ]; + + mockParser.extractUniqueModules.mockReturnValue(['example.com/vulnerable', 'another.com/package']); + mockParser.extractCallSites.mockReturnValue([]); + + await summaryGenerator.generateSummary(vulnerabilities, mockParser, '.'); + + expect(mockSummary.addList).toHaveBeenCalledWith([ + 'Update another.com/package to version v2.1.0 or later', + 'Update example.com/vulnerable to version v1.2.3 or later' + ]); + }); + + it('should handle vulnerabilities without fixed versions', async () => { + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234', + trace: [{ module: 'example.com/vulnerable', version: 'v1.0.0' }] + } + } + ]; + + mockParser.extractUniqueModules.mockReturnValue(['example.com/vulnerable']); + mockParser.extractCallSites.mockReturnValue([]); + + await summaryGenerator.generateSummary(vulnerabilities, mockParser, '.'); + + expect(mockSummary.addRaw).toHaveBeenCalledWith('No specific version recommendations available.'); + }); + + it('should handle vulnerabilities without trace data', async () => { + const vulnerabilities = [ + { + finding: { + osv: 'GO-2023-1234' + } + } + ]; + + mockParser.extractUniqueModules.mockReturnValue([]); + mockParser.extractCallSites.mockReturnValue([]); + + await summaryGenerator.generateSummary(vulnerabilities, mockParser, '.'); + + expect(mockSummary.write).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file