From 99a58c763570eb550b0e4484506d02520eefe82a 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 1/2] 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). --- 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) From be90269be49400bc120614449c70277cb3924eb6 Mon Sep 17 00:00:00 2001 From: Ben Schwartz Date: Fri, 21 Aug 2026 11:41:48 -0400 Subject: [PATCH 2/2] fix(config): make permission tightening best-effort --- censys/common/config.py | 23 ++++++++++++++++++++--- tests/cli/test_config.py | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/censys/common/config.py b/censys/common/config.py index abc4517b..ce7fa1ea 100644 --- a/censys/common/config.py +++ b/censys/common/config.py @@ -42,11 +42,28 @@ def _restricted_opener(path: str, flags: int) -> int: 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 - restricted to the owner (0700/0600) rather than inheriting the umask. + 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. @@ -63,9 +80,9 @@ def write_config(config: configparser.ConfigParser) -> None: elif not os.path.isdir(CENSYS_PATH): os.makedirs(CENSYS_PATH, mode=0o700) else: - os.chmod(CENSYS_PATH, 0o700) + _try_chmod(CENSYS_PATH, 0o700) if os.path.isfile(config_path): - os.chmod(config_path, 0o600) + _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 91ff061e..2760de28 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -1,5 +1,6 @@ import os import stat +from unittest.mock import patch import pytest import responses @@ -50,7 +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.os.chmod") + self.mock_chmod = self.mocker.patch("censys.common.config._try_chmod") def test_search_config(self): # Mock @@ -182,15 +183,39 @@ def test_write_config_restricts_permissions(tmp_path, mocker, monkeypatch): 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 + 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) == 0o700 - assert stat.S_IMODE(os.stat(config_path).st_mode) == 0o600 + 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