A log trust page that says what it checked A static export in Bun and TypeScript
Six reports in, four verdicts out, and a page that says Verified only when every line and the tail check out.
F-Sec · A · arc: self-healing-edge · langs: ts, gleam · DOP-6.3, DOP-4.2
By Evan Eugene Pincham.
Concept
A deploy log is the record a deployer program keeps of the releases it started, promoted and rolled back, and it is written by the same machinery it describes. Whoever can edit the file can turn a line that says rolled_back into one that says promoted, and the edited line is still valid JSON with the right fields. A log like that is a claim, not evidence. To get evidence you need a way to notice edits, and you need to be honest about the edits you cannot notice.
Three mechanisms do the noticing, and each covers less than people assume.
A keyed fingerprint (formally an HMAC, a keyed-hash message authentication code) is a short value computed from a secret key and the bytes of a line. RFC 2104 defines it as H(K XOR opad, H(K XOR ipad, text)), where H is a hash function such as SHA-256, K is the key, and ipad and opad are two fixed padding strings the RFC defines. Without the key you cannot compute the right fingerprint for a line you changed or invented. The same RFC says keys shorter than the hash output length are "strongly discouraged", that keys "need to be chosen at random" and "periodically refreshed", and that the mechanism depends on "good secrecy protection of keys".
A hash chain makes each line carry the fingerprint of the line before it. Change one line and its fingerprint no longer matches. Delete a line and the next line points at a fingerprint that is no longer there. Moving lines around breaks the pointers the same way. That is a design property of the chain; the captured runs below show edits and deletions.
The head is the fingerprint of the last line, and the tail is the end of the file. A chain checks every link between lines it can see, so a file with its last two lines cut off is still a perfectly good chain, just a shorter one. Only a head recorded somewhere else can reveal that. NIST SP 800-53 Rev. 5 control AU-9(2), "Store on Separate Physical Systems or Components", gives the reason: it "helps to ensure that a compromise of the system being audited does not also result in a compromise of the audit records". AWS CloudTrail applies the same idea at service scale: its digest files are signed hourly, each contains the signature of the previous digest, and they are put "into a folder separate from the log files".
One more limit: a keyed fingerprint is symmetric, so anyone who can verify can also forge. Whoever holds the key can edit a line and re-seal the whole file, and the chain will look clean. The stored head is what pins history in that case. AU-9(3), "Cryptographic Protection", points toward "signed hash functions using asymmetric cryptography" so that verifying and signing are separate powers.
What the page adds
A small verifier program reads a log whose lines each carry a keyed fingerprint of the line and of the line before it, then prints a text report. This lesson builds a static export of that report: a set of plain HTML files generated once at build time, with no server, no accounts, no data store and no client-side JavaScript. Bun does the build.
The page reads the report text. It does not read the log and never touches the key. A build machine that had the key could forge lines as well as verify them, and the verifier should stay the only program that knows how a line is checked. The page's job is smaller: say exactly what the report says, say what was not checked, and refuse to be built from a report that contradicts itself. It cannot tell whether the report itself is genuine; it only checks that the report agrees with itself.
Two other subjects touch the page. The first is data: the report is a small text format with a schema (a fixed set of named fields) and an invariant, so reading it carefully is the same job as reading any other structured input. The second is AI, covered as a hypothetical at the end of the Lab section.
The AWS DevOps Engineer Professional exam guide (DOP-C02, version 1.6) frames the surrounding skills. Task Statement 6.3, "Implement security monitoring and auditing solutions", lists "Security auditing services and features (for example, CloudTrail, AWS Config, VPC Flow Logs, CloudFormation drift detection)" as knowledge, and Task Statement 4.2 is "Audit, monitor, and analyze logs and metrics to detect issues". This page is a small local version of the audit view those statements point at: it reads an integrity result and states what that result does and does not establish.
Two more terms. An invariant is a statement that must hold for every valid input; here, lines = verified + broken + unreadable. A verdict is the single overall conclusion the page draws, chosen from a fixed list:
| Verdict | Condition | May the headline start with "Verified:"? |
|---|---|---|
verified | no problems, and the tail was checked against a recorded head | yes |
tail-unchecked | no problems, no head supplied; the page says the tail was not checked | no |
tail-mismatch | every line verifies but the head differs from the recorded one | no |
broken | any broken or unreadable line | no |
No page says "healthy".
Worked example
The log in question is written by a small deployer program that starts a new release beside the old one and keeps the old one unless the new one proves ready in time. Each line is a JSON object with ts, event, version and detail; the events are candidate_started, promoted and rolled_back. The sample log has six lines: version v2 was promoted, and v3 and v4 were rolled back. A verifier program sealed that log, then checked six variants of it, and the page is built from the six reports it printed.
The report
The report format is: chainlog verify: <file>, then lines:, verified:, broken:, unreadable:, head:, first_break: (none or line N (kind)), an optional tail: line (ok (head matches) or MISMATCH (expected head <hex>)), then problems: followed by one row per problem: N kind message, with two spaces between columns.
Kinds mac (the line's fingerprint is wrong) and link (the fingerprint is right but it points at the wrong previous line) are broken lines: readable, but they failed a check. Every other kind (truncated, not json, and so on) is an unreadable line. Each line lands in exactly one bucket.
Six real reports ship with the lab, so you do not need to build the verifier:
clean: verified with no head supplied.clean-head: verified with the head set to the true last fingerprint.edited: line 4 changed fromrolled_backtopromoted.deleted: line 3 removed.damaged: line 2 cut off mid-write and line 5 replaced by a stray message.cut-head: the last two lines removed, then verified against the earlier head.
The damaged one, byte for byte:
chainlog verify: damaged.log
lines: 6
verified: 2
broken: 2
unreadable: 2
head: 7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd
first_break: line 2 (truncated)
problems:
2 truncated EOF while parsing a value at line 1 column 60
3 link prev does not match the line before it
5 not json expected ident at line 1 column 2
6 link prev does not match the line before it
Lines 2 and 5 are unreadable. Lines 3 and 6 are link breaks because the lines before them were lost, so they point at fingerprints that no longer exist. The two lines that verified are 1 and 4. 2 + 2 + 2 = 6.
Parsing and refusing
report.ts reads that text into a typed Report, then calls assertConsistent. Four rules turn a self-contradicting report into a thrown error: zero lines; counts that do not add up; problem rows that do not match the broken and unreadable counts; and a first_break that is not the first problem row. A missing field is also an error, because need throws.
// Reads the text report printed by a log verifier and checks that the report agrees with itself.
export type Problem = { line: number; kind: string; message: string; bucket: "broken" | "unreadable" };
export type Tail = "not checked" | "ok" | "mismatch";
export type Report = {
file: string;
lines: number;
verified: number;
broken: number;
unreadable: number;
head: string;
firstBreak: string | null; // e.g. "line 4 (mac)"; null when the report says none
tail: Tail;
expectedHead: string | null; // only present when the tail did not match
problems: Problem[];
};
export type Verdict = "verified" | "tail-unchecked" | "tail-mismatch" | "broken";
const BROKEN_KINDS = ["mac", "link"]; // readable lines that failed a check; every other kind is unreadable
function need(fields: Map<string, string>, key: string): string {
const v = fields.get(key);
if (v === undefined) throw new Error(`report has no \`${key}:\` line`);
return v;
}
function count(fields: Map<string, string>, key: string): number {
const v = need(fields, key);
if (!/^\d+$/.test(v)) throw new Error(`\`${key}:\` must be a whole number, got "${v}"`);
return Number(v);
}
export function parseReport(text: string): Report {
const fields = new Map<string, string>();
const problems: Problem[] = [];
let file = "";
let inProblems = false;
for (const raw of text.split("\n")) {
if (raw.trim() === "") continue;
if (!inProblems) {
if (raw.startsWith("chainlog verify: ")) { file = raw.slice("chainlog verify: ".length); continue; }
if (raw === "problems:") { inProblems = true; continue; }
const i = raw.indexOf(": ");
if (i < 0) throw new Error(`cannot read report line: ${raw}`);
fields.set(raw.slice(0, i), raw.slice(i + 2));
} else {
const m = /^ {2}(\d+) {2}(.+?) {2}(.*)$/.exec(raw);
if (!m) throw new Error(`cannot read problem row: ${raw}`);
const kind = m[2];
problems.push({ line: Number(m[1]), kind, message: m[3], bucket: BROKEN_KINDS.includes(kind) ? "broken" : "unreadable" });
}
}
const tailText = fields.get("tail");
const tail: Tail = tailText === undefined ? "not checked" : tailText.startsWith("ok") ? "ok" : tailText.startsWith("MISMATCH") ? "mismatch" : (() => { throw new Error(`unknown tail line: ${tailText}`); })();
const fb = need(fields, "first_break");
const r: Report = {
file,
lines: count(fields, "lines"),
verified: count(fields, "verified"),
broken: count(fields, "broken"),
unreadable: count(fields, "unreadable"),
head: need(fields, "head"),
firstBreak: fb === "none" ? null : fb,
tail,
expectedHead: tailText?.match(/expected head ([0-9a-f]{64})/)?.[1] ?? null,
problems,
};
assertConsistent(r);
return r;
}
// A page built from a report that does not add up is a build error.
export function assertConsistent(r: Report): void {
if (r.lines === 0) throw new Error("report covers no lines");
if (r.verified + r.broken + r.unreadable !== r.lines) {
throw new Error(`totals do not add up: ${r.lines} lines but ${r.verified} verified + ${r.broken} broken + ${r.unreadable} unreadable`);
}
const b = r.problems.filter((p) => p.bucket === "broken").length;
const u = r.problems.length - b;
if (b !== r.broken || u !== r.unreadable) {
throw new Error(`problem rows do not match the counts: ${b} broken and ${u} unreadable rows, report says ${r.broken} and ${r.unreadable}`);
}
const first = r.problems[0];
const want = first ? `line ${first.line} (${first.kind})` : null;
if (r.firstBreak !== want) {
throw new Error(`first_break says ${r.firstBreak ?? "none"} but the first problem row is ${want ?? "absent"}`);
}
}
// "verified" is only for a clean chain whose tail was checked against a head recorded elsewhere.
export function verdict(r: Report): Verdict {
if (r.broken > 0 || r.unreadable > 0) return "broken";
if (r.tail === "mismatch") return "tail-mismatch";
if (r.tail === "ok") return "verified";
return "tail-unchecked";
}
verdict checks problems first. A log with a broken line and a mismatching tail is broken, not tail-mismatch, so the more serious finding is never masked. "Verified" is reachable by exactly one path: zero problems and tail === "ok".
Building the pages
build.ts reads every .report file in fixtures/, parses it (which throws on an inconsistent report), picks the headline with a switch over the verdict, and writes one HTML file per report. Report text is run through esc before it goes into the page, because problem messages come from whatever the verifier saw in the log.
Bun's file API (Bun.file(...).text() and Bun.write) is the recommended way to read and write files; for things it does not cover, such as listing a directory, its documentation points to the node:fs module, which is why readdirSync is imported.
// Static export: one "log trust" page per verifier report, written to dist/. No server, no client JS.
import { readdirSync } from "node:fs";
import { parseReport, verdict, type Report } from "./report.ts";
const esc = (s: string) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
function headline(r: Report): string {
switch (verdict(r)) {
case "verified":
return `Verified: all ${plural(r.lines, "line")} check out and the tail matches the recorded head.`;
case "tail-unchecked":
return `Chain intact, tail not checked: all ${plural(r.lines, "line")} verify, but a log with its last lines cut off would look the same. Supply the recorded head to check.`;
case "tail-mismatch":
return `Tail does not match the recorded head: ${plural(r.lines, "line")} verify, but the log ends somewhere other than where it was last known to end.`;
case "broken":
return `Not verified: ${r.broken} broken and ${r.unreadable} unreadable of ${plural(r.lines, "line")}. First break: ${r.firstBreak}.`;
}
}
for (const file of readdirSync("fixtures").filter((f) => f.endsWith(".report")).sort()) {
const r = parseReport(await Bun.file(`fixtures/${file}`).text()); // throws if the report disagrees with itself
const v = verdict(r);
const tail = r.tail === "ok" ? "head matches" : r.tail === "mismatch" ? `does not match the recorded head ${r.expectedHead}` : "not checked";
const problems = r.problems
.map((p) => `<li data-line="${p.line}" data-kind="${esc(p.kind)}" data-bucket="${p.bucket}">Line ${p.line}: <span class="kind">${esc(p.kind)}</span> <span class="msg">${esc(p.message)}</span></li>`)
.join("\n");
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>edge-svc log trust</title>
<link rel="stylesheet" href="trust.css"></head>
<body><main data-verdict="${v}">
<h1>edge-svc log trust</h1>
<p class="verdict" data-verdict-text>${esc(headline(r))}</p>
<p class="totals" data-lines="${r.lines}" data-verified="${r.verified}" data-broken="${r.broken}" data-unreadable="${r.unreadable}"><span>${plural(r.lines, "line")}</span><span>${r.verified} verified</span><span>${r.broken} broken</span><span>${r.unreadable} unreadable</span></p>
<dl>
<dt>First break</dt><dd data-first-break>${esc(r.firstBreak ?? "none")}</dd>
<dt>Tail</dt><dd data-tail="${r.tail === "not checked" ? "unchecked" : r.tail}">${esc(tail)}</dd>
<dt>Head</dt><dd><code>${esc(r.head)}</code></dd>
</dl>
${r.problems.length > 0 ? `<h2>Problems</h2>\n<ol class="problems">\n${problems}\n</ol>\n` : ""}</main></body></html>
`;
await Bun.write(`dist/${file.replace(/\.report$/, ".html")}`, html);
console.log(`${file} -> dist/${file.replace(/\.report$/, ".html")} verdict=${v} lines=${r.lines} verified=${r.verified} broken=${r.broken} unreadable=${r.unreadable}`);
}
await Bun.write("dist/trust.css", await Bun.file("trust.css").text());
The two supporting files:
{
"name": "edge-svc-log-trust",
"private": true,
"type": "module",
"scripts": { "build": "bun build.ts" }
}
body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem; }
.verdict { font-weight: 600; }
[data-verdict="verified"] .verdict { color: #2f6b3a; }
[data-verdict="broken"] .verdict, [data-verdict="tail-mismatch"] .verdict { color: #b4462f; }
[data-verdict="tail-unchecked"] .verdict { color: #8a6d1d; }
.totals span { margin-right: 1rem; font-weight: 600; }
dt { font-weight: 600; float: left; clear: left; width: 8rem; }
dd { margin: 0 0 .25rem 8rem; }
code { word-break: break-all; }
.problems li { margin: .25rem 0; }
.kind { font-weight: 600; }
.msg { color: #555; }
What the reference build printed
Run in a Bun 1.3.14 container with no network:
clean-head.report -> dist/clean-head.html verdict=verified lines=6 verified=6 broken=0 unreadable=0
clean.report -> dist/clean.html verdict=tail-unchecked lines=6 verified=6 broken=0 unreadable=0
cut-head.report -> dist/cut-head.html verdict=tail-mismatch lines=4 verified=4 broken=0 unreadable=0
damaged.report -> dist/damaged.html verdict=broken lines=6 verified=2 broken=2 unreadable=2
deleted.report -> dist/deleted.html verdict=broken lines=5 verified=4 broken=1 unreadable=0
edited.report -> dist/edited.html verdict=broken lines=6 verified=5 broken=1 unreadable=0
built 6 pages
Only clean-head earns "verified". clean has the same six verified lines but no head to compare, so the page says the tail was not checked. The <main> part of the damaged page:
<body><main data-verdict="broken">
<h1>edge-svc log trust</h1>
<p class="verdict" data-verdict-text>Not verified: 2 broken and 2 unreadable of 6 lines. First break: line 2 (truncated).</p>
<p class="totals" data-lines="6" data-verified="2" data-broken="2" data-unreadable="2"><span>6 lines</span><span>2 verified</span><span>2 broken</span><span>2 unreadable</span></p>
<dl>
<dt>First break</dt><dd data-first-break>line 2 (truncated)</dd>
<dt>Tail</dt><dd data-tail="unchecked">not checked</dd>
<dt>Head</dt><dd><code>7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd</code></dd>
</dl>
<h2>Problems</h2>
<ol class="problems">
<li data-line="2" data-kind="truncated" data-bucket="unreadable">Line 2: <span class="kind">truncated</span> <span class="msg">EOF while parsing a value at line 1 column 60</span></li>
<li data-line="3" data-kind="link" data-bucket="broken">Line 3: <span class="kind">link</span> <span class="msg">prev does not match the line before it</span></li>
<li data-line="5" data-kind="not json" data-bucket="unreadable">Line 5: <span class="kind">not json</span> <span class="msg">expected ident at line 1 column 2</span></li>
<li data-line="6" data-kind="link" data-bucket="broken">Line 6: <span class="kind">link</span> <span class="msg">prev does not match the line before it</span></li>
</ol>
</main></body></html>
And the cut-tail page, where every line verifies yet the page refuses to call it good:
<body><main data-verdict="tail-mismatch">
<h1>edge-svc log trust</h1>
<p class="verdict" data-verdict-text>Tail does not match the recorded head: 4 lines verify, but the log ends somewhere other than where it was last known to end.</p>
<p class="totals" data-lines="4" data-verified="4" data-broken="0" data-unreadable="0"><span>4 lines</span><span>4 verified</span><span>0 broken</span><span>0 unreadable</span></p>
<dl>
<dt>First break</dt><dd data-first-break>none</dd>
<dt>Tail</dt><dd data-tail="mismatch">does not match the recorded head 7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd</dd>
<dt>Head</dt><dd><code>8d4fcdcfad140115e80c97b15da3e1c18d89e59e04c58ea4b7d65d0a57968086</code></dd>
</dl>
</main></body></html>
What the page says it checked
Every page carries four separate facts: the counts (verified, broken, unreadable), the first break, the tail status, and the head. "The log verified" is not one claim. It means the lines were checked against the key, the links between them were checked, and, only if the tail line says so, that the log still ends where it once ended. A page that collapses these into one green word cannot tell the reader which of them was actually checked.
Lab
Copy lab/starter/ (report.ts, build.ts, trust.css, package.json) beside check.sh. You need Bun and nothing else: no network, no key, no Rust toolchain. check.sh writes the six report fixtures into fixtures/ itself, so the bytes match the reports shown above. The starter reads only lines and verified and calls the page "Healthy" whenever those two are equal.
Task: make the page account for the whole report.
- Parse every field, including the optional
tail:line. - Classify problem rows as broken or unreadable by kind.
- Add
assertConsistent, so a report that disagrees with itself stops the build. - Add
verdict()and one headline per verdict. - Show the first break, the tail status, the head, and the list of problem rows.
Run sh check.sh. It prints one line per check: the word RESULT, the check name, then the outcome. The eleven checks:
| Check | What it looks at |
|---|---|
static_build | the build exits 0 and writes pages |
clean_head_verified | the clean report with a matching head gets the verified verdict and 6/6/0/0 totals |
tail_not_checked_is_said | the clean report without a head says the tail was not checked |
edit_named | the edited report's page names the break and shows 6 lines, 5 verified |
deletion_named | the deleted report's page names the break |
damaged_totals | 6 lines, 2 verified, 2 broken, 2 unreadable |
totals_add_up | totals on every page sum to the line count |
problem_rows_listed | each problem row appears on the page |
cut_tail_flagged | the cut-tail page reports a mismatch |
no_good_news_unless_clean | no page except the verified one opens with good news, and none says "healthy" |
inconsistent_report_fails_build | broken copies of a report (wrong count, wrong first break, missing field) make the build throw |
Expected output for the reference, recorded on 2026-11-04 in the oven/bun:1.3-debian image (Bun 1.3.14) with --pull=never --network=none:
clean-head.report -> dist/clean-head.html verdict=verified lines=6 verified=6 broken=0 unreadable=0
clean.report -> dist/clean.html verdict=tail-unchecked lines=6 verified=6 broken=0 unreadable=0
cut-head.report -> dist/cut-head.html verdict=tail-mismatch lines=4 verified=4 broken=0 unreadable=0
damaged.report -> dist/damaged.html verdict=broken lines=6 verified=2 broken=2 unreadable=2
deleted.report -> dist/deleted.html verdict=broken lines=5 verified=4 broken=1 unreadable=0
edited.report -> dist/edited.html verdict=broken lines=6 verified=5 broken=1 unreadable=0
built 6 pages
RESULT static_build PASS
clean-head page: verdict=verified totals=6 6 0 0
RESULT clean_head_verified PASS
clean page: verdict=tail-unchecked
RESULT tail_not_checked_is_said PASS
edited page: verdict=broken totals=6 5 1 0
RESULT edit_named PASS
RESULT deletion_named PASS
damaged page: totals=6 2 2 2
RESULT damaged_totals PASS
RESULT totals_add_up PASS
RESULT problem_rows_listed PASS
cut-head page: verdict=tail-mismatch
RESULT cut_tail_flagged PASS
RESULT no_good_news_unless_clean PASS
verified count off by one: threw: totals do not add up: 6 lines but 3 verified + 2 broken + 2 unreadable
first_break names the wrong line: threw: first_break says none but the first problem row is line 2 (truncated)
no lines: key: threw: report has no `lines:` line
RESULT inconsistent_report_fails_build PASS
The starter passes only static_build and fails the other ten.
Diagnostics
Symptom: the page says "Healthy" for a log that lost its last two lines. How to see it: the starter's output for the cut-tail report.
--- dist/cut-head.html
<body><main>
<h1>edge-svc log trust</h1>
<p class="verdict">Healthy</p>
<p class="totals"><span>4 lines</span><span>4 verified</span></p>
</main></body></html>
The report has a tail: MISMATCH line that the starter never reads. Fix: parse tail, and let only tail === "ok" reach the verified verdict.
Symptom: an edited log shows "5 verified" and nothing else. How to see it: the starter's edited page.
--- dist/edited.html
<body><main>
<h1>edge-svc log trust</h1>
<p class="verdict">Some lines are not verified</p>
<p class="totals"><span>6 lines</span><span>5 verified</span></p>
</main></body></html>
The reader is told something is wrong but not what, where, or whether the missing line is broken or unreadable. Fix: parse broken, unreadable, first_break and the problem rows, and render all of them.
Symptom: a report with a wrong number still produces a page. How to see it: in a scratch run (not part of the checker) the damaged report's verified was changed from 2 to 3. With assertConsistent in place the build stops:
clean-head.report -> dist/clean-head.html verdict=verified lines=6 verified=6 broken=0 unreadable=0
error: totals do not add up: 6 lines but 3 verified + 2 broken + 2 unreadable
at assertConsistent (/lab/report.ts:74:15)
at parseReport (/lab/report.ts:66:3)
at /lab/build.ts:23:13
error: script "build" exited with code 1
build exit=1
clean-head.html
Fix: call assertConsistent inside parseReport, so no caller can skip it. One more thing the output shows: damaged.html was never written, but clean-head.html, built before the failure, is still in dist/. A failed build can leave earlier pages behind, so publish only after an exit code of 0. check.sh deletes dist/ before every build for the same reason.
Symptom: markup in a problem message could end up as markup on the page. How to see it: in another scratch run, line 4 of a copy of the edited report got the message <script>alert(1)</script> "quoted" & more. The built page:
<li data-line="4" data-kind="mac" data-bucket="broken">Line 4: <span class="kind">mac</span> <span class="msg"><script>alert(1)</script> "quoted" & more</span></li>
Fix: escape every report-derived string, as esc does. Report text is data the verifier copied out of a log, and the log is the thing you do not trust.
A hypothetical consumer
This part is a hypothetical; no model is run in the lab. A page like this is also what you would show an AI assistant instead of the raw log. OWASP's LLM01 entry on prompt injection describes indirect injection as occurring "when an LLM accepts input from external sources, such as websites or files", and lists "Segregate and identify external content" among its mitigations. Applied here: lines whose status is not verified are counted and kept out of the prompt, and any action the assistant proposes, such as a restart or a rollback, should wait for a human or a deterministic check ("Require human approval for high-risk actions" is another listed mitigation).
Stretch exercises
No results are claimed for these.
- Add a seventh fixture: a report from a wrong-key run, where every line is
mac. - Write a report whose problem rows exceed the counts and see which assertion fires.
- Make a page link to the previous report for comparison.
Other paths
Gleam with Lustre (hypothetical, a sketch, not run). The same page could be written in Gleam, using Lustre as an HTML templating library. Lustre's documentation says you can do this "without using its runtime features, by passing elements to functions like to_string_tree or to_document_string". The design claim, which is not a run result: make the verdict a sum type with four variants (Verified, TailUnchecked, TailMismatch, Broken). Then a page cannot be built without choosing exactly one variant, and a case expression must give every variant a headline, so adding a fifth verdict later is a compile error until every headline is written. The TypeScript version in this lab runs no type checker, so the lab does not claim that guarantee for it.
One caution from the Lustre documentation: unsafe_raw_html renders "the provided HTML verbatim", meaning unescaped, so report text must never be passed to it. The passage describing text ("a function for turning a Gleam string into a text node") does not say whether it escapes <, so any claim about escaping there would be a guess; check before relying on it.
Citations
- RFC 2104, "HMAC: Keyed-Hashing for Message Authentication" (https://www.rfc-editor.org/rfc/rfc2104.txt): section 2 "Definition of HMAC", section 3 "Keys", section 6 "Security".
- NIST SP 800-53 Rev. 5, "Security and Privacy Controls for Information Systems and Organizations" (https://doi.org/10.6028/NIST.SP.800-53r5): control AU-9 "Protection of Audit Information" and its enhancements AU-9(2) "Store on Separate Physical Systems or Components" and AU-9(3) "Cryptographic Protection".
- AWS CloudTrail User Guide, "Validating CloudTrail log file integrity" (https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-log-file-validation-intro.html): digest files signed hourly, each carrying the previous digest's signature, kept in a folder separate from the logs.
- OWASP Gen AI Security Project, "LLM01:2025 Prompt Injection" (https://genai.owasp.org/llmrisk/llm01-prompt-injection/): indirect prompt injection; mitigations "Segregate and identify external content" and "Require human approval for high-risk actions". Used only for the hypothetical consumer.
- Bun docs, "File I/O" (https://bun.sh/docs/api/file-io):
Bun.file,BunFile.text(),Bun.write, and the pointer tonode:fsfor operations such as reading a directory. - Lustre documentation,
lustre/element, v5.7.1 (https://hexdocs.pm/lustre/lustre/element.html):to_string_tree,to_document_string,text,unsafe_raw_html. - AWS Certified DevOps Engineer - Professional (DOP-C02) Exam Guide, version 1.6 (https://d1.awsstatic.com/training-and-certification/docs-devops-pro/AWS-Certified-DevOps-Engineer-Professional_Exam-Guide.pdf): Domain 6 Task Statement 6.3 "Implement security monitoring and auditing solutions", and Domain 4 Task Statement 4.2 "Audit, monitor, and analyze logs and metrics to detect issues".