@calltelemetry/ct-lab-mcp 0.8.106 → 0.8.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/stig/SHA256SUMS +14 -1
- package/assets/stig/guest/collect-fips-state.sh +6 -0
- package/assets/stig/guest/collect-hardware.sh +3 -0
- package/assets/stig/guest/collect-host-checks.sh +3 -0
- package/assets/stig/guest/collect-network-services.sh +15 -0
- package/assets/stig/guest/collect-repolist.sh +3 -0
- package/assets/stig/guest/collect-rpm-inventory.sh +3 -0
- package/assets/stig/guest/collect-rpm-nevra.sh +3 -0
- package/assets/stig/guest/collect-rpmlist.sh +3 -0
- package/assets/stig/guest/collect-updateinfo-all.sh +3 -0
- package/assets/stig/guest/collect-updateinfo-security.sh +3 -0
- package/assets/stig/guest/collect-updateinfo-summary.sh +3 -0
- package/assets/stig/guest/oval-eval.sh +11 -0
- package/assets/stig/guest/oval-prep.sh +4 -0
- package/assets/stig/guest/verify-root.sh +13 -0
- package/dist/src/generated-version.d.ts +1 -1
- package/dist/src/generated-version.js +1 -1
- package/dist/src/stig/distribution.d.ts +16 -1
- package/dist/src/stig/distribution.d.ts.map +1 -1
- package/dist/src/stig/distribution.js +64 -1
- package/dist/src/stig/distribution.js.map +1 -1
- package/dist/src/stig/evidence.d.ts +135 -0
- package/dist/src/stig/evidence.d.ts.map +1 -0
- package/dist/src/stig/evidence.js +637 -0
- package/dist/src/stig/evidence.js.map +1 -0
- package/dist/src/stig/live.d.ts +12 -1
- package/dist/src/stig/live.d.ts.map +1 -1
- package/dist/src/stig/live.js +39 -1
- package/dist/src/stig/live.js.map +1 -1
- package/dist/src/stig/markers.d.ts +6 -0
- package/dist/src/stig/markers.d.ts.map +1 -0
- package/dist/src/stig/markers.js +23 -0
- package/dist/src/stig/markers.js.map +1 -0
- package/dist/src/stig/ports.d.ts +17 -0
- package/dist/src/stig/ports.d.ts.map +1 -1
- package/dist/src/stig/pve.d.ts +1 -0
- package/dist/src/stig/pve.d.ts.map +1 -1
- package/dist/src/stig/runner.d.ts +6 -0
- package/dist/src/stig/runner.d.ts.map +1 -1
- package/dist/src/stig/runner.js +31 -18
- package/dist/src/stig/runner.js.map +1 -1
- package/dist/src/tools/quick-provision.d.ts +2 -2
- package/dist/src/tools/stig-tools.d.ts +28 -2
- package/dist/src/tools/stig-tools.d.ts.map +1 -1
- package/dist/src/tools/stig-tools.js +43 -4
- package/dist/src/tools/stig-tools.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* collect_stig_evidence: the claim-governed driver of the DoD evidence package (E1-E10) for one STIG clone.
|
|
3
|
+
*
|
|
4
|
+
* One source of truth. The generators are the ct-meta `rhel8-stig-template` skill's own Python tools, run with the same
|
|
5
|
+
* arguments `evidence.sh` passes them (tools/stig-parity.mjs and test/stig/evidence.test.ts diff the argument lists);
|
|
6
|
+
* the clone-side collection stages (`clone.sh scan|oval|keyhash|collect|verify`) run through the claim's guest access
|
|
7
|
+
* path with guest scripts that are the same commands (assets/stig/guest/collect-*.sh, oval-*.sh).
|
|
8
|
+
*
|
|
9
|
+
* Guards: claim first (the clone's claim must exist, be Ready and carry a clone descriptor), the clone must come from the
|
|
10
|
+
* named template, and nothing here can create, change, publish or destroy a template, a clone or a claim: the guest
|
|
11
|
+
* scripts only read (scratch files under /var/tmp are removed at the end), the Proxmox shell runs `qm config` and
|
|
12
|
+
* `pveversion`, and the claim port is used for `get` only. The tool returns a content-free receipt.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, createReadStream } from "node:fs";
|
|
16
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
17
|
+
import { dopplerKeyFor, verifyGuestFacts } from "./runner.js";
|
|
18
|
+
import { clearStage, cloneIdentity, markStage, stageCurrent } from "./markers.js";
|
|
19
|
+
import { redact, stigDescriptor, parseKv } from "./model.js";
|
|
20
|
+
import { STIG_EVIDENCE_FPR, STIG_NODE_NAMES, nodeForClaimNode, resolveStigNode } from "./nodes.js";
|
|
21
|
+
export const SKILL_REL = "plugins/ct-lab/skills/rhel8-stig-template";
|
|
22
|
+
export const LEDGER_REL = "knowledge/rhel8-stig";
|
|
23
|
+
export const OVAL_FEED_URL = "https://security.access.redhat.com/data/oval/v2/RHEL8/rhel-8.oval.xml.bz2";
|
|
24
|
+
const CI_USER = "ctadmin";
|
|
25
|
+
const XZ_THRESHOLD = 5 * 1024 * 1024;
|
|
26
|
+
/** The stage order of evidence.sh (its STAGES array); the parity test compares `evidence.sh --list`. */
|
|
27
|
+
export const EVIDENCE_STAGES = [
|
|
28
|
+
"preflight", "scan", "oval", "keyhash", "collect", "verify", "receipt", "e1", "e7", "e3", "e6", "e8", "e5", "report", "e2", "e4", "e10", "docs",
|
|
29
|
+
"distribution", "sign", "explorer", "resign", "verify-bundle", "tests",
|
|
30
|
+
];
|
|
31
|
+
/** Stages that read the clone (guest or hypervisor, read-only). */
|
|
32
|
+
export const CLONE_STAGES = ["scan", "oval", "keyhash", "collect", "verify"];
|
|
33
|
+
/** Generator stages that are one python invocation. */
|
|
34
|
+
export const GENERATOR_STAGES = ["e1", "e7", "e3", "e6", "e8", "e5", "report", "e2", "e4", "e10", "docs"];
|
|
35
|
+
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
36
|
+
const sha256Text = (t) => createHash("sha256").update(t).digest("hex");
|
|
37
|
+
async function sha256File(path) {
|
|
38
|
+
const h = createHash("sha256");
|
|
39
|
+
await new Promise((res, rej) => { const s = createReadStream(path); s.on("data", (c) => h.update(c)); s.on("end", () => res()); s.on("error", rej); });
|
|
40
|
+
return h.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
43
|
+
export const skillDir = (ctMeta) => join(ctMeta, SKILL_REL);
|
|
44
|
+
const toolsDir = (c) => join(skillDir(c.ctMeta), "tools");
|
|
45
|
+
export const pinsFile = (ctMeta) => join(skillDir(ctMeta), "scripts", "evidence-pins.json");
|
|
46
|
+
export const tailoringFile = (ctMeta) => join(skillDir(ctMeta), "scripts", "tailoring.xml");
|
|
47
|
+
/** `--plan-root ... --pins ...` (evidence.sh CA) */
|
|
48
|
+
const commonArgs = (c) => ["--plan-root", c.planRoot, "--build", c.template, "--clone", c.clone, "--state-dir", c.state, "--pins", pinsFile(c.ctMeta)];
|
|
49
|
+
/** evidence.sh EXTRA */
|
|
50
|
+
const extraArgs = (c) => [
|
|
51
|
+
"--manual-stig", c.manualStigZip, "--schema-dir", c.schemaDir, "--run-dir", c.runDir, "--work-dir", join(c.state, "trivy-work"),
|
|
52
|
+
...(c.benchmarkZip ? ["--benchmark-zip", c.benchmarkZip] : []), ...(c.benchmarkXml ? ["--benchmark-xml", c.benchmarkXml] : []),
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* The argument list evidence.sh passes the generator of `stage` (null for stages that are not one python call).
|
|
56
|
+
* `receipt` yields the receipt gate; `verify-bundle` the bundle verification; `preflight` the pins check.
|
|
57
|
+
*/
|
|
58
|
+
export function generatorCommand(stage, c) {
|
|
59
|
+
const py = c.python ?? "python3";
|
|
60
|
+
const tool = (name) => join(toolsDir(c), name);
|
|
61
|
+
const ev = join(c.planRoot, "evidence", c.template);
|
|
62
|
+
const receipts = join(c.planRoot, "receipts");
|
|
63
|
+
const fpr = STIG_EVIDENCE_FPR;
|
|
64
|
+
switch (stage) {
|
|
65
|
+
case "preflight": return [py, tool("rhel8_stig_evidence.py"), "pins-check", ...commonArgs(c), ...extraArgs(c)];
|
|
66
|
+
case "e1":
|
|
67
|
+
case "e7":
|
|
68
|
+
case "e3":
|
|
69
|
+
case "e6":
|
|
70
|
+
case "e8": return [py, tool("rhel8_stig_evidence.py"), stage, ...commonArgs(c), ...extraArgs(c)];
|
|
71
|
+
case "receipt": return [py, tool("rhel8_stig_evidence_docs.py"), "receipt-gate", ...commonArgs(c)];
|
|
72
|
+
case "e5": return [py, tool("rhel8_stig_evidence_docs.py"), "e5", ...commonArgs(c), "--tailoring", tailoringFile(c.ctMeta), "--today", c.today];
|
|
73
|
+
case "report":
|
|
74
|
+
case "e10":
|
|
75
|
+
case "docs": return [py, tool("rhel8_stig_evidence_docs.py"), stage, ...commonArgs(c), "--today", c.today];
|
|
76
|
+
case "e2": return [py, tool("rhel8_stig_ckl.py"), "--build", c.template, "--manual-stig", c.manualStigZip,
|
|
77
|
+
"--scanner-lock", join(c.planRoot, "rhel8-stig-scanner-lock.json"), "--disa-results", join(ev, "E1-disa-benchmark", "e1-xccdf-results.xml"),
|
|
78
|
+
"--ssg-arf", join(receipts, `${c.template}-clone-arf.xml.xz`), "--report", join(receipts, `${c.template}-report.md`),
|
|
79
|
+
"--deviations", join(ev, "E5-deviations", "E5-deviation-register.csv"), "--out-dir", join(ev, "E2-ckl")];
|
|
80
|
+
case "e4": return [py, tool("rhel8_stig_poam.py"), "--build", c.template, "--disa-results", join(ev, "E1-disa-benchmark", "e1-xccdf-results.xml"),
|
|
81
|
+
"--ckl-summary", join(ev, "E2-ckl", "E2-summary.json"), "--trivy-csv", join(ev, "E3-vuln-scan", "e3-trivy-standin-critical-high.csv"),
|
|
82
|
+
"--deviations", join(ev, "E5-deviations", "E5-deviation-register.csv"), "--ppsm", join(ev, "E6-ppsm", "E6-ppsm.csv"),
|
|
83
|
+
"--crypto", join(ev, "E8-crypto-modules", "E8-crypto-modules.csv"), "--report", join(receipts, `${c.template}-report.md`),
|
|
84
|
+
"--receipt-json", join(receipts, `${c.template}.json`), "--as-of", c.today, "--out-dir", join(ev, "E4-poam")];
|
|
85
|
+
case "sign":
|
|
86
|
+
case "resign": return [py, tool("rhel8_stig_bundle.py"), "all", ev, "--fingerprint", fpr, "--claim-name", c.clone, "--claim-uid", c.claimUid];
|
|
87
|
+
case "explorer": return [py, tool("rhel8_stig_explorer.py"), "--plan-root", c.planRoot, "--build", c.template];
|
|
88
|
+
case "verify-bundle": return [py, tool("rhel8_stig_bundle.py"), "verify", ev, "--pubkey", join(c.planRoot, "keys", "stig-evidence-signing.pub.asc"), "--fingerprint", fpr];
|
|
89
|
+
case "tests": return [py, "-m", "pytest", "-q", join(toolsDir(c), "tests")];
|
|
90
|
+
default: return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The plan root is written (and, when scratch, re-seeded) by the tool, so it must never be, contain or sit inside the ct-meta
|
|
95
|
+
* checkout or the ledger, except the ledger itself; a scratch root must be new, empty, or one this tool seeded.
|
|
96
|
+
*/
|
|
97
|
+
export function assertSafePlanRoot(planRoot, ctMeta, ledger, baseDir) {
|
|
98
|
+
const inside = (child, parent) => child === parent || child.startsWith(`${parent}/`);
|
|
99
|
+
if (planRoot === ledger)
|
|
100
|
+
return;
|
|
101
|
+
if (planRoot === "/" || planRoot === resolve(process.env.HOME ?? "/nonexistent"))
|
|
102
|
+
throw new Error(`refusing planRoot ${planRoot}: not a work directory`);
|
|
103
|
+
if (inside(planRoot, ctMeta) || inside(ctMeta, planRoot) || inside(planRoot, ledger) || inside(ledger, planRoot))
|
|
104
|
+
throw new Error(`refusing planRoot ${planRoot}: it overlaps the ct-meta checkout (name the ledger exactly, or a directory outside it)`);
|
|
105
|
+
if (inside(planRoot, baseDir) || inside(baseDir, planRoot)) {
|
|
106
|
+
if (inside(baseDir, planRoot) && planRoot !== baseDir)
|
|
107
|
+
throw new Error(`refusing planRoot ${planRoot}: it contains the run directory`);
|
|
108
|
+
}
|
|
109
|
+
if (existsSync(planRoot)) {
|
|
110
|
+
const entries = readdirSync(planRoot);
|
|
111
|
+
if (entries.length > 0 && !entries.includes(".seeded-for"))
|
|
112
|
+
throw new Error(`refusing planRoot ${planRoot}: it is not empty and was not seeded by this tool`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export class EvidenceError extends Error {
|
|
116
|
+
}
|
|
117
|
+
/* ------------------------------------------------------------ the collector */
|
|
118
|
+
export class StigEvidence {
|
|
119
|
+
d;
|
|
120
|
+
constructor(d) {
|
|
121
|
+
this.d = d;
|
|
122
|
+
}
|
|
123
|
+
log(line) { this.d.log.info(redact(line)); }
|
|
124
|
+
resolveInputs(i) {
|
|
125
|
+
const cloneName = i.cloneName ?? (i.claimName ? i.claimName.replace(/^clone-/, "") : undefined);
|
|
126
|
+
if (!cloneName || (i.cloneName && i.claimName && i.claimName !== `clone-${i.cloneName}`))
|
|
127
|
+
throw new Error("name exactly one clone: cloneName, or claimName clone-<cloneName>");
|
|
128
|
+
const ctMeta = resolve(i.ctMetaPath);
|
|
129
|
+
// The generators are Python run from this checkout: when an operator pins the allowed checkouts, only those are accepted.
|
|
130
|
+
const allowed = (process.env.CT_LAB_STIG_CTMETA_ROOTS ?? "").split(":").filter(Boolean).map((p) => resolve(p));
|
|
131
|
+
if (allowed.length && !allowed.some((a) => ctMeta === a || ctMeta.startsWith(`${a}/`)))
|
|
132
|
+
throw new Error(`ctMetaPath ${ctMeta} is not under CT_LAB_STIG_CTMETA_ROOTS`);
|
|
133
|
+
if (!existsSync(join(skillDir(ctMeta), "tools", "rhel8_stig_evidence.py")))
|
|
134
|
+
throw new Error(`ctMetaPath ${ctMeta} has no ${SKILL_REL}/tools (point it at a ct-meta checkout)`);
|
|
135
|
+
const ledger = join(ctMeta, LEDGER_REL);
|
|
136
|
+
const planRoot = resolve(i.planRoot ?? join(this.d.baseDir, "evidence-out", i.template, LEDGER_REL));
|
|
137
|
+
const env = process.env;
|
|
138
|
+
const manualStigZip = i.manualStigZip ?? env.MANUAL_STIG_ZIP ?? "";
|
|
139
|
+
const ssgDatastream = i.ssgDatastream ?? env.SSG_DATASTREAM ?? "";
|
|
140
|
+
const schemaDir = i.schemaDir ?? env.SBOM_SCHEMA_DIR ?? join(this.d.baseDir, "sbom-schemas");
|
|
141
|
+
return {
|
|
142
|
+
cloneName, claim: `clone-${cloneName}`, ctMeta, ledger, planRoot, isLedger: planRoot === ledger,
|
|
143
|
+
manualStigZip, ssgDatastream, schemaDir,
|
|
144
|
+
benchmarkZip: i.benchmarkZip ?? env.DISA_BENCHMARK_ZIP, benchmarkXml: i.benchmarkXml ?? env.DISA_BENCHMARK_XML,
|
|
145
|
+
today: i.today ?? env.TODAY ?? new Date(this.d.clock.now()).toISOString().slice(0, 10),
|
|
146
|
+
state: join(this.d.baseDir, `clone-${cloneName}`),
|
|
147
|
+
distDir: resolve(i.distDir ?? join(this.d.baseDir, "dist")),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Read-only checks plus the stage table. Throws when a guard refuses. */
|
|
151
|
+
async plan(i) {
|
|
152
|
+
const r = this.resolveInputs(i);
|
|
153
|
+
const rec = await this.d.claims.get(r.claim);
|
|
154
|
+
if (!rec)
|
|
155
|
+
throw new Error(`claim ${r.claim} not found; claim first (create_stig_claim)`);
|
|
156
|
+
let desc = null;
|
|
157
|
+
try {
|
|
158
|
+
desc = stigDescriptor(rec);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
desc = null;
|
|
162
|
+
}
|
|
163
|
+
if (desc?.mode !== "clone")
|
|
164
|
+
throw new Error(`refusing: claim ${r.claim} is not a STIG clone claim; evidence is collected from clones only, never from a template`);
|
|
165
|
+
if (rec.status?.phase !== "Ready")
|
|
166
|
+
throw new Error(`claim ${r.claim} is not Ready (phase=${rec.status?.phase ?? "none"}); claim first`);
|
|
167
|
+
const node = nodeForClaimNode(rec.spec?.node);
|
|
168
|
+
if (!node)
|
|
169
|
+
throw new Error(`claim ${r.claim} is on node '${rec.spec?.node}', which is not in the STIG node map`);
|
|
170
|
+
if (i.node !== undefined && resolveStigNode(i.node).name !== node.name)
|
|
171
|
+
throw new Error(`claim ${r.claim} lives on ${node.name}, not ${resolveStigNode(i.node).name}`);
|
|
172
|
+
for (const f of ["ctadmin_password", "ip", "id_key", "vmid", "template", "known_hosts"]) {
|
|
173
|
+
if (!existsSync(join(r.state, f)))
|
|
174
|
+
throw new Error(`no runner state for clone ${r.cloneName} (${f} missing); it must be created by create_stig_claim on this host`);
|
|
175
|
+
}
|
|
176
|
+
const tpl = readFileSync(join(r.state, "template"), "utf8").trim();
|
|
177
|
+
if (tpl !== i.template)
|
|
178
|
+
throw new Error(`clone ${r.cloneName} was made from ${tpl}, not ${i.template}; recreate it (create_stig_claim recreate=true)`);
|
|
179
|
+
if (!existsSync(pinsFile(r.ctMeta)))
|
|
180
|
+
throw new Error(`missing ${pinsFile(r.ctMeta)}`);
|
|
181
|
+
if (!existsSync(join(r.ledger, "rhel8-stig-scanner-lock.json")))
|
|
182
|
+
throw new Error(`missing ${join(r.ledger, "rhel8-stig-scanner-lock.json")}`);
|
|
183
|
+
const vmid = Number(readFileSync(join(r.state, "vmid"), "utf8").trim());
|
|
184
|
+
if (rec.spec?.vmid !== vmid)
|
|
185
|
+
throw new Error(`runner state for clone ${r.cloneName} is VMID ${vmid}, the claim is VMID ${rec.spec?.vmid}; the state is stale (recreate the clone)`);
|
|
186
|
+
assertSafePlanRoot(r.planRoot, r.ctMeta, r.ledger, this.d.baseDir);
|
|
187
|
+
const identity = cloneIdentity(tpl, vmid, rec.metadata.uid);
|
|
188
|
+
let started = i.from === undefined;
|
|
189
|
+
const stages = EVIDENCE_STAGES.map((stage) => {
|
|
190
|
+
if (i.from === stage)
|
|
191
|
+
started = true;
|
|
192
|
+
const selected = (i.only ? stage === i.only : true) && started;
|
|
193
|
+
const kind = CLONE_STAGES.includes(stage) ? "clone-read" : "controller";
|
|
194
|
+
const state = !selected ? "not-selected" : kind === "clone-read" && !i.force && stageCurrent(r.state, stage, identity) ? "current" : "pending";
|
|
195
|
+
return { stage, kind, state };
|
|
196
|
+
});
|
|
197
|
+
const receiptSource = i.templateRunDir ? "regenerate" : "ledger";
|
|
198
|
+
const guards = [
|
|
199
|
+
"claim exists, is Ready and carries a clone descriptor (never a template claim)",
|
|
200
|
+
`clone was made from ${tpl}`,
|
|
201
|
+
"read-only on the guest (scratch files under /var/tmp are removed) and on Proxmox (qm config, pveversion); no claim, clone or template is created, changed, published or destroyed",
|
|
202
|
+
r.isLedger ? "planRoot is the ct-meta ledger: generated evidence overwrites the ledger copy" : "output goes to a scratch plan root; the ct-meta ledger is not written",
|
|
203
|
+
];
|
|
204
|
+
const base = {
|
|
205
|
+
cloneName: r.cloneName, claimUid: rec.metadata.uid, node: node.name, template: i.template, vmid, planRoot: r.planRoot, ctMeta: r.ctMeta, receiptSource,
|
|
206
|
+
stages: stages.map((s) => `${s.stage}:${s.state}`), today: r.today, noSign: !!i.noSign, skipTests: !!i.skipTests, force: !!i.force,
|
|
207
|
+
inputs: [i.templateRunDir, r.distDir, r.manualStigZip, r.ssgDatastream, r.schemaDir, r.benchmarkZip, r.benchmarkXml].map((x) => (x ? resolve(x) : null)),
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
mode: "plan", cloneName: r.cloneName, claim: r.claim, claimUid: rec.metadata.uid, node: node.name, template: i.template, vmid,
|
|
211
|
+
planRoot: r.planRoot, ledger: r.isLedger, ctMeta: r.ctMeta, receiptSource, stages, guards, planDigest: digest({ kind: "evidence", ...base }),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async collect(i) {
|
|
215
|
+
const plan = await this.plan(i);
|
|
216
|
+
if (!i.apply)
|
|
217
|
+
return plan;
|
|
218
|
+
if (!i.planDigest || i.planDigest !== plan.planDigest)
|
|
219
|
+
throw new Error("plan digest missing or stale; re-run the plan and apply with its planDigest");
|
|
220
|
+
return this.apply(i, plan);
|
|
221
|
+
}
|
|
222
|
+
/* ---------------------------------------------------------------- apply */
|
|
223
|
+
async apply(i, plan) {
|
|
224
|
+
const r = this.resolveInputs(i);
|
|
225
|
+
const node = resolveStigNode(plan.node);
|
|
226
|
+
const identity = cloneIdentity(i.template, plan.vmid, plan.claimUid);
|
|
227
|
+
const pw = readFileSync(join(r.state, "ctadmin_password"), "utf8");
|
|
228
|
+
const sudoStdin = (extra = "") => `${pw}\n${extra}`;
|
|
229
|
+
const runDir = i.templateRunDir ? resolve(i.templateRunDir) : join(this.d.baseDir, i.template);
|
|
230
|
+
const ctx = {
|
|
231
|
+
ctMeta: r.ctMeta, template: i.template, clone: r.cloneName, planRoot: r.planRoot, state: r.state, runDir,
|
|
232
|
+
manualStigZip: r.manualStigZip, schemaDir: r.schemaDir, ssgDatastream: r.ssgDatastream, benchmarkZip: r.benchmarkZip, benchmarkXml: r.benchmarkXml,
|
|
233
|
+
today: r.today, claimUid: plan.claimUid,
|
|
234
|
+
};
|
|
235
|
+
this.d.guest.configure({ user: CI_USER, keyFile: join(r.state, "id_key"), knownHostsFile: join(r.state, "known_hosts") });
|
|
236
|
+
this.d.guest.setTarget(readFileSync(join(r.state, "ip"), "utf8").trim());
|
|
237
|
+
this.seedPlanRoot(r, i);
|
|
238
|
+
const outcomes = [];
|
|
239
|
+
for (const st of plan.stages) {
|
|
240
|
+
if (st.state === "not-selected")
|
|
241
|
+
continue;
|
|
242
|
+
if (st.stage === "sign" || st.stage === "explorer" || st.stage === "resign" || st.stage === "verify-bundle") {
|
|
243
|
+
if (i.noSign) {
|
|
244
|
+
outcomes.push({ stage: st.stage, status: "skipped", detail: "noSign", ms: 0 });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (st.stage === "tests" && i.skipTests) {
|
|
249
|
+
outcomes.push({ stage: st.stage, status: "skipped", detail: "skipTests", ms: 0 });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (st.state === "current") {
|
|
253
|
+
this.log(`${st.stage}: current for this clone (marker matches); skipped (force=true to redo)`);
|
|
254
|
+
outcomes.push({ stage: st.stage, status: "skipped-current", ms: 0 });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const t0 = this.d.clock.now();
|
|
258
|
+
this.log(`stage ${st.stage}`);
|
|
259
|
+
if (st.kind === "clone-read")
|
|
260
|
+
clearStage(r.state, st.stage); // a failed stage must not leave a marker vouching for partial evidence
|
|
261
|
+
try {
|
|
262
|
+
const detail = await this.runStage(st.stage, { ctx, r, node, plan, i, sudoStdin });
|
|
263
|
+
if (st.kind === "clone-read")
|
|
264
|
+
markStage(r.state, st.stage, identity);
|
|
265
|
+
outcomes.push({ stage: st.stage, status: "ran", ...(detail ? { detail } : {}), ms: this.d.clock.now() - t0 });
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
throw new Error(`evidence stage ${st.stage} failed: ${redact(String(e?.message ?? e)).slice(0, 600)} (fix the cause and re-apply with from=${st.stage})`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, ...this.receiptOf(plan, r, ctx, outcomes, i) };
|
|
272
|
+
}
|
|
273
|
+
async runStage(stage, h) {
|
|
274
|
+
const { ctx, r, node, plan, sudoStdin } = h;
|
|
275
|
+
const g = this.d.guest;
|
|
276
|
+
switch (stage) {
|
|
277
|
+
case "preflight": return this.preflight(h);
|
|
278
|
+
case "scan": {
|
|
279
|
+
if (!(await g.waitSsh("yes", 3)))
|
|
280
|
+
throw new Error("clone is not reachable over ssh (strict host key)");
|
|
281
|
+
const ev = join(r.state, "evidence");
|
|
282
|
+
try {
|
|
283
|
+
await g.runScript(this.d.readAsset("guest/scan.sh"), { sudo: true, stdin: sudoStdin(), timeoutMs: 30 * 60_000 });
|
|
284
|
+
await g.runScript(this.d.readAsset("guest/scan-disa.sh"), { sudo: true, stdin: sudoStdin(), timeoutMs: 30 * 60_000 });
|
|
285
|
+
rmSync(ev, { recursive: true, force: true });
|
|
286
|
+
await g.fetchDir("/var/tmp/ct-scan", ev);
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
await g.runScript("rm -rf /var/tmp/ct-scan\n", { sudo: true, stdin: sudoStdin() }).catch(() => undefined); // scratch never stays on the guest
|
|
290
|
+
}
|
|
291
|
+
if (!existsSync(join(ev, "clone-disa-summary.txt")))
|
|
292
|
+
throw new Error("the clone has no DISA benchmark scan (the template predates the dual-scan recipe)");
|
|
293
|
+
await this.compressScanArtifacts(ev);
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
case "oval": return this.oval(h);
|
|
297
|
+
case "keyhash": {
|
|
298
|
+
const rec = await this.d.secrets.get(dopplerKeyFor(r.cloneName));
|
|
299
|
+
if (!rec)
|
|
300
|
+
throw new Error("recovery passphrase is not in the secret store; cannot take the volume key digest");
|
|
301
|
+
const out = await g.runScript(this.d.readAsset("guest/keyhash.sh"), { sudo: true, stdin: sudoStdin(rec) });
|
|
302
|
+
const hex = out.stdout.trim().split("\n").pop() ?? "";
|
|
303
|
+
if (!/^[a-f0-9]{64}$/.test(hex))
|
|
304
|
+
throw new Error("keyhash did not return a sha256");
|
|
305
|
+
writeFileSync(join(r.state, "keyhash.txt"), `${hex}\n`, { mode: 0o600 });
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
case "collect": return this.collectHost(h);
|
|
309
|
+
case "verify": {
|
|
310
|
+
const v = await verifyGuestFacts(this.d, dopplerKeyFor(r.cloneName), sudoStdin);
|
|
311
|
+
writeFileSync(join(r.state, "verify.txt"), v.text, { mode: 0o600 });
|
|
312
|
+
return `fips=${v.kv.fips_enabled} selinux=${v.kv.selinux} tpm_bound=${v.kv.tpm_bound} bootstrap_key=${v.kv.bootstrap_key_file}`;
|
|
313
|
+
}
|
|
314
|
+
case "receipt": return this.receipt(h);
|
|
315
|
+
case "distribution": return this.distribution(h);
|
|
316
|
+
case "verify-bundle": {
|
|
317
|
+
const out = await this.gen("verify-bundle", ctx);
|
|
318
|
+
const tmp = join(this.d.baseDir, `explorer-check-${plan.cloneName}.html`);
|
|
319
|
+
const ex = generatorCommand("explorer", ctx);
|
|
320
|
+
const res = await this.d.local.run([...ex, "--out", tmp], { env: this.genEnv(ctx) });
|
|
321
|
+
if (res.code !== 0)
|
|
322
|
+
throw new Error(`explorer regeneration failed: ${redact(res.stderr).slice(-300)}`);
|
|
323
|
+
const same = existsSync(tmp) && sha256Text(readFileSync(tmp)) === sha256Text(readFileSync(join(ctx.planRoot, "evidence", ctx.template, "explorer", "index.html")));
|
|
324
|
+
rmSync(tmp, { force: true });
|
|
325
|
+
if (!same)
|
|
326
|
+
throw new Error("explorer page is not current after signing");
|
|
327
|
+
return out.split("\n").filter(Boolean).pop();
|
|
328
|
+
}
|
|
329
|
+
default: {
|
|
330
|
+
const out = await this.gen(stage, ctx);
|
|
331
|
+
return out.split("\n").filter(Boolean).pop()?.slice(0, 160);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
genEnv(ctx) {
|
|
336
|
+
return { STIG_RECEIPT_DIR: join(ctx.planRoot, "receipts"), SSG_DATASTREAM: ctx.ssgDatastream };
|
|
337
|
+
}
|
|
338
|
+
async gen(stage, ctx) {
|
|
339
|
+
const argv = generatorCommand(stage, ctx);
|
|
340
|
+
if (!argv)
|
|
341
|
+
throw new Error(`stage ${stage} has no generator`);
|
|
342
|
+
const res = await this.d.local.run(argv, { env: this.genEnv(ctx), timeoutMs: 40 * 60_000 });
|
|
343
|
+
if (res.code !== 0)
|
|
344
|
+
throw new Error(`${basename(argv[1] ?? "generator")} ${stage} exited ${res.code}: ${redact(res.stderr || res.stdout).slice(-500)}`);
|
|
345
|
+
return res.stdout;
|
|
346
|
+
}
|
|
347
|
+
/* ---------------------------------------------------------------- stages */
|
|
348
|
+
async preflight(h) {
|
|
349
|
+
const { ctx, r, i } = h;
|
|
350
|
+
const missing = [];
|
|
351
|
+
const need = i.noSign ? ["python3", "xz", "trivy", "bzip2"] : ["python3", "xz", "trivy", "bzip2", "gpg", "doppler"];
|
|
352
|
+
for (const t of need)
|
|
353
|
+
if ((await this.d.local.run(["sh", "-c", `command -v ${t}`])).code !== 0)
|
|
354
|
+
missing.push(t);
|
|
355
|
+
if (missing.length)
|
|
356
|
+
throw new Error(`missing tool: ${missing.join(", ")}`);
|
|
357
|
+
const py = await this.d.local.run(["python3", "-c", "import jsonschema, referencing"]);
|
|
358
|
+
if (py.code !== 0)
|
|
359
|
+
throw new Error("python needs jsonschema and referencing (pip install --user jsonschema referencing)");
|
|
360
|
+
if (!ctx.manualStigZip || !existsSync(ctx.manualStigZip))
|
|
361
|
+
throw new Error("DISA manual STIG zip not found (manualStigZip or MANUAL_STIG_ZIP)");
|
|
362
|
+
if (!ctx.ssgDatastream || !existsSync(ctx.ssgDatastream))
|
|
363
|
+
throw new Error("SSG 0.1.82 datastream not found (ssgDatastream or SSG_DATASTREAM)");
|
|
364
|
+
await this.fetchSchemas(ctx.schemaDir, ctx.ctMeta);
|
|
365
|
+
const res = await this.d.local.run(generatorCommand("preflight", ctx), { env: this.genEnv(ctx) });
|
|
366
|
+
if (res.code !== 0)
|
|
367
|
+
throw new Error(`pinned input check failed: ${redact(res.stderr || res.stdout).slice(-400)}`);
|
|
368
|
+
if (!r.isLedger && !existsSync(join(r.planRoot, "rhel8-stig-scanner-lock.json")))
|
|
369
|
+
throw new Error("scratch plan root was not seeded");
|
|
370
|
+
return "pins OK";
|
|
371
|
+
}
|
|
372
|
+
/** SBOM schemas pinned by sha256 (evidence-pins.json); downloaded when the cache is missing or stale. */
|
|
373
|
+
async fetchSchemas(dir, ctMeta) {
|
|
374
|
+
const pins = JSON.parse(readFileSync(pinsFile(ctMeta), "utf8"));
|
|
375
|
+
mkdirSync(dir, { recursive: true });
|
|
376
|
+
for (const [name, want] of Object.entries(pins.sbom_schemas)) {
|
|
377
|
+
const f = join(dir, name);
|
|
378
|
+
if (existsSync(f) && sha256Text(readFileSync(f)) === want)
|
|
379
|
+
continue;
|
|
380
|
+
this.log(`fetching SBOM schema ${name}`);
|
|
381
|
+
const part = `${f}.part`;
|
|
382
|
+
await this.d.local.download(pins.sbom_schema_urls[name], part);
|
|
383
|
+
if (sha256Text(readFileSync(part)) !== want) {
|
|
384
|
+
rmSync(part, { force: true });
|
|
385
|
+
throw new Error(`SBOM schema ${name} does not match the pinned sha256`);
|
|
386
|
+
}
|
|
387
|
+
copyFileSync(part, f);
|
|
388
|
+
rmSync(part, { force: true });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async oval(h) {
|
|
392
|
+
const { r, sudoStdin } = h;
|
|
393
|
+
const g = this.d.guest;
|
|
394
|
+
const feed = join(this.d.baseDir, "oval-feed");
|
|
395
|
+
mkdirSync(feed, { recursive: true, mode: 0o700 });
|
|
396
|
+
const bz = join(feed, "rhel-8.oval.xml.bz2");
|
|
397
|
+
const xml = join(feed, "rhel-8.oval.xml");
|
|
398
|
+
const { headers } = await this.d.local.download(OVAL_FEED_URL, bz);
|
|
399
|
+
writeFileSync(`${bz}.headers`, Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n") + "\n");
|
|
400
|
+
const unz = await this.d.local.run(["sh", "-c", 'bzip2 -dc "$1" > "$2"', "sh", bz, xml]);
|
|
401
|
+
if (unz.code !== 0)
|
|
402
|
+
throw new Error("could not decompress the OVAL feed");
|
|
403
|
+
const o = join(r.state, "oval");
|
|
404
|
+
rmSync(o, { recursive: true, force: true });
|
|
405
|
+
mkdirSync(o, { recursive: true, mode: 0o700 });
|
|
406
|
+
try {
|
|
407
|
+
await g.runScript(this.d.readAsset("guest/oval-prep.sh"), { sudo: true, stdin: sudoStdin() });
|
|
408
|
+
await g.putFile(xml, "/var/tmp/ct-oval/rhel-8.oval.xml");
|
|
409
|
+
await g.runScript(this.d.readAsset("guest/oval-eval.sh"), { sudo: true, stdin: sudoStdin(), timeoutMs: 60 * 60_000 });
|
|
410
|
+
await g.fetchDir("/var/tmp/ct-oval", o);
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
await g.runScript("rm -rf /var/tmp/ct-oval\n", { sudo: true, stdin: sudoStdin() }).catch(() => undefined);
|
|
414
|
+
}
|
|
415
|
+
const lastModified = headers["last-modified"] ?? "";
|
|
416
|
+
const summary = [
|
|
417
|
+
readFileSync(join(o, "oval-summary.txt"), "utf8").trimEnd(),
|
|
418
|
+
`oval_feed_bz2_sha256=${await sha256File(bz)}`, `oval_feed_xml_sha256=${await sha256File(xml)}`, `oval_feed_url=${OVAL_FEED_URL}`,
|
|
419
|
+
`oval_feed_last_modified=${lastModified}`, `oval_feed_date=${new Date(this.d.clock.now()).toISOString().replace(/\.\d{3}Z$/, "Z")}`,
|
|
420
|
+
].join("\n") + "\n";
|
|
421
|
+
writeFileSync(join(r.state, "oval-summary.txt"), summary, { mode: 0o600 });
|
|
422
|
+
copyFileSync(xml, join(o, "rhel-8.oval.xml")); // kept for the offline NEVRA cross-check
|
|
423
|
+
const kv = parseKv(summary);
|
|
424
|
+
return `oval_true=${kv.oval_true} oval_false=${kv.oval_false}`;
|
|
425
|
+
}
|
|
426
|
+
/** Read-only host facts for E3/E6/E7/E8 (clone.sh `do_collect`). */
|
|
427
|
+
async collectHost(h) {
|
|
428
|
+
const { r, node, plan, sudoStdin } = h;
|
|
429
|
+
const c = join(r.state, "collect");
|
|
430
|
+
rmSync(c, { recursive: true, force: true });
|
|
431
|
+
mkdirSync(c, { recursive: true, mode: 0o700 });
|
|
432
|
+
const TSV_HEADER = "name\tepoch\tversion\trelease\tarch\tvendor\tlicense\tsourcerpm\tsignature_rsa_header\tsignature_sigpgp\tsignature_dsa_header\tsha256_header\tpackager\n";
|
|
433
|
+
for (const [file, asset, header] of COLLECT_FILES) {
|
|
434
|
+
const res = await this.d.guest.runScript(this.d.readAsset(`guest/${asset}`), { sudo: true, stdin: sudoStdin(), timeoutMs: 15 * 60_000 });
|
|
435
|
+
if (res.code === 255 || res.code === 124 || res.code === 127)
|
|
436
|
+
throw new Error(`${asset} failed (exit ${res.code}): ${redact(res.stderr).slice(0, 200)}`);
|
|
437
|
+
if (REQUIRED_NONEMPTY.has(file) && !res.stdout.trim())
|
|
438
|
+
throw new Error(`${asset} returned nothing`);
|
|
439
|
+
writeFileSync(join(c, file), (header ? TSV_HEADER : "") + res.stdout);
|
|
440
|
+
}
|
|
441
|
+
// qm config must succeed (the pipeline's last command is head, so its own status would hide a failure)
|
|
442
|
+
const hv = await this.d.pveFor(node.host).run(`out=$(qm config ${plan.vmid}) || exit 1; printf '%s\\n' "$out" | grep -vE '^(sshkeys|cipassword|ciuser):'; echo; pveversion; kvm --version | head -1`);
|
|
443
|
+
if (hv.code !== 0 || !/^name: /m.test(hv.stdout))
|
|
444
|
+
throw new Error(`could not read the guest's Proxmox config (qm config ${plan.vmid} exit ${hv.code}): ${redact(hv.stderr).slice(0, 200)}`);
|
|
445
|
+
writeFileSync(join(c, "hypervisor.txt"), hv.stdout);
|
|
446
|
+
const names = readdirSync(c).sort();
|
|
447
|
+
writeFileSync(join(c, "SHA256SUMS"), names.map((n) => `${sha256Text(readFileSync(join(c, n)))} ${n}`).join("\n") + "\n");
|
|
448
|
+
writeFileSync(join(c, "collect.json"), `{"template":"${plan.template}","clone":"${plan.cloneName}","vmid":${plan.vmid},"collected":"${new Date(this.d.clock.now()).toISOString().replace(/\.\d{3}Z$/, "Z")}"}\n`);
|
|
449
|
+
return `${names.length + 1} files`;
|
|
450
|
+
}
|
|
451
|
+
/** receipt.sh (receipt.py plus the artifact copies) then the receipt gate; or the gate alone when the ledger holds the receipt. */
|
|
452
|
+
/**
|
|
453
|
+
* clone.sh `scan` stores an ARF over 5 MB as `.xz` with `.raw.sha256` and `.xz.sha256` beside it; validate_stig_claim leaves the
|
|
454
|
+
* raw file. Normalize to the shell recipe's form (idempotent), because receipt.sh and the E2 generator read the `.xz`.
|
|
455
|
+
*/
|
|
456
|
+
async compressScanArtifacts(ev) {
|
|
457
|
+
for (const f of ["clone-arf.xml", "clone-disa-arf.xml"]) {
|
|
458
|
+
const raw = join(ev, f);
|
|
459
|
+
if (!existsSync(raw) || statSync(raw).size <= XZ_THRESHOLD)
|
|
460
|
+
continue;
|
|
461
|
+
writeFileSync(`${raw}.raw.sha256`, `${await sha256File(raw)}\n`);
|
|
462
|
+
const x = await this.d.local.run(["sh", "-c", 'xz -9e -c "$1" > "$2"', "sh", raw, `${raw}.xz`], { timeoutMs: 20 * 60_000 });
|
|
463
|
+
if (x.code !== 0)
|
|
464
|
+
throw new Error(`xz of ${f} failed`);
|
|
465
|
+
writeFileSync(`${raw}.xz.sha256`, `${await sha256File(`${raw}.xz`)}\n`);
|
|
466
|
+
rmSync(raw);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async receipt(h) {
|
|
470
|
+
const { ctx, r, i } = h;
|
|
471
|
+
await this.compressScanArtifacts(join(r.state, "evidence"));
|
|
472
|
+
const out = join(ctx.planRoot, "receipts");
|
|
473
|
+
mkdirSync(out, { recursive: true });
|
|
474
|
+
if (i.templateRunDir) {
|
|
475
|
+
const ev = join(ctx.runDir, "evidence", "stig-report");
|
|
476
|
+
if (!existsSync(ev))
|
|
477
|
+
throw new Error(`no template build evidence in ${ev}`);
|
|
478
|
+
const cs = join(r.state, "evidence");
|
|
479
|
+
const tpl = ctx.template;
|
|
480
|
+
const cpbig = async (src, dst) => {
|
|
481
|
+
if (statSync(src).size > XZ_THRESHOLD) {
|
|
482
|
+
const x = await this.d.local.run(["sh", "-c", 'xz -9e -c "$1" > "$2"', "sh", src, `${dst}.xz`]);
|
|
483
|
+
if (x.code !== 0)
|
|
484
|
+
throw new Error(`xz of ${basename(src)} failed`);
|
|
485
|
+
}
|
|
486
|
+
else
|
|
487
|
+
copyFileSync(src, dst);
|
|
488
|
+
};
|
|
489
|
+
await cpbig(join(ev, "final-report.html"), join(out, `${tpl}-template-report.html`));
|
|
490
|
+
await cpbig(join(ev, "final-disa-report.html"), join(out, `${tpl}-template-disa-report.html`));
|
|
491
|
+
await cpbig(join(cs, "clone-report.html"), join(out, `${tpl}-clone-report.html`));
|
|
492
|
+
await cpbig(join(cs, "clone-disa-report.html"), join(out, `${tpl}-clone-disa-report.html`));
|
|
493
|
+
copyFileSync(join(ev, "final-arf.xml.xz"), join(out, `${tpl}-template-arf.xml.xz`));
|
|
494
|
+
copyFileSync(join(ev, "final-disa-arf.xml.xz"), join(out, `${tpl}-template-disa-arf.xml.xz`));
|
|
495
|
+
copyFileSync(join(cs, "clone-arf.xml.xz"), join(out, `${tpl}-clone-arf.xml.xz`));
|
|
496
|
+
copyFileSync(join(cs, "clone-disa-arf.xml.xz"), join(out, `${tpl}-clone-disa-arf.xml.xz`));
|
|
497
|
+
const res = await this.d.local.run([ctx.python ?? "python3", "-I", join(skillDir(ctx.ctMeta), "scripts", "receipt.py"), tpl, ctx.clone, ctx.runDir, r.state, out, ctx.ssgDatastream], { env: this.genEnv(ctx) });
|
|
498
|
+
if (res.code !== 0)
|
|
499
|
+
throw new Error(`receipt.py exited ${res.code}: ${redact(res.stderr || res.stdout).slice(-400)}`);
|
|
500
|
+
}
|
|
501
|
+
else if (!existsSync(join(out, `${ctx.template}.json`))) {
|
|
502
|
+
throw new Error(`no receipt for ${ctx.template} in ${out}; pass templateRunDir (the template build evidence) to write it for this clone`);
|
|
503
|
+
}
|
|
504
|
+
const gate = await this.d.local.run(generatorCommand("receipt", ctx), { env: this.genEnv(ctx) });
|
|
505
|
+
if (gate.code !== 0)
|
|
506
|
+
throw new Error(`receipt gate failed: ${redact(gate.stderr || gate.stdout).slice(-600)}`);
|
|
507
|
+
return gate.stdout.trim().split("\n").pop() ?? "";
|
|
508
|
+
}
|
|
509
|
+
async distribution(h) {
|
|
510
|
+
const { ctx, r } = h;
|
|
511
|
+
const tpl = ctx.template;
|
|
512
|
+
const pub = join(r.distDir, `publish-${tpl}.json`);
|
|
513
|
+
if (!existsSync(pub))
|
|
514
|
+
return "no publish receipt for this template; skipped";
|
|
515
|
+
const out = join(ctx.planRoot, "receipts");
|
|
516
|
+
mkdirSync(out, { recursive: true });
|
|
517
|
+
copyFileSync(pub, join(out, `${tpl}-publish.json`));
|
|
518
|
+
let n = 0;
|
|
519
|
+
for (const node of STIG_NODE_NAMES) {
|
|
520
|
+
const f = `sync-${tpl}-${node}.json`;
|
|
521
|
+
if (existsSync(join(r.distDir, f))) {
|
|
522
|
+
copyFileSync(join(r.distDir, f), join(out, `${tpl}-sync-${node}.json`));
|
|
523
|
+
n++;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return `publish receipt and ${n} node sync receipts`;
|
|
527
|
+
}
|
|
528
|
+
/* ---------------------------------------------------------------- plan root */
|
|
529
|
+
/** A scratch plan root is a copy of the ledger without this template's evidence (and, when the receipt is regenerated, its receipt files). */
|
|
530
|
+
seedPlanRoot(r, i) {
|
|
531
|
+
if (r.isLedger)
|
|
532
|
+
return;
|
|
533
|
+
const seeded = join(r.planRoot, ".seeded-for");
|
|
534
|
+
const tag = `${i.template}|${r.cloneName}\n`;
|
|
535
|
+
if (existsSync(seeded) && readFileSync(seeded, "utf8") === tag)
|
|
536
|
+
return;
|
|
537
|
+
rmSync(r.planRoot, { recursive: true, force: true });
|
|
538
|
+
mkdirSync(dirname(r.planRoot), { recursive: true, mode: 0o700 });
|
|
539
|
+
const tpl = i.template;
|
|
540
|
+
const regenerate = !!i.templateRunDir;
|
|
541
|
+
const ownReceipt = new RegExp(`^${tpl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\.json|\\.md|-(template|clone)-(disa-)?(report\\.html|arf\\.xml\\.xz)(\\.xz)?)$`);
|
|
542
|
+
cpSync(r.ledger, r.planRoot, {
|
|
543
|
+
recursive: true,
|
|
544
|
+
filter: (src) => {
|
|
545
|
+
const rel = src.slice(r.ledger.length + 1);
|
|
546
|
+
if (rel === `evidence/${tpl}` || rel.startsWith(`evidence/${tpl}/`))
|
|
547
|
+
return false;
|
|
548
|
+
if (regenerate && rel.startsWith("receipts/") && ownReceipt.test(basename(rel)))
|
|
549
|
+
return false;
|
|
550
|
+
return true;
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
chmodSync(r.planRoot, 0o700);
|
|
554
|
+
writeFileSync(seeded, tag);
|
|
555
|
+
}
|
|
556
|
+
/* ---------------------------------------------------------------- content-free receipt */
|
|
557
|
+
receiptOf(plan, r, ctx, outcomes, i) {
|
|
558
|
+
const ev = join(ctx.planRoot, "evidence", ctx.template);
|
|
559
|
+
const kv = (p) => (existsSync(p) ? parseKv(readFileSync(p, "utf8")) : {});
|
|
560
|
+
const num = (m, keys) => Object.fromEntries(keys.filter((k) => m[k] !== undefined).map((k) => [k.replace(/^result_/, ""), Number(m[k])]));
|
|
561
|
+
const scanKeys = ["result_pass", "result_fail", "result_notapplicable", "result_notchecked", "result_informational", "result_error"];
|
|
562
|
+
const json = (p) => { try {
|
|
563
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return null;
|
|
567
|
+
} };
|
|
568
|
+
const csvRows = (p) => {
|
|
569
|
+
if (!existsSync(p))
|
|
570
|
+
return null;
|
|
571
|
+
// records, not lines: count rows whose first field starts a record (quotes balanced)
|
|
572
|
+
let rows = 0;
|
|
573
|
+
let inQ = false;
|
|
574
|
+
let atStart = true;
|
|
575
|
+
for (const ch of readFileSync(p, "utf8")) {
|
|
576
|
+
if (ch === '"')
|
|
577
|
+
inQ = !inQ;
|
|
578
|
+
if (ch === "\n" && !inQ) {
|
|
579
|
+
atStart = true;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (atStart && ch !== "\n") {
|
|
583
|
+
rows++;
|
|
584
|
+
atStart = false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return Math.max(0, rows - 1);
|
|
588
|
+
};
|
|
589
|
+
const ckl = json(join(ev, "E2-ckl", "E2-summary.json"))?.summary ?? null;
|
|
590
|
+
const poam = json(join(ev, "E4-poam", "E4-poam.json"));
|
|
591
|
+
const spdx = json(join(ev, "E7-sbom", "sbom.spdx.json"));
|
|
592
|
+
const cdx = json(join(ev, "E7-sbom", "sbom.cdx.json"));
|
|
593
|
+
const manifest = join(ev, "MANIFEST.sha256");
|
|
594
|
+
const bundle = outcomes.find((o) => o.stage === "verify-bundle");
|
|
595
|
+
const oval = kv(join(r.state, "oval-summary.txt"));
|
|
596
|
+
return {
|
|
597
|
+
node: plan.node, claim: plan.claim, template: plan.template, planRoot: plan.planRoot, ledgerWritten: plan.ledger,
|
|
598
|
+
stages: outcomes.map((o) => ({ stage: o.stage, status: o.status, ms: o.ms })),
|
|
599
|
+
counts: {
|
|
600
|
+
ssgClone: num(kv(join(r.state, "evidence", "clone-summary.txt")), scanKeys),
|
|
601
|
+
disaClone: num(kv(join(r.state, "evidence", "clone-disa-summary.txt")), [...scanKeys, "result_unknown"]),
|
|
602
|
+
oval: { true: Number(oval.oval_true ?? NaN), false: Number(oval.oval_false ?? NaN) },
|
|
603
|
+
ckl: ckl ? { total: ckl.total, byStatus: ckl.by_status } : null,
|
|
604
|
+
poamItems: Array.isArray(poam?.items) ? poam.items.length : null,
|
|
605
|
+
deviations: csvRows(join(ev, "E5-deviations", "E5-deviation-register.csv")),
|
|
606
|
+
ppsmRows: csvRows(join(ev, "E6-ppsm", "E6-ppsm.csv")),
|
|
607
|
+
cryptoModules: csvRows(join(ev, "E8-crypto-modules", "E8-crypto-modules.csv")),
|
|
608
|
+
rpmInventory: existsSync(join(ev, "E7-sbom", "rpm-inventory.tsv")) ? readFileSync(join(ev, "E7-sbom", "rpm-inventory.tsv"), "utf8").split("\n").filter(Boolean).length - 1 : null,
|
|
609
|
+
sbomSpdxPackages: Array.isArray(spdx?.packages) ? spdx.packages.length : null,
|
|
610
|
+
sbomCycloneDxComponents: Array.isArray(cdx?.components) ? cdx.components.length : null,
|
|
611
|
+
},
|
|
612
|
+
bundle: {
|
|
613
|
+
manifestFiles: existsSync(manifest) ? readFileSync(manifest, "utf8").split("\n").filter(Boolean).length : null,
|
|
614
|
+
manifestSha256: existsSync(manifest) ? sha256Text(readFileSync(manifest)) : null,
|
|
615
|
+
signed: existsSync(`${manifest}.asc`),
|
|
616
|
+
verify: bundle ? (/VERIFY OK/.test(bundle.detail ?? "") ? "VERIFY OK" : (bundle.detail ?? "ran")) : i.noSign ? "not run (noSign)" : "not run",
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/** [evidence file, guest script, add the rpm-inventory header]. Order = clone.sh `do_collect`. */
|
|
622
|
+
const COLLECT_FILES = [
|
|
623
|
+
["rpm-inventory.tsv", "collect-rpm-inventory.sh", true],
|
|
624
|
+
["rpm-nevra.txt", "collect-rpm-nevra.sh", false],
|
|
625
|
+
["rpmlist.txt", "collect-rpmlist.sh", false],
|
|
626
|
+
["repolist.txt", "collect-repolist.sh", false],
|
|
627
|
+
["updateinfo-all.txt", "collect-updateinfo-all.sh", false],
|
|
628
|
+
["updateinfo-security.txt", "collect-updateinfo-security.sh", false],
|
|
629
|
+
["updateinfo-summary.txt", "collect-updateinfo-summary.sh", false],
|
|
630
|
+
["network-services.txt", "collect-network-services.sh", false],
|
|
631
|
+
["fips-state.txt", "collect-fips-state.sh", false],
|
|
632
|
+
["hardware.txt", "collect-hardware.sh", false],
|
|
633
|
+
["host-checks.txt", "collect-host-checks.sh", false],
|
|
634
|
+
];
|
|
635
|
+
const REQUIRED_NONEMPTY = new Set(["rpm-inventory.tsv", "rpm-nevra.txt", "rpmlist.txt", "fips-state.txt", "hardware.txt"]);
|
|
636
|
+
export { COLLECT_FILES, shq };
|
|
637
|
+
//# sourceMappingURL=evidence.js.map
|