Applies when the deliverable is a single HTML file the client opens in their own browser, from their own file share. No server, no network calls, no data leaving their environment. This annex adds to baseline-runbook.md; it does not replace it.
The security argument for tier 1 used to be that the file is inert and offline. Half of that is still exactly true and half of it is not, and the distinction now matters:
- Offline is unchanged and absolute. The file carries every dependency, makes zero network requests, holds no credential, and there is nothing of the client's on my side to breach.
- Inert is no longer accurate, because a deliverable can now include a local file reader — the client points it at their own CSV or XLSX export and the dashboard re-renders. That is code executing over input I did not generate, which is a real change in posture and is not something to leave implied.
So the honest version is: nothing leaves, and the only thing that runs is a small parser I wrote, on a file the user chose, inside the browser sandbox.
Two consequences the rest of this annex now has to carry. The parser is first-party and deliberately small — a few hundred lines using the browser's own DecompressionStream, DOMParser and FileReader — rather than a large third-party spreadsheet library, precisely so the added surface is readable and auditable. And the escaping discipline below stops being hygiene and becomes load-bearing, because the input is now genuinely untrusted rather than merely plausible.
Embedded data — the sensitivity check
A tier-1 dashboard usually carries its data inside the file. That means the file is the data: wherever it gets forwarded, the data goes too. Before I embed anything, I check:
- ☐Is any of this personal data? Names, emails, employee IDs, customer identifiers, free-text notes that could name someone. If yes, it either comes out, gets aggregated, or gets pseudonymised — or the file is classified and distributed accordingly.
- ☐Is any of it commercially sensitive? Rates, margins, client names, supplier pricing. Same three options.
- ☐Does the dashboard actually need every embedded column? If a column is not read by any chart, table or filter, it does not get embedded.
- ☐Is the embedded grain finer than the questions need? If every view is weekly by team, I embed weekly-by-team, not per-person-per-minute.
- ☐Would I be comfortable if this file were forwarded to someone outside the team? Not because that is allowed, but because it is what happens.
For demos and anything public-facing the rule is absolute: synthetic data only, generated by a seeded script, and labelled in-page as sample data. No real client extract ever becomes a demo, not even lightly edited.
Escaping — text is text
Every value that comes from data is written with textContent (or an equivalent that escapes). innerHTML is reserved for markup I wrote by hand, never for a value read from a file. A spreadsheet cell containing a <script> tag is a realistic accident, not a hypothetical attack.
Checks before delivery:
- ☐Search the file for
innerHTML,outerHTML,insertAdjacentHTML,document.write. Every hit is either hardcoded markup or gets rewritten. - ☐No
eval, nonew Function, no data value used to build a selector or a URL. - ☐Table cells, tooltips, axis labels, filter dropdowns and "top N" lists all go through the same text-setting helper — those are the places an unescaped value sneaks in.
- ☐Test with a deliberately hostile row in the sample data (a cell containing
<img src=x onerror=alert(1)>) and confirm it renders as visible text. - ☐Test the same value through the file reader, not only baked into the build. These are two separate paths and only one of them was ever covered.
- ☐
</script>in a data value. Embedded data is written into a<script>element, andJSON.stringify/json.dumpsdoes not escape</, so one free-text cell can close the element early and have the rest of the file parsed as HTML. Escaped at the embed step and asserted in the build.
The file reader, where a deliverable includes one
Optional, and stated as such: a dashboard either carries its data baked in, or ships as a viewer the client points at their own export. The second is stronger on data handling — the file is no longer the data, so it can be forwarded harmlessly — and weaker on the inert claim above. Which one applies is decided with the client, not defaulted.
- ☐The reader is first-party and dependency-free. If a third-party parser is ever used instead, it is pinned, recorded in the file header, and its CVE history is checked — a spreadsheet library is a large amount of code processing hostile input.
- ☐Schema validation refuses loudly. A file missing an expected column produces a named error and changes nothing on screen. It never partially loads, and it never renders a chart from columns it guessed at.
- ☐Nothing is silently mixed. Where a view cannot be fed from the client's file, the page says which views those are and why, rather than leaving sample data on screen looking like a result.
- ☐Re-verified offline after adding the reader: load a real file with the network disconnected and with devtools recording, and confirm zero requests. A parser that fetched anything would falsify the whole claim.
- ☐Memory sanity-checked on the largest file the client actually has, on the worst laptop they actually use.
Bundled dependencies
Everything the file needs is inside the file. No CDN, no web font fetch, no remote image, no analytics.
- ☐Charting and parsing libraries are inlined, at a pinned version recorded in the file header.
- ☐Library source is the official release artifact, checked before bundling — not copied from a blog or a random gist.
- ☐Fonts are system fonts or embedded; no
@importfrom a font service. - ☐Verified offline: open the file with the network disabled and confirm every chart, filter and table still works.
- ☐Verified quiet: open with the browser devtools network tab recording and confirm zero outbound requests.
The pinned versions go in the handover note so the client knows what to review if an advisory lands later.
File distribution rules
- The file is delivered through the client's own channel — their SharePoint or Google Drive, their network drive, their repo. I do not host it for them, and I do not email a data-bearing file unless the client asks and it goes encrypted.
- One file, one audience. If two teams should not see each other's numbers, they get two files with two data slices, not one file with a filter.
- The filename says what it is and when:
client-topic-YYYY-MM-DD.html. The page carries a visible "data as of" timestamp so nobody makes a decision on a stale copy. - The client owns distribution and access control after delivery — it is their file share and their permissions. I say this explicitly at handover so it is not an assumption.
- My working copies of any real client extract are deleted at handover, along with the generated file if it carries real data. See
templates/handover.md. - Anything published publicly — portfolio, demo site — is synthetic-only, and I check that claim by regenerating it from the seeded script rather than trusting the file in the folder.