Keep the research moving.
Read the handler, name the sink, write the diff, draft the rule, table the cases. For authorized work on systems you own or are engaged to test.
A path traversal, from reading to test table.
Three steps on one function. The interesting step is the third — the rows that most patches never get tested against.
The join that does nothing
os.path.join does not sanitize. It concatenates, and only the first argument is treated as a root — until the second argument starts with a slash, at which point the root is discarded entirely.
So read_report("../../etc/passwd") becomes "/srv/reports/../../etc/passwd", and open() does the rest. Nothing in the function checks that the resolved path ever stayed under /srv/reports.
The reading to write into the ticket: user input name reaches the open() sink with no containment check; the bug is CWE-22, path traversal. That sentence is the whole finding. Everything after it is proof.
# reports.py — the version under review
import os
BASE = "/srv/reports"
def read_report(name: str) -> bytes:
path = os.path.join(BASE, name)
with open(path, "rb") as fh:
return fh.read()
Resolve, then compare components
realpath resolves .. and follows symlinks before the comparison happens, so the check is applied to the path that will actually be opened.
commonpath compares path components, not string prefixes. A prefix check — path.startswith(BASE) — passes /srv/reports-archive/x, which is a sibling directory, not a child. That is the difference between closing the class of bug and closing the one string you thought of.
Note what this does not fix: between the check and the open() there is still a window. If another process can replace a component with a symlink inside that window, the resolution is stale. Closing the race means openat with O_NOFOLLOW or an already-held directory descriptor — a different patch, for a different threat model. Say which one you wrote.
--- a/reports.py
+++ b/reports.py
@@ -1,9 +1,14 @@
import os
-BASE = "/srv/reports"
+BASE = os.path.realpath("/srv/reports")
def read_report(name: str) -> bytes:
- path = os.path.join(BASE, name)
+ path = os.path.realpath(os.path.join(BASE, name))
+ if os.path.commonpath([BASE, path]) != BASE:
+ raise ValueError("path outside report root")
with open(path, "rb") as fh:
return fh.read()
The rows a patch has to survive
A patch is a hypothesis. The table is how it becomes a fact. These are the inputs that separate a real containment check from a check that only caught the string from the report.
Two of the rows below do not produce the answer you might expect on a first read. Keep them. A test table where everything passes the way you assumed is a test table that was written by the same person as the patch.
| input | what resolves | expected |
|---|---|---|
| q3-2025.pdf | inside the root; commonpath is BASE |
returned |
| ../../etc/passwd | resolves to /etc/passwd; commonpath is / |
rejected |
| /etc/passwd | absolute second argument discards BASE in join |
rejected |
| sub/2025/q3.pdf | two levels down, still a child of the root | returned |
| link-escape | symlink inside the root pointing at /etc/shadow; resolution follows it |
rejected |
| ..%2f..%2fetc%2fpasswd | decoding happens in whatever sits in front of you; by the time the function sees it, it is row 2 | rejected |
| q3-2025.pdf\x00 | embedded null; realpath raises before the guard runs |
raises |
| . | resolves to the root itself; the guard passes, open() raises IsADirectoryError |
raises |
Rows 7 and 8 are the useful ones. The guard passes and something downstream fails. That is not a hole — it is an error path you now know about, and the difference between "we block traversal" and "we block traversal, and a directory request is a 500 unless someone handles it". Write the second sentence in the ticket.
Three artefacts, in the shapes you would hand to a reviewer: a data-flow sentence, a patch against the file you pasted, and rows with expected outcomes.
Nothing was executed. The model has never run realpath on your filesystem, and it cannot tell you whether your Python version behaves the way the snippet assumes.
Apply the patch on a branch, run the table as a real test file, confirm the rejection is a rejection and not an exception that happens to look like one.
The same finding, as a rule.
A patch closes the instance. A rule buys you notice if something else in the fleet is still running the vulnerable build — and tells you who is trying.
Draft the rule against the log line you actually have. Paste one real request from the access log, not a description of it: field names differ between nginx, ALB, and whatever the proxy in the middle normalises, and a rule keyed to the wrong field is a rule that never fires.
Then tune it. Run the draft against a day of your own traffic before enabling it, and read the false positives — they tell you which encodings your stack already decodes before the rule sees them.
The traversal rows in the table above and this rule describe the same event from two ends: one is a unit test, the other is a monitor. Neither replaces the other.
title: Traversal attempt against report download
id: 3b9f6a12-0c47-4d55-9a0e-8f21c7d0be31
status: experimental
description: Parent-directory sequences on /reports/* downloads.
logsource:
category: webserver
detection:
route:
cs-uri-stem|startswith: '/reports/'
traversal:
cs-uri-query|contains:
- '../'
- '..%2f'
- '%2e%2e%2f'
- '....//'
filter_internal:
c-ip|startswith: '10.0.0.'
condition: route and traversal and not filter_internal
fields: [c-ip, cs-method, cs-uri-stem, cs-uri-query, sc-status]
falsepositives:
- Legacy client that URL-encodes a literal filename.
level: high
tags:
- attack.initial_access
- attack.t1190
Where it earns the afternoon back.
Each row is a draft loop: you paste the artefact, you get a draft back, you check it against reality.
Vulnerability analysis
Paste the handler, the route, or the parser. The draft comes back as a reading rather than a lecture: the tainted input, the sink it reaches, the guard that is not there, the class of bug, and the ticket text a reviewer will accept without asking you to redo it.
in handler · model · template → out data-flow note, CWE, ticket draft
Patch review
Put the diff and the original finding in the same message. The question worth asking is whether the patch closed the class or the instance — absolute paths, symlinks, unicode normalisation, Windows separators, and the second call site that was never updated are where instance patches fall over.
in diff + prior finding → out gap list, revised hunks, missing-call-site list
Test cases
Inputs and expected outcomes, written for the runner you already use. The value is concentrated in the rejects: traversal, XXE, SSRF, and deserialisation bugs survive a patch mostly because nobody wrote the row that fails.
in function + language + framework → out case table, pytest/unittest skeletons
Detection rules
Sigma for the log event, YARA for the sample, Suricata for the wire. The draft is only as good as your sample — paste the actual log line, not a description of it, and ask for the false-positive notes alongside the rule.
in log sample · strings · pcap notes → out rule draft, FP notes, tuning fields
Malware & binary triage
Strings, imports, config blobs, persistence hypotheses, ATT&CK mapping from what was actually observed. Sandbox screenshots and disassembly excerpts go in as input; a written triage note comes back out.
in strings, imports, capa output, screenshot → out triage note, ATT&CK candidates, IOCs to check
Engagement reports
Findings shaped for the template your client already expects: summary, affected component, reproduction steps, CVSS vector with the reasoning behind each metric, remediation advice, and the retest criteria that decides whether the finding closes.
in notes, evidence, template → out finding, vector justification, retest list
Short functions, familiar bugs.
Same pattern as the traversal: the code is unremarkable, the sink is one line away, and the fix has a wrong version that looks right.
def preview(url: str) -> str:
r = requests.get(url, timeout=5)
return r.text[:2000]
No scheme check, no host allowlist, redirects followed by default, and DNS resolving whatever the user typed — including a link-local metadata address. A regex that blocks localhost is not a fix; the attacker writes the IP.
The reading to write down: user-controlled URL reaches an outbound request; SSRF, CWE-918. The fix is a scheme allowlist, an IP check after resolution on the pinned address, redirects disabled, and a streamed size cap.
return render_template_string(user_template, rows=rows)
Rendering a template the user supplied is server-side template injection, not a formatting convenience. Sanitising the string with a regex does not close it — the expression syntax is the feature.
Two honest fixes: a SandboxedEnvironment with a restricted context, or a static template that takes the user's values as data. The second one is the patch a reviewer will accept without an argument.
Notice what the model does with the fix. It offers the wrong version too, and you can ask for it: "what is the fix that a rushed engineer writes here, and why does it fail?" That question is worth asking before review, not during it.
What a finding looks like when it closes.
A finding is not the paragraph describing the bug. It is the paragraph, the vector with its reasoning, the remediation, and the retest criteria that decide whether anyone can call this done.
Ask for the CVSS reasoning explicitly. "High severity" is an opinion; AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N is an argument you can be asked to defend, metric by metric, in a client call.
The retest line is the part people skip. Write what would have to be true for the finding to close — a specific input that must be rejected, not a code review.
### F-002 — Path traversal in report download
**Component:** reports.py / read_report()
**Class:** CWE-22 · **Severity:** high
**Vector:** CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
**Why:** network-reachable with no auth on the route,
no interaction required, read-only impact on
arbitrary files readable by the service account.
**Repro:** GET /reports?name=../../etc/passwd
returns the first line of the file, HTTP 200.
**Fix:** resolve, then compare components
path = realpath(join(BASE, name))
if commonpath([BASE, path]) != BASE: reject
**Not fixed by:** blocking the string '../' or a
startswith() prefix check on BASE alone.
**Retest:** the eight rows in the case table, run
against the patched build; all rejections must be
rejections, not unhandled exceptions.
You bring the engagement: a system you own, or a scope document that names you and the testing window. You run whatever a draft proposes, on the machine you control. You verify that it worked. If a draft suggests something outside your scope, the answer is no — and the model produced a bad draft, which is a normal thing for a text generator to do.
Everything on this page assumes authorized security work. If you do not have permission for the system in the prompt, you are not the user this endpoint is for, and no model's willingness changes that.
Where it will let you down.
Six ways this fails. Knowing them is cheaper than discovering them at 4pm on a Friday.
There is no CVE feed, no vendor advisory, and no internet behind the endpoint. Given a version number it will produce a confident CVE identifier that may well be wrong. Check every identifier before it enters a report.
No interpreter, no linker, no shell. A snippet that looks correct on screen can be wrong in a passing way: wrong import, wrong signature, a decorator that changes what the function returns. Compile it yourself.
How a library behaves in the exact version you deploy is your field knowledge, not its memory. realpath semantics differ at the edges; framework routers normalise paths before your code sees them. Test the version you ship.
The prose is smooth whether or not the finding is real. A wrong analysis reads exactly like a correct one. Treat every claim about your codebase as a hypothesis with a test attached, or do not use it.
Zero retention means the prompts are never written to disk. That is the point, and it also means there is no history to scroll back through. Save what matters in your own notes as you go.
We do not review, monitor, or filter prompts. Nothing here will stop a bad request or flag one for follow-up. The judgement about what belongs in a prompt is entirely on the operator — you.
No SDK, no integration, no plugin.
One POST. The tooling you already have is the tooling you use — curl, a script, or the playground in a tab.
curl "$BASE_URL/v1/chat/completions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "qwen-flash-next",
"effort": "low",
"messages": [
{ "role": "user",
"content": "Read this handler. Name the sink,"
+ " the missing guard, and draft the diff." }
]
}'
{
"id": "chatcmpl-9f2c…",
"object": "chat.completion",
"model": "qwen-flash-next",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "The sink is open() on line 8…"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1840,
"completion_tokens": 612,
"total_tokens": 2452
}
}
qwen-flash-next is the only value that has ever been valid. There is no second model to fall back to and no alias that resolves to something else.
/v1/chat/completions, /v1/models, /v1/usage. Nothing for embeddings, batches, files, assistants, or fine-tuning.
Tokens, requests, timestamp, per key. The prompt is not part of the record, which is also why /v1/usage can show you spend but never history.
The four questions that come up.
Is it legal for me to use this?
Depends on where you live, what you are doing, and who you work for. We do not verify, we do not ask, and we do not provide authorization — that document comes from the system owner, not from a model vendor.
The assumption this page runs on is narrow: you are testing systems you own, or systems you are engaged in writing to test. If that is not true for the prompt in front of you, a refusal from some other model would not have made it legal either.
Do you keep the code I paste?
No. Prompts and completions are never written to disk, never logged, and never trained on. What is retained is a per-key counter: tokens, requests, timestamp. That is the whole record, and it is not enough to reconstruct a request.
The practical consequence is the one people forget: we cannot produce your prompt later, because we never had it. If you need a record, keep one.
Will it actually draft the intrusion-relevant part, or hedge?
It complies. HarmBench-320 TRUE_COMPLY sits at 91.2% at effort off and 92.5% at low, with zero hard refusals across all 320 prompts. Cybercrime and intrusion-compliance prompts were tested separately: 52 of 52 at off and at low.
There is no refusal layer to route around, because the refusals were removed at the weight level. What you get back is the draft — which means the judgement about scope has to sit with you, because nothing in the pipeline is going to add it.
Vendor-reported. Model card, agentic harness, checked 2026-11. Not a measurement of this endpoint.
How much code fits in one request?
262,144 tokens of context natively, extensible toward 1M, with 131,072 tokens of output. A few hundred files fit. That is not the same as a good reading: the further a file sits from the question, the softer the answer.
The pattern that works is one file plus its caller, then a second pass with the file it calls. Keep the reasoning effort readable — off for prose and refactors, low for analysis, xhigh when the trace itself is the deliverable.
Bring the finding.
Bring the authorization.
Fifty a month, unlimited. Crypto only, no logs. Try it for $5 first — the same key covers every page in this section.
You are the operator. Everything that follows is yours.