mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-24 09:36:31 +00:00
Add check for pwned secrets to resolution center (#2614)
* Add check for pwned secrets to resolution center * restructure check * add checks * Add test * Add test * reload secrets before check * simplify * create notification * Use own exceptions * Check on startup * Apply suggestions from code review Co-authored-by: Franck Nijhof <git@frenck.dev> * Add job decorator * Update supervisor/resolution/notify.py Co-authored-by: Franck Nijhof <git@frenck.dev> * Update supervisor/utils/pwned.py Co-authored-by: Franck Nijhof <git@frenck.dev> Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
parent
dd561da819
commit
85d527bfbc
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -282,6 +282,13 @@ class JsonFileError(HassioError):
|
||||
"""Invalid json file."""
|
||||
|
||||
|
||||
# util/pwned
|
||||
|
||||
|
||||
class PwnedError(HassioError):
|
||||
"""Errors while checking pwned passwords."""
|
||||
|
||||
|
||||
# docker/api
|
||||
|
||||
|
||||
|
@ -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."""
|
||||
|
85
supervisor/resolution/checks/addon_pwned.py
Normal file
85
supervisor/resolution/checks/addon_pwned.py
Normal file
@ -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]
|
@ -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."""
|
||||
|
@ -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"):
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
34
supervisor/utils/pwned.py
Normal file
34
supervisor/utils/pwned.py
Normal file
@ -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
|
@ -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)(
|
||||
|
104
tests/resolution/check/test_check_addon_pwned.py
Normal file
104
tests/resolution/check/test_check_addon_pwned.py
Normal file
@ -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()
|
Loading…
x
Reference in New Issue
Block a user