cascadeguard 0.1.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.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: cascadeguard
3
+ Version: 0.1.0
4
+ Summary: Guardian of the container cascade — event-driven image lifecycle management
5
+ Author-email: CascadeGuard <hello@cascadeguard.com>
6
+ License-Expression: BUSL-1.1
7
+ Project-URL: Homepage, https://cascadeguard.com
8
+ Project-URL: Documentation, https://docs.cascadeguard.com
9
+ Project-URL: Repository, https://github.com/cascadeguard/cascadeguard
10
+ Project-URL: Issues, https://github.com/cascadeguard/cascadeguard/issues
11
+ Keywords: containers,docker,image-lifecycle,kargo,argocd,gitops,security
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Topic :: Software Development :: Build Tools
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: rich>=13.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+
29
+ # CascadeGuard
30
+
31
+ Guardian of the container cascade. Event-driven image & tooling lifecycle management that integrates with your build & deployment tooling to eliminate vulnerability to supply chain attacks.
32
+
33
+ ## Quick Start
34
+
35
+ ```bash
36
+ # Install (macOS / Linux)
37
+ curl -sSL https://raw.githubusercontent.com/cascadeguard/cascadeguard/main/install.sh | sh
38
+
39
+ # Windows (PowerShell)
40
+ irm https://raw.githubusercontent.com/cascadeguard/cascadeguard/main/install.ps1 | iex
41
+ ```
42
+
43
+ Then in your state repository:
44
+
45
+ ```bash
46
+ cg images init # Scaffold from seed repo (includes workflows)
47
+ cg images validate # Validate images.yaml
48
+ cg images check # Discover base images, check drift and upstream tags
49
+ ```
50
+
51
+ Requires Python 3.11+. Both `cg` and `cascadeguard` are installed as aliases.
52
+
53
+
54
+ ## How It Works
55
+
56
+ 1. **Image Enrollment**: Images are defined in `images.yaml` in a state repository
57
+ 2. **State Files**: Detailed configuration for each image in `base-images/` and `images/` directories
58
+ 3. **Kubernetes Manifest Generation**: Uses CDK8s under the hood to generate manifests for popular kubernetes based tools like Kargo, ArgoCD, etc
59
+
60
+ ## Using CascadeGuard
61
+
62
+ ### 1. Install
63
+
64
+ ```bash
65
+ # macOS / Linux
66
+ curl -sSL https://raw.githubusercontent.com/cascadeguard/cascadeguard/main/install.sh | sh
67
+ ```
68
+
69
+ ### 2. Set up your state repository
70
+
71
+ Scaffold a new state repository from the seed repo:
72
+
73
+ ```bash
74
+ mkdir my-images && cd my-images && git init
75
+ cg images init
76
+ ```
77
+
78
+ Or create an `images.yaml` and `.cascadeguard.yaml` manually:
79
+
80
+ ```yaml
81
+ # .cascadeguard.yaml
82
+ defaults:
83
+ registry: ghcr.io/myorg
84
+ local:
85
+ dir: images # folder containing per-image Dockerfiles
86
+
87
+ ci:
88
+ platform: github
89
+ ```
90
+
91
+ ```yaml
92
+ # images.yaml — managed images inherit registry from .cascadeguard.yaml
93
+ - name: nginx
94
+ dockerfile: images/nginx/Dockerfile
95
+ image: nginx
96
+ tag: stable-alpine-slim
97
+
98
+ # Upstream-tracked images (CVE monitoring only, no build)
99
+ - name: memcached
100
+ enabled: false
101
+ namespace: library
102
+ ```
103
+
104
+ ### 3. Validate, generate, and build
105
+
106
+ ```bash
107
+ # Validate images.yaml (applies config defaults before checking)
108
+ cg images validate
109
+
110
+ # Enrol a new image
111
+ cg images enrol --name myapp --registry ghcr.io --repository org/myapp
112
+
113
+ # Check for base image drift and new upstream tags
114
+ cg images check
115
+ cg images check --format json # JSON output for CI consumption
116
+ cg images check --image myapp # Scope to a single image
117
+
118
+ # Generate CI/CD pipeline files (GitHub Actions)
119
+ cg build generate
120
+
121
+ # Generate CI with explicit platform or dry-run
122
+ cg build generate --platform github --dry-run
123
+ ```
124
+
125
+ See [cascadeguard-exemplar](https://github.com/cascadeguard/cascadeguard-exemplar) for a complete working example.
126
+
127
+ ### Config Inheritance
128
+
129
+ Common fields can be set once in `.cascadeguard.yaml` under `defaults` instead of repeating them on every image:
130
+
131
+ | Key | Description |
132
+ |-----|-------------|
133
+ | `defaults.registry` | Default container registry (e.g. `ghcr.io/cascadeguard`) |
134
+ | `defaults.repository` | Default repository prefix |
135
+ | `defaults.local.dir` | Default folder containing per-image Dockerfiles |
136
+
137
+ Per-image values in `images.yaml` always override the defaults.
138
+
139
+ ## Generating CI/CD Pipelines
140
+
141
+ `cg build generate` reads `images.yaml` and emits four GitHub Actions workflow files under `.github/workflows/`:
142
+
143
+ | File | Trigger | Purpose |
144
+ |------|---------|---------|
145
+ | `build-image.yaml` | `workflow_call` | Reusable single-image build, scan (Grype + Trivy), SBOM, and Cosign signing |
146
+ | `ci.yaml` | `push` to `main`, `pull_request` | Matrix build of all images; pushes and signs on merge to main |
147
+ | `scheduled-scan.yaml` | Nightly cron + `workflow_dispatch` | Re-scans all published images; opens a GitHub Issue on new CVEs |
148
+ | `release.yaml` | Tag push (`v*`) | Builds, signs, and pushes all images; creates a GitHub Release with changelog |
149
+
150
+ ```bash
151
+ cg build generate
152
+ cg build generate --dry-run # preview without writing
153
+ ```
154
+
155
+ Commit the generated files. Adding a new image to `images.yaml` and re-running `cg build generate` will automatically include it in every pipeline.
156
+
157
+ ## Overview
158
+
159
+ CascadeGuard automates the process of monitoring base images and shared build steps (github action workflows & steps, Gitlab pipelines, etc), discovering Dockerfile dependencies, package vulnerabilities and orchestrating intelligent pinning & container image rebuilds.
160
+
161
+ ## Licensing
162
+
163
+ CascadeGuard is licensed under the [Business Source License 1.1](LICENSE) (BUSL-1.1).
164
+
165
+ You are free to use, copy, modify, and distribute CascadeGuard for non-production purposes. Production use is permitted provided you are not offering CascadeGuard to third parties as a commercial container image lifecycle management service or a managed image rebuild service.
166
+
167
+ Each version of CascadeGuard automatically converts to the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) four years after its first public release.
168
+
169
+ For commercial licensing enquiries, contact [licensing@cascadeguard.com](mailto:licensing@cascadeguard.com).
@@ -0,0 +1,141 @@
1
+ # CascadeGuard
2
+
3
+ Guardian of the container cascade. Event-driven image & tooling lifecycle management that integrates with your build & deployment tooling to eliminate vulnerability to supply chain attacks.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # Install (macOS / Linux)
9
+ curl -sSL https://raw.githubusercontent.com/cascadeguard/cascadeguard/main/install.sh | sh
10
+
11
+ # Windows (PowerShell)
12
+ irm https://raw.githubusercontent.com/cascadeguard/cascadeguard/main/install.ps1 | iex
13
+ ```
14
+
15
+ Then in your state repository:
16
+
17
+ ```bash
18
+ cg images init # Scaffold from seed repo (includes workflows)
19
+ cg images validate # Validate images.yaml
20
+ cg images check # Discover base images, check drift and upstream tags
21
+ ```
22
+
23
+ Requires Python 3.11+. Both `cg` and `cascadeguard` are installed as aliases.
24
+
25
+
26
+ ## How It Works
27
+
28
+ 1. **Image Enrollment**: Images are defined in `images.yaml` in a state repository
29
+ 2. **State Files**: Detailed configuration for each image in `base-images/` and `images/` directories
30
+ 3. **Kubernetes Manifest Generation**: Uses CDK8s under the hood to generate manifests for popular kubernetes based tools like Kargo, ArgoCD, etc
31
+
32
+ ## Using CascadeGuard
33
+
34
+ ### 1. Install
35
+
36
+ ```bash
37
+ # macOS / Linux
38
+ curl -sSL https://raw.githubusercontent.com/cascadeguard/cascadeguard/main/install.sh | sh
39
+ ```
40
+
41
+ ### 2. Set up your state repository
42
+
43
+ Scaffold a new state repository from the seed repo:
44
+
45
+ ```bash
46
+ mkdir my-images && cd my-images && git init
47
+ cg images init
48
+ ```
49
+
50
+ Or create an `images.yaml` and `.cascadeguard.yaml` manually:
51
+
52
+ ```yaml
53
+ # .cascadeguard.yaml
54
+ defaults:
55
+ registry: ghcr.io/myorg
56
+ local:
57
+ dir: images # folder containing per-image Dockerfiles
58
+
59
+ ci:
60
+ platform: github
61
+ ```
62
+
63
+ ```yaml
64
+ # images.yaml — managed images inherit registry from .cascadeguard.yaml
65
+ - name: nginx
66
+ dockerfile: images/nginx/Dockerfile
67
+ image: nginx
68
+ tag: stable-alpine-slim
69
+
70
+ # Upstream-tracked images (CVE monitoring only, no build)
71
+ - name: memcached
72
+ enabled: false
73
+ namespace: library
74
+ ```
75
+
76
+ ### 3. Validate, generate, and build
77
+
78
+ ```bash
79
+ # Validate images.yaml (applies config defaults before checking)
80
+ cg images validate
81
+
82
+ # Enrol a new image
83
+ cg images enrol --name myapp --registry ghcr.io --repository org/myapp
84
+
85
+ # Check for base image drift and new upstream tags
86
+ cg images check
87
+ cg images check --format json # JSON output for CI consumption
88
+ cg images check --image myapp # Scope to a single image
89
+
90
+ # Generate CI/CD pipeline files (GitHub Actions)
91
+ cg build generate
92
+
93
+ # Generate CI with explicit platform or dry-run
94
+ cg build generate --platform github --dry-run
95
+ ```
96
+
97
+ See [cascadeguard-exemplar](https://github.com/cascadeguard/cascadeguard-exemplar) for a complete working example.
98
+
99
+ ### Config Inheritance
100
+
101
+ Common fields can be set once in `.cascadeguard.yaml` under `defaults` instead of repeating them on every image:
102
+
103
+ | Key | Description |
104
+ |-----|-------------|
105
+ | `defaults.registry` | Default container registry (e.g. `ghcr.io/cascadeguard`) |
106
+ | `defaults.repository` | Default repository prefix |
107
+ | `defaults.local.dir` | Default folder containing per-image Dockerfiles |
108
+
109
+ Per-image values in `images.yaml` always override the defaults.
110
+
111
+ ## Generating CI/CD Pipelines
112
+
113
+ `cg build generate` reads `images.yaml` and emits four GitHub Actions workflow files under `.github/workflows/`:
114
+
115
+ | File | Trigger | Purpose |
116
+ |------|---------|---------|
117
+ | `build-image.yaml` | `workflow_call` | Reusable single-image build, scan (Grype + Trivy), SBOM, and Cosign signing |
118
+ | `ci.yaml` | `push` to `main`, `pull_request` | Matrix build of all images; pushes and signs on merge to main |
119
+ | `scheduled-scan.yaml` | Nightly cron + `workflow_dispatch` | Re-scans all published images; opens a GitHub Issue on new CVEs |
120
+ | `release.yaml` | Tag push (`v*`) | Builds, signs, and pushes all images; creates a GitHub Release with changelog |
121
+
122
+ ```bash
123
+ cg build generate
124
+ cg build generate --dry-run # preview without writing
125
+ ```
126
+
127
+ Commit the generated files. Adding a new image to `images.yaml` and re-running `cg build generate` will automatically include it in every pipeline.
128
+
129
+ ## Overview
130
+
131
+ CascadeGuard automates the process of monitoring base images and shared build steps (github action workflows & steps, Gitlab pipelines, etc), discovering Dockerfile dependencies, package vulnerabilities and orchestrating intelligent pinning & container image rebuilds.
132
+
133
+ ## Licensing
134
+
135
+ CascadeGuard is licensed under the [Business Source License 1.1](LICENSE) (BUSL-1.1).
136
+
137
+ You are free to use, copy, modify, and distribute CascadeGuard for non-production purposes. Production use is permitted provided you are not offering CascadeGuard to third parties as a commercial container image lifecycle management service or a managed image rebuild service.
138
+
139
+ Each version of CascadeGuard automatically converts to the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) four years after its first public release.
140
+
141
+ For commercial licensing enquiries, contact [licensing@cascadeguard.com](mailto:licensing@cascadeguard.com).
@@ -0,0 +1,396 @@
1
+ """
2
+ CascadeGuard — GitHub Actions policy enforcement.
3
+
4
+ Provides:
5
+ scan_pinning_status — scan workflows and report pinning status per action
6
+ PolicyAuditor — validates workflow files against an actions-policy.yaml
7
+ load_policy — loads and validates a policy file
8
+ init_policy — writes a starter policy file to disk
9
+
10
+ Policy file schema: .cascadeguard/actions-policy.schema.json
11
+
12
+ Audit logic (precedence order, highest first):
13
+ 1. denied_actions — explicitly blocked; always a violation
14
+ 2. allowed_actions — explicitly permitted; never a violation
15
+ 3. exceptions — one-off overrides with mandatory reason (and optional expiry)
16
+ 4. allowed_owners — all actions from trusted orgs are permitted
17
+ 5. default — 'deny' → violation for anything else; 'allow' → permitted
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import sys
24
+ from dataclasses import dataclass, field
25
+ from datetime import date
26
+ from pathlib import Path
27
+ from typing import Dict, List, Optional
28
+
29
+ import yaml
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Regex — re-uses the same pattern as ActionsPinner in app.py
34
+ # ---------------------------------------------------------------------------
35
+
36
+ _USES_RE = re.compile(
37
+ r'^(\s*(?:-\s+)?uses:\s+)([^@\n\s]+)@([^\s\n#]+)(.*?)$'
38
+ )
39
+
40
+ _SHA_RE = re.compile(r'^[0-9a-f]{40}$')
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Data classes
45
+ # ---------------------------------------------------------------------------
46
+
47
+ @dataclass
48
+ class PolicyViolation:
49
+ workflow_file: str
50
+ line_number: int
51
+ action: str
52
+ ref: str
53
+ reason: str
54
+
55
+ def __str__(self) -> str:
56
+ return (
57
+ f"{self.workflow_file}:{self.line_number} {self.action}@{self.ref}"
58
+ f" — {self.reason}"
59
+ )
60
+
61
+
62
+ @dataclass
63
+ class AuditResult:
64
+ violations: List[PolicyViolation] = field(default_factory=list)
65
+ allowed: int = 0
66
+ skipped: int = 0 # local / unresolvable actions
67
+
68
+ @property
69
+ def passed(self) -> bool:
70
+ return len(self.violations) == 0
71
+
72
+
73
+ @dataclass
74
+ class ActionRef:
75
+ """A single action reference found in a workflow file."""
76
+ workflow_file: str
77
+ line_number: int
78
+ action: str
79
+ ref: str
80
+ status: str # "pinned" | "tag" | "branch" | "local"
81
+
82
+ @property
83
+ def mutable(self) -> bool:
84
+ """True if the ref is mutable (tag or branch — not SHA-pinned)."""
85
+ return self.status in {"tag", "branch"}
86
+
87
+ def as_dict(self) -> dict:
88
+ return {
89
+ "workflow_file": self.workflow_file,
90
+ "line_number": self.line_number,
91
+ "action": self.action,
92
+ "ref": self.ref,
93
+ "status": self.status,
94
+ }
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Pinning-status scan (no policy required)
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def _classify_ref(action: str, ref: str) -> str:
102
+ """
103
+ Classify a ref as 'pinned', 'tag', 'branch', or 'local'.
104
+
105
+ - local: relative path or no owner/repo
106
+ - pinned: 40-char lowercase hex SHA
107
+ - tag: version-like string (starts with 'v' or digit with dots)
108
+ - branch: everything else (main, master, develop, …)
109
+ """
110
+ if action.startswith("./") or "/" not in action:
111
+ return "local"
112
+ if _SHA_RE.match(ref):
113
+ return "pinned"
114
+ # Heuristic: version tags start with 'v' or a digit followed by a dot
115
+ if ref.startswith("v") or (ref and ref[0].isdigit() and "." in ref):
116
+ return "tag"
117
+ return "branch"
118
+
119
+
120
+ def scan_pinning_status(workflows_dir: Path) -> List[ActionRef]:
121
+ """
122
+ Scan all workflow files in *workflows_dir* and return the pinning
123
+ status of every action reference found.
124
+
125
+ Each entry is an ActionRef with status: 'pinned' | 'tag' | 'branch' | 'local'.
126
+ """
127
+ refs: List[ActionRef] = []
128
+ patterns = list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml"))
129
+ for wf_path in sorted(patterns):
130
+ try:
131
+ lines = wf_path.read_text().splitlines()
132
+ except OSError:
133
+ continue
134
+ for lineno, line in enumerate(lines, start=1):
135
+ m = _USES_RE.match(line)
136
+ if not m:
137
+ continue
138
+ _, action, ref, _ = m.groups()
139
+ status = _classify_ref(action, ref)
140
+ refs.append(ActionRef(
141
+ workflow_file=str(wf_path),
142
+ line_number=lineno,
143
+ action=action,
144
+ ref=ref,
145
+ status=status,
146
+ ))
147
+ return refs
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Policy loading
152
+ # ---------------------------------------------------------------------------
153
+
154
+ _REQUIRED_FIELDS = {"version"}
155
+ _VALID_VERSION = "1"
156
+ _VALID_DEFAULTS = {"allow", "deny"}
157
+
158
+
159
+ class PolicyError(ValueError):
160
+ """Raised when a policy file is malformed or fails validation."""
161
+
162
+
163
+ def load_policy(policy_path: Path) -> dict:
164
+ """
165
+ Load and validate an actions-policy.yaml file.
166
+
167
+ Returns the parsed policy dict on success.
168
+ Raises PolicyError on any structural or semantic problem.
169
+ """
170
+ if not policy_path.exists():
171
+ raise PolicyError(f"Policy file not found: {policy_path}")
172
+
173
+ try:
174
+ with open(policy_path) as f:
175
+ policy = yaml.safe_load(f) or {}
176
+ except yaml.YAMLError as exc:
177
+ raise PolicyError(f"YAML parse error in {policy_path}: {exc}") from exc
178
+
179
+ if not isinstance(policy, dict):
180
+ raise PolicyError(f"{policy_path}: expected a YAML mapping, got {type(policy).__name__}")
181
+
182
+ # Required fields
183
+ for field_name in _REQUIRED_FIELDS:
184
+ if field_name not in policy:
185
+ raise PolicyError(f"{policy_path}: missing required field '{field_name}'")
186
+
187
+ if str(policy["version"]) != _VALID_VERSION:
188
+ raise PolicyError(
189
+ f"{policy_path}: unsupported version '{policy['version']}' "
190
+ f"(expected '{_VALID_VERSION}')"
191
+ )
192
+
193
+ default = policy.get("default", "deny")
194
+ if default not in _VALID_DEFAULTS:
195
+ raise PolicyError(
196
+ f"{policy_path}: 'default' must be one of {sorted(_VALID_DEFAULTS)}, got '{default}'"
197
+ )
198
+
199
+ # List fields
200
+ for list_field in ("allowed_owners", "allowed_actions", "denied_actions"):
201
+ val = policy.get(list_field, [])
202
+ if not isinstance(val, list):
203
+ raise PolicyError(f"{policy_path}: '{list_field}' must be a list")
204
+
205
+ # Exceptions
206
+ exceptions = policy.get("exceptions", [])
207
+ if not isinstance(exceptions, list):
208
+ raise PolicyError(f"{policy_path}: 'exceptions' must be a list")
209
+ for i, exc in enumerate(exceptions):
210
+ if not isinstance(exc, dict):
211
+ raise PolicyError(f"{policy_path}: exceptions[{i}] must be a mapping")
212
+ if "action" not in exc:
213
+ raise PolicyError(f"{policy_path}: exceptions[{i}] missing required field 'action'")
214
+ if "reason" not in exc:
215
+ raise PolicyError(f"{policy_path}: exceptions[{i}] missing required field 'reason'")
216
+
217
+ return policy
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Auditor
222
+ # ---------------------------------------------------------------------------
223
+
224
+ class PolicyAuditor:
225
+ """
226
+ Audits GitHub Actions workflow files against an actions-policy.yaml.
227
+
228
+ Usage:
229
+ auditor = PolicyAuditor(policy)
230
+ result = auditor.audit(workflows_dir)
231
+ """
232
+
233
+ def __init__(self, policy: dict):
234
+ self._default: str = policy.get("default", "deny")
235
+ self._allowed_owners: set = set(policy.get("allowed_owners", []))
236
+ self._allowed_actions: set = set(policy.get("allowed_actions", []))
237
+ self._denied_actions: set = set(policy.get("denied_actions", []))
238
+ self._delay_hours: int = int(policy.get("delay_window_hours", 0))
239
+
240
+ # Build exception lookup: action → {reason, expires}
241
+ self._exceptions: Dict[str, dict] = {}
242
+ for exc in policy.get("exceptions", []):
243
+ self._exceptions[exc["action"]] = exc
244
+
245
+ # ------------------------------------------------------------------
246
+ # Public
247
+ # ------------------------------------------------------------------
248
+
249
+ def audit(self, workflows_dir: Path) -> AuditResult:
250
+ """Audit all workflow files in *workflows_dir* and return an AuditResult."""
251
+ result = AuditResult()
252
+ patterns = (
253
+ list(workflows_dir.glob("*.yml"))
254
+ + list(workflows_dir.glob("*.yaml"))
255
+ )
256
+ for wf_path in sorted(patterns):
257
+ self._audit_file(wf_path, result)
258
+ return result
259
+
260
+ def audit_file(self, wf_path: Path) -> AuditResult:
261
+ """Audit a single workflow file and return an AuditResult."""
262
+ result = AuditResult()
263
+ self._audit_file(wf_path, result)
264
+ return result
265
+
266
+ # ------------------------------------------------------------------
267
+ # Internals
268
+ # ------------------------------------------------------------------
269
+
270
+ def _audit_file(self, wf_path: Path, result: AuditResult) -> None:
271
+ try:
272
+ lines = wf_path.read_text().splitlines()
273
+ except OSError:
274
+ return
275
+
276
+ for lineno, line in enumerate(lines, start=1):
277
+ m = _USES_RE.match(line)
278
+ if not m:
279
+ continue
280
+
281
+ _, action, ref, _ = m.groups()
282
+
283
+ # Skip local composite actions (relative paths or no owner/repo)
284
+ if action.startswith("./") or "/" not in action:
285
+ result.skipped += 1
286
+ continue
287
+
288
+ violation_reason = self._evaluate(action)
289
+ if violation_reason is None:
290
+ result.allowed += 1
291
+ else:
292
+ result.violations.append(PolicyViolation(
293
+ workflow_file=str(wf_path),
294
+ line_number=lineno,
295
+ action=action,
296
+ ref=ref,
297
+ reason=violation_reason,
298
+ ))
299
+
300
+ def _evaluate(self, action: str) -> Optional[str]:
301
+ """
302
+ Evaluate *action* against policy rules.
303
+
304
+ Returns None if the action is permitted, or a string reason if it is a violation.
305
+ Precedence: denied_actions > allowed_actions > exceptions > allowed_owners > default.
306
+ """
307
+ owner = action.split("/")[0] if "/" in action else action
308
+
309
+ # 1. Explicitly denied — always a violation, exceptions cannot override this.
310
+ if action in self._denied_actions:
311
+ return f"'{action}' is in denied_actions"
312
+
313
+ # 2. Explicitly allowed.
314
+ if action in self._allowed_actions:
315
+ return None
316
+
317
+ # 3. Exception (with optional expiry check).
318
+ if action in self._exceptions:
319
+ exc = self._exceptions[action]
320
+ expires = exc.get("expires")
321
+ if expires:
322
+ try:
323
+ expiry_date = date.fromisoformat(str(expires))
324
+ if date.today() > expiry_date:
325
+ return (
326
+ f"'{action}' exception expired on {expires} "
327
+ f"(reason: {exc['reason']})"
328
+ )
329
+ except ValueError:
330
+ pass # malformed date — treat exception as valid
331
+ return None # exception is active
332
+
333
+ # 4. Trusted owner.
334
+ if owner in self._allowed_owners:
335
+ return None
336
+
337
+ # 5. Default disposition.
338
+ if self._default == "deny":
339
+ return (
340
+ f"'{action}' is not in allowed_actions or allowed_owners "
341
+ f"and default policy is 'deny'"
342
+ )
343
+ return None # default: allow
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Policy init
348
+ # ---------------------------------------------------------------------------
349
+
350
+ _STARTER_POLICY = """\
351
+ # CascadeGuard Actions Policy
352
+ # Schema: https://cascadeguard.dev/schemas/actions-policy/v1
353
+ #
354
+ # All fields except 'version' are optional.
355
+ # Provide as much or as little context as you need — sensible defaults apply.
356
+
357
+ version: "1"
358
+
359
+ # What to do with actions not matched by any rule below.
360
+ # 'deny' (recommended) enforces an explicit allow-list.
361
+ # 'allow' trusts everything except items in denied_actions.
362
+ default: deny
363
+
364
+ # GitHub organisations whose actions are trusted by default.
365
+ # Any action published under a listed owner is permitted.
366
+ allowed_owners:
367
+ - actions # github.com/actions/*
368
+ - aws-actions # github.com/aws-actions/*
369
+ - docker # github.com/docker/*
370
+
371
+ # Specific actions that are explicitly permitted (owner/repo).
372
+ allowed_actions: []
373
+
374
+ # Specific actions that are always denied, even if the owner is trusted.
375
+ denied_actions: []
376
+
377
+ # One-off exceptions — each must include a 'reason'.
378
+ # Optionally set 'expires' (YYYY-MM-DD) to auto-expire the exception.
379
+ exceptions: []
380
+ # - action: some-org/some-action
381
+ # reason: "Needed for legacy pipeline; migrating in Q3."
382
+ # expires: "2026-09-30"
383
+ """
384
+
385
+
386
+ def init_policy(output_path: Path, force: bool = False) -> bool:
387
+ """
388
+ Write a starter actions-policy.yaml to *output_path*.
389
+
390
+ Returns True if the file was written, False if it already exists (and force=False).
391
+ """
392
+ if output_path.exists() and not force:
393
+ return False
394
+ output_path.parent.mkdir(parents=True, exist_ok=True)
395
+ output_path.write_text(_STARTER_POLICY)
396
+ return True