gitlab-compliance 2.2.0__tar.gz → 2.4.0__tar.gz

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 (84) hide show
  1. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/PKG-INFO +1 -1
  2. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/pyproject.toml +1 -1
  3. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/behave_support/environment.py +12 -0
  4. gitlab_compliance-2.4.0/src/compliance/include_fetch.py +340 -0
  5. gitlab_compliance-2.4.0/src/compliance/include_resolution.py +116 -0
  6. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/model.py +20 -3
  7. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/models.py +1 -0
  8. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/render.py +217 -12
  9. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/runner.py +29 -1
  10. gitlab_compliance-2.4.0/src/compliance/shell_render.py +632 -0
  11. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/gitlab_compliance.py +91 -8
  12. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/constants.py +17 -1
  13. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/job_composition.py +58 -7
  14. gitlab_compliance-2.4.0/src/modules/pipeline_data.py +868 -0
  15. gitlab_compliance-2.2.0/src/modules/pipeline_data.py +0 -493
  16. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/LICENSE +0 -0
  17. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/docs/index.md +0 -0
  18. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/__init__.py +0 -0
  19. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/__init__.py +0 -0
  20. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/api_config.py +0 -0
  21. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/api_enrichment.py +0 -0
  22. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/behave_support/steps/__init__.py +0 -0
  23. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/behave_support/steps/given_steps.py +0 -0
  24. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/behave_support/steps/script_steps.py +0 -0
  25. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/behave_support/steps/then_steps.py +0 -0
  26. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/behave_support/steps/when_steps.py +0 -0
  27. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/__init__.py +0 -0
  28. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/baseline-jobs.feature +0 -0
  29. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/component-inputs.feature +0 -0
  30. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/include-pinning.feature +0 -0
  31. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-ci-conventions.feature +0 -0
  32. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-command-substitution.feature +0 -0
  33. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-conditionals.feature +0 -0
  34. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-error-handling.feature +0 -0
  35. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-file-operations.feature +0 -0
  36. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-pinning.feature +0 -0
  37. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-pipelines.feature +0 -0
  38. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-portability.feature +0 -0
  39. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-quoting.feature +0 -0
  40. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-references.feature +0 -0
  41. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/shell/shell-security.feature +0 -0
  42. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/supply-chain/image-pinning.feature +0 -0
  43. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/supply-chain/include-versions.feature +0 -0
  44. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/supply-chain/service-pinning.feature +0 -0
  45. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/builtin_policies/variable-allowlist.feature +0 -0
  46. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/console.py +0 -0
  47. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/container_fix.py +0 -0
  48. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/gitlab_api.py +0 -0
  49. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/image_versions.py +0 -0
  50. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/include_fix.py +0 -0
  51. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/include_versions.py +0 -0
  52. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/merge_requests.py +0 -0
  53. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/metadata.py +0 -0
  54. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/oci_registry.py +0 -0
  55. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/policy_doc.py +0 -0
  56. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/policy_fix.py +0 -0
  57. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/release_cache.py +0 -0
  58. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/script_analysis.py +0 -0
  59. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/secret_redact.py +0 -0
  60. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/stash.py +0 -0
  61. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/compliance/supply_chain_fix.py +0 -0
  62. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/gitlab_docs.py +0 -0
  63. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/__init__.py +0 -0
  64. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/command_reference.py +0 -0
  65. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/common.py +0 -0
  66. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/doc_controller.py +0 -0
  67. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/gitlab_reference.py +0 -0
  68. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/gitstrings.py +0 -0
  69. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/logging.py +0 -0
  70. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/output_filters.py +0 -0
  71. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/release.py +0 -0
  72. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/swagger_html.py +0 -0
  73. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/swagger_markdown.py +0 -0
  74. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/yaml_lines.py +0 -0
  75. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/modules/yaml_md_table.py +0 -0
  76. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/__init__.py +0 -0
  77. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/extract_job_attribute.py +0 -0
  78. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/includes.py +0 -0
  79. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/inputs.py +0 -0
  80. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/jobs.py +0 -0
  81. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/table_render.py +0 -0
  82. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/variables.py +0 -0
  83. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/workflows.py +0 -0
  84. {gitlab_compliance-2.2.0 → gitlab_compliance-2.4.0}/src/properties/yaml_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlab-compliance
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: BDD compliance testing for GitLab CI/CD pipelines and project settings — by MaturityBuilder
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gitlab-compliance"
3
- version = "2.2.0"
3
+ version = "2.4.0"
4
4
  dynamic = [ "readme" ]
5
5
  license = "Apache-2.0"
6
6
  requires-python = ">=3.12"
@@ -6,6 +6,15 @@ from src.compliance.model import load_pipeline_entities
6
6
  from src.compliance.release_cache import ReleaseMetadataCache
7
7
 
8
8
 
9
+ def _resolve_external_flag(value: str | None) -> bool | None:
10
+ normalized = str(value or "auto").strip().lower()
11
+ if normalized in {"true", "yes", "on", "1"}:
12
+ return True
13
+ if normalized in {"false", "no", "off", "0"}:
14
+ return False
15
+ return None
16
+
17
+
9
18
  def before_all(context):
10
19
  context.compliance_entities = {}
11
20
  context.stash = []
@@ -42,6 +51,9 @@ def before_scenario(context, scenario):
42
51
  == "true",
43
52
  load_api_entities=context.config.userdata.get("load_api_entities", "true")
44
53
  == "true",
54
+ resolve_external_includes=_resolve_external_flag(
55
+ context.config.userdata.get("resolve_external_includes", "auto")
56
+ ),
45
57
  cache=getattr(context, "release_cache", None),
46
58
  )
47
59
 
@@ -0,0 +1,340 @@
1
+ """Fetch GitLab CI YAML from remote and project include entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+ from urllib.parse import urljoin, urlparse
9
+
10
+ import requests
11
+
12
+ from src.compliance.include_versions import resolve_include_project_path
13
+ from src.modules.common import load_yml_documents
14
+
15
+ GITLAB_TEMPLATE_BASE = (
16
+ "https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates"
17
+ )
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class FetchedInclude:
22
+ """YAML content retrieved from an external include."""
23
+
24
+ config_label: str
25
+ yaml_text: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class IncludeFetchFailure:
30
+ """Reason an include could not be fetched."""
31
+
32
+ reason: str
33
+ detail: str = ""
34
+
35
+
36
+ @dataclass
37
+ class IncludeFetchCache:
38
+ """In-memory cache for fetched include YAML."""
39
+
40
+ entries: dict[str, FetchedInclude | IncludeFetchFailure] = field(
41
+ default_factory=dict
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ExternalIncludeContext:
47
+ """Origin metadata for YAML loaded from remote or project includes."""
48
+
49
+ kind: str
50
+ visit_key: str
51
+ base_url: str = ""
52
+ project_path: str = ""
53
+ ref: str = ""
54
+
55
+
56
+ def _cache_key(include_type: str, *parts: str) -> str:
57
+ return f"{include_type}:" + ":".join(parts)
58
+
59
+
60
+ def _gitlab_client(gitlab_url: str, token: str):
61
+ import gitlab
62
+
63
+ client = gitlab.Gitlab(gitlab_url, private_token=token)
64
+ client.auth()
65
+ return client
66
+
67
+
68
+ def _normalize_project_files(file_value: Any) -> list[str]:
69
+ if file_value is None or file_value == "":
70
+ return [".gitlab-ci.yml"]
71
+ if isinstance(file_value, str):
72
+ return [file_value]
73
+ if isinstance(file_value, list):
74
+ return [str(item) for item in file_value if item]
75
+ return [str(file_value)]
76
+
77
+
78
+ def remote_url_allows_token(url: str, gitlab_url: str | None) -> bool:
79
+ """Return True when ``url`` is on the same host as the GitLab instance."""
80
+ if not gitlab_url:
81
+ return False
82
+ remote_host = (urlparse(url).netloc or "").lower()
83
+ gitlab_host = (urlparse(gitlab_url).netloc or "").lower()
84
+ if not remote_host or not gitlab_host:
85
+ return False
86
+ if remote_host == gitlab_host:
87
+ return True
88
+ return remote_host == f"www.{gitlab_host}" or gitlab_host == f"www.{remote_host}"
89
+
90
+
91
+ def _remote_headers(
92
+ url: str, token: str | None, gitlab_url: str | None
93
+ ) -> dict[str, str]:
94
+ if token and remote_url_allows_token(url, gitlab_url):
95
+ return {"PRIVATE-TOKEN": token}
96
+ return {}
97
+
98
+
99
+ def remote_directory_url(url: str) -> str:
100
+ """Return the directory URL for resolving nested local includes."""
101
+ parsed = urlparse(url)
102
+ path = parsed.path or "/"
103
+ if not path.endswith("/"):
104
+ path = path.rsplit("/", 1)[0] + "/"
105
+ return parsed._replace(path=path, params="", query="", fragment="").geturl()
106
+
107
+
108
+ def _fetch_remote(
109
+ url: str,
110
+ token: str | None,
111
+ gitlab_url: str | None = None,
112
+ ) -> FetchedInclude | IncludeFetchFailure:
113
+ headers = _remote_headers(url, token, gitlab_url)
114
+ try:
115
+ response = requests.get(url, headers=headers, timeout=30)
116
+ response.raise_for_status()
117
+ except requests.RequestException as exc:
118
+ return IncludeFetchFailure(reason="fetch_failed", detail=str(exc))
119
+ text = response.text
120
+ if not text.strip():
121
+ return IncludeFetchFailure(reason="fetch_failed", detail="empty response")
122
+ try:
123
+ load_yml_documents(text)
124
+ except Exception as exc:
125
+ return IncludeFetchFailure(reason="fetch_failed", detail=f"invalid YAML: {exc}")
126
+ return FetchedInclude(config_label=url, yaml_text=text)
127
+
128
+
129
+ def _fetch_project_files(
130
+ *,
131
+ project_path: str,
132
+ ref: str,
133
+ files: list[str],
134
+ gitlab_url: str,
135
+ token: str,
136
+ ) -> FetchedInclude | IncludeFetchFailure:
137
+ try:
138
+ gl = _gitlab_client(gitlab_url, token)
139
+ project = gl.projects.get(project_path)
140
+ except Exception as exc:
141
+ return IncludeFetchFailure(reason="fetch_failed", detail=str(exc))
142
+
143
+ documents: list[dict] = []
144
+ label_parts: list[str] = []
145
+ for file_path in files:
146
+ try:
147
+ raw = project.files.raw(file_path=file_path, ref=ref or "main")
148
+ except Exception as exc:
149
+ return IncludeFetchFailure(
150
+ reason="fetch_failed",
151
+ detail=f"{file_path}@{ref or 'main'}: {exc}",
152
+ )
153
+ text = raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
154
+ try:
155
+ parsed_docs = load_yml_documents(text)
156
+ except Exception as exc:
157
+ return IncludeFetchFailure(
158
+ reason="fetch_failed",
159
+ detail=f"{file_path}: invalid YAML: {exc}",
160
+ )
161
+ documents.extend(doc for doc in parsed_docs if isinstance(doc, dict))
162
+ label_parts.append(file_path)
163
+
164
+ if not documents:
165
+ return IncludeFetchFailure(reason="fetch_failed", detail="no YAML documents")
166
+
167
+ import yaml
168
+
169
+ merged = yaml.safe_dump_all(documents, sort_keys=False)
170
+ label = f"project:{project_path}@{ref or 'main'}:" + ",".join(label_parts)
171
+ return FetchedInclude(config_label=label, yaml_text=merged)
172
+
173
+
174
+ def _fetch_template(template_name: str) -> FetchedInclude | IncludeFetchFailure:
175
+ name = str(template_name).strip()
176
+ if not name:
177
+ return IncludeFetchFailure(
178
+ reason="unsupported_type", detail="empty template name"
179
+ )
180
+ url = f"{GITLAB_TEMPLATE_BASE}/{name}"
181
+ return _fetch_remote(url, token=None, gitlab_url=None)
182
+
183
+
184
+ def fetch_include_content(
185
+ parsed: dict[str, Any],
186
+ *,
187
+ gitlab_url: str,
188
+ token: str | None,
189
+ cache: IncludeFetchCache | None = None,
190
+ allow_template: bool = False,
191
+ ) -> FetchedInclude | IncludeFetchFailure:
192
+ """Fetch YAML for a parsed include entry (remote or project)."""
193
+ include_type = parsed.get("include_type", "")
194
+ cache = cache or IncludeFetchCache()
195
+
196
+ if include_type == "remote":
197
+ url = str(parsed.get("project", "")).strip()
198
+ key = _cache_key("remote", url)
199
+ if key in cache.entries:
200
+ return cache.entries[key]
201
+ result = _fetch_remote(url, token, gitlab_url=gitlab_url)
202
+ cache.entries[key] = result
203
+ return result
204
+
205
+ if include_type == "project":
206
+ if not token:
207
+ return IncludeFetchFailure(reason="no_token")
208
+ project_path = resolve_include_project_path(
209
+ "project", str(parsed.get("project", ""))
210
+ )
211
+ if not project_path:
212
+ return IncludeFetchFailure(
213
+ reason="fetch_failed", detail="could not resolve project path"
214
+ )
215
+ ref = str(parsed.get("version", "") or parsed.get("ref", "") or "main")
216
+ files = _normalize_project_files(parsed.get("file"))
217
+ key = _cache_key("project", project_path, ref, *files)
218
+ if key in cache.entries:
219
+ return cache.entries[key]
220
+ result = _fetch_project_files(
221
+ project_path=project_path,
222
+ ref=ref,
223
+ files=files,
224
+ gitlab_url=gitlab_url,
225
+ token=token,
226
+ )
227
+ cache.entries[key] = result
228
+ return result
229
+
230
+ if include_type == "template" and allow_template:
231
+ template_name = str(parsed.get("project", "")).strip()
232
+ key = _cache_key("template", template_name)
233
+ if key in cache.entries:
234
+ return cache.entries[key]
235
+ result = _fetch_template(template_name)
236
+ cache.entries[key] = result
237
+ return result
238
+
239
+ if include_type in {"component", "template"}:
240
+ return IncludeFetchFailure(reason="unsupported_type")
241
+
242
+ return IncludeFetchFailure(reason="unsupported_type", detail=include_type)
243
+
244
+
245
+ def context_for_fetched(
246
+ parsed: dict[str, Any], fetched: FetchedInclude
247
+ ) -> ExternalIncludeContext:
248
+ """Build origin context used to resolve nested locals inside fetched YAML."""
249
+ include_type = parsed.get("include_type", "")
250
+ if include_type == "remote":
251
+ return ExternalIncludeContext(
252
+ kind="remote",
253
+ visit_key=fetched.config_label,
254
+ base_url=remote_directory_url(fetched.config_label),
255
+ )
256
+ if include_type == "project":
257
+ project_path = resolve_include_project_path(
258
+ "project", str(parsed.get("project", ""))
259
+ ) or str(parsed.get("project", ""))
260
+ ref = str(parsed.get("version", "") or parsed.get("ref", "") or "main")
261
+ return ExternalIncludeContext(
262
+ kind="project",
263
+ visit_key=fetched.config_label,
264
+ project_path=project_path,
265
+ ref=ref or "main",
266
+ )
267
+ return ExternalIncludeContext(kind=include_type, visit_key=fetched.config_label)
268
+
269
+
270
+ def resolve_nested_local_from_context(
271
+ local_path: str,
272
+ context: ExternalIncludeContext,
273
+ *,
274
+ gitlab_url: str,
275
+ token: str | None,
276
+ cache: IncludeFetchCache,
277
+ ) -> FetchedInclude | IncludeFetchFailure:
278
+ """Resolve a nested ``local:`` path relative to an external include origin."""
279
+ normalized = str(local_path).lstrip("/")
280
+ if context.kind == "remote":
281
+ url = urljoin(context.base_url, normalized)
282
+ key = _cache_key("remote", url)
283
+ if key in cache.entries:
284
+ return cache.entries[key]
285
+ result = _fetch_remote(url, token, gitlab_url=gitlab_url)
286
+ cache.entries[key] = result
287
+ return result
288
+
289
+ if context.kind == "project":
290
+ if not token:
291
+ return IncludeFetchFailure(reason="no_token")
292
+ parsed = {
293
+ "include_type": "project",
294
+ "project": context.project_path,
295
+ "version": context.ref,
296
+ "file": normalized,
297
+ }
298
+ return fetch_include_content(
299
+ parsed,
300
+ gitlab_url=gitlab_url,
301
+ token=token,
302
+ cache=cache,
303
+ )
304
+
305
+ return IncludeFetchFailure(
306
+ reason="unsupported_type", detail=f"nested local from {context.kind}"
307
+ )
308
+
309
+
310
+ def include_reference_label(parsed: dict[str, Any]) -> str:
311
+ """Human-readable label for an include entry."""
312
+ include_type = parsed.get("include_type", "unknown")
313
+ if include_type == "local":
314
+ return str(parsed.get("project", ""))
315
+ if include_type == "remote":
316
+ return str(parsed.get("project", ""))
317
+ if include_type == "project":
318
+ file_part = parsed.get("file") or ".gitlab-ci.yml"
319
+ ref = parsed.get("version") or "main"
320
+ return f"{parsed.get('project', '')} ({file_part} @ {ref})"
321
+ if include_type == "component":
322
+ return f"{parsed.get('project', '')}@{parsed.get('version', '')}"
323
+ if include_type == "template":
324
+ return str(parsed.get("project", ""))
325
+ return str(parsed.get("project", ""))
326
+
327
+
328
+ def default_gitlab_url(gitlab_url: str | None = None) -> str:
329
+ if gitlab_url:
330
+ return gitlab_url.rstrip("/")
331
+ for env_key in ("CI_SERVER_URL", "GITLAB_URL"):
332
+ value = os.getenv(env_key)
333
+ if value:
334
+ return value.rstrip("/")
335
+ return "https://gitlab.com"
336
+
337
+
338
+ def is_public_gitlab_host(url: str) -> bool:
339
+ parsed = urlparse(url)
340
+ return parsed.netloc in {"gitlab.com", "www.gitlab.com"}
@@ -0,0 +1,116 @@
1
+ """Include resolution reporting for compliance and shell-check runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from src.compliance.include_fetch import include_reference_label
9
+
10
+ REASON_MESSAGES = {
11
+ "no_token": "missing authentication", # nosec B105 - user-facing reason label
12
+ "fetch_failed": "fetch failed",
13
+ "unsupported_type": "unsupported include type",
14
+ "depth_exceeded": "max include depth exceeded",
15
+ "include_nested_disabled": "nested includes disabled",
16
+ "external_resolution_disabled": "external include resolution disabled",
17
+ "path_rejected": "local include path rejected",
18
+ "not_found": "include file not found",
19
+ }
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class UnresolvedInclude:
24
+ """An include entry that was not merged into the pipeline stash."""
25
+
26
+ include_type: str
27
+ reference: str
28
+ reason: str
29
+ source_file: str = ""
30
+ line: int = 0
31
+ detail: str = ""
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "include_type": self.include_type,
36
+ "reference": self.reference,
37
+ "reason": self.reason,
38
+ "source_file": self.source_file,
39
+ "line": self.line,
40
+ "detail": self.detail,
41
+ }
42
+
43
+ @classmethod
44
+ def from_dict(cls, payload: dict[str, Any]) -> UnresolvedInclude:
45
+ return cls(
46
+ include_type=str(payload.get("include_type", "")),
47
+ reference=str(payload.get("reference", "")),
48
+ reason=str(payload.get("reason", "")),
49
+ source_file=str(payload.get("source_file", "")),
50
+ line=int(payload.get("line") or 0),
51
+ detail=str(payload.get("detail", "")),
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class IncludeResolutionReport:
57
+ """Aggregated include coverage information for a pipeline load."""
58
+
59
+ unresolved: list[UnresolvedInclude] = field(default_factory=list)
60
+
61
+ def extend(self, items: list[UnresolvedInclude | dict[str, Any]]) -> None:
62
+ for item in items:
63
+ if isinstance(item, UnresolvedInclude):
64
+ self.unresolved.append(item)
65
+ else:
66
+ self.unresolved.append(UnresolvedInclude.from_dict(item))
67
+
68
+ def to_dicts(self) -> list[dict[str, Any]]:
69
+ return [item.to_dict() for item in self.unresolved]
70
+
71
+ def has_gaps(self) -> bool:
72
+ return bool(self.unresolved)
73
+
74
+
75
+ def record_unresolved(
76
+ bucket: list[UnresolvedInclude],
77
+ parsed: dict[str, Any],
78
+ *,
79
+ reason: str,
80
+ detail: str = "",
81
+ ) -> None:
82
+ bucket.append(
83
+ UnresolvedInclude(
84
+ include_type=str(parsed.get("include_type", "")),
85
+ reference=include_reference_label(parsed),
86
+ reason=reason,
87
+ source_file=str(parsed.get("source_file", "")),
88
+ line=int(parsed.get("line") or 0),
89
+ detail=detail,
90
+ )
91
+ )
92
+
93
+
94
+ def format_include_warning(report: IncludeResolutionReport) -> str:
95
+ """Format unresolved includes for console warnings."""
96
+ if not report.unresolved:
97
+ return ""
98
+
99
+ lines = [
100
+ "Some include entries were not resolved; scripts from those files were "
101
+ + "not validated. Pass --token (or set GITLAB_TOKEN) and enable external "
102
+ + "include resolution to fetch project and remote includes.",
103
+ "",
104
+ ]
105
+ for item in report.unresolved:
106
+ reason = REASON_MESSAGES.get(item.reason, item.reason)
107
+ detail = f" ({item.detail})" if item.detail else ""
108
+ location = ""
109
+ if item.source_file:
110
+ location = f" in {item.source_file}"
111
+ if item.line:
112
+ location += f":{item.line}"
113
+ lines.append(
114
+ f"- [{item.include_type}] {item.reference}{location}: {reason}{detail}"
115
+ )
116
+ return "\n".join(lines)
@@ -110,7 +110,10 @@ def load_yaml_entities(
110
110
  pipeline_file: str,
111
111
  include_nested: bool = True,
112
112
  max_include_depth: int | None = None,
113
- ) -> dict[str, list[dict]]:
113
+ resolve_external_includes: bool | None = None,
114
+ gitlab_url: str | None = None,
115
+ token: str | None = None,
116
+ ) -> tuple[dict[str, list[dict]], list[dict]]:
114
117
  if not os.path.exists(pipeline_file):
115
118
  raise FileNotFoundError(f"Pipeline file not found: {pipeline_file}")
116
119
 
@@ -121,6 +124,9 @@ def load_yaml_entities(
121
124
  max_include_depth=max_include_depth,
122
125
  include_scripts=True,
123
126
  resolve_job_composition=True,
127
+ resolve_external_includes=resolve_external_includes,
128
+ gitlab_url=gitlab_url,
129
+ token=token,
124
130
  )
125
131
 
126
132
  line_index = pipeline_data.get("line_index") or {}
@@ -170,7 +176,7 @@ def load_yaml_entities(
170
176
  for index, image in enumerate(pipeline_data["container_images"], 1)
171
177
  ],
172
178
  }
173
- return entities
179
+ return entities, list(pipeline_data.get("unresolved_includes") or [])
174
180
 
175
181
 
176
182
  def _job_values(job: dict) -> dict:
@@ -205,6 +211,9 @@ def load_merged_job_scripts(
205
211
  pipeline_file: str,
206
212
  include_nested: bool = True,
207
213
  max_include_depth: int | None = None,
214
+ resolve_external_includes: bool | None = None,
215
+ gitlab_url: str | None = None,
216
+ token: str | None = None,
208
217
  ) -> list[dict]:
209
218
  """Return jobs with composed effective scripts for shell validation."""
210
219
  pipeline_data = collect_pipeline_data(
@@ -214,6 +223,9 @@ def load_merged_job_scripts(
214
223
  max_include_depth=max_include_depth,
215
224
  include_scripts=True,
216
225
  resolve_job_composition=True,
226
+ resolve_external_includes=resolve_external_includes,
227
+ gitlab_url=gitlab_url,
228
+ token=token,
217
229
  )
218
230
  return list(pipeline_data.get("merged_jobs") or pipeline_data.get("jobs") or [])
219
231
 
@@ -229,16 +241,21 @@ def load_pipeline_entities(
229
241
  enrich_includes: bool = True,
230
242
  enrich_images: bool = True,
231
243
  load_api_entities: bool = True,
244
+ resolve_external_includes: bool | None = None,
232
245
  cache: "ReleaseMetadataCache | None" = None,
233
246
  ) -> dict[str, list[dict]]:
234
247
  from src.compliance.api_config import resolve_group, resolve_project, resolve_token
235
248
  from src.compliance.release_cache import ReleaseMetadataCache
236
249
 
237
- entities = load_yaml_entities(
250
+ entities, unresolved_includes = load_yaml_entities(
238
251
  pipeline_file,
239
252
  include_nested=include_nested,
240
253
  max_include_depth=max_include_depth,
254
+ resolve_external_includes=resolve_external_includes,
255
+ gitlab_url=gitlab_url,
256
+ token=token,
241
257
  )
258
+ entities["unresolved_includes"] = unresolved_includes
242
259
 
243
260
  userdata = {
244
261
  "project": project or "",
@@ -27,3 +27,4 @@ class ComplianceResult:
27
27
  failed: int = 0
28
28
  skipped: int = 0
29
29
  scenario_results: list[ScenarioResult] = field(default_factory=list)
30
+ unresolved_includes: list[dict] = field(default_factory=list)