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,250 @@
1
+ """Include version validation and GitLab release metadata for compliance checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ import semver
11
+
12
+ from src.compliance.release_cache import ReleaseMetadataCache
13
+ from src.modules.release import parse_gitlab_date
14
+
15
+
16
+ def is_valid_semver_version(version: str) -> bool:
17
+ if not version or not str(version).strip():
18
+ return False
19
+ try:
20
+ return semver.Version.is_valid(str(version).strip())
21
+ except Exception:
22
+ return False
23
+
24
+
25
+ def resolve_include_project_path(include_type: str, project: str) -> str | None:
26
+ if include_type not in {"project", "component"}:
27
+ return None
28
+
29
+ path = str(project or "").strip()
30
+ if not path:
31
+ return None
32
+
33
+ if path.startswith("http://") or path.startswith("https://"):
34
+ path = urlparse(path).path.lstrip("/")
35
+ elif "/" in path and "." in path.split("/", 1)[0]:
36
+ path = "/".join(path.split("/")[1:])
37
+
38
+ if path.endswith(".git"):
39
+ path = path[:-4]
40
+
41
+ return path or None
42
+
43
+
44
+ def days_between(start: datetime, end: datetime) -> int:
45
+ """Whole days from start to end (end may be before start)."""
46
+ start_utc = start.astimezone(timezone.utc)
47
+ end_utc = end.astimezone(timezone.utc)
48
+ return (end_utc.date() - start_utc.date()).days
49
+
50
+
51
+ def compute_release_lag_days(
52
+ pinned_date: datetime | None, latest_date: datetime | None
53
+ ) -> int | None:
54
+ if pinned_date is None or latest_date is None:
55
+ return None
56
+ return days_between(pinned_date, latest_date)
57
+
58
+
59
+ def compute_latest_release_age_days(
60
+ latest_date: datetime | None, *, now: datetime | None = None
61
+ ) -> int | None:
62
+ if latest_date is None:
63
+ return None
64
+ reference = now or datetime.now(timezone.utc)
65
+ return days_between(latest_date, reference)
66
+
67
+
68
+ def _tag_committed_date(tag: Any) -> datetime | None:
69
+ commit = getattr(tag, "commit", None) or {}
70
+ if isinstance(commit, dict):
71
+ committed_date = commit.get("committed_date")
72
+ else:
73
+ committed_date = getattr(commit, "committed_date", None)
74
+ if not committed_date:
75
+ return None
76
+ try:
77
+ return parse_gitlab_date(str(committed_date))
78
+ except ValueError:
79
+ return None
80
+
81
+
82
+ def fetch_semver_tag_dates(
83
+ gl: Any,
84
+ project_path: str,
85
+ *,
86
+ gitlab_url: str | None = None,
87
+ cache: ReleaseMetadataCache | None = None,
88
+ ) -> dict[str, datetime]:
89
+ """Map semver tag names to commit dates from a single tags.list call."""
90
+ if cache is not None and gitlab_url:
91
+ cached = cache.get_gitlab_tag_dates(gitlab_url, project_path)
92
+ if cached is not None:
93
+ return cached
94
+
95
+ project = gl.projects.get(project_path)
96
+ tag_dates: dict[str, datetime] = {}
97
+ for tag in project.tags.list(all=True):
98
+ if not is_valid_semver_version(tag.name):
99
+ continue
100
+ committed_date = _tag_committed_date(tag)
101
+ if committed_date is not None:
102
+ tag_dates[tag.name] = committed_date
103
+
104
+ if cache is not None and gitlab_url:
105
+ cache.set_gitlab_tag_dates(gitlab_url, project_path, tag_dates)
106
+ return tag_dates
107
+
108
+
109
+ def _semver_tags_descending(tag_names: list[str]) -> list[str]:
110
+ valid = [name for name in tag_names if is_valid_semver_version(name)]
111
+ return sorted(valid, key=lambda value: semver.Version.parse(value), reverse=True)
112
+
113
+
114
+ def compute_version_tag_rank(version: str, tags_desc: list[str]) -> int | None:
115
+ try:
116
+ return tags_desc.index(version) + 1
117
+ except ValueError:
118
+ return None
119
+
120
+
121
+ def version_within_latest_tags(version: str, tags_desc: list[str], count: int) -> bool:
122
+ rank = compute_version_tag_rank(version, tags_desc)
123
+ if rank is None:
124
+ return False
125
+ return rank <= count
126
+
127
+
128
+ def _latest_semver_tag(tag_names: list[str]) -> str | None:
129
+ valid = [name for name in tag_names if is_valid_semver_version(name)]
130
+ if not valid:
131
+ return None
132
+ return max(valid, key=lambda value: semver.Version.parse(value))
133
+
134
+
135
+ def fetch_latest_semver_version(gl: Any, project_path: str) -> str | None:
136
+ tag_dates = fetch_semver_tag_dates(gl, project_path)
137
+ return _latest_semver_tag(list(tag_dates.keys()))
138
+
139
+
140
+ def include_update_available(current_version: str, latest_version: str) -> bool:
141
+ if not latest_version:
142
+ return False
143
+ current = str(current_version or "").strip()
144
+ if not is_valid_semver_version(current):
145
+ return is_valid_semver_version(latest_version)
146
+ if not is_valid_semver_version(latest_version):
147
+ return False
148
+ return semver.Version.parse(latest_version) > semver.Version.parse(current_version)
149
+
150
+
151
+ def enrich_include_release_metadata(
152
+ include: dict,
153
+ *,
154
+ gitlab_url: str | None,
155
+ token: str,
156
+ gl: Any | None = None,
157
+ cache: ReleaseMetadataCache | None = None,
158
+ ) -> dict:
159
+ enriched = dict(include)
160
+ include_type = enriched.get("include_type", "")
161
+ version = str(enriched.get("version", "")).strip()
162
+ enriched["valid_version"] = is_valid_semver_version(version)
163
+ enriched["latest_version"] = ""
164
+ enriched["update_available"] = False
165
+ enriched["release_metadata_resolved"] = False
166
+ enriched["version_released_at"] = ""
167
+ enriched["latest_version_released_at"] = ""
168
+ enriched["latest_release_age_days"] = None
169
+ enriched["release_lag_days"] = None
170
+ enriched["version_tag_rank"] = None
171
+ enriched["semver_tag_count"] = 0
172
+
173
+ project_path = resolve_include_project_path(
174
+ include_type, enriched.get("project", "")
175
+ )
176
+ if not project_path:
177
+ return enriched
178
+
179
+ gitlab_url = (
180
+ gitlab_url
181
+ or os.getenv("CI_SERVER_URL")
182
+ or os.getenv("GITLAB_URL")
183
+ or "https://gitlab.com"
184
+ )
185
+
186
+ try:
187
+ if gl is None:
188
+ import gitlab
189
+
190
+ gl = gitlab.Gitlab(gitlab_url, private_token=token)
191
+ gl.auth()
192
+ tag_dates = fetch_semver_tag_dates(
193
+ gl, project_path, gitlab_url=gitlab_url, cache=cache
194
+ )
195
+ except Exception:
196
+ return enriched
197
+
198
+ latest_version = _latest_semver_tag(list(tag_dates.keys()))
199
+ if not latest_version:
200
+ return enriched
201
+
202
+ tags_desc = _semver_tags_descending(list(tag_dates.keys()))
203
+ pinned_date = tag_dates.get(version) if enriched["valid_version"] else None
204
+ latest_date = tag_dates.get(latest_version)
205
+
206
+ enriched["latest_version"] = latest_version
207
+ enriched["update_available"] = include_update_available(version, latest_version)
208
+ enriched["release_metadata_resolved"] = True
209
+ enriched["semver_tag_count"] = len(tags_desc)
210
+ if enriched["valid_version"]:
211
+ enriched["version_tag_rank"] = compute_version_tag_rank(version, tags_desc)
212
+
213
+ if pinned_date is not None:
214
+ enriched["version_released_at"] = pinned_date.isoformat()
215
+ if latest_date is not None:
216
+ enriched["latest_version_released_at"] = latest_date.isoformat()
217
+ enriched["latest_release_age_days"] = compute_latest_release_age_days(
218
+ latest_date
219
+ )
220
+
221
+ if (
222
+ enriched["update_available"]
223
+ and pinned_date is not None
224
+ and latest_date is not None
225
+ ):
226
+ enriched["release_lag_days"] = compute_release_lag_days(
227
+ pinned_date, latest_date
228
+ )
229
+
230
+ return enriched
231
+
232
+
233
+ def enrich_includes_with_releases(
234
+ includes: list[dict],
235
+ *,
236
+ gitlab_url: str | None,
237
+ token: str,
238
+ gl: Any | None = None,
239
+ cache: ReleaseMetadataCache | None = None,
240
+ ) -> list[dict]:
241
+ return [
242
+ enrich_include_release_metadata(
243
+ include,
244
+ gitlab_url=gitlab_url,
245
+ token=token,
246
+ gl=gl,
247
+ cache=cache,
248
+ )
249
+ for include in includes
250
+ ]
@@ -0,0 +1,245 @@
1
+ """Parse Conftest-style # METADATA annotations from compliance policy files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ METADATA_MARKER = "# METADATA"
13
+
14
+
15
+ @dataclass
16
+ class PolicyAnnotation:
17
+ policy_id: str
18
+ title: str
19
+ description: str = ""
20
+ scope: str = ""
21
+ feature_file: str = ""
22
+ line: int = 0
23
+ feature_name: str = ""
24
+ scenario_name: str = ""
25
+ custom: dict = field(default_factory=dict)
26
+
27
+
28
+ @dataclass
29
+ class FeaturePolicies:
30
+ feature_file: str
31
+ feature_name: str
32
+ annotation: PolicyAnnotation | None = None
33
+ scenarios: list[PolicyAnnotation] = field(default_factory=list)
34
+
35
+
36
+ @dataclass
37
+ class PolicyCatalog:
38
+ features: list[FeaturePolicies] = field(default_factory=list)
39
+
40
+ def lookup_scenario(
41
+ self, feature_file: str, scenario_name: str
42
+ ) -> PolicyAnnotation | None:
43
+ normalized = os.path.realpath(feature_file)
44
+ for feature in self.features:
45
+ if os.path.realpath(feature.feature_file) != normalized:
46
+ continue
47
+ for scenario in feature.scenarios:
48
+ if scenario.scenario_name == scenario_name:
49
+ return scenario
50
+ return None
51
+
52
+
53
+ def _slug(value: str) -> str:
54
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").upper()
55
+ return slug or "POLICY"
56
+
57
+
58
+ def _auto_feature_id(feature_file: str) -> str:
59
+ return f"GLCI-{_slug(Path(feature_file).stem)}"
60
+
61
+
62
+ def _auto_scenario_id(feature_id: str, index: int) -> str:
63
+ return f"{feature_id}-{index:03d}"
64
+
65
+
66
+ def _quote_yaml_scalar(value: str) -> str:
67
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
68
+ return f'"{escaped}"'
69
+
70
+
71
+ def _prepare_metadata_yaml_line(line: str) -> str:
72
+ if line.startswith(" "):
73
+ return line
74
+ if ":" not in line:
75
+ return line
76
+ key, sep, rest = line.partition(":")
77
+ value = rest.strip()
78
+ if not value or value in ("|", ">"):
79
+ return line
80
+ if (value.startswith('"') and value.endswith('"')) or (
81
+ value.startswith("'") and value.endswith("'")
82
+ ):
83
+ return line
84
+ if ":" in value:
85
+ return f"{key}{sep} {_quote_yaml_scalar(value)}"
86
+ return line
87
+
88
+
89
+ def _parse_metadata_yaml(yaml_lines: list[str]) -> dict:
90
+ if not yaml_lines:
91
+ return {}
92
+ prepared = [_prepare_metadata_yaml_line(line) for line in yaml_lines]
93
+ parsed = yaml.safe_load("\n".join(prepared))
94
+ return parsed if isinstance(parsed, dict) else {}
95
+
96
+
97
+ def _collect_metadata_block(lines: list[str], start_index: int) -> tuple[dict, int]:
98
+ yaml_lines: list[str] = []
99
+ index = start_index + 1
100
+ while index < len(lines):
101
+ line = lines[index]
102
+ if line.startswith("# "):
103
+ yaml_lines.append(line[2:])
104
+ index += 1
105
+ continue
106
+ break
107
+ return _parse_metadata_yaml(yaml_lines), index
108
+
109
+
110
+ def _annotation_from_raw(
111
+ raw: dict,
112
+ *,
113
+ scope: str,
114
+ feature_file: str,
115
+ line: int,
116
+ feature_name: str,
117
+ scenario_name: str = "",
118
+ default_title: str,
119
+ default_id: str,
120
+ ) -> PolicyAnnotation:
121
+ custom = raw.get("custom", {})
122
+ if not isinstance(custom, dict):
123
+ custom = {}
124
+
125
+ policy_id = str(custom.get("id") or raw.get("id") or default_id)
126
+ title = str(raw.get("title") or default_title)
127
+ description = str(raw.get("description") or "")
128
+
129
+ return PolicyAnnotation(
130
+ policy_id=policy_id,
131
+ title=title,
132
+ description=description,
133
+ scope=scope,
134
+ feature_file=feature_file,
135
+ line=line,
136
+ feature_name=feature_name,
137
+ scenario_name=scenario_name,
138
+ custom=custom,
139
+ )
140
+
141
+
142
+ def parse_feature_policies(feature_file: str) -> FeaturePolicies:
143
+ feature_path = os.path.realpath(feature_file)
144
+ with open(feature_path, encoding="utf-8") as handle:
145
+ lines = handle.read().splitlines()
146
+
147
+ pending_metadata: dict = {}
148
+ feature_name = Path(feature_path).stem
149
+ feature_annotation: PolicyAnnotation | None = None
150
+ scenarios: list[PolicyAnnotation] = []
151
+ scenario_index = 0
152
+
153
+ index = 0
154
+ while index < len(lines):
155
+ line = lines[index]
156
+ if line.startswith(METADATA_MARKER):
157
+ pending_metadata, index = _collect_metadata_block(lines, index)
158
+ continue
159
+
160
+ if line.startswith("Feature:"):
161
+ feature_name = line.split(":", 1)[1].strip() or feature_name
162
+ feature_id = _auto_feature_id(feature_path)
163
+ feature_annotation = _annotation_from_raw(
164
+ pending_metadata,
165
+ scope="feature",
166
+ feature_file=feature_path,
167
+ line=index + 1,
168
+ feature_name=feature_name,
169
+ default_title=feature_name,
170
+ default_id=feature_id,
171
+ )
172
+ pending_metadata = {}
173
+ index += 1
174
+ continue
175
+
176
+ stripped = line.strip()
177
+ if stripped.startswith("Scenario:"):
178
+ scenario_name = stripped.split(":", 1)[1].strip()
179
+ scenario_index += 1
180
+ feature_id = (
181
+ feature_annotation.policy_id
182
+ if feature_annotation
183
+ else _auto_feature_id(feature_path)
184
+ )
185
+ scenarios.append(
186
+ _annotation_from_raw(
187
+ pending_metadata,
188
+ scope="scenario",
189
+ feature_file=feature_path,
190
+ line=index + 1,
191
+ feature_name=feature_name,
192
+ scenario_name=scenario_name,
193
+ default_title=scenario_name,
194
+ default_id=_auto_scenario_id(feature_id, scenario_index),
195
+ )
196
+ )
197
+ pending_metadata = {}
198
+ index += 1
199
+ continue
200
+
201
+ index += 1
202
+
203
+ if feature_annotation is None:
204
+ feature_id = _auto_feature_id(feature_path)
205
+ feature_annotation = PolicyAnnotation(
206
+ policy_id=feature_id,
207
+ title=feature_name,
208
+ scope="feature",
209
+ feature_file=feature_path,
210
+ line=1,
211
+ feature_name=feature_name,
212
+ )
213
+
214
+ return FeaturePolicies(
215
+ feature_file=feature_path,
216
+ feature_name=feature_name,
217
+ annotation=feature_annotation,
218
+ scenarios=scenarios,
219
+ )
220
+
221
+
222
+ def build_policy_catalog(features_dir: str) -> PolicyCatalog:
223
+ catalog = PolicyCatalog()
224
+ if not os.path.isdir(features_dir):
225
+ raise FileNotFoundError(f"Features directory not found: {features_dir}")
226
+
227
+ feature_files: list[str] = []
228
+ for root, _dirs, files in os.walk(features_dir):
229
+ for filename in sorted(files):
230
+ if filename.endswith(".feature"):
231
+ feature_files.append(os.path.join(root, filename))
232
+
233
+ for feature_file in feature_files:
234
+ catalog.features.append(parse_feature_policies(feature_file))
235
+
236
+ return catalog
237
+
238
+
239
+ def collect_policy_index(catalog: PolicyCatalog) -> list[PolicyAnnotation]:
240
+ policies: list[PolicyAnnotation] = []
241
+ for feature in catalog.features:
242
+ if feature.annotation:
243
+ policies.append(feature.annotation)
244
+ policies.extend(feature.scenarios)
245
+ return policies