diff --git a/internal/commands/gitmetadata.go b/internal/commands/gitmetadata.go index 01ca8f81b..5f8c0ae2a 100644 --- a/internal/commands/gitmetadata.go +++ b/internal/commands/gitmetadata.go @@ -171,6 +171,7 @@ func buildContributorsCSV(commits []*object.Commit) ([]byte, error) { } func buildMetadataJSON(repositoryURL string, headCommit *object.Commit, commitsCount int) ([]byte, error) { + repositoryURL = sanitizeRepoURL(repositoryURL) metadata := contributorsMetadata{ RepositoryURL: repositoryURL, LastCommitHash: headCommit.Hash.String(), @@ -193,6 +194,61 @@ func remoteURL(repo *gogit.Repository) string { return urls[0] } +const urlSchemeSeparator = "://" + +// sanitizeRepoURL strips credentials from git remote URLs for metadata.json. Removes userinfo from http(s)/git/ftp, +// preserves ssh usernames while dropping passwords, leaves SCP-style and local paths unchanged, and removes query/fragments. +func sanitizeRepoURL(raw string) string { + raw = strings.TrimSpace(raw) + schemeEnd := strings.Index(raw, urlSchemeSeparator) + if schemeEnd < 0 { + // No scheme found (e.g., SCP-style git@host:path or local paths) + return raw + } + scheme := strings.ToLower(raw[:schemeEnd]) + rest := raw[schemeEnd+len(urlSchemeSeparator):] + + // Find @ (userinfo separator). If present, split userinfo from host+path. + atIndex := strings.LastIndexByte(rest, '@') + if atIndex < 0 { + // No credentials, return as-is after removing query/fragment + if i := strings.IndexAny(rest, "?#"); i >= 0 { + rest = rest[:i] + } + return raw[:schemeEnd+len(urlSchemeSeparator)] + rest + } + + // Split: userinfo@host+path + userinfo := rest[:atIndex] + hostPath := rest[atIndex+1:] + + // Find path separator in host+path + slashIndex := strings.IndexByte(hostPath, '/') + var host, path string + if slashIndex < 0 { + host = hostPath + path = "" + } else { + host = hostPath[:slashIndex] + path = hostPath[slashIndex:] + } + + // For SSH, keep username but drop password; for HTTP(S), remove all userinfo + if strings.Contains(scheme, "ssh") { + if colon := strings.IndexByte(userinfo, ':'); colon >= 0 { + userinfo = userinfo[:colon] + } + host = userinfo + "@" + host + } + + // Drop query strings and fragments from path + if i := strings.IndexAny(path, "?#"); i >= 0 { + path = path[:i] + } + + return raw[:schemeEnd+len(urlSchemeSeparator)] + host + path +} + // generateViaSystemGit extracts repo info via system git when go-git fails (e.g., unsupported extensions). func generateViaSystemGit(repoPath string, isPrivateRepo bool) error { remoteURL, err := gitCommand(repoPath, "config", "--get", "remote.origin.url") @@ -326,6 +382,7 @@ func convertToCoreCommits(commits []map[string]string) []*object.Commit { // buildMetadataJSONFromSystem builds metadata.json using data from system git. func buildMetadataJSONFromSystem(remoteURL, headHash string, commitCount int, lastCommitDate string) ([]byte, error) { + remoteURL = sanitizeRepoURL(remoteURL) metadata := contributorsMetadata{ RepositoryURL: remoteURL, LastCommitHash: headHash, @@ -371,13 +428,14 @@ func detectRepositoryPrivacy(repoPath string, httpClient *http.Client) (isPrivat // Method 1: Try go-git repo, err := gogit.PlainOpen(repoPath) if err == nil { - return isPrivateByURL(remoteURL(repo), httpClient) + rawURL := remoteURL(repo) + return isPrivateByURL(sanitizeRepoURL(rawURL), httpClient) } // Method 2: Try system git remoteURL, err := gitCommand(repoPath, "config", "--get", "remote.origin.url") if err == nil && remoteURL != "" { - return isPrivateByURL(remoteURL, httpClient) + return isPrivateByURL(sanitizeRepoURL(remoteURL), httpClient) } // Default: Conservative (treat as private) diff --git a/internal/commands/scan.go b/internal/commands/scan.go index 37931d163..1b7424124 100644 --- a/internal/commands/scan.go +++ b/internal/commands/scan.go @@ -1821,6 +1821,20 @@ func addDirFilesIgnoreFilter(zipWriter *zip.Writer, baseDir, parentDir string) e err = addDirFilesIgnoreFilter(zipWriter, newBase, newParent) } else { fileName := parentDir + file.Name() + relPath := filepath.ToSlash(baseDir + file.Name()) + + excluded := false + for _, pattern := range commonParams.BaseExcludedFiles { + if relPath == pattern || strings.HasSuffix(relPath, pattern) { + excluded = true + logger.PrintIfVerbose("Excluded: " + fileName) + break + } + } + if excluded { + continue + } + logger.PrintIfVerbose("Included: " + fileName) dat, _ := ioutil.ReadFile(fileName) diff --git a/internal/commands/util/ssh_key_masking_test.go b/internal/commands/util/ssh_key_masking_test.go new file mode 100644 index 000000000..135c60b4b --- /dev/null +++ b/internal/commands/util/ssh_key_masking_test.go @@ -0,0 +1,48 @@ +//go:build !integration + +package util + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "strings" + "testing" + + "github.com/checkmarx/ast-cli/internal/logger" + "github.com/checkmarx/ast-cli/internal/params" + "github.com/spf13/viper" + "gotest.tools/assert" +) + +// Request bodies are dumped JSON-encoded, so a multi-line SSH key appears with +// escaped newlines and must still be masked in debug logs. +func TestSSHKeyIsMaskedInRequestDump(t *testing.T) { + const sshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\r\n" + + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\r\n" + + "QyNTUxOQAAACDqaGl0aGVyZWZha2VrZXlmb3J0ZXN0aW5nb25seQAAAJgAAAAAAAAAAAAA\r\n" + + "-----END OPENSSH PRIVATE KEY-----\n" + viper.Set(params.SSHValue, sshKey) + viper.Set(params.DebugFlag, true) + defer viper.Reset() + + body, err := json.Marshal(map[string]string{"key": "scan.handler.git.sshKey", "value": sshKey}) + assert.NilError(t, err) + req, err := http.NewRequest(http.MethodPatch, "http://localhost/api/configuration/project", bytes.NewReader(body)) + assert.NilError(t, err) + + var buf strings.Builder + original := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(original) + + logger.PrintRequest(req) + + out := buf.String() + for _, line := range strings.Split(strings.TrimSpace(sshKey), "\n") { + line = strings.TrimSpace(line) + assert.Assert(t, !strings.Contains(out, line), "ssh key line leaked to logs: %s", out) + } + assert.Assert(t, strings.Contains(out, `"value":"***"`), "expected masked ssh key in log output: %s", out) +} diff --git a/internal/logger/utils.go b/internal/logger/utils.go index 66b5c6bb8..721237e7a 100644 --- a/internal/logger/utils.go +++ b/internal/logger/utils.go @@ -1,6 +1,7 @@ package logger import ( + "encoding/json" "fmt" "io" "log" @@ -88,13 +89,23 @@ func sanitizeLogs(msg string) string { for _, flag := range sanitizeFlags { value := viper.GetString(flag) if value != "" { - msg = strings.ReplaceAll(msg, value, "***") + msg = maskValue(msg, value) } } sensitiveValuesMu.RLock() defer sensitiveValuesMu.RUnlock() for value := range sensitiveValues { - msg = strings.ReplaceAll(msg, value, "***") + msg = maskValue(msg, value) + } + return msg +} + +// maskValue masks both the raw value and its JSON-escaped form, since request +// bodies are logged JSON-encoded (e.g. SSH key newlines appear as \n). +func maskValue(msg, value string) string { + msg = strings.ReplaceAll(msg, value, "***") + if escaped, err := json.Marshal(value); err == nil { + msg = strings.ReplaceAll(msg, string(escaped[1:len(escaped)-1]), "***") } return msg } diff --git a/internal/params/filters.go b/internal/params/filters.go index 65b1aee46..7536621be 100644 --- a/internal/params/filters.go +++ b/internal/params/filters.go @@ -203,6 +203,11 @@ var BaseExcludeFilters = []string{ "!node_modules", } +// BaseExcludedFiles defines the base set of files to exclude from ZIP archives during scans. +var BaseExcludedFiles = []string{ + ".git/config", +} + var KicsBaseFilters = []string{ ".tf", ".yaml",