Repository: gitguardian/ggshield
Issue: `ggshield secret scan docker` scans all versions of images if `:latest` is omitted

## Environment

- ggshield version: 1.14.3
- Operating system (Linux, macOS, Windows): -
- Operating system version: -
- Python version: -

## Describe the bug

Steps to reproduce:

When scanning a Docker image using `ggshield secret scan docker` without adding the `:latest` tag, ggshield downloads **all** versions of the image.

1. Run command `ggshield secret scan docker gitguardian/ggshield:latest`
2. Notice it scanned 186 files and took 6s
3. Run command `ggshield secret scan docker gitguardian/ggshield`

Actual result:

GGShield scanned 188 files and took 35s.

Expected result:

GGShield should only have scanned 186 files in 6s.

The difference in number of files is not large here, but it could be on other images. The difference in execution time is massive.

## Explanation

Under the hood, GGShield runs `docker save <image_name> -o archive.tar`. Unlike other docker commands, `docker save` does not automatically append `:latest` if it's not present (see [`docker save` doc](https://docs.docker.com/engine/reference/commandline/save/))

## TODO

- [ ] Before running `docker save`, check if the image name includes a tag. If it's not the case, append `:latest` to it.

Changed files:
tests/unit/scan/test_scan_docker.py, ggshield/scan/docker.py

diff --git a/ggshield/scan/docker.py b/ggshield/scan/docker.py
index 2d4cab2..8ad4b17 100644
--- a/ggshield/scan/docker.py
+++ b/ggshield/scan/docker.py
@@ -41,6 +41,8 @@ FILEPATH_BANLIST_PATTERNS = {
 
 LAYER_TO_SCAN_PATTERN = re.compile(r"\\b(copy|add)\\b", re.IGNORECASE)
 
+TAG_PATTERN = re.compile(r":[a-zA-Z0-9_][-.a-zA-Z0-9_]{0,127}$")
+
 
 class InvalidDockerArchiveException(Exception):
     pass
@@ -219,6 +221,9 @@ def docker_save_to_tmp(image_name: str, destination_path: Path, timeout: int) ->
 
     Limit docker commands to run at most `timeout` seconds.
     """
+    image_name = (
+        image_name if TAG_PATTERN.search(image_name) else image_name + ":latest"
+    )
     command = ["docker", "save", image_name, "-o", str(destination_path)]
 
     try:
diff --git a/tests/unit/scan/test_scan_docker.py b/tests/unit/scan/test_scan_docker.py
index 30a04cb..f51e918 100644
--- a/tests/unit/scan/test_scan_docker.py
+++ b/tests/unit/scan/test_scan_docker.py
@@ -153,7 +153,7 @@ class TestDockerSave:
                 [
                     "docker",
                     "save",
-                    "ggshield-non-existant",
+                    "ggshield-non-existant:latest",
                     "-o",
                     str(self.TMP_ARCHIVE),
                 ],
@@ -171,7 +171,7 @@ class TestDockerSave:
         ):
             with pytest.raises(
                 click.UsageError,
-                match='Image "ggshield-non-existant" not found',
+                match='Image "ggshield-non-existant:latest" not found',
             ):
                 docker_save_to_tmp(
                     "ggshield-non-existant", self.TMP_ARCHIVE, DOCKER_TIMEOUT
@@ -226,7 +226,7 @@ class TestDockerSave:
             "subprocess.run",
             side_effect=subprocess.TimeoutExpired(cmd=[], timeout=DOCKER_TIMEOUT),
         ):
-            expected_msg = f'Command "docker save ggshield-non-existant -o {str(self.TMP_ARCHIVE)}" timed out'  # noqa: E501
+            expected_msg = f'Command "docker save ggshield-non-existant:latest -o {str(self.TMP_ARCHIVE)}" timed out'  # noqa: E501
             with pytest.raises(
                 UnexpectedError,
                 match=re.escape(expected_msg),