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..5bd888a 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 }; } @@ -1202,12 +1205,24 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { })); const customCandidate = customServices[svc]; - const customMatch = customCandidate && (!relevantTuples.length || relevantTuples.every(t => { + // Pour ICMP sans type/code brut, le nom de service FortiGate observé + // peut être réutilisé uniquement si la configuration sélectionnée + // contient exactement le même objet ICMP dans le même scope/VDOM. + const icmpCustomMatch = customCandidate + && customCandidate.proto === 'ICMP' + && relevantTuples.length > 0 + && relevantTuples.every(t => { + if (!/^(1|icmp)$/i.test(String(t.proto))) return false; + if (Number.isInteger(t.icmpType) && customCandidate.icmptype !== t.icmpType) return false; + if (Number.isInteger(t.icmpCode) && customCandidate.icmpcode !== t.icmpCode) return false; + return true; + }); + const customMatch = icmpCustomMatch ? customCandidate : customCandidate && (!relevantTuples.length || relevantTuples.every(t => { const port = Number(t.port); 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; @@ -1215,7 +1230,8 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { // Fuzzy name match (e.g. "NETBIOS" → "NetBIOS_NS" / "NetBIOS_DS") // Skip port-notation labels (e.g. "TCP/853") — they use the port-based fallback path const isPortNotationLabel = /^(TCP|UDP)\/\d+$/i.test(svc); - const fuzzyMatch = (!knownPredef && !customMatch && !icmpMatch && !isPortNotationLabel) + const fuzzyMatch = (!knownPredef && !customMatch && !icmpMatch && !isPortNotationLabel + && ['TCP', 'UDP'].includes(observedProtoLabel)) ? (observedProtoLabel ? findServiceByName(svc, observedPorts, observedProtoLabel, customServices) : null) : null; @@ -1283,7 +1299,7 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { // Réutiliser un objet uniquement si son périmètre protocole/port est // exactement égal aux tuples observés. Un simple sous-ensemble ouvrirait // silencieusement des ports supplémentaires. - const exactExisting = !!icmpMatch || ( + const exactExisting = !!icmpMatch || !!icmpCustomMatch || ( candidateFound && relevantTuples.length > 0 && observedTransport.size === relevantTuples.length @@ -2060,6 +2076,27 @@ 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') { + if (Number.isInteger(flow?.icmpType) && Number.isInteger(flow?.icmpCode)) { + return `ICMP:${flow.icmpType}:${flow.icmpCode}`; + } + const label = String(flow?.service || '').trim().toUpperCase(); + const icmp = label.match(/^ICMP\/(\d+)\/(\d+)$/); + if (icmp) return `ICMP:${Number(icmp[1])}:${Number(icmp[2])}`; + if (label && !['ICMP', 'ALL_ICMP', 'ALL_ICMP6'].includes(label)) return `ICMP:NAME:${label}`; + } + 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(); @@ -2108,7 +2145,39 @@ function segmentationEvidenceHosts(policy, side) { return match ? [match[1]] : []; } -function preflightValidation(selectedPolicies, config, observedFlows = null) { +function policyEngineSelectionMetrics(requiredAtoms, selectedPolicies) { + const key = (partitionKey, source, destination, serviceKey) => + [partitionKey, source, destination, serviceKey].join('||'); + const required = new Set((requiredAtoms || []) + .filter(atom => atom?.service?.deploymentBlocked !== true) + .map(atom => key(atom.partitionKey, atom.source, atom.destination, atom.service.key))); + const allowed = new Set(); + for (const policy of (selectedPolicies || [])) { + for (const source of (policy.allowedSources || policy.sources || [])) { + for (const destination of (policy.allowedDestinations || policy.destinations || [])) { + for (const serviceKey of (policy.serviceKeys || [])) { + allowed.add(key(policy.partitionKey, source, destination, serviceKey)); + } + } + } + } + let coveredRequiredTuples = 0; + for (const tuple of required) if (allowed.has(tuple)) coveredRequiredTuples++; + let unexpectedAllowedTuples = 0; + for (const tuple of allowed) if (!required.has(tuple)) unexpectedAllowedTuples++; + const missingRequiredTuples = required.size - coveredRequiredTuples; + return { + observedRequiredTuples: required.size, + coveredRequiredTuples, + missingRequiredTuples, + allowedTuples: allowed.size, + unexpectedAllowedTuples, + coverageRatio: required.size ? coveredRequiredTuples / required.size : 1, + expansionRatio: required.size ? unexpectedAllowedTuples / required.size : 0, + }; +} + +function preflightValidation(selectedPolicies, config, observedFlows = null, requiredAtoms = null) { const issues = []; // { level: 'warn'|'error', msg } const addresses = config.addresses || {}; const addressGroups = config.addressGroups || {}; @@ -2273,9 +2342,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 +2372,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) { @@ -2381,6 +2484,29 @@ function preflightValidation(selectedPolicies, config, observedFlows = null) { }); } + let selectionMetrics = null; + const v2Policies = selectedPolicies.filter(policy => policy?._policyEngineV2); + if (v2Policies.length > 0 && Array.isArray(requiredAtoms)) { + selectionMetrics = policyEngineSelectionMetrics(requiredAtoms, v2Policies); + if (selectionMetrics.missingRequiredTuples > 0) { + issues.push({ + level: 'error', + code: 'POLICY_ENGINE_MISSING_REQUIRED_TUPLES', + msg: `${selectionMetrics.missingRequiredTuples} tuple(s) déployable(s) requis ne sont plus couverts par la sélection finale`, + }); + } + const safeProfile = v2Policies.every(policy => + ['recommended', 'strict', 'expert'].includes(policy._policyEngineV2.profile) + ); + if (safeProfile && selectionMetrics.unexpectedAllowedTuples > 0) { + issues.push({ + level: 'error', + code: 'POLICY_ENGINE_UNEXPECTED_ALLOWED_TUPLES', + msg: `${selectionMetrics.unexpectedAllowedTuples} tuple(s) inattendu(s) sont autorisés par la sélection finale`, + }); + } + } + // Summary counts const errors = issues.filter(i => i.level === 'error').length; const warnings = issues.filter(i => i.level === 'warn').length; @@ -2394,7 +2520,7 @@ function preflightValidation(selectedPolicies, config, observedFlows = null) { unclassifiedPolicies, routingContextUnproven, }; - return { issues, errors, warnings, ok: errors === 0, certification }; + return { issues, errors, warnings, ok: errors === 0, certification, selectionMetrics }; } function formatExistingPolicies(policies) { @@ -2427,6 +2553,7 @@ module.exports = { generateConfig, validateAgainstExisting, preflightValidation, + policyEngineSelectionMetrics, findInterfaceForSubnet, detectWanCandidates, findAddress, diff --git a/app/web/lib/parser.js b/app/web/lib/parser.js index 39d9ee7..d8c5b58 100644 --- a/app/web/lib/parser.js +++ b/app/web/lib/parser.js @@ -112,6 +112,10 @@ const HEADER_MAP = { // Service service: 'service', 'service name': 'service', servicename: 'service', app: 'service', + // ICMP evidence when exported explicitly by FortiOS/FortiAnalyzer + icmptype: 'icmptype', icmp_type: 'icmptype', 'icmp type': 'icmptype', + icmpcode: 'icmpcode', icmp_code: 'icmpcode', 'icmp code': 'icmpcode', + // Interfaces srcintf: 'srcintf', src_intf: 'srcintf', srcinterface: 'srcintf', 'source interface': 'srcintf', 'src interface': 'srcintf', 'interface source': 'srcintf', ingressintf: 'srcintf', @@ -353,6 +357,14 @@ function extractFlow(fields) { const policytype = String(fields.policytype || '').toLowerCase().trim(); const trandisp = String(fields.trandisp || '').toLowerCase().trim(); const dstport = String(fields.dstport || '').trim(); + const parseIcmpByte = value => { + const text = String(value ?? '').trim(); + if (!/^\d+$/.test(text)) return null; + const number = Number(text); + return number >= 0 && number <= 255 ? number : null; + }; + const icmpType = parseIcmpByte(fields.icmptype); + const icmpCode = parseIcmpByte(fields.icmpcode); const evidenceIssues = []; if (decision === 'allow') { @@ -392,6 +404,8 @@ function extractFlow(fields) { action, decision, service, + icmpType, + icmpCode, srcintf: fields.srcintf || '', dstintf: fields.dstintf || '', policyid: fields.policyid || '', @@ -470,12 +484,16 @@ function aggregateFlow(flowMap, flow, dedupeState = null) { // légitimement utiliser les mêmes réseaux RFC1918 sans représenter le même contexte. // Le libellé de service n'est pas une identité réseau : FortiOS peut ne le // renseigner qu'au log terminal. Le tuple protocole/port reste la preuve. - const key = `${flow.devid || flow.devname}|${flow.vdom}|${flow.srcip}|${flow.dstip}|${flow.dstport}|${flow.proto}|${flow.decision}|${flow.srcintf}|${flow.dstintf}|${flow.policyid}|${flow.subtype}|${flow.policytype}|${flow.trandisp}|${flow.evidenceIssues.join(',')}`; + const portlessServiceIdentity = !/^(6|17|tcp|udp)$/i.test(String(flow.proto || '')) + ? String(flow.service || '').toUpperCase() + : ''; + const key = `${flow.devid || flow.devname}|${flow.vdom}|${flow.srcip}|${flow.dstip}|${flow.dstport}|${flow.proto}|${flow.icmpType ?? ''}|${flow.icmpCode ?? ''}|${portlessServiceIdentity}|${flow.decision}|${flow.srcintf}|${flow.dstintf}|${flow.policyid}|${flow.subtype}|${flow.policytype}|${flow.trandisp}|${flow.evidenceIssues.join(',')}`; if (!flowMap.has(key)) { flowMap.set(key, { srcip: flow.srcip, dstip: flow.dstip, srcport: flow.srcport, dstport: flow.dstport, proto: flow.proto, action: flow.action, decision: flow.decision, service: flow.service, + icmpType: flow.icmpType, icmpCode: flow.icmpCode, srcintf: flow.srcintf, dstintf: flow.dstintf, policyid: flow.policyid, policyname: flow.policyname, devname: flow.devname, devid: flow.devid, vdom: flow.vdom, logid: flow.logid, poluuid: flow.poluuid, diff --git a/app/web/lib/policy-engine-v2.js b/app/web/lib/policy-engine-v2.js new file mode 100644 index 0000000..bc5165a --- /dev/null +++ b/app/web/lib/policy-engine-v2.js @@ -0,0 +1,876 @@ +'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 explicitIcmp = protocol === 'ICMP' + && Number.isInteger(flow.icmpType) + && Number.isInteger(flow.icmpCode); + const namedIcmp = protocol === 'ICMP' + && observedLabel + && observedLabel !== 'ICMP' + && !icmp + && !explicitIcmp; + const icmpType = explicitIcmp ? flow.icmpType : icmp ? Number(icmp[1]) : null; + const icmpCode = explicitIcmp ? flow.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 = explicitIcmp || icmp + ? `${protocol}:${icmpType}:${icmpCode}` + : namedIcmp ? `${protocol}:NAME:${observedLabel}` + : 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.protocol === 'ICMP' + && service.label + && !['ICMP', 'ALL_ICMP', 'ALL_ICMP6'].includes(service.label)) { + return Object.values(customServices || {}) + .filter(candidate => candidate.proto === 'ICMP' + && String(candidate.name || '').toUpperCase() === service.label) + .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, + icmpType: service.icmpType, + icmpCode: service.icmpCode, + 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 destinationsByService = new Map(serviceKeys.map(serviceKey => [serviceKey, new Set()])); + for (const policy of group) { + for (const serviceKey of policy.serviceKeys) { + const serviceDestinations = destinationsByService.get(serviceKey); + for (const destination of policy.destinations) serviceDestinations.add(destination); + } + } + const matrix = {}; + for (const serviceKey of serviceKeys) { + matrix[serviceKey] = {}; + for (const destination of destinations) { + matrix[serviceKey][destination] = destinationsByService.get(serviceKey).has(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, + icmpType: atom.service.icmpType, + icmpCode: atom.service.icmpCode, + 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/public/app.js b/app/web/public/app.js index 59d6f29..c5d06ea 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() {