diff --git a/censys/common/config.py b/censys/common/config.py index bb53776e..ce7fa1ea 100644 --- a/censys/common/config.py +++ b/censys/common/config.py @@ -29,9 +29,42 @@ 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 _try_chmod(path: str, mode: int) -> None: + """Best-effort permission tightening. + + Files are already created owner-only by `_restricted_opener`, so failing to + tighten an existing path must never stop the config from being written. + + Args: + path (str): Path to tighten. + mode (int): Desired permission bits. + """ + try: + os.chmod(path, mode) + except OSError: + pass + + def write_config(config: configparser.ConfigParser) -> None: """Writes config to file. + The config file contains API credentials, so the directory and file are + created owner-only (0700/0600). Existing paths are tightened on a + best-effort basis; the requested modes are still subject to the umask. + Args: config (configparser.ConfigParser): Configuration to write. @@ -45,8 +78,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: + _try_chmod(CENSYS_PATH, 0o700) + if os.path.isfile(config_path): + _try_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..2760de28 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -1,3 +1,7 @@ +import os +import stat +from unittest.mock import patch + import pytest import responses @@ -9,8 +13,10 @@ CENSYS_PATH, CONFIG_PATH, DEFAULT, + _restricted_opener, default_config, get_config, + write_config, ) TEST_CONFIG_PATH = CONFIG_PATH + ".test" @@ -45,6 +51,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._try_chmod") def test_search_config(self): # Mock @@ -65,7 +72,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 +115,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 +150,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 +169,53 @@ 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) & 0o077 == 0 + assert stat.S_IMODE(os.stat(config_path).st_mode) & 0o077 == 0 + + # 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) & 0o077 == 0 + assert stat.S_IMODE(os.stat(config_path).st_mode) & 0o077 == 0 + finally: + os.umask(old_umask) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only") +def test_write_config_survives_unchmodable_path(tmp_path, mocker, monkeypatch): + # A path we may not chmod must not stop the config from being written: + # root-owned config dirs in containers, a non-owned CENSYS_CONFIG_PATH, and + # mounts that reject chmod outright (NFS, CIFS, WSL DrvFs without metadata). + 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)) + + write_config(get_config()) + + with patch( + "censys.common.config.os.chmod", + side_effect=PermissionError(1, "Operation not permitted"), + ): + write_config(get_config()) + + assert config_path.is_file() + assert stat.S_IMODE(os.stat(config_path).st_mode) & 0o077 == 0