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.
- gitlab_compliance-2.0.0.dist-info/METADATA +92 -0
- gitlab_compliance-2.0.0.dist-info/RECORD +50 -0
- gitlab_compliance-2.0.0.dist-info/WHEEL +4 -0
- gitlab_compliance-2.0.0.dist-info/entry_points.txt +4 -0
- gitlab_compliance-2.0.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +1 -0
- src/compliance/__init__.py +1 -0
- src/compliance/api_config.py +94 -0
- src/compliance/api_enrichment.py +52 -0
- src/compliance/behave_support/environment.py +43 -0
- src/compliance/behave_support/steps/__init__.py +0 -0
- src/compliance/behave_support/steps/given_steps.py +154 -0
- src/compliance/behave_support/steps/then_steps.py +234 -0
- src/compliance/behave_support/steps/when_steps.py +168 -0
- src/compliance/console.py +95 -0
- src/compliance/container_fix.py +135 -0
- src/compliance/gitlab_api.py +94 -0
- src/compliance/image_versions.py +316 -0
- src/compliance/include_fix.py +112 -0
- src/compliance/include_versions.py +250 -0
- src/compliance/metadata.py +245 -0
- src/compliance/model.py +277 -0
- src/compliance/models.py +29 -0
- src/compliance/oci_registry.py +147 -0
- src/compliance/policy_doc.py +177 -0
- src/compliance/release_cache.py +51 -0
- src/compliance/render.py +360 -0
- src/compliance/runner.py +310 -0
- src/compliance/stash.py +203 -0
- src/compliance/supply_chain_fix.py +69 -0
- src/gitlab_compliance.py +636 -0
- src/gitlab_docs.py +40 -0
- src/modules/__init__.py +1 -0
- src/modules/command_reference.py +220 -0
- src/modules/common.py +144 -0
- src/modules/constants.py +22 -0
- src/modules/doc_controller.py +178 -0
- src/modules/logging.py +36 -0
- src/modules/pipeline_data.py +364 -0
- src/modules/release.py +343 -0
- src/modules/swagger_html.py +408 -0
- src/modules/yaml_lines.py +70 -0
- src/modules/yaml_md_table.py +29 -0
- src/properties/__init__.py +0 -0
- src/properties/extract_job_attribute.py +76 -0
- src/properties/includes.py +178 -0
- src/properties/inputs.py +86 -0
- src/properties/jobs.py +167 -0
- src/properties/variables.py +91 -0
- 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
|