gitlab-compliance 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. gitlab_compliance-2.0.0.dist-info/METADATA +92 -0
  2. gitlab_compliance-2.0.0.dist-info/RECORD +50 -0
  3. gitlab_compliance-2.0.0.dist-info/WHEEL +4 -0
  4. gitlab_compliance-2.0.0.dist-info/entry_points.txt +4 -0
  5. gitlab_compliance-2.0.0.dist-info/licenses/LICENSE +21 -0
  6. src/__init__.py +1 -0
  7. src/compliance/__init__.py +1 -0
  8. src/compliance/api_config.py +94 -0
  9. src/compliance/api_enrichment.py +52 -0
  10. src/compliance/behave_support/environment.py +43 -0
  11. src/compliance/behave_support/steps/__init__.py +0 -0
  12. src/compliance/behave_support/steps/given_steps.py +154 -0
  13. src/compliance/behave_support/steps/then_steps.py +234 -0
  14. src/compliance/behave_support/steps/when_steps.py +168 -0
  15. src/compliance/console.py +95 -0
  16. src/compliance/container_fix.py +135 -0
  17. src/compliance/gitlab_api.py +94 -0
  18. src/compliance/image_versions.py +316 -0
  19. src/compliance/include_fix.py +112 -0
  20. src/compliance/include_versions.py +250 -0
  21. src/compliance/metadata.py +245 -0
  22. src/compliance/model.py +277 -0
  23. src/compliance/models.py +29 -0
  24. src/compliance/oci_registry.py +147 -0
  25. src/compliance/policy_doc.py +177 -0
  26. src/compliance/release_cache.py +51 -0
  27. src/compliance/render.py +360 -0
  28. src/compliance/runner.py +310 -0
  29. src/compliance/stash.py +203 -0
  30. src/compliance/supply_chain_fix.py +69 -0
  31. src/gitlab_compliance.py +636 -0
  32. src/gitlab_docs.py +40 -0
  33. src/modules/__init__.py +1 -0
  34. src/modules/command_reference.py +220 -0
  35. src/modules/common.py +144 -0
  36. src/modules/constants.py +22 -0
  37. src/modules/doc_controller.py +178 -0
  38. src/modules/logging.py +36 -0
  39. src/modules/pipeline_data.py +364 -0
  40. src/modules/release.py +343 -0
  41. src/modules/swagger_html.py +408 -0
  42. src/modules/yaml_lines.py +70 -0
  43. src/modules/yaml_md_table.py +29 -0
  44. src/properties/__init__.py +0 -0
  45. src/properties/extract_job_attribute.py +76 -0
  46. src/properties/includes.py +178 -0
  47. src/properties/inputs.py +86 -0
  48. src/properties/jobs.py +167 -0
  49. src/properties/variables.py +91 -0
  50. src/properties/workflows.py +54 -0
@@ -0,0 +1,234 @@
1
+ """THEN steps for GitLab compliance policies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from behave import then
6
+
7
+ from src.compliance.stash import (
8
+ assert_all,
9
+ container_image_has_newer_release,
10
+ container_image_newer_release_older_than_days,
11
+ container_image_release_lag_exceeds_days,
12
+ container_image_uses_sha256,
13
+ container_image_within_latest_tags,
14
+ entity_has_property,
15
+ extends_includes,
16
+ get_property,
17
+ include_has_newer_release,
18
+ include_has_valid_semver,
19
+ include_newer_release_older_than_days,
20
+ include_release_lag_exceeds_days,
21
+ include_version_is_latest,
22
+ include_within_latest_tags,
23
+ normalize_value,
24
+ property_matches,
25
+ property_matches_regex,
26
+ property_not_matches_regex,
27
+ )
28
+
29
+
30
+ def _ensure_then_mode(context):
31
+ if context.scenario_skipped:
32
+ return False
33
+ context.step_mode = "then"
34
+ if not context.stash:
35
+ raise AssertionError("No entities in stash to assert against.")
36
+ return True
37
+
38
+
39
+ @then("it must contain {property_name}")
40
+ def then_must_contain(context, property_name):
41
+ if not _ensure_then_mode(context):
42
+ return
43
+ assert_all(
44
+ context.stash,
45
+ lambda e: entity_has_property(e, property_name),
46
+ f"Entities missing required property '{property_name}'",
47
+ )
48
+
49
+
50
+ @then("it must not contain {property_name}")
51
+ def then_must_not_contain(context, property_name):
52
+ if not _ensure_then_mode(context):
53
+ return
54
+ assert_all(
55
+ context.stash,
56
+ lambda e: not entity_has_property(e, property_name),
57
+ f"Entities must not contain '{property_name}'",
58
+ )
59
+
60
+
61
+ @then("its {property_name} must be {expected}")
62
+ def then_property_must_be(context, property_name, expected):
63
+ if not _ensure_then_mode(context):
64
+ return
65
+ assert_all(
66
+ context.stash,
67
+ lambda e: property_matches(e, property_name, expected),
68
+ f"Entities where {property_name} must be {expected}",
69
+ )
70
+
71
+
72
+ @then('its {property_name} must match "{pattern}"')
73
+ def then_property_must_match(context, property_name, pattern):
74
+ if not _ensure_then_mode(context):
75
+ return
76
+ assert_all(
77
+ context.stash,
78
+ lambda e: property_matches_regex(e, property_name, pattern),
79
+ f"Entities where {property_name} must match /{pattern}/",
80
+ )
81
+
82
+
83
+ @then('its {property_name} must not match "{pattern}"')
84
+ def then_property_must_not_match(context, property_name, pattern):
85
+ if not _ensure_then_mode(context):
86
+ return
87
+ assert_all(
88
+ context.stash,
89
+ lambda e: property_not_matches_regex(e, property_name, pattern),
90
+ f"Entities where {property_name} must not match /{pattern}/",
91
+ )
92
+
93
+
94
+ @then("its {property_name} must not be null")
95
+ def then_property_not_null(context, property_name):
96
+ if not _ensure_then_mode(context):
97
+ return
98
+ assert_all(
99
+ context.stash,
100
+ lambda e: normalize_value(get_property(e, property_name)).strip() != "",
101
+ f"Entities where {property_name} must not be null",
102
+ )
103
+
104
+
105
+ @then('its extends must include "{template}"')
106
+ def then_extends_includes(context, template):
107
+ if not _ensure_then_mode(context):
108
+ return
109
+ assert_all(
110
+ context.stash,
111
+ lambda e: extends_includes(e, template),
112
+ f"Entities must extend '{template}'",
113
+ )
114
+
115
+
116
+ @then("it must use valid semver")
117
+ def then_version_valid_semver(context):
118
+ if not _ensure_then_mode(context):
119
+ return
120
+ assert_all(
121
+ context.stash,
122
+ include_has_valid_semver,
123
+ "Includes must use a valid semver version",
124
+ )
125
+
126
+
127
+ @then("a newer release must not be available")
128
+ def then_no_newer_release(context):
129
+ if not _ensure_then_mode(context):
130
+ return
131
+ assert_all(
132
+ context.stash,
133
+ lambda e: not include_has_newer_release(e),
134
+ "Includes must not have a newer release available",
135
+ )
136
+
137
+
138
+ @then("a newer release must not be available for more than {days:d} days")
139
+ def then_no_newer_release_beyond_grace_days(context, days):
140
+ if not _ensure_then_mode(context):
141
+ return
142
+ assert_all(
143
+ context.stash,
144
+ lambda e: not include_newer_release_older_than_days(e, days),
145
+ f"Includes must not have a newer release available for more than {days} days",
146
+ )
147
+
148
+
149
+ @then("its release lag must not exceed {days:d} days")
150
+ def then_release_lag_must_not_exceed_days(context, days):
151
+ if not _ensure_then_mode(context):
152
+ return
153
+ assert_all(
154
+ context.stash,
155
+ lambda e: not include_release_lag_exceeds_days(e, days),
156
+ f"Includes must not have release lag exceeding {days} days",
157
+ )
158
+
159
+
160
+ @then("it must be within the latest {count:d} tags")
161
+ def then_must_be_within_latest_tags(context, count):
162
+ if not _ensure_then_mode(context):
163
+ return
164
+ assert_all(
165
+ context.stash,
166
+ lambda e: include_within_latest_tags(e, count),
167
+ f"Includes must be within the latest {count} semver tags",
168
+ )
169
+
170
+
171
+ @then("it must use sha256 digest")
172
+ def then_must_use_sha256_digest(context):
173
+ if not _ensure_then_mode(context):
174
+ return
175
+ assert_all(
176
+ context.stash,
177
+ container_image_uses_sha256,
178
+ "Container images must use a sha256 digest",
179
+ )
180
+
181
+
182
+ @then("a newer image release must not be available")
183
+ def then_no_newer_image_release(context):
184
+ if not _ensure_then_mode(context):
185
+ return
186
+ assert_all(
187
+ context.stash,
188
+ lambda e: not container_image_has_newer_release(e),
189
+ "Container images must not have a newer release available",
190
+ )
191
+
192
+
193
+ @then("a newer image release must not be available for more than {days:d} days")
194
+ def then_no_newer_image_release_beyond_grace_days(context, days):
195
+ if not _ensure_then_mode(context):
196
+ return
197
+ assert_all(
198
+ context.stash,
199
+ lambda e: not container_image_newer_release_older_than_days(e, days),
200
+ f"Container images must not have a newer release available for more than {days} days",
201
+ )
202
+
203
+
204
+ @then("its image release lag must not exceed {days:d} days")
205
+ def then_image_release_lag_must_not_exceed_days(context, days):
206
+ if not _ensure_then_mode(context):
207
+ return
208
+ assert_all(
209
+ context.stash,
210
+ lambda e: not container_image_release_lag_exceeds_days(e, days),
211
+ f"Container images must not have image release lag exceeding {days} days",
212
+ )
213
+
214
+
215
+ @then("it must be within the latest {count:d} image tags")
216
+ def then_must_be_within_latest_image_tags(context, count):
217
+ if not _ensure_then_mode(context):
218
+ return
219
+ assert_all(
220
+ context.stash,
221
+ lambda e: container_image_within_latest_tags(e, count),
222
+ f"Container images must be within the latest {count} image tags",
223
+ )
224
+
225
+
226
+ @then("it must track the latest release")
227
+ def then_version_is_latest_release(context):
228
+ if not _ensure_then_mode(context):
229
+ return
230
+ assert_all(
231
+ context.stash,
232
+ include_version_is_latest,
233
+ "Includes must reference the latest semver release",
234
+ )
@@ -0,0 +1,168 @@
1
+ """WHEN steps for GitLab compliance policies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from behave import when
6
+
7
+ from src.compliance.behave_support.environment import _skip_remaining_steps
8
+ from src.compliance.stash import (
9
+ container_image_has_newer_release,
10
+ container_image_newer_release_older_than_days,
11
+ container_image_not_within_latest_tags,
12
+ container_image_release_lag_exceeds_days,
13
+ entity_has_property,
14
+ filter_entities,
15
+ include_has_newer_release,
16
+ include_newer_release_older_than_days,
17
+ include_not_within_latest_tags,
18
+ include_release_lag_exceeds_days,
19
+ name_starts_with,
20
+ property_matches,
21
+ property_matches_regex,
22
+ )
23
+
24
+
25
+ def _apply_filter(context, entities: list[dict], reason: str):
26
+ if context.scenario_skipped:
27
+ return
28
+ context.step_mode = "when"
29
+ if not entities:
30
+ _skip_remaining_steps(context, reason)
31
+ return
32
+ context.stash = entities
33
+
34
+
35
+ @when("it has {property_name}")
36
+ def when_it_has(context, property_name):
37
+ filtered = filter_entities(
38
+ context.stash, lambda e: entity_has_property(e, property_name)
39
+ )
40
+ _apply_filter(context, filtered, f"No entities with property '{property_name}'")
41
+
42
+
43
+ @when("it does not have {property_name}")
44
+ def when_it_does_not_have(context, property_name):
45
+ filtered = filter_entities(
46
+ context.stash, lambda e: not entity_has_property(e, property_name)
47
+ )
48
+ _apply_filter(
49
+ context, filtered, f"All entities already have property '{property_name}'"
50
+ )
51
+
52
+
53
+ @when("its {property_name} is {expected}")
54
+ def when_property_is(context, property_name, expected):
55
+ filtered = filter_entities(
56
+ context.stash,
57
+ lambda e: property_matches(e, property_name, expected),
58
+ )
59
+ _apply_filter(context, filtered, f"No entities where {property_name} is {expected}")
60
+
61
+
62
+ @when('its {property_name} matches "{pattern}"')
63
+ def when_property_matches(context, property_name, pattern):
64
+ filtered = filter_entities(
65
+ context.stash,
66
+ lambda e: property_matches_regex(e, property_name, pattern),
67
+ )
68
+ _apply_filter(
69
+ context, filtered, f"No entities where {property_name} matches {pattern}"
70
+ )
71
+
72
+
73
+ @when('its name does not start with "{prefix}"')
74
+ def when_name_not_starts_with(context, prefix):
75
+ filtered = filter_entities(context.stash, lambda e: not name_starts_with(e, prefix))
76
+ _apply_filter(context, filtered, f"All entity names start with '{prefix}'")
77
+
78
+
79
+ @when("a newer release is available")
80
+ def when_newer_release_available(context):
81
+ filtered = filter_entities(context.stash, include_has_newer_release)
82
+ _apply_filter(context, filtered, "No includes with a newer release available")
83
+
84
+
85
+ @when("a newer release is available for more than {days:d} days")
86
+ def when_newer_release_available_for_more_than_days(context, days):
87
+ filtered = filter_entities(
88
+ context.stash,
89
+ lambda e: include_newer_release_older_than_days(e, days),
90
+ )
91
+ _apply_filter(
92
+ context,
93
+ filtered,
94
+ f"No includes with a newer release available for more than {days} days",
95
+ )
96
+
97
+
98
+ @when("its release lag exceeds {days:d} days")
99
+ def when_release_lag_exceeds_days(context, days):
100
+ filtered = filter_entities(
101
+ context.stash,
102
+ lambda e: include_release_lag_exceeds_days(e, days),
103
+ )
104
+ _apply_filter(
105
+ context,
106
+ filtered,
107
+ f"No includes with release lag exceeding {days} days",
108
+ )
109
+
110
+
111
+ @when("it is not within the latest {count:d} tags")
112
+ def when_not_within_latest_tags(context, count):
113
+ filtered = filter_entities(
114
+ context.stash,
115
+ lambda e: include_not_within_latest_tags(e, count),
116
+ )
117
+ _apply_filter(
118
+ context,
119
+ filtered,
120
+ f"No includes outside the latest {count} tags",
121
+ )
122
+
123
+
124
+ @when("a newer image release is available")
125
+ def when_newer_image_release_available(context):
126
+ filtered = filter_entities(context.stash, container_image_has_newer_release)
127
+ _apply_filter(
128
+ context, filtered, "No container images with a newer release available"
129
+ )
130
+
131
+
132
+ @when("a newer image release is available for more than {days:d} days")
133
+ def when_newer_image_release_available_for_more_than_days(context, days):
134
+ filtered = filter_entities(
135
+ context.stash,
136
+ lambda e: container_image_newer_release_older_than_days(e, days),
137
+ )
138
+ _apply_filter(
139
+ context,
140
+ filtered,
141
+ f"No container images with a newer release available for more than {days} days",
142
+ )
143
+
144
+
145
+ @when("its image release lag exceeds {days:d} days")
146
+ def when_image_release_lag_exceeds_days(context, days):
147
+ filtered = filter_entities(
148
+ context.stash,
149
+ lambda e: container_image_release_lag_exceeds_days(e, days),
150
+ )
151
+ _apply_filter(
152
+ context,
153
+ filtered,
154
+ f"No container images with image release lag exceeding {days} days",
155
+ )
156
+
157
+
158
+ @when("it is not within the latest {count:d} image tags")
159
+ def when_not_within_latest_image_tags(context, count):
160
+ filtered = filter_entities(
161
+ context.stash,
162
+ lambda e: container_image_not_within_latest_tags(e, count),
163
+ )
164
+ _apply_filter(
165
+ context,
166
+ filtered,
167
+ f"No container images outside the latest {count} image tags",
168
+ )
@@ -0,0 +1,95 @@
1
+ """Rich console output for compliance results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from src.compliance.models import ComplianceResult
11
+
12
+ console = Console()
13
+
14
+ _STATUS_STYLES = {
15
+ "passed": "bold green",
16
+ "failed": "bold red",
17
+ "skipped": "bold yellow",
18
+ }
19
+
20
+
21
+ def render_compliance_console(
22
+ result: ComplianceResult,
23
+ pipeline_file: str,
24
+ features_dir: str,
25
+ ) -> None:
26
+ overall = "Compliance Passed" if result.success else "Compliance Failed"
27
+ border_style = "green" if result.success else "red"
28
+
29
+ console.print(
30
+ Panel(
31
+ f"[bold]Pipeline:[/bold] {pipeline_file}\n"
32
+ f"[bold]Policies:[/bold] {features_dir}",
33
+ title=overall,
34
+ border_style=border_style,
35
+ )
36
+ )
37
+
38
+ summary = Table(title="Summary", show_header=True, header_style="bold")
39
+ summary.add_column("Status", style="cyan")
40
+ summary.add_column("Count", justify="right")
41
+ summary.add_row("[green]Passed[/green]", str(result.passed))
42
+ summary.add_row("[red]Failed[/red]", str(result.failed))
43
+ summary.add_row("[yellow]Skipped[/yellow]", str(result.skipped))
44
+ summary.add_row("[bold]Total[/bold]", str(result.scenarios))
45
+ console.print(summary)
46
+
47
+ failures = [
48
+ scenario for scenario in result.scenario_results if scenario.status == "failed"
49
+ ]
50
+ if failures:
51
+ console.rule("[bold red]Failure details")
52
+ for scenario in failures:
53
+ label = scenario.policy_id or scenario.feature
54
+ title = scenario.title or scenario.name
55
+ console.print(f"[bold red]{label}[/bold red] — {title}")
56
+ if scenario.description:
57
+ console.print(scenario.description, style="italic")
58
+ if scenario.message:
59
+ console.print(scenario.message, style="dim")
60
+
61
+ skipped = [
62
+ scenario for scenario in result.scenario_results if scenario.status == "skipped"
63
+ ]
64
+ if skipped:
65
+ console.rule("[bold yellow]Skipped scenarios")
66
+ for scenario in skipped:
67
+ reason = scenario.message or "Filter did not match any entities."
68
+ label = scenario.policy_id or scenario.feature
69
+ title = scenario.title or scenario.name
70
+ console.print(f"[yellow]{label}[/yellow] — {title}: {reason}")
71
+
72
+ scenarios = Table(title="Scenarios", show_header=True, header_style="bold")
73
+ scenarios.add_column("ID", style="magenta", no_wrap=True)
74
+ scenarios.add_column("Feature", style="cyan", no_wrap=True)
75
+ scenarios.add_column("Policy")
76
+ scenarios.add_column("Status", justify="center")
77
+
78
+ for scenario in result.scenario_results:
79
+ style = _STATUS_STYLES.get(scenario.status, "bold white")
80
+ scenarios.add_row(
81
+ scenario.policy_id or "-",
82
+ scenario.feature,
83
+ scenario.title or scenario.name,
84
+ Text(scenario.status.upper(), style=style),
85
+ )
86
+
87
+ console.print(scenarios)
88
+
89
+ console.print()
90
+ console.print(
91
+ f"[bold]{result.passed} passed[/bold], "
92
+ f"[bold]{result.failed} failed[/bold], "
93
+ f"[bold]{result.skipped} skipped[/bold] "
94
+ f"({result.scenarios} scenarios in {result.features} features)"
95
+ )
@@ -0,0 +1,135 @@
1
+ """Auto-fix container images to sha256 digests in GitLab CI YAML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class ContainerImageFix:
11
+ source_file: str
12
+ line: int
13
+ parent_job: str
14
+ image_source: str
15
+ current_image: str
16
+ fixed_image: str
17
+
18
+
19
+ def collect_container_image_fixes(entities: dict) -> list[ContainerImageFix]:
20
+ fixes: list[ContainerImageFix] = []
21
+ for image in entities.get("container_images", []):
22
+ current = str(image.get("image", "")).strip()
23
+ digest = str(image.get("latest_digest", "")).strip()
24
+ if not current or not digest:
25
+ continue
26
+ if "@sha256:" in current:
27
+ continue
28
+ repository_part = current.split("@sha256:", 1)[0]
29
+ if ":" in repository_part:
30
+ base = repository_part.rsplit(":", 1)[0]
31
+ else:
32
+ base = repository_part
33
+ fixed_image = f"{base}@sha256:{digest}"
34
+ fixes.append(
35
+ ContainerImageFix(
36
+ source_file=str(image.get("source_file", "")),
37
+ line=int(image.get("line", 0) or 0),
38
+ parent_job=str(image.get("parent_job", "")),
39
+ image_source=str(image.get("image_source", "")),
40
+ current_image=current,
41
+ fixed_image=fixed_image,
42
+ )
43
+ )
44
+ return fixes
45
+
46
+
47
+ def _replace_image_line(line: str, current_image: str, fixed_image: str) -> str | None:
48
+ escaped = re.escape(current_image)
49
+ match = re.match(
50
+ rf"^(\s*image:\s*)(['\"]?)({escaped})\2(\s*(?:#.*)?)?$",
51
+ line.rstrip("\n"),
52
+ )
53
+ if not match:
54
+ return None
55
+ prefix, quote, _value, suffix = match.groups()
56
+ suffix = suffix or ""
57
+ if quote:
58
+ result = f"{prefix}{quote}{fixed_image}{quote}{suffix}"
59
+ else:
60
+ result = f"{prefix}{fixed_image}{suffix}"
61
+ return result + ("\n" if line.endswith("\n") else "")
62
+
63
+
64
+ def _replace_service_entry(
65
+ line: str, current_image: str, fixed_image: str
66
+ ) -> str | None:
67
+ escaped = re.escape(current_image)
68
+ stripped = line.rstrip("\n")
69
+ newline = "\n" if line.endswith("\n") else ""
70
+
71
+ for pattern in (
72
+ rf"^(\s*-\s*)(['\"]?)({escaped})\2(\s*(?:#.*)?)?$",
73
+ rf"^(\s*name:\s*)(['\"]?)({escaped})\2(\s*(?:#.*)?)?$",
74
+ ):
75
+ match = re.match(pattern, stripped)
76
+ if not match:
77
+ continue
78
+ prefix, quote, _value, suffix = match.groups()
79
+ suffix = suffix or ""
80
+ if quote:
81
+ return f"{prefix}{quote}{fixed_image}{quote}{suffix}{newline}"
82
+ return f"{prefix}{fixed_image}{suffix}{newline}"
83
+ return None
84
+
85
+
86
+ def _is_service_image_line(line: str) -> bool:
87
+ stripped = line.lstrip()
88
+ return stripped.startswith("- ") or stripped.startswith("name:")
89
+
90
+
91
+ def _apply_fix_to_file(fix: ContainerImageFix, lines: list[str]) -> bool:
92
+ if fix.line <= 0 or fix.line > len(lines):
93
+ return False
94
+ start = fix.line - 1
95
+ job_header = lines[start].strip()
96
+ if not job_header.startswith(f"{fix.parent_job}:"):
97
+ return False
98
+ end = min(len(lines), start + 40)
99
+ for index in range(start, end):
100
+ line = lines[index]
101
+ if index > start and re.match(r"^[a-zA-Z0-9_.-]+:\s*$", line):
102
+ break
103
+ updated = None
104
+ if fix.image_source == "job" and re.search(r"^\s*image:\s*", line):
105
+ updated = _replace_image_line(line, fix.current_image, fix.fixed_image)
106
+ elif fix.image_source == "service" and _is_service_image_line(line):
107
+ updated = _replace_service_entry(line, fix.current_image, fix.fixed_image)
108
+ if updated and updated != line:
109
+ lines[index] = updated if updated.endswith("\n") else updated + "\n"
110
+ return True
111
+ return False
112
+
113
+
114
+ def apply_container_image_fixes(
115
+ fixes: list[ContainerImageFix],
116
+ ) -> list[ContainerImageFix]:
117
+ applied: list[ContainerImageFix] = []
118
+ by_file: dict[str, list[ContainerImageFix]] = {}
119
+ for fix in fixes:
120
+ if not fix.source_file:
121
+ continue
122
+ by_file.setdefault(fix.source_file, []).append(fix)
123
+
124
+ for source_file, file_fixes in by_file.items():
125
+ with open(source_file, encoding="utf-8") as handle:
126
+ lines = handle.readlines()
127
+ changed = False
128
+ for fix in file_fixes:
129
+ if _apply_fix_to_file(fix, lines):
130
+ applied.append(fix)
131
+ changed = True
132
+ if changed:
133
+ with open(source_file, "w", encoding="utf-8") as handle:
134
+ handle.writelines(lines)
135
+ return applied