diff --git a/internal/commands/result.go b/internal/commands/result.go index a8aa5ef7..7714d90e 100644 --- a/internal/commands/result.go +++ b/internal/commands/result.go @@ -1,6 +1,7 @@ package commands import ( + "bufio" "encoding/json" "fmt" "html" @@ -14,6 +15,7 @@ import ( "strings" "text/template" "time" + "unicode/utf8" "github.com/MakeNowJust/heredoc" "github.com/checkmarx/ast-cli/internal/commands/util" @@ -132,6 +134,18 @@ const ( statusCompleted = "Completed" statusPartial = "Partial" statusFailed = "Failed" + maxSonarLineBytes = 1024 * 1024 + byteOrderMarkRune = rune(0xFEFF) + parentDir = ".." +) + +// lineStatus reports how much of a location the source file could confirm. +type lineStatus int + +const ( + lineStatusFileUnknown lineStatus = iota + lineStatusLineMissing + lineStatusOK ) var ( @@ -2332,6 +2346,8 @@ func parseSonar(results *wrappers.ScanResultsCollection) ([]wrappers.SonarIssues var sonarIssues []wrappers.SonarIssues var sonarRules []wrappers.SonarRules seenRuleIDs := make(map[string]bool) // Track already added rule IDs + // Shared across the whole export so each source file is read at most once. + lineIndex := newSonarLineIndex() if results != nil { for _, result := range results.Results { @@ -2346,8 +2362,15 @@ func parseSonar(results *wrappers.ScanResultsCollection) ([]wrappers.SonarIssues engineType := strings.TrimSpace(result.Type) if engineType == commonParams.SastType { - auxIssue.PrimaryLocation = parseSonarPrimaryLocation(result) - auxIssue.SecondaryLocations = parseSonarSecondaryLocations(result) + auxIssue.PrimaryLocation = parseSonarPrimaryLocation(result, lineIndex) + auxIssue.SecondaryLocations = parseSonarSecondaryLocations(result, lineIndex) + if auxIssue.PrimaryLocation.FilePath == "" { + // filePath is mandatory on the primary location, so an issue + // without one cannot be imported at all. Skipping it keeps + // the rest of the report loadable instead of having + // SonarQube reject every issue in the file. + continue + } sonarIssues = append(sonarIssues, auxIssue) } else if engineType == commonParams.KicsType { auxIssue.PrimaryLocation = parseLocationKics(result) @@ -2376,7 +2399,7 @@ func parseContainersSonar(result *wrappers.ScanResult) wrappers.SonarLocation { textRange.EndColumn = 2 textRange.StartLine = 1 textRange.EndLine = 2 - auxLocation.TextRange = textRange + auxLocation.TextRange = &textRange return auxLocation } @@ -2388,7 +2411,7 @@ func parseSscsSonar(result *wrappers.ScanResult, sonarIssue *wrappers.SonarIssue textRange.StartColumn = 1 textRange.EndColumn = 2 textRange.StartLine = result.ScanResultData.Line - sonarIssue.PrimaryLocation.TextRange = textRange + sonarIssue.PrimaryLocation.TextRange = &textRange return *sonarIssue } @@ -2472,7 +2495,7 @@ func parseScaSonarLocations(result *wrappers.ScanResult) []wrappers.SonarIssues textRange.StartLine = 1 textRange.EndLine = 2 - primaryLocation.TextRange = textRange + primaryLocation.TextRange = &textRange issueByLocation.PrimaryLocation = primaryLocation @@ -2490,48 +2513,185 @@ func parseLocationKics(results *wrappers.ScanResult) wrappers.SonarLocation { auxTextRange.StartLine = results.ScanResultData.Line auxTextRange.StartColumn = 0 auxTextRange.EndColumn = 1 - auxLocation.TextRange = auxTextRange + auxLocation.TextRange = &auxTextRange return auxLocation } -func parseSonarPrimaryLocation(results *wrappers.ScanResult) wrappers.SonarLocation { +// sonarLineIndex caches source line lengths so each report file is read at most once per export. +type sonarLineIndex struct { + baseDir string + files map[string][]uint +} + +func newSonarLineIndex() *sonarLineIndex { + baseDir, err := os.Getwd() + if err == nil { + // Resolved once here so resolveSourcePath can compare against it directly on every call. + baseDir, err = filepath.EvalSymlinks(baseDir) + } + if err != nil { + baseDir = "" + } + return &sonarLineIndex{baseDir: baseDir, files: make(map[string][]uint)} +} + +// resolveLine reports whether the given 1-based line of fileName exists and, when it does, how many characters it holds. +func (index *sonarLineIndex) resolveLine(fileName string, line uint) (length uint, status lineStatus) { + if fileName == "" { + return 0, lineStatusFileUnknown + } + + lengths, cached := index.files[fileName] + if !cached { + lengths = index.readLineLengths(fileName) + index.files[fileName] = lengths + } + if lengths == nil { + return 0, lineStatusFileUnknown + } + + if line == 0 || line > uint(len(lengths)) { + return 0, lineStatusLineMissing + } + return lengths[line-1], lineStatusOK +} + +// resolveSourcePath cleans a report file path and confines it to baseDir, rejecting anything that escapes. +func (index *sonarLineIndex) resolveSourcePath(fileName string) (path string, ok bool) { + if index.baseDir == "" { + return "", false + } + + relative := filepath.FromSlash(strings.TrimLeft(fileName, "/\\")) + if relative == "" || filepath.IsAbs(relative) || filepath.VolumeName(relative) != "" { + return "", false + } + + cleaned := filepath.Join(index.baseDir, relative) + + // EvalSymlinks confines containment to the real path, not a lexical one. + realPath, err := filepath.EvalSymlinks(cleaned) + if err != nil { + return "", false + } + + inside, err := filepath.Rel(index.baseDir, realPath) + if err != nil || inside == parentDir || strings.HasPrefix(inside, parentDir+string(os.PathSeparator)) { + return "", false + } + return realPath, true +} + +// readLineLengths returns the character length of each line of a file, or nil when it cannot be read in full. +func (index *sonarLineIndex) readLineLengths(fileName string) []uint { + path, ok := index.resolveSourcePath(fileName) + if !ok { + return nil + } + + file, err := os.Open(path) + if err != nil { + return nil + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), maxSonarLineBytes) + + lengths := []uint{} + for scanner.Scan() { + text := scanner.Text() + if len(lengths) == 0 { + text = strings.TrimPrefix(text, string(byteOrderMarkRune)) + } + text = strings.TrimSuffix(text, "\r") + lengths = append(lengths, uint(utf8.RuneCountInString(text))) + } + if scanner.Err() != nil { + return nil + } + return lengths +} + +// clampSonarColumns constrains a start offset and length to a line of lineLength characters. +func clampSonarColumns(startColumn, length, lineLength uint) (start, end uint, emit bool) { + if lineLength == 0 { + return 0, 0, false + } + + start = startColumn + if start > lineLength { + start = lineLength + } + + end = start + length + if end > lineLength { + end = lineLength + } + + // No forward span left: highlight the whole line instead of an invalid zero-width range. + if end <= start { + return 0, lineLength, true + } + return start, end, true +} + +func parseSonarPrimaryLocation(results *wrappers.ScanResult, lineIndex *sonarLineIndex) wrappers.SonarLocation { var auxLocation wrappers.SonarLocation // fill the details in the primary Location if len(results.ScanResultData.Nodes) > 0 { auxLocation.FilePath = strings.TrimLeft(results.ScanResultData.Nodes[0].FileName, "/") auxLocation.Message = html.UnescapeString(strings.ReplaceAll(results.ScanResultData.QueryName, "_", " ")) - auxLocation.TextRange = parseSonarTextRange(results.ScanResultData.Nodes[0]) + auxLocation.TextRange = parseSonarTextRange(results.ScanResultData.Nodes[0], lineIndex) } return auxLocation } -func parseSonarSecondaryLocations(results *wrappers.ScanResult) []wrappers.SonarLocation { +func parseSonarSecondaryLocations(results *wrappers.ScanResult, lineIndex *sonarLineIndex) []wrappers.SonarLocation { var auxSecondaryLocations []wrappers.SonarLocation // Traverse all the rest of the scan result nodes into secondary location of sonar if len(results.ScanResultData.Nodes) >= 1 { for _, node := range results.ScanResultData.Nodes[1:] { + filePath := strings.TrimLeft(node.FileName, "/") + if filePath == "" { + // filePath is mandatory on a secondary location too, so a node without one is skipped. + continue + } + textRange := parseSonarTextRange(node, lineIndex) + if textRange == nil { + // textRange is mandatory on secondary locations, so a node without a valid one is dropped. + continue + } var auxSecondaryLocation wrappers.SonarLocation - auxSecondaryLocation.FilePath = strings.TrimLeft(node.FileName, "/") + auxSecondaryLocation.FilePath = filePath auxSecondaryLocation.Message = html.UnescapeString(strings.ReplaceAll(results.ScanResultData.QueryName, "_", " ")) - auxSecondaryLocation.TextRange = parseSonarTextRange(node) + auxSecondaryLocation.TextRange = textRange auxSecondaryLocations = append(auxSecondaryLocations, auxSecondaryLocation) } } return auxSecondaryLocations } -func parseSonarTextRange(results *wrappers.ScanResultNode) wrappers.SonarTextRange { - var auxTextRange wrappers.SonarTextRange - auxTextRange.StartLine = results.Line - startColumn := getSastStartColumn(results.Column) +// parseSonarTextRange maps a scan result node onto a Sonar text range, clamping columns to the verified line length. +func parseSonarTextRange(results *wrappers.ScanResultNode, lineIndex *sonarLineIndex) *wrappers.SonarTextRange { + lineLength, status := lineIndex.resolveLine(results.FileName, results.Line) + if status == lineStatusLineMissing { + return nil + } - auxTextRange.StartColumn = startColumn - auxTextRange.EndColumn = startColumn + results.Length + auxTextRange := &wrappers.SonarTextRange{StartLine: results.Line} + if status != lineStatusOK { + return auxTextRange + } - if auxTextRange.StartColumn == auxTextRange.EndColumn { - auxTextRange.EndColumn++ + startColumn, endColumn, emit := clampSonarColumns(getSastStartColumn(results.Column), results.Length, lineLength) + if !emit { + return auxTextRange } + auxTextRange.StartColumn = startColumn + auxTextRange.EndColumn = endColumn + return auxTextRange } diff --git a/internal/commands/result_test.go b/internal/commands/result_test.go index ce82ad0e..66007499 100644 --- a/internal/commands/result_test.go +++ b/internal/commands/result_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "path/filepath" "reflect" "regexp" "strings" @@ -19,6 +20,7 @@ import ( "github.com/checkmarx/ast-cli/internal/wrappers" "github.com/checkmarx/ast-cli/internal/wrappers/mock" "github.com/pkg/errors" + asserts "github.com/stretchr/testify/assert" "golang.org/x/text/cases" "golang.org/x/text/language" "gotest.tools/assert" @@ -1938,3 +1940,320 @@ func (m *customScanSummaryMockWrapper) GetScanSummaryByScanID(scanID string) (*w TotalCount: 1, }, nil, nil } + +// writeSourceFile creates a file with the given content inside dir and returns its path relative to dir. +func writeSourceFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + asserts.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) + asserts.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return filepath.ToSlash(name) +} + +// assertValidSonarRange asserts a textRange satisfies the bounds SonarScanner enforces on import. +func assertValidSonarRange(t *testing.T, tr *wrappers.SonarTextRange, totalLines, lineLength uint) { + t.Helper() + if tr == nil { + return // omitted entirely, so the issue is file level and always valid + } + asserts.NotZero(t, tr.StartLine, "startLine must be set when textRange is present") + asserts.LessOrEqual(t, tr.StartLine, totalLines, "startLine must exist in the file") + + if tr.StartColumn == 0 && tr.EndColumn == 0 { + return // line level range, always accepted + } + asserts.LessOrEqual(t, tr.StartColumn, lineLength, "startColumn must not exceed line length") + asserts.LessOrEqual(t, tr.EndColumn, lineLength, "endColumn must not exceed line length") + asserts.Less(t, tr.StartColumn, tr.EndColumn, "range must move forward") +} + +func TestClampSonarColumns(t *testing.T) { + tests := []struct { + name string + startColumn uint + length uint + lineLength uint + wantStart uint + wantEnd uint + wantEmit bool + }{ + { + // Reported case: a 12 character line with Column=12, Length=6 produced endColumn 17. + name: "overflowing end column is clamped to line length", + startColumn: 11, length: 6, lineLength: 12, + wantStart: 11, wantEnd: 12, wantEmit: true, + }, + { + name: "valid range is preserved unchanged", + startColumn: 4, length: 5, lineLength: 40, + wantStart: 4, wantEnd: 9, wantEmit: true, + }, + { + name: "range ending exactly at line length is valid", + startColumn: 0, length: 12, lineLength: 12, + wantStart: 0, wantEnd: 12, wantEmit: true, + }, + { + name: "zero length node falls back to the whole line", + startColumn: 4, length: 0, lineLength: 40, + wantStart: 0, wantEnd: 40, wantEmit: true, + }, + { + name: "start beyond end of line falls back to the whole line", + startColumn: 50, length: 3, lineLength: 12, + wantStart: 0, wantEnd: 12, wantEmit: true, + }, + { + name: "empty line yields no column range", + startColumn: 0, length: 3, lineLength: 0, + wantStart: 0, wantEnd: 0, wantEmit: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, end, emit := clampSonarColumns(tt.startColumn, tt.length, tt.lineLength) + asserts.Equal(t, tt.wantEmit, emit) + asserts.Equal(t, tt.wantStart, start) + asserts.Equal(t, tt.wantEnd, end) + if emit { + asserts.LessOrEqual(t, end, tt.lineLength) + asserts.Less(t, start, end) + } + }) + } +} + +func TestSonarLineIndexLineLength(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + lfFile := writeSourceFile(t, dir, "lf.ts", "one\nthree3\n") + crlfFile := writeSourceFile(t, dir, "crlf.ts", "one\r\nthree3\r\n") + bomFile := writeSourceFile(t, dir, "bom.ts", string(byteOrderMarkRune)+"abc\n") + utf8File := writeSourceFile(t, dir, "utf8.ts", "héllo→\n") + emptyLineFile := writeSourceFile(t, dir, "nested/dir/empty.ts", "\nabc\n") + + index := newSonarLineIndex() + + t.Run("counts characters on an LF file", func(t *testing.T) { + length, status := index.resolveLine(lfFile, 2) + asserts.Equal(t, lineStatusOK, status) + asserts.Equal(t, uint(6), length) + }) + + t.Run("CRLF terminator is not counted", func(t *testing.T) { + length, status := index.resolveLine(crlfFile, 2) + asserts.Equal(t, lineStatusOK, status) + asserts.Equal(t, uint(6), length) + }) + + t.Run("leading BOM is not counted", func(t *testing.T) { + length, status := index.resolveLine(bomFile, 1) + asserts.Equal(t, lineStatusOK, status) + asserts.Equal(t, uint(3), length) + }) + + t.Run("multi byte characters count as one character each", func(t *testing.T) { + length, status := index.resolveLine(utf8File, 1) + asserts.Equal(t, lineStatusOK, status) + // "héllo→" is 6 characters but 9 bytes. + asserts.Equal(t, uint(6), length) + }) + + t.Run("empty line reports zero length", func(t *testing.T) { + length, status := index.resolveLine(emptyLineFile, 1) + asserts.Equal(t, lineStatusOK, status) + asserts.Equal(t, uint(0), length) + }) + + t.Run("leading slash in the report path is tolerated", func(t *testing.T) { + length, status := index.resolveLine("/"+lfFile, 1) + asserts.Equal(t, lineStatusOK, status) + asserts.Equal(t, uint(3), length) + }) + + t.Run("line past end of a readable file is reported missing", func(t *testing.T) { + _, status := index.resolveLine(lfFile, 99) + asserts.Equal(t, lineStatusLineMissing, status) + }) + + t.Run("line zero of a readable file is reported missing", func(t *testing.T) { + _, status := index.resolveLine(lfFile, 0) + asserts.Equal(t, lineStatusLineMissing, status) + }) + + t.Run("missing file is reported unknown, not missing line", func(t *testing.T) { + _, status := index.resolveLine("does/not/exist.ts", 1) + asserts.Equal(t, lineStatusFileUnknown, status) + }) + + t.Run("empty file name is reported unknown", func(t *testing.T) { + _, status := index.resolveLine("", 1) + asserts.Equal(t, lineStatusFileUnknown, status) + }) +} + +func TestSonarLineIndexRejectsPathsOutsideBaseDir(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + index := newSonarLineIndex() + + for _, fileName := range []string{ + "../escape.ts", + "../../../../etc/passwd", + "nested/../../escape.ts", + } { + t.Run("rejects "+fileName, func(t *testing.T) { + _, ok := index.resolveSourcePath(fileName) + asserts.False(t, ok, "path outside the working directory must be rejected") + }) + } + + t.Run("rejects an absolute path", func(t *testing.T) { + absolute := filepath.Join(dir, "abs.ts") + asserts.NoError(t, os.WriteFile(absolute, []byte("abc\n"), 0o600)) + // Absolute inputs are refused even when they resolve inside baseDir. + _, ok := index.resolveSourcePath(absolute) + asserts.False(t, ok) + }) + + t.Run("accepts a plain relative path inside the base directory", func(t *testing.T) { + name := writeSourceFile(t, dir, "inside.ts", "abc\n") + resolved, ok := index.resolveSourcePath(name) + asserts.True(t, ok) + asserts.True(t, strings.HasSuffix(resolved, "inside.ts")) + }) +} + +// TestParseSonarTextRangeCustomerRegression reproduces the reported "17 is not a valid line offset" failure end to end. +func TestParseSonarTextRangeCustomerRegression(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + const componentLine = "@Component({" // 12 characters, line 10 below + source := "import { Component, inject, OnInit } from '@angular/core'\n" + + "import { DomSanitizer } from '@angular/platform-browser'\n" + + "import jwtDecode from 'jwt-decode'\n" + + "import { TranslateModule } from '@ngx-translate/core'\n" + + "import { MatCardModule } from '@angular/material/card'\n" + + "\n\n\n\n" + // lines 6 to 9 + componentLine + "\n" + // line 10 + " selector: 'app-last-login-ip',\n" + + fileName := writeSourceFile(t, + dir, + "cxone-sq-integration/juice-shop-master/frontend/src/app/last-login-ip/last-login-ip.component.ts", + source, + ) + + index := newSonarLineIndex() + node := &wrappers.ScanResultNode{ + FileName: fileName, + Line: 10, + Column: 12, + Length: 6, + } + + textRange := parseSonarTextRange(node, index) + + asserts.NotNil(t, textRange) + asserts.Equal(t, uint(10), textRange.StartLine) + asserts.Equal(t, uint(11), textRange.StartColumn) + asserts.Equal(t, uint(12), textRange.EndColumn, "endColumn must be clamped to the 12 character line, not 17") + assertValidSonarRange(t, textRange, 11, uint(len(componentLine))) +} + +func TestParseSonarTextRangeFallsBackToLineLevel(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + index := newSonarLineIndex() + + t.Run("unreadable file keeps the line and emits no columns", func(t *testing.T) { + node := &wrappers.ScanResultNode{FileName: "absent.ts", Line: 7, Column: 12, Length: 6} + textRange := parseSonarTextRange(node, index) + asserts.NotNil(t, textRange) + asserts.Equal(t, uint(7), textRange.StartLine) + asserts.Zero(t, textRange.StartColumn, "columns are omitempty, so zero drops them from the report") + asserts.Zero(t, textRange.EndColumn) + }) + + t.Run("empty line emits no columns", func(t *testing.T) { + fileName := writeSourceFile(t, dir, "blank.ts", "\nabc\n") + node := &wrappers.ScanResultNode{FileName: fileName, Line: 1, Column: 3, Length: 4} + textRange := parseSonarTextRange(node, index) + asserts.NotNil(t, textRange) + asserts.Equal(t, uint(1), textRange.StartLine) + asserts.Zero(t, textRange.StartColumn) + asserts.Zero(t, textRange.EndColumn) + }) +} + +// TestParseSonarTextRangeOmitsRangeForMissingLine covers the minified-asset case where the engine reports a line past the end of the file. +func TestParseSonarTextRangeOmitsRangeForMissingLine(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + fileName := writeSourceFile(t, dir, "assets/private/dat.gui.min.js", "var a=1\nvar b=2\n") + index := newSonarLineIndex() + + node := &wrappers.ScanResultNode{FileName: fileName, Line: 803, Column: 5, Length: 4} + textRange := parseSonarTextRange(node, index) + + asserts.Nil(t, textRange, "textRange must be omitted when the line does not exist in the file") +} + +// TestParseSonarAllLocationsAreValid asserts every location of a result with overflowing nodes satisfies SonarQube's invariant. +func TestParseSonarAllLocationsAreValid(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + const line = "@Component({" // 12 characters + fileName := writeSourceFile(t, dir, "app/widget.component.ts", line+"\n"+line+"\n") + + results := &wrappers.ScanResultsCollection{ + Results: []*wrappers.ScanResult{ + { + Type: params.SastType, + ScanResultData: wrappers.ScanResultData{ + QueryName: "Angular_Client_Stored_DOM_XSS", + Nodes: []*wrappers.ScanResultNode{ + {FileName: fileName, Line: 1, Column: 12, Length: 6}, + {FileName: fileName, Line: 2, Column: 40, Length: 9}, + {FileName: fileName, Line: 1, Column: 1, Length: 0}, + {FileName: "missing.ts", Line: 3, Column: 5, Length: 4}, + // Line beyond end of a readable file: must be dropped. + {FileName: fileName, Line: 803, Column: 5, Length: 4}, + }, + }, + }, + }, + } + + issues, _ := parseSonar(results) + + asserts.Len(t, issues, 1) + // The node on line 803 is dropped: it has no valid textRange, which is mandatory on secondary locations. + asserts.Len(t, issues[0].SecondaryLocations, 3) + + all := append([]wrappers.SonarLocation{issues[0].PrimaryLocation}, issues[0].SecondaryLocations...) + for i := range all { + location := all[i] + t.Run("location "+string(rune('A'+i)), func(t *testing.T) { + if location.FilePath != fileName { + // A file that is not on disk keeps its line but drops the columns. + asserts.NotNil(t, location.TextRange) + asserts.NotZero(t, location.TextRange.StartLine) + asserts.Zero(t, location.TextRange.StartColumn) + asserts.Zero(t, location.TextRange.EndColumn) + return + } + assertValidSonarRange(t, location.TextRange, 2, uint(len(line))) + }) + } + + // SonarQube rejects the whole report if any secondary location has a nil textRange. + for _, location := range issues[0].SecondaryLocations { + asserts.NotNil(t, location.TextRange, "secondary locations must always carry a textRange") + } +} diff --git a/internal/wrappers/results-sonar.go b/internal/wrappers/results-sonar.go index 4fa0c05c..a8f18392 100644 --- a/internal/wrappers/results-sonar.go +++ b/internal/wrappers/results-sonar.go @@ -27,9 +27,10 @@ type SonarIssues struct { } type SonarLocation struct { - Message string `json:"message,omitempty"` - FilePath string `json:"filePath,omitempty"` - TextRange SonarTextRange `json:"textRange"` + Message string `json:"message,omitempty"` + FilePath string `json:"filePath,omitempty"` + // TextRange is omitted when the engine reports a line that does not exist in the file on disk. + TextRange *SonarTextRange `json:"textRange,omitempty"` } type SonarTextRange struct {