feat: add flow-level firewall rule and policy matching with decision classification and audit mode visualization

Add firewall_rule_matches_flow to check if active rules match traffic flows using protocol/port/IP/direction matching with enable status validation, implement policy_matches_flow to evaluate policy definitions against flows with workload/network endpoint resolution and protocol/port matching, add flow_policy_decision to determine final decision from active rules and policies with audit
This commit is contained in:
2026-07-09 19:47:39 +02:00
parent b12ac38c6c
commit 6d5dc310df
2 changed files with 312 additions and 3 deletions
+91
View File
@@ -23,8 +23,29 @@ type TrafficSummary = {
interfaceName: string;
note: string;
ipAddresses: string[];
matchingFirewallRules: Array<Record<string, unknown>>;
matchingAuditPolicies: Array<Record<string, unknown>>;
matchingPolicies: Array<Record<string, unknown>>;
};
function records(value: unknown) {
return Array.isArray(value) ? (value.filter((item) => item && typeof item === "object") as Array<Record<string, unknown>>) : [];
}
function mergeRecords(left: Array<Record<string, unknown>>, right: Array<Record<string, unknown>>) {
const seen = new Set<string>();
const merged: Array<Record<string, unknown>> = [];
for (const item of [...left, ...right]) {
const key = String(item.id ?? item.pos ?? item.comment ?? JSON.stringify(item));
if (seen.has(key)) {
continue;
}
seen.add(key);
merged.push(item);
}
return merged;
}
function formatBytes(value: unknown) {
const bytes = Number(value ?? 0);
if (!Number.isFinite(bytes) || bytes <= 0) {
@@ -49,11 +70,20 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
const bytes = Number(flow.bytes ?? 0);
const packets = Number(flow.packets ?? 0);
const ipAddresses = Array.isArray(flow.ip_addresses) ? flow.ip_addresses.map(String) : [];
const matchingFirewallRules = records(flow.matching_firewall_rules);
const matchingAuditPolicies = records(flow.matching_audit_policies);
const matchingPolicies = records(flow.matching_policies);
if (existing) {
existing.bytes += Number.isFinite(bytes) ? bytes : 0;
existing.packets += Number.isFinite(packets) ? packets : 0;
existing.count += 1;
existing.ipAddresses = Array.from(new Set([...existing.ipAddresses, ...ipAddresses]));
existing.matchingFirewallRules = mergeRecords(existing.matchingFirewallRules, matchingFirewallRules);
existing.matchingAuditPolicies = mergeRecords(existing.matchingAuditPolicies, matchingAuditPolicies);
existing.matchingPolicies = mergeRecords(existing.matchingPolicies, matchingPolicies);
if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") {
existing.decision = String(flow.decision ?? "observed");
}
continue;
}
summaries.set(key, {
@@ -71,6 +101,9 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
interfaceName: String(flow.interface ?? ""),
note: String(flow.note ?? ""),
ipAddresses,
matchingFirewallRules,
matchingAuditPolicies,
matchingPolicies,
});
}
return Array.from(summaries.values()).sort((left, right) => right.bytes - left.bytes);
@@ -154,6 +187,59 @@ function ruleLabel(rule: Record<string, unknown>) {
return `${type} ${action} ${proto}${port}`;
}
function policyLabel(policy: Record<string, unknown>) {
const name = String(policy.name ?? "Policy");
const mode = String(policy.enforcement_mode ?? "enforced");
const decision = String(policy.decision ?? "observed").replace("_", " ");
const protocol = String(policy.protocol ?? "any");
const ports = policy.ports ? `:${String(policy.ports)}` : "";
return `${name} · ${mode} · ${decision} · ${protocol}${ports}`;
}
function decisionClass(decision: string) {
if (decision.includes("would")) {
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
}
if (decision.includes("block")) {
return "border-danger/40 bg-danger/10 text-danger";
}
if (decision.includes("allow")) {
return "border-accent/40 bg-accent/10 text-accent";
}
return "border-border bg-canvas text-slate-500";
}
function FlowRuleContext({ flow }: { flow: TrafficSummary }) {
const activeRules = flow.matchingFirewallRules.slice(0, 2);
const auditPolicies = flow.matchingAuditPolicies.slice(0, 2);
const hasContext = activeRules.length || auditPolicies.length;
if (!hasContext) {
return <div className="mt-1 text-xs text-slate-500">No matching active or audit rule.</div>;
}
return (
<div className="mt-2 grid gap-1.5 text-xs">
{activeRules.map((rule, index) => (
<div key={`rule-${flow.key}-${index}`} className="rounded-md border border-border bg-canvas px-2 py-1">
<span className="font-medium">Rule:</span> {ruleLabel(rule)}
<span className={`ml-2 rounded border px-1.5 py-0.5 ${decisionClass(String(rule.decision ?? "observed"))}`}>{String(rule.decision ?? "observed")}</span>
</div>
))}
{auditPolicies.map((policy, index) => (
<div key={`audit-${flow.key}-${index}`} className="rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-amber-200">
<span className="font-medium">Audit:</span> {policyLabel(policy)}
</div>
))}
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length > activeRules.length + auditPolicies.length ? (
<div className="text-slate-500">
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length - activeRules.length - auditPolicies.length} more match
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length - activeRules.length - auditPolicies.length === 1 ? "" : "es"}
</div>
) : null}
</div>
);
}
function ActiveRulesList({ rules, compact = false }: { rules: Array<Record<string, unknown>>; compact?: boolean }) {
const visibleRules = compact ? rules.slice(0, 3) : rules;
if (!rules.length) {
@@ -241,6 +327,7 @@ function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
<tr>
<th className="px-3 py-2 font-medium">Flow</th>
<th className="px-3 py-2 font-medium">Protocol</th>
<th className="px-3 py-2 font-medium">Decision</th>
<th className="px-3 py-2 font-medium">Traffic</th>
<th className="px-3 py-2 font-medium">Packets</th>
<th className="px-3 py-2 font-medium">Seen</th>
@@ -252,8 +339,12 @@ function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
<td className="px-3 py-3">
<div className="font-medium">{endpointText(flow)}</div>
<div className="text-xs text-slate-500">{flow.ipAddresses.join(", ") || flow.interfaceName || "no endpoint metadata"}</div>
<FlowRuleContext flow={flow} />
</td>
<td className="px-3 py-3">{flow.protocol}{flow.port ? `:${flow.port}` : ""}</td>
<td className="px-3 py-3">
<span className={`inline-flex rounded-md border px-2 py-1 text-xs ${decisionClass(flow.decision)}`}>{flow.decision.replace("_", " ")}</span>
</td>
<td className="px-3 py-3">{formatBytes(flow.bytes)}</td>
<td className="px-3 py-3">{flow.packets}</td>
<td className="px-3 py-3">{flow.count} sample{flow.count === 1 ? "" : "s"}</td>