Skip to content

Commit

Permalink
Add Lastpass support (#1083)
Browse files Browse the repository at this point in the history
This source will pull users from Lastpass

It uses Human as pivot with other modules (GSuite, GitHub, HiBob ...)
  • Loading branch information
jychp authored Apr 17, 2023
1 parent 97f64db commit 588de92
Show file tree
Hide file tree
Showing 16 changed files with 458 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions cartography/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions cartography/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
39 changes: 39 additions & 0 deletions cartography/intel/lastpass/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
)
85 changes: 85 additions & 0 deletions cartography/intel/lastpass/users.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
17 changes: 17 additions & 0 deletions cartography/models/lastpass/tenant.py
Original file line number Diff line number Diff line change
@@ -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()
76 changes: 76 additions & 0 deletions cartography/models/lastpass/user.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions cartography/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
})

Expand Down
9 changes: 9 additions & 0 deletions docs/root/modules/lastpass/config.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions docs/root/modules/lastpass/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Lastpass
########

The lastpass module has the following coverage:

* Users

.. toctree::
:hidden:
:glob:

*
45 changes: 45 additions & 0 deletions docs/root/modules/lastpass/schema.md
Original file line number Diff line number Diff line change
@@ -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) |
Empty file added tests/data/lastpass/__init__.py
Empty file.
Loading

0 comments on commit 588de92

Please sign in to comment.