Skip to main content
Ethical Hacking (methodology, lab-only) · lesson 1 of 6
Open the Ethical Hacking (methodology, lab-only) page →

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 penetration-testing scope rules-of-engagement python

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

  1. The client adds an IPv6 lab range, fd00:20::/64. Add it to in_scope and check fd00:20::5, fd00:21::5 and ::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.

  1. 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.

  1. 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.

  1. 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.

Frequently Asked Questions

What makes hacking ethical rather than illegal?
Written authorization from someone who owns the systems or has the legal right to approve testing them. Laws such as the US Computer Fraud and Abuse Act and the UK Computer Misuse Act turn on authorization, not intent. Without a signed scope, a well-meant port scan of someone else's network can still be an offence.
What are rules of engagement in a penetration test?
Rules of engagement are the agreed limits of a test: which addresses and applications are in scope, which are excluded, when testing may happen, which techniques are allowed, and who to call if something breaks. They are signed before testing starts, and the tester keeps a copy because it is the proof that the work was authorized.
Should a scope list hostnames or IP addresses?
Prefer IP addresses and CIDR ranges, and confirm any hostname's address with the client in writing. A hostname can resolve to a shared host, a CDN or a third party's server that the client has no right to authorize. A tool that accepts only IP addresses forces that confirmation to happen.
Can I practise penetration testing without a client?
Yes, on systems you own or on platforms built for it. Your own virtual machines and Docker containers are fair game, and TryHackMe, Hack The Box and PortSwigger's Web Security Academy provide legal, purpose-built targets. Scanning a host on the internet because it looks interesting is not practice; it is unauthorized access.
Why check the testing window in code?
Because the client plans around it. Testing outside the agreed hours can trip alerts when nobody expects them, collide with a change freeze or overload a system during peak traffic. A checker that refuses outside the window stops a scheduled job or a forgotten terminal from running on the wrong day.