diff --git a/internal/commands/scan.go b/internal/commands/scan.go index c40043547..85944edcf 100644 --- a/internal/commands/scan.go +++ b/internal/commands/scan.go @@ -43,17 +43,19 @@ import ( ) const ( - failedCreating = "Failed creating a scan" - failedGetting = "Failed showing a scan" - failedGettingTags = "Failed getting tags" - failedDeleting = "Failed deleting a scan" - failedCanceling = "Failed canceling a scan" - failedGettingAll = "Failed listing" - thresholdLog = "%s: Limit = %d, Current = %v" - thresholdMsgLog = "Threshold check finished with status %s : %s" - mbBytes = 1024.0 * 1024.0 - notExploitable = "NOT_EXPLOITABLE" - ignored = "IGNORED" + failedCreating = "Failed creating a scan" + failedGetting = "Failed showing a scan" + failedGettingTags = "Failed getting tags" + failedDeleting = "Failed deleting a scan" + failedCanceling = "Failed canceling a scan" + failedGettingAll = "Failed listing" + thresholdLog = "%s: Limit = %d, Current = %v" + thresholdMsgLog = "Threshold check finished with status %s : %s" + mbBytes = 1024.0 * 1024.0 + notExploitable = "NOT_EXPLOITABLE" + ignored = "IGNORED" + minWindowsPathLength = 3 + containerImagesFlagError = "--container-images flag error" git = "git" invalidSSHSource = "provided source does not need a key. Make sure you are defining the right source or remove the flag --ssh-key" @@ -1262,7 +1264,7 @@ func addContainersScan(cmd *cobra.Command, resubmitConfig []wrappers.Config) (ma continue } if containerImagesErr := validateContainerImageFormat(containerImageName); containerImagesErr != nil { - errorMsg := strings.TrimPrefix(containerImagesErr.Error(), "--container-images flag error: ") + errorMsg := strings.TrimPrefix(containerImagesErr.Error(), containerImagesFlagError+": ") validationErrors = append(validationErrors, fmt.Sprintf("User input: '%s' error: %s", containerImageName, errorMsg)) } } @@ -2322,20 +2324,11 @@ func enforceLocalResolutionForTarFiles(cmd *cobra.Command) error { } // isTarFileReference checks if a container image reference points to a tar file. -// Container-security scan-type related function. +// Handles both Unix and Windows paths (e.g., C:\path\file.tar). func isTarFileReference(imageRef string) bool { - // Known prefixes that might precede the actual file path - knownPrefixes := []string{ - dockerArchivePrefix, - ociArchivePrefix, - filePrefix, - ociDirPrefix, - } + knownPrefixes := []string{dockerArchivePrefix, ociArchivePrefix, filePrefix, ociDirPrefix} - // First, trim quotes from the entire input actualRef := strings.Trim(imageRef, "'\"") - - // Strip known prefixes to get the actual reference for _, prefix := range knownPrefixes { if strings.HasPrefix(actualRef, prefix) { actualRef = strings.TrimPrefix(actualRef, prefix) @@ -2344,31 +2337,35 @@ func isTarFileReference(imageRef string) bool { } } - // Check if the reference ends with .tar (case-insensitive) lowerRef := strings.ToLower(actualRef) - - // If it ends with .tar, it's a tar file (no tag suffix allowed) if strings.HasSuffix(lowerRef, ".tar") { return true } - // If it contains a colon but doesn't end with .tar, check if it's a file.tar:tag format (invalid) - // A tar file cannot have a tag suffix like file.tar:tag + if isWindowsAbsolutePath(actualRef) { + return strings.Contains(lowerRef, ".tar") + } + if strings.Contains(actualRef, ":") { parts := strings.Split(actualRef, ":") - const minPartsForTaggedImage = 2 - if len(parts) >= minPartsForTaggedImage { - firstPart := strings.ToLower(parts[0]) - // If the part before the colon is a tar file, this is invalid (tar files don't have tags) - if strings.HasSuffix(firstPart, ".tar") { - return false - } + if len(parts) >= 2 && strings.HasSuffix(strings.ToLower(parts[0]), ".tar") { + return false } } return false } +// isWindowsAbsolutePath checks for Windows drive letter paths (e.g., C:\, D:/). +func isWindowsAbsolutePath(path string) bool { + if len(path) < minWindowsPathLength { + return false + } + firstChar := path[0] + isLetter := (firstChar >= 'A' && firstChar <= 'Z') || (firstChar >= 'a' && firstChar <= 'z') + return isLetter && path[1] == ':' && (path[2] == '\\' || path[2] == '/') +} + func runCreateScanCommand( scansWrapper wrappers.ScansWrapper, exportWrapper wrappers.ExportWrapper, @@ -3586,7 +3583,7 @@ const ( // Container-security scan-type related function. // This function implements comprehensive validation logic for all supported container image formats: // - Standard image:tag format -// - Tar files (.tar) +// - Tar files (.tar) - including full file paths on Windows (C:\path\file.tar) and Unix (/path/file.tar) // - Prefixed formats (docker:, podman:, containerd:, registry:, docker-archive:, oci-archive:, oci-dir:, file:) // It provides helpful error messages and hints for common user mistakes. func validateContainerImageFormat(containerImage string) error { @@ -3626,6 +3623,11 @@ func validateContainerImageFormat(containerImage string) error { sanitizedInput = containerImage } + // Check if this looks like a file path before parsing colons + if looksLikeFilePath(sanitizedInput) { + return validateFilePath(sanitizedInput) + } + // Step 2: Look for the last colon (:) in the sanitized input lastColonIndex := strings.LastIndex(sanitizedInput, ":") @@ -3661,23 +3663,22 @@ func validateContainerImageFormat(containerImage string) error { // It's a tar file - check if it exists locally exists, err := osinstaller.FileExists(sanitizedInput) if err != nil { - return errors.Errorf("--container-images flag error: %v", err) + return errors.Errorf("%s: %v", containerImagesFlagError, err) } if !exists { - return errors.Errorf("--container-images flag error: file '%s' does not exist", sanitizedInput) + return errors.Errorf("%s: file '%s' does not exist", containerImagesFlagError, sanitizedInput) } return nil // Valid tar file } // Check for compressed tar files - if strings.HasSuffix(lowerInput, ".tar.gz") || strings.HasSuffix(lowerInput, ".tar.bz2") || - strings.HasSuffix(lowerInput, ".tar.xz") || strings.HasSuffix(lowerInput, ".tgz") { - return errors.Errorf("--container-images flag error: file '%s' is compressed, use non-compressed format (tar)", sanitizedInput) + if isCompressedTarFile(sanitizedInput) { + return errors.Errorf("%s: file '%s' is compressed, use non-compressed format (tar)", containerImagesFlagError, sanitizedInput) } // Check if it looks like a tar file extension (contains ".tar." but not a valid extension) if strings.Contains(lowerInput, ".tar.") { - return errors.Errorf("--container-images flag error: image does not have a tag. Did you try to scan a tar file?") + return errors.Errorf("%s: image does not have a tag. Did you try to scan a tar file?", containerImagesFlagError) } // Step 4: Special handling for prefixes that don't require tags (e.g., oci-dir:) @@ -3694,7 +3695,69 @@ func validateContainerImageFormat(containerImage string) error { } // Step 5: Not a tar file, no special prefix, and no colon - assume user forgot to add tag (error) - return errors.Errorf("--container-images flag error: image does not have a tag") + return errors.Errorf("%s: image does not have a tag", containerImagesFlagError) +} + +// isCompressedTarFile checks if the given path has a compressed tar file extension. +func isCompressedTarFile(path string) bool { + lowerPath := strings.ToLower(path) + return strings.HasSuffix(lowerPath, ".tar.gz") || strings.HasSuffix(lowerPath, ".tar.bz2") || + strings.HasSuffix(lowerPath, ".tar.xz") || strings.HasSuffix(lowerPath, ".tgz") +} + +// looksLikeFilePath checks if input looks like a file path rather than image:tag. +func looksLikeFilePath(input string) bool { + lowerInput := strings.ToLower(input) + + if isWindowsAbsolutePath(input) { + return true + } + + // If colon exists and part before it looks like a prefix (no separators/dots), it's not a file path + if colonIndex := strings.Index(input, ":"); colonIndex > 0 { + beforeColon := input[:colonIndex] + if !strings.Contains(beforeColon, "/") && !strings.Contains(beforeColon, "\\") && !strings.Contains(beforeColon, ".") { + return false + } + } + + if strings.HasSuffix(lowerInput, ".tar") { + return true + } + + if isCompressedTarFile(input) { + return true + } + + hasPathSeparators := strings.Contains(input, "/") || strings.Contains(input, "\\") + if hasPathSeparators && strings.Contains(lowerInput, ".tar") { + return true + } + + return false +} + +// validateFilePath validates file path input for tar files. +func validateFilePath(filePath string) error { + lowerPath := strings.ToLower(filePath) + + if isCompressedTarFile(filePath) { + return errors.Errorf("%s: file '%s' is compressed, use non-compressed format (tar)", containerImagesFlagError, filePath) + } + + if !strings.HasSuffix(lowerPath, ".tar") { + return errors.Errorf("%s: file '%s' is not a valid tar file. Expected .tar extension", containerImagesFlagError, filePath) + } + + exists, err := osinstaller.FileExists(filePath) + if err != nil { + return errors.Errorf("%s: %v", containerImagesFlagError, err) + } + if !exists { + return errors.Errorf("%s: file '%s' does not exist", containerImagesFlagError, filePath) + } + + return nil } // getPrefixFromInput extracts the prefix from a container image reference. @@ -3744,14 +3807,14 @@ func validatePrefixedContainerImage(containerImage, prefix string) error { func validateArchivePrefix(imageRef string) error { exists, err := osinstaller.FileExists(imageRef) if err != nil { - return errors.Errorf("--container-images flag error: %v", err) + return errors.Errorf("%s: %v", containerImagesFlagError, err) } if !exists { // Check if user mistakenly used archive prefix with an image name:tag format if strings.Contains(imageRef, ":") && !strings.HasSuffix(strings.ToLower(imageRef), ".tar") { - return errors.Errorf("--container-images flag error: file '%s' does not exist. Did you try to scan an image using image name and tag?", imageRef) + return errors.Errorf("%s: file '%s' does not exist. Did you try to scan an image using image name and tag?", containerImagesFlagError, imageRef) } - return errors.Errorf("--container-images flag error: file '%s' does not exist", imageRef) + return errors.Errorf("%s: file '%s' does not exist", containerImagesFlagError, imageRef) } return nil } @@ -3775,10 +3838,10 @@ func validateOCIDirPrefix(imageRef string) error { exists, err := osinstaller.FileExists(pathToCheck) if err != nil { - return errors.Errorf("--container-images flag error: path %s does not exist: %v", pathToCheck, err) + return errors.Errorf("%s: path %s does not exist: %v", containerImagesFlagError, pathToCheck, err) } if !exists { - return errors.Errorf("--container-images flag error: path %s does not exist", pathToCheck) + return errors.Errorf("%s: path %s does not exist", containerImagesFlagError, pathToCheck) } return nil } diff --git a/internal/commands/scan_test.go b/internal/commands/scan_test.go index 8d0ba7889..e51614dc4 100644 --- a/internal/commands/scan_test.go +++ b/internal/commands/scan_test.go @@ -2425,6 +2425,91 @@ func Test_validateThresholds(t *testing.T) { // TestValidateContainerImageFormat_Comprehensive tests the complete validation logic // including input normalization, helpful hints, and all error cases. +// TestIsWindowsAbsolutePath tests the Windows absolute path detection. +// Container-security scan-type related test function. +func TestIsWindowsAbsolutePath(t *testing.T) { + testCases := []struct { + name string + input string + expected bool + }{ + // Valid Windows absolute paths + {name: "C drive with backslash", input: "C:\\Users\\file.tar", expected: true}, + {name: "D drive with backslash", input: "D:\\data\\image.tar", expected: true}, + {name: "C drive with forward slash", input: "C:/Users/file.tar", expected: true}, + {name: "Lowercase drive letter", input: "c:\\path\\file.tar", expected: true}, + + // Not Windows absolute paths + {name: "Unix absolute path", input: "/path/to/file.tar", expected: false}, + {name: "Relative path", input: "Downloads/file.tar", expected: false}, + {name: "Simple filename", input: "file.tar", expected: false}, + {name: "Image with tag", input: "nginx:latest", expected: false}, + {name: "Too short", input: "C:", expected: false}, + {name: "No path separator after colon", input: "C:file.tar", expected: false}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result := isWindowsAbsolutePath(tc.input) + if result != tc.expected { + t.Errorf("isWindowsAbsolutePath(%q) = %v, expected %v", tc.input, result, tc.expected) + } + }) + } +} + +// TestLooksLikeFilePath tests the file path detection logic for cross-platform support. +// Container-security scan-type related test function. +// This test validates the looksLikeFilePath function for various Windows and Unix path formats. +func TestLooksLikeFilePath(t *testing.T) { + testCases := []struct { + name string + input string + expected bool + }{ + // Tar file extensions + {name: "Simple tar file", input: "image.tar", expected: true}, + {name: "Tar.gz file", input: "image.tar.gz", expected: true}, + {name: "Tar.bz2 file", input: "image.tar.bz2", expected: true}, + {name: "Tar.xz file", input: "image.tar.xz", expected: true}, + {name: "Tgz file", input: "image.tgz", expected: true}, + + // Unix-style paths + {name: "Unix relative path with tar", input: "subdir/image.tar", expected: true}, + {name: "Unix absolute path with tar", input: "/path/to/image.tar", expected: true}, + {name: "Unix path with version in filename", input: "Downloads/alpine_3.21.0_podman.tar", expected: true}, + {name: "Unix nested path", input: "path/to/nested/dir/file.tar", expected: true}, + + // Windows-style paths + {name: "Windows absolute path with drive letter", input: "C:\\Users\\Downloads\\image.tar", expected: true}, + {name: "Windows path with forward slash after drive", input: "C:/Users/Downloads/image.tar", expected: true}, + {name: "Windows relative path with backslash", input: "Downloads\\alpine_3.21.0_podman.tar", expected: true}, + {name: "Windows D drive path", input: "D:\\data\\images\\test.tar", expected: true}, + + // Not file paths (image:tag format) + {name: "Simple image:tag", input: "nginx:latest", expected: false}, + {name: "Image with registry", input: "registry.io/namespace/image:tag", expected: false}, + {name: "Image with port", input: "registry.io:5000/image:tag", expected: false}, + {name: "Image without tag", input: "nginx", expected: false}, + + // Edge cases + {name: "Tar file with dots in name", input: "alpine.3.18.0.tar", expected: true}, + {name: "Tar file with version like name", input: "app_v1.2.3.tar", expected: true}, + {name: "Path with tar in middle", input: "tarball/other.tar", expected: true}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result := looksLikeFilePath(tc.input) + if result != tc.expected { + t.Errorf("looksLikeFilePath(%q) = %v, expected %v", tc.input, result, tc.expected) + } + }) + } +} + // Container-security scan-type related test function. // This test validates all supported container image formats, prefixes, tar files, // error messages, and helpful hints for the --container-images flag. @@ -2511,6 +2596,36 @@ func TestValidateContainerImageFormat_Comprehensive(t *testing.T) { expectedError: "--container-images flag error: file 'image.tgz' is compressed, use non-compressed format (tar)", }, + // ==================== File Path Tests (Windows and Unix) ==================== + // Note: These tests validate that path-like inputs are correctly recognized as file paths + { + name: "Valid tar file with filename containing version number", + containerImage: "alpine_3.21.0_podman.tar", + expectedError: "", + setupFiles: []string{"alpine_3.21.0_podman.tar"}, + }, + { + name: "Valid tar file with filename containing underscore and version", + containerImage: "mysql_5.7_backup.tar", + expectedError: "", + setupFiles: []string{"mysql_5.7_backup.tar"}, + }, + { + name: "Invalid - Unix relative path does not exist", + containerImage: "subdir/image.tar", + expectedError: "--container-images flag error: file 'subdir/image.tar' does not exist", + }, + { + name: "Invalid - Unix nested path does not exist", + containerImage: "path/to/archive/my-image.tar", + expectedError: "--container-images flag error: file 'path/to/archive/my-image.tar' does not exist", + }, + { + name: "Invalid - file path with version-like name does not exist", + containerImage: "Downloads/alpine_3.21.0_podman.tar", + expectedError: "--container-images flag error: file 'Downloads/alpine_3.21.0_podman.tar' does not exist", + }, + // ==================== Helpful Hints Tests ==================== { name: "Hint - looks like tar file (wrong extension)",