|
3 | 3 |
|
4 | 4 | const assert = require('assert'); |
5 | 5 | const fs = require('fs'); |
| 6 | +const os = require('os'); |
6 | 7 | const path = require('path'); |
7 | 8 | const { spawnSync } = require('child_process'); |
8 | 9 | const { parseTable } = require('./lib/data-loaders'); |
@@ -47,6 +48,33 @@ function assertIncludes(haystack, needle, message) { |
47 | 48 | assert.ok(haystack.includes(needle), message || `Expected output to include "${needle}"`); |
48 | 49 | } |
49 | 50 |
|
| 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 | + |
50 | 78 | function test(name, fn) { |
51 | 79 | try { |
52 | 80 | fn(); |
@@ -342,9 +370,68 @@ function evalOutputSanitization() { |
342 | 370 | assert.ok(compare.includes('encodeURIComponent(p)'), 'Compare page should encode dynamic href path segments.'); |
343 | 371 | assert.ok(compare.includes('var cmpData = '), 'Compare page should serialize comparison data.'); |
344 | 372 | 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 | + } |
345 | 410 | }); |
346 | 411 | } |
347 | 412 |
|
| 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 | + |
348 | 435 | function evalHtmlSnapshots() { |
349 | 436 | buildDefault(); |
350 | 437 | const snapshots = [ |
@@ -401,6 +488,8 @@ const evals = [ |
401 | 488 | ['config override', evalConfigOverride], |
402 | 489 | ['parser fixtures', evalParserFixtures], |
403 | 490 | ['output sanitization', evalOutputSanitization], |
| 491 | + ['manifest freshness', evalManifestFreshness], |
| 492 | + ['changelog release tags', evalChangelogReleaseTags], |
404 | 493 | ['HTML snapshots', evalHtmlSnapshots], |
405 | 494 | ['MCP smoke', evalMcpSmoke], |
406 | 495 | ['docs consistency', evalDocsConsistency] |
|
0 commit comments