Skip to content
Merged
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
40 changes: 38 additions & 2 deletions censys/common/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Interact with the config file."""

import configparser
import contextlib
import os
from pathlib import Path

Expand Down Expand Up @@ -29,9 +30,40 @@ 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.
"""
with contextlib.suppress(OSError):
os.chmod(path, mode)


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 +77,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
91 changes: 88 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 @@ -6,8 +10,10 @@
CENSYS_PATH,
CONFIG_PATH,
DEFAULT,
_restricted_opener,
default_config,
get_config,
write_config,
)
from tests.search.v1.test_api import ACCOUNT_JSON
from tests.utils import V1_URL, CensysTestCase
Expand Down Expand Up @@ -42,6 +48,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 @@ -62,7 +69,7 @@ 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 @@ -103,7 +110,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("censys.common.config.os.path.isfile", return_value=True)
Expand Down Expand Up @@ -134,7 +141,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 @@ -153,3 +160,81 @@ def test_search_config_perm_error(self):

with pytest.raises(SystemExit, match="1"):
cli_main()


@pytest.fixture
def home_config(tmp_path, mocker, monkeypatch):
"""Points the default config location at a throwaway home directory."""
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))
return censys_path, config_path


def test_write_config_creates_and_rewrites(home_config):
censys_path, config_path = home_config

# First write creates the directory and the file
write_config(get_config())
assert censys_path.is_dir()
assert config_path.is_file()

# Second write takes the existing-directory and existing-file branches
write_config(get_config())
assert get_config().get(DEFAULT, "color") == "auto"


def test_write_config_survives_unchmodable_path(home_config):
# 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).
_, config_path = home_config

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()


@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only")
def test_write_config_restricts_permissions(home_config):
censys_path, config_path = home_config
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_keeps_permissions_when_chmod_fails(home_config):
_, config_path = home_config

write_config(get_config())

with patch(
"censys.common.config.os.chmod",
side_effect=PermissionError(1, "Operation not permitted"),
):
write_config(get_config())

assert stat.S_IMODE(os.stat(config_path).st_mode) & 0o077 == 0
Loading