Skip to content

Commit 7049f08

Browse files
bomly-guyclaude
andauthored
feat(output): emit PackageLocation.Position in SARIF physicalLocation.region (#60)
The SARIF formatter previously emitted a synthetic location keyed on the package's qualified name. That gave consumers (GitHub code scanning, IDE plugins, Azure DevOps) nothing to deep-link to. This change reads each finding's Package.Locations and emits one SARIF location per entry: - artifactLocation.uri = the actual lockfile / manifest path (e.g. 'go.mod', 'package-lock.json', 'apps/api/Cargo.lock') - region.startLine / startColumn / endLine = the line / column / end line from PackageLocation.Position, when present A PackageLocation with a RealPath but no Position still gets a SARIF location pointing at the file, just without a region — honest about what we know. When a finding's package has no Locations at all, the formatter keeps emitting the prior synthetic location (qualified package name as URI) so SARIF consumers that key on URI continue to work. JSON output already exposes Locations through PackageRef (shipped in C.4); no JSON change needed here. Tests cover: - Region populated from Position - Multiple SARIF locations when a package has multiple PackageLocations (monorepo case) - Fallback to synthetic URI when no Locations - Location with RealPath but no Position emits no region Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 93a3456 commit 7049f08

2 files changed

Lines changed: 190 additions & 14 deletions

File tree

internal/output/sarif.go

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,24 @@ type sarifLocation struct {
6363

6464
type sarifPhysicalLocation struct {
6565
ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
66+
Region *sarifRegion `json:"region,omitempty"`
6667
}
6768

6869
type sarifArtifactLocation struct {
6970
URI string `json:"uri"`
7071
URIBaseID string `json:"uriBaseId,omitempty"`
7172
}
7273

74+
// sarifRegion is the SARIF 2.1.0 region descriptor. All numeric
75+
// fields are 1-based; omitted when unknown. Used to deep-link from
76+
// a result to the line in the lockfile where the affected
77+
// dependency is declared.
78+
type sarifRegion struct {
79+
StartLine int `json:"startLine,omitempty"`
80+
StartColumn int `json:"startColumn,omitempty"`
81+
EndLine int `json:"endLine,omitempty"`
82+
}
83+
7384
type sarifMessage struct {
7485
Text string `json:"text"`
7586
}
@@ -110,21 +121,11 @@ func WriteSARIF(w io.Writer, findings []sdk.Finding, toolName, toolVersion strin
110121
if pkgName != "" {
111122
msgText = fmt.Sprintf("%s in %s@%s", f.Title, pkgName, pkgVersion)
112123
}
113-
artifactURI := pkgName
114-
if artifactURI == "" {
115-
artifactURI = f.ID
116-
}
117124
results = append(results, sarifResult{
118-
RuleID: f.ID,
119-
Level: severityToSARIFLevel(f.Severity),
120-
Message: sarifMessage{Text: msgText},
121-
Locations: []sarifLocation{
122-
{
123-
PhysicalLocation: sarifPhysicalLocation{
124-
ArtifactLocation: sarifArtifactLocation{URI: artifactURI},
125-
},
126-
},
127-
},
125+
RuleID: f.ID,
126+
Level: severityToSARIFLevel(f.Severity),
127+
Message: sarifMessage{Text: msgText},
128+
Locations: sarifLocationsForFinding(f, pkgName),
128129
})
129130
}
130131

@@ -151,6 +152,62 @@ func WriteSARIF(w io.Writer, findings []sdk.Finding, toolName, toolVersion strin
151152
return enc.Encode(log)
152153
}
153154

155+
// sarifLocationsForFinding builds the SARIF Locations array for a
156+
// finding. When the finding's package carries one or more
157+
// PackageLocation entries with a non-nil Position, one SARIF
158+
// location per entry is emitted with artifactLocation pointing at
159+
// the source file and a region carrying the line / column. When the
160+
// package has no positions, a single synthetic location is emitted
161+
// with the package's qualified name as URI — preserves backward
162+
// compat for SARIF consumers that already keyed on the package URI.
163+
//
164+
// PackageLocations without a Position but with a non-empty RealPath
165+
// still get a SARIF location with artifactLocation.uri = RealPath
166+
// and no region. This is honest: we know which file the dep lives
167+
// in but not exactly where.
168+
func sarifLocationsForFinding(f sdk.Finding, fallbackURI string) []sarifLocation {
169+
if f.Package != nil && len(f.Package.Locations) > 0 {
170+
locations := make([]sarifLocation, 0, len(f.Package.Locations))
171+
for _, loc := range f.Package.Locations {
172+
uri := strings.TrimSpace(loc.RealPath)
173+
if uri == "" {
174+
uri = strings.TrimSpace(loc.AccessPath)
175+
}
176+
if uri == "" {
177+
continue
178+
}
179+
pl := sarifPhysicalLocation{
180+
ArtifactLocation: sarifArtifactLocation{URI: uri},
181+
}
182+
if loc.Position != nil && (loc.Position.Line > 0 || loc.Position.Column > 0 || loc.Position.EndLine > 0) {
183+
pl.Region = &sarifRegion{
184+
StartLine: loc.Position.Line,
185+
StartColumn: loc.Position.Column,
186+
EndLine: loc.Position.EndLine,
187+
}
188+
}
189+
locations = append(locations, sarifLocation{PhysicalLocation: pl})
190+
}
191+
if len(locations) > 0 {
192+
return locations
193+
}
194+
}
195+
// Fallback: emit a synthetic location keyed on the package name
196+
// so SARIF consumers always have a non-empty Locations array
197+
// (the SARIF spec requires one).
198+
uri := strings.TrimSpace(fallbackURI)
199+
if uri == "" {
200+
uri = f.ID
201+
}
202+
return []sarifLocation{
203+
{
204+
PhysicalLocation: sarifPhysicalLocation{
205+
ArtifactLocation: sarifArtifactLocation{URI: uri},
206+
},
207+
},
208+
}
209+
}
210+
154211
func severityToSARIFLevel(severity string) string {
155212
switch severity {
156213
case "critical", "high":

internal/output/sarif_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,122 @@ func TestWriteSARIF_OSVHelpURI(t *testing.T) {
145145
t.Error("expected OSV help URI in SARIF output")
146146
}
147147
}
148+
149+
func TestWriteSARIF_EmitsRegionFromPackageLocation(t *testing.T) {
150+
pkg := &sdk.Package{
151+
Name: "lodash",
152+
Version: "4.17.15",
153+
Locations: []sdk.PackageLocation{
154+
{
155+
RealPath: "package-lock.json",
156+
AccessPath: "package-lock.json",
157+
Position: &sdk.SourcePosition{
158+
File: "package-lock.json",
159+
Line: 142,
160+
},
161+
},
162+
},
163+
}
164+
findings := []sdk.Finding{
165+
{ID: "CVE-2021-23337", Kind: sdk.FindingKindVulnerability, Package: pkg, Title: "Vuln", Severity: "high", Source: "osv"},
166+
}
167+
168+
var buf bytes.Buffer
169+
if err := WriteSARIF(&buf, findings, "bomly", "0.1.0"); err != nil {
170+
t.Fatalf("WriteSARIF: %v", err)
171+
}
172+
var doc map[string]any
173+
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
174+
t.Fatalf("invalid JSON: %v", err)
175+
}
176+
177+
results := doc["runs"].([]any)[0].(map[string]any)["results"].([]any)
178+
if len(results) != 1 {
179+
t.Fatalf("results = %d, want 1", len(results))
180+
}
181+
locations := results[0].(map[string]any)["locations"].([]any)
182+
if len(locations) != 1 {
183+
t.Fatalf("locations = %d, want 1", len(locations))
184+
}
185+
pl := locations[0].(map[string]any)["physicalLocation"].(map[string]any)
186+
if pl["artifactLocation"].(map[string]any)["uri"] != "package-lock.json" {
187+
t.Errorf("artifactLocation.uri = %v, want package-lock.json", pl["artifactLocation"])
188+
}
189+
region, ok := pl["region"].(map[string]any)
190+
if !ok {
191+
t.Fatal("expected physicalLocation.region")
192+
}
193+
if region["startLine"] != float64(142) {
194+
t.Errorf("region.startLine = %v, want 142", region["startLine"])
195+
}
196+
}
197+
198+
func TestWriteSARIF_EmitsMultipleLocationsWhenPackageHasSeveral(t *testing.T) {
199+
pkg := &sdk.Package{
200+
Name: "express",
201+
Version: "4.18.0",
202+
Locations: []sdk.PackageLocation{
203+
{RealPath: "package-lock.json", Position: &sdk.SourcePosition{File: "package-lock.json", Line: 50}},
204+
{RealPath: "apps/api/package-lock.json", Position: &sdk.SourcePosition{File: "apps/api/package-lock.json", Line: 12}},
205+
},
206+
}
207+
findings := []sdk.Finding{
208+
{ID: "CVE-test", Kind: sdk.FindingKindVulnerability, Package: pkg, Title: "Vuln", Severity: "high"},
209+
}
210+
var buf bytes.Buffer
211+
if err := WriteSARIF(&buf, findings, "bomly", "0.1.0"); err != nil {
212+
t.Fatalf("WriteSARIF: %v", err)
213+
}
214+
var doc map[string]any
215+
_ = json.Unmarshal(buf.Bytes(), &doc)
216+
locations := doc["runs"].([]any)[0].(map[string]any)["results"].([]any)[0].(map[string]any)["locations"].([]any)
217+
if len(locations) != 2 {
218+
t.Fatalf("locations = %d, want 2", len(locations))
219+
}
220+
}
221+
222+
func TestWriteSARIF_FallsBackToPackageNameWhenNoLocations(t *testing.T) {
223+
pkg := &sdk.Package{Name: "lodash", Version: "4.17.15"}
224+
findings := []sdk.Finding{
225+
{ID: "CVE-2021-23337", Kind: sdk.FindingKindVulnerability, Package: pkg, Title: "Vuln", Severity: "high"},
226+
}
227+
var buf bytes.Buffer
228+
if err := WriteSARIF(&buf, findings, "bomly", "0.1.0"); err != nil {
229+
t.Fatalf("WriteSARIF: %v", err)
230+
}
231+
var doc map[string]any
232+
_ = json.Unmarshal(buf.Bytes(), &doc)
233+
pl := doc["runs"].([]any)[0].(map[string]any)["results"].([]any)[0].(map[string]any)["locations"].([]any)[0].(map[string]any)["physicalLocation"].(map[string]any)
234+
if pl["artifactLocation"].(map[string]any)["uri"] != "lodash" {
235+
t.Errorf("fallback uri = %v, want package qualified name", pl["artifactLocation"])
236+
}
237+
if _, hasRegion := pl["region"]; hasRegion {
238+
t.Errorf("fallback location should have no region")
239+
}
240+
}
241+
242+
func TestWriteSARIF_LocationWithoutPositionSkipsRegion(t *testing.T) {
243+
pkg := &sdk.Package{
244+
Name: "lodash",
245+
Version: "4.17.15",
246+
Locations: []sdk.PackageLocation{
247+
{RealPath: "package-lock.json"},
248+
},
249+
}
250+
findings := []sdk.Finding{
251+
{ID: "CVE-2021-23337", Kind: sdk.FindingKindVulnerability, Package: pkg, Title: "Vuln", Severity: "high"},
252+
}
253+
var buf bytes.Buffer
254+
if err := WriteSARIF(&buf, findings, "bomly", "0.1.0"); err != nil {
255+
t.Fatalf("WriteSARIF: %v", err)
256+
}
257+
var doc map[string]any
258+
_ = json.Unmarshal(buf.Bytes(), &doc)
259+
pl := doc["runs"].([]any)[0].(map[string]any)["results"].([]any)[0].(map[string]any)["locations"].([]any)[0].(map[string]any)["physicalLocation"].(map[string]any)
260+
if pl["artifactLocation"].(map[string]any)["uri"] != "package-lock.json" {
261+
t.Errorf("uri = %v, want package-lock.json", pl["artifactLocation"])
262+
}
263+
if _, hasRegion := pl["region"]; hasRegion {
264+
t.Error("location without Position should not have a region")
265+
}
266+
}

0 commit comments

Comments
 (0)