Skip to content

Commit d132760

Browse files
committed
Release v1.0.1 hardening fixes
1 parent a9cafcc commit d132760

5 files changed

Lines changed: 113 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,20 @@ pattern.
1313
1414
## [Unreleased]
1515

16+
## [1.0.1] — 2026-05-30
17+
1618
### Added
19+
- Added eval coverage for manifest hash freshness, changelog release-tag consistency, and generated-output injection boundaries.
1720
- Documented generated-output safety behavior for external URLs, CSS tokens, table parsing, and client-side comparison labels.
1821
- Adopted the GuideCheck Human-Verifiable Assistant Guide profile at Level 4 with `assistant-guide.txt`, a well-known canonical guide, a sidecar manifest, and discovery links.
1922

2023
### Changed
2124
- Normalized example and public-facing URLs to `https` bare-domain form.
2225

2326
### Fixed
27+
- Restricted generated navigation hrefs to safe internal paths before HTML emission.
28+
- Normalized coverage-badge group classes on generated container detail pages before HTML emission.
29+
- Refreshed the README content-provenance hash after the intentional schema documentation update.
2430
- Escaped generated compare-page labels and script data before client-side HTML insertion.
2531
- Validated generated CSS class names and color values before writing inline styles.
2632
- Restricted generated external links to `https` and normalized `www` hostnames to bare domains.
@@ -127,7 +133,8 @@ canonical pattern definition at <https://knowledge-as-code.com/>.
127133
site generator, `scripts/validate.js` cross-reference linter, example data for ISO 27001 and
128134
NIST CSF, dark/light themed static site output, JSON API, bridge pages, `CNAME` fixture.
129135

130-
[Unreleased]: https://github.com/snapsynapse/knowledge-as-code-template/compare/v1.0.0...HEAD
136+
[Unreleased]: https://github.com/snapsynapse/knowledge-as-code-template/compare/v1.0.1...HEAD
137+
[1.0.1]: https://github.com/snapsynapse/knowledge-as-code-template/releases/tag/v1.0.1
131138
[1.0.0]: https://github.com/snapsynapse/knowledge-as-code-template/releases/tag/v1.0.0
132139
[0.4.0]: https://github.com/snapsynapse/knowledge-as-code-template/releases/tag/v0.4.0
133140
[0.3.0]: https://github.com/snapsynapse/knowledge-as-code-template/releases/tag/v0.3.0

MANIFEST.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
# - This file itself
2828

2929
bundle: knowledge-as-code-template
30-
bundle_version: 0.1.0
31-
bundle_date: 2026-04-19
30+
bundle_version: 1.0.1
31+
bundle_date: 2026-05-30
3232
description: >
3333
Knowledge as Code starter template — scaffolds a KaC site with
3434
Primary/Container/Authority/Secondary entities, gist ontology binding,
@@ -47,7 +47,7 @@ files:
4747

4848
- path: README.md
4949
role: protocol
50-
hash: sha256:67e284b2ae2fa7cddee5354f34b1c21f76908bab7f50fcdd8ca9abac8e62723c
50+
hash: sha256:61811994959fe4f8d52f1802593d79957527fa890f3573aeea1f3e96f0105c13
5151
note: Template overview
5252

5353
- path: assistant-guide.txt
@@ -72,7 +72,7 @@ files:
7272

7373
- path: scripts/build.js
7474
role: tooling
75-
hash: sha256:daa154b9d3cb9ea0704eb69e43b69bdaa177092fb0bef4cc6653a3b0137fbf7a
75+
hash: sha256:c735180a53071ae90facd6ddf9641ff26e32906acaf2775e3cef1fe872e6af9e
7676
note: Static site generator
7777

7878
- path: scripts/validate.js

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "knowledge-as-code-template",
3-
"version": "1.0.0",
3+
"version": "1.0.1",
44
"description": "Template for building structured, version-controlled knowledge bases with an ontology-first approach.",
55
"license": "MIT",
66
"scripts": {

scripts/build.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ function safeURL(url, fallback = '#') {
8282
return fallback;
8383
}
8484

85+
function safeInternalHref(href, fallback = 'index.html') {
86+
const raw = String(href || '').trim();
87+
if (!raw) return fallback;
88+
if (/[\x00-\x20"'<>\\]/.test(raw)) return fallback;
89+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return fallback;
90+
if (raw.startsWith('//')) return fallback;
91+
return raw;
92+
}
93+
8594
function humanizeId(id) {
8695
return String(id || '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
8796
}
@@ -308,7 +317,7 @@ function renderSiteNav(config, activePage, prefix) {
308317
</button>
309318
<nav class="site-nav" id="siteNav" aria-label="Main navigation">
310319
${navItems.map(item =>
311-
`<a href="${prefix}${item.href}" class="site-nav-link${item.id === activePage ? ' active' : ''}" onclick="passTheme(this)">${escapeHTML(item.label)}</a>`
320+
`<a href="${escapeHTML(prefix + safeInternalHref(item.href))}" class="site-nav-link${item.id === activePage ? ' active' : ''}" onclick="passTheme(this)">${escapeHTML(item.label)}</a>`
312321
).join('\n ')}
313322
</nav>
314323
<div class="header-actions">
@@ -790,7 +799,7 @@ function generateContainerDetail(config, container, data, configCSS) {
790799
</div>
791800
${cPrimaries.length ? `<h3>${config.entities?.primary?.plural || 'Primaries'} Covered</h3>
792801
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:1rem;">
793-
${cPrimaries.map(pId => { const p = primaries.find(pr => pr.id === pId); return `<a href="../../primary/${pId}/index.html" onclick="passTheme(this)" class="group-badge ${p?.group || ''}" style="text-decoration:none;">${escapeHTML(p?.name || humanizeId(pId))}</a>`; }).join(' ')}
802+
${cPrimaries.map(pId => { const p = primaries.find(pr => pr.id === pId); return `<a href="../../primary/${pId}/index.html" onclick="passTheme(this)" class="group-badge ${cssClassName(p?.group, 'other')}" style="text-decoration:none;">${escapeHTML(p?.name || humanizeId(pId))}</a>`; }).join(' ')}
794803
</div>` : ''}
795804
${timelineRows ? `<h3>Timeline</h3><table class="data-table"><thead><tr><th>Milestone</th><th>Date</th><th>Notes</th></tr></thead><tbody>${timelineRows}</tbody></table>` : ''}
796805
<h3>Provisions (${container.provisions.length})</h3>

scripts/eval.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
const assert = require('assert');
55
const fs = require('fs');
6+
const os = require('os');
67
const path = require('path');
78
const { spawnSync } = require('child_process');
89
const { parseTable } = require('./lib/data-loaders');
@@ -47,6 +48,33 @@ function assertIncludes(haystack, needle, message) {
4748
assert.ok(haystack.includes(needle), message || `Expected output to include "${needle}"`);
4849
}
4950

51+
function assertNoExecutableHtml(html, label) {
52+
assert.ok(!/<script>alert\(1\)<\/script>/i.test(html), `${label} should not contain raw script tags from source data.`);
53+
assert.ok(!/<img\s/i.test(html), `${label} should not contain raw image tags from source data.`);
54+
assert.ok(!/\s(?:onerror|onfocus|onmouseover)\s*=\s*["']/i.test(html), `${label} should not contain event-handler attributes from source data.`);
55+
assert.ok(!/<[^>]+\s(?:autofocus|formaction|srcdoc)(?:\s|=|>)/i.test(html), `${label} should not contain injected active attributes from source data.`);
56+
assert.ok(!/href="(?:javascript:|http:|\/\/)/i.test(html), `${label} should not contain unsafe href protocols.`);
57+
}
58+
59+
function copyRepoToTemp(prefix) {
60+
const tempRepo = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
61+
fs.cpSync(ROOT, tempRepo, {
62+
recursive: true,
63+
filter: source => {
64+
const rel = path.relative(ROOT, source);
65+
return rel !== '.git' && !rel.startsWith(`.git${path.sep}`) &&
66+
rel !== '.tmp-evals' && !rel.startsWith(`.tmp-evals${path.sep}`);
67+
}
68+
});
69+
return tempRepo;
70+
}
71+
72+
function replaceInFile(filePath, search, replacement) {
73+
const original = fs.readFileSync(filePath, 'utf8');
74+
assert.ok(original.includes(search), `Expected ${filePath} to include fixture text: ${search}`);
75+
fs.writeFileSync(filePath, original.replace(search, replacement));
76+
}
77+
5078
function test(name, fn) {
5179
try {
5280
fn();
@@ -342,9 +370,68 @@ function evalOutputSanitization() {
342370
assert.ok(compare.includes('encodeURIComponent(p)'), 'Compare page should encode dynamic href path segments.');
343371
assert.ok(compare.includes('var cmpData = '), 'Compare page should serialize comparison data.');
344372
assert.ok(container.includes('href="https://iso.org/standard/27001"'));
373+
374+
const maliciousRepo = copyRepoToTemp('kac-malicious-fixture-');
375+
try {
376+
const maliciousPrimary = path.join(maliciousRepo, 'data', 'examples', 'requirements', 'access-control.md');
377+
replaceInFile(maliciousPrimary, 'name: Access Control', 'name: Access <script>alert(1)</script> Control');
378+
replaceInFile(maliciousPrimary, 'group: governance', 'group: governance" autofocus onfocus="alert(1)');
379+
replaceInFile(maliciousPrimary, 'Requirement to restrict system', '<img src=x onerror=alert(1)> Requirement to restrict system');
380+
381+
const maliciousContainerSource = path.join(maliciousRepo, 'data', 'examples', 'frameworks', 'iso-27001.md');
382+
replaceInFile(maliciousContainerSource, 'status: active', 'status: active" onmouseover="alert(1)');
383+
replaceInFile(maliciousContainerSource, 'official_url: https://iso.org/standard/27001', 'official_url: javascript:alert(1)');
384+
replaceInFile(maliciousContainerSource, '[ISO 27001:2022](https://iso.org/standard/27001)', '[ISO 27001:2022](http://iso.org/standard/27001)');
385+
386+
const maliciousProject = path.join(maliciousRepo, 'project.yml');
387+
replaceInFile(maliciousProject, 'label: Home', 'label: Home <script>alert(1)</script>');
388+
replaceInFile(maliciousProject, 'href: index.html', 'href: index.html" onclick="alert(1)');
389+
390+
const maliciousBuild = spawnSync(process.execPath, ['scripts/build.js'], {
391+
cwd: maliciousRepo,
392+
encoding: 'utf8',
393+
env: { ...process.env, KAC_OUTPUT_DIR: 'poc-out' }
394+
});
395+
assert.strictEqual(maliciousBuild.status, 0, `Malicious fixture build failed:\n${maliciousBuild.stdout}\n${maliciousBuild.stderr}`);
396+
397+
const maliciousContainer = fs.readFileSync(path.join(maliciousRepo, 'poc-out', 'container', 'iso-27001', 'index.html'), 'utf8');
398+
const maliciousPrimaryHtml = fs.readFileSync(path.join(maliciousRepo, 'poc-out', 'primary', 'access-control', 'index.html'), 'utf8');
399+
const maliciousIndex = fs.readFileSync(path.join(maliciousRepo, 'poc-out', 'index.html'), 'utf8');
400+
const coverageBadge = maliciousContainer.match(/class="group-badge [^"]+"/);
401+
assert.ok(coverageBadge, 'Malicious group should still render as one quoted class attribute.');
402+
assert.ok(coverageBadge[0].includes('governance-autofocus-onfocus-alert-1'), 'Malicious group should be normalized to a safe CSS class token.');
403+
assert.ok(maliciousIndex.includes('href="index.html"'), 'Unsafe nav href should fall back to a safe internal path.');
404+
assertNoExecutableHtml(maliciousContainer, 'container detail page');
405+
assertNoExecutableHtml(maliciousPrimaryHtml, 'primary detail page');
406+
assertNoExecutableHtml(maliciousIndex, 'homepage');
407+
} finally {
408+
fs.rmSync(maliciousRepo, { recursive: true, force: true });
409+
}
345410
});
346411
}
347412

413+
function evalManifestFreshness() {
414+
const result = runCommand('./scripts/validate-hashes.sh', []);
415+
assert.strictEqual(result.status, 0, `Manifest hash verification failed:\n${result.stdout}\n${result.stderr}`);
416+
assertIncludes(result.stdout, 'All hashes verified.');
417+
}
418+
419+
function evalChangelogReleaseTags() {
420+
const changelog = fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8');
421+
const linkedVersions = [...changelog.matchAll(/^\[([0-9]+\.[0-9]+\.[0-9]+)\]:\s+https:\/\/github\.com\/snapsynapse\/knowledge-as-code-template\/releases\/tag\/v\1$/gm)]
422+
.map(match => `v${match[1]}`);
423+
assert.ok(linkedVersions.length > 0, 'Expected changelog to contain release tag links.');
424+
425+
const result = runCommand('git', ['tag', '--list']);
426+
assert.strictEqual(result.status, 0, `Could not list local tags:\n${result.stdout}\n${result.stderr}`);
427+
const localTags = result.stdout.split(/\s+/).filter(Boolean);
428+
if (localTags.length === 0) return;
429+
430+
for (const tag of linkedVersions) {
431+
assert.ok(localTags.includes(tag), `Changelog links ${tag}, but no matching local tag exists.`);
432+
}
433+
}
434+
348435
function evalHtmlSnapshots() {
349436
buildDefault();
350437
const snapshots = [
@@ -401,6 +488,8 @@ const evals = [
401488
['config override', evalConfigOverride],
402489
['parser fixtures', evalParserFixtures],
403490
['output sanitization', evalOutputSanitization],
491+
['manifest freshness', evalManifestFreshness],
492+
['changelog release tags', evalChangelogReleaseTags],
404493
['HTML snapshots', evalHtmlSnapshots],
405494
['MCP smoke', evalMcpSmoke],
406495
['docs consistency', evalDocsConsistency]

0 commit comments

Comments
 (0)