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.
- flowsec-0.1.0.dist-info/METADATA +206 -0
- flowsec-0.1.0.dist-info/RECORD +26 -0
- flowsec-0.1.0.dist-info/WHEEL +4 -0
- flowsec-0.1.0.dist-info/entry_points.txt +2 -0
- pipelineguard/__init__.py +0 -0
- pipelineguard/ai_narrative.py +73 -0
- pipelineguard/cli.py +141 -0
- pipelineguard/parser.py +33 -0
- pipelineguard/report.py +35 -0
- pipelineguard/rules/__init__.py +0 -0
- pipelineguard/rules/artifact_signing.py +63 -0
- pipelineguard/rules/base.py +37 -0
- pipelineguard/rules/dependency_pinning.py +72 -0
- pipelineguard/rules/excessive_permissions.py +40 -0
- pipelineguard/rules/hardcoded_secrets.py +119 -0
- pipelineguard/rules/missing_branch_protection.py +15 -0
- pipelineguard/rules/missing_env_protection.py +33 -0
- pipelineguard/rules/missing_oidc.py +56 -0
- pipelineguard/rules/missing_timeout.py +65 -0
- pipelineguard/rules/pull_request_target.py +58 -0
- pipelineguard/rules/secrets_in_run.py +64 -0
- pipelineguard/rules/self_hosted_runner.py +79 -0
- pipelineguard/rules/unpinned_actions.py +45 -0
- pipelineguard/rules/workflow_dispatch_injection.py +44 -0
- pipelineguard/scanner.py +84 -0
- pipelineguard/scoring.py +0 -0
|
@@ -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
|