diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 8405e9302..fd2c68b54 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -425,7 +425,7 @@ class Addon(AddonModel): @property def devices(self) -> Set[Device]: - """Create a schema for add-on options.""" + """Extract devices from add-on options.""" raw_schema = self.data[ATTR_SCHEMA] if isinstance(raw_schema, bool) or not raw_schema: return set() @@ -437,6 +437,20 @@ class Addon(AddonModel): return options_validator.devices + @property + def pwned(self) -> Set[str]: + """Extract pwned data for add-on options.""" + raw_schema = self.data[ATTR_SCHEMA] + if isinstance(raw_schema, bool) or not raw_schema: + return set() + + # Validate devices + options_validator = AddonOptions(self.coresys, raw_schema, self.name, self.slug) + with suppress(vol.Invalid): + options_validator(self.options) + + return options_validator.pwned + def save_persist(self) -> None: """Save data of add-on.""" self.sys_addons.data.save_data() diff --git a/supervisor/addons/options.py b/supervisor/addons/options.py index c7bbf31cd..45a2f9951 100644 --- a/supervisor/addons/options.py +++ b/supervisor/addons/options.py @@ -1,4 +1,5 @@ """Add-on Options / UI rendering.""" +import hashlib import logging from pathlib import Path import re @@ -64,6 +65,7 @@ class AddonOptions(CoreSysAttributes): self.coresys: CoreSys = coresys self.raw_schema: Dict[str, Any] = raw_schema self.devices: Set[Device] = set() + self.pwned: Set[str] = set() self._name = name self._slug = slug @@ -136,6 +138,8 @@ class AddonOptions(CoreSysAttributes): range_args[group_name[2:]] = float(group_value) if typ.startswith(_STR) or typ.startswith(_PASSWORD): + if typ.startswith(_PASSWORD): + self.pwned.add(hashlib.sha1(str(value).encode()).hexdigest()) return vol.All(str(value), vol.Range(**range_args))(value) elif typ.startswith(_INT): return vol.All(vol.Coerce(int), vol.Range(**range_args))(value) diff --git a/supervisor/core.py b/supervisor/core.py index 77c79aed6..acf3b7477 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -243,6 +243,7 @@ class Core(CoreSysAttributes): # Upate Host/Deivce information self.sys_create_task(self.sys_host.reload()) self.sys_create_task(self.sys_updater.reload()) + self.sys_create_task(self.sys_resolution.healthcheck()) self.sys_create_task(self.sys_resolution.fixup.run_autofix()) self.state = CoreState.RUNNING diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index cdeddcc15..abf9fca9a 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -282,6 +282,13 @@ class JsonFileError(HassioError): """Invalid json file.""" +# util/pwned + + +class PwnedError(HassioError): + """Errors while checking pwned passwords.""" + + # docker/api diff --git a/supervisor/resolution/check.py b/supervisor/resolution/check.py index 810e77f59..1c5d6ad1e 100644 --- a/supervisor/resolution/check.py +++ b/supervisor/resolution/check.py @@ -3,6 +3,7 @@ import logging from typing import List from ..coresys import CoreSys, CoreSysAttributes +from .checks.addon_pwned import CheckAddonPwned from .checks.base import CheckBase from .checks.core_security import CheckCoreSecurity from .checks.free_space import CheckFreeSpace @@ -19,11 +20,12 @@ class ResolutionCheck(CoreSysAttributes): self._core_security = CheckCoreSecurity(coresys) self._free_space = CheckFreeSpace(coresys) + self._addon_pwned = CheckAddonPwned(coresys) @property def all_tests(self) -> List[CheckBase]: """Return all list of all checks.""" - return [self._core_security, self._free_space] + return [self._core_security, self._free_space, self._addon_pwned] async def check_system(self) -> None: """Check the system.""" diff --git a/supervisor/resolution/checks/addon_pwned.py b/supervisor/resolution/checks/addon_pwned.py new file mode 100644 index 000000000..b46bd3085 --- /dev/null +++ b/supervisor/resolution/checks/addon_pwned.py @@ -0,0 +1,85 @@ +"""Helpers to check core security.""" +from contextlib import suppress +from typing import List, Optional + +from ...const import AddonState, CoreState +from ...exceptions import PwnedError +from ...jobs.const import JobCondition +from ...jobs.decorator import Job +from ...utils.pwned import check_pwned_password +from ..const import ContextType, IssueType, SuggestionType +from .base import CheckBase + + +class CheckAddonPwned(CheckBase): + """CheckAddonPwned class for check.""" + + @Job(conditions=[JobCondition.INTERNET_SYSTEM]) + async def run_check(self) -> None: + """Run check if not affected by issue.""" + await self.sys_homeassistant.secrets.reload() + + for addon in self.sys_addons.installed: + secrets = addon.pwned + if not secrets: + continue + + # check passwords + for secret in secrets: + try: + if not await check_pwned_password(self.sys_websession, secret): + continue + except PwnedError: + continue + + # Check possible suggestion + if addon.state == AddonState.STARTED: + suggestions = [SuggestionType.EXECUTE_STOP] + else: + suggestions = None + + self.sys_resolution.create_issue( + IssueType.PWNED, + ContextType.ADDON, + reference=addon.slug, + suggestions=suggestions, + ) + break + + @Job(conditions=[JobCondition.INTERNET_SYSTEM]) + async def approve_check(self, reference: Optional[str] = None) -> bool: + """Approve check if it is affected by issue.""" + addon = self.sys_addons.get(reference) + + # Uninstalled + if not addon: + return False + + # Not in use anymore + secrets = addon.pwned + if not secrets: + return False + + # Check if still pwned + for secret in secrets: + with suppress(PwnedError): + if not await check_pwned_password(self.sys_websession, secret): + continue + return True + + return False + + @property + def issue(self) -> IssueType: + """Return a IssueType enum.""" + return IssueType.PWNED + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.ADDON + + @property + def states(self) -> List[CoreState]: + """Return a list of valid states when this check can run.""" + return [CoreState.RUNNING] diff --git a/supervisor/resolution/checks/base.py b/supervisor/resolution/checks/base.py index c3e176931..3146ede4b 100644 --- a/supervisor/resolution/checks/base.py +++ b/supervisor/resolution/checks/base.py @@ -6,7 +6,6 @@ from typing import List, Optional from ...const import CoreState from ...coresys import CoreSys, CoreSysAttributes from ..const import ContextType, IssueType -from ..data import Issue _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -24,31 +23,35 @@ class CheckBase(ABC, CoreSysAttributes): return # Check if system is affected by the issue - affected: Optional[Issue] = None + affected: bool = False for issue in self.sys_resolution.issues: if issue.type != self.issue or issue.context != self.context: continue - affected = issue - break + affected = True + + # Check if issue still exists + _LOGGER.debug( + "Run approve check for %s/%s - %s", + self.issue, + self.context, + issue.reference, + ) + if await self.approve_check(reference=issue.reference): + continue + self.sys_resolution.dismiss_issue(issue) # System is not affected - if affected is None: - _LOGGER.debug("Run check for %s/%s", self.issue, self.context) - await self.run_check() + if affected and not self.multiple: return - - # Check if issue still exists - if await self.approve_check(): - return - - self.sys_resolution.dismiss_issue(affected) + _LOGGER.info("Run check for %s/%s", self.issue, self.context) + await self.run_check() @abstractmethod async def run_check(self) -> None: """Run check if not affected by issue.""" @abstractmethod - async def approve_check(self) -> bool: + async def approve_check(self, reference: Optional[str] = None) -> bool: """Approve check if it is affected by issue.""" @property @@ -61,6 +64,11 @@ class CheckBase(ABC, CoreSysAttributes): def context(self) -> ContextType: """Return a ContextType enum.""" + @property + def multiple(self) -> bool: + """Return True if they can have multiple issues referenced by reference.""" + return self.context in (ContextType.ADDON, ContextType.PLUGIN) + @property def states(self) -> List[CoreState]: """Return a list of valid states when this check can run.""" diff --git a/supervisor/resolution/checks/core_security.py b/supervisor/resolution/checks/core_security.py index 82a680b5d..0c180a78a 100644 --- a/supervisor/resolution/checks/core_security.py +++ b/supervisor/resolution/checks/core_security.py @@ -1,7 +1,7 @@ """Helpers to check core security.""" from enum import Enum from pathlib import Path -from typing import List +from typing import List, Optional from awesomeversion import AwesomeVersion, AwesomeVersionException @@ -35,7 +35,7 @@ class CheckCoreSecurity(CheckBase): except (AwesomeVersionException, OSError): return - async def approve_check(self) -> bool: + async def approve_check(self, reference: Optional[str] = None) -> bool: """Approve check if it is affected by issue.""" try: if self.sys_homeassistant.version >= AwesomeVersion("2021.1.5"): diff --git a/supervisor/resolution/checks/free_space.py b/supervisor/resolution/checks/free_space.py index 7f692de58..ecc1eb6c1 100644 --- a/supervisor/resolution/checks/free_space.py +++ b/supervisor/resolution/checks/free_space.py @@ -1,6 +1,6 @@ """Helpers to check and fix issues with free space.""" import logging -from typing import List +from typing import List, Optional from ...const import SNAPSHOT_FULL, CoreState from ..const import ( @@ -46,7 +46,7 @@ class CheckFreeSpace(CheckBase): IssueType.FREE_SPACE, ContextType.SYSTEM, suggestions=suggestions ) - async def approve_check(self) -> bool: + async def approve_check(self, reference: Optional[str] = None) -> bool: """Approve check if it is affected by issue.""" if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD: return False diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index eb52423e4..a84037a15 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -56,6 +56,7 @@ class IssueType(str, Enum): UPDATE_ROLLBACK = "update_rollback" FATAL_ERROR = "fatal_error" DNS_LOOP = "dns_loop" + PWNED = "pwned" class SuggestionType(str, Enum): @@ -68,4 +69,5 @@ class SuggestionType(str, Enum): EXECUTE_RESET = "execute_reset" EXECUTE_RELOAD = "execute_reload" EXECUTE_REMOVE = "execute_remove" + EXECUTE_STOP = "execute_stop" REGISTRY_LOGIN = "registry_login" diff --git a/supervisor/resolution/notify.py b/supervisor/resolution/notify.py index ce4d42cf5..9517bd725 100644 --- a/supervisor/resolution/notify.py +++ b/supervisor/resolution/notify.py @@ -52,6 +52,14 @@ class ResolutionNotify(CoreSysAttributes): "notification_id": "supervisor_update_home_assistant_2021_1_5", } ) + if issue.type == IssueType.PWNED and issue.context == ContextType.ADDON: + messages.append( + { + "title": f"Insecure Secrets on {issue.reference}", + "message": f"The Add-on {issue.reference} uses secrets which are detected as not secure, see see https://www.home-assistant.io/more-info/pwned-passwords for more information.", + "notification_id": f"supervisor_issue_pwned_{issue.reference}", + } + ) for message in messages: try: diff --git a/supervisor/utils/dt.py b/supervisor/utils/dt.py index cfca9d760..c7c5e1760 100644 --- a/supervisor/utils/dt.py +++ b/supervisor/utils/dt.py @@ -26,7 +26,7 @@ DATETIME_RE = re.compile( ) -async def fetch_timezone(websession): +async def fetch_timezone(websession: aiohttp.ClientSession): """Read timezone from freegeoip.""" data = {} try: diff --git a/supervisor/utils/pwned.py b/supervisor/utils/pwned.py new file mode 100644 index 000000000..5693c0946 --- /dev/null +++ b/supervisor/utils/pwned.py @@ -0,0 +1,34 @@ +"""Small wrapper for haveibeenpwned.com API.""" +import asyncio +import io +import logging + +import aiohttp + +from ..exceptions import PwnedError + +_LOGGER: logging.Logger = logging.getLogger(__name__) +_API_CALL = "https://api.pwnedpasswords.com/range/{hash}" + + +async def check_pwned_password(websession: aiohttp.ClientSession, sha1_pw: str) -> bool: + """Check if password is pwned.""" + try: + async with websession.get( + _API_CALL.format(hash=sha1_pw[:5]), timeout=aiohttp.ClientTimeout(total=10) + ) as request: + if request.status != 200: + raise PwnedError() + data = await request.text() + + buffer = io.StringIO(data) + for line in buffer.readline(): + if sha1_pw != line.split(":")[0]: + continue + return True + + except (aiohttp.ClientError, asyncio.TimeoutError) as err: + _LOGGER.warning("Can't fetch hibp data: %s", err) + raise PwnedError() from err + + return False diff --git a/tests/addons/test_options.py b/tests/addons/test_options.py index 2b081b71f..ca58a82e2 100644 --- a/tests/addons/test_options.py +++ b/tests/addons/test_options.py @@ -166,6 +166,22 @@ def test_simple_device_schema(coresys): )({"name": "Pascal", "password": "1234", "input": "/dev/video1"}) +def test_simple_schema_password(coresys): + """Test with simple schema password pwned.""" + validate = AddonOptions( + coresys, + {"name": "str", "password": "password", "fires": "bool", "alias": "str?"}, + MOCK_ADDON_NAME, + MOCK_ADDON_SLUG, + ) + + assert validate( + {"name": "Pascal", "password": "1234", "fires": True, "alias": "test"} + ) + + assert validate.pwned == {"7110eda4d09e062aa5e4a390b0a572ac0d2c0220"} + + def test_ui_simple_schema(coresys): """Test with simple schema.""" assert UiOptions(coresys)( diff --git a/tests/resolution/check/test_check_addon_pwned.py b/tests/resolution/check/test_check_addon_pwned.py new file mode 100644 index 000000000..a76b5ba9e --- /dev/null +++ b/tests/resolution/check/test_check_addon_pwned.py @@ -0,0 +1,104 @@ +"""Test Check Addon Pwned.""" +# pylint: disable=import-error,protected-access +from unittest.mock import AsyncMock, patch + +from supervisor.const import AddonState, CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.checks.addon_pwned import CheckAddonPwned +from supervisor.resolution.const import IssueType, SuggestionType + + +class TestAddon: + """Test Addon.""" + + slug = "my_test" + pwned = set() + state = AddonState.STARTED + + +async def test_check(coresys: CoreSys): + """Test check.""" + addon_pwned = CheckAddonPwned(coresys) + coresys.core.state = CoreState.RUNNING + + addon = TestAddon() + coresys.addons.local[addon.slug] = addon + + assert len(coresys.resolution.issues) == 0 + + with patch( + "supervisor.resolution.checks.addon_pwned.check_pwned_password", + AsyncMock(return_value=True), + ) as mock: + await addon_pwned.run_check() + assert not mock.called + + addon.pwned.add("123456") + with patch( + "supervisor.resolution.checks.addon_pwned.check_pwned_password", + AsyncMock(return_value=False), + ) as mock: + await addon_pwned.run_check() + assert mock.called + + assert len(coresys.resolution.issues) == 0 + + with patch( + "supervisor.resolution.checks.addon_pwned.check_pwned_password", + AsyncMock(return_value=True), + ) as mock: + await addon_pwned.run_check() + assert mock.called + + assert len(coresys.resolution.issues) == 1 + assert coresys.resolution.issues[-1].type == IssueType.PWNED + assert coresys.resolution.issues[-1].reference == addon.slug + assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_STOP + assert coresys.resolution.suggestions[-1].reference == addon.slug + + +async def test_approve(coresys: CoreSys): + """Test check.""" + addon_pwned = CheckAddonPwned(coresys) + coresys.core.state = CoreState.RUNNING + + addon = TestAddon() + coresys.addons.local[addon.slug] = addon + addon.pwned.add("123456") + + with patch( + "supervisor.resolution.checks.addon_pwned.check_pwned_password", + AsyncMock(return_value=True), + ): + assert await addon_pwned.approve_check(reference=addon.slug) + + with patch( + "supervisor.resolution.checks.addon_pwned.check_pwned_password", + AsyncMock(return_value=False), + ): + assert not await addon_pwned.approve_check(reference=addon.slug) + + +async def test_did_run(coresys: CoreSys): + """Test that the check ran as expected.""" + addon_pwned = CheckAddonPwned(coresys) + should_run = addon_pwned.states + should_not_run = [state for state in CoreState if state not in should_run] + assert len(should_run) != 0 + assert len(should_not_run) != 0 + + with patch( + "supervisor.resolution.checks.addon_pwned.CheckAddonPwned.run_check", + return_value=None, + ) as check: + for state in should_run: + coresys.core.state = state + await addon_pwned() + check.assert_called_once() + check.reset_mock() + + for state in should_not_run: + coresys.core.state = state + await addon_pwned() + check.assert_not_called() + check.reset_mock()