Repository: securesauce/precli (language: python)

Issue:
New rules to check for os.setuid(0)
**Is your feature request related to a problem? Please describe.**
Setting the user ID of a process to root can be dangerous if another vulnerability is exploited to control the process. Basically a privilege escalation.

**Describe the solution you'd like**
Create a new rule that checks for whether the UID is set to 0. 0 is almost always used to represent
the user root, the most privileged user.

**Describe alternatives you've considered**
n/a

**Additional context**
https://docs.python.org/3/library/os.html#os.setuid
https://pkg.go.dev/syscall#Setuid
https://cwe.mitre.org/data/definitions/250.html


Love this idea? Give it a 👍. We prioritize fulfilling features with the most 👍.



Discussion / hints:

Fix patch:
diff --git a/docs/rules.md b/docs/rules.md
index 195cc4ac..6b26b7f0 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -60,3 +60,4 @@
 | PY035 | [hashlib — improper prng](rules/python/stdlib/hashlib-improper-prng.md) | Improper Randomness for Cryptographic `hashlib` Functions |
 | PY036 | [os — incorrect permission](rules/python/stdlib/os-loose-file-perm.md) | Incorrect Permission Assignment for Critical Resource using `os` Module |
 | PY037 | [pathlib — incorrect permission](rules/python/stdlib/pathlib-loose-file-perm.md) | Incorrect Permission Assignment for Critical Resource using `pathlib` Module |
+| PY038 | [os — unnecessary privileges](rules/python/stdlib/os-setuid-root.md) | Execution with Unnecessary Privileges using `os` Module |
diff --git a/docs/rules/python/stdlib/os-setuid-root.md b/docs/rules/python/stdlib/os-setuid-root.md
new file mode 100644
index 00000000..dc3744aa
--- /dev/null
+++ b/docs/rules/python/stdlib/os-setuid-root.md
@@ -0,0 +1,10 @@
+---
+id: PY038
+title: os — unnecessary privileges
+hide_title: true
+pagination_prev: null
+pagination_next: null
+slug: /rules/PY038
+---
+
+::: precli.rules.python.stdlib.os_setuid_root
diff --git a/precli/core/cwe.py b/precli/core/cwe.py
index 2387baef..a7433888 100644
--- a/precli/core/cwe.py
+++ b/precli/core/cwe.py
@@ -11,6 +11,7 @@ class Cwe:
         208: "Observable Timing Discrepancy",
         214: "Invocation of Process Using Visible Sensitive Information",
         215: "Insertion of Sensitive Information Into Debugging Code",
+        250: "Execution with Unnecessary Privileges",
         295: "Improper Certificate Validation",
         319: "Cleartext Transmission of Sensitive Information",
         326: "Inadequate Encryption Strength",
diff --git a/precli/rules/python/stdlib/os_setuid_root.py b/precli/rules/python/stdlib/os_setuid_root.py
new file mode 100644
index 00000000..110875ba
--- /dev/null
+++ b/precli/rules/python/stdlib/os_setuid_root.py
@@ -0,0 +1,98 @@
+# Copyright 2024 Secure Sauce LLC
+r"""
+# Execution with Unnecessary Privileges using `os` Module
+
+The Python function os.setuid() is used to set the user ID of the current
+process. Passing a user ID of 0 to setuid() changes the process’s user to the
+root user (superuser). This can lead to privilege escalation, allowing the
+current process to execute with root-level permissions, which could be
+exploited by malicious actors to gain control over the system.
+
+Processes running with elevated privileges (such as root) can pose significant
+security risks if misused. For instance, a vulnerability in such a process
+could be leveraged by attackers to compromise the entire system. Therefore,
+it is essential to avoid changing the process’s user ID to 0 unless absolutely
+necessary and to ensure such usage is thoroughly reviewed and justified.
+
+## Examples
+
+```python linenums="1" hl_lines="4" title="os_setuid_0.py"
+import os
+
+
+os.setuid(0)
+```
+
+??? example "Example Output"
+    ```
+    > precli tests/unit/rules/python/stdlib/os/examples/os_setuid_0.py
+    ⚠️  Warning on line 9 in tests/unit/rules/python/stdlib/os/examples/os_setuid_0.py
+    PY038: Execution with Unnecessary Privileges
+    The function 'os.setuid(0)' escalates the process to run with root (superuser) privileges.
+    ```
+
+## Remediation
+
+ - Avoid using setuid(0) unless absolutely necessary: Review whether running
+   as the root user is required for the task at hand. It is safer to operate
+   with the least privileges necessary.
+ - Drop privileges as soon as possible: If elevated privileges are required
+   temporarily, ensure that the process drops those privileges immediately
+   after performing the necessary tasks.
+ - Validate input to avoid malicious manipulation: If input parameters control
+   the user ID passed to setuid(), ensure they are securely validated and not
+   influenced by untrusted sources.
+ - Use alternatives to running as root: If feasible, design your application
+   to avoid needing root privileges entirely. Consider utilizing a dedicated
+   service or capability that performs the task in a secure, controlled manner.
+
+```python linenums="1" hl_lines="4" title="os_setuid_0.py"
+import os
+
+
+os.setuid(1000)
+```
+
+## See also
+
+!!! info
+    - [os — Miscellaneous operating system interfaces — Python documentation](https://docs.python.org/3/library/os.html#os.setuid)
+    - [Principle of Least Privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege)
+    - [CWE-250: Execution with Unnecessary Privileges](https://cwe.mitre.org/data/definitions/250.html)
+
+_New in version 0.6.6_
+
+"""  # noqa: E501
+from precli.core.call import Call
+from precli.core.config import Config
+from precli.core.level import Level
+from precli.core.location import Location
+from precli.core.result import Result
+from precli.rules import Rule
+
+
+class OsSetuidRoot(Rule):
+    def __init__(self, id: str):
+        super().__init__(
+            id=id,
+            name="unnecessary_privileges",
+            description=__doc__,
+            cwe_id=250,
+            message="The function '{0}(0)' escalates the process to run with "
+            "root (superuser) privileges.",
+            config=Config(level=Level.ERROR),
+        )
+
+    def analyze_call(self, context: dict, call: Call) -> Result | None:
+        if call.name_qualified != "os.setuid":
+            return
+
+        argument = call.get_argument(position=0, name="uid")
+        uid = argument.value
+
+        if isinstance(uid, int) and uid == 0:
+            return Result(
+                rule_id=self.id,
+                location=Location(node=argument.node),
+                message=self.message.format(call.name_qualified),
+            )
diff --git a/setup.cfg b/setup.cfg
index 5951f744..66d9d36e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -191,3 +191,6 @@ precli.rules.python =
 
     # precli/rules/python/stdlib/pathlib_loose_file_perm.py
     PY037 = precli.rules.python.stdlib.pathlib_loose_file_perm:PathlibLooseFilePermissions
+
+    # precli/rules/python/stdlib/os_setuid_root.py
+    PY038 = precli.rules.python.stdlib.os_setuid_root:OsSetuidRoot


Test patch:
diff --git a/tests/unit/rules/python/stdlib/os/examples/os_setuid_0.py b/tests/unit/rules/python/stdlib/os/examples/os_setuid_0.py
new file mode 100644
index 00000000..68dc9ecf
--- /dev/null
+++ b/tests/unit/rules/python/stdlib/os/examples/os_setuid_0.py
@@ -0,0 +1,9 @@
+# level: ERROR
+# start_line: 9
+# end_line: 9
+# start_column: 10
+# end_column: 11
+import os
+
+
+os.setuid(0)
diff --git a/tests/unit/rules/python/stdlib/os/examples/os_setuid_1000.py b/tests/unit/rules/python/stdlib/os/examples/os_setuid_1000.py
new file mode 100644
index 00000000..71638c6d
--- /dev/null
+++ b/tests/unit/rules/python/stdlib/os/examples/os_setuid_1000.py
@@ -0,0 +1,5 @@
+# level: NONE
+import os
+
+
+os.setuid(1000)
diff --git a/tests/unit/rules/python/stdlib/os/examples/os_setuid_root.py b/tests/unit/rules/python/stdlib/os/examples/os_setuid_root.py
new file mode 100644
index 00000000..95357eae
--- /dev/null
+++ b/tests/unit/rules/python/stdlib/os/examples/os_setuid_root.py
@@ -0,0 +1,10 @@
+# level: ERROR
+# start_line: 10
+# end_line: 10
+# start_column: 10
+# end_column: 14
+import os
+
+
+root = 0
+os.setuid(root)
diff --git a/tests/unit/rules/python/stdlib/os/test_os_setuid_root.py b/tests/unit/rules/python/stdlib/os/test_os_setuid_root.py
new file mode 100644
index 00000000..99038a8d
--- /dev/null
+++ b/tests/unit/rules/python/stdlib/os/test_os_setuid_root.py
@@ -0,0 +1,49 @@
+# Copyright 2024 Secure Sauce LLC
+import os
+
+import pytest
+
+from precli.core.level import Level
+from precli.parsers import python
+from precli.rules import Rule
+from tests.unit.rules import test_case
+
+
+class TestOsSetuidRoot(test_case.TestCase):
+    @classmethod
+    def setup_class(cls):
+        cls.rule_id = "PY038"
+        cls.parser = python.Python()
+        cls.base_path = os.path.join(
+            "tests",
+            "unit",
+            "rules",
+            "python",
+            "stdlib",
+            "os",
+            "examples",
+        )
+
+    def test_rule_meta(self):
+        rule = Rule.get_by_id(self.rule_id)
+        assert rule.id == self.rule_id
+        assert rule.name == "unnecessary_privileges"
+        assert (
+            rule.help_url
+            == f"https://docs.securesauce.dev/rules/{self.rule_id}"
+        )
+        assert rule.default_config.enabled is True
+        assert rule.default_config.level == Level.ERROR
+        assert rule.default_config.rank == -1.0
+        assert rule.cwe.id == 250
+
+    @pytest.mark.parametrize(
+        "filename",
+        [
+            "os_setuid_0.py",
+            "os_setuid_1000.py",
+            "os_setuid_root.py",
+        ],
+    )
+    def test(self, filename):
+        self.check(filename)