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,94 @@
1
+ """Load GitLab project/group settings via API for compliance checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ PROJECT_SETTING_KEYS = [
9
+ "public_jobs",
10
+ "auto_devops_enabled",
11
+ "builds_access_level",
12
+ "forking_access_level",
13
+ "merge_requests_enabled",
14
+ "jobs_enabled",
15
+ ]
16
+
17
+
18
+ def _setting_entity(setting_type: str, name: str, value: Any) -> dict:
19
+ return {
20
+ "address": f"{setting_type}.{name}",
21
+ "type": setting_type,
22
+ "name": name,
23
+ "value": value,
24
+ "values": {"value": value},
25
+ }
26
+
27
+
28
+ def _ci_variable_entity(variable: Any) -> dict:
29
+ return {
30
+ "address": f"project_ci_variable.{variable.key}",
31
+ "type": "project_ci_variable",
32
+ "name": variable.key,
33
+ "key": variable.key,
34
+ "value": "***",
35
+ "protected": getattr(variable, "protected", False),
36
+ "masked": getattr(variable, "masked", False),
37
+ "values": {
38
+ "key": variable.key,
39
+ "protected": getattr(variable, "protected", False),
40
+ "masked": getattr(variable, "masked", False),
41
+ },
42
+ }
43
+
44
+
45
+ def load_api_entities(
46
+ gitlab_url: str | None = None,
47
+ token: str | None = None,
48
+ project: str | None = None,
49
+ group: str | None = None,
50
+ gl: Any | None = None,
51
+ ) -> dict[str, list[dict]]:
52
+ gitlab_url = (
53
+ gitlab_url
54
+ or os.getenv("CI_SERVER_URL")
55
+ or os.getenv("GITLAB_URL")
56
+ or "https://gitlab.com"
57
+ )
58
+ token = token or os.getenv("GITLAB_TOKEN") or os.getenv("CI_JOB_TOKEN")
59
+
60
+ if not token:
61
+ raise ValueError("GitLab API token required. Set --token or GITLAB_TOKEN.")
62
+
63
+ if gl is None:
64
+ import gitlab
65
+
66
+ gl = gitlab.Gitlab(gitlab_url, private_token=token)
67
+ gl.auth()
68
+
69
+ entities: dict[str, list[dict]] = {
70
+ "project_settings": [],
71
+ "project_ci_variables": [],
72
+ "group_settings": [],
73
+ }
74
+
75
+ if project:
76
+ project_obj = gl.projects.get(project)
77
+ for key in PROJECT_SETTING_KEYS:
78
+ if hasattr(project_obj, key):
79
+ entities["project_settings"].append(
80
+ _setting_entity("project_setting", key, getattr(project_obj, key))
81
+ )
82
+
83
+ for variable in project_obj.variables.list(all=True):
84
+ entities["project_ci_variables"].append(_ci_variable_entity(variable))
85
+
86
+ if group:
87
+ group_obj = gl.groups.get(group)
88
+ for attr in ("shared_runners_enabled", "request_access_enabled"):
89
+ if hasattr(group_obj, attr):
90
+ entities["group_settings"].append(
91
+ _setting_entity("group_setting", attr, getattr(group_obj, attr))
92
+ )
93
+
94
+ return entities
@@ -0,0 +1,316 @@
1
+ """Container image version validation and registry metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from urllib.parse import urlparse
7
+
8
+ import requests
9
+ import semver
10
+
11
+ from src.compliance.include_versions import (
12
+ _semver_tags_descending,
13
+ compute_version_tag_rank,
14
+ is_valid_semver_version,
15
+ )
16
+ from src.compliance.release_cache import ReleaseMetadataCache
17
+
18
+
19
+ def parse_container_image_ref(image: str) -> dict[str, str]:
20
+ image = str(image or "").strip()
21
+ if not image:
22
+ return {}
23
+
24
+ digest = ""
25
+ repository_part = image
26
+ if "@sha256:" in image:
27
+ repository_part, digest_value = image.split("@sha256:", 1)
28
+ digest = digest_value.strip()
29
+
30
+ registry = "registry-1.docker.io"
31
+ repository = repository_part
32
+ tag = "latest"
33
+
34
+ if "/" in repository_part and "." in repository_part.split("/", 1)[0]:
35
+ registry, repository = repository_part.split("/", 1)
36
+
37
+ if ":" in repository:
38
+ repository, tag = repository.rsplit(":", 1)
39
+
40
+ if "/" not in repository and registry in {
41
+ "registry-1.docker.io",
42
+ "docker.io",
43
+ "index.docker.io",
44
+ }:
45
+ repository = f"library/{repository}"
46
+
47
+ return {
48
+ "registry": registry,
49
+ "repository": repository,
50
+ "tag": tag,
51
+ "digest": digest,
52
+ "image": image,
53
+ }
54
+
55
+
56
+ def image_uses_sha256_digest(image: str) -> bool:
57
+ return "@sha256:" in str(image or "")
58
+
59
+
60
+ def _docker_auth_token(
61
+ repository: str, cache: ReleaseMetadataCache | None = None
62
+ ) -> str:
63
+ if cache is not None:
64
+ cached = cache.get_docker_token(repository)
65
+ if cached:
66
+ return cached
67
+
68
+ response = requests.get(
69
+ "https://auth.docker.io/token",
70
+ params={
71
+ "service": "registry.docker.io",
72
+ "scope": f"repository:{repository}:pull",
73
+ },
74
+ timeout=30,
75
+ )
76
+ response.raise_for_status()
77
+ token = response.json()["token"]
78
+ if cache is not None:
79
+ cache.set_docker_token(repository, token)
80
+ return token
81
+
82
+
83
+ def _list_docker_hub_tags(
84
+ repository: str, cache: ReleaseMetadataCache | None = None
85
+ ) -> list[str]:
86
+ registry = "registry-1.docker.io"
87
+ if cache is not None:
88
+ cached = cache.get_registry_tags(registry, repository)
89
+ if cached is not None:
90
+ return cached
91
+
92
+ token = _docker_auth_token(repository, cache=cache)
93
+ response = requests.get(
94
+ f"https://registry-1.docker.io/v2/{repository}/tags/list",
95
+ headers={"Authorization": f"Bearer {token}"},
96
+ timeout=30,
97
+ )
98
+ response.raise_for_status()
99
+ tags = list(response.json().get("tags") or [])
100
+ if cache is not None:
101
+ cache.set_registry_tags(registry, repository, tags)
102
+ return tags
103
+
104
+
105
+ def _fetch_docker_hub_digest(
106
+ repository: str, tag: str, cache: ReleaseMetadataCache | None = None
107
+ ) -> str:
108
+ registry = "registry-1.docker.io"
109
+ if cache is not None:
110
+ cached = cache.get_registry_digest(registry, repository, tag)
111
+ if cached is not None:
112
+ return cached
113
+
114
+ token = _docker_auth_token(repository, cache=cache)
115
+ response = requests.get(
116
+ f"https://registry-1.docker.io/v2/{repository}/manifests/{tag}",
117
+ headers={
118
+ "Authorization": f"Bearer {token}",
119
+ "Accept": "application/vnd.docker.distribution.manifest.v2+json",
120
+ },
121
+ timeout=30,
122
+ )
123
+ response.raise_for_status()
124
+ digest = response.headers.get("Docker-Content-Digest", "")
125
+ digest_value = digest.split(":", 1)[1] if digest.startswith("sha256:") else ""
126
+ if cache is not None:
127
+ cache.set_registry_digest(registry, repository, tag, digest_value)
128
+ return digest_value
129
+
130
+
131
+ def _gitlab_registry_host(gitlab_url: str | None) -> str:
132
+ gitlab_url = (
133
+ gitlab_url
134
+ or os.getenv("CI_SERVER_URL")
135
+ or os.getenv("GITLAB_URL")
136
+ or "https://gitlab.com"
137
+ )
138
+ host = urlparse(gitlab_url).netloc or "gitlab.com"
139
+ return f"registry.{host}"
140
+
141
+
142
+ def _list_gitlab_registry_tags(
143
+ repository: str,
144
+ *,
145
+ gitlab_url: str | None,
146
+ token: str,
147
+ cache: ReleaseMetadataCache | None = None,
148
+ ) -> list[str]:
149
+ registry_host = _gitlab_registry_host(gitlab_url)
150
+ if cache is not None:
151
+ cached = cache.get_registry_tags(registry_host, repository)
152
+ if cached is not None:
153
+ return cached
154
+
155
+ response = requests.get(
156
+ f"https://{registry_host}/v2/{repository}/tags/list",
157
+ headers={"Authorization": f"Bearer {token}"},
158
+ timeout=30,
159
+ )
160
+ response.raise_for_status()
161
+ tags = list(response.json().get("tags") or [])
162
+ if cache is not None:
163
+ cache.set_registry_tags(registry_host, repository, tags)
164
+ return tags
165
+
166
+
167
+ def _fetch_gitlab_registry_digest(
168
+ repository: str,
169
+ tag: str,
170
+ *,
171
+ gitlab_url: str | None,
172
+ token: str,
173
+ cache: ReleaseMetadataCache | None = None,
174
+ ) -> str:
175
+ registry_host = _gitlab_registry_host(gitlab_url)
176
+ if cache is not None:
177
+ cached = cache.get_registry_digest(registry_host, repository, tag)
178
+ if cached is not None:
179
+ return cached
180
+
181
+ response = requests.get(
182
+ f"https://{registry_host}/v2/{repository}/manifests/{tag}",
183
+ headers={
184
+ "Authorization": f"Bearer {token}",
185
+ "Accept": "application/vnd.docker.distribution.manifest.v2+json",
186
+ },
187
+ timeout=30,
188
+ )
189
+ response.raise_for_status()
190
+ digest = response.headers.get("Docker-Content-Digest", "")
191
+ digest_value = digest.split(":", 1)[1] if digest.startswith("sha256:") else ""
192
+ if cache is not None:
193
+ cache.set_registry_digest(registry_host, repository, tag, digest_value)
194
+ return digest_value
195
+
196
+
197
+ def _latest_semver_tag(tag_names: list[str]) -> str | None:
198
+ valid = [name for name in tag_names if is_valid_semver_version(name)]
199
+ if not valid:
200
+ return None
201
+ return max(valid, key=lambda value: semver.Version.parse(value))
202
+
203
+
204
+ def image_update_available(current_tag: str, latest_tag: str) -> bool:
205
+ if not latest_tag:
206
+ return False
207
+ current = str(current_tag or "").strip()
208
+ if current in {"", "latest"} or not is_valid_semver_version(current):
209
+ return is_valid_semver_version(latest_tag)
210
+ if not is_valid_semver_version(latest_tag):
211
+ return False
212
+ return semver.Version.parse(latest_tag) > semver.Version.parse(current)
213
+
214
+
215
+ def enrich_container_image_metadata(
216
+ image_entity: dict,
217
+ *,
218
+ gitlab_url: str | None,
219
+ token: str | None,
220
+ cache: ReleaseMetadataCache | None = None,
221
+ ) -> dict:
222
+ enriched = dict(image_entity)
223
+ image_ref = str(enriched.get("image", enriched.get("project", ""))).strip()
224
+ parsed = parse_container_image_ref(image_ref)
225
+ enriched["image"] = image_ref
226
+ enriched["registry"] = parsed.get("registry", "")
227
+ enriched["repository"] = parsed.get("repository", "")
228
+ enriched["version"] = parsed.get("tag", "")
229
+ enriched["digest"] = parsed.get("digest", "")
230
+ enriched["valid_version"] = is_valid_semver_version(enriched["version"])
231
+ enriched["latest_version"] = ""
232
+ enriched["latest_digest"] = ""
233
+ enriched["update_available"] = False
234
+ enriched["release_metadata_resolved"] = False
235
+ enriched["latest_release_age_days"] = None
236
+ enriched["release_lag_days"] = None
237
+ enriched["version_tag_rank"] = None
238
+ enriched["semver_tag_count"] = 0
239
+
240
+ if image_uses_sha256_digest(image_ref):
241
+ enriched["release_metadata_resolved"] = bool(enriched["digest"])
242
+ return enriched
243
+
244
+ repository = enriched.get("repository", "")
245
+ tag = enriched.get("version", "latest")
246
+ registry = enriched.get("registry", "")
247
+ tags: list[str] = []
248
+ latest_tag = ""
249
+ latest_digest = ""
250
+
251
+ try:
252
+ if registry in {"registry-1.docker.io", "docker.io", "index.docker.io"}:
253
+ tags = _list_docker_hub_tags(repository, cache=cache)
254
+ latest_tag = _latest_semver_tag(tags) or (
255
+ "latest" if "latest" in tags else (tags[0] if tags else "")
256
+ )
257
+ latest_digest = _fetch_docker_hub_digest(repository, tag, cache=cache)
258
+ if latest_tag and latest_tag != tag:
259
+ enriched["latest_digest"] = _fetch_docker_hub_digest(
260
+ repository, latest_tag, cache=cache
261
+ )
262
+ elif registry == _gitlab_registry_host(gitlab_url) and token:
263
+ tags = _list_gitlab_registry_tags(
264
+ repository, gitlab_url=gitlab_url, token=token, cache=cache
265
+ )
266
+ latest_tag = _latest_semver_tag(tags) or (
267
+ "latest" if "latest" in tags else (tags[0] if tags else "")
268
+ )
269
+ latest_digest = _fetch_gitlab_registry_digest(
270
+ repository, tag, gitlab_url=gitlab_url, token=token, cache=cache
271
+ )
272
+ if latest_tag and latest_tag != tag:
273
+ enriched["latest_digest"] = _fetch_gitlab_registry_digest(
274
+ repository,
275
+ latest_tag,
276
+ gitlab_url=gitlab_url,
277
+ token=token,
278
+ cache=cache,
279
+ )
280
+ else:
281
+ return enriched
282
+ except Exception:
283
+ return enriched
284
+
285
+ if not latest_tag:
286
+ return enriched
287
+
288
+ tags_desc = _semver_tags_descending(
289
+ [name for name in tags if is_valid_semver_version(name)]
290
+ )
291
+ enriched["latest_version"] = latest_tag
292
+ enriched["latest_digest"] = latest_digest or enriched.get("latest_digest", "")
293
+ enriched["update_available"] = image_update_available(tag, latest_tag)
294
+ enriched["release_metadata_resolved"] = True
295
+ enriched["semver_tag_count"] = len(tags_desc)
296
+ if enriched["valid_version"]:
297
+ enriched["version_tag_rank"] = compute_version_tag_rank(tag, tags_desc)
298
+ return enriched
299
+
300
+
301
+ def enrich_container_images_with_releases(
302
+ images: list[dict],
303
+ *,
304
+ gitlab_url: str | None,
305
+ token: str | None,
306
+ cache: ReleaseMetadataCache | None = None,
307
+ ) -> list[dict]:
308
+ return [
309
+ enrich_container_image_metadata(
310
+ image,
311
+ gitlab_url=gitlab_url,
312
+ token=token or "",
313
+ cache=cache,
314
+ )
315
+ for image in images
316
+ ]
@@ -0,0 +1,112 @@
1
+ """Auto-fix outdated include refs 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 IncludeVersionFix:
11
+ source_file: str
12
+ line: int
13
+ include_type: str
14
+ project: str
15
+ current_version: str
16
+ latest_version: str
17
+
18
+
19
+ def collect_include_version_fixes(entities: dict) -> list[IncludeVersionFix]:
20
+ fixes: list[IncludeVersionFix] = []
21
+ for include in entities.get("includes", []):
22
+ include_type = include.get("include_type", "")
23
+ if include_type not in {"project", "component"}:
24
+ continue
25
+ latest = str(include.get("latest_version", "")).strip()
26
+ current = str(include.get("version", "")).strip()
27
+ if not latest or not include.get("update_available"):
28
+ continue
29
+ if current == latest:
30
+ continue
31
+ fixes.append(
32
+ IncludeVersionFix(
33
+ source_file=include.get("source_file", ""),
34
+ line=int(include.get("line", 0) or 0),
35
+ include_type=include_type,
36
+ project=str(include.get("project", "")),
37
+ current_version=current,
38
+ latest_version=latest,
39
+ )
40
+ )
41
+ return fixes
42
+
43
+
44
+ def _replace_ref_line(line: str, latest_version: str) -> str | None:
45
+ match = re.match(r"^(\s*ref:\s*)(['\"]?)([^'\"#\n]+)\2\s*(#.*)?$", line)
46
+ if not match:
47
+ return None
48
+ prefix, quote, _value, suffix = match.groups()
49
+ suffix = suffix or ""
50
+ if quote:
51
+ return f"{prefix}{quote}{latest_version}{quote}{suffix}\n"
52
+ return f"{prefix}{latest_version}{suffix}\n"
53
+
54
+
55
+ def _replace_component_line(
56
+ line: str, current_version: str, latest_version: str
57
+ ) -> str | None:
58
+ if "component:" not in line and "component :" not in line:
59
+ return None
60
+ pattern = re.compile(
61
+ rf"(@{re.escape(current_version)})(?=(['\"\s#]|$))",
62
+ re.IGNORECASE,
63
+ )
64
+ if not pattern.search(line):
65
+ return None
66
+ return pattern.sub(f"@{latest_version}", line, count=1)
67
+
68
+
69
+ def _apply_fix_to_file(fix: IncludeVersionFix, lines: list[str]) -> bool:
70
+ if fix.line <= 0 or fix.line > len(lines):
71
+ return False
72
+ start = fix.line - 1
73
+ end = min(len(lines), start + 12)
74
+ for index in range(start, end):
75
+ line = lines[index]
76
+ if index > start and re.match(r"^\s*-\s+", line):
77
+ break
78
+ updated = None
79
+ if fix.include_type == "project":
80
+ updated = _replace_ref_line(line, fix.latest_version)
81
+ elif fix.include_type == "component":
82
+ updated = _replace_component_line(
83
+ line, fix.current_version, fix.latest_version
84
+ )
85
+ if updated and updated != line:
86
+ lines[index] = updated if updated.endswith("\n") else updated + "\n"
87
+ return True
88
+ return False
89
+
90
+
91
+ def apply_include_version_fixes(
92
+ fixes: list[IncludeVersionFix],
93
+ ) -> list[IncludeVersionFix]:
94
+ applied: list[IncludeVersionFix] = []
95
+ by_file: dict[str, list[IncludeVersionFix]] = {}
96
+ for fix in fixes:
97
+ if not fix.source_file:
98
+ continue
99
+ by_file.setdefault(fix.source_file, []).append(fix)
100
+
101
+ for source_file, file_fixes in by_file.items():
102
+ with open(source_file, encoding="utf-8") as handle:
103
+ lines = handle.readlines()
104
+ changed = False
105
+ for fix in file_fixes:
106
+ if _apply_fix_to_file(fix, lines):
107
+ applied.append(fix)
108
+ changed = True
109
+ if changed:
110
+ with open(source_file, "w", encoding="utf-8") as handle:
111
+ handle.writelines(lines)
112
+ return applied