@j3r3mcdev/scoring 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +34 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/jest.config.js +11 -0
- package/package.json +29 -0
- package/src/core/__tests__/scoring-context.test.ts +47 -0
- package/src/core/__tests__/scoring-engine.test.ts +110 -0
- package/src/core/__tests__/scoring-result.test.ts +14 -0
- package/src/core/index.ts +8 -0
- package/src/core/scoring-context.ts +80 -0
- package/src/core/scoring-engine.ts +126 -0
- package/src/core/scoring-result.ts +15 -0
- package/src/core/scoring-types.ts +125 -0
- package/src/correlation/__tests__/chain-detector.test.ts +76 -0
- package/src/correlation/__tests__/correlator.test.ts +49 -0
- package/src/correlation/__tests__/event-grouper.test.ts +62 -0
- package/src/correlation/chain-detector.ts +99 -0
- package/src/correlation/correlator.ts +39 -0
- package/src/correlation/event-grouper.ts +47 -0
- package/src/correlation/index.ts +3 -0
- package/src/index.ts +21 -0
- package/src/normalizers/__tests__/dns.normalizer.test.ts +40 -0
- package/src/normalizers/__tests__/http.normalizer.test.ts +55 -0
- package/src/normalizers/__tests__/normalizer-registry.test.ts +89 -0
- package/src/normalizers/__tests__/waf.normalizer.test.ts +45 -0
- package/src/normalizers/dns.normalizer.ts +28 -0
- package/src/normalizers/http.normalizer.ts +53 -0
- package/src/normalizers/index.ts +34 -0
- package/src/normalizers/waf.normalizer.ts +39 -0
- package/src/reporters/__tests__/html-reporter.test.ts +51 -0
- package/src/reporters/__tests__/json-reporter.test.ts +50 -0
- package/src/reporters/__tests__/markdown-reporter.test.ts +75 -0
- package/src/reporters/__tests__/reporter-factory.test.ts +25 -0
- package/src/reporters/__tests__/reporters-integration.test.ts +46 -0
- package/src/reporters/base/BaseReporter.ts +56 -0
- package/src/reporters/base/ReporterTypes.ts +21 -0
- package/src/reporters/html/HTMLReporter.ts +240 -0
- package/src/reporters/index.ts +0 -0
- package/src/reporters/json/JSONReporter.ts +98 -0
- package/src/reporters/markdown/MarkdownReporter.ts +157 -0
- package/src/reporters/reporter-factory.ts +29 -0
- package/src/rules/__tests__/dns.rule.test.ts +42 -0
- package/src/rules/__tests__/http.rule.test.ts +46 -0
- package/src/rules/__tests__/lfi.rule.test.ts +42 -0
- package/src/rules/__tests__/path-traversal.rule.test.ts +42 -0
- package/src/rules/__tests__/rce.rule.test.ts +42 -0
- package/src/rules/__tests__/rule-registry.test.ts +40 -0
- package/src/rules/__tests__/ssrf.rule.test.ts +42 -0
- package/src/rules/__tests__/waf.rule.test.ts +40 -0
- package/src/rules/base-rule.ts +43 -0
- package/src/rules/dns.rules.ts +50 -0
- package/src/rules/http.rules.ts +72 -0
- package/src/rules/index.ts +35 -0
- package/src/rules/lfi.rule.ts +76 -0
- package/src/rules/path-transversal.rule.ts +65 -0
- package/src/rules/rce.rules.ts +73 -0
- package/src/rules/rule-registry.ts +39 -0
- package/src/rules/sqli.rules.ts +69 -0
- package/src/rules/ssrf.rules.ts +76 -0
- package/src/rules/waf.rules.ts +62 -0
- package/src/rules/xss.rules.ts +66 -0
- package/src/utils/chain-utils.ts +73 -0
- package/src/utils/date-utils.ts +80 -0
- package/src/utils/finding-utils.ts +97 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/report-utils.ts +118 -0
- package/src/utils/score-utils.ts +103 -0
- package/src/utils/string-utils.ts +54 -0
- package/src.txt +0 -0
- package/tests/scoring-engine.test.ts +7 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ScoringResult } from "./scoring-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Helper pour créer un résultat vide (utile pour les tests).
|
|
5
|
+
*/
|
|
6
|
+
export function createEmptyResult(): ScoringResult {
|
|
7
|
+
return {
|
|
8
|
+
score: 0,
|
|
9
|
+
severity: "low",
|
|
10
|
+
findings: [],
|
|
11
|
+
chains: [],
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
metadata: {},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────
|
|
3
|
+
* SCORING TYPES — Version avancée et stable
|
|
4
|
+
* Source de vérité unique pour tout le moteur.
|
|
5
|
+
* ─────────────────────────────────────────────────────────────
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
//
|
|
9
|
+
// 1. Vulnérabilités supportées
|
|
10
|
+
//
|
|
11
|
+
export type Vulnerability =
|
|
12
|
+
| "ssrf"
|
|
13
|
+
| "xss"
|
|
14
|
+
| "sqli"
|
|
15
|
+
| "dns"
|
|
16
|
+
| "http"
|
|
17
|
+
| "waf"
|
|
18
|
+
| "rce"
|
|
19
|
+
| "lfi"
|
|
20
|
+
| "path_traversal";
|
|
21
|
+
|
|
22
|
+
//
|
|
23
|
+
// 2. Sévérité standardisée
|
|
24
|
+
//
|
|
25
|
+
export type Severity = "low" | "medium" | "high" | "critical";
|
|
26
|
+
|
|
27
|
+
//
|
|
28
|
+
// 3. Source d’un événement normalisé
|
|
29
|
+
//
|
|
30
|
+
export type EventSource = "http" | "dns" | "waf" | "scan" | "oast";
|
|
31
|
+
|
|
32
|
+
//
|
|
33
|
+
// 4. Événement normalisé (format unique pour tout le moteur)
|
|
34
|
+
//
|
|
35
|
+
export interface NormalizedEvent {
|
|
36
|
+
id: string; // UUID
|
|
37
|
+
source: EventSource;
|
|
38
|
+
protocol?: string; // http, dns, smtp, websocket, etc.
|
|
39
|
+
timestamp: number;
|
|
40
|
+
|
|
41
|
+
// Payload brut ou transformé
|
|
42
|
+
payload?: string;
|
|
43
|
+
|
|
44
|
+
// Données additionnelles (headers, body, metadata…)
|
|
45
|
+
metadata: Record<string, any>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
//
|
|
49
|
+
// 5. Résultat d’une règle individuelle
|
|
50
|
+
//
|
|
51
|
+
export interface RuleFinding {
|
|
52
|
+
ruleId: string;
|
|
53
|
+
vulnerability: Vulnerability;
|
|
54
|
+
score: number; // score brut (0 → 1)
|
|
55
|
+
severity: Severity;
|
|
56
|
+
details?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//
|
|
60
|
+
// 6. Chaîne de corrélation (ex : DNS → HTTP = SSRF)
|
|
61
|
+
//
|
|
62
|
+
export interface CorrelationChain {
|
|
63
|
+
id: string;
|
|
64
|
+
type: Vulnerability;
|
|
65
|
+
events: NormalizedEvent[];
|
|
66
|
+
confidence: number; // 0 → 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
//
|
|
70
|
+
// 7. Résultat final d’une vulnérabilité consolidée
|
|
71
|
+
//
|
|
72
|
+
export interface Finding {
|
|
73
|
+
id: string;
|
|
74
|
+
vulnerability: Vulnerability;
|
|
75
|
+
severity: Severity;
|
|
76
|
+
score: number; // score final pondéré
|
|
77
|
+
evidence: NormalizedEvent[];
|
|
78
|
+
chains?: CorrelationChain[];
|
|
79
|
+
details?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//
|
|
83
|
+
// 8. Interface de base pour toutes les règles
|
|
84
|
+
//
|
|
85
|
+
export interface Rule {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
description?: string;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Vérifie si la règle doit s’appliquer au contexte.
|
|
92
|
+
*/
|
|
93
|
+
applies(context: ScoringContext): boolean;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Retourne un ou plusieurs findings bruts.
|
|
97
|
+
*/
|
|
98
|
+
execute(context: ScoringContext): RuleFinding[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
//
|
|
102
|
+
// 9. Contexte minimal pour les règles et le moteur
|
|
103
|
+
//
|
|
104
|
+
export interface ScoringContext {
|
|
105
|
+
events: NormalizedEvent[];
|
|
106
|
+
chains: CorrelationChain[];
|
|
107
|
+
metadata?: Record<string, any>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//
|
|
111
|
+
// 10. Résultat final du moteur de scoring
|
|
112
|
+
//
|
|
113
|
+
export interface ScoringResult {
|
|
114
|
+
score: number; // score global (0–100)
|
|
115
|
+
severity: Severity;
|
|
116
|
+
findings: Finding[];
|
|
117
|
+
chains: CorrelationChain[];
|
|
118
|
+
timestamp: number;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Métadonnées additionnelles (optionnelles).
|
|
122
|
+
* Ex : cible, IP, user-agent, contexte d'analyse…
|
|
123
|
+
*/
|
|
124
|
+
metadata?: Record<string, any>;
|
|
125
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ChainDetector } from "../chain-detector";
|
|
2
|
+
import { NormalizedEvent } from "../../core/scoring-types";
|
|
3
|
+
|
|
4
|
+
const evt = (partial: Partial<NormalizedEvent>): NormalizedEvent => ({
|
|
5
|
+
id: partial.id ?? "id",
|
|
6
|
+
source: partial.source ?? "http",
|
|
7
|
+
timestamp: partial.timestamp ?? Date.now(),
|
|
8
|
+
payload: partial.payload ?? "",
|
|
9
|
+
metadata: partial.metadata ?? {},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("ChainDetector (PRO)", () => {
|
|
13
|
+
it("détecte une chaîne SSRF (HTTP + DNS)", () => {
|
|
14
|
+
const events = [
|
|
15
|
+
evt({ source: "http", timestamp: 1 }),
|
|
16
|
+
evt({ source: "dns", timestamp: 2 }),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const chains = ChainDetector.detect(events);
|
|
20
|
+
|
|
21
|
+
expect(chains.length).toBe(1);
|
|
22
|
+
expect(chains[0].chain).toBe("ssrf_chain");
|
|
23
|
+
expect(chains[0].events.length).toBe(2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("détecte une chaîne XSS (HTTP + WAF)", () => {
|
|
27
|
+
const events = [
|
|
28
|
+
evt({ source: "http", timestamp: 1 }),
|
|
29
|
+
evt({
|
|
30
|
+
source: "waf",
|
|
31
|
+
timestamp: 2,
|
|
32
|
+
metadata: { triggeredRules: ["xss"] },
|
|
33
|
+
}),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const chains = ChainDetector.detect(events);
|
|
37
|
+
|
|
38
|
+
expect(chains.some((c) => c.chain === "xss_chain")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("détecte une chaîne SQLi", () => {
|
|
42
|
+
const events = [
|
|
43
|
+
evt({ source: "http", timestamp: 1 }),
|
|
44
|
+
evt({
|
|
45
|
+
source: "waf",
|
|
46
|
+
timestamp: 2,
|
|
47
|
+
metadata: { triggeredRules: ["sqli"] },
|
|
48
|
+
}),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const chains = ChainDetector.detect(events);
|
|
52
|
+
|
|
53
|
+
expect(chains.some((c) => c.chain === "sqli_chain")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("détecte une chaîne WAF confirmée (score >= 0.9)", () => {
|
|
57
|
+
const events = [
|
|
58
|
+
evt({
|
|
59
|
+
source: "waf",
|
|
60
|
+
metadata: { wafScore: 0.95 },
|
|
61
|
+
}),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const chains = ChainDetector.detect(events);
|
|
65
|
+
|
|
66
|
+
expect(chains.some((c) => c.chain === "waf_confirmed_chain")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ne détecte rien si aucun pattern n'est présent", () => {
|
|
70
|
+
const events = [evt({ source: "http" }), evt({ source: "http" })];
|
|
71
|
+
|
|
72
|
+
const chains = ChainDetector.detect(events);
|
|
73
|
+
|
|
74
|
+
expect(chains.length).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Correlator } from "../correlator";
|
|
2
|
+
import { NormalizedEvent } from "../../core/scoring-types";
|
|
3
|
+
|
|
4
|
+
const evt = (partial: Partial<NormalizedEvent>): NormalizedEvent => ({
|
|
5
|
+
id: partial.id ?? "id",
|
|
6
|
+
source: partial.source ?? "http",
|
|
7
|
+
timestamp: partial.timestamp ?? Date.now(),
|
|
8
|
+
payload: partial.payload ?? "",
|
|
9
|
+
metadata: partial.metadata ?? {},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("Correlator (PRO)", () => {
|
|
13
|
+
it("retourne des groupes et des chaînes", () => {
|
|
14
|
+
const events = [
|
|
15
|
+
evt({ source: "http", timestamp: 1, metadata: { ip: "1.1.1.1" } }),
|
|
16
|
+
evt({ source: "dns", timestamp: 2, metadata: { ip: "1.1.1.1" } }),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const result = Correlator.correlate(events);
|
|
20
|
+
|
|
21
|
+
expect(result.groups.length).toBe(1);
|
|
22
|
+
expect(result.chains.length).toBe(1);
|
|
23
|
+
expect(result.chains[0].chain).toBe("ssrf_chain");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("gère plusieurs groupes distincts", () => {
|
|
27
|
+
const events = [
|
|
28
|
+
evt({ metadata: { ip: "A" } }),
|
|
29
|
+
evt({ metadata: { ip: "B" } }),
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const result = Correlator.correlate(events);
|
|
33
|
+
|
|
34
|
+
expect(result.groups.length).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("lance une erreur si l'entrée n'est pas un tableau", () => {
|
|
38
|
+
// @ts-expect-error test volontaire
|
|
39
|
+
expect(() => Correlator.correlate(null)).toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("ne détecte aucune chaîne si aucun pattern n'est présent", () => {
|
|
43
|
+
const events = [evt({ source: "http" }), evt({ source: "http" })];
|
|
44
|
+
|
|
45
|
+
const result = Correlator.correlate(events);
|
|
46
|
+
|
|
47
|
+
expect(result.chains.length).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { EventGrouper } from "../event-grouper";
|
|
2
|
+
import { NormalizedEvent } from "../../core/scoring-types";
|
|
3
|
+
|
|
4
|
+
const evt = (partial: Partial<NormalizedEvent>): NormalizedEvent => ({
|
|
5
|
+
id: partial.id ?? "id",
|
|
6
|
+
source: partial.source ?? "http",
|
|
7
|
+
timestamp: partial.timestamp ?? Date.now(),
|
|
8
|
+
payload: partial.payload ?? "",
|
|
9
|
+
metadata: partial.metadata ?? {},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("EventGrouper (PRO)", () => {
|
|
13
|
+
it("groupe les événements par IP (par défaut)", () => {
|
|
14
|
+
const events = [
|
|
15
|
+
evt({ metadata: { ip: "1.1.1.1" } }),
|
|
16
|
+
evt({ metadata: { ip: "1.1.1.1" } }),
|
|
17
|
+
evt({ metadata: { ip: "2.2.2.2" } }),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const groups = EventGrouper.groupBy(events);
|
|
21
|
+
|
|
22
|
+
expect(groups.length).toBe(2);
|
|
23
|
+
expect(groups.find((g) => g.key === "1.1.1.1")!.events.length).toBe(2);
|
|
24
|
+
expect(groups.find((g) => g.key === "2.2.2.2")!.events.length).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("groupe selon un sélecteur personnalisé", () => {
|
|
28
|
+
const events = [
|
|
29
|
+
evt({ metadata: { sessionId: "A" } }),
|
|
30
|
+
evt({ metadata: { sessionId: "B" } }),
|
|
31
|
+
evt({ metadata: { sessionId: "A" } }),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const groups = EventGrouper.groupBy(events, (e) => e.metadata.sessionId);
|
|
35
|
+
|
|
36
|
+
expect(groups.length).toBe(2);
|
|
37
|
+
expect(groups.find((g) => g.key === "A")!.events.length).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("gère les événements sans clé (fallback 'unknown')", () => {
|
|
41
|
+
const events = [evt({ metadata: {} }), evt({ metadata: {} })];
|
|
42
|
+
|
|
43
|
+
const groups = EventGrouper.groupBy(events);
|
|
44
|
+
|
|
45
|
+
expect(groups.length).toBe(1);
|
|
46
|
+
expect(groups[0].key).toBe("unknown");
|
|
47
|
+
expect(groups[0].events.length).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("tri les événements dans chaque groupe", () => {
|
|
51
|
+
const events = [
|
|
52
|
+
evt({ timestamp: 3, metadata: { ip: "1.1.1.1" } }),
|
|
53
|
+
evt({ timestamp: 1, metadata: { ip: "1.1.1.1" } }),
|
|
54
|
+
evt({ timestamp: 2, metadata: { ip: "1.1.1.1" } }),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const groups = EventGrouper.groupBy(events);
|
|
58
|
+
const timestamps = groups[0].events.map((e) => e.timestamp);
|
|
59
|
+
|
|
60
|
+
expect(timestamps).toEqual([1, 2, 3]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { NormalizedEvent } from "../core/scoring-types";
|
|
2
|
+
|
|
3
|
+
export type AttackChain =
|
|
4
|
+
| "ssrf_chain"
|
|
5
|
+
| "xss_chain"
|
|
6
|
+
| "sqli_chain"
|
|
7
|
+
| "lfi_chain"
|
|
8
|
+
| "path_traversal_chain"
|
|
9
|
+
| "waf_confirmed_chain";
|
|
10
|
+
|
|
11
|
+
export interface ChainDetectionResult {
|
|
12
|
+
chain: AttackChain;
|
|
13
|
+
events: NormalizedEvent[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ─────────────────────────────────────────────────────────────
|
|
18
|
+
* CHAIN DETECTOR — Détecte les chaînes d’attaque multi‑événements
|
|
19
|
+
* Exemple : HTTP suspect → DNS interne → WAF → SSRF confirmé
|
|
20
|
+
* ─────────────────────────────────────────────────────────────
|
|
21
|
+
*/
|
|
22
|
+
export class ChainDetector {
|
|
23
|
+
/**
|
|
24
|
+
* Analyse une liste d'événements normalisés et détecte
|
|
25
|
+
* les chaînes d’attaque connues.
|
|
26
|
+
*/
|
|
27
|
+
static detect(events: NormalizedEvent[]): ChainDetectionResult[] {
|
|
28
|
+
const results: ChainDetectionResult[] = [];
|
|
29
|
+
|
|
30
|
+
// Tri chronologique (important pour les chaînes)
|
|
31
|
+
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────
|
|
34
|
+
// 1. Chaîne SSRF : HTTP → DNS → WAF
|
|
35
|
+
// ─────────────────────────────────────────────────────────────
|
|
36
|
+
const http = sorted.find((e) => e.source === "http");
|
|
37
|
+
const dns = sorted.find((e) => e.source === "dns");
|
|
38
|
+
const waf = sorted.find((e) => e.source === "waf");
|
|
39
|
+
|
|
40
|
+
if (http && dns) {
|
|
41
|
+
results.push({
|
|
42
|
+
chain: "ssrf_chain",
|
|
43
|
+
events: [http, dns, ...(waf ? [waf] : [])],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─────────────────────────────────────────────────────────────
|
|
48
|
+
// 2. Chaîne XSS : HTTP → WAF
|
|
49
|
+
// ─────────────────────────────────────────────────────────────
|
|
50
|
+
if (http && waf && waf.metadata.triggeredRules?.includes("xss")) {
|
|
51
|
+
results.push({
|
|
52
|
+
chain: "xss_chain",
|
|
53
|
+
events: [http, waf],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────
|
|
58
|
+
// 3. Chaîne SQLi : HTTP → WAF
|
|
59
|
+
// ─────────────────────────────────────────────────────────────
|
|
60
|
+
if (http && waf && waf.metadata.triggeredRules?.includes("sqli")) {
|
|
61
|
+
results.push({
|
|
62
|
+
chain: "sqli_chain",
|
|
63
|
+
events: [http, waf],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────────────────────────────────────────
|
|
68
|
+
// 4. Chaîne LFI : HTTP → WAF
|
|
69
|
+
// ─────────────────────────────────────────────────────────────
|
|
70
|
+
if (http && waf && waf.metadata.triggeredRules?.includes("lfi")) {
|
|
71
|
+
results.push({
|
|
72
|
+
chain: "lfi_chain",
|
|
73
|
+
events: [http, waf],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─────────────────────────────────────────────────────────────
|
|
78
|
+
// 5. Chaîne Path Traversal : HTTP → WAF
|
|
79
|
+
// ─────────────────────────────────────────────────────────────
|
|
80
|
+
if (http && waf && waf.metadata.triggeredRules?.includes("path")) {
|
|
81
|
+
results.push({
|
|
82
|
+
chain: "path_traversal_chain",
|
|
83
|
+
events: [http, waf],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────
|
|
88
|
+
// 6. Chaîne WAF Confirmed : WAF seul
|
|
89
|
+
// ─────────────────────────────────────────────────────────────
|
|
90
|
+
if (waf && waf.metadata.wafScore >= 0.9) {
|
|
91
|
+
results.push({
|
|
92
|
+
chain: "waf_confirmed_chain",
|
|
93
|
+
events: [waf],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NormalizedEvent } from "../core/scoring-types";
|
|
2
|
+
import { EventGrouper, EventGroup } from "./event-grouper";
|
|
3
|
+
import { ChainDetector, ChainDetectionResult } from "./chain-detector";
|
|
4
|
+
|
|
5
|
+
export interface CorrelationResult {
|
|
6
|
+
groups: EventGroup[];
|
|
7
|
+
chains: ChainDetectionResult[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ─────────────────────────────────────────────────────────────
|
|
12
|
+
* CORRELATOR — Orchestrateur de la corrélation
|
|
13
|
+
* 1. Regroupe les événements (EventGrouper)
|
|
14
|
+
* 2. Détecte les chaînes d’attaque (ChainDetector)
|
|
15
|
+
* ─────────────────────────────────────────────────────────────
|
|
16
|
+
*/
|
|
17
|
+
export class Correlator {
|
|
18
|
+
static correlate(events: NormalizedEvent[]): CorrelationResult {
|
|
19
|
+
if (!Array.isArray(events)) {
|
|
20
|
+
throw new Error("Correlator: events must be an array");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 1. Groupement logique (par IP par défaut)
|
|
24
|
+
const groups = EventGrouper.groupBy(events);
|
|
25
|
+
|
|
26
|
+
// 2. Détection des chaînes dans chaque groupe
|
|
27
|
+
const chains: ChainDetectionResult[] = [];
|
|
28
|
+
|
|
29
|
+
for (const group of groups) {
|
|
30
|
+
const detected = ChainDetector.detect(group.events);
|
|
31
|
+
chains.push(...detected);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
groups,
|
|
36
|
+
chains,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { NormalizedEvent } from "../core/scoring-types";
|
|
2
|
+
|
|
3
|
+
export interface EventGroup {
|
|
4
|
+
key: string;
|
|
5
|
+
events: NormalizedEvent[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ─────────────────────────────────────────────────────────────
|
|
10
|
+
* EVENT GROUPER — Regroupe les événements par contexte
|
|
11
|
+
* (IP, sessionId, requestId, etc.)
|
|
12
|
+
* ─────────────────────────────────────────────────────────────
|
|
13
|
+
*/
|
|
14
|
+
export class EventGrouper {
|
|
15
|
+
/**
|
|
16
|
+
* Regroupe les événements selon une clé dynamique.
|
|
17
|
+
* Par défaut : groupement par IP.
|
|
18
|
+
*/
|
|
19
|
+
static groupBy(
|
|
20
|
+
events: NormalizedEvent[],
|
|
21
|
+
selector: (evt: NormalizedEvent) => string = EventGrouper.defaultSelector,
|
|
22
|
+
): EventGroup[] {
|
|
23
|
+
const map = new Map<string, NormalizedEvent[]>();
|
|
24
|
+
|
|
25
|
+
for (const evt of events) {
|
|
26
|
+
const key = selector(evt) || "unknown";
|
|
27
|
+
|
|
28
|
+
if (!map.has(key)) {
|
|
29
|
+
map.set(key, []);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
map.get(key)!.push(evt);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Array.from(map.entries()).map(([key, events]) => ({
|
|
36
|
+
key,
|
|
37
|
+
events: events.sort((a, b) => a.timestamp - b.timestamp),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sélecteur par défaut : groupement par IP.
|
|
43
|
+
*/
|
|
44
|
+
private static defaultSelector(evt: NormalizedEvent): string {
|
|
45
|
+
return evt.metadata?.ip ?? "unknown";
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// CORE (scoring complet)
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
export * from "./core";
|
|
5
|
+
|
|
6
|
+
// ─────────────────────────────────────────────────────────────
|
|
7
|
+
// RULES
|
|
8
|
+
// ─────────────────────────────────────────────────────────────
|
|
9
|
+
export * from "./rules";
|
|
10
|
+
export * from "./rules/rule-registry";
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────
|
|
13
|
+
// NORMALIZERS
|
|
14
|
+
// ─────────────────────────────────────────────────────────────
|
|
15
|
+
export * from "./normalizers";
|
|
16
|
+
export { Normalizers, normalizeEvent } from "./normalizers";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────
|
|
19
|
+
// CORRELATION
|
|
20
|
+
// ─────────────────────────────────────────────────────────────
|
|
21
|
+
export * from "./correlation";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { DnsNormalizer } from "../dns.normalizer";
|
|
2
|
+
|
|
3
|
+
describe("DnsNormalizer (PRO)", () => {
|
|
4
|
+
it("normalise un événement DNS complet", () => {
|
|
5
|
+
const raw = {
|
|
6
|
+
ip: "10.0.0.5",
|
|
7
|
+
query: "internal.service.local",
|
|
8
|
+
type: "A",
|
|
9
|
+
raw: { packet: true },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const evt = DnsNormalizer.normalize(raw);
|
|
13
|
+
|
|
14
|
+
expect(evt.source).toBe("dns");
|
|
15
|
+
expect(evt.payload).toBe("internal.service.local");
|
|
16
|
+
expect(evt.metadata.ip).toBe("10.0.0.5");
|
|
17
|
+
expect(evt.metadata.recordType).toBe("A");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("gère un événement DNS minimal", () => {
|
|
21
|
+
const raw = { query: "example.com" };
|
|
22
|
+
const evt = DnsNormalizer.normalize(raw);
|
|
23
|
+
|
|
24
|
+
expect(evt.metadata.ip).toBe("unknown");
|
|
25
|
+
expect(evt.metadata.recordType).toBe("A");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("gère un événement DNS sans query", () => {
|
|
29
|
+
const evt = DnsNormalizer.normalize({});
|
|
30
|
+
expect(evt.payload).toBe("");
|
|
31
|
+
expect(evt.metadata.query).toBe("");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("gère un événement DNS avec type inconnu", () => {
|
|
35
|
+
const raw = { query: "test", type: "AAAAA" };
|
|
36
|
+
const evt = DnsNormalizer.normalize(raw);
|
|
37
|
+
|
|
38
|
+
expect(evt.metadata.recordType).toBe("AAAAA");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { HttpNormalizer } from "../http.normalizer";
|
|
2
|
+
|
|
3
|
+
describe("HttpNormalizer (PRO)", () => {
|
|
4
|
+
it("normalise correctement une requête HTTP complète", () => {
|
|
5
|
+
const raw = {
|
|
6
|
+
method: "POST",
|
|
7
|
+
url: "/login",
|
|
8
|
+
headers: { "x-forwarded-for": "1.2.3.4", host: "example.com" },
|
|
9
|
+
query: { user: "admin" },
|
|
10
|
+
body: { pass: "123" },
|
|
11
|
+
socket: { remoteAddress: "5.6.7.8" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const evt = HttpNormalizer.normalize(raw);
|
|
15
|
+
|
|
16
|
+
expect(evt.source).toBe("http");
|
|
17
|
+
expect(typeof evt.id).toBe("string");
|
|
18
|
+
expect(typeof evt.timestamp).toBe("number");
|
|
19
|
+
|
|
20
|
+
expect(evt.payload).toContain("POST /login");
|
|
21
|
+
expect(evt.metadata.method).toBe("POST");
|
|
22
|
+
expect(evt.metadata.path).toBe("/login");
|
|
23
|
+
expect(evt.metadata.headers.host).toBe("example.com");
|
|
24
|
+
expect(evt.metadata.ip).toBe("1.2.3.4");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("gère une requête sans headers ni body", () => {
|
|
28
|
+
const raw = { method: "GET", url: "/test" };
|
|
29
|
+
const evt = HttpNormalizer.normalize(raw);
|
|
30
|
+
|
|
31
|
+
expect(evt.metadata.headers).toEqual({});
|
|
32
|
+
expect(evt.metadata.body).toBe(null);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("gère une requête avec body string", () => {
|
|
36
|
+
const raw = { method: "POST", url: "/x", body: "raw-body" };
|
|
37
|
+
const evt = HttpNormalizer.normalize(raw);
|
|
38
|
+
|
|
39
|
+
expect(evt.payload).toContain("raw-body");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("fallback IP si aucune info n'est fournie", () => {
|
|
43
|
+
const raw = { method: "GET", url: "/" };
|
|
44
|
+
const evt = HttpNormalizer.normalize(raw);
|
|
45
|
+
|
|
46
|
+
expect(evt.metadata.ip).toBe("unknown");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("gère un input vide sans planter", () => {
|
|
50
|
+
const evt = HttpNormalizer.normalize({});
|
|
51
|
+
expect(evt.source).toBe("http");
|
|
52
|
+
expect(evt.metadata.method).toBe("UNKNOWN");
|
|
53
|
+
expect(evt.metadata.path).toBe("/");
|
|
54
|
+
});
|
|
55
|
+
});
|