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