Replace inventory-counting risk with bounded workload scoring and separate confidence
This commit is contained in:
243
samsara/analyzers/dependencies.py
Normal file
243
samsara/analyzers/dependencies.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user