Skip to content

Publish the reporting route at the site, not only in the tree [#86] - #147

Merged
iderex merged 1 commit into
mainfrom
publish-the-reporting-route
Aug 11, 2026
Merged

Publish the reporting route at the site, not only in the tree [#86]#147
iderex merged 1 commit into
mainfrom
publish-the-reporting-route

Conversation

@iderex

@iderex iderex commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What was wrong

The site published no route for a security report:

curl -sS -o /dev/null -w "%{http_code}\n" https://flowfin.dev/.well-known/security.txt
404

Run 2026-08-11. SECURITY.md is read by somebody who already found the source.
A person who found a problem in the pages had one obvious route, a public issue
on the tracker, and that is the one place a report of this kind must not go.

What this does

The build writes .well-known/security.txt into the output. It names the route
the security policy writes down, the policy itself, and the date after which the
file should not be trusted:

go run . build
read templates/page.html.tmpl
read content/index.txt
read data/design-tokens.json (206 value(s))
wrote dist/index.html (1140 bytes)
wrote dist/.well-known/security.txt (379 bytes)
no assets/ in the tree, so nothing was copied verbatim
2 file(s) written into dist

cat dist/.well-known/security.txt
# A problem found in these pages goes to the route below rather than to the
# public tracker, where a report is readable by everybody from the moment it
# is made. What that route is and what state it is in is the policy.
Contact: https://github.com/Flowfin/site/security/advisories/new
Policy: https://github.com/Flowfin/site/blob/main/SECURITY.md
Expires: 2027-08-11T00:00:00Z

The expiry is the part of this file that goes wrong by sitting still, and one
that has passed is worse than no file, because it tells a finder the route was
abandoned. So it is not typed. It is a duration added to the day the route was
last confirmed, which is data in data/security-contact.json, and a produced
file whose expiry has passed is refused by a row over the output. A build that
pushed the expiry forward on every run would keep the file valid forever without
anybody ever looking at the route again, which is the failure the field exists
against. Moving the day forward is a change somebody makes, and it is the act
this arrangement is built to force.

Nothing in the build reads the clock. What it writes is derived from the tree,
so two builds of one commit are the same bytes:

go run . reproduce
reproduce: two builds of ., compared byte for byte
  2 file(s), identical in both builds

Whether a date has passed is a question about the world rather than about the
tree, so it is asked by the row that reads the output. That is the one row in
the table that reads a clock, and it says so where it is written.

The file repeats nothing about the state of the route. SECURITY.md says what
the route is and that the form does not answer today, with the command that
reads that, and this file points at it rather than carrying a second copy that
could disagree in either direction.

It carries no preferred language. Which language this site publishes in is entry
2 of #7 and is open, and a field answering it here would settle it in the one
place nobody would look for the answer.

Closes

Closes #86.

Every clause of the done-when, in order. The build writes the path. The file
names the route from #59, the policy and an expiry. The expiry is a duration
added to a date the build is given, shown by a case that moves that date and
sees only that field move. A produced file whose expiry has passed reds the gate
and the failure names the date. And the path answers on a served build, asked
for over loopback from a server holding nothing but what the build wrote, with a
neighbour asking for a path nothing wrote so the case is about the path rather
than about a server handing out one file for everything.

What was run

go run . ci
gate: 6 legs, in order: format, vet, test, build, links, invariants
  needs-network was not asked for. Asking costs a request to the public name from whatever machine runs it, and a verdict that moves when somebody else's service does rather than when this tree changes. Ask with: go run ./harness/needs-network
  format: ok, 29 file(s)
  vet: ok
  test: ok, 14 test file(s)
  build: ok, 2 file(s)
  links: every reference that stays inside this site resolves to a file the build wrote
  invariants: ok, 17 rule(s) decided, 1 owed and not decided
6 of 6 legs ran. None was skipped.

Two produced files where there was one, and seventeen decided rows where there
were sixteen. The one still owed is image-dimensions-match-the-file, which
waits on the first image the build writes and belongs to #69.

git diff --name-only origin/main...HEAD
data/security-contact.json
internal/invariant/invariant.go
internal/invariant/invariant_test.go
internal/security/security.go
internal/security/security_test.go
internal/site/site.go
internal/site/site_test.go

All run 2026-08-11.

The guards, deleted and watched go red

Each one on its own, with the rest of the tree left alone:

the expiry row made to accept a date that has passed
  --- FAIL: TestEveryRowRefusesItsOwnViolationAndPassesTheNeighbour
      row output-carries-no-expired-reporting-route passed its own violation
  --- FAIL: TestTheExpiryRowRefusesAPassedDateAndPassesTheRest
      the expiry row did not refuse a date that has passed: []

the refusal of a tree carrying no source for the file removed
  --- FAIL: TestRunRefusesATreeCarryingNoSourceForTheReportingRoute

the build made to pass over a contact it cannot read
  --- FAIL: TestBuildRefusesASecurityContactItCannotRead

The near miss the expiry row has to get right is the file that is not its
subject. It reads the produced output, where most files carry no expiry at all,
so a row that refused everything it could not parse would refuse every page. The
three answers are told apart in the suite: no expiry, an expiry ahead, and an
expiry that has passed, plus a fourth for an expiry that is not a moment.

No test was skipped. The one case that serves the output binds loopback through
the toolchain's own test server and asks for the path over it; nothing here
needs a display, a browser, elevation or the public network.

The means

Go and the packages that already exist, plus one small JSON file in the tree for
the two addresses and the date. JSON because it is read by a program and edited
rarely, which is the reason the roster schema gives for the same choice, and
because the alternative is three values parsed out of prose.

The file format is RFC 9116, which is not a choice this repository makes: the
value of the file is that it sits at a fixed path in a shape a finder's tooling
already reads, so inventing anything here would defeat what it is for.

The rule about the expiry is a row in the existing invariant table, decided by
the same verb as every other row and reported under the same check name, which
is what makes it provable by the suite and runnable on the machine where the
mistake was made.

Who read it

Nobody else has read this. The ruleset requires no approving review, so this is
merged by whoever opened it, and the evidence above stands in place of a second
reader.

SECURITY.md is read by somebody who already found the source. The reports this
plan most wants are from people who found a problem in the published pages, and
for them the site published nothing, so the obvious route was a public issue on
the tracker, which is the one place a report of this kind must not go.

The build now writes .well-known/security.txt, at the one path a person looking
for it already knows. It names the route the security policy writes down, the
policy itself, and the date after which the file should not be trusted.

The expiry is the part that goes wrong by sitting still, and an expiry that has
passed is worse than no file, because it tells a finder the route was abandoned.
So it is not typed. It is a duration added to the day the route was last
confirmed, which is data in the tree, and the day the sum falls into the past a
row over the produced output refuses it. A build that pushed the expiry forward
on every run would keep the file valid forever without anybody ever looking at
the route again, which is the failure the field exists against.

Nothing in the build reads the clock. What it writes is derived from the tree,
so two builds of one commit are the same bytes; whether a date has passed is a
question about the world and is asked by the check that reads the output. That
is the one row in the table that reads a clock and it says so.

The file is produced rather than committed, because a committed file describing
generated output is a second copy that drifts from it, and this one carries a
date that would drift first.

It carries no preferred language. Which language this site publishes in is open
on the tracker, and a field answering it here would settle it in the one place
nobody would look for the answer. It repeats nothing about the state of the
route either: the policy is where that is written, with the command that reads
it, so the two cannot disagree.

Signed-off-by: Nils Lehnen <[email protected]>
@iderex
iderex merged commit 195020a into main Aug 11, 2026
17 checks passed
@iderex
iderex deleted the publish-the-reporting-route branch August 11, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish the reporting route at the site, not only in the tree

1 participant