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
15 changes: 11 additions & 4 deletions app/web/lib/analyzer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const { portName } = require('./ports');
const { buildPolicyEngineV2 } = require('./policy-engine-v2');

// ─── RFC1918 ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -596,11 +597,13 @@ function consolidatePolicies(rawPolicies) {
for (const p of rawPolicies) {
const fp = serviceFingerprint(p);
const scopeKey = `${p.scope?.devid || p.scope?.devname || ''}::${p.scope?.vdom || ''}`;
const key = `${scopeKey}||${p.dstTarget}||${fp}`;
const srcIntf = p.flowSrcintf || p.srcintf || '';
const dstIntf = p.flowDstintf || p.dstintf || '';
const key = `${scopeKey}||${srcIntf}||${dstIntf}||${p.dstTarget}||${fp}`;
if (!phase1.has(key)) {
phase1.set(key, {
srcs: new Set(), dst: p.dstTarget, dstType: p.dstType,
scope: p.scope || {}, fp, services: p.services, ports: p.ports, protos: p.protos,
scope: p.scope || {}, srcIntf, dstIntf, fp, services: p.services, ports: p.ports, protos: p.protos,
serviceTuples: p.serviceTuples || [],
serviceDesc: p.serviceDesc, sessions: 0, sentBytes: 0, rcvdBytes: 0, noRcvdFlows: 0, noRcvdSrcHosts: [],
});
Expand All @@ -619,11 +622,11 @@ function consolidatePolicies(rawPolicies) {
for (const e of phase1.values()) {
const srcsKey = [...e.srcs].sort().join('|');
const scopeKey = `${e.scope?.devid || e.scope?.devname || ''}::${e.scope?.vdom || ''}`;
const key = `${scopeKey}||${srcsKey}||${e.fp}`;
const key = `${scopeKey}||${e.srcIntf}||${e.dstIntf}||${srcsKey}||${e.fp}`;
if (!phase2.has(key)) {
phase2.set(key, {
srcSubnets: [...e.srcs].sort(), dstTargets: [], dstTypes: {},
scope: e.scope, fp: e.fp, services: e.services, ports: e.ports, protos: e.protos,
scope: e.scope, srcIntf: e.srcIntf, dstIntf: e.dstIntf, fp: e.fp, services: e.services, ports: e.ports, protos: e.protos,
serviceTuples: e.serviceTuples,
serviceDesc: e.serviceDesc, sessions: 0, sentBytes: 0, rcvdBytes: 0, noRcvdFlows: 0, noRcvdSrcHosts: [],
});
Expand Down Expand Up @@ -653,6 +656,9 @@ function consolidatePolicies(rawPolicies) {
dstTypes: g.dstTypes,
dstTypeSummary,
scope: g.scope,
flowSrcintf: g.srcIntf,
srcintf: g.srcIntf,
dstintf: g.dstIntf,
services: g.services,
ports: g.ports,
protos: g.protos,
Expand Down Expand Up @@ -682,4 +688,5 @@ module.exports = {
isExpectedOneWayFlow,
buildAnalysis,
consolidatePolicies,
buildPolicyEngineV2,
};
151 changes: 139 additions & 12 deletions app/web/lib/forticonfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -1009,7 +1009,7 @@ function findServiceByName(label, observedPorts, protoName, customServices) {
if (!observedPorts?.length) return true;
const portSet = isUdp ? cs._udpSet : cs._tcpSet;
const ports = isUdp ? (cs.udpPorts || []) : (cs.tcpPorts || []);
return observedPorts.every(port => portSet ? portSet.has(Number(port)) : ports.includes(Number(port)));
return observedPorts.every(port => portSet instanceof Set ? portSet.has(Number(port)) : ports.includes(Number(port)));
};

// 1. Correspondance exacte, mais jamais au prix d'une incompatibilité port/protocole.
Expand Down Expand Up @@ -1075,14 +1075,17 @@ function findService(port, protoName, customServices, opts) {
for (const [name, svc] of Object.entries(customServices)) {
const ports = isUdp ? svc.udpPorts : svc.tcpPorts;
const portSet = isUdp ? svc._udpSet : svc._tcpSet; // P1: lookup O(1)
if (ports.length <= maxPortCount && (portSet ? portSet.has(p) : ports.includes(p))) {
if (ports.length <= maxPortCount && (portSet instanceof Set ? portSet.has(p) : ports.includes(p))) {
matches.push({ name, source: 'custom', portCount: ports.length });
}
}

if (matches.length === 0) return { found: false };
// Prefer most specific match (fewest ports)
matches.sort((a, b) => a.portCount - b.portCount);
// Prefer the most specific match, then an exact object from the loaded
// configuration over a predefined alias with the same cardinality.
matches.sort((a, b) => a.portCount - b.portCount
|| (a.source === 'custom' ? 0 : 1) - (b.source === 'custom' ? 0 : 1)
|| a.name.localeCompare(b.name));
return { found: true, name: matches[0].name, source: matches[0].source, allMatches: matches };
}

Expand Down Expand Up @@ -1202,20 +1205,33 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) {
}));

const customCandidate = customServices[svc];
const customMatch = customCandidate && (!relevantTuples.length || relevantTuples.every(t => {
// Pour ICMP sans type/code brut, le nom de service FortiGate observé
// peut être réutilisé uniquement si la configuration sélectionnée
// contient exactement le même objet ICMP dans le même scope/VDOM.
const icmpCustomMatch = customCandidate
&& customCandidate.proto === 'ICMP'
&& relevantTuples.length > 0
&& relevantTuples.every(t => {
if (!/^(1|icmp)$/i.test(String(t.proto))) return false;
if (Number.isInteger(t.icmpType) && customCandidate.icmptype !== t.icmpType) return false;
if (Number.isInteger(t.icmpCode) && customCandidate.icmpcode !== t.icmpCode) return false;
return true;
});
const customMatch = icmpCustomMatch ? customCandidate : customCandidate && (!relevantTuples.length || relevantTuples.every(t => {
const port = Number(t.port);
const isUdpTuple = /^(17|udp)$/i.test(String(t.proto));
const set = isUdpTuple ? customCandidate._udpSet : customCandidate._tcpSet;
const ports = isUdpTuple ? customCandidate.udpPorts : customCandidate.tcpPorts;
return set ? set.has(port) : (ports || []).includes(port);
return set instanceof Set ? set.has(port) : (ports || []).includes(port);
})) ? customCandidate : null;
// Try ICMP/CODE/TYPE label matching if not directly found
const icmpMatch = (!knownPredef && !customMatch) ? findIcmpService(svc, customServices) : null;

// Fuzzy name match (e.g. "NETBIOS" → "NetBIOS_NS" / "NetBIOS_DS")
// Skip port-notation labels (e.g. "TCP/853") — they use the port-based fallback path
const isPortNotationLabel = /^(TCP|UDP)\/\d+$/i.test(svc);
const fuzzyMatch = (!knownPredef && !customMatch && !icmpMatch && !isPortNotationLabel)
const fuzzyMatch = (!knownPredef && !customMatch && !icmpMatch && !isPortNotationLabel
&& ['TCP', 'UDP'].includes(observedProtoLabel))
? (observedProtoLabel ? findServiceByName(svc, observedPorts, observedProtoLabel, customServices) : null)
: null;

Expand Down Expand Up @@ -1283,7 +1299,7 @@ function analyzePolicies(policies, fortiConfig, preferredWanIntf) {
// Réutiliser un objet uniquement si son périmètre protocole/port est
// exactement égal aux tuples observés. Un simple sous-ensemble ouvrirait
// silencieusement des ports supplémentaires.
const exactExisting = !!icmpMatch || (
const exactExisting = !!icmpMatch || !!icmpCustomMatch || (
candidateFound
&& relevantTuples.length > 0
&& observedTransport.size === relevantTuples.length
Expand Down Expand Up @@ -2060,6 +2076,27 @@ function segmentationFlowServiceKey(flow) {
return Number.isInteger(port) && proto ? `${port}/${proto}` : '';
}

function segmentationFlowTechnicalKey(flow) {
const port = Number(flow?.dstport ?? flow?.port);
const rawProto = String(flow?.proto || '').trim().toUpperCase();
const proto = /^(6|tcp)$/i.test(rawProto) ? 'TCP'
: /^(17|udp)$/i.test(rawProto) ? 'UDP'
: /^(1|icmp)$/i.test(rawProto) ? 'ICMP'
: rawProto ? `PROTO-${rawProto}` : 'PROTO-UNKNOWN';
if (proto === 'ICMP') {
if (Number.isInteger(flow?.icmpType) && Number.isInteger(flow?.icmpCode)) {
return `ICMP:${flow.icmpType}:${flow.icmpCode}`;
}
const label = String(flow?.service || '').trim().toUpperCase();
const icmp = label.match(/^ICMP\/(\d+)\/(\d+)$/);
if (icmp) return `ICMP:${Number(icmp[1])}:${Number(icmp[2])}`;
if (label && !['ICMP', 'ALL_ICMP', 'ALL_ICMP6'].includes(label)) return `ICMP:NAME:${label}`;
}
return ['TCP', 'UDP'].includes(proto) && Number.isInteger(port) && port >= 1
? `${proto}:${port}`
: proto;
}

function segmentationServiceMatchesTuple(service, tuple) {
const wanted = segmentationServiceKey(service);
const named = String(tuple?.service || '').toUpperCase();
Expand Down Expand Up @@ -2108,7 +2145,39 @@ function segmentationEvidenceHosts(policy, side) {
return match ? [match[1]] : [];
}

function preflightValidation(selectedPolicies, config, observedFlows = null) {
function policyEngineSelectionMetrics(requiredAtoms, selectedPolicies) {
const key = (partitionKey, source, destination, serviceKey) =>
[partitionKey, source, destination, serviceKey].join('||');
const required = new Set((requiredAtoms || [])
.filter(atom => atom?.service?.deploymentBlocked !== true)
.map(atom => key(atom.partitionKey, atom.source, atom.destination, atom.service.key)));
const allowed = new Set();
for (const policy of (selectedPolicies || [])) {
for (const source of (policy.allowedSources || policy.sources || [])) {
for (const destination of (policy.allowedDestinations || policy.destinations || [])) {
for (const serviceKey of (policy.serviceKeys || [])) {
allowed.add(key(policy.partitionKey, source, destination, serviceKey));
}
}
}
}
let coveredRequiredTuples = 0;
for (const tuple of required) if (allowed.has(tuple)) coveredRequiredTuples++;
let unexpectedAllowedTuples = 0;
for (const tuple of allowed) if (!required.has(tuple)) unexpectedAllowedTuples++;
const missingRequiredTuples = required.size - coveredRequiredTuples;
return {
observedRequiredTuples: required.size,
coveredRequiredTuples,
missingRequiredTuples,
allowedTuples: allowed.size,
unexpectedAllowedTuples,
coverageRatio: required.size ? coveredRequiredTuples / required.size : 1,
expansionRatio: required.size ? unexpectedAllowedTuples / required.size : 0,
};
}

function preflightValidation(selectedPolicies, config, observedFlows = null, requiredAtoms = null) {
const issues = []; // { level: 'warn'|'error', msg }
const addresses = config.addresses || {};
const addressGroups = config.addressGroups || {};
Expand Down Expand Up @@ -2273,9 +2342,10 @@ function preflightValidation(selectedPolicies, config, observedFlows = null) {
}

const effectiveDestinationMode = isWan ? 'host' : plan.destination;
const exactScope = plan.source === 'host'
const v2SafeExact = p._policyEngineV2?.safeExact === true;
const exactScope = v2SafeExact || (plan.source === 'host'
&& effectiveDestinationMode === 'host'
&& plan.services === 'separate';
&& plan.services === 'separate');
if (exactScope) exactScopePolicies++;
else generalizedPolicies++;
const srcEvidenceHosts = segmentationEvidenceHosts(p, 'src');
Expand All @@ -2302,6 +2372,39 @@ function preflightValidation(selectedPolicies, config, observedFlows = null) {

if (!evidenceFlows.length) {
issues.push({ level: 'error', msg: `${label}: aucun flux accepté ne prouve cette règle` });
} else if (v2SafeExact) {
for (const src of srcEvidenceHosts) {
for (const dst of dstEvidenceHosts) {
for (const serviceKey of (p.serviceKeys || [])) {
if (!evidenceFlows.some(flow =>
flow.srcip === src && flow.dstip === dst && segmentationFlowTechnicalKey(flow) === serviceKey
)) {
issues.push({
level: 'error',
msg: `${label}: couple ${src} → ${dst} / ${serviceKey} non observé`,
});
}
}
}
}
} else if (plan.source === 'host' && effectiveDestinationMode === 'host' && (p.serviceTuples || []).length) {
const technicalKeys = [...new Set((p.serviceTuples || [])
.map(segmentationFlowTechnicalKey)
.filter(Boolean))];
for (const src of srcEvidenceHosts) {
for (const dst of dstEvidenceHosts) {
for (const serviceKey of technicalKeys) {
if (!evidenceFlows.some(flow =>
flow.srcip === src && flow.dstip === dst && segmentationFlowTechnicalKey(flow) === serviceKey
)) {
issues.push({
level: 'error',
msg: `${label}: couple ${src} → ${dst} / ${serviceKey} non observé`,
});
}
}
}
}
} else if (plan.source === 'host' && effectiveDestinationMode === 'host') {
for (const src of srcEvidenceHosts) {
for (const dst of dstEvidenceHosts) {
Expand Down Expand Up @@ -2381,6 +2484,29 @@ function preflightValidation(selectedPolicies, config, observedFlows = null) {
});
}

let selectionMetrics = null;
const v2Policies = selectedPolicies.filter(policy => policy?._policyEngineV2);
if (v2Policies.length > 0 && Array.isArray(requiredAtoms)) {
selectionMetrics = policyEngineSelectionMetrics(requiredAtoms, v2Policies);
if (selectionMetrics.missingRequiredTuples > 0) {
issues.push({
level: 'error',
code: 'POLICY_ENGINE_MISSING_REQUIRED_TUPLES',
msg: `${selectionMetrics.missingRequiredTuples} tuple(s) déployable(s) requis ne sont plus couverts par la sélection finale`,
});
}
const safeProfile = v2Policies.every(policy =>
['recommended', 'strict', 'expert'].includes(policy._policyEngineV2.profile)
);
if (safeProfile && selectionMetrics.unexpectedAllowedTuples > 0) {
issues.push({
level: 'error',
code: 'POLICY_ENGINE_UNEXPECTED_ALLOWED_TUPLES',
msg: `${selectionMetrics.unexpectedAllowedTuples} tuple(s) inattendu(s) sont autorisés par la sélection finale`,
});
}
}

// Summary counts
const errors = issues.filter(i => i.level === 'error').length;
const warnings = issues.filter(i => i.level === 'warn').length;
Expand All @@ -2394,7 +2520,7 @@ function preflightValidation(selectedPolicies, config, observedFlows = null) {
unclassifiedPolicies,
routingContextUnproven,
};
return { issues, errors, warnings, ok: errors === 0, certification };
return { issues, errors, warnings, ok: errors === 0, certification, selectionMetrics };
}

function formatExistingPolicies(policies) {
Expand Down Expand Up @@ -2427,6 +2553,7 @@ module.exports = {
generateConfig,
validateAgainstExisting,
preflightValidation,
policyEngineSelectionMetrics,
findInterfaceForSubnet,
detectWanCandidates,
findAddress,
Expand Down
20 changes: 19 additions & 1 deletion app/web/lib/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ const HEADER_MAP = {
// Service
service: 'service', 'service name': 'service', servicename: 'service', app: 'service',

// ICMP evidence when exported explicitly by FortiOS/FortiAnalyzer
icmptype: 'icmptype', icmp_type: 'icmptype', 'icmp type': 'icmptype',
icmpcode: 'icmpcode', icmp_code: 'icmpcode', 'icmp code': 'icmpcode',

// Interfaces
srcintf: 'srcintf', src_intf: 'srcintf', srcinterface: 'srcintf',
'source interface': 'srcintf', 'src interface': 'srcintf', 'interface source': 'srcintf', ingressintf: 'srcintf',
Expand Down Expand Up @@ -353,6 +357,14 @@ function extractFlow(fields) {
const policytype = String(fields.policytype || '').toLowerCase().trim();
const trandisp = String(fields.trandisp || '').toLowerCase().trim();
const dstport = String(fields.dstport || '').trim();
const parseIcmpByte = value => {
const text = String(value ?? '').trim();
if (!/^\d+$/.test(text)) return null;
const number = Number(text);
return number >= 0 && number <= 255 ? number : null;
};
const icmpType = parseIcmpByte(fields.icmptype);
const icmpCode = parseIcmpByte(fields.icmpcode);
const evidenceIssues = [];

if (decision === 'allow') {
Expand Down Expand Up @@ -392,6 +404,8 @@ function extractFlow(fields) {
action,
decision,
service,
icmpType,
icmpCode,
srcintf: fields.srcintf || '',
dstintf: fields.dstintf || '',
policyid: fields.policyid || '',
Expand Down Expand Up @@ -470,12 +484,16 @@ function aggregateFlow(flowMap, flow, dedupeState = null) {
// légitimement utiliser les mêmes réseaux RFC1918 sans représenter le même contexte.
// Le libellé de service n'est pas une identité réseau : FortiOS peut ne le
// renseigner qu'au log terminal. Le tuple protocole/port reste la preuve.
const key = `${flow.devid || flow.devname}|${flow.vdom}|${flow.srcip}|${flow.dstip}|${flow.dstport}|${flow.proto}|${flow.decision}|${flow.srcintf}|${flow.dstintf}|${flow.policyid}|${flow.subtype}|${flow.policytype}|${flow.trandisp}|${flow.evidenceIssues.join(',')}`;
const portlessServiceIdentity = !/^(6|17|tcp|udp)$/i.test(String(flow.proto || ''))
? String(flow.service || '').toUpperCase()
: '';
const key = `${flow.devid || flow.devname}|${flow.vdom}|${flow.srcip}|${flow.dstip}|${flow.dstport}|${flow.proto}|${flow.icmpType ?? ''}|${flow.icmpCode ?? ''}|${portlessServiceIdentity}|${flow.decision}|${flow.srcintf}|${flow.dstintf}|${flow.policyid}|${flow.subtype}|${flow.policytype}|${flow.trandisp}|${flow.evidenceIssues.join(',')}`;
if (!flowMap.has(key)) {
flowMap.set(key, {
srcip: flow.srcip, dstip: flow.dstip,
srcport: flow.srcport, dstport: flow.dstport,
proto: flow.proto, action: flow.action, decision: flow.decision, service: flow.service,
icmpType: flow.icmpType, icmpCode: flow.icmpCode,
srcintf: flow.srcintf, dstintf: flow.dstintf, policyid: flow.policyid, policyname: flow.policyname,
devname: flow.devname, devid: flow.devid, vdom: flow.vdom,
logid: flow.logid, poluuid: flow.poluuid,
Expand Down
Loading