diff --git a/.github/workflows/example.yml b/.github/workflows/example.yml index 5a2fe74..acf5821 100644 --- a/.github/workflows/example.yml +++ b/.github/workflows/example.yml @@ -25,23 +25,6 @@ jobs: with: working-directory: './example' - - name: Report vulnerability scan results - if: always() - run: | - echo "## Vulnerability Scan Results" - echo "- Vulnerabilities found: ${{ steps.govulncheck.outputs.vulnerabilities-found }}" - echo "- Vulnerability count: ${{ steps.govulncheck.outputs.vulnerability-count }}" - - if [ "${{ steps.govulncheck.outputs.vulnerabilities-found }}" == "true" ]; then - echo "" - echo "⚠️ Security vulnerabilities were detected in the example code." - echo "This is expected! The example intentionally uses a vulnerable version of golang.org/x/net" - echo "to demonstrate how the action creates annotations." - else - echo "" - echo "✅ No vulnerabilities found (this would be unexpected for the example)." - fi - # Note: We don't fail the workflow here since the example is intentionally vulnerable - name: Demonstrate conditional failure run: | diff --git a/dist/index.js b/dist/index.js index 962c2f5..811740a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -8,12 +8,14 @@ const core = __nccwpck_require__(7484); const GovulncheckRunner = __nccwpck_require__(7515); const VulnerabilityParser = __nccwpck_require__(2177); const AnnotationCreator = __nccwpck_require__(2164); +const SummaryGenerator = __nccwpck_require__(3228); async function run(dependencies = {}) { // Allow dependency injection for testing const govulncheck = dependencies.govulncheck || new GovulncheckRunner(); const parser = dependencies.parser || new VulnerabilityParser(); const annotator = dependencies.annotator || new AnnotationCreator(core); + const summaryGenerator = dependencies.summaryGenerator || new SummaryGenerator(core); try { const workingDirectory = core.getInput('working-directory'); @@ -47,7 +49,10 @@ async function run(dependencies = {}) { const vulnerabilities = parser.parse(output); // Create annotations - await annotator.createAnnotations(vulnerabilities, parser, '.'); + await annotator.createAnnotations(vulnerabilities, parser, workingDirectory); + + // Generate workflow summary + await summaryGenerator.generateSummary(vulnerabilities, parser, workingDirectory); // Set outputs const hasVulnerabilities = vulnerabilities.length > 0; @@ -98,17 +103,48 @@ class AnnotationCreator { this.core.info(`Found ${modules.length} vulnerable modules and ${callSites.length} call sites`); + // Check which modules have actual call sites + const modulesWithCallSites = this.findModulesWithCallSites(vulnerabilities, callSites); + // Annotate go.mod for vulnerable modules - await this.annotateGoMod(modules, vulnerabilities, workingDirectory); + await this.annotateGoMod(modules, vulnerabilities, workingDirectory, modulesWithCallSites); // Annotate source files for call sites - this.annotateCallSites(callSites); + this.annotateCallSites(callSites, workingDirectory); + } + + findModulesWithCallSites(vulnerabilities, callSites) { + const modulesWithCallSites = new Map(); + + // For each call site, find which module it belongs to + for (const site of callSites) { + const vuln = vulnerabilities.find(v => v.finding.osv === site.osv); + if (vuln && vuln.finding.trace && vuln.finding.trace[0]) { + const module = vuln.finding.trace[0].module; + const version = vuln.finding.trace[0].version; + const fixedVersion = vuln.finding.fixed_version; + + if (!modulesWithCallSites.has(module)) { + modulesWithCallSites.set(module, { + currentVersion: version, + fixedVersion: fixedVersion, + osvs: [] + }); + } + + if (!modulesWithCallSites.get(module).osvs.includes(site.osv)) { + modulesWithCallSites.get(module).osvs.push(site.osv); + } + } + } + + return modulesWithCallSites; } - async annotateGoMod(modules, vulnerabilities, workingDirectory) { + async annotateGoMod(modules, vulnerabilities, workingDirectory, modulesWithCallSites) { if (modules.length === 0) return; - const goModPath = path.join(workingDirectory, 'go.mod'); + const goModPath = 'go.mod'; let goModContent = ''; try { @@ -121,8 +157,8 @@ class AnnotationCreator { const goModLines = goModContent.split('\n'); for (const module of modules) { - // Find the vulnerability info for this module - const vuln = vulnerabilities.find(v => + // Find all vulnerabilities for this module + const moduleVulns = vulnerabilities.filter(v => v.finding.trace && v.finding.trace[0] && v.finding.trace[0].module === module @@ -132,41 +168,145 @@ class AnnotationCreator { for (let i = 0; i < goModLines.length; i++) { if (goModLines[i].includes(module)) { const lineNumber = i + 1; - let message = `Vulnerable module: ${module}`; - if (vuln && vuln.finding.osv) { - message += ` (${vuln.finding.osv})`; + // Build detailed message + let message = `⚠️ Security vulnerabilities found in ${module}\n\n`; + + // Sort vulnerabilities by OSV ID + const sortedVulns = moduleVulns.sort((a, b) => { + const aId = a.finding.osv || ''; + const bId = b.finding.osv || ''; + return aId.localeCompare(bId); + }); + + for (const vuln of sortedVulns) { + if (vuln.finding.osv) { + message += `• ${vuln.finding.osv}`; + + if (vuln.osvDetails) { + message += `: ${vuln.osvDetails.summary || 'No summary available'}`; + + // Add CVE aliases if available + if (vuln.osvDetails.aliases && vuln.osvDetails.aliases.length > 0) { + const cves = vuln.osvDetails.aliases.filter(a => a.startsWith('CVE-')); + if (cves.length > 0) { + message += ` (${cves.join(', ')})`; + } + } + } + + message += `\n`; + + // Add link to vulnerability database + message += ` 🔗 https://pkg.go.dev/vuln/${vuln.finding.osv}\n`; + + // Add fixed version info + if (vuln.finding.fixed_version) { + message += ` ✅ Fixed in: ${vuln.finding.fixed_version}\n`; + } + + message += `\n`; + } + } + + // Add upgrade suggestion + if (moduleVulns.length > 0 && moduleVulns[0].finding.fixed_version) { + message += `💡 Recommended action: Update to ${moduleVulns[0].finding.fixed_version} or later`; } this.core.info(`Creating annotation for ${module} at go.mod:${lineNumber}`); // GitHub Actions annotation format + const filePath = workingDirectory === '.' ? 'go.mod' : path.join(workingDirectory, 'go.mod'); this.core.warning(message, { - title: 'Security Vulnerability', - file: 'go.mod', + title: `${moduleVulns.length} vulnerabilities in ${module}`, + file: filePath, startLine: lineNumber, endLine: lineNumber }); + + // If this module has actual call sites, create a suggested edit + if (modulesWithCallSites.has(module)) { + const moduleInfo = modulesWithCallSites.get(module); + if (moduleInfo.fixedVersion) { + // Create a notice with the suggested fix + const currentLine = goModLines[i]; + const suggestedLine = currentLine.replace(moduleInfo.currentVersion, moduleInfo.fixedVersion); + + let editMessage = `🔧 Suggested fix: Update this dependency to fix vulnerabilities that are actually being called in your code.\n\n`; + editMessage += `Current: ${currentLine.trim()}\n`; + editMessage += `Suggested: ${suggestedLine.trim()}\n\n`; + editMessage += `This fixes ${moduleInfo.osvs.length} vulnerabilities that have active call sites in your code:\n`; + editMessage += moduleInfo.osvs.sort().map(osv => `• ${osv}`).join('\n'); + + this.core.notice(editMessage, { + title: `Fix available for ${module}`, + file: filePath, + startLine: lineNumber, + endLine: lineNumber + }); + } + } + break; } } } } - annotateCallSites(callSites) { + annotateCallSites(callSites, workingDirectory) { + // Group call sites by OSV ID to avoid duplicate annotations + const groupedSites = {}; for (const site of callSites) { - let message = `Vulnerable code path: ${site.function}`; + const key = `${site.filename}:${site.line}:${site.osv}`; + if (!groupedSites[key]) { + groupedSites[key] = site; + } + } + + for (const site of Object.values(groupedSites)) { + // Build detailed message + let message = `🚨 Vulnerable code detected\n\n`; + message += `This code calls ${site.vulnerableFunction} which has a known vulnerability.\n\n`; if (site.osv) { - message += ` (${site.osv})`; + message += `Vulnerability: ${site.osv}`; + + if (site.osvDetails) { + message += ` - ${site.osvDetails.summary || 'No summary available'}`; + + // Add CVE aliases if available + if (site.osvDetails.aliases && site.osvDetails.aliases.length > 0) { + const cves = site.osvDetails.aliases.filter(a => a.startsWith('CVE-')); + if (cves.length > 0) { + message += `\nCVE: ${cves.join(', ')}`; + } + } + + // Add details if available + if (site.osvDetails.details) { + message += `\n\nDetails: ${site.osvDetails.details}`; + } + } + + message += `\n\n🔗 More info: https://pkg.go.dev/vuln/${site.osv}`; + } + + if (site.fixedVersion) { + message += `\n\n✅ Fix available: Update the dependency to ${site.fixedVersion} or later`; } this.core.info(`Creating annotation for call site at ${site.filename}:${site.line}`); // GitHub Actions annotation format + // If we're in a subdirectory, prepend it to the filename + const filePath = workingDirectory === '.' ? site.filename : path.join(workingDirectory, site.filename); + + const title = site.osv ? `Vulnerable code: ${site.osv}` : 'Vulnerable code detected'; + this.core.warning(message, { - title: 'Security Vulnerability', - file: site.filename, + title: title, + file: filePath, startLine: site.line, endLine: site.line }); @@ -236,6 +376,7 @@ module.exports = GovulncheckRunner; class VulnerabilityParser { parse(output) { const vulnerabilities = []; + const osvDetails = {}; // Try to parse as JSON Lines first (one JSON object per line) const lines = output.split('\n').filter(line => line.trim()); @@ -257,7 +398,10 @@ class VulnerabilityParser { for (const line of lines) { try { const json = JSON.parse(line); - if (json.finding) { + if (json.osv) { + // Store OSV details + osvDetails[json.osv.id] = json.osv; + } else if (json.finding) { console.log(`Found vulnerability: ${JSON.stringify(json.finding.osv || json.finding)}`); vulnerabilities.push(json); } @@ -274,7 +418,10 @@ class VulnerabilityParser { for (const jsonStr of jsonObjects) { try { const json = JSON.parse(jsonStr); - if (json.finding) { + if (json.osv) { + // Store OSV details + osvDetails[json.osv.id] = json.osv; + } else if (json.finding) { console.log(`Found vulnerability: ${JSON.stringify(json.finding.osv || json.finding)}`); vulnerabilities.push(json); } @@ -284,6 +431,13 @@ class VulnerabilityParser { } } + // Attach OSV details to vulnerabilities + for (const vuln of vulnerabilities) { + if (vuln.finding.osv && osvDetails[vuln.finding.osv]) { + vuln.osvDetails = osvDetails[vuln.finding.osv]; + } + } + console.log(`Parsed ${vulnerabilities.length} vulnerabilities`); return vulnerabilities; } @@ -306,14 +460,23 @@ class VulnerabilityParser { for (const vuln of vulnerabilities) { const finding = vuln.finding; - if (finding.trace) { - for (const frame of finding.trace) { - if (frame.position && frame.position.filename) { + if (finding.trace && finding.trace.length > 0) { + // The first frame in the trace is the vulnerable function + const vulnerableFunction = finding.trace[0].function || 'unknown function'; + + // Look for frames that represent our code (not the vulnerable library) + for (let i = 1; i < finding.trace.length; i++) { + const frame = finding.trace[i]; + if (frame.position && frame.position.filename && !frame.position.filename.includes('/')) { + // This frame is in our code (doesn't have path separators like library code) callSites.push({ filename: frame.position.filename, line: frame.position.line || 1, function: frame.function || 'unknown function', - osv: finding.osv || null + vulnerableFunction: vulnerableFunction, + osv: finding.osv || null, + osvDetails: vuln.osvDetails || null, + fixedVersion: finding.fixed_version || null }); } } @@ -328,6 +491,209 @@ module.exports = VulnerabilityParser; /***/ }), +/***/ 3228: +/***/ ((module) => { + +class SummaryGenerator { + constructor(core) { + this.core = core; + } + + async generateSummary(vulnerabilities, parser, workingDirectory = '.') { + const modules = parser.extractUniqueModules(vulnerabilities); + const callSites = parser.extractCallSites(vulnerabilities); + + // Group vulnerabilities by module + const vulnsByModule = new Map(); + for (const vuln of vulnerabilities) { + if (vuln.finding.trace && vuln.finding.trace[0]) { + const module = vuln.finding.trace[0].module; + if (!vulnsByModule.has(module)) { + vulnsByModule.set(module, []); + } + vulnsByModule.get(module).push(vuln); + } + } + + // Sort modules for consistent output + const sortedModules = Array.from(vulnsByModule.keys()).sort(); + + // Start building the summary + await this.core.summary + .addHeading('🔍 Govulncheck Security Report', 1) + .addEOL(); + + if (vulnerabilities.length === 0) { + await this.core.summary + .addRaw('✅ No vulnerabilities found!') + .addEOL() + .write(); + return; + } + + // Add overview + await this.core.summary + .addHeading('📊 Overview', 2) + .addList([ + `Total vulnerabilities found: ${vulnerabilities.length}`, + `Vulnerable modules: ${modules.length}`, + `Vulnerable code locations: ${callSites.length}` + ]) + .addEOL(); + + // Add vulnerable modules section + await this.core.summary + .addHeading('📦 Vulnerable Modules', 2) + .addEOL(); + + for (const module of sortedModules) { + const moduleVulns = vulnsByModule.get(module); + const moduleInfo = moduleVulns[0].finding.trace[0]; + + await this.core.summary + .addHeading(`${module}`, 3) + .addRaw(`Current version: ${moduleInfo.version}`) + .addEOL() + .addEOL(); + + // Sort vulnerabilities by OSV ID + const sortedVulns = moduleVulns.sort((a, b) => { + const aId = a.finding.osv || ''; + const bId = b.finding.osv || ''; + return aId.localeCompare(bId); + }); + + // Create a table of vulnerabilities + const tableRows = [['Vulnerability', 'Summary', 'Fixed Version', 'Details']]; + + for (const vuln of sortedVulns) { + const osvId = vuln.finding.osv; + const summary = vuln.osvDetails?.summary || 'No summary available'; + const fixedVersion = vuln.finding.fixed_version || 'Not specified'; + + let details = []; + if (vuln.osvDetails?.aliases) { + const cves = vuln.osvDetails.aliases.filter(a => a.startsWith('CVE-')); + if (cves.length > 0) { + details.push(`CVE: ${cves.join(', ')}`); + } + } + + // For the link, we'll add it as a separate line after the table + tableRows.push([ + osvId, + summary.length > 60 ? summary.substring(0, 60) + '...' : summary, + fixedVersion, + details.join(', ') + ]); + } + + await this.core.summary.addTable(tableRows); + + // Add links separately since we can't put them in the table + await this.core.summary.addEOL().addRaw('Links: '); + for (let i = 0; i < sortedVulns.length; i++) { + const vuln = sortedVulns[i]; + if (i > 0) await this.core.summary.addRaw(' | '); + await this.core.summary.addLink(vuln.finding.osv, `https://pkg.go.dev/vuln/${vuln.finding.osv}`); + } + await this.core.summary.addEOL().addEOL(); + } + + // Add vulnerable code locations section + if (callSites.length > 0) { + await this.core.summary + .addHeading('🚨 Vulnerable Code Locations', 2) + .addEOL(); + + // Group call sites by file + const callSitesByFile = new Map(); + for (const site of callSites) { + const filePath = workingDirectory === '.' ? site.filename : `${workingDirectory}/${site.filename}`; + if (!callSitesByFile.has(filePath)) { + callSitesByFile.set(filePath, []); + } + callSitesByFile.get(filePath).push(site); + } + + // Sort files + const sortedFiles = Array.from(callSitesByFile.keys()).sort(); + + for (const file of sortedFiles) { + await this.core.summary + .addHeading(`📄 ${file}`, 3); + + const sites = callSitesByFile.get(file); + + for (const site of sites) { + await this.core.summary + .addRaw(`• Line ${site.line}: calls ${site.vulnerableFunction}`); + + if (site.osv) { + await this.core.summary + .addRaw(' - ') + .addLink(site.osv, `https://pkg.go.dev/vuln/${site.osv}`); + } + + await this.core.summary.addEOL(); + } + + await this.core.summary.addEOL(); + } + } + + // Add recommendations section + await this.core.summary + .addHeading('💡 Recommendations', 2) + .addEOL(); + + const recommendations = []; + + // Find modules that need updates + for (const module of sortedModules) { + const moduleVulns = vulnsByModule.get(module); + const fixedVersions = moduleVulns + .map(v => v.finding.fixed_version) + .filter(v => v) + .sort(); + + if (fixedVersions.length > 0) { + // Get the latest fixed version + const latestFix = fixedVersions[fixedVersions.length - 1]; + recommendations.push( + `Update ${module} to version ${latestFix} or later` + ); + } + } + + if (recommendations.length > 0) { + await this.core.summary.addList(recommendations); + } else { + await this.core.summary.addRaw('No specific version recommendations available.'); + } + + await this.core.summary + .addEOL() + .addSeparator() + .addEOL() + .addRaw('🔗 Learn more about these vulnerabilities:') + .addEOL() + .addRaw('• ') + .addLink('Go Vulnerability Database', 'https://pkg.go.dev/vuln/') + .addEOL() + .addRaw('• ') + .addLink('Govulncheck Documentation', 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck') + .addEOL(); + + // Write the summary + await this.core.summary.write(); + } +} + +module.exports = SummaryGenerator; + +/***/ }), + /***/ 4914: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { diff --git a/example/go.mod b/example/go.mod index 1b5ab87..2ffdbca 100644 --- a/example/go.mod +++ b/example/go.mod @@ -2,4 +2,4 @@ module github.com/imjasonh/govulncheck-action/example go 1.20 -require golang.org/x/net v0.30.0 +require golang.org/x/net v0.0.0-20220906165146-f3363e06e74c diff --git a/example/go.sum b/example/go.sum index b338806..bfadfc8 100644 --- a/example/go.sum +++ b/example/go.sum @@ -1,2 +1,2 @@ -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/net v0.0.0-20220906165146-f3363e06e74c h1:yKufUcDwucU5urd+50/Opbt4AYpqthk7wHpHok8f1lo= +golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= diff --git a/example/main.go b/example/main.go index de6ff96..d9f343c 100644 --- a/example/main.go +++ b/example/main.go @@ -8,7 +8,7 @@ import ( func main() { // html.Parse in x/net/html has a vulnerability in the version we depend on. The action should find it. - _, err := html.Parse(strings.NewReader(`

Test

`)) + _, err := html.Parse(strings.NewReader(`

blah

`)) if err != nil { panic(err) } diff --git a/index.js b/index.js index cc709cf..96a33a7 100644 --- a/index.js +++ b/index.js @@ -2,12 +2,14 @@ const core = require('@actions/core'); const GovulncheckRunner = require('./lib/govulncheck'); const VulnerabilityParser = require('./lib/parser'); const AnnotationCreator = require('./lib/annotator'); +const SummaryGenerator = require('./lib/summary'); async function run(dependencies = {}) { // Allow dependency injection for testing const govulncheck = dependencies.govulncheck || new GovulncheckRunner(); const parser = dependencies.parser || new VulnerabilityParser(); const annotator = dependencies.annotator || new AnnotationCreator(core); + const summaryGenerator = dependencies.summaryGenerator || new SummaryGenerator(core); try { const workingDirectory = core.getInput('working-directory'); @@ -41,7 +43,10 @@ async function run(dependencies = {}) { const vulnerabilities = parser.parse(output); // Create annotations - await annotator.createAnnotations(vulnerabilities, parser, '.'); + await annotator.createAnnotations(vulnerabilities, parser, workingDirectory); + + // Generate workflow summary + await summaryGenerator.generateSummary(vulnerabilities, parser, workingDirectory); // Set outputs const hasVulnerabilities = vulnerabilities.length > 0; diff --git a/lib/annotator.js b/lib/annotator.js index a5e9052..8983f03 100644 --- a/lib/annotator.js +++ b/lib/annotator.js @@ -15,17 +15,48 @@ class AnnotationCreator { this.core.info(`Found ${modules.length} vulnerable modules and ${callSites.length} call sites`); + // Check which modules have actual call sites + const modulesWithCallSites = this.findModulesWithCallSites(vulnerabilities, callSites); + // Annotate go.mod for vulnerable modules - await this.annotateGoMod(modules, vulnerabilities, workingDirectory); + await this.annotateGoMod(modules, vulnerabilities, workingDirectory, modulesWithCallSites); // Annotate source files for call sites - this.annotateCallSites(callSites); + this.annotateCallSites(callSites, workingDirectory); + } + + findModulesWithCallSites(vulnerabilities, callSites) { + const modulesWithCallSites = new Map(); + + // For each call site, find which module it belongs to + for (const site of callSites) { + const vuln = vulnerabilities.find(v => v.finding.osv === site.osv); + if (vuln && vuln.finding.trace && vuln.finding.trace[0]) { + const module = vuln.finding.trace[0].module; + const version = vuln.finding.trace[0].version; + const fixedVersion = vuln.finding.fixed_version; + + if (!modulesWithCallSites.has(module)) { + modulesWithCallSites.set(module, { + currentVersion: version, + fixedVersion: fixedVersion, + osvs: [] + }); + } + + if (!modulesWithCallSites.get(module).osvs.includes(site.osv)) { + modulesWithCallSites.get(module).osvs.push(site.osv); + } + } + } + + return modulesWithCallSites; } - async annotateGoMod(modules, vulnerabilities, workingDirectory) { + async annotateGoMod(modules, vulnerabilities, workingDirectory, modulesWithCallSites) { if (modules.length === 0) return; - const goModPath = path.join(workingDirectory, 'go.mod'); + const goModPath = 'go.mod'; let goModContent = ''; try { @@ -38,8 +69,8 @@ class AnnotationCreator { const goModLines = goModContent.split('\n'); for (const module of modules) { - // Find the vulnerability info for this module - const vuln = vulnerabilities.find(v => + // Find all vulnerabilities for this module + const moduleVulns = vulnerabilities.filter(v => v.finding.trace && v.finding.trace[0] && v.finding.trace[0].module === module @@ -49,41 +80,145 @@ class AnnotationCreator { for (let i = 0; i < goModLines.length; i++) { if (goModLines[i].includes(module)) { const lineNumber = i + 1; - let message = `Vulnerable module: ${module}`; - if (vuln && vuln.finding.osv) { - message += ` (${vuln.finding.osv})`; + // Build detailed message + let message = `⚠️ Security vulnerabilities found in ${module}\n\n`; + + // Sort vulnerabilities by OSV ID + const sortedVulns = moduleVulns.sort((a, b) => { + const aId = a.finding.osv || ''; + const bId = b.finding.osv || ''; + return aId.localeCompare(bId); + }); + + for (const vuln of sortedVulns) { + if (vuln.finding.osv) { + message += `• ${vuln.finding.osv}`; + + if (vuln.osvDetails) { + message += `: ${vuln.osvDetails.summary || 'No summary available'}`; + + // Add CVE aliases if available + if (vuln.osvDetails.aliases && vuln.osvDetails.aliases.length > 0) { + const cves = vuln.osvDetails.aliases.filter(a => a.startsWith('CVE-')); + if (cves.length > 0) { + message += ` (${cves.join(', ')})`; + } + } + } + + message += `\n`; + + // Add link to vulnerability database + message += ` 🔗 https://pkg.go.dev/vuln/${vuln.finding.osv}\n`; + + // Add fixed version info + if (vuln.finding.fixed_version) { + message += ` ✅ Fixed in: ${vuln.finding.fixed_version}\n`; + } + + message += `\n`; + } + } + + // Add upgrade suggestion + if (moduleVulns.length > 0 && moduleVulns[0].finding.fixed_version) { + message += `💡 Recommended action: Update to ${moduleVulns[0].finding.fixed_version} or later`; } this.core.info(`Creating annotation for ${module} at go.mod:${lineNumber}`); // GitHub Actions annotation format + const filePath = workingDirectory === '.' ? 'go.mod' : path.join(workingDirectory, 'go.mod'); this.core.warning(message, { - title: 'Security Vulnerability', - file: 'go.mod', + title: `${moduleVulns.length} vulnerabilities in ${module}`, + file: filePath, startLine: lineNumber, endLine: lineNumber }); + + // If this module has actual call sites, create a suggested edit + if (modulesWithCallSites.has(module)) { + const moduleInfo = modulesWithCallSites.get(module); + if (moduleInfo.fixedVersion) { + // Create a notice with the suggested fix + const currentLine = goModLines[i]; + const suggestedLine = currentLine.replace(moduleInfo.currentVersion, moduleInfo.fixedVersion); + + let editMessage = `🔧 Suggested fix: Update this dependency to fix vulnerabilities that are actually being called in your code.\n\n`; + editMessage += `Current: ${currentLine.trim()}\n`; + editMessage += `Suggested: ${suggestedLine.trim()}\n\n`; + editMessage += `This fixes ${moduleInfo.osvs.length} vulnerabilities that have active call sites in your code:\n`; + editMessage += moduleInfo.osvs.sort().map(osv => `• ${osv}`).join('\n'); + + this.core.notice(editMessage, { + title: `Fix available for ${module}`, + file: filePath, + startLine: lineNumber, + endLine: lineNumber + }); + } + } + break; } } } } - annotateCallSites(callSites) { + annotateCallSites(callSites, workingDirectory) { + // Group call sites by OSV ID to avoid duplicate annotations + const groupedSites = {}; for (const site of callSites) { - let message = `Vulnerable code path: ${site.function}`; + const key = `${site.filename}:${site.line}:${site.osv}`; + if (!groupedSites[key]) { + groupedSites[key] = site; + } + } + + for (const site of Object.values(groupedSites)) { + // Build detailed message + let message = `🚨 Vulnerable code detected\n\n`; + message += `This code calls ${site.vulnerableFunction} which has a known vulnerability.\n\n`; if (site.osv) { - message += ` (${site.osv})`; + message += `Vulnerability: ${site.osv}`; + + if (site.osvDetails) { + message += ` - ${site.osvDetails.summary || 'No summary available'}`; + + // Add CVE aliases if available + if (site.osvDetails.aliases && site.osvDetails.aliases.length > 0) { + const cves = site.osvDetails.aliases.filter(a => a.startsWith('CVE-')); + if (cves.length > 0) { + message += `\nCVE: ${cves.join(', ')}`; + } + } + + // Add details if available + if (site.osvDetails.details) { + message += `\n\nDetails: ${site.osvDetails.details}`; + } + } + + message += `\n\n🔗 More info: https://pkg.go.dev/vuln/${site.osv}`; + } + + if (site.fixedVersion) { + message += `\n\n✅ Fix available: Update the dependency to ${site.fixedVersion} or later`; } this.core.info(`Creating annotation for call site at ${site.filename}:${site.line}`); // GitHub Actions annotation format + // If we're in a subdirectory, prepend it to the filename + const filePath = workingDirectory === '.' ? site.filename : path.join(workingDirectory, site.filename); + + const title = site.osv ? `Vulnerable code: ${site.osv}` : 'Vulnerable code detected'; + this.core.warning(message, { - title: 'Security Vulnerability', - file: site.filename, + title: title, + file: filePath, startLine: site.line, endLine: site.line }); diff --git a/lib/parser.js b/lib/parser.js index 4e9e28e..f3de532 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -1,6 +1,7 @@ class VulnerabilityParser { parse(output) { const vulnerabilities = []; + const osvDetails = {}; // Try to parse as JSON Lines first (one JSON object per line) const lines = output.split('\n').filter(line => line.trim()); @@ -22,7 +23,10 @@ class VulnerabilityParser { for (const line of lines) { try { const json = JSON.parse(line); - if (json.finding) { + if (json.osv) { + // Store OSV details + osvDetails[json.osv.id] = json.osv; + } else if (json.finding) { console.log(`Found vulnerability: ${JSON.stringify(json.finding.osv || json.finding)}`); vulnerabilities.push(json); } @@ -39,7 +43,10 @@ class VulnerabilityParser { for (const jsonStr of jsonObjects) { try { const json = JSON.parse(jsonStr); - if (json.finding) { + if (json.osv) { + // Store OSV details + osvDetails[json.osv.id] = json.osv; + } else if (json.finding) { console.log(`Found vulnerability: ${JSON.stringify(json.finding.osv || json.finding)}`); vulnerabilities.push(json); } @@ -49,6 +56,13 @@ class VulnerabilityParser { } } + // Attach OSV details to vulnerabilities + for (const vuln of vulnerabilities) { + if (vuln.finding.osv && osvDetails[vuln.finding.osv]) { + vuln.osvDetails = osvDetails[vuln.finding.osv]; + } + } + console.log(`Parsed ${vulnerabilities.length} vulnerabilities`); return vulnerabilities; } @@ -71,14 +85,23 @@ class VulnerabilityParser { for (const vuln of vulnerabilities) { const finding = vuln.finding; - if (finding.trace) { - for (const frame of finding.trace) { - if (frame.position && frame.position.filename) { + if (finding.trace && finding.trace.length > 0) { + // The first frame in the trace is the vulnerable function + const vulnerableFunction = finding.trace[0].function || 'unknown function'; + + // Look for frames that represent our code (not the vulnerable library) + for (let i = 1; i < finding.trace.length; i++) { + const frame = finding.trace[i]; + if (frame.position && frame.position.filename && !frame.position.filename.includes('/')) { + // This frame is in our code (doesn't have path separators like library code) callSites.push({ filename: frame.position.filename, line: frame.position.line || 1, function: frame.function || 'unknown function', - osv: finding.osv || null + vulnerableFunction: vulnerableFunction, + osv: finding.osv || null, + osvDetails: vuln.osvDetails || null, + fixedVersion: finding.fixed_version || null }); } } diff --git a/lib/summary.js b/lib/summary.js new file mode 100644 index 0000000..c4c3cf7 --- /dev/null +++ b/lib/summary.js @@ -0,0 +1,197 @@ +class SummaryGenerator { + constructor(core) { + this.core = core; + } + + async generateSummary(vulnerabilities, parser, workingDirectory = '.') { + const modules = parser.extractUniqueModules(vulnerabilities); + const callSites = parser.extractCallSites(vulnerabilities); + + // Group vulnerabilities by module + const vulnsByModule = new Map(); + for (const vuln of vulnerabilities) { + if (vuln.finding.trace && vuln.finding.trace[0]) { + const module = vuln.finding.trace[0].module; + if (!vulnsByModule.has(module)) { + vulnsByModule.set(module, []); + } + vulnsByModule.get(module).push(vuln); + } + } + + // Sort modules for consistent output + const sortedModules = Array.from(vulnsByModule.keys()).sort(); + + // Start building the summary + await this.core.summary + .addHeading('🔍 Govulncheck Security Report', 1) + .addEOL(); + + if (vulnerabilities.length === 0) { + await this.core.summary + .addRaw('✅ No vulnerabilities found!') + .addEOL() + .write(); + return; + } + + // Add overview + await this.core.summary + .addHeading('📊 Overview', 2) + .addList([ + `Total vulnerabilities found: ${vulnerabilities.length}`, + `Vulnerable modules: ${modules.length}`, + `Vulnerable code locations: ${callSites.length}` + ]) + .addEOL(); + + // Add vulnerable modules section + await this.core.summary + .addHeading('📦 Vulnerable Modules', 2) + .addEOL(); + + for (const module of sortedModules) { + const moduleVulns = vulnsByModule.get(module); + const moduleInfo = moduleVulns[0].finding.trace[0]; + + await this.core.summary + .addHeading(`${module}`, 3) + .addRaw(`Current version: ${moduleInfo.version}`) + .addEOL() + .addEOL(); + + // Sort vulnerabilities by OSV ID + const sortedVulns = moduleVulns.sort((a, b) => { + const aId = a.finding.osv || ''; + const bId = b.finding.osv || ''; + return aId.localeCompare(bId); + }); + + // Create a table of vulnerabilities + const tableRows = [['Vulnerability', 'Summary', 'Fixed Version', 'Details']]; + + for (const vuln of sortedVulns) { + const osvId = vuln.finding.osv; + const summary = vuln.osvDetails?.summary || 'No summary available'; + const fixedVersion = vuln.finding.fixed_version || 'Not specified'; + + let details = []; + if (vuln.osvDetails?.aliases) { + const cves = vuln.osvDetails.aliases.filter(a => a.startsWith('CVE-')); + if (cves.length > 0) { + details.push(`CVE: ${cves.join(', ')}`); + } + } + + // For the link, we'll add it as a separate line after the table + tableRows.push([ + osvId, + summary.length > 60 ? summary.substring(0, 60) + '...' : summary, + fixedVersion, + details.join(', ') + ]); + } + + await this.core.summary.addTable(tableRows); + + // Add links separately since we can't put them in the table + await this.core.summary.addEOL().addRaw('Links: '); + for (let i = 0; i < sortedVulns.length; i++) { + const vuln = sortedVulns[i]; + if (i > 0) await this.core.summary.addRaw(' | '); + await this.core.summary.addLink(vuln.finding.osv, `https://pkg.go.dev/vuln/${vuln.finding.osv}`); + } + await this.core.summary.addEOL().addEOL(); + } + + // Add vulnerable code locations section + if (callSites.length > 0) { + await this.core.summary + .addHeading('🚨 Vulnerable Code Locations', 2) + .addEOL(); + + // Group call sites by file + const callSitesByFile = new Map(); + for (const site of callSites) { + const filePath = workingDirectory === '.' ? site.filename : `${workingDirectory}/${site.filename}`; + if (!callSitesByFile.has(filePath)) { + callSitesByFile.set(filePath, []); + } + callSitesByFile.get(filePath).push(site); + } + + // Sort files + const sortedFiles = Array.from(callSitesByFile.keys()).sort(); + + for (const file of sortedFiles) { + await this.core.summary + .addHeading(`📄 ${file}`, 3); + + const sites = callSitesByFile.get(file); + + for (const site of sites) { + await this.core.summary + .addRaw(`• Line ${site.line}: calls ${site.vulnerableFunction}`); + + if (site.osv) { + await this.core.summary + .addRaw(' - ') + .addLink(site.osv, `https://pkg.go.dev/vuln/${site.osv}`); + } + + await this.core.summary.addEOL(); + } + + await this.core.summary.addEOL(); + } + } + + // Add recommendations section + await this.core.summary + .addHeading('💡 Recommendations', 2) + .addEOL(); + + const recommendations = []; + + // Find modules that need updates + for (const module of sortedModules) { + const moduleVulns = vulnsByModule.get(module); + const fixedVersions = moduleVulns + .map(v => v.finding.fixed_version) + .filter(v => v) + .sort(); + + if (fixedVersions.length > 0) { + // Get the latest fixed version + const latestFix = fixedVersions[fixedVersions.length - 1]; + recommendations.push( + `Update ${module} to version ${latestFix} or later` + ); + } + } + + if (recommendations.length > 0) { + await this.core.summary.addList(recommendations); + } else { + await this.core.summary.addRaw('No specific version recommendations available.'); + } + + await this.core.summary + .addEOL() + .addSeparator() + .addEOL() + .addRaw('🔗 Learn more about these vulnerabilities:') + .addEOL() + .addRaw('• ') + .addLink('Go Vulnerability Database', 'https://pkg.go.dev/vuln/') + .addEOL() + .addRaw('• ') + .addLink('Govulncheck Documentation', 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck') + .addEOL(); + + // Write the summary + await this.core.summary.write(); + } +} + +module.exports = SummaryGenerator; \ No newline at end of file