Lab 06 — One Core, Two Surfaces: a typer CLI and a FastAPI Service¶
Hands-on lab. Environment:
plaintext-labs/python-for-security/06-cli-and-api(a container withuv,typer,fastapi, anduvicorn, seeded with thesiftcore you grew through M5 — the pydantic EVEAlertEventmodel, the triage layer, the async enricher — plus a few bundledeve.jsonrecords, clean and malformed). Objective: add two thin surfaces to the samesift— atyperCLI an analyst runs and aFastAPIservice a pipeline calls — sharing one core with zero duplicated logic. Target: ~2–3 hrs. Intermediate-plus: the steps state objectives; you derive thetyper/FastAPIcode (with the copilot).
✈ Flight card — the 6 things to hold¶
Glance here when you lose the thread.
| # | Fact | Why it matters |
|---|---|---|
| 1 | One core, two surfaces. | The pydantic models + core functions are sift; CLI and API are adapters over them. |
| 2 | A surface is thin. | Get input into a model, call a core function, render out — no business logic in the adapter. |
| 3 | typer and FastAPI share a shape. |
Both derive their interface (args, request schema) from your type annotations. |
| 4 | The API edge is a second untrusted edge. | Typing a body event: AlertEvent reuses the M2 model — a bad EVE line → 422, not a crash. |
| 5 | Zero duplicated logic is the bar. | The scoring/triage code appears exactly once in the repo; both surfaces import it. |
| 6 | Both surfaces agree because they share the core. | sift triage on the CLI and POST /triage on the API return byte-identical results. |
↳ Go deeper — pull only when a step doesn't click: the module's core idea, the Typer and FastAPI request-body tutorials, and the Cosmic Python "thin adapters over a stable core" chapter.
Warm-up — answer before you build (2 min)¶
- If a teammate adds a new severity rule, which files must change so the CLI and the API stay in agreement?
- When a
POST /triagearrives withalert.severityoutside Suricata's1..3range, what rejects it, what does the client get back, and why did you get that for free?
Setup¶
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/python-for-security/06-cli-and-api
make up # build the container with the M5 sift core preinstalled
make shell # drop into the project
make demo # runs the CLI and the API against the same eve.json alert and diffs the two results
make down # stop when done (make reset to also drop the image + volumes)
Authorization note. Everything runs locally in the lab container against bundled sample data — only test systems you own or have explicit written permission to test.
The lab builds on the custom sift core you've grown across the track: the whole lesson is the
shared-core architecture, which means importing the same functions from two adapters — something a
black-box image can't teach. It is reproducible at zero cost.
Build it — objective, then a signal (intermediate-plus: you drive the code)¶
Step 1 — Write the spec first¶
Concept (30 sec): Flight-card #1 + #5. The spec is the contract you review the copilot against — here it names the architecture, not the feature.
Do: spec the increment — expose the existing sift core through a typer CLI and a FastAPI
service; both import the same models and the same core function; zero duplicated business logic; the API
validates request bodies against the EVE AlertEvent model.
▸ On track if: the spec says "two thin adapters over the existing core," not "add a CLI and add an API" — it forbids re-implementation up front.
Step 2 — Isolate the core¶
Concept (30 sec): Flight-card #2. A surface can only stay thin if the logic is already a clean, import-able function.
Do: confirm the triage/enrich logic is reachable as plain functions (from sift.core import triage,
enrich) with no CLI or HTTP concerns mixed in; if M5 left any I/O or arg-parsing inside them, lift it
out — the surfaces will own that.
▸ On track if:
triage(event) -> TriageResultimports and runs from a bare Python REPL, with notyper/fastapi/sys.argvanywhere in its call path.
Step 3 — Build the typer adapter¶
Concept (30 sec): Flight-card #3. A type-annotated function becomes a CLI — parameter types drive
parsing and --help.
Do: add a sift triage <eve.json> command that reads a real EVE record, validates it into an
AlertEvent with model_validate_json, calls triage, and prints the result as JSON. Keep it to a
handful of lines: parse, delegate, render.
▸ On track if: the command is a thin shell — it validates into the model, delegates to the same
triageyou isolated in Step 2, and contains no scoring branch of its own.
Step 4 — Build the FastAPI adapter¶
Concept (30 sec): Flight-card #3 + #4. The same typed-function-to-interface move, now over HTTP — and the request body is a second untrusted edge.
Do: add a POST /triage endpoint whose body parameter is typed event: AlertEvent and whose return
is typed as your TriageResult model; do not parse or validate by hand — let FastAPI do it against
your model. Serve it with uvicorn.
▸ On track if: the same
siftcore answers bothsift triageon the CLI andPOST /triageon the API, and the endpoint delegates to the identicaltriagefunction — no logic re-typed inside it.
Step 5 — Prove the validation payoff (the second edge holds)¶
Concept (30 sec): Flight-card #4. Parse-don't-trust was never about one edge.
Do: POST a malformed EVE line — a truncated/non-JSON body, an alert.severity outside 1..3, or a
non-alert event_type your model doesn't accept — and watch the response.
▸ On track if: you get a clean
422with a precise error before your code runs, and it's the sameAlertEventmodel rejecting it that guards the CLI — reject an invalid request through the shared model, on both surfaces.
Step 6 — Grep for duplication, then collapse it¶
Concept (30 sec): Flight-card #5. This is the whole review move — trace the business logic.
Do: search both adapters for any scoring/triage decision (an if severity > ... inside an
@app.command() or @api.post()). If the copilot duplicated it, delete the copy and delegate to the core.
▸ On track if: the scoring/triage logic appears exactly once in the repo (in the core), and both adapters reach it only through
import.
Prove the control — your finish line¶
Run the same EVE alert record through both surfaces from one core and show they can't disagree:
- [ ]
sift triage <eve.json>andPOST /triagereturn byte-for-byte identical results from the same EVE record (make demodiffs them and asserts equality). - [ ] A malformed EVE line
POSTed to/triagereturns a422validated againstAlertEvent— the same model that guards the CLI — not a crash. - [ ] The triage/scoring logic appears exactly once in the repo; both adapters import it.
- [ ] Both adapters are thin: each parses input into a model, delegates to the core, and renders out.
Recall check — close the doc, answer from memory (3 min)¶
- Name the three jobs of a "thin" surface, in order.
- When
POST /triagegets a bad EVE body, what validates it and what does the caller receive? - Why can the CLI and the API never drift apart in this design — what single fact guarantees it?
Deliverables¶
The two adapter modules (cli.py and api.py, or your project's equivalent), the updated sift package
exposing the shared core, the migration/increment spec, and the demo/regression check that asserts CLI
and API agreement — committed to your sift repo. Do not commit any real API keys, .env, or live TI
responses; the enricher's secrets handling stays as configured in M2/M4.
Automate & own it¶
Required. Commit the two adapters plus a small make demo (or script) that runs both surfaces
against one alert and asserts the results match — a regression guard against future divergence. In the
commit/PR, note where the copilot tried to duplicate the triage logic (it will scaffold typer and
FastAPI in separate passes and re-implement scoring inside each) and how you collapsed it to a single
delegate call. That collapse is the whole lesson.
Definition of done (cli-and-api ✅)¶
- [ ] Both surfaces are live over the same
siftcore, with zero duplicated business logic. - [ ]
make demoproves the CLI and the API return identical results, and a bad EVE line422s on the API. - [ ] You can explain all six flight-card facts cold.
Connects forward¶
This "one core, N surfaces" pattern is exactly what Module 07 extends: the MCP server becomes a
third surface over the same core, so an LLM can call sift's enrich/triage as tools. Because the logic
already lives in one typed place, adding MCP is another thin adapter — not a third re-implementation.
Marketable proof¶
"I expose one validated Python core through multiple surfaces — a
typerCLI and aFastAPIservice that share the same pydantic models with zero duplicated logic — so the analyst's tool and the SOAR pipeline's API can never drift apart, and every HTTP request is validated against the same models."
Stretch (optional)¶
- Dissector rung — one filter, both surfaces. By now the discriminated union has grown well past
alert: M02 addeddns, M03http, M04tls, M05flow/fileinfo. Expose anevent_typefilter as a shared concept across both adapters — a--event-typeoption on thetyperCLI and an?event_type=query parameter on the API — so a caller streams a mixedeve.jsonand triages only the chosen dissected type (e.g. justdns, or justhttp). Prove the two-surfaces-one-core discipline holds: the filter predicate lives once in the core (a function over the union), and both surfaces merely pass the selected type into it — the CLI flag and the query param must produce byte-for-byte identical filtered output, and an unrecognisedevent_typeis rejected the same way on both surfaces. Acceptance:sift triage mixed.eve.json --event-type dnsandPOST /triage?event_type=dnsreturn the same records, and neither surface re-implements the filter. - Add FastAPI's auto-generated OpenAPI docs to the deliverable and generate a typed client from them — showing the API is consumable by other services without hand-written glue.
- Make the CLI call the running API over HTTP (instead of importing the core directly) behind a flag, and confirm the verdict is still identical — a preview of surface-vs-transport separation.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).