Python’s object-oriented model is quietly one of the reasons the language became the default for security tooling. Decorators, ABCs, composition, and properties aren’t academic ceremony; they’re what let a codebase stay readable when it grows past a single-file script into a real tool with tests, plugins, and multiple contributors. This post walks through the advanced OOP features you’ll actually reach for, using worked examples pulled from security work: scanner hierarchies, analyzers, detectors, and instrumentation.
Assumes you’re comfortable with Python’s syntax and basic classes and inheritance already.
Decorators#
Decorators modify or extend the behavior of a function or method without touching its source. They’re how Python handles cross-cutting concerns like logging, caching, retry logic, and access control without scattering that logic through every function that needs it.
Function decorators#
A function decorator is a function that takes another function as input and returns a new function wrapping it. A canonical example is timing:
import time
def timing_decorator(func):
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"{func.__name__} took {end_time - start_time:.5f} seconds to execute.")
return result
return wrapper
@timing_decorator
def slow_function():
time.sleep(1)
return "I am a slow function."
print(slow_function())Here, timing_decorator wraps slow_function in additional timing logic without either function knowing about the other.
Method decorators#
Method decorators work the same way as function decorators; the wrapped callable just happens to be a class method:
import random
import time
class PortScanner:
@timing_decorator
def scan_ports(self, host, port_range):
open_ports = []
for port in port_range:
time.sleep(0.1) # simulate scan latency
if random.choice([True, False]):
open_ports.append(port)
return open_ports
scanner = PortScanner()
open_ports = scanner.scan_ports("192.168.1.1", range(80, 90))
print(f"Open ports: {open_ports}")The same timing_decorator from earlier applies to scan_ports unmodified. This is a common pattern in security tools: instrument every method that touches the network with timing, retry, or rate-limit logic, without duplicating that logic in every method body.
Class decorators#
A class decorator takes a class and returns a modified version of it. Here’s one that wraps every method on a class in a shared lock, so no two calls into the object can run concurrently:
import threading
def synchronized_class(cls):
lock = threading.Lock()
class SynchronizedClass(cls):
def __getattribute__(self, name):
attr = super().__getattribute__(name)
if callable(attr):
def wrapper(*args, **kwargs):
with lock:
return attr(*args, **kwargs)
return wrapper
return attr
return SynchronizedClass
@synchronized_class
class SynchronizedPortScanner(PortScanner):
pass
sync_scanner = SynchronizedPortScanner()
open_ports = sync_scanner.scan_ports("192.168.1.1", range(80, 90))
print(f"Open ports: {open_ports}")Note the single lock allocated once per class, in the outer decorator scope. A common mistake here is defining the lock inside the per-attribute wrapper function, which creates a fresh lock on every call and defeats the synchronization entirely.
Inheritance and polymorphism#
Inheritance lets one class extend another; polymorphism lets code work with any object that provides the right interface, regardless of its concrete type. Together they’re the mechanism that makes “many kinds of scanners” or “many kinds of detectors” a single unified system.
Inheritance#
A simple hierarchy: a base NetworkScanner class, and a PortScanner subclass that specializes it:
class NetworkScanner:
def __init__(self, target):
self.target = target
def scan(self):
print(f"Scanning target: {self.target}")
class PortScanner(NetworkScanner):
def __init__(self, target, port_range):
super().__init__(target)
self.port_range = port_range
def scan(self):
super().scan()
print(f"Scanning ports: {self.port_range}")
port_scanner = PortScanner("192.168.1.1", range(80, 90))
port_scanner.scan()PortScanner inherits NetworkScanner’s constructor via super().__init__(target), then adds its own state and overrides scan() to do something more specific while still calling the parent’s implementation.
Polymorphism#
Python leans on duck typing rather than declared interfaces: any object with a scan() method can be passed to code that expects to call scan() on it, regardless of what class it came from:
class VulnerabilityScanner(NetworkScanner):
def __init__(self, target, vulnerabilities):
super().__init__(target)
self.vulnerabilities = vulnerabilities
def scan(self):
super().scan()
print(f"Scanning for vulnerabilities: {self.vulnerabilities}")
def start_scanner(scanner):
scanner.scan()
port_scanner = PortScanner("192.168.1.1", range(80, 90))
vuln_scanner = VulnerabilityScanner("192.168.1.1", ["CVE-2023-1234", "CVE-2023-5678"])
start_scanner(port_scanner)
start_scanner(vuln_scanner)start_scanner accepts anything with a scan() method. Note that start_scanner is a module-level function, not a method on VulnerabilityScanner, which is a common bug when refactoring polymorphism examples.
Abstract Base Classes#
Abstract Base Classes (ABCs) formalize the “shared interface” pattern by making it explicit. An ABC can’t be instantiated directly, and any concrete subclass has to implement its abstract methods or it stays abstract too.
import abc
class Scanner(abc.ABC):
def __init__(self, target):
self.target = target
@abc.abstractmethod
def scan(self):
pass
class NetworkScanner(Scanner):
def scan(self):
print(f"Scanning target: {self.target}")Any class that inherits from Scanner must implement scan() or it can’t be instantiated. NetworkScanner provides an implementation, so it works:
# Trying to instantiate the abstract class 'Scanner' will raise a TypeError:
# scanner = Scanner("192.168.1.1")
# TypeError: Can't instantiate abstract class Scanner with abstract methods scan
# Instantiating a concrete subclass works:
network_scanner = NetworkScanner("192.168.1.1")
network_scanner.scan()ABCs earn their weight when you’re building a plugin architecture (every detector plugin must implement a defined interface, or the loader rejects it at import time rather than mysteriously at runtime).
Composition and aggregation#
Composition and aggregation are the two most common ways one class holds instances of others. The distinction is about ownership: with composition, the container owns its parts and they live and die with it; with aggregation, the container holds references to parts that exist independently.
Composition#
A PenetrationTester class that constructs and owns its scanner instances:
class PenetrationTester:
def __init__(self, target):
self.target = target
self.network_scanner = NetworkScanner(target)
self.port_scanner = PortScanner(target, range(80, 90))
self.vulnerability_scanner = VulnerabilityScanner(target, ["CVE-2023-1234", "CVE-2023-5678"])
def perform_scan(self):
print(f"Performing scan on target: {self.target}")
self.network_scanner.scan()
self.port_scanner.scan()
self.vulnerability_scanner.scan()
pen_tester = PenetrationTester("192.168.1.1")
pen_tester.perform_scan()The scanners come into existence with the PenetrationTester and go away with it. Nothing outside holds references to them.
Aggregation#
Same shape, but the scanners are constructed elsewhere and injected:
class PenetrationTester:
def __init__(self, target, network_scanner, port_scanner, vulnerability_scanner):
self.target = target
self.network_scanner = network_scanner
self.port_scanner = port_scanner
self.vulnerability_scanner = vulnerability_scanner
def perform_scan(self):
print(f"Performing scan on target: {self.target}")
self.network_scanner.scan()
self.port_scanner.scan()
self.vulnerability_scanner.scan()
network_scanner = NetworkScanner("192.168.1.1")
port_scanner = PortScanner("192.168.1.1", range(80, 90))
vulnerability_scanner = VulnerabilityScanner("192.168.1.1", ["CVE-2023-1234", "CVE-2023-5678"])
pen_tester = PenetrationTester("192.168.1.1", network_scanner, port_scanner, vulnerability_scanner)
pen_tester.perform_scan()The scanners can outlive the PenetrationTester, be shared with other objects, or be swapped out for test doubles. That last point is why aggregation is almost always the better default in code you plan to test: injected dependencies are trivially mockable, composed ones aren’t.
Advanced uses of properties#
Properties in Python let you attach getter, setter, and deleter logic to what looks like an ordinary attribute. Uses include input validation, computed values, and enforcing invariants.
Validation with property setters#
A Host class that validates its IP address and hostname on assignment:
import ipaddress
import re
class Host:
def __init__(self, ip_address, hostname):
self.ip_address = ip_address
self.hostname = hostname
@property
def ip_address(self):
return self._ip_address
@ip_address.setter
def ip_address(self, value):
try:
ipaddress.ip_address(value)
except ValueError:
raise ValueError("Invalid IP address")
self._ip_address = value
@property
def hostname(self):
return self._hostname
@hostname.setter
def hostname(self, value):
if not re.match(r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$", value):
raise ValueError("Invalid hostname")
self._hostname = value
host = Host("192.168.1.1", "example.com")
# Invalid values raise ValueError on assignment:
# host.ip_address = "256.0.0.1" # ValueError: Invalid IP address
# host.hostname = "!example" # ValueError: Invalid hostnameThe validation runs on every assignment, including the initial one from __init__, so there’s no way to end up with a Host in an invalid state via ordinary attribute access.
Computed properties#
Properties that derive their value from other attributes rather than storing anything themselves:
class ScanResult:
def __init__(self, total_ports, open_ports):
self.total_ports = total_ports
self.open_ports = open_ports
@property
def open_ports_percentage(self):
return (len(self.open_ports) / self.total_ports) * 100
@property
def summary(self):
return f"{len(self.open_ports)} out of {self.total_ports} ports open ({self.open_ports_percentage:.2f}%)."
scan_result = ScanResult(1000, [80, 443, 8080])
print(scan_result.summary)open_ports_percentage and summary are computed fresh every time they’re accessed. That’s the point: they can never disagree with total_ports and open_ports.
Enforcing invariants with properties#
Property setters can enforce conditions that must always hold true for the object’s state:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if value <= 0:
raise ValueError("Width must be positive")
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
if value <= 0:
raise ValueError("Height must be positive")
self._height = value
rect = Rectangle(10, 5)
# Invalid values raise ValueError on assignment:
# rect.width = -1 # ValueError: Width must be positive
# rect.height = 0 # ValueError: Height must be positiveThe invariant (width and height must be positive) is enforced at every point of mutation, not just at construction time.
Practical code examples#
Three worked examples that exercise the concepts above in code you’d actually want to build for security engineering: an analyzer for suspicious network connections, a plugin architecture for a detection framework, and a stateful log correlator.
C2 traffic detector (composition + properties)#
A common defensive task is scoring outbound connections against indicators of C2 (command-and-control) traffic: unusual destinations, beaconing intervals, uncommon protocols. A composed class that pulls in an IOC feed, a beaconing analyzer, and a scoring aggregator:
class IOCFeed:
"""Hydrated from your threat intel platform of choice."""
def __init__(self, known_bad_ips):
self._known_bad = set(known_bad_ips)
def is_known_bad(self, ip):
return ip in self._known_bad
class BeaconingAnalyzer:
"""Flags near-uniform inter-connection intervals as suspicious."""
def __init__(self, tolerance_seconds=5):
self.tolerance = tolerance_seconds
def looks_like_beacon(self, timestamps):
if len(timestamps) < 4:
return False
intervals = [b - a for a, b in zip(timestamps, timestamps[1:])]
avg = sum(intervals) / len(intervals)
return all(abs(i - avg) <= self.tolerance for i in intervals)
class C2TrafficDetector:
"""Composes an IOC feed and a beaconing analyzer."""
def __init__(self, ioc_feed, beaconing_analyzer):
self.ioc_feed = ioc_feed
self.beaconing_analyzer = beaconing_analyzer
self._observations = []
def observe(self, ip, timestamp):
self._observations.append((ip, timestamp))
@property
def score(self):
if not self._observations:
return 0
by_ip = {}
for ip, ts in self._observations:
by_ip.setdefault(ip, []).append(ts)
score = 0
for ip, timestamps in by_ip.items():
if self.ioc_feed.is_known_bad(ip):
score += 50
if self.beaconing_analyzer.looks_like_beacon(sorted(timestamps)):
score += 25
return score
detector = C2TrafficDetector(
ioc_feed=IOCFeed(known_bad_ips={"203.0.113.7"}),
beaconing_analyzer=BeaconingAnalyzer(tolerance_seconds=3),
)
for ts in [0, 60, 120, 180]:
detector.observe("198.51.100.5", ts)
detector.observe("203.0.113.7", 200)
print(f"Suspicion score: {detector.score}")The design uses aggregation deliberately: IOCFeed and BeaconingAnalyzer are constructed externally and injected, so unit tests can swap in fakes without a live IOC feed. score is a computed property because it should reflect the current observations, not a stale snapshot.
Detection plugin framework (ABCs + inheritance)#
A plugin architecture where every detector plugin conforms to a shared interface enforced by an ABC:
import abc
class Detector(abc.ABC):
"""Base interface every detection plugin must implement."""
def __init__(self, name):
self.name = name
@abc.abstractmethod
def evaluate(self, event):
"""Return True if the event matches this detector's rule."""
pass
class KnownBadHashDetector(Detector):
def __init__(self, hashes):
super().__init__(name="known-bad-hash")
self.hashes = set(hashes)
def evaluate(self, event):
return event.get("sha256") in self.hashes
class SuspiciousParentDetector(Detector):
def __init__(self, parent_child_pairs):
super().__init__(name="suspicious-parent")
self.pairs = set(parent_child_pairs)
def evaluate(self, event):
parent = event.get("parent_process")
child = event.get("process")
return (parent, child) in self.pairs
class DetectionEngine:
def __init__(self, detectors):
self.detectors = detectors
def process(self, event):
matches = [d.name for d in self.detectors if d.evaluate(event)]
if matches:
print(f"Alert on event {event.get('id')}: {matches}")
engine = DetectionEngine(detectors=[
KnownBadHashDetector(hashes={"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}),
SuspiciousParentDetector(parent_child_pairs={("winword.exe", "powershell.exe")}),
])
engine.process({
"id": 1,
"parent_process": "winword.exe",
"process": "powershell.exe",
"sha256": "abc123",
})Adding a new detection type is just writing a new Detector subclass; the engine doesn’t need to know it exists. The ABC guarantees at import time that every plugin implements evaluate, rather than crashing at runtime the first time an event flows through.
SSH auth-log correlator (decorators + composition)#
A stateful log processor that watches authentication events and flags credential-stuffing patterns (many failures against the same account from a single source):
import time
from collections import defaultdict
def counted(func):
"""Track how many times a method fires. Useful for load metrics on a detector."""
def wrapper(self, *args, **kwargs):
self._call_counts[func.__name__] = self._call_counts.get(func.__name__, 0) + 1
return func(self, *args, **kwargs)
return wrapper
class AuthLogCorrelator:
def __init__(self, failure_threshold=10, window_seconds=60):
self.failure_threshold = failure_threshold
self.window_seconds = window_seconds
self._failures = defaultdict(list) # (source_ip, username) -> [timestamps]
self._call_counts = {}
@counted
def observe_failure(self, source_ip, username, timestamp):
key = (source_ip, username)
self._failures[key].append(timestamp)
# trim entries outside the sliding window
cutoff = timestamp - self.window_seconds
self._failures[key] = [t for t in self._failures[key] if t >= cutoff]
if len(self._failures[key]) >= self.failure_threshold:
print(f"Alert: {len(self._failures[key])} failures for "
f"{username} from {source_ip} in the last {self.window_seconds}s")
@counted
def observe_success(self, source_ip, username, timestamp):
# a successful auth after many failures is a strong indicator
key = (source_ip, username)
prior_failures = len(self._failures.get(key, []))
if prior_failures >= self.failure_threshold:
print(f"Alert: successful auth for {username} from {source_ip} "
f"after {prior_failures} recent failures")
self._failures.pop(key, None)
@property
def call_counts(self):
return dict(self._call_counts)
correlator = AuthLogCorrelator(failure_threshold=5, window_seconds=60)
now = time.time()
for i in range(6):
correlator.observe_failure("203.0.113.7", "root", now + i)
correlator.observe_success("203.0.113.7", "root", now + 7)
print(f"Method call counts: {correlator.call_counts}")The @counted decorator adds instrumentation to every wrapped method without any of them knowing about it, exactly the cross-cutting-concern use case decorators were designed for. call_counts is a computed property that returns a defensive copy of the internal dict, so external code can’t mutate the correlator’s state by accident.
Conclusion#
Decorators, inheritance, ABCs, composition, aggregation, and properties aren’t just Python language features; they’re the vocabulary that lets a security tool’s codebase grow past a single script into something you’d actually want to maintain. Detection frameworks, analyzers, correlators, and any tool with a plugin architecture all lean on these patterns as their structural backbone.
The specific worked examples above map to concrete security engineering tasks (C2 detection, plugin dispatch, log correlation), but the underlying patterns are what to keep in mind: use ABCs when you need to enforce an interface across multiple implementations, use composition or aggregation for containment (aggregate when you want testability), use properties for validation and computed values so invariants get enforced at the point of assignment rather than checked scattered through the codebase, and use decorators for the cross-cutting stuff that would otherwise get duplicated in every method.