diff --git a/app/web/lib/forticonfig.js b/app/web/lib/forticonfig.js index 5bd888a..687929a 100644 --- a/app/web/lib/forticonfig.js +++ b/app/web/lib/forticonfig.js @@ -2062,6 +2062,16 @@ function segmentationServiceKey(service) { return String(service.label || service.name || '').toUpperCase(); } +function sameServiceLabelScope(requestedServices, recalculatedServices) { + const labels = services => [...new Set((services || []) + .map(segmentationServiceKey) + .filter(Boolean))].sort(); + const requested = labels(requestedServices); + const recalculated = labels(recalculatedServices); + return requested.length === recalculated.length + && requested.every((label, index) => label === recalculated[index]); +} + function segmentationProto(proto) { if (/^(6|tcp)$/i.test(String(proto || ''))) return 'TCP'; if (/^(17|udp)$/i.test(String(proto || ''))) return 'UDP'; @@ -2554,6 +2564,7 @@ module.exports = { validateAgainstExisting, preflightValidation, policyEngineSelectionMetrics, + sameServiceLabelScope, findInterfaceForSubnet, detectWanCandidates, findAddress, diff --git a/app/web/package-lock.json b/app/web/package-lock.json index 57f90ee..0cc9ab2 100644 --- a/app/web/package-lock.json +++ b/app/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fortiflow", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fortiflow", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "exceljs": "^4.4.0", "express": "^4.18.2", diff --git a/app/web/package.json b/app/web/package.json index bc0dd9b..8acfff3 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -1,6 +1,6 @@ { "name": "fortiflow", - "version": "1.0.0", + "version": "1.0.1", "description": "FortiGate/FortiAnalyzer traffic log analyzer for network segmentation", "main": "server.js", "scripts": { diff --git a/app/web/server.js b/app/web/server.js index 9ae1d90..22ec333 100644 --- a/app/web/server.js +++ b/app/web/server.js @@ -13,7 +13,7 @@ const { createSession, getSession, setSessionData, setFortiConfig, getStats, listSessions } = require('./lib/store'); const { parseFortiConfig, analyzePolicies, generateConfig, validateAgainstExisting, - preflightValidation, + preflightValidation, sameServiceLabelScope, parseFullRoutingTable, parseOspfRoutingTable, parseBgpNetworkTable, sortRoutes, formatExistingPolicies } = require('./lib/forticonfig'); const { buildHostPairCoverage, buildPolicyOrderIssues } = require('./lib/coverage'); @@ -1973,10 +1973,12 @@ app.post('/api/deploy/generate', (req, res) => { } if (src._segmentationPlan) { - const expected = srcServices.map(s => String(s.label || s.name || '').toUpperCase()).filter(Boolean).sort(); - const recalculated = analyzed[i].analysis.services - .map(s => String(s.label || s.name || '').toUpperCase()).filter(Boolean).sort(); - if (expected.length !== recalculated.length || expected.some((value, index) => value !== recalculated[index])) { + const expected = [...new Set(srcServices + .map(s => String(s.label || s.name || '').toUpperCase()).filter(Boolean))].sort(); + const recalculatedServices = analyzed[i].analysis.services; + const recalculated = [...new Set(recalculatedServices + .map(s => String(s.label || s.name || '').toUpperCase()).filter(Boolean))].sort(); + if (!sameServiceLabelScope(srcServices, recalculatedServices)) { serviceScopeIssues.push({ level: 'error', msg: `Policy #${i + 1}: dérive de services après ré-analyse (attendu: ${expected.join(', ') || 'aucun'}; obtenu: ${recalculated.join(', ') || 'aucun'})`, diff --git a/app/web/test/policy-engine-v2.test.js b/app/web/test/policy-engine-v2.test.js index a85ae11..8d63d4d 100644 --- a/app/web/test/policy-engine-v2.test.js +++ b/app/web/test/policy-engine-v2.test.js @@ -5,13 +5,37 @@ const assert = require('node:assert/strict'); const { Readable } = require('node:stream'); const analyzer = require('../lib/analyzer'); -const { analyzePolicies, preflightValidation, generateConfig } = require('../lib/forticonfig'); +const { + analyzePolicies, + preflightValidation, + generateConfig, + sameServiceLabelScope, +} = require('../lib/forticonfig'); const { parseStream } = require('../lib/parser'); test('Policy Engine V2 exposes a pure deterministic build entry point', () => { assert.equal(typeof analyzer.buildPolicyEngineV2, 'function'); }); +test('service scope comparison ignores duplicate labels but still detects real drift', () => { + const requested = [ + { label: 'MMS' }, + { label: 'MMS' }, + { label: 'DNS' }, + { label: 'DNS' }, + { label: 'LDAP' }, + ]; + const recalculated = [ + { label: 'MMS' }, + { label: 'DNS' }, + { label: 'LDAP' }, + ]; + + assert.equal(sameServiceLabelScope(requested, recalculated), true); + assert.equal(sameServiceLabelScope(requested, [...recalculated, { label: 'SMB' }]), false); + assert.equal(sameServiceLabelScope(requested, recalculated.filter(service => service.label !== 'LDAP')), false); +}); + function flow(source, destination, proto, port, service, extra = {}) { return { srcip: source, @@ -293,6 +317,27 @@ test('synthetic mode aggregates a dense known subnet and measures every addition assert.equal(result.metrics.expansionRatio, 1 / 3); }); +test('re-analysis may deduplicate presentation labels without widening the technical service scope', () => { + const flows = [ + flow('10.0.0.10', '10.0.1.10', 17, 3479, 'MMS'), + flow('10.0.0.10', '10.0.1.10', 17, 3481, 'MMS'), + flow('10.0.0.10', '10.0.1.10', 6, 53, 'DNS'), + flow('10.0.0.10', '10.0.1.10', 17, 53, 'DNS'), + ]; + const config = { + addresses: {}, addressGroups: {}, customServices: {}, serviceGroups: {}, zones: {}, + interfaces: { users: {}, servers: {} }, + }; + const engine = analyzer.buildPolicyEngineV2(flows, { profile: 'recommended', fortiConfig: config }); + const analyzed = analyzePolicies(engine.policies, config, null); + + assert.deepEqual(engine.policies[0].analysis.services.map(service => service.label).sort(), ['DNS', 'DNS', 'MMS', 'MMS']); + assert.deepEqual(analyzed[0].analysis.services.map(service => service.label).sort(), ['DNS', 'MMS']); + assert.equal(sameServiceLabelScope(engine.policies[0].analysis.services, analyzed[0].analysis.services), true); + assert.deepEqual(analyzed[0].analysis.services.find(service => service.label === 'MMS').udpPorts, [3479, 3481]); + assert.equal(preflightValidation(analyzed, config, flows, engine.atoms).ok, true); +}); + test('service normalization prefers exact existing objects and classifies predefined, rare and dynamic ports', () => { const result = analyzer.buildPolicyEngineV2([ flow('192.0.2.10', '198.51.100.10', 6, 443, 'HTTPS'), diff --git a/docs/POLICY_ENGINE_V2.md b/docs/POLICY_ENGINE_V2.md index 73df71e..7898a45 100644 --- a/docs/POLICY_ENGINE_V2.md +++ b/docs/POLICY_ENGINE_V2.md @@ -121,7 +121,7 @@ Les ports TCP élevés restent des tuples exacts en recommandé/strict. Une plag ## Intégration et traçabilité -Le moteur V2 est un module backend pur. L'API renvoie les policies, métriques, inventaire d'objets et explications. Le pipeline FortiGate existant continue d'analyser les objets, routes, zones et services puis exécute le preflight avant génération CLI. Après toute sélection utilisateur, le preflight recalcule coverage, missing et unexpected sur les FlowAtoms déployables complets ; une sélection incomplète ne peut plus être certifiée `exact`. +Le moteur V2 est un module backend pur. L'API renvoie les policies, métriques, inventaire d'objets et explications. Le pipeline FortiGate existant continue d'analyser les objets, routes, zones et services puis exécute le preflight avant génération CLI. Après toute sélection utilisateur, le preflight recalcule coverage, missing et unexpected sur les FlowAtoms déployables complets ; une sélection incomplète ne peut plus être certifiée `exact`. Lors de la ré-analyse FortiGate, plusieurs tuples techniques portant le même libellé peuvent être représentés par un seul objet service exact ; le contrôle de dérive compare donc les ensembles de libellés uniques, tandis que le preflight technique reste autoritaire sur protocoles et ports. Chaque policy conserve : identifiants d'atomes, membres exacts, signatures, motif de regroupement, services communs/résiduels, métriques et niveau de confiance. L'UI peut ainsi répondre « pourquoi ? » sans reconstruire artificiellement la preuve.