diff --git a/app/web/lib/analyzer.js b/app/web/lib/analyzer.js index a79f5b6..23ee532 100644 --- a/app/web/lib/analyzer.js +++ b/app/web/lib/analyzer.js @@ -34,7 +34,7 @@ function getSubnet24(ip) { } // Longest-prefix match against knownSubnets (sorted most-specific first). -// Falls back to /24 if no match or no knownSubnets provided. +// Keeps the observed IP as a /32 host when no FortiGate network matches. // knownSubnets = [{ prefix: Number, networkInt: Number, cidr: String }] function getSubnetForIP(ip, knownSubnets) { if (knownSubnets && knownSubnets.length > 0) { @@ -46,7 +46,7 @@ function getSubnetForIP(ip, knownSubnets) { } } } - return getSubnet24(ip); + return isNaN(ip2int(ip)) ? null : `${ip}/32`; } // ─── Proto labels ───────────────────────────────────────────────────────────── diff --git a/app/web/lib/forticonfig.js b/app/web/lib/forticonfig.js index b3a3b5e..e93bc70 100644 --- a/app/web/lib/forticonfig.js +++ b/app/web/lib/forticonfig.js @@ -134,19 +134,53 @@ function fortiSubnetToCIDR(subnet) { return null; } -function parsePorts(portrange) { - if (!portrange) return []; +// Réseaux connus par la configuration FortiGate, triés du plus spécifique +// au plus large. Les objets address sont ajoutés avant les interfaces afin +// qu'ils restent prioritaires lorsque le préfixe est identique. +function extractKnownSubnets(fortiConfig) { + const byCidr = new Map(); + + function addCidr(cidr) { + if (!cidr || !cidr.includes('/')) return; + const slash = cidr.lastIndexOf('/'); + const ip = cidr.slice(0, slash); + const prefix = parseInt(cidr.slice(slash + 1), 10); + const parts = ip.split('.').map(Number); + if (!Number.isInteger(prefix) || prefix <= 0 || prefix >= 32) return; + if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return; + + const mask = (0xFFFFFFFF << (32 - prefix)) >>> 0; + const networkInt = (ip2int(ip) & mask) >>> 0; + const normalized = `${int2ip(networkInt)}/${prefix}`; + if (!byCidr.has(normalized)) { + byCidr.set(normalized, { prefix, networkInt, cidr: normalized }); + } + } + + for (const address of Object.values(fortiConfig?.addresses || {})) addCidr(address.cidr); + for (const iface of Object.values(fortiConfig?.interfaces || {})) addCidr(iface.cidr); + + return [...byCidr.values()].sort((a, b) => b.prefix - a.prefix); +} + +function parsePortSpec(portrange) { const ports = []; + const ranges = []; + if (!portrange) return { ports, ranges }; for (const part of portrange.trim().split(/\s+/)) { const clean = part.split(':')[0]; // strip :src_portrange suffix (FortiGate format) - let [a, b] = clean.split('-').map(Number); - if (b && !isNaN(b)) { - if (a > b) { const t = a; a = b; b = t; } - for (let i = a; i <= Math.min(b, a + 10000); i++) ports.push(i); - } - else if (a && !isNaN(a)) ports.push(a); + let [start, end] = clean.split('-').map(Number); + if (!Number.isInteger(start) || start < 1 || start > 65535) continue; + if (!Number.isInteger(end)) end = start; + if (end < 1 || end > 65535) continue; + if (start > end) [start, end] = [end, start]; + ranges.push({ start, end }); + if (start === end) ports.push(start); } - return ports; + return { + ports: [...new Set(ports)].sort((a, b) => a - b), + ranges, + }; } // ─── FortiGate predefined services ─────────────────────────────────────────── @@ -386,13 +420,17 @@ function parseFortiConfig(text, selectedVdom = null) { const proto = (props.protocol || 'TCP/UDP/SCTP').toUpperCase(); const icmptype = props.icmptype !== undefined && props.icmptype !== '' ? parseInt(props.icmptype, 10) : null; const icmpcode = props.icmpcode !== undefined && props.icmpcode !== '' ? parseInt(props.icmpcode, 10) : null; - const tcpPorts = parsePorts(props['tcp-portrange'] || ''); - const udpPorts = parsePorts(props['udp-portrange'] || ''); + const tcpSpec = parsePortSpec(props['tcp-portrange'] || ''); + const udpSpec = parsePortSpec(props['udp-portrange'] || ''); + const tcpPorts = tcpSpec.ports; + const udpPorts = udpSpec.ports; customServices[name] = { name, proto, tcpPorts, udpPorts, + tcpRanges: tcpSpec.ranges, + udpRanges: udpSpec.ranges, // P1: Sets pré-calculés pour un lookup O(1) dans findService (au lieu de ports.includes O(n)) _tcpSet: new Set(tcpPorts), _udpSet: new Set(udpPorts), @@ -897,6 +935,53 @@ function findIcmpService(label, customServices) { return null; } +function serviceRanges(service, isUdp) { + const ranges = isUdp ? service.udpRanges : service.tcpRanges; + if (Array.isArray(ranges)) return ranges; + const ports = isUdp ? service.udpPorts : service.tcpPorts; + return (ports || []).map(port => ({ start: port, end: port })); +} + +function mergedRangeCount(ranges) { + const sorted = (ranges || []) + .map(range => ({ start: range.start, end: range.end })) + .sort((a, b) => a.start - b.start || a.end - b.end); + let count = 0; + let current = null; + for (const range of sorted) { + if (!current || range.start > current.end + 1) { + if (current) count += current.end - current.start + 1; + current = range; + } else { + current.end = Math.max(current.end, range.end); + } + } + if (current) count += current.end - current.start + 1; + return count; +} + +function formatRanges(proto, ranges) { + const values = (ranges || []).map(range => range.start === range.end + ? String(range.start) + : `${range.start}-${range.end}`); + return values.length ? `${proto}/${values.join(',')}` : ''; +} + +function formatCustomServicePortHint(service) { + return [ + formatRanges('TCP', serviceRanges(service, false)), + formatRanges('UDP', serviceRanges(service, true)), + ].filter(Boolean).join(' / ') || null; +} + +function isCatchAllTransportService(name, ranges) { + const normalizedName = String(name || '').toUpperCase().replace(/[-\s]/g, '_'); + if (normalizedName === 'ALL_TCP' || normalizedName === 'ALL_UDP') return true; + return mergedRangeCount(ranges) === 65535 + && ranges.some(range => range.start <= 1 && range.end >= 1) + && ranges.some(range => range.start <= 65535 && range.end >= 65535); +} + // Fuzzy name match: find a service by label similarity (prefix/contains) + observed ports filter function findServiceByName(label, observedPorts, protoName, customServices) { // Never fuzzy-match port-notation labels — they have their own resolution path @@ -906,9 +991,7 @@ function findServiceByName(label, observedPorts, protoName, customServices) { // 1. Case-insensitive exact match in custom services for (const [name, cs] of Object.entries(customServices)) { if (name.toLowerCase() === label.toLowerCase()) { - const tcp = (cs.tcpPorts || []).slice(0, 8).join(', '); - const udp = (cs.udpPorts || []).slice(0, 8).join(', '); - const portHint = [tcp && `TCP: ${tcp}`, udp && `UDP: ${udp}`].filter(Boolean).join(' / ') || null; + const portHint = formatCustomServicePortHint(cs); return { found: true, name, source: 'custom', portHint }; } } @@ -949,30 +1032,60 @@ function findServiceByName(label, observedPorts, protoName, customServices) { return null; } -function findService(port, protoName, customServices, opts) { +function findService(port, protoName, customServices, _opts) { const p = parseInt(port, 10); const isUdp = /^(udp|17)$/i.test(String(protoName)); - const maxPortCount = opts?.maxPortCount || Infinity; // skip services broader than this - - const matches = []; + const proto = isUdp ? 'UDP' : 'TCP'; + const exactMatches = []; + const compatibleMatches = []; - // Check predefined + // Preserve existing predefined behavior: the static mapping represents an exact service lookup. const predef = findPredefinedService(p, protoName); - if (predef) matches.push({ name: predef, source: 'predefined', portCount: 1 }); + if (predef) { + exactMatches.push({ + name: predef, source: 'predefined', proto, + portSpec: `${proto}/${p}`, coverageCount: 1, extraPortCount: 0, + }); + } - // Check custom services from config (may be multiple) + // Custom services are evaluated from structural ranges, never expanded port arrays. 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))) { - matches.push({ name, source: 'custom', portCount: ports.length }); - } + const ranges = serviceRanges(svc, isUdp); + if (!ranges.some(range => p >= range.start && p <= range.end)) continue; + if (isCatchAllTransportService(name, ranges)) continue; + + const relevantCount = mergedRangeCount(ranges); + const otherCount = mergedRangeCount(serviceRanges(svc, !isUdp)); + const coverageCount = relevantCount + otherCount; + const candidate = { + name, source: 'custom', proto, + portSpec: formatRanges(proto, ranges), + coverageCount, + extraPortCount: Math.max(0, coverageCount - 1), + }; + if (coverageCount === 1) exactMatches.push(candidate); + else compatibleMatches.push(candidate); } - if (matches.length === 0) return { found: false }; - // Prefer most specific match (fewest ports) - matches.sort((a, b) => a.portCount - b.portCount); - return { found: true, name: matches[0].name, source: matches[0].source, allMatches: matches }; + if (exactMatches.length > 0) { + const sourceRank = source => source === 'predefined' ? 0 : 1; + exactMatches.sort((a, b) => a.coverageCount - b.coverageCount + || sourceRank(a.source) - sourceRank(b.source) + || a.name.localeCompare(b.name)); + const exactMatch = exactMatches[0]; + return { + found: true, + name: exactMatch.name, + source: exactMatch.source, + exactMatch, + allMatches: exactMatches, + }; + } + if (compatibleMatches.length > 0) { + compatibleMatches.sort((a, b) => a.extraPortCount - b.extraPortCount || a.name.localeCompare(b.name)); + return { found: false, compatibleMatch: compatibleMatches[0], compatibleMatches }; + } + return { found: false }; } // ─── Policy analysis ────────────────────────────────────────────────────────── @@ -981,6 +1094,110 @@ function suggestAddrName(cidr) { return 'FF_' + (cidr || '').replace(/\//g, '_').replace(/\./g, '_'); } +// Préserve l'affinité destination/service d'une policy multi-destination à +// partir des policies d'origine conservées dans _mergedFrom. +function preserveDestinationServiceAffinity(policies) { + const serviceKey = (svc) => { + if (typeof svc === 'string') return `label:${svc}`; + if (svc?.isNamed || svc?.label || svc?.name) return `label:${svc.label || svc.name}`; + if (svc?.port != null) return `port:${svc.port}/${String(svc.proto || '').toUpperCase()}`; + if (Array.isArray(svc?.ports)) return `ports:${[...svc.ports].sort((a, b) => a - b).join(',')}/${String(svc.proto || '').toUpperCase()}`; + return JSON.stringify(svc); + }; + + const serviceLabel = (svc) => typeof svc === 'string' ? svc : (svc?.label || svc?.name || ''); + + return (policies || []).flatMap((policy) => { + const origins = (policy._mergedFrom || []).filter(origin => + origin?.dstTarget && Array.isArray(origin.analysis?.services) + ); + const destinations = [...new Set(origins.map(origin => origin.dstTarget))]; + if (destinations.length < 2) return [policy]; + + const servicesByDestination = new Map(); + for (const destination of destinations) { + const map = new Map(); + for (const origin of origins.filter(item => item.dstTarget === destination)) { + for (const svc of origin.analysis.services) map.set(serviceKey(svc), svc); + } + servicesByDestination.set(destination, map); + } + + const [firstDestination, ...remainingDestinations] = destinations; + const commonKeys = new Set(servicesByDestination.get(firstDestination).keys()); + for (const destination of remainingDestinations) { + const keys = servicesByDestination.get(destination); + for (const key of [...commonKeys]) if (!keys.has(key)) commonKeys.delete(key); + } + + const destinationMeta = new Map( + (policy._multiDstSubnets || []).map(item => [item.subnet, item]) + ); + + const buildPolicy = (targetDestinations, serviceMap) => { + const multiDestination = targetDestinations.length > 1; + const metadata = targetDestinations.map(destination => + destinationMeta.get(destination) || { + subnet: destination, + hosts: [], + useSubnet: true, + addrName: '', + addrFound: false, + } + ); + const services = [...serviceMap.values()]; + const selectedKeys = new Set(serviceMap.keys()); + return { + ...policy, + dstTarget: targetDestinations[0], + dstTargets: targetDestinations, + _isMultiDst: multiDestination, + _multiDstSubnets: multiDestination ? metadata : null, + _dstUseAll: false, + dstHosts: [...new Set(metadata.flatMap(item => item.hosts || []))].sort(), + services: services.map(serviceLabel).filter(Boolean), + serviceDesc: services.map(serviceLabel).filter(Boolean).join(', '), + _mergedCount: targetDestinations.length, + _mergedFrom: origins + .filter(origin => targetDestinations.includes(origin.dstTarget)) + .map(origin => ({ + ...origin, + analysis: { + ...origin.analysis, + services: origin.analysis.services.filter(svc => selectedKeys.has(serviceKey(svc))), + }, + })), + analysis: { + ...policy.analysis, + services, + needsWork: services.some(svc => !svc?.found), + }, + }; + }; + + const result = []; + if (commonKeys.size > 0) { + const commonServices = new Map(); + for (const key of commonKeys) commonServices.set(key, servicesByDestination.get(firstDestination).get(key)); + result.push(buildPolicy(destinations, commonServices)); + } + + const specificGroups = new Map(); + for (const destination of destinations) { + const specificServices = new Map( + [...servicesByDestination.get(destination)].filter(([key]) => !commonKeys.has(key)) + ); + if (specificServices.size === 0) continue; + const signature = [...specificServices.keys()].sort().join('\u0001'); + if (!specificGroups.has(signature)) specificGroups.set(signature, { destinations: [], services: specificServices }); + specificGroups.get(signature).destinations.push(destination); + } + for (const group of specificGroups.values()) result.push(buildPolicy(group.destinations, group.services)); + + return result.length > 0 ? result : [policy]; + }); +} + function analyzePolicies(policies, fortiConfig, preferredWanIntf) { const { addresses, customServices, interfaces, zones } = fortiConfig; @@ -998,6 +1215,12 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { // Services const protoLabel = p.protos?.[0] || 'TCP'; const serviceItems = []; + const acceptedCompatibleReuse = (port, proto, compatibleMatches) => { + if (!port || !compatibleMatches?.length) return null; + const key = `${String(proto || '').toUpperCase()}/${port}`; + const requested = p._serviceReuse?.[key]; + return compatibleMatches.some(match => match.name === requested) ? requested : null; + }; if (p.services && p.services.length > 0) { for (const svc of p.services) { @@ -1015,12 +1238,15 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { // Fallback: if name-based lookup failed, try matching by port against custom services let portFallback = null; + let compatibleMatch = null; + let portResolution = null; if (!knownPredef && !customMatch && !icmpMatch && !fuzzyMatch) { // Port-notation label (e.g. "UDP/11436"): use the port embedded in the label const pnm = svc.match(/^(TCP|UDP)\/(\d+)$/i); if (pnm) { - const m = findService(parseInt(pnm[2], 10), pnm[1], customServices, { maxPortCount: 100 }); - if (m.found) portFallback = m; + portResolution = findService(parseInt(pnm[2], 10), pnm[1], customServices); + if (portResolution.found) portFallback = portResolution; + else compatibleMatch = portResolution.compatibleMatch || null; } else if (p.ports?.length > 0) { // Named service (e.g. "NETBIOS-RPC"): try all observed ports, accept only if // all matches resolve to the same service (unambiguous single candidate) @@ -1045,12 +1271,12 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { ? `${cs.proto} type ${cs.icmptype}${cs.icmpcode !== null ? ` code ${cs.icmpcode}` : ''}` : cs.proto; } else if (cs) { - const tcp = cs.tcpPorts.slice(0, 8).join(', '); - const udp = cs.udpPorts.slice(0, 8).join(', '); - portHint = [tcp && `TCP: ${tcp}`, udp && `UDP: ${udp}`].filter(Boolean).join(' / '); + portHint = formatCustomServicePortHint(cs) || ''; } else if (portFallback) { portHint = `${protoLabel}: ${p.ports[0]} (observé)`; } + } else if (compatibleMatch) { + portHint = `${compatibleMatch.proto}: ${svc.match(/\d+$/)?.[0] || ''} (observé)`; } else if (fuzzyMatch) { portHint = fuzzyMatch.portHint || ''; } else if (knownPredef) { @@ -1061,19 +1287,31 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { portHint = `${protoLabel}: ${p.ports[0]} (observé)`; } - const found = knownPredef || !!customMatch || !!icmpMatch || !!fuzzyMatch || !!portFallback; - const resolvedName = icmpMatch ? icmpMatch.name + const portNotation = svc.match(/^(TCP|UDP)\/(\d+)$/i); + const reusedCompatibleName = portNotation + ? acceptedCompatibleReuse( + parseInt(portNotation[2], 10), portNotation[1], + portResolution?.compatibleMatches || (compatibleMatch ? [compatibleMatch] : []), + ) + : null; + const found = knownPredef || !!customMatch || !!icmpMatch || !!fuzzyMatch || !!portFallback || !!reusedCompatibleName; + const resolvedName = reusedCompatibleName || (icmpMatch ? icmpMatch.name : fuzzyMatch ? fuzzyMatch.name : portFallback ? portFallback.name - : (knownPredef || customMatch ? svc : null); + : (knownPredef || customMatch ? svc : null)); serviceItems.push({ label: svc, found, name: resolvedName, - source: icmpMatch ? icmpMatch.source : fuzzyMatch ? fuzzyMatch.source : portFallback ? portFallback.source : (knownPredef ? 'predefined' : customMatch ? 'custom' : null), - suggestedName: resolvedName || svc, + source: reusedCompatibleName ? 'custom-compatible' : icmpMatch ? icmpMatch.source : fuzzyMatch ? fuzzyMatch.source : portFallback ? portFallback.source : (knownPredef ? 'predefined' : customMatch ? 'custom' : null), + suggestedName: resolvedName || (portNotation ? `FF_SVC_${portNotation[2]}_${portNotation[1].toUpperCase()}` : svc), isNamed: true, + port: portNotation ? parseInt(portNotation[2], 10) : undefined, + proto: portNotation ? portNotation[1].toUpperCase() : undefined, portHint, + compatibleMatch: compatibleMatch || undefined, + compatibleMatches: portResolution?.compatibleMatches || undefined, + compatibilityAccepted: reusedCompatibleName ? true : undefined, }); } } @@ -1088,18 +1326,22 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { deduped.forEach(i => serviceItems.push(i)); // Fallback sur les ports bruts si aucun service nommé reconnu (ou tous ISDB) - if (serviceItems.length === 0 && p.ports?.length) { + if (serviceItems.length === 0 && p.ports?.length && !p._mergedServices?.length) { for (const port of p.ports.slice(0, 10)) { const match = findService(port, protoLabel, customServices); + const reusedCompatibleName = acceptedCompatibleReuse(port, protoLabel, match.compatibleMatches); serviceItems.push({ label: `${port}/${protoLabel}`, port, proto: protoLabel, portHint: `${protoLabel}: ${port}`, - found: match.found, - name: match.found ? match.name : null, - source: match.source || null, + found: match.found || !!reusedCompatibleName, + name: match.found ? match.name : reusedCompatibleName, + source: match.source || (reusedCompatibleName ? 'custom-compatible' : null), suggestedName: `FF_SVC_${port}_${protoLabel}`, + compatibleMatch: match.compatibleMatch || undefined, + compatibleMatches: match.compatibleMatches || undefined, + compatibilityAccepted: reusedCompatibleName ? true : undefined, }); } } @@ -1241,6 +1483,628 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) { }); } +function normalizedFlowProtocol(flow) { + const value = String(flow?.protoName || flow?.proto || '').toUpperCase(); + if (value === '6' || value === 'TCP') return 'TCP'; + if (value === '17' || value === 'UDP') return 'UDP'; + return value; +} + +function flowMatchesPolicySide(ip, subnet, policy, multiField, targetsField, hostsField, targetField, useHosts) { + const multi = policy[multiField]; + if (Array.isArray(multi) && multi.length > 0) { + return multi.some(item => item?.useSubnet !== false + ? item?.subnet === subnet + : (item?.hosts || []).includes(ip)); + } + const hosts = policy[hostsField]; + if (useHosts) return Array.isArray(hosts) && hosts.includes(ip); + return policy[targetField] === subnet || policy[targetField] === ip; +} + +function flowMatchesPolicyScope(flow, policy) { + const sourceMatches = flowMatchesPolicySide( + flow.srcip, flow.srcSubnet, policy, + '_multiSrcSubnets', 'srcSubnets', 'srcHosts', 'srcSubnet', + policy._use32Src === true, + ); + const publicAll = policy.dstType === 'public' && (policy.dstTarget === 'all' || policy._dstUseAll === true); + const destinationMatches = publicAll + ? flow.dstType === 'public' + : flowMatchesPolicySide( + flow.dstip, flow.dstSubnet, policy, + '_multiDstSubnets', 'dstTargets', 'dstHosts', 'dstTarget', + policy._use32Dst === true, + ); + return sourceMatches && destinationMatches; +} + +function policySideElementsProven(policy, evidenceFlows, side) { + if (side === 'dst' && policy.dstType === 'public' + && (policy.dstTarget === 'all' || policy._dstUseAll === true)) return evidenceFlows.length > 0; + const multi = policy[side === 'src' ? '_multiSrcSubnets' : '_multiDstSubnets']; + const ipField = side === 'src' ? 'srcip' : 'dstip'; + const subnetField = side === 'src' ? 'srcSubnet' : 'dstSubnet'; + if (Array.isArray(multi) && multi.length > 0) { + return multi.every(item => item?.useSubnet !== false + ? evidenceFlows.some(flow => flow[subnetField] === item?.subnet) + : (item?.hosts || []).every(host => evidenceFlows.some(flow => flow[ipField] === host))); + } + const useHosts = side === 'src' ? policy._use32Src === true : policy._use32Dst === true; + if (useHosts) { + const hosts = policy[side === 'src' ? 'srcHosts' : 'dstHosts']; + return Array.isArray(hosts) && hosts.length > 0 + && hosts.every(host => evidenceFlows.some(flow => flow[ipField] === host)); + } + const target = policy[side === 'src' ? 'srcSubnet' : 'dstTarget']; + return evidenceFlows.some(flow => flow[subnetField] === target || flow[ipField] === target); +} + +function policyRepresentationIssue(policy) { + for (const field of ['_use32Src', '_use32Dst', '_dstUseAll', '_isWan', '_useSrcGroup', '_useDstGroup', '_isMultiDst']) { + if (policy[field] !== undefined && typeof policy[field] !== 'boolean') return `${field} mal formé`; + } + for (const field of ['_multiSrcSubnets', '_multiDstSubnets']) { + if (policy[field] !== undefined && !Array.isArray(policy[field])) return `${field} mal formé`; + for (const item of (policy[field] || [])) { + if (!item || typeof item !== 'object' || typeof item.useSubnet !== 'boolean') return `${field}.useSubnet mal formé`; + if (item.addrFound !== undefined && typeof item.addrFound !== 'boolean') return `${field}.addrFound mal formé`; + if (item.hosts !== undefined && !Array.isArray(item.hosts)) return `${field}.hosts mal formé`; + if (item.addrName !== undefined && typeof item.addrName !== 'string') return `${field}.addrName mal formé`; + } + } + if (policy._isWan === true && policy.dstType !== 'public') return '_isWan incohérent avec le type destination'; + if (!policy._multiSrcSubnets?.length && policy.srcSubnets?.length + && (policy.srcSubnets.length !== 1 || (policy.srcSubnets[0]?.subnet || policy.srcSubnets[0]) !== policy.srcSubnet)) { + return 'srcSubnet/srcSubnets incohérents'; + } + if (!policy._multiDstSubnets?.length && policy.dstTargets?.length + && (policy.dstTargets.length !== 1 || (policy.dstTargets[0]?.subnet || policy.dstTargets[0]) !== policy.dstTarget)) { + return 'dstTarget/dstTargets incohérents'; + } + if (policy._srcMode === 'hosts' && policy._use32Src !== true) return 'mode source hosts incohérent'; + if (policy._srcMode === 'subnet' && policy._use32Src === true) return 'mode source subnet incohérent'; + if (policy._dstMode === 'hosts' && policy._use32Dst !== true) return 'mode destination hosts incohérent'; + if (policy._dstMode === 'subnet' && policy._use32Dst === true) return 'mode destination subnet incohérent'; + if (policy.dstTarget === 'all' && policy._dstUseAll !== true) return 'destination all non confirmée'; + if (policy.dstTarget !== 'all' && policy._dstUseAll === true) return 'destination spécifique marquée all'; + return null; +} + +function validatePolicyDecisionShapes(policies) { + const issues = []; + if (!Array.isArray(policies)) { + return { ok: false, issues: [{ level: 'error', code: 'SCOPE_DECISION_INVALID', msg: 'Policies mal formées' }] }; + } + policies.forEach((policy, index) => { + const issue = policyRepresentationIssue(policy || {}); + if (issue) issues.push({ level: 'error', code: 'SCOPE_DECISION_INVALID', msg: `Policy #${index + 1}: ${issue}` }); + }); + return { ok: issues.length === 0, issues }; +} + +function isPolicyEvidenceFlow(flow) { + return ['accept', 'deny', 'drop'].includes(String(flow?.action || '').toLowerCase()); +} + +function observedServiceTuples(policy, serviceLabel, observedFlows) { + const tuples = new Map(); + for (const flow of (observedFlows || [])) { + if (!isPolicyEvidenceFlow(flow) || !flowMatchesPolicyScope(flow, policy)) continue; + if (String(flow.service || '').toUpperCase() !== String(serviceLabel || '').toUpperCase()) continue; + const port = Number(flow.dstport); + const proto = normalizedFlowProtocol(flow); + if (!Number.isInteger(port) || port < 1 || port > 65535 || !['TCP', 'UDP'].includes(proto)) continue; + tuples.set(`${proto}/${port}`, { proto, port }); + } + return [...tuples.values()]; +} + +function serviceNameDecisionIssue(name, fortiConfig) { + const reserved = new Set(['ALL', 'ALL_TCP', 'ALL_UDP', 'ALL_ICMP', 'ALL_ICMP6']); + if (!name || name.length > 79 || reserved.has(name.toUpperCase()) + || /["\\?*#\u0000-\u001f\u007f]/.test(name)) { + return { code: 'SERVICE_NAME_INVALID', msg: `nom de service invalide "${name}"` }; + } + const existingName = Object.keys(fortiConfig?.customServices || {}) + .find(candidate => candidate.toLowerCase() === name.toLowerCase()); + const existingGroup = Object.keys(fortiConfig?.serviceGroups || {}) + .find(candidate => candidate.toLowerCase() === name.toLowerCase()); + const predefinedName = Object.values(PREDEFINED) + .some(service => service.name.toLowerCase() === name.toLowerCase()); + if (existingName || existingGroup || predefinedName) { + return { code: 'SERVICE_NAME_CONFLICT', msg: `nom de service déjà utilisé "${name}"` }; + } + return null; +} + +function serviceTransportKey(service) { + const notation = String(service?.label || '').match(/^(TCP|UDP)\/(\d+)$/i); + const proto = String(notation ? notation[1] : service?.proto || '').toUpperCase(); + const port = Number(notation ? notation[2] : service?.port); + return ['TCP', 'UDP'].includes(proto) && Number.isInteger(port) && port >= 1 && port <= 65535 + ? `${proto}/${port}` + : ''; +} + +function serviceEvidenceProven(service, evidenceFlows) { + const label = String(service?.label || '').toUpperCase(); + const forward = label.match(/^(TCP|UDP)\/(\d+)$/); + const reverse = label.match(/^(\d+)\/(TCP|UDP)$/); + const notation = forward + ? { proto: forward[1], port: Number(forward[2]) } + : reverse ? { proto: reverse[2], port: Number(reverse[1]) } : null; + if (!notation) { + return evidenceFlows.some(flow => String(flow.service || '').toUpperCase() === label); + } + const expectedProto = notation.proto; + const expectedPort = notation.port; + const acceptedLabels = new Set([`${expectedProto}/${expectedPort}`, `${expectedPort}/${expectedProto}`]); + return evidenceFlows.some(flow => { + const flowLabel = String(flow.service || '').toUpperCase(); + return normalizedFlowProtocol(flow) === expectedProto + && Number(flow.dstport) === expectedPort + && (!flowLabel || acceptedLabels.has(flowLabel)); + }); +} + +function foundServiceEvidenceProven(service, evidenceFlows, fortiConfig) { + const selectedKey = serviceTransportKey(service); + const relevant = evidenceFlows.filter(flow => service.compatibilityAccepted === true + ? `${normalizedFlowProtocol(flow)}/${Number(flow.dstport)}` === selectedKey + : String(flow.service || '').toUpperCase() === String(service.label || '').toUpperCase()); + if (relevant.length === 0) return false; + const custom = fortiConfig?.customServices?.[service.name]; + return relevant.every(flow => { + const proto = normalizedFlowProtocol(flow); + const port = Number(flow.dstport); + if (!['TCP', 'UDP'].includes(proto) || !Number.isInteger(port)) return false; + if (custom) { + const ports = proto === 'TCP' ? (custom.tcpPorts || []) : (custom.udpPorts || []); + const ranges = proto === 'TCP' ? (custom.tcpRanges || []) : (custom.udpRanges || []); + return ports.includes(port) || ranges.some(range => port >= range.start && port <= range.end); + } + return Object.entries(PREDEFINED).some(([definedPort, definition]) => + definition.name === service.name + && Number(definedPort) === port + && (definition.proto === 'both' || definition.proto.toUpperCase() === proto) + ); + }); +} + +function observedPolicyTransportKeys(policy, observedFlows) { + const keys = new Set(); + for (const flow of (observedFlows || [])) { + if (!isPolicyEvidenceFlow(flow) || !flowMatchesPolicyScope(flow, policy)) continue; + const proto = normalizedFlowProtocol(flow); + const port = Number(flow.dstport); + if (['TCP', 'UDP'].includes(proto) && Number.isInteger(port) && port >= 1 && port <= 65535) { + keys.add(`${proto}/${port}`); + } + } + return keys; +} + +function validateGenerationOptions(input, fortiConfig) { + const issues = []; + const validShape = input == null || (typeof input === 'object' && !Array.isArray(input)); + if (!validShape) { + issues.push({ level: 'error', code: 'OPTIONS_DECISION_INVALID', msg: 'Options globales mal formées' }); + } + const source = validShape && input ? input : {}; + const action = String(source.action ?? 'accept').toLowerCase(); + const log = String(source.log ?? 'all').toLowerCase(); + if (!['accept', 'deny'].includes(action)) { + issues.push({ level: 'error', code: 'ACTION_DECISION_INVALID', msg: `Action globale invalide "${source.action}"` }); + } + if (!['all', 'utm', 'disable'].includes(log)) { + issues.push({ level: 'error', code: 'LOG_DECISION_INVALID', msg: `Mode de log global invalide "${source.log}"` }); + } + if (source.nat !== undefined && typeof source.nat !== 'boolean') { + issues.push({ level: 'error', code: 'NAT_DECISION_INVALID', msg: 'Décision NAT globale invalide' }); + } + const securityProfiles = {}; + if (source.securityProfiles !== undefined) { + if (!source.securityProfiles || typeof source.securityProfiles !== 'object' || Array.isArray(source.securityProfiles)) { + issues.push({ level: 'error', code: 'SECURITY_PROFILE_DECISION_INVALID', msg: 'Profils de sécurité globaux invalides' }); + } else { + for (const [key, name] of Object.entries(source.securityProfiles)) { + if (!['antivirus', 'webfilter', 'ips', 'sslSsh', 'profileGroup'].includes(key) + || !(fortiConfig?.securityProfiles?.[key] || []).includes(name)) { + issues.push({ level: 'error', code: 'SECURITY_PROFILE_DECISION_INVALID', msg: `Profil global ${key} inconnu "${name}"` }); + } else { + securityProfiles[key] = name; + } + } + } + } + const wanOverrides = []; + if (source.wanOverrides !== undefined && !Array.isArray(source.wanOverrides)) { + issues.push({ level: 'error', code: 'WAN_DECISION_INVALID', msg: 'Overrides WAN invalides' }); + } else { + for (const name of (source.wanOverrides || [])) { + if (typeof name !== 'string' || !fortiConfig?.interfaces?.[name]) { + issues.push({ level: 'error', code: 'WAN_DECISION_INVALID', msg: `Override WAN inconnu "${name}"` }); + } else if (!wanOverrides.includes(name)) { + wanOverrides.push(name); + } + } + } + const validWanNames = new Set([ + ...Object.values(fortiConfig?.interfaces || {}).filter(iface => iface.isWan).map(iface => iface.name), + ...Object.values(fortiConfig?.zones || {}).filter(zone => zone.isWan).map(zone => zone.name), + ...(fortiConfig?.sdwanZoneNames || []), + fortiConfig?.sdwanIntfName, + ...wanOverrides, + ].filter(Boolean)); + const preferredWanIntf = source.preferredWanIntf || null; + if (preferredWanIntf !== null + && (typeof preferredWanIntf !== 'string' || !validWanNames.has(preferredWanIntf))) { + issues.push({ level: 'error', code: 'WAN_DECISION_INVALID', msg: `Interface WAN préférée invalide "${preferredWanIntf}"` }); + } + return { + ok: issues.length === 0, + issues, + opts: { + action: ['accept', 'deny'].includes(action) ? action : 'accept', + log: ['all', 'utm', 'disable'].includes(log) ? log : 'all', + nat: typeof source.nat === 'boolean' ? source.nat : false, + securityProfiles, + preferredWanIntf: validWanNames.has(preferredWanIntf) ? preferredWanIntf : null, + wanOverrides, + }, + }; +} + +function applyPolicyUserDecisions(authoritativePolicies, submittedPolicies, fortiConfig, observedFlows) { + const policies = structuredClone(authoritativePolicies || []); + const issues = []; + const serviceDefinitions = new Map(); + const addressNamesByTarget = new Map(); + const addressTargetsByName = new Map(); + const registerServiceDefinition = (name, signature, policyIndex) => { + const key = name.toLowerCase(); + const previous = serviceDefinitions.get(key); + if (previous && previous !== signature) { + issues.push({ level: 'error', code: 'SERVICE_NAME_CONFLICT', msg: `Policy #${policyIndex + 1}: nom de service "${name}" utilisé pour plusieurs définitions` }); + return false; + } + serviceDefinitions.set(key, signature); + return true; + }; + const registerAddressDefinition = (name, target, policyIndex) => { + if (!name || !target || name.toLowerCase() === 'all' + || name.length > 79 || /["\\?*#\u0000-\u001f\u007f]/.test(name)) { + issues.push({ level: 'error', code: 'ADDRESS_NAME_INVALID', msg: `Policy #${policyIndex + 1}: nom d’adresse invalide "${name}"` }); + return false; + } + const normalizedName = name.toLowerCase(); + const previousName = addressNamesByTarget.get(target); + const previousTarget = addressTargetsByName.get(normalizedName); + if ((previousName && previousName !== name) || (previousTarget && previousTarget !== target) + || (fortiConfig?.addresses?.[name] && fortiConfig.addresses[name].cidr !== target) + || fortiConfig?.addressGroups?.[name]) { + issues.push({ level: 'error', code: 'ADDRESS_NAME_CONFLICT', msg: `Policy #${policyIndex + 1}: nom d’adresse "${name}" incompatible avec "${target}"` }); + return false; + } + addressNamesByTarget.set(target, name); + addressTargetsByName.set(normalizedName, target); + return true; + }; + for (let index = 0; index < policies.length; index++) { + const policy = policies[index]; + const submitted = submittedPolicies?.[index] || {}; + delete policy.serviceNames; + delete policy.action; + delete policy.log; + delete policy.securityProfiles; + delete policy.nat; + delete policy._srcAddrGrpFound; + delete policy._dstAddrGrpFound; + const representationIssue = policyRepresentationIssue(submitted); + if (representationIssue) { + issues.push({ level: 'error', code: 'SCOPE_DECISION_INVALID', msg: `Policy #${index + 1}: ${representationIssue}` }); + continue; + } + if (!policy._multiSrcSubnets?.length && !policy._use32Src && policy.analysis?.srcAddr?.found) { + delete policy._srcAddrName; + delete policy.srcAddrName; + } + if (!policy._multiDstSubnets?.length && !policy._use32Dst && policy.analysis?.dstAddr?.found) { + delete policy._dstAddrName; + delete policy.dstAddrName; + } + for (const [side, multiField, hostFlag] of [ + ['src', '_multiSrcSubnets', '_use32Src'], + ['dst', '_multiDstSubnets', '_use32Dst'], + ]) { + const address = policy.analysis?.[`${side}Addr`]; + if (policy[multiField]?.length || policy[hostFlag] || address?.found || !address?.cidr) continue; + const field = `${side}AddrName`; + const privateField = `_${side}AddrName`; + const requestedName = String(submitted[privateField] || submitted[field] || address.suggestedName || '').trim(); + if (registerAddressDefinition(requestedName, address.cidr, index)) { + policy[field] = requestedName; + delete policy[privateField]; + } + } + for (const [side, multiField, hostFlag, groupFlag] of [ + ['src', '_multiSrcSubnets', '_use32Src', '_useSrcGroup'], + ['dst', '_multiDstSubnets', '_use32Dst', '_useDstGroup'], + ]) { + const groupName = submitted[`${side}AddrName`] || submitted[`_${side}AddrName`]; + if (submitted[groupFlag] === true && groupName && fortiConfig?.addressGroups?.[groupName]) { + issues.push({ level: 'error', code: 'ADDRESS_NAME_CONFLICT', msg: `Policy #${index + 1}: groupe existant non prouvé "${groupName}"` }); + } + const multi = policy[multiField] || []; + for (const item of multi) { + if (item.useSubnet === false) continue; + const exact = Object.entries(fortiConfig?.addresses || {}) + .find(([, address]) => address.cidr === item.subnet); + if (exact) { + item.addrFound = true; + item.addrName = exact[0]; + } else { + item.addrFound = false; + const name = String(item.addrName || suggestAddrName(item.subnet)).trim(); + if (registerAddressDefinition(name, item.subnet, index)) item.addrName = name; + } + } + const hosts = policy[hostFlag] + ? (policy[side === 'src' ? 'srcHosts' : 'dstHosts'] || []) + : multi.filter(item => item.useSubnet === false).flatMap(item => item.hosts || []); + if (hosts.length > 0) { + const sourceNames = submitted[`_${side}HostNames`] || {}; + const validatedNames = {}; + for (const host of hosts) { + const exact = Object.entries(fortiConfig?.addresses || {}) + .find(([, address]) => address.cidr === `${host}/32`); + if (exact) { + validatedNames[host] = exact[0]; + continue; + } + const name = String(sourceNames[host] || `FF_HOST_${host.replace(/\./g, '_')}`).trim(); + if (registerAddressDefinition(name, `${host}/32`, index)) validatedNames[host] = name; + } + policy[`_${side}HostNames`] = validatedNames; + } + } + if (policy.dstType === 'public' && policy.dstTarget !== 'all' && policy._dstUseAll === undefined) { + policy._dstUseAll = false; + } + policy._isWan = policy.dstType === 'public'; + const evidenceFlows = (observedFlows || []).filter(flow => + isPolicyEvidenceFlow(flow) && flowMatchesPolicyScope(flow, policy) + ); + if (evidenceFlows.length === 0 + || !policySideElementsProven(policy, evidenceFlows, 'src') + || !policySideElementsProven(policy, evidenceFlows, 'dst')) { + issues.push({ level: 'error', code: 'SCOPE_DECISION_INVALID', msg: `Policy #${index + 1}: scope absent des flux observés` }); + } + const validInterfaces = new Set([ + ...Object.keys(fortiConfig?.interfaces || {}), + ...Object.keys(fortiConfig?.zones || {}), + ...(fortiConfig?.sdwanZoneNames || []), + fortiConfig?.sdwanIntfName, + ].filter(Boolean)); + const allowedInterfaces = { + srcintf: new Set(evidenceFlows.map(flow => flow.srcintf).filter(Boolean)), + dstintf: new Set(evidenceFlows.map(flow => flow.dstintf).filter(Boolean)), + }; + for (const [field, zoneField, ifaceField] of [ + ['srcintf', 'srcZone', 'srcIface'], + ['dstintf', 'dstZone', 'dstIface'], + ]) { + for (const [zoneName, zone] of Object.entries(fortiConfig?.zones || {})) { + if ((zone.members || []).some(member => allowedInterfaces[field].has(member))) { + allowedInterfaces[field].add(zone.name || zoneName); + } + } + if (field === 'dstintf' + && (fortiConfig?.sdwanMembers || []).some(member => allowedInterfaces[field].has(member))) { + for (const zoneName of (fortiConfig.sdwanZoneNames || [])) allowedInterfaces[field].add(zoneName); + if (fortiConfig.sdwanIntfName) allowedInterfaces[field].add(fortiConfig.sdwanIntfName); + } + if (allowedInterfaces[field].size === 0) { + if (policy.analysis?.[zoneField]) allowedInterfaces[field].add(policy.analysis[zoneField]); + if (policy.analysis?.[ifaceField]) allowedInterfaces[field].add(policy.analysis[ifaceField]); + } + } + for (const [field, label] of [['srcintf', 'source'], ['dstintf', 'destination']]) { + if (submitted[field] === undefined) continue; + const values = (Array.isArray(submitted[field]) ? submitted[field] : [submitted[field]]) + .map(value => String(value || '').trim()).filter(Boolean); + if (values.length === 0 || values.some(value => + !validInterfaces.has(value) || !allowedInterfaces[field].has(value) + )) { + issues.push({ level: 'error', code: 'INTERFACE_DECISION_INVALID', msg: `Policy #${index + 1}: interface ${label} inconnue "${values.join(', ')}"` }); + } else { + policy[field] = Array.isArray(submitted[field]) ? values : values[0]; + } + } + const interfaceMatches = (choice, actual, side) => { + if (choice === actual) return true; + if ((fortiConfig?.zones?.[choice]?.members || []).includes(actual)) return true; + return side === 'dst' + && (fortiConfig?.sdwanZoneNames || []).includes(choice) + && (fortiConfig?.sdwanMembers || []).includes(actual); + }; + const chosenSrcInterfaces = [].concat(policy.srcintf || policy.analysis?.srcZone || policy.analysis?.srcIface || []).filter(Boolean); + const chosenDstInterfaces = [].concat(policy.dstintf || policy.analysis?.dstZone || policy.analysis?.dstIface || []).filter(Boolean); + const pairProven = chosenSrcInterfaces.every(srcChoice => + chosenDstInterfaces.every(dstChoice => evidenceFlows.some(flow => + interfaceMatches(srcChoice, flow.srcintf, 'src') + && interfaceMatches(dstChoice, flow.dstintf, 'dst') + )) + ); + if (!pairProven) { + issues.push({ level: 'error', code: 'INTERFACE_DECISION_INVALID', msg: `Policy #${index + 1}: paire d’interfaces absente des flux observés` }); + } + for (const [field, zoneField, ifaceField, label] of [ + ['srcintf', 'srcZone', 'srcIface', 'source'], + ['dstintf', 'dstZone', 'dstIface', 'destination'], + ]) { + const effective = policy[field] || policy.analysis?.[zoneField] || policy.analysis?.[ifaceField]; + const values = (Array.isArray(effective) ? effective : [effective]).filter(Boolean).map(String); + if (values.length === 0 || values.some(value => + !validInterfaces.has(value) || !allowedInterfaces[field].has(value) + )) { + issues.push({ level: 'error', code: 'INTERFACE_DECISION_INVALID', msg: `Policy #${index + 1}: interface ${label} effective inconnue "${values.join(', ')}"` }); + } + } + if (submitted.action != null) { + const action = String(submitted.action).toLowerCase(); + if (!['accept', 'deny'].includes(action)) { + issues.push({ level: 'error', code: 'ACTION_DECISION_INVALID', msg: `Policy #${index + 1}: action invalide "${submitted.action}"` }); + } else { + policy.action = action; + } + } + if (submitted.log != null) { + const log = String(submitted.log).toLowerCase(); + if (!['all', 'utm', 'disable'].includes(log)) { + issues.push({ level: 'error', code: 'LOG_DECISION_INVALID', msg: `Policy #${index + 1}: mode de log invalide "${submitted.log}"` }); + } else { + policy.log = log; + } + } + if (submitted.nat != null) { + if (typeof submitted.nat !== 'boolean') { + issues.push({ level: 'error', code: 'NAT_DECISION_INVALID', msg: `Policy #${index + 1}: décision NAT invalide` }); + } else { + policy.nat = submitted.nat; + } + } + if (submitted.securityProfiles != null) { + const selectedProfiles = {}; + if (typeof submitted.securityProfiles !== 'object' || Array.isArray(submitted.securityProfiles)) { + issues.push({ level: 'error', code: 'SECURITY_PROFILE_DECISION_INVALID', msg: `Policy #${index + 1}: profils de sécurité mal formés` }); + } else { + for (const [key, name] of Object.entries(submitted.securityProfiles)) { + if (!['antivirus', 'webfilter', 'ips', 'sslSsh', 'profileGroup'].includes(key) + || !(fortiConfig?.securityProfiles?.[key] || []).includes(name)) { + issues.push({ level: 'error', code: 'SECURITY_PROFILE_DECISION_INVALID', msg: `Policy #${index + 1}: profil ${key} inconnu "${name}"` }); + } else { + selectedProfiles[key] = name; + } + } + } + policy.securityProfiles = selectedProfiles; + } + if (submitted._serviceReuse !== undefined) { + const reuseEntries = submitted._serviceReuse && typeof submitted._serviceReuse === 'object' + && !Array.isArray(submitted._serviceReuse) + ? Object.entries(submitted._serviceReuse) + : []; + if (reuseEntries.length === 0 && Object.keys(submitted._serviceReuse || {}).length > 0) { + issues.push({ level: 'error', code: 'SERVICE_REUSE_DECISION_INVALID', msg: `Policy #${index + 1}: choix de réutilisation invalide` }); + } + for (const [rawKey, requestedName] of reuseEntries) { + const key = String(rawKey).toUpperCase(); + const accepted = (policy.analysis?.services || []).find(service => + serviceTransportKey(service) === key + && service.found === true + && service.compatibilityAccepted === true + && service.name === requestedName + ); + if (!accepted) { + issues.push({ level: 'error', code: 'SERVICE_REUSE_DECISION_INVALID', msg: `Policy #${index + 1}: réutilisation stale ou forgée "${rawKey}" → "${requestedName}"` }); + } + } + } + const submittedServices = submitted.analysis?.services || []; + const mergedServices = Array.isArray(submitted._mergedServices) ? submitted._mergedServices : []; + for (const service of (policy.analysis?.services || [])) { + if (!serviceEvidenceProven(service, evidenceFlows)) { + issues.push({ level: 'error', code: 'SERVICE_DECISION_UNPROVEN', msg: `Policy #${index + 1}: service "${service.label || service.name || '?'}" absent des flux observés` }); + continue; + } + if (service.found) { + if (service.compatibilityAccepted !== true + && !foundServiceEvidenceProven(service, evidenceFlows, fortiConfig)) { + issues.push({ level: 'error', code: 'SERVICE_DECISION_UNPROVEN', msg: `Policy #${index + 1}: service "${service.name}" incompatible avec le tuple observé` }); + } + continue; + } + const requested = submittedServices.find(item => item.label === service.label); + const suggestedName = typeof requested?.suggestedName === 'string' && requested.suggestedName.trim() + ? requested.suggestedName.trim() + : String(service.suggestedName || '').trim(); + if (!service.port || !service.proto) { + const tuples = observedServiceTuples(policy, service.label, observedFlows); + if (tuples.length !== 1) { + issues.push({ level: 'error', code: 'SERVICE_DECISION_AMBIGUOUS', msg: `Policy #${index + 1}: service "${service.label}" sans tuple protocole/port unique` }); + continue; + } + service.port = tuples[0].port; + service.proto = tuples[0].proto; + } + const nameIssue = serviceNameDecisionIssue(suggestedName, fortiConfig); + if (nameIssue) { + issues.push({ level: 'error', code: nameIssue.code, msg: `Policy #${index + 1}: ${nameIssue.msg}` }); + continue; + } + if (!registerServiceDefinition(suggestedName, `${service.proto}/${service.port}`, index)) continue; + service.suggestedName = suggestedName; + } + if (mergedServices.length > 0) { + const authoritativeKeys = new Set((policy.analysis?.services || []).map(serviceTransportKey).filter(Boolean)); + const observedKeys = observedPolicyTransportKeys(policy, observedFlows); + const consumedKeys = new Set(); + const validatedMerged = []; + const mergedNames = new Set(); + for (const merged of mergedServices) { + const proto = String(merged?.proto || '').toUpperCase(); + const sourcePorts = [...new Set((merged?.sourcePorts || []).map(Number))].sort((a, b) => a - b); + const name = typeof merged?.name === 'string' ? merged.name.trim() : ''; + const nameIssue = serviceNameDecisionIssue(name, fortiConfig); + const sourceKeys = sourcePorts.map(port => `${proto}/${port}`); + let invalid = !['TCP', 'UDP'].includes(proto) + || sourcePorts.length < 2 + || (Array.isArray(merged?.ports) && typeof merged?.portRange === 'string') + || sourcePorts.some(port => !Number.isInteger(port) || port < 1 || port > 65535) + || sourceKeys.some(key => !authoritativeKeys.has(key) || !observedKeys.has(key) || consumedKeys.has(key)); + let ports = null; + let portRange = null; + if (Array.isArray(merged?.ports)) { + const requestedPorts = [...new Set(merged.ports.map(Number))].sort((a, b) => a - b); + invalid ||= requestedPorts.length !== sourcePorts.length + || requestedPorts.some((port, offset) => port !== sourcePorts[offset]); + ports = sourcePorts; + } else if (typeof merged?.portRange === 'string') { + const range = merged.portRange.match(/^(\d+)-(\d+)$/); + const start = range ? Number(range[1]) : NaN; + const end = range ? Number(range[2]) : NaN; + invalid ||= !range || start !== sourcePorts[0] || end !== sourcePorts[sourcePorts.length - 1]; + invalid ||= sourcePorts.some((port, offset) => offset > 0 && port !== sourcePorts[offset - 1] + 1); + if (range) portRange = `${start}-${end}`; + } else { + invalid = true; + } + if (nameIssue || mergedNames.has(name.toLowerCase())) invalid = true; + const definition = portRange ? `${proto}/${portRange}` : `${proto}/${sourcePorts.join(',')}`; + if (!invalid && !registerServiceDefinition(name, definition, index)) invalid = true; + if (invalid) { + issues.push({ level: 'error', code: nameIssue?.code || 'MERGED_SERVICE_DECISION_INVALID', msg: `Policy #${index + 1}: fusion de service invalide "${name}"` }); + continue; + } + sourceKeys.forEach(key => consumedKeys.add(key)); + mergedNames.add(name.toLowerCase()); + validatedMerged.push({ label: name, found: false, name: null, source: null, suggestedName: name, isNamed: false, proto, ports, portRange, sourcePorts, _isMerged: true }); + } + if (validatedMerged.length > 0) { + policy.analysis.services = (policy.analysis.services || []) + .filter(service => !consumedKeys.has(serviceTransportKey(service))) + .concat(validatedMerged); + } + } + if ((policy.analysis?.services || []).length === 0) { + issues.push({ level: 'error', code: 'SERVICE_DECISION_EMPTY', msg: `Policy #${index + 1}: aucun service validé` }); + } + } + return { ok: issues.length === 0, policies, issues }; +} + // ─── CLI config generator ───────────────────────────────────────────────────── // Sanitise une valeur pour insertion dans une commande CLI FortiGate (entre quotes) @@ -1500,13 +2364,15 @@ function generateConfig(selectedPolicies, opts = {}) { } else if (svc.portRange) { newServices.set(customName, { name: customName, portRange: svc.portRange, proto: svc.proto }); } else if (resolvedPort) { - newServices.set(`${resolvedPort}/${resolvedProto}`, { + newServices.set(customName, { name: customName, port: resolvedPort, proto: resolvedProto, }); } } } - if (serviceNames.length === 0) serviceNames.push('ALL'); + if (serviceNames.length === 0) { + throw new Error(`Policy "${p.policyName || p.name || p.id || '?'}" sans service validé`); + } // Check if services match an existing service group const svcGrpMatch = opts.serviceGroups ? findServiceGroup(serviceNames, opts.serviceGroups) : null; @@ -1537,6 +2403,9 @@ function generateConfig(selectedPolicies, opts = {}) { serviceDesc: p.serviceDesc, sessions: p.sessions, tags: p.tags || [], disabled: p._disabled || false, + action: p.action, + log: p.log, + securityProfiles: p.securityProfiles, }); } @@ -1691,8 +2560,8 @@ function preflightValidation(selectedPolicies, config) { const label = `Policy #${i + 1}`; // Missing interfaces - const srcIntf = p._srcintf || a.srcIface; - const dstIntf = p._dstintf || a.dstIface; + const srcIntf = p.srcintf || p._srcintf || a.srcZone || a.srcIface; + const dstIntf = p.dstintf || p._dstintf || a.dstZone || a.dstIface; if (!srcIntf) issues.push({ level: 'error', msg: `${label}: interface source manquante` }); if (!dstIntf) issues.push({ level: 'error', msg: `${label}: interface destination manquante` }); if (srcIntf && dstIntf && srcIntf === dstIntf) { @@ -1763,6 +2632,8 @@ function formatExistingPolicies(policies) { module.exports = { parseFortiConfig, + extractKnownSubnets, + preserveDestinationServiceAffinity, analyzePolicies, generateConfig, validateAgainstExisting, @@ -1773,6 +2644,9 @@ module.exports = { findAddressGroup, findService, findServiceGroup, + applyPolicyUserDecisions, + validateGenerationOptions, + validatePolicyDecisionShapes, PREDEFINED, parseFullRoutingTable, parseOspfRoutingTable, diff --git a/app/web/public/app.js b/app/web/public/app.js index 4b4638c..d1c3a46 100644 --- a/app/web/public/app.js +++ b/app/web/public/app.js @@ -393,52 +393,36 @@ async function dashboard() { const s = state.stats; const m = state.meta; - const pct = s.totalSessions ? Math.round(s.acceptSessions / s.totalSessions * 100) : 0; + let policyCount = 0; + try { + const policyData = await api('/api/policies?include_no_rcvd=1'); + policyCount = (policyData.policies || policyData || []).length; + } catch { /* le dashboard reste disponible si les policies ne sont pas prêtes */ } el(_renderTarget || 'content').innerHTML = `
${fmtNum(s.totalSessions)}
-
Sessions totales
-
-
-
${fmtNum(s.uniqueFlows)}
-
Flux uniques
-
-
-
${fmtNum(s.uniqueSrcIPs)}
-
IPs source
-
-
-
${fmtNum(s.uniqueDstIPs)}
-
IPs destination
-
-
-
${fmtNum(s.srcSubnets)}
-
Subnets /24 source
-
-
-
${fmtNum(s.privateSrcIPs)}
-
Hôtes RFC1918
+
Sessions analysées
-
${pct}%
-
Taux d'acceptation
+
${fmtNum(s.uniqueFlows)}
+
Flux analysés
-
${fmtNum(s.denySessions)}
-
Sessions refusées
+
${fmtNum(s.srcSubnets)}
+
Réseaux détectés
-
${fmtBytes(s.totalBytes)}
-
Volume total
+
${fmtNum(policyCount)}
+
Policies proposées
Fichier analysé
-
${m?.filename || ''} — ${fmtNum(m?.lineCount)} lignes lues · ${fmtNum(m?.uniqueFlows || 0)} flux uniques · ${fmtNum(m?.skipped || 0)} ignorées${m?.skipReasons ? ` (${fmtNum(m.skipReasons.nonTraffic || 0)} non-traffic, ${fmtNum(m.skipReasons.invalidFlow || 0)} invalides)` : ''}
+
${m?.filename || ''} — ${fmtNum(s.totalSessions)} sessions · ${fmtBytes(s.totalBytes)} · ${fmtNum(m?.lineCount)} lignes · ${fmtNum(m?.skipped || 0)} ignorées${m?.skipReasons ? ` (${fmtNum(m.skipReasons.nonTraffic || 0)} non-traffic, ${fmtNum(m.skipReasons.invalidFlow || 0)} invalides)` : ''}
@@ -455,7 +439,7 @@ async function dashboard() {
${fmtNum(s.denySessions)}
DENY/DROP
-
+
Destinations
${fmtNum(s.privateDstIPs)}
LAN (RFC1918)
@@ -700,8 +684,8 @@ async function matrix() { el(_renderTarget || 'content').innerHTML = `
-
Heatmap LAN → LAN
-
Intensité = nombre de sessions entre subnets /24 privés
+
Matrice réseau LAN → LAN
+
Communications observées entre réseaux privés · intensité selon le nombre de sessions
@@ -778,16 +762,17 @@ function renderMatrix(data, mode = 'accept', signal) { return; } - const CELL = 32; - const FONT = '11px monospace'; + const availableMatrixWidth = Math.max(480, (el('matrix-wrap')?.clientWidth || 900) - 180); + const CELL = Math.max(38, Math.min(92, Math.floor(availableMatrixWidth / Math.max(srcSubnets.length, dstSubnets.length)))); + const FONT = `${CELL >= 72 ? 12 : 11}px monospace`; const PAD = 8; // Measure the longest label to set left margin dynamically const tmpCanvas = document.createElement('canvas'); const tmpCtx = tmpCanvas.getContext('2d'); tmpCtx.font = FONT; - const longestSrc = Math.max(...srcSubnets.map(s => tmpCtx.measureText(s.replace('.0/24', '.x')).width)); - const longestDst = Math.max(...dstSubnets.map(s => tmpCtx.measureText(s.replace('.0/24', '.x')).width)); + const longestSrc = Math.max(...srcSubnets.map(s => tmpCtx.measureText(s).width)); + const longestDst = Math.max(...dstSubnets.map(s => tmpCtx.measureText(s).width)); // Left margin = longest src label + padding const LABEL_LEFT = Math.ceil(longestSrc) + 16; @@ -801,9 +786,27 @@ function renderMatrix(data, mode = 'accept', signal) { canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); + const rootStyle = getComputedStyle(document.documentElement); + const cssColor = (name, fallback) => rootStyle.getPropertyValue(name).trim() || fallback; + const parseColor = (value) => { + const hex = value.match(/^#([0-9a-f]{6})$/i); + if (hex) return [parseInt(hex[1].slice(0,2),16), parseInt(hex[1].slice(2,4),16), parseInt(hex[1].slice(4,6),16)]; + const rgb = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : [80, 80, 110]; + }; + const mix = (from, to, amount) => { + const a = parseColor(from), b = parseColor(to); + return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * amount)).join(',')})`; + }; + const matrixBg = cssColor('--bg1', '#101018'); + const matrixCell = cssColor('--bg2', '#161622'); + const matrixDiagonal = cssColor('--bg3', '#202034'); + const matrixAccept = cssColor('--accent2', '#6b9ee8'); + const matrixDeny = cssColor('--danger', '#c95252'); + const matrixText = cssColor('--text2', '#9090b0'); // Background - ctx.fillStyle = '#0e0e1a'; + ctx.fillStyle = matrixBg; ctx.fillRect(0, 0, W, H); // Legend canvas — vert pour accept, rouge pour deny @@ -811,13 +814,13 @@ function renderMatrix(data, mode = 'accept', signal) { if (lc) { const lctx = lc.getContext('2d'); const grad = lctx.createLinearGradient(0, 0, 120, 0); - grad.addColorStop(0, '#0e0e1a'); + grad.addColorStop(0, matrixBg); if (mode === 'deny') { - grad.addColorStop(0.5, '#550000'); - grad.addColorStop(1, '#ff1744'); + grad.addColorStop(0.5, mix(matrixBg, matrixDeny, 0.55)); + grad.addColorStop(1, matrixDeny); } else { - grad.addColorStop(0.5, '#005533'); - grad.addColorStop(1, '#00e676'); + grad.addColorStop(0.5, mix(matrixBg, matrixAccept, 0.55)); + grad.addColorStop(1, matrixAccept); } lctx.fillStyle = grad; lctx.fillRect(0, 0, 120, 12); @@ -829,7 +832,7 @@ function renderMatrix(data, mode = 'accept', signal) { // Draw column labels (dst subnets) — rotated -45°, anchored at bottom-left of each column ctx.font = FONT; - ctx.fillStyle = '#9090b0'; + ctx.fillStyle = matrixText; ctx.textAlign = 'left'; for (let di = 0; di < dstSubnets.length; di++) { const x = LABEL_LEFT + di * CELL + CELL / 2; @@ -837,18 +840,18 @@ function renderMatrix(data, mode = 'accept', signal) { ctx.save(); ctx.translate(x, y); ctx.rotate(-Math.PI / 4); - ctx.fillText(dstSubnets[di].replace('.0/24', '.x'), 0, 0); + ctx.fillText(dstSubnets[di], 0, 0); ctx.restore(); } // Draw row labels (src subnets) — right-aligned, vertically centred on each row ctx.font = FONT; - ctx.fillStyle = '#9090b0'; + ctx.fillStyle = matrixText; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (let si = 0; si < srcSubnets.length; si++) { const y = LABEL_TOP + si * CELL + CELL / 2; - ctx.fillText(srcSubnets[si].replace('.0/24', '.x'), LABEL_LEFT - 8, y); + ctx.fillText(srcSubnets[si], LABEL_LEFT - 8, y); } ctx.textBaseline = 'alphabetic'; @@ -859,27 +862,21 @@ function renderMatrix(data, mode = 'accept', signal) { const y = LABEL_TOP + si * CELL; // Grid cell background - ctx.fillStyle = si === di ? '#12122a' : '#0b0b18'; - ctx.fillRect(x + 1, y + 1, CELL - 2, CELL - 2); + ctx.fillStyle = si === di ? matrixDiagonal : matrixCell; + ctx.beginPath(); + ctx.roundRect(x + 2, y + 2, CELL - 4, CELL - 4, 4); + ctx.fill(); const c = cellMap.get(`${si},${di}`); if (c) { const t = maxCount > 0 ? Math.log1p(c.count) / Math.log1p(maxCount) : 0; - // Couleur : vert (accept) ou rouge (deny) sur échelle log - if (mode === 'deny') { - const r = Math.round(80 + t * 175); - ctx.fillStyle = `rgb(${r},${Math.round(t * 23)},${Math.round(t * 20)})`; - } else { - const g = Math.round(60 + t * 170); - const b = Math.round(60 + t * 58); - ctx.fillStyle = `rgb(0,${g},${b})`; - } - ctx.fillRect(x + 1, y + 1, CELL - 2, CELL - 2); + ctx.fillStyle = mix(matrixCell, mode === 'deny' ? matrixDeny : matrixAccept, 0.25 + t * 0.75); + ctx.beginPath(); + ctx.roundRect(x + 2, y + 2, CELL - 4, CELL - 4, 4); + ctx.fill(); // Session count inside cell - const textColor = mode === 'deny' - ? (t > 0.55 ? '#000' : '#ff5252') - : (t > 0.55 ? '#000' : '#00e676'); + const textColor = t > 0.55 ? cssColor('--bg0', '#09090e') : cssColor('--text', '#f0eef8'); ctx.fillStyle = textColor; ctx.font = '9px monospace'; ctx.textAlign = 'center'; @@ -1247,6 +1244,8 @@ function renderPoliciesTable(policies, excluded) { const pid = 'pd-' + i; const srcB64 = btoa(p.srcSubnet); const dstB64 = btoa(p.dstTarget); + const serviceItems = String(p.serviceDesc || '').split(',').map(item => item.trim()).filter(Boolean); + const serviceCell = `${serviceItems.slice(0, 8).map(escHtml).join(', ')}${serviceItems.length > 8 ? ` +${serviceItems.length - 8} autres` : ''}`; const drillBtn = p.srcSubnet && p.dstTarget ? '' : ''; @@ -1254,7 +1253,7 @@ function renderPoliciesTable(policies, excluded) { + '' + (i + 1) + '' + '' + typeTag('private') + ' ' + escHtml(p.srcSubnet) + '' + '' + typeTag(p.dstType) + ' ' + escHtml(p.dstTarget) + '' - + '' + escHtml(p.serviceDesc) + '' + + '' + serviceCell + '' + '' + (p.sessions > 0 ? fmtNum(p.sessions) : '\u2013') + '' + '' + fmtBytes(p.sentBytes + p.rcvdBytes) + '' + '' + actionTag(p.action) + '' @@ -1267,7 +1266,7 @@ function renderPoliciesTable(policies, excluded) { }).join(''); const isRaw = state.policies.viewMode === 'raw'; - const srcHeader = isRaw ? 'Source /32' : 'Source (subnet /24)'; + const srcHeader = isRaw ? 'Source /32' : 'Source (réseau)'; const dstHeader = isRaw ? 'Destination /32' : 'Destination'; const countLabel = fmtNum(policies.length) + ' règle' + (policies.length > 1 ? 's' : '') + ' — ordonnées par volume de sessions'; @@ -2339,7 +2338,7 @@ function collectMissingObjects() { } // Services manquants for (const svc of a.services || []) { - if (!svc.found) { + if (!svc.found && !svc._isMerged && !isCompatibleServiceSelected(p, svc)) { const key = svc.isNamed ? `label:${svc.label}` : `${svc.port}/${svc.proto}`; const defaultName = svc.isNamed ? (svc.suggestedName || svc.label) : (svc.suggestedName || `FF_SVC_${svc.port}_${svc.proto}`); if (!services.has(key)) services.set(key, { key, port: svc.port, proto: svc.proto, label: svc.label, name: defaultName, policyCount: 0 }); @@ -2513,6 +2512,7 @@ function showObjectsModal() { suggestedName: svcName, isNamed: false, proto: proto.toLowerCase(), ports: portRange ? null : ports, portRange: portRange || null, + sourcePorts: ports, port: portRange ? null : ports[0], portHint: portRange ? `${proto}: ${portRange}` : `${proto}: ${ports.join(', ')}`, _isMerged: true, @@ -2697,7 +2697,7 @@ function _snapDrawer(p) { const snap = {}; const keys = ['_srcAddrName','_dstAddrName','_policyName','_srcMode','_dstMode', '_use32Src','_use32Dst','_srcHostNames','_dstHostNames','_useSrcGroup','_useDstGroup', - '_srcintf','_dstintf','_nat','_action','_log','_mergeMode','_mergedSvcName','_mergeRange']; + '_srcintf','_dstintf','_nat','_action','_log','_mergeMode','_mergedSvcName','_mergeRange','_serviceReuse','_resolvedServiceKeys','_resolvedObjectKeys','_dismissedCompatibleSelection']; for (const k of keys) { if (!(k in p)) continue; const v = p[k]; @@ -2824,6 +2824,71 @@ function mountDrawer() { const hint = document.getElementById('drawer-undo-hint'); if (hint) hint.style.display = ''; }; + const useSelectedCompatible = e.target.closest('.svc-use-compatible-selected'); + if (useSelectedCompatible) { + _snapAndShow(); + if (!p._serviceReuse) p._serviceReuse = {}; + const proto = useSelectedCompatible.dataset.proto; + const name = useSelectedCompatible.dataset.serviceName; + useSelectedCompatible.dataset.ports.split(',').filter(Boolean) + .forEach(port => { + const serviceKey = `${proto}/${port}`; + p._serviceReuse[serviceKey] = name; + markServiceDecisionResolved(p, serviceKey, `existing:${name}`); + }); + delete p._dismissedCompatibleSelection; + populateDrawer(_drawerIdx); + syncRowStatus(_drawerIdx); + renderDeployPolicies(filterDeployPolicies(), false); + return; + } + const createNewSelected = e.target.closest('.svc-create-new-selected'); + if (createNewSelected) { + _snapAndShow(); + p._dismissedCompatibleSelection = createNewSelected.dataset.selectionSignature; + populateDrawer(_drawerIdx); + return; + } + const useCompatible = e.target.closest('.drawer-use-compatible-service'); + if (useCompatible) { + _snapAndShow(); + if (!p._serviceReuse) p._serviceReuse = {}; + p._serviceReuse[useCompatible.dataset.serviceKey] = useCompatible.dataset.serviceName; + markServiceDecisionResolved( + p, useCompatible.dataset.serviceKey, `existing:${useCompatible.dataset.serviceName}`, + ); + populateDrawer(_drawerIdx); + syncRowStatus(_drawerIdx); + renderDeployPolicies(filterDeployPolicies(), false); + return; + } + const createSpecific = e.target.closest('.drawer-create-specific-service'); + if (createSpecific) { + _snapAndShow(); + const serviceKey = createSpecific.dataset.serviceKey; + const [proto, port] = serviceKey.split('/'); + const service = (p.analysis?.services || []).find(item => serviceReuseKey(item) === serviceKey); + const typedName = createSpecific.closest('.drawer-service-item') + ?.querySelector('.drawer-svc-name')?.value.trim(); + if (service) service.suggestedName = typedName || `FF_SVC_${proto}_${port}`; + markServiceDecisionResolved(p, serviceKey, 'specific'); + populateDrawer(_drawerIdx); + syncRowStatus(_drawerIdx); + renderDeployPolicies(filterDeployPolicies(), false); + return; + } + if (e.target.closest('.drawer-services-toggle')) { + p._servicesExpanded = !p._servicesExpanded; + populateDrawer(_drawerIdx); + return; + } + const hostsToggle = e.target.closest('.drawer-hosts-toggle'); + if (hostsToggle) { + const key = hostsToggle.dataset.hostsType === 'src' ? '_srcHostsExpanded' : '_dstHostsExpanded'; + p[key] = !p[key]; + populateDrawer(_drawerIdx); + return; + } // Action toggle (accept / deny) if (e.target.matches('.drawer-action-btn')) { p._action = e.target.dataset.action; @@ -2836,7 +2901,11 @@ function mountDrawer() { _snapAndShow(); const _svcList = p.analysis?.services || []; const _getSvcPP = s => { const m = s.label?.match(/^(TCP|UDP)\/(\d+)$/i); return m ? { port: parseInt(m[2],10), proto: m[1].toUpperCase() } : { port: s.port, proto: (s.proto||'').toUpperCase() }; }; - const _selectable = _svcList.filter(s => { if (s.found) return false; const m = s.label?.match(/^(TCP|UDP)\/(\d+)$/i); return m || (!s.isNamed && s.port); }); + const _selectable = _svcList.filter(s => { + if (s.found || isServiceDecisionResolved(p, s)) return false; + const m = s.label?.match(/^(TCP|UDP)\/\d+$/i); + return m || (!s.isNamed && s.port); + }); if (!p._selectedSvcKeys) p._selectedSvcKeys = new Set(); const _allKeys = _selectable.map(s => { const {port, proto} = _getSvcPP(s); return `${port}/${proto}`; }); const _allSel = _allKeys.every(k => p._selectedSvcKeys.has(k)); @@ -2891,6 +2960,7 @@ function mountDrawer() { proto, ports: portRange ? null : ports, portRange: portRange || null, + sourcePorts: ports, port: portRange ? null : ports[0], portHint: portRange ? `${proto.toUpperCase()}: ${portRange}` : `${proto.toUpperCase()}: ${ports.join(', ')}`, _isMerged: true, @@ -3091,6 +3161,8 @@ function mountDrawer() { return k === svcKey; }); if (!svc) return; + svc.suggestedName = newName; + markServiceDecisionResolved(p, serviceReuseKey(svc), 'specific'); const _sm = svc.label?.match(/^(TCP|UDP)\/(\d+)$/i); const targetPort = _sm ? parseInt(_sm[2], 10) : svc.port; const targetProto = _sm ? _sm[1].toUpperCase() : (svc.proto || '').toUpperCase(); @@ -3113,14 +3185,21 @@ function mountDrawer() { } if (count > 0) { p._propagatePending = { svcKey, newName, port: targetPort, proto: targetProto, label: targetLabel, portHint: svc.portHint || null, count }; - populateDrawer(_drawerIdx); } + populateDrawer(_drawerIdx); + syncRowStatus(_drawerIdx); + renderDeployPolicies(filterDeployPolicies(), false); }); // Propagation check on addr name blur drawer.addEventListener('focusout', e => { const p = _drawerIdx !== null ? deployState.analyzed[_drawerIdx] : null; if (!p) return; + const resolvedObjectKey = e.target.dataset.objectKey; + const resolvedObjectName = typeof e.target.value === 'string' ? e.target.value.trim() : ''; + if (resolvedObjectKey && resolvedObjectName) { + markDrawerObjectResolved(p, resolvedObjectKey, resolvedObjectName); + } // Mode /24 subnet let addrType = null; @@ -3141,8 +3220,10 @@ function mountDrawer() { } if (count > 0) { p._propagateAddrPending = { addrType, cidr, newName, count }; - populateDrawer(_drawerIdx); } + populateDrawer(_drawerIdx); + syncRowStatus(_drawerIdx); + renderDeployPolicies(filterDeployPolicies(), false); return; } @@ -3166,8 +3247,16 @@ function mountDrawer() { } if (count > 0) { p._propagateAddrPending = { addrType: hostAddrType, hostIp, newName, count, isHost: true }; - populateDrawer(_drawerIdx); } + populateDrawer(_drawerIdx); + syncRowStatus(_drawerIdx); + renderDeployPolicies(filterDeployPolicies(), false); + return; + } + if (resolvedObjectKey && resolvedObjectName) { + populateDrawer(_drawerIdx); + syncRowStatus(_drawerIdx); + renderDeployPolicies(filterDeployPolicies(), false); } }); } @@ -3180,13 +3269,15 @@ function buildDrawerSecProfiles(p, idx) { (list || []).map(n => ``).join(''); const row = (label, key, list) => !list?.length ? '' : `
${label}
`; - return `
-
Profils de sécurité
- ${row('Antivirus', 'antivirus', sp.antivirus)} - ${row('Web Filter', 'webfilter', sp.webfilter)} - ${row('IPS', 'ips', sp.ips)} - ${row('SSL/SSH', 'sslSsh', sp.sslSsh)} -
`; + return `
+ Security Profiles +
+ ${row('Antivirus', 'antivirus', sp.antivirus)} + ${row('Web Filter', 'webfilter', sp.webfilter)} + ${row('IPS', 'ips', sp.ips)} + ${row('SSL Inspection', 'sslSsh', sp.sslSsh)} +
+
`; } function openDrawer(idx) { @@ -3221,6 +3312,109 @@ function cleanHostName(h, name) { return name.startsWith(prefix) ? name.slice(prefix.length) : name; } +function markDrawerObjectResolved(policy, objectKey, name) { + if (!objectKey || !name) return; + if (!policy._resolvedObjectKeys) policy._resolvedObjectKeys = {}; + policy._resolvedObjectKeys[objectKey] = name; +} + +function isDrawerObjectResolved(policy, objectKey) { + return !!objectKey && !!policy?._resolvedObjectKeys?.[objectKey]; +} + +function drawerResolvedObjectHtml(name, title) { + return `✓ ${escHtml(name)}${badgeHtml('config')}`; +} + +function drawerNamedObjectControl(policy, objectKey, name, found, inputHtml, title) { + if (found || isDrawerObjectResolved(policy, objectKey)) { + return drawerResolvedObjectHtml(name, title); + } + return inputHtml; +} + +function drawerHostControl(policy, host, type) { + const isSrc = type === 'src'; + const names = isSrc ? policy._srcHostNames : policy._dstHostNames; + const found = new Set(isSrc ? (policy._srcHostsFound || []) : (policy._dstHostsFound || [])).has(host); + const autoName = `FF_HOST_${host.replace(/\./g, '_')}`; + const storedName = cleanHostName(host, names?.[host]); + const objectKey = `host:${type}:${host}`; + const displayName = storedName || autoName; + if (found || isDrawerObjectResolved(policy, objectKey)) { + return drawerResolvedObjectHtml(displayName, `${host}/32`); + } + return ``; +} + +function serviceReuseKey(svc) { + const notation = svc?.label?.match(/^(TCP|UDP)\/(\d+)$/i); + const proto = notation ? notation[1] : svc?.proto; + const port = notation ? parseInt(notation[2], 10) : svc?.port; + if (!proto || !port) return ''; + return `${String(proto).toUpperCase()}/${port}`; +} + +function isCompatibleServiceSelected(policy, svc) { + const key = serviceReuseKey(svc); + const matches = svc?.compatibleMatches || (svc?.compatibleMatch ? [svc.compatibleMatch] : []); + return !!key && matches.some(match => policy?._serviceReuse?.[key] === match.name); +} + +function selectedCompatibleService(services) { + if (!services || services.length < 2) return null; + const keys = services.map(serviceReuseKey); + const protos = new Set(keys.map(key => key.split('/')[0]).filter(Boolean)); + if (protos.size !== 1 || keys.some(key => !key)) return null; + const candidateLists = services.map(service => + service.compatibleMatches || (service.compatibleMatch ? [service.compatibleMatch] : []) + ); + const common = candidateLists[0].filter(candidate => + candidateLists.slice(1).every(list => list.some(match => match.name === candidate.name)) + ); + common.sort((a, b) => a.coverageCount - b.coverageCount || a.name.localeCompare(b.name)); + if (!common.length) return null; + return { + ...common[0], + ports: keys.map(key => parseInt(key.split('/')[1], 10)).sort((a, b) => a - b), + extraPortCount: Math.max(0, common[0].coverageCount - services.length), + }; +} + +function isServiceDecisionResolved(policy, svc) { + const key = serviceReuseKey(svc); + const decision = key ? policy?._resolvedServiceKeys?.[key] : null; + if (decision === 'specific') return true; + if (decision?.startsWith('existing:')) { + return policy?._serviceReuse?.[key] === decision.slice('existing:'.length); + } + return false; +} + +function clearSelectedServiceKey(policy, serviceKey) { + const [proto, port] = String(serviceKey || '').split('/'); + if (proto && port) policy?._selectedSvcKeys?.delete(`${port}/${proto}`); +} + +function markServiceDecisionResolved(policy, serviceKey, decision) { + if (!serviceKey) return; + if (!policy._resolvedServiceKeys) policy._resolvedServiceKeys = {}; + if (decision === 'specific' && policy._serviceReuse) { + delete policy._serviceReuse[serviceKey]; + } else if (decision.startsWith('existing:')) { + if (!policy._serviceReuse) policy._serviceReuse = {}; + policy._serviceReuse[serviceKey] = decision.slice('existing:'.length); + } + policy._resolvedServiceKeys[serviceKey] = decision; + clearSelectedServiceKey(policy, serviceKey); +} + +function mergedServicePortLabel(svc) { + const proto = String(svc?.proto || '').toUpperCase(); + const portSpec = svc?.portRange || (svc?.ports || []).join(','); + return portSpec ? `${proto}/${portSpec}` : (svc?.portHint || svc?.label || proto); +} + function syncHostCell(idx, type) { const p = deployState.analyzed[idx]; if (!p) return; @@ -3249,14 +3443,27 @@ function syncHostCell(idx, type) { syncRowStatus(idx); } -function _buildSvcCellHtml(p) { +function _buildSvcCellHtml(p, limit = Infinity) { const svcList = p.analysis?.services || []; const stripPredef = n => (n || '').replace(/PREDEFINED$/i, ''); - const html = svcList.map(svc => { - if (svc.found) { - const dispName = stripPredef(svc.name); + const html = svcList.slice(0, limit).map(svc => { + if (svc._isMerged) { + const mergedName = svc.suggestedName || svc.label; + const mergedPortLabel = mergedServicePortLabel(svc); + return `✓ ${escHtml(mergedName)}${badgeHtml('config')}`; + } + const compatibleSelected = isCompatibleServiceSelected(p, svc); + const reuseKey = serviceReuseKey(svc); + const selectedReuseName = p._serviceReuse?.[reuseKey]; + const resolvedDecision = p._resolvedServiceKeys?.[reuseKey]; + if (svc.found || compatibleSelected) { + const dispName = stripPredef(compatibleSelected ? selectedReuseName : svc.name); return `✓ ${escHtml(dispName)}${badgeHtml('config')}`; } + if (resolvedDecision === 'specific') { + const dispName = svc.suggestedName || svc.label || `FF_SVC_${svc.port}_${svc.proto}`; + return `✓ ${escHtml(dispName)}${badgeHtml('config')}`; + } const isPortNotation = /^(TCP|UDP)\/\d+$/i.test(svc.suggestedName || ''); const autoLabel = svc.isNamed ? svc.label : `FF_SVC_${svc.port}_${svc.proto}`; const customName = svc.suggestedName && !isPortNotation && svc.suggestedName !== autoLabel ? svc.suggestedName : ''; @@ -3266,14 +3473,15 @@ function _buildSvcCellHtml(p) { } return `${displayName ? escHtml(displayName) + ' ' : ''}${badgeHtml('auto')}`; }).join(' '); - return html || ''; + const more = svcList.length > limit ? `+${svcList.length - limit} autres` : ''; + return (html + (html && more ? ' ' : '') + more) || ''; } function syncSvcCell(idx) { const p = deployState.analyzed?.[idx]; if (!p) return; const cell = document.querySelector(`.svc-cell[data-svc-idx="${idx}"]`); - if (cell) cell.innerHTML = _buildSvcCellHtml(p); + if (cell) cell.innerHTML = _buildSvcCellHtml(p, 3); syncRowStatus(idx); } @@ -3323,89 +3531,81 @@ function populateDrawer(idx) { const srcSubs = p._multiSrcSubnets; const srcSubRows = srcSubs.map((s, si) => { const isSubnet = s.useSubnet !== false; - const statusIcon = s.addrFound ? `` : `+`; - const nameInput = ``; + const objectKey = `multi-src:${si}`; + const statusIcon = (s.addrFound || isDrawerObjectResolved(p, objectKey)) ? `` : `+`; + const nameInput = ``; let hostsHtml = ''; if (!isSubnet && s.hosts?.length > 0) { const visibleSrcHosts = s.hosts.filter(h => !p._excludedSrcHosts?.has(h)); - hostsHtml = `
${visibleSrcHosts.slice(0, 50).map(h => { + const shownSrcHosts = p._srcHostsExpanded ? visibleSrcHosts : visibleSrcHosts.slice(0, 8); + hostsHtml = `
${shownSrcHosts.map(h => { const foundSet = new Set(p._srcHostsFound || []); const hostName = cleanHostName(h, (p._srcHostNames || {})[h]) || `FF_HOST_${h.replace(/\./g,'_')}`; const hostFound = foundSet.has(h); return `
${escHtml(h)} - ${hostFound - ? `✓ ${escHtml(hostName)}${badgeHtml('config')}` - : ``} + ${drawerHostControl(p, h, 'src')}
`; - }).join('')}${visibleSrcHosts.length > 50 ? `
+${visibleSrcHosts.length - 50} autres…
` : ''}
`; + }).join('')}${visibleSrcHosts.length > 8 ? `` : ''}
`; } return `
${escHtml(s.subnet)} ${isSubnet ? statusIcon : ''} - ${isSubnet ? (s.addrFound ? `${escHtml(s.addrName)}${badgeHtml('config')}` : nameInput) : ''} + ${isSubnet ? drawerNamedObjectControl(p, objectKey, s.addrName, s.addrFound, nameInput, s.subnet) : ''}
${hostsHtml}`; }).join(''); - srcSection = `
+ srcSection = `
Sources (${srcSubs.length} subnets)
${srcSubRows}
- ${p._useSrcGroup ? (p._srcAddrGrpFound - ? `✓ ${escHtml(p._srcAddrName)}` - : ``) - : ''} + ${p._useSrcGroup ? drawerNamedObjectControl(p, 'group:src', p._srcAddrName, p._srcAddrGrpFound, ``, srcSubs.map(s => s.subnet).join(', ')) : ''}
${_addrBanner('src')} -
Interface
+
Interface
`; } else { // ── Single source subnet ── let srcHostsHtml = ''; if (srcHosts.length > 0 && srcMode === 'hosts') { const visibleSrcHostsSingle = srcHosts.filter(h => !p._excludedSrcHosts?.has(h)); - srcHostsHtml = `
${visibleSrcHostsSingle.slice(0, 80).map(h => { + const shownSrcHostsSingle = p._srcHostsExpanded ? visibleSrcHostsSingle : visibleSrcHostsSingle.slice(0, 8); + srcHostsHtml = `
${shownSrcHostsSingle.map(h => { const foundSet = new Set(p._srcHostsFound || []); const hostFound = foundSet.has(h); const name = cleanHostName(h, (p._srcHostNames || {})[h]) || `FF_HOST_${h.replace(/\./g,'_')}`; return `
${escHtml(h)} - ${hostFound - ? `✓ ${escHtml(name)}${badgeHtml('config')}` - : ``} + ${drawerHostControl(p, h, 'src')}
`; - }).join('')}
`; + }).join('')}${visibleSrcHostsSingle.length > 8 ? `` : ''}
`; if (srcHosts.length > 1) { const srcGrpFound = p._srcAddrGrpFound; srcHostsHtml += `
- ${p._useSrcGroup ? (srcGrpFound - ? `✓ ${escHtml(p._srcAddrName)}` - : ``) - : ''} + ${p._useSrcGroup ? drawerNamedObjectControl(p, 'group:src', p._srcAddrName, srcGrpFound, ``, srcHosts.map(h => h + '/32').join(', ')) : ''}
`; } } - srcSection = `
+ srcSection = `
Source
Subnet${escHtml(p.srcSubnet || '')}
Mode : - +
- ${srcMode === 'subnet' ? `
+ ${srcMode === 'subnet' ? `
Objet addr - ${srcFound ? `✓ ${escHtml(srcAddrName)}${badgeHtml('config')}` - : `${badgeHtml('auto')}`} + ${drawerNamedObjectControl(p, 'addr:src', srcAddrName, srcFound, `${badgeHtml('auto')}`, a.srcAddr?.cidr || p.srcSubnet || '')}
` : ''} ${srcHostsHtml} ${_addrBanner('src')} -
Interface
+
Interface
`; } @@ -3415,53 +3615,51 @@ function populateDrawer(idx) { const subs = p._multiDstSubnets; const subRows = subs.map((s, si) => { const isSubnet = s.useSubnet !== false; - const statusIcon = s.addrFound ? `` : `+`; - const nameInput = ``; + const objectKey = `multi-dst:${si}`; + const statusIcon = (s.addrFound || isDrawerObjectResolved(p, objectKey)) ? `` : `+`; + const nameInput = ``; let hostsHtml = ''; if (!isSubnet && s.hosts?.length > 0) { const visibleDstHosts = s.hosts.filter(h => !p._excludedDstHosts?.has(h)); - hostsHtml = `
${visibleDstHosts.slice(0, 50).map(h => { + const shownDstHosts = p._dstHostsExpanded ? visibleDstHosts : visibleDstHosts.slice(0, 8); + hostsHtml = `
${shownDstHosts.map(h => { const foundSet = new Set(p._dstHostsFound || []); const hostName = cleanHostName(h, (p._dstHostNames || {})[h]) || `FF_HOST_${h.replace(/\./g,'_')}`; const hostFound = foundSet.has(h); return `
${escHtml(h)} - ${hostFound - ? `✓ ${escHtml(hostName)}${badgeHtml('config')}` - : ``} + ${drawerHostControl(p, h, 'dst')}
`; - }).join('')}${visibleDstHosts.length > 50 ? `
+${visibleDstHosts.length - 50} autres…
` : ''}
`; + }).join('')}${visibleDstHosts.length > 8 ? `` : ''}
`; } return `
${escHtml(s.subnet)} ${isSubnet ? statusIcon : ''} - ${isSubnet ? (s.addrFound ? `${escHtml(s.addrName)}${badgeHtml('config')}` : nameInput) : ''} + ${isSubnet ? drawerNamedObjectControl(p, objectKey, s.addrName, s.addrFound, nameInput, s.subnet) : ''}
${hostsHtml}`; }).join(''); const isMultiDstWan = p._isWan || p.dstTypeSummary === 'public' || subs.some(s => s.subnet === 'all' || (p.dstTypes || {})[s.subnet] === 'public'); const dstUseAllMulti = p._dstUseAll === true; - dstSection = `
+ dstSection = `
Destinations (${subs.length})
${isMultiDstWan ? `
Mode :
` : ''} - ${isMultiDstWan && dstUseAllMulti ? `
+ ${isMultiDstWan && dstUseAllMulti ? `
Objet addr ✓ all${badgeHtml('config')}
` : `${subRows}
- ${p._useDstGroup ? (p._dstAddrGrpFound - ? `✓ ${escHtml(p._dstAddrName)}` - : ``) - : ''} + ${p._useDstGroup ? drawerNamedObjectControl(p, 'group:dst', p._dstAddrName, p._dstAddrGrpFound, ``, subs.map(s => s.subnet).join(', ')) : ''}
`} ${_addrBanner('dst')} +
Interface
`; } else { const dstAddrName = p._dstAddrName || a.dstAddr?.name || ''; @@ -3472,17 +3670,16 @@ function populateDrawer(idx) { if (dstHosts.length > 0 && (dstMode === 'hosts' || (isWan && !dstUseAll))) { const dstFoundSet = new Set(p._dstHostsFound || []); const visibleDstHostsSingle = dstHosts.filter(h => !p._excludedDstHosts?.has(h)); - dstHostsHtml = `
${visibleDstHostsSingle.slice(0, 80).map(h => { + const shownDstHostsSingle = p._dstHostsExpanded ? visibleDstHostsSingle : visibleDstHostsSingle.slice(0, 8); + dstHostsHtml = `
${shownDstHostsSingle.map(h => { const name = cleanHostName(h, (p._dstHostNames || {})[h]) || `FF_HOST_${h.replace(/\./g,'_')}`; const hostFound = dstFoundSet.has(h); return `
${escHtml(h)} - ${hostFound - ? `✓ ${escHtml(name)}${badgeHtml('config')}` - : ``} + ${drawerHostControl(p, h, 'dst')}
`; - }).join('')}
`; + }).join('')}${visibleDstHostsSingle.length > 8 ? `` : ''}
`; } // WAN + IPs spécifiques + pas de dstHosts : montrer dstTarget comme objet à nommer let dstWanSpecificHtml = ''; @@ -3491,17 +3688,13 @@ function populateDrawer(idx) { const autoName = `FF_HOST_${ip.replace(/[\./]/g,'_')}`; const customName = p._dstAddrName || ''; const dstTargetFound = dstFound && dstAddrName !== 'all'; - dstWanSpecificHtml = dstTargetFound - ? `
- Objet addr - ✓ ${escHtml(dstAddrName)}${badgeHtml('config')} -
` - : `
- Objet addr - ${badgeHtml('auto')} -
`; + const dstInput = `${badgeHtml('auto')}`; + dstWanSpecificHtml = `
+ Objet addr + ${drawerNamedObjectControl(p, 'addr:dst', dstAddrName || customName, dstTargetFound, dstInput, ip)} +
`; } - dstSection = `
+ dstSection = `
Destination
Target @@ -3512,23 +3705,23 @@ function populateDrawer(idx) {
- ${dstUseAll ? `
+ ${dstUseAll ? `
Objet addr ✓ all${badgeHtml('config')}
` : dstWanSpecificHtml} ` : `${p.dstType === 'private' ? `
Mode : - +
` : ''} - ${dstMode === 'subnet' ? `
+ ${dstMode === 'subnet' ? `
Objet addr - ${dstFound ? `✓ ${escHtml(dstAddrName)}${badgeHtml('config')}` - : `${badgeHtml('auto')}`} + ${drawerNamedObjectControl(p, 'addr:dst', dstAddrName, dstFound, `${badgeHtml('auto')}`, a.dstAddr?.cidr || p.dstTarget || '')}
` : ''} `} ${dstHostsHtml} ${_addrBanner('dst')} +
Interface
`; } @@ -3539,15 +3732,27 @@ function populateDrawer(idx) { const selKeys = p._selectedSvcKeys; // Compute merge bar state const getSvcPortProto = s => { const m = s.label?.match(/^(TCP|UDP)\/(\d+)$/i); return m ? { port: parseInt(m[2],10), proto: m[1].toUpperCase() } : { port: s.port, proto: (s.proto||'').toUpperCase() }; }; - const selectableSvcs = svcList.filter(s => { if (s.found) return false; const m = s.label?.match(/^(TCP|UDP)\/(\d+)$/i); return m || (!s.isNamed && s.port); }); + const selectableSvcs = svcList.filter(s => { + if (s.found || isServiceDecisionResolved(p, s)) return false; + const m = s.label?.match(/^(TCP|UDP)\/\d+$/i); + return m || (!s.isNamed && s.port); + }); const selectedSvcs = selectableSvcs.filter(s => { const { port, proto } = getSvcPortProto(s); return selKeys.has(`${port}/${proto}`); }); const canMerge = selectedSvcs.length >= 2 && new Set(selectedSvcs.map(s => getSvcPortProto(s).proto)).size === 1; + const selectionSignature = selectedSvcs.map(serviceReuseKey).sort().join('|'); + const commonCompatibleService = canMerge ? selectedCompatibleService(selectedSvcs) : null; + const compatibleSelectionDismissed = !!selectionSignature + && p._dismissedCompatibleSelection === selectionSignature; + const showGlobalCompatibleDecision = !!commonCompatibleService && !compatibleSelectionDismissed; + const globalCompatibleSelected = !!commonCompatibleService + && commonCompatibleService.ports.every(port => p._serviceReuse?.[`${getSvcPortProto(selectedSvcs[0]).proto}/${port}`] === commonCompatibleService.name); + const selectedGlobalServiceKeys = new Set(selectedSvcs.map(serviceReuseKey)); const mergeProto = canMerge ? getSvcPortProto(selectedSvcs[0]).proto : ''; const mergePorts = canMerge ? selectedSvcs.map(s => getSvcPortProto(s).port).sort((a, b) => a - b) : []; const mergeRangeSuggestion = canMerge ? `${mergePorts[0]}-${mergePorts[mergePorts.length - 1]}` : ''; const mergeName = p._mergedSvcName || (canMerge ? `FF_SVC_${mergeProto}_MULTI` : ''); const mergeMode = p._mergeMode || 'list'; - const mergeBar = canMerge ? ` + const mergeBar = canMerge && !showGlobalCompatibleDecision ? `
${selectedSvcs.length} ports ${mergeProto} sélectionnés @@ -3556,8 +3761,49 @@ function populateDrawer(idx) { ${mergeMode === 'range' ? `` : `${mergePorts.join(', ')}`}
` : ''; + const compatibleSelectionHtml = showGlobalCompatibleDecision ? ` +
+ Un service FortiGate existant peut couvrir ces ports : + Service existant compatible : ${escHtml(commonCompatibleService.name)} ${escHtml(commonCompatibleService.portSpec)} + Ports sélectionnés : ${commonCompatibleService.ports.map(port => `${mergeProto}/${port}`).join(', ')} + ${fmtNum(commonCompatibleService.extraPortCount)} ports supplémentaires couverts +
+ + +
+
` : ''; const stripPd = n => (n || '').replace(/PREDEFINED$/i, ''); - const svcsHtml = svcList.map(svc => { + const resolvedExistingGroups = new Map(); + const resolvedExistingKeys = new Set(); + for (const service of svcList) { + const key = serviceReuseKey(service); + const decision = key ? p._resolvedServiceKeys?.[key] : null; + if (!decision?.startsWith('existing:')) continue; + const serviceName = decision.slice('existing:'.length); + if (p._serviceReuse?.[key] !== serviceName) continue; + if (!resolvedExistingGroups.has(serviceName)) resolvedExistingGroups.set(serviceName, []); + resolvedExistingGroups.get(serviceName).push(service); + resolvedExistingKeys.add(key); + } + const resolvedExistingHtml = [...resolvedExistingGroups].map(([serviceName, services]) => ` +
+ Ports couverts + ✓ ${escHtml(serviceName)}${badgeHtml('config')} +
`).join(''); + const servicesWithoutResolvedExisting = svcList.filter(service => !resolvedExistingKeys.has(serviceReuseKey(service))); + const visibleSvcList = showGlobalCompatibleDecision + ? servicesWithoutResolvedExisting.filter(service => !selectedGlobalServiceKeys.has(serviceReuseKey(service))) + : servicesWithoutResolvedExisting; + const displayServiceCount = visibleSvcList.length + resolvedExistingGroups.size + (showGlobalCompatibleDecision ? 1 : 0); + const svcsHtml = resolvedExistingHtml + visibleSvcList.map(svc => { + if (svc._isMerged) { + const mergedName = svc.suggestedName || svc.label; + const mergedPortLabel = svc.portRange + ? `${String(svc.proto || '').toUpperCase()}/${svc.portRange}` + : mergedServicePortLabel(svc); + const rawKey = svc.label || mergedName; + return `
${escHtml(mergedPortLabel)}✓ ${escHtml(mergedName)}${badgeHtml('config')}
`; + } if (svc.found) { const dispLabel = stripPd(svc.label || svc.name); const dispName = stripPd(svc.name); @@ -3569,10 +3815,20 @@ function populateDrawer(idx) { const svcProto = _pnm ? _pnm[1].toUpperCase() : (svc.proto || '').toUpperCase(); const svcPort = _pnm ? parseInt(_pnm[2], 10) : svc.port; const svcKey = _pnm ? `${svcPort}/${svcProto}` : (svc.isNamed ? `label:${svc.label}` : `${svc.port}/${svc.proto}`); - const isSelectable = !svc.found && (_pnm || (!svc.isNamed && svc.port)); + const reuseKey = serviceReuseKey(svc); + const compatibleMatch = svc.compatibleMatch; + const usingCompatible = isCompatibleServiceSelected(p, svc); + const serviceDecisionResolved = isServiceDecisionResolved(p, svc); + const serviceDecision = reuseKey ? p._resolvedServiceKeys?.[reuseKey] : null; + const isSelectable = !serviceDecisionResolved && !usingCompatible && !svc.found && (_pnm || (!svc.isNamed && svc.port)); const isSelected = selKeys.has(svcKey); - const svcAutoName = _pnm ? `FF_SVC_${svcPort}_${svcProto}` : (svc.isNamed ? svc.label : `FF_SVC_${svc.port}_${svc.proto}`); - const svcDefaultName = svc.suggestedName || svcAutoName; + const legacySvcAutoName = _pnm ? `FF_SVC_${svcPort}_${svcProto}` : null; + const svcAutoName = _pnm ? `FF_SVC_${svcProto}_${svcPort}` : (svc.isNamed ? svc.label : `FF_SVC_${svc.port}_${svc.proto}`); + const hasCustomSuggestedName = svc.suggestedName + && svc.suggestedName !== svcAutoName + && svc.suggestedName !== legacySvcAutoName; + const svcDefaultName = hasCustomSuggestedName ? svc.suggestedName : svcAutoName; + const svcInputValue = hasCustomSuggestedName ? svc.suggestedName : ''; // Show inline port hint only when it's precise (predefined/custom/port-notation resolved) // — never when it's the raw multi-port "observé" fallback (misleading for named services) const precisHint = svc.portHint && !svc.portHint.includes('observé'); @@ -3580,15 +3836,30 @@ function populateDrawer(idx) { const hintText = precisHint ? `${escHtml(svc.portHint)}` : ''; - return `
- ${isSelectable ? `` : ''} - ${escHtml(svc.label || `${svc.port}/${svc.proto}`)} - ${svc.isNamed && !_pnm ? hintText : ''} - ${badgeHtml('auto')} - + if (serviceDecision === 'specific') { + const finalName = svc.suggestedName || svcAutoName; + return `
${escHtml(svc.label || `${svcProto}/${svcPort}`)}✓ ${escHtml(finalName)}${badgeHtml('config')}
`; + } + const compatibilityHtml = compatibleMatch && !serviceDecisionResolved && !commonCompatibleService ? `
+
Service observé${escHtml(`${svcProto}/${svcPort}`)}
+
Service compatible${escHtml(compatibleMatch.name)}${escHtml(compatibleMatch.portSpec)}
+
Extension possible${fmtNum(compatibleMatch.extraPortCount)} ports supplémentaires
+
+ + +
+
` : ''; + return `
+
+ ${isSelectable ? `` : ''} + ${escHtml(svc.label || `${svc.port}/${svc.proto}`)} + ${svc.isNamed && !_pnm ? hintText : ''} + ${badgeHtml('auto')} + +
+ ${compatibilityHtml}
`; }).join(''); - // Propagation banner (shown after blur on svc name when other policies have same port/proto) const pp = p._propagatePending; const propagateBanner = pp ? `
@@ -3599,37 +3870,24 @@ function populateDrawer(idx) { const body = document.getElementById('drawer-body'); body.innerHTML = ` -
-
General
-
Direction${p._isWan ? 'WAN' : 'LAN'}
-
Policy IDs${(p.policyIds||[]).join(', ') || '—'}
-
Sessions${fmtNum(p.sessions||0)}
-
Action -
- - -
-
-
Log - -
-
NAT
-
Nom policy
-
- +
+
+
Général
+
+
Direction${p._isWan ? 'WAN' : 'LAN'}
+
Sessions${fmtNum(p.sessions || 0)}
+
Policy ID${(p.policyIds || [])[0] || '—'}
+
Action
+
Log
+
NAT
+
Nom policy
- ${srcSection} - ${dstSection} -
-
Interfaces destination
-
Interface
+
+ ${srcSection} + ${dstSection}
- ${svcList.length ? `
Services (${svcList.length})${selectableSvcs.length > 1 ? `` : ''}
${svcsHtml}${mergeBar}${propagateBanner}
` : ''} + ${svcList.length ? `
Services (${displayServiceCount})${!showGlobalCompatibleDecision && selectableSvcs.length > 1 ? `` : ''}
${compatibleSelectionHtml}
${svcsHtml}
${mergeBar}${propagateBanner}
` : ''} ${buildDrawerSecProfiles(p, idx)} `; } @@ -3639,11 +3897,11 @@ function populateDrawer(idx) { // ═══════════════════════════════════════════════════════════════ async function analyse() { - const sub = state.subView.analyse; + let sub = state.subView.analyse; + if (sub === 'groups') sub = state.subView.analyse = 'flows'; const pills = [ { key: 'flows', label: 'Flux', icon: '≡' }, { key: 'matrix', label: 'Matrice', icon: '⊞' }, - { key: 'groups', label: 'Groupes', icon: '⊕' }, { key: 'ports', label: 'Ports', icon: '◫' }, ]; const pillsHtml = pills.map(p => @@ -3799,20 +4057,15 @@ async function deploy() {
4 Policies à générer -
- - - +
+
@@ -3860,7 +4113,7 @@ async function deploy() {
${{ service: '↳ 1 policy par service — sources et destinations restent groupées. Vue propre par protocole.', host: '↳ 1:1 complet : 1 policy par hôte src /32 × hôte dst /32 × service. Maximum de granularité.', - 'src-agg-dst-detail': '↳ Hybride : sources en subnet /24, destinations en IP /32. Idéal pour flux utilisateurs → serveurs (WSUS, DC, VEEAM…).', + 'src-agg-dst-detail': '↳ Hybride : sources en réseau CIDR, destinations en IP /32. Idéal pour flux utilisateurs → serveurs (WSUS, DC, VEEAM…).', }[deployState.bruteMode] || ''}
@@ -4198,7 +4451,7 @@ async function deploy() { if (hintEl) hintEl.textContent = { service: '↳ 1 policy par service — sources et destinations restent groupées. Vue propre par protocole.', host: '↳ 1:1 complet : 1 policy par hôte src /32 × hôte dst /32 × service. Maximum de granularité.', - 'src-agg-dst-detail': '↳ Hybride : sources en subnet /24, destinations en IP /32. Idéal pour flux utilisateurs → serveurs (WSUS, DC, VEEAM…).', + 'src-agg-dst-detail': '↳ Hybride : sources en réseau CIDR, destinations en IP /32. Idéal pour flux utilisateurs → serveurs (WSUS, DC, VEEAM…).', }[deployState.bruteMode] || ''; return; } @@ -6612,23 +6865,38 @@ function syncAddrCell(idx, type) { syncRowStatus(idx); } +function objectStatusTag(addrAnalysis, currentName) { + if (!addrAnalysis?.found) { + return currentName + ? 'À CRÉER' + : 'AUTO'; + } + const prefix = parseInt(String(addrAnalysis.cidr || '').split('/')[1], 10); + const source = String(addrAnalysis.source || '').replace('config-range', 'config'); + if (source === 'config' && Number.isInteger(prefix) && prefix <= 16) { + return `LARGE /${prefix}`; + } + return source === 'config' + ? 'EXACT' + : 'AUTO'; +} + function addrCell(addrAnalysis, currentName, idx, field) { if (!addrAnalysis?.found) { const displayName = currentName || addrAnalysis?.suggestedName || ''; // Si l'utilisateur a tapé un nom custom → neutre (sera créé), sinon orange (action requise) if (currentName) { - return `${escHtml(currentName)} ${badgeHtml('auto')}`; + return `${escHtml(currentName)}${objectStatusTag(addrAnalysis, currentName)}`; } - return `${displayName ? escHtml(displayName) + ' ' : ''}${badgeHtml('auto')}`; + return `${displayName ? escHtml(displayName) : '—'}${objectStatusTag(addrAnalysis, currentName)}`; } const matches = addrAnalysis.allMatches || [{ name: addrAnalysis.name, source: addrAnalysis.source }]; const cidrTip = addrAnalysis.cidr ? ` (${addrAnalysis.cidr})` : ''; - const src = (matches[0].source || addrAnalysis.source || '').replace('config-range', 'config'); - const badge = src === 'config' ? badgeHtml('config') : badgeHtml('auto'); + const badge = objectStatusTag(addrAnalysis, currentName); if (matches.length === 1) { - return `${escHtml(matches[0].name)}${badge}`; + return `${escHtml(matches[0].name)}${badge}`; } - return `${escHtml(matches[0].name)}${badge}`; + return `${escHtml(matches[0].name)}${badge}`; } // Legacy addrCell for drawer/modal contexts (with full input) @@ -6765,7 +7033,7 @@ function isPolicyComplete(p, _debug) { // Services — must be found, merged, or explicitly renamed by user // Aligné avec svcCells: orange si pas de customName (= suggestedName identique au label auto) for (const svc of a.services || []) { - if (svc.found || svc._isMerged) continue; + if (svc.found || svc._isMerged || isCompatibleServiceSelected(p, svc)) continue; const isPortNotation = /^(TCP|UDP)\/\d+$/i.test(svc.suggestedName || ''); const autoLabel = svc.isNamed ? svc.label : `FF_SVC_${svc.port}_${svc.proto}`; const hasCustomName = svc.suggestedName && !isPortNotation && svc.suggestedName !== autoLabel; @@ -7338,7 +7606,7 @@ function renderDeployPolicies(analyzed, resetPage = true) { const dstAddrCell = _buildDstAddrCell(p, idx); // Services — compact - const svcCells = _buildSvcCellHtml(p); + const svcCells = _buildSvcCellHtml(p, 3); // Interfaces — read-only text, editable in drawer let srcIntf, dstIntf; @@ -7391,22 +7659,24 @@ function renderDeployPolicies(analyzed, resetPage = true) { const isHighlighted = !isAgg && idx === deployState._highlightIdx; if (isHighlighted) deployState._highlightIdx = null; // consommer une seule fois const isScan = isScanPolicy(p); + const objectState = rowStatus === 'ok' + ? 'Prête' + : `À compléter`; + const interfaceSummary = `${srcIntf}${dstIntf}`; return ` - - - -
- -
${fmtNum(p.sessions || 0)} - ${actionBadge}${dirBadge} - ${warnBadge}${seqBadge}${isScan ? '⚠ silencieux' : ''}${p._hpsUnverified ? '⚠ non vérifié' : ''}${srcSubnetText}${srcModeBadge} - ${srcAddrCell} - ${allSrcAutoFlag ? '' : `${srcIntf}`} - ${dstTargetCell(p, idx)} - ${dstAddrCell} - ${allDstAutoFlag ? '' : `${dstIntf}`} - ${svcCells} + + + + + + +
${actionBadge}${dirBadge}${warnBadge}${seqBadge}${isScan ? '⚠ silencieux' : ''}${srcSubnetText}${srcModeBadge}${fmtNum(p.sessions || 0)}
+ ${dstTargetCell(p, idx)} + ${svcCells} + ${interfaceSummary} +
${srcAddrCell}${dstAddrCell}
+ ${objectState} `; } @@ -7460,16 +7730,13 @@ function renderDeployPolicies(analyzed, resetPage = true) {
- - - - - - ${thSort('Sessions', 'sessions')} - ${thSort('Dir.', 'dir')} - ${thSort('Source', 'source')}${thSort('Src addr', 'srcAddr')}${allSrcAutoFlag ? '' : thSort('Src intf', 'srcIntf')} - ${thSort('Destination', 'dst')}${thSort('Dst addr', 'dstAddr')}${allDstAutoFlag ? '' : thSort('Dst intf', 'dstIntf')} + + ${thSort('Source', 'source')} + ${thSort('Destination', 'dst')} ${thSort('Services', 'services')} + + + ${rows}
InterfacesObjets FortiGateÉtat
@@ -7588,7 +7855,7 @@ async function generateDeployConf() { selectedPolicies = aggregated.map(p => ({ ...p, services: (p.analysis?.services || []).filter(s => !s._isMerged).map(s => s.label), - _mergedServices: (p.analysis?.services || []).filter(s => s._isMerged).map(s => ({ name: s.suggestedName, ports: s.ports || null, portRange: s.portRange || null, proto: s.proto })), + _mergedServices: (p.analysis?.services || []).filter(s => s._isMerged).map(s => ({ name: s.suggestedName, ports: s.ports || null, portRange: s.portRange || null, proto: s.proto, sourcePorts: s.sourcePorts || [] })), srcintf: p._isAggregated ? (p._srcintfList || []) : (p._srcintf || p.srcintf || ''), dstintf: p._isAggregated ? (p._dstintfList || []) : (p._dstintf || p.dstintf || ''), srcAddrName: p._srcAddrName, @@ -7610,7 +7877,7 @@ async function generateDeployConf() { .map(p => ({ ...p, services: (p.analysis?.services || []).filter(s => !s._isMerged).map(s => s.label), - _mergedServices: (p.analysis?.services || []).filter(s => s._isMerged).map(s => ({ name: s.suggestedName, ports: s.ports || null, portRange: s.portRange || null, proto: s.proto })), + _mergedServices: (p.analysis?.services || []).filter(s => s._isMerged).map(s => ({ name: s.suggestedName, ports: s.ports || null, portRange: s.portRange || null, proto: s.proto, sourcePorts: s.sourcePorts || [] })), srcintf: p._srcintf || p.srcintf || '', dstintf: p._dstintf || p.dstintf || '', srcAddrName: p._srcAddrName, @@ -7621,6 +7888,9 @@ async function generateDeployConf() { srcHosts: (p.srcHosts || []).filter(h => !p._excludedSrcHosts?.has(h)), dstHosts: (p.dstHosts || []).filter(h => !p._excludedDstHosts?.has(h)), tags: p._tags || [], + securityProfiles: p._secProfiles || null, + action: p._action || null, + log: p._log || null, disabled: p._disabled || false, })); } @@ -7659,7 +7929,7 @@ async function generateDeployConf() { const pfRes = await fetch(`/api/deploy/preflight?session=${state.session}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ selectedPolicies }), + body: JSON.stringify({ selectedPolicies, opts }), }); if (pfRes.ok) { const pf = await pfRes.json(); diff --git a/app/web/public/style.css b/app/web/public/style.css index 45c6d39..e2ebe9e 100644 --- a/app/web/public/style.css +++ b/app/web/public/style.css @@ -215,7 +215,7 @@ html, body { /* ── Content area ───────────────────────────────────────────────────────────── */ -#content { flex: 1; padding: 36px 40px; overflow-y: auto; } +#content { flex: 1; padding: 24px 28px; overflow-y: auto; } /* ── Upload zone ────────────────────────────────────────────────────────────── */ @@ -521,9 +521,13 @@ td.mono { font-family: var(--mono); } border-radius: var(--radius); background: var(--bg1); position: relative; + display: grid; + place-items: start center; + padding: 28px; + min-height: 380px; } -#matrix-canvas { display: block; image-rendering: pixelated; } +#matrix-canvas { display: block; image-rendering: auto; } #matrix-tooltip { display: none; @@ -879,12 +883,12 @@ td.mono { font-family: var(--mono); } /* ── Deploy view ─────────────────────────────────────────────────────────────── */ -.deploy-wrap { display: flex; flex-direction: column; gap: 20px; padding: 32px 36px; } +.deploy-wrap { display: flex; flex-direction: column; gap: 14px; padding: 0; } .deploy-step { background: var(--bg1); border: 1px solid var(--border); border-radius: 6px; min-width: 0; } .deploy-step-header { - display: flex; align-items: center; gap: 12px; padding: 14px 20px; + display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--bg2); border-bottom: 1px solid var(--border); font-size: 13px; font-weight: 600; color: var(--text); letter-spacing: 0.1px; } @@ -895,7 +899,7 @@ td.mono { font-family: var(--mono); } display: flex; align-items: center; justify-content: center; flex-shrink: 0; } -.deploy-step-body { padding: 18px 20px; } +.deploy-step-body { padding: 14px 16px; } .deploy-step-footer { padding: 14px 20px; border-top: 1px solid var(--border); @@ -1032,11 +1036,42 @@ td.mono { font-family: var(--mono); } /* Policy table */ .deploy-policy-table { width: 100%; border-collapse: collapse; font-size: 12px; } -.deploy-policy-table th, .deploy-policy-table td { padding: 7px 9px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; } +.deploy-policy-table th, .deploy-policy-table td { padding: 7px 9px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: middle; } .deploy-policy-table th { color: var(--text2); font-weight: 700; font-size: 9.5px; background: var(--bg2); text-transform: uppercase; letter-spacing: 0.8px; } .deploy-policy-table th.sortable-th:hover { color: var(--text1); background: var(--bg3, var(--bg2)); } .deploy-policy-table th.sort-active { color: var(--accent, #4f8ef7); } .deploy-policy-row:hover { background: var(--bg2); } +.policy-controls-cell { width: 116px; white-space: nowrap; display: table-cell; } +.policy-controls-cell > * { margin-right: 6px; vertical-align: middle; } +.policy-controls-cell .deploy-merge-chk, +.policy-controls-cell .policy-row-secondary { opacity: 0; transition: opacity 0.15s; } +.deploy-policy-row:hover .deploy-merge-chk, +.deploy-policy-row:hover .policy-row-secondary, +.policy-controls-cell .deploy-merge-chk:checked { opacity: 1; } +.policy-main-cell { min-width: 170px; white-space: nowrap !important; } +.policy-primary-line { display: flex; align-items: center; gap: 6px; min-width: 0; } +.policy-primary-value { font-family: var(--mono); font-size: 12px; color: var(--text); } +.policy-session-inline { margin-left: auto; color: var(--text2); font-family: var(--mono); font-size: 10px; } +.policy-services-cell { min-width: 250px; max-width: 390px; white-space: nowrap !important; overflow: hidden; text-overflow: ellipsis; line-height: 1.6; } +.compact-more { display: inline-block; color: var(--text2); background: var(--bg3); border: 1px solid var(--border); border-radius: 10px; padding: 0 7px; font-size: 10px; white-space: nowrap; } +.policy-interfaces-cell { min-width: 190px; white-space: normal !important; } +.policy-interface-pair { display: grid; grid-template-columns: minmax(0,1fr) 18px minmax(0,1fr); align-items: center; gap: 4px; } +.policy-interface-pair > span:not(.policy-interface-arrow) { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.policy-interface-arrow { text-align: center; color: var(--text3); } +.policy-objects-cell { min-width: 270px; max-width: 390px; white-space: nowrap !important; overflow: hidden; } +.policy-object-pair { display: grid; grid-template-columns: minmax(0,1fr) 18px minmax(0,1fr); align-items: center; gap: 4px; min-width: 0; } +.policy-object-pair .inline-editable { max-width: none; min-width: 0; display: flex; align-items: center; overflow: visible; } +.policy-object-pair .object-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.policy-object-pair .object-status-tag { flex-shrink: 0; } +.policy-state-cell { width: 90px; text-align: center !important; } +.policy-ready, .policy-needs-work { display: inline-block; padding: 3px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; white-space: nowrap; } +.policy-ready { color: var(--success); background: rgba(74,158,114,0.1); border: 1px solid rgba(74,158,114,0.25); } +.policy-needs-work { color: var(--warn); background: rgba(200,133,58,0.1); border: 1px solid rgba(200,133,58,0.25); } +.object-status-tag { display: inline-block; margin-left: 5px; padding: 1px 5px; border-radius: 3px; font-size: 8px; font-weight: 700; letter-spacing: 0.3px; vertical-align: middle; white-space: nowrap; } +.object-status-tag.exact { color: var(--success); background: rgba(74,158,114,0.12); border: 1px solid rgba(74,158,114,0.25); } +.object-status-tag.create { color: var(--brand); background: rgba(192,112,136,0.12); border: 1px solid rgba(192,112,136,0.28); } +.object-status-tag.auto { color: var(--accent2); background: rgba(107,158,232,0.12); border: 1px solid rgba(107,158,232,0.25); } +.object-status-tag.broad { color: var(--warn); background: rgba(200,133,58,0.12); border: 1px solid rgba(200,133,58,0.28); } .policy-deny-row { background: rgba(239,68,68,0.08) !important; } .policy-deny-row:hover { background: rgba(239,68,68,0.15) !important; } .policy-deny-row td { border-bottom-color: rgba(239,68,68,0.2); } @@ -1354,7 +1389,7 @@ td:has([data-tip]) { overflow: visible; } } .policy-drawer-overlay.open { opacity: 1; pointer-events: auto; } .policy-drawer { - position: fixed; top: 0; right: -460px; width: 440px; height: 100vh; + position: fixed; top: 0; right: -560px; width: min(520px, 94vw); height: 100vh; background: var(--bg1); border-left: 1px solid var(--border2); box-shadow: -8px 0 40px rgba(0,0,0,0.5); z-index: 901; transition: right 0.25s ease; display: flex; flex-direction: column; @@ -1368,8 +1403,8 @@ td:has([data-tip]) { overflow: visible; } .drawer-header h3 { font-size: 13px; font-weight: 700; margin: 0; flex: 1; } .drawer-close { background: none; border: none; color: var(--text2); font-size: 18px; cursor: pointer; padding: 4px 8px; border-radius: 4px; } .drawer-close:hover { background: var(--bg3); color: var(--text); } -.drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; } -.drawer-section { margin-bottom: 20px; } +.drawer-body { flex: 1; overflow-y: auto; padding: 14px 18px 24px; } +.drawer-section { margin-bottom: 16px; } .drawer-section-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text2); margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border); @@ -1408,6 +1443,20 @@ td:has([data-tip]) { overflow: visible; } border-bottom: 1px solid var(--border); } .drawer-multidst-subnet { font-size: 10px; font-family: var(--mono); min-width: 110px; } +.drawer-more-toggle { width: 100%; margin-top: 6px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg2); color: var(--accent2); font-size: 10px; cursor: pointer; } +.drawer-more-toggle:hover { background: var(--bg3); border-color: var(--border2); } +.drawer-interface-field { margin-bottom: 6px; } +.drawer-object-field { margin-bottom: 6px; } +.drawer-general-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; } +.drawer-general-grid > div { min-height: 34px; display: flex; align-items: center; gap: 7px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg2); } +.drawer-general-grid > div > span:first-child { color: var(--text2); font-size: 10px; margin-right: auto; } +.drawer-general-grid strong { font-family: var(--mono); font-size: 11px; color: var(--text); } +.drawer-general-action { grid-column: 1 / -1; } +.drawer-advanced { margin-top: 4px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg2); } +.drawer-advanced > summary { cursor: pointer; list-style: none; padding: 10px 12px; color: var(--text2); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.7px; } +.drawer-advanced > summary::after { content: '▾'; float: right; color: var(--text3); } +.drawer-advanced[open] > summary::after { content: '▴'; } +.drawer-advanced-body { padding: 10px 12px 4px; border-top: 1px solid var(--border); } /* ── Inline editable cell (click-to-edit) ────────────────────────────────────── */ .inline-editable { @@ -1523,9 +1572,13 @@ td:has([data-tip]) { overflow: visible; } .dropdown-item:first-child { border-radius: var(--radius) var(--radius) 0 0; } .dropdown-item:last-child { border-radius: 0 0 var(--radius) var(--radius); } .dropdown-sep { height: 1px; background: var(--border); margin: 4px 0; } +.deploy-options-menu { left: auto; right: 0; min-width: 210px; padding: 8px 10px; } +.deploy-option-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 7px 4px; font-size: 11px; color: var(--text2); } +.deploy-option-row + .deploy-option-row { border-top: 1px solid var(--border); } +.deploy-option-row .deploy-select { min-width: 104px; } /* ── Wizard progress ─────────────────────────────────────────────────────────── */ -.wizard-progress { display: flex; align-items: center; justify-content: center; gap: 0; padding: 24px 28px; margin-bottom: 8px; } +.wizard-progress { display: flex; align-items: center; justify-content: center; gap: 0; padding: 12px 18px; margin-bottom: 0; } .wizard-step-indicator { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text2); opacity: 0.3; cursor: pointer; user-select: none; transition: opacity 0.2s; } .wizard-step-indicator.active { opacity: 1; color: var(--text); } .wizard-step-indicator.done { opacity: 1; color: var(--brand); } diff --git a/app/web/server.js b/app/web/server.js index 5f6ed7a..f8929bf 100644 --- a/app/web/server.js +++ b/app/web/server.js @@ -10,8 +10,8 @@ const { parseFile } = require('./lib/parser const { buildAnalysis, consolidatePolicies } = require('./lib/analyzer'); const { createSession, getSession, setSessionData, setFortiConfig, setSessionError, deleteSession, getStats, listSessions } = require('./lib/store'); -const { parseFortiConfig, analyzePolicies, - generateConfig, validateAgainstExisting, +const { parseFortiConfig, extractKnownSubnets, preserveDestinationServiceAffinity, analyzePolicies, + generateConfig, validateAgainstExisting, applyPolicyUserDecisions, validateGenerationOptions, validatePolicyDecisionShapes, preflightValidation, parseFullRoutingTable, parseOspfRoutingTable, parseBgpNetworkTable, sortRoutes, formatExistingPolicies } = require('./lib/forticonfig'); @@ -131,27 +131,6 @@ app.use((req, res, next) => { // ─── Helpers ────────────────────────────────────────────────────────────────── -// Extracts sorted (most-specific first) subnet list from fortiConfig.addresses, -// used to re-analyze flows with real CIDR boundaries instead of hardcoded /24. -function extractKnownSubnets(fortiConfig) { - const addresses = fortiConfig?.addresses || {}; - const subnets = []; - for (const addr of Object.values(addresses)) { - if (!addr.cidr || !addr.cidr.includes('/')) continue; - const slash = addr.cidr.lastIndexOf('/'); - const ip = addr.cidr.slice(0, slash); - const prefix = parseInt(addr.cidr.slice(slash + 1), 10); - if (isNaN(prefix) || prefix < 0 || prefix > 32) continue; - if (prefix === 32) continue; // /32 hosts: used for individual matching only, not subnet grouping - const parts = ip.split('.'); - if (parts.length !== 4) continue; - const ipInt = parts.reduce((acc, p) => (acc * 256) + parseInt(p, 10), 0); - const mask = prefix === 0 ? 0 : (0xFFFFFFFF << (32 - prefix)) >>> 0; - subnets.push({ prefix, networkInt: (ipInt & mask) >>> 0, cidr: addr.cidr }); - } - return subnets.sort((a, b) => b.prefix - a.prefix); -} - function requireSession(req, res) { const id = req.query.session || req.params.session; const s = getSession(id); @@ -160,6 +139,39 @@ function requireSession(req, res) { return s; } +function preparePolicyDecisions(session, selectedPolicies, opts = {}) { + const optionDecision = validateGenerationOptions(opts, session.fortiConfig); + const normalizedOpts = optionDecision.opts; + let fortiConfig = session.fortiConfig; + if (normalizedOpts.wanOverrides.length > 0) { + const interfaces = { ...fortiConfig.interfaces }; + for (const name of normalizedOpts.wanOverrides) { + if (interfaces[name]) interfaces[name] = { ...interfaces[name], isWan: true }; + } + fortiConfig = { ...fortiConfig, interfaces }; + } + const submittedPolicies = preserveDestinationServiceAffinity(selectedPolicies); + const shapeDecision = validatePolicyDecisionShapes(submittedPolicies); + if (!shapeDecision.ok) { + return { ok: false, issues: [...optionDecision.issues, ...shapeDecision.issues], policies: [], fortiConfig, opts: normalizedOpts }; + } + const analysisInput = structuredClone(submittedPolicies); + for (const policy of analysisInput) delete policy._mergedServices; + const authoritativePolicies = analyzePolicies( + analysisInput, + fortiConfig, + normalizedOpts.preferredWanIntf, + ); + const decision = applyPolicyUserDecisions( + authoritativePolicies, + submittedPolicies, + fortiConfig, + session.data?.flows || [], + ); + const issues = [...optionDecision.issues, ...decision.issues]; + return { ...decision, ok: issues.length === 0, issues, fortiConfig, opts: normalizedOpts }; +} + // Vérifie si une valeur IP correspond au terme recherché. // Utilise une limite de fin de chiffre pour éviter que "10.1.6.19" matche "10.1.6.192". function ipTermMatches(value, term) { @@ -429,6 +441,17 @@ app.get('/api/policies', (req, res) => { const s = requireSession(req, res); if (!s) return; + // Une session restaurée peut contenir des policies calculées avant le lookup + // FortiGate. Recalculer une seule fois avant de les envoyer au drawer. + if (s.fortiConfig && s.data?.flows?.length > 0 && !s._networkResolutionApplied) { + const meta = s.data.meta; + const newAnalysis = buildAnalysis(s.data.flows, extractKnownSubnets(s.fortiConfig)); + newAnalysis.meta = meta; + s.data = newAnalysis; + s._networkResolutionApplied = true; + setSessionData(s.id, newAnalysis); + } + let policies = s.data.policies; if (req.query.subnet) { policies = policies.filter(p => @@ -1362,6 +1385,7 @@ app.post('/api/deploy/config-upload', upload.single('conffile'), async (req, res const newAnalysis = buildAnalysis(s.data.flows, knownSubnets); newAnalysis.meta = meta; s.data = newAnalysis; + s._networkResolutionApplied = true; setSessionData(s.id, newAnalysis); } } @@ -1410,6 +1434,16 @@ app.post('/api/deploy/config-vdom', express.json(), (req, res) => { s.fortiConfig = fortiConfig; setFortiConfig(s.id, fortiConfig); + // Recalculer les réseaux proposés avec les objets et interfaces du VDOM choisi. + if (s.data?.flows?.length > 0) { + const meta = s.data.meta; + const newAnalysis = buildAnalysis(s.data.flows, extractKnownSubnets(fortiConfig)); + newAnalysis.meta = meta; + s.data = newAnalysis; + s._networkResolutionApplied = true; + setSessionData(s.id, newAnalysis); + } + const policyMap = new Map(); for (const pol of fortiConfig.existingPolicies || []) { policyMap.set(String(pol.policyid), pol); @@ -1519,13 +1553,21 @@ app.post('/api/deploy/preflight', (req, res) => { if (!s) return; if (!s.fortiConfig) return res.status(404).json({ error: 'Aucune config FortiGate chargée' }); - const { selectedPolicies } = req.body || {}; + const { selectedPolicies, opts } = req.body || {}; if (!Array.isArray(selectedPolicies) || selectedPolicies.length === 0) { return res.status(400).json({ error: 'selectedPolicies requis' }); } try { - const result = preflightValidation(selectedPolicies, s.fortiConfig); + const decision = preparePolicyDecisions(s, selectedPolicies, opts || {}); + if (!decision.ok) { + return res.status(422).json({ error: 'Décision utilisateur invalide', code: 'POLICY_DECISION_INVALID', issues: decision.issues }); + } + const validatedPolicies = decision.policies; + const result = preflightValidation(validatedPolicies, decision.fortiConfig); + if (!result.ok) { + return res.status(422).json({ error: 'Preflight refusé', preflight: result }); + } res.json(result); } catch (err) { res.status(500).json({ error: err.message }); @@ -1552,70 +1594,27 @@ app.post('/api/deploy/generate', (req, res) => { } try { - const o = opts || {}; - - // Apply user WAN toggles — build a patched config without mutating the session - let configToUse = s.fortiConfig; - if (Array.isArray(o.wanOverrides) && o.wanOverrides.length > 0) { - const patchedInterfaces = { ...s.fortiConfig.interfaces }; - o.wanOverrides.forEach(name => { - if (patchedInterfaces[name]) { - patchedInterfaces[name] = { ...patchedInterfaces[name], isWan: true }; - } - }); - configToUse = { ...s.fortiConfig, interfaces: patchedInterfaces }; - } - - // SD-WAN zone takes priority; if none, preferredWanIntf falls to null (detectWanCandidates handles it) - const analyzed = analyzePolicies(selectedPolicies, configToUse, o.preferredWanIntf || null); - - // Re-inject per-policy overrides from frontend (action, log, securityProfiles) - for (let i = 0; i < analyzed.length; i++) { - const src = selectedPolicies[i] || {}; - if (src.action) analyzed[i].action = src.action; - if (src.log) analyzed[i].log = src.log; - if (src.securityProfiles) analyzed[i].securityProfiles = src.securityProfiles; + const decision = preparePolicyDecisions(s, selectedPolicies, opts || {}); + if (!decision.ok) { + return res.status(422).json({ error: 'Décision utilisateur invalide', code: 'POLICY_DECISION_INVALID', issues: decision.issues }); } - - // Inject frontend-merged services (multi-port / range) into each policy's analysis - // Also re-inject user-set suggestedName for standard services (lost during re-analysis) - for (let i = 0; i < analyzed.length; i++) { - const src = selectedPolicies[i] || {}; - - // Re-inject port/suggestedName from frontend analysis (perdu lors de la re-analyse) - const srcServices = src.analysis?.services || []; - for (const reAnalyzed of analyzed[i].analysis.services) { - const orig = srcServices.find(s => s.label === reAnalyzed.label); - if (orig && !reAnalyzed.found) { - if (orig.suggestedName) reAnalyzed.suggestedName = orig.suggestedName; - if (orig.port) reAnalyzed.port = orig.port; - if (orig.proto) reAnalyzed.proto = orig.proto; - if (orig.ports) reAnalyzed.ports = orig.ports; - if (orig.portRange) reAnalyzed.portRange = orig.portRange; - } - } - - const merged = src._mergedServices; - if (Array.isArray(merged) && merged.length > 0) { - for (const ms of merged) { - analyzed[i].analysis.services.push({ - label: ms.name, found: false, name: null, source: null, - suggestedName: ms.name, isNamed: false, - proto: ms.proto, ports: ms.ports || null, portRange: ms.portRange || null, - _isMerged: true, - }); - } - } + const validatedPolicies = decision.policies; + const generationPreflight = preflightValidation(validatedPolicies, decision.fortiConfig); + if (!generationPreflight.ok) { + return res.status(422).json({ error: 'Génération refusée par le preflight', preflight: generationPreflight }); } + const analyzed = validatedPolicies; + const configToUse = decision.fortiConfig; + const o = decision.opts; const genOpts = { natEnabled: o.nat || false, actionVerb: o.action || 'accept', logTraffic: o.log || 'all', - serviceGroups: s.fortiConfig.serviceGroups || {}, - addresses: s.fortiConfig.addresses || {}, - addressGroups: s.fortiConfig.addressGroups || {}, - zones: s.fortiConfig.zones || {}, + serviceGroups: configToUse.serviceGroups || {}, + addresses: configToUse.addresses || {}, + addressGroups: configToUse.addressGroups || {}, + zones: configToUse.zones || {}, securityProfiles: o.securityProfiles || {}, }; const cli = generateConfig(analyzed, genOpts); diff --git a/app/web/test/backend-policy-decisions.test.js b/app/web/test/backend-policy-decisions.test.js new file mode 100644 index 0000000..72b1184 --- /dev/null +++ b/app/web/test/backend-policy-decisions.test.js @@ -0,0 +1,853 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + parseFortiConfig, + analyzePolicies, + generateConfig, + applyPolicyUserDecisions, + validateGenerationOptions, + preflightValidation, + validatePolicyDecisionShapes, +} = require('../lib/forticonfig'); + +function fortiConfig(extra = '') { + return parseFortiConfig(` +config firewall address + edit "SRC" + set subnet 10.0.0.0 255.255.255.0 + next + edit "DST" + set subnet 10.0.1.0 255.255.255.0 + next +end +config system interface + edit "LAN" + set ip 10.0.0.1 255.255.255.0 + next + edit "DMZ" + set ip 10.0.1.1 255.255.255.0 + next +end +${extra} +`); +} + +function policy(overrides = {}) { + return { + srcSubnet: '10.0.0.0/24', + dstTarget: '10.0.1.0/24', + dstType: 'private', + services: ['APPX'], + ports: [5555], + protos: ['TCP'], + srcHosts: ['10.0.0.10'], + dstHosts: ['10.0.1.20'], + flowSrcintf: 'LAN', + sessions: 1, + action: 'accept', + ...overrides, + }; +} + +function observedFlow(overrides = {}) { + return { + srcip: '10.0.0.10', + dstip: '10.0.1.20', + srcSubnet: '10.0.0.0/24', + dstSubnet: '10.0.1.0/24', + dstType: 'private', + srcintf: 'LAN', + dstintf: 'DMZ', + service: 'APPX', + dstport: '5555', + proto: '6', + protoName: 'TCP', + action: 'accept', + ...overrides, + }; +} + +test('FF2-15 crée le service nommé avec le tuple unique observé', () => { + assert.equal(typeof applyPolicyUserDecisions, 'function'); + const config = fortiConfig(); + const authoritative = analyzePolicies([policy()], config); + const submitted = structuredClone(authoritative); + submitted[0].analysis.services[0].suggestedName = 'MyApp'; + + const decision = applyPolicyUserDecisions( + authoritative, + submitted, + config, + [observedFlow()], + ); + + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + const service = decision.policies[0].analysis.services[0]; + assert.equal(service.suggestedName, 'MyApp'); + assert.equal(service.port, 5555); + assert.equal(service.proto, 'TCP'); + + const cli = generateConfig(decision.policies, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + }); + assert.match(cli, /edit "MyApp"/); + assert.match(cli, /set tcp-portrange 5555/); + assert.match(cli, /set service "MyApp"/); +}); + +test('FF2-04 conserve action, log et profils de sécurité jusqu’à la CLI', () => { + const config = fortiConfig(` +config ips sensor + edit "STRICT_IPS" + next +end +`); + const authoritative = analyzePolicies([policy({ services: ['HTTPS'], ports: [443] })], config); + const submitted = structuredClone(authoritative); + submitted[0].action = 'deny'; + submitted[0].log = 'disable'; + submitted[0].securityProfiles = { ips: 'STRICT_IPS' }; + + const decision = applyPolicyUserDecisions( + authoritative, + submitted, + config, + [observedFlow({ service: 'HTTPS', dstport: '443' })], + ); + + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + const cli = generateConfig(decision.policies, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + actionVerb: 'accept', + logTraffic: 'all', + }); + assert.match(cli, /set action deny/); + assert.match(cli, /set logtraffic disable/); + assert.match(cli, /set utm-status enable/); + assert.match(cli, /set ips-sensor "STRICT_IPS"/); + assert.doesNotMatch(cli, /set action accept/); +}); + +test('FF2-04 sérialise les décisions du drawer dans les deux modes de vue', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8'); + const start = source.indexOf('async function generateDeployConf()'); + const end = source.indexOf('\n// ─── Preflight modal', start); + assert.ok(start >= 0 && end > start); + const generate = source.slice(start, end); + + assert.equal((generate.match(/securityProfiles:\s*p\._secProfiles\s*\|\|\s*null/g) || []).length, 2); + assert.equal((generate.match(/action:\s*p\._action\s*\|\|\s*null/g) || []).length, 2); + assert.equal((generate.match(/log:\s*p\._log\s*\|\|\s*null/g) || []).length, 2); + assert.match(generate, /body:\s*JSON\.stringify\(\{\s*selectedPolicies,\s*opts\s*\}\)/); +}); + +test('FF2-01 refuse une interface utilisateur absente de la configuration', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy()], config); + const submitted = structuredClone(authoritative); + submitted[0].srcintf = 'FORGED-INTERFACE'; + submitted[0].dstintf = 'DMZ'; + + const decision = applyPolicyUserDecisions( + authoritative, + submitted, + config, + [observedFlow()], + ); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'INTERFACE_DECISION_INVALID')); +}); + +test('FF2-01 refuse les noms de service globaux, normalisés ou en collision', () => { + const config = fortiConfig(` +config firewall service custom + edit "SAFE_NAME" + set tcp-portrange 9999 + next +end +`); + const authoritative = analyzePolicies([policy()], config); + + for (const name of ['ALL', 'SAFE"NAME', 'SAFE_NAME', 'HTTPS']) { + const submitted = structuredClone(authoritative); + submitted[0].analysis.services[0].suggestedName = name; + const decision = applyPolicyUserDecisions( + authoritative, + submitted, + config, + [observedFlow()], + ); + assert.equal(decision.ok, false, `${name} aurait dû être refusé`); + assert.ok(decision.issues.some(issue => + issue.code === 'SERVICE_NAME_INVALID' || issue.code === 'SERVICE_NAME_CONFLICT' + )); + } +}); + +test('FF2-01 refuse une policy sans service au lieu de générer ALL', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy({ services: [], ports: [] })], config); + const submitted = structuredClone(authoritative); + + const decision = applyPolicyUserDecisions( + authoritative, + submitted, + config, + [observedFlow()], + ); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SERVICE_DECISION_EMPTY')); +}); + +test('FF2-01 accepte un range uniquement depuis les ports sources observés', () => { + const config = fortiConfig(); + const basePolicy = policy({ + services: [], + ports: [12000, 12001], + }); + const authoritative = analyzePolicies([basePolicy], config); + const submitted = structuredClone(authoritative); + submitted[0].services = []; + submitted[0].analysis.services = []; + submitted[0]._mergedServices = [{ + name: 'CUSTOM_RANGE', + proto: 'TCP', + ports: null, + portRange: '12000-12001', + sourcePorts: [12000, 12001], + }]; + + const decision = applyPolicyUserDecisions( + authoritative, + submitted, + config, + [ + observedFlow({ service: 'TCP/12000', dstport: '12000' }), + observedFlow({ service: 'TCP/12001', dstport: '12001' }), + ], + ); + + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + assert.deepEqual(decision.policies[0].analysis.services, [{ + label: 'CUSTOM_RANGE', + found: false, + name: null, + source: null, + suggestedName: 'CUSTOM_RANGE', + isNamed: false, + proto: 'TCP', + ports: null, + portRange: '12000-12001', + sourcePorts: [12000, 12001], + _isMerged: true, + }]); +}); + +test('FF2-01 refuse les fusions de services forgées ou non prouvées', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy({ + services: ['TCP/12000', 'TCP/13000'], + ports: [12000, 13000], + })], config); + const flows = [ + observedFlow({ service: 'TCP/12000', dstport: '12000' }), + observedFlow({ service: 'TCP/13000', dstport: '13000' }), + ]; + const forged = [ + { name: 'EVIL-ALL', proto: 'TCP', portRange: '1-65535', sourcePorts: [12000, 13000] }, + { name: 'GAPPED', proto: 'TCP', portRange: '12000-13000', sourcePorts: [12000, 13000] }, + { name: 'NO-PROOF', proto: 'TCP', portRange: '12000-13000' }, + { name: 'UNOBSERVED', proto: 'TCP', ports: [12000, 14000], sourcePorts: [12000, 14000] }, + { name: 'CONTRADICTORY', proto: 'TCP', ports: [12000, 13000], portRange: '1-65535', sourcePorts: [12000, 13000] }, + ]; + + for (const merged of forged) { + const submitted = structuredClone(authoritative); + submitted[0].analysis.services = []; + submitted[0].services = []; + submitted[0]._mergedServices = [merged]; + const decision = applyPolicyUserDecisions(authoritative, submitted, config, flows); + assert.equal(decision.ok, false, `${merged.name} aurait dû être refusé`); + assert.ok(decision.issues.some(issue => issue.code === 'MERGED_SERVICE_DECISION_INVALID')); + } +}); + +test('FF2-01 refuse une fusion dont les labels ne correspondent pas aux flux', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy({ + services: ['TCP/12000', 'TCP/12001'], + ports: [12000, 12001], + })], config); + const submitted = structuredClone(authoritative); + submitted[0].services = []; + submitted[0].analysis.services = []; + submitted[0]._mergedServices = [{ + name: 'MERGED_REAL', proto: 'TCP', portRange: '12000-12001', sourcePorts: [12000, 12001], + }]; + const decision = applyPolicyUserDecisions(authoritative, submitted, config, [ + observedFlow({ service: 'REAL12000', dstport: '12000' }), + observedFlow({ service: 'REAL12001', dstport: '12001' }), + ]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SERVICE_DECISION_UNPROVEN')); +}); + +test('FF2-01 transmet la preuve des ports sources pour chaque fusion UI', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8'); + assert.equal((source.match(/sourcePorts:\s*ports/g) || []).length, 2); + assert.equal((source.match(/sourcePorts:\s*s\.sourcePorts\s*\|\|\s*\[\]/g) || []).length, 2); +}); + +test('FF2-01 ignore les champs techniques forgés d’un service standard', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy()], config); + const submitted = structuredClone(authoritative); + Object.assign(submitted[0].analysis.services[0], { + suggestedName: 'MyApp', + port: 9999, + proto: 'UDP', + ports: [9999], + portRange: '1-65535', + }); + + const decision = applyPolicyUserDecisions(authoritative, submitted, config, [observedFlow()]); + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + assert.equal(decision.policies[0].analysis.services[0].port, 5555); + assert.equal(decision.policies[0].analysis.services[0].proto, 'TCP'); + assert.equal(decision.policies[0].analysis.services[0].ports, undefined); + assert.equal(decision.policies[0].analysis.services[0].portRange, undefined); +}); + +test('FF2-01 applique le validateur autoritatif au preflight et à la génération', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8'); + assert.match(source, /applyPolicyUserDecisions/); + assert.match(source, /analysisInput\s*=\s*structuredClone\(submittedPolicies\)/); + assert.match(source, /delete policy\._mergedServices/); + for (const marker of ["app.post('/api/deploy/preflight'", "app.post('/api/deploy/generate'"]) { + const start = source.indexOf(marker); + const end = source.indexOf('\n});', start); + assert.ok(start >= 0 && end > start); + const route = source.slice(start, end); + assert.match(route, /preparePolicyDecisions\(/); + assert.match(route, /POLICY_DECISION_INVALID/); + } + const generateStart = source.indexOf("app.post('/api/deploy/generate'"); + const generateEnd = source.indexOf('\n});', generateStart); + const generateRoute = source.slice(generateStart, generateEnd); + assert.match(generateRoute, /preflightValidation\(validatedPolicies/); + assert.match(generateRoute, /Génération refusée par le preflight/); + const preflightStart = source.indexOf("app.post('/api/deploy/preflight'"); + const preflightEnd = source.indexOf('\n});', preflightStart); + assert.match(source.slice(preflightStart, preflightEnd), /Preflight refusé/); +}); + +test('FF2-01 refuse un service trouvé mais absent des flux observés de la policy', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy({ services: ['HTTPS'], ports: [443] })], config); + const submitted = structuredClone(authoritative); + + const decision = applyPolicyUserDecisions( + authoritative, + submitted, + config, + [observedFlow()], + ); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SERVICE_DECISION_UNPROVEN')); +}); + +test('FF2-01 refuse deux définitions différentes portant le même nom', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy({ + services: ['TCP/1000', 'TCP/2000'], + ports: [1000, 2000], + })], config); + const submitted = structuredClone(authoritative); + submitted[0].analysis.services.forEach(service => { service.suggestedName = 'SAME'; }); + + const decision = applyPolicyUserDecisions(authoritative, submitted, config, [ + observedFlow({ service: 'TCP/1000', dstport: '1000' }), + observedFlow({ service: 'TCP/2000', dstport: '2000' }), + ]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SERVICE_NAME_CONFLICT')); +}); + +test('FF2-01 le générateur refuse toute policy sans service validé', () => { + const config = fortiConfig(); + const empty = analyzePolicies([policy({ services: [], ports: [] })], config); + assert.throws(() => generateConfig(empty, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + }), /sans service validé/); +}); + +test('FF2-01 refuse aussi une interface WAN préférée inconnue', () => { + const config = fortiConfig(); + const wanPolicy = policy({ dstTarget: '8.8.8.8', dstType: 'public', dstHosts: [] }); + const authoritative = analyzePolicies([wanPolicy], config, 'FORGED-WAN'); + const decision = applyPolicyUserDecisions( + authoritative, + [wanPolicy], + config, + [observedFlow({ dstip: '8.8.8.8', dstSubnet: null, dstType: 'public' })], + ); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'INTERFACE_DECISION_INVALID')); +}); + +test('FF2-01 conserve la preuve de service pour une destination multi-subnet mixte', () => { + const config = fortiConfig(); + const mixedPolicy = policy({ + dstTargets: ['10.0.1.0/24', '10.0.2.0/24'], + dstHosts: ['10.0.2.20'], + _multiDstSubnets: [ + { subnet: '10.0.1.0/24', useSubnet: true, hosts: ['10.0.1.20', '10.0.1.30'] }, + { subnet: '10.0.2.0/24', useSubnet: false, hosts: ['10.0.2.20'] }, + ], + }); + const authoritative = analyzePolicies([mixedPolicy], config); + const decision = applyPolicyUserDecisions( + authoritative, + [mixedPolicy], + config, + [ + observedFlow({ dstip: '10.0.1.30' }), + observedFlow({ dstip: '10.0.2.20', dstSubnet: '10.0.2.0/24' }), + ], + ); + + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); +}); + +test('FF2-01 accepte une zone SD-WAN secondaire présente dans la configuration', () => { + const config = fortiConfig(); + config.sdwanIntfName = 'virtual-wan-link'; + config.sdwanZoneNames = ['virtual-wan-link', 'overlay-wan']; + config.sdwanMembers = ['DMZ']; + const wanPolicy = policy({ dstTarget: '8.8.8.8', dstType: 'public', dstHosts: [] }); + const authoritative = analyzePolicies([wanPolicy], config, 'overlay-wan'); + const decision = applyPolicyUserDecisions( + authoritative, + [wanPolicy], + config, + [observedFlow({ dstip: '8.8.8.8', dstSubnet: null, dstType: 'public' })], + ); + + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); +}); + +test('FF2-15 crée deux noms distincts pour un même tuple utilisé par deux policies', () => { + const config = fortiConfig(); + const input = [policy({ services: ['APPX'] }), policy({ services: ['APPY'] })]; + const authoritative = analyzePolicies(input, config); + const submitted = structuredClone(authoritative); + submitted[0].analysis.services[0].suggestedName = 'MyApp'; + submitted[1].analysis.services[0].suggestedName = 'MyOther'; + const decision = applyPolicyUserDecisions(authoritative, submitted, config, [ + observedFlow({ service: 'APPX' }), + observedFlow({ service: 'APPY' }), + ]); + + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + const cli = generateConfig(decision.policies, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + }); + assert.match(cli, /edit "MyApp"[\s\S]*set tcp-portrange 5555/); + assert.match(cli, /edit "MyOther"[\s\S]*set tcp-portrange 5555/); +}); + +test('FF2-01 refuse explicitement un choix de réutilisation forgé ou stale', () => { + const config = fortiConfig(` +config firewall service custom + edit "MS-RPC-DYNAMIC" + set tcp-portrange 49152-65535 + next +end +`); + const submitted = policy({ + services: ['TCP/52980'], + ports: [52980], + _serviceReuse: { 'TCP/52980': 'FORGED-SERVICE' }, + }); + const authoritative = analyzePolicies([submitted], config); + const decision = applyPolicyUserDecisions(authoritative, [submitted], config, [ + observedFlow({ service: 'TCP/52980', dstport: '52980' }), + ]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SERVICE_REUSE_DECISION_INVALID')); +}); + +test('FF2-01 conserve une réutilisation compatible explicitement revalidée', () => { + const config = fortiConfig(` +config firewall service custom + edit "MS-RPC-DYNAMIC" + set tcp-portrange 49152-65535 + next +end +`); + const submitted = policy({ + services: ['TCP/52980'], + ports: [52980], + _serviceReuse: { 'TCP/52980': 'MS-RPC-DYNAMIC' }, + }); + const authoritative = analyzePolicies([submitted], config); + const decision = applyPolicyUserDecisions(authoritative, [submitted], config, [ + observedFlow({ service: 'TCP/52980', dstport: '52980' }), + ]); + + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + assert.equal(decision.policies[0].analysis.services[0].name, 'MS-RPC-DYNAMIC'); + const cli = generateConfig(decision.policies, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + }); + assert.match(cli, /set service "MS-RPC-DYNAMIC"/); + assert.doesNotMatch(cli, /edit "MS-RPC-DYNAMIC"/); +}); + +test('FF2-01 ignore la table legacy serviceNames fournie par le navigateur', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy({ serviceNames: { APPX: 'ALL' } })], config); + const submitted = structuredClone(authoritative); + submitted[0].analysis.services[0].suggestedName = 'MyApp'; + + const decision = applyPolicyUserDecisions(authoritative, submitted, config, [observedFlow()]); + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + const cli = generateConfig(decision.policies, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + }); + assert.match(cli, /edit "MyApp"/); + assert.doesNotMatch(cli, /edit "ALL"/); + assert.doesNotMatch(cli, /set service "ALL"/); +}); + +test('FF2-01 ignore un groupe d’adresse client non prouvé', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy()], config); + authoritative[0]._srcAddrGrpFound = true; + authoritative[0]._srcAddrName = 'EVIL'; + authoritative[0].srcAddrName = 'EVIL'; + const decision = applyPolicyUserDecisions(authoritative, authoritative, config, [observedFlow()]); + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + const cli = generateConfig(decision.policies, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + }); + assert.match(cli, /set srcaddr "SRC"/); + assert.doesNotMatch(cli, /set srcaddr "EVIL"/); +}); + +test('FF2-01 refuse deux noms différents pour le même CIDR à créer', () => { + const config = parseFortiConfig(` +config system interface + edit "LAN" + set ip 10.0.0.1 255.255.255.0 + next + edit "DMZ" + set ip 10.0.1.1 255.255.255.0 + next +end +`); + const authoritative = analyzePolicies([policy(), policy()], config); + authoritative[0].srcAddrName = 'SRC-A'; + authoritative[1].srcAddrName = 'SRC-B'; + const decision = applyPolicyUserDecisions(authoritative, authoritative, config, [observedFlow()]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'ADDRESS_NAME_CONFLICT')); +}); + +test('FF2-01 refuse les métadonnées imbriquées d’adresse ou groupe forgées', () => { + const config = fortiConfig(); + config.addressGroups.SAFEGRP = { name: 'SAFEGRP', members: ['SRC', 'DST'] }; + const forged = policy({ + _isMultiDst: true, + _multiDstSubnets: [ + { subnet: '10.0.1.0/24', useSubnet: true, addrFound: true, addrName: 'EVIL_ADDR', hosts: ['10.0.1.20'] }, + ], + _useDstGroup: true, + dstAddrName: 'SAFEGRP', + }); + const authoritative = analyzePolicies([forged], config); + const decision = applyPolicyUserDecisions(authoritative, [forged], config, [observedFlow()]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'ADDRESS_NAME_CONFLICT')); +}); + +test('FF2-01 refuse les noms d’hôtes réservés ou en collision', () => { + const config = parseFortiConfig(` +config system interface + edit "LAN" + set ip 10.0.0.1 255.255.255.0 + next + edit "DMZ" + set ip 10.0.1.1 255.255.255.0 + next +end +`); + const forged = policy({ + _use32Src: true, + srcHosts: ['10.0.0.10', '10.0.0.11'], + _srcHostNames: { '10.0.0.10': 'all', '10.0.0.11': 'all' }, + }); + const authoritative = analyzePolicies([forged], config); + const decision = applyPolicyUserDecisions(authoritative, [forged], config, [ + observedFlow(), + observedFlow({ srcip: '10.0.0.11' }), + ]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => + issue.code === 'ADDRESS_NAME_INVALID' || issue.code === 'ADDRESS_NAME_CONFLICT' + )); +}); + +test('FF2-01 refuse un subnet forgé même si un hôte observé est conservé', () => { + const config = fortiConfig(); + const forged = policy({ srcSubnet: '10.0.0.0/8' }); + const authoritative = analyzePolicies([forged], config); + const decision = applyPolicyUserDecisions(authoritative, [forged], config, [observedFlow()]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SCOPE_DECISION_INVALID')); +}); + +test('FF2-01 refuse les représentations de scope contradictoires', () => { + const config = fortiConfig(); + const forgedPolicies = [ + policy({ srcSubnet: '10.0.0.0/8', srcSubnets: ['10.0.0.0/24'] }), + policy({ _srcMode: 'hosts', _use32Src: false }), + policy({ _use32Src: 'true', srcHosts: ['10.0.0.10', '10.0.0.99'] }), + policy({ _use32Dst: 'true', dstHosts: ['10.0.1.20', '10.0.1.99'] }), + policy({ _isWan: true }), + policy({ dstType: 'public', dstTarget: '8.8.8.8', dstHosts: [], _dstUseAll: 'false' }), + policy({ _isMultiDst: true, _multiDstSubnets: [{ subnet: '10.0.1.0/24', useSubnet: 'false', hosts: ['10.0.1.20'] }] }), + policy({ _useDstGroup: 'true' }), + policy({ _multiSrcSubnets: [{ subnet: '10.0.0.0/24', useSubnet: true, addrFound: 'true', hosts: ['10.0.0.10'] }] }), + policy({ _multiDstSubnets: [{ subnet: '10.0.1.0/24', useSubnet: true, addrName: 42, hosts: ['10.0.1.20'] }] }), + policy({ _multiSrcSubnets: [{ subnet: '10.0.0.0/24', useSubnet: false, hosts: '10.0.0.10' }] }), + policy({ _multiDstSubnets: [{ subnet: '10.0.1.0/24', useSubnet: false, hosts: 42 }] }), + ]; + for (const forged of forgedPolicies) { + const submitted = structuredClone(forged); + const shapeDecision = validatePolicyDecisionShapes([submitted]); + if (!shapeDecision.ok) { + assert.ok(shapeDecision.issues.some(issue => issue.code === 'SCOPE_DECISION_INVALID')); + continue; + } + const authoritative = analyzePolicies([structuredClone(forged)], config); + const decision = applyPolicyUserDecisions(authoritative, [submitted], config, [observedFlow()]); + assert.equal(decision.ok, false, JSON.stringify(forged)); + assert.ok(decision.issues.some(issue => issue.code === 'SCOPE_DECISION_INVALID')); + } +}); + +test('FF2-01 conserve une destination WAN spécifique quand all n’est pas demandé', () => { + const config = fortiConfig(); + const submitted = policy({ dstTarget: '8.8.8.8', dstType: 'public', dstHosts: [] }); + const authoritative = analyzePolicies([submitted], config, 'DMZ'); + const decision = applyPolicyUserDecisions(authoritative, [submitted], config, [ + observedFlow({ dstip: '8.8.8.8', dstSubnet: null, dstType: 'public' }), + ]); + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + const cli = generateConfig(decision.policies, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + }); + assert.doesNotMatch(cli, /set dstaddr "all"/); +}); + +test('FF2-01 refuse un hôte /32 sans preuve ajouté au scope', () => { + const config = fortiConfig(); + const forged = policy({ _use32Src: true, srcHosts: ['10.0.0.10', '10.0.0.99'] }); + const authoritative = analyzePolicies([forged], config); + const decision = applyPolicyUserDecisions(authoritative, [forged], config, [observedFlow()]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SCOPE_DECISION_INVALID')); +}); + +test('FF2-01 refuse une paire d’interfaces existante mais hors du scope observé', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy()], config); + const submitted = structuredClone(authoritative); + submitted[0].srcintf = 'DMZ'; + submitted[0].dstintf = 'LAN'; + const decision = applyPolicyUserDecisions(authoritative, submitted, config, [observedFlow()]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'INTERFACE_DECISION_INVALID')); +}); + +test('FF2-01 refuse une paire croisée jamais observée', () => { + const config = fortiConfig(); + config.interfaces['ALT-SRC'] = { name: 'ALT-SRC' }; + config.interfaces['ALT-DST'] = { name: 'ALT-DST' }; + const authoritative = analyzePolicies([policy()], config); + const submitted = structuredClone(authoritative); + submitted[0].srcintf = 'LAN'; + submitted[0].dstintf = 'ALT-DST'; + const decision = applyPolicyUserDecisions(authoritative, submitted, config, [ + observedFlow(), + observedFlow({ srcintf: 'ALT-SRC', dstintf: 'ALT-DST' }), + ]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'INTERFACE_DECISION_INVALID')); +}); + +test('FF2-01 lie le label technique et le tuple au même flux observé', () => { + const config = fortiConfig(); + const forged = policy({ services: ['TCP/9999'], ports: [9999] }); + const authoritative = analyzePolicies([forged], config); + const decision = applyPolicyUserDecisions(authoritative, [forged], config, [ + observedFlow({ service: 'APPX', dstport: '9999' }), + ]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SERVICE_DECISION_UNPROVEN')); +}); + +test('FF2-01 refuse un service found dont le tuple ne correspond pas au flux', () => { + const config = fortiConfig(); + const forged = policy({ services: ['HTTPS'], ports: [9999] }); + const authoritative = analyzePolicies([forged], config); + const decision = applyPolicyUserDecisions(authoritative, [forged], config, [ + observedFlow({ service: 'HTTPS', dstport: '5555' }), + ]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SERVICE_DECISION_UNPROVEN')); +}); + +test('FF2-01 conserve un service custom exact TCP ou UDP', () => { + for (const [proto, configLine, protoNumber] of [ + ['TCP', 'set tcp-portrange 5555', '6'], + ['UDP', 'set udp-portrange 5555', '17'], + ]) { + const config = fortiConfig(` +config firewall service custom + edit "EXACT" + ${configLine} + next +end +`); + const submitted = policy({ services: ['EXACT'], ports: [5555], protos: [proto] }); + const authoritative = analyzePolicies([submitted], config); + const decision = applyPolicyUserDecisions(authoritative, [submitted], config, [ + observedFlow({ service: 'EXACT', proto: protoNumber, protoName: proto }), + ]); + assert.equal(decision.ok, true, `${proto}: ${JSON.stringify(decision.issues)}`); + assert.equal(decision.policies[0].analysis.services[0].name, 'EXACT'); + } +}); + +test('FF2-04 refuse les options globales forgées avant la génération', () => { + const config = fortiConfig(); + const validation = validateGenerationOptions({ + action: 'accept\nset admin enable', + log: 'all\nnext', + nat: 'yes', + securityProfiles: { ips: 'MISSING_IPS' }, + }, config); + + assert.equal(validation.ok, false); + assert.deepEqual(new Set(validation.issues.map(issue => issue.code)), new Set([ + 'ACTION_DECISION_INVALID', + 'LOG_DECISION_INVALID', + 'NAT_DECISION_INVALID', + 'SECURITY_PROFILE_DECISION_INVALID', + ])); + const malformed = validateGenerationOptions('FORGED', config); + assert.equal(malformed.ok, false); + assert.ok(malformed.issues.some(issue => issue.code === 'OPTIONS_DECISION_INVALID')); +}); + +test('FF2-04 refuse les profils par-policy mal formés ou inconnus', () => { + const config = fortiConfig(); + for (const securityProfiles of ['FORGED', { bogus: 'FORGED' }]) { + const authoritative = analyzePolicies([policy({ securityProfiles })], config); + const decision = applyPolicyUserDecisions(authoritative, authoritative, config, [observedFlow()]); + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'SECURITY_PROFILE_DECISION_INVALID')); + } +}); + +test('FF2-01 refuse les overrides WAN absents ou non-WAN', () => { + const config = fortiConfig(); + const validation = validateGenerationOptions({ + preferredWanIntf: 'LAN', + wanOverrides: ['MISSING'], + }, config); + + assert.equal(validation.ok, false); + assert.ok(validation.issues.some(issue => issue.code === 'WAN_DECISION_INVALID')); +}); + +test('FF2-04 applique les choix globaux quand aucun override par-policy n’existe', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy()], config); + const submitted = structuredClone(authoritative); + submitted[0].action = null; + submitted[0].log = null; + const decision = applyPolicyUserDecisions(authoritative, submitted, config, [observedFlow()]); + const optionDecision = validateGenerationOptions({ action: 'deny', log: 'disable' }, config); + + assert.equal(decision.ok, true, JSON.stringify(decision.issues)); + assert.equal(optionDecision.ok, true, JSON.stringify(optionDecision.issues)); + const cli = generateConfig(decision.policies, { + addresses: config.addresses, + addressGroups: config.addressGroups, + zones: config.zones, + actionVerb: optionDecision.opts.action, + logTraffic: optionDecision.opts.log, + }); + assert.match(cli, /set action deny/); + assert.match(cli, /set logtraffic disable/); +}); + +test('FF2-01 refuse une décision NAT par-policy non booléenne', () => { + const config = fortiConfig(); + const authoritative = analyzePolicies([policy({ nat: 'enable\nset action accept' })], config); + const decision = applyPolicyUserDecisions(authoritative, authoritative, config, [observedFlow()]); + + assert.equal(decision.ok, false); + assert.ok(decision.issues.some(issue => issue.code === 'NAT_DECISION_INVALID')); +}); + +test('FF2-01 le preflight lit les interfaces normalisées utilisées par le générateur', () => { + const config = fortiConfig(); + const analyzed = analyzePolicies([policy()], config); + analyzed[0].srcintf = 'LAN'; + analyzed[0].dstintf = 'LAN'; + const result = preflightValidation(analyzed, config); + + assert.equal(result.warnings, 1); + assert.ok(result.issues.some(issue => issue.msg.includes('même interface'))); +}); diff --git a/app/web/test/destination-service-affinity.test.js b/app/web/test/destination-service-affinity.test.js new file mode 100644 index 0000000..fb87721 --- /dev/null +++ b/app/web/test/destination-service-affinity.test.js @@ -0,0 +1,89 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + parseFortiConfig, + preserveDestinationServiceAffinity, + analyzePolicies, + generateConfig, +} = require('../lib/forticonfig'); + +function service(label) { + return { label, name: label, found: true, source: 'predefined', isNamed: true }; +} + +test('keeps common services grouped and splits destination-specific services', () => { + const policy = { + srcSubnet: '10.252.16.0/23', + srcintf: 'Z-Stations', + dstintf: 'Z-Serveurs', + vdom: 'root', + dstTarget: '10.40.1.10/32', + dstTargets: ['10.40.1.10/32', '10.40.1.20/32'], + dstType: 'private', + _isMultiDst: true, + _multiDstSubnets: [ + { subnet: '10.40.1.10/32', hosts: ['10.40.1.10'], useSubnet: true, addrName: 'Serveur-A', addrFound: true }, + { subnet: '10.40.1.20/32', hosts: ['10.40.1.20'], useSubnet: true, addrName: 'Serveur-B', addrFound: true }, + ], + analysis: { services: ['DNS', 'HTTP', 'LDAP', 'SMB'].map(service) }, + services: ['DNS', 'HTTP', 'LDAP', 'SMB'], + _mergedFrom: [ + { srcSubnet: '10.252.16.0/23', dstTarget: '10.40.1.10/32', analysis: { services: ['DNS', 'HTTP', 'LDAP'].map(service) } }, + { srcSubnet: '10.252.16.0/23', dstTarget: '10.40.1.20/32', analysis: { services: ['DNS', 'SMB'].map(service) } }, + ], + }; + + const result = preserveDestinationServiceAffinity([policy]); + const normalized = result.map(item => ({ + destinations: item.dstTargets, + services: item.analysis.services.map(svc => svc.label).sort(), + srcintf: item.srcintf, + dstintf: item.dstintf, + vdom: item.vdom, + })).sort((a, b) => a.destinations.join(',').localeCompare(b.destinations.join(',')) || a.services.join(',').localeCompare(b.services.join(','))); + + assert.deepEqual(normalized, [ + { destinations: ['10.40.1.10/32'], services: ['HTTP', 'LDAP'], srcintf: 'Z-Stations', dstintf: 'Z-Serveurs', vdom: 'root' }, + { destinations: ['10.40.1.10/32', '10.40.1.20/32'], services: ['DNS'], srcintf: 'Z-Stations', dstintf: 'Z-Serveurs', vdom: 'root' }, + { destinations: ['10.40.1.20/32'], services: ['SMB'], srcintf: 'Z-Stations', dstintf: 'Z-Serveurs', vdom: 'root' }, + ]); + + const fortiConfig = parseFortiConfig(` +config firewall address + edit "Stations" + set subnet 10.252.16.0 255.255.254.0 + next + edit "Serveur-A" + set subnet 10.40.1.10 255.255.255.255 + next + edit "Serveur-B" + set subnet 10.40.1.20 255.255.255.255 + next +end +config system interface + edit "Z-Stations" + set ip 10.252.17.254 255.255.254.0 + next + edit "Z-Serveurs" + set ip 10.40.1.254 255.255.255.0 + next +end +`); + const analyzed = analyzePolicies(result, fortiConfig); + const cli = generateConfig(analyzed, { + addresses: fortiConfig.addresses, + addressGroups: fortiConfig.addressGroups, + zones: fortiConfig.zones, + }); + const policySection = cli.split('config firewall policy')[1]; + const blocks = [...policySection.matchAll(/\n edit 0([\s\S]*?)\n next/g)].map(match => match[1]); + + assert.equal(blocks.length, 3); + assert.ok(blocks.some(block => block.includes('set dstaddr "Serveur-A" "Serveur-B"') && block.includes('set service "DNS"'))); + assert.ok(blocks.some(block => block.includes('set dstaddr "Serveur-A"') && block.includes('set service "HTTP" "LDAP"'))); + assert.ok(blocks.some(block => block.includes('set dstaddr "Serveur-B"') && block.includes('set service "SMB"'))); + assert.ok(blocks.every(block => block.includes('set srcintf "Z-Stations"') && block.includes('set dstintf "Z-Serveurs"'))); +}); diff --git a/app/web/test/network-resolution.test.js b/app/web/test/network-resolution.test.js new file mode 100644 index 0000000..79055a3 --- /dev/null +++ b/app/web/test/network-resolution.test.js @@ -0,0 +1,298 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const { buildAnalysis } = require('../lib/analyzer'); +const { parseFortiConfig, extractKnownSubnets } = require('../lib/forticonfig'); + +function acceptedFlow(srcip, dstip, srcintf = 'Stations', dstintf = 'Admin') { + return { + srcip, + dstip, + srcport: '55000', + dstport: '443', + proto: '6', + action: 'accept', + service: 'HTTPS', + srcintf, + dstintf, + policyid: '1', + count: 1, + sentBytes: 100, + rcvdBytes: 200, + }; +} + +async function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(error => error ? reject(error) : resolve(port)); + }); + }); +} + +async function waitForReady(baseUrl) { + for (let attempt = 0; attempt < 50; attempt++) { + try { + const response = await fetch(`${baseUrl}/api/health`); + if (response.ok) return; + } catch {} + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error('FortiFlow test server did not start'); +} + +test('uses the most specific FortiGate interface networks instead of a broad address object', () => { + const fortiConfig = parseFortiConfig(` +config firewall address + edit "RFC1918-10.0.0.0/8" + set subnet 10.0.0.0 255.0.0.0 + next +end +config system interface + edit "Stations" + set ip 10.250.17.254 255.255.254.0 + next + edit "Admin" + set ip 10.250.7.254 255.255.255.0 + next +end +`); + + const analysis = buildAnalysis( + [acceptedFlow('10.250.16.49', '10.250.7.106')], + extractKnownSubnets(fortiConfig), + ); + + assert.equal(analysis.flows[0].srcSubnet, '10.250.16.0/23'); + assert.equal(analysis.flows[0].dstSubnet, '10.250.7.0/24'); + assert.deepEqual(analysis.policies.map(policy => [policy.srcSubnet, policy.dstTarget]), [ + ['10.250.16.0/23', '10.250.7.0/24'], + ]); +}); + +test('prefers a more specific firewall address object over an interface network', () => { + const fortiConfig = parseFortiConfig(` +config firewall address + edit "Stations-Printers" + set subnet 10.250.16.0 255.255.255.128 + next +end +config system interface + edit "Stations" + set ip 10.250.17.254 255.255.254.0 + next +end +`); + + const analysis = buildAnalysis( + [acceptedFlow('10.250.16.49', '8.8.8.8')], + extractKnownSubnets(fortiConfig), + ); + + assert.equal(analysis.flows[0].srcSubnet, '10.250.16.0/25'); +}); + +test('keeps an unmatched private IP as a host instead of inventing a subnet', () => { + const fortiConfig = parseFortiConfig(` +config system interface + edit "Stations" + set ip 10.250.17.254 255.255.254.0 + next +end +`); + + const analysis = buildAnalysis( + [acceptedFlow('10.99.1.12', '10.99.2.34', 'unknown-src', 'unknown-dst')], + extractKnownSubnets(fortiConfig), + ); + + assert.equal(analysis.flows[0].srcSubnet, '10.99.1.12/32'); + assert.equal(analysis.flows[0].dstSubnet, '10.99.2.34/32'); +}); + +test('reanalyzes imported logs with the networks from the selected VDOM', async t => { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const appDir = path.resolve(__dirname, '..'); + const child = spawn(process.execPath, ['server.js'], { + cwd: appDir, + env: { + ...process.env, + PORT: String(port), + SSL_KEY: '/nonexistent/fortiflow-test.key', + SSL_CERT: '/nonexistent/fortiflow-test.crt', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let serverOutput = ''; + let sessionId = null; + child.stdout.on('data', chunk => { serverOutput += chunk; }); + child.stderr.on('data', chunk => { serverOutput += chunk; }); + t.after(async () => { + if (sessionId) { + await fetch(`${baseUrl}/api/admin/sessions/${sessionId}`, { method: 'DELETE' }).catch(() => {}); + } + child.kill('SIGTERM'); + }); + + await waitForReady(baseUrl); + + const logForm = new FormData(); + logForm.append('logfile', new Blob([ + 'type=traffic srcip=10.250.16.49 dstip=10.250.7.106 srcport=55000 dstport=443 proto=6 action=accept service=HTTPS srcintf="Stations" dstintf="Admin" policyid=1 sentbyte=100 rcvdbyte=200\n', + ]), 'traffic.log'); + const uploadResponse = await fetch(`${baseUrl}/api/upload`, { method: 'POST', body: logForm }); + assert.equal(uploadResponse.status, 200, serverOutput); + ({ sessionId } = await uploadResponse.json()); + + for (let attempt = 0; attempt < 50; attempt++) { + const progressResponse = await fetch(`${baseUrl}/api/progress/${sessionId}`); + const progress = await progressResponse.json(); + if (progress.done) break; + await new Promise(resolve => setTimeout(resolve, 50)); + } + + const configForm = new FormData(); + configForm.append('conffile', new Blob([` +config vdom + edit "root" + config system interface + edit "root-lan" + set ip 192.168.1.254 255.255.255.0 + next + end + next + edit "tenant" + config system interface + edit "Stations" + set ip 10.250.17.254 255.255.254.0 + next + edit "Admin" + set ip 10.250.7.254 255.255.255.0 + next + end + next +end +`]), 'fortigate.conf'); + const configResponse = await fetch(`${baseUrl}/api/deploy/config-upload?session=${sessionId}`, { + method: 'POST', + body: configForm, + }); + assert.equal(configResponse.status, 200, serverOutput); + const configResult = await configResponse.json(); + assert.equal(configResult.selectedVdom, 'root'); + assert.deepEqual(configResult.vdomList, ['root', 'tenant']); + + const switchResponse = await fetch(`${baseUrl}/api/deploy/config-vdom?session=${sessionId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vdom: 'tenant' }), + }); + assert.equal(switchResponse.status, 200, serverOutput); + + const flowsResponse = await fetch(`${baseUrl}/api/flows?session=${sessionId}`); + assert.equal(flowsResponse.status, 200, serverOutput); + const flows = await flowsResponse.json(); + assert.equal(flows.data[0].srcSubnet, '10.250.16.0/23'); + assert.equal(flows.data[0].dstSubnet, '10.250.7.0/24'); + + const policiesResponse = await fetch(`${baseUrl}/api/policies?session=${sessionId}`); + assert.equal(policiesResponse.status, 200, serverOutput); + const { policies } = await policiesResponse.json(); + assert.deepEqual(policies.map(policy => [policy.srcSubnet, policy.dstTarget]), [ + ['10.250.16.0/23', '10.250.7.0/24'], + ]); + + const generateResponse = await fetch(`${baseUrl}/api/deploy/generate?session=${sessionId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ selectedPolicies: policies, opts: {} }), + }); + assert.equal(generateResponse.status, 200, serverOutput); + const generated = await generateResponse.json(); + assert.match(generated.cli, /set subnet 10\.250\.16\.0 255\.255\.254\.0/); + assert.match(generated.cli, /set subnet 10\.250\.7\.0 255\.255\.255\.0/); +}); + +test('re-resolves a persisted policy before it is sent to the drawer', async t => { + const fortiConfig = parseFortiConfig(` +config firewall address + edit "RFC1918-10.0.0.0/8" + set subnet 10.0.0.0 255.0.0.0 + next +end +config system interface + edit "Stations" + set ip 10.250.17.254 255.255.254.0 + next + edit "Admin" + set ip 10.250.7.254 255.255.255.0 + next +end +`); + const broadOnly = parseFortiConfig(` +config firewall address + edit "RFC1918-10.0.0.0/8" + set subnet 10.0.0.0 255.0.0.0 + next +end +`); + const staleAnalysis = buildAnalysis( + [acceptedFlow('10.250.16.49', '10.250.7.106')], + extractKnownSubnets(broadOnly), + ); + staleAnalysis.meta = { filename: 'persisted.log' }; + assert.equal(staleAnalysis.policies[0].srcSubnet, '10.0.0.0/8'); + + const sessionId = `networkresolution${process.pid}${Date.now()}`; + const cacheDir = path.resolve(__dirname, '../../sessions-cache'); + const cachePath = path.join(cacheDir, `${sessionId}.json`); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify({ + id: sessionId, + createdAt: Date.now(), + lastAccess: Date.now(), + status: 'ready', + data: staleAnalysis, + fortiConfig, + })); + + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const child = spawn(process.execPath, ['server.js'], { + cwd: path.resolve(__dirname, '..'), + env: { + ...process.env, + PORT: String(port), + SSL_KEY: '/nonexistent/fortiflow-test.key', + SSL_CERT: '/nonexistent/fortiflow-test.crt', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let serverOutput = ''; + child.stdout.on('data', chunk => { serverOutput += chunk; }); + child.stderr.on('data', chunk => { serverOutput += chunk; }); + t.after(async () => { + await fetch(`${baseUrl}/api/admin/sessions/${sessionId}`, { method: 'DELETE' }).catch(() => {}); + child.kill('SIGTERM'); + fs.rmSync(cachePath, { force: true }); + }); + + await waitForReady(baseUrl); + const policiesResponse = await fetch(`${baseUrl}/api/policies?session=${sessionId}&include_no_rcvd=1`); + assert.equal(policiesResponse.status, 200, serverOutput); + const { policies } = await policiesResponse.json(); + + assert.equal(policies[0].srcSubnet, '10.250.16.0/23'); + assert.equal(policies[0].dstTarget, '10.250.7.0/24'); + assert.notEqual(policies[0].srcSubnet, '10.0.0.0/8'); +});