diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23793a4e..4bb7efa4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,6 +121,15 @@ jobs: go-version: '1.23.0' cache: true + - name: Set up Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Build embedded SootUp wrapper + run: make build-sootup-jar-embed + - name: Import GPG signing key id: import_gpg uses: crazy-max/ghaction-import-gpg@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2f926c1..e259b938 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,7 +97,7 @@ jobs: strategy: matrix: os: [ 'ubuntu-latest', 'macos-latest', 'windows-latest' ] - java: [11, 17, 20] + java: [11, 17, 21] fail-fast: false runs-on: ${{ matrix.os }} @@ -174,7 +174,7 @@ jobs: run: | cd cmd/debricked go generate -v -x + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 - name: golangci-lint - uses: golangci/golangci-lint-action@v4 - with: - version: v1.62 + run: $(go env GOPATH)/bin/golangci-lint run ./... diff --git a/.gitignore b/.gitignore index 3c3dd29e..d5d33e69 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ internal/scan/testdata/npm/result.json /internal/file/testdata/misc/yarn.lock /internal/callgraph/finder/javafinder/testdata/guava/maven.debricked.lock /internal/resolution/pm/maven/testdata/guava/maven.debricked.lock +build/sootup/SootUpWrapper/target/ diff --git a/Makefile b/Makefile index 62f215d7..301ff2e3 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,21 @@ test: test-e2e: bash scripts/test_e2e.sh $(type) +# Build the SootUp fat JAR from source. +# The built artifact is placed in: +# build/sootup/SootUpWrapper/target/sootup-wrapper-1.0.0.jar +.PHONY: build-sootup-jar +build-sootup-jar: + cd build/sootup/SootUpWrapper && mvn clean package -q + +# Build the SootUp fat JAR AND copy it into the Go CLI embedded directory so +# that it is picked up by the //go:embed directive in sootup_handler.go. +.PHONY: build-sootup-jar-embed +build-sootup-jar-embed: build-sootup-jar + cp build/sootup/SootUpWrapper/target/sootup-wrapper-1.0.0.jar \ + internal/callgraph/language/java/embedded/SootUpWrapper.jar + @echo "SootUpWrapper.jar embedded at internal/callgraph/language/java/embedded/SootUpWrapper.jar" + .PHONY: test-e2e-docker docker-build-dev: docker build -f build/docker/alpine.Dockerfile -t debricked/cli:dev --target dev . diff --git a/build/docker/debian.Dockerfile b/build/docker/debian.Dockerfile index 983ad27d..35bea2c9 100644 --- a/build/docker/debian.Dockerfile +++ b/build/docker/debian.Dockerfile @@ -75,7 +75,7 @@ RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg - RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list RUN apt -y update && apt -y install nodejs && \ apt -y clean && rm -rf /var/lib/apt/lists/* -RUN npm install --global npm@latest && \ +RUN npm install --global npm@10 && \ npm install --global yarn && \ npm install --global pnpm && \ npm install --global bower diff --git a/internal/callgraph/README.md b/internal/callgraph/README.md index f0af4e89..3caebc56 100644 --- a/internal/callgraph/README.md +++ b/internal/callgraph/README.md @@ -2,13 +2,23 @@ The Debricked CLI can generate static callgraphs for projects to enable reachability analysis for vulnerabilities. -For the only currently supported language, Java, some setup is required for callgraph generation to work -properly. For more information on this see the Language Support section below. - ## Language Support -Debricked CLI callgraph generation currently only supports Java, the specific -documentation for the Java callgraph generation can be -found [here](https://github.com/debricked/cli/blob/main/internal/callgraph/language/java11/README.md). +Debricked CLI callgraph generation currently supports Java and Go. + +Java callgraph generation requires compiled classes and supports both the `soot` +and `sootup` engines. For command usage and setup details, see the full CLI +documentation: +https://docs.debricked.com/tools-and-integrations/cli/debricked-cli + +## Java engine behavior notes + +- `soot` and `sootup` are both supported, but output size is not expected to be + identical between engines. +- `sootup` can produce a smaller graph due to more targeted traversal. +- `sootup` invokedynamic placeholder signatures (`sootup.dummy.InvokeDynamic`) + are filtered from the exported callgraph payload. +- If the SootUp TypeAssigner workaround is triggered, the CLI surfaces which JARs + were excluded from deep analysis during retry. ## Use @@ -32,4 +42,5 @@ debricked scan --callgraph To analyze the generated callgraph it needs to be uploaded using the scan command, either with the callgraph generation flag as above, or with an already generated call graph by omitting the flag. -For more information see documentation on the specific langauge implementation or see full CLI documentation [here](https://docs.debricked.com/tools-and-integrations/cli/debricked-cli) +For more information, see the full CLI documentation: +https://docs.debricked.com/tools-and-integrations/cli/debricked-cli diff --git a/internal/callgraph/language/java/callgraph.go b/internal/callgraph/language/java/callgraph.go index 6633dfd8..d2b2659e 100644 --- a/internal/callgraph/language/java/callgraph.go +++ b/internal/callgraph/language/java/callgraph.go @@ -2,7 +2,9 @@ package java import ( "fmt" + "path/filepath" "regexp" + "strings" "github.com/debricked/cli/internal/callgraph/cgexec" ioFs "github.com/debricked/cli/internal/io" @@ -103,6 +105,43 @@ func (cg *Callgraph) RunCallGraph(callgraphJarPath string) error { cmd := cgexec.NewCommand(osCmd) err = cgexec.RunCommand(*cmd, cg.ctx) + if err != nil { + return err + } - return err + if isSootUpWrapperJar(callgraphJarPath) { + for _, line := range extractSootUpDiagnostics(cmd.GetStdOut().String(), cmd.GetStdErr().String()) { + fmt.Println(line) + } + } + + return nil +} + +func isSootUpWrapperJar(callgraphJarPath string) bool { + return strings.EqualFold(filepath.Base(callgraphJarPath), "SootUpWrapper.jar") +} + +func extractSootUpDiagnostics(stdout string, stderr string) []string { + all := strings.Split(strings.Join([]string{stdout, stderr}, "\n"), "\n") + seen := map[string]struct{}{} + out := []string{} + + for _, line := range all { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if strings.Contains(trimmed, "TypeAssigner bug in jar") || + strings.Contains(trimmed, "Excluding jar from deep analysis and retrying") || + strings.Contains(trimmed, "Call graph succeeded after excluding") { + if _, ok := seen[trimmed]; !ok { + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + } + } + + return out } diff --git a/internal/callgraph/language/java/callgraph_test.go b/internal/callgraph/language/java/callgraph_test.go index 94d3e5bf..73079d21 100644 --- a/internal/callgraph/language/java/callgraph_test.go +++ b/internal/callgraph/language/java/callgraph_test.go @@ -59,3 +59,19 @@ func TestRunCallGraphErrorMock(t *testing.T) { assert.NotNil(t, err) } + +func TestIsSootUpWrapperJar(t *testing.T) { + assert.True(t, isSootUpWrapperJar("/tmp/.debricked/SootUpWrapper.jar")) + assert.False(t, isSootUpWrapperJar("/tmp/.debricked/soot-wrapper.jar")) +} + +func TestExtractSootUpDiagnostics(t *testing.T) { + stdout := "[SootUpWrapper] Call graph succeeded after excluding 1 problematic jar(s): [foo.jar]\n" + stderr := "[SootUpWrapper] Warning: TypeAssigner bug in jar 'foo.jar' (x)\n Excluding jar from deep analysis and retrying...\n" + lines := extractSootUpDiagnostics(stdout, stderr) + + assert.Len(t, lines, 3) + assert.Contains(t, lines[0], "Call graph succeeded after excluding") + assert.Contains(t, lines[1], "TypeAssigner bug in jar") + assert.Contains(t, lines[2], "Excluding jar from deep analysis and retrying") +} diff --git a/internal/callgraph/language/java/embedded/SootUpWrapper.jar b/internal/callgraph/language/java/embedded/SootUpWrapper.jar new file mode 100644 index 00000000..c9386654 Binary files /dev/null and b/internal/callgraph/language/java/embedded/SootUpWrapper.jar differ diff --git a/internal/callgraph/language/java/job.go b/internal/callgraph/language/java/job.go index 42bfa04f..644d67a5 100644 --- a/internal/callgraph/language/java/job.go +++ b/internal/callgraph/language/java/job.go @@ -55,8 +55,24 @@ func NewJob( func (j *Job) Run() { workingDirectory := j.GetDir() pmConfig := j.config.PackageManager() + outputName := j.outputName() targetDir := path.Join(workingDirectory, dependencyDir) + + // Resolve compiled classes directory based on package manager + classesPath := workingDirectory + switch pmConfig { + case maven: + classesPath = path.Join(workingDirectory, "target", "classes") + case gradle: + classesPath = path.Join(workingDirectory, "build", "classes", "java", "main") + } + + // Use compiled classes path if it exists; otherwise fallback to working directory targetClasses := []string{workingDirectory} + if _, err := j.fs.Stat(classesPath); !j.fs.IsNotExist(err) { + targetClasses = []string{classesPath} + } + if len(j.GetFiles()) > 0 { targetClasses = j.GetFiles() } @@ -123,6 +139,7 @@ func (j *Job) runCallGraph(callgraph ICallgraph) { func (j *Job) runPostProcess() { workingDirectory := j.GetDir() + outputName := j.outputName() outputFullPath := path.Join(workingDirectory, outputName) outputFullPathZip := outputFullPath + ".zip" j.SendStatus("zipping callgraph") @@ -154,3 +171,7 @@ func (j *Job) runPostProcess() { } } } + +func (j *Job) outputName() string { + return outputName +} diff --git a/internal/callgraph/language/java/job_test.go b/internal/callgraph/language/java/job_test.go index 1d4ebcce..26eeeb08 100644 --- a/internal/callgraph/language/java/job_test.go +++ b/internal/callgraph/language/java/job_test.go @@ -42,6 +42,18 @@ func TestNewJob(t *testing.T) { assert.False(t, j.Errors().HasError()) } +func TestOutputNameDefault(t *testing.T) { + config := conf.NewConfig("java", nil, map[string]string{}, true, "maven", "") + j := NewJob(dir, files, testdata.NewEchoCmdFactory(), io.FileWriter{}, io.NewArchive("dir"), config, nil, io.FileSystem{}, testdata.MockSootHandler{}) + assert.Equal(t, "debricked-call-graph.java", j.outputName()) +} + +func TestOutputNameSootUp(t *testing.T) { + config := conf.NewConfig("java", nil, map[string]string{"java-callgraph-engine": "sootup"}, true, "maven", "") + j := NewJob(dir, files, testdata.NewEchoCmdFactory(), io.FileWriter{}, io.NewArchive("dir"), config, nil, io.FileSystem{}, testdata.MockSootHandler{}) + assert.Equal(t, "debricked-call-graph.java", j.outputName()) +} + func TestRunMakeMavenCopyDependenciesCmdErr(t *testing.T) { cmdErr := errors.New("cmd-error") cmdFactoryMock := testdata.NewEchoCmdFactory() diff --git a/internal/callgraph/language/java/soot_handler.go b/internal/callgraph/language/java/soot_handler.go index c8e6ae43..42f5ccec 100644 --- a/internal/callgraph/language/java/soot_handler.go +++ b/internal/callgraph/language/java/soot_handler.go @@ -114,20 +114,24 @@ func (sh SootHandler) GetSootWrapper(version string, fs ioFs.IFileSystem, arc io return "", err } } - path, err := filepath.Abs(path.Join(debrickedDir, "soot-wrapper.jar")) + absDebrickedDir, err := filepath.Abs(debrickedDir) + if err != nil { + return "", err + } + jarPath, err := filepath.Abs(path.Join(debrickedDir, "soot-wrapper.jar")) if err != nil { return "", err } - if _, err := fs.Stat(path); fs.IsNotExist(err) { + if _, err := fs.Stat(jarPath); fs.IsNotExist(err) { if version == "21" { - return sh.initializeSootWrapper(fs, debrickedDir) + return sh.initializeSootWrapper(fs, absDebrickedDir) } - return path, sh.downloadSootWrapper(arc, fs, path, version) + return jarPath, sh.downloadSootWrapper(arc, fs, jarPath, version) } - return path, nil + return jarPath, nil } func (sh SootHandler) getSootHandlerJavaVersion(version int) (string, error) { diff --git a/internal/callgraph/language/java/soot_handler_component_test.go b/internal/callgraph/language/java/soot_handler_component_test.go new file mode 100644 index 00000000..aaa9a6f8 --- /dev/null +++ b/internal/callgraph/language/java/soot_handler_component_test.go @@ -0,0 +1,57 @@ +package java + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + ioTestData "github.com/debricked/cli/internal/io/testdata" + "github.com/stretchr/testify/assert" +) + +func TestSootHandlerComponent_GetSootWrapperInvalidVersionString(t *testing.T) { + h := SootHandler{} + _, err := h.GetSootWrapper("not-a-number", ioTestData.FileSystemMock{}, ioTestData.ArchiveMock{}) + assert.EqualError(t, err, "could not convert version to int") +} + +func TestSootHandlerComponent_GetSootWrapperUnsupportedJavaVersion(t *testing.T) { + h := SootHandler{} + _, err := h.GetSootWrapper("8", ioTestData.FileSystemMock{}, ioTestData.ArchiveMock{}) + assert.EqualError(t, err, "lowest supported version for running callgraph generation is 11") +} + +func TestSootHandlerComponent_GetSootWrapperMkdirError(t *testing.T) { + h := SootHandler{} + fsMock := ioTestData.FileSystemMock{ + StatError: fmt.Errorf("missing"), + IsNotExistBool: true, + MkdirError: fmt.Errorf("mkdir failed"), + } + + _, err := h.GetSootWrapper("11", fsMock, ioTestData.ArchiveMock{}) + assert.EqualError(t, err, "mkdir failed") +} + +func TestSootHandlerComponent_GetSootWrapperVersion21UsesEmbeddedJar(t *testing.T) { + h := SootHandler{} + fsMock := ioTestData.FileSystemMock{ + StatError: fmt.Errorf("missing"), + IsNotExistBool: true, + } + + p, err := h.GetSootWrapper("21", fsMock, ioTestData.ArchiveMock{}) + assert.NoError(t, err) + assert.NotEmpty(t, p) + assert.True(t, strings.HasSuffix(filepath.ToSlash(p), "/.debricked/SootWrapper.jar")) +} + +func TestSootHandlerComponent_GetSootWrapperReturnsExistingPath(t *testing.T) { + h := SootHandler{} + fsMock := ioTestData.FileSystemMock{} + + p, err := h.GetSootWrapper("17", fsMock, ioTestData.ArchiveMock{}) + assert.NoError(t, err) + assert.True(t, strings.HasSuffix(filepath.ToSlash(p), "/.debricked/soot-wrapper.jar")) +} diff --git a/internal/callgraph/language/java/soot_handler_test.go b/internal/callgraph/language/java/soot_handler_test.go index 3f2e32a7..95dd9614 100644 --- a/internal/callgraph/language/java/soot_handler_test.go +++ b/internal/callgraph/language/java/soot_handler_test.go @@ -12,6 +12,8 @@ import ( var sootHandler = SootHandler{} +const createErr = "create error" + func TestInitializeSootWrapper(t *testing.T) { fsMock := ioTestData.FileSystemMock{} tempDir, err := fsMock.MkdirTemp(".tmp") @@ -68,7 +70,7 @@ func TestDownloadSootWrapperMkdirTempError(t *testing.T) { } func TestDownloadSootWrapperCreateError(t *testing.T) { - errString := "create error" + errString := createErr fsMock := ioTestData.FileSystemMock{CreateError: fmt.Errorf("%s", errString)} //nolint arcMock := ioTestData.ArchiveMock{} err := sootHandler.downloadSootWrapper(arcMock, fsMock, "soot-wrapper.jar", "11") @@ -77,7 +79,7 @@ func TestDownloadSootWrapperCreateError(t *testing.T) { } func TestDownloadSootWrapperUnzipError(t *testing.T) { - errString := "create error" + errString := createErr fsMock := ioTestData.FileSystemMock{} arcMock := ioTestData.ArchiveMock{UnzipFileError: fmt.Errorf("%s", errString)} //nolint err := sootHandler.downloadSootWrapper(arcMock, fsMock, "soot-wrapper.jar", "11") diff --git a/internal/callgraph/language/java/sootup_handler.go b/internal/callgraph/language/java/sootup_handler.go new file mode 100644 index 00000000..e5f56548 --- /dev/null +++ b/internal/callgraph/language/java/sootup_handler.go @@ -0,0 +1,166 @@ +package java + +import ( + "embed" + "fmt" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + ioFs "github.com/debricked/cli/internal/io" +) + +// ISootUpHandler is the interface for obtaining the SootUpWrapper JAR path. +// It is the SootUp equivalent of ISootHandler and will be used by the +// dual-engine callgraph runner. +type ISootUpHandler interface { + GetSootUpWrapper(version string, fs ioFs.IFileSystem, arc ioFs.IArchive) (string, error) +} + +// SootUpHandler resolves and extracts the SootUpWrapper.jar. +// cliVersion is used only when a per-version ZIP must be downloaded from +// GitHub Releases; for the embedded JAR (Java 11 bytecode, JVM 11/17/21 +// compatible) cliVersion is not needed. +type SootUpHandler struct{ cliVersion string } + +// GetSootWrapper keeps compatibility with the existing ISootHandler contract. +// It delegates to GetSootUpWrapper so callers can swap engines without +// changing callgraph/job wiring. +func (sh SootUpHandler) GetSootWrapper(version string, fs ioFs.IFileSystem, arc ioFs.IArchive) (string, error) { + return sh.GetSootUpWrapper(version, fs, arc) +} + +//go:embed embedded/SootUpWrapper.jar +var sootUpJarFS embed.FS + +// initializeSootUpWrapper copies the embedded SootUpWrapper.jar into tempDir +// and returns its absolute path. +func (sh SootUpHandler) initializeSootUpWrapper(fs ioFs.IFileSystem, tempDir string) (string, error) { + jarFile, err := fs.FsOpenEmbed(sootUpJarFS, "embedded/SootUpWrapper.jar") + if err != nil { + return "", err + } + defer fs.FsCloseFile(jarFile) + + tempJarFile := filepath.Join(tempDir, "SootUpWrapper.jar") + + jarBytes, err := fs.FsReadAll(jarFile) + if err != nil { + return "", err + } + + err = fs.FsWriteFile(tempJarFile, jarBytes, 0600) + if err != nil { + return "", err + } + + return tempJarFile, nil +} + +// downloadSootUpWrapper downloads a release-specific ZIP from GitHub and +// unzips it to path. Used for future per-version SootUp releases. +func (sh SootUpHandler) downloadSootUpWrapper(arc ioFs.IArchive, fs ioFs.IFileSystem, jarPath string, version string) error { + dir, err := fs.MkdirTemp(".tmp") + if err != nil { + return err + } + + zipPath := dir + "/sootup_wrapper.zip" + zipFile, err := fs.Create(zipPath) + if err != nil { + return err + } + defer zipFile.Close() + + err = sh.downloadCompressedSootUpWrapper(fs, zipFile, version) + if err != nil { + return err + } + + return arc.UnzipFile(zipPath, jarPath) +} + +func (sh SootUpHandler) downloadCompressedSootUpWrapper(fs ioFs.IFileSystem, zipFile *os.File, version string) error { + fullURLFile := strings.Join([]string{ + "https://github.com/debricked/cli/releases/download/", + sh.cliVersion, + "/sootup-wrapper-", + version, + ".zip", + }, "") + + client := http.Client{ + CheckRedirect: func(r *http.Request, via []*http.Request) error { + r.URL.Opaque = r.URL.Path + + return nil + }, + } + resp, err := client.Get(fullURLFile) + if err != nil { + return err + } + defer resp.Body.Close() + + _, err = fs.Copy(zipFile, resp.Body) + + return err +} + +// GetSootUpWrapper returns the path to a ready-to-run SootUpWrapper.jar for +// the given Java version. +// +// The SootUp JAR is compiled at Java 11 bytecode level and runs on JVM 11, +// 17, and 21 without modification, so a single embedded artifact covers all +// supported versions. The JAR is extracted to .debricked/ on first use and +// reused on subsequent runs. +func (sh SootUpHandler) GetSootUpWrapper(version string, fs ioFs.IFileSystem, arc ioFs.IArchive) (string, error) { + versionInt, err := strconv.Atoi(version) + if err != nil { + return "", fmt.Errorf("could not convert version to int") + } + if _, err = sh.getSootUpHandlerJavaVersion(versionInt); err != nil { + return "", err + } + + debrickedDir := ".debricked" + if _, err := fs.Stat(debrickedDir); fs.IsNotExist(err) { + if mkdirErr := fs.Mkdir(debrickedDir, 0755); mkdirErr != nil { + return "", mkdirErr + } + } + + absDebrickedDir, err := filepath.Abs(debrickedDir) + if err != nil { + return "", err + } + + jarPath, err := filepath.Abs(path.Join(debrickedDir, "SootUpWrapper.jar")) + if err != nil { + return "", err + } + + // Always use the embedded JAR; it covers all supported JVM versions. + if _, err := fs.Stat(jarPath); fs.IsNotExist(err) { + return sh.initializeSootUpWrapper(fs, absDebrickedDir) + } + + return jarPath, nil +} + +// getSootUpHandlerJavaVersion validates the JVM version and returns the +// canonical version string used by SootUp ("11", "17", or "21"). +func (sh SootUpHandler) getSootUpHandlerJavaVersion(version int) (string, error) { + if version >= 21 { + return "21", nil + } else if version >= 17 { + return "17", nil + } else if version >= 11 { + return "11", nil + } + + return "", fmt.Errorf("lowest supported version for running callgraph generation is 11") +} diff --git a/internal/callgraph/language/java/sootup_handler_component_test.go b/internal/callgraph/language/java/sootup_handler_component_test.go new file mode 100644 index 00000000..6b1ce9fd --- /dev/null +++ b/internal/callgraph/language/java/sootup_handler_component_test.go @@ -0,0 +1,57 @@ +package java + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + ioTestData "github.com/debricked/cli/internal/io/testdata" + "github.com/stretchr/testify/assert" +) + +func TestSootUpHandlerComponent_GetSootUpWrapperInvalidVersionString(t *testing.T) { + h := SootUpHandler{} + _, err := h.GetSootUpWrapper("not-a-number", ioTestData.FileSystemMock{}, ioTestData.ArchiveMock{}) + assert.Error(t, err) +} + +func TestSootUpHandlerComponent_GetSootUpWrapperUnsupportedJavaVersion(t *testing.T) { + h := SootUpHandler{} + _, err := h.GetSootUpWrapper("8", ioTestData.FileSystemMock{}, ioTestData.ArchiveMock{}) + assert.Error(t, err) +} + +func TestSootUpHandlerComponent_GetSootUpWrapperMkdirError(t *testing.T) { + h := SootUpHandler{} + errString := "mkdir error" + fsMock := ioTestData.FileSystemMock{ + IsNotExistBool: true, + MkdirError: fmt.Errorf("%s", errString), //nolint + } + arcMock := ioTestData.ArchiveMock{} + _, err := h.GetSootUpWrapper("21", fsMock, arcMock) + assert.EqualError(t, err, errString) +} + +func TestSootUpHandlerComponent_GetSootUpWrapperVersion21UsesEmbeddedJar(t *testing.T) { + h := SootUpHandler{} + fsMock := ioTestData.FileSystemMock{ + StatError: fmt.Errorf("missing"), + IsNotExistBool: true, + } + + p, err := h.GetSootUpWrapper("21", fsMock, ioTestData.ArchiveMock{}) + assert.NoError(t, err) + assert.NotEmpty(t, p) + assert.True(t, strings.HasSuffix(filepath.ToSlash(p), "/.debricked/SootUpWrapper.jar")) +} + +func TestSootUpHandlerComponent_GetSootUpWrapperReturnsExistingPath(t *testing.T) { + h := SootUpHandler{} + fsMock := ioTestData.FileSystemMock{} + + p, err := h.GetSootUpWrapper("17", fsMock, ioTestData.ArchiveMock{}) + assert.NoError(t, err) + assert.True(t, strings.HasSuffix(filepath.ToSlash(p), "/.debricked/SootUpWrapper.jar")) +} diff --git a/internal/callgraph/language/java/sootup_handler_test.go b/internal/callgraph/language/java/sootup_handler_test.go new file mode 100644 index 00000000..a26d9c83 --- /dev/null +++ b/internal/callgraph/language/java/sootup_handler_test.go @@ -0,0 +1,115 @@ +package java + +import ( + "fmt" + "testing" + + ioTestData "github.com/debricked/cli/internal/io/testdata" + + "github.com/stretchr/testify/assert" +) + +var sootUpHandler = SootUpHandler{} + +// ── initializeSootUpWrapper ─────────────────────────────────────────────────── + +func TestInitializeSootUpWrapper(t *testing.T) { + fsMock := ioTestData.FileSystemMock{} + tempDir, err := fsMock.MkdirTemp(".tmp") + assert.NoError(t, err) + jarPath, err := sootUpHandler.initializeSootUpWrapper(fsMock, tempDir) + assert.NotEmpty(t, jarPath) + assert.NoError(t, err) +} + +func TestInitializeSootUpWrapperOpenEmbedError(t *testing.T) { + errString := "fs open embed error" + fsMock := ioTestData.FileSystemMock{FsOpenEmbedError: fmt.Errorf("%s", errString)} //nolint + tempDir, err := fsMock.MkdirTemp(".tmp") + assert.NoError(t, err) + _, err = sootUpHandler.initializeSootUpWrapper(fsMock, tempDir) + assert.Error(t, err) + assert.Equal(t, errString, err.Error()) +} + +func TestInitializeSootUpWrapperFsReadAllError(t *testing.T) { + errString := "fs read all error" + fsMock := ioTestData.FileSystemMock{FsReadAllError: fmt.Errorf("%s", errString)} //nolint + tempDir, err := fsMock.MkdirTemp(".tmp") + assert.NoError(t, err) + _, err = sootUpHandler.initializeSootUpWrapper(fsMock, tempDir) + assert.Error(t, err) + assert.Equal(t, errString, err.Error()) +} + +func TestInitializeSootUpWrapperFsWriteFileError(t *testing.T) { + errString := "fs write file error" + fsMock := ioTestData.FileSystemMock{FsWriteFileError: fmt.Errorf("%s", errString)} //nolint + tempDir, err := fsMock.MkdirTemp(".tmp") + assert.NoError(t, err) + _, err = sootUpHandler.initializeSootUpWrapper(fsMock, tempDir) + assert.Error(t, err) + assert.Equal(t, errString, err.Error()) +} + +// ── downloadSootUpWrapper ───────────────────────────────────────────────────── + +func TestDownloadSootUpWrapperMkdirTempError(t *testing.T) { + errString := "mkdir temp error" + fsMock := ioTestData.FileSystemMock{MkdirTempError: fmt.Errorf("%s", errString)} //nolint + arcMock := ioTestData.ArchiveMock{} + err := sootUpHandler.downloadSootUpWrapper(arcMock, fsMock, "sootup-wrapper.jar", "11") + assert.Error(t, err) + assert.Equal(t, errString, err.Error()) +} + +func TestDownloadSootUpWrapperCreateError(t *testing.T) { + errString := "create error" + fsMock := ioTestData.FileSystemMock{CreateError: fmt.Errorf("%s", errString)} //nolint + arcMock := ioTestData.ArchiveMock{} + err := sootUpHandler.downloadSootUpWrapper(arcMock, fsMock, "sootup-wrapper.jar", "11") + assert.Error(t, err) + assert.Equal(t, errString, err.Error()) +} + +func TestDownloadSootUpWrapperUnzipError(t *testing.T) { + errString := "unzip error" + fsMock := ioTestData.FileSystemMock{} + arcMock := ioTestData.ArchiveMock{UnzipFileError: fmt.Errorf("%s", errString)} //nolint + err := sootUpHandler.downloadSootUpWrapper(arcMock, fsMock, "sootup-wrapper.jar", "11") + assert.Error(t, err) + assert.Equal(t, errString, err.Error()) +} + +// ── getSootUpHandlerJavaVersion ─────────────────────────────────────────────── + +func TestGetSootUpHandlerJavaVersionTooLow(t *testing.T) { + _, err := sootUpHandler.getSootUpHandlerJavaVersion(8) + assert.Error(t, err) +} + +func TestGetSootUpHandlerJavaVersion11(t *testing.T) { + v, err := sootUpHandler.getSootUpHandlerJavaVersion(11) + assert.NoError(t, err) + assert.Equal(t, "11", v) +} + +func TestGetSootUpHandlerJavaVersion17(t *testing.T) { + v, err := sootUpHandler.getSootUpHandlerJavaVersion(17) + assert.NoError(t, err) + assert.Equal(t, "17", v) +} + +func TestGetSootUpHandlerJavaVersion21(t *testing.T) { + v, err := sootUpHandler.getSootUpHandlerJavaVersion(21) + assert.NoError(t, err) + assert.Equal(t, "21", v) +} + +func TestGetSootUpHandlerJavaVersionAbove21(t *testing.T) { + v, err := sootUpHandler.getSootUpHandlerJavaVersion(23) + assert.NoError(t, err) + assert.Equal(t, "21", v) +} + +// Component tests moved to sootup_handler_component_test.go for consistency with soot_handler structure diff --git a/internal/callgraph/language/java/strategy.go b/internal/callgraph/language/java/strategy.go index 029fdebb..8347d413 100644 --- a/internal/callgraph/language/java/strategy.go +++ b/internal/callgraph/language/java/strategy.go @@ -3,6 +3,7 @@ package java import ( "fmt" "log" + "os" "path/filepath" "strings" @@ -25,9 +26,14 @@ type Strategy struct { ctx cgexec.IContext } +const ( + javaCallgraphEngineEnv = "DEBRICKED_JAVA_CALLGRAPH_ENGINE" + javaCallgraphEngineSoot = "soot" + javaCallgraphEngineSootUp = "sootup" +) + func (s Strategy) Invoke() ([]job.IJob, error) { var jobs []job.IJob - // Filter relevant files if s.config == nil { strategyWarning("No config is setup") @@ -76,18 +82,63 @@ func (s Strategy) Invoke() ([]job.IJob, error) { absClassDirs, _ := finder.ConvertPathsToAbsPaths(javaClassDirs) rootClassMapping := finder.MapFilesToDir(absRoots, absClassDirs) + foundRootsWoClasses := countRootsWithoutClasses(absRoots, rootClassMapping) + if foundRootsWoClasses > 0 { + strategyWarning("Found " + fmt.Sprint(foundRootsWoClasses) + " roots without related classes, make sure to build your project before running.") + } + jobs = s.createJobs(rootClassMapping) + + return jobs, nil +} + +func selectJavaCallgraphHandler(config conf.IConfig) ISootHandler { + engine := "" + cliVersion := "" + if config != nil { + cliVersion = config.Version() + engine = strings.ToLower(strings.TrimSpace(config.Kwargs()["java-callgraph-engine"])) + } + if engine == "" { + engine = strings.ToLower(strings.TrimSpace(os.Getenv(javaCallgraphEngineEnv))) + } + + switch engine { + case "", javaCallgraphEngineSoot: + handler := SootHandler{cliVersion} + + return handler + case javaCallgraphEngineSootUp: + handler := SootUpHandler{cliVersion} + + return handler + default: + strategyWarning(fmt.Sprintf("Unknown %s value '%s'; defaulting to '%s'", javaCallgraphEngineEnv, engine, javaCallgraphEngineSoot)) + handler := SootHandler{cliVersion} + + return handler + } +} + +func countRootsWithoutClasses(absRoots []string, rootClassMapping map[string][]string) int { foundRootsWoClasses := 0 for _, root := range absRoots { if _, ok := rootClassMapping[root]; !ok { - foundRootsWoClasses += 1 + foundRootsWoClasses++ } } - if foundRootsWoClasses > 0 { - strategyWarning("Found " + fmt.Sprint(foundRootsWoClasses) + " roots without related classes, make sure to build your project before running.") - } + + return foundRootsWoClasses +} + +func (s Strategy) createJobs(rootClassMapping map[string][]string) []job.IJob { + jobs := make([]job.IJob, 0, len(rootClassMapping)) + handler := selectJavaCallgraphHandler(s.config) + for rootFile, classDirs := range rootClassMapping { - // For each class paths dir within the root, find GCDPath as entrypoint - // classDir := finder.GCDPath(classDirs) + if _, isSootUp := handler.(SootUpHandler); isSootUp { + classDirs = normalizeSootUpUserClassDirs(classDirs) + } + rootDir := filepath.Dir(rootFile) jobs = append(jobs, NewJob( rootDir, @@ -98,12 +149,64 @@ func (s Strategy) Invoke() ([]job.IJob, error) { s.config, s.ctx, io.FileSystem{}, - SootHandler{s.config.Version()}, + handler, ), ) } - return jobs, nil + return jobs +} + +func normalizeSootUpUserClassDirs(classDirs []string) []string { + roots := make(map[string]struct{}) + + for _, classDir := range classDirs { + normalized := normalizeToClassRoot(classDir) + if isSootUpTestClassRoot(normalized) { + continue + } + roots[normalized] = struct{}{} + } + + result := make([]string, 0, len(roots)) + for root := range roots { + result = append(result, root) + } + + return result +} + +func isSootUpTestClassRoot(path string) bool { + testMarkers := []string{ + filepath.Join("target", "test-classes"), + filepath.Join("build", "classes", "java", "test"), + } + + for _, marker := range testMarkers { + if strings.Contains(path, marker) { + return true + } + } + + return false +} + +func normalizeToClassRoot(classDir string) string { + clean := filepath.Clean(classDir) + markers := []string{ + filepath.Join("target", "classes"), + filepath.Join("target", "test-classes"), + filepath.Join("build", "classes", "java", "main"), + filepath.Join("build", "classes", "java", "test"), + } + + for _, marker := range markers { + if idx := strings.Index(clean, marker); idx >= 0 { + return clean[:idx+len(marker)] + } + } + + return clean } func NewStrategy( @@ -169,8 +272,8 @@ func buildProjects(s Strategy, roots []string) error { return fmt.Errorf("%s", strings.Join([]string{ "Build failed for all projects, if already built disable the build flag.", - "Or you can refer to the documentation for a detailed guide on manually building your Java project:", - "https://github.com/debricked/cli/blob/main/internal/callgraph/language/java11/README.md", + "Or you can refer to the CLI documentation for a detailed guide on manually building your Java project:", + "https://docs.debricked.com/tools-and-integrations/cli/debricked-cli", }, " ")) } diff --git a/internal/callgraph/language/java/strategy_test.go b/internal/callgraph/language/java/strategy_test.go index 8d39d3c4..d853591d 100644 --- a/internal/callgraph/language/java/strategy_test.go +++ b/internal/callgraph/language/java/strategy_test.go @@ -97,3 +97,68 @@ func TestBuildProjectsError(t *testing.T) { assert.NotNil(t, err) } + +func TestSelectJavaCallgraphHandlerDefault(t *testing.T) { + t.Setenv(javaCallgraphEngineEnv, "") + config := config.NewConfig("java", []string{"."}, map[string]string{}, true, "maven", "v2.0.0") + h := selectJavaCallgraphHandler(config) + _, ok := h.(SootHandler) + assert.True(t, ok) +} + +func TestSelectJavaCallgraphHandlerSootUp(t *testing.T) { + config := config.NewConfig("java", []string{"."}, map[string]string{"java-callgraph-engine": "sootup"}, true, "maven", "v2.0.0") + h := selectJavaCallgraphHandler(config) + _, ok := h.(SootUpHandler) + assert.True(t, ok) +} + +func TestSelectJavaCallgraphHandlerUnknownFallsBackToSoot(t *testing.T) { + t.Setenv(javaCallgraphEngineEnv, "unknown") + + config := config.NewConfig("java", []string{"."}, map[string]string{}, true, "maven", "v2.0.0") + h := selectJavaCallgraphHandler(config) + _, ok := h.(SootHandler) + assert.True(t, ok) +} + +func TestSelectJavaCallgraphHandlerEnvFallback(t *testing.T) { + t.Setenv(javaCallgraphEngineEnv, "sootup") + config := config.NewConfig("java", []string{"."}, map[string]string{}, true, "maven", "v2.0.0") + h := selectJavaCallgraphHandler(config) + _, ok := h.(SootUpHandler) + assert.True(t, ok) +} + +func TestNormalizeToClassRootTargetClasses(t *testing.T) { + in := filepath.Join("/tmp", "demo", "target", "classes", "com", "example") + out := normalizeToClassRoot(in) + expected := filepath.Join("/tmp", "demo", "target", "classes") + assert.Equal(t, expected, out) +} + +func TestNormalizeToClassRootTargetTestClasses(t *testing.T) { + in := filepath.Join("/tmp", "demo", "target", "test-classes", "com", "example") + out := normalizeToClassRoot(in) + expected := filepath.Join("/tmp", "demo", "target", "test-classes") + assert.Equal(t, expected, out) +} + +func TestNormalizeSootUpUserClassDirsDeduplicatesRoots(t *testing.T) { + in := []string{ + filepath.Join("/tmp", "demo", "target", "classes", "com", "example", "a"), + filepath.Join("/tmp", "demo", "target", "classes", "com", "example", "b"), + filepath.Join("/tmp", "demo", "target", "test-classes", "com", "example"), + } + out := normalizeSootUpUserClassDirs(in) + + assert.ElementsMatch(t, []string{ + filepath.Join("/tmp", "demo", "target", "classes"), + }, out) +} + +func TestIsSootUpTestClassRoot(t *testing.T) { + assert.True(t, isSootUpTestClassRoot(filepath.Join("/tmp", "demo", "target", "test-classes"))) + assert.True(t, isSootUpTestClassRoot(filepath.Join("/tmp", "demo", "build", "classes", "java", "test"))) + assert.False(t, isSootUpTestClassRoot(filepath.Join("/tmp", "demo", "target", "classes"))) +} diff --git a/internal/callgraph/language/java/testdata/cmd_factory_mock.go b/internal/callgraph/language/java/testdata/cmd_factory_mock.go index a38a0161..56dceefd 100644 --- a/internal/callgraph/language/java/testdata/cmd_factory_mock.go +++ b/internal/callgraph/language/java/testdata/cmd_factory_mock.go @@ -53,3 +53,13 @@ type MockSootHandler struct { func (msh MockSootHandler) GetSootWrapper(version string, fs ioFs.IFileSystem, arc ioFs.IArchive) (string, error) { return "", msh.GetSootWrapperError } + +type MockSootUpHandler struct { + GetSootUpWrapperError error + GetSootUpWrapperPath string +} + +func (msh MockSootUpHandler) GetSootUpWrapper(version string, fs ioFs.IFileSystem, arc ioFs.IArchive) (string, error) { + return msh.GetSootUpWrapperPath, msh.GetSootUpWrapperError +} + diff --git a/internal/callgraph/testdata/generator_mock.go b/internal/callgraph/testdata/generator_mock.go index 3ca85623..6da01290 100644 --- a/internal/callgraph/testdata/generator_mock.go +++ b/internal/callgraph/testdata/generator_mock.go @@ -9,15 +9,20 @@ import ( ) type GeneratorMock struct { - Err error - files []string + Err error + files []string + LastOptions callgraph.DebrickedOptions } -func (r *GeneratorMock) GenerateWithTimer(_ callgraph.DebrickedOptions) error { +func (r *GeneratorMock) GenerateWithTimer(options callgraph.DebrickedOptions) error { + r.LastOptions = options + return r.Err } -func (r *GeneratorMock) Generate(_ callgraph.DebrickedOptions, _ cgexec.IContext) error { +func (r *GeneratorMock) Generate(options callgraph.DebrickedOptions, _ cgexec.IContext) error { + r.LastOptions = options + for _, f := range r.files { createdFile, err := os.Create(f) if err != nil { diff --git a/internal/cmd/callgraph/callgraph.go b/internal/cmd/callgraph/callgraph.go index aab282cf..4bf3fe11 100644 --- a/internal/cmd/callgraph/callgraph.go +++ b/internal/cmd/callgraph/callgraph.go @@ -15,24 +15,29 @@ import ( ) var ( - exclusions = file.DefaultExclusions() - inclusions []string - buildDisabled bool - generateTimeout int - languages string - supportedLanguages = []string{"java", "golang"} - languageMap = map[string]string{ + exclusions = file.DefaultExclusions() + inclusions []string + buildDisabled bool + generateTimeout int + languages string + javaCallgraphEngine string + javaCallgraphEngineAlias string + supportedLanguages = []string{"java", "golang"} + supportedJavaCallgraphEngines = []string{"soot", "sootup"} + languageMap = map[string]string{ "java": "maven", "golang": "go", } ) const ( - ExclusionFlag = "exclusion" - InclusionFlag = "inclusion" - NoBuildFlag = "no-build" - GenerateTimeoutFlag = "generate-timeout" - LanguagesFlag = "languages" + ExclusionFlag = "exclusion" + InclusionFlag = "inclusion" + NoBuildFlag = "no-build" + GenerateTimeoutFlag = "generate-timeout" + LanguagesFlag = "languages" + JavaCallgraphEngineFlag = "java-callgraph-engine" + JavaCallgraphEngineAliasFlag = "engine" ) func NewCallgraphCmd(generator cg.IGenerator) *cobra.Command { @@ -82,6 +87,8 @@ This option requires a pre-built project. For more detailed documentation on the https://docs.debricked.com/tools-and-integrations/cli/debricked-cli#callgraph`) cmd.Flags().IntVar(&generateTimeout, GenerateTimeoutFlag, 60*60, "Timeout (in seconds) on call graph generation.") cmd.Flags().StringVarP(&languages, LanguagesFlag, "l", strings.Join(supportedLanguages, ","), "Colon separated list of languages to create a call graph for.") + cmd.Flags().StringVar(&javaCallgraphEngine, JavaCallgraphEngineFlag, "soot", "Java call graph engine to use: soot or sootup.") + cmd.Flags().StringVar(&javaCallgraphEngineAlias, JavaCallgraphEngineAliasFlag, "", "Alias for --java-callgraph-engine.") viper.MustBindEnv(ExclusionFlag) @@ -126,11 +133,22 @@ func RunE(callgraph callgraph.IGenerator) func(_ *cobra.Command, args []string) return err } + effectiveJavaEngine := resolveJavaCallgraphEngine(javaCallgraphEngine, javaCallgraphEngineAlias) + javaEngine, err := parseAndValidateJavaCallgraphEngine(effectiveJavaEngine) + if err != nil { + return err + } + configs := []conf.IConfig{} version := viper.GetString("cliVersion") for _, language := range languages { - configs = append(configs, conf.NewConfig(language, args, map[string]string{}, !buildDisabled, languageMap[language], version)) + kwargs := map[string]string{} + if language == "java" { + kwargs[JavaCallgraphEngineFlag] = javaEngine + } + + configs = append(configs, conf.NewConfig(language, args, kwargs, !buildDisabled, languageMap[language], version)) } options := cg.DebrickedOptions{ @@ -144,3 +162,22 @@ func RunE(callgraph callgraph.IGenerator) func(_ *cobra.Command, args []string) return callgraph.GenerateWithTimer(options) } } + +func parseAndValidateJavaCallgraphEngine(engine string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(engine)) + for _, supportedEngine := range supportedJavaCallgraphEngines { + if normalized == supportedEngine { + return normalized, nil + } + } + + return "", errors.New(engine + " is not a supported java callgraph engine") +} + +func resolveJavaCallgraphEngine(javaEngine string, aliasEngine string) string { + if strings.TrimSpace(aliasEngine) != "" { + return aliasEngine + } + + return javaEngine +} diff --git a/internal/cmd/callgraph/callgraph_test.go b/internal/cmd/callgraph/callgraph_test.go index ec854537..75099e5a 100644 --- a/internal/cmd/callgraph/callgraph_test.go +++ b/internal/cmd/callgraph/callgraph_test.go @@ -20,10 +20,12 @@ func TestNewCallgraphCmd(t *testing.T) { flags := cmd.Flags() flagAssertions := map[string]string{ - ExclusionFlag: "e", - InclusionFlag: "", - NoBuildFlag: "", - GenerateTimeoutFlag: "", + ExclusionFlag: "e", + InclusionFlag: "", + NoBuildFlag: "", + GenerateTimeoutFlag: "", + JavaCallgraphEngineFlag: "", + JavaCallgraphEngineAliasFlag: "", } for name, shorthand := range flagAssertions { flag := flags.Lookup(name) @@ -100,3 +102,22 @@ func TestParseAndValidateLanguages(t *testing.T) { _, err = parseAndValidateLanguages(languages) assert.Error(t, err) } + +func TestParseAndValidateJavaCallgraphEngine(t *testing.T) { + engine, err := parseAndValidateJavaCallgraphEngine("soot") + assert.NoError(t, err) + assert.Equal(t, "soot", engine) + + engine, err = parseAndValidateJavaCallgraphEngine(" SOOTUP ") + assert.NoError(t, err) + assert.Equal(t, "sootup", engine) + + _, err = parseAndValidateJavaCallgraphEngine("invalid") + assert.Error(t, err) +} + +func TestResolveJavaCallgraphEngine(t *testing.T) { + assert.Equal(t, "soot", resolveJavaCallgraphEngine("soot", "")) + assert.Equal(t, "sootup", resolveJavaCallgraphEngine("soot", "sootup")) + assert.Equal(t, " sootup ", resolveJavaCallgraphEngine("soot", " sootup ")) +} diff --git a/internal/cmd/scan/scan.go b/internal/cmd/scan/scan.go index 981a1792..a246d068 100644 --- a/internal/cmd/scan/scan.go +++ b/internal/cmd/scan/scan.go @@ -19,6 +19,7 @@ var branchName string var callgraph bool var callgraphGenerateTimeout int var callgraphUploadTimeout int +var javaCallgraphEngine string var commitAuthor string var commitName string var generateCommitName bool @@ -47,6 +48,7 @@ const ( CallGraphFlag = "callgraph" CallGraphGenerateTimeoutFlag = "callgraph-generate-timeout" CallGraphUploadTimeoutFlag = "callgraph-upload-timeout" + JavaCallgraphEngineFlag = "java-callgraph-engine" CommitFlag = "commit" CommitAuthorFlag = "author" DebugFlag = "debug" @@ -165,6 +167,7 @@ $ debricked scan . --inclusion '**/node_modules/**'`) For example, if there is a "go.mod" in the target path, its dependencies are going to get resolved onto a lock file, and latter scanned.`) cmd.Flags().BoolVar(&noFingerprint, NoFingerprintFlag, false, "Toggle fingerprinting for undeclared component identification. Can be run as a standalone command [fingerprint] with more granular options.") cmd.Flags().BoolVar(&callgraph, CallGraphFlag, false, `Enables call graph generation during scan.`) + cmd.Flags().StringVar(&javaCallgraphEngine, JavaCallgraphEngineFlag, "soot", "Java call graph engine to use during scan callgraph generation: soot or sootup.") cmd.Flags().IntVar(&callgraphUploadTimeout, CallGraphUploadTimeoutFlag, 10*60, "Set a timeout (in seconds) on call graph upload.") cmd.Flags().IntVar(&callgraphGenerateTimeout, CallGraphGenerateTimeoutFlag, 60*60, "Set a timeout (in seconds) on call graph generation.") cmd.Flags().IntVar(&minFingerprintContentLength, MinFingerprintContentLengthFlag, 0, "Set minimum content length (in bytes) for files to fingerprint.") @@ -252,6 +255,7 @@ func RunE(s *scan.IScanner) func(_ *cobra.Command, args []string) error { NpmPreferred: viper.GetBool(NpmPreferredFlag), PassOnTimeOut: viper.GetBool(PassOnTimeOut), CallGraph: viper.GetBool(CallGraphFlag), + JavaCallgraphEngine: viper.GetString(JavaCallgraphEngineFlag), CallGraphUploadTimeout: viper.GetInt(CallGraphUploadTimeoutFlag), CallGraphGenerateTimeout: viper.GetInt(CallGraphGenerateTimeoutFlag), MinFingerprintContentLength: viper.GetInt(MinFingerprintContentLengthFlag), diff --git a/internal/cmd/scan/scan_test.go b/internal/cmd/scan/scan_test.go index 866d954c..f11f50c0 100644 --- a/internal/cmd/scan/scan_test.go +++ b/internal/cmd/scan/scan_test.go @@ -23,6 +23,7 @@ func TestNewScanCmd(t *testing.T) { PassOnTimeOut: "p", NoResolveFlag: "", CallGraphFlag: "", + JavaCallgraphEngineFlag: "", CallGraphUploadTimeoutFlag: "", CallGraphGenerateTimeoutFlag: "", } diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 373a21c0..df4ecaab 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -51,6 +51,7 @@ type DebrickedOptions struct { Resolve bool Fingerprint bool CallGraph bool + JavaCallgraphEngine string SBOM string SBOMOutput string Exclusions []string @@ -240,9 +241,15 @@ func (dScanner *DebrickedScanner) scan(options DebrickedOptions, gitMetaObject g } if options.CallGraph { - debug.Log("Running scanFingerprint...", options.Debug) + debug.Log("Running callgraph generation...", options.Debug) + javaConfigKwargs := map[string]string{"pm": "maven"} + javaEngine := options.JavaCallgraphEngine + if javaEngine == "" { + javaEngine = "soot" + } + javaConfigKwargs["java-callgraph-engine"] = javaEngine configs := []config.IConfig{ - config.NewConfig("java", []string{}, map[string]string{"pm": "maven"}, true, "maven", options.Version), + config.NewConfig("java", []string{}, javaConfigKwargs, true, "maven", options.Version), config.NewConfig("golang", []string{}, map[string]string{"pm": "go"}, true, "go", options.Version), } timeout := options.CallGraphGenerateTimeout diff --git a/internal/scan/scanner_test.go b/internal/scan/scanner_test.go index c97d29ea..beb15dc3 100644 --- a/internal/scan/scanner_test.go +++ b/internal/scan/scanner_test.go @@ -822,10 +822,45 @@ func TestScanWithCallgraph(t *testing.T) { } err := scanner.Scan(opts) assert.ErrorContains(t, err, "failed to find dependency files") + assert.Len(t, generatorMock.LastOptions.Configs, 2) + assert.Equal(t, "soot", generatorMock.LastOptions.Configs[0].Kwargs()["java-callgraph-engine"]) cwd, _ = os.Getwd() assert.Contains(t, cwd, path) } +func TestScanWithCallgraphSootUpEngine(t *testing.T) { + if runtime.GOOS == windowsOS { + t.Skipf("TestScan is skipped due to Windows env") + } + clientMock := testdata.NewDebClientMock() + addMockedFormatsResponse(clientMock, "yarn\\.lock") + addMockedFileUploadResponse(clientMock) + addMockedFinishResponse(clientMock, http.StatusNoContent) + addMockedStatusResponse(clientMock, http.StatusOK, 100) + + generatorMock := callgraphTestdata.GeneratorMock{} + + scanner := makeScanner(clientMock, nil, &generatorMock) + scanner.fingerprint = fingerprint.NewFingerprinter() + + cwd, _ := os.Getwd() + defer resetWd(t, cwd) + + opts := DebrickedOptions{ + Path: testdataNpm, + Resolve: false, + Fingerprint: false, + CallGraph: true, + JavaCallgraphEngine: "sootup", + RepositoryName: testdataNpm, + CommitName: "testdata/npm-commit-callgraph-sootup", + } + err := scanner.Scan(opts) + assert.ErrorContains(t, err, "failed to find dependency files") + assert.Len(t, generatorMock.LastOptions.Configs, 2) + assert.Equal(t, "sootup", generatorMock.LastOptions.Configs[0].Kwargs()["java-callgraph-engine"]) +} + func TestScanWithSBOMReport(t *testing.T) { if runtime.GOOS == windowsOS { t.Skipf("TestScan is skipped due to Windows env") diff --git a/internal/upload/batch.go b/internal/upload/batch.go index 4213cd83..e1a946bf 100644 --- a/internal/upload/batch.go +++ b/internal/upload/batch.go @@ -32,6 +32,10 @@ var ( const callgraphName = "debricked-call-graph" +func isCallgraphFile(filePath string) bool { + return strings.HasPrefix(filepath.Base(filePath), callgraphName) +} + type uploadBatch struct { client *client.IDebClient fileGroups file.Groups @@ -74,7 +78,7 @@ func (uploadBatch *uploadBatch) upload() error { fileName := filepath.Base(f) var err error timeout := 0 - if strings.HasSuffix(fileName, callgraphName) { + if isCallgraphFile(fileName) { timeout = uploadBatch.callGraphTimeout } err = uploadBatch.uploadFile(f, timeout) @@ -277,7 +281,7 @@ func (uploadBatch *uploadBatch) initUpload() ([]string, error) { entryFile = files[0] files = files[1:] timeout := 0 - if strings.HasSuffix(filepath.Base(entryFile), callgraphName) { + if isCallgraphFile(entryFile) { timeout = 30 } err = uploadBatch.uploadFile(entryFile, timeout) diff --git a/internal/upload/batch_test.go b/internal/upload/batch_test.go index dc4b6abb..9904feda 100644 --- a/internal/upload/batch_test.go +++ b/internal/upload/batch_test.go @@ -154,6 +154,13 @@ func TestInitUpload(t *testing.T) { assert.Equal(t, 1, batch.ciUploadId) } +func TestIsCallgraphFile(t *testing.T) { + assert.True(t, isCallgraphFile("debricked-call-graph.java")) + assert.True(t, isCallgraphFile("debricked-call-graph.golang")) + assert.True(t, isCallgraphFile("debricked-call-graph-sootup.java")) + assert.False(t, isCallgraphFile("package.json")) +} + func TestGetDebrickedConfig(t *testing.T) { config := GetDebrickedConfig(filepath.Join("testdata", "debricked-config.yaml")) configJSON, err := json.Marshal(config) diff --git a/scripts/test_e2e_callgraph_java_version.sh b/scripts/test_e2e_callgraph_java_version.sh index 9c9744db..4e6e2267 100755 --- a/scripts/test_e2e_callgraph_java_version.sh +++ b/scripts/test_e2e_callgraph_java_version.sh @@ -4,5 +4,17 @@ else DEBRICKED_JAVA_VERSION=$1 fi -sed -i "s/[0-9]\+<\/java.version>/$DEBRICKED_JAVA_VERSION<\/java.version>/" test/callgraph/testdata/mvnproj-build/pom.xml +POM_PATH="test/callgraph/testdata/mvnproj-build/pom.xml" +POM_BAK="${POM_PATH}.bak" + +cp "$POM_PATH" "$POM_BAK" + +sed "s/[0-9]\+<\/java.version>/$DEBRICKED_JAVA_VERSION<\/java.version>/" "$POM_PATH" > "${POM_PATH}.tmp" +mv "${POM_PATH}.tmp" "$POM_PATH" + go test -v ./test/callgraph/maven_test.go +test_exit=$? + +mv "$POM_BAK" "$POM_PATH" + +exit $test_exit diff --git a/test/callgraph/maven_test.go b/test/callgraph/maven_test.go index 11192b57..bbc9c9df 100644 --- a/test/callgraph/maven_test.go +++ b/test/callgraph/maven_test.go @@ -8,11 +8,29 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGenerateCallgraph(t *testing.T) { + testGenerateCallgraph(t, filepath.Join("testdata", "mvnproj-build"), []string{"callgraph"}) +} + +func TestGenerateCallgraphSootUp(t *testing.T) { + testGenerateCallgraph(t, filepath.Join("testdata", "mvnproj-build"), []string{"callgraph", "--java-callgraph-engine", "sootup"}) +} + +func TestGenerateCallgraphNoBuild(t *testing.T) { + testGenerateCallgraphNoBuild(t, filepath.Join("testdata", "mvnproj-no-build"), []string{"callgraph", "--no-build"}) +} + +func TestGenerateCallgraphNoBuildSootUp(t *testing.T) { + testGenerateCallgraphNoBuild(t, filepath.Join("testdata", "mvnproj-no-build"), []string{"callgraph", "--no-build", "--java-callgraph-engine", "sootup"}) +} + +func testGenerateCallgraph(t *testing.T, mavenProjectPath string, commandArgs []string) { + t.Helper() + requireDebrickedBinary(t) - mavenProjectPath := filepath.Join("testdata", "mvnproj-build") tmpFolder := filepath.Join(mavenProjectPath, ".debrickedTmpFolder") targetFolder := filepath.Join(mavenProjectPath, "target") callgraphFile := filepath.Join(mavenProjectPath, "debricked-call-graph.java") @@ -21,7 +39,7 @@ func TestGenerateCallgraph(t *testing.T) { assert.NoDirExists(t, targetFolder) assert.NoFileExists(t, callgraphFile) - args := []string{"callgraph", mavenProjectPath} + args := append(commandArgs, mavenProjectPath) out, err := exec.Command("debricked", args...).Output() fmt.Println("debricked callgraph output:") fmt.Println(string(out)) @@ -35,39 +53,59 @@ func TestGenerateCallgraph(t *testing.T) { os.RemoveAll(tmpFolder) os.RemoveAll(targetFolder) os.Remove(callgraphFile) - } -func TestGenerateCallgraphNoBuild(t *testing.T) { +func testGenerateCallgraphNoBuild(t *testing.T, mavenProjectPath string, commandArgs []string) { + t.Helper() + requireDebrickedBinary(t) - mavenProjectPath := filepath.Join("testdata", "mvnproj-no-build") tmpFolder := filepath.Join(mavenProjectPath, ".debrickedTmpFolder") targetFolder := filepath.Join(mavenProjectPath, "target") callgraphFile := filepath.Join(mavenProjectPath, "debricked-call-graph.java") assert.NoFileExists(t, callgraphFile) tmpFolderInfoBefore, tmpErr := os.Stat(tmpFolder) - assert.NoError(t, tmpErr) - tmpFolderModTimeBefore := tmpFolderInfoBefore.ModTime() + tmpFolderExistsBefore := tmpErr == nil + var tmpFolderModTimeBefore interface{} = nil + if tmpFolderExistsBefore { + tmpFolderModTimeBefore = tmpFolderInfoBefore.ModTime() + } else { + assert.True(t, os.IsNotExist(tmpErr)) + } + targetFolderInfoBefore, targetErr := os.Stat(targetFolder) - assert.NoError(t, targetErr) + require.NoError(t, targetErr) targetFolderModTimeBefore := targetFolderInfoBefore.ModTime() - args := []string{"callgraph", mavenProjectPath, "--no-build"} + args := append(commandArgs, mavenProjectPath) out, err := exec.Command("debricked", args...).Output() fmt.Println("debricked callgraph --no-build output:") fmt.Println(string(out)) assert.NoError(t, err) assert.NotContains(t, string(out), "Errors") - tmpFolderInfoAfter, _ := os.Stat(tmpFolder) - tmpFolderModTimeAfter := tmpFolderInfoAfter.ModTime() + tmpFolderInfoAfter, tmpErrAfter := os.Stat(tmpFolder) + tmpFolderExistsAfter := tmpErrAfter == nil + var tmpFolderModTimeAfter interface{} = nil + if tmpFolderExistsAfter { + tmpFolderModTimeAfter = tmpFolderInfoAfter.ModTime() + } else { + assert.True(t, os.IsNotExist(tmpErrAfter)) + } + targetFolderInfoAfter, _ := os.Stat(targetFolder) targetFolderModTimeAfter := targetFolderInfoAfter.ModTime() + assert.Equal(t, tmpFolderExistsBefore, tmpFolderExistsAfter) assert.True(t, tmpFolderModTimeBefore == tmpFolderModTimeAfter) assert.True(t, targetFolderModTimeBefore == targetFolderModTimeAfter) assert.FileExists(t, callgraphFile) - os.Remove(callgraphFile) +} + +func requireDebrickedBinary(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("debricked"); err != nil { + t.Skip("debricked binary is not available in PATH") + } } diff --git a/test/callgraph/testdata/mvnproj-no-build/.debrickedTmpFolder/ignore b/test/callgraph/testdata/mvnproj-no-build/.debrickedTmpFolder/ignore deleted file mode 100644 index e69de29b..00000000 diff --git a/test/callgraph/testdata/mvnproj-no-build/target/classes/HelloWorld.class b/test/callgraph/testdata/mvnproj-no-build/target/classes/HelloWorld.class deleted file mode 100644 index f9b5c642..00000000 Binary files a/test/callgraph/testdata/mvnproj-no-build/target/classes/HelloWorld.class and /dev/null differ