gitlab-compliance 2.2.0__tar.gz → 2.3.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.
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/PKG-INFO +1 -1
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/pyproject.toml +1 -1
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/behave_support/environment.py +12 -0
- gitlab_compliance-2.3.0/src/compliance/include_fetch.py +340 -0
- gitlab_compliance-2.3.0/src/compliance/include_resolution.py +116 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/model.py +20 -3
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/models.py +1 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/render.py +217 -12
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/runner.py +29 -1
- gitlab_compliance-2.3.0/src/compliance/shell_render.py +632 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/gitlab_compliance.py +91 -8
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/constants.py +17 -1
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/job_composition.py +58 -7
- gitlab_compliance-2.3.0/src/modules/pipeline_data.py +868 -0
- gitlab_compliance-2.2.0/src/modules/pipeline_data.py +0 -493
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/LICENSE +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/docs/index.md +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/__init__.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/__init__.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/api_config.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/api_enrichment.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/behave_support/steps/__init__.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/behave_support/steps/given_steps.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/behave_support/steps/script_steps.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/behave_support/steps/then_steps.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/behave_support/steps/when_steps.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/__init__.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/baseline-jobs.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/component-inputs.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/include-pinning.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-ci-conventions.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-command-substitution.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-conditionals.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-error-handling.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-file-operations.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-pinning.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-pipelines.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-portability.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-quoting.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-references.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/shell/shell-security.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/supply-chain/image-pinning.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/supply-chain/include-versions.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/supply-chain/service-pinning.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/builtin_policies/variable-allowlist.feature +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/console.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/container_fix.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/gitlab_api.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/image_versions.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/include_fix.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/include_versions.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/merge_requests.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/metadata.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/oci_registry.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/policy_doc.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/policy_fix.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/release_cache.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/script_analysis.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/secret_redact.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/stash.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/supply_chain_fix.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/gitlab_docs.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/__init__.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/command_reference.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/common.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/doc_controller.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/gitlab_reference.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/gitstrings.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/logging.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/output_filters.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/release.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/swagger_html.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/swagger_markdown.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/yaml_lines.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/modules/yaml_md_table.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/__init__.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/extract_job_attribute.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/includes.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/inputs.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/jobs.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/table_render.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/variables.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/workflows.py +0 -0
- {gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/properties/yaml_paths.py +0 -0
{gitlab_compliance-2.2.0 → gitlab_compliance-2.3.0}/src/compliance/behave_support/environment.py
RENAMED
|
@@ -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
|
-
|
|
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 "",
|