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:
Pascal Vizeli 2021-02-25 09:37:45 +01:00 committed by GitHub
parent dd561da819
commit 85d527bfbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 306 additions and 21 deletions

View File

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

View File

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

View File

@ -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

View File

@ -282,6 +282,13 @@ class JsonFileError(HassioError):
"""Invalid json file."""
# util/pwned
class PwnedError(HassioError):
"""Errors while checking pwned passwords."""
# docker/api

View File

@ -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."""

View 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]

View File

@ -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."""

View File

@ -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"):

View File

@ -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

View File

@ -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"

View File

@ -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:

View File

@ -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
View 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

View File

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

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