feat(web): interpolate {{var}} placeholders in analyze-rule explanations

Rule authors can now embed `{{fieldName}}` tokens in explanation,
remediation title, and remediation steps; the renderer substitutes them
with the corresponding evidence value from matchedConditions (resolved
first by event-data field name, then by signal name as fallback).
Unresolved tokens stay literal so typos surface visibly.

Wired into AnalysisResultsSection (session page) and the diagnosis page
hero/quick-fix/expandable cards plus the copyRemediation clipboard text.
ANALYZE-SEC-001 ("Secure Boot UEFI CA 2023 not deployed") now renders
"Current UEFI CA 2023 status: unknown" instead of the raw template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-28 22:57:13 +02:00
parent 6ae95ffe0d
commit f985e9bd71
4 changed files with 150 additions and 10 deletions
@@ -9,6 +9,7 @@ import { useNotifications } from "../../../contexts/NotificationContext";
import { ProtectedRoute } from "../../../components/ProtectedRoute";
import { api } from "@/lib/api";
import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown";
import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { ConfidenceBadge, SeverityBadge } from "./components/DiagnosisBadges";
import { Session, EnrollmentEvent, RuleResult } from "@/types";
@@ -190,7 +191,13 @@ export default function DiagnosisPage() {
const copyRemediation = (result: RuleResult) => {
const text = result.remediation
.map((r) => `${r.title}\n${r.steps.map((s) => ` - ${s}`).join("\n")}`)
.map((r) => {
const title = interpolateRuleTemplate(r.title, result.matchedConditions);
const steps = r.steps
.map((s) => ` - ${interpolateRuleTemplate(s, result.matchedConditions)}`)
.join("\n");
return `${title}\n${steps}`;
})
.join("\n\n");
navigator.clipboard.writeText(text).then(() => {
setCopiedId(result.ruleId);
@@ -379,7 +386,7 @@ export default function DiagnosisPage() {
</h2>
<p className="text-gray-600 leading-relaxed mb-6">
{primaryResult.explanation}
{interpolateRuleTemplate(primaryResult.explanation, primaryResult.matchedConditions)}
</p>
{/* Evidence Summary */}
@@ -435,7 +442,7 @@ export default function DiagnosisPage() {
{primaryResult.remediation.map((rem, i) => (
<div key={i} className="mb-3 last:mb-0">
<p className="text-sm font-medium text-green-700 mb-1">
{rem.title}
{interpolateRuleTemplate(rem.title, primaryResult.matchedConditions)}
</p>
<ul className="space-y-1">
{rem.steps.map((step, j) => (
@@ -446,7 +453,7 @@ export default function DiagnosisPage() {
<span className="text-green-400 mt-0.5">
&#x2022;
</span>
<span>{step}</span>
<span>{interpolateRuleTemplate(step, primaryResult.matchedConditions)}</span>
</li>
))}
</ul>
@@ -581,7 +588,7 @@ export default function DiagnosisPage() {
{expandedResult === result.ruleId && (
<div className="mt-4 pl-16 space-y-3">
<p className="text-sm text-gray-600">
{formatInlineMarkdown(result.explanation)}
{formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))}
</p>
{result.remediation?.length > 0 && (
@@ -605,11 +612,11 @@ export default function DiagnosisPage() {
{result.remediation.map((rem, i) => (
<div key={i} className="mb-2 last:mb-0">
<p className="text-xs font-medium text-gray-700">
{rem.title}
{interpolateRuleTemplate(rem.title, result.matchedConditions)}
</p>
<ul className="list-disc list-inside text-xs text-gray-600 ml-2">
{rem.steps.map((step, j) => (
<li key={j}>{step}</li>
<li key={j}>{interpolateRuleTemplate(step, result.matchedConditions)}</li>
))}
</ul>
</div>
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { RuleResult } from "@/types";
import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown";
import { interpolateRuleTemplate } from "@/lib/interpolateRuleTemplate";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { useAuth } from "@/contexts/AuthContext";
@@ -230,7 +231,9 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?:
{result.explanation && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-1">Explanation</h4>
<p className="text-sm text-gray-600">{formatInlineMarkdown(result.explanation)}</p>
<p className="text-sm text-gray-600">
{formatInlineMarkdown(interpolateRuleTemplate(result.explanation, result.matchedConditions))}
</p>
</div>
)}
@@ -239,10 +242,12 @@ function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?:
<h4 className="text-sm font-medium text-gray-700 mb-1">Remediation</h4>
{result.remediation.map((rem, i) => (
<div key={i} className="mb-2">
<p className="text-sm font-medium text-gray-600">{rem.title}</p>
<p className="text-sm font-medium text-gray-600">
{interpolateRuleTemplate(rem.title, result.matchedConditions)}
</p>
<ul className="list-disc list-inside text-sm text-gray-600 ml-2">
{rem.steps.map((step, j) => (
<li key={j}>{step}</li>
<li key={j}>{interpolateRuleTemplate(step, result.matchedConditions)}</li>
))}
</ul>
</div>
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import { interpolateRuleTemplate } from "../interpolateRuleTemplate";
describe("interpolateRuleTemplate", () => {
const matched = {
ca2023_not_updated: {
eventId: "abc",
sequence: 23,
timestamp: "2026-04-28T18:24:16Z",
field: "uefiCA2023Status",
value: "unknown",
},
secureboot_enabled: {
eventId: "abc",
field: "uefiSecureBootEnabled",
value: "True",
},
};
it("replaces {{field}} tokens from matchedConditions", () => {
const out = interpolateRuleTemplate(
"Status: `{{uefiCA2023Status}}`",
matched
);
expect(out).toBe("Status: `unknown`");
});
it("replaces multiple distinct tokens in one string", () => {
const out = interpolateRuleTemplate(
"SB={{uefiSecureBootEnabled}} CA={{uefiCA2023Status}}",
matched
);
expect(out).toBe("SB=True CA=unknown");
});
it("falls back to signal-name lookup when field is unknown", () => {
const out = interpolateRuleTemplate("{{ca2023_not_updated}}", matched);
expect(out).toBe("unknown");
});
it("tolerates whitespace inside braces", () => {
const out = interpolateRuleTemplate("[{{ uefiCA2023Status }}]", matched);
expect(out).toBe("[unknown]");
});
it("leaves unknown tokens untouched so authors notice the typo", () => {
const out = interpolateRuleTemplate("{{notARealField}}", matched);
expect(out).toBe("{{notARealField}}");
});
it("returns input unchanged when matchedConditions is null/undefined", () => {
expect(interpolateRuleTemplate("hello {{x}}", null)).toBe("hello {{x}}");
expect(interpolateRuleTemplate("hello {{x}}", undefined)).toBe(
"hello {{x}}"
);
});
it("handles non-string scalar values (numbers, booleans)", () => {
const out = interpolateRuleTemplate("seq={{n}} ok={{b}}", {
n: { field: "n", value: 42 },
b: { field: "b", value: false },
});
expect(out).toBe("seq=42 ok=false");
});
it("returns empty string for null/undefined input", () => {
expect(interpolateRuleTemplate(null, matched)).toBe("");
expect(interpolateRuleTemplate(undefined, matched)).toBe("");
});
it("does not blow up on text with no placeholders", () => {
expect(interpolateRuleTemplate("plain text", matched)).toBe("plain text");
});
});
@@ -0,0 +1,54 @@
/**
* Replaces `{{token}}` placeholders in a rule's explanation/remediation text
* with values pulled from the rule's `matchedConditions` evidence map.
*
* Resolution order for `{{token}}`:
* 1. The first matchedConditions entry whose `field` equals `token`
* (e.g. {{uefiCA2023Status}} -> entry where field === "uefiCA2023Status").
* 2. A matchedConditions entry whose key (signal name) equals `token`
* (e.g. {{ca2023_not_updated}} -> that signal's value).
* 3. Unresolved tokens are left untouched so authors notice the typo.
*
* Token chars: [a-zA-Z0-9_]. Whitespace inside the braces is tolerated.
*/
export function interpolateRuleTemplate(
text: string | null | undefined,
matchedConditions: Record<string, unknown> | null | undefined
): string {
if (!text) return text ?? "";
if (!matchedConditions || typeof matchedConditions !== "object") return text;
const byField = new Map<string, string>();
const bySignal = new Map<string, string>();
for (const [signal, evidence] of Object.entries(matchedConditions)) {
if (!evidence || typeof evidence !== "object") {
if (evidence != null) bySignal.set(signal, formatValue(evidence));
continue;
}
const e = evidence as Record<string, unknown>;
if (typeof e.field === "string" && "value" in e) {
byField.set(e.field, formatValue(e.value));
}
if ("value" in e) {
bySignal.set(signal, formatValue(e.value));
}
}
return text.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (raw, token: string) => {
if (byField.has(token)) return byField.get(token)!;
if (bySignal.has(token)) return bySignal.get(token)!;
return raw;
});
}
function formatValue(v: unknown): string {
if (v == null) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}