Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions censys/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)


Expand Down
65 changes: 62 additions & 3 deletions tests/cli/test_config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import os
import stat
from unittest.mock import patch

import pytest
import responses

Expand All @@ -9,8 +13,10 @@
CENSYS_PATH,
CONFIG_PATH,
DEFAULT,
_restricted_opener,
default_config,
get_config,
write_config,
)

TEST_CONFIG_PATH = CONFIG_PATH + ".test"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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
Loading