Skip to content
Merged
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
12 changes: 12 additions & 0 deletions internal/onlinescan/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type Vulnerability struct {

CallLine string `json:"callLine,omitempty"`

Trace []TraceFrame `json:"trace,omitempty"`

VulnerableSymbols []VulnImport `json:"vulnerableSymbols,omitempty"`

SeveritySource string `json:"severitySource,omitempty"`
Expand All @@ -62,6 +64,16 @@ type Vulnerability struct {
Remediation *Remediation `json:"remediation,omitempty"`
}

// TraceFrame identifies one positioned frame in a govulncheck call chain.
type TraceFrame struct {
Module string `json:"module,omitempty"`
Package string `json:"package,omitempty"`
Function string `json:"function,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
FirstParty bool `json:"firstParty,omitempty"`
}

// Remediation describes the actionable upgrade for a (usually transitive)
// vulnerability: bump Direct from CurrentVersion to FixVersion, after which
// the vulnerable package resolves to ChildFixed (or is dropped entirely).
Expand Down
38 changes: 38 additions & 0 deletions internal/output/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,44 @@ func stringMatrixField(v reflect.Value, n string) [][]string {
return out
}

type traceField struct {
File string
Line int
Function string
FirstParty bool
}

func traceFramesField(v reflect.Value, n string) []traceField {
f := v.FieldByName(n)
if !f.IsValid() || f.Kind() != reflect.Slice {
return nil
}
out := make([]traceField, 0, f.Len())
for i := 0; i < f.Len(); i++ {
frame := f.Index(i)
if frame.Kind() == reflect.Ptr {
if frame.IsNil() {
continue
}
frame = frame.Elem()
}
if frame.Kind() != reflect.Struct {
continue
}
out = append(out, traceField{
File: stringField(frame, "File"), Line: intField(frame, "Line"),
Function: stringField(frame, "Function"),
FirstParty: traceBoolField(frame, "FirstParty"),
})
}
return out
}

func traceBoolField(v reflect.Value, n string) bool {
f := v.FieldByName(n)
return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
}

func relevantField(v reflect.Value, n string) (value bool, known bool) {
f := v.FieldByName(n)
if !f.IsValid() || f.Kind() != reflect.Ptr || f.IsNil() {
Expand Down
66 changes: 66 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ type tVuln struct {
Reachable string
CallSite string
CallLine string
Trace []tTraceFrame
}
type tTraceFrame struct {
Function string
File string
Line int
FirstParty bool
}
type tMalware struct {
Found bool
Expand Down Expand Up @@ -189,6 +196,65 @@ func TestSARIF_Render_ExportsGoReachabilityTrace(t *testing.T) {
}
}

func TestSARIF_Render_ExportsFullGoTraceInSourceOrder(t *testing.T) {
report := &tReport{Components: []tComponent{{
System: "GO", Name: "example.org/vulnerable", Version: "v1.2.3",
Vulnerabilities: []tVuln{{ID: "GO-2026-0001", Reachable: "reachable", CallSite: "internal/handler.go:42", Trace: []tTraceFrame{
{Function: "vulnerable", File: "vendor/lib.go", Line: 9},
{Function: "handler", File: "internal/handler.go", Line: 42},
{Function: "main", File: "cmd/app.go", Line: 18},
}}},
}}}

var buf bytes.Buffer
if err := (SARIF{}).Render(&buf, report); err != nil {
t.Fatal(err)
}
var doc struct {
Runs []struct {
Results []struct {
CodeFlows []struct {
ThreadFlows []struct {
Locations []struct {
Location struct {
PhysicalLocation struct {
ArtifactLocation struct {
URI string `json:"uri"`
} `json:"artifactLocation"`
Region struct {
StartLine int `json:"startLine"`
} `json:"region"`
} `json:"physicalLocation"`
} `json:"location"`
Kinds []string `json:"kinds"`
} `json:"locations"`
} `json:"threadFlows"`
} `json:"codeFlows"`
} `json:"results"`
} `json:"runs"`
}
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Fatal(err)
}
locations := doc.Runs[0].Results[0].CodeFlows[0].ThreadFlows[0].Locations
if len(locations) != 3 {
t.Fatalf("got %d trace locations, want 3", len(locations))
}
want := []struct {
file string
line int
kind string
}{
{"cmd/app.go", 18, "entrypoint"}, {"internal/handler.go", 42, "call"}, {"vendor/lib.go", 9, "vulnerable-function"},
}
for i, want := range want {
got := locations[i]
if got.Location.PhysicalLocation.ArtifactLocation.URI != want.file || got.Location.PhysicalLocation.Region.StartLine != want.line || got.Kinds[0] != want.kind {
t.Errorf("location %d = %+v, want %s:%d (%s)", i, got, want.file, want.line, want.kind)
}
}
}

func TestFixPlan_Render_OneLinePerVulnerability(t *testing.T) {
var buf bytes.Buffer
report := tFixPlanReport{Source: "fs:./app", FixPlan: &tFixPlan{Groups: []tFixGroup{{
Expand Down
39 changes: 39 additions & 0 deletions internal/output/sarif.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ func resultProps(v reflect.Value) map[string]any {
if reachable := stringField(v, "Reachable"); reachable != "" {
props["reachability"] = reachable
}
if callSite := stringField(v, "CallSite"); callSite != "" {
props["callSite"] = callSite
}
if callLine := stringField(v, "CallLine"); callLine != "" {
props["callLine"] = callLine
}
return props
}

Expand Down Expand Up @@ -230,6 +236,39 @@ func buildLocations(pkgLabel string, v reflect.Value) []sarifLocation {
}

func buildCodeFlows(v reflect.Value) []sarifCodeFlow {
trace := traceFramesField(v, "Trace")
locations := make([]sarifThreadFlowLocation, 0, len(trace))
for i := len(trace) - 1; i >= 0; i-- {
frame := trace[i]
if frame.File == "" || frame.Line <= 0 {
continue
}
Comment on lines +243 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unpositioned frames in the SARIF flow

When a govulncheck function frame lacks a source position, this branch drops the frame entirely even though its module, package, and function were retained in Trace. If all frames are unpositioned, SARIF falls back to the single call site or emits no flow; if only some are unpositioned, the remaining frames can also be mislabeled as the entry point or vulnerable function. Emit a message/logical-only thread-flow location for such frames rather than removing them.

Useful? React with 👍 / 👎.

kind := "call"
if len(locations) == 0 {
kind = "entrypoint"
}
message := frame.Function
if message == "" {
message = fmt.Sprintf("call frame: %s:%d", frame.File, frame.Line)
}
locations = append(locations, sarifThreadFlowLocation{
Location: sarifLocation{
PhysicalLocation: &sarifPhysicalLocation{
ArtifactLocation: sarifArtifactLocation{URI: frame.File},
Region: sarifRegion{StartLine: frame.Line},
},
Message: &sarifMessage{Text: message},
},
Kinds: []string{kind},
})
}
if len(locations) > 1 {
locations[len(locations)-1].Kinds = []string{"vulnerable-function"}
}
if len(locations) > 0 {
return []sarifCodeFlow{{ThreadFlows: []sarifThreadFlow{{Locations: locations}}}}
}

file, line := parseCallSite(stringField(v, "CallSite"))
if file == "" || line <= 0 {
return nil
Expand Down
33 changes: 33 additions & 0 deletions internal/reachability/govulncheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ func parseGovulncheck(r io.Reader, dir string, res *Result, o Options) (reachabl
seen := map[string]bool{}
callSites := map[string]string{}
callLines := map[string]string{}
traces := map[string][]onlinescan.TraceFrame{}
calledMod := map[string]string{}
osvModules := map[string][]string{}
severity := map[string]string{}
Expand Down Expand Up @@ -221,6 +222,19 @@ func parseGovulncheck(r io.Reader, dir string, res *Result, o Options) (reachabl
seen[id] = true
if len(m.Finding.Trace) > 0 && m.Finding.Trace[0].Function != "" {
called[id] = true
if _, exists := traces[normID(id)]; !exists {
for _, fr := range m.Finding.Trace {
Comment on lines +225 to +226

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retain every govulncheck finding for each OSV

When govulncheck emits multiple finding messages for the same OSV—for example, separate vulnerable symbols or distinct call paths—this exists guard permanently keeps only the first trace. Consequently the JSON report and SARIF code flow silently omit every subsequent path, so the newly exported trace is incomplete and may not show the call path relevant to a user; store all traces per vulnerability and render each as a separate thread flow.

Useful? React with 👍 / 👎.

frame := onlinescan.TraceFrame{
Module: fr.Module, Package: fr.Package, Function: fr.Function,
FirstParty: isFirstPartyFrame(fr, res),
}
if fr.Position != nil {
frame.File = fr.Position.Filename
frame.Line = fr.Position.Line
}
traces[normID(id)] = append(traces[normID(id)], frame)
}
}

if calledMod[id] == "" {
for _, fr := range m.Finding.Trace {
Expand Down Expand Up @@ -300,6 +314,18 @@ func parseGovulncheck(r io.Reader, dir string, res *Result, o Options) (reachabl
}
}
}
if trace := traces[normID(id)]; len(trace) > 0 {
if res.Traces == nil {
res.Traces = map[string][]onlinescan.TraceFrame{}
}
res.Traces[normID(id)] = trace
for _, a := range aliases[id] {
nid := normID(a)
if len(res.Traces[nid]) == 0 {
res.Traces[nid] = trace
}
}
}
} else {
unreachable++
}
Expand All @@ -318,6 +344,13 @@ func parseGovulncheck(r io.Reader, dir string, res *Result, o Options) (reachabl
return reachable, unreachable, nil
}

func isFirstPartyFrame(fr gvkFrame, res *Result) bool {
if fr.Position == nil || fr.Position.Filename == "" || fr.Module == "stdlib" {
return false
}
return !res.HaveModuleUsage || !res.Modules[fr.Module] || fr.Module == res.MainModule
}

func gvkExtractSeverity(osv *gvkOSV) string {
if osv == nil {
return ""
Expand Down
7 changes: 7 additions & 0 deletions internal/reachability/govulncheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ func TestParseGovulncheck(t *testing.T) {
if got := res.GOSeverity["GO-2022-0001"]; got != "LOW" {
t.Errorf("GO-2022-0001 severity: got %q want LOW", got)
}
trace := res.VulnTrace("CVE-2021-38561")
if len(trace) != 2 || trace[0].Function != "Parse" || trace[1].Function != "main" {
t.Fatalf("trace = %+v, want innermost-first Parse -> main", trace)
}
if trace[0].FirstParty || trace[1].FirstParty {
t.Errorf("unexpected first-party flags: %+v", trace)
}
}

func TestLookupStrongestWins(t *testing.T) {
Expand Down
21 changes: 20 additions & 1 deletion internal/reachability/model.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package reachability

import "strings"
import (
"strings"

"github.com/shinigamikiko/wolfee-cli/internal/onlinescan"
)

type State string

Expand Down Expand Up @@ -34,6 +38,9 @@ type Result struct {

CallLines map[string]string

// Traces are stored in govulncheck's native order: innermost frame first.
Traces map[string][]onlinescan.TraceFrame

ImportSites map[string]string

ImportLines map[string]string
Expand Down Expand Up @@ -129,6 +136,18 @@ func (r *Result) VulnCallLine(ids ...string) string {
return ""
}

func (r *Result) VulnTrace(ids ...string) []onlinescan.TraceFrame {
if r == nil {
return nil
}
for _, id := range ids {
if trace := r.Traces[normID(id)]; len(trace) > 0 {
return trace
}
}
return nil
}

func (r *Result) ImportSite(purlNoVersion string) string {
if r == nil {
return ""
Expand Down
3 changes: 3 additions & 0 deletions internal/sbomscan/reachability_join.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func applyReachability(cr *ComponentReport, oracle *reachability.Result) {
v.Reachable = string(st)
v.CallSite = oracle.VulnCallSite(ids...)
v.CallLine = oracle.VulnCallLine(ids...)
v.Trace = oracle.VulnTrace(ids...)
case reachability.StateUnreachable:
v.Reachable = string(st)

Expand Down Expand Up @@ -101,6 +102,7 @@ func injectGovulncheckFindings(components *[]ComponentReport, oracle *reachabili
Reachable: string(reachability.StateReachable),
CallSite: oracle.VulnCallSite(goID),
CallLine: oracle.VulnCallLine(goID),
Trace: oracle.VulnTrace(goID),
})
}
cr.TopSeverity, cr.VulnCount = topAndCount(cr.Vulnerabilities)
Expand Down Expand Up @@ -137,6 +139,7 @@ func injectGovulncheckFindings(components *[]ComponentReport, oracle *reachabili
Reachable: string(reachability.StateReachable),
CallSite: oracle.VulnCallSite(goID),
CallLine: oracle.VulnCallLine(goID),
Trace: oracle.VulnTrace(goID),
})
}
stdlib.Vulnerabilities = dedupeVulns(stdlib.Vulnerabilities)
Expand Down
6 changes: 6 additions & 0 deletions internal/sbomscan/vulns.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ func dedupeVulns(vulns []onlinescan.Vulnerability) []onlinescan.Vulnerability {
if v.CallLine == "" {
v.CallLine = prev.CallLine
}
if len(v.Trace) == 0 {
v.Trace = prev.Trace
}
prev = v
}
if reachRank(v.Reachable) < reachRank(prev.Reachable) {
Expand All @@ -171,6 +174,9 @@ func dedupeVulns(vulns []onlinescan.Vulnerability) []onlinescan.Vulnerability {
prev.CallSite = v.CallSite
prev.CallLine = v.CallLine
}
if len(prev.Trace) == 0 {
prev.Trace = v.Trace
}

seen := map[string]bool{strings.ToUpper(prev.ID): true}
for _, a := range prev.Aliases {
Expand Down
Loading