actionscope 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.
Files changed (35) hide show
  1. actionscope-0.1.0/.gitignore +12 -0
  2. actionscope-0.1.0/CHANGELOG.md +23 -0
  3. actionscope-0.1.0/CONTRIBUTING.md +34 -0
  4. actionscope-0.1.0/LICENSE +21 -0
  5. actionscope-0.1.0/PKG-INFO +132 -0
  6. actionscope-0.1.0/README.md +89 -0
  7. actionscope-0.1.0/action.yml +109 -0
  8. actionscope-0.1.0/actionscope/__init__.py +6 -0
  9. actionscope-0.1.0/actionscope/analyzers/__init__.py +3 -0
  10. actionscope-0.1.0/actionscope/analyzers/github_token.py +201 -0
  11. actionscope-0.1.0/actionscope/analyzers/iam_risk.py +367 -0
  12. actionscope-0.1.0/actionscope/analyzers/privesc_detector.py +240 -0
  13. actionscope-0.1.0/actionscope/analyzers/risk_engine.py +217 -0
  14. actionscope-0.1.0/actionscope/cli.py +245 -0
  15. actionscope-0.1.0/actionscope/models.py +193 -0
  16. actionscope-0.1.0/actionscope/parsers/__init__.py +3 -0
  17. actionscope-0.1.0/actionscope/parsers/policy_json.py +260 -0
  18. actionscope-0.1.0/actionscope/parsers/terraform.py +386 -0
  19. actionscope-0.1.0/actionscope/parsers/workflow.py +187 -0
  20. actionscope-0.1.0/actionscope/reporters/__init__.py +3 -0
  21. actionscope-0.1.0/actionscope/reporters/json_reporter.py +142 -0
  22. actionscope-0.1.0/actionscope/reporters/markdown.py +244 -0
  23. actionscope-0.1.0/actionscope/reporters/terminal.py +394 -0
  24. actionscope-0.1.0/actionscope/verifiers/__init__.py +3 -0
  25. actionscope-0.1.0/actionscope/verifiers/aws_verifier.py +363 -0
  26. actionscope-0.1.0/docs/aws-verify-permissions.md +30 -0
  27. actionscope-0.1.0/pyproject.toml +96 -0
  28. actionscope-0.1.0/research/FINDINGS.md +132 -0
  29. actionscope-0.1.0/research/README.md +46 -0
  30. actionscope-0.1.0/research/generate_report.py +209 -0
  31. actionscope-0.1.0/research/launch_posts.md +161 -0
  32. actionscope-0.1.0/research/methodology.md +63 -0
  33. actionscope-0.1.0/research/scan_public_repos.py +474 -0
  34. actionscope-0.1.0/scripts/bump_version.py +35 -0
  35. actionscope-0.1.0/scripts/pre_release_check.py +65 -0
@@ -0,0 +1,12 @@
1
+ .DS_Store
2
+ .idea/
3
+ .venv/
4
+ .ruff_cache/
5
+ .pytest_cache/
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ build/
10
+ dist/
11
+ .coverage
12
+ htmlcov/
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to ActionScope are documented here.
4
+
5
+ ## [0.1.0] - 2026-05-16
6
+
7
+ ### Added
8
+ - GitHub Actions workflow parser: detects AWS credential configurations
9
+ - IAM policy risk classifier using policy-sentry action database
10
+ - Terraform HCL parser for IAM resource definitions
11
+ - JSON IAM policy file parser
12
+ - GITHUB_TOKEN permission analyzer
13
+ - Privilege escalation path detector (8 common paths)
14
+ - Terminal reporter with Rich color output
15
+ - JSON output format for CI integration
16
+ - Markdown output format for GitHub PR comments
17
+ - GitHub Action integration (actionscope@v0)
18
+ - `--aws-verify` flag for live AWS IAM API verification
19
+
20
+ ### Security
21
+ - All analysis is read-only and deterministic
22
+ - No data is sent to external services
23
+ - AWS verification uses minimum required IAM permissions
@@ -0,0 +1,34 @@
1
+ # Contributing to ActionScope
2
+
3
+ ## Adding a New IAM Risk Rule
4
+
5
+ The easiest contribution is adding IAM action risk classifications.
6
+ See `actionscope/analyzers/iam_risk.py` — the `ALWAYS_CRITICAL` set.
7
+
8
+ To add a new always-critical action:
9
+
10
+ 1. Add it to the `ALWAYS_CRITICAL` set in iam_risk.py
11
+ 2. Add a test in tests/test_iam_risk.py
12
+ 3. Open a PR
13
+
14
+ ## Adding a New Workflow Parser Pattern
15
+
16
+ ActionScope currently detects `aws-actions/configure-aws-credentials`.
17
+ To add support for another credential provider (e.g. Google Cloud):
18
+
19
+ 1. Add a parser function in `actionscope/parsers/workflow.py`
20
+ 2. Add a new CredentialSource type in models.py if needed
21
+ 3. Add test fixtures in tests/fixtures/workflows/
22
+ 4. Open a PR
23
+
24
+ ## Running Tests
25
+
26
+ ```bash
27
+ pip install -e ".[dev]"
28
+ pytest tests/ -v
29
+ ```
30
+
31
+ ## Self-Scan
32
+
33
+ ActionScope scans itself in CI. This repo intentionally uses minimal
34
+ permissions (contents: read only) to demonstrate good practice.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rishabh Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: actionscope
3
+ Version: 0.1.0
4
+ Summary: Map the AWS blast radius of GitHub Actions workflows
5
+ Project-URL: Homepage, https://github.com/r12habh/ActionScope
6
+ Project-URL: Documentation, https://github.com/r12habh/ActionScope#readme
7
+ Project-URL: Repository, https://github.com/r12habh/ActionScope
8
+ Project-URL: Issues, https://github.com/r12habh/ActionScope/issues
9
+ Author-email: Rishabh Singh <rishabhsinghe@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: aws,blast-radius,cicd,devops,devsecops,github-actions,iam,security
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Build Tools
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: click>=8.0
25
+ Requires-Dist: policy-sentry>=0.12.0
26
+ Requires-Dist: python-hcl2>=4.0
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: rich>=13.0
29
+ Provides-Extra: aws
30
+ Requires-Dist: boto3>=1.26; extra == 'aws'
31
+ Provides-Extra: dev
32
+ Requires-Dist: build>=1.0; extra == 'dev'
33
+ Requires-Dist: hatchling>=1.25; extra == 'dev'
34
+ Requires-Dist: moto[iam]>=5.0; extra == 'dev'
35
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
36
+ Requires-Dist: pytest>=7.0; extra == 'dev'
37
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
38
+ Requires-Dist: twine>=5.0; extra == 'dev'
39
+ Provides-Extra: research
40
+ Requires-Dist: requests>=2.31; extra == 'research'
41
+ Requires-Dist: tqdm>=4.66; extra == 'research'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # ActionScope
45
+
46
+ > Map the AWS blast radius of your GitHub Actions workflows.
47
+
48
+ [![PyPI](https://img.shields.io/pypi/v/actionscope)](https://pypi.org/project/actionscope/)
49
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
50
+
51
+ ActionScope reads your `.github/workflows/` files, Terraform IAM resources,
52
+ and inline JSON IAM policies, then tells you — in plain English — what your
53
+ CI/CD pipelines can actually do to your AWS environment.
54
+
55
+ **It answers the question no other tool answers:**
56
+ "If this workflow is compromised, what can an attacker do in AWS?"
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install actionscope
62
+ ```
63
+
64
+ ## Quick Start
65
+
66
+ ```bash
67
+ actionscope scan .
68
+ ```
69
+
70
+ ## Example Output
71
+
72
+ ```
73
+ ActionScope — Blast Radius Report
74
+ Path: /my-repo | Workflows: 2 | Overall Risk: 🔴 CRITICAL
75
+
76
+ deploy.yml → deploy → Configure AWS credentials
77
+ AWS Role: arn:aws:iam::123456789012:role/github-deploy-role
78
+ Auth: OIDC ✓
79
+
80
+ ┌─────────────────────────────┬────────────────────┬──────────┐
81
+ │ Action │ Access Level │ Risk │
82
+ ├─────────────────────────────┼────────────────────┼──────────┤
83
+ │ iam:PassRole │ Permissions mgmt │ 🔴 CRIT │
84
+ │ ec2:TerminateInstances │ Write │ 🟠 HIGH │
85
+ │ s3:GetObject │ Read │ 🟢 LOW │
86
+ └─────────────────────────────┴────────────────────┴──────────┘
87
+
88
+ ⚠️ iam:PassRole on * — privilege escalation path exists
89
+ ```
90
+
91
+ ## Use as a GitHub Action
92
+
93
+ ```yaml
94
+ - uses: r12habh/ActionScope@v0
95
+ with:
96
+ fail-on: high
97
+ comment-pr: true
98
+ ```
99
+
100
+ ## How It Works
101
+
102
+ ActionScope performs **static analysis only** — it never sends your code to
103
+ any external service.
104
+
105
+ 1. Finds all `.github/workflows/*.yml` files
106
+ 2. Extracts AWS role ARNs and GITHUB_TOKEN permission declarations
107
+ 3. Finds matching IAM policies in Terraform or JSON files in your repo
108
+ 4. Classifies each IAM action by risk using the
109
+ [policy-sentry](https://github.com/salesforce/policy_sentry) database
110
+ 5. Outputs a plain-English blast radius report
111
+
112
+ ### What If My Policies Aren't in the Repo?
113
+
114
+ ```
115
+ ℹ️ Policy not found in repo for role: arn:aws:iam::123456:role/ci-deploy
116
+ 💡 Run with --aws-verify to fetch live policies from AWS (coming in v1.0)
117
+ ```
118
+
119
+ In v1.0, `--aws-verify` will use read-only AWS API calls to fetch the real
120
+ attached policies for any role ARN found in your workflows.
121
+
122
+ ## Public Research
123
+
124
+ ActionScope includes a reproducible public-data research scaffold for analyzing
125
+ workflow-level AWS security patterns across public GitHub repositories. See
126
+ [`research/`](research/) for the scanner, methodology, and anonymized findings
127
+ template.
128
+
129
+ ## Built By
130
+
131
+ Rishabh Singh — AWS Security Engineer.
132
+ [GitHub](https://github.com/r12habh)
@@ -0,0 +1,89 @@
1
+ # ActionScope
2
+
3
+ > Map the AWS blast radius of your GitHub Actions workflows.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/actionscope)](https://pypi.org/project/actionscope/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
8
+ ActionScope reads your `.github/workflows/` files, Terraform IAM resources,
9
+ and inline JSON IAM policies, then tells you — in plain English — what your
10
+ CI/CD pipelines can actually do to your AWS environment.
11
+
12
+ **It answers the question no other tool answers:**
13
+ "If this workflow is compromised, what can an attacker do in AWS?"
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install actionscope
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```bash
24
+ actionscope scan .
25
+ ```
26
+
27
+ ## Example Output
28
+
29
+ ```
30
+ ActionScope — Blast Radius Report
31
+ Path: /my-repo | Workflows: 2 | Overall Risk: 🔴 CRITICAL
32
+
33
+ deploy.yml → deploy → Configure AWS credentials
34
+ AWS Role: arn:aws:iam::123456789012:role/github-deploy-role
35
+ Auth: OIDC ✓
36
+
37
+ ┌─────────────────────────────┬────────────────────┬──────────┐
38
+ │ Action │ Access Level │ Risk │
39
+ ├─────────────────────────────┼────────────────────┼──────────┤
40
+ │ iam:PassRole │ Permissions mgmt │ 🔴 CRIT │
41
+ │ ec2:TerminateInstances │ Write │ 🟠 HIGH │
42
+ │ s3:GetObject │ Read │ 🟢 LOW │
43
+ └─────────────────────────────┴────────────────────┴──────────┘
44
+
45
+ ⚠️ iam:PassRole on * — privilege escalation path exists
46
+ ```
47
+
48
+ ## Use as a GitHub Action
49
+
50
+ ```yaml
51
+ - uses: r12habh/ActionScope@v0
52
+ with:
53
+ fail-on: high
54
+ comment-pr: true
55
+ ```
56
+
57
+ ## How It Works
58
+
59
+ ActionScope performs **static analysis only** — it never sends your code to
60
+ any external service.
61
+
62
+ 1. Finds all `.github/workflows/*.yml` files
63
+ 2. Extracts AWS role ARNs and GITHUB_TOKEN permission declarations
64
+ 3. Finds matching IAM policies in Terraform or JSON files in your repo
65
+ 4. Classifies each IAM action by risk using the
66
+ [policy-sentry](https://github.com/salesforce/policy_sentry) database
67
+ 5. Outputs a plain-English blast radius report
68
+
69
+ ### What If My Policies Aren't in the Repo?
70
+
71
+ ```
72
+ ℹ️ Policy not found in repo for role: arn:aws:iam::123456:role/ci-deploy
73
+ 💡 Run with --aws-verify to fetch live policies from AWS (coming in v1.0)
74
+ ```
75
+
76
+ In v1.0, `--aws-verify` will use read-only AWS API calls to fetch the real
77
+ attached policies for any role ARN found in your workflows.
78
+
79
+ ## Public Research
80
+
81
+ ActionScope includes a reproducible public-data research scaffold for analyzing
82
+ workflow-level AWS security patterns across public GitHub repositories. See
83
+ [`research/`](research/) for the scanner, methodology, and anonymized findings
84
+ template.
85
+
86
+ ## Built By
87
+
88
+ Rishabh Singh — AWS Security Engineer.
89
+ [GitHub](https://github.com/r12habh)
@@ -0,0 +1,109 @@
1
+ name: 'ActionScope'
2
+ description: 'Map the AWS blast radius of GitHub Actions workflows and AI agent configs'
3
+ author: 'Rishabh Singh'
4
+
5
+ branding:
6
+ icon: 'shield'
7
+ color: 'orange'
8
+
9
+ inputs:
10
+ path:
11
+ description: 'Path to the repository root to scan'
12
+ required: false
13
+ default: '.'
14
+ fail-on:
15
+ description: 'Fail the action if overall risk is at or above this level (critical, high, medium, low)'
16
+ required: false
17
+ default: 'high'
18
+ output-format:
19
+ description: 'Output format: terminal, json, or markdown'
20
+ required: false
21
+ default: 'terminal'
22
+ comment-pr:
23
+ description: 'Post findings as a PR comment (requires pull-requests: write permission)'
24
+ required: false
25
+ default: 'false'
26
+ github-token:
27
+ description: 'GitHub token for posting PR comments'
28
+ required: false
29
+ default: '${{ github.token }}'
30
+ version:
31
+ description: 'ActionScope version to install (default: latest)'
32
+ required: false
33
+ default: ''
34
+
35
+ outputs:
36
+ overall-risk:
37
+ description: 'The overall risk level found (critical, high, medium, low, info)'
38
+ value: ${{ steps.scan.outputs.overall-risk }}
39
+ findings-json:
40
+ description: 'JSON string of all findings'
41
+ value: ${{ steps.scan.outputs.findings-json }}
42
+ credential-sources-count:
43
+ description: 'Number of AWS credential sources found'
44
+ value: ${{ steps.scan.outputs.credential-sources-count }}
45
+
46
+ runs:
47
+ using: 'composite'
48
+ steps:
49
+ - name: Set up Python
50
+ uses: actions/setup-python@v6
51
+ with:
52
+ python-version: '3.11'
53
+
54
+ - name: Install ActionScope
55
+ shell: bash
56
+ run: |
57
+ if [ -n "${{ inputs.version }}" ]; then
58
+ pip install "actionscope==${{ inputs.version }}"
59
+ else
60
+ pip install actionscope
61
+ fi
62
+
63
+ - name: Run ActionScope
64
+ id: scan
65
+ shell: bash
66
+ run: |
67
+ actionscope scan ${{ inputs.path }} \
68
+ --output-format json \
69
+ --output-file /tmp/actionscope-results.json || true
70
+
71
+ if [ -f /tmp/actionscope-results.json ]; then
72
+ RISK=$(python3 -c "
73
+ import json
74
+ d = json.load(open('/tmp/actionscope-results.json'))
75
+ print(d.get('overall_risk', 'info'))
76
+ ")
77
+ COUNT=$(python3 -c "
78
+ import json
79
+ d = json.load(open('/tmp/actionscope-results.json'))
80
+ print(d.get('summary', {}).get('credential_sources', 0))
81
+ ")
82
+ echo "overall-risk=$RISK" >> "$GITHUB_OUTPUT"
83
+ {
84
+ echo 'findings-json<<JSON_EOF'
85
+ cat /tmp/actionscope-results.json
86
+ echo JSON_EOF
87
+ } >> "$GITHUB_OUTPUT"
88
+ echo "credential-sources-count=$COUNT" >> "$GITHUB_OUTPUT"
89
+ fi
90
+
91
+
92
+ - name: Post PR comment
93
+ if: inputs.comment-pr == 'true' && github.event_name == 'pull_request'
94
+ shell: bash
95
+ env:
96
+ GH_TOKEN: ${{ inputs.github-token }}
97
+ run: |
98
+ MARKDOWN=$(actionscope scan ${{ inputs.path }} --output-format markdown 2>/dev/null || echo "ActionScope scan failed")
99
+ gh pr comment ${{ github.event.pull_request.number }} \
100
+ --body "$MARKDOWN" \
101
+ --edit-last 2>/dev/null || \
102
+ gh pr comment ${{ github.event.pull_request.number }} \
103
+ --body "$MARKDOWN"
104
+
105
+ - name: Fail on risk level
106
+ if: inputs.fail-on != ''
107
+ shell: bash
108
+ run: |
109
+ actionscope scan ${{ inputs.path }} --fail-on ${{ inputs.fail-on }}
@@ -0,0 +1,6 @@
1
+ """Top-level package for ActionScope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ version = "0.1.0"
6
+ __version__ = version
@@ -0,0 +1,3 @@
1
+ """Analyzers that classify IAM and GitHub token blast radius."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,201 @@
1
+ """GITHUB_TOKEN permission analyzer for workflow-level and job-level scopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from actionscope.models import GitHubTokenPermission, RiskLevel
8
+
9
+ KNOWN_PERMISSION_SCOPES = (
10
+ "actions",
11
+ "checks",
12
+ "contents",
13
+ "deployments",
14
+ "discussions",
15
+ "id-token",
16
+ "issues",
17
+ "packages",
18
+ "pages",
19
+ "pull-requests",
20
+ "repository-projects",
21
+ "security-events",
22
+ "statuses",
23
+ )
24
+
25
+ HIGH_WRITE_SCOPES = {"pull-requests", "packages", "id-token"}
26
+ MEDIUM_WRITE_SCOPES = {"contents", "actions", "deployments"}
27
+
28
+
29
+ def analyze_workflow_permissions(
30
+ workflow_data: dict,
31
+ workflow_file: str,
32
+ ) -> list[GitHubTokenPermission]:
33
+ """Return GITHUB_TOKEN permission findings from parsed workflow YAML."""
34
+ if not isinstance(workflow_data, dict):
35
+ return []
36
+
37
+ findings: list[GitHubTokenPermission] = []
38
+
39
+ if "permissions" in workflow_data and workflow_data["permissions"] is not None:
40
+ findings.extend(
41
+ _permissions_to_findings(
42
+ permissions=workflow_data["permissions"],
43
+ workflow_file=workflow_file,
44
+ job_name="",
45
+ oidc_expected=_workflow_uses_role_to_assume(workflow_data),
46
+ )
47
+ )
48
+
49
+ jobs = workflow_data.get("jobs")
50
+ if jobs is None:
51
+ jobs = {}
52
+ if isinstance(jobs, dict):
53
+ for job_name, job_data in jobs.items():
54
+ if not isinstance(job_data, dict):
55
+ continue
56
+ if "permissions" not in job_data or job_data["permissions"] is None:
57
+ continue
58
+ findings.extend(
59
+ _permissions_to_findings(
60
+ permissions=job_data["permissions"],
61
+ workflow_file=workflow_file,
62
+ job_name=str(job_name),
63
+ oidc_expected=_job_uses_role_to_assume(job_data),
64
+ )
65
+ )
66
+
67
+ return findings
68
+
69
+
70
+ def get_dangerous_token_permissions(
71
+ perms: list[GitHubTokenPermission],
72
+ ) -> list[GitHubTokenPermission]:
73
+ """Return only permissions with risk MEDIUM or higher."""
74
+ return [
75
+ permission
76
+ for permission in perms
77
+ if permission.risk_level >= RiskLevel.MEDIUM
78
+ ]
79
+
80
+
81
+ def summarize_token_risk(perms: list[GitHubTokenPermission]) -> dict:
82
+ """Summarize notable GITHUB_TOKEN permission risks."""
83
+ write_permissions = {
84
+ permission.scope
85
+ for permission in perms
86
+ if permission.access.lower() == "write"
87
+ }
88
+
89
+ return {
90
+ "has_write_all": all(
91
+ scope in write_permissions for scope in KNOWN_PERMISSION_SCOPES
92
+ ),
93
+ "has_code_write": "contents" in write_permissions,
94
+ "has_workflow_write": "actions" in write_permissions,
95
+ "has_pr_write": "pull-requests" in write_permissions,
96
+ "has_package_write": "packages" in write_permissions,
97
+ "overall_risk": max(
98
+ (permission.risk_level for permission in perms),
99
+ default=RiskLevel.INFO,
100
+ ),
101
+ }
102
+
103
+
104
+ def _permissions_to_findings(
105
+ permissions: Any,
106
+ workflow_file: str,
107
+ job_name: str,
108
+ oidc_expected: bool,
109
+ ) -> list[GitHubTokenPermission]:
110
+ if permissions is None or permissions == {}:
111
+ return []
112
+
113
+ if isinstance(permissions, str):
114
+ access = permissions.strip().lower()
115
+ if access == "write-all":
116
+ return [
117
+ GitHubTokenPermission(
118
+ workflow_file=workflow_file,
119
+ job_name=job_name,
120
+ scope=scope,
121
+ access="write",
122
+ risk_level=RiskLevel.HIGH,
123
+ )
124
+ for scope in KNOWN_PERMISSION_SCOPES
125
+ ]
126
+ if access == "read-all":
127
+ return [
128
+ GitHubTokenPermission(
129
+ workflow_file=workflow_file,
130
+ job_name=job_name,
131
+ scope=scope,
132
+ access="read",
133
+ risk_level=RiskLevel.LOW,
134
+ )
135
+ for scope in KNOWN_PERMISSION_SCOPES
136
+ ]
137
+ return []
138
+
139
+ if not isinstance(permissions, dict):
140
+ return []
141
+
142
+ findings: list[GitHubTokenPermission] = []
143
+ for scope, access in permissions.items():
144
+ normalized_scope = str(scope).strip().lower()
145
+ normalized_access = str(access).strip().lower()
146
+ findings.append(
147
+ GitHubTokenPermission(
148
+ workflow_file=workflow_file,
149
+ job_name=job_name,
150
+ scope=normalized_scope,
151
+ access=normalized_access,
152
+ risk_level=_classify_permission(
153
+ normalized_scope,
154
+ normalized_access,
155
+ oidc_expected,
156
+ ),
157
+ )
158
+ )
159
+ return findings
160
+
161
+
162
+ def _classify_permission(scope: str, access: str, oidc_expected: bool) -> RiskLevel:
163
+ if access != "write":
164
+ return RiskLevel.LOW
165
+
166
+ if scope == "id-token" and oidc_expected:
167
+ return RiskLevel.INFO
168
+
169
+ if scope in HIGH_WRITE_SCOPES:
170
+ return RiskLevel.HIGH
171
+
172
+ if scope in MEDIUM_WRITE_SCOPES:
173
+ return RiskLevel.MEDIUM
174
+
175
+ return RiskLevel.LOW
176
+
177
+
178
+ def _workflow_uses_role_to_assume(workflow_data: dict) -> bool:
179
+ jobs = workflow_data.get("jobs")
180
+ if jobs is None:
181
+ jobs = {}
182
+ if not isinstance(jobs, dict):
183
+ return False
184
+ return any(
185
+ isinstance(job_data, dict) and _job_uses_role_to_assume(job_data)
186
+ for job_data in jobs.values()
187
+ )
188
+
189
+
190
+ def _job_uses_role_to_assume(job_data: dict) -> bool:
191
+ steps = job_data.get("steps", [])
192
+ if not isinstance(steps, list):
193
+ return False
194
+
195
+ for step in steps:
196
+ if not isinstance(step, dict):
197
+ continue
198
+ step_with = step.get("with", {})
199
+ if isinstance(step_with, dict) and step_with.get("role-to-assume"):
200
+ return True
201
+ return False