fraud — the form below is real, and so is everything it reports
Fraud watches a form being filled in and tells an operator which submissions deserve a human look. It advises; it never blocks a sale and it never auto-rejects. This page is a seller's page: it loads the collector from the deployed service, that collector mints a session, reports what it observes, and posts a snapshot of this page when you submit — and the panels beside the form show you exactly what went over the wire.
Get a quote
An ordinary seller's lead form. It submits nowhere. There is no lead here and no buyer to sell one to, so this page stops the submission after the collector has done its work. Type into it, paste into it, tab around it — the collector is watching all of that and the panel beside this one shows what it made of it.
Two fields on this form are here to prove something
The password field is captured by nothing. In the interaction
stream its value_length is reported as 0, so not even the length of a
password leaves this page; in the DOM snapshot it is marked
data-lrs-redacted="password" rather than left looking empty, so a reviewer can tell
we did not take this from they typed nothing.
The honeypot is not in the markup above. The collector appends a
hidden input[name=lrs_hp] to every <form> on the page when it starts. A
form-filling bot fills every input it finds, including that one. It is reported as
filled or not and never as what was typed into it — whatever a bot types there is still
somebody's data.
What the collector did
inspector —- Session keynone — the collector needs JavaScript to run at all
- Statusnot run
- Seller campaign keyexample-site
- Endpointhttps://fraud.lrs.amtechhq.com
The collector mints a session on load. If this row still says not run, JavaScript is off — and that is precisely the state the service records as unscored, never as clear.
On the wire
nothing yet- Nothing yet.
Live. New requests append here and the pane follows the newest one. Pause holds the view still so you can read a body without it moving; nothing is dropped while paused, and the button says how many are waiting.
The page’s own health check, and what it is honestly able to say
Health
not askedThis check runs in your browser and has not run yet. With JavaScript off it never runs, and this line stays as it is — which is the honest answer: nothing was asked, so nothing is known.
This route answers for the storage as well as the process. A 503 here
means the configured media root is unusable — a silently unmounted NAS leaves a writable empty
directory behind, so the check looks for a marker file rather than trusting that a write succeeds.
It is a status code and not a warning field because the deploy's auto-revert reads status codes.
Payload inspector is on. To show you the request and response bodies, this page has
wrapped window.fetch. The collector notices — fetch_tampered is one of the
fifteen things it probes, computed as
Function.prototype.toString.call(window.fetch) not containing [native code], which
no wrapper written in JavaScript can pass. So this session reports
fetch_tampered="true", and under the ten rules the service's migration 0002
seeds that matches Fetch API Tampered, weight 35, against a seeded threshold of 100.
That is the probe working, and it is the honest cost of being able to read the bodies.
Run it clean instead and this page falls back to the browser's own resource
timings, which reveal the session key and every request but no bodies.
Clean run. Nothing on this page is wrapped or patched, so the collector has no
complaint about it. The wire log beside the form is built from the browser's PerformanceObserver
resource timings, which give the URL and duration of every request — and, because the session key is
in the path of every request after the first, the session key. They do not give bodies.
Turn the inspector on to see those, and read what it costs.
The one thing this page cannot show you, and will not pretend to
There is no score on this page. The collector reports observations; the server
decides. Reading back what it decided — the verdict, the total, the threshold in force, which rules
fired — is GET /api/v1/sessions/{key}/score, which requires the service credential, and a
public page must not carry one.
That is not an oversight in the demonstration, it is the second of the four properties that make an anonymous write boundary safe: it reads nothing back. A caller who could ask whether their own traffic had been flagged would have a tuning loop for evading the rules. Press the read the score button below and watch the real service refuse it.
Seeing a score needs an operator login to the console at admin.lrs.amtechhq.com/fraud, which will redirect you to a login form this site has no account for.
Try to break it — live, against the real deployment
Each button sends a request the service is designed to refuse and shows you the refusal it actually returned. Nothing is simulated. Nothing reaches a table.
1. Assert that you are not fraudulent
The legacy system this service replaces took IS_FRAUD as an ordinary
form field, and that field decided distribution — so any submitter could set it to 0. This
posts {"name":"is_fraud","value":"0"} as a signal on your live session.
The refusal list is belt and braces, and the service's own source says so. The
property that actually holds is structural: every rule weight is CHECKed positive and the
scorer only ever adds, so an observation a caller invents can raise a total and can never lower one.
There is no observation that clears anything.
2. Ask the server to fetch a URL for you
Legacy's page capture sent the list of URLs the page referenced and the server
retrieved every one of them, following redirects, with no scheme allowlist and no private-address
rejection, from an unauthenticated endpoint. This posts a snapshot with an extra
document_url field pointing at the cloud metadata service.
The snapshot route takes one string of markup. There is no field for a URL, a
host, a path or a filename, and unknown fields are refused — so the field cannot be added without
somebody also deciding to honour it. A test in the repository drives twelve spellings of a URL list
against this route, and a second reads the package's whole source and fails the build on an outbound
HTTP client, an exec.Command or a net.Dial.
3. Read your own score
The tuning loop. If this worked, a caller could vary their behaviour until the service stopped flagging them.
$ curl -s -w '\n# HTTP %{http_code}\n' \
https://fraud.lrs.amtechhq.com/collect/9ef8f80a29b60bfed257d0cfd323fc9c/score
404 page not found
# HTTP 404
Recorded 2026-09-06. There is no such route, which is a stronger answer than a
refusal: GET /api/v1/sessions/{key}/score exists and is behind the credential, and nothing
on the public boundary reads it back under any name.
4. Read the console
Session counts, rules, verdicts, the threshold. Everything an operator sees.
This one is refused twice over and your browser will probably only show you the
second refusal: no CORS header on /api/v1, so the browser will not let this page read the
response at all. Here is what it actually says, recorded 2026-09-06:
$ curl -s https://fraud.lrs.amtechhq.com/api/v1/console/overview
{"success":false,"errors":["invalid service credential"],"meta":{"timestamp":"2026-09-06T17:54:42Z"}}
$ # HTTP 401
The recording policy for this campaign — live
GET /collect/example-site/recording-policy
Not read. This panel makes the request from your browser; with JavaScript off it is not made, and nothing here is filled in from anywhere else.
This is the only route on the public boundary that reads anything back, and the service's source names it rather than leaving it to be discovered. It is not a hole in the property above: the prompts are operator-authored text whose entire purpose is to be shown to a member of the public on that campaign's own page. They carry no observation, no score and no session, and a collector that could not read them could not show anybody the words they were agreeing to.
If a modality were enabled, the collector would show that campaign's exact words in
a shadow root, post the person's answer before touching getUserMedia, and
post the browser's answer separately afterwards — because a browser can grant a permission from a
decision somebody made on an earlier visit without seeing anything today, and a browser permission is
not consent. Audio is never requested on either modality.
The collector itself
Are the bytes served today the bytes this page was built against?
- Served now—
- Size—
- Built againstsha384-s5RxjvP/INr8P8EA7DqQ61kLfjkjSSLRZS2Xpbf6nz+KyJOCREDKS+QcF/QlAINy
Not checked. The comparison is computed in your browser from the bytes the service serves right now.
The script is fixed, reviewed source, embedded in the service binary and served at a stable path. It is never generated, never templated and never concatenated with rule data. Everything per-campaign arrives as JSON in a block the script parses:
<script type="application/json" id="lrs-fraud-manifest">
{"endpoint":"https://fraud.lrs.amtechhq.com","campaign_key":"example-site","signals":[…]}
</script>
<script src="https://fraud.lrs.amtechhq.com/collect/collector.js" defer></script>
Rule names, signal names and condition values are text an operator typed into a console. Splicing
them into JavaScript would make the rules table a code-execution surface on every seller's page, so they
never enter the script — they arrive as data, and Go's JSON encoder turns < and
> into escapes, so a campaign key containing </script> cannot close the
block.
Two honest differences between the snippet above and the production one.
First, the production snippet carries integrity="sha384-…" and
crossorigin="anonymous", computed from the bytes actually served. This page reports the
comparison instead of enforcing it: an integrity attribute that stopped matching would stop the
collector loading, and a demonstration page that demonstrates nothing is a worse failure here than a
redeploy going unnoticed. The panel above is that check, done in the page.
Second, in production the signals list is not written by hand. It comes from
GET /api/v1/console/integration/{seller_campaign_key}, which composes the whole snippet from
the campaign's active rules — and, in the same response, names every active rule whose signal
the collector cannot produce, with a sentence written to be shown to an operator verbatim. That read
needs the service credential. So the manifest on this page lists all fifteen signals the
collector is capable of producing, which is a superset of any real campaign's list and is a fact about
the script rather than a claim about this campaign's rules.
The fifteen signals — from source: cmd/fraud/assets/collector.js
| Signal | When | What it observes |
|---|---|---|
| js_enabled | load | Constant true. Its absence is the signal: a session that reported nothing is unscored. |
| navigator_webdriver | load | The browser's own automation flag. |
| iframe_webdriver | load | The same flag read from a fresh same-origin iframe. A page-level override does not survive into one, so a disagreement between the two is itself evidence. |
| known_automation_globals | load | Eleven named globals, plus any cdc_-prefixed property on document. |
| ua_has_headless_keywords | load | Headless, phantom, puppeteer, playwright and friends in the user agent. |
| fn_tostring_tampered | load | Whether Function.prototype.toString has itself been rewritten — which is how a patched navigator stays invisible to the probes above. |
| fetch_tampered | load | Whether window.fetch is still native. This page's inspector trips it. |
| plugins_empty | load | Whether navigator.plugins is empty. |
| canvas_hash | load | An 8-hex FNV-1a of a fixed canvas rendering. A fingerprint, not a value anybody typed. |
| webgl_vendor | load | The unmasked WebGL vendor string, or nothing if the extension is absent. |
| webgl_renderer | load | The unmasked WebGL renderer string, likewise. |
| honeypot_filled | submit | Filled or not. Never what was typed. |
| paste_count | submit | How many paste events happened. |
| rapid_input_count | submit | Input events less than 30 ms apart. |
| interaction_count | submit | Pointer-downs and key-downs. |
The four at the bottom are sent on submit and on pagehide, never at load.
Legacy computed its behavioural checks during page load, before any interaction had happened, so every
one of them was structurally dead and its mouse check flagged all desktop traffic. A counter read before
the visitor has done anything is not a measurement.
A probe that throws contributes nothing rather than a false. Could not look and
looked and found nothing are different facts, and the service records them differently.
About the rule weights quoted on this page. They come from
fraud/migrations/0002, which seeds ten rules — nine active and one
(JavaScript Disabled) seeded already archived — and a threshold of 100. That is a
fact about the migration, not a claim about what the live rules table holds today: an operator can add,
edit, archive and restore rules, and reading which ones are in force needs the service credential.
Rules are never DELETEd — archive and restore, so the receipt on an old score still names a
rule somebody can look up. This page quotes the seeded weights and does not compute a total from them.
What else went over the wire, and what deliberately did not
The interaction stream
Typed events with millisecond offsets from one origin, the field they were on, and how many
characters that field held: pointerdown, keydown, input,
change, focus, blur, paste, copy,
scroll, submit. Batched, and idempotent per (session, sequence), so
a flush that may or may not have arrived can simply be sent again.
It does not capture keystrokes. The keydown handler has e.key
in its hand and does not read it; there is no field on the wire that could carry it and no column behind
it. Legacy recorded the actual character for every single-character key and kept it forever, so anything
typed into a captured field was reconstructible character by character out of the database. The
behavioural value is entirely in the type, the timing and the length — those travel, the content does
not, and the column that would hold it does not exist, which is a stronger statement than a policy saying
not to write to it.
Legacy's mousemove is deliberately absent. It was that system's highest-volume event and
the cheapest of all of them to synthesise.
The DOM snapshot
Taken at submit, and the sensitive one, because it carries the values somebody typed. Built entirely
in this browser: scripts removed, every URL-bearing attribute moved to an inert data-lrs-*
attribute, every CSS url() defused, and every readable stylesheet inlined. A
cross-origin stylesheet throws on .cssRules and is skipped — which is precisely where a
replay renders degraded, and that degradation is the feature, because the alternative is the server
retrieving a URL on a stranger's say-so.
Over the size ceiling it is not sent at all. Truncated markup is a lie about what the page looked like, not a smaller snapshot.
Retention, which is the reason any of this is storable
Three windows, and they are deliberately not one number: recordings and page snapshots at 7 days,
signals and the interaction stream at 30, the score receipt at 365. Expiry is a sweep that
DELETEs on a timer, never a read-time filter — a filter leaves the rows on disk and calls
them deleted. The system this replaces held 670,389 rows of captured page content and 93,799 form
sessions going back 290 days, with no retention limit, because nothing was ever configured to delete it
and no row existed to say what the answer should have been.
Verify any of it yourself — recorded 2026-09-06
$ curl -s -X POST -H 'Content-Type: application/json' -d '{}' \
https://fraud.lrs.amtechhq.com/collect/example-site/session
{"session_key":"9ef8f80a29b60bfed257d0cfd323fc9c"} # HTTP 201
$ curl -s https://fraud.lrs.amtechhq.com/collect/example-site/recording-policy
{"screen":{"mode":"off"},"webcam":{"mode":"off"}}
$ K=9ef8f80a29b60bfed257d0cfd323fc9c
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"signals":[{"name":"js_enabled","value":"true","observed_at":"2026-09-06T17:55:00Z"}]}' \
https://fraud.lrs.amtechhq.com/collect/$K/signals
204
$ curl -s -X POST -H 'Content-Type: application/json' \
-d '{"signals":[{"name":"is_fraud","value":"0","observed_at":"2026-09-06T17:55:00Z"}]}' \
https://fraud.lrs.amtechhq.com/collect/$K/signals
{"error":"this boundary records observations only: is_fraud is not something a client may report"}
# HTTP 422
$ curl -s -X POST -H 'Content-Type: application/json' \
-d '{"document_html":"<html></html>","document_url":"http://169.254.169.254/",
"viewport_width":1,"viewport_height":1,"captured_at":"2026-09-06T17:55:00Z"}' \
https://fraud.lrs.amtechhq.com/collect/$K/snapshot
{"error":"a JSON body with document_html, viewport_width, viewport_height and captured_at is
required, and it may carry nothing else"}
# HTTP 400
What is behind the credential, in full
Everything below needs Authorization: Bearer <service key>, which this page does not
have and must never have. Listed so you can see the size of what is not on this page:
- Scores, verdicts and the threshold in force —
/api/v1/sessions/{key}/score,/console/settings - The console overview: sessions total, clear, review,
unscored, rules active and archived - The rules themselves, and the integration screen that names every active rule whose signal the collector cannot produce
- Replay: the stored markup, the interaction stream and the recording bytes, each through the sharing intersection and then the masking policy, in that order
- The sharing policies, the campaign-to-seller mapping, and the share links — every one of which resolves at view time, so tightening a policy narrows links already in a buyer's inbox
- The retention policy and its dry run, which is the actual sweep with the deleting turned off rather than a second estimate
- The masking policy, which starts empty on purpose: masking is for a viewer with no claim on the value, and the buyer who purchased the lead is not one
- Attaching a session to a lead — deliberately not on the public boundary, because a browser-reachable route that could attach a lead key would let anybody who could guess one point it at a session they controlled