bitwarden_workflow_linter 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ """A Rule to enforce all 'name' values start with a capital letter."""
2
+
3
+ from typing import Optional, Tuple, Union
4
+
5
+ from ..models.job import Job
6
+ from ..models.step import Step
7
+ from ..models.workflow import Workflow
8
+ from ..rule import Rule
9
+ from ..utils import LintLevels, Settings
10
+
11
+
12
+ class RuleNameCapitalized(Rule):
13
+ """Rule to enforce all 'name' values start with a capital letter.
14
+
15
+ A simple standard to help keep uniformity in naming.
16
+ """
17
+
18
+ def __init__(self, settings: Optional[Settings] = None) -> None:
19
+ """Constructor for RuleNameCapitalized to override the Rule class.
20
+
21
+ Args:
22
+ settings:
23
+ A Settings object that contains any default, overridden, or custom settings
24
+ required anywhere in the application.
25
+ """
26
+ self.message = "name must capitalized"
27
+ self.on_fail = LintLevels.ERROR
28
+ self.settings = settings
29
+
30
+ def fn(self, obj: Union[Workflow, Job, Step]) -> Tuple[bool, str]:
31
+ """Enforces capitalization of the first letter of any name key.
32
+
33
+ Example:
34
+ ---
35
+ name: Test Workflow
36
+
37
+ on:
38
+ workflow_dispatch:
39
+
40
+ jobs:
41
+ job-key:
42
+ name: Test
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - name: Test
46
+ run: echo test
47
+
48
+ 'Test Workflow', 'Test', and 'Test' all start with a capital letter.
49
+
50
+ See tests/rules/test_name_capitalized.py for examples of incorrectly
51
+ capitalized names. This Rule DOES NOT enforce that the name exists.
52
+ It only enforces capitalization IF it does.
53
+ """
54
+ if obj.name:
55
+ return obj.name[0].isupper(), self.message
56
+ return True, "" # Force passing if obj.name doesn't exist
@@ -0,0 +1,59 @@
1
+ """A Rule to enforce that a 'name' key exists."""
2
+
3
+ from typing import Optional, Tuple, Union
4
+
5
+ from ..models.workflow import Workflow
6
+ from ..models.job import Job
7
+ from ..models.step import Step
8
+ from ..rule import Rule
9
+ from ..utils import LintLevels, Settings
10
+
11
+
12
+ class RuleNameExists(Rule):
13
+ """Rule to enforce a 'name' key exists for every object in GitHub Actions.
14
+
15
+ For pipeline run troubleshooting and debugging, it is helpful to have a
16
+ name to immediately identify a Workflow, Job, or Step while moving between
17
+ run and the code.
18
+
19
+ It also helps with uniformity of runs.
20
+ """
21
+
22
+ def __init__(self, settings: Optional[Settings] = None) -> None:
23
+ """Constructor for RuleNameCapitalized to override Rule class.
24
+
25
+ Args:
26
+ settings:
27
+ A Settings object that contains any default, overridden, or custom settings
28
+ required anywhere in the application.
29
+ """
30
+ self.message = "name must exist"
31
+ self.on_fail = LintLevels.ERROR
32
+ self.settings = settings
33
+
34
+ def fn(self, obj: Union[Workflow, Job, Step]) -> Tuple[bool, str]:
35
+ """Enforces the existence of names.
36
+
37
+ Example:
38
+ ---
39
+ name: Test Workflow
40
+
41
+ on:
42
+ workflow_dispatch:
43
+
44
+ jobs:
45
+ job-key:
46
+ name: Test
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - name: Test
50
+ run: echo test
51
+
52
+ 'Test Workflow', 'Test', and 'Test' all exist.
53
+
54
+ See tests/rules/test_name_exists.py for examples where a name does not
55
+ exist.
56
+ """
57
+ if obj.name is not None:
58
+ return True, ""
59
+ return False, self.message
@@ -0,0 +1,52 @@
1
+ """A Rule to enforce pinning runners to a specific OS version."""
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ from ..models.job import Job
6
+ from ..rule import Rule
7
+ from ..utils import LintLevels, Settings
8
+
9
+
10
+ class RuleJobRunnerVersionPinned(Rule):
11
+ """Rule to enforce pinned Runner OS versions.
12
+
13
+ Using `*-latest` versions will update automatically and has broken all of
14
+ our workflows in the past. To avoid this and prevent a single event from
15
+ breaking the majority of our pipelines, we pin the versions.
16
+ """
17
+
18
+ def __init__(self, settings: Optional[Settings] = None) -> None:
19
+ """Constructor for RuleJobRunnerVersionPinned to override Rule class.
20
+
21
+ Args:
22
+ settings:
23
+ A Settings object that contains any default, overridden, or custom settings
24
+ required anywhere in the application.
25
+ """
26
+ self.message = "Workflow runner must be pinned"
27
+ self.on_fail = LintLevels.ERROR
28
+ self.compatibility = [Job]
29
+ self.settings = settings
30
+
31
+ def fn(self, obj: Job) -> Tuple[bool, str]:
32
+ """Enforces runners are pinned to a version
33
+
34
+ Example:
35
+ ---
36
+ on:
37
+ workflow_dispatch:
38
+
39
+ jobs:
40
+ job-key:
41
+ runs-on: ubuntu-22.04
42
+ steps:
43
+ - run: echo test
44
+
45
+ call-workflow:
46
+ uses: bitwarden/server/.github/workflows/workflow-linter.yml@master
47
+
48
+ 'runs-on' is pinned to '22.04' instead of 'latest'
49
+ """
50
+ if obj.runs_on is not None and "latest" in obj.runs_on:
51
+ return False, self.message
52
+ return True, ""
@@ -0,0 +1,101 @@
1
+ """A Rule to enforce the use of a list of pre-approved Actions."""
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ from ..models.step import Step
6
+ from ..rule import Rule
7
+ from ..utils import LintLevels, Settings
8
+
9
+
10
+ class RuleStepUsesApproved(Rule):
11
+ """Rule to enforce that all Actions have been pre-approved.
12
+
13
+ To limit the surface area of a supply chain attack in our pipelines, all Actions
14
+ are required to pass a security review and be added to the pre-approved list to
15
+ check against.
16
+ """
17
+
18
+ def __init__(self, settings: Optional[Settings] = None) -> None:
19
+ """Constructor for RuleStepUsesApproved to override Rule class.
20
+
21
+ Args:
22
+ settings:
23
+ A Settings object that contains any default, overridden, or custom settings
24
+ required anywhere in the application.
25
+ """
26
+ self.on_fail = LintLevels.WARNING
27
+ self.compatibility = [Step]
28
+ self.settings = settings
29
+
30
+ def skip(self, obj: Step) -> bool:
31
+ """Skip this Rule on some Steps.
32
+
33
+ This Rule does not apply to a few types of Steps. These
34
+ Rules are skipped.
35
+ """
36
+ ## Force pass for any shell steps
37
+ if not obj.uses:
38
+ return True
39
+
40
+ ## Force pass for any local actions
41
+ if "@" not in obj.uses:
42
+ return True
43
+
44
+ ## Force pass for any bitwarden/gh-actions
45
+ if obj.uses.startswith("bitwarden/gh-actions"):
46
+ return True
47
+
48
+ return False
49
+
50
+ def fn(self, obj: Step) -> Tuple[bool, str]:
51
+ """Enforces all externally used Actions are on the pre-approved list.
52
+
53
+ The pre-approved list allows tight auditing on what Actions are trusted
54
+ and allowed to be run in our environments. This helps mitigate risks
55
+ against supply chain attacks in our pipelines.
56
+
57
+ Example:
58
+ ---
59
+ on:
60
+ workflow_dispatch:
61
+
62
+ jobs:
63
+ job-key:
64
+ runs-on: ubuntu-22.04
65
+ steps:
66
+ - name: Checkout Branch
67
+ uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
68
+
69
+ - name: Test Bitwarden Action
70
+ uses: bitwarden/gh-actions/get-keyvault-secrets@main
71
+
72
+ - name: Test Local Action
73
+ uses: ./actions/test-action
74
+
75
+ - name: Test Run Action
76
+ run: echo "test"
77
+
78
+ In this example, 'actions/checkout' must be on the pre-approved list
79
+ and the metadata must match in order to succeed. The other three
80
+ Steps will be skipped.
81
+ """
82
+ if self.skip(obj):
83
+ return True, ""
84
+
85
+ # Actions in bitwarden/gh-actions are auto-approved
86
+ if obj.uses and not obj.uses_path in self.settings.approved_actions:
87
+ return False, (
88
+ f"New Action detected: {obj.uses_path}\nFor security purposes, "
89
+ "actions must be reviewed and be on the pre-approved list"
90
+ )
91
+
92
+ action = self.settings.approved_actions[obj.uses_path]
93
+
94
+ if obj.uses_version != action.version or obj.uses_ref != action.sha:
95
+ return False, (
96
+ "Action is out of date. Please update to:\n"
97
+ f" commit: {action.version}"
98
+ f" version: {action.sha}"
99
+ )
100
+
101
+ return True, ""
@@ -0,0 +1,98 @@
1
+ """A Rule to enforce Actions are pinned correctly."""
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ from ..models.step import Step
6
+ from ..rule import Rule
7
+ from ..utils import LintLevels, Settings
8
+
9
+
10
+ class RuleStepUsesPinned(Rule):
11
+ """Rule to contain the enforcement logic for pinning Actions versions.
12
+
13
+ Definition of Internal Action:
14
+ An Action that exists in the `bitwarden/gh-actions` GitHub Repository.
15
+
16
+ For any external Action (any Action that does not fit the above definition of
17
+ an Internal Action), to mitigate the risks of supply chain attacks in our CI
18
+ pipelines, we pin any use of an Action to a specific hash that has been verified
19
+ and pre-approved after a security audit of the version of the Action.
20
+
21
+ All Internal Actions, should be pinned to 'main'. This prevents Renovate from
22
+ spamming a bunch of PRs across all of our repos when `bitwarden/gh-actions` is
23
+ updated.
24
+ """
25
+
26
+ def __init__(self, settings: Optional[Settings] = None) -> None:
27
+ """Constructor for RuleStepUsesPinned to override base Rule.
28
+
29
+ Args:
30
+ settings:
31
+ A Settings object that contains any default, overridden, or custom settings
32
+ required anywhere in the application.
33
+ """
34
+ self.on_fail = LintLevels.ERROR
35
+ self.compatibility = [Step]
36
+ self.settings = settings
37
+
38
+ def skip(self, obj: Step) -> bool:
39
+ """Skip this Rule on some Steps.
40
+
41
+ This Rule does not apply to a few types of Steps. These
42
+ Rules are skipped.
43
+ """
44
+ if not obj.uses:
45
+ return True
46
+
47
+ ## Force pass for any local actions
48
+ if "@" not in obj.uses:
49
+ return True
50
+
51
+ return False
52
+
53
+ def fn(self, obj: Step) -> Tuple[bool, str]:
54
+ """Enforces all Actions to be pinned in a specific way.
55
+
56
+ Pinning external Action hashes prevents unknown updates that could
57
+ break the pipelines or be the entry point to a supply chain attack.
58
+
59
+ Pinning internal Actions to branches allow for less updates as work
60
+ is done on those repos. This is mainly to support our Action
61
+ monorepo architecture of our Actions.
62
+
63
+ Example:
64
+ - name: Checkout Branch
65
+ uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
66
+
67
+ - name: Test Bitwarden Action
68
+ uses: bitwarden/gh-actions/get-keyvault-secrets@main
69
+
70
+ - name: Test Local Action
71
+ uses: ./actions/test-action
72
+
73
+ - name: Test Run Action
74
+ run: echo "test"
75
+
76
+ In this example, 'actions/checkout' must be pinned to the full commit
77
+ of the tag while 'bitwarden/gh-actions/get-keyvault-secrets' must be
78
+ pinned to 'main'. The other two Steps will be skipped.
79
+ """
80
+ if self.skip(obj):
81
+ return True, ""
82
+
83
+ path, ref = obj.uses.split("@")
84
+
85
+ if path.startswith("bitwarden/gh-actions"):
86
+ if ref == "main":
87
+ return True, ""
88
+ return False, "Please pin to main"
89
+
90
+ try:
91
+ int(ref, 16)
92
+ except ValueError:
93
+ return False, "Please pin the action to a commit sha"
94
+
95
+ if len(ref) != 40:
96
+ return False, "Please use the full commit sha to pin the action"
97
+
98
+ return True, ""
@@ -0,0 +1,179 @@
1
+ """Module of a collection of random utilities."""
2
+
3
+ import importlib.resources
4
+ import json
5
+ import os
6
+
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from typing import Optional, Self, TypeVar
10
+
11
+ from ruamel.yaml import YAML
12
+
13
+
14
+ yaml = YAML()
15
+
16
+
17
+ @dataclass
18
+ class Colors:
19
+ """Class containing color codes for printing strings to output."""
20
+
21
+ black = "30m"
22
+ red = "31m"
23
+ green = "32m"
24
+ yellow = "33m"
25
+ blue = "34m"
26
+ magenta = "35m"
27
+ cyan = "36m"
28
+ white = "37m"
29
+
30
+
31
+ @dataclass
32
+ class LintLevel:
33
+ """Class to contain the numeric level and color of linting."""
34
+
35
+ code: int
36
+ color: Colors
37
+
38
+
39
+ class LintLevels(LintLevel, Enum):
40
+ """Collection of the different types of LintLevels available."""
41
+
42
+ NONE = 0, Colors.white
43
+ WARNING = 1, Colors.yellow
44
+ ERROR = 2, Colors.red
45
+
46
+
47
+ class LintFinding:
48
+ """Represents a problem detected by linting."""
49
+
50
+ def __init__(self, description: str, level: LintLevels) -> None:
51
+ self.description = description
52
+ self.level = level
53
+
54
+ def __str__(self) -> str:
55
+ """String representation of the class.
56
+
57
+ Returns:
58
+ String representation of itself.
59
+ """
60
+ return (
61
+ f"\033[{self.level.color}{self.level.name.lower()}\033[0m "
62
+ f"{self.description}"
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class Action:
68
+ """Collection of the metadata associated with a GitHub Action."""
69
+
70
+ name: str
71
+ version: str = ""
72
+ sha: str = ""
73
+
74
+ def __eq__(self, other: Self) -> bool:
75
+ """Override Action equality.
76
+
77
+ Args:
78
+ other:
79
+ Another Action type object to compare
80
+
81
+ Return
82
+ The state of equality
83
+ """
84
+ return (
85
+ self.name == other.name
86
+ and self.version == other.version
87
+ and self.sha == other.sha
88
+ )
89
+
90
+ def __ne__(self, other: Self) -> bool:
91
+ """Override Action unequality.
92
+
93
+ Args:
94
+ other:
95
+ Another Action type object to compare
96
+
97
+ Return
98
+ The negation of the state of equality
99
+ """
100
+ return not self.__eq__(other)
101
+
102
+
103
+ class SettingsError(Exception):
104
+ """Custom Exception to indicate an error with loading Settings."""
105
+
106
+ pass
107
+
108
+
109
+ SettingsFromFactory = TypeVar("SettingsFromFactory", bound="Settings")
110
+
111
+
112
+ class Settings:
113
+ """Class that contains configuration-as-code for any portion of the app."""
114
+
115
+ enabled_rules: list[str]
116
+ approved_actions: dict[str, Action]
117
+
118
+ def __init__(
119
+ self,
120
+ enabled_rules: Optional[list[str]] = None,
121
+ approved_actions: Optional[dict[str, dict[str, str]]] = None,
122
+ ) -> None:
123
+ """Settings object that can be overridden in settings.py.
124
+
125
+ Args:
126
+ enabled_rules:
127
+ All of the python modules that implement a Rule to be run against
128
+ the workflows. These must be available somewhere on the PYTHONPATH
129
+ approved_actions:
130
+ The colleciton of GitHub Actions that are pre-approved to be used
131
+ in any workflow (Required by src.rules.step_approved)
132
+ """
133
+ if enabled_rules is None:
134
+ enabled_rules = []
135
+
136
+ if approved_actions is None:
137
+ approved_actions = {}
138
+
139
+ self.enabled_rules = enabled_rules
140
+ self.approved_actions = {
141
+ name: Action(**action) for name, action in approved_actions.items()
142
+ }
143
+
144
+ @staticmethod
145
+ def factory() -> SettingsFromFactory:
146
+ with (
147
+ importlib.resources.files("bitwarden_workflow_linter")
148
+ .joinpath("default_settings.yaml")
149
+ .open("r", encoding="utf-8") as file
150
+ ):
151
+ settings = yaml.load(file)
152
+
153
+ settings_filename = "settings.yaml"
154
+ local_settings = None
155
+
156
+ if os.path.exists(settings_filename):
157
+ with open(settings_filename, encoding="utf8") as settings_file:
158
+ local_settings = yaml.load(settings_file)
159
+
160
+ if local_settings:
161
+ settings.update(local_settings)
162
+
163
+ if settings["approved_actions_path"] == "default_actions.json":
164
+ with (
165
+ importlib.resources.files("bitwarden_workflow_linter")
166
+ .joinpath("default_actions.json")
167
+ .open("r", encoding="utf-8") as file
168
+ ):
169
+ settings["approved_actions"] = json.load(file)
170
+ else:
171
+ with open(
172
+ settings["approved_actions_path"], "r", encoding="utf8"
173
+ ) as action_file:
174
+ settings["approved_actions"] = json.load(action_file)
175
+
176
+ return Settings(
177
+ enabled_rules=settings["enabled_rules"],
178
+ approved_actions=settings["approved_actions"],
179
+ )