Files
Samsara/samsara/analyzers/dependencies.py

244 lines
18 KiB
Python

"""Correlate workload evidence without altering collected inventory.
A TCP snapshot cannot prove connection initiation. Listener/PID inference is
explicitly labeled; a confirmed finding describes observed application transport
activity, not verified business ownership or permission to retire.
"""
from collections import defaultdict
import ipaddress
import re
CONFIRMED = 'Confirmed workload dependency'
POTENTIAL = 'Potential workload dependency'
STANDARD = 'Standard operating system activity'
UNKNOWN = 'Unknown / insufficient evidence'
FAMILIES = {
'directory': ('Directory services', ('ntds',), (), ()),
'sql': ('SQL Server', ('mssqlserver','sqlserveragent'), ('mssql$','sqlagent$'), ('sqlservr',)),
'iis': ('IIS application hosting', ('w3svc',), (), ('w3wp','iisexpress')),
'web': ('Web application hosting', ('nginx','apache2','httpd','haproxy','tomcat'), ('tomcat',), ('nginx','apache2','httpd','haproxy','tomcat')),
'postgres': ('PostgreSQL', ('postgresql',), ('postgresql-',), ('postgres','postmaster')),
'mysql': ('MySQL / MariaDB', ('mysql','mysqld','mariadb'), ('mysql','mariadb'), ('mysqld','mariadbd')),
'redis': ('Redis', ('redis','redis-server'), ('redis-',), ('redis-server',)),
'containers': ('Container workloads', ('docker','containerd','podman'), (), ('dockerd','containerd','podman')),
'dns': ('DNS server', ('dns','named','bind9'), (), ('named',)),
'dhcp': ('DHCP server', ('dhcpserver','isc-dhcp-server','kea-dhcp4'), (), ('dhcpd','kea-dhcp4')),
'ca': ('Certificate authority', ('certsvc',), (), ()),
'file-sharing': ('Business file shares', ('smbd','smb','nfs-server'), (), ('smbd','nfsd')),
'game-server': ('Dedicated game server', ('minecraft','minecraft-server','valheim','rustserver'), ('minecraft@','valheim-server'), ('srcds','srcds_linux','hlds','valheim_server','rustdedicated','arkascendedserver','shootergameserver','palserver','bedrock_server')),
}
ROLE_FAMILIES = {'web-server':'iis','dns':'dns','dhcp':'dhcp','adcs-cert-authority':'ca',
'ad-domain-services':'directory','hyper-v':'virtualization','failover-clustering':'clustering',
'rds-rd-server':'remote-desktop'}
TITLES = {key: value[0] for key, value in FAMILIES.items()}
TITLES.update({'directory':'Directory services','virtualization':'Virtualization','clustering':'Failover clustering',
'remote-desktop':'Remote desktop workloads','scheduled-workloads':'Custom scheduled workloads'})
STANDARD_SERVICES = set('''bfe bits brokerinfrastructure cbdhsvc cdpsvc cdpsusersvc clipsvc coremessagingregistrar cryptsvc
dcomlaunch defragsvc deviceassociationservice dhcp diagnosticshub.standardcollector.service diagtrack dnscache dps
dusmsvc eventlog eventsystem fdphost fdrespub fontcache gpsvc hidserv hvhost iphlpsvc keyiso ktmrm lanmanserver
lanmanworkstation lfsvc licensemanager lmhosts lsm mpssvc msdtc netlogon netman netprofm nlasvc nsi pcasvc
plugplay policyagent power profsvc rasman remoteaccess remoteregistry rpcss rpceptmapper samss schedule seclogon
securityhealthservice sens sense sessionenv shellhwdetection spooler sppsvc ssdpSrv stisvc storSvc swprv sysmain
systemeventsbroker termservice themes timebrokersvc trustedinstaller ualsvc uhssvc umrdpservice usosvc vds vmicheartbeat
vmickvpexchange vmicshutdown vmictimesync vmicvss vss w32time w3logsvc was wdiservicehost wdisystemhost wdnisSvc
wecsvc wersvc winDefend winhttpautoproxysvc winmgmt winrm wlidsvc wpnservice wsearch wuauserv
sqlwriter acpid auditd chronyd crond cron dbus firewalld irqbalance multipathd polkit rsyslog ssh sshd tuned udisks2'''.lower().split())
STANDARD_PROCESS = {'system','idle','svchost','lsass','services','wininit','winlogon','smss','spoolsv','msmpeng','wmiprvse','wsmprovhost','sshd'}
CLIENT_PROCESS = {'steam','steamservice','steamwebhelper','chrome','msedge','firefox','iexplore','brave','opera','curl','wget'}
MANAGEMENT_PORTS = {22,135,137,138,139,445,3389,5357,5985,5986}
STANDARD_TASK_HINTS = ('apt','dpkg','logrotate','man-db','fstrim','updatedb','systemd-','dnf-','yum-','fwupd','packagekit','run-parts','anacron')
def normal_name(value):
return str(value or '').replace('\\','/').rsplit('/',1)[-1].lower().removesuffix('.exe').removesuffix('.service')
def service_family(value):
name = normal_name(value)
for key, (_, names, prefixes, _) in FAMILIES.items():
if name in names or any(name.startswith(prefix) for prefix in prefixes):
return key
return None
def process_family(value):
name = normal_name(value)
return next((key for key, (_, _, _, names) in FAMILIES.items() if name in names), None)
def is_standard_service(name):
original = str(name).lower()
name = normal_name(name)
return original in STANDARD_SERVICES or name in STANDARD_SERVICES or name.startswith(('systemd-','vmic','onesyncsvc_','cbdhsvc_','cdpusersvc_','wpnuserservice_'))
def administrative_share(row):
return row.get('type') in ('SMB Share','Samba Share') and bool(re.fullmatch(r'(?:admin|ipc|[a-z])\$', str(row.get('name','')), re.I))
def address(value):
try:
result = ipaddress.ip_address(str(value).strip('[]').split('%')[0])
return result.ipv4_mapped if isinstance(result, ipaddress.IPv6Address) and result.ipv4_mapped else result
except ValueError:
return None
def known_pid(row):
value = row.get('pid')
return value if type(value) is int and value > 0 else None
def classify_dependencies(data):
inventory = data['inventory']
groups = {}
aggregates = defaultdict(list)
network_observations, task_observations = [], []
def evidence(group, rule, text, ref, confirmed=False):
entry = groups.setdefault(group, {'id':group, 'title':TITLES.get(group,group), 'rules':set(),
'evidence':{}, 'evidence_rules':{}, 'confirmed':False})
entry['rules'].add(rule)
entry['evidence'][ref] = text
entry['evidence_rules'][ref] = rule
entry['confirmed'] |= confirmed
def note(category, classification, text, ref):
aggregates[(category,classification)].append((ref,text))
services = inventory.get('services',[])
pid_families = defaultdict(set)
running_families = set()
for index, row in enumerate(services):
ref = f'services[{index}]'
family = service_family(row['name'])
running = str(row.get('state','')).lower() == 'running'
if family and running:
running_families.add(family)
if known_pid(row):
pid_families[known_pid(row)].add(family)
evidence(family,'running_application_service',f"Running service {row['name']}; account {row.get('account','not collected')}. Ownership/use requires review.",ref)
elif family:
note('Historical workload hints',UNKNOWN,f"{row['name']} is {row.get('state','unknown')}; no active service evidence.",ref)
elif is_standard_service(row['name']):
note('OS services',STANDARD,f"{row['name']} ({row.get('state','unknown')}) is a baseline service, not hosted workload evidence.",ref)
elif normal_name(row['name']) in CLIENT_PROCESS or 'steam client' in str(row.get('display_name','')).lower():
note('Client activity',UNKNOWN,f"{row['name']} is a client component, not evidence of a dedicated server.",ref)
else:
note('Unclassified services',UNKNOWN,f"{row['name']}: no workload rule; review service ownership.",ref)
for index, row in enumerate(inventory.get('roles',[])):
name = row['name'].lower()
ref = f'roles[{index}]'
family = ROLE_FAMILIES.get(name)
if family and str(row.get('state','Installed')).lower() in ('installed','enabled'):
evidence(family,'installed_workload_role',f"Installed role {row['name']}; role installation alone does not prove configured or active workloads.",ref)
else:
standard = name.startswith(('net-framework','powershell','windows-defender','defender','storage-services','file-services','fs-fileserver','wow64','telnet-client','rsat','windows-search'))
note('Installed components',STANDARD if standard else UNKNOWN,f"{row['name']}: installation is not application-use evidence.",ref)
if data['server'].get('platform') == 'windows' and data['server'].get('product_type') == 2:
evidence('directory','verified_domain_controller','CIM ProductType=2 identifies this host as a domain controller. Validate AD replication, FSMO, DNS, authentication and retirement procedures.','server.product_type',True)
business_shares = []
for index,row in enumerate(inventory.get('storage',[])):
ref = f'storage[{index}]'
if administrative_share(row):
note('Administrative shares',STANDARD,f"{row['name']} is a default administrative share; retained in inventory with no workload score.",ref)
elif row.get('type') in ('SMB Share','Samba Share','NFS Export'):
business_shares.append(row)
evidence('file-sharing','configured_business_share',f"Configured non-administrative share {row['name']}: {row['path']}; consumers/use not established.",ref)
if row.get('activity_source') == 'Get-SmbOpenFile (share-root-correlated)' and type(row.get('observed_open_files')) is int and row['observed_open_files'] > 0:
evidence('file-sharing','observed_business_share_clients',f"{row['name']}: {row['observed_open_files']} open files share-root-correlated by Get-SmbOpenFile at {row.get('activity_observed_at','an unspecified time')}. Current SMB use observed; owner approval still required.",ref+':client_activity',True)
elif row.get('type') == 'Remote Mount':
note('Remote storage consumers',UNKNOWN,f"{row['name']} mounted at {row['path']}; outbound storage use is not evidence of inbound consumers.",ref)
for index,row in enumerate(inventory.get('software',[])):
name = row['name']
if any(term in name.lower() for term in ('server','sql','nginx','apache','minecraft','valheim','rust','steam')):
note('Installed software hints',UNKNOWN,f"Installed {name}; installation alone does not prove an active hosted workload.",f'software[{index}]')
for index,row in enumerate(inventory.get('tasks',[])):
ref = f'tasks[{index}]'
path,name = str(row.get('path','')).lower(), str(row['name']).lower()
enabled = bool(row.get('enabled')) or str(row.get('state','')).lower() == 'running'
family = service_family(name) or process_family(row.get('executables',row.get('executable','')))
task_name = normal_name(name.split(' (',1)[0])
maintenance = any(task_name == hint.rstrip('-') or task_name.startswith((hint.rstrip('-')+'-',hint.rstrip('-')+'.')) for hint in STANDARD_TASK_HINTS)
windows_path = path.replace('/','\\')
os_namespace = windows_path.startswith('\\microsoft\\windows\\') and not windows_path.startswith('\\microsoft\\windows\\powershell\\scheduledjobs')
standard = not family and (os_namespace or maintenance)
classification = STANDARD if standard else POTENTIAL if enabled else UNKNOWN
if enabled and not standard:
group = family or 'scheduled-workloads'
evidence(group,'configured_custom_schedule',f"Enabled task {row['name']} at {row.get('path','unknown path')}; schedule: {row.get('triggers','not collected')}. Configuration does not confirm execution.",ref)
else:
note('OS scheduled maintenance' if standard else 'Disabled schedules',classification,f"{row['name']}: routine maintenance or disabled schedule; no workload points.",ref)
task_observations.append({'inventory_index':index,'classification':classification,'enabled':enabled})
rows = inventory.get('network',[])
listener_index = {}
own_addresses = {address(value) for value in data['server'].get('ip_addresses',[])} - {None}
for index,row in enumerate(rows):
local = address(row.get('local_address'))
if local and not local.is_unspecified:
own_addresses.add(local)
if str(row.get('state','')).lower() in ('listen','listening'):
if local is not None and known_pid(row):
listener_index.setdefault((row.get('local_port'),known_pid(row),local),index)
def row_family(row):
family = process_family(row.get('process',''))
choices = pid_families.get(known_pid(row),set())
if family:
return family
return next(iter(choices)) if len(choices)==1 else None
for index,row in enumerate(rows):
ref = f'network[{index}]'
state = str(row.get('state','')).lower()
proc = normal_name(row.get('process',''))
remote,local = address(row.get('remote_address')), address(row.get('local_address'))
matching = []
if state in ('established','estab') and local is not None and known_pid(row):
wildcard = ipaddress.ip_address('0.0.0.0' if local.version==4 else '::')
for endpoint in {local,wildcard}:
match = listener_index.get((row.get('local_port'),known_pid(row),endpoint))
if match is not None:
matching.append(match)
matching.sort()
direction, basis = 'Unknown', 'No reliable direction evidence; a TCP snapshot does not record the initiating handshake.'
if state in ('listen','listening'):
direction,basis = 'Listener','Local listening endpoint; not proof of a consumer.'
elif remote and (remote.is_loopback or remote in own_addresses):
direction,basis = 'Loopback / same host','Remote address is loopback or belongs to this host; no external client inferred.'
elif str(row.get('direction','')).lower() in ('inbound','outbound'):
direction = str(row['direction']).title() + ' (reported)'
basis = 'Direction supplied by inventory source; not independently verified.'
elif state in ('established','estab') and matching:
direction,basis = 'Inbound (inferred)','Local address/port and nonzero PID match a listener. Client initiation is inferred, not observed.'
elif state in ('established','estab') and remote and type(row.get('local_port')) is int and row['local_port']>=49152 and 0 < row.get('remote_port',0)<49152:
direction,basis = 'Outbound (inferred)','Ephemeral local port to a non-ephemeral remote port without an attributed listener; heuristic only.'
family = row_family(row)
classification, rationale = UNKNOWN, 'No attributed application workload. No score from the connection alone.'
if proc in CLIENT_PROCESS:
family = None
rationale = 'Known client process (including browsers/Steam); not a dedicated server workload.'
note('Client activity',UNKNOWN,rationale,ref)
elif family:
if direction == 'Listener':
evidence(family,'attributed_application_listener',f"{row.get('process','unknown')} PID {row.get('pid','unknown')} listening at {row.get('local_address')}:{row.get('local_port')}; a listener alone is potential exposure.",ref)
classification,rationale = POTENTIAL,'Listener attributed to a known application; grouped with service evidence.'
elif state in ('established','estab') and direction.startswith('Inbound') and matching and remote and not remote.is_unspecified:
if family == 'file-sharing':
evidence(family,'observed_smb_transport',f"File-sharing transport observed on local port {row.get('local_port')}; specific share/export use is unverified without correlated open files.",ref)
classification,rationale = POTENTIAL,'File-sharing transport observed; no named business share attributed.'
else:
evidence(family,'observed_application_clients',f"Observed application TCP activity {row.get('local_address')}:{row.get('local_port')} ↔ {row.get('remote_address')}:{row.get('remote_port')}, PID {row.get('pid')}; matches listener(s) {matching}. {basis} Business ownership remains unverified.",ref,True)
classification,rationale = CONFIRMED,'Observed transport activity on an attributed application listener; direction inference is recorded separately.'
else:
note('Other application traffic',UNKNOWN,f"{direction}: {row.get('process','unknown')}; not scored as an inbound consumer. {basis}",ref)
elif business_shares and row.get('local_port') == 445 and (direction == 'Listener' or direction.startswith('Inbound')) and proc in ('system','smbd','unknown','unavailable or process exited'):
family='file-sharing'
if state in ('established','estab') and matching:
evidence(family,'observed_smb_transport',f"SMB TCP activity observed on local port 445 with matching listener PID {row.get('pid')}; the accessed share is unknown and could be administrative. Do not equate this with business-share use.",ref)
classification,rationale = POTENTIAL,'SMB transport plus configured business shares; accessed share unverified without open-file evidence.'
elif proc in STANDARD_PROCESS and (row.get('local_port') in MANAGEMENT_PORTS or (type(row.get('local_port')) is int and row['local_port']>=49152)):
classification,rationale=STANDARD,'Baseline OS/management endpoint; no application workload attributed.'
note('OS networking',STANDARD,rationale,ref)
else:
note('Unattributed networking',UNKNOWN,f"{direction}: {row.get('local_address')}:{row.get('local_port')} ↔ {row.get('remote_address')}:{row.get('remote_port')}. {basis}",ref)
network_observations.append({'inventory_index':index,'direction':direction,'direction_basis':basis,
'classification':classification,'workload':family or '', 'rationale':rationale})
return groups, aggregates, network_observations, task_observations