diff --git a/internal/onlinescan/types.go b/internal/onlinescan/types.go index b2ac286..76fe188 100644 --- a/internal/onlinescan/types.go +++ b/internal/onlinescan/types.go @@ -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"` @@ -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). diff --git a/internal/output/fields.go b/internal/output/fields.go index 503b249..96d41a5 100644 --- a/internal/output/fields.go +++ b/internal/output/fields.go @@ -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() { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 7451428..0ef333b 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -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 @@ -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{{ diff --git a/internal/output/sarif.go b/internal/output/sarif.go index 9ac5675..10af6cf 100644 --- a/internal/output/sarif.go +++ b/internal/output/sarif.go @@ -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 } @@ -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 + } + 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 diff --git a/internal/reachability/govulncheck.go b/internal/reachability/govulncheck.go index d1ceecf..beda2cc 100644 --- a/internal/reachability/govulncheck.go +++ b/internal/reachability/govulncheck.go @@ -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{} @@ -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 { + 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 { @@ -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++ } @@ -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 "" diff --git a/internal/reachability/govulncheck_test.go b/internal/reachability/govulncheck_test.go index b3345c8..9c65cb7 100644 --- a/internal/reachability/govulncheck_test.go +++ b/internal/reachability/govulncheck_test.go @@ -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) { diff --git a/internal/reachability/model.go b/internal/reachability/model.go index 464f7d8..323b266 100644 --- a/internal/reachability/model.go +++ b/internal/reachability/model.go @@ -1,6 +1,10 @@ package reachability -import "strings" +import ( + "strings" + + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" +) type State string @@ -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 @@ -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 "" diff --git a/internal/sbomscan/reachability_join.go b/internal/sbomscan/reachability_join.go index 8cc35bc..3b1be63 100644 --- a/internal/sbomscan/reachability_join.go +++ b/internal/sbomscan/reachability_join.go @@ -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) @@ -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) @@ -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) diff --git a/internal/sbomscan/vulns.go b/internal/sbomscan/vulns.go index de14b92..f6b2b1d 100644 --- a/internal/sbomscan/vulns.go +++ b/internal/sbomscan/vulns.go @@ -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) { @@ -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 {