From 588de92895aa7a9f9b46af06a9baa51ae032d9cc Mon Sep 17 00:00:00 2001 From: Jeremy Chapeau <113923302+resilience-jychp@users.noreply.github.com> Date: Mon, 17 Apr 2023 21:54:07 +0200 Subject: [PATCH] Add Lastpass support (#1083) This source will pull users from Lastpass It uses Human as pivot with other modules (GSuite, GitHub, HiBob ...) --- README.md | 1 + cartography/cli.py | 28 ++++++ cartography/config.py | 8 ++ cartography/intel/lastpass/__init__.py | 39 ++++++++ cartography/intel/lastpass/users.py | 85 +++++++++++++++++ cartography/models/lastpass/__init__.py | 0 cartography/models/lastpass/tenant.py | 17 ++++ cartography/models/lastpass/user.py | 76 +++++++++++++++ cartography/sync.py | 2 + docs/root/modules/lastpass/config.md | 9 ++ docs/root/modules/lastpass/index.rst | 12 +++ docs/root/modules/lastpass/schema.md | 45 +++++++++ tests/data/lastpass/__init__.py | 0 tests/data/lastpass/users.py | 44 +++++++++ .../cartography/intel/lastpass/__init__.py | 0 .../cartography/intel/lastpass/test_users.py | 92 +++++++++++++++++++ 16 files changed, 458 insertions(+) create mode 100644 cartography/intel/lastpass/__init__.py create mode 100644 cartography/intel/lastpass/users.py create mode 100644 cartography/models/lastpass/__init__.py create mode 100644 cartography/models/lastpass/tenant.py create mode 100644 cartography/models/lastpass/user.py create mode 100644 docs/root/modules/lastpass/config.md create mode 100644 docs/root/modules/lastpass/index.rst create mode 100644 docs/root/modules/lastpass/schema.md create mode 100644 tests/data/lastpass/__init__.py create mode 100644 tests/data/lastpass/users.py create mode 100644 tests/integration/cartography/intel/lastpass/__init__.py create mode 100644 tests/integration/cartography/intel/lastpass/test_users.py diff --git a/README.md b/README.md index ad07af837..9636001f9 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Start [here](https://lyft.github.io/cartography/install.html). - [PagerDuty](https://lyft.github.io/cartography/modules/pagerduty/index.html) - Users, teams, services, schedules, escalation policies, integrations, vendors - [Crowdstrike Falcon](https://lyft.github.io/cartography/modules/crowdstrike/index.html) - Hosts, Spotlight vulnerabilites, CVEs - [NIST CVE](https://lyft.github.io/cartography/modules/cve/index.html) - Common Vulnerabilities and Exposures (CVE) data from NIST database +- [Lastpass](https://lyft.github.io/cartography/modules/lastpass/index.html) - users ## Usage Start with our [tutorial](https://lyft.github.io/cartography/usage/tutorial.html). Our [data schema](https://lyft.github.io/cartography/usage/schema.html) is a helpful reference when you get stuck. diff --git a/cartography/cli.py b/cartography/cli.py index 2c0f08afe..edfcc0914 100644 --- a/cartography/cli.py +++ b/cartography/cli.py @@ -436,6 +436,22 @@ def _build_parser(self): 'The name of environment variable containing secrets for GSuite authentication.' ), ) + parser.add_argument( + '--lastpass-cid-env-var', + type=str, + default=None, + help=( + 'The name of environment variable containing the Lastpass CID for authentication.' + ), + ) + parser.add_argument( + '--lastpass-provhash-env-var', + type=str, + default=None, + help=( + 'The name of environment variable containing the Lastpass provhash for authentication.' + ), + ) return parser def main(self, argv: str) -> int: @@ -576,6 +592,18 @@ def main(self, argv: str) -> int: else: config.github_config = None + # Lastpass config + if config.lastpass_cid_env_var: + logger.debug(f"Reading CID for Lastpass from environment variable {config.lastpass_cid_env_var}") + config.lastpass_cid = os.environ.get(config.lastpass_cid_env_var) + else: + config.lastpass_cid = None + if config.lastpass_provhash_env_var: + logger.debug(f"Reading provhash for Lastpass from environment variable {config.lastpass_provhash_env_var}") + config.lastpass_provhash = os.environ.get(config.lastpass_provhash_env_var) + else: + config.lastpass_provhash = None + # Run cartography try: return cartography.sync.run_with_config(self.sync, config) diff --git a/cartography/config.py b/cartography/config.py index a18d5f5ad..950362e96 100644 --- a/cartography/config.py +++ b/cartography/config.py @@ -87,6 +87,10 @@ class Config: :param gsuite_auth_method: Auth method (delegated, oauth) used for Google Workspace. Optional. :type gsuite_config: str :param gsuite_config: Base64 encoded config object or config file path for Google Workspace. Optional. + :type lastpass_cid: str + :param lastpass_cid: Lastpass account ID. Optional. + :type lastpass_provhash: str + :param lastpass_provhash: Lastpass API KEY. Optional. """ def __init__( @@ -133,6 +137,8 @@ def __init__( crowdstrike_api_url=None, gsuite_auth_method=None, gsuite_config=None, + lastpass_cid=None, + lastpass_provhash=None, ): self.neo4j_uri = neo4j_uri self.neo4j_user = neo4j_user @@ -176,3 +182,5 @@ def __init__( self.crowdstrike_api_url = crowdstrike_api_url self.gsuite_auth_method = gsuite_auth_method self.gsuite_config = gsuite_config + self.lastpass_cid = lastpass_cid + self.lastpass_provhash = lastpass_provhash diff --git a/cartography/intel/lastpass/__init__.py b/cartography/intel/lastpass/__init__.py new file mode 100644 index 000000000..200356705 --- /dev/null +++ b/cartography/intel/lastpass/__init__.py @@ -0,0 +1,39 @@ +import logging + +import neo4j + +import cartography.intel.lastpass.users +from cartography.config import Config +from cartography.util import timeit + +logger = logging.getLogger(__name__) + + +@timeit +def start_lastpass_ingestion(neo4j_session: neo4j.Session, config: Config) -> None: + """ + If this module is configured, perform ingestion of Lastpass data. Otherwise warn and exit + :param neo4j_session: Neo4J session for database interface + :param config: A cartography.config object + :return: None + """ + + if not config.lastpass_cid or not config.lastpass_provhash: + logger.info( + 'Lastpass import is not configured - skipping this module. ' + 'See docs to configure.', + ) + return + + common_job_parameters = { + "UPDATE_TAG": config.update_tag, + "TENANT_ID": config.lastpass_cid, + } + + cartography.intel.lastpass.users.sync( + neo4j_session, + config.lastpass_provhash, + int(config.lastpass_cid), + config.update_tag, + common_job_parameters, + ) diff --git a/cartography/intel/lastpass/users.py b/cartography/intel/lastpass/users.py new file mode 100644 index 000000000..df5f09825 --- /dev/null +++ b/cartography/intel/lastpass/users.py @@ -0,0 +1,85 @@ +import logging +from typing import Any +from typing import Dict +from typing import List + +import neo4j +from dateutil import parser as dt_parse +from requests import Session + +from cartography.client.core.tx import load +from cartography.graph.job import GraphJob +from cartography.models.lastpass.tenant import LastpassTenantSchema +from cartography.models.lastpass.user import LastpassUserSchema +from cartography.util import timeit + +logger = logging.getLogger(__name__) +# Connect and read timeouts of 60 seconds each; see https://requests.readthedocs.io/en/master/user/advanced/#timeouts +_TIMEOUT = (60, 60) + + +@timeit +def sync( + neo4j_session: neo4j.Session, + lastpass_provhash: str, + tenant_id: int, + update_tag: int, + common_job_parameters: Dict[str, Any], +) -> None: + users = get(lastpass_provhash, tenant_id) + formated_users = transform(users) + load_users(neo4j_session, formated_users, tenant_id, update_tag) + cleanup(neo4j_session, common_job_parameters) + + +@timeit +def get(lastpass_provhash: str, tenant_id: int) -> Dict[str, Any]: + payload = { + 'cid': tenant_id, + 'provhash': lastpass_provhash, + 'cmd': 'getuserdata', + 'data': None, + } + session = Session() + req = session.post('https://lastpass.com/enterpriseapi.php', data=payload, timeout=_TIMEOUT) + req.raise_for_status() + return req.json() + + +@timeit +def transform(api_result: Dict[str, Any]) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + for uid, user in api_result['Users'].items(): + n_user = user.copy() + n_user['id'] = int(uid) + for k in ('created', 'last_pw_change', 'last_login'): + n_user[k] = int(dt_parse.parse(user[k]).timestamp() * 1000) if user[k] else None + result.append(n_user) + return result + + +def load_users( + neo4j_session: neo4j.Session, + data: List[Dict[str, Any]], + tenant_id: int, + update_tag: int, +) -> None: + + load( + neo4j_session, + LastpassTenantSchema(), + [{'id': tenant_id}], + lastupdated=update_tag, + ) + + load( + neo4j_session, + LastpassUserSchema(), + data, + lastupdated=update_tag, + TENANT_ID=tenant_id, + ) + + +def cleanup(neo4j_session: neo4j.Session, common_job_parameters: Dict[str, Any]) -> None: + GraphJob.from_node_schema(LastpassUserSchema(), common_job_parameters).run(neo4j_session) diff --git a/cartography/models/lastpass/__init__.py b/cartography/models/lastpass/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cartography/models/lastpass/tenant.py b/cartography/models/lastpass/tenant.py new file mode 100644 index 000000000..65e80c3ce --- /dev/null +++ b/cartography/models/lastpass/tenant.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass + +from cartography.models.core.common import PropertyRef +from cartography.models.core.nodes import CartographyNodeProperties +from cartography.models.core.nodes import CartographyNodeSchema + + +@dataclass(frozen=True) +class LastpassTenantNodeProperties(CartographyNodeProperties): + id: PropertyRef = PropertyRef('id') + lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True) + + +@dataclass(frozen=True) +class LastpassTenantSchema(CartographyNodeSchema): + label: str = 'LastpassTenant' + properties: LastpassTenantNodeProperties = LastpassTenantNodeProperties() diff --git a/cartography/models/lastpass/user.py b/cartography/models/lastpass/user.py new file mode 100644 index 000000000..20421c749 --- /dev/null +++ b/cartography/models/lastpass/user.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass + +from cartography.models.core.common import PropertyRef +from cartography.models.core.nodes import CartographyNodeProperties +from cartography.models.core.nodes import CartographyNodeSchema +from cartography.models.core.relationships import CartographyRelProperties +from cartography.models.core.relationships import CartographyRelSchema +from cartography.models.core.relationships import LinkDirection +from cartography.models.core.relationships import make_target_node_matcher +from cartography.models.core.relationships import OtherRelationships +from cartography.models.core.relationships import TargetNodeMatcher + + +@dataclass(frozen=True) +class LastpassUserNodeProperties(CartographyNodeProperties): + id: PropertyRef = PropertyRef('id') + lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True) + name: PropertyRef = PropertyRef('fullname') + email: PropertyRef = PropertyRef('username', extra_index=True) + created: PropertyRef = PropertyRef('created') + last_pw_change: PropertyRef = PropertyRef('last_pw_change') + last_login: PropertyRef = PropertyRef('last_login') + neverloggedin: PropertyRef = PropertyRef('neverloggedin') + disabled: PropertyRef = PropertyRef('disabled') + admin: PropertyRef = PropertyRef('admin') + totalscore: PropertyRef = PropertyRef('totalscore') + mpstrength: PropertyRef = PropertyRef('mpstrength') + sites: PropertyRef = PropertyRef('sites') + notes: PropertyRef = PropertyRef('notes') + formfills: PropertyRef = PropertyRef('formfills') + applications: PropertyRef = PropertyRef('applications') + attachments: PropertyRef = PropertyRef('attachments') + password_reset_required: PropertyRef = PropertyRef('password_reset_required') + multifactor: PropertyRef = PropertyRef('multifactor') + + +@dataclass(frozen=True) +class LastpassUserToHumanRelProperties(CartographyRelProperties): + lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True) + + +@dataclass(frozen=True) +# (:LastpassUser)<-[:IDENTITY_LASTPASS]-(:Human) +class LastpassHumanToUserRel(CartographyRelSchema): + target_node_label: str = 'Human' + target_node_matcher: TargetNodeMatcher = make_target_node_matcher( + {'email': PropertyRef('username')}, + ) + direction: LinkDirection = LinkDirection.INWARD + rel_label: str = "IDENTITY_LASTPASS" + properties: LastpassUserToHumanRelProperties = LastpassUserToHumanRelProperties() + + +@dataclass(frozen=True) +class LastpassTenantToLastpassUserRelProperties(CartographyRelProperties): + lastupdated: PropertyRef = PropertyRef('lastupdated', set_in_kwargs=True) + + +@dataclass(frozen=True) +# (:LastpassTenant)<-[:RESOURCE]-(:LastpassUser) +class LastpassTenantToUserRel(CartographyRelSchema): + target_node_label: str = 'LastpassTenant' + target_node_matcher: TargetNodeMatcher = make_target_node_matcher( + {'id': PropertyRef('TENANT_ID', set_in_kwargs=True)}, + ) + direction: LinkDirection = LinkDirection.OUTWARD + rel_label: str = "RESOURCE" + properties: LastpassTenantToLastpassUserRelProperties = LastpassTenantToLastpassUserRelProperties() + + +@dataclass(frozen=True) +class LastpassUserSchema(CartographyNodeSchema): + label: str = 'LastpassUser' + properties: LastpassUserNodeProperties = LastpassUserNodeProperties() + other_relationships: OtherRelationships = OtherRelationships(rels=[LastpassHumanToUserRel()]) + sub_resource_relationship: LastpassTenantToUserRel = LastpassTenantToUserRel() diff --git a/cartography/sync.py b/cartography/sync.py index b417bb286..c91c0ab2f 100644 --- a/cartography/sync.py +++ b/cartography/sync.py @@ -23,6 +23,7 @@ import cartography.intel.github import cartography.intel.gsuite import cartography.intel.kubernetes +import cartography.intel.lastpass import cartography.intel.oci import cartography.intel.okta from cartography.config import Config @@ -47,6 +48,7 @@ 'github': cartography.intel.github.start_github_ingestion, 'digitalocean': cartography.intel.digitalocean.start_digitalocean_ingestion, 'kubernetes': cartography.intel.kubernetes.start_k8s_ingestion, + 'lastpass': cartography.intel.lastpass.start_lastpass_ingestion, 'analysis': cartography.intel.analysis.run, }) diff --git a/docs/root/modules/lastpass/config.md b/docs/root/modules/lastpass/config.md new file mode 100644 index 000000000..3d8ebe753 --- /dev/null +++ b/docs/root/modules/lastpass/config.md @@ -0,0 +1,9 @@ +## Lastpass Configuration + +.. _lastpass_config: + +Follow these steps to analyze Lastpass objects with Cartography. + +1. Prepare your Lastpass CID & ProvHash key. + 1. Get your CID (account number) and ProvHash from Lastpass [Where can I find the CID and API secret?](https://support.lastpass.com/help/where-can-i-find-the-cid-and-api-secret) + 1. Populate an environment variable with the CID and Provhash. You can pass the environment variable name via CLI with the `--lastpass-cid-env-var` and `--lastpass-provhash-env-var` parameter. diff --git a/docs/root/modules/lastpass/index.rst b/docs/root/modules/lastpass/index.rst new file mode 100644 index 000000000..96a036e94 --- /dev/null +++ b/docs/root/modules/lastpass/index.rst @@ -0,0 +1,12 @@ +Lastpass +######## + +The lastpass module has the following coverage: + +* Users + +.. toctree:: + :hidden: + :glob: + + * diff --git a/docs/root/modules/lastpass/schema.md b/docs/root/modules/lastpass/schema.md new file mode 100644 index 000000000..cbbff9a5b --- /dev/null +++ b/docs/root/modules/lastpass/schema.md @@ -0,0 +1,45 @@ +## Lastpass Schema + +.. _lastpass_schema: + + +### Human + +Lastpass use Human node as pivot with other Identity Providers (GSuite, GitHub ...) + +Human nodes are not created by Lastpass module, link is made using analysis job. + + +#### Relationships + +- Human as an access to Lastpass + ``` + (Human)-[IDENTITY_LASTPASS]->(LastpassUser) + ``` + +### LastpassUser + +Representation of a single User in Lastpass + +| Field | Description | +|-------|--------------| +| firstseen| Timestamp of when a sync job first created this node | +| lastupdated | Timestamp of the last time the node was updated | +| id | Lastpass ID | +| name | Full name of the user | +| email | User email | +| created | Timestamp of when the account was created | +| last_pw_change | Timestamp of the last master password change | +| last_login | Timestamp of the last login | +| neverloggedin | Flag indicating the user never logged in | +| disabled | Flag indicating accout is disabled | +| admin | Flag for admin account | +| totalscore | Lastpass security score (max 100) | +| mpstrength | Master password strenght (max 100) | +| sites | Number of site credentials stored | +| notes | Number of secured notes stored | +| formfills | Number of forms stored | +| applications | Number of applications (mobile) stored | +| attachments | Number of file attachments stored | +| password_reset_required | Flag indicating user requested password reset | +| multifactor | MFA method (null if None) | diff --git a/tests/data/lastpass/__init__.py b/tests/data/lastpass/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/data/lastpass/users.py b/tests/data/lastpass/users.py new file mode 100644 index 000000000..c18100ccc --- /dev/null +++ b/tests/data/lastpass/users.py @@ -0,0 +1,44 @@ +LASTPASS_USERS = { + 'Users': { + 123456: { + "username": "john.doe@domain.tld", + "fullname": "John Doe", + "mpstrength": "100", + "created": "2022-08-31 04:45:15", + "last_pw_change": "2022-08-31 06:03:16", + "last_login": "", + "neverloggedin": False, + "disabled": False, + "admin": True, + "totalscore": "83.2", + "multifactor": "lastpassauth", + "duousername": "john.doe@domain.tld", + "sites": 25, + "notes": 1, + "formfills": 0, + "applications": 0, + "attachments": 0, + "password_reset_required": False, + }, + 234567: { + "username": "jane.smith@domain.tld", + "fullname": "Jane Smith", + "mpstrength": "60", + "created": "2023-03-18 12:42:03", + "last_pw_change": "2023-03-18 12:42:03", + "last_login": "2023-03-18 12:42:03", + "neverloggedin": False, + "disabled": True, + "admin": False, + "totalscore": "16.4", + "multifactor": "lastpassauth", + "duousername": "jane.smith@domain.tld", + "sites": 12, + "notes": 0, + "formfills": 0, + "applications": 0, + "attachments": 2, + "password_reset_required": False, + }, + }, +} diff --git a/tests/integration/cartography/intel/lastpass/__init__.py b/tests/integration/cartography/intel/lastpass/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/cartography/intel/lastpass/test_users.py b/tests/integration/cartography/intel/lastpass/test_users.py new file mode 100644 index 000000000..e81898eef --- /dev/null +++ b/tests/integration/cartography/intel/lastpass/test_users.py @@ -0,0 +1,92 @@ +from unittest.mock import patch + +import cartography.intel.lastpass.users +import tests.data.lastpass.users +from tests.integration.util import check_nodes +from tests.integration.util import check_rels + +TEST_UPDATE_TAG = 123456789 +TEST_TENANT_ID = 11223344 + + +@patch.object(cartography.intel.lastpass.users, 'get', return_value=tests.data.lastpass.users.LASTPASS_USERS) +def test_load_lastpass_users(mock_api, neo4j_session): + """ + Ensure that users actually get loaded + """ + + # Arrange + # LastPass intel only link users to existing Humans (created by other module like Gsuite) + # We have to create mock humans to tests rels are well created by Lastpass module + query = """ + UNWIND $UserData as user + + MERGE (h:Human{id: user}) + ON CREATE SET h.firstseen = timestamp() + SET h.email = user, + h.email = user, + h.lastupdated = $UpdateTag + """ + data = [] + for v in tests.data.lastpass.users.LASTPASS_USERS['Users'].values(): + data.append(v['username']) + neo4j_session.run( + query, + UserData=data, + UpdateTag=TEST_UPDATE_TAG, + ) + + # Act + cartography.intel.lastpass.users.sync( + neo4j_session, + 'fakeProvHash', + TEST_TENANT_ID, + TEST_UPDATE_TAG, + {"UPDATE_TAG": TEST_UPDATE_TAG, "TENANT_ID": TEST_TENANT_ID}, + ) + + # Assert Human exists + expected_nodes = { + ('john.doe@domain.tld', 'john.doe@domain.tld'), + ('jane.smith@domain.tld', 'jane.smith@domain.tld'), + } + assert check_nodes(neo4j_session, 'Human', ['id', 'email']) == expected_nodes + + # Assert Tenant exists + expected_nodes = { + (TEST_TENANT_ID,), + } + assert check_nodes(neo4j_session, 'LastpassTenant', ['id']) == expected_nodes + + # Assert Users exists + expected_nodes = { + (123456, 'john.doe@domain.tld'), + (234567, 'jane.smith@domain.tld'), + } + assert check_nodes(neo4j_session, 'LastpassUser', ['id', 'email']) == expected_nodes + + # Assert Users are connected with Tenant + expected_rels = { + (123456, TEST_TENANT_ID), + (234567, TEST_TENANT_ID), + } + assert check_rels( + neo4j_session, + 'LastpassUser', 'id', + 'LastpassTenant', 'id', + 'RESOURCE', + rel_direction_right=True, + ) == expected_rels + + # Assert Users are connected with Humans + expected_rels = { + (123456, 'john.doe@domain.tld'), + (234567, 'jane.smith@domain.tld'), + } + assert check_rels( + neo4j_session, + 'LastpassUser', 'id', + 'Human', 'email', + 'IDENTITY_LASTPASS', + rel_direction_right=False, + ) == expected_rels