flowsec 0.1.0__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,72 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+ PACKAGE_MANAGERS = ["pip install", "pip3 install", "npm install", "yarn add", "npm i"]
5
+
6
+
7
+ class DependencyPinningRule(BaseRule):
8
+ rule_id = "FS009"
9
+ title = "Unpinned Dependency — Package Installed Without Version Lock"
10
+ severity = Severity.HIGH
11
+
12
+ def _is_pinned(self, command: str) -> bool:
13
+ for manager in PACKAGE_MANAGERS:
14
+ if manager in command:
15
+ parts = command.split(manager, 1)
16
+ if len(parts) < 2:
17
+ return False
18
+ packages = parts[1].strip().split()
19
+ for pkg in packages:
20
+ if pkg.startswith("-"):
21
+ continue
22
+ if "==" not in pkg and "@" not in pkg:
23
+ return False
24
+ return True
25
+
26
+ def _check_commands(self, commands: list[str], file_path: str) -> list[Finding]:
27
+ findings = []
28
+ for command in commands:
29
+ for line in command.split("\n"):
30
+ line = line.strip()
31
+ for manager in PACKAGE_MANAGERS:
32
+ if manager in line and not self._is_pinned(line):
33
+ findings.append(Finding(
34
+ rule_id=self.rule_id,
35
+ title=self.title,
36
+ severity=self.severity,
37
+ description=f"Command '{line.strip()}' installs dependencies without version pins.",
38
+ remediation="Pin all dependencies to exact versions. Use 'pip install requests==2.31.0' instead of 'pip install requests'.",
39
+ mitre_technique="T1195.002",
40
+ owasp_category="CICD-SEC-3",
41
+ file_path=file_path,
42
+ ))
43
+ break
44
+ return findings
45
+
46
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
47
+ findings = []
48
+
49
+ if platform == "github":
50
+ jobs = config.get("jobs", {})
51
+ for job in jobs.values():
52
+ if not isinstance(job, dict):
53
+ continue
54
+ for step in job.get("steps", []):
55
+ if not isinstance(step, dict):
56
+ continue
57
+ run = step.get("run", "")
58
+ if run:
59
+ findings.extend(self._check_commands([run], file_path))
60
+
61
+ elif platform in ("gitlab", "azure"):
62
+ for key, value in config.items():
63
+ if not isinstance(value, dict):
64
+ continue
65
+ scripts = value.get("script", [])
66
+ if isinstance(scripts, str):
67
+ scripts = [scripts]
68
+ if isinstance(scripts, list):
69
+ string_scripts = [s for s in scripts if isinstance(s, str)]
70
+ findings.extend(self._check_commands(string_scripts, file_path))
71
+
72
+ return findings
@@ -0,0 +1,40 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+
5
+ class ExcessivePermissions(BaseRule):
6
+ rule_id = "FS003"
7
+ title = "Excessive Permissions — Overprivileged Workflow Token"
8
+ severity = Severity.HIGH #
9
+
10
+ SUSPICIOUS_PATTERNS = [
11
+ "write-all", "read-all"
12
+ ]
13
+
14
+ def _extract_permissions_(self, config: dict[str, Any]) -> Any:
15
+ return config.get("permissions", None)
16
+
17
+
18
+
19
+
20
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
21
+ if platform != "github":
22
+ return []
23
+ findings = []
24
+ permission = self._extract_permissions_(config)
25
+
26
+ if (permission in self.SUSPICIOUS_PATTERNS) or (permission is None) :
27
+ findings.append(Finding(
28
+ rule_id = self.rule_id,
29
+ title = self.title,
30
+ severity = self.severity,
31
+ description = f"Pipeline permissions are set to '{permission}' giving the workflow token broad access across the repository. An attacker who compromises this pipeline can read code, write to branches, modify releases, and exfiltrate secrets.",
32
+ remediation = f"Define permissions explicitly at the workflow level using least privilege. Only grant the specific scopes the job needs. Example: 'permissions: contents: read'. Never use write-all or read-all.",
33
+ mitre_technique="T1078",
34
+ file_path=file_path,
35
+ owasp_category="CICD-SEC-5",
36
+
37
+ ))
38
+
39
+
40
+ return findings
@@ -0,0 +1,119 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+
5
+ class HardcodedSecretsRule(BaseRule):
6
+ rule_id = "FS001"
7
+ title = "Hardcoded Secret — Plaintext Credential in Workflow"
8
+ severity = Severity.CRITICAL
9
+
10
+ SUSPICIOUS_PATTERNS = [
11
+ "api_key", "apikey", "api_token", "access_token",
12
+ "secret", "password", "passwd", "token",
13
+ "private_key", "client_secret", "auth_token",
14
+ "subscription_id", "account_key", "login", "private"
15
+ ]
16
+
17
+ def _extract_github_env_vars(self, config: dict[str, Any]) -> dict[str, Any]:
18
+ env_vars = {}
19
+ jobs = config.get("jobs", {})
20
+ for job in jobs.values():
21
+ if not isinstance(job, dict):
22
+ continue
23
+ env_vars.update({
24
+ k: v for k, v in job.get("env", {}).items()
25
+ if not k.startswith("__line_")
26
+ })
27
+ for step in job.get("steps", []):
28
+ if not isinstance(step, dict):
29
+ continue
30
+ env_vars.update({
31
+ k: v for k, v in step.get("env", {}).items()
32
+ if not k.startswith("__line_")
33
+ })
34
+ return env_vars
35
+
36
+ def _extract_gitlab_variables(self, config: dict[str, Any]) -> dict[str, Any]:
37
+ variables = {}
38
+ top_level = config.get("variables", {})
39
+ if isinstance(top_level, dict):
40
+ variables.update({
41
+ k: v for k, v in top_level.items()
42
+ if not k.startswith("__line_")
43
+ })
44
+ for key, value in config.items():
45
+ if isinstance(value, dict) and "script" in value:
46
+ job_vars = value.get("variables", {})
47
+ if isinstance(job_vars, dict):
48
+ variables.update({
49
+ k: v for k, v in job_vars.items()
50
+ if not k.startswith("__line_")
51
+ })
52
+ return variables
53
+
54
+ def _extract_azure_variables(self, config: dict[str, Any]) -> dict[str, Any]:
55
+ variables = {}
56
+ top_level = config.get("variables", {})
57
+ if isinstance(top_level, dict):
58
+ variables.update({
59
+ k: v for k, v in top_level.items()
60
+ if not k.startswith("__line_")
61
+ })
62
+ return variables
63
+
64
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
65
+ findings = []
66
+
67
+ if platform == "github":
68
+ jobs = config.get("jobs", {})
69
+ for job in jobs.values():
70
+ if not isinstance(job, dict):
71
+ continue
72
+ all_envs = [job.get("env", {})]
73
+ for step in job.get("steps", []):
74
+ if isinstance(step, dict):
75
+ all_envs.append(step.get("env", {}))
76
+ for env in all_envs:
77
+ for var_name, var_value in env.items():
78
+ if var_name.startswith("__line_"):
79
+ continue
80
+ if not isinstance(var_value, str):
81
+ continue
82
+ if any(pattern in var_name.lower() for pattern in self.SUSPICIOUS_PATTERNS):
83
+ if not var_value.startswith("${{"):
84
+ findings.append(Finding(
85
+ rule_id=self.rule_id,
86
+ title=self.title,
87
+ severity=self.severity,
88
+ description=f"Hardcoded secret found in environment variable '{var_name}'",
89
+ remediation=f"Move '{var_name}' to GitHub Secrets and reference it as ${{{{ secrets.{var_name} }}}}",
90
+ mitre_technique="T1552.001",
91
+ owasp_category="CICD-SEC-6",
92
+ file_path=file_path,
93
+ line_number=env.get(f"__line_{var_name}__", 0),
94
+ ))
95
+
96
+ elif platform in ("gitlab", "azure"):
97
+ if platform == "gitlab":
98
+ env_vars = self._extract_gitlab_variables(config)
99
+ else:
100
+ env_vars = self._extract_azure_variables(config)
101
+
102
+ for var_name, var_value in env_vars.items():
103
+ if not isinstance(var_value, str):
104
+ continue
105
+ if any(pattern in var_name.lower() for pattern in self.SUSPICIOUS_PATTERNS):
106
+ safe_prefixes = ("${{", "${", "$CI_", "$AZURE_")
107
+ if not any(var_value.startswith(p) for p in safe_prefixes):
108
+ findings.append(Finding(
109
+ rule_id=self.rule_id,
110
+ title=self.title,
111
+ severity=self.severity,
112
+ description=f"Hardcoded secret found in variable '{var_name}'",
113
+ remediation=f"Move '{var_name}' to your platform's secret manager and reference it as an environment variable.",
114
+ mitre_technique="T1552.001",
115
+ owasp_category="CICD-SEC-6",
116
+ file_path=file_path,
117
+ ))
118
+
119
+ return findings
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+ class MissingBranchProtectionRule(BaseRule):
5
+ rule_id = "FS011"
6
+ title = "Missing Branch Protection — Direct Push to Default Branch Possible"
7
+ severity = Severity.HIGH
8
+
9
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
10
+ if platform != "github":
11
+ return []
12
+ # Branch protection is a repo setting not a workflow config
13
+ # This rule is wired up via the GitHub API scanner
14
+ # Scaffold here — full implementation in scanner refactor
15
+ return []
@@ -0,0 +1,33 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+ DEPLOY_KEYWORDS = ["deploy", "release", "publish", "prod", "production", "ship"]
5
+
6
+ class MissingEnvProtectionRule(BaseRule):
7
+ rule_id = "FS012"
8
+ title = "Missing Environment Protection — Deploy Job Has No Approval Gate"
9
+ severity = Severity.HIGH
10
+
11
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
12
+ if platform != "github":
13
+ return []
14
+ findings = []
15
+ jobs = config.get("jobs", {})
16
+
17
+ for job_name, job in jobs.items():
18
+ job_name_lower = job_name.lower()
19
+ is_deploy = any(keyword in job_name_lower for keyword in DEPLOY_KEYWORDS)
20
+
21
+ if is_deploy and not job.get("environment"):
22
+ findings.append(Finding(
23
+ rule_id=self.rule_id,
24
+ title=self.title,
25
+ severity=self.severity,
26
+ description=f"Job '{job_name}' appears to be a deployment job but has no GitHub Environment configured. Without environment protection rules, deployments to production can happen automatically with no human approval required.",
27
+ remediation="Add an 'environment: production' key to this job and configure required reviewers in your GitHub repository's Environment settings. This adds a manual approval gate before any production deployment.",
28
+ mitre_technique="T1078",
29
+ owasp_category="CICD-SEC-5",
30
+ file_path=file_path,
31
+ ))
32
+
33
+ return findings
@@ -0,0 +1,56 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+
5
+ class MissingOIDCRule(BaseRule):
6
+ rule_id = "FS004"
7
+ title = "Missing OIDC — Long-Lived Cloud Credential in Use"
8
+ severity = Severity.HIGH #
9
+
10
+ SAFE_PATTERNS = [
11
+ "aws-actions/", "azure/login", "google-github-actions/"
12
+ ]
13
+
14
+ def _extract_uses(self, config: dict[str, Any], ) -> list[str]:
15
+ uses = []
16
+ jobs = config.get("jobs", {})
17
+ for job in jobs.values():
18
+ for step in job.get("steps", []):
19
+ if "uses" in step:
20
+ uses.append(step["uses"])
21
+ return uses
22
+
23
+ def _extract_permissions(self, config: dict[str, Any]) -> Any:
24
+ return config.get("permissions", None)
25
+
26
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
27
+ if platform != "github":
28
+ return []
29
+ findings = []
30
+ uses = self._extract_uses(config)
31
+ permissions = self._extract_permissions(config)
32
+
33
+ uses_cloud_provider = any(
34
+ any(pattern in step for pattern in self.SAFE_PATTERNS)
35
+ for step in uses
36
+ )
37
+
38
+ has_oidc = (
39
+ isinstance(permissions, dict) and
40
+ permissions.get("id-token") == "write"
41
+ )
42
+
43
+ if uses_cloud_provider and not has_oidc:
44
+ findings.append(Finding(
45
+ rule_id=self.rule_id,
46
+ title=self.title,
47
+ severity=self.severity,
48
+ description=f"This pipeline connects to a cloud provider but does not use OIDC for authentication. Long-lived credentials stored as secrets can be stolen through a compromised action or leaked pipeline log and will remain valid until manually rotated.",
49
+ remediation="Add 'id-token: write' to the workflow permissions block and replace long-lived credential secrets with a role-to-assume (AWS) or client-id/tenant-id (Azure). This ensures credentials expire automatically after each run.",
50
+ mitre_technique="T1552.004",
51
+ file_path=file_path,
52
+ owasp_category="CICD-SEC-6",
53
+
54
+ ))
55
+
56
+ return findings
@@ -0,0 +1,65 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+
5
+ class MissingTimeoutRule(BaseRule):
6
+ rule_id = "FS006"
7
+ title = "Missing Timeout — Job Runs Up to 6 Hours Unchecked"
8
+ severity = Severity.LOW
9
+
10
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
11
+ findings = []
12
+
13
+ if platform == "github":
14
+ jobs = config.get("jobs", {})
15
+ for job_name, job in jobs.items():
16
+ if not isinstance(job, dict):
17
+ continue
18
+ if "timeout-minutes" not in job:
19
+ findings.append(Finding(
20
+ rule_id=self.rule_id,
21
+ title=self.title,
22
+ severity=self.severity,
23
+ description=f"Job '{job_name}' has no timeout defined. GitHub's default timeout is 6 hours.",
24
+ remediation=f"Add 'timeout-minutes: 15' to job '{job_name}'.",
25
+ mitre_technique="T1499",
26
+ owasp_category="CICD-SEC-10",
27
+ file_path=file_path,
28
+ ))
29
+
30
+ elif platform == "gitlab":
31
+ for key, value in config.items():
32
+ if not isinstance(value, dict):
33
+ continue
34
+ if "script" in value and "timeout" not in value:
35
+ findings.append(Finding(
36
+ rule_id=self.rule_id,
37
+ title=self.title,
38
+ severity=self.severity,
39
+ description=f"GitLab job '{key}' has no timeout defined. GitLab's default timeout is 1 hour.",
40
+ remediation=f"Add 'timeout: 15 minutes' to job '{key}'.",
41
+ mitre_technique="T1499",
42
+ owasp_category="CICD-SEC-10",
43
+ file_path=file_path,
44
+ ))
45
+
46
+ elif platform == "azure":
47
+ jobs = config.get("jobs", [])
48
+ if isinstance(jobs, list):
49
+ for job in jobs:
50
+ if not isinstance(job, dict):
51
+ continue
52
+ job_name = job.get("job", "unknown")
53
+ if "timeoutInMinutes" not in job:
54
+ findings.append(Finding(
55
+ rule_id=self.rule_id,
56
+ title=self.title,
57
+ severity=self.severity,
58
+ description=f"Azure DevOps job '{job_name}' has no timeout defined.",
59
+ remediation=f"Add 'timeoutInMinutes: 15' to job '{job_name}'.",
60
+ mitre_technique="T1499",
61
+ owasp_category="CICD-SEC-10",
62
+ file_path=file_path,
63
+ ))
64
+
65
+ return findings
@@ -0,0 +1,58 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+
5
+ class PullRequestTargetRule(BaseRule):
6
+ rule_id = "FS005"
7
+ title = "Pull Request Target — Secrets Exposed to Fork Code"
8
+ severity = Severity.CRITICAL
9
+
10
+ def _extract_triggers(self, config: dict[str, Any]) -> list[str]:
11
+ triggers = config.get(True, {}) # PyYAML converts 'on' to True
12
+ if isinstance(triggers, dict):
13
+ return list(triggers.keys())
14
+ if isinstance(triggers, list):
15
+ return triggers
16
+ return []
17
+
18
+ def _extract_uses(self, config: dict[str, Any]) -> list[str]:
19
+ uses = []
20
+ jobs = config.get("jobs", {})
21
+ for job in jobs.values():
22
+ for step in job.get("steps", []):
23
+ if "uses" in step:
24
+ uses.append(step["uses"])
25
+ return uses
26
+
27
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
28
+ if platform != "github":
29
+ return []
30
+ findings = []
31
+ triggers = self._extract_triggers(config)
32
+ uses = self._extract_uses(config)
33
+
34
+ if "pull_request_target" not in triggers:
35
+ return findings
36
+
37
+ # pull_request_target is dangerous when combined with
38
+ # checking out untrusted code from the PR
39
+ dangerous_actions = [
40
+ "actions/checkout",
41
+ ]
42
+
43
+ for use in uses:
44
+ if any(action in use for action in dangerous_actions):
45
+ findings.append(Finding(
46
+ rule_id=self.rule_id,
47
+ title=self.title,
48
+ severity=self.severity,
49
+ description="This workflow uses 'pull_request_target' and checks out code from the pull request. 'pull_request_target' runs in the context of the base branch with access to secrets — combined with checking out PR code, an attacker can submit a malicious PR that executes arbitrary code with access to your repository secrets.",
50
+ remediation="Do not combine pull_request_target with actions/checkout of the PR head. If you need to run code from a PR, use the pull_request event instead which does not have access to secrets. If pull_request_target is required, never check out or execute code from the PR branch.",
51
+ mitre_technique="T1611",
52
+ file_path=file_path,
53
+ owasp_category="CICD-SEC-4",
54
+
55
+ ))
56
+ break
57
+
58
+ return findings
@@ -0,0 +1,64 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+ SUSPICIOUS_PATTERNS = [
5
+ "password=", "passwd=", "token=", "api_key=", "apikey=",
6
+ "secret=", "Authorization: Bearer", "Authorization: Token",
7
+ "access_key=", "private_key=", "client_secret=",
8
+ ]
9
+
10
+
11
+ class SecretsInRunRule(BaseRule):
12
+ rule_id = "FS010"
13
+ title = "Secret in Run Command — Plaintext Credential in Shell Step"
14
+ severity = Severity.CRITICAL
15
+
16
+ def _check_commands(self, commands: list[str], file_path: str) -> list[Finding]:
17
+ findings = []
18
+ for command in commands:
19
+ for line in command.split("\n"):
20
+ line_lower = line.lower()
21
+ for pattern in SUSPICIOUS_PATTERNS:
22
+ if pattern.lower() in line_lower:
23
+ after = line_lower.split(pattern.lower(), 1)[1].strip()
24
+ if after and not after.startswith("${{") and not after.startswith("${"):
25
+ findings.append(Finding(
26
+ rule_id=self.rule_id,
27
+ title=self.title,
28
+ severity=self.severity,
29
+ description=f"Run command contains what appears to be a hardcoded credential matching pattern '{pattern}'.",
30
+ remediation="Move the credential to your platform's secret manager and reference it as an environment variable.",
31
+ mitre_technique="T1552.001",
32
+ owasp_category="CICD-SEC-6",
33
+ file_path=file_path,
34
+ ))
35
+ break
36
+ return findings
37
+
38
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
39
+ findings = []
40
+
41
+ if platform == "github":
42
+ jobs = config.get("jobs", {})
43
+ for job in jobs.values():
44
+ if not isinstance(job, dict):
45
+ continue
46
+ for step in job.get("steps", []):
47
+ if not isinstance(step, dict):
48
+ continue
49
+ run = step.get("run", "")
50
+ if run:
51
+ findings.extend(self._check_commands([run], file_path))
52
+
53
+ elif platform in ("gitlab", "azure"):
54
+ for key, value in config.items():
55
+ if not isinstance(value, dict):
56
+ continue
57
+ scripts = value.get("script", [])
58
+ if isinstance(scripts, str):
59
+ scripts = [scripts]
60
+ if isinstance(scripts, list):
61
+ string_scripts = [s for s in scripts if isinstance(s, str)]
62
+ findings.extend(self._check_commands(string_scripts, file_path))
63
+
64
+ return findings
@@ -0,0 +1,79 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+
5
+ class SelfHostedRunnerRule(BaseRule):
6
+ rule_id = "FS007"
7
+ title = "Self-Hosted Runner — Persistent Environment Risk"
8
+ severity = Severity.HIGH
9
+
10
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
11
+ findings = []
12
+
13
+ if platform == "github":
14
+ jobs = config.get("jobs", {})
15
+ for job_name, job in jobs.items():
16
+ if not isinstance(job, dict):
17
+ continue
18
+ runs_on = job.get("runs-on", "")
19
+ if isinstance(runs_on, list):
20
+ runs_on_str = " ".join(runs_on).lower()
21
+ else:
22
+ runs_on_str = str(runs_on).lower()
23
+ if "self-hosted" in runs_on_str:
24
+ findings.append(Finding(
25
+ rule_id=self.rule_id,
26
+ title=self.title,
27
+ severity=self.severity,
28
+ description=f"Job '{job_name}' runs on a self-hosted runner. Self-hosted runners persist between jobs and are not automatically cleaned up.",
29
+ remediation="Use ephemeral containerized runners or configure the runner to reset to a clean state after every job.",
30
+ mitre_technique="T1053",
31
+ owasp_category="CICD-SEC-7",
32
+ file_path=file_path,
33
+ ))
34
+
35
+ elif platform == "gitlab":
36
+ for key, value in config.items():
37
+ if not isinstance(value, dict):
38
+ continue
39
+ if "script" not in value:
40
+ continue
41
+ tags = value.get("tags", [])
42
+ if isinstance(tags, list):
43
+ tags_str = " ".join(tags).lower()
44
+ else:
45
+ tags_str = str(tags).lower()
46
+ if "self-hosted" in tags_str or "self_hosted" in tags_str:
47
+ findings.append(Finding(
48
+ rule_id=self.rule_id,
49
+ title=self.title,
50
+ severity=self.severity,
51
+ description=f"GitLab job '{key}' uses a self-hosted runner via tags. Self-hosted runners persist between jobs.",
52
+ remediation="Use ephemeral containerized runners or configure the runner to reset after every job.",
53
+ mitre_technique="T1053",
54
+ owasp_category="CICD-SEC-7",
55
+ file_path=file_path,
56
+ ))
57
+
58
+ elif platform == "azure":
59
+ jobs = config.get("jobs", [])
60
+ if isinstance(jobs, list):
61
+ for job in jobs:
62
+ if not isinstance(job, dict):
63
+ continue
64
+ pool = job.get("pool", {})
65
+ if isinstance(pool, dict):
66
+ name = pool.get("name", "").lower()
67
+ if name and "azure pipelines" not in name:
68
+ findings.append(Finding(
69
+ rule_id=self.rule_id,
70
+ title=self.title,
71
+ severity=self.severity,
72
+ description=f"Azure DevOps job uses a self-hosted agent pool '{pool.get('name')}'. Self-hosted agents persist between jobs.",
73
+ remediation="Use Microsoft-hosted agents where possible or configure self-hosted agents to reset after every job.",
74
+ mitre_technique="T1053",
75
+ owasp_category="CICD-SEC-7",
76
+ file_path=file_path,
77
+ ))
78
+
79
+ return findings
@@ -0,0 +1,45 @@
1
+ from typing import Any
2
+ from .base import BaseRule, Finding, Severity
3
+
4
+
5
+ class UnpinnedActionsRule(BaseRule):
6
+ rule_id = "FS002"
7
+ title = "Unpinned Action — Supply Chain Attack Vector"
8
+ severity = Severity.CRITICAL # Supply chain attacks are in OWASP top 10 right now (04/14/2026)
9
+
10
+
11
+ def _extract_uses(self, config: dict[str, Any]) -> dict[str, str]:
12
+ uses = [] # Creating list of ENV Variable
13
+ jobs = config.get("jobs", {}) # jobs is set too whats inside {} for jobs
14
+ for job in jobs.values():
15
+ for step in job.get("steps",[]):
16
+ if "uses" in step:
17
+ uses.append(step["uses"])
18
+ return uses
19
+
20
+
21
+
22
+ def check(self, config: dict[str, Any], file_path: str, platform: str = "github") -> list[Finding]:
23
+ if platform != "github":
24
+ return []
25
+ findings = []
26
+ uses = self._extract_uses(config)
27
+
28
+ for use in uses:
29
+ before, separator, after = use.partition("@")
30
+ is_pinned = len(after) == 40 and all(c in "0123456789abcdef" for c in after)
31
+
32
+ if not is_pinned:
33
+ findings.append(Finding(
34
+ rule_id=self.rule_id,
35
+ title=self.title,
36
+ severity=self.severity,
37
+ description=f"Action '{use}' is not pinned to a commit hash",
38
+ remediation=f"Pin '{use}' to a full commit SHA instead of a branch or tag. Example: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae1",
39
+ mitre_technique="T1195.001",
40
+ file_path=file_path,
41
+ owasp_category="CICD-SEC-3",
42
+
43
+ ))
44
+
45
+ return findings