Build a Python Security Toolkit
Build a Python CLI for port checks, file integrity, and secure passwords.
Introduction
30 Second Summary
Security practice can feel abstract when each check lives in a different tool. A home lab gives those checks a safe target plus results you can inspect.
In this project, you will build a Windows-based Python toolkit with a command-line interface for defensive tasks inside your authorized home lab. Its commands check TCP ports, detect file changes with SHA-256, and generate passwords with cryptographically secure randomness.
What You'll Build
You run your toolkit in Visual Studio Code, choose a command from its help screen, and watch it report an open SSH port, expose a changed file, or produce a secure password.
By the end of this project, you'll have:
- An authorized TCP port scanner that checks one or more ports on your Ubuntu lab target with an OPEN or NOT OPEN result for each port.
- A file integrity workflow that saves a trusted SHA-256 baseline. After a deliberate edit, it displays both hashes beside a CHANGED alert.
- A help-driven security toolkit that generates secure passwords. It also rejects malformed ports, invalid timeouts, unresolvable targets, missing files, and too-short password requests with useful messages.
- Secret Mission: Turn a sanitized copy of your existing firewall logs into ranked summaries for source addresses, protocols, and destination ports.
Are there any prerequisites?
This project assumes an existing Oracle VirtualBox home lab with a Windows host that can reach an Ubuntu Server 26.04 LTS VM over its isolated network.
You need permission to scan that VM, Python 3.14.7, Visual Studio Code, the Microsoft Python extension 2026.4.0, SSH access, UFW experience, and existing firewall logs.
Before We Start
Before any hands-on work begins this checkpoint locks in four defensive workflows: authorized TCP connection checks plus SHA-256 file hashing with integrity comparisons plus secure password generation plus optional defensive firewall-log analysis. Your ownership of the Ubuntu Server VM plus its isolated home-lab network makes it the authorized target.
Prepare the Windows Python Workspace
A scanner is hard to debug when its workspace is unproven. A known working environment gives every later test a reliable starting point.
This step verifies Python 3.14.7 inside Visual Studio Code. A small test script confirms the workspace before you scan your authorized lab target.
In this step, get ready to:
- Open security-automation-toolkit in Visual Studio Code with an integrated PowerShell terminal.
- Verify Python 3.14.7 with the Microsoft Python extension 2026.4.0.
- Run hello.py successfully.
Create the workspace and open PowerShell
A workspace keeps every toolkit file under one named folder. Its integrated PowerShell terminal runs commands from that folder.
- Press the Windows key to open the Windows search bar.
- Type File Explorer and press Enter to open it.
You should see File Explorer with common locations in its left sidebar.
- Select Desktop in the left sidebar.
- Use the new-folder control to create a folder named security-automation-toolkit.
You should see the security-automation-toolkit folder on your Desktop.
- Press the Windows key to return to the Windows search bar.
- Type Visual Studio Code and press Enter to open it.
Visual Studio Code should open in a new window.
- Click File in the top menu bar.
- Select Open Folder.
You should see a folder picker for choosing the workspace.
- Select the security-automation-toolkit folder on your Desktop.
- Confirm the folder selection.
Visual Studio Code may ask whether you trust this folder. This folder is safe to trust because you created it for this project.
- Confirm the workspace trust prompt if it appears.
You should see security-automation-toolkit at the top of the Explorer sidebar.
- Click Terminal in the top menu bar.
- Select New Terminal.
You should see a terminal panel at the bottom of Visual Studio Code. Its terminal profile should show PowerShell.
PowerShell not selected?
- Open the terminal profile dropdown in the terminal panel.
- Select PowerShell from the available profiles.
- Check that the terminal prompt ends inside the security-automation-toolkit folder.
Still seeing a different terminal profile?
Your workspace now keeps the editor and terminal focused on the same folder. That removes a common source of missing-file errors.
Verify Python and the Microsoft extension
The Microsoft Python extension connects Visual Studio Code to your chosen interpreter. Matching the runtime and extension versions keeps the project behavior predictable.
- Check the active Python 3 runtime by running this command:
py -3 --version
What does this check?
The py launcher asks Windows for its active Python 3 runtime. The version output tells you whether this workspace can use Python 3.14.7.
✔️ I see version 3.14.7
Your Python runtime matches the project version. Keep this PowerShell terminal open for the final test.
ⓧ I see an older version
The current Python 3 runtime is below the project version. Update it before selecting an interpreter in Visual Studio Code.
- Open the official Python on Windows guide in your browser.
- Install the Python Install Manager from python.org.
- Use the Python Install Manager to install Python 3.14.7.
- Restart Visual Studio Code after the installation finishes.
- Return to the integrated PowerShell terminal.
- Repeat the version check above.
ⓧ Command not found
Windows cannot currently reach the Python launcher. The Python Install Manager provides the required py command.
- Open the official Python on Windows guide in your browser.
- Download the Python Install Manager from python.org.
- Complete the Python Install Manager installation.
- Use the manager to install Python 3.14.7.
- Restart Visual Studio Code after the installation finishes.
- Return to the integrated PowerShell terminal.
- Repeat the version check above.
Still seeing the wrong Python version?
- Confirm that the Python Install Manager completed the Python 3.14.7 installation.
- Close any terminal panel that was open before the installation.
- Create a new PowerShell terminal before repeating the version check.
Help me troubleshoot my Python version.
The runtime is one half of the setup. The extension supplies Python language support and lets Visual Studio Code select that runtime for this workspace.
- Press Ctrl+Shift+X to open the Extensions view.
You should see the Extensions view in the left sidebar.
- Search for Python in the Extensions view.
- Select the Python extension published by Microsoft.
- Check the version shown on its details page.
✔️ I see version 2026.4.0
The Microsoft Python extension matches the project version. You can now connect it to the verified interpreter.
ⓧ I see an older version
The installed extension needs the current stable update before interpreter selection.
- Select Update on the extension details page.
- Select Restart Extensions if Visual Studio Code prompts you.
- Return to the Python extension details page.
- Confirm that the displayed version is 2026.4.0.
ⓧ It is not installed
The extension details page provides the installation control for this missing tool.
- Select Install on the extension details page.
- Wait for the installation to finish.
- Confirm that Microsoft is listed as the publisher.
- Confirm that the displayed version is 2026.4.0.
- Press Ctrl+Shift+P to open the Command Palette.
- Type Python: Select Interpreter into the Command Palette.
- Select Python: Select Interpreter from the results.
- Choose the interpreter labeled Python 3.14.7.
The interpreter selector should now show Python 3.14.7. Your editor and terminal now agree on the runtime.
Run the test script and record the target
A one-line script proves that the selected interpreter can execute a file from this workspace. The target details then connect the workspace to your authorized Ubuntu Server VM.
- Select the security-automation-toolkit folder in the Explorer sidebar.
- Use the Explorer's new-file control to create hello.py.
You should see a blank hello.py editor tab.
- Add the environment test to hello.py by pasting this code:
print("Toolkit environment ready")
What does this code do?
The script prints one fixed message when Python executes the file. Seeing that message confirms that the file and interpreter are working together.
- Save hello.py in Visual Studio Code.
✔️ Awesome, I've got everything!
Your saved hello.py matches the project checkpoint.
ⓧ I'd like to double check the full code
print("Toolkit environment ready")
How should I use this reference?
Compare this reference with your complete hello.py file. The message and quotation marks must match exactly.
The scanner needs a reachable destination and a known listening port. Use the same details that already work for your existing SSH connection.
- Record the authorized Ubuntu hostname or IPv4 address here: ubuntu-hostname-or-ip.
- Record the existing SSH port here: ssh-port.
Before you run the final check, what two lines do you expect the terminal to print?
- Confirm the Python version and script execution by running these commands:
py -3 --version
py hello.py
What should you see?
- The first command prints Python 3.14.7.
- The second command prints Toolkit environment ready.
Test script not running?
- Check that the terminal prompt is inside the security-automation-toolkit folder.
- Confirm that the file is named hello.py.
- Compare the saved line with the full-code reference above.
Help me debug the workspace test.
That confirms the whole workspace path: Windows reaches Python 3.14.7. Python successfully runs your first toolkit file.
Your Windows Python workspace is ready. Next, you will use the recorded Ubuntu target details to make your first authorized TCP connection check.
Make the First TCP Connection Check
Your Windows workspace can now run Python. The next milestone is a real network result from your authorized Ubuntu Server VM.
A one-port TCP connection check keeps each socket action visible. You will send valid input plus malformed input through the same temporary prompt path.
In this step, get ready to:
- Create a temporary TCP connection check with a 0.5-second timeout.
- Test the authorized SSH port to see a successful connection.
- Try malformed port input to inspect the program's response.
Build the one-port connection check
The scanner creates an IPv4 TCP socket for one connection attempt. A short timeout stops an unresponsive target from holding the script indefinitely.
- In the Visual Studio Code window from earlier, click the new-file icon in the file sidebar.
- Enter security_toolkit.py as the file name.
- Confirm security_toolkit.py appears beside hello.py in the file sidebar.
- Add the socket import plus the connection-check function by pasting this code into security_toolkit.py:
import socket
def scan_ports(target: str, port: int) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.settimeout(0.5)
result = client.connect_ex((target, port))
status = "OPEN" if result == 0 else "NOT OPEN"
print(f"{port}/tcp {status}")
How Does the Socket Check Work?
- The socket.AF_INET value selects an IPv4 address.
- The socket.SOCK_STREAM value creates a TCP stream socket.
- The client.settimeout(0.5) call limits how long the connection attempt can wait.
- The client.connect_ex() call stores the connection result in result.
- A result of 0 becomes OPEN.
- Every other result becomes NOT OPEN because the connection did not succeed.
- The context manager closes the socket after collecting the result.
- Save security_toolkit.py.
- Confirm the file contains the complete scan_ports() function shown above.
Having Trouble Adding the Function?
- Check that import socket begins at the left edge of the file.
- Check that each line inside scan_ports() uses the indentation shown above.
- Check that the parentheses plus quotation marks match the snippet.
Get help checking the temporary socket function.
The function now knows how to test one port. A temporary runner collects the target plus port before calling it.
- Add the temporary prompt runner below scan_ports() by pasting this code:
target = input("Ubuntu hostname or IPv4 address: ")
port = int(input("TCP port: "))
scan_ports(target, port)
What Does the Runner Do?
- The first input() call stores the target text in target.
- The second input() call collects the port text.
- The int() call converts that text into the integer stored in port.
- The scan_ports() call sends both values into the connection check.
- Save security_toolkit.py.
- Confirm the file ends with scan_ports(target, port).
Seeing a Problem in the Runner?
- Check that the three runner lines sit below the function without indentation.
- Check that target plus port use the same spelling in every line.
- Check that both prompt strings have matching quotation marks.
Get help checking the temporary prompt runner.
✔️ Awesome, I've got everything!
Your saved security_toolkit.py matches the complete temporary one-port scanner.
ⓧ I'd like to double check the full code
import socket
def scan_ports(target: str, port: int) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.settimeout(0.5)
result = client.connect_ex((target, port))
status = "OPEN" if result == 0 else "NOT OPEN"
print(f"{port}/tcp {status}")
target = input("Ubuntu hostname or IPv4 address: ")
port = int(input("TCP port: "))
scan_ports(target, port)
Run the authorized SSH-port check
A connection result of 0 means the attempt succeeded. Your existing SSH service provides an authorized success case.
- Predict whether the known SSH port accepts this connection.
- Start the temporary scanner in the integrated PowerShell terminal by running:
py security_toolkit.py
What Happens When This Runs?
The py launcher starts security_toolkit.py. The script pauses at each prompt while it waits for your input.
- Enter the Ubuntu hostname or IPv4 address you recorded earlier at the first prompt.
- Enter the SSH port you recorded earlier at the second prompt.
You'll see the SSH port number followed by OPEN.
That connection worked. Your scanner can now reach the authorized SSH service on your Ubuntu Server VM.
Don't See OPEN?
- Confirm that you entered the same target used by your working SSH connection.
- Confirm that your existing SSH connection still reaches the Ubuntu Server VM.
- Check the target plus port for typing mistakes if the scanner reports NOT OPEN.
Get help debugging the authorized TCP check.
Test malformed port input
The valid input proves the connection path works. Malformed input tests how the same temporary runner behaves outside that happy path.
- Predict how the temporary runner responds when the port text is not a number.
- Start a fresh run of the temporary scanner by running:
py security_toolkit.py
Why Start a Fresh Run?
Each run collects a new target plus port. This lets you test malformed input through the exact path that accepted the SSH port.
- Enter the Ubuntu hostname or IPv4 address you recorded earlier at the first prompt.
- Enter not-a-port at the second prompt.
You'll see a ValueError traceback from the failed int() conversion. The script stops before scan_ports() runs.
This Failure Is Expected
The raw text from input() goes directly to int(). The value not-a-port cannot become an integer.
The unhandled traceback exposes the temporary prompt path as brittle.
- Leave security_toolkit.py unchanged after observing the traceback.
Your scanner reaches the authorized SSH port. Its malformed-input failure now gives you a clear target for the resilient CLI in the next step.
Build a Resilient Scanner CLI
Your first TCP check reached the authorized Ubuntu server. The not-a-port run then exposed an unhandled conversion failure.
Raw prompts leave the scanner vulnerable to malformed values. This step turns that temporary script into a reusable command-line interface with argparse validation.
In this step, get ready to:
- Validate port numbers from 1 through 65535.
- Build a required scan subcommand for repeatable checks.
- Compare the authorized SSH port with an existing UFW-blocked test port.
Add reusable input validation
An argparse type converter receives one command-line string. It returns a valid value or raises a formatted parser error.
- Replace the top of security_toolkit.py through the line before scan_ports() with this code:
import argparse
import socket
def port_number(value: str) -> int:
try:
port = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("port must be an integer") from exc
if not 1 <= port <= 65535:
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
return port
How does port validation work?
- The first check converts the supplied string into an integer.
- The range check accepts ports from 1 through 65535.
- An ArgumentTypeError lets the parser present invalid input as command usage guidance.
- Save security_toolkit.py.
- Confirm that port_number() appears above the existing scan_ports() function without red editor diagnostics.
Seeing a validator error?
Check that import argparse appears above import socket. Keep each except block indented beneath its matching try block.
Help me fix the port validator.
- Insert positive_timeout() below port_number() by using this code:
def positive_timeout(value: str) -> float:
try:
timeout = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("timeout must be a number") from exc
if timeout <= 0:
raise argparse.ArgumentTypeError("timeout must be greater than zero")
return timeout
How does timeout validation work?
- The converter accepts decimal values because a timeout can represent part of a second.
- The second check rejects zero or negative values.
- The valid float becomes the maximum wait for each connection attempt.
- Save security_toolkit.py.
- Check that the existing interactive scanner still runs by using this command:
py security_toolkit.py
- Enter the recorded Ubuntu hostname or IPv4 address at the target prompt.
- Enter the recorded SSH port at the port prompt.
You should still see OPEN for the authorized SSH port. This confirms that the new validators have not disrupted the existing connection check.
Did the regression check fail?
Confirm that both validator functions end with their own return statements. Check that the original scan_ports() function remains below them.
Help me restore the working scanner.
Build the scan command
A subcommand gives each toolkit feature its own entry point. The scan parser collects a target plus one or more validated ports.
- Insert build_parser() above the temporary target = input line by using this code:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Security automation toolkit for an authorized home lab"
)
subparsers = parser.add_subparsers(dest="command", required=True)
scan_parser = subparsers.add_parser("scan", help="check one or more authorized TCP ports")
scan_parser.add_argument("target", help="IPv4 hostname or address")
scan_parser.add_argument("ports", nargs="+", type=port_number)
scan_parser.add_argument("--timeout", type=positive_timeout, default=0.5, help="seconds per port")
return parser
What does the parser define?
- The required scan subcommand separates port scanning from later toolkit features.
- The target argument stores one hostname or IPv4 address.
- The nargs="+" setting collects one or more ports into a list.
- The optional --timeout value defaults to 0.5 seconds for each port.
- Save security_toolkit.py.
- Confirm that build_parser() appears below scan_ports() and above the temporary prompts.
Is the parser underlined?
Check that port_number plus positive_timeout match the function names you added. Keep return parser inside build_parser().
Help me fix the parser definition.
- Insert main() below build_parser() by using this code:
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "scan":
scan_ports(args.target, args.ports, args.timeout)
except (OSError, ValueError) as exc:
print(f"Error: {exc}")
return 1
return 0
What does the command runner do?
- The parser stores validated command-line values in args.
- The command check sends the selected target plus ports plus timeout into scan_ports().
- The exception handler converts target or socket failures into short learner-facing messages.
- The integer return value communicates success or failure to the terminal.
- Save security_toolkit.py.
- Run the existing interactive scanner once more with this command:
py security_toolkit.py
- Enter the recorded Ubuntu target at the first prompt.
- Enter the known SSH port at the second prompt.
You should see the known port report OPEN again. This regression check proves the added command functions contain valid Python syntax before the runner changes.
Seeing a syntax problem in main?
Keep the except block aligned with try. Keep the final return 0 inside main().
Help me correct the command runner.
Scan multiple authorized ports
The resilient scanner performs DNS resolution before connecting. It creates a fresh socket for every port so each attempt has its own timeout plus cleanup.
- Replace the existing scan_ports() function with this version:
def scan_ports(target: str, ports: list[int], timeout: float) -> None:
try:
ip_address = socket.gethostbyname(target)
except socket.gaierror as exc:
raise ValueError(f"could not resolve target '{target}'") from exc
print(f"Target: {target} ({ip_address})")
for port in ports:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.settimeout(timeout)
result = client.connect_ex((ip_address, port))
except OSError as exc:
print(f"{port:>5}/tcp ERROR: {exc}")
continue
status = "OPEN" if result == 0 else "NOT OPEN"
print(f"{port:>5}/tcp {status}")
How does the resilient scan work?
- The target resolves to one IPv4 address before the port loop begins.
- Each loop creates a fresh AF_INET stream socket inside a with block.
- A successful connect_ex() result becomes OPEN.
- A nonzero result becomes NOT OPEN because the connection did not succeed. A socket exception produces ERROR for that port.
- Save security_toolkit.py.
- Confirm that scan_ports() now accepts ports plus timeout.
Does the scanner loop look wrong?
Keep the socket creation inside the for port in ports loop. Keep the status calculation outside the inner try block.
Help me repair the multi-port loop.
- Find the temporary prompt runner at the bottom of security_toolkit.py.
- Locate these three lines:
target = input("Ubuntu hostname or IPv4 address: ")
port = int(input("TCP port: "))
scan_ports(target, port)
What is being replaced?
These prompts accept one unvalidated target plus one raw port. Removing them makes the parser the only input path.
- Replace the temporary prompt runner with this entry point:
if __name__ == "__main__":
raise SystemExit(main())
Why use this entry point?
The guard calls main() when you launch the file as a script. Its return value becomes the process exit status.
- Save security_toolkit.py.
- Replace <ubuntu-hostname-or-ip> in the command below with your recorded target.
- Replace <ssh-port> with the port used by your existing SSH connection.
- Replace <blocked-test-port> with one port already blocked by your existing firewall policy.
Before you run the scan, consider which port you expect to report OPEN.
- Run the authorized two-port scan after replacing all three placeholders:
py security_toolkit.py scan <ubuntu-hostname-or-ip> <ssh-port> <blocked-test-port>
You should see the resolved target first. The SSH port should report OPEN while the blocked test port reports NOT OPEN.
What does NOT OPEN prove?
A NOT OPEN result confirms that the TCP connection did not succeed. The basic connection check cannot identify whether a firewall filtered the request or no service accepted it.
Before the malformed-input check, consider whether validation should stop the command before any socket is created.
- Press the Up Arrow key to recall the scan command.
- Replace one port value with not-a-port.
- Press Enter to run the edited command.
You should see an argparse usage error explaining that the port must be an integer. The unhandled ValueError traceback is gone.
Did malformed input still cause a traceback?
Confirm that the old input() lines are gone. Check that the ports argument uses type=port_number.
Help me trace the remaining raw input path.
That brittle edge is closed. Your scanner now handles repeatable multi-port checks without exposing learners to raw conversion tracebacks.
✔️ Awesome, I've got everything!
Great work. Save security_toolkit.py one final time before continuing.
ⓧ I'd like to double check the full code
import argparse
import socket
def port_number(value: str) -> int:
try:
port = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("port must be an integer") from exc
if not 1 <= port <= 65535:
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
return port
def positive_timeout(value: str) -> float:
try:
timeout = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("timeout must be a number") from exc
if timeout <= 0:
raise argparse.ArgumentTypeError("timeout must be greater than zero")
return timeout
def scan_ports(target: str, ports: list[int], timeout: float) -> None:
try:
ip_address = socket.gethostbyname(target)
except socket.gaierror as exc:
raise ValueError(f"could not resolve target '{target}'") from exc
print(f"Target: {target} ({ip_address})")
for port in ports:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.settimeout(timeout)
result = client.connect_ex((ip_address, port))
except OSError as exc:
print(f"{port:>5}/tcp ERROR: {exc}")
continue
status = "OPEN" if result == 0 else "NOT OPEN"
print(f"{port:>5}/tcp {status}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Security automation toolkit for an authorized home lab"
)
subparsers = parser.add_subparsers(dest="command", required=True)
scan_parser = subparsers.add_parser("scan", help="check one or more authorized TCP ports")
scan_parser.add_argument("target", help="IPv4 hostname or address")
scan_parser.add_argument("ports", nargs="+", type=port_number)
scan_parser.add_argument("--timeout", type=positive_timeout, default=0.5, help="seconds per port")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "scan":
scan_ports(args.target, args.ports, args.timeout)
except (OSError, ValueError) as exc:
print(f"Error: {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Your scanner now accepts repeatable commands plus validated input. Next, you will give the toolkit a way to detect file changes.
Detect File Tampering with SHA-256
Your scanner now resolves its target before checking multiple authorized ports. It also handles malformed port values without a traceback.
Port state can change between scans. File integrity monitoring needs a trusted fingerprint that makes a later file change visible.
In this step, you will use SHA-256 to fingerprint a file. You will save the fingerprint as a trusted baseline before testing a deliberate change.
In this step, get ready to:
- Calculate a SHA-256 digest from a chosen file.
- Save the digest as a trusted integrity baseline.
- Detect a deliberate one-character change.
Add SHA-256 file hashing
Python's hashlib module calculates a digest from the exact bytes in a file. A one-character edit produces a different fingerprint.
- Replace the import section in security_toolkit.py with this expanded group:
import argparse
import hashlib
import secrets
import socket
from pathlib import Path
What Do These Imports Add?
- The hashlib module calculates the SHA-256 digest.
- The secrets module provides the digest comparison used later in this step.
- The Path class provides file checks plus file reading and writing.
- Add sha256_file() below scan_ports() in security_toolkit.py by copying this function:
def sha256_file(path: Path) -> str:
if not path.is_file():
raise FileNotFoundError(f"file not found: {path}")
with path.open("rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
What Does This Function Do?
- The is_file() check rejects paths that do not point to a file.
- The rb mode reads the file's exact bytes.
- The file_digest() call calculates the SHA-256 value.
- The hexdigest() call returns that value as hexadecimal text.
- Replace the existing build_parser() function with this version:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Security automation toolkit for an authorized home lab"
)
subparsers = parser.add_subparsers(dest="command", required=True)
scan_parser = subparsers.add_parser(
"scan", help="check one or more authorized TCP ports"
)
scan_parser.add_argument("target", help="IPv4 hostname or address")
scan_parser.add_argument("ports", nargs="+", type=port_number)
scan_parser.add_argument(
"--timeout", type=positive_timeout, default=0.5, help="seconds per port"
)
hash_parser = subparsers.add_parser("hash", help="calculate a SHA-256 digest")
hash_parser.add_argument("file", type=Path)
return parser
How Is the Hash Command Defined?
The hash subcommand accepts one file path. The parser converts that path into a Path object.
- Replace the existing main() function with this expanded version:
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "scan":
scan_ports(args.target, args.ports, args.timeout)
elif args.command == "hash":
print(f"SHA-256: {sha256_file(args.file)}")
except (FileNotFoundError, OSError, ValueError) as exc:
print(f"Error: {exc}")
return 1
return 0
How Does the Command Run?
The hash branch passes the selected file to sha256_file().
The exception handler converts a missing file into a short message beginning with Error:.
- Use the file sidebar in Visual Studio Code to create sample.txt inside the security-automation-toolkit folder.
- Enter Integrity baseline version 1. in sample.txt.
- Save sample.txt.
- Save security_toolkit.py.
Before you run the command, what do you expect the digest to represent?
- Calculate the first digest in the integrated PowerShell terminal by running:
py security_toolkit.py hash sample.txt
What Does This Command Prove?
The command reads the current bytes from sample.txt. You will see SHA-256: followed by a hexadecimal digest.
Your first file fingerprint is working. The digest now gives you a precise value to preserve as a baseline.
Hash Command Not Working?
- Confirm that sample.txt appears beside security_toolkit.py in the file sidebar.
- Check that sha256_file() sits outside scan_ports().
- Save security_toolkit.py before rerunning the command.
Help me troubleshoot the hash command.
Create the integrity baseline
A baseline preserves the digest from a version you trust. A later check calculates a fresh digest for comparison.
- Add create_baseline() below sha256_file() by copying this function:
def create_baseline(file_path: Path, baseline_path: Path) -> None:
digest = sha256_file(file_path)
baseline_path.write_text(digest + "\n", encoding="utf-8")
print(f"Baseline saved: {baseline_path}")
print(f"SHA-256: {digest}")
How Is the Baseline Saved?
- The function calculates the trusted file's current digest through sha256_file().
- The write_text() call stores the digest in a separate file.
- The confirmation output identifies the saved baseline path.
- Add check_baseline() below create_baseline() by copying this function:
def check_baseline(file_path: Path, baseline_path: Path) -> None:
if not baseline_path.is_file():
raise FileNotFoundError(f"baseline not found: {baseline_path}")
expected = baseline_path.read_text(encoding="utf-8").strip()
current = sha256_file(file_path)
if secrets.compare_digest(expected, current):
print("UNCHANGED")
print(f"SHA-256: {current}")
else:
print("CHANGED")
print(f"Expected: {expected}")
print(f"Current: {current}")
How Does the Comparison Work?
- The expected value comes from the saved baseline.
- The current value comes from the file's current bytes.
- The compare_digest() call checks whether both values match.
- A mismatch prints CHANGED with both digests.
- Add these parser definitions below the hash parser inside build_parser().
baseline_parser = subparsers.add_parser(
"baseline", help="save a trusted SHA-256 baseline"
)
baseline_parser.add_argument("file", type=Path)
baseline_parser.add_argument("baseline", type=Path)
check_parser = subparsers.add_parser(
"check", help="compare a file with a saved baseline"
)
check_parser.add_argument("file", type=Path)
check_parser.add_argument("baseline", type=Path)
What Do These Parsers Collect?
The baseline command accepts a source file plus a destination for its trusted digest.
The check command accepts the current file plus the saved baseline.
- Replace main() with this expanded version:
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "scan":
scan_ports(args.target, args.ports, args.timeout)
elif args.command == "hash":
print(f"SHA-256: {sha256_file(args.file)}")
elif args.command == "baseline":
create_baseline(args.file, args.baseline)
elif args.command == "check":
check_baseline(args.file, args.baseline)
except (FileNotFoundError, OSError, ValueError) as exc:
print(f"Error: {exc}")
return 1
return 0
How Are Integrity Errors Handled?
The new branches route each integrity command to its matching function. The shared exception handler catches missing file paths.
A missing baseline reaches the same handler. You receive a short Error: message instead of a traceback.
- Save security_toolkit.py.
- Create the trusted baseline by running:
py security_toolkit.py baseline sample.txt sample.sha256
What Does the Baseline Command Create?
The command hashes sample.txt before writing the digest to sample.sha256.
You will see Baseline saved: sample.sha256 followed by the trusted SHA-256 digest. The new baseline file also appears in the file sidebar.
Before you compare the files, do you expect the current digest to match the saved baseline?
- Check the untouched file against its baseline by running:
py security_toolkit.py check sample.txt sample.sha256
What Does the First Check Prove?
The command calculates a fresh digest from sample.txt. It compares that value with the digest in sample.sha256.
You will see UNCHANGED followed by the current digest. Your baseline now recognizes the trusted version.
Baseline Check Not Working?
- Confirm that sample.sha256 appears beside sample.txt in the file sidebar.
- Check that the baseline parser appears inside build_parser().
- Check that main() includes the check branch.
Help me troubleshoot the baseline workflow.
Trigger the tampering alert
The trusted digest stays fixed inside sample.sha256. Editing the source file produces a different current digest.
- Replace 1 with 2 in sample.txt.
- Save sample.txt.
- Leave sample.sha256 unchanged.
Before you check again, do you expect the saved digest to match the edited file?
- Compare the edited file with its original baseline by running:
py security_toolkit.py check sample.txt sample.sha256
What Reveals the File Change?
You will see CHANGED followed by separate Expected: and Current: hashes.
You caught a one-character modification. The fixed baseline turned an easy-to-miss edit into visible evidence.
Still Seeing UNCHANGED?
- Confirm that sample.txt contains Integrity baseline version 2..
- Save sample.txt before rerunning the check.
- Keep the original digest inside sample.sha256.
Help me investigate an unexpected UNCHANGED result.
Use these tabs to confirm that your cumulative security_toolkit.py matches the completed code for this step.
✔️ Awesome, I've got everything!
Your files are saved. Your toolkit can now calculate a digest before detecting a deliberate integrity change.
ⓧ I'd like to double check the full code
import argparse
import hashlib
import secrets
import socket
from pathlib import Path
def port_number(value: str) -> int:
try:
port = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("port must be an integer") from exc
if not 1 <= port <= 65535:
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
return port
def positive_timeout(value: str) -> float:
try:
timeout = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("timeout must be a number") from exc
if timeout <= 0:
raise argparse.ArgumentTypeError("timeout must be greater than zero")
return timeout
def scan_ports(target: str, ports: list[int], timeout: float) -> None:
try:
ip_address = socket.gethostbyname(target)
except socket.gaierror as exc:
raise ValueError(f"could not resolve target '{target}'") from exc
print(f"Target: {target} ({ip_address})")
for port in ports:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.settimeout(timeout)
result = client.connect_ex((ip_address, port))
except OSError as exc:
print(f"{port:>5}/tcp ERROR: {exc}")
continue
status = "OPEN" if result == 0 else "NOT OPEN"
print(f"{port:>5}/tcp {status}")
def sha256_file(path: Path) -> str:
if not path.is_file():
raise FileNotFoundError(f"file not found: {path}")
with path.open("rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
def create_baseline(file_path: Path, baseline_path: Path) -> None:
digest = sha256_file(file_path)
baseline_path.write_text(digest + "\n", encoding="utf-8")
print(f"Baseline saved: {baseline_path}")
print(f"SHA-256: {digest}")
def check_baseline(file_path: Path, baseline_path: Path) -> None:
if not baseline_path.is_file():
raise FileNotFoundError(f"baseline not found: {baseline_path}")
expected = baseline_path.read_text(encoding="utf-8").strip()
current = sha256_file(file_path)
if secrets.compare_digest(expected, current):
print("UNCHANGED")
print(f"SHA-256: {current}")
else:
print("CHANGED")
print(f"Expected: {expected}")
print(f"Current: {current}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Security automation toolkit for an authorized home lab"
)
subparsers = parser.add_subparsers(dest="command", required=True)
scan_parser = subparsers.add_parser(
"scan", help="check one or more authorized TCP ports"
)
scan_parser.add_argument("target", help="IPv4 hostname or address")
scan_parser.add_argument("ports", nargs="+", type=port_number)
scan_parser.add_argument(
"--timeout", type=positive_timeout, default=0.5, help="seconds per port"
)
hash_parser = subparsers.add_parser("hash", help="calculate a SHA-256 digest")
hash_parser.add_argument("file", type=Path)
baseline_parser = subparsers.add_parser(
"baseline", help="save a trusted SHA-256 baseline"
)
baseline_parser.add_argument("file", type=Path)
baseline_parser.add_argument("baseline", type=Path)
check_parser = subparsers.add_parser(
"check", help="compare a file with a saved baseline"
)
check_parser.add_argument("file", type=Path)
check_parser.add_argument("baseline", type=Path)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "scan":
scan_ports(args.target, args.ports, args.timeout)
elif args.command == "hash":
print(f"SHA-256: {sha256_file(args.file)}")
elif args.command == "baseline":
create_baseline(args.file, args.baseline)
elif args.command == "check":
check_baseline(args.file, args.baseline)
except (FileNotFoundError, OSError, ValueError) as exc:
print(f"Error: {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
How to Use This Reference
Compare each import plus each function with your saved security_toolkit.py. The parser definitions and command branches should appear in the same order.
Your toolkit now catches invisible file changes through a trusted digest. Next, you will complete the CLI with secure password generation.
Finish the Toolkit with Secure Passwords
Your integrity workflow now turns a one-character edit into a visible SHA-256 alert. Your toolkit needs one final defensive capability before it is complete.
Password generation depends on cryptographically secure randomness. This step uses Python's secrets module because security tools need operating-system-backed randomness.
In this step, get ready to:
- Build a secure password generator with all four character groups.
- Expose the generator through a validated password command.
- Verify every feature in the completed toolkit.
Build the secure password generator
The secrets.choice function selects characters with cryptographically strong randomness. The string module supplies the lowercase letters, uppercase letters, digits, and punctuation used by the generator.
Why is the minimum four characters?
The generator requires at least one character from each of four groups. A length below four cannot represent every group.
This minimum supports the algorithm. It does not define a general password-strength recommendation.
- Select the import section at the top of security_toolkit.py.
- Replace the import section by pasting this code:
import argparse
import hashlib
import secrets
import socket
import string
from pathlib import Path
What do the new imports provide?
- The secrets module provides secure character selection and digest comparison.
- The string module provides reusable character-group constants.
- Place the new validator directly below positive_timeout() by pasting this code:
def password_length(value: str) -> int:
try:
length = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("length must be an integer") from exc
if length < 4:
raise argparse.ArgumentTypeError("length must be at least 4 to include every character group")
return length
What does this validator do?
- The password_length() function converts the command-line value into an integer.
- An invalid value becomes a formatted parser error.
- A value below 4 is rejected because every character group must fit.
- Save security_toolkit.py.
- Confirm the current command-line interface still loads by running this command in the integrated PowerShell terminal:
py security_toolkit.py --help
What should you see?
You should see the existing help screen without a syntax error. This confirms that the imports and validator load successfully.
Seeing a syntax error?
- Check that each import begins at the left edge of the file.
- Check that the body of password_length() uses consistent indentation.
Need another pair of eyes? Help me fix this password validator.
- Place generate_password() directly below check_baseline() by pasting this code:
def generate_password(length: int) -> str:
alphabet = string.ascii_letters + string.digits + string.punctuation
while True:
password = "".join(secrets.choice(alphabet) for _ in range(length))
if any(character in string.ascii_lowercase for character in password) and any(character in string.ascii_uppercase for character in password) and any(character in string.digits for character in password) and any(character in string.punctuation for character in password):
return password
How does the generator work?
- The alphabet combines letters, digits, and punctuation.
- The loop creates each candidate with secrets.choice.
- Each any() check covers one required character group.
- The function returns the first candidate that covers all four groups.
- Locate the condition immediately below current = sha256_file(file_path) inside check_baseline().
- Replace the condition with this line:
if secrets.compare_digest(expected, current):
Why change the comparison?
The secrets.compare_digest() function compares the saved digest with the current digest. Its comparison design reduces timing-attack risk.
- Save security_toolkit.py.
- Confirm the integrity check still detects your deliberate change by running:
py security_toolkit.py check sample.txt sample.sha256
What should you see?
You should see CHANGED followed by the expected hash and current hash. This proves the secure comparison preserves the integrity workflow.
Integrity check stopped working?
- Check that secrets appears in the import section.
- Check that secrets.compare_digest(expected, current) remains indented inside check_baseline().
Still stuck? Help me restore the integrity check.
Add the password command
The generator now exists as a reusable function. An argparse subcommand makes it available through the same interface as the scanner and integrity tools.
- Locate the final parser definition inside build_parser().
- Insert this password parser directly above return parser:
password_parser = subparsers.add_parser("password", help="generate a password with secure randomness")
password_parser.add_argument("--length", type=password_length, default=20, help="password length")
What does the parser add?
- The password subcommand gives the generator its own help entry.
- The --length option passes its value through password_length().
- The default length is 20 characters.
- Locate the check branch inside main().
- Insert this dispatch branch directly below it:
elif args.command == "password":
print(generate_password(args.length))
How does the command run?
The branch passes the validated length to generate_password(). The returned password is printed in the terminal.
- Save security_toolkit.py.
Before you run the new command, how many characters do you expect the terminal to print?
- Generate the first password by running:
py security_toolkit.py password --length 20
What should you see?
You should see a 20-character password. It contains at least one lowercase letter, uppercase letter, digit, and punctuation character.
Password command not available?
- Check that the password parser appears above return parser.
- Check that the password branch uses the same indentation as the other command branches.
- Check that generate_password is spelled consistently in the function and dispatch branch.
Need help tracing the command? Help me debug the password subcommand.
✔️ Awesome, I've got everything!
Your completed security_toolkit.py now includes every toolkit command.
ⓧ I'd like to double check the full code
import argparse
import hashlib
import secrets
import socket
import string
from pathlib import Path
def port_number(value: str) -> int:
try:
port = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("port must be an integer") from exc
if not 1 <= port <= 65535:
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
return port
def positive_timeout(value: str) -> float:
try:
timeout = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("timeout must be a number") from exc
if timeout <= 0:
raise argparse.ArgumentTypeError("timeout must be greater than zero")
return timeout
def password_length(value: str) -> int:
try:
length = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("length must be an integer") from exc
if length < 4:
raise argparse.ArgumentTypeError("length must be at least 4 to include every character group")
return length
def scan_ports(target: str, ports: list[int], timeout: float) -> None:
try:
ip_address = socket.gethostbyname(target)
except socket.gaierror as exc:
raise ValueError(f"could not resolve target '{target}'") from exc
print(f"Target: {target} ({ip_address})")
for port in ports:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.settimeout(timeout)
result = client.connect_ex((ip_address, port))
except OSError as exc:
print(f"{port:>5}/tcp ERROR: {exc}")
continue
status = "OPEN" if result == 0 else "NOT OPEN"
print(f"{port:>5}/tcp {status}")
def sha256_file(path: Path) -> str:
if not path.is_file():
raise FileNotFoundError(f"file not found: {path}")
with path.open("rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
def create_baseline(file_path: Path, baseline_path: Path) -> None:
digest = sha256_file(file_path)
baseline_path.write_text(digest + "\n", encoding="utf-8")
print(f"Baseline saved: {baseline_path}")
print(f"SHA-256: {digest}")
def check_baseline(file_path: Path, baseline_path: Path) -> None:
if not baseline_path.is_file():
raise FileNotFoundError(f"baseline not found: {baseline_path}")
expected = baseline_path.read_text(encoding="utf-8").strip()
current = sha256_file(file_path)
if secrets.compare_digest(expected, current):
print("UNCHANGED")
print(f"SHA-256: {current}")
else:
print("CHANGED")
print(f"Expected: {expected}")
print(f"Current: {current}")
def generate_password(length: int) -> str:
alphabet = string.ascii_letters + string.digits + string.punctuation
while True:
password = "".join(secrets.choice(alphabet) for _ in range(length))
if any(character in string.ascii_lowercase for character in password) and any(character in string.ascii_uppercase for character in password) and any(character in string.digits for character in password) and any(character in string.punctuation for character in password):
return password
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Security automation toolkit for an authorized home lab")
subparsers = parser.add_subparsers(dest="command", required=True)
scan_parser = subparsers.add_parser("scan", help="check one or more authorized TCP ports")
scan_parser.add_argument("target", help="IPv4 hostname or address")
scan_parser.add_argument("ports", nargs="+", type=port_number)
scan_parser.add_argument("--timeout", type=positive_timeout, default=0.5, help="seconds per port")
hash_parser = subparsers.add_parser("hash", help="calculate a SHA-256 digest")
hash_parser.add_argument("file", type=Path)
baseline_parser = subparsers.add_parser("baseline", help="save a trusted SHA-256 baseline")
baseline_parser.add_argument("file", type=Path)
baseline_parser.add_argument("baseline", type=Path)
check_parser = subparsers.add_parser("check", help="compare a file with a saved baseline")
check_parser.add_argument("file", type=Path)
check_parser.add_argument("baseline", type=Path)
password_parser = subparsers.add_parser("password", help="generate a password with secure randomness")
password_parser.add_argument("--length", type=password_length, default=20, help="password length")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "scan":
scan_ports(args.target, args.ports, args.timeout)
elif args.command == "hash":
print(f"SHA-256: {sha256_file(args.file)}")
elif args.command == "baseline":
create_baseline(args.file, args.baseline)
elif args.command == "check":
check_baseline(args.file, args.baseline)
elif args.command == "password":
print(generate_password(args.length))
except (FileNotFoundError, OSError, ValueError) as exc:
print(f"Error: {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Verify every toolkit feature
A reusable command-line interface must preserve its earlier workflows when a new command is added. Your final check covers scanning, integrity monitoring, password generation, and generated help.
Before you run the final checks, which command do you expect to reveal the deliberate file change?
- Replace <ubuntu-hostname-or-ip> in the scan command below with the target detail you recorded earlier.
- Replace <ssh-port> with the port used by your existing SSH connection.
- Replace <blocked-test-port> with the blocked port you tested earlier.
- Run the final checks in the integrated PowerShell terminal:
py security_toolkit.py password --length 20
py security_toolkit.py --help
py security_toolkit.py check sample.txt sample.sha256
py security_toolkit.py scan <ubuntu-hostname-or-ip> <ssh-port> <blocked-test-port>
What do the final checks prove?
- The password command prints a 20-character result with all four character groups.
- The help screen lists scan, hash, baseline, check, and password.
- The integrity check prints CHANGED with the expected hash and current hash.
- The scan prints OPEN for the known SSH port. It prints NOT OPEN for the blocked test port.
One of the final checks failed?
- Confirm that the scan command uses the same authorized Ubuntu target details from earlier.
- Confirm that sample.txt still differs from the trusted version stored in sample.sha256.
- Compare security_toolkit.py with the full-code tab above.
Need help isolating the failed command? Help me debug my final toolkit verification.
That is the toolkit complete. Your single CLI now runs all five commands without editing the source.
Secret mission
Summarize an Exported Firewall Log
Create a parser for a sanitized copy of your existing firewall logs. It ranks source addresses plus protocols. It also ranks destination ports while preserving missing values as unknown.
Clean Up Your Resources
Clean Up Your Resources
The project created no cloud resources. There are no ongoing costs.
Decide whether to keep your resources available, pause your local workspace, or delete the project entirely.
Resources you used:
- The local security-automation-toolkit folder containing your complete project.
Keep everything running
No action is needed. Choose this option if you want to keep practising with the toolkit.
- Keep the security-automation-toolkit folder on your computer.
- Preserve sample.sha256 with sample.txt if you want to rerun the integrity demonstration.
- Keep ufw-sample.log sanitized before sharing it.
Pause - I'll come back to this later
Visual Studio Code is the only active application state left to pause. Your project files stay on disk after the editor closes.
- Close Visual Studio Code by selecting the X button in the top-right corner.
- Leave the security-automation-toolkit folder in its current location.
The integrated PowerShell terminal closes with the editor. Every project file remains available in the same folder.
Delete - I don't want to use this again
Permanent deletion removes only the local project copy. Your Ubuntu VM and its original firewall logs stay untouched.
- Close Visual Studio Code by selecting the X button in the top-right corner.
- Use Windows search to find the security-automation-toolkit folder.
- Select the matching folder in File Explorer.
- Hold Shift while pressing Delete to remove the folder permanently.
- Confirm the permanent deletion if Windows asks.
- Search for security-automation-toolkit again to verify the deletion.
You should see no matching folder after the search completes. This confirms the local project copy is gone.
Nice Work!
Nice Work!
You made it! Your Windows-based Python security toolkit now turns your isolated home lab into an authorized environment for defensive automation.
You've learned how to:
- Build a validated command-line scanner for authorized TCP ports. Report each result honestly as OPEN or NOT OPEN.
- Create a trusted SHA-256 integrity baseline for a file. Detect a deliberate change by comparing its current digest with the saved fingerprint.
- Generate passwords with cryptographically secure randomness. Enforce coverage across all four required character groups.
- Secret Mission: Turn sanitized firewall logs into ranked defensive summaries with named-group regular expressions.
Ready to quiz yourself?