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.
- cascadeguard-0.1.0/PKG-INFO +169 -0
- cascadeguard-0.1.0/README.md +141 -0
- cascadeguard-0.1.0/actions_policy.py +396 -0
- cascadeguard-0.1.0/app.py +2234 -0
- cascadeguard-0.1.0/cascadeguard.egg-info/PKG-INFO +169 -0
- cascadeguard-0.1.0/cascadeguard.egg-info/SOURCES.txt +23 -0
- cascadeguard-0.1.0/cascadeguard.egg-info/dependency_links.txt +1 -0
- cascadeguard-0.1.0/cascadeguard.egg-info/entry_points.txt +3 -0
- cascadeguard-0.1.0/cascadeguard.egg-info/requires.txt +5 -0
- cascadeguard-0.1.0/cascadeguard.egg-info/top_level.txt +4 -0
- cascadeguard-0.1.0/generate_state.py +879 -0
- cascadeguard-0.1.0/pyproject.toml +50 -0
- cascadeguard-0.1.0/scan/__init__.py +12 -0
- cascadeguard-0.1.0/scan/discoverers.py +553 -0
- cascadeguard-0.1.0/scan/engine.py +152 -0
- cascadeguard-0.1.0/scan/models.py +114 -0
- cascadeguard-0.1.0/scan/report.py +484 -0
- cascadeguard-0.1.0/setup.cfg +4 -0
- cascadeguard-0.1.0/tests/test_actions_policy.py +436 -0
- cascadeguard-0.1.0/tests/test_app.py +916 -0
- cascadeguard-0.1.0/tests/test_cascadeguard.py +882 -0
- cascadeguard-0.1.0/tests/test_discoverers.py +501 -0
- cascadeguard-0.1.0/tests/test_report_analysis.py +405 -0
- cascadeguard-0.1.0/tests/test_scan.py +171 -0
- cascadeguard-0.1.0/tests/test_scan_issues_api.py +416 -0
|
@@ -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
|