Build a Local AI Security Reporter
Build a local CLI that scopes scans and turns Nmap evidence into reports.
Introduction
30 Second Summary
Security tools can act on a system in seconds. Permission can be much harder to prove after the fact.
In this project, you will build ScopeLock, a Windows-compatible Python CLI that blocks every target outside one learner-controlled OWASP Juice Shop container before Nmap runs. Each approved run produces audited scan evidence plus a readable report with grounded AI explanations.
What You'll Build
Picture opening artifacts/report.md after one authorized local scan to see exactly what Nmap observed within the approved boundary.
By the end of this project, you'll have:
- A fail-closed CLI that refuses every target outside the approved local lab before Nmap starts.
- An approved local assessment that writes raw scan evidence to artifacts/nmap.xml. It appends audit events to artifacts/audit.jsonl. It presents the result in artifacts/report.md.
- A readable risk report that keeps observed evidence separate from vulnerability claims. The report shows a deterministic learning label. It identifies the model or local fallback behind each explanation.
- Secret Mission: Draft a professional one-page Rules of Engagement record for the local exercise. The record turns ScopeLock's safeguards into written authorization boundaries.
Are there any prerequisites?
Basic Python functions plus dictionaries are helpful. No prior Nmap or AI API experience is required.
You need Visual Studio Code plus Docker Desktop installed on Windows before you begin.
Before We Start
This first checkpoint locks in ScopeLock's ethical boundary before any hands-on work begins. Your assessment stays limited to your learner-controlled OWASP Juice Shop container on IPv4 loopback because ScopeLock's safeguards cannot replace separate written authorization for any other system.
Set Up the Local Lab
ScopeLock can only be as safe as the lab underneath it. It needs an isolated target plus verified tools before any security automation can run.
You will use Docker to keep OWASP Juice Shop on IPv4 loopback.
Python runs the assessment CLI. Nmap captures service evidence.
Visual Studio Code gives the project a dedicated workspace. OpenRouter later provides an optional plain-language explanation.
In this step, get ready to:
- Verify Python plus Docker Desktop on Windows.
- Install Nmap 7.992 when needed.
- Launch the loopback-only Juice Shop lab and prepare the ScopeLock workspace.
Verify Python, Docker, and Nmap
Each tool owns one part of the workflow. Version checks confirm that Windows can find the command before ScopeLock depends on it.
- Press the Windows key to open Windows search.
- Type PowerShell and press Enter to open it.
- Verify Python plus the Docker command-line tool by running:
python --version
docker --version
What do these checks prove?
- The Python check confirms that the recommended Windows command can reach your installed runtime.
- The Docker check confirms that Windows can reach the command-line tool installed with Docker Desktop.
You should see a version line for each tool. These results confirm that both commands are available in PowerShell.
Is a tool unavailable?
Make sure Python plus Docker Desktop are installed. Restart PowerShell after any installation so Windows can refresh the available commands.
Still blocked? Help me troubleshoot my Python or Docker version check on Windows.
ScopeLock uses Nmap 7.992 so everyone runs the same documented scan options. Choose the tab that matches the result on your computer.
✔️ I see version 7.992
- Confirm the installed Nmap version by running:
nmap --version
What does this check prove?
The command reads the installed Nmap version. A result showing 7.992 confirms that the required release is available to ScopeLock.
Your Nmap installation is ready for the bounded scan later in the project.
ⓧ I see an older version
- Visit the official Nmap download page.
- Download the Windows self-installer named nmap-7.992-setup.exe.
- Run the downloaded installer.
- Approve the Windows permission request if it appears.
- Keep the default installer components.
A fresh PowerShell session lets Windows pick up the updated Nmap command.
- Close the current PowerShell window.
- Press the Windows key to open Windows search.
- Type PowerShell and press Enter to reopen it.
- Verify the updated Nmap version by running:
nmap --version
What does this check prove?
The new PowerShell process reads the updated command path. A result showing 7.992 confirms that the upgrade worked.
You should now see Nmap version 7.992 in the output.
ⓧ Command not found
- Visit the official Nmap download page.
- Download the Windows self-installer named nmap-7.992-setup.exe.
- Run the downloaded installer.
- Approve the Windows permission request if it appears.
- Keep the default installer components.
Windows needs a fresh PowerShell process before the newly installed command becomes available.
- Close the current PowerShell window.
- Press the Windows key to open Windows search.
- Type PowerShell and press Enter to reopen it.
- Verify the Nmap installation by running:
nmap --version
What does this check prove?
The command asks the new Nmap installation for its version. A result showing 7.992 confirms that Windows can now find it.
You should now see Nmap version 7.992 in the output.
Nmap still unavailable?
- Confirm that the installer finished before reopening PowerShell.
- Restart Windows if the command remains unavailable after installation.
Need another pair of eyes? Help me troubleshoot why Windows cannot find Nmap 7.992.
Launch the loopback-only Juice Shop lab
The lab is intentionally vulnerable. Binding its host port to 127.0.0.1 keeps it on your computer instead of publishing it on every host interface.
The first image pull can take a few minutes while Docker downloads the application layers. The progress shown in PowerShell means the download is moving forward.
- Pull the pinned Juice Shop image by running:
docker pull bkimminich/juice-shop:v20.3.0
What does this command do?
- The command downloads the Juice Shop image from Docker Hub.
- The tag v20.3.0 pins the lab to the project release.
You should see download progress followed by confirmation that the image is available locally.
- Start the bounded lab container by running:
docker run -d --name scopelock-juice-shop -p 127.0.0.1:3000:3000 bkimminich/juice-shop:v20.3.0
How is the lab contained?
- Detached mode keeps the container running in the background.
- The name scopelock-juice-shop gives the lab a stable identifier for later cleanup.
- The mapping 127.0.0.1:3000:3000 publishes container port 3000 only through IPv4 loopback.
- The image reference keeps the running container on bkimminich/juice-shop:v20.3.0.
Before you check the site, do you expect a browser on this computer to reach the loopback-bound container?
- Visit http://localhost:3000 in your web browser.
You should see the OWASP Juice Shop interface load. This proves the container is reachable through the approved local address.
That is your first visible win. The intentionally vulnerable lab is running inside the boundary ScopeLock is designed to protect.
Juice Shop not loading?
- Confirm that Docker Desktop is running.
- Check Docker Desktop for a stopped container named scopelock-juice-shop.
- Remove any older container with the same name through Docker Desktop before retrying the launch command.
Still stuck? Help me troubleshoot why my loopback-only Juice Shop container is not loading.
Prepare the ScopeLock workspace and optional API key
The project needs one named folder for the code you build next. Opening that folder in Visual Studio Code keeps every ScopeLock file in the same place.
Visual Studio Code may show a Workspace Trust prompt when it opens the new folder. You created this empty folder yourself.
- Switch back to the PowerShell window from earlier.
- Create the ScopeLock folder on your Desktop by running these commands:
Set-Location -Path "$Env:USERPROFILE\Desktop"
New-Item -Path .\ScopeLock -ItemType Directory
Set-Location -Path .\ScopeLock
Get-Location
code .
What do these commands do?
- The first command moves PowerShell to your Desktop.
- The second command creates the ScopeLock folder.
- The next commands enter the folder and print its location.
- The final command opens the current folder in Visual Studio Code.
- Select Yes, I trust the authors if the Workspace Trust prompt appears.
You should see a path ending in Desktop\ScopeLock in PowerShell. Visual Studio Code should show ScopeLock as the open folder.
Your workspace now has a clear home. Every file created in the next step stays inside this folder.
OpenRouter is optional because the finished program includes a deterministic local fallback. Its free model endpoint has zero prompt plus completion charges within the provider's free-model limits.
Credentials deserve a careful pause. The command below stores the key only in the current PowerShell session.
- Visit the OpenRouter API key page.
- Create a free OpenRouter account if the page asks you to sign up.
- Create an API key named ScopeLock.
- Copy the new key without placing it in a project file.
- Store the copied key in the current PowerShell session by replacing the plain text placeholder below:
$Env:OPENROUTER_API_KEY = "your-api-key-here"
Test-Path Env:OPENROUTER_API_KEY
How is the key protected?
- The first line creates OPENROUTER_API_KEY only inside the current PowerShell process.
- Closing that PowerShell session removes the session-only value.
- The second line checks whether the environment variable exists without printing the secret.
You should see True if the optional key is available. That result confirms the AI path is ready without exposing the credential.
Before the final check, do you expect the local lab to remain reachable while the ScopeLock workspace is open?
- Return to the browser tab at http://localhost:3000.
You should still see the OWASP Juice Shop interface. This confirms that the loopback-only container remains active while your project workspace is ready.
✔️ Everything is ready
Your verified tools plus isolated lab are ready. Keep the PowerShell session open if you configured the optional OpenRouter key.
ⓧ I'd like to double check the setup
- Python plus Docker return version information in PowerShell.
- Nmap reports version 7.992.
- The container scopelock-juice-shop is running from bkimminich/juice-shop:v20.3.0.
- The container uses the host mapping 127.0.0.1:3000:3000.
- The Juice Shop interface is reachable at http://localhost:3000.
- The lab is published only through IPv4 loopback.
- The ScopeLock folder is open in Visual Studio Code.
- The optional OPENROUTER_API_KEY exists only in the current PowerShell session.
- No API key is stored in a project file.
Your tools are verified plus your local lab is contained. Next, you will build the authorization gate that rejects every target outside this exact scope.
Build the Authorization Gate
Your OWASP Juice Shop lab is running on loopback. Your verified Nmap installation could scan a target once your program invokes it.
The next risk is scope drift. A fail-closed authorization gate must reject every unapproved target before requesting consent. It must also record each decision for later review.
In this step, get ready to:
- Document ScopeLock's local-only boundary.
- Build a CLI that validates the exact lab identifier, target, and port.
- Prove that approved input passes while out-of-scope input is denied.
Document the local-only boundary
The project files should preserve the same safety boundary as the code. The documentation names the only approved lab. The ignore rules keep generated evidence out of source control.
- Use the new-file control in the Visual Studio Code file sidebar to create .gitignore inside the ScopeLock folder.
- Add the ignore rules by pasting this content into .gitignore:
__pycache__/
*.pyc
.venv/
.env
artifacts/
What do these rules protect?
- The Python cache rules exclude generated bytecode.
- The environment rules exclude local configuration files that could contain credentials.
- The artifacts rule excludes audit logs and scan evidence generated during assessments.
- Save .gitignore.
- Confirm that .gitignore is listed in the Visual Studio Code file sidebar.
- Use the new-file control in the file sidebar to create README.md inside the ScopeLock folder.
- Add the project purpose and ethics boundary by pasting this first section into README.md:
# ScopeLock
ScopeLock is a Windows-compatible educational CLI for one learner-controlled OWASP Juice Shop container. It demonstrates authorization checks, narrow service enumeration, audit logging, XML evidence parsing, transparent risk communication, and grounded AI explanation.
## Ethics and Legal Use
This project is not legal advice. Use it only against the local lab container you own and control. Do not point security tools at third-party systems unless you have explicit written authorization, a clearly defined scope, permitted methods, stop conditions, and any jurisdiction-specific review you need.
ScopeLock intentionally permits only:
- Lab ID: `owasp-juice-shop-local`
- Target: `127.0.0.1`
- TCP port: `3000`
- Scan type: one bounded TCP connect scan with light service version detection
It prohibits non-loopback targets in code and does not include exploitation, Nmap scripts, credential attacks, denial of service, persistence, data exfiltration, lateral movement, or application crawling.
## Prerequisites
- Windows with Python 3
- Visual Studio Code
- Docker Desktop
- Nmap 7.992
- Optional free OpenRouter API key for the open model explanation
No third-party Python packages are required.
Why state the boundary twice?
The README tells a human what ScopeLock permits. The program enforces the same boundary before any scan can begin.
- Save README.md.
- Confirm that the file now shows the local lab identifier owasp-juice-shop-local.
- Add the existing lab and optional model setup below the current content in README.md:
## Start the Local Lab
```powershell
docker pull bkimminich/juice-shop:v20.3.0
docker run -d --name scopelock-juice-shop -p 127.0.0.1:3000:3000 bkimminich/juice-shop:v20.3.0
```
Open `http://localhost:3000` and confirm Juice Shop loads.
## Configure the Free Open Model
Create an OpenRouter API key and set it only for the current PowerShell session:
```powershell
$Env:OPENROUTER_API_KEY = "your-api-key-here"
```
ScopeLock uses `nvidia/nemotron-3.5-lightning:free`. If the key is missing, the endpoint is rate-limited, or a provider is unavailable, the report uses a deterministic local fallback.
Why document the setup?
The Docker command records the loopback-only port binding. The PowerShell command keeps the optional API key out of project files.
- Save README.md.
- Confirm that the Docker command includes 127.0.0.1:3000:3000.
- Add the test and cleanup sections below the current content in README.md:
## Run Tests
```powershell
python -m unittest -v test_scopelock.py
```
## Cleanup
Pause the lab:
```powershell
docker stop scopelock-juice-shop
```
Resume it:
```powershell
docker start scopelock-juice-shop
```
Delete it and clear the session key:
```powershell
docker rm --force scopelock-juice-shop
$Env:OPENROUTER_API_KEY = $null
```
What does this section preserve?
The test command provides a repeatable safety check. The cleanup section records how to stop or remove the intentionally vulnerable local lab.
- Save README.md.
- Confirm that the final heading in the file is Cleanup.
An append-only JSON Lines log stores one JSON record per line. This format lets ScopeLock preserve earlier decisions whenever it appends a new event.
- Use the new-file control in the file sidebar to create scopelock.py inside the ScopeLock folder.
- Define the imports and fixed local scope by pasting this code into scopelock.py:
import argparse
import datetime as dt
import json
from pathlib import Path
APP_NAME = "ScopeLock"
APPROVED_LAB_ID = "owasp-juice-shop-local"
APPROVED_TARGET = "127.0.0.1"
APPROVED_PORT = 3000
AUTHORIZATION_PHRASE = "I OWN AND AUTHORIZE THIS LOCAL LAB"
ARTIFACTS_DIR = Path("artifacts")
AUDIT_LOG = ARTIFACTS_DIR / "audit.jsonl"
LEGAL_NOTICE = (
"Educational local-lab use only. This is not legal advice. "
"ScopeLock permits only the learner-controlled OWASP Juice Shop lab "
"at 127.0.0.1:3000. Do not test third-party systems without explicit "
"written authorization and an appropriate Rules of Engagement."
)
What does this code establish?
- The approved lab constants create one exact allowlist entry.
- The authorization phrase creates an explicit consent checkpoint.
- The artifact paths keep generated evidence in one named directory.
- The legal notice explains that a technical control does not create legal authorization.
- Save scopelock.py.
- Check the file for valid Python syntax by running this command in the Visual Studio Code PowerShell terminal:
python -m py_compile scopelock.py
What does this check do?
Python compiles the file without running the assessment workflow. A return to the PowerShell prompt without a syntax error confirms that this first slice is valid.
- Add the UTC timestamp and audit helpers below the legal notice in scopelock.py:
def utc_now():
return dt.datetime.now(dt.timezone.utc).isoformat()
def ensure_artifacts():
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
def audit_event(event, status, details=None):
ensure_artifacts()
record = {
"timestamp_utc": utc_now(),
"application": APP_NAME,
"event": event,
"status": status,
"details": details or {},
}
with AUDIT_LOG.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
How does the audit trail work?
- The timestamp helper records each event in UTC.
- The directory helper creates artifacts when the first event is written.
- The audit helper appends one sorted JSON record without replacing earlier records.
- Save scopelock.py.
- Check the expanded audit foundation for valid syntax by running:
python -m py_compile scopelock.py
What does this result prove?
A clean return to the PowerShell prompt confirms that the timestamp and file-writing functions compile with the fixed configuration.
Seeing a syntax problem?
- Check that every opening parenthesis in LEGAL_NOTICE has a matching closing parenthesis.
- Check that the dictionary inside audit_event() keeps each key inside double quotes.
- Check that the final write call remains indented inside the with block.
Still stuck? Help me fix the syntax in my ScopeLock audit foundation.
Build the fail-closed CLI
Fail-closed validation starts from denial. Only an exact match for all three approved values produces an allowed result.
- Add the scope validator below audit_event() in scopelock.py:
def validate_scope(target, port, lab_id):
if target != APPROVED_TARGET:
return False, "Target rejected: only 127.0.0.1 is allowed."
if port != APPROVED_PORT:
return False, "Port rejected: only the local lab port 3000 is allowed."
if lab_id != APPROVED_LAB_ID:
return False, "Lab rejected: the approved lab identifier does not match."
return True, "Scope approved."
Why does validation happen in this order?
The function checks the target before every later checkpoint. Any mismatch immediately returns a denial result with a specific reason.
Approval is reachable only after the target, port, and lab identifier all match their fixed constants.
- Save scopelock.py.
- Check the validator for valid syntax by running:
python -m py_compile scopelock.py
What does this check confirm?
A clean return confirms that every rejection branch is valid Python. The behavioral tests come after the CLI is complete.
- Add the argument parser and entry point at the bottom of scopelock.py:
def build_parser():
parser = argparse.ArgumentParser(
description="Assess one authorized local OWASP Juice Shop lab.",
allow_abbrev=False,
)
parser.add_argument("--target", default=APPROVED_TARGET)
parser.add_argument("--port", type=int, default=APPROVED_PORT)
parser.add_argument("--lab-id", default=APPROVED_LAB_ID)
parser.add_argument("--no-ai", action="store_true")
return parser
if __name__ == "__main__":
raise SystemExit(main())
What does the parser control?
- The target, port, and lab identifier default to the approved local values.
- Disabling abbreviated options prevents partial flag names from being accepted.
- The entry point passes the result from main() back to PowerShell as the process exit status.
- Save scopelock.py.
- Check the parser and entry point for valid syntax by running:
python -m py_compile scopelock.py
What does this result prove?
A clean return confirms that the command-line arguments compile. The next function gives the entry point its executable workflow.
- Insert the main workflow between build_parser() and the final entry point in scopelock.py:
def main():
args = build_parser().parse_args()
print(LEGAL_NOTICE)
allowed, message = validate_scope(args.target, args.port, args.lab_id)
if not allowed:
audit_event(
"scope_denied",
"denied",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
print(message)
return 2
print("Type this exact phrase to continue:")
print(AUTHORIZATION_PHRASE)
typed_phrase = input("> ").strip()
if typed_phrase != AUTHORIZATION_PHRASE:
audit_event("authorization_denied", "denied", {"reason": "phrase_mismatch"})
print("Authorization phrase did not match. No scan was run.")
return 3
audit_event(
"authorization_accepted",
"allowed",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
How does the gate protect execution?
- The legal notice appears before ScopeLock evaluates any request.
- A scope mismatch is logged before the program returns exit status 2.
- The authorization phrase is requested only after scope approval.
- A phrase mismatch is logged before the program returns exit status 3.
- An exact phrase records an authorization_accepted event.
- Save scopelock.py.
Before you run ScopeLock, do you expect the default arguments to reach the authorization checkpoint?
- Run the approved local scope with this command:
python scopelock.py
What should happen at the checkpoint?
ScopeLock prints its legal notice. It then displays the exact authorization phrase above an input prompt.
- Type I OWN AND AUTHORIZE THIS LOCAL LAB at the prompt.
- Press Enter to submit the phrase.
- Expand the artifacts folder in the Visual Studio Code file sidebar.
- Open audit.jsonl from the expanded folder.
You should see an event named authorization_accepted with status allowed. Its details preserve the approved target, port, and lab identifier.
The first gate is working. ScopeLock now records explicit approval without invoking a scanner.
Did the authorization event fail to appear?
- Check that you typed the authorization phrase with the same capitalization as the displayed phrase.
- Check that main() appears above the final entry point.
- Check that audit_event() opens audit.jsonl in append mode.
Need a second pair of eyes? Help me debug why ScopeLock does not record authorization_accepted.
Prove the gate rejects scope drift
A safety gate needs repeatable evidence. These unit tests cover one approved case plus three rejected variations.
- Use the new-file control in the file sidebar to create test_scopelock.py inside the ScopeLock folder.
- Add the imports and first two scope tests by pasting this code into test_scopelock.py:
import unittest
from scopelock import (
APPROVED_LAB_ID,
APPROVED_PORT,
APPROVED_TARGET,
validate_scope,
)
class ScopeValidationTests(unittest.TestCase):
def test_scope_accepts_exact_lab(self):
allowed, _ = validate_scope(
APPROVED_TARGET, APPROVED_PORT, APPROVED_LAB_ID
)
self.assertTrue(allowed)
def test_scope_rejects_public_target(self):
allowed, _ = validate_scope("8.8.8.8", APPROVED_PORT, APPROVED_LAB_ID)
self.assertFalse(allowed)
What do these tests establish?
The first test protects the exact approved path. The second test proves that a public address cannot pass validation.
- Save test_scopelock.py.
- Add the private-network and wrong-port tests below test_scope_rejects_public_target() in the class:
def test_scope_rejects_private_network_target(self):
allowed, _ = validate_scope(
"192.168.1.10", APPROVED_PORT, APPROVED_LAB_ID
)
self.assertFalse(allowed)
def test_scope_rejects_wrong_port(self):
allowed, _ = validate_scope(APPROVED_TARGET, 80, APPROVED_LAB_ID)
self.assertFalse(allowed)
if __name__ == "__main__":
unittest.main()
Why test these variations?
A private-network address can still belong to another device. The wrong-port test proves that even localhost remains out of scope unless it uses port 3000.
- Save test_scopelock.py.
Before you run the suite, do you expect all four scope decisions to match the allowlist?
- Run the four scope tests with this command:
python -m unittest -v test_scopelock.py
What should the suite prove?
You should see four tests pass. This proves the validator accepts only the exact local lab configuration represented by the test cases.
Now test the refusal through the real command-line path. The request uses a private-network address outside the approved loopback target.
Before you run it, do you think ScopeLock will ask for the authorization phrase?
- Run the out-of-scope request with this command:
python scopelock.py --target 192.168.1.10
What does the refusal prove?
You should see Target rejected: only 127.0.0.1 is allowed. before any authorization prompt. ScopeLock exits before any scanner could be invoked.
- Return to audit.jsonl in the Visual Studio Code file sidebar.
- Confirm that the newest record has event scope_denied.
- Confirm that artifacts/nmap.xml is absent from the file sidebar.
That is the core safeguard working. The rejected attempt is auditable while scan evidence remains absent.
Did a scope test fail?
- Check that APPROVED_TARGET is exactly 127.0.0.1.
- Check that APPROVED_PORT is the integer 3000.
- Check that each mismatch in validate_scope() returns False immediately.
Still seeing an unexpected result? Help me diagnose my ScopeLock validation tests.
✔️ Awesome, I've got everything!
Your authorization gate is complete. Save .gitignore, README.md, scopelock.py, and test_scopelock.py.
ⓧ I'd like to double check the full code
- Compare each saved file with the complete versions below.
__pycache__/
*.pyc
.venv/
.env
artifacts/
# ScopeLock
ScopeLock is a Windows-compatible educational CLI for one learner-controlled OWASP Juice Shop container. It demonstrates authorization checks, narrow service enumeration, audit logging, XML evidence parsing, transparent risk communication, and grounded AI explanation.
## Ethics and Legal Use
This project is not legal advice. Use it only against the local lab container you own and control. Do not point security tools at third-party systems unless you have explicit written authorization, a clearly defined scope, permitted methods, stop conditions, and any jurisdiction-specific review you need.
ScopeLock intentionally permits only:
- Lab ID: `owasp-juice-shop-local`
- Target: `127.0.0.1`
- TCP port: `3000`
- Scan type: one bounded TCP connect scan with light service version detection
It prohibits non-loopback targets in code and does not include exploitation, Nmap scripts, credential attacks, denial of service, persistence, data exfiltration, lateral movement, or application crawling.
## Prerequisites
- Windows with Python 3
- Visual Studio Code
- Docker Desktop
- Nmap 7.992
- Optional free OpenRouter API key for the open model explanation
No third-party Python packages are required.
## Start the Local Lab
```powershell
docker pull bkimminich/juice-shop:v20.3.0
docker run -d --name scopelock-juice-shop -p 127.0.0.1:3000:3000 bkimminich/juice-shop:v20.3.0
```
Open `http://localhost:3000` and confirm Juice Shop loads.
## Configure the Free Open Model
Create an OpenRouter API key and set it only for the current PowerShell session:
```powershell
$Env:OPENROUTER_API_KEY = "your-api-key-here"
```
ScopeLock uses `nvidia/nemotron-3.5-lightning:free`. If the key is missing, the endpoint is rate-limited, or a provider is unavailable, the report uses a deterministic local fallback.
## Run Tests
```powershell
python -m unittest -v test_scopelock.py
```
## Cleanup
Pause the lab:
```powershell
docker stop scopelock-juice-shop
```
Resume it:
```powershell
docker start scopelock-juice-shop
```
Delete it and clear the session key:
```powershell
docker rm --force scopelock-juice-shop
$Env:OPENROUTER_API_KEY = $null
```
import argparse
import datetime as dt
import json
from pathlib import Path
APP_NAME = "ScopeLock"
APPROVED_LAB_ID = "owasp-juice-shop-local"
APPROVED_TARGET = "127.0.0.1"
APPROVED_PORT = 3000
AUTHORIZATION_PHRASE = "I OWN AND AUTHORIZE THIS LOCAL LAB"
ARTIFACTS_DIR = Path("artifacts")
AUDIT_LOG = ARTIFACTS_DIR / "audit.jsonl"
LEGAL_NOTICE = (
"Educational local-lab use only. This is not legal advice. "
"ScopeLock permits only the learner-controlled OWASP Juice Shop lab "
"at 127.0.0.1:3000. Do not test third-party systems without explicit "
"written authorization and an appropriate Rules of Engagement."
)
def utc_now():
return dt.datetime.now(dt.timezone.utc).isoformat()
def ensure_artifacts():
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
def audit_event(event, status, details=None):
ensure_artifacts()
record = {
"timestamp_utc": utc_now(),
"application": APP_NAME,
"event": event,
"status": status,
"details": details or {},
}
with AUDIT_LOG.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
def validate_scope(target, port, lab_id):
if target != APPROVED_TARGET:
return False, "Target rejected: only 127.0.0.1 is allowed."
if port != APPROVED_PORT:
return False, "Port rejected: only the local lab port 3000 is allowed."
if lab_id != APPROVED_LAB_ID:
return False, "Lab rejected: the approved lab identifier does not match."
return True, "Scope approved."
def build_parser():
parser = argparse.ArgumentParser(
description="Assess one authorized local OWASP Juice Shop lab.",
allow_abbrev=False,
)
parser.add_argument("--target", default=APPROVED_TARGET)
parser.add_argument("--port", type=int, default=APPROVED_PORT)
parser.add_argument("--lab-id", default=APPROVED_LAB_ID)
parser.add_argument("--no-ai", action="store_true")
return parser
def main():
args = build_parser().parse_args()
print(LEGAL_NOTICE)
allowed, message = validate_scope(args.target, args.port, args.lab_id)
if not allowed:
audit_event(
"scope_denied",
"denied",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
print(message)
return 2
print("Type this exact phrase to continue:")
print(AUTHORIZATION_PHRASE)
typed_phrase = input("> ").strip()
if typed_phrase != AUTHORIZATION_PHRASE:
audit_event("authorization_denied", "denied", {"reason": "phrase_mismatch"})
print("Authorization phrase did not match. No scan was run.")
return 3
audit_event(
"authorization_accepted",
"allowed",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
if __name__ == "__main__":
raise SystemExit(main())
import unittest
from scopelock import (
APPROVED_LAB_ID,
APPROVED_PORT,
APPROVED_TARGET,
validate_scope,
)
class ScopeValidationTests(unittest.TestCase):
def test_scope_accepts_exact_lab(self):
allowed, _ = validate_scope(
APPROVED_TARGET, APPROVED_PORT, APPROVED_LAB_ID
)
self.assertTrue(allowed)
def test_scope_rejects_public_target(self):
allowed, _ = validate_scope("8.8.8.8", APPROVED_PORT, APPROVED_LAB_ID)
self.assertFalse(allowed)
def test_scope_rejects_private_network_target(self):
allowed, _ = validate_scope(
"192.168.1.10", APPROVED_PORT, APPROVED_LAB_ID
)
self.assertFalse(allowed)
def test_scope_rejects_wrong_port(self):
allowed, _ = validate_scope(APPROVED_TARGET, 80, APPROVED_LAB_ID)
self.assertFalse(allowed)
if __name__ == "__main__":
unittest.main()
Your CLI now blocks scope drift before authorization. Next, you will place one fixed Nmap command behind this gate and preserve its raw XML evidence.
Capture Bounded Scan Evidence
Your authorization gate already rejects targets outside the approved local lab. The next goal is to collect evidence from the service running on the approved port.
This step runs a fixed Nmap command through Python. It preserves the scanner's raw XML before any reporting logic interprets the observations.
In this step, get ready to:
- Construct the fixed Nmap argument list for the approved local service.
- Run the command under a timeout with shell execution disabled.
- Capture successful XML evidence plus matching audit events.
Build the fixed Nmap command
Nmap accepts each option as a separate process argument. ScopeLock stores those arguments in a fixed list so input from the command line cannot alter the target or scan shape.
- In scopelock.py, find the existing import pair near the top of the file.
- Locate these adjacent lines:
import json
from pathlib import Path
Why This Pair Matters
This reference identifies the exact place where the process-management import belongs. Keeping standard-library imports together makes the dependency easy to review.
- Replace that import pair with this updated group:
import json
import subprocess
from pathlib import Path
What Changed?
The subprocess module lets Python launch Nmap with a structured argument list. ScopeLock uses that interface without invoking a command shell.
- Scroll below validate_scope() in scopelock.py.
- Add the fixed command builder in that location by copying this code:
def build_nmap_command():
return [
"nmap",
"-sT",
"-sV",
"--version-light",
"-p",
str(APPROVED_PORT),
"-oX",
"-",
APPROVED_TARGET,
]
Why a Fixed Argument List?
- The list limits Nmap to one TCP connect scan against the approved loopback target.
- The port value comes from APPROVED_PORT, which is fixed at 3000.
- The separate -oX and - arguments send XML to standard output.
- The fixed list prevents a command-line target from reaching the scanner.
- Save scopelock.py.
Before you run the existing tests, do you expect the new import to change any scope decision?
- Check the authorization gate for regressions by running this command in the PowerShell terminal from earlier:
python -m unittest -v test_scopelock.py
What Does This Check Prove?
The test command imports the updated module before running the original scope checks. A clean run proves the file still loads and the four authorization decisions remain unchanged.
You should see four tests pass. Good progress. The authorization gate still protects the exact local scope after the command builder was added.
Tests No Longer Pass?
- Check that import subprocess appears once near the top of scopelock.py.
- Check the indentation inside build_nmap_command() against the reference block.
- Confirm every command argument remains inside the returned list.
Still stuck? Help me debug the Nmap command builder without changing ScopeLock's approved target or port.
Run Nmap without a shell
The command builder defines what Nmap may run. The runner now needs to enforce a time limit and capture the result before saving successful evidence.
- Near the top of scopelock.py, find these existing artifact paths:
ARTIFACTS_DIR = Path("artifacts")
AUDIT_LOG = ARTIFACTS_DIR / "audit.jsonl"
What Are These Paths?
The first path names the evidence folder. The second path keeps the append-only audit history inside that folder.
- Replace those path declarations with this updated group:
ARTIFACTS_DIR = Path("artifacts")
AUDIT_LOG = ARTIFACTS_DIR / "audit.jsonl"
NMAP_XML = ARTIFACTS_DIR / "nmap.xml"
What Changed?
The new NMAP_XML path gives successful raw scan output a predictable destination. Failed scans never reach the write operation.
- Scroll below build_nmap_command() in scopelock.py.
- Add the bounded process runner by copying this code:
def run_nmap():
command = build_nmap_command()
audit_event("scan_started", "allowed", {"command": command})
result = subprocess.run(
command,
capture_output=True,
text=True,
shell=False,
timeout=60,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or "Nmap returned a nonzero exit status."
raise RuntimeError(message)
ensure_artifacts()
NMAP_XML.write_text(result.stdout, encoding="utf-8")
audit_event(
"scan_completed",
"success",
{"returncode": result.returncode, "xml_path": str(NMAP_XML)},
)
return result.stdout
What Does This Code Do?
- The first audit event records the exact argument list before execution.
- The subprocess.run() call captures standard output as text under a 60 second timeout.
- The shell=False setting keeps the arguments away from shell interpretation.
- A nonzero return code raises RuntimeError before any new XML is saved.
- A successful result writes the captured XML before recording scan_completed.
- Save scopelock.py.
- Confirm the updated module still passes the existing tests by running:
python -m unittest -v test_scopelock.py
What Does This Check Prove?
This run confirms Python can import the new runner. It also confirms the original four fail-closed scope tests remain green.
You should see four tests pass again. The bounded runner now loads without weakening any scope rule.
Seeing a Runner Error?
- Confirm NMAP_XML appears below AUDIT_LOG.
- Check that every subprocess.run() option ends with a comma.
- Check that return result.stdout aligns with the first line inside run_nmap().
Need another pair of eyes? Help me find the syntax problem in run_nmap without changing its fixed command or safety settings.
Capture the authorized evidence
The runner is ready, but main() must call it only after scope approval and the exact authorization phrase. The same success path also records the accepted authorization before Nmap starts.
- In scopelock.py, find the success-only ending below the authorization phrase mismatch block.
- Replace the existing authorization success message plus its final return with this code:
audit_event(
"authorization_accepted",
"allowed",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
try:
xml_text = run_nmap()
except Exception as error:
audit_event("assessment_failed", "error", {"error_type": type(error).__name__})
print("Assessment failed: " + str(error))
return 4
print("Assessment complete.")
print("Raw XML: " + str(NMAP_XML))
print("Audit log: " + str(AUDIT_LOG))
return 0
How Does the Evidence Flow Work?
- The accepted authorization event records the exact approved target details.
- The call to run_nmap() occurs only after scope validation and phrase matching succeed.
- Process failures create an assessment_failed event before the program exits.
- A successful run prints the two evidence paths created by the workflow.
- Save scopelock.py.
Before you run ScopeLock, do you expect raw scanner evidence to read like a finished assessment?
- Run the authorized local assessment without AI by entering this command in the PowerShell terminal from earlier:
python scopelock.py --no-ai
What Does This Command Do?
The command starts ScopeLock with its existing local defaults. The program pauses before scanning so it can verify the typed authorization phrase.
- Type I OWN AND AUTHORIZE THIS LOCAL LAB when ScopeLock requests the exact phrase.
You should see a completion message followed by paths for artifacts/nmap.xml and artifacts/audit.jsonl.
- Open artifacts/nmap.xml from the Visual Studio Code file tree to inspect the successful scan evidence.
- Open artifacts/audit.jsonl from the same file tree to confirm the matching audit trail.
In the XML file, you should see an entry describing TCP port 3000. Its nested fields preserve the observed port state and service evidence.
The audit log should include authorization_accepted, scan_started, and scan_completed events. Together, those records prove the scan followed the authorization path.
The Intended Shortfall
The XML is machine-oriented evidence. It provides no risk explanation or polished finding.
That limitation is intentional at this point. You have preserved the observation before adding interpretation.
No XML Evidence File?
- Confirm the scopelock-juice-shop container is still running on the approved loopback port.
- Confirm Nmap remains available in the PowerShell session.
- Read the newest assessment_failed event in artifacts/audit.jsonl to identify the failure category.
Still missing the evidence? Help me trace why ScopeLock recorded assessment_failed instead of writing nmap.xml.
The README should now show how another learner can repeat this evidence capture. Add only the commands and files that work at this stage.
- Append this usage section to README.md by copying the following content:
## Run Without AI
```powershell
python scopelock.py --no-ai
```
ScopeLock writes:
- `artifacts/nmap.xml`
- `artifacts/audit.jsonl`
## Demonstrate Fail-Closed Scope
```powershell
python scopelock.py --target 192.168.1.10
```
The command is rejected before authorization or Nmap execution, and the denial is recorded in the audit log.
What Does This Document?
The first section records the authorized evidence workflow. The second section preserves the fail-closed demonstration from the previous step.
- Save README.md.
- Review the saved usage section to confirm it names both evidence files.
That is the evidence chain working. ScopeLock now records authorization, executes one bounded scan, and preserves the raw result for review.
✔️ Awesome, I've got everything!
Great. Keep scopelock.py and README.md saved. Your raw XML plus audit history are ready for the reporting pipeline.
ⓧ I'd like to double check the full code
import argparse
import datetime as dt
import json
import subprocess
from pathlib import Path
APP_NAME = "ScopeLock"
APPROVED_LAB_ID = "owasp-juice-shop-local"
APPROVED_TARGET = "127.0.0.1"
APPROVED_PORT = 3000
AUTHORIZATION_PHRASE = "I OWN AND AUTHORIZE THIS LOCAL LAB"
ARTIFACTS_DIR = Path("artifacts")
AUDIT_LOG = ARTIFACTS_DIR / "audit.jsonl"
NMAP_XML = ARTIFACTS_DIR / "nmap.xml"
LEGAL_NOTICE = (
"Educational local-lab use only. This is not legal advice. "
"ScopeLock permits only the learner-controlled OWASP Juice Shop lab "
"at 127.0.0.1:3000. Do not test third-party systems without explicit "
"written authorization and an appropriate Rules of Engagement."
)
def utc_now():
return dt.datetime.now(dt.timezone.utc).isoformat()
def ensure_artifacts():
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
def audit_event(event, status, details=None):
ensure_artifacts()
record = {
"timestamp_utc": utc_now(),
"application": APP_NAME,
"event": event,
"status": status,
"details": details or {},
}
with AUDIT_LOG.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
def validate_scope(target, port, lab_id):
if target != APPROVED_TARGET:
return False, "Target rejected: only 127.0.0.1 is allowed."
if port != APPROVED_PORT:
return False, "Port rejected: only the local lab port 3000 is allowed."
if lab_id != APPROVED_LAB_ID:
return False, "Lab rejected: the approved lab identifier does not match."
return True, "Scope approved."
def build_nmap_command():
return [
"nmap",
"-sT",
"-sV",
"--version-light",
"-p",
str(APPROVED_PORT),
"-oX",
"-",
APPROVED_TARGET,
]
def run_nmap():
command = build_nmap_command()
audit_event("scan_started", "allowed", {"command": command})
result = subprocess.run(
command,
capture_output=True,
text=True,
shell=False,
timeout=60,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or "Nmap returned a nonzero exit status."
raise RuntimeError(message)
ensure_artifacts()
NMAP_XML.write_text(result.stdout, encoding="utf-8")
audit_event(
"scan_completed",
"success",
{"returncode": result.returncode, "xml_path": str(NMAP_XML)},
)
return result.stdout
def build_parser():
parser = argparse.ArgumentParser(
description="Assess one authorized local OWASP Juice Shop lab.",
allow_abbrev=False,
)
parser.add_argument("--target", default=APPROVED_TARGET)
parser.add_argument("--port", type=int, default=APPROVED_PORT)
parser.add_argument("--lab-id", default=APPROVED_LAB_ID)
parser.add_argument("--no-ai", action="store_true")
return parser
def main():
args = build_parser().parse_args()
print(LEGAL_NOTICE)
allowed, message = validate_scope(args.target, args.port, args.lab_id)
if not allowed:
audit_event(
"scope_denied",
"denied",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
print(message)
return 2
print("Type this exact phrase to continue:")
print(AUTHORIZATION_PHRASE)
typed_phrase = input("> ").strip()
if typed_phrase != AUTHORIZATION_PHRASE:
audit_event("authorization_denied", "denied", {"reason": "phrase_mismatch"})
print("Authorization phrase did not match. No scan was run.")
return 3
audit_event(
"authorization_accepted",
"allowed",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
try:
xml_text = run_nmap()
except Exception as error:
audit_event("assessment_failed", "error", {"error_type": type(error).__name__})
print("Assessment failed: " + str(error))
return 4
print("Assessment complete.")
print("Raw XML: " + str(NMAP_XML))
print("Audit log: " + str(AUDIT_LOG))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Your bounded scan now leaves an auditable trail from authorization to raw evidence. Next, you will turn those observations into a readable risk report without treating reachability as proof of a vulnerability.
Build the Evidence Report
Your authorization gate now protects a bounded Nmap scan of the local lab. The successful scan produces raw evidence that is difficult to interpret or present.
This step turns the machine-oriented XML into a readable assessment report. Deterministic code owns every finding and learning risk label.
In this step, get ready to:
- Parse the documented fields from the locally generated Nmap evidence.
- Apply deterministic learning risk labels that avoid unsupported vulnerability claims.
- Generate a readable report with evidence boundaries and defensive next steps.
Parse the local Nmap evidence
Nmap stores each observed port as structured XML elements. ScopeLock reads only the documented port attributes plus their state and service fields.
- Add the XML parser import below import subprocess in scopelock.py.
- Add the report path below the existing NMAP_XML constant by copying this code:
import xml.etree.ElementTree as ET
REPORT_FILE = ARTIFACTS_DIR / "report.md"
What does this setup add?
- xml.etree.ElementTree gives ScopeLock a standard-library XML parser.
- REPORT_FILE points to the readable assessment inside the existing artifacts folder.
- Add the evidence parser below run_nmap() in scopelock.py by copying this code:
def parse_nmap_xml(xml_text):
root = ET.fromstring(xml_text)
findings = []
for port_element in root.findall(".//port"):
state_element = port_element.find("state")
service_element = port_element.find("service")
finding = {
"port": int(port_element.get("portid", "0")),
"protocol": port_element.get("protocol", "unknown"),
"state": state_element.get("state", "unknown") if state_element is not None else "unknown",
"service": service_element.get("name", "unknown") if service_element is not None else "unknown",
"product": service_element.get("product", "") if service_element is not None else "",
"version": service_element.get("version", "") if service_element is not None else "",
}
findings.append(finding)
return findings
What does the parser preserve?
- ET.fromstring() converts the locally generated XML text into elements that Python can inspect.
- findall(".//port") locates each recorded port element.
- Each finding preserves the observed port and protocol.
- Each finding also preserves the state and service evidence.
Before you run the tests, do you expect the unchanged authorization checks to keep passing after the parser is added?
- Save scopelock.py.
- Check that the updated module still loads by running:
python -m unittest -v test_scopelock.py
What does this test run check?
The test runner imports scopelock.py before executing the suite. This catches parser syntax errors while preserving the four existing scope checks.
You should see four passing tests. The fail-closed authorization gate still accepts only the exact local lab.
- Replace the parenthesized import group in test_scopelock.py with this expanded group:
from scopelock import (
APPROVED_LAB_ID,
APPROVED_PORT,
APPROVED_TARGET,
parse_nmap_xml,
validate_scope,
)
Why import the parser?
The suite needs direct access to parse_nmap_xml(). This lets it check predictable evidence without launching another scan.
- Add the controlled XML sample below the import group in test_scopelock.py.
- Add EvidencePipelineTests below the existing ScopeValidationTests class by copying this code:
SAMPLE_XML = """<?xml version="1.0"?>
<nmaprun>
<host>
<ports>
<port protocol="tcp" portid="3000">
<state state="open" reason="syn-ack" />
<service name="http" product="Training Web App" version="1.0" />
</port>
</ports>
</host>
</nmaprun>
"""
class EvidencePipelineTests(unittest.TestCase):
def test_parse_nmap_xml_extracts_documented_fields(self):
findings = parse_nmap_xml(SAMPLE_XML)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["port"], 3000)
self.assertEqual(findings[0]["protocol"], "tcp")
self.assertEqual(findings[0]["state"], "open")
self.assertEqual(findings[0]["service"], "http")
What does this test prove?
SAMPLE_XML contains one known TCP port with an open state. The assertions prove that ScopeLock preserves the expected port and service evidence.
Before you run the expanded suite, how many tests do you expect to pass?
- Save test_scopelock.py.
- Run the expanded suite with:
python -m unittest -v test_scopelock.py
What should pass now?
The suite now contains four scope tests plus one parser test. A successful run reports five passing tests.
That is the first reporting milestone complete. ScopeLock can now turn raw local XML into a structured finding.
Seeing an XML test failure?
- Check that every opening tag in SAMPLE_XML has a matching closing tag.
- Confirm that parse_nmap_xml appears inside the parenthesized import group.
- Compare the dictionary keys in the test with the keys returned by parse_nmap_xml().
Still stuck? Help me debug the ScopeLock XML parser test.
Apply the deterministic learning rubric
An open port proves that a service is reachable. ScopeLock uses a fixed learning rubric so that this observation cannot become an unsupported vulnerability claim.
- Add the assessment function below parse_nmap_xml() in scopelock.py by copying this code:
def assess_findings(findings):
assessed = []
for original in findings:
finding = dict(original)
if finding["state"] == "open":
finding["risk"] = "Low"
finding["risk_score"] = "1/5"
finding["risk_basis"] = (
"The expected lab service is reachable through a host port bound "
"to IPv4 loopback. Reachability alone does not prove a vulnerability."
)
else:
finding["risk"] = "Informational"
finding["risk_score"] = "0/5"
finding["risk_basis"] = (
"The bounded scan did not confirm a listening service on the "
"single approved port."
)
assessed.append(finding)
return assessed
How does the rubric work?
- An open service receives the learning label Low with the score 1/5.
- A non-open result receives the learning label Informational with the score 0/5.
- risk_basis records the deterministic reason for the result.
Before you test the rubric itself, do you expect the five existing checks to keep passing?
- Save scopelock.py.
- Confirm that the existing suite still passes by running:
python -m unittest -v test_scopelock.py
What does this run confirm?
Five passing tests confirm that the assessment function loads correctly. The next test locks its interpretation boundary in place.
- Replace the parenthesized import group in test_scopelock.py with this version:
from scopelock import (
APPROVED_LAB_ID,
APPROVED_PORT,
APPROVED_TARGET,
assess_findings,
parse_nmap_xml,
validate_scope,
)
Why import the assessment function?
The suite tests assess_findings() separately from report formatting. This gives the learning label direct test coverage.
- Add the learning risk test below the parser test inside EvidencePipelineTests by copying this code:
def test_open_loopback_service_uses_low_learning_label(self):
findings = assess_findings(parse_nmap_xml(SAMPLE_XML))
self.assertEqual(findings[0]["risk"], "Low")
self.assertEqual(findings[0]["risk_score"], "1/5")
self.assertIn("does not prove a vulnerability", findings[0]["risk_basis"])
What does this test protect?
The test locks the open loopback result to Low with a score of 1/5. It also protects the statement that reachability does not prove a vulnerability.
Before you run the suite, do you expect the fixed open-port sample to receive the Low label or the Informational label?
- Save test_scopelock.py.
- Run all six tests with:
python -m unittest -v test_scopelock.py
What should the suite report?
You should see six tests pass. The final test confirms that the open loopback service receives the fixed Low learning label without a vulnerability claim.
The interpretation boundary is now protected by an automated test. A changing service banner cannot control ScopeLock's learning risk label.
- Add the local explanation and Markdown escaping helpers below assess_findings() in scopelock.py by copying this code:
def fallback_explanation(findings):
if any(finding["state"] == "open" for finding in findings):
return (
"Nmap confirmed that the approved local lab port is reachable. "
"This is evidence of an open service, not proof of a vulnerability or exploitability. "
"Keep the container bound to 127.0.0.1 and stop or remove it when the exercise ends."
)
return (
"The bounded scan did not confirm an open service on the approved port. "
"Check that the local container is running and still bound to 127.0.0.1. "
"No vulnerability conclusion can be drawn from this result alone."
)
def markdown_safe(value):
return str(value).replace("|", "\\|").replace("\n", " ")
Why add a local explanation?
- fallback_explanation() converts the deterministic result into plain language without an external service.
- markdown_safe() prevents observed pipe characters from damaging the report table.
- markdown_safe() also removes line breaks from table values.
Write and verify the Markdown report
A Markdown report makes the evidence readable while keeping its limitations visible. It records the fixed scope plus the observed service data and deterministic interpretation.
- Add the opening of build_report() below markdown_safe() by copying this code:
def build_report(findings, explanation, model_used):
command_text = " ".join(build_nmap_command())
lines = [
"# ScopeLock Local Lab Assessment",
"",
"## Legal and Scope Notice",
"",
LEGAL_NOTICE,
"",
"## Engagement Details",
"",
"- Timestamp UTC: " + utc_now(),
"- Lab: " + APPROVED_LAB_ID,
"- Authorized target: " + APPROVED_TARGET,
"- Authorized port: " + str(APPROVED_PORT),
"- Command: `" + command_text + "`",
"- Model used: `" + model_used + "`",
"",
"## Methodology",
"",
"ScopeLock used a TCP connect scan with light service version detection, "
"limited to one loopback target and one approved port. It did not run "
"Nmap scripts, exploit code, credential attacks, denial-of-service tests, "
"or scans against any non-loopback address.",
"",
"## Findings",
"",
"| Port | Protocol | State | Service | Product | Version | Learning Risk |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
What does the report header establish?
The opening sections record the legal boundary and approved endpoint. They also preserve the fixed scan command plus the explanation source.
The methodology lists the intrusive activities that ScopeLock excludes. This keeps the assessment's limits visible to anyone reading it.
- Save scopelock.py.
- Check the report header for syntax errors by running:
python -m unittest -v test_scopelock.py
Why test during assembly?
Importing the file catches indentation or string errors close to the edit that caused them. You should still see six passing tests.
- Continue inside build_report() by adding the findings table logic after the lines list:
if findings:
for finding in findings:
lines.append(
"| {port} | {protocol} | {state} | {service} | {product} | {version} | {risk} ({score}) |".format(
port=markdown_safe(finding["port"]),
protocol=markdown_safe(finding["protocol"]),
state=markdown_safe(finding["state"]),
service=markdown_safe(finding["service"]),
product=markdown_safe(finding["product"] or "Not identified"),
version=markdown_safe(finding["version"] or "Not identified"),
risk=markdown_safe(finding["risk"]),
score=markdown_safe(finding["risk_score"]),
)
)
else:
lines.append("| 3000 | tcp | No port entry | Not identified | Not identified | Not identified | Informational (0/5) |")
How are findings displayed?
Each assessed finding becomes one Markdown table row. An empty findings list receives an explicit Informational row.
- Continue inside build_report() by adding the risk basis and explanation sections after the findings logic:
lines.extend(
[
"",
"## Risk Basis",
"",
"This is a transparent learning rubric, not CVSS. An open loopback-bound "
"lab service is labeled Low because it is locally reachable. Reachability "
"does not establish that a vulnerability exists.",
"",
"## Plain-Language Explanation",
"",
explanation,
]
)
Why name the scoring boundary?
CVSS is a formal vulnerability scoring system. ScopeLock states that its learning rubric is not CVSS because this scan establishes reachability without establishing a vulnerability.
Before you run the suite again, do you expect the reporting edits to change any tested scope or risk result?
- Save scopelock.py.
- Check the assembled report sections by running:
python -m unittest -v test_scopelock.py
What does this check prove?
Six passing tests show that the report table and risk sections form valid Python. The tested scope and risk behavior remains unchanged.
- Insert the remaining report sections after explanation inside the lines.extend() list:
"",
"## Defensive Next Steps",
"",
"- Keep the Docker port mapping bound to `127.0.0.1`.",
"- Stop or remove the intentionally vulnerable container after the exercise.",
"- Review the raw XML and audit log before sharing the report.",
"- Do not generalize this result into a vulnerability claim without separate evidence.",
"",
"## Limitations",
"",
"- One IPv4 loopback address and one TCP port were assessed.",
"- Service detection is an observation and can be incomplete or incorrect.",
"- No vulnerability scripts, exploit attempts, authentication tests, or application crawling were performed.",
"- AI text is explanatory only. Deterministic code owns the evidence and learning risk label.",
"",
"## Evidence Files",
"",
"- `artifacts/nmap.xml`",
"- `artifacts/audit.jsonl`",
"- `artifacts/report.md`",
What completes the assessment?
The final sections turn the observation into defensive actions. The limitations prevent the result from being presented as evidence of exploitation.
The evidence list connects the report to the raw scan and append-only audit trail. A reviewer can trace the readable conclusion back to its source.
- Finish build_report() by adding these lines after lines.extend().
- Keep these lines inside the function at the same indentation level as lines.extend().
ensure_artifacts()
REPORT_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
return REPORT_FILE
How is the report saved?
ensure_artifacts() confirms that the evidence folder exists. The completed report is written to artifacts/report.md with UTF-8 encoding.
- In main() replace the existing scan and error-handling section with this local reporting pipeline:
try:
xml_text = run_nmap()
findings = assess_findings(parse_nmap_xml(xml_text))
explanation = fallback_explanation(findings)
report_path = build_report(findings, explanation, "local-fallback")
audit_event("report_created", "success", {"path": str(report_path)})
except (FileNotFoundError, subprocess.TimeoutExpired, RuntimeError, ET.ParseError) as error:
audit_event("assessment_failed", "error", {"error_type": type(error).__name__})
print("Assessment failed: " + str(error))
return 4
print("Assessment complete.")
print("Report: " + str(report_path))
print("Raw XML: " + str(NMAP_XML))
print("Audit log: " + str(AUDIT_LOG))
return 0
How does the pipeline fit together?
- run_nmap() returns XML only after exact scope validation and authorization succeed.
- parse_nmap_xml() converts the local scan into structured evidence.
- assess_findings() applies the deterministic learning rubric.
- build_report() writes the assessment before report_created is added to the audit log.
Before you run the complete workflow, what evidence do you expect the report to preserve from the loopback-bound service?
- Save scopelock.py.
- Run the deterministic local assessment in the PowerShell terminal from earlier with:
python scopelock.py --no-ai
Why run without AI?
The --no-ai option keeps this verification local and repeatable. Deterministic code supplies the evidence and explanation.
- Type I OWN AND AUTHORIZE THIS LOCAL LAB when ScopeLock requests the authorization phrase.
You should see Assessment complete. followed by the three evidence paths. The audit trail now contains a report_created event.
Assessment did not complete?
- Confirm that the scopelock-juice-shop container from earlier is still running.
- Check that the authorization phrase matches the displayed phrase exactly.
- Review the audited failure type for an Nmap problem or malformed XML.
Need a second pair of eyes? Help me troubleshoot the ScopeLock report pipeline.
The README should explain how to interpret the new report. This prevents the learning labels from being mistaken for formal vulnerability scores.
- Add the report path below the audit log path in the expected artifact list inside README.md by copying this line:
- `artifacts/report.md`
Why list the report?
The README now names all three evidence artifacts produced by a successful assessment. A reviewer can move from raw XML to the audit trail and readable report.
- Add this interpretation boundary at the end of README.md by copying this section:
## Interpretation Boundary
An open port proves reachability, not a vulnerability. Nmap service identification can be incomplete. The report's `Low` and `Informational` values are a learning rubric, not CVSS. The language model explains sanitized evidence but does not control the finding, score, or legal scope.
What does this boundary communicate?
The README explains what the labels mean and what they cannot prove. The report cannot be presented as a formal vulnerability score.
- Add the cleanup section below the interpretation boundary in README.md by copying this section:
## Cleanup
Pause the lab:
```powershell
docker stop scopelock-juice-shop
```
Resume it:
```powershell
docker start scopelock-juice-shop
```
Delete it and clear the session key:
```powershell
docker rm --force scopelock-juice-shop
$Env:OPENROUTER_API_KEY = $null
```
Why document cleanup?
The cleanup section records how to pause or remove the intentionally vulnerable container. It also records how to clear the session-scoped API key.
- Save README.md.
- Expand the artifacts folder in the Visual Studio Code Explorer sidebar.
- Select report.md to inspect the generated assessment.
You should see the observed port state plus a Low or Informational learning label. You should also see the phrase not CVSS and a statement that reachability does not establish a vulnerability.
✔️ Awesome, I've got everything!
Your six tests pass. The generated artifacts/report.md now presents the local evidence with a deterministic interpretation boundary.
ⓧ I'd like to double check the full code
Compare your cumulative scopelock.py with this version:
import argparse
import datetime as dt
import json
import subprocess
import xml.etree.ElementTree as ET
from pathlib import Path
APP_NAME = "ScopeLock"
APPROVED_LAB_ID = "owasp-juice-shop-local"
APPROVED_TARGET = "127.0.0.1"
APPROVED_PORT = 3000
AUTHORIZATION_PHRASE = "I OWN AND AUTHORIZE THIS LOCAL LAB"
ARTIFACTS_DIR = Path("artifacts")
AUDIT_LOG = ARTIFACTS_DIR / "audit.jsonl"
NMAP_XML = ARTIFACTS_DIR / "nmap.xml"
REPORT_FILE = ARTIFACTS_DIR / "report.md"
LEGAL_NOTICE = (
"Educational local-lab use only. This is not legal advice. "
"ScopeLock permits only the learner-controlled OWASP Juice Shop lab "
"at 127.0.0.1:3000. Do not test third-party systems without explicit "
"written authorization and an appropriate Rules of Engagement."
)
def utc_now():
return dt.datetime.now(dt.timezone.utc).isoformat()
def ensure_artifacts():
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
def audit_event(event, status, details=None):
ensure_artifacts()
record = {
"timestamp_utc": utc_now(),
"application": APP_NAME,
"event": event,
"status": status,
"details": details or {},
}
with AUDIT_LOG.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
def validate_scope(target, port, lab_id):
if target != APPROVED_TARGET:
return False, "Target rejected: only 127.0.0.1 is allowed."
if port != APPROVED_PORT:
return False, "Port rejected: only the local lab port 3000 is allowed."
if lab_id != APPROVED_LAB_ID:
return False, "Lab rejected: the approved lab identifier does not match."
return True, "Scope approved."
def build_nmap_command():
return [
"nmap",
"-sT",
"-sV",
"--version-light",
"-p",
str(APPROVED_PORT),
"-oX",
"-",
APPROVED_TARGET,
]
def run_nmap():
command = build_nmap_command()
audit_event("scan_started", "allowed", {"command": command})
result = subprocess.run(
command,
capture_output=True,
text=True,
shell=False,
timeout=60,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or "Nmap returned a nonzero exit status."
raise RuntimeError(message)
ensure_artifacts()
NMAP_XML.write_text(result.stdout, encoding="utf-8")
audit_event(
"scan_completed",
"success",
{"returncode": result.returncode, "xml_path": str(NMAP_XML)},
)
return result.stdout
def parse_nmap_xml(xml_text):
root = ET.fromstring(xml_text)
findings = []
for port_element in root.findall(".//port"):
state_element = port_element.find("state")
service_element = port_element.find("service")
finding = {
"port": int(port_element.get("portid", "0")),
"protocol": port_element.get("protocol", "unknown"),
"state": state_element.get("state", "unknown") if state_element is not None else "unknown",
"service": service_element.get("name", "unknown") if service_element is not None else "unknown",
"product": service_element.get("product", "") if service_element is not None else "",
"version": service_element.get("version", "") if service_element is not None else "",
}
findings.append(finding)
return findings
def assess_findings(findings):
assessed = []
for original in findings:
finding = dict(original)
if finding["state"] == "open":
finding["risk"] = "Low"
finding["risk_score"] = "1/5"
finding["risk_basis"] = (
"The expected lab service is reachable through a host port bound "
"to IPv4 loopback. Reachability alone does not prove a vulnerability."
)
else:
finding["risk"] = "Informational"
finding["risk_score"] = "0/5"
finding["risk_basis"] = (
"The bounded scan did not confirm a listening service on the "
"single approved port."
)
assessed.append(finding)
return assessed
def fallback_explanation(findings):
if any(finding["state"] == "open" for finding in findings):
return (
"Nmap confirmed that the approved local lab port is reachable. "
"This is evidence of an open service, not proof of a vulnerability or exploitability. "
"Keep the container bound to 127.0.0.1 and stop or remove it when the exercise ends."
)
return (
"The bounded scan did not confirm an open service on the approved port. "
"Check that the local container is running and still bound to 127.0.0.1. "
"No vulnerability conclusion can be drawn from this result alone."
)
def markdown_safe(value):
return str(value).replace("|", "\\|").replace("\n", " ")
def build_report(findings, explanation, model_used):
command_text = " ".join(build_nmap_command())
lines = [
"# ScopeLock Local Lab Assessment",
"",
"## Legal and Scope Notice",
"",
LEGAL_NOTICE,
"",
"## Engagement Details",
"",
"- Timestamp UTC: " + utc_now(),
"- Lab: " + APPROVED_LAB_ID,
"- Authorized target: " + APPROVED_TARGET,
"- Authorized port: " + str(APPROVED_PORT),
"- Command: `" + command_text + "`",
"- Model used: `" + model_used + "`",
"",
"## Methodology",
"",
"ScopeLock used a TCP connect scan with light service version detection, "
"limited to one loopback target and one approved port. It did not run "
"Nmap scripts, exploit code, credential attacks, denial-of-service tests, "
"or scans against any non-loopback address.",
"",
"## Findings",
"",
"| Port | Protocol | State | Service | Product | Version | Learning Risk |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
if findings:
for finding in findings:
lines.append(
"| {port} | {protocol} | {state} | {service} | {product} | {version} | {risk} ({score}) |".format(
port=markdown_safe(finding["port"]),
protocol=markdown_safe(finding["protocol"]),
state=markdown_safe(finding["state"]),
service=markdown_safe(finding["service"]),
product=markdown_safe(finding["product"] or "Not identified"),
version=markdown_safe(finding["version"] or "Not identified"),
risk=markdown_safe(finding["risk"]),
score=markdown_safe(finding["risk_score"]),
)
)
else:
lines.append("| 3000 | tcp | No port entry | Not identified | Not identified | Not identified | Informational (0/5) |")
lines.extend(
[
"",
"## Risk Basis",
"",
"This is a transparent learning rubric, not CVSS. An open loopback-bound "
"lab service is labeled Low because it is locally reachable. Reachability "
"does not establish that a vulnerability exists.",
"",
"## Plain-Language Explanation",
"",
explanation,
"",
"## Defensive Next Steps",
"",
"- Keep the Docker port mapping bound to `127.0.0.1`.",
"- Stop or remove the intentionally vulnerable container after the exercise.",
"- Review the raw XML and audit log before sharing the report.",
"- Do not generalize this result into a vulnerability claim without separate evidence.",
"",
"## Limitations",
"",
"- One IPv4 loopback address and one TCP port were assessed.",
"- Service detection is an observation and can be incomplete or incorrect.",
"- No vulnerability scripts, exploit attempts, authentication tests, or application crawling were performed.",
"- AI text is explanatory only. Deterministic code owns the evidence and learning risk label.",
"",
"## Evidence Files",
"",
"- `artifacts/nmap.xml`",
"- `artifacts/audit.jsonl`",
"- `artifacts/report.md`",
]
)
ensure_artifacts()
REPORT_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
return REPORT_FILE
def build_parser():
parser = argparse.ArgumentParser(
description="Assess one authorized local OWASP Juice Shop lab.",
allow_abbrev=False,
)
parser.add_argument("--target", default=APPROVED_TARGET)
parser.add_argument("--port", type=int, default=APPROVED_PORT)
parser.add_argument("--lab-id", default=APPROVED_LAB_ID)
parser.add_argument("--no-ai", action="store_true")
return parser
def main():
args = build_parser().parse_args()
print(LEGAL_NOTICE)
allowed, message = validate_scope(args.target, args.port, args.lab_id)
if not allowed:
audit_event(
"scope_denied",
"denied",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
print(message)
return 2
print("Type this exact phrase to continue:")
print(AUTHORIZATION_PHRASE)
typed_phrase = input("> ").strip()
if typed_phrase != AUTHORIZATION_PHRASE:
audit_event("authorization_denied", "denied", {"reason": "phrase_mismatch"})
print("Authorization phrase did not match. No scan was run.")
return 3
audit_event(
"authorization_accepted",
"allowed",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
try:
xml_text = run_nmap()
findings = assess_findings(parse_nmap_xml(xml_text))
explanation = fallback_explanation(findings)
report_path = build_report(findings, explanation, "local-fallback")
audit_event("report_created", "success", {"path": str(report_path)})
except (FileNotFoundError, subprocess.TimeoutExpired, RuntimeError, ET.ParseError) as error:
audit_event("assessment_failed", "error", {"error_type": type(error).__name__})
print("Assessment failed: " + str(error))
return 4
print("Assessment complete.")
print("Report: " + str(report_path))
print("Raw XML: " + str(NMAP_XML))
print("Audit log: " + str(AUDIT_LOG))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Compare your cumulative test_scopelock.py with this version:
import unittest
from scopelock import (
APPROVED_LAB_ID,
APPROVED_PORT,
APPROVED_TARGET,
assess_findings,
parse_nmap_xml,
validate_scope,
)
SAMPLE_XML = """<?xml version="1.0"?>
<nmaprun>
<host>
<ports>
<port protocol="tcp" portid="3000">
<state state="open" reason="syn-ack" />
<service name="http" product="Training Web App" version="1.0" />
</port>
</ports>
</host>
</nmaprun>
"""
class ScopeValidationTests(unittest.TestCase):
def test_scope_accepts_exact_lab(self):
allowed, _ = validate_scope(
APPROVED_TARGET, APPROVED_PORT, APPROVED_LAB_ID
)
self.assertTrue(allowed)
def test_scope_rejects_public_target(self):
allowed, _ = validate_scope("8.8.8.8", APPROVED_PORT, APPROVED_LAB_ID)
self.assertFalse(allowed)
def test_scope_rejects_private_network_target(self):
allowed, _ = validate_scope(
"192.168.1.10", APPROVED_PORT, APPROVED_LAB_ID
)
self.assertFalse(allowed)
def test_scope_rejects_wrong_port(self):
allowed, _ = validate_scope(APPROVED_TARGET, 80, APPROVED_LAB_ID)
self.assertFalse(allowed)
class EvidencePipelineTests(unittest.TestCase):
def test_parse_nmap_xml_extracts_documented_fields(self):
findings = parse_nmap_xml(SAMPLE_XML)
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0]["port"], 3000)
self.assertEqual(findings[0]["protocol"], "tcp")
self.assertEqual(findings[0]["state"], "open")
self.assertEqual(findings[0]["service"], "http")
def test_open_loopback_service_uses_low_learning_label(self):
findings = assess_findings(parse_nmap_xml(SAMPLE_XML))
self.assertEqual(findings[0]["risk"], "Low")
self.assertEqual(findings[0]["risk_score"], "1/5")
self.assertIn("does not prove a vulnerability", findings[0]["risk_basis"])
if __name__ == "__main__":
unittest.main()
Your bounded scan now produces a deterministic evidence report with an explicit interpretation boundary. Next, you will add a constrained OpenRouter explanation while keeping evidence and risk decisions under local code control.
Add Grounded AI Explanations
Your deterministic report already turns local scan evidence into transparent learning risk labels. The remaining gap is readability for someone who has never seen raw security evidence before.
A constrained language model can explain those findings in plain language. In this step, you'll use OpenRouter while keeping evidence collection and risk decisions inside deterministic Python code.
In this step, get ready to:
- Minimize the evidence sent to the model.
- Request a constrained explanation with safe fallback behavior.
- Record model provenance in the report and audit history.
Minimize the AI payload
Data minimization limits the model to the facts needed for its explanation. ScopeLock excludes the API key, audit history, raw XML, host identifier, and discovered product details from the request.
- In scopelock.py, replace the import and application-constant section at the top with this expanded section:
import argparse
import datetime as dt
import json
import subprocess
import urllib.request
import xml.etree.ElementTree as ET
from os import getenv
from pathlib import Path
APP_NAME = "ScopeLock"
APPROVED_LAB_ID = "owasp-juice-shop-local"
APPROVED_TARGET = "127.0.0.1"
APPROVED_PORT = 3000
AUTHORIZATION_PHRASE = "I OWN AND AUTHORIZE THIS LOCAL LAB"
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_MODEL = "nvidia/nemotron-3.5-lightning:free"
What does this code add?
- The urllib.request import provides the standard-library HTTP client used for the model request.
- The getenv import reads the optional API key from the current PowerShell session.
- The two OpenRouter constants keep the endpoint and model identity explicit for auditability.
- In scopelock.py, find assess_findings().
- Add this function immediately below assess_findings():
def sanitize_for_ai(findings):
return [
{
"port": finding["port"],
"protocol": finding["protocol"],
"state": finding["state"],
"service": finding["service"],
"risk": finding["risk"],
"risk_basis": finding["risk_basis"],
}
for finding in findings
]
What does this code do?
- The function creates a new list containing only the fields approved for explanation.
- The deterministic risk and risk_basis values travel with the evidence. The model receives a conclusion to explain instead of a severity decision to make.
- The omitted product and version fields prevent extra fingerprinting details from leaving the local workflow.
- Save scopelock.py.
- Confirm that the existing scope and evidence behavior remains intact by running:
python -m unittest -v test_scopelock.py
You should see all six tests pass. Your privacy boundary is now part of the code without changing the deterministic findings.
Tests no longer pass?
- Check that sanitize_for_ai() starts at the left edge of scopelock.py.
- Check that each selected field remains inside the dictionary returned by the list comprehension.
- Confirm that the closing square bracket lines up with return.
Still stuck? Help me fix the sanitize_for_ai function without adding new fields to the OpenRouter payload.
Call the constrained model
The model receives a strict system instruction before it receives the sanitized findings. That instruction confines the response to supplied evidence and blocks unsupported security claims.
- In scopelock.py, find fallback_explanation().
- Start explain_with_openrouter() immediately below fallback_explanation() by adding this first segment:
def explain_with_openrouter(findings, no_ai=False):
fallback = fallback_explanation(findings)
if no_ai:
return fallback, "local-fallback", "AI disabled by command-line option."
api_key = getenv("OPENROUTER_API_KEY")
if not api_key:
return fallback, "local-fallback", "OPENROUTER_API_KEY is not set."
payload = {
"model": OPENROUTER_MODEL,
"messages": [
{
"role": "system",
"content": (
"You explain evidence from an authorized local training lab. "
"Use only the supplied facts. Do not claim a CVE, vulnerability, "
"exploitability, compromise, or severity beyond the supplied risk label. "
"Write three short sentences: evidence, limitation, and defensive next step."
),
},
{
"role": "user",
"content": json.dumps(sanitize_for_ai(findings), sort_keys=True),
},
],
}
How is the model constrained?
- The function returns the local explanation immediately when AI is disabled or the session key is missing.
- The system instruction prohibits unsupported claims about CVEs, vulnerabilities, exploitability, compromise, or severity.
- The user message contains only the dictionary produced by sanitize_for_ai().
- Continue the same explain_with_openrouter() function by adding this request segment directly below payload.
request = urllib.request.Request(
OPENROUTER_URL,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + api_key,
"Content-Type": "application/json",
},
method="POST",
)
How is the request built?
- The request encodes the constrained payload as JSON using UTF-8.
- The API key is used only in the authorization header. It never enters the report or model message.
- The request explicitly uses the POST method required by the chat completions endpoint.
- Complete explain_with_openrouter() by adding this response and fallback segment directly below the request:
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = json.loads(response.read().decode("utf-8"))
explanation = data["choices"][0]["message"]["content"].strip()
model_used = data.get("model", OPENROUTER_MODEL)
if not explanation:
raise ValueError("OpenRouter returned an empty explanation.")
return explanation, model_used, "OpenRouter explanation completed."
except Exception as error:
return fallback, "local-fallback", type(error).__name__
How does fallback stay safe?
- A successful response returns the explanation with the provider's model identifier.
- An empty response becomes a failure instead of entering the report as missing content.
- Any request or response failure returns the deterministic explanation with local-fallback as its provenance.
- Save scopelock.py.
- Check the completed function for syntax errors by running:
python -m unittest -v test_scopelock.py
You should see all six tests pass again. The model client can now fail without taking the evidence pipeline down.
Seeing a syntax error?
- Check that the request and response segments remain indented inside explain_with_openrouter().
- Check that the try and except lines use the same indentation level.
- Confirm that the three code segments appear in the order shown.
Need another pair of eyes? Help me trace the indentation in explain_with_openrouter.
Record provenance and verify fallback
The final connection point records whether the explanation came from the requested model or the local fallback. This gives each report and audit event a traceable origin.
- In scopelock.py, scroll to the try block inside main().
- Find this existing line:
findings = assess_findings(parse_nmap_xml(xml_text))
Where does this change begin?
This line is the handoff from raw local evidence to deterministic findings. The new explanation and provenance logic belongs immediately after it.
- Replace the existing explanation and report lines below that anchor with this block:
explanation, model_used, ai_status = explain_with_openrouter(
findings, no_ai=args.no_ai
)
audit_event(
"ai_complete" if model_used != "local-fallback" else "ai_fallback",
"success" if model_used != "local-fallback" else "degraded",
{"model": model_used, "status": ai_status},
)
report_path = build_report(findings, explanation, model_used)
audit_event("report_created", "success", {"path": str(report_path)})
What does this wiring prove?
- The command-line option determines whether the model request is attempted.
- The ai_complete event identifies a provider-backed explanation.
- The ai_fallback event records degraded operation without turning it into an assessment failure.
- The report receives the returned model identifier as provenance.
- Update README.md with the local-only ethics boundary and exact allowlist.
- Add the Windows prerequisites and loopback-bound Docker setup to README.md.
- Add the current-session OpenRouter configuration and fallback behavior to README.md.
- Add the test, no-AI, OpenRouter, and fail-closed run instructions to README.md.
- Add the cleanup commands and interpretation boundary to README.md.
- Save scopelock.py.
- Save README.md.
Before you run ScopeLock, which provenance value do you expect from the current PowerShell session?
- Run the complete authorized assessment from the ScopeLock terminal:
python scopelock.py
- Enter I OWN AND AUTHORIZE THIS LOCAL LAB when ScopeLock requests the authorization phrase.
- Use the Visual Studio Code workspace file tree to open artifacts/report.md.
- Find the Model used field in the report.
- Use the workspace file tree to open artifacts/audit.jsonl.
- Inspect the final AI event in the audit history.
You should see nvidia/nemotron-3.5-lightning:free in the report when the request succeeds. If the endpoint is unavailable, you should see local-fallback while the audit history records ai_fallback.
That completes the safety boundary. ScopeLock can improve readability while deterministic code keeps control of evidence and learning risk.
Report missing model provenance?
- Check that build_report() receives model_used in the updated main() block.
- Check that the current PowerShell session still contains the optional OpenRouter key if you expected a provider-backed explanation.
- Confirm that artifacts/audit.jsonl contains either ai_complete or ai_fallback before report_created.
Still unsure? Help me trace why ScopeLock did not record the expected model provenance.
✔️ Awesome, I've got everything!
Your completed workflow keeps evidence and risk decisions deterministic. Save scopelock.py before moving on.
ⓧ I'd like to double check the full code
import argparse
import datetime as dt
import json
import subprocess
import urllib.request
import xml.etree.ElementTree as ET
from os import getenv
from pathlib import Path
APP_NAME = "ScopeLock"
APPROVED_LAB_ID = "owasp-juice-shop-local"
APPROVED_TARGET = "127.0.0.1"
APPROVED_PORT = 3000
AUTHORIZATION_PHRASE = "I OWN AND AUTHORIZE THIS LOCAL LAB"
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_MODEL = "nvidia/nemotron-3.5-lightning:free"
ARTIFACTS_DIR = Path("artifacts")
AUDIT_LOG = ARTIFACTS_DIR / "audit.jsonl"
NMAP_XML = ARTIFACTS_DIR / "nmap.xml"
REPORT_FILE = ARTIFACTS_DIR / "report.md"
LEGAL_NOTICE = (
"Educational local-lab use only. This is not legal advice. "
"ScopeLock permits only the learner-controlled OWASP Juice Shop lab "
"at 127.0.0.1:3000. Do not test third-party systems without explicit "
"written authorization and an appropriate Rules of Engagement."
)
def utc_now():
return dt.datetime.now(dt.timezone.utc).isoformat()
def ensure_artifacts():
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
def audit_event(event, status, details=None):
ensure_artifacts()
record = {
"timestamp_utc": utc_now(),
"application": APP_NAME,
"event": event,
"status": status,
"details": details or {},
}
with AUDIT_LOG.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
def validate_scope(target, port, lab_id):
if target != APPROVED_TARGET:
return False, "Target rejected: only 127.0.0.1 is allowed."
if port != APPROVED_PORT:
return False, "Port rejected: only the local lab port 3000 is allowed."
if lab_id != APPROVED_LAB_ID:
return False, "Lab rejected: the approved lab identifier does not match."
return True, "Scope approved."
def build_nmap_command():
return [
"nmap",
"-sT",
"-sV",
"--version-light",
"-p",
str(APPROVED_PORT),
"-oX",
"-",
APPROVED_TARGET,
]
def run_nmap():
command = build_nmap_command()
audit_event("scan_started", "allowed", {"command": command})
result = subprocess.run(
command,
capture_output=True,
text=True,
shell=False,
timeout=60,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or "Nmap returned a nonzero exit status."
raise RuntimeError(message)
ensure_artifacts()
NMAP_XML.write_text(result.stdout, encoding="utf-8")
audit_event(
"scan_completed",
"success",
{"returncode": result.returncode, "xml_path": str(NMAP_XML)},
)
return result.stdout
def parse_nmap_xml(xml_text):
root = ET.fromstring(xml_text)
findings = []
for port_element in root.findall(".//port"):
state_element = port_element.find("state")
service_element = port_element.find("service")
finding = {
"port": int(port_element.get("portid", "0")),
"protocol": port_element.get("protocol", "unknown"),
"state": state_element.get("state", "unknown") if state_element is not None else "unknown",
"service": service_element.get("name", "unknown") if service_element is not None else "unknown",
"product": service_element.get("product", "") if service_element is not None else "",
"version": service_element.get("version", "") if service_element is not None else "",
}
findings.append(finding)
return findings
def assess_findings(findings):
assessed = []
for original in findings:
finding = dict(original)
if finding["state"] == "open":
finding["risk"] = "Low"
finding["risk_score"] = "1/5"
finding["risk_basis"] = (
"The expected lab service is reachable through a host port bound "
"to IPv4 loopback. Reachability alone does not prove a vulnerability."
)
else:
finding["risk"] = "Informational"
finding["risk_score"] = "0/5"
finding["risk_basis"] = (
"The bounded scan did not confirm a listening service on the "
"single approved port."
)
assessed.append(finding)
return assessed
def sanitize_for_ai(findings):
return [
{
"port": finding["port"],
"protocol": finding["protocol"],
"state": finding["state"],
"service": finding["service"],
"risk": finding["risk"],
"risk_basis": finding["risk_basis"],
}
for finding in findings
]
def fallback_explanation(findings):
if any(finding["state"] == "open" for finding in findings):
return (
"Nmap confirmed that the approved local lab port is reachable. "
"This is evidence of an open service, not proof of a vulnerability or exploitability. "
"Keep the container bound to 127.0.0.1 and stop or remove it when the exercise ends."
)
return (
"The bounded scan did not confirm an open service on the approved port. "
"Check that the local container is running and still bound to 127.0.0.1. "
"No vulnerability conclusion can be drawn from this result alone."
)
def explain_with_openrouter(findings, no_ai=False):
fallback = fallback_explanation(findings)
if no_ai:
return fallback, "local-fallback", "AI disabled by command-line option."
api_key = getenv("OPENROUTER_API_KEY")
if not api_key:
return fallback, "local-fallback", "OPENROUTER_API_KEY is not set."
payload = {
"model": OPENROUTER_MODEL,
"messages": [
{
"role": "system",
"content": (
"You explain evidence from an authorized local training lab. "
"Use only the supplied facts. Do not claim a CVE, vulnerability, "
"exploitability, compromise, or severity beyond the supplied risk label. "
"Write three short sentences: evidence, limitation, and defensive next step."
),
},
{
"role": "user",
"content": json.dumps(sanitize_for_ai(findings), sort_keys=True),
},
],
}
request = urllib.request.Request(
OPENROUTER_URL,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + api_key,
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = json.loads(response.read().decode("utf-8"))
explanation = data["choices"][0]["message"]["content"].strip()
model_used = data.get("model", OPENROUTER_MODEL)
if not explanation:
raise ValueError("OpenRouter returned an empty explanation.")
return explanation, model_used, "OpenRouter explanation completed."
except Exception as error:
return fallback, "local-fallback", type(error).__name__
def markdown_safe(value):
return str(value).replace("|", "\\|").replace("\n", " ")
def build_report(findings, explanation, model_used):
command_text = " ".join(build_nmap_command())
lines = [
"# ScopeLock Local Lab Assessment",
"",
"## Legal and Scope Notice",
"",
LEGAL_NOTICE,
"",
"## Engagement Details",
"",
"- Timestamp UTC: " + utc_now(),
"- Lab: " + APPROVED_LAB_ID,
"- Authorized target: " + APPROVED_TARGET,
"- Authorized port: " + str(APPROVED_PORT),
"- Command: `" + command_text + "`",
"- Model used: `" + model_used + "`",
"",
"## Methodology",
"",
"ScopeLock used a TCP connect scan with light service version detection, "
"limited to one loopback target and one approved port. It did not run "
"Nmap scripts, exploit code, credential attacks, denial-of-service tests, "
"or scans against any non-loopback address.",
"",
"## Findings",
"",
"| Port | Protocol | State | Service | Product | Version | Learning Risk |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
if findings:
for finding in findings:
lines.append(
"| {port} | {protocol} | {state} | {service} | {product} | {version} | {risk} ({score}) |".format(
port=markdown_safe(finding["port"]),
protocol=markdown_safe(finding["protocol"]),
state=markdown_safe(finding["state"]),
service=markdown_safe(finding["service"]),
product=markdown_safe(finding["product"] or "Not identified"),
version=markdown_safe(finding["version"] or "Not identified"),
risk=markdown_safe(finding["risk"]),
score=markdown_safe(finding["risk_score"]),
)
)
else:
lines.append("| 3000 | tcp | No port entry | Not identified | Not identified | Not identified | Informational (0/5) |")
lines.extend(
[
"",
"## Risk Basis",
"",
"This is a transparent learning rubric, not CVSS. An open loopback-bound "
"lab service is labeled Low because it is locally reachable. Reachability "
"does not establish that a vulnerability exists.",
"",
"## Plain-Language Explanation",
"",
explanation,
"",
"## Defensive Next Steps",
"",
"- Keep the Docker port mapping bound to `127.0.0.1`.",
"- Stop or remove the intentionally vulnerable container after the exercise.",
"- Review the raw XML and audit log before sharing the report.",
"- Do not generalize this result into a vulnerability claim without separate evidence.",
"",
"## Limitations",
"",
"- One IPv4 loopback address and one TCP port were assessed.",
"- Service detection is an observation and can be incomplete or incorrect.",
"- No vulnerability scripts, exploit attempts, authentication tests, or application crawling were performed.",
"- AI text is explanatory only. Deterministic code owns the evidence and learning risk label.",
"",
"## Evidence Files",
"",
"- `artifacts/nmap.xml`",
"- `artifacts/audit.jsonl`",
"- `artifacts/report.md`",
]
)
ensure_artifacts()
REPORT_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
return REPORT_FILE
def build_parser():
parser = argparse.ArgumentParser(
description="Assess one authorized local OWASP Juice Shop lab.",
allow_abbrev=False,
)
parser.add_argument("--target", default=APPROVED_TARGET)
parser.add_argument("--port", type=int, default=APPROVED_PORT)
parser.add_argument("--lab-id", default=APPROVED_LAB_ID)
parser.add_argument("--no-ai", action="store_true")
return parser
def main():
args = build_parser().parse_args()
print(LEGAL_NOTICE)
allowed, message = validate_scope(args.target, args.port, args.lab_id)
if not allowed:
audit_event(
"scope_denied",
"denied",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
print(message)
return 2
print("Type this exact phrase to continue:")
print(AUTHORIZATION_PHRASE)
typed_phrase = input("> ").strip()
if typed_phrase != AUTHORIZATION_PHRASE:
audit_event("authorization_denied", "denied", {"reason": "phrase_mismatch"})
print("Authorization phrase did not match. No scan was run.")
return 3
audit_event(
"authorization_accepted",
"allowed",
{"target": args.target, "port": args.port, "lab_id": args.lab_id},
)
try:
xml_text = run_nmap()
findings = assess_findings(parse_nmap_xml(xml_text))
explanation, model_used, ai_status = explain_with_openrouter(
findings, no_ai=args.no_ai
)
audit_event(
"ai_complete" if model_used != "local-fallback" else "ai_fallback",
"success" if model_used != "local-fallback" else "degraded",
{"model": model_used, "status": ai_status},
)
report_path = build_report(findings, explanation, model_used)
audit_event("report_created", "success", {"path": str(report_path)})
except (FileNotFoundError, subprocess.TimeoutExpired, RuntimeError, ET.ParseError) as error:
audit_event("assessment_failed", "error", {"error_type": type(error).__name__})
print("Assessment failed: " + str(error))
return 4
print("Assessment complete.")
print("Report: " + str(report_path))
print("Raw XML: " + str(NMAP_XML))
print("Audit log: " + str(AUDIT_LOG))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Secret mission
Draft a Rules of Engagement Record
ScopeLock enforces a narrow technical boundary for one local lab. Translate those safeguards into a one-page Rules of Engagement record that documents authorization, scope, permitted testing, stop conditions, and cleanup duties.
Clean Up Your Resources
Clean Up Your Resources
This project has no ongoing charges under its current local and free-model setup. Choose whether to keep the lab active, pause it for later, or remove the container plus local evidence.
Resources you used:
- A Docker container named scopelock-juice-shop hosting OWASP Juice Shop at 127.0.0.1:3000.
- An optional OPENROUTER_API_KEY value stored in the current PowerShell session for free-model requests through OpenRouter.
- Local source and documentation files: scopelock.py, test_scopelock.py, .gitignore, README.md, and RULES_OF_ENGAGEMENT.md.
- Local evidence in artifacts: Nmap XML in nmap.xml, a JSON Lines audit log in audit.jsonl, and a Markdown report in report.md.
Keep everything running
No action is needed. Choose this option while you are still testing ScopeLock against your local lab.
- Leave the scopelock-juice-shop container running on 127.0.0.1:3000.
- Leave the optional OPENROUTER_API_KEY in its current session only while you are using ScopeLock.
- Retain the source files plus RULES_OF_ENGAGEMENT.md for future local exercises.
- Retain the artifacts folder as evidence of the authorized assessment.
Pause - I'll come back to this later
Stop the lab to free its local resources. Your source files plus assessment evidence remain available for the next session.
- Stop the local lab by running this command:
docker stop scopelock-juice-shop
What does this command do?
Docker stops the named container without deleting it. Your ScopeLock files plus saved evidence stay in place.
- Confirm the paused state by visiting http://localhost:3000. The Juice Shop interface should no longer load.
- Resume the saved lab later by running this command:
docker start scopelock-juice-shop
What does this command do?
Docker starts the existing container with its original loopback port mapping. It does not create a second container.
- Confirm the resumed state by visiting http://localhost:3000. You should see the Juice Shop interface again.
Delete - I don't want to use this again
Removing the container plus local files is permanent. Use this option only after you have finished the exercise.
- Remove the local lab container by running this command:
docker rm --force scopelock-juice-shop
What does this command do?
Docker force-removes the named container even when it is running. The loopback-hosted Juice Shop lab stops with it.
- Clear the optional key from the current PowerShell session by running this command:
$Env:OPENROUTER_API_KEY = $null
What does this command do?
PowerShell removes the environment variable from the current session. The key value no longer remains available to ScopeLock in that session.
- Confirm the container removal by visiting http://localhost:3000. The Juice Shop interface should no longer load.
- Use your Windows file browser to return to the folder that contains scopelock.py.
- Select scopelock.py, test_scopelock.py, .gitignore, README.md, RULES_OF_ENGAGEMENT.md, and the artifacts folder.
- Delete the selection.
- Confirm that scopelock.py plus the artifacts folder are no longer listed.
Nice Work!
Nice Work!
You did it! ScopeLock now validates an authorized local target before running one bounded Nmap scan.
You've learned how to:
- Built a fail-closed authorization gate that accepts only the exact local lab configuration. ScopeLock records each accepted or denied action in an append-only JSON Lines audit log. You verified the guardrail with six automated tests.
- Captured Nmap XML evidence from a fixed loopback service scan. You parsed that evidence into a readable Markdown report. You also applied a transparent learning rubric that keeps reachability separate from vulnerability proof.
- Added grounded open-model explanations through OpenRouter. The workflow shares only sanitized evidence. It records model provenance. It uses a deterministic local fallback when the API is unavailable.
- Completed the optional Secret Mission by producing a one-page Rules of Engagement record. It documents the full engagement boundary from authorization through cleanup.
Ready to quiz yourself?