🎯 What You'll Learn
- Use AI for the four things it genuinely accelerates in an engagement — and stop there
- Keep every use inside scope, and keep client data out of any model you do not control
- Treat a model's confident output as a lead to verify, never as a finding
- Build a
pytestchecker that fails a report containing a claim not backed by evidence - Run all of it CPU-only, in Docker, with a local model — nothing leaves your machine
Level check — for people who already run engagements. It assumes you have scoped a test, written findings, and handled client data under an NDA. It does not assume any machine-learning background, and there is no maths on this page. The companion from the target's side is AI Red Teaming: A Methodology; the defensive-work companion is Applications of AI in InfoSec.
The one idea
AI does not make you a better pentester. It makes a competent pentester faster at the parts of the job that are typing, reading and summarising — and it makes an incompetent one produce plausible nonsense at scale. The skill this lab teaches is not prompting. It is knowing exactly where the tool helps, drawing the professional and legal lines it must never cross, and building the verification step that stands between "the model said so" and "I put it in the report".
Hold one sentence for the whole lab:
A model's confident output is a lead, not a finding. It becomes a finding only when you have reproduced it against the target with your own hands and captured the evidence.
Everything below is built around making that sentence enforceable rather than aspirational.
The boundaries — read these before the techniques
These are not caveats bolted on at the end. They are the reason this page is pitched at people who already know security: an experienced tester will recognise every one of them as a rule they already follow, extended to a new tool.
Boundary 1 — Scope is scope, and the model does not know where it ends. A model will happily generate commands against a host, a subnet or a domain that is adjacent to your target and outside your authorisation. It has no concept of your rules of engagement. Every command it produces is a suggestion you must check against the scope document before it touches a network. AI-generated recon that wanders one CIDR past the authorised range is the same unauthorised-access offence it would be if you typed it yourself — the tool is no defence.
Boundary 2 — Client data never goes into a model you do not control. No hostnames, IPs, credentials, source code, PII, screenshots, config files, or raw tool output pasted into a public consumer AI service. Those services may retain and train on input; you would be exfiltrating your client's data to a third party, breaching your NDA, and very possibly breaking data-protection law in the client's jurisdiction. The rule is absolute and simple: if it identifies the client or their systems, it only meets a model running on hardware you control. This lab runs a local model in Docker precisely so that the safe path is also the easy path.
Boundary 3 — A confident output is not a finding. Models fabricate with total fluency: a CVE that does not exist, a version that was never shipped, an exploit path that cannot work in this configuration, a "vulnerability" in code that is guarded three layers up. Reporting an unverified model claim is negligence with your name on it and your firm's reputation behind it. Nothing reaches a client report until you have reproduced it against the actual target and captured the evidence. Step 5 turns this boundary into a test that fails your report if you break it.
Boundary 4 — Get AI use authorised, and disclose it. Some clients contractually prohibit AI tools in their engagements; some regulated environments require disclosure of any automated processing of their data. Put your intended AI use — which tools, which model, where it runs, what touches it — in the pre-engagement documentation, and get it signed off alongside the rest of your methodology. "I used a local model that never saw your data" is a much easier conversation to have before the test than after a leak.
Those four boundaries are the assessable core of this lab's exam alignment (CY0-001 3.2 and 3.3 for the technique, 4.3 for the compliance dimension). Learn them as a set.
The CPU-only rig
CPU-ONLY. Everything runs on a 16 GB laptop with no GPU, under docker compose up. The local model is not just a cost saver here — it is Boundary 2 made structural, and the compose file below does the structural part deliberately. aidesk, the container that handles engagement data, is attached only to a network declared internal: true, so it has no route off the host: it can reach Ollama and nothing else. That is a property you can check with docker network inspect, not a promise.
Read what it does not give you, because that matters more. The ollama container keeps egress — it has to, or you cannot pull a model — so the boundary holds one hop out, not two. OLLAMA_HOST is an environment variable, and a careless edit repoints it. And nothing here stops you pasting client data into a browser on the same laptop. The rig makes the safe path the default path; it does not make the unsafe path impossible.
services:
ollama:
image: ollama/ollama:latest
volumes: ["ollama:/root/.ollama"]
networks: [offline, egress] # egress ONLY so `ollama pull` can work
healthcheck:
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
interval: 10s
retries: 12
aidesk:
build: ./aidesk
depends_on:
ollama: {condition: service_healthy}
environment:
OLLAMA_HOST: "http://ollama:11434"
networks: [offline] # no route off the host. Boundary 2, enforced.
volumes: ["./engagement:/engagement"] # your notes + evidence, bind-mounted
working_dir: /engagement
command: sleep infinity
networks:
offline:
internal: true # docker refuses to give this one a gateway
egress: {}
volumes:
ollama:Image builds are unaffected — docker build uses its own network, so aidesk's apt-get and pip install still work. Prove the boundary rather than believing this page: docker compose exec aidesk python -c "import socket; socket.create_connection(('1.1.1.1', 443), timeout=5)" must fail, and docker compose exec aidesk python -c "import requests, os; print(requests.get(os.environ['OLLAMA_HOST']).status_code)" must succeed.
FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
jq ca-certificates && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir pytest requests
COPY . /opt/aideskdocker compose up -d
docker compose exec ollama ollama pull llama3.2:1b # ~1.3 GB; :3b (~2.0 GB) reads code better
docker compose exec aidesk python -c "import requests; print('desk ready')"What this lab does not prove. llama3.2:1b is a tiny model. Its recon summaries miss things, its payload variations are unimaginative, and its code reading is shallow. The point of the lab is the workflow and the boundaries, not the model's capability. A production tester uses a far stronger model — under the same four boundaries. Nothing you do against this 1B model tells you how the same prompt behaves on a frontier model, and nothing here is a substitute for reproducing a finding by hand.
Step 1 — Recon synthesis
Turn raw tool output into a structured, prioritised picture
The genuine win: you have three Nmap scans, a gobuster run and a whatweb dump, and you need the attack surface as a ranked list. Synthesis across sources is tedious and the model is good at it — provided the data stays local. Feed it your own tool output (already client data — hence the local model) and ask for structure, not conclusions.
The framing matters. Ask for a prioritised surface with the evidence line for each item, not "what should I exploit". You want a map you then verify, not a plan you blindly follow.
#!/usr/bin/env python3
"""Synthesise raw recon output into a ranked attack surface.
Runs against the LOCAL model only. The input IS client data — that is the whole
reason this must never touch a hosted service.
"""
import sys, json, os, requests
OLLAMA = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
PROMPT = """You are assisting an AUTHORISED penetration test. Below is raw output
from reconnaissance tools. Produce a JSON array of surface items, each:
{"asset": "...", "observation": "...", "evidence_line": "<the exact line this came from>",
"why_interesting": "...", "verify_how": "<the exact command the tester should run to confirm>"}
Rules: every item MUST quote the evidence_line it is derived from, verbatim, from the input.
Do NOT invent services, versions or CVEs. If unsure, omit the item. Output JSON only.
RAW RECON>>>
{data}
<<<RAW RECON"""
def synth(raw: str) -> list:
r = requests.post(f"{OLLAMA}/api/chat", timeout=300, json={
"model": "llama3.2:1b", "stream": False, "format": "json",
"options": {"temperature": 0},
"messages": [{"role": "user", "content": PROMPT.format(data=raw)}],
})
items = json.loads(r.json()["message"]["content"])
# Enforce the evidence rule on our side: drop any item whose quoted line is
# not actually present in the input. This is the anti-hallucination gate.
return [it for it in items if it.get("evidence_line", "\0") in raw]
if __name__ == "__main__":
raw = sys.stdin.read()
for it in synth(raw):
print(json.dumps(it, indent=2))The evidence_line in raw filter is the pattern to internalise: the model may only surface things it can point at in your data. Be precise about how much that buys you. It is a substring test on one field, so an item that cannot point at a line in your data is dropped — and nothing else is. The observation, why_interesting and verify_how text is never checked, so a fabricated claim attached to a genuine line survives the filter intact, and a one-token evidence_line like 80 matches almost any scan. The words around the line are still yours to verify. Practise the underlying recon in OSINT & Recon and Web Recon.
Step 2 — Payload variation
Generate variants of a payload you already understand
You have a working injection or a filter to get past, and you need forty variants — different encodings, comment styles, case permutations, whitespace tricks. This is mechanical generation, and it is a legitimate accelerant for a payload you already understand and are authorised to fire.
The boundary here is subtle and important. Using AI to vary a technique you understand and control is fine. Using AI to hand you an attack you do not understand, against a target, is how you cause unintended damage and cannot explain what you did in the report. Vary what you know; do not deploy what you do not.
#!/usr/bin/env python3
"""Expand ONE understood payload into encoding/obfuscation variants for filter
testing. Runs locally. You must understand and be authorised to send each one —
the model is a variation engine, not a decision-maker."""
import sys, json, os, requests
OLLAMA = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
PROMPT = """Authorised web-app test. Given ONE base payload, produce JSON:
{{"variants": [{{"payload": "...", "technique": "<how it differs>"}}]}}
Apply only ENCODING/OBFUSCATION transforms (URL/HTML/unicode escaping, case,
comments, whitespace). Do not change what the payload DOES. Output JSON only.
BASE: {base}"""
def variants(base: str):
r = requests.post(f"{OLLAMA}/api/chat", timeout=180, json={
"model": "llama3.2:1b", "stream": False, "format": "json",
"options": {"temperature": 0.7}, # some spread is the point here
"messages": [{"role": "user", "content": PROMPT.format(base=base)}],
})
return json.loads(r.json()["message"]["content"]).get("variants", [])
if __name__ == "__main__":
for v in variants(sys.argv[1]):
print(v.get("technique", "?"), "->", v.get("payload", ""))Related hands-on work: Web Fuzzing with ffuf, SQL Injection, Advanced XSS & CSRF.
Step 3 — Code understanding
Read unfamiliar code faster — as a first pass, never a verdict
On a white-box test you are handed a codebase in a language you half-know. The model gives you a fast first pass: what a function does, where user input flows, which patterns smell wrong. This is real value on a time-boxed engagement.
The failure mode from the defensive workbook applies exactly here: the model sees a window, not the program. It will miss the sanitiser in a decorator two files away and confidently flag safe code; it will miss a whole-program authorisation gap and confidently pass dangerous code. Use it to decide where to read carefully, then read carefully. A clean AI pass is not clearance — it is a to-do list for your own eyes. Deeper: Whitebox Web RCE and Secure Coding.
Step 4 — Report drafting
Turn verified findings into prose — facts in, prose out
You have your findings, evidence and reproduction steps in structured notes. The model turns them into readable, consistently-toned prose, drafts the executive summary, and rewrites a finding for a different audience. This is the single biggest time saver in the whole list, because report writing is where engagements overrun.
The rule is the one from Boundary 3 made concrete: the model may only rephrase facts that already exist in your notes. No new hostnames, versions, CVEs, ports or numbers may appear in the prose that were not in the source. Fabricated specificity in a client deliverable is a professional incident. Step 5 enforces exactly this, automatically.
You run the grader, and it runs on your machine. kalirange does not host these labs, watch your terminal, or receive your work — the script below is printed here for you to create and run locally, and nothing is submitted anywhere. That is deliberate: the lab works offline, on a laptop, with no account and no data leaving it.
Step 5 — The verification gate (this is the graded step)
Fail any report that contains a claim not backed by evidence
This is the step that makes the whole workflow safe, and it is what the lab grades. A report is a set of claims. Every atomic claim — a CVE, a version, a hostname, a port — must trace to something in your evidence bundle. The gate extracts those atoms from the drafted report and fails if any one of them is absent from the evidence. A hallucinated CVE cannot survive it.
Layout the checker expects:
engagement/
evidence/
facts.json # atoms you actually verified, with the evidence pointer for each
report/
draft.md # the AI-assisted prose
tests/
test_report_grounded.pyfacts.json is the ground truth you built by hand while testing — the only place a fact is allowed to originate:
{
"cves": ["CVE-2021-41773"],
"versions": ["Apache httpd 2.4.49"],
"hosts": ["10.10.10.42", "intranet.acme.local"],
"ports": [80, 443, 8080],
"evidence": {
"CVE-2021-41773": "evidence/F-01/curl-path-traversal.txt",
"Apache httpd 2.4.49": "evidence/F-01/nmap-sV.txt"
}
}#!/usr/bin/env python3
"""GRADING GATE. Passes only if every atomic claim in the drafted report is
present in the verified facts bundle. A model-invented CVE, version, host or port
fails the build. This is Boundary 3 turned into an invariant — it grades the
DISCIPLINE, not any particular model output, so it is immune to model flakiness.
"""
import json, re, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
FACTS = json.loads((ROOT / "evidence" / "facts.json").read_text())
REPORT = (ROOT / "report" / "draft.md").read_text()
CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
VER_RE = re.compile(r"\b(?:Apache|nginx|OpenSSH|MySQL|PHP|IIS)[^,.\n;]*?\d+\.\d+(?:\.\d+)?", re.I)
HOST_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b|\b[a-z0-9-]+\.(?:local|internal|acme\.local)\b", re.I)
PORT_RE = re.compile(r"\bport\s+(\d{1,5})\b", re.I)
def _norm(s): return re.sub(r"\s+", " ", s).strip().lower()
def test_no_ungrounded_cve():
allowed = {c.lower() for c in FACTS["cves"]}
found = {m.lower() for m in CVE_RE.findall(REPORT)}
invented = found - allowed
assert not invented, f"Report cites CVEs not in evidence: {sorted(invented)}"
def test_no_ungrounded_version():
# Exact normalised equality, NOT substring matching in either direction. A
# substring test passes "Apache httpd 2.4.499" (a build that never shipped)
# because the verified string is a prefix of it, and passes the vaguer
# "Apache httpd 2.4" because it is a prefix of the verified string. Both are
# exactly what this gate exists to catch.
allowed = {_norm(v) for v in FACTS["versions"]}
for m in VER_RE.findall(REPORT):
assert _norm(m) in allowed, \
f"Report cites an unverified version string: {m!r}"
def test_no_ungrounded_host():
allowed = {h.lower() for h in FACTS["hosts"]}
invented = {m.lower() for m in HOST_RE.findall(REPORT)} - allowed
assert not invented, f"Report names hosts not in evidence: {sorted(invented)}"
def test_no_ungrounded_port():
allowed = {str(p) for p in FACTS["ports"]}
invented = {m for m in PORT_RE.findall(REPORT)} - allowed
assert not invented, f"Report cites ports not in evidence: {sorted(invented)}"
def test_every_finding_has_evidence_pointer():
# A claimed fact with no evidence file behind it is not a finding. Iterate the
# CLAIMS, not the pointers: iterating FACTS["evidence"] means `"evidence": {}`
# passes trivially, so deleting two lines from facts.json turns a red green —
# which is the one move the closing alert forbids.
for key in [*FACTS["cves"], *FACTS["versions"]]:
path = FACTS["evidence"].get(key)
assert path, f"Claimed fact with no evidence pointer: {key!r}"
assert (ROOT / path).exists(), f"Evidence file missing for {key!r}: {path}"docker compose exec aidesk pytest -q tests/test_report_grounded.pyHow this lab is graded. The pytest suite above is the grader, printed in full — there is nothing withheld, because the discipline is the point and knowing the checks does not let you cheat them. It passes only when every CVE, version, host and port in report/draft.md is present in evidence/facts.json, and every CVE and version claimed there resolves to a real evidence file on disk. It grades the invariant — no claim without evidence — not any particular wording, so it does not care which model you used or how it phrased the prose. To pass, you must have actually done the verification work; a slick AI-written report with one invented CVE fails, which is exactly the outcome you want. Because this is a defensive discipline, the gate holds to the strict standard: it must pass on all checks, every run, not "at least once".
Extend it for your firm. Add regexes for the atoms your reports actually contain — internal ticket IDs, asset tags, CVSS vectors, control references — and wire the suite into whatever produces your deliverables so no report ships without passing it. The checks above are a starting skeleton, not the finished control.
What this lab did and did not prove
It proved you can wire AI into an engagement so that engagement data never leaves your machine, and that a mechanical gate can stop a fabricated fact reaching a client. It did not prove that any of these techniques will find real vulnerabilities — that depends on your skill, the target, and a far stronger model than the one you ran. And it is silent on the human failure the tooling cannot fix: automation bias. The gate catches invented facts; it cannot catch the real finding you stopped looking for because the model's summary read complete. Stay suspicious of your own tools.
Where to go next
- AI Red Teaming: A Methodology — the same discipline from the attacker-of-AI side.
- Applications of AI in InfoSec — the six defensive use cases, each with its failure mode.
- ATLAS Mapping Lab — map an incident to MITRE ATLAS and render it.
- Documentation & Reporting — the reporting craft the facts-in/prose-out step is accelerating.
Lab complete when the grader is green. Build a small facts.json from a target you are authorised to test, draft a report with AI help, and get test_report_grounded.py to pass without weakening a single check. If a test is red, the fix is to verify the claim or cut it — never to loosen the test.