#!/usr/bin/env node /** * TIR-CMM reference scoring server. * * A minimal, dependency-free HTTP wrapper around the TIR-CMM library, provided * so that platforms which need REST can have it WITHOUT assessment data leaving * the organisation that owns it. * * There is deliberately no hosted TIR-CMM API. The assessment tool's strongest * property is that nothing you type ever leaves your machine, and that property * is only credible if there is no server to send it to. Run this one yourself. * * node server/server.mjs [--port 8787] * * Endpoints * GET /health liveness * GET /model the complete machine-readable model * GET /model/domains domains and sub-capabilities * GET /model/constraints the integrity constraints * GET /model/tiers the three assessment tiers * GET /model/scenarios the starter scenarios * POST /score score an assessment, return the full result * POST /report score, and return the narrative report + blueprint * * Licence: source-available, all rights reserved. Free to run and self-host. */ import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const HERE = dirname(fileURLToPath(import.meta.url)); const lib = await import(join(HERE, '..', 'dist', 'tir-cmm.mjs')); const argPort = process.argv.indexOf('--port'); const PORT = argPort > -1 ? Number(process.argv[argPort + 1]) : Number(process.env.PORT || 8787); const MAX_BODY = 2 * 1024 * 1024; const json = (res, code, obj) => { const body = JSON.stringify(obj, null, 2); res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body), 'cache-control': 'no-store', 'x-tir-cmm-version': lib.MODEL_VERSION, }); res.end(body); }; const readBody = (req) => new Promise((resolve, reject) => { let n = 0; const chunks = []; req.on('data', c => { n += c.length; if (n > MAX_BODY) { reject(new Error('body too large')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => { if (!chunks.length) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (e) { reject(new Error('invalid JSON: ' + e.message)); } }); req.on('error', reject); }); /** Shared scoring path for /score and /report. */ function runAssessment(input) { const { scores = {}, lattice = {}, tempo = null, telemetry = {}, scenarios = {}, governance = {}, prereq = {}, detectionScore = null, tier = 'baseline', } = input; const detection = lib.resolveDetection({ imported: detectionScore, prereq }); const t = lib.tierById(tier); const gov = lib.governanceScore(governance); const ceilings = []; if (t.ceiling) ceilings.push({ id: 'R7', bandId: t.ceiling, reason: `${t.name} tier.` }); if (t.id === 'assurance' && gov.ceiling) ceilings.push({ id: 'R7', bandId: gov.ceiling, reason: gov.note }); const r = lib.score(scores, { detection, lattice, tempo, ceilings, skipEvidenceCap: t.id === 'pulse', }); r.readiness = lib.readiness(r.effSubs, r.domains.RV); r.tier = t; r.governance = gov; r.scenarios = lib.scenarioScore(scenarios); r.telemetry = lib.telemetryScore(telemetry, lib.ASSETS.filter(a => telemetry[a.id])); return r; } const shape = (r) => ({ schema: 'tir-cmm/export/0.2', model_version: lib.MODEL_VERSION, tier: r.tier.id, overall: +r.overall.toFixed(2), self_assessed: +r.selfOverall.toFixed(2), band: r.band.id, band_name: r.band.name, band_capped_by: r.caps.map(c => c.id), domains: Object.fromEntries(lib.DOMAINS.map(d => [d.id, +r.domains[d.id].toFixed(2)])), domains_self_assessed: Object.fromEntries(lib.DOMAINS.map(d => [d.id, +r.rawDomains[d.id].toFixed(2)])), detection_score: r.detectionScore, detection_source: r.detection.source, readiness: Object.fromEntries(r.readiness.map(l => [l.id, { proven: +l.proven.toFixed(2), claimed: +l.claimed.toFixed(2), verdict: l.verdict.label }])), vrs: +r.lattice.vrs.toFixed(4), vrs_cj: +r.lattice.vrsCj.toFixed(4), engineered_rate: +r.lattice.engineered.toFixed(4), proven_rate: +r.lattice.proven.toFixed(4), cells_in_scope: r.lattice.n, blind_cells_t1t2: r.lattice.blind.map(c => ({ stage: c.stage, asset_class: c.asset, tier: c.tier === 3 ? 'T1' : 'T2' })), tempo: r.tempo ? [{ actor: r.tempo.actor, breakout_min: r.tempo.breakout, mttd_min: r.tempo.mttd, mttdecide_min: r.tempo.mttdecide, mttc_min: r.tempo.mttc, containment_margin_min: r.tempo.margin, tempo_ratio: +r.tempo.ratio.toFixed(2) }] : [], constraint_adjustments: r.adjustments.filter(a => a.marginal && a.marginal.length) .map(a => ({ constraint: a.id, domains: a.marginal.map(m => m.d) })) .concat(r.r3Hits.length ? [{ constraint: 'R3', sub_capabilities: r.r3Hits }] : []), scenarios: { in_scope: r.scenarios.n, exercised: r.scenarios.exercised, passed: r.scenarios.passed, run_but_untimed: r.scenarios.unmeasured }, }); const ROUTES = { 'GET /health': () => ({ ok: true, model: 'TIR-CMM', version: lib.MODEL_VERSION }), 'GET /model': () => JSON.parse(readFileSync(join(HERE, '..', 'data', 'tir-cmm-model.json'), 'utf8')), 'GET /model/domains': () => lib.DOMAINS, 'GET /model/constraints': () => lib.CONSTRAINTS, 'GET /model/tiers': () => lib.TIERS, 'GET /model/scenarios': () => ({ scenarios: lib.SCENARIOS, stages: lib.SCENARIO_STAGES, pass_conditions: lib.PASS_CONDITIONS }), }; createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const key = `${req.method} ${url.pathname.replace(/\/$/, '') || '/'}`; if (req.method === 'OPTIONS') { res.writeHead(204).end(); return; } try { if (ROUTES[key]) return json(res, 200, ROUTES[key]()); if (key === 'POST /score') { const r = runAssessment(await readBody(req)); return json(res, 200, shape(r)); } if (key === 'POST /report') { const input = await readBody(req); const r = runAssessment(input); return json(res, 200, { ...shape(r), headline: lib.headline(r), state_report: lib.stateReport(r, { org: input.organisation }), findings: lib.weaknesses(r).map(w => ({ kind: w.kind, id: w.id, severity: w.severity, title: w.title, detail: w.body, what_to_do: w.fix })), blueprint: lib.blueprint(r, 99).map(b => ({ bucket: b.id, name: b.name, window: b.window, count: b.total, items: b.items.map(i => ({ id: i.id, owner: i.owner, type: i.type, action: i.act, outcome: i.win })) })), }); } json(res, 404, { error: 'not found', routes: [...Object.keys(ROUTES), 'POST /score', 'POST /report'] }); } catch (e) { json(res, 400, { error: e.message }); } }).listen(PORT, () => { console.log(`TIR-CMM reference server v${lib.MODEL_VERSION} on http://localhost:${PORT}`); console.log('Assessment data stays in this process. Nothing is sent anywhere.'); });