Ethical Hacking (methodology, lab-only) · lesson 1 of 6
Beginner
3Intermediate
3Also part of Ethical Hacking (methodology, lab-only)
12- + Port Scanning Your Own Lab with nmap: Open, Closed, Filtered from Networking
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
- + Permission Bits, Ownership, and Why passwd Needs setuid from Linux
- + Broken Access Control and IDOR: Fix It in Code, Pin It with a Test from Web Security (OWASP Top 10, fix-side)
- + CSRF: Synchronizer Tokens and SameSite Cookies, Fixed and Tested from Web Security (OWASP Top 10, fix-side)
- + SSRF: Allowlists, Resolved-IP Checks and No Redirects from Web Security (OWASP Top 10, fix-side)
- + Security Misconfiguration: Error Pages, Debug Mode and Security Headers from Web Security (OWASP Top 10, fix-side)
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Authentication Failures: Username Enumeration and Login Rate Limiting from Web Security (OWASP Top 10, fix-side)
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + Password Hashing Work Factors, Measured from Cryptography
- + DAST with an OWASP ZAP Baseline Scan Against Your Own App from DevSecOps (AppSec in the pipeline)
Scope and Rules of Engagement: A Checker That Refuses
Turn a signed scope into scope.yaml and a Python checker that refuses out-of-scope, excluded and out-of-window targets before any tool runs.
By SkillByExample · Published
Ethical hacking is testing systems with permission, and the permission is written down before any
tool runs. A penetration test starts with a scope and rules of engagement (RoE): which addresses
are in, which are excluded, when testing may happen, and who to call if something breaks. This
lesson turns that document into scope.yaml and a Python checker that refuses any target the
scope doesn’t cover. Nothing here sends a packet. The checker only compares addresses, so you can
run every example with 8.8.8.8 as the out-of-scope target and nothing is contacted.
This topic teaches the methodology around a test: scope, assessment, and the report. It doesn’t teach exploitation. For hands-on offensive practice against legal targets, use TryHackMe or Hack The Box Academy.
The examples use Python 3.12 and PyYAML in a virtual environment:
$ python -m venv .venv && source .venv/bin/activate
$ pip install pyyaml==6.0.3
The scope, as data
The signed scope letter is the source of truth. scope.yaml is a copy of the parts a tool can
check: the engagement name, who authorized it, the window, the ranges and the exclusions.
# scope.yaml: what the client authorized, in writing
engagement: ACME-2026-10
authorized_by: "J. Doe, CISO, signed letter dated 2026-10-01"
window:
start: "2026-10-20T09:00:00+00:00"
end: "2026-10-24T17:00:00+00:00"
in_scope:
- 10.0.0.0/24
- 172.20.0.0/16
exclude:
- 10.0.0.1 # production gateway
- 10.0.0.250/31 # the client's monitoring hosts
contacts:
technical: "ops@acme.example, +1-555-0100"
emergency: "SOC on-call, +1-555-0199"
The exclusions matter as much as the ranges. A client often authorizes a whole subnet but carves out a fragile gateway or the monitoring hosts that will page someone at 3 a.m. The emergency contact is the number you call when a test takes something down, and the RoE should say you stop testing until you’ve called it.
A first checker, using only the ranges
Python’s ipaddress module compares addresses numerically, so ip in network is exact. It’s
never a string prefix match.
# scope_check.py: is each target inside the written scope?
import ipaddress
import sys
import yaml
scope = yaml.safe_load(open("scope.yaml"))
networks = [ipaddress.ip_network(cidr) for cidr in scope["in_scope"]]
for target in sys.argv[1:]:
ip = ipaddress.ip_address(target)
if any(ip in net for net in networks):
print(f"ALLOWED {ip}")
else:
print(f"REFUSED {ip}: out of scope")
$ python scope_check.py 10.0.0.5 172.20.3.9 10.0.0.1 8.8.8.8
ALLOWED 10.0.0.5
ALLOWED 172.20.3.9
ALLOWED 10.0.0.1
REFUSED 8.8.8.8: out of scope
8.8.8.8 is refused, which is the point. But 10.0.0.1 is allowed, and it’s the production
gateway the client excluded. The checker reads in_scope and never looks at exclude, so the
one host the client asked you not to touch passes. A hostname is a problem too:
python scope_check.py acme.example crashes with
ValueError: 'acme.example' does not appear to be an IPv4 or IPv6 address.
Exclusions win, and names are refused
Move the decision into a function that returns a reason to refuse, or None. Check the
exclusions before the ranges, so an excluded host can never be allowed.
# scope_check.py: refuse anything the written scope doesn't cover
import ipaddress
import sys
import yaml
def check(target, scope):
try:
ip = ipaddress.ip_address(target)
except ValueError:
return "not an IP address; resolve it and confirm the IP in writing"
if any(ip in ipaddress.ip_network(c) for c in scope["exclude"]):
return "explicitly excluded"
if not any(ip in ipaddress.ip_network(c) for c in scope["in_scope"]):
return "out of scope"
return None
scope = yaml.safe_load(open("scope.yaml"))
for target in sys.argv[1:]:
reason = check(target, scope)
print(f"REFUSED {target}: {reason}" if reason else f"ALLOWED {target}")
$ python scope_check.py 10.0.0.5 10.0.0.1 10.0.0.251 8.8.8.8 acme.example 10.0.0.256
ALLOWED 10.0.0.5
REFUSED 10.0.0.1: explicitly excluded
REFUSED 10.0.0.251: explicitly excluded
REFUSED 8.8.8.8: out of scope
REFUSED acme.example: not an IP address; resolve it and confirm the IP in writing
REFUSED 10.0.0.256: not an IP address; resolve it and confirm the IP in writing
10.0.0.251 is refused even though the exclusion list never names it. 10.0.0.250/31 covers
two addresses, .250 and .251, and ipaddress does that arithmetic for you. 10.0.0.256 is
refused as “not an IP address”, because no octet can exceed 255.
The checker refuses hostnames on purpose. A name can resolve to a CDN, a shared host or a third party’s server, and the client can only authorize what it owns. Resolving the name yourself and then trusting the answer would let DNS decide your scope. Ask the client to confirm the IP in writing and add it to the scope instead.
The time window
Testing outside the agreed hours can set off alerts when nobody expects them. Add the window to the check, and let an environment variable pin “now” so the result is reproducible. Without it, the checker uses the real clock.
# scope_check.py: refuse anything outside the written scope or time window
import ipaddress
import os
import sys
from datetime import datetime, timezone
import yaml
def check(target, scope, now):
start, end = (datetime.fromisoformat(scope["window"][k]) for k in ("start", "end"))
if not start <= now <= end:
return "outside the testing window"
try:
ip = ipaddress.ip_address(target)
except ValueError:
return "not an IP address; resolve it and confirm the IP in writing"
if any(ip in ipaddress.ip_network(c) for c in scope["exclude"]):
return "explicitly excluded"
if not any(ip in ipaddress.ip_network(c) for c in scope["in_scope"]):
return "out of scope"
return None
scope = yaml.safe_load(open("scope.yaml"))
now = os.environ.get("SCOPE_NOW")
now = datetime.fromisoformat(now) if now else datetime.now(timezone.utc)
refused = 0
for target in sys.argv[1:]:
reason = check(target, scope, now)
print(f"REFUSED {target}: {reason}" if reason else f"ALLOWED {target}")
refused += reason is not None
sys.exit(1 if refused else 0)
$ SCOPE_NOW=2026-10-19T23:00:00+00:00 python scope_check.py 10.0.0.5
REFUSED 10.0.0.5: outside the testing window
$ SCOPE_NOW=2026-10-21T10:00:00+00:00 python scope_check.py 10.0.0.5
ALLOWED 10.0.0.5
The same address flips from refused to allowed with nothing changed but the clock. The window
check runs first, so on the wrong day every target is refused, in scope or not. Run it without
SCOPE_NOW on any day outside 20-24 October 2026 and it refuses too, because the real clock is outside the
window. Keep the timezone in the scope (+00:00 here). A window written without one is
ambiguous by however many hours separate you from the client.
The exit code is the gate
The script now exits 1 if any target was refused. That lets a shell refuse to start the next
command:
$ export SCOPE_NOW=2026-10-21T10:00:00+00:00
$ python scope_check.py 10.0.0.5 && echo "gate passed: start the scan"
ALLOWED 10.0.0.5
gate passed: start the scan
$ python scope_check.py 8.8.8.8 && echo "gate passed: start the scan"; echo "exit=$?"
REFUSED 8.8.8.8: out of scope
exit=1
&& runs the right-hand command only when the left one exits 0, so the second echo never
ran. In a real engagement the right-hand side is your scanner, for example
python scope_check.py "$t" && nmap "$t". The scanner then can’t be pointed at an address the
checker refused, even by a typo at the end of a long day. To practise scanning itself, use your
own lab: Port scanning your own lab does that against
containers on your machine.
A typo in the scope
Clients write scopes by hand, and a common slip is 10.0.0.5/24 when they mean the single host
10.0.0.5.
# host_bits.py: the client meant one host but wrote a /24
import ipaddress
for entry in ["10.0.0.5/32", "10.0.0.5/24"]:
try:
net = ipaddress.ip_network(entry)
print(f"{entry}: {net.num_addresses} address(es)")
except ValueError as err:
print(f"{entry}: ValueError: {err}")
net = ipaddress.ip_network("10.0.0.5/24", strict=False)
print(f"strict=False quietly widens it to {net}: {net.num_addresses} addresses")
$ python host_bits.py
10.0.0.5/32: 1 address(es)
10.0.0.5/24: ValueError: 10.0.0.5/24 has host bits set
strict=False quietly widens it to 10.0.0.0/24: 256 addresses
By default ip_network refuses 10.0.0.5/24, because a /24 network must end in .0. That
error is useful, so keep it. strict=False is the fix you’ll find on forums, and it turns one
authorized host into 256. When the scope fails to parse, ask the client what they meant. Don’t
let the code guess.
Why the times in scope.yaml are quoted
YAML has its own timestamp type, and PyYAML’s safe_load converts a bare timestamp to a
datetime before your code sees it.
# yaml_dates.py: why the window times in scope.yaml are quoted
from datetime import datetime
import yaml
doc = yaml.safe_load("quoted: '2026-10-20T09:00:00+00:00'\nbare: 2026-10-20T09:00:00+00:00\n")
for key, value in doc.items():
print(f"{key}: {type(value).__name__}")
try:
datetime.fromisoformat(doc["bare"])
except TypeError as err:
print(f"TypeError: {err}")
$ python yaml_dates.py
quoted: str
bare: datetime
TypeError: fromisoformat: argument must be str
The two values look identical in the file, but they arrive as different types. Quoting the times
keeps them strings, so the checker parses every one the same way. Without quotes, the window
check would crash on the first target. The CIDR ranges are safe unquoted, because 10.0.0.0/24
isn’t a number or a timestamp to YAML.
Practice
- The client adds an IPv6 lab range,
fd00:20::/64. Add it toin_scopeand checkfd00:20::5,fd00:21::5and::1. Does the checker need a code change?
Solution
in_scope:
- 10.0.0.0/24
- 172.20.0.0/16
- fd00:20::/64
$ SCOPE_NOW=2026-10-21T10:00:00+00:00 python scope_check.py fd00:20::5 fd00:21::5 ::1
ALLOWED fd00:20::5
REFUSED fd00:21::5: out of scope
REFUSED ::1: out of scope
No code change. ip_address and ip_network handle both IPv4 and IPv6, and an IPv6 address
is simply never inside an IPv4 network. ::1 is your own loopback, but it’s refused because the
client’s scope doesn’t list it. The scope decides, not the tester’s opinion of what’s harmless.
- A second client’s scope has no exclusions, so their file says just
exclude:with nothing under it. Run the checker against it. What happens, and how do you fix it?
Solution
YAML reads an empty key as None, and for c in None raises
TypeError: 'NoneType' object is not iterable. Treat a missing or empty list as “no exclusions”:
if any(ip in ipaddress.ip_network(c) for c in scope.get("exclude") or []):
return "explicitly excluded"
$ SCOPE_NOW=2026-10-21T10:00:00+00:00 python scope_check.py 10.0.0.5 10.0.0.1
ALLOWED 10.0.0.5
ALLOWED 10.0.0.1
10.0.0.1 is now allowed because this client excluded nothing. An empty in_scope: crashes the
same way; adding or [] there makes any([]) refuse every target instead.
- Someone types the window’s end date as
2026-10-14, before the start. Every target is now refused with “outside the testing window”, which hides the real problem. Make the checker reject the scope file itself.
Solution
Validate once, after loading:
scope = yaml.safe_load(open("scope.yaml"))
start, end = (datetime.fromisoformat(scope["window"][k]) for k in ("start", "end"))
if end <= start:
sys.exit(f"scope.yaml is invalid: window ends {end} before it starts {start}")
$ SCOPE_NOW=2026-10-21T10:00:00+00:00 python scope_check.py 10.0.0.5; echo "exit=$?"
scope.yaml is invalid: window ends 2026-10-14 17:00:00+00:00 before it starts 2026-10-20 09:00:00+00:00
exit=1
sys.exit with a string prints it to stderr and exits 1, so the gate still holds. A broken
scope should stop the engagement until it’s corrected. It shouldn’t look like a quiet day.
- The kickoff call produced a target list in a file, with comments and blank lines. Change the loop to read targets from the file named on the command line.
Solution
# targets.txt: from the client kickoff call
10.0.0.5
10.0.0.250 # monitoring, should be refused
172.20.0.10
Replace the for target in sys.argv[1:]: line with:
targets = [line.split("#")[0].strip() for line in open(sys.argv[1])]
for target in filter(None, targets):
$ SCOPE_NOW=2026-10-21T10:00:00+00:00 python scope_check.py targets.txt
ALLOWED 10.0.0.5
REFUSED 10.0.0.250: explicitly excluded
ALLOWED 172.20.0.10
Stripping the comment and then the whitespace turns the comment line and the blank line into
empty strings, and filter(None, ...) drops them. The client’s own list included a host their
scope excludes, which is common. When the two disagree, ask the client which one is right.