@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,76 @@
|
|
|
1
|
+
import { Rule, RuleFinding, ScoringContext } from "../core/scoring-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ─────────────────────────────────────────────────────────────
|
|
5
|
+
* SSRF RULE — Détection simple basée sur les patterns classiques
|
|
6
|
+
* Version minimaliste, sans callback OAST (viendra plus tard).
|
|
7
|
+
* Compatible avec le moteur de scoring.
|
|
8
|
+
* ─────────────────────────────────────────────────────────────
|
|
9
|
+
*/
|
|
10
|
+
export class SsrfRule implements Rule {
|
|
11
|
+
id = "ssrf-basic";
|
|
12
|
+
name = "Basic SSRF Detection";
|
|
13
|
+
description = "Détecte les patterns SSRF classiques dans les événements normalisés.";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* La règle s’applique si au moins un event contient un payload suspect.
|
|
17
|
+
*/
|
|
18
|
+
applies(context: ScoringContext): boolean {
|
|
19
|
+
return context.events.some(
|
|
20
|
+
(evt) => typeof evt.payload === "string" && this.containsSsrf(evt.payload)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Retourne un ou plusieurs findings SSRF.
|
|
26
|
+
*/
|
|
27
|
+
execute(context: ScoringContext): RuleFinding[] {
|
|
28
|
+
const findings: RuleFinding[] = [];
|
|
29
|
+
|
|
30
|
+
for (const evt of context.events) {
|
|
31
|
+
if (typeof evt.payload !== "string") continue;
|
|
32
|
+
|
|
33
|
+
if (this.containsSsrf(evt.payload)) {
|
|
34
|
+
findings.push({
|
|
35
|
+
ruleId: this.id,
|
|
36
|
+
vulnerability: "ssrf",
|
|
37
|
+
score: 0.9, // score brut (0 → 1)
|
|
38
|
+
severity: "critical",
|
|
39
|
+
details: `Payload SSRF détecté : ${evt.payload}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return findings;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Détection simple de patterns SSRF.
|
|
49
|
+
*/
|
|
50
|
+
private containsSsrf(input: string): boolean {
|
|
51
|
+
const patterns = [
|
|
52
|
+
// Accès aux IP internes
|
|
53
|
+
/127\.0\.0\.1/i,
|
|
54
|
+
/localhost/i,
|
|
55
|
+
/0\.0\.0\.0/i,
|
|
56
|
+
/169\.254\.169\.254/i, // AWS metadata
|
|
57
|
+
/metadata\.google\.internal/i,
|
|
58
|
+
/metadata\/v1/i,
|
|
59
|
+
|
|
60
|
+
// Protocoles dangereux
|
|
61
|
+
/file:\/\//i,
|
|
62
|
+
/gopher:\/\//i,
|
|
63
|
+
/dict:\/\//i,
|
|
64
|
+
/ftp:\/\//i,
|
|
65
|
+
|
|
66
|
+
// Bypass classiques
|
|
67
|
+
/\[::1\]/i,
|
|
68
|
+
/@localhost/i,
|
|
69
|
+
/\/\/localhost/i,
|
|
70
|
+
/\/\/127\.0\.0\.1/i,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
return patterns.some((regex) => regex.test(input));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Rule, RuleFinding, ScoringContext } from "../core/scoring-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ─────────────────────────────────────────────────────────────
|
|
5
|
+
* WAF RULE — Agrégation des signaux WAF
|
|
6
|
+
* Cette règle transforme les scores WAF en findings consolidés.
|
|
7
|
+
* Elle ne détecte rien elle-même : elle consomme les signaux
|
|
8
|
+
* produits par ton middleware WAF.
|
|
9
|
+
* ─────────────────────────────────────────────────────────────
|
|
10
|
+
*/
|
|
11
|
+
export class WafRule implements Rule {
|
|
12
|
+
id = "waf-basic";
|
|
13
|
+
name = "Basic WAF Signal Aggregation";
|
|
14
|
+
description = "Transforme les signaux WAF (scores) en findings consolidés.";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* La règle s’applique si au moins un event WAF contient un score.
|
|
18
|
+
*/
|
|
19
|
+
applies(context: ScoringContext): boolean {
|
|
20
|
+
return context.events.some(
|
|
21
|
+
(evt) =>
|
|
22
|
+
evt.source === "waf" &&
|
|
23
|
+
typeof evt.metadata?.wafScore === "number" &&
|
|
24
|
+
evt.metadata.wafScore > 0,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Retourne un ou plusieurs findings basés sur les scores WAF.
|
|
30
|
+
*/
|
|
31
|
+
execute(context: ScoringContext): RuleFinding[] {
|
|
32
|
+
const findings: RuleFinding[] = [];
|
|
33
|
+
|
|
34
|
+
for (const evt of context.events) {
|
|
35
|
+
if (evt.source !== "waf") continue;
|
|
36
|
+
|
|
37
|
+
const score =
|
|
38
|
+
typeof evt.metadata?.wafScore === "number" ? evt.metadata.wafScore : 0;
|
|
39
|
+
|
|
40
|
+
if (score <= 0) continue;
|
|
41
|
+
|
|
42
|
+
const severity =
|
|
43
|
+
score >= 0.85
|
|
44
|
+
? "critical"
|
|
45
|
+
: score >= 0.65
|
|
46
|
+
? "high"
|
|
47
|
+
: score >= 0.4
|
|
48
|
+
? "medium"
|
|
49
|
+
: "low";
|
|
50
|
+
|
|
51
|
+
findings.push({
|
|
52
|
+
ruleId: this.id,
|
|
53
|
+
vulnerability: "waf",
|
|
54
|
+
score,
|
|
55
|
+
severity,
|
|
56
|
+
details: `Score WAF détecté (${score}) sur l’événement ${evt.id}`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Rule, RuleFinding, ScoringContext } from "../core/scoring-types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ─────────────────────────────────────────────────────────────
|
|
5
|
+
* XSS RULE — Détection simple basée sur les patterns classiques
|
|
6
|
+
* Cette règle est volontairement minimaliste :
|
|
7
|
+
* - pas d’heuristique lourde
|
|
8
|
+
* - pas de normalizer
|
|
9
|
+
* - pas de dépendance externe
|
|
10
|
+
* - compatible avec le moteur de scoring
|
|
11
|
+
* ─────────────────────────────────────────────────────────────
|
|
12
|
+
*/
|
|
13
|
+
export class XssRule implements Rule {
|
|
14
|
+
id = "xss-basic";
|
|
15
|
+
name = "Basic XSS Detection";
|
|
16
|
+
description =
|
|
17
|
+
"Détecte les patterns XSS classiques dans les événements normalisés.";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* La règle s’applique si au moins un event contient un payload exploitable.
|
|
21
|
+
*/
|
|
22
|
+
applies(context: ScoringContext): boolean {
|
|
23
|
+
return context.events.some(
|
|
24
|
+
(evt) => typeof evt.payload === "string" && this.containsXss(evt.payload),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Retourne un ou plusieurs findings XSS.
|
|
30
|
+
*/
|
|
31
|
+
execute(context: ScoringContext): RuleFinding[] {
|
|
32
|
+
const findings: RuleFinding[] = [];
|
|
33
|
+
|
|
34
|
+
for (const evt of context.events) {
|
|
35
|
+
if (typeof evt.payload !== "string") continue;
|
|
36
|
+
|
|
37
|
+
if (this.containsXss(evt.payload)) {
|
|
38
|
+
findings.push({
|
|
39
|
+
ruleId: this.id,
|
|
40
|
+
vulnerability: "xss",
|
|
41
|
+
score: 0.8, // score brut (0 → 1)
|
|
42
|
+
severity: "high",
|
|
43
|
+
details: `Payload suspect détecté : ${evt.payload}`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return findings;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Détection simple de patterns XSS.
|
|
53
|
+
*/
|
|
54
|
+
private containsXss(input: string): boolean {
|
|
55
|
+
const patterns = [
|
|
56
|
+
/<script[\s>]/i,
|
|
57
|
+
/<\/script>/i,
|
|
58
|
+
/on\w+=/i, // onclick=, onerror=, onload=...
|
|
59
|
+
/javascript:/i,
|
|
60
|
+
/alert\s*\(/i,
|
|
61
|
+
/document\.cookie/i,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
return patterns.some((regex) => regex.test(input));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────
|
|
3
|
+
* CHAIN UTILS — Version PRO
|
|
4
|
+
* Outils avancés pour manipuler les chaînes de corrélation.
|
|
5
|
+
* ─────────────────────────────────────────────────────────────
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { CorrelationChain, NormalizedEvent } from "../core/scoring-types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Retourne un label lisible pour la confiance (0 → 1).
|
|
12
|
+
*/
|
|
13
|
+
export function chainConfidenceLabel(confidence: number): string {
|
|
14
|
+
if (confidence >= 0.9) return "very_high";
|
|
15
|
+
if (confidence >= 0.75) return "high";
|
|
16
|
+
if (confidence >= 0.5) return "medium";
|
|
17
|
+
if (confidence >= 0.25) return "low";
|
|
18
|
+
return "very_low";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Formate une chaîne en texte lisible (pour Markdown/HTML).
|
|
23
|
+
*/
|
|
24
|
+
export function formatChain(chain: CorrelationChain): string {
|
|
25
|
+
const events = chain.events
|
|
26
|
+
.map(
|
|
27
|
+
(e) => `${e.source.toUpperCase()}@${new Date(e.timestamp).toISOString()}`,
|
|
28
|
+
)
|
|
29
|
+
.join(" → ");
|
|
30
|
+
|
|
31
|
+
return `${chain.type.toUpperCase()} (${chainConfidenceLabel(chain.confidence)})\n${events}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Résumé compact d'une chaîne (pour tableaux, JSON, logs).
|
|
36
|
+
*/
|
|
37
|
+
export function chainSummary(chain: CorrelationChain): string {
|
|
38
|
+
const first = chain.events[0];
|
|
39
|
+
const last = chain.events[chain.events.length - 1];
|
|
40
|
+
|
|
41
|
+
return `${chain.type} | ${first.source} → ${last.source} | conf=${chain.confidence.toFixed(2)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Trie les chaînes par confiance décroissante.
|
|
46
|
+
*/
|
|
47
|
+
export function sortChains(chains: CorrelationChain[]): CorrelationChain[] {
|
|
48
|
+
return [...chains].sort((a, b) => b.confidence - a.confidence);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Groupe les chaînes par type de vulnérabilité.
|
|
53
|
+
*/
|
|
54
|
+
export function groupChainsByType(
|
|
55
|
+
chains: CorrelationChain[],
|
|
56
|
+
): Record<string, CorrelationChain[]> {
|
|
57
|
+
return chains.reduce(
|
|
58
|
+
(acc, chain) => {
|
|
59
|
+
if (!acc[chain.type]) acc[chain.type] = [];
|
|
60
|
+
acc[chain.type].push(chain);
|
|
61
|
+
return acc;
|
|
62
|
+
},
|
|
63
|
+
{} as Record<string, CorrelationChain[]>,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Génère une signature unique pour une chaîne (utile pour les reporters).
|
|
69
|
+
*/
|
|
70
|
+
export function chainSignature(chain: CorrelationChain): string {
|
|
71
|
+
const ids = chain.events.map((e: NormalizedEvent) => e.id).join("-");
|
|
72
|
+
return `${chain.type}:${ids}`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────
|
|
3
|
+
* DATE UTILS — Version PRO
|
|
4
|
+
* Outils avancés pour formater les dates dans les rapports.
|
|
5
|
+
* ─────────────────────────────────────────────────────────────
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Formate un timestamp en ISO (lisible, stable, triable).
|
|
10
|
+
*/
|
|
11
|
+
export function formatIso(timestamp: number): string {
|
|
12
|
+
return new Date(timestamp).toISOString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Formate une date en format humain (YYYY-MM-DD HH:mm:ss).
|
|
17
|
+
*/
|
|
18
|
+
export function formatHuman(timestamp: number): string {
|
|
19
|
+
const d = new Date(timestamp);
|
|
20
|
+
|
|
21
|
+
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
`${d.getFullYear()}-` +
|
|
25
|
+
`${pad(d.getMonth() + 1)}-` +
|
|
26
|
+
`${pad(d.getDate())} ` +
|
|
27
|
+
`${pad(d.getHours())}:` +
|
|
28
|
+
`${pad(d.getMinutes())}:` +
|
|
29
|
+
`${pad(d.getSeconds())}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Retourne la durée entre deux timestamps (ms → texte lisible).
|
|
35
|
+
*/
|
|
36
|
+
export function formatDuration(ms: number): string {
|
|
37
|
+
if (ms < 1000) return `${ms}ms`;
|
|
38
|
+
|
|
39
|
+
const sec = Math.floor(ms / 1000);
|
|
40
|
+
if (sec < 60) return `${sec}s`;
|
|
41
|
+
|
|
42
|
+
const min = Math.floor(sec / 60);
|
|
43
|
+
if (min < 60) return `${min}m ${sec % 60}s`;
|
|
44
|
+
|
|
45
|
+
const h = Math.floor(min / 60);
|
|
46
|
+
if (h < 24) return `${h}h ${min % 60}m`;
|
|
47
|
+
|
|
48
|
+
const d = Math.floor(h / 24);
|
|
49
|
+
return `${d}d ${h % 24}h`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Retourne "il y a X" (utile pour les rapports Markdown/HTML).
|
|
54
|
+
*/
|
|
55
|
+
export function timeAgo(timestamp: number): string {
|
|
56
|
+
const diff = Date.now() - timestamp;
|
|
57
|
+
|
|
58
|
+
const sec = Math.floor(diff / 1000);
|
|
59
|
+
if (sec < 60) return `${sec}s ago`;
|
|
60
|
+
|
|
61
|
+
const min = Math.floor(sec / 60);
|
|
62
|
+
if (min < 60) return `${min}m ago`;
|
|
63
|
+
|
|
64
|
+
const h = Math.floor(min / 60);
|
|
65
|
+
if (h < 24) return `${h}h ago`;
|
|
66
|
+
|
|
67
|
+
const d = Math.floor(h / 24);
|
|
68
|
+
return `${d}d ago`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Retourne un objet détaillé (utile pour JSON reporter).
|
|
73
|
+
*/
|
|
74
|
+
export function dateInfo(timestamp: number) {
|
|
75
|
+
return {
|
|
76
|
+
iso: formatIso(timestamp),
|
|
77
|
+
human: formatHuman(timestamp),
|
|
78
|
+
ago: timeAgo(timestamp),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────
|
|
3
|
+
* FINDING UTILS — Version PRO
|
|
4
|
+
* Outils avancés pour manipuler les findings consolidés.
|
|
5
|
+
* ─────────────────────────────────────────────────────────────
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Finding, Severity } from "../core/scoring-types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Poids interne pour trier les sévérités.
|
|
12
|
+
*/
|
|
13
|
+
const SEVERITY_ORDER: Record<Severity, number> = {
|
|
14
|
+
critical: 4,
|
|
15
|
+
high: 3,
|
|
16
|
+
medium: 2,
|
|
17
|
+
low: 1,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Trie les findings par sévérité décroissante.
|
|
22
|
+
*/
|
|
23
|
+
export function sortFindingsBySeverity(findings: Finding[]): Finding[] {
|
|
24
|
+
return [...findings].sort(
|
|
25
|
+
(a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity],
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Trie les findings par score décroissant.
|
|
31
|
+
*/
|
|
32
|
+
export function sortFindingsByScore(findings: Finding[]): Finding[] {
|
|
33
|
+
return [...findings].sort((a, b) => b.score - a.score);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Groupe les findings par vulnérabilité.
|
|
38
|
+
*/
|
|
39
|
+
export function groupFindingsByVulnerability(
|
|
40
|
+
findings: Finding[],
|
|
41
|
+
): Record<string, Finding[]> {
|
|
42
|
+
return findings.reduce((acc, f) => {
|
|
43
|
+
if (!acc[f.vulnerability]) acc[f.vulnerability] = [];
|
|
44
|
+
acc[f.vulnerability].push(f);
|
|
45
|
+
return acc;
|
|
46
|
+
}, {} as Record<string, Finding[]>);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Filtre les findings par sévérité minimale.
|
|
51
|
+
*/
|
|
52
|
+
export function filterFindingsBySeverity(
|
|
53
|
+
findings: Finding[],
|
|
54
|
+
min: Severity,
|
|
55
|
+
): Finding[] {
|
|
56
|
+
const minWeight = SEVERITY_ORDER[min];
|
|
57
|
+
return findings.filter((f) => SEVERITY_ORDER[f.severity] >= minWeight);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Génère une signature unique pour un finding.
|
|
62
|
+
* Utile pour les reporters, logs, déduplication.
|
|
63
|
+
*/
|
|
64
|
+
export function findingSignature(f: Finding): string {
|
|
65
|
+
return `${f.vulnerability}:${f.score}:${f.severity}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Résumé compact d’un finding (pour tableaux, logs, JSON).
|
|
70
|
+
*/
|
|
71
|
+
export function findingSummary(f: Finding): string {
|
|
72
|
+
return `${f.vulnerability.toUpperCase()} | score=${f.score} | severity=${f.severity}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fusionne plusieurs findings du même type (utile si un reporter veut regrouper).
|
|
77
|
+
*/
|
|
78
|
+
export function mergeFindings(findings: Finding[]): Finding {
|
|
79
|
+
if (findings.length === 1) return findings[0];
|
|
80
|
+
|
|
81
|
+
const base = findings[0];
|
|
82
|
+
|
|
83
|
+
const merged: Finding = {
|
|
84
|
+
...base,
|
|
85
|
+
score: Math.max(...findings.map((f) => f.score)),
|
|
86
|
+
severity: findings.reduce(
|
|
87
|
+
(acc, f) =>
|
|
88
|
+
SEVERITY_ORDER[f.severity] > SEVERITY_ORDER[acc] ? f.severity : acc,
|
|
89
|
+
base.severity,
|
|
90
|
+
),
|
|
91
|
+
evidence: findings.flatMap((f) => f.evidence),
|
|
92
|
+
chains: findings.flatMap((f) => f.chains ?? []),
|
|
93
|
+
details: findings.map((f) => f.details ?? "").join("\n"),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return merged;
|
|
97
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────
|
|
3
|
+
* REPORT UTILS — Version PRO
|
|
4
|
+
* Outils avancés pour générer du Markdown, HTML et JSON propres.
|
|
5
|
+
* ─────────────────────────────────────────────────────────────
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Échappe les caractères Markdown sensibles.
|
|
10
|
+
*/
|
|
11
|
+
export function escapeMarkdown(text: string): string {
|
|
12
|
+
if (!text) return "";
|
|
13
|
+
return text
|
|
14
|
+
.replace(/\\/g, "\\\\")
|
|
15
|
+
.replace(/`/g, "\\`")
|
|
16
|
+
.replace(/\*/g, "\\*")
|
|
17
|
+
.replace(/_/g, "\\_")
|
|
18
|
+
.replace(/{/g, "\\{")
|
|
19
|
+
.replace(/}/g, "\\}")
|
|
20
|
+
.replace(/\[/g, "\\[")
|
|
21
|
+
.replace(/]/g, "\\]")
|
|
22
|
+
.replace(/\(/g, "\\(")
|
|
23
|
+
.replace(/\)/g, "\\)")
|
|
24
|
+
.replace(/#/g, "\\#")
|
|
25
|
+
.replace(/\+/g, "\\+")
|
|
26
|
+
.replace(/-/g, "\\-")
|
|
27
|
+
.replace(/\!/g, "\\!");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Échappe les caractères HTML dangereux.
|
|
32
|
+
*/
|
|
33
|
+
export function escapeHtml(text: string): string {
|
|
34
|
+
if (!text) return "";
|
|
35
|
+
return text
|
|
36
|
+
.replace(/&/g, "&")
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/"/g, """)
|
|
40
|
+
.replace(/'/g, "'");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Nettoie un texte pour éviter les injections dans les rapports.
|
|
45
|
+
*/
|
|
46
|
+
export function sanitizeText(text: string): string {
|
|
47
|
+
if (!text) return "";
|
|
48
|
+
return escapeHtml(text).trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rend une liste Markdown propre.
|
|
53
|
+
*/
|
|
54
|
+
export function renderList(items: string[]): string {
|
|
55
|
+
if (!items || items.length === 0) return "- (empty)";
|
|
56
|
+
return items.map((i) => `- ${escapeMarkdown(i)}`).join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rend un tableau Markdown.
|
|
61
|
+
*/
|
|
62
|
+
export function renderTable(headers: string[], rows: string[][]): string {
|
|
63
|
+
const escapedHeaders = headers.map(escapeMarkdown);
|
|
64
|
+
const escapedRows = rows.map((row) => row.map(escapeMarkdown));
|
|
65
|
+
|
|
66
|
+
const headerLine = `| ${escapedHeaders.join(" | ")} |`;
|
|
67
|
+
const separator = `| ${escapedHeaders.map(() => "---").join(" | ")} |`;
|
|
68
|
+
const rowLines = escapedRows.map((r) => `| ${r.join(" | ")} |`);
|
|
69
|
+
|
|
70
|
+
return [headerLine, separator, ...rowLines].join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rend un bloc de code Markdown.
|
|
75
|
+
*/
|
|
76
|
+
export function renderCodeBlock(code: string, lang = ""): string {
|
|
77
|
+
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rend une section Markdown avec un titre.
|
|
82
|
+
*/
|
|
83
|
+
export function renderSection(title: string, content: string): string {
|
|
84
|
+
return `## ${escapeMarkdown(title)}\n\n${content}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Rend un paragraphe HTML sécurisé.
|
|
89
|
+
*/
|
|
90
|
+
export function renderHtmlParagraph(text: string): string {
|
|
91
|
+
return `<p>${escapeHtml(text)}</p>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rend une liste HTML sécurisée.
|
|
96
|
+
*/
|
|
97
|
+
export function renderHtmlList(items: string[]): string {
|
|
98
|
+
const li = items.map((i) => `<li>${escapeHtml(i)}</li>`).join("");
|
|
99
|
+
return `<ul>${li}</ul>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Rend un tableau HTML sécurisé.
|
|
104
|
+
*/
|
|
105
|
+
export function renderHtmlTable(headers: string[], rows: string[][]): string {
|
|
106
|
+
const thead = `<tr>${headers
|
|
107
|
+
.map((h) => `<th>${escapeHtml(h)}</th>`)
|
|
108
|
+
.join("")}</tr>`;
|
|
109
|
+
|
|
110
|
+
const tbody = rows
|
|
111
|
+
.map(
|
|
112
|
+
(row) =>
|
|
113
|
+
`<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`,
|
|
114
|
+
)
|
|
115
|
+
.join("");
|
|
116
|
+
|
|
117
|
+
return `<table><thead>${thead}</thead><tbody>${tbody}</tbody></table>`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────
|
|
3
|
+
* SCORE UTILS — Version PRO
|
|
4
|
+
* Outils avancés pour manipuler les scores et sévérités.
|
|
5
|
+
* ─────────────────────────────────────────────────────────────
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Severity } from "../core/scoring-types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalise un score brut (0 → 1) en score final (0 → 100).
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeScore(raw: number): number {
|
|
14
|
+
if (raw < 0) return 0;
|
|
15
|
+
if (raw > 1) return 100;
|
|
16
|
+
return Math.round(raw * 100);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convertit un score (0–100) en sévérité standardisée.
|
|
21
|
+
*/
|
|
22
|
+
export function scoreToSeverity(score: number): Severity {
|
|
23
|
+
if (score >= 90) return "critical";
|
|
24
|
+
if (score >= 70) return "high";
|
|
25
|
+
if (score >= 40) return "medium";
|
|
26
|
+
return "low";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Retourne une couleur lisible pour les reporters HTML/Markdown.
|
|
31
|
+
*/
|
|
32
|
+
export function severityColor(severity: Severity): string {
|
|
33
|
+
switch (severity) {
|
|
34
|
+
case "critical":
|
|
35
|
+
return "#d32f2f"; // rouge fort
|
|
36
|
+
case "high":
|
|
37
|
+
return "#f57c00"; // orange
|
|
38
|
+
case "medium":
|
|
39
|
+
return "#fbc02d"; // jaune
|
|
40
|
+
case "low":
|
|
41
|
+
return "#388e3c"; // vert
|
|
42
|
+
default:
|
|
43
|
+
return "#616161"; // gris
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Combine plusieurs scores bruts (0 → 1) en un score unique.
|
|
49
|
+
* Utilise la méthode "max" (la plus utilisée en sécurité).
|
|
50
|
+
*/
|
|
51
|
+
export function combineScoresMax(scores: number[]): number {
|
|
52
|
+
if (scores.length === 0) return 0;
|
|
53
|
+
return Math.max(...scores);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Combine plusieurs scores bruts en moyenne pondérée.
|
|
58
|
+
*/
|
|
59
|
+
export function combineScoresWeighted(
|
|
60
|
+
scores: number[],
|
|
61
|
+
weights: number[],
|
|
62
|
+
): number {
|
|
63
|
+
if (scores.length === 0 || weights.length === 0) return 0;
|
|
64
|
+
if (scores.length !== weights.length) {
|
|
65
|
+
throw new Error("scores and weights must have the same length");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
|
69
|
+
if (totalWeight === 0) return 0;
|
|
70
|
+
|
|
71
|
+
const weighted = scores.reduce(
|
|
72
|
+
(acc, score, i) => acc + score * weights[i],
|
|
73
|
+
0,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return weighted / totalWeight;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Retourne un label lisible pour un score (utile pour les reporters).
|
|
81
|
+
*/
|
|
82
|
+
export function scoreLabel(score: number): string {
|
|
83
|
+
if (score >= 90) return "Critical risk";
|
|
84
|
+
if (score >= 70) return "High risk";
|
|
85
|
+
if (score >= 40) return "Medium risk";
|
|
86
|
+
return "Low risk";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Retourne un objet complet pour les reporters JSON.
|
|
91
|
+
*/
|
|
92
|
+
export function scoreInfo(raw: number) {
|
|
93
|
+
const normalized = normalizeScore(raw);
|
|
94
|
+
const severity = scoreToSeverity(normalized);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
raw,
|
|
98
|
+
normalized,
|
|
99
|
+
severity,
|
|
100
|
+
label: scoreLabel(normalized),
|
|
101
|
+
color: severityColor(severity),
|
|
102
|
+
};
|
|
103
|
+
}
|