Day 17/90: Working with Processes -- Python Security
90 Day Python Security Scripting Challenge
Process analysis is core to incident response on any operating system. Today we use psutil to build practical triage tooling.
Process Inventory with Security Context
During triage you need more than just a process name. You need the owning user, the full command line, parent PID for process tree reconstruction, and the executable path to verify it is running from a legitimate location.
import psutil
from datetime import datetime
def security_inventory():
"""Build a process inventory with fields relevant to IR triage."""
inventory = []
for proc in psutil.process_iter(
['pid', 'ppid', 'name', 'exe', 'username', 'cmdline', 'create_time']
):
try:
info = proc.info
inventory.append({
'pid': info['pid'],
'ppid': info['ppid'],
'name': info['name'],
'exe_path': info['exe'],
'user': info['username'],
'cmdline': ' '.join(info['cmdline'] or []),
'age_seconds': (
datetime.now() - datetime.fromtimestamp(info['create_time'])
).total_seconds(),
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return inventory
recent = [p for p in security_inventory() if p['age_seconds'] < 300]
for p in recent:
exe = p['exe_path'] or 'unknown'
suspicious_paths = ['/tmp', '/dev/shm', 'AppData/Local/Temp']
flag = ' [SUSPICIOUS PATH]' if any(sp in exe for sp in suspicious_paths) else ''
print(f"NEW: PID={p['pid']} user={p['user']} exe={exe}{flag}")
Adding parent PID lets you reconstruct process trees. If PID 4500 spawned from PID 1200 which is svchost.exe, that tells a different story than if it spawned from cmd.exe running under a web server user.
Continuous Resource Monitoring
A single snapshot misses intermittent activity. Real monitoring samples repeatedly and builds a statistical baseline.
import psutil
import time
import statistics
def monitor_resources(duration=60, sample_interval=5):
"""Monitor process resources over a time window and report outliers."""
cpu_history = {}
for elapsed in range(0, duration, sample_interval):
for proc in psutil.process_iter(['pid', 'name']):
try:
cpu = proc.cpu_percent(interval=0.05)
pid = proc.info['pid']
if pid not in cpu_history:
cpu_history[pid] = {'name': proc.info['name'], 'samples': []}
cpu_history[pid]['samples'].append(cpu)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
time.sleep(sample_interval)
outliers = []
for pid, data in cpu_history.items():
if len(data['samples']) >= 3:
avg = statistics.mean(data['samples'])
if avg > 25:
outliers.append({
'pid': pid, 'name': data['name'],
'avg_cpu': round(avg, 1),
'samples': len(data['samples'])
})
return sorted(outliers, key=lambda x: x['avg_cpu'], reverse=True)
for proc in monitor_resources(duration=30, sample_interval=5):
print(f"OUTLIER: {proc['name']} PID={proc['pid']} avg_cpu={proc['avg_cpu']}%")
Network Connection Forensics
The most common question during an incident: what is this process talking to?
import psutil
def external_connections(
private_prefixes=('10.', '172.16.', '172.17.', '172.18.', '172.19.',
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.',
'172.25.', '172.26.', '172.27.', '172.28.', '172.29.',
'172.30.', '172.31.', '192.168.', '127.')
):
"""Find processes with connections to non-private IP addresses."""
external = []
for conn in psutil.net_connections(kind='inet'):
if not conn.raddr or conn.pid is None:
continue
rip = conn.raddr.ip
if any(rip.startswith(prefix) for prefix in private_prefixes):
continue
try:
proc = psutil.Process(conn.pid)
external.append({
'pid': conn.pid,
'process': proc.name(),
'user': proc.username(),
'remote': f"{rip}:{conn.raddr.port}",
'status': conn.status,
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
external.append({
'pid': conn.pid, 'process': 'unknown',
'remote': f"{rip}:{conn.raddr.port}",
'status': conn.status
})
return external
for conn in external_connections():
print(f"{conn['process']} (PID {conn['pid']}) -> {conn['remote']} [{conn['status']}]")
Safe Process Termination
The IR-safe kill pattern: freeze, document, terminate.
import psutil
import json
from pathlib import Path
from datetime import datetime, timezone
def ir_terminate(pid, case_id, analyst):
"""Freeze process, capture state, then terminate with chain of custody."""
proc = psutil.Process(pid)
proc.suspend()
state = {
'case_id': case_id,
'analyst': analyst,
'timestamp': datetime.now(timezone.utc).isoformat(),
'process': {
'pid': pid, 'name': proc.name(),
'exe': proc.exe(), 'cmdline': proc.cmdline(),
'user': proc.username()
},
'network': [
{'remote': f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else None}
for c in proc.net_connections()
],
'files': [f.path for f in proc.open_files()],
'children': [c.pid for c in proc.children(recursive=True)],
}
evidence_dir = Path('evidence') / case_id
evidence_dir.mkdir(parents=True, exist_ok=True)
evidence_file = evidence_dir / f"proc_{pid}.json"
evidence_file.write_text(json.dumps(state, indent=2))
proc.terminate()
return str(evidence_file)
Key Takeaways
Process triage with psutil gives you structured, cross-platform data that is far more reliable than parsing command output. The suspend-snapshot-terminate pattern preserves evidence. Multi-sample resource monitoring reduces false positives compared to single snapshots.
Day 17/90 of the 90 Day Python Security Scripting Challenge
Top comments (0)