From d449e7a9121fce699fef9b97df05d3c921807d75 Mon Sep 17 00:00:00 2001 From: zackaryia <30780411+Zackaryia@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:07:05 -0400 Subject: [PATCH] fix(config): restrict censys.cfg to owner-only permissions Create ~/.config/censys with mode 0700 and write censys.cfg with mode 0600 instead of inheriting the process umask, and tighten permissions on pre-existing files/directories on rewrite. Under the default umask of 022 the config file was previously world-readable (0644), exposing api_secret and asm_api_key to other local users (CWE-276). Fixes GHSA-xm52-gr3v-ffpq (IIP-22870) --- censys/common/config.py | 24 ++++++++++++++++++++++-- tests/cli/test_config.py | 40 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/censys/common/config.py b/censys/common/config.py index bb53776e..abc4517b 100644 --- a/censys/common/config.py +++ b/censys/common/config.py @@ -29,9 +29,25 @@ def get_config_path() -> str: return CONFIG_PATH +def _restricted_opener(path: str, flags: int) -> int: + """Opener that creates files readable and writable by the owner only. + + Args: + path (str): Path to open. + flags (int): Flags passed by `open()`. + + Returns: + int: File descriptor. + """ + return os.open(path, flags, 0o600) + + def write_config(config: configparser.ConfigParser) -> None: """Writes config to file. + The config file contains API credentials, so the directory and file are + restricted to the owner (0700/0600) rather than inheriting the umask. + Args: config (configparser.ConfigParser): Configuration to write. @@ -45,8 +61,12 @@ def write_config(config: configparser.ConfigParser) -> None: "Cannot write to home directory. Please set the `CENSYS_CONFIG_PATH` environmental variable to a writeable location." ) elif not os.path.isdir(CENSYS_PATH): - os.makedirs(CENSYS_PATH) - with open(config_path, "w") as configfile: + os.makedirs(CENSYS_PATH, mode=0o700) + else: + os.chmod(CENSYS_PATH, 0o700) + if os.path.isfile(config_path): + os.chmod(config_path, 0o600) + with open(config_path, "w", opener=_restricted_opener) as configfile: config.write(configfile) diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 3f22d00f..91ff061e 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -1,3 +1,6 @@ +import os +import stat + import pytest import responses @@ -9,8 +12,10 @@ CENSYS_PATH, CONFIG_PATH, DEFAULT, + _restricted_opener, default_config, get_config, + write_config, ) TEST_CONFIG_PATH = CONFIG_PATH + ".test" @@ -45,6 +50,7 @@ def setUp(self): ) self.mocker.patch("rich.prompt.Prompt.ask", side_effect=prompt_side_effect) self.mocker.patch("rich.prompt.Confirm.ask", side_effect=confirm_side_effect) + self.mock_chmod = self.mocker.patch("censys.common.config.os.chmod") def test_search_config(self): # Mock @@ -65,7 +71,9 @@ def test_search_config(self): cli_main() # Assert that the config file was read from the right place - self.mock_open.assert_called_with(TEST_CONFIG_PATH, "w") + self.mock_open.assert_called_with( + TEST_CONFIG_PATH, "w", opener=_restricted_opener + ) def test_search_config_failed(self): # Mock @@ -106,7 +114,7 @@ def test_search_config_makedirs(self): with pytest.raises(SystemExit, match="0"): cli_main() - mock_makedirs.assert_called_with(CENSYS_PATH) + mock_makedirs.assert_called_with(CENSYS_PATH, mode=0o700) def test_config_default(self): mock_isfile = self.mocker.patch( @@ -141,7 +149,7 @@ def test_search_config_custom_config(self): cli_main() # Assert that the config file was read from the right place - self.mock_open.assert_called_with("censys.cfg", "w") + self.mock_open.assert_called_with("censys.cfg", "w", opener=_restricted_opener) def test_search_config_perm_error(self): self.patch_args( @@ -160,3 +168,29 @@ def test_search_config_perm_error(self): with pytest.raises(SystemExit, match="1"): cli_main() + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only") +def test_write_config_restricts_permissions(tmp_path, mocker, monkeypatch): + monkeypatch.delenv("CENSYS_CONFIG_PATH", raising=False) + censys_path = tmp_path / ".config" / "censys" + config_path = censys_path / "censys.cfg" + mocker.patch("censys.common.config.HOME_PATH", str(tmp_path)) + mocker.patch("censys.common.config.CENSYS_PATH", str(censys_path)) + mocker.patch("censys.common.config.CONFIG_PATH", str(config_path)) + old_umask = os.umask(0o022) + try: + write_config(get_config()) + + assert stat.S_IMODE(os.stat(censys_path).st_mode) == 0o700 + assert stat.S_IMODE(os.stat(config_path).st_mode) == 0o600 + + # Pre-existing loose permissions are tightened on rewrite + os.chmod(censys_path, 0o755) + os.chmod(config_path, 0o644) + write_config(get_config()) + + assert stat.S_IMODE(os.stat(censys_path).st_mode) == 0o700 + assert stat.S_IMODE(os.stat(config_path).st_mode) == 0o600 + finally: + os.umask(old_umask)