A command-line pentest orchestrator and reporting tool. It sits on top of your existing offensive stack — BloodHound, Nmap, Certipy, Nuclei, NetExec, Hashcat, enum4linux-ng — ingests their output into a unified state model, runs detection rules over the state, and produces client-deliverable PDF reports with or without an AI writer.
CL-1201 is not a C2, an implant, or a loader. Operators bring their own offensive stack; CL-1201 captures, correlates, and reports.
Operator workflow on a typical AD engagement spreads across BloodHound, NetExec, Hashcat, Certipy, Nuclei, and a handful of one-off scripts. Findings live in screenshots and ad-hoc notes. Reports get stitched together by hand at the end. The crown-jewel cost on most engagements isn't recon or exploitation — it's the 2-to-5 days the report eats up after the active work is done. That's the billable hours you can't bill, and the weekends you don't get back.
CL-1201 closes that gap. Three things separate it from existing reporting tools:
Findings are typed objects with structured boilerplate prose — description, evidence, remediation, references, MITRE techniques. Jinja2 + WeasyPrint render the final PDF directly from the state DB; the boilerplate library handles the long-form prose that gets retyped per engagement today. The first engagement is faster than your current process. The tenth is dramatically faster, because the library compounds.
Every red team shop wants AI help on reports. Most can't use it because client hostnames, IPs, SIDs, and usernames crossing the wire to a cloud LLM is a contract violation. CL-1201 solves this at the wire.
Real entity values — FQDNs, IPs, SIDs, usernames, SPNs — are swapped for engagement-scoped opaque IDs (HOST_017, USER_042) before the HTTPS request to Anthropic or OpenAI. The LLM writes the report against anonymised data; CL-1201 rehydrates the real values locally for operator display. The AI provably never touches client data.
cl report ai produces a full engagement deliverable — executive summary, attack narrative, per-finding detail, remediation, root cause — written against a fixed blueprint derived from three real red-team reports. Not AI slop; grounded in your actual findings data.
No API key required to evaluate. --dry-run writes the redacted prompt to a file. Paste it into any chat UI on a personal account, save the reply, feed it back with --from-response. End-to-end run without touching a paid API.
CL-1201 is not a C2, not an implant, not a loader, and not a replacement for the tools in your kit. It ingests output from BloodHound, Nmap, Certipy, Nuclei, NetExec, Hashcat, and enum4linux-ng, correlates findings across them, and produces the report. Keep every tool you already use. CL-1201 is the layer that turns their JSON into a deliverable.
Re-running an adapter against a remediated environment surfaces which rules still fire. Per-finding verified-fix proof blocks carry the evidence forward into the deliverable automatically.
Approval gates hold destructive actions behind a senior-approver flow without blocking read-only triage. The session-leader model prevents conflicting concurrent writes without needing a server in the loop.
Generated end-to-end by cl report ai against the synthetic INITECH engagement bundled in examples/initech/. No manual editing.
- PDF:
examples/initech/sample-report.pdf - Pipeline:
examples/initech/*→cl ingest run …→cl findings run→cl report ai --all-findings --all-files --format pdf - Blueprint:
SampleReports/x/(style-spec + 5 exemplars)
Screenshot of page 3 Sample report — page 3
Requires Python 3.11+. Not yet on PyPI — install from source:
git clone https://github.com/dasokkk/cl-1201.git
cd cl-1201
pip install .Or with uv:
uv pip install .For C2 integrations (extras defined in pyproject.toml):
pip install ".[c2-sliver]"
pip install ".[c2-mythic]"Editable install for development:
pip install -e ".[dev]"examples/initech/ ships a self-consistent fake engagement: one domain, one set of users and hosts, seven tool outputs (BloodHound, Nmap, Nuclei, NetExec, Certipy, Hashcat, enum4linux-ng). Same identifiers across every file, so the rule engine surfaces a coherent attack chain rather than disconnected items.
# 1. Create an engagement
cl init demo --client "Initech, Inc."
cd demo
# 2. Ingest the bundled synthetic outputs (set EX to your local checkout path)
EX=../examples/initech
cl ingest run bloodhound $EX/bloodhound
cl ingest run nmap $EX/nmap_internal_scan.xml
cl ingest run nuclei $EX/nuclei_web_scan.jsonl
cl ingest run netexec $EX/netexec_smb_sweep.jsonl
cl ingest run enum4linux-ng $EX/enum4linux_dc01.json
cl ingest run certipy $EX/certipy_dump.json
cl ingest run hashcat $EX/hashcat.potfile
# 3. Run detection rules + render the deterministic report
cl findings run
cl report build --format htmlOpen the generated HTML under reports/. You should see kerberoastable accounts, Log4j RCE, ESC1 / ESC6 ADCS misconfig, SMB null session, cracked-password reuse, and stale privileged accounts — all linked back to the seed Log4j foothold on WEB01.
PDF note.
--format pdfneeds WeasyPrint's native deps (GTK / Pango / Cairo). On Linux:sudo apt install python3-weasyprint. On Windows, see the WeasyPrint first-steps docs. HTML output works everywhere.
# 1. Dump the redacted prompt to disk
cl report ai --all-findings --all-files --dry-run prompt.txt
# 2. Paste prompt.txt into Claude / ChatGPT / your LLM of choice, save the reply
# 3. Feed the reply back — render straight to PDF
cl report ai --from-response reply.md --format pdf -o report.pdf# engagement.yaml
ai:
mode: anonymized
task_backends:
report-narrative: anthropic:claude-opus-4-7cl report ai --all-findings --all-files --format pdf -o report.pdf ┌─────────────────────────┐
tool output │ ingest adapters │ normalize → entities + relationships
(JSON/XML) │ bloodhound, nmap, │
─────► │ certipy, nuclei, │
│ netexec, hashcat, │
│ enum4linux-ng │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ per-engagement state │
│ state.db (SQLite) │ ◄── append-only telemetry.jsonl
│ loot/ (AES-GCM) │ round IDs, actor IDs, payloads
└────────────┬────────────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ rule engine │ │ AI report engine │
│ → ProposedFinding[] │ │ anonymizer proxy │
│ AD / ADCS / web / │ │ blueprint x (fixed) │
│ network / ops rules │ │ grounding assembler │
└────────────┬────────────┘ └────────────┬────────────┘
│ │
└──────────────┬──────────────┘
▼
┌─────────────────────────┐
│ report builder │ Jinja2 / Markdown → WeasyPrint PDF
│ deterministic PDF │ 3-tier override chain:
│ AI narrative PDF │ engagement › firm theme › builtin
└─────────────────────────┘
Per-engagement directory:
engagements/<name>/
├── engagement.yaml config (scope, adapters, profile, AI mode, operators)
├── state.db SQLite, WAL mode, FK on
├── telemetry.jsonl append-only event log
├── loot/ AES-256-GCM blobs + salt.bin (Argon2id KDF)
├── reports/ generated PDFs / HTML
└── templates/ per-engagement boilerplate overrides
| Command | What it does |
|---|---|
init |
Create a new engagement directory |
ingest |
Run tool adapters that parse and store tool output |
findings |
Evaluate rules against state, manage proposed findings |
report build |
Render the deterministic boilerplate report (PDF or HTML) |
report preview |
Alias for report build --format html |
report ai |
Generate a full report via cloud LLM, modelled on the real-world blueprint |
loot |
Encrypted credential store — unlock, add, list, show, export |
boilerplate |
Manage reusable report boilerplate snippets |
state |
Query the engagement SQLite state DB directly |
verify |
Re-test mode: flag confirmed findings for re-check |
approval |
Approval workflow for findings before they hit the report |
leader |
Transfer session-leader role between operators |
run |
Invoke a raw tool action |
ask |
LLM-backed planner — describe a task, get structured next steps |
listen |
Subscribe to C2 events (Sliver / Mythic) |
serve |
Read-only HTTPS mirror of engagement state for the team |
dash |
Live operator dashboard |
snapshot |
Pre-migration state snapshots |
log |
Query the engagement telemetry log |
migrate |
Run DB schema migrations |
completion |
Generate shell completions (bash, zsh, fish, PowerShell) |
Global options (work on every command):
-e, --engagement PATH Engagement directory. Overrides CL_ENGAGEMENT and cwd walk-up.
-a, --actor TEXT Your handle for telemetry. Defaults to OS user.
-v, --verbose Full Python tracebacks on errors.
--no-color Disable ANSI (also honours NO_COLOR env).
--json Machine-readable JSON output.
--version Print version and exit.
--findings TEXT Comma-separated finding IDs. Interactive picker if omitted.
--files TEXT Comma-separated loot IDs. Interactive picker if omitted.
--all-findings Include every finding without prompting.
--all-files Include every loot item (metadata only) without prompting.
--archetype TEXT Shape hint: narrative, findings, or technical.
--dry-run PATH Write the redacted prompt to disk; skip the backend entirely.
In anonymized mode also writes <path>.mapping.json.
--from-response PATH Skip prompt building; rehydrate this LLM reply file and render.
--mapping PATH Entity mapping from --dry-run. Only valid with --from-response.
--format / -f TEXT Output format: md (default), html, or pdf.
Inferred from --output extension if omitted.
-o, --output PATH Output path. Defaults to reports/<name>-ai-<ts>.<ext>.
Tier 1 — default enabled, metadata only:
| Adapter | Input format | What lands in state |
|---|---|---|
bloodhound |
SharpHound JSON (BH CE v4+) | Domains, users, hosts, groups, OUs, GPOs and edges |
nmap |
XML | Hosts with port/service attributes; NSE script results |
certipy |
Certipy JSON | Certificate templates and CAs for ADCS rules |
nuclei |
Nuclei JSON | Per-template findings tagged by severity + CVE/CWE |
Tier 2 — opt-in, handle secrets:
| Adapter | Input format | What lands in state |
|---|---|---|
netexec |
Normalised JSON | Auth results, lifted credentials and hashes (loot) |
hashcat |
Normalised JSON | Cracked passwords; updates linked hash validation status |
enum4linux_ng |
Normalised JSON | SMB signing, anonymous session flags, share/user enumeration |
Tier 2 adapters require --force-tier2 and either an unlocked loot session or --loot-passphrase-stdin. Plaintext credentials never touch the telemetry log.
# Operator with write access shares a read-only view
cl serve --port 8443 --cert cert.pem --key key.pem
# Transfer write access to another operator
cl leader transfer --to teammate-handlecl listen subscribes to a live Sliver or Mythic event stream and feeds new data into engagement state in real time.
cl-1201/
├── cl1201/
│ ├── cli/ typer subcommands
│ ├── core/ engagement context, errors
│ ├── config/ pydantic schemas for engagement.yaml
│ ├── ingest/ adapter registry and 7 built-in adapters
│ ├── state/ SQLite store, migrations, telemetry writer
│ ├── findings/ rule engine and rules
│ ├── loot/ encrypted blob store and session
│ ├── anonymizer/ entity redaction + rehydration proxy
│ ├── planner/ LLM task dispatch, including report-narrative
│ ├── report/ IR, builder, templates, boilerplate, AI renderer
│ └── telemetry/ append-only JSONL writer
├── SampleReports/x/ fixed real-world blueprint (style-spec + exemplars)
├── examples/initech/ self-consistent synthetic engagement (7 tool outputs + sample-report.pdf)
├── docs/ getting-started + adapter / rule / boilerplate authoring guides
├── tests/ mirrors the package layout (1149 tests)
│ └── fixtures/ hand-crafted JSON/XML for adapter tests
├── pyproject.toml
├── README.md
└── LICENSE MIT
MIT — see LICENSE.
This section is for people who want to understand the internals — whether to extend the tool, contribute, or just satisfy curiosity. You don't need any of this to use CL-1201.
cl init creates a directory that acts as the single source of truth for one engagement. Everything — state, loot, telemetry, templates — lives inside it. You can zip it up, share it, or archive it and nothing is lost.
engagement.yaml holds the operator-editable config: client name, scope (IP ranges, domains), which adapters are enabled, which report profile to use, AI mode and backends, which operators are registered. Most commands read it at startup to build an EngagementContext object that flows through the entire call stack.
state.db is a plain SQLite database opened in WAL mode with foreign keys enabled. The schema is versioned — cl migrate applies pending migrations and cl snapshot creates a checkpoint before any breaking change.
The entity model is flat: hosts, services, users, groups, computers, certificate templates, and so on are all rows in typed tables. Relationships (edges) are separate rows with from_id, to_id, and a type column. This mirrors the BloodHound graph model but stays relational so rules can use SQL rather than graph traversal for most queries.
cl state query runs raw SQL against the DB. Useful for ad-hoc exploration during an engagement.
When you run cl ingest run <adapter> <file>:
- Lookup — the adapter name is matched against the registry (built-ins + any YAML manifests in the engagement's
adapters/directory). - Parse — the adapter reads the file and extracts
IncomingEntityobjects with a type, attributes, and optional relationships. - Normalise — entities map to the internal schema via attribute mappings defined in the adapter.
- Upsert — entities and relationships go into
state.dbviaEngagementContext. Duplicates are merged; telemetry records the round ID and actor. - Result — counts of created / updated / skipped records come back to the CLI.
Built-in adapters are Python classes subclassing ToolAdapter. For cases where Python isn't needed, the DeclarativeAdapter lets you define the same thing in YAML:
adapter: my_tool
input_format: json
entities:
- type: Host
source: $.hosts[*]
attrs:
ip: $.address
hostname: $.name
relationships:
- from: Host
to: Service
via: $.open_ports[*]
attrs:
port: $.port
protocol: $.protocolDrop this file in adapters/ and cl ingest run my_tool output.json picks it up automatically. The YAML manifest is compiled once on startup and runs through the same code path as the built-ins.
cl findings run evaluates every registered Rule class against the current engagement state. Each rule is a self-contained class that:
- Reads from
state.dbviaEngagementContext(some rules also read from the loot store if unlocked). - Applies its detection logic.
- Returns zero or more
ProposedFindingobjects.
Rules are organised by category under cl1201/findings/rules/:
| Category | Examples |
|---|---|
ad/ |
Kerberoastable accounts, AS-REP roastable, unconstrained delegation, stale DA accounts, weak ACLs / DCSync |
adcs/ |
ESC1 (enrollee supplies subject), privilege lift via certificate |
network/ |
Weak SMB signing, NFS exports, anonymous LDAP, RPC anonymous enumeration, out-of-scope services |
web/ |
Nuclei vulnerability lift |
ops/ |
Classifier fallback rate |
After cl findings run, proposals sit in state.db with status proposed. You triage them with cl findings triage <id> --status confirmed|dismissed|needs-evidence, run them through cl approval, and they flow into the report.
Each confirmed finding links to a boilerplate YAML that carries the long-form prose — description, background, remediation, references, MITRE ATT&CK technique. This is what cuts the report writing time.
The loot store is a separate encrypted SQLite database. The encryption uses:
- Argon2id (t=4, m=64 MiB, p=2) for key derivation from the operator passphrase
- AES-256-GCM with a per-blob 96-bit nonce for encryption at rest
The loot DB is unlocked with cl loot unlock at the start of a session. It auto-locks after a configurable idle timeout. SIGINT / SIGTERM trigger an immediate scrub of the in-memory key.
Tier 2 adapters (NetExec, Hashcat) write lifted credentials directly into the loot store during ingest. Plaintext passwords and hashes never appear in telemetry.jsonl or the main state DB.
- Builds an IR — loads confirmed findings, boilerplate prose, engagement metadata, and evidence blobs from the DB into a Python object tree (the intermediate representation).
- Renders HTML — passes the IR through a Jinja2 template. Templates follow a 3-tier override chain: engagement-local
templates/wins over a firm-wide theme directory, which wins over the builtin default. - Converts to PDF — pipes the HTML through WeasyPrint. The
--format htmlflag stops after step 2.
Two report profiles are available out of the box: concise (executive-friendly, shorter) and exhaustive (full technical detail, appendices, evidence). You can define custom profiles in engagement.yaml.
- Selection — operator picks which findings and loot items to send (interactive tick-box picker,
--all-findings, or--findings ID,ID). Loot metadata only — blob contents are never sent. - Grounding — selected findings and entity attributes are assembled into a structured Markdown block: finding titles, severities, MITRE techniques, affected entities, and file labels.
- Anonymization — in
anonymizedmode, anAnonymizingProxywraps the LLM backend.redact()swaps real values (hostnames, IPs, usernames, SPNs) for opaque IDs outbound.rehydrate()reverses them on the response. - Blueprint — the system prompt is built from the fixed blueprint
x(style-spec + five real-world exemplar sections derived from professional red-team reports). This is what stops the LLM from writing generic slop. - Generation — the LLM writes a full report: executive summary, attack narrative, per-finding sections, remediation, root cause.
- Render — the Markdown response is piped through markdown-it-py → WeasyPrint and written as
.md,.html, or.pdf.
The --dry-run / --from-response flags let operators run this entire pipeline manually through any chat UI — no API key required.
The anonymizer is a thin proxy around any LLM backend. It maintains an EntityMapping — a two-way dict between real values and stable IDs (HOST_017, USER_042, SPN_003). The mapping is built from the engagement state DB at dry-run or generation time and saved alongside the prompt.
Sensitive attribute keys covered: fqdn, ip, hostname, sid, samaccountname, spn, dns_name, url.
In raw mode the proxy is not installed and real values flow through. In offline mode no backend is called; --dry-run still works in offline mode because it never reaches the network.
Session leader. One operator owns the write-capable DB connection. This isn't a lock — it's a coordination primitive. cl leader transfer --to <handle> hands ownership to another registered operator. The intent is to avoid conflicting concurrent writes without needing a server in the loop.
Read-only mirror. cl serve starts a local HTTPS server (self-signed or supplied cert) that exposes read-only engagement state over a simple API. Team members query it without needing a local copy of state.db. cl dash consumes the same API for its live view.
C2 event stream. cl listen subscribes to a Sliver or Mythic event stream. New implant callbacks, credential material, and host data feed directly into engagement state in real time, skipping the manual ingest step for live operations.
LLM planner. cl ask takes a free-text task description, serialises relevant engagement context (findings so far, scope, discovered hosts), and sends it to a configured LLM endpoint. The response comes back as structured next steps. Useful for "what should I check given these BloodHound results" during an engagement.
Every significant action appends a JSON event to telemetry.jsonl. Each event includes a timestamp, the actor handle, the command, and relevant metadata (entities written, findings proposed, rules run, etc.).
This log is local — nothing phones home. Its purpose is the "who did what and when" section of the final report and post-engagement review. cl log queries it with filters for actor, time range, command type, and event kind.
The CLI is built with Typer (which wraps Click). Entry point: cl1201.cli.main:app. Each command group is a separate typer.Typer() instance registered as a subapp on the root app.
Global state — engagement path, actor handle, verbose/color/JSON flags — is collected by the root() callback and stored in a CliContext object on ctx.obj. Every subcommand receives it via the Typer/Click context chain.
Error handling is centralised in main(). CL1201Error subclasses render a clean one-line user_message to stderr rather than a traceback. --verbose adds the full traceback for debugging. The separation means operators get actionable hints by default and developers get full detail when they need it.
If the YAML DSL covers your tool's output format, drop a manifest in adapters/ — no further registration needed.
If you need custom logic, subclass ToolAdapter:
from cl1201.ingest.base import ToolAdapter, IngestResult
from cl1201.core.engagement import EngagementContext
class MyToolAdapter(ToolAdapter):
name = "my_tool"
def ingest(self, path: str, ctx: EngagementContext) -> IngestResult:
# parse path, write entities via ctx, return counts
...Register it by calling register_adapter(MyToolAdapter) before the CLI starts, or put it in a plugin package that declares the cl1201.adapters entry point.
Subclass Rule in cl1201/findings/rules/ (or in a plugin):
from cl1201.findings import Rule, ProposedFinding
from cl1201.core.engagement import EngagementContext
class MyRule(Rule):
id = "my_rule"
title = "My custom finding"
def evaluate(self, ctx: EngagementContext) -> list[ProposedFinding]:
rows = ctx.state.execute("SELECT ...").fetchall()
return [ProposedFinding(rule_id=self.id, ...) for row in rows if ...]Rules are auto-discovered — any Rule subclass in the rules package is registered at startup. Pair the rule with a boilerplate YAML in cl1201/report/boilerplate/findings/ carrying the prose (severity, MITRE ID, description, remediation, references) and the finding renders correctly in the report without any additional wiring.