@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
867 lines (830 loc) • 27.4 kB
JavaScript
import { buildAnnotationText } from "../helpers/annotationFormatter.js";
import { table } from "../helpers/table.js";
import { getTimestamp } from "../helpers/utils.js";
import { renderBomAuditConsoleReport } from "../stages/postgen/auditBom.js";
import { severityMeetsThreshold } from "./scoring.js";
const SARIF_VERSION = "2.1.0";
const SARIF_SCHEMA =
"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/Schemata/sarif-schema-2.1.0.json";
const AUDIT_ERROR_RULE_ID = "AUDIT-ERROR";
/**
* Filter results by final severity threshold.
*
* @param {object[]} results results list
* @param {string} minSeverity threshold severity
* @returns {object[]} filtered results
*/
function filterResults(results, minSeverity) {
return results.filter((result) =>
severityMeetsThreshold(result?.assessment?.severity || "none", minSeverity),
);
}
function filterDirectFindingEntries(report, minSeverity) {
const entries = [];
for (const result of report?.results || []) {
for (const finding of result?.findings || []) {
if (severityMeetsThreshold(finding?.severity || "none", minSeverity)) {
entries.push({ finding, result });
}
}
}
return entries;
}
function effectiveResults(report) {
return report.groupedResults?.length
? report.groupedResults
: report.results || [];
}
function formatAnalysisErrorCounts(summary) {
const entries = Object.entries(summary?.analysisErrorCounts || {});
if (!entries.length) {
return undefined;
}
return entries
.sort(([left], [right]) => left.localeCompare(right))
.map(([errorType, count]) => `${errorType}: ${count}`)
.join(", ");
}
function severityToSarifLevel(severity) {
switch (severity) {
case "critical":
case "high":
return "error";
case "medium":
return "warning";
default:
return "note";
}
}
function directBomFindingLocations(finding, result) {
const bomRef =
finding?.location?.bomRef ||
finding?.location?.purl ||
result?.serialNumber ||
result?.source;
if (finding?.location?.file) {
return [
{
physicalLocation: {
artifactLocation: {
uri: finding.location.file,
},
},
logicalLocations: bomRef
? [{ fullyQualifiedName: bomRef, kind: "package" }]
: undefined,
},
];
}
if (bomRef) {
return [
{
logicalLocations: [{ fullyQualifiedName: bomRef, kind: "package" }],
},
];
}
return [
{
logicalLocations: [{ fullyQualifiedName: "cdx-audit", kind: "tool" }],
},
];
}
function deriveDirectBomSarifRules(entries) {
const rulesById = new Map();
for (const { finding } of entries) {
if (rulesById.has(finding.ruleId)) {
continue;
}
rulesById.set(finding.ruleId, {
id: finding.ruleId,
name: finding.name || finding.ruleId,
shortDescription: {
text: finding.name || finding.ruleId,
},
fullDescription: {
text: finding.description || finding.name || finding.ruleId,
},
defaultConfiguration: {
level: severityToSarifLevel(finding.severity),
},
help: finding.mitigation
? {
markdown: `**Remediation:** ${finding.mitigation}`,
text: finding.mitigation,
}
: undefined,
properties: {
attackTactics: finding.attackTactics,
attackTechniques: finding.attackTechniques,
category: finding.category,
engine: "cdx-audit-direct-bom",
tags: attackTags(finding),
},
});
}
return [...rulesById.values()];
}
function directBomFindingToSarifResult(finding, result) {
return {
level: severityToSarifLevel(finding?.severity),
locations: directBomFindingLocations(finding, result),
message: {
text: finding?.message || finding?.description || finding?.ruleId,
},
properties: {
attackTactics: finding?.attackTactics,
attackTechniques: finding?.attackTechniques,
category: finding?.category,
description: finding?.description,
evidence: finding?.evidence,
inputBom: result?.source,
mitigation: finding?.mitigation,
severity: finding?.severity,
},
ruleId: finding?.ruleId || AUDIT_ERROR_RULE_ID,
};
}
function splitCsv(value) {
return String(value || "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function extractLocalDispatchEdge(finding) {
const senderFile = finding?.location?.file;
const receiverFiles = splitCsv(finding?.evidence?.localReceiverWorkflowFiles);
const receiverNames = splitCsv(finding?.evidence?.localReceiverWorkflowNames);
const matchBasis = splitCsv(finding?.evidence?.localReceiverMatchBasis);
const hasLocalDispatchReceiver =
finding?.evidence?.hasLocalDispatchReceiver === "true" ||
receiverFiles.length > 0 ||
receiverNames.length > 0;
if (!senderFile || !hasLocalDispatchReceiver) {
return undefined;
}
return {
matchBasis,
receiverFiles,
receiverNames,
senderFile,
};
}
function formatLocalDispatchEdge(edge) {
if (!edge) {
return undefined;
}
const receiverLabel = edge.receiverNames[0] || edge.receiverFiles[0];
if (!receiverLabel) {
return undefined;
}
return `${edge.senderFile} -> ${receiverLabel}`;
}
function targetSarifLocations(result, findingLocation) {
const bomRef =
findingLocation?.bomRef ||
result?.target?.bomRefs?.[0] ||
result?.target?.purl ||
result?.grouping?.label;
if (findingLocation?.file) {
return [
{
physicalLocation: {
artifactLocation: {
uri: findingLocation.file,
},
},
logicalLocations: bomRef
? [{ fullyQualifiedName: bomRef, kind: "package" }]
: undefined,
},
];
}
if (bomRef) {
return [
{
logicalLocations: [{ fullyQualifiedName: bomRef, kind: "package" }],
},
];
}
return [
{
logicalLocations: [{ fullyQualifiedName: "cdx-audit", kind: "tool" }],
},
];
}
function resultProperties(result) {
const properties = {
auditSeverity: result?.assessment?.severity || "none",
confidence: result?.assessment?.confidenceLabel,
reasons: result?.assessment?.reasons || [],
score: result?.assessment?.score,
status: result?.status,
target: {
bomRefs: result?.target?.bomRefs || [],
name: result?.target?.name,
namespace: result?.target?.namespace,
purl: result?.target?.purl,
type: result?.target?.type,
version: result?.target?.version,
},
};
if (result?.grouping) {
properties.grouping = result.grouping;
}
if (result?.repoUrl) {
properties.repoUrl = result.repoUrl;
}
if (result?.sourceDirectoryConfidence) {
properties.sourceDirectoryConfidence = result.sourceDirectoryConfidence;
}
return properties;
}
function findingProperties(finding) {
const properties = {
attackTactics: finding?.attackTactics,
attackTechniques: finding?.attackTechniques,
category: finding?.category,
mitigation: finding?.mitigation,
severity: finding?.severity,
tags: attackTags(finding),
};
const localDispatchEdge = extractLocalDispatchEdge(finding);
if (localDispatchEdge) {
properties.localDispatchEdge = formatLocalDispatchEdge(localDispatchEdge);
properties.localDispatchReceiverFiles = localDispatchEdge.receiverFiles;
properties.localDispatchReceiverNames = localDispatchEdge.receiverNames;
properties.localDispatchMatchBasis = localDispatchEdge.matchBasis;
}
return properties;
}
function sarifRelatedLocations(finding) {
const localDispatchEdge = extractLocalDispatchEdge(finding);
if (!localDispatchEdge?.receiverFiles?.length) {
return undefined;
}
return localDispatchEdge.receiverFiles.map((receiverFile, index) => ({
id: index + 1,
logicalLocations: localDispatchEdge.receiverNames[index]
? [
{
fullyQualifiedName: localDispatchEdge.receiverNames[index],
kind: "function",
},
]
: undefined,
message: {
text: `Local dispatch receiver: ${
localDispatchEdge.receiverNames[index] || receiverFile
}`,
},
physicalLocation: {
artifactLocation: {
uri: receiverFile,
},
},
}));
}
function sarifHelp(finding, result) {
const helpText = [];
if (finding?.mitigation) {
helpText.push(finding.mitigation);
}
const upstreamEscalation = summarizeUpstreamEscalation(result);
if (upstreamEscalation) {
helpText.push(upstreamEscalation);
}
if (!helpText.length) {
return undefined;
}
return {
markdown: helpText
.map((entry, index) =>
index === 0
? `**Remediation:** ${entry}`
: `**External maintainer path:** ${entry}`,
)
.join("\n\n"),
text: helpText.join(" "),
};
}
function attackTags(finding) {
return [
...(finding?.attackTactics || []),
...(finding?.attackTechniques || []),
].map((id) => `ATT&CK:${id}`);
}
function deriveSarifRules(entries) {
const rulesById = new Map();
for (const entry of entries) {
const finding = entry.finding;
const result = entry.result;
if (rulesById.has(finding.ruleId)) {
continue;
}
rulesById.set(finding.ruleId, {
id: finding.ruleId,
name: finding.name || finding.ruleId,
shortDescription: {
text: finding.name || finding.ruleId,
},
fullDescription: {
text: finding.description || finding.name || finding.ruleId,
},
defaultConfiguration: {
level: severityToSarifLevel(finding.severity),
},
properties: {
attackTactics: finding.attackTactics,
attackTechniques: finding.attackTechniques,
category: finding.category,
engine: finding.engine || "cdx-audit",
tags: attackTags(finding),
},
help: sarifHelp(finding, result),
});
}
return [...rulesById.values()];
}
function findingToSarifResult(finding, result) {
const nextAction = summarizeNextAction(result);
const upstreamEscalation = summarizeUpstreamEscalation(result);
return {
level: severityToSarifLevel(
finding?.severity || result?.assessment?.severity,
),
locations: targetSarifLocations(result, finding?.location),
message: {
text: finding?.message || finding?.description || finding?.ruleId,
},
properties: {
...resultProperties(result),
...findingProperties(finding),
nextAction,
upstreamEscalation,
},
relatedLocations: sarifRelatedLocations(finding),
ruleId: finding?.ruleId || AUDIT_ERROR_RULE_ID,
};
}
function errorToSarifEntry(result) {
const severity = result?.assessment?.severity || "high";
return {
category: result?.errorType || "runtime",
description:
"cdx-audit could not complete predictive analysis for the resolved target.",
message: result?.error || "cdx-audit failed to analyze the target.",
name: "Target analysis error",
ruleId: AUDIT_ERROR_RULE_ID,
severity,
};
}
function consoleTargetLabel(result) {
if (result?.grouping?.label) {
return result.grouping.label;
}
if (result?.target?.purl) {
return result.target.purl;
}
const namespacePrefix = result?.target?.namespace
? `${result.target.namespace}/`
: "";
const versionSuffix = result?.target?.version
? `@${result.target.version}`
: "";
return `${result?.target?.type || "pkg"}:${namespacePrefix}${result?.target?.name || "unknown"}${versionSuffix}`;
}
function topFinding(result) {
return result?.findings?.[0];
}
function summarizeWhy(result) {
const finding = topFinding(result);
const localDispatchEdge = extractLocalDispatchEdge(finding);
if (finding?.message && localDispatchEdge) {
return `${finding.ruleId} — ${finding.message} (${formatLocalDispatchEdge(localDispatchEdge)})`;
}
if (finding?.message) {
return `${finding.ruleId} — ${finding.message}`;
}
if (result?.error) {
return result.error;
}
return (
result?.assessment?.reasons?.[0] || "Review the predictive audit details."
);
}
function groupedPurlPreview(result) {
if (!result?.grouping?.groupedPurls?.length) {
return undefined;
}
const preview = result.grouping.groupedPurls.slice(0, 2).join(", ");
return result.grouping.groupedPurls.length > 2 ? `${preview}, …` : preview;
}
function summarizeReviewFocus(result) {
const finding = topFinding(result);
const localDispatchEdge = extractLocalDispatchEdge(finding);
if (localDispatchEdge?.receiverFiles?.length) {
return `Review sender '${localDispatchEdge.senderFile}' together with receiver '${localDispatchEdge.receiverFiles[0]}' for the flagged workflow-dispatch chain.`;
}
if (finding?.location?.file && result?.repoUrl) {
return `Review '${finding.location.file}' in ${result.repoUrl}.`;
}
if (finding?.location?.file) {
return `Review '${finding.location.file}' for the flagged workflow or release step.`;
}
if (result?.grouping?.memberCount > 1) {
return `Start with ${groupedPurlPreview(result) || result.grouping.label} and inspect the shared repository or workflow pattern.`;
}
if (result?.repoUrl) {
return `Review ${result.repoUrl} for the flagged release workflow, provenance, or publish behavior.`;
}
if (finding?.location?.purl) {
return `Inspect ${finding.location.purl} in your dependency tree and verify its source and release posture.`;
}
if (result?.target?.purl) {
return `Inspect ${result.target.purl} and verify its source repository, release workflow, and provenance signals.`;
}
return "Review the reported target and verify the associated repository, workflow, or package metadata.";
}
function summarizeUpstreamEscalation(result) {
const finding = topFinding(result);
if (finding?.location?.file && result?.repoUrl) {
return `If you do not maintain this repository, open an issue or discussion with the upstream maintainers and reference '${finding.location.file}'.`;
}
if (result?.grouping?.memberCount > 1) {
return `If these dependencies are maintained externally, open an issue or discussion with the upstream maintainers and reference ${result.grouping.label}.`;
}
if (result?.target?.purl) {
return `If this dependency is maintained externally, open an issue or discussion with the upstream maintainers and reference ${result.target.purl}.`;
}
if (result?.repoUrl) {
return "If you do not maintain this repository, open an issue or discussion with the upstream maintainers and share the predictive audit finding.";
}
return undefined;
}
function summarizeNextAction(result) {
const finding = topFinding(result);
if (result?.error) {
return `${summarizeReviewFocus(result)} Verify repository access, source resolution, and clone permissions before re-running the audit.`;
}
const nextSteps = [summarizeReviewFocus(result)];
if (finding?.mitigation) {
nextSteps.push(finding.mitigation);
}
const upstreamEscalation = summarizeUpstreamEscalation(result);
if (upstreamEscalation) {
nextSteps.push(upstreamEscalation);
}
return nextSteps.join(" ");
}
function renderActionTable(results) {
const rows = [
["Severity", "Target", "Why this needs action", "What to do next"],
];
results.forEach((result) => {
rows.push([
result?.assessment?.severity?.toUpperCase() || "NONE",
consoleTargetLabel(result),
summarizeWhy(result),
summarizeNextAction(result),
]);
});
return table(rows, {
columns: [{ width: 10 }, { width: 36 }, { width: 52 }, { width: 68 }],
columnDefault: { wrapWord: false },
});
}
export function renderSarifReport(report, options = {}) {
const minSeverity = options.minSeverity || "low";
const visibleResults = filterResults(effectiveResults(report), minSeverity);
const entries = [];
const sarifResults = [];
for (const result of visibleResults) {
if (result?.findings?.length) {
for (const finding of result.findings) {
entries.push({ finding, result });
sarifResults.push(findingToSarifResult(finding, result));
}
continue;
}
if (result?.error) {
const errorEntry = errorToSarifEntry(result);
entries.push({ finding: errorEntry, result });
sarifResults.push(findingToSarifResult(errorEntry, result));
}
}
const toolName = report?.tool?.name || "cdx-audit";
const toolVersion = report?.tool?.version || "v12";
const log = {
$schema: SARIF_SCHEMA,
version: SARIF_VERSION,
runs: [
{
tool: {
driver: {
informationUri: "https://cdxgen.github.io/cdxgen/",
name: toolName,
rules: deriveSarifRules(entries),
version: toolVersion,
},
},
invocations: [
{
executionSuccessful: report?.summary?.erroredTargets === 0,
},
],
properties: {
aggregateReportFile: report?.aggregateReportFile,
generatedAt: report?.generatedAt,
inputs: report?.inputs || [],
summary: report?.summary,
},
results: sarifResults,
},
],
};
return `${JSON.stringify(log, null, 2)}\n`;
}
/**
* Render an audit report as pretty JSON.
*
* @param {object} report aggregate report
* @returns {string} JSON output
*/
export function renderJsonReport(report) {
return `${JSON.stringify(report, null, 2)}\n`;
}
/**
* Render a direct BOM audit report for terminal output.
*
* @param {object} report aggregate direct audit report
* @param {object} options render options
* @returns {string} console report text
*/
export function renderDirectBomConsoleReport(report, options = {}) {
const minSeverity = options.minSeverity || "low";
const visibleResults = (report?.results || [])
.map((result) => ({
...result,
findings: (result.findings || []).filter((finding) =>
severityMeetsThreshold(finding?.severity || "none", minSeverity),
),
}))
.filter((result) => result.findings.length > 0);
if (report?.results?.length === 1) {
if (visibleResults.length > 0) {
return `${renderBomAuditConsoleReport(visibleResults[0].findings)}\n`;
}
return [
"cdx-audit — direct BOM policy audit",
"",
`Input BOMs: ${report?.summary?.inputBomCount || 0}`,
`Findings: ${report?.summary?.totalFindings || 0}`,
"",
`No direct BOM findings met or exceeded the configured severity threshold ('${minSeverity}').`,
]
.join("\n")
.concat("\n");
}
const lines = [];
lines.push("cdx-audit — direct BOM policy audit");
lines.push("");
lines.push(`Input BOMs: ${report?.summary?.inputBomCount || 0}`);
lines.push(`BOMs with findings: ${report?.summary?.bomsWithFindings || 0}`);
lines.push(`Findings: ${report?.summary?.totalFindings || 0}`);
lines.push("");
if (!visibleResults.length) {
lines.push(
`No direct BOM findings met or exceeded the configured severity threshold ('${minSeverity}').`,
);
return `${lines.join("\n")}\n`;
}
for (const result of visibleResults) {
lines.push(`Input BOM: ${result.source}`);
lines.push(renderBomAuditConsoleReport(result.findings));
lines.push("");
}
return `${lines.join("\n")}\n`;
}
/**
* Render a direct BOM audit report as SARIF 2.1.0 output.
*
* @param {object} report aggregate direct audit report
* @param {object} [options] render options
* @returns {string} SARIF output
*/
export function renderDirectBomSarifReport(report, options = {}) {
const minSeverity = options.minSeverity || "low";
const entries = filterDirectFindingEntries(report, minSeverity);
const sarifResults = entries.map(({ finding, result }) =>
directBomFindingToSarifResult(finding, result),
);
const toolName = report?.tool?.name || "cdx-audit";
const toolVersion = report?.tool?.version || "v12";
const log = {
$schema: SARIF_SCHEMA,
version: SARIF_VERSION,
runs: [
{
tool: {
driver: {
informationUri: "https://cdxgen.github.io/cdxgen/",
name: toolName,
rules: deriveDirectBomSarifRules(entries),
version: toolVersion,
},
},
invocations: [
{
executionSuccessful: true,
},
],
properties: {
auditMode: report?.auditMode,
generatedAt: report?.generatedAt,
inputs: report?.inputs || [],
summary: report?.summary,
},
results: sarifResults,
},
],
};
return `${JSON.stringify(log, null, 2)}\n`;
}
/**
* Render an audit report for terminal output.
*
* @param {object} report aggregate report
* @param {object} options render options
* @returns {string} console report text
*/
export function renderConsoleReport(report, options = {}) {
const minSeverity = options.minSeverity || "low";
const visibleResults = filterResults(effectiveResults(report), minSeverity);
const lines = [];
lines.push("cdx-audit — predictive supply-chain exposure audit");
lines.push("");
lines.push(`Input BOMs: ${report.summary.inputBomCount}`);
lines.push(`Candidate targets: ${report.summary.totalTargets}`);
lines.push(`Scanned targets: ${report.summary.scannedTargets}`);
lines.push(`Errored targets: ${report.summary.erroredTargets}`);
lines.push(`Skipped targets: ${report.summary.skippedTargets}`);
const analysisErrorSummary = formatAnalysisErrorCounts(report.summary);
if (analysisErrorSummary) {
lines.push(`Analysis error types: ${analysisErrorSummary}`);
}
if (report.summary.groupedResultCount) {
lines.push(
`Consolidated alert groups: ${report.summary.groupedResultCount}`,
);
}
lines.push("");
if (!visibleResults.length) {
lines.push("No dependencies require your attention right now.");
lines.push(
`No predictive findings met or exceeded the configured severity threshold ('${minSeverity}').`,
);
if (report.summary?.predictiveDryRun) {
lines.push(
"Dry-run mode only planned predictive audit targets. Registry metadata fetches, upstream repository cloning, and child SBOM generation were intentionally skipped.",
);
if (report.summary.totalTargets > 0) {
lines.push(
"Re-run without --dry-run to analyze the planned targets with the predictive dependency audit.",
);
}
}
if (report.summary.erroredTargets > 0) {
lines.push(
"Some targets could not be fully analyzed, so review the recorded analysis errors before treating this rollup as complete.",
);
}
return `${lines.join("\n")}\n`;
}
lines.push("Dependencies requiring your attention:");
lines.push("");
lines.push(renderActionTable(visibleResults));
lines.push("");
if (report.summary.erroredTargets > 0) {
lines.push(
"Note: one or more targets could not be fully analyzed, so the final rollup may be incomplete until those analysis errors are resolved.",
);
lines.push("");
}
lines.push(
"Next step: review the file, repository, or package listed in 'What to do next'. If you maintain it, make the remediation directly; otherwise, open an upstream issue or discussion with the relevant maintainers, then re-run cdx-audit or cdxgen --bom-audit.",
);
return `${lines.join("\n")}\n`;
}
/**
* Render the requested report format.
*
* @param {string} reportType format name
* @param {object} report aggregate report
* @param {object} options render options
* @returns {string} rendered report
*/
export function renderAuditReport(reportType, report, options = {}) {
if (report?.auditMode === "direct") {
if ((reportType || "console") === "json") {
return renderJsonReport(report);
}
if ((reportType || "console") === "sarif") {
return renderDirectBomSarifReport(report, options);
}
return renderDirectBomConsoleReport(report, options);
}
if ((reportType || "console") === "json") {
return renderJsonReport(report);
}
if ((reportType || "console") === "sarif") {
return renderSarifReport(report, options);
}
return renderConsoleReport(report, options);
}
/**
* Convert predictive audit results into CycloneDX annotations.
*
* @param {object} report aggregate audit report
* @param {object} bomJson root CycloneDX BOM
* @param {object} [options] annotation options
* @returns {object[]} annotations
*/
export function formatPredictiveAnnotations(report, bomJson, options = {}) {
const cdxgenAnnotator = bomJson?.metadata?.tools?.components?.find(
(component) => component.name === "cdxgen",
);
if (!cdxgenAnnotator) {
return [];
}
const minSeverity = options.minSeverity || "low";
const actionableResults = filterResults(
report.results || [],
minSeverity,
).filter((result) => (result?.assessment?.severity || "none") !== "none");
return actionableResults.map((result) => {
const nextAction = summarizeNextAction(result);
const upstreamEscalation = summarizeUpstreamEscalation(result);
const properties = [
{ name: "cdx:audit:engine", value: "cdx-audit" },
{ name: "cdx:audit:severity", value: result.assessment.severity },
{
name: "cdx:audit:confidence",
value: result.assessment.confidenceLabel,
},
{ name: "cdx:audit:score", value: String(result.assessment.score) },
{ name: "cdx:audit:nextAction", value: nextAction },
{ name: "cdx:audit:target:purl", value: result.target.purl },
];
if (upstreamEscalation) {
properties.push({
name: "cdx:audit:upstreamGuidance",
value: upstreamEscalation,
});
}
if (result.repoUrl) {
properties.push({
name: "cdx:audit:target:repoUrl",
value: result.repoUrl,
});
}
if (result.findings?.length) {
const localDispatchEdge = extractLocalDispatchEdge(result.findings[0]);
properties.push({
name: "cdx:audit:topFinding:ruleId",
value: result.findings[0].ruleId,
});
if (localDispatchEdge) {
properties.push({
name: "cdx:audit:dispatch:edge",
value: formatLocalDispatchEdge(localDispatchEdge),
});
if (localDispatchEdge.receiverFiles.length) {
properties.push({
name: "cdx:audit:dispatch:receiverFiles",
value: localDispatchEdge.receiverFiles.join(","),
});
}
if (localDispatchEdge.receiverNames.length) {
properties.push({
name: "cdx:audit:dispatch:receiverNames",
value: localDispatchEdge.receiverNames.join(","),
});
}
}
}
return {
annotator: {
component: cdxgenAnnotator,
},
subjects: result.target.bomRefs?.length
? result.target.bomRefs
: [bomJson.serialNumber],
text: buildAnnotationText(
`Predictive audit score ${result.assessment.score} (${result.assessment.severity}) for ${result.target.purl}.`,
properties,
[result.assessment.reasons?.[0] || "", `Next action: ${nextAction}`],
),
timestamp: getTimestamp(),
};
});
}