diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro
index 6c597d2..c3f9251 100644
--- a/site/src/components/Footer.astro
+++ b/site/src/components/Footer.astro
@@ -5,7 +5,7 @@
// Starlight's footer (edit link, previous and next) plus the project's own line: the
// license, the trademark note and the privacy promise this site keeps.
import Default from "@astrojs/starlight/components/Footer.astro";
-import { site } from "../config";
+import { openssf, site } from "../config";
---
@@ -18,6 +18,11 @@ import { site } from "../config";
This site sets no cookies, has no analytics and loads nothing from other sites. It is hosted on GitHub Pages
behind Cloudflare, which see visitors' IP addresses, as any web host does.
+
+
+ OpenSSF Best Practices {openssf.level}
+
+
Privacy policy · Source code · Report a problem
@@ -34,4 +39,35 @@ import { site } from "../config";
.kotiko-footer p + p {
margin-top: 0.5rem;
}
+ /* The badge, drawn here rather than loaded from bestpractices.dev (see config.ts). */
+ .kotiko-badge {
+ display: inline-flex;
+ border-radius: 4px;
+ overflow: hidden;
+ font-size: var(--sl-text-xs);
+ line-height: 1.8;
+ text-decoration: none;
+ border: 1px solid var(--sl-color-hairline-light);
+ }
+ .kotiko-badge span {
+ padding: 0 0.5rem;
+ }
+ .kotiko-badge span:first-child {
+ background: var(--sl-color-gray-6);
+ color: var(--sl-color-white);
+ }
+ .kotiko-badge .level {
+ text-transform: capitalize;
+ font-weight: 600;
+ color: #1b1b1b;
+ }
+ .kotiko-badge .level-passing {
+ background: #c9a227;
+ }
+ .kotiko-badge .level-silver {
+ background: #c0c6cc;
+ }
+ .kotiko-badge .level-gold {
+ background: #e2b437;
+ }
diff --git a/site/src/config.ts b/site/src/config.ts
index 454b599..b1bdbbd 100644
--- a/site/src/config.ts
+++ b/site/src/config.ts
@@ -17,3 +17,11 @@ export const site = {
releases: "https://github.com/ScriptKittyOS/kotiko/releases",
issues: "https://github.com/ScriptKittyOS/kotiko/issues",
};
+
+// The OpenSSF Best Practices badge (https://www.bestpractices.dev/projects/15259). The site
+// loads nothing from other hosts, so it shows the level as text instead of the live image
+// the README uses: when the entry reaches a new level, change `level` here.
+export const openssf = {
+ level: "silver" as "passing" | "silver" | "gold",
+ url: "https://www.bestpractices.dev/projects/15259",
+};
diff --git a/site/tests/badge.test.mjs b/site/tests/badge.test.mjs
new file mode 100644
index 0000000..6eb3edb
--- /dev/null
+++ b/site/tests/badge.test.mjs
@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors
+// SPDX-License-Identifier: Apache-2.0
+
+// The OpenSSF Best Practices badge in the footer: every page links to the entry and shows
+// the level set in src/config.ts, drawn in CSS rather than loaded from another host.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { ensureBuilt, read, text, SITE } from "./helpers.mjs";
+
+const level = fs.readFileSync(path.join(SITE, "src/config.ts"), "utf8").match(/level:\s*"(passing|silver|gold)"/)?.[1];
+
+test("config.ts names a badge level", () => {
+ assert.ok(level, "src/config.ts sets openssf.level");
+});
+
+for (const page of ["index.html", "install/index.html", "privacy/index.html"]) {
+ test(`${page} shows the ${level} badge linked to the entry`, () => {
+ ensureBuilt();
+ const html = read(page);
+ const badge = html.match(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/);
+ assert.ok(badge, "the footer has the badge");
+ assert.equal(badge[1], "https://www.bestpractices.dev/projects/15259");
+ assert.equal(text(badge[2]), `OpenSSF Best Practices ${level}`);
+ assert.match(badge[0], new RegExp(`aria-label="OpenSSF Best Practices: ${level}"`));
+ assert.doesNotMatch(html, /bestpractices\.dev\/projects\/15259\/badge/, "no image loaded from bestpractices.dev");
+ });
+}