Skip to content
Closed
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
29 changes: 26 additions & 3 deletions app/connectors_service/connectors/agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,42 @@
import asyncio
import functools
import signal
import sys

from elastic_agent_client.util.async_tools import (
sleeps_for_retryable,
)

from connectors.agent.component import ConnectorsAgentComponent
from connectors.agent.logger import get_logger
from connectors.fips import FIPSModeError

logger = get_logger("cli")

# Exit code used when the component stops because of an error, so that Agent can
# tell a failure apart from a clean shutdown
FAILURE_EXIT_CODE = 1


def main(args=None):
"""Script entry point into running Connectors Service on Agent.

It initialises an event loop, creates a component and runs the component.
Additionally, signals are handled for graceful termination of the component.

Returns:
int: FAILURE_EXIT_CODE if the component stopped because of an error,
None if it shut down cleanly.
"""
loop = asyncio.get_event_loop()
logger.info("Running agent")
component = ConnectorsAgentComponent()

try:
component = ConnectorsAgentComponent()
except FIPSModeError as e:
# Raised while reading FIPS mode from the environment
logger.error(f"Cannot start connectors agent component: {e}")
return FAILURE_EXIT_CODE

def _shutdown(signal_name):
sleeps_for_retryable.cancel(signal_name)
Expand All @@ -34,11 +50,18 @@ def _shutdown(signal_name):
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, functools.partial(_shutdown, sig.name))

return loop.run_until_complete(component.run())
try:
return loop.run_until_complete(component.run())
except FIPSModeError as e:
logger.error(f"Connectors agent component stopped: {e}")
return FAILURE_EXIT_CODE
except Exception as e:
logger.exception(f"Connectors agent component stopped with an error: {e}")
return FAILURE_EXIT_CODE


if __name__ == "__main__":
try:
main()
sys.exit(main())
finally:
logger.info("Bye")
9 changes: 9 additions & 0 deletions app/connectors_service/connectors/agent/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ async def run(self):
instance of Connectors Service with this configuration.

Additionally services for handling Check-in and Actions will be started to implement the protocol correctly.

Raises:
Exception: Whatever killed the Connectors Service, so that the process
exits with a failure instead of looking like a clean shutdown.
"""
logger.info("Starting connectors agent component")
client = new_v2_from_reader(self.buffer, self.ver, self.opts)
Expand All @@ -72,6 +76,11 @@ async def run(self):

await self.multi_service.run()

# MultiService returns without re-raising when one of its services dies,
# so ask the Connectors Service whether it stopped because of an error
if self.connector_service_manager.fatal_error is not None:
raise self.connector_service_manager.fatal_error

def stop(self, sig):
"""Shutdown everything running in the component.

Expand Down
4 changes: 4 additions & 0 deletions app/connectors_service/connectors/agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from connectors.agent.logger import get_logger
from connectors.config import add_defaults
from connectors.fips import fips_mode_from_env

logger = get_logger("config")

Expand All @@ -28,10 +29,13 @@ def __init__(self):
There's default config that allows us to run connectors service. When final
configuration is reported these defaults will be merged with defaults from
Connectors Service config and specific config coming from Agent.

Agent does not report FIPS mode, so it is read from the environment.
"""
self._default_config = {
"service": {
"log_level": "INFO",
"fips_mode": fips_mode_from_env(),
},
"connectors": [],
}
Expand Down
46 changes: 42 additions & 4 deletions app/connectors_service/connectors/agent/service_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

import connectors.agent.logger
from connectors.agent.logger import get_logger
from connectors.fips import (
FIPSModeError,
apply_fips_mode,
)
from connectors.services.base import (
ServiceAlreadyRunningError,
get_services,
Expand Down Expand Up @@ -41,6 +45,32 @@ def __init__(self, configuration):
self._multi_service = None
self._running = False
self._sleeps = CancellableSleeps()
# Set when the run loop dies. The component reads it so that Agent sees a
# failure instead of a clean exit.
self.fatal_error = None

def _apply_fips_mode(self, config):
"""Turn FIPS mode on when the configuration asks for it.

Thin wrapper over connectors.fips.apply_fips_mode that reports the failure
with the agent logger, which writes ECS logs that Agent can read.

Returns:
dict: The configuration to run with, with non-FIPS connectors removed
if FIPS mode is on. The input is left untouched.

Raises:
FIPSModeError: If FIPS mode is on but the system is not FIPS ready.
"""
try:
return apply_fips_mode(config)
except FIPSModeError as e:
# Never fall back to non-FIPS crypto, failing to start is the safe outcome
logger.error(
f"FIPS mode is enabled but the system is not FIPS ready: {e} "
"Refusing to start connector services."
)
raise

async def run(self):
"""Starts the running loop of the service.
Expand All @@ -62,22 +92,30 @@ async def run(self):
try:
logger.info("Starting connector services")
config = self._agent_config.get()
self._multi_service = get_services(
["schedule", "sync_content", "sync_access_control", "cleanup"],
config,
)

# Set the loggers up first, so that everything after this point
# (FIPS messages included) is logged in the format Agent expects
log_level = config.get("service", {}).get(
"log_level", logging.INFO
) # Log Level for connectors is managed like this
connectors_sdk.logger.set_logger(log_level, filebeat=True)
# Log Level for agent connectors component itself
connectors.agent.logger.update_logger_level(log_level)

config = self._apply_fips_mode(config)
self._multi_service = get_services(
["schedule", "sync_content", "sync_access_control", "cleanup"],
config,
)

await self._multi_service.run()
except Exception as e:
logger.exception(
f"Error while running services in ConnectorServiceManager: {e}"
)
# MultiService swallows the exception of the task that fails
# first, so hand it over to the component explicitly
self.fatal_error = e
raise
finally:
logger.info("Finished running, exiting")
Expand Down
75 changes: 73 additions & 2 deletions app/connectors_service/connectors/fips.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

from connectors_sdk.logger import logger

# Used to turn FIPS mode on when there is no config file, e.g. when running on Agent
FIPS_MODE_ENV_VAR = "ELASTICSEARCH_CONNECTORS_FIPS_MODE"

# Connectors that use NTLM or other non-FIPS-compliant algorithms
NON_FIPS_COMPLIANT_CONNECTORS = frozenset(
{
Expand All @@ -35,6 +38,36 @@ class FIPSModeError(Exception):
pass


def fips_mode_from_env() -> bool:
"""Read the FIPS mode setting from the environment.

The only accepted values are 'true' and 'false', case-insensitive. An unset
variable means FIPS mode is off.

Returns:
bool: True if the environment asks for FIPS mode, False otherwise.

Raises:
FIPSModeError: If the variable is set to anything else.
"""
raw_value = os.environ.get(FIPS_MODE_ENV_VAR, "")
value = raw_value.strip().lower()

if value == "true":
return True

# Unset means off, which is the default everywhere else too
if value in ("", "false"):
return False

# A typo must not silently turn FIPS mode off on an image built for FIPS
msg = (
f"{FIPS_MODE_ENV_VAR} is set to '{raw_value}', which is not a valid value. "
"Use 'true' to turn FIPS mode on, or 'false' to turn it off."
)
raise FIPSModeError(msg)


class FIPSConfig:
"""FIPS configuration and validation."""

Expand All @@ -50,8 +83,7 @@ def __new__(cls):
def is_fips_mode_enabled(cls) -> bool:
"""Check if FIPS mode is enabled via configuration or environment."""
if cls._fips_mode is None:
env_fips = os.environ.get("ELASTICSEARCH_CONNECTORS_FIPS_MODE", "").lower()
cls._fips_mode = env_fips == "true"
cls._fips_mode = fips_mode_from_env()
return cls._fips_mode

@classmethod
Expand Down Expand Up @@ -148,3 +180,42 @@ def validate_fips_mode():
raise FIPSModeError(msg)

logger.info(f"FIPS mode initialized. OpenSSL version: {ssl.OPENSSL_VERSION}")


def apply_fips_mode(config: dict) -> dict:
"""Turn FIPS mode on or off for this process, as the configuration asks.

This is the single place that puts FIPS mode into effect. Both entry points use
it: the standalone service (`connectors.service_cli`) and the service running
under Elastic Agent (`connectors.agent.service_manager`).

It does three things:
1. Store the requested mode, so the rest of the process can read it
2. Validate that the system's OpenSSL is in FIPS mode
3. Remove the connectors that cannot run under FIPS

Args:
config: The service configuration.

Returns:
dict: The configuration to run with. The input is left untouched. When FIPS
mode is on, the returned copy has the non-FIPS connectors removed.

Raises:
FIPSModeError: If FIPS mode is on but the system is not FIPS ready.
"""
fips_enabled = config.get("service", {}).get("fips_mode", False)
FIPSConfig.set_fips_mode(fips_enabled)

# Always say which mode we are in. Silence here reads as "FIPS is on" to an
# operator who set the environment variable but got the value wrong.
logger.info(f"FIPS mode is {'enabled' if fips_enabled else 'disabled'}")

validate_fips_mode()

if not fips_enabled:
return config

return config | {
"sources": filter_fips_compliant_sources(config.get("sources", {}))
}
16 changes: 4 additions & 12 deletions app/connectors_service/connectors/service_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@
from connectors.build_info import __build_info__
from connectors.config import load_config
from connectors.fips import (
FIPSConfig,
FIPSModeError,
filter_fips_compliant_sources,
validate_fips_mode,
apply_fips_mode,
)
from connectors.preflight_check import PreflightCheck
from connectors.services import get_services
Expand Down Expand Up @@ -133,16 +131,10 @@ def run(action, config_file, log_level, filebeat, service_type, uvloop):
logger.exception(f"{msg}.\n{e}")
raise ClickException(msg) from e

# Initialize FIPS mode from config
fips_enabled = config.get("service", {}).get("fips_mode", False)
FIPSConfig.set_fips_mode(fips_enabled)

# Enable FIPS mode if configured (validates OpenSSL)
# Enable FIPS mode if configured: validates OpenSSL and drops the connectors
# that cannot run under FIPS
try:
validate_fips_mode()
if fips_enabled:
# Filter out non-FIPS-compliant connectors
config["sources"] = filter_fips_compliant_sources(config.get("sources", {}))
config = apply_fips_mode(config)
except FIPSModeError as e:
set_logger(logging.ERROR, filebeat=filebeat)
msg = f"FIPS validation failed: {e}"
Expand Down
36 changes: 35 additions & 1 deletion app/connectors_service/tests/agent/test_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
# or more contributor license agreements. Licensed under the Elastic License 2.0;
# you may not use this file except in compliance with the Elastic License 2.0.
#
import os
import warnings
from unittest.mock import MagicMock, Mock
from unittest.mock import MagicMock, Mock, patch

import pytest
from elastic_transport import SecurityWarning
from google.protobuf import json_format
from google.protobuf.struct_pb2 import Struct

from connectors.agent.config import ConnectorsAgentConfigurationWrapper
from connectors.es.client import ESClient
from connectors.fips import FIPS_MODE_ENV_VAR, FIPSModeError

CONNECTOR_ID = "test-connector"
SERVICE_TYPE = "test-service-type"
Expand Down Expand Up @@ -430,3 +433,34 @@ def test_config_changed_when_connectors_did_not_change():
}

assert config_wrapper.config_changed(new_config) is False


def test_fips_mode_is_off_when_env_var_is_not_set():
with patch.dict(os.environ, {}, clear=True):
config_wrapper = ConnectorsAgentConfigurationWrapper()

assert config_wrapper.get()["service"]["fips_mode"] is False


@pytest.mark.parametrize("value", ["true", "TRUE", " true "])
def test_fips_mode_is_on_when_env_var_is_true(value):
with patch.dict(os.environ, {FIPS_MODE_ENV_VAR: value}, clear=True):
config_wrapper = ConnectorsAgentConfigurationWrapper()

assert config_wrapper.get()["service"]["fips_mode"] is True


@pytest.mark.parametrize("value", ["false", "FALSE", ""])
def test_fips_mode_is_off_when_env_var_is_false(value):
with patch.dict(os.environ, {FIPS_MODE_ENV_VAR: value}, clear=True):
config_wrapper = ConnectorsAgentConfigurationWrapper()

assert config_wrapper.get()["service"]["fips_mode"] is False


@pytest.mark.parametrize("value", ["ture", "enabled", "yes", "1"])
def test_startup_fails_when_env_var_value_is_not_recognised(value):
"""A typo must not silently turn FIPS mode off."""
with patch.dict(os.environ, {FIPS_MODE_ENV_VAR: value}, clear=True):
with pytest.raises(FIPSModeError):
ConnectorsAgentConfigurationWrapper()
Loading
Loading