244 lines
18 KiB
Python
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
|