From d2d51307df0cb581ec1e146e4e76f69aae712a4d Mon Sep 17 00:00:00 2001 From: "Hermes (Tetrax)" <10426516+Tetrax@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:33:17 +0000 Subject: [PATCH 1/4] [verified] feat(policy-engine): add safe affinity-based v2 --- app/web/lib/analyzer.js | 15 +- app/web/lib/forticonfig.js | 67 +- app/web/lib/policy-engine-v2.js | 843 ++++++++++++++++++ ...ent-policy-engine.characterization.test.js | 80 ++ app/web/test/policy-engine-v2.test.js | 519 +++++++++++ 5 files changed, 1513 insertions(+), 11 deletions(-) create mode 100644 app/web/lib/policy-engine-v2.js create mode 100644 app/web/test/current-policy-engine.characterization.test.js create mode 100644 app/web/test/policy-engine-v2.test.js diff --git a/app/web/lib/analyzer.js b/app/web/lib/analyzer.js index d348337..efa6ba2 100644 --- a/app/web/lib/analyzer.js +++ b/app/web/lib/analyzer.js @@ -1,6 +1,7 @@ 'use strict'; const { portName } = require('./ports'); +const { buildPolicyEngineV2 } = require('./policy-engine-v2'); // ─── RFC1918 ────────────────────────────────────────────────────────────────── @@ -596,11 +597,13 @@ function consolidatePolicies(rawPolicies) { for (const p of rawPolicies) { const fp = serviceFingerprint(p); const scopeKey = `${p.scope?.devid || p.scope?.devname || ''}::${p.scope?.vdom || ''}`; - const key = `${scopeKey}||${p.dstTarget}||${fp}`; + const srcIntf = p.flowSrcintf || p.srcintf || ''; + const dstIntf = p.flowDstintf || p.dstintf || ''; + const key = `${scopeKey}||${srcIntf}||${dstIntf}||${p.dstTarget}||${fp}`; if (!phase1.has(key)) { phase1.set(key, { srcs: new Set(), dst: p.dstTarget, dstType: p.dstType, - scope: p.scope || {}, fp, services: p.services, ports: p.ports, protos: p.protos, + scope: p.scope || {}, srcIntf, dstIntf, fp, services: p.services, ports: p.ports, protos: p.protos, serviceTuples: p.serviceTuples || [], serviceDesc: p.serviceDesc, sessions: 0, sentBytes: 0, rcvdBytes: 0, noRcvdFlows: 0, noRcvdSrcHosts: [], }); @@ -619,11 +622,11 @@ function consolidatePolicies(rawPolicies) { for (const e of phase1.values()) { const srcsKey = [...e.srcs].sort().join('|'); const scopeKey = `${e.scope?.devid || e.scope?.devname || ''}::${e.scope?.vdom || ''}`; - const key = `${scopeKey}||${srcsKey}||${e.fp}`; + const key = `${scopeKey}||${e.srcIntf}||${e.dstIntf}||${srcsKey}||${e.fp}`; if (!phase2.has(key)) { phase2.set(key, { srcSubnets: [...e.srcs].sort(), dstTargets: [], dstTypes: {}, - scope: e.scope, fp: e.fp, services: e.services, ports: e.ports, protos: e.protos, + scope: e.scope, srcIntf: e.srcIntf, dstIntf: e.dstIntf, fp: e.fp, services: e.services, ports: e.ports, protos: e.protos, serviceTuples: e.serviceTuples, serviceDesc: e.serviceDesc, sessions: 0, sentBytes: 0, rcvdBytes: 0, noRcvdFlows: 0, noRcvdSrcHosts: [], }); @@ -653,6 +656,9 @@ function consolidatePolicies(rawPolicies) { dstTypes: g.dstTypes, dstTypeSummary, scope: g.scope, + flowSrcintf: g.srcIntf, + srcintf: g.srcIntf, + dstintf: g.dstIntf, services: g.services, ports: g.ports, protos: g.protos, @@ -682,4 +688,5 @@ module.exports = { isExpectedOneWayFlow, buildAnalysis, consolidatePolicies, + buildPolicyEngineV2, }; diff --git a/app/web/lib/forticonfig.js b/app/web/lib/forticonfig.js index 6177392..a7f0786 100644 --- a/app/web/lib/forticonfig.js +++ b/app/web/lib/forticonfig.js @@ -1009,7 +1009,7 @@ function findServiceByName(label, observedPorts, protoName, customServices) { if (!observedPorts?.length) return true; const portSet = isUdp ? cs._udpSet : cs._tcpSet; const ports = isUdp ? (cs.udpPorts || []) : (cs.tcpPorts || []); - return observedPorts.every(port => portSet ? portSet.has(Number(port)) : ports.includes(Number(port))); + return observedPorts.every(port => portSet instanceof Set ? portSet.has(Number(port)) : ports.includes(Number(port))); }; // 1. Correspondance exacte, mais jamais au prix d'une incompatibilité port/protocole. @@ -1075,14 +1075,17 @@ function findService(port, protoName, customServices, opts) { for (const [name, svc] of Object.entries(customServices)) { const ports = isUdp ? svc.udpPorts : svc.tcpPorts; const portSet = isUdp ? svc._udpSet : svc._tcpSet; // P1: lookup O(1) - if (ports.length <= maxPortCount && (portSet ? portSet.has(p) : ports.includes(p))) { + if (ports.length <= maxPortCount && (portSet instanceof Set ? portSet.has(p) : ports.includes(p))) { matches.push({ name, source: 'custom', portCount: ports.length }); } } if (matches.length === 0) return { found: false }; - // Prefer most specific match (fewest ports) - matches.sort((a, b) => a.portCount - b.portCount); + // Prefer the most specific match, then an exact object from the loaded + // configuration over a predefined alias with the same cardinality. + matches.sort((a, b) => a.portCount - b.portCount + || (a.source === 'custom' ? 0 : 1) - (b.source === 'custom' ? 0 : 1) + || a.name.localeCompare(b.name)); return { found: true, name: matches[0].name, source: matches[0].source, allMatches: matches }; } @@ -1207,7 +1210,7 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { const isUdpTuple = /^(17|udp)$/i.test(String(t.proto)); const set = isUdpTuple ? customCandidate._udpSet : customCandidate._tcpSet; const ports = isUdpTuple ? customCandidate.udpPorts : customCandidate.tcpPorts; - return set ? set.has(port) : (ports || []).includes(port); + return set instanceof Set ? set.has(port) : (ports || []).includes(port); })) ? customCandidate : null; // Try ICMP/CODE/TYPE label matching if not directly found const icmpMatch = (!knownPredef && !customMatch) ? findIcmpService(svc, customServices) : null; @@ -2060,6 +2063,22 @@ function segmentationFlowServiceKey(flow) { return Number.isInteger(port) && proto ? `${port}/${proto}` : ''; } +function segmentationFlowTechnicalKey(flow) { + const port = Number(flow?.dstport ?? flow?.port); + const rawProto = String(flow?.proto || '').trim().toUpperCase(); + const proto = /^(6|tcp)$/i.test(rawProto) ? 'TCP' + : /^(17|udp)$/i.test(rawProto) ? 'UDP' + : /^(1|icmp)$/i.test(rawProto) ? 'ICMP' + : rawProto ? `PROTO-${rawProto}` : 'PROTO-UNKNOWN'; + if (proto === 'ICMP') { + const icmp = String(flow?.service || '').trim().toUpperCase().match(/^ICMP\/(\d+)\/(\d+)$/); + if (icmp) return `ICMP:${Number(icmp[1])}:${Number(icmp[2])}`; + } + return ['TCP', 'UDP'].includes(proto) && Number.isInteger(port) && port >= 1 + ? `${proto}:${port}` + : proto; +} + function segmentationServiceMatchesTuple(service, tuple) { const wanted = segmentationServiceKey(service); const named = String(tuple?.service || '').toUpperCase(); @@ -2273,9 +2292,10 @@ function preflightValidation(selectedPolicies, config, observedFlows = null) { } const effectiveDestinationMode = isWan ? 'host' : plan.destination; - const exactScope = plan.source === 'host' + const v2SafeExact = p._policyEngineV2?.safeExact === true; + const exactScope = v2SafeExact || (plan.source === 'host' && effectiveDestinationMode === 'host' - && plan.services === 'separate'; + && plan.services === 'separate'); if (exactScope) exactScopePolicies++; else generalizedPolicies++; const srcEvidenceHosts = segmentationEvidenceHosts(p, 'src'); @@ -2302,6 +2322,39 @@ function preflightValidation(selectedPolicies, config, observedFlows = null) { if (!evidenceFlows.length) { issues.push({ level: 'error', msg: `${label}: aucun flux accepté ne prouve cette règle` }); + } else if (v2SafeExact) { + for (const src of srcEvidenceHosts) { + for (const dst of dstEvidenceHosts) { + for (const serviceKey of (p.serviceKeys || [])) { + if (!evidenceFlows.some(flow => + flow.srcip === src && flow.dstip === dst && segmentationFlowTechnicalKey(flow) === serviceKey + )) { + issues.push({ + level: 'error', + msg: `${label}: couple ${src} → ${dst} / ${serviceKey} non observé`, + }); + } + } + } + } + } else if (plan.source === 'host' && effectiveDestinationMode === 'host' && (p.serviceTuples || []).length) { + const technicalKeys = [...new Set((p.serviceTuples || []) + .map(segmentationFlowTechnicalKey) + .filter(Boolean))]; + for (const src of srcEvidenceHosts) { + for (const dst of dstEvidenceHosts) { + for (const serviceKey of technicalKeys) { + if (!evidenceFlows.some(flow => + flow.srcip === src && flow.dstip === dst && segmentationFlowTechnicalKey(flow) === serviceKey + )) { + issues.push({ + level: 'error', + msg: `${label}: couple ${src} → ${dst} / ${serviceKey} non observé`, + }); + } + } + } + } } else if (plan.source === 'host' && effectiveDestinationMode === 'host') { for (const src of srcEvidenceHosts) { for (const dst of dstEvidenceHosts) { diff --git a/app/web/lib/policy-engine-v2.js b/app/web/lib/policy-engine-v2.js new file mode 100644 index 0000000..61f5c13 --- /dev/null +++ b/app/web/lib/policy-engine-v2.js @@ -0,0 +1,843 @@ +'use strict'; + +const { PREDEFINED } = require('./forticonfig'); + +const PROFILE_NAMES = new Set(['recommended', 'strict', 'synthetic', 'expert']); + +function normalizeProtocol(proto) { + const value = String(proto || '').trim().toUpperCase(); + if (value === '6' || value === 'TCP') return 'TCP'; + if (value === '17' || value === 'UDP') return 'UDP'; + if (value === '1' || value === 'ICMP') return 'ICMP'; + return value ? `PROTO-${value}` : 'PROTO-UNKNOWN'; +} + +function protocolNumber(protocol) { + if (protocol === 'TCP') return '6'; + if (protocol === 'UDP') return '17'; + if (protocol === 'ICMP') return '1'; + return protocol.replace(/^PROTO-/, ''); +} + +function canonicalService(flow) { + const protocol = normalizeProtocol(flow.proto); + const observedLabel = String(flow.service || '').trim().toUpperCase(); + const icmp = protocol === 'ICMP' ? observedLabel.match(/^ICMP\/(\d+)\/(\d+)$/) : null; + const icmpType = icmp ? Number(icmp[1]) : null; + const icmpCode = icmp ? Number(icmp[2]) : null; + const parsedPort = Number(flow.dstport); + const port = ['TCP', 'UDP'].includes(protocol) + && Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 + ? parsedPort + : null; + const invalidPort = ['TCP', 'UDP'].includes(protocol) && port === null; + const key = icmp + ? `${protocol}:${icmpType}:${icmpCode}` + : port === null ? protocol : `${protocol}:${port}`; + const label = observedLabel || (port === null ? protocol : `${protocol}/${port}`); + return { key, protocol, port, label, icmpType, icmpCode, invalidPort }; +} + +function scopeOf(flow) { + return { + devid: String(flow.devid || ''), + devname: String(flow.devname || ''), + vdom: String(flow.vdom || ''), + }; +} + +function scopeKey(scope) { + return `${scope.devid || scope.devname || 'unknown-device'}::${scope.vdom || 'root'}`; +} + +function isAllowedFlow(flow) { + const decision = String(flow.decision || '').toLowerCase(); + const action = String(flow.action || '').toLowerCase(); + return decision === 'allow' || ['accept', 'accepted', 'allow', 'pass'].includes(action); +} + +function isDeployableAllow(flow) { + return flow.deploymentEligible === true && isAllowedFlow(flow); +} + +function summarizeInput(flows) { + const summary = { + inputFlows: 0, + inputSessions: 0, + includedFlows: 0, + includedSessions: 0, + excludedFlows: 0, + excludedSessions: 0, + exclusionReasons: {}, + }; + for (const flow of (flows || [])) { + const sessions = Number(flow.count || 1); + summary.inputFlows++; + summary.inputSessions += sessions; + let reason = null; + if (!isAllowedFlow(flow)) reason = 'not_allowed'; + else if (flow.deploymentEligible !== true) reason = 'deployment_ineligible'; + else if (!flow.srcip || !flow.dstip) reason = 'missing_endpoint'; + if (reason) { + summary.excludedFlows++; + summary.excludedSessions += sessions; + summary.exclusionReasons[reason] = (summary.exclusionReasons[reason] || 0) + sessions; + } else { + summary.includedFlows++; + summary.includedSessions += sessions; + } + } + return summary; +} + +function atomSortKey(atom) { + return [atom.partitionKey, atom.source, atom.destination, atom.service.key].join('|'); +} + +function defaultAddressType(ip) { + return String(ip).startsWith('10.') + || String(ip).startsWith('192.168.') + || /^172\.(1[6-9]|2\d|3[01])\./.test(String(ip)) + ? 'private' + : 'public'; +} + +function canonicalizeFlows(flows) { + const aggregated = new Map(); + for (const flow of (flows || [])) { + if (!isDeployableAllow(flow) || !flow.srcip || !flow.dstip) continue; + const scope = scopeOf(flow); + const sourceInterface = String(flow.srcintf || ''); + const destinationInterface = String(flow.dstintf || ''); + const partitionKey = [scopeKey(scope), sourceInterface, destinationInterface].join('||'); + const service = canonicalService(flow); + const key = [partitionKey, flow.srcip, flow.dstip, service.key].join('||'); + if (!aggregated.has(key)) { + aggregated.set(key, { + id: '', + source: String(flow.srcip), + destination: String(flow.dstip), + sourceType: ['private', 'public'].includes(flow.srcType) ? flow.srcType : defaultAddressType(flow.srcip), + destinationType: ['private', 'public'].includes(flow.dstType) ? flow.dstType : defaultAddressType(flow.dstip), + protocol: service.protocol, + destinationPort: service.port, + service, + sourceInterface, + destinationInterface, + scope, + partitionKey, + count: 0, + sentBytes: 0, + receivedBytes: 0, + firstSeen: null, + lastSeen: null, + days: new Set(), + observedLabels: new Set(), + }); + } + const atom = aggregated.get(key); + atom.count += Number(flow.count || 1); + atom.sentBytes += Number(flow.sentBytes || 0); + atom.receivedBytes += Number(flow.rcvdBytes || 0); + if (flow.firstTs != null && (atom.firstSeen == null || flow.firstTs < atom.firstSeen)) atom.firstSeen = flow.firstTs; + if (flow.lastTs != null && (atom.lastSeen == null || flow.lastTs > atom.lastSeen)) atom.lastSeen = flow.lastTs; + for (const day of (flow.days || [])) atom.days.add(String(day)); + if (service.label) atom.observedLabels.add(service.label); + if (flow.srcType === 'private') atom.sourceType = 'private'; + if (flow.dstType === 'private') atom.destinationType = 'private'; + } + + return [...aggregated.values()] + .sort((a, b) => atomSortKey(a).localeCompare(atomSortKey(b))) + .map((atom, index) => { + const labels = [...atom.observedLabels].sort(); + return { + ...atom, + id: `A-${String(index + 1).padStart(6, '0')}`, + service: { ...atom.service, label: labels[0] || atom.service.label }, + days: [...atom.days].sort(), + observedLabels: labels, + }; + }); +} + +function exactExistingService(service, customServices) { + if (service.protocol === 'ICMP' && service.icmpType != null && service.icmpCode != null) { + return Object.values(customServices || {}) + .filter(candidate => candidate.proto === 'ICMP' + && candidate.icmptype === service.icmpType + && candidate.icmpcode === service.icmpCode) + .map(candidate => candidate.name) + .filter(Boolean) + .sort()[0] || null; + } + if (service.port == null || !['TCP', 'UDP'].includes(service.protocol)) return null; + const expectedField = service.protocol === 'TCP' ? 'tcpPorts' : 'udpPorts'; + const otherField = service.protocol === 'TCP' ? 'udpPorts' : 'tcpPorts'; + return Object.values(customServices || {}) + .filter(candidate => { + const expected = [...new Set(candidate[expectedField] || [])].sort((a, b) => a - b); + const other = [...new Set(candidate[otherField] || [])]; + return expected.length === 1 && expected[0] === service.port && other.length === 0; + }) + .map(candidate => candidate.name) + .filter(Boolean) + .sort()[0] || null; +} + +function exactPredefinedService(service) { + if (service.port == null || !['TCP', 'UDP'].includes(service.protocol)) return null; + const candidate = PREDEFINED[service.port]; + if (!candidate || candidate.proto === 'both') return null; + if (candidate.proto.toUpperCase() !== service.protocol) return null; + return candidate.name; +} + +function buildServiceInventory(atoms, fortiConfig) { + const grouped = new Map(); + for (const atom of atoms) { + if (!grouped.has(atom.service.key)) grouped.set(atom.service.key, []); + grouped.get(atom.service.key).push(atom); + } + return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, serviceAtoms]) => { + const service = serviceAtoms[0].service; + const existing = exactExistingService(service, fortiConfig?.customServices || {}); + const predefined = existing ? null : exactPredefinedService(service); + const count = serviceAtoms.reduce((sum, atom) => sum + atom.count, 0); + const days = new Set(serviceAtoms.flatMap(atom => atom.days || [])); + const dynamic = service.protocol === 'TCP' && service.port >= 49152 && service.port <= 65535; + let classification; + if (existing) classification = 'existing'; + else if (predefined) classification = 'predefined'; + else if (service.invalidPort) classification = 'unresolved-port'; + else if (!['TCP', 'UDP'].includes(service.protocol)) classification = 'unresolved-protocol'; + else if (dynamic) classification = 'dynamic'; + else if (count <= 1) classification = 'rare'; + else if (days.size >= 2 || count >= 10) classification = 'custom-stable'; + else classification = 'application-specific'; + const rpcCandidate = dynamic && serviceAtoms.some(atom => + atom.service.label.includes('RPC') + || atoms.some(other => other.partitionKey === atom.partitionKey + && other.source === atom.source + && other.destination === atom.destination + && other.service.protocol === 'TCP' + && other.service.port === 135) + ); + return { + key, + protocol: service.protocol, + port: service.port, + label: service.label, + classification, + selectedObject: existing || predefined || null, + observedCount: count, + observedDays: days.size, + rpcCandidate, + generalizedRange: null, + deploymentBlocked: classification === 'unresolved-protocol' || classification === 'unresolved-port', + }; + }); +} + +function ipv4ToInt(ip) { + const parts = String(ip || '').split('.').map(Number); + if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return null; + return parts.reduce((value, part) => ((value * 256) + part) >>> 0, 0) >>> 0; +} + +function intToIpv4(value) { + const n = value >>> 0; + return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.'); +} + +function parseCidr(cidr) { + const match = String(cidr || '').match(/^(\d{1,3}(?:\.\d{1,3}){3})\/(\d|[12]\d|3[0-2])$/); + if (!match) return null; + const ip = ipv4ToInt(match[1]); + const prefix = Number(match[2]); + if (ip == null) return null; + const mask = prefix === 0 ? 0 : (0xFFFFFFFF << (32 - prefix)) >>> 0; + const network = (ip & mask) >>> 0; + const size = 2 ** (32 - prefix); + return { + cidr: `${intToIpv4(network)}/${prefix}`, + prefix, + network, + size, + contains(address) { + const candidate = ipv4ToInt(address); + return candidate != null && ((candidate & mask) >>> 0) === network; + }, + }; +} + +function enumerateCidr(parsed) { + return Array.from({ length: parsed.size }, (_unused, offset) => intToIpv4((parsed.network + offset) >>> 0)); +} + +function networkCandidates(options) { + const configured = (options.networks || []).map(network => ({ cidr: network.cidr, name: network.name || null })); + for (const [name, address] of Object.entries(options.fortiConfig?.addresses || {})) { + configured.push({ cidr: address.cidr, name }); + } + const deduped = new Map(); + for (const candidate of configured) { + const parsed = parseCidr(candidate.cidr); + if (!parsed || parsed.prefix === 32) continue; + const existing = deduped.get(parsed.cidr); + if (!existing || (!existing.name && candidate.name)) deduped.set(parsed.cidr, { ...parsed, name: candidate.name }); + } + return [...deduped.values()].sort((a, b) => b.prefix - a.prefix || a.cidr.localeCompare(b.cidr)); +} + +function chooseNetwork(members, candidates, config) { + if (members.length < config.minHosts) return null; + for (const candidate of candidates) { + if (candidate.prefix < config.minPrefix || candidate.size > config.maxAddresses) continue; + if (!members.every(member => candidate.contains(member))) continue; + const density = members.length / candidate.size; + if (density < config.minDensity) continue; + return { candidate, density }; + } + return null; +} + +function applySyntheticAggregation(policies, options) { + const requested = options.networkAggregation || {}; + const config = { + minDensity: Number.isFinite(requested.minDensity) ? requested.minDensity : 0.8, + minHosts: Number.isInteger(requested.minHosts) ? requested.minHosts : 4, + minPrefix: Number.isInteger(requested.minPrefix) ? requested.minPrefix : 23, + maxAddresses: Number.isInteger(requested.maxAddresses) ? requested.maxAddresses : 4096, + }; + const candidates = networkCandidates(options); + if (!candidates.length) return policies.map(policy => ({ + ...policy, + profile: 'synthetic', + networkAggregation: {}, + _policyEngineV2: { ...policy._policyEngineV2, profile: 'synthetic', safeExact: true }, + })); + + return policies.map(policy => { + const result = { + ...policy, + profile: 'synthetic', + networkAggregation: {}, + _policyEngineV2: { ...policy._policyEngineV2, profile: 'synthetic', safeExact: true }, + }; + const sourceChoice = chooseNetwork(policy.sources, candidates, config); + if (sourceChoice) { + const { candidate, density } = sourceChoice; + result.allowedSources = enumerateCidr(candidate); + result.srcSubnet = candidate.cidr; + result.srcSubnets = [candidate.cidr]; + result._srcCidrOverride = candidate.cidr; + result._use32Src = false; + result._srcMode = 'subnet'; + result._useSrcGroup = false; + result._multiSrcSubnets = null; + result._segmentationPlan = { ...result._segmentationPlan, source: 'network' }; + result._policyEngineV2.safeExact = false; + result.networkAggregation.source = { + cidr: candidate.cidr, + objectName: candidate.name, + observedHosts: policy.sources.length, + possibleHosts: candidate.size, + density, + additionalHosts: candidate.size - policy.sources.length, + }; + } + + const destinationChoice = policy.dstType === 'private' + ? chooseNetwork(policy.destinations, candidates, config) + : null; + if (destinationChoice) { + const { candidate, density } = destinationChoice; + result.allowedDestinations = enumerateCidr(candidate); + result.dstTarget = candidate.cidr; + result.dstTargets = [candidate.cidr]; + result._dstCidrOverride = candidate.cidr; + result._use32Dst = false; + result._dstMode = 'subnet'; + result._useDstGroup = false; + result._isMultiDst = false; + result._multiDstSubnets = null; + result._segmentationPlan = { ...result._segmentationPlan, destination: 'network' }; + result._policyEngineV2.safeExact = false; + result.networkAggregation.destination = { + cidr: candidate.cidr, + objectName: candidate.name, + observedHosts: policy.destinations.length, + possibleHosts: candidate.size, + density, + additionalHosts: candidate.size - policy.destinations.length, + }; + } + return result; + }); +} + +function edgeKey(atom) { + return `${atom.source}|${atom.destination}`; +} + +function buildRectanglesBySource(edges) { + const destinationsBySource = new Map(); + for (const edge of edges) { + if (!destinationsBySource.has(edge.source)) destinationsBySource.set(edge.source, new Set()); + destinationsBySource.get(edge.source).add(edge.destination); + } + const groups = new Map(); + for (const [source, destinations] of destinationsBySource) { + const sortedDestinations = [...destinations].sort(); + const signature = sortedDestinations.join('|'); + if (!groups.has(signature)) groups.set(signature, { sources: [], destinations: sortedDestinations }); + groups.get(signature).sources.push(source); + } + return [...groups.values()].map(group => ({ + sources: group.sources.sort(), + destinations: group.destinations, + orientation: 'source', + })); +} + +function buildRectanglesByDestination(edges) { + const sourcesByDestination = new Map(); + for (const edge of edges) { + if (!sourcesByDestination.has(edge.destination)) sourcesByDestination.set(edge.destination, new Set()); + sourcesByDestination.get(edge.destination).add(edge.source); + } + const groups = new Map(); + for (const [destination, sources] of sourcesByDestination) { + const sortedSources = [...sources].sort(); + const signature = sortedSources.join('|'); + if (!groups.has(signature)) groups.set(signature, { sources: sortedSources, destinations: [] }); + groups.get(signature).destinations.push(destination); + } + return [...groups.values()].map(group => ({ + sources: group.sources, + destinations: group.destinations.sort(), + orientation: 'destination', + })); +} + +function rectangleSortKey(rectangle) { + return [rectangle.destinations[0] || '', String(rectangle.destinations.length).padStart(8, '0'), rectangle.destinations.join(','), rectangle.sources.join(',')].join('|'); +} + +function chooseRectangles(edges) { + const bySource = buildRectanglesBySource(edges); + const byDestination = buildRectanglesByDestination(edges); + const chosen = byDestination.length < bySource.length ? byDestination : bySource; + return chosen.sort((a, b) => rectangleSortKey(a).localeCompare(rectangleSortKey(b))); +} + +function confidenceForAtoms(atoms) { + const days = new Set(atoms.flatMap(atom => atom.days || [])); + if (!atoms.some(atom => atom.firstSeen != null)) return 'unknown'; + if (days.size <= 1) return 'low'; + if (days.size >= 7) return 'high'; + return 'medium'; +} + +function policySortKey(policy) { + return [ + policy.partitionKey, + policy.destinations[0] || '', + String(policy.destinations.length).padStart(8, '0'), + policy.destinations.join(','), + policy.sources.join(','), + policy.serviceKeys.join(','), + ].join('|'); +} + +function mergeIdenticalRectangles(policies) { + const groups = new Map(); + for (const policy of policies) { + const key = [policy.partitionKey, policy.sources.join(','), policy.destinations.join(',')].join('||'); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(policy); + } + return [...groups.values()].map(group => { + if (group.length === 1) return group[0]; + const base = group[0]; + const serviceDescriptors = [...new Map( + group.flatMap(policy => policy.serviceDescriptors).map(service => [service.key, service]) + ).values()].sort((a, b) => a.key.localeCompare(b.key)); + const tupleByKey = new Map(); + for (const tuple of group.flatMap(policy => policy.serviceTuples)) { + const key = `${tuple.proto}|${tuple.port}|${tuple.service}`; + if (!tupleByKey.has(key)) tupleByKey.set(key, { ...tuple, sessions: 0 }); + tupleByKey.get(key).sessions += Number(tuple.sessions || 0); + } + const days = [...new Set(group.flatMap(policy => policy.days || []))].sort(); + const firstSeen = group.map(policy => policy.firstSeen).filter(value => value != null); + const lastSeen = group.map(policy => policy.lastSeen).filter(value => value != null); + return { + ...base, + serviceKeys: serviceDescriptors.map(service => service.key), + serviceDescriptors, + serviceTuples: [...tupleByKey.values()].sort((a, b) => `${a.proto}|${a.port}|${a.service}`.localeCompare(`${b.proto}|${b.port}|${b.service}`)), + services: serviceDescriptors.map(service => service.label), + ports: [...new Set(serviceDescriptors.map(service => service.port).filter(port => port != null))].sort((a, b) => a - b), + protos: [...new Set(serviceDescriptors.map(service => service.protocol))].sort(), + serviceDesc: serviceDescriptors.map(service => service.label).join(', '), + analysis: { ...base.analysis, services: serviceDescriptors }, + sessions: group.reduce((sum, policy) => sum + policy.sessions, 0), + sentBytes: group.reduce((sum, policy) => sum + policy.sentBytes, 0), + rcvdBytes: group.reduce((sum, policy) => sum + policy.rcvdBytes, 0), + firstSeen: firstSeen.length ? Math.min(...firstSeen) : null, + lastSeen: lastSeen.length ? Math.max(...lastSeen) : null, + days, + daysObserved: firstSeen.length ? days.length : null, + confidence: group.every(policy => policy.confidence === group[0].confidence) ? group[0].confidence : 'mixed', + trace: { + atomIds: [...new Set(group.flatMap(policy => policy.trace.atomIds))].sort(), + reason: 'Services fusionnés sur un rectangle source-destination strictement identique', + orientation: base.trace.orientation, + }, + }; + }); +} + +function buildRecommendedPolicies(atoms) { + const partitions = new Map(); + for (const atom of atoms) { + if (!partitions.has(atom.partitionKey)) partitions.set(atom.partitionKey, []); + partitions.get(atom.partitionKey).push(atom); + } + + const policies = []; + for (const partitionAtoms of partitions.values()) { + const services = new Map(); + for (const atom of partitionAtoms) { + if (!services.has(atom.service.key)) services.set(atom.service.key, []); + services.get(atom.service.key).push(atom); + } + + const behaviorGroups = new Map(); + for (const [serviceKey, serviceAtoms] of services) { + const signature = serviceAtoms.map(edgeKey).sort().join(','); + if (!behaviorGroups.has(signature)) behaviorGroups.set(signature, { serviceKeys: [], atoms: [] }); + const group = behaviorGroups.get(signature); + group.serviceKeys.push(serviceKey); + group.atoms.push(...serviceAtoms); + } + + for (const group of behaviorGroups.values()) { + group.serviceKeys.sort(); + const representativeEdges = services.get(group.serviceKeys[0]); + const serviceAtoms = group.serviceKeys.map(key => services.get(key)[0]); + const serviceDescriptors = serviceAtoms.map(atom => ({ ...atom.service })).sort((a, b) => a.key.localeCompare(b.key)); + for (const rectangle of chooseRectangles(representativeEdges)) { + const sourceSet = new Set(rectangle.sources); + const destinationSet = new Set(rectangle.destinations); + const supportingAtoms = group.atoms.filter(atom => sourceSet.has(atom.source) && destinationSet.has(atom.destination)); + const base = supportingAtoms[0]; + const observedDays = [...new Set(supportingAtoms.flatMap(atom => atom.days || []))].sort(); + const firstSeenValues = supportingAtoms.map(atom => atom.firstSeen).filter(value => value != null); + const lastSeenValues = supportingAtoms.map(atom => atom.lastSeen).filter(value => value != null); + const serviceTuples = serviceDescriptors.map(service => ({ + proto: protocolNumber(service.protocol), + port: service.port == null ? '' : String(service.port), + service: service.label, + sessions: supportingAtoms + .filter(atom => atom.service.key === service.key) + .reduce((sum, atom) => sum + atom.count, 0), + })); + policies.push({ + id: '', + name: '', + profile: 'recommended', + scope: base.scope, + partitionKey: base.partitionKey, + sourceInterface: base.sourceInterface, + destinationInterface: base.destinationInterface, + flowSrcintf: base.sourceInterface, + srcintf: base.sourceInterface, + dstintf: base.destinationInterface, + _srcintf: base.sourceInterface, + _dstintf: base.destinationInterface, + sources: rectangle.sources, + destinations: rectangle.destinations, + serviceKeys: group.serviceKeys, + serviceDescriptors, + serviceTuples, + services: serviceDescriptors.map(service => service.label), + ports: serviceDescriptors.map(service => service.port).filter(port => port != null), + protos: [...new Set(serviceDescriptors.map(service => service.protocol))].sort(), + serviceDesc: serviceDescriptors.map(service => service.label).join(', '), + srcHosts: rectangle.sources, + dstHosts: rectangle.destinations, + srcSubnet: `${rectangle.sources[0]}/32`, + srcSubnets: rectangle.sources.map(source => `${source}/32`), + dstTarget: `${rectangle.destinations[0]}/32`, + dstTargets: rectangle.destinations.map(destination => `${destination}/32`), + dstType: base.destinationType, + _use32Src: true, + _use32Dst: true, + _srcMode: 'hosts', + _dstMode: 'hosts', + _useSrcGroup: rectangle.sources.length > 1, + _useDstGroup: rectangle.destinations.length > 1, + _segmentationPlan: { source: 'host', destination: 'host', services: 'grouped' }, + _segmentationEvidence: { + verified: true, + observedPairCount: rectangle.sources.length * rectangle.destinations.length, + }, + _policyEngineV2: { profile: 'recommended', safeExact: true }, + _isMultiDst: rectangle.destinations.length > 1, + _multiSrcSubnets: rectangle.sources.length > 1 + ? rectangle.sources.map(source => ({ subnet: `${source}/32`, hosts: [source], useSubnet: false, addrFound: false, addrName: '' })) + : null, + _multiDstSubnets: rectangle.destinations.length > 1 + ? rectangle.destinations.map(destination => ({ subnet: `${destination}/32`, hosts: [destination], useSubnet: false, addrFound: false, addrName: '' })) + : null, + sessions: supportingAtoms.reduce((sum, atom) => sum + atom.count, 0), + sentBytes: supportingAtoms.reduce((sum, atom) => sum + atom.sentBytes, 0), + rcvdBytes: supportingAtoms.reduce((sum, atom) => sum + atom.receivedBytes, 0), + firstSeen: firstSeenValues.length ? Math.min(...firstSeenValues) : null, + lastSeen: lastSeenValues.length ? Math.max(...lastSeenValues) : null, + days: observedDays, + daysObserved: firstSeenValues.length ? observedDays.length : null, + confidence: confidenceForAtoms(supportingAtoms), + analysis: { services: serviceDescriptors }, + trace: { + atomIds: supportingAtoms.map(atom => atom.id).sort(), + reason: group.serviceKeys.length > 1 + ? `Services aux signatures source-destination identiques; rectangle ${rectangle.orientation} complet` + : `Rectangle ${rectangle.orientation} complet sans tuple ajouté`, + orientation: rectangle.orientation, + }, + action: 'accept', + }); + } + } + } + + return mergeIdenticalRectangles(policies) + .sort((a, b) => policySortKey(a).localeCompare(policySortKey(b))) + .map((policy, index) => ({ + ...policy, + id: `P-${String(index + 1).padStart(5, '0')}`, + name: `FFV2-${String(index + 1).padStart(5, '0')}`, + })); +} + +function permissionKey(partitionKey, source, destination, serviceKey) { + return [partitionKey, source, destination, serviceKey].join('||'); +} + +function evaluatePolicies(atoms, policies) { + const observed = new Set(atoms.map(atom => permissionKey(atom.partitionKey, atom.source, atom.destination, atom.service.key))); + const allowed = new Set(); + for (const policy of policies) { + for (const source of (policy.allowedSources || policy.sources)) { + for (const destination of (policy.allowedDestinations || policy.destinations)) { + for (const serviceKey of policy.serviceKeys) { + allowed.add(permissionKey(policy.partitionKey, source, destination, serviceKey)); + } + } + } + } + let coveredRequiredTuples = 0; + for (const tuple of observed) if (allowed.has(tuple)) coveredRequiredTuples++; + let unexpectedAllowedTuples = 0; + for (const tuple of allowed) if (!observed.has(tuple)) unexpectedAllowedTuples++; + const missingRequiredTuples = observed.size - coveredRequiredTuples; + return { + observedRequiredTuples: observed.size, + coveredRequiredTuples, + missingRequiredTuples, + allowedTuples: allowed.size, + unexpectedAllowedTuples, + coverageRatio: observed.size ? coveredRequiredTuples / observed.size : 1, + expansionRatio: observed.size ? unexpectedAllowedTuples / observed.size : 0, + }; +} + +function evaluatePolicy(observedTupleSet, policy) { + const allowed = new Set(); + for (const source of (policy.allowedSources || policy.sources)) { + for (const destination of (policy.allowedDestinations || policy.destinations)) { + for (const serviceKey of policy.serviceKeys) { + allowed.add(permissionKey(policy.partitionKey, source, destination, serviceKey)); + } + } + } + let observedTuples = 0; + for (const tuple of allowed) if (observedTupleSet.has(tuple)) observedTuples++; + const unexpectedAllowedTuples = allowed.size - observedTuples; + return { + observedTuples, + allowedTuples: allowed.size, + unexpectedAllowedTuples, + expansionRatio: observedTuples ? unexpectedAllowedTuples / observedTuples : 0, + }; +} + +function buildAffinityViews(policies) { + const groups = new Map(); + for (const policy of policies) { + const key = `${policy.partitionKey}||${policy.sources.join('|')}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(policy); + } + return [...groups.values()] + .sort((a, b) => policySortKey(a[0]).localeCompare(policySortKey(b[0]))) + .map((group, index) => { + const sources = [...new Set(group.flatMap(policy => policy.sources))].sort(); + const destinations = [...new Set(group.flatMap(policy => policy.destinations))].sort(); + const serviceKeys = [...new Set(group.flatMap(policy => policy.serviceKeys))].sort(); + const matrix = {}; + for (const serviceKey of serviceKeys) { + matrix[serviceKey] = {}; + for (const destination of destinations) { + matrix[serviceKey][destination] = group.some(policy => + policy.serviceKeys.includes(serviceKey) && policy.destinations.includes(destination) + ); + } + } + const commonServiceKeys = serviceKeys.filter(serviceKey => + destinations.every(destination => matrix[serviceKey][destination]) + ); + const commonSet = new Set(commonServiceKeys); + const residualServiceKeysByDestination = {}; + for (const destination of destinations) { + const residual = serviceKeys.filter(serviceKey => matrix[serviceKey][destination] && !commonSet.has(serviceKey)); + if (residual.length) residualServiceKeysByDestination[destination] = residual; + } + return { + id: `AV-${String(index + 1).padStart(5, '0')}`, + policyIds: group.map(policy => policy.id).sort(), + sources, + destinations, + serviceKeys, + commonServiceKeys, + residualServiceKeysByDestination, + matrix, + }; + }); +} + +function buildStrictPolicies(atoms) { + return atoms.map((atom, index) => ({ + id: `P-${String(index + 1).padStart(5, '0')}`, + name: `FFV2-STRICT-${String(index + 1).padStart(5, '0')}`, + profile: 'strict', + scope: atom.scope, + partitionKey: atom.partitionKey, + sourceInterface: atom.sourceInterface, + destinationInterface: atom.destinationInterface, + flowSrcintf: atom.sourceInterface, + srcintf: atom.sourceInterface, + dstintf: atom.destinationInterface, + _srcintf: atom.sourceInterface, + _dstintf: atom.destinationInterface, + sources: [atom.source], + destinations: [atom.destination], + serviceKeys: [atom.service.key], + serviceDescriptors: [{ ...atom.service }], + serviceTuples: [{ proto: protocolNumber(atom.service.protocol), port: atom.service.port == null ? '' : String(atom.service.port), service: atom.service.label, sessions: atom.count }], + services: [atom.service.label], + ports: atom.service.port == null ? [] : [atom.service.port], + protos: [atom.service.protocol], + serviceDesc: atom.service.label, + srcHosts: [atom.source], + dstHosts: [atom.destination], + srcSubnet: `${atom.source}/32`, + srcSubnets: [`${atom.source}/32`], + dstTarget: `${atom.destination}/32`, + dstTargets: [`${atom.destination}/32`], + dstType: atom.destinationType, + _use32Src: true, + _use32Dst: true, + _srcMode: 'hosts', + _dstMode: 'hosts', + _useSrcGroup: false, + _useDstGroup: false, + _segmentationPlan: { source: 'host', destination: 'host', services: 'grouped' }, + _segmentationEvidence: { verified: true, observedPairCount: 1 }, + _policyEngineV2: { profile: 'strict', safeExact: true }, + _isMultiDst: false, + _multiSrcSubnets: null, + _multiDstSubnets: null, + sessions: atom.count, + sentBytes: atom.sentBytes, + rcvdBytes: atom.receivedBytes, + firstSeen: atom.firstSeen, + lastSeen: atom.lastSeen, + days: atom.days, + daysObserved: atom.firstSeen != null ? atom.days.length : null, + confidence: confidenceForAtoms([atom]), + analysis: { services: [{ ...atom.service }] }, + trace: { atomIds: [atom.id], reason: 'Tuple canonique strict', orientation: 'strict' }, + action: 'accept', + })); +} + +function buildPolicyEngineV2(flows, options = {}) { + const profile = PROFILE_NAMES.has(options.profile) ? options.profile : 'recommended'; + const inputSummary = summarizeInput(flows); + const canonicalAtoms = canonicalizeFlows(flows); + const serviceInventory = buildServiceInventory(canonicalAtoms, options.fortiConfig || {}); + const serviceByKey = new Map(serviceInventory.map(service => [service.key, service])); + const atoms = canonicalAtoms.map(atom => ({ + ...atom, + service: { ...atom.service, ...serviceByKey.get(atom.service.key) }, + })); + const exactPolicies = profile === 'strict' ? buildStrictPolicies(atoms) : buildRecommendedPolicies(atoms); + const policies = profile === 'synthetic' + ? applySyntheticAggregation(exactPolicies, options) + : exactPolicies.map(policy => ({ + ...policy, + profile, + _policyEngineV2: { + ...policy._policyEngineV2, + profile, + deploymentBlocked: policy.serviceDescriptors.some(service => service.deploymentBlocked), + }, + })); + const observedTupleSet = new Set(atoms.map(atom => + permissionKey(atom.partitionKey, atom.source, atom.destination, atom.service.key) + )); + for (const policy of policies) { + policy._policyEngineV2.deploymentBlocked = policy.serviceDescriptors.some(service => service.deploymentBlocked); + policy.metrics = evaluatePolicy(observedTupleSet, policy); + } + const metrics = evaluatePolicies(atoms, policies); + const affinityViews = buildAffinityViews(policies); + const blockers = serviceInventory + .filter(service => service.deploymentBlocked) + .map(service => service.classification === 'unresolved-port' ? { + code: 'MISSING_DSTPORT', + serviceKey: service.key, + affectedTuples: atoms.filter(atom => atom.service.key === service.key).length, + message: 'Le port destination observé est absent ou illisible ; aucune permission FortiGate exacte ne peut être calculée.', + } : { + code: 'UNRESOLVED_PROTOCOL_SERVICE', + serviceKey: service.key, + affectedTuples: atoms.filter(atom => atom.service.key === service.key).length, + message: 'Le protocole observé ne fournit pas une définition FortiGate assez précise pour une génération automatique.', + }); + metrics.blockedRequiredTuples = blockers.reduce((sum, blocker) => sum + blocker.affectedTuples, 0); + metrics.deployableRequiredTuples = metrics.observedRequiredTuples - metrics.blockedRequiredTuples; + if ((profile === 'recommended' || profile === 'strict') + && (metrics.missingRequiredTuples !== 0 || metrics.unexpectedAllowedTuples !== 0)) { + throw new Error('Policy Engine V2 invariant violation: safe profile changed the required tuple set'); + } + const expertParameters = profile === 'expert' ? { + groupingStrategy: 'deterministic-safe-rectangles', + serviceIdentity: 'protocol-destination-port', + allowImplicitExpansion: false, + networkAggregation: false, + } : undefined; + return { profile, atoms, policies, metrics, serviceInventory, affinityViews, blockers, inputSummary, expertParameters }; +} + +module.exports = { + buildPolicyEngineV2, + canonicalizeFlows, + evaluatePolicies, + normalizeProtocol, +}; diff --git a/app/web/test/current-policy-engine.characterization.test.js b/app/web/test/current-policy-engine.characterization.test.js new file mode 100644 index 0000000..b8ae2cf --- /dev/null +++ b/app/web/test/current-policy-engine.characterization.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { buildPoliciesByPlan } = require('../public/segmentation-plan.js'); + +function allowedTuples(policy) { + const sources = policy.srcHosts || []; + const destinations = policy.dstHosts || []; + const services = (policy.analysis?.services || []).map(service => service.label || service.name); + return new Set(sources.flatMap(source => + destinations.flatMap(destination => + services.map(service => `${source}|${destination}|${service}`) + ) + )); +} + +test('characterization: wide mode unions services across destinations and creates phantom tuples', () => { + const policy = { + srcSubnet: '192.0.2.0/24', + dstTarget: '198.51.100.0/24', + dstType: 'private', + srcHosts: ['192.0.2.10'], + dstHosts: ['198.51.100.10', '198.51.100.20'], + services: ['DNS', 'HTTPS', 'LDAP', 'SMB'], + serviceTuples: [ + { proto: '17', port: '53', service: 'DNS' }, + { proto: '6', port: '443', service: 'HTTPS' }, + { proto: '6', port: '389', service: 'LDAP' }, + { proto: '6', port: '445', service: 'SMB' }, + ], + analysis: { + services: ['DNS', 'HTTPS', 'LDAP', 'SMB'].map(label => ({ label, name: label })), + }, + }; + const hostPairServices = { + '192.0.2.10|198.51.100.10': ['DNS', 'HTTPS', 'LDAP'], + '192.0.2.10|198.51.100.20': ['DNS', 'HTTPS', 'SMB'], + }; + + const result = buildPoliciesByPlan([policy], { + source: 'network', + destination: 'network', + services: 'grouped', + }, { + hostPairServices, + getServicesForPair: (source, destination) => + (hostPairServices[`${source}|${destination}`] || []).map(label => ({ label, name: label })), + }); + + assert.equal(result.length, 1); + const allowed = allowedTuples(result[0]); + assert.equal(allowed.has('192.0.2.10|198.51.100.10|SMB'), true); + assert.equal(allowed.has('192.0.2.10|198.51.100.20|LDAP'), true); +}); + +test('characterization: network profiles generalize sparse observed hosts to their containing networks', () => { + const policy = { + srcSubnet: '192.0.2.0/24', + dstTarget: '198.51.100.0/24', + dstType: 'private', + srcHosts: ['192.0.2.10'], + dstHosts: ['198.51.100.20'], + services: ['HTTPS'], + serviceTuples: [{ proto: '6', port: '443', service: 'HTTPS' }], + analysis: { services: [{ label: 'HTTPS', name: 'HTTPS' }] }, + }; + + const [result] = buildPoliciesByPlan([policy], { + source: 'network', + destination: 'network', + services: 'grouped', + }); + + assert.equal(result.srcSubnet, '192.0.2.0/24'); + assert.equal(result.dstTarget, '198.51.100.0/24'); + assert.equal(result._srcMode, 'subnet'); + assert.equal(result._dstMode, 'subnet'); +}); diff --git a/app/web/test/policy-engine-v2.test.js b/app/web/test/policy-engine-v2.test.js new file mode 100644 index 0000000..4f96aad --- /dev/null +++ b/app/web/test/policy-engine-v2.test.js @@ -0,0 +1,519 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const analyzer = require('../lib/analyzer'); +const { analyzePolicies, preflightValidation, generateConfig } = require('../lib/forticonfig'); + +test('Policy Engine V2 exposes a pure deterministic build entry point', () => { + assert.equal(typeof analyzer.buildPolicyEngineV2, 'function'); +}); + +function flow(source, destination, proto, port, service, extra = {}) { + return { + srcip: source, + dstip: destination, + proto: String(proto), + dstport: String(port), + service, + action: 'accept', + decision: 'allow', + deploymentEligible: true, + srcintf: 'users', + dstintf: 'servers', + devid: 'FGT-A', + vdom: 'root', + count: 1, + sentBytes: 100, + rcvdBytes: 200, + ...extra, + }; +} + +test('recommended mode extracts common destination services and residual policies without expansion', () => { + const source = '192.0.2.10'; + const dstA = '198.51.100.10'; + const dstB = '198.51.100.20'; + const flows = [ + flow(source, dstA, 17, 53, 'DNS'), + flow(source, dstA, 6, 443, 'HTTPS'), + flow(source, dstA, 6, 389, 'LDAP'), + flow(source, dstB, 17, 53, 'DNS'), + flow(source, dstB, 6, 443, 'HTTPS'), + flow(source, dstB, 6, 445, 'SMB'), + ]; + + const result = analyzer.buildPolicyEngineV2(flows, { profile: 'recommended' }); + + assert.equal(result.policies.length, 3); + assert.deepEqual( + result.policies.map(policy => ({ + sources: policy.sources, + destinations: policy.destinations, + serviceKeys: policy.serviceKeys, + })), + [ + { sources: [source], destinations: [dstA], serviceKeys: ['TCP:389'] }, + { sources: [source], destinations: [dstA, dstB], serviceKeys: ['TCP:443', 'UDP:53'] }, + { sources: [source], destinations: [dstB], serviceKeys: ['TCP:445'] }, + ], + ); + assert.deepEqual(result.metrics, { + observedRequiredTuples: 6, + coveredRequiredTuples: 6, + missingRequiredTuples: 0, + allowedTuples: 6, + unexpectedAllowedTuples: 0, + coverageRatio: 1, + expansionRatio: 0, + blockedRequiredTuples: 0, + deployableRequiredTuples: 6, + }); + assert.deepEqual(result.policies.map(policy => policy.metrics), [ + { observedTuples: 1, allowedTuples: 1, unexpectedAllowedTuples: 0, expansionRatio: 0 }, + { observedTuples: 4, allowedTuples: 4, unexpectedAllowedTuples: 0, expansionRatio: 0 }, + { observedTuples: 1, allowedTuples: 1, unexpectedAllowedTuples: 0, expansionRatio: 0 }, + ]); + assert.deepEqual(result.affinityViews, [{ + id: 'AV-00001', + policyIds: ['P-00001', 'P-00002', 'P-00003'], + sources: [source], + destinations: [dstA, dstB], + serviceKeys: ['TCP:389', 'TCP:443', 'TCP:445', 'UDP:53'], + commonServiceKeys: ['TCP:443', 'UDP:53'], + residualServiceKeysByDestination: { + [dstA]: ['TCP:389'], + [dstB]: ['TCP:445'], + }, + matrix: { + 'TCP:389': { [dstA]: true, [dstB]: false }, + 'TCP:443': { [dstA]: true, [dstB]: true }, + 'TCP:445': { [dstA]: false, [dstB]: true }, + 'UDP:53': { [dstA]: true, [dstB]: true }, + }, + }]); +}); + +test('identical destination signatures collapse into one exact policy', () => { + const flows = [ + flow('192.0.2.10', '198.51.100.10', 17, 53, 'DNS'), + flow('192.0.2.10', '198.51.100.10', 6, 443, 'HTTPS'), + flow('192.0.2.10', '198.51.100.20', 17, 53, 'DNS'), + flow('192.0.2.10', '198.51.100.20', 6, 443, 'HTTPS'), + ]; + const result = analyzer.buildPolicyEngineV2(flows, { profile: 'recommended' }); + assert.equal(result.policies.length, 1); + assert.deepEqual(result.policies[0].destinations, ['198.51.100.10', '198.51.100.20']); + assert.deepEqual(result.policies[0].serviceKeys, ['TCP:443', 'UDP:53']); + assert.equal(result.metrics.unexpectedAllowedTuples, 0); +}); + +test('services with no common destination behavior remain separate', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.10', '198.51.100.10', 6, 389, 'LDAP'), + flow('192.0.2.10', '198.51.100.20', 6, 445, 'SMB'), + ], { profile: 'recommended' }); + assert.equal(result.policies.length, 2); + assert.ok(result.policies.every(policy => policy.destinations.length === 1)); + assert.equal(result.metrics.unexpectedAllowedTuples, 0); +}); + +test('partially similar sources keep source-service affinity', () => { + const destination = '198.51.100.10'; + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.10', destination, 17, 53, 'DNS'), + flow('192.0.2.10', destination, 6, 443, 'HTTPS'), + flow('192.0.2.20', destination, 17, 53, 'DNS'), + flow('192.0.2.20', destination, 6, 445, 'SMB'), + ], { profile: 'recommended' }); + assert.equal(result.policies.length, 3); + assert.equal(result.metrics.missingRequiredTuples, 0); + assert.equal(result.metrics.unexpectedAllowedTuples, 0); + const dns = result.policies.find(policy => policy.serviceKeys.includes('UDP:53')); + assert.deepEqual(dns.sources, ['192.0.2.10', '192.0.2.20']); + assert.deepEqual(dns.destinations, [destination]); +}); + +test('recommended mode exactly preserves every non-empty 2x2x2 permission graph', () => { + const sources = ['192.0.2.10', '192.0.2.20']; + const destinations = ['198.51.100.10', '198.51.100.20']; + const services = [ + { proto: 6, port: 443, name: 'HTTPS' }, + { proto: 17, port: 53, name: 'DNS' }, + ]; + const universe = sources.flatMap(source => + destinations.flatMap(destination => + services.map(service => ({ source, destination, ...service })) + ) + ); + for (let mask = 1; mask < (1 << universe.length); mask++) { + const selected = universe.filter((_tuple, index) => mask & (1 << index)); + const result = analyzer.buildPolicyEngineV2(selected.map(tuple => + flow(tuple.source, tuple.destination, tuple.proto, tuple.port, tuple.name) + ), { profile: 'recommended' }); + assert.equal(result.metrics.observedRequiredTuples, selected.length, `mask=${mask}`); + assert.equal(result.metrics.coveredRequiredTuples, selected.length, `mask=${mask}`); + assert.equal(result.metrics.missingRequiredTuples, 0, `mask=${mask}`); + assert.equal(result.metrics.unexpectedAllowedTuples, 0, `mask=${mask}`); + } +}); + +test('input order does not change atoms, policies, names or metrics', () => { + const flows = [ + flow('192.0.2.20', '198.51.100.20', 6, 445, 'SMB'), + flow('192.0.2.10', '198.51.100.10', 6, 443, 'HTTPS'), + flow('192.0.2.20', '198.51.100.10', 17, 53, 'DNS'), + flow('192.0.2.10', '198.51.100.20', 17, 53, 'DNS'), + ]; + const forward = analyzer.buildPolicyEngineV2(flows, { profile: 'recommended' }); + const reverse = analyzer.buildPolicyEngineV2([...flows].reverse(), { profile: 'recommended' }); + assert.deepEqual(reverse, forward); +}); + +test('different FortiGate scopes and interface pairs are never merged', () => { + const base = flow('192.0.2.10', '198.51.100.10', 6, 443, 'HTTPS'); + const result = analyzer.buildPolicyEngineV2([ + base, + { ...base, vdom: 'tenant-b' }, + { ...base, dstintf: 'dmz' }, + ], { profile: 'recommended' }); + assert.equal(result.atoms.length, 3); + assert.equal(result.policies.length, 3); + assert.equal(result.metrics.observedRequiredTuples, 3); + assert.equal(result.metrics.unexpectedAllowedTuples, 0); +}); + +test('strict mode emits one exact policy per canonical tuple', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.10', '198.51.100.10', 6, 443, 'HTTPS'), + flow('192.0.2.10', '198.51.100.10', 17, 53, 'DNS'), + flow('192.0.2.10', '198.51.100.10', 17, 53, 'DNS', { count: 5 }), + ], { profile: 'strict' }); + assert.equal(result.atoms.length, 2); + assert.equal(result.policies.length, 2); + assert.equal(result.metrics.coverageRatio, 1); + assert.equal(result.metrics.expansionRatio, 0); +}); + +test('dynamic and rare ports remain exact and protocol-specific', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.10', '198.51.100.10', 6, 52121, 'DCE-RPC'), + flow('192.0.2.10', '198.51.100.10', 6, 52134, 'DCE-RPC'), + flow('192.0.2.10', '198.51.100.10', 17, 52121, 'DCE-RPC'), + ], { profile: 'recommended' }); + assert.deepEqual(result.atoms.map(atom => atom.service.key), ['TCP:52121', 'TCP:52134', 'UDP:52121']); + assert.equal(result.policies.some(policy => policy.serviceKeys.includes('TCP:49152-65535')), false); + assert.equal(result.metrics.unexpectedAllowedTuples, 0); +}); + +test('missing temporal evidence propagates an unknown confidence instead of certification', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.10', '198.51.100.10', 6, 443, 'HTTPS'), + ], { profile: 'recommended' }); + assert.equal(result.policies[0].confidence, 'unknown'); +}); + +test('synthetic mode aggregates a dense known subnet and measures every additional tuple', () => { + const destination = '198.51.100.10'; + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.0', destination, 6, 443, 'HTTPS'), + flow('192.0.2.1', destination, 6, 443, 'HTTPS'), + flow('192.0.2.2', destination, 6, 443, 'HTTPS'), + ], { + profile: 'synthetic', + networks: [{ cidr: '192.0.2.0/30', name: 'CLIENTS-DENSE' }], + networkAggregation: { minDensity: 0.75, minHosts: 3, minPrefix: 24 }, + }); + + assert.equal(result.policies.length, 1); + assert.equal(result.policies[0].srcSubnet, '192.0.2.0/30'); + assert.equal(result.policies[0]._use32Src, false); + assert.deepEqual(result.policies[0].networkAggregation.source, { + cidr: '192.0.2.0/30', + objectName: 'CLIENTS-DENSE', + observedHosts: 3, + possibleHosts: 4, + density: 0.75, + additionalHosts: 1, + }); + assert.equal(result.metrics.observedRequiredTuples, 3); + assert.equal(result.metrics.coveredRequiredTuples, 3); + assert.equal(result.metrics.missingRequiredTuples, 0); + assert.equal(result.metrics.allowedTuples, 4); + assert.equal(result.metrics.unexpectedAllowedTuples, 1); + assert.equal(result.metrics.expansionRatio, 1 / 3); +}); + +test('service normalization prefers exact existing objects and classifies predefined, rare and dynamic ports', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.10', '198.51.100.10', 6, 443, 'HTTPS'), + flow('192.0.2.10', '198.51.100.10', 6, 8443, 'APP-HTTPS', { count: 20, days: ['2026-08-01', '2026-08-02'] }), + flow('192.0.2.10', '198.51.100.10', 6, 12345, '', { count: 1 }), + flow('192.0.2.10', '198.51.100.10', 6, 52121, 'DCE-RPC'), + ], { + profile: 'recommended', + fortiConfig: { + customServices: { + 'APP-HTTPS-EXACT': { name: 'APP-HTTPS-EXACT', proto: 'TCP/UDP/SCTP', tcpPorts: [8443], udpPorts: [] }, + 'APP-HTTPS-WIDE': { name: 'APP-HTTPS-WIDE', proto: 'TCP/UDP/SCTP', tcpPorts: [8443, 9443], udpPorts: [] }, + }, + }, + }); + + const byKey = Object.fromEntries(result.serviceInventory.map(service => [service.key, service])); + assert.equal(byKey['TCP:443'].classification, 'predefined'); + assert.equal(byKey['TCP:443'].selectedObject, 'HTTPS'); + assert.equal(byKey['TCP:8443'].classification, 'existing'); + assert.equal(byKey['TCP:8443'].selectedObject, 'APP-HTTPS-EXACT'); + assert.equal(byKey['TCP:12345'].classification, 'rare'); + assert.equal(byKey['TCP:52121'].classification, 'dynamic'); + assert.equal(byKey['TCP:52121'].generalizedRange, null); +}); + +test('safe optimizer finds local service intersections even when global service signatures differ', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.10', '198.51.100.10', 17, 53, 'DNS'), + flow('192.0.2.10', '198.51.100.10', 6, 443, 'HTTPS'), + flow('192.0.2.10', '198.51.100.10', 6, 389, 'LDAP'), + flow('192.0.2.10', '198.51.100.20', 17, 53, 'DNS'), + flow('192.0.2.10', '198.51.100.20', 6, 443, 'HTTPS'), + flow('192.0.2.10', '198.51.100.20', 6, 445, 'SMB'), + flow('192.0.2.30', '198.51.100.30', 17, 53, 'DNS'), + ], { profile: 'recommended' }); + + assert.equal(result.policies.length, 4); + assert.ok(result.policies.some(policy => + policy.sources.length === 1 + && policy.sources[0] === '192.0.2.10' + && policy.destinations.length === 2 + && policy.serviceKeys.join(',') === 'TCP:443,UDP:53' + )); + assert.equal(result.metrics.missingRequiredTuples, 0); + assert.equal(result.metrics.unexpectedAllowedTuples, 0); +}); + +test('V2 policies can be reconciled against a workspace-restored FortiGate service inventory', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('192.0.2.10', '198.51.100.10', 6, 8443, ''), + ], { profile: 'recommended' }); + const fortiConfig = { + addresses: {}, + interfaces: {}, + zones: {}, + customServices: { + 'APP-HTTPS': { + name: 'APP-HTTPS', + proto: 'TCP/UDP/SCTP', + tcpPorts: [8443], + udpPorts: [], + _tcpSet: {}, + _udpSet: {}, + }, + }, + }; + + const analyzed = analyzePolicies(result.policies, fortiConfig, null); + assert.equal(analyzed.length, 1); + assert.equal(analyzed[0].analysis.services[0].found, true); + assert.equal(analyzed[0].analysis.services[0].name, 'APP-HTTPS'); +}); + +test('preflight certifies grouped V2 rectangles from exact technical tuples', () => { + const flows = [ + flow('10.0.0.10', '10.0.1.10', 17, 53, 'DNS'), + flow('10.0.0.10', '10.0.1.10', 6, 443, 'HTTPS'), + flow('10.0.0.10', '10.0.1.10', 6, 389, 'LDAP'), + flow('10.0.0.10', '10.0.1.20', 17, 53, 'DNS'), + flow('10.0.0.10', '10.0.1.20', 6, 443, 'HTTPS'), + flow('10.0.0.10', '10.0.1.20', 6, 445, 'SMB'), + ]; + const config = { + addresses: {}, + addressGroups: {}, + customServices: {}, + serviceGroups: {}, + interfaces: { users: {}, servers: {} }, + zones: {}, + }; + const result = analyzer.buildPolicyEngineV2(flows, { profile: 'recommended', fortiConfig: config }); + const analyzed = analyzePolicies(result.policies, config, null); + const preflight = preflightValidation(analyzed, config, flows); + + assert.equal(preflight.ok, true); + assert.equal(preflight.certification.level, 'exact'); +}); + +test('preflight rejects the same service label when protocol or port differs', () => { + const policy = { + srcintf: 'users', dstintf: 'servers', + srcSubnet: '10.0.0.10/32', dstTarget: '10.0.1.10/32', + srcHosts: ['10.0.0.10'], dstHosts: ['10.0.1.10'], + services: ['APP'], + serviceTuples: [{ proto: '6', port: '443', service: 'APP' }], + _use32Src: true, _use32Dst: true, + _segmentationPlan: { source: 'host', destination: 'host', services: 'separate' }, + action: 'accept', log: 'all', + scope: { devid: 'FGT-A', vdom: 'root' }, + analysis: { + srcIface: 'users', dstIface: 'servers', + services: [{ label: 'APP', name: 'APP', found: true }], + }, + }; + const config = { addresses: {}, addressGroups: {}, interfaces: { users: {}, servers: {} }, zones: {} }; + const mismatched = [flow('10.0.0.10', '10.0.1.10', 6, 8443, 'APP')]; + + const result = preflightValidation([policy], config, mismatched); + assert.equal(result.ok, false); + assert.ok(result.issues.some(issue => /non observé/.test(issue.msg))); +}); + +test('V2 preserves an explicit internal classification for public-address LANs', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('203.0.113.10', '198.51.100.10', 6, 443, 'HTTPS', { + srcType: 'private', + dstType: 'private', + }), + ], { profile: 'recommended' }); + assert.equal(result.policies[0].dstType, 'private'); +}); + +test('V2 exact path reaches FortiGate CLI generation without ALL fallbacks', () => { + const flows = [ + flow('10.0.0.10', '10.0.1.10', 6, 443, 'HTTPS'), + flow('10.0.0.20', '10.0.1.10', 6, 443, 'HTTPS'), + ]; + const config = { + addresses: {}, addressGroups: {}, customServices: {}, serviceGroups: {}, zones: {}, + interfaces: { users: {}, servers: {} }, + }; + const result = analyzer.buildPolicyEngineV2(flows, { profile: 'recommended', fortiConfig: config }); + const analyzed = analyzePolicies(result.policies, config, null); + const preflight = preflightValidation(analyzed, config, flows); + assert.equal(preflight.ok, true); + + const cli = generateConfig(analyzed, { + addresses: config.addresses, + addressGroups: config.addressGroups, + serviceGroups: config.serviceGroups, + zones: config.zones, + namingPrefix: 'FFV2', + }); + assert.match(cli, /config firewall policy/); + assert.doesNotMatch(cli, /set srcaddr "all"/); + assert.doesNotMatch(cli, /set service "ALL"/); +}); + +test('legacy consolidation never crosses observed interface pairs', () => { + const base = { + srcSubnet: '10.0.0.0/24', dstTarget: '10.0.1.0/24', dstType: 'private', + services: ['HTTPS'], ports: [443], protos: ['TCP'], + serviceTuples: [{ proto: '6', port: '443', service: 'HTTPS' }], + serviceDesc: 'HTTPS', sessions: 1, sentBytes: 1, rcvdBytes: 1, + scope: { devid: 'FGT-A', vdom: 'root' }, noRcvdSrcHosts: [], + }; + const result = analyzer.consolidatePolicies([ + { ...base, flowSrcintf: 'lan', dstintf: 'servers' }, + { ...base, flowSrcintf: 'dmz', dstintf: 'servers' }, + ]); + assert.equal(result.length, 2); +}); + +test('V2 reports every flow excluded before canonical policy generation', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('10.0.0.10', '10.0.1.10', 6, 443, 'HTTPS', { count: 2 }), + flow('10.0.0.10', '10.0.1.20', 6, 443, 'HTTPS', { + count: 3, + deploymentEligible: false, + evidenceIssues: ['nat_observed'], + }), + flow('10.0.0.10', '10.0.1.30', 6, 443, 'HTTPS', { + count: 4, + action: 'deny', + decision: 'deny', + }), + ], { profile: 'recommended' }); + + assert.deepEqual(result.inputSummary, { + inputFlows: 3, + inputSessions: 9, + includedFlows: 1, + includedSessions: 2, + excludedFlows: 2, + excludedSessions: 7, + exclusionReasons: { + deployment_ineligible: 3, + not_allowed: 4, + }, + }); +}); + +test('expert profile exposes the effective safe optimizer parameters', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('10.0.0.10', '10.0.1.10', 6, 443, 'HTTPS'), + ], { profile: 'expert' }); + assert.deepEqual(result.expertParameters, { + groupingStrategy: 'deterministic-safe-rectangles', + serviceIdentity: 'protocol-destination-port', + allowImplicitExpansion: false, + networkAggregation: false, + }); + assert.equal(result.metrics.expansionRatio, 0); +}); + +test('ICMP type and code remain distinct and reuse an exact FortiGate object', () => { + const config = { + addresses: {}, addressGroups: {}, serviceGroups: {}, zones: {}, + interfaces: { users: {}, servers: {} }, + customServices: { + 'PING-EXACT': { + name: 'PING-EXACT', proto: 'ICMP', tcpPorts: [], udpPorts: [], + icmptype: 8, icmpcode: 0, + }, + }, + }; + const flows = [flow('10.0.0.10', '10.0.1.10', 1, 0, 'ICMP/8/0')]; + const result = analyzer.buildPolicyEngineV2(flows, { profile: 'recommended', fortiConfig: config }); + assert.equal(result.atoms[0].service.key, 'ICMP:8:0'); + assert.equal(result.serviceInventory[0].classification, 'existing'); + assert.equal(result.serviceInventory[0].selectedObject, 'PING-EXACT'); + assert.equal(result.blockers.length, 0); + const analyzed = analyzePolicies(result.policies, config, null); + assert.equal(preflightValidation(analyzed, config, flows).ok, true); +}); + +test('TCP or UDP without a numeric destination port is blocked and counted separately', () => { + const result = analyzer.buildPolicyEngineV2([ + flow('10.0.0.10', '10.0.1.10', 6, '80-90', 'APP-RANGE'), + ], { profile: 'recommended' }); + assert.equal(result.serviceInventory[0].classification, 'unresolved-port'); + assert.equal(result.serviceInventory[0].deploymentBlocked, true); + assert.deepEqual(result.blockers, [{ + code: 'MISSING_DSTPORT', + serviceKey: 'TCP', + affectedTuples: 1, + message: 'Le port destination observé est absent ou illisible ; aucune permission FortiGate exacte ne peut être calculée.', + }]); + assert.equal(result.metrics.blockedRequiredTuples, 1); +}); + +test('synthetic never reuses an existing network object broader than the measured CIDR', () => { + const flows = [0, 1, 2].map(host => + flow(`10.0.0.${host}`, '10.0.1.10', 6, 443, 'HTTPS') + ); + const config = { + addresses: { BROAD: { name: 'BROAD', cidr: '10.0.0.0/29' } }, + addressGroups: {}, customServices: {}, serviceGroups: {}, zones: {}, + interfaces: { users: {}, servers: {} }, + }; + const result = analyzer.buildPolicyEngineV2(flows, { + profile: 'synthetic', + fortiConfig: config, + networks: [{ cidr: '10.0.0.0/30', name: 'MEASURED' }], + networkAggregation: { minDensity: 0.75, minHosts: 3, minPrefix: 24 }, + }); + const analyzed = analyzePolicies(result.policies, config, null); + assert.equal(result.policies[0].srcSubnet, '10.0.0.0/30'); + assert.equal(analyzed[0].analysis.srcAddr.found, false); + assert.notEqual(analyzed[0].analysis.srcAddr.name, 'BROAD'); +}); From bd15702ddeaeee302671af57b0dadf828d7f45a1 Mon Sep 17 00:00:00 2001 From: "Hermes (Tetrax)" <10426516+Tetrax@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:33:17 +0000 Subject: [PATCH 2/4] [verified] feat(ui): integrate Policy Engine V2 deployment workflow --- app/web/public/app.js | 264 +++++++++++++---------- app/web/public/style.css | 34 ++- app/web/server.js | 65 +++++- app/web/test/policy-engine-v2-ui.test.js | 31 +++ app/web/test/server-dependencies.test.js | 7 +- docs/POLICY_ENGINE_V2.md | 185 ++++++++++++++++ 6 files changed, 467 insertions(+), 119 deletions(-) create mode 100644 app/web/test/policy-engine-v2-ui.test.js create mode 100644 docs/POLICY_ENGINE_V2.md diff --git a/app/web/public/app.js b/app/web/public/app.js index 59d6f29..c3de938 100644 --- a/app/web/public/app.js +++ b/app/web/public/app.js @@ -2134,10 +2134,18 @@ const deployState = { wizardStep: 1, // 1: config upload, 2: routes, 3: interfaces, 4: policies use32Global: false, // global /32 mode (use real hosts instead of /24) bruteMode: 'off', // 'off' | 'service' (split by svc) | 'host' (split by src+svc) - granularity: 'reseau', // compat workspaces v2 (remplacé par segmentationPlan) - segmentationPlan: { source: 'network', destination: 'network', services: 'grouped' }, - segmentationPreset: 'wide', + granularity: 'policy-engine-v2', + segmentationPlan: { source: 'host', destination: 'host', services: 'grouped' }, + segmentationPreset: 'recommended', segmentationCustomOpen: false, + policyEngineProfile: 'recommended', + policyEngineMetrics: null, + policyEngineQuality: null, + policyEngineBlockers: [], + policyEngineAtomCount: 0, + policyEngineInputSummary: null, + serviceInventory: [], + affinityViews: [], deploymentBlockers: { unsupportedIpv6: 0, unknownActionSessions: 0, failedConnectionSessions: 0, hasExcludedTraffic: false, blocked: false }, _detailOriginal: null, // M3: snapshot pré-détail (≠ _analyzedOriginal pré-fusion) captureWindow: null, // #1: { start, end, days, available } — fenêtre d'observation @@ -2297,6 +2305,14 @@ async function exportSession() { namingPrefix: deployState.namingPrefix, // #5 segmentationPlan: deployState.segmentationPlan, segmentationPreset: deployState.segmentationPreset, + policyEngineProfile: deployState.policyEngineProfile, + policyEngineMetrics: deployState.policyEngineMetrics, + policyEngineQuality: deployState.policyEngineQuality, + policyEngineBlockers: deployState.policyEngineBlockers, + policyEngineAtomCount: deployState.policyEngineAtomCount, + policyEngineInputSummary: deployState.policyEngineInputSummary, + serviceInventory: deployState.serviceInventory, + affinityViews: deployState.affinityViews, }, }; // Compression gzip via l'API native (zéro dépendance) @@ -2367,6 +2383,14 @@ function importSession(file) { deployState.namingPrefix = ds.namingPrefix || 'FF'; // #5 deployState.segmentationPlan = window.FortiFlowSegmentation?.normalizePlan(ds.segmentationPlan) || { source: 'network', destination: 'network', services: 'grouped' }; deployState.segmentationPreset = ds.segmentationPreset || window.FortiFlowSegmentation?.inferPreset(deployState.segmentationPlan) || 'wide'; + deployState.policyEngineProfile = ds.policyEngineProfile || 'recommended'; + deployState.policyEngineMetrics = ds.policyEngineMetrics || null; + deployState.policyEngineQuality = ds.policyEngineQuality || null; + deployState.policyEngineBlockers = ds.policyEngineBlockers || []; + deployState.policyEngineAtomCount = ds.policyEngineAtomCount || 0; + deployState.policyEngineInputSummary = ds.policyEngineInputSummary || null; + deployState.serviceInventory = ds.serviceInventory || []; + deployState.affinityViews = ds.affinityViews || []; } // Navigation : deploy si dispo, sinon dashboard @@ -3062,6 +3086,11 @@ function mountDrawer() { drawer.addEventListener('click', e => { const p = _drawerIdx !== null ? deployState.analyzed[_drawerIdx] : null; if (!p) return; + const structuralControl = e.target.closest('.drawer-mode-btn, .drawer-dstall-btn, .drawer-multidst-mode, .drawer-multisrc-mode, .svc-do-merge'); + if (p._policyEngineV2 && structuralControl) { + alert('Périmètre verrouillé par Policy Engine V2. Changez de profil puis relancez l’analyse au lieu d’élargir manuellement cette policy.'); + return; + } const _snapAndShow = () => { _snapDrawer(p); const hint = document.getElementById('drawer-undo-hint'); @@ -3520,12 +3549,38 @@ function syncSvcCell(idx) { syncRowStatus(idx); } +function buildPolicyAffinityHtml(policy) { + const view = (deployState.affinityViews || []).find(candidate => candidate.policyIds?.includes(policy.id)); + if (!view) return ''; + const destinations = view.destinations.slice(0, 20); + const serviceKeys = view.serviceKeys.slice(0, 30); + const labels = new Map((deployState.serviceInventory || []).map(service => [service.key, service.label || service.key])); + const head = destinations.map(destination => `${escHtml(destination)}`).join(''); + const rows = serviceKeys.map(serviceKey => ` + ${escHtml(labels.get(serviceKey) || serviceKey)} + ${destinations.map(destination => `${view.matrix?.[serviceKey]?.[destination] ? '✓' : '·'}`).join('')} + `).join(''); + const common = (view.commonServiceKeys || []).map(key => labels.get(key) || key); + const residuals = Object.entries(view.residualServiceKeysByDestination || {}) + .map(([destination, keys]) => `
  • ${escHtml(destination)} : ${keys.map(key => escHtml(labels.get(key) || key)).join(', ')}
  • `) + .join(''); + const truncated = view.destinations.length > destinations.length || view.serviceKeys.length > serviceKeys.length; + return `
    +
    Affinité destination × service
    +
    ${escHtml(policy.trace?.reason || 'Rectangle exact issu des flow atoms.')}
    +
    ${head}${rows}
    Service
    +
    Communs : ${common.length ? common.map(escHtml).join(', ') : 'aucun'}
    + ${residuals ? `
    Résiduels :
    ` : ''} + ${truncated ? 'Matrice tronquée dans cette vue ; l’export conserve toutes les associations.' : ''} +
    `; +} + function populateDrawer(idx) { const p = deployState.analyzed[idx]; if (!p) return; const a = p.analysis || {}; const title = document.getElementById('drawer-title'); - title.textContent = `Policy ${p._policyName || (p.policyIds || [])[0] || idx}`; + title.textContent = `Policy ${p._policyName || p.name || (p.policyIds || [])[0] || idx + 1}`; const ifOpts = (deployState.ifaceOpts || []).map(o => `` @@ -3533,7 +3588,7 @@ function populateDrawer(idx) { const ifOptsDst = (deployState.ifaceOpts || []).map(o => `` ).join(''); - const pid0 = (p.policyIds || [])[0] || idx; + const pid0 = String((p.policyIds || [])[0] || p.id || idx + 1).replace(/[^A-Za-z0-9_-]/g, '_'); const suggestedSrcGrp = `FF_POLICY_${pid0}_SRC`; const suggestedDstGrp = `GRP_${pid0}_DST`; @@ -3872,11 +3927,14 @@ function populateDrawer(idx) { const body = document.getElementById('drawer-body'); body.innerHTML = ` + ${p._policyEngineV2 ? '
    Périmètre V2 verrouilléSources, destinations et services sont issus des tuples mesurés. Changez de profil pour recalculer la stratégie.
    ' : ''}
    Général
    Direction${p._isWan ? 'WAN' : 'LAN'}
    Policy IDs${(p.policyIds||[]).join(', ') || '—'}
    Sessions${fmtNum(p.sessions||0)}
    + ${p.metrics ? `
    Tuples${fmtNum(p.metrics.observedTuples)} observés · ${fmtNum(p.metrics.allowedTuples)} autorisés
    +
    Expansion${fmtNum(p.metrics.unexpectedAllowedTuples)} inattendus · ${(Number(p.metrics.expansionRatio || 0) * 100).toFixed(2)} %
    ` : ''}
    Action
    @@ -3898,6 +3956,7 @@ function populateDrawer(idx) {
    ${srcSection} ${dstSection} + ${buildPolicyAffinityHtml(p)}
    Interface de destination
    Interface
    @@ -4135,7 +4194,7 @@ async function deploy() {