Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/web/lib/forticonfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2554,6 +2564,7 @@ module.exports = {
validateAgainstExisting,
preflightValidation,
policyEngineSelectionMetrics,
sameServiceLabelScope,
findInterfaceForSubnet,
detectWanCandidates,
findAddress,
Expand Down
4 changes: 2 additions & 2 deletions app/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/web/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
12 changes: 7 additions & 5 deletions app/web/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'})`,
Expand Down
47 changes: 46 additions & 1 deletion app/web/test/policy-engine-v2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion docs/POLICY_ENGINE_V2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down