First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env*
|
||||
!.env.example
|
||||
.npmrc
|
||||
dist/
|
||||
dist-cjs/
|
||||
@@ -0,0 +1,6 @@
|
||||
stages: [test]
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: node:22-alpine
|
||||
script: [npm ci, npm run build, npm test]
|
||||
@@ -0,0 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-28
|
||||
|
||||
First public release under MIT.
|
||||
|
||||
- `renderControlDoc()` builds a seven-section NIST 800-53 control document
|
||||
in HTML and markdown from verification checks, with an Evidence section.
|
||||
- Checks without evidence are shown as NOT ATTACHED and counted.
|
||||
Unrecognised statuses are counted, never dropped.
|
||||
- Output is reproducible: the date is a parameter.
|
||||
- All interpolated values are HTML-escaped.
|
||||
- `toReport()` and `formatControl()` produce an `@extant2000/evidence-record`
|
||||
report of the same assessment.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Contributing
|
||||
|
||||
Issues and merge requests are welcome at
|
||||
https://gitlab.com/extant2000/cert-pack.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
|
||||
that fails without your fix, and check that it does fail before you submit.
|
||||
- Measure, do not assume. Two modules with the same line count can be
|
||||
different programs.
|
||||
- Keep dependencies minimal. Every new dependency needs a reason.
|
||||
- Explain why in comments, not what.
|
||||
|
||||
## Before you open a merge request
|
||||
|
||||
Run both of these and make sure they pass:
|
||||
|
||||
```
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Extant 2000 LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,102 @@
|
||||
# CertPack
|
||||
|
||||
Generates a NIST 800-53 control document (a System Security Plan section with
|
||||
a remediation plan) from automated verification checks, with the evidence for
|
||||
each check attached. It is used in Hitt Hosting products.
|
||||
|
||||
## What it does and why
|
||||
|
||||
An accreditation package that lists results without the scan output behind
|
||||
them is a set of claims. **CertPack attaches the evidence to each check and
|
||||
labels any check without evidence as a claim.** It does not drop the check or
|
||||
leave the cell blank, because a blank cell reads as "nothing to report" and a
|
||||
missing check is worse than a weak one.
|
||||
|
||||
You pass a control (document number, title, control family, the gap, the fix)
|
||||
and a list of checks, each with a status and optional evidence in the
|
||||
`@extant2000/evidence-record` format. `renderControlDoc()` returns HTML and
|
||||
markdown built from one list of seven sections, so the two never drift apart:
|
||||
|
||||
1. Document information
|
||||
2. Purpose and scope, including the gap and the deployments in scope
|
||||
3. Current implementation status: a table of checks with their evidence, a
|
||||
summary line, and a count of checks with no attached evidence
|
||||
4. Remediation plan with a target completion time (30 days by default)
|
||||
5. Evidence: the full citation for every check, or a statement that the
|
||||
result is a claim and not a verified finding
|
||||
6. References (NIST SP 800-53 Rev. 5, SP 800-53A and the FedRAMP SSP template
|
||||
by default)
|
||||
7. Approval and sign-off table
|
||||
|
||||
The rules that keep the document from claiming more than it knows:
|
||||
|
||||
- A status other than `pass`, `fail` or `warn` is counted as unrecognised and
|
||||
shown in the summary, never folded into another status or dropped.
|
||||
- A control with no checks says that an unassessed control is not an
|
||||
implemented control, instead of printing "0 failing".
|
||||
- Missing fields print "Not specified", never `undefined` or `null`.
|
||||
- The date is a parameter, not read from the clock, so the same input always
|
||||
produces byte-identical output. A document that changes when regenerated is
|
||||
not reproducible evidence.
|
||||
|
||||
Every value interpolated into the HTML goes through `escapeHtml()`, including
|
||||
titles, table cells and evidence, with no "trusted field" exception. The
|
||||
markdown output decodes entities because markdown is not an HTML context; if
|
||||
you inject that markdown into a page as HTML, escape it there.
|
||||
|
||||
`toReport()` and `formatControl()` give the same assessment as an
|
||||
evidence-record report, so a control's status can be rolled up without
|
||||
parsing HTML. `pass` passes, `fail` and `warn` fail, and an unrecognised
|
||||
status is not assessed.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { renderControlDoc, formatControl, type ControlDocInput } from '@extant2000/cert-pack'
|
||||
import { evidence } from '@extant2000/evidence-record'
|
||||
|
||||
const input: ControlDocInput = {
|
||||
docNumber: 'SSP-AC-01',
|
||||
title: 'Access Control',
|
||||
family: 'AC',
|
||||
familyName: 'Access Control',
|
||||
gap: 'Tenant isolation is not documented',
|
||||
fix: 'Document the row-level security policy',
|
||||
date: '2026-09-28',
|
||||
systemName: 'Example App',
|
||||
checks: [
|
||||
{ check: 'Row-level security on every tenant table', status: 'pass',
|
||||
evidence: [evidence.command('psql -c "select relname, relrowsecurity from pg_class ..."', 0, '42 of 42 tables')] },
|
||||
{ check: 'Least privilege enforced', status: 'pass' },
|
||||
{ check: 'MFA for admins', status: 'skipped' },
|
||||
],
|
||||
}
|
||||
|
||||
const { html, markdown, sections } = renderControlDoc(input)
|
||||
console.log(formatControl(input))
|
||||
```
|
||||
|
||||
In the generated document, section 3 ends with "2 passing, 0 failing, 0
|
||||
warnings, 1 unrecognised out of 3 checks" and "2 of 3 check(s) carry no
|
||||
attached evidence". The two checks without evidence show NOT ATTACHED.
|
||||
`formatControl()` prints:
|
||||
|
||||
```
|
||||
✗ HIGH MFA for admins [NOT ASSESSED]
|
||||
Status "skipped" is not recognised. An unrecognised status is not a passing one.
|
||||
◦ info Row-level security on every tenant table [MET]
|
||||
◦ info Least privilege enforced [MET]
|
||||
```
|
||||
|
||||
`controlDocSections()`, `renderDocument()`, `table()`, `stripTags()`,
|
||||
`escapeHtml()` and `tally()` are exported for building other documents.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
npm install @extant2000/cert-pack
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,6 @@
|
||||
# Security
|
||||
|
||||
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||
Do not open a public issue for a security problem.
|
||||
|
||||
We aim to acknowledge every report within 5 business days.
|
||||
Generated
+1504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@extant2000/cert-pack",
|
||||
"version": "0.2.0",
|
||||
"private": false,
|
||||
"description": "Builds certification evidence packages from scan results.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitlab.com/extant2000/cert-pack.git"
|
||||
},
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extant2000/evidence-record": "^0.1.2"
|
||||
},
|
||||
"author": "Extant 2000 LLC",
|
||||
"homepage": "https://gitlab.com/extant2000/cert-pack",
|
||||
"bugs": {
|
||||
"url": "https://gitlab.com/extant2000/cert-pack/-/issues"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { type Evidence, cite, citeVerbose } from '@extant2000/evidence-record'
|
||||
import {
|
||||
escapeHtml,
|
||||
renderDocument,
|
||||
table,
|
||||
type DocSection,
|
||||
type RenderedDocument,
|
||||
} from './html.js'
|
||||
|
||||
/** Outcome of a single automated verification check against a control. */
|
||||
export interface VerificationCheck {
|
||||
check: string
|
||||
/** 'pass' | 'fail' | 'warn' — anything else counts as unknown. */
|
||||
status: string
|
||||
/**
|
||||
* What produced this result: the scan output, the file, the row, the
|
||||
* command that was run.
|
||||
*
|
||||
* For an accreditation package this is the difference between a claim and
|
||||
* evidence, and it is exactly what SustainScore's rubric penalises under
|
||||
* "Unverified = absent". A check with no evidence still renders — it is
|
||||
* labelled as unevidenced rather than dropped, because a missing check is
|
||||
* worse than a weak one.
|
||||
*/
|
||||
evidence?: Evidence[]
|
||||
}
|
||||
|
||||
export interface VerificationTally {
|
||||
pass: number
|
||||
fail: number
|
||||
warn: number
|
||||
/** Checks whose status is none of the above. Counted, never ignored. */
|
||||
unknown: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally verification outcomes.
|
||||
*
|
||||
* `unknown` is tracked explicitly rather than folded into `warn` or dropped: a
|
||||
* status the tool does not recognise is not a passing status, and an
|
||||
* accreditation package that quietly loses checks is worse than one that says
|
||||
* it does not know.
|
||||
*/
|
||||
export function tally(checks: VerificationCheck[]): VerificationTally {
|
||||
const t: VerificationTally = { pass: 0, fail: 0, warn: 0, unknown: 0, total: checks.length }
|
||||
for (const c of checks) {
|
||||
switch (c.status) {
|
||||
case 'pass': t.pass++; break
|
||||
case 'fail': t.fail++; break
|
||||
case 'warn': t.warn++; break
|
||||
default: t.unknown++
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
export interface ControlDocInput {
|
||||
/** Document identifier, e.g. "SSP-AC-01". */
|
||||
docNumber: string
|
||||
title: string
|
||||
/** Control family short code, e.g. "AC". */
|
||||
family?: string | null
|
||||
/** Control family long name, e.g. "Access Control". */
|
||||
familyName?: string | null
|
||||
/** The gap this document exists to address. */
|
||||
gap?: string | null
|
||||
/** The remediation. */
|
||||
fix?: string | null
|
||||
checks?: VerificationCheck[]
|
||||
/** ISO date. Injected rather than read from the clock, so output is testable. */
|
||||
date: string
|
||||
classification?: string
|
||||
status?: string
|
||||
version?: string
|
||||
/** Deployment configurations in scope. */
|
||||
deployments?: string[]
|
||||
/** Reference list. Sensible NIST/FedRAMP defaults if omitted. */
|
||||
references?: string[]
|
||||
/** Sign-off roles. */
|
||||
approvers?: string[]
|
||||
/** Product/system name used in prose. */
|
||||
systemName?: string
|
||||
/** Days allowed for remediation, used in the timeline sentence. */
|
||||
remediationDays?: number
|
||||
}
|
||||
|
||||
const DEFAULT_REFERENCES = [
|
||||
'NIST Special Publication 800-53 Rev. 5 — Security and Privacy Controls for Information Systems',
|
||||
'NIST SP 800-53A — Assessing Security and Privacy Controls',
|
||||
'FedRAMP System Security Plan (SSP) Template',
|
||||
]
|
||||
|
||||
const DEFAULT_APPROVERS = [
|
||||
'Document Author',
|
||||
'Security Reviewer',
|
||||
'System Owner',
|
||||
'Authorizing Official',
|
||||
]
|
||||
|
||||
/**
|
||||
* Build the section list for a NIST 800-53 control document (SSP body with an
|
||||
* embedded POA&M-style remediation plan).
|
||||
*
|
||||
* Pure: `date` is a parameter, not `new Date()`. A document generator that
|
||||
* reads the clock cannot be tested for exact output, and an accreditation
|
||||
* artifact that changes when regenerated is not reproducible evidence.
|
||||
*/
|
||||
export function controlDocSections(input: ControlDocInput): DocSection[] {
|
||||
const family = input.family || 'Unknown'
|
||||
const familyName = input.familyName || 'Unknown'
|
||||
const gap = input.gap || 'Not specified'
|
||||
const fix = input.fix || 'Not specified'
|
||||
const checks = input.checks ?? []
|
||||
const t = tally(checks)
|
||||
const system = input.systemName || 'the system'
|
||||
const days = input.remediationDays ?? 30
|
||||
|
||||
const deployments = input.deployments ?? []
|
||||
const deploymentList = deployments.length
|
||||
? `<p>This document applies to all ${escapeHtml(system)} deployment configurations including:</p><ul>${
|
||||
deployments.map(d => `<li>${escapeHtml(d)}</li>`).join('')}</ul>`
|
||||
: ''
|
||||
|
||||
const unevidenced = checks.filter(c => (c.evidence?.length ?? 0) === 0).length
|
||||
|
||||
const summary = t.unknown > 0
|
||||
? `${t.pass} passing, ${t.fail} failing, ${t.warn} warnings, ${t.unknown} unrecognised out of ${t.total} checks.`
|
||||
: `${t.pass} passing, ${t.fail} failing, ${t.warn} warnings out of ${t.total} checks.`
|
||||
|
||||
return [
|
||||
{
|
||||
title: '1. Document Information',
|
||||
content: [
|
||||
`<p><strong>Document Number:</strong> ${escapeHtml(input.docNumber)}</p>`,
|
||||
`<p><strong>Title:</strong> ${escapeHtml(input.title)}</p>`,
|
||||
`<p><strong>NIST 800-53 Control Family:</strong> ${escapeHtml(family)} — ${escapeHtml(familyName)}</p>`,
|
||||
`<p><strong>Classification:</strong> ${escapeHtml(input.classification || 'Unclassified')}</p>`,
|
||||
`<p><strong>Status:</strong> ${escapeHtml(input.status || 'Draft')}</p>`,
|
||||
`<p><strong>Generated:</strong> ${escapeHtml(input.date)}</p>`,
|
||||
`<p><strong>Version:</strong> ${escapeHtml(input.version || '1.0')}</p>`,
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
title: '2. Purpose and Scope',
|
||||
content: [
|
||||
`<p>This document addresses a compliance gap identified during the NIST 800-53 control assessment for ${escapeHtml(system)}. `,
|
||||
`The ${escapeHtml(family)} (${escapeHtml(familyName)}) control family requires specific documentation to demonstrate implementation of security controls.</p>`,
|
||||
`<h3>Gap Identified</h3><p>${escapeHtml(gap)}</p>`,
|
||||
deploymentList ? `<h3>Scope</h3>${deploymentList}` : '',
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
title: '3. Current Implementation Status',
|
||||
content: [
|
||||
`<p>The following verification checks were evaluated for the ${escapeHtml(family)} control family:</p>`,
|
||||
checks.length
|
||||
? table(
|
||||
['Check', 'Status', 'Evidence'],
|
||||
checks.map(c => [
|
||||
c.check || 'Unknown check',
|
||||
(c.status || 'unknown').toUpperCase(),
|
||||
// Unevidenced is stated, not left blank. A blank cell in an
|
||||
// accreditation package reads as "nothing to report"; this
|
||||
// reads as what it is.
|
||||
(c.evidence?.length ?? 0) === 0
|
||||
? 'NOT ATTACHED'
|
||||
: c.evidence!.map(e => cite(e)).join('; '),
|
||||
]),
|
||||
)
|
||||
: '<p><em>No automated verification checks have been run for this control family. '
|
||||
+ 'An unassessed control is not an implemented control.</em></p>',
|
||||
`<p><strong>Summary:</strong> ${escapeHtml(summary)}</p>`,
|
||||
unevidenced > 0
|
||||
? `<p><strong>${unevidenced} of ${t.total} check(s) carry no attached evidence.</strong> `
|
||||
+ 'An asserted result is not a verified one; these are recorded as claims pending evidence.</p>'
|
||||
: '',
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
title: '4. Remediation Plan',
|
||||
content: [
|
||||
`<h3>Required Action</h3><p>${escapeHtml(fix)}</p>`,
|
||||
'<h3>Implementation Steps</h3><ol>',
|
||||
'<li>Review the current implementation status above and identify all failing controls</li>',
|
||||
'<li>Implement the required action described above</li>',
|
||||
'<li>Verify the implementation by re-running the verification checks</li>',
|
||||
'<li>Update this document with the verification results</li>',
|
||||
'<li>Submit for security review and approval</li>',
|
||||
'</ol>',
|
||||
`<h3>Timeline</h3><p>This remediation should be completed prior to authorization submission. `,
|
||||
`Target completion: within ${escapeHtml(days)} days of document generation.</p>`,
|
||||
].join(''),
|
||||
},
|
||||
{
|
||||
title: '5. Evidence',
|
||||
content: checks.length
|
||||
? checks.map(c => {
|
||||
const head = `<h3>${escapeHtml(c.check || 'Unknown check')} — ${escapeHtml((c.status || 'unknown').toUpperCase())}</h3>`
|
||||
if ((c.evidence?.length ?? 0) === 0) {
|
||||
return head + '<p><em>No evidence attached. This result is a claim, not a verified finding.</em></p>'
|
||||
}
|
||||
return head + `<ul>${c.evidence!.map(e =>
|
||||
`<li>${citeVerbose(e).map(l => escapeHtml(l.trim())).join('<br>')}</li>`).join('')}</ul>`
|
||||
}).join('')
|
||||
: '<p><em>No checks were run, so no evidence exists to attach.</em></p>',
|
||||
},
|
||||
{
|
||||
title: '6. References',
|
||||
content: `<ul>${(input.references ?? DEFAULT_REFERENCES).map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>`,
|
||||
},
|
||||
{
|
||||
title: '7. Approval and Sign-Off',
|
||||
content: [
|
||||
table(
|
||||
['Role', 'Name', 'Signature', 'Date'],
|
||||
(input.approvers ?? DEFAULT_APPROVERS).map(role => [role, '', '', '']),
|
||||
),
|
||||
`<p><em>This document was auto-generated on ${escapeHtml(input.date)}. `,
|
||||
'It should be reviewed, completed, and approved by qualified security personnel before submission.</em></p>',
|
||||
].join(''),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Render a control document to HTML + markdown. */
|
||||
export function renderControlDoc(input: ControlDocInput): RenderedDocument {
|
||||
return renderDocument(controlDocSections(input))
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* HTML/markdown primitives for compliance document rendering.
|
||||
*
|
||||
* These are separated out because escaping is the part that must be right
|
||||
* everywhere and is easiest to forget in one template out of six. Any renderer
|
||||
* that interpolates caller-controlled values into markup uses `escapeHtml`;
|
||||
* there is no "this field is trusted" exception.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape HTML entities in caller-controllable strings interpolated into
|
||||
* document templates. Prevents stored-XSS when data reaches control metadata,
|
||||
* finding descriptions, or evidence references.
|
||||
*/
|
||||
export function escapeHtml(input: unknown): string {
|
||||
const str = input == null ? '' : String(input)
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip tags and collapse whitespace to produce a markdown body.
|
||||
*
|
||||
* NOTE the entity decoding at the end: it deliberately UNDOES `escapeHtml`,
|
||||
* because markdown is not an HTML context and a reader should see `<` rather
|
||||
* than `<`. That is only safe while the consumer re-escapes when it renders
|
||||
* markdown to HTML. If you add a consumer that injects this string into the
|
||||
* DOM directly, escape it there — the safety lives at the render boundary, not
|
||||
* in this function.
|
||||
*/
|
||||
export function stripTags(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|li|tr|div|h[1-6])>/gi, '\n')
|
||||
.replace(/<li>/gi, '- ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
}
|
||||
|
||||
export interface DocSection {
|
||||
title: string
|
||||
/** HTML fragment. Interpolated values MUST already be escaped. */
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
html: string
|
||||
markdown: string
|
||||
sections: DocSection[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render sections into both an HTML document and a markdown equivalent.
|
||||
*
|
||||
* Both are returned so a consumer can pick: a rich renderer takes `html`, a
|
||||
* plain one takes `markdown`. Producing them from one section list is what
|
||||
* keeps the two from drifting into different documents.
|
||||
*/
|
||||
export function renderDocument(sections: DocSection[]): RenderedDocument {
|
||||
const html = sections
|
||||
.map(s => `<section><h2>${escapeHtml(s.title)}</h2>${s.content}</section>`)
|
||||
.join('\n')
|
||||
|
||||
const markdown = sections
|
||||
.map(s => `## ${s.title}\n\n${stripTags(s.content).trim()}\n`)
|
||||
.join('\n')
|
||||
|
||||
return { html, markdown, sections }
|
||||
}
|
||||
|
||||
/** Build an escaped HTML table. Cells are escaped; headers are escaped. */
|
||||
export function table(headers: string[], rows: unknown[][]): string {
|
||||
const head = headers.map(h => `<th>${escapeHtml(h)}</th>`).join('')
|
||||
const body = rows
|
||||
.map(r => `<tr>${r.map(c => `<td>${escapeHtml(c)}</td>`).join('')}</tr>`)
|
||||
.join('')
|
||||
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './html.js'
|
||||
export * from './control-doc.js'
|
||||
export { toReport, formatControl } from './report.js'
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
type CapabilityReport,
|
||||
type Determination,
|
||||
type FormatOptions,
|
||||
formatReport as renderReport,
|
||||
} from '@extant2000/evidence-record'
|
||||
import type { ControlDocInput, VerificationCheck } from './control-doc.js'
|
||||
|
||||
function determinationFor(status: string): Determination {
|
||||
switch (status) {
|
||||
case 'pass': return 'pass'
|
||||
case 'fail': return 'fail'
|
||||
case 'warn': return 'fail'
|
||||
// An unrecognised status is not a passing status. Folding it into `warn`
|
||||
// or dropping it is how an accreditation package quietly loses a check.
|
||||
default: return 'not-assessed'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the control assessment behind the document.
|
||||
*
|
||||
* The document is the deliverable; this is the machine-readable view of the
|
||||
* same facts, so a control's status can be rolled up across a portfolio
|
||||
* without parsing HTML.
|
||||
*/
|
||||
export function toReport(input: ControlDocInput): CapabilityReport {
|
||||
const checks: VerificationCheck[] = input.checks ?? []
|
||||
|
||||
return {
|
||||
capability: 'CertPack',
|
||||
scope: `${input.docNumber} — ${input.family ?? 'Unknown'} control family`,
|
||||
examined: checks.length,
|
||||
findings: checks.map((c, i) => ({
|
||||
id: `check-${i}`,
|
||||
summary: c.check || 'Unknown check',
|
||||
determination: determinationFor(c.status),
|
||||
severity: c.status === 'fail' ? 'critical' : c.status === 'warn' ? 'high' : c.status === 'pass' ? 'info' : 'high',
|
||||
detail: determinationFor(c.status) === 'not-assessed' && c.status !== 'pass' && c.status !== 'fail' && c.status !== 'warn'
|
||||
? `Status "${c.status}" is not recognised. An unrecognised status is not a passing one.`
|
||||
: undefined,
|
||||
evidence: c.evidence ?? [],
|
||||
})),
|
||||
notes: checks.length === 0
|
||||
? ['No automated verification checks have been run for this control family. An unassessed control is not an implemented control.']
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the control assessment for a terminal or CI log. */
|
||||
export function formatControl(input: ControlDocInput, opts: FormatOptions = {}): string {
|
||||
return renderReport(toReport(input), opts)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { evidence, overall, validateReport } from '@extant2000/evidence-record'
|
||||
import {
|
||||
controlDocSections,
|
||||
toReport,
|
||||
formatControl,
|
||||
escapeHtml,
|
||||
renderControlDoc,
|
||||
renderDocument,
|
||||
stripTags,
|
||||
table,
|
||||
tally,
|
||||
} from '../src/index.js'
|
||||
|
||||
const base = { docNumber: 'SSP-AC-01', title: 'Access Control', date: '2026-07-28' }
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes every dangerous character', () => {
|
||||
expect(escapeHtml(`<script>"x"&'y'`))
|
||||
.toBe('<script>"x"&'y'')
|
||||
})
|
||||
|
||||
it('escapes ampersands first so entities are not double-broken', () => {
|
||||
expect(escapeHtml('<')).toBe('&lt;')
|
||||
})
|
||||
|
||||
it('renders null and undefined as empty, not "null"', () => {
|
||||
expect(escapeHtml(null)).toBe('')
|
||||
expect(escapeHtml(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('coerces non-strings', () => {
|
||||
expect(escapeHtml(42)).toBe('42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('table', () => {
|
||||
it('escapes both headers and cells', () => {
|
||||
const html = table(['<h>'], [['<script>alert(1)</script>']])
|
||||
expect(html).toContain('<h>')
|
||||
expect(html).toContain('<script>')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDocument', () => {
|
||||
it('escapes section titles', () => {
|
||||
const { html } = renderDocument([{ title: '<img onerror=x>', content: '<p>ok</p>' }])
|
||||
expect(html).toContain('<img onerror=x>')
|
||||
expect(html).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('produces html and markdown from one section list', () => {
|
||||
const r = renderDocument([{ title: 'One', content: '<p>Body text</p>' }])
|
||||
expect(r.html).toContain('<section><h2>One</h2>')
|
||||
expect(r.markdown).toContain('## One')
|
||||
expect(r.markdown).toContain('Body text')
|
||||
expect(r.sections).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripTags', () => {
|
||||
it('decodes entities — markdown is not an HTML context', () => {
|
||||
// Documented and deliberate: it undoes escapeHtml so a reader sees `<`.
|
||||
// Safe only because the consumer re-escapes at the render boundary.
|
||||
expect(stripTags('<p><tag></p>').trim()).toBe('<tag>')
|
||||
})
|
||||
|
||||
it('turns list items into markdown bullets', () => {
|
||||
expect(stripTags('<ul><li>a</li><li>b</li></ul>')).toContain('- a')
|
||||
})
|
||||
|
||||
it('collapses runs of blank lines', () => {
|
||||
expect(stripTags('<p>a</p><p></p><p></p><p></p><p>b</p>')).not.toMatch(/\n{3,}/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tally', () => {
|
||||
it('counts each status', () => {
|
||||
expect(tally([
|
||||
{ check: 'a', status: 'pass' },
|
||||
{ check: 'b', status: 'fail' },
|
||||
{ check: 'c', status: 'warn' },
|
||||
])).toEqual({ pass: 1, fail: 1, warn: 1, unknown: 0, total: 3 })
|
||||
})
|
||||
|
||||
it('counts an unrecognised status as unknown rather than dropping it', () => {
|
||||
// A status the tool does not recognise is not a passing status, and an
|
||||
// accreditation package must not quietly lose checks.
|
||||
const t = tally([{ check: 'a', status: 'skipped' }, { check: 'b', status: 'pass' }])
|
||||
expect(t.unknown).toBe(1)
|
||||
expect(t.total).toBe(2)
|
||||
expect(t.pass).toBe(1)
|
||||
})
|
||||
|
||||
it('handles an empty set', () => {
|
||||
expect(tally([])).toEqual({ pass: 0, fail: 0, warn: 0, unknown: 0, total: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('controlDocSections', () => {
|
||||
it('emits the seven standard sections in order', () => {
|
||||
const titles = controlDocSections(base).map(s => s.title)
|
||||
expect(titles).toEqual([
|
||||
'1. Document Information',
|
||||
'2. Purpose and Scope',
|
||||
'3. Current Implementation Status',
|
||||
'4. Remediation Plan',
|
||||
'5. Evidence',
|
||||
'6. References',
|
||||
'7. Approval and Sign-Off',
|
||||
])
|
||||
})
|
||||
|
||||
it('escapes hostile input in every field it interpolates', () => {
|
||||
const { html } = renderControlDoc({
|
||||
...base,
|
||||
title: '<script>alert(1)</script>',
|
||||
family: '<img src=x onerror=1>',
|
||||
gap: '"><script>bad()</script>',
|
||||
fix: `'; DROP TABLE--`,
|
||||
checks: [{ check: '<b>evil</b>', status: '<i>pass</i>' }],
|
||||
})
|
||||
expect(html).not.toContain('<script>')
|
||||
expect(html).not.toContain('<img src=x')
|
||||
expect(html).not.toContain('<b>evil</b>')
|
||||
expect(html).toContain('<script>')
|
||||
})
|
||||
|
||||
it('does NOT report an unassessed control as clean', () => {
|
||||
// With no checks, "0 failing" would read as a pass. Say so explicitly.
|
||||
const s = controlDocSections({ ...base, checks: [] })
|
||||
const status = s.find(x => x.title.startsWith('3.'))!
|
||||
expect(status.content).toContain('An unassessed control is not an implemented control')
|
||||
})
|
||||
|
||||
it('surfaces unrecognised statuses in the summary line', () => {
|
||||
const s = controlDocSections({
|
||||
...base,
|
||||
checks: [{ check: 'a', status: 'weird' }, { check: 'b', status: 'pass' }],
|
||||
})
|
||||
expect(s.find(x => x.title.startsWith('3.'))!.content).toContain('1 unrecognised')
|
||||
})
|
||||
|
||||
it('omits the unrecognised clause when there are none', () => {
|
||||
const s = controlDocSections({ ...base, checks: [{ check: 'a', status: 'pass' }] })
|
||||
expect(s.find(x => x.title.startsWith('3.'))!.content).not.toContain('unrecognised')
|
||||
})
|
||||
|
||||
it('is reproducible — same input, byte-identical output', () => {
|
||||
// The date is a parameter, not new Date(). An accreditation artifact that
|
||||
// changes when regenerated is not reproducible evidence.
|
||||
const a = renderControlDoc({ ...base, gap: 'g', fix: 'f' })
|
||||
const b = renderControlDoc({ ...base, gap: 'g', fix: 'f' })
|
||||
expect(a.html).toBe(b.html)
|
||||
expect(a.markdown).toBe(b.markdown)
|
||||
})
|
||||
|
||||
it('falls back to "Not specified" rather than printing undefined', () => {
|
||||
const { markdown } = renderControlDoc(base)
|
||||
expect(markdown).toContain('Not specified')
|
||||
expect(markdown).not.toContain('undefined')
|
||||
expect(markdown).not.toContain('null')
|
||||
})
|
||||
|
||||
it('accepts custom references, approvers, deployments and timeline', () => {
|
||||
const { html } = renderControlDoc({
|
||||
...base,
|
||||
references: ['ISO 27001'],
|
||||
approvers: ['CISO'],
|
||||
deployments: ['Air-gapped'],
|
||||
remediationDays: 14,
|
||||
systemName: 'Extant 2000',
|
||||
})
|
||||
expect(html).toContain('ISO 27001')
|
||||
expect(html).toContain('CISO')
|
||||
expect(html).toContain('Air-gapped')
|
||||
expect(html).toContain('within 14 days')
|
||||
expect(html).toContain('Extant 2000')
|
||||
})
|
||||
|
||||
it('omits the scope list entirely when no deployments are given', () => {
|
||||
expect(renderControlDoc(base).html).not.toContain('<h3>Scope</h3>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('evidence attached, not asserted', () => {
|
||||
const base = { docNumber: 'SSP-AC-01', title: 'Access Control', family: 'AC', date: '2026-08-01' }
|
||||
|
||||
it('renders the evidence behind each check, not just its status', () => {
|
||||
// The gap this closes: CertPack rendered a document asserting results
|
||||
// without attaching the scan output that produced them.
|
||||
const doc = renderControlDoc({
|
||||
...base,
|
||||
checks: [{
|
||||
check: 'RLS enabled on every tenant table',
|
||||
status: 'pass',
|
||||
evidence: [evidence.command('psql -c "select relrowsecurity..."', 0, '42 of 42 tables')],
|
||||
}],
|
||||
})
|
||||
expect(doc.html).toContain('42 of 42 tables')
|
||||
expect(doc.html).toContain('5. Evidence')
|
||||
})
|
||||
|
||||
it('labels an unevidenced check as a claim rather than leaving it blank', () => {
|
||||
// A blank cell in an accreditation package reads as "nothing to report".
|
||||
const doc = renderControlDoc({
|
||||
...base,
|
||||
checks: [{ check: 'Least privilege enforced', status: 'pass' }],
|
||||
})
|
||||
expect(doc.html).toContain('NOT ATTACHED')
|
||||
expect(doc.html).toContain('carry no attached evidence')
|
||||
expect(doc.html).toContain('This result is a claim, not a verified finding')
|
||||
})
|
||||
|
||||
it('says so when there are no checks at all', () => {
|
||||
const doc = renderControlDoc(base)
|
||||
expect(doc.html).toContain('An unassessed control is not an implemented control')
|
||||
expect(doc.html).toContain('no evidence exists to attach')
|
||||
})
|
||||
|
||||
it('an unrecognised status is not-assessed, never a pass', () => {
|
||||
const r = toReport({ ...base, checks: [{ check: 'x', status: 'skipped' }] })
|
||||
expect(r.findings[0]!.determination).toBe('not-assessed')
|
||||
expect(overall(r)).toBe('not-assessed')
|
||||
})
|
||||
|
||||
it('a zero-check control reports NOT ASSESSED', () => {
|
||||
expect(overall(toReport(base))).toBe('not-assessed')
|
||||
expect(formatControl(base)).toContain('NOT ASSESSED')
|
||||
})
|
||||
|
||||
it('every conclusion carries a citation', () => {
|
||||
const r = toReport({
|
||||
...base,
|
||||
checks: [{ check: 'RLS enabled', status: 'pass', evidence: [evidence.command('psql', 0)] }],
|
||||
})
|
||||
expect(validateReport(r)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||
"strict": true, "skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user