@greenarmor/ges-inference-engine 1.6.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/LICENSE +21 -0
- package/dist/analyzers/finding-clusterer.d.ts +10 -0
- package/dist/analyzers/finding-clusterer.js +165 -0
- package/dist/analyzers/root-cause.d.ts +13 -0
- package/dist/analyzers/root-cause.js +131 -0
- package/dist/analyzers/score-anomaly.d.ts +11 -0
- package/dist/analyzers/score-anomaly.js +136 -0
- package/dist/analyzers/trend-predictor.d.ts +12 -0
- package/dist/analyzers/trend-predictor.js +134 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +140 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +1 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025–2026 greenarmor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InferenceFinding, ClusteringResult } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Cluster findings using cosine similarity on n-gram token vectors.
|
|
4
|
+
* Two findings are in the same cluster if they share the same ruleId,
|
|
5
|
+
* or if their text similarity exceeds the threshold.
|
|
6
|
+
*
|
|
7
|
+
* Uses a greedy union-join approach: iterate findings, assign to the first
|
|
8
|
+
* sufficiently similar existing cluster, or create a new one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function clusterFindings(findings: InferenceFinding[]): ClusteringResult;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const SEVERITY_RANK = {
|
|
2
|
+
critical: 4,
|
|
3
|
+
high: 3,
|
|
4
|
+
medium: 2,
|
|
5
|
+
low: 1,
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Tokenize a string into normalized n-gram tokens.
|
|
9
|
+
* Lowercase, strip non-alphanumeric, split on whitespace, then generate 2-grams + 3-grams.
|
|
10
|
+
*/
|
|
11
|
+
function tokenize(text) {
|
|
12
|
+
const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
|
13
|
+
if (!cleaned)
|
|
14
|
+
return [];
|
|
15
|
+
const words = cleaned.split(/\s+/).filter((w) => w.length > 1);
|
|
16
|
+
const tokens = [...words];
|
|
17
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
18
|
+
tokens.push(`${words[i]} ${words[i + 1]}`);
|
|
19
|
+
}
|
|
20
|
+
for (let i = 0; i < words.length - 2; i++) {
|
|
21
|
+
tokens.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
|
|
22
|
+
}
|
|
23
|
+
return tokens;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build a term-frequency map from a set of tokens.
|
|
27
|
+
*/
|
|
28
|
+
function termFrequency(tokens) {
|
|
29
|
+
const tf = new Map();
|
|
30
|
+
for (const t of tokens) {
|
|
31
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
return tf;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Compute cosine similarity between two term-frequency vectors.
|
|
37
|
+
*/
|
|
38
|
+
function cosineSimilarity(tfA, tfB) {
|
|
39
|
+
let dotProduct = 0;
|
|
40
|
+
let magA = 0;
|
|
41
|
+
let magB = 0;
|
|
42
|
+
const keys = new Set([...tfA.keys(), ...tfB.keys()]);
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
const a = tfA.get(key) ?? 0;
|
|
45
|
+
const b = tfB.get(key) ?? 0;
|
|
46
|
+
dotProduct += a * b;
|
|
47
|
+
magA += a * a;
|
|
48
|
+
magB += b * b;
|
|
49
|
+
}
|
|
50
|
+
if (magA === 0 || magB === 0)
|
|
51
|
+
return 0;
|
|
52
|
+
return dotProduct / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
53
|
+
}
|
|
54
|
+
const SIMILARITY_THRESHOLD = 0.55;
|
|
55
|
+
/**
|
|
56
|
+
* Cluster findings using cosine similarity on n-gram token vectors.
|
|
57
|
+
* Two findings are in the same cluster if they share the same ruleId,
|
|
58
|
+
* or if their text similarity exceeds the threshold.
|
|
59
|
+
*
|
|
60
|
+
* Uses a greedy union-join approach: iterate findings, assign to the first
|
|
61
|
+
* sufficiently similar existing cluster, or create a new one.
|
|
62
|
+
*/
|
|
63
|
+
export function clusterFindings(findings) {
|
|
64
|
+
if (findings.length === 0) {
|
|
65
|
+
return {
|
|
66
|
+
totalFindings: 0,
|
|
67
|
+
clusterCount: 0,
|
|
68
|
+
clusters: [],
|
|
69
|
+
reductionRatio: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// Pre-compute term-frequency vectors for each finding
|
|
73
|
+
const tfVectors = findings.map((f) => {
|
|
74
|
+
const combinedText = `${f.ruleId} ${f.category} ${f.title} ${f.description} ${f.evidence}`;
|
|
75
|
+
return termFrequency(tokenize(combinedText));
|
|
76
|
+
});
|
|
77
|
+
// Union-Find data structure
|
|
78
|
+
const parent = findings.map((_, i) => i);
|
|
79
|
+
function find(x) {
|
|
80
|
+
while (parent[x] !== x) {
|
|
81
|
+
parent[x] = parent[parent[x]];
|
|
82
|
+
x = parent[x];
|
|
83
|
+
}
|
|
84
|
+
return x;
|
|
85
|
+
}
|
|
86
|
+
function union(x, y) {
|
|
87
|
+
const px = find(x);
|
|
88
|
+
const py = find(y);
|
|
89
|
+
if (px !== py)
|
|
90
|
+
parent[py] = px;
|
|
91
|
+
}
|
|
92
|
+
// Compare all pairs — O(n²) but finding counts in GESF are typically < 500
|
|
93
|
+
for (let i = 0; i < findings.length; i++) {
|
|
94
|
+
for (let j = i + 1; j < findings.length; j++) {
|
|
95
|
+
if (find(i) === find(j))
|
|
96
|
+
continue;
|
|
97
|
+
// Same ruleId → always cluster together
|
|
98
|
+
if (findings[i].ruleId === findings[j].ruleId) {
|
|
99
|
+
union(i, j);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const similarity = cosineSimilarity(tfVectors[i], tfVectors[j]);
|
|
103
|
+
if (similarity >= SIMILARITY_THRESHOLD) {
|
|
104
|
+
union(i, j);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Group findings by cluster root
|
|
109
|
+
const clusterMap = new Map();
|
|
110
|
+
for (let i = 0; i < findings.length; i++) {
|
|
111
|
+
const root = find(i);
|
|
112
|
+
if (!clusterMap.has(root))
|
|
113
|
+
clusterMap.set(root, []);
|
|
114
|
+
clusterMap.get(root).push(i);
|
|
115
|
+
}
|
|
116
|
+
// Build cluster objects
|
|
117
|
+
const clusters = [];
|
|
118
|
+
let clusterIdx = 0;
|
|
119
|
+
for (const [root, indices] of clusterMap) {
|
|
120
|
+
const memberFindings = indices.map((i) => findings[i]);
|
|
121
|
+
// Pick the highest-severity finding as representative
|
|
122
|
+
const representativeIdx = indices.reduce((best, current) => {
|
|
123
|
+
const bestSev = SEVERITY_RANK[findings[best].severity] ?? 0;
|
|
124
|
+
const curSev = SEVERITY_RANK[findings[current].severity] ?? 0;
|
|
125
|
+
return curSev > bestSev ? current : best;
|
|
126
|
+
}, indices[0]);
|
|
127
|
+
const representative = findings[representativeIdx];
|
|
128
|
+
// Aggregate unique files
|
|
129
|
+
const fileSet = new Set(memberFindings.map((f) => f.file));
|
|
130
|
+
// Determine cluster severity (max of members)
|
|
131
|
+
const maxSeverity = memberFindings.reduce((max, f) => {
|
|
132
|
+
return (SEVERITY_RANK[f.severity] ?? 0) > (SEVERITY_RANK[max] ?? 0) ? f.severity : max;
|
|
133
|
+
}, memberFindings[0].severity);
|
|
134
|
+
// Generate a pattern description from the most common ruleId
|
|
135
|
+
const ruleIdCounts = new Map();
|
|
136
|
+
for (const f of memberFindings) {
|
|
137
|
+
ruleIdCounts.set(f.ruleId, (ruleIdCounts.get(f.ruleId) ?? 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
const dominantRuleId = [...ruleIdCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
140
|
+
clusters.push({
|
|
141
|
+
clusterId: `CLUSTER-${String(clusterIdx + 1).padStart(3, "0")}`,
|
|
142
|
+
pattern: dominantRuleId,
|
|
143
|
+
ruleId: dominantRuleId,
|
|
144
|
+
category: representative.category,
|
|
145
|
+
severity: maxSeverity,
|
|
146
|
+
findingCount: indices.length,
|
|
147
|
+
files: [...fileSet].sort(),
|
|
148
|
+
representativeTitle: representative.title,
|
|
149
|
+
representativeFix: representative.fix,
|
|
150
|
+
findingIndices: indices,
|
|
151
|
+
});
|
|
152
|
+
clusterIdx++;
|
|
153
|
+
}
|
|
154
|
+
// Sort clusters by finding count descending
|
|
155
|
+
clusters.sort((a, b) => b.findingCount - a.findingCount);
|
|
156
|
+
const reductionRatio = findings.length > 0
|
|
157
|
+
? (1 - clusters.length / findings.length) * 100
|
|
158
|
+
: 0;
|
|
159
|
+
return {
|
|
160
|
+
totalFindings: findings.length,
|
|
161
|
+
clusterCount: clusters.length,
|
|
162
|
+
clusters,
|
|
163
|
+
reductionRatio: Math.round(reductionRatio * 10) / 10,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { InferenceFinding, RootCauseResult } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Root Cause Analysis via bipartite graph (findings ↔ files ↔ controls).
|
|
4
|
+
*
|
|
5
|
+
* Builds a bipartite graph: findings connect to files, findings connect to controls.
|
|
6
|
+
* Computes each file/control node's:
|
|
7
|
+
* - severityScore: weighted sum of connected finding severities
|
|
8
|
+
* - betweenness: fraction of all findings this node is connected to (simplified)
|
|
9
|
+
* - connectedFindings: which findings touch this node
|
|
10
|
+
*
|
|
11
|
+
* The "root cause" is the file or control with the highest severityScore × betweenness.
|
|
12
|
+
*/
|
|
13
|
+
export declare function analyzeRootCause(findings: InferenceFinding[]): RootCauseResult;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const SEVERITY_WEIGHT = {
|
|
2
|
+
critical: 40,
|
|
3
|
+
high: 20,
|
|
4
|
+
medium: 8,
|
|
5
|
+
low: 2,
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Root Cause Analysis via bipartite graph (findings ↔ files ↔ controls).
|
|
9
|
+
*
|
|
10
|
+
* Builds a bipartite graph: findings connect to files, findings connect to controls.
|
|
11
|
+
* Computes each file/control node's:
|
|
12
|
+
* - severityScore: weighted sum of connected finding severities
|
|
13
|
+
* - betweenness: fraction of all findings this node is connected to (simplified)
|
|
14
|
+
* - connectedFindings: which findings touch this node
|
|
15
|
+
*
|
|
16
|
+
* The "root cause" is the file or control with the highest severityScore × betweenness.
|
|
17
|
+
*/
|
|
18
|
+
export function analyzeRootCause(findings) {
|
|
19
|
+
if (findings.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
nodes: [],
|
|
22
|
+
topCause: null,
|
|
23
|
+
summary: "No findings to analyze.",
|
|
24
|
+
totalFindingsTraced: 0,
|
|
25
|
+
maxSeverityScore: 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Build adjacency: file → finding indices, control → finding indices
|
|
29
|
+
const fileToFindings = new Map();
|
|
30
|
+
const controlToFindings = new Map();
|
|
31
|
+
for (let i = 0; i < findings.length; i++) {
|
|
32
|
+
const f = findings[i];
|
|
33
|
+
// File mapping
|
|
34
|
+
if (!fileToFindings.has(f.file))
|
|
35
|
+
fileToFindings.set(f.file, []);
|
|
36
|
+
fileToFindings.get(f.file).push(i);
|
|
37
|
+
// Control mapping
|
|
38
|
+
for (const cid of f.controlIds) {
|
|
39
|
+
if (!controlToFindings.has(cid))
|
|
40
|
+
controlToFindings.set(cid, []);
|
|
41
|
+
controlToFindings.get(cid).push(i);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Collect unique frameworks
|
|
45
|
+
const frameworkSet = new Set();
|
|
46
|
+
// Build nodes for files
|
|
47
|
+
const fileNodes = [];
|
|
48
|
+
for (const [file, findIndices] of fileToFindings) {
|
|
49
|
+
const severityScore = findIndices.reduce((sum, idx) => {
|
|
50
|
+
return sum + (SEVERITY_WEIGHT[findings[idx].severity] ?? 1);
|
|
51
|
+
}, 0);
|
|
52
|
+
const frameworks = new Set();
|
|
53
|
+
for (const idx of findIndices) {
|
|
54
|
+
for (const cid of findings[idx].controlIds) {
|
|
55
|
+
const prefix = cid.split("-")[0];
|
|
56
|
+
if (prefix)
|
|
57
|
+
frameworks.add(prefix);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const node = {
|
|
61
|
+
type: "file",
|
|
62
|
+
identifier: file,
|
|
63
|
+
connectedFindings: findIndices,
|
|
64
|
+
severityScore,
|
|
65
|
+
betweenness: findIndices.length / findings.length,
|
|
66
|
+
frameworks: [...frameworks].sort(),
|
|
67
|
+
};
|
|
68
|
+
fileNodes.push(node);
|
|
69
|
+
for (const fw of frameworks)
|
|
70
|
+
frameworkSet.add(fw);
|
|
71
|
+
}
|
|
72
|
+
// Build framework name mapping from known prefixes
|
|
73
|
+
const prefixMap = {
|
|
74
|
+
GDPR: "GDPR",
|
|
75
|
+
OWASP: "OWASP",
|
|
76
|
+
CIS: "CIS",
|
|
77
|
+
NIST: "NIST",
|
|
78
|
+
GOVP: "GDPR",
|
|
79
|
+
AI: "GDPR",
|
|
80
|
+
GOV: "GDPR",
|
|
81
|
+
SOC2: "SOC2",
|
|
82
|
+
HIPAA: "HIPAA",
|
|
83
|
+
PCI: "PCI-DSS",
|
|
84
|
+
ISO: "ISO27001",
|
|
85
|
+
};
|
|
86
|
+
// Build nodes for controls
|
|
87
|
+
const controlNodes = [];
|
|
88
|
+
for (const [control, findIndices] of controlToFindings) {
|
|
89
|
+
const severityScore = findIndices.reduce((sum, idx) => {
|
|
90
|
+
return sum + (SEVERITY_WEIGHT[findings[idx].severity] ?? 1);
|
|
91
|
+
}, 0);
|
|
92
|
+
const prefix = control.split("-")[0];
|
|
93
|
+
const framework = prefixMap[prefix] ?? prefix;
|
|
94
|
+
const node = {
|
|
95
|
+
type: "control",
|
|
96
|
+
identifier: control,
|
|
97
|
+
connectedFindings: findIndices,
|
|
98
|
+
severityScore,
|
|
99
|
+
betweenness: findIndices.length / findings.length,
|
|
100
|
+
frameworks: [framework],
|
|
101
|
+
};
|
|
102
|
+
controlNodes.push(node);
|
|
103
|
+
frameworkSet.add(framework);
|
|
104
|
+
}
|
|
105
|
+
// Combine and sort by severityScore × betweenness (composite impact)
|
|
106
|
+
const allNodes = [...fileNodes, ...controlNodes].sort((a, b) => {
|
|
107
|
+
const scoreA = a.severityScore * a.betweenness;
|
|
108
|
+
const scoreB = b.severityScore * b.betweenness;
|
|
109
|
+
return scoreB - scoreA;
|
|
110
|
+
});
|
|
111
|
+
const topCause = allNodes.length > 0 ? allNodes[0] : null;
|
|
112
|
+
const maxSeverityScore = allNodes.length > 0
|
|
113
|
+
? Math.max(...allNodes.map((n) => n.severityScore))
|
|
114
|
+
: 0;
|
|
115
|
+
let summary;
|
|
116
|
+
if (topCause) {
|
|
117
|
+
const pct = Math.round(topCause.betweenness * 100);
|
|
118
|
+
const nodeLabel = topCause.type === "file" ? "file" : "control";
|
|
119
|
+
summary = `${topCause.identifier} is the primary root cause, connected to ${topCause.connectedFindings.length} of ${findings.length} findings (${pct}%).`;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
summary = "No root cause identified.";
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
nodes: allNodes,
|
|
126
|
+
topCause,
|
|
127
|
+
summary,
|
|
128
|
+
totalFindingsTraced: findings.length,
|
|
129
|
+
maxSeverityScore,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ScoreDataPoint, ScoreAnomalyResult, ActivityEntry } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Score anomaly detection using z-score analysis on score deltas.
|
|
4
|
+
*
|
|
5
|
+
* Takes a history of score data points and detects which frameworks
|
|
6
|
+
* experienced statistically significant score changes (|z-score| > 1.5).
|
|
7
|
+
*
|
|
8
|
+
* Also correlates anomalies with recent activity log entries to identify
|
|
9
|
+
* triggering events (e.g., policy install, audit, fix).
|
|
10
|
+
*/
|
|
11
|
+
export declare function detectScoreAnomalies(scoreHistory: ScoreDataPoint[], activityLog: ActivityEntry[]): ScoreAnomalyResult;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Score anomaly detection using z-score analysis on score deltas.
|
|
3
|
+
*
|
|
4
|
+
* Takes a history of score data points and detects which frameworks
|
|
5
|
+
* experienced statistically significant score changes (|z-score| > 1.5).
|
|
6
|
+
*
|
|
7
|
+
* Also correlates anomalies with recent activity log entries to identify
|
|
8
|
+
* triggering events (e.g., policy install, audit, fix).
|
|
9
|
+
*/
|
|
10
|
+
export function detectScoreAnomalies(scoreHistory, activityLog) {
|
|
11
|
+
if (scoreHistory.length < 2) {
|
|
12
|
+
return {
|
|
13
|
+
anomalies: [],
|
|
14
|
+
hasAnomalies: false,
|
|
15
|
+
averageScore: scoreHistory.length > 0 ? scoreHistory[0].overall : 0,
|
|
16
|
+
standardDeviation: 0,
|
|
17
|
+
dataPointCount: scoreHistory.length,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// Collect all framework keys across all data points
|
|
21
|
+
const frameworkKeys = new Set();
|
|
22
|
+
for (const dp of scoreHistory) {
|
|
23
|
+
for (const fw of Object.keys(dp.frameworks)) {
|
|
24
|
+
frameworkKeys.add(fw);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// For each framework, compute deltas and z-scores
|
|
28
|
+
const anomalies = [];
|
|
29
|
+
const allDeltas = [];
|
|
30
|
+
for (const fw of frameworkKeys) {
|
|
31
|
+
const fwScores = scoreHistory
|
|
32
|
+
.map((dp) => dp.frameworks[fw])
|
|
33
|
+
.filter((s) => s !== undefined)
|
|
34
|
+
.map((s) => s.score);
|
|
35
|
+
if (fwScores.length < 2)
|
|
36
|
+
continue;
|
|
37
|
+
// Compute deltas between consecutive data points
|
|
38
|
+
const deltas = [];
|
|
39
|
+
for (let i = 1; i < fwScores.length; i++) {
|
|
40
|
+
deltas.push(fwScores[i] - fwScores[i - 1]);
|
|
41
|
+
}
|
|
42
|
+
if (deltas.length === 0)
|
|
43
|
+
continue;
|
|
44
|
+
// Mean and standard deviation of deltas
|
|
45
|
+
const mean = deltas.reduce((sum, d) => sum + d, 0) / deltas.length;
|
|
46
|
+
const variance = deltas.reduce((sum, d) => sum + (d - mean) ** 2, 0) / deltas.length;
|
|
47
|
+
const stdDev = Math.sqrt(variance);
|
|
48
|
+
for (const d of deltas) {
|
|
49
|
+
allDeltas.push(d);
|
|
50
|
+
}
|
|
51
|
+
// Check the most recent delta for anomaly
|
|
52
|
+
const latestDelta = deltas[deltas.length - 1];
|
|
53
|
+
const prevScore = fwScores[fwScores.length - 2];
|
|
54
|
+
const currScore = fwScores[fwScores.length - 1];
|
|
55
|
+
// z-score: avoid division by zero; with only 1 delta (2 data points), flag by magnitude alone
|
|
56
|
+
const zScore = stdDev > 0 ? Math.abs(latestDelta - mean) / stdDev : (Math.abs(latestDelta) > 10 ? 3 : 0);
|
|
57
|
+
const isAnomalous = zScore > 1.5 && Math.abs(latestDelta) > 5; // z > 1.5 AND meaningful change > 5%
|
|
58
|
+
// Try to find a triggering event near the time of the latest score point
|
|
59
|
+
const latestTimestamp = scoreHistory[scoreHistory.length - 1].evaluatedAt;
|
|
60
|
+
const triggeringEvent = findTriggeringEvent(activityLog, latestTimestamp, fw);
|
|
61
|
+
let description;
|
|
62
|
+
if (isAnomalous) {
|
|
63
|
+
const direction = latestDelta < 0 ? "dropped" : "increased";
|
|
64
|
+
const magnitude = Math.abs(latestDelta).toFixed(1);
|
|
65
|
+
description = `${fw} score ${direction} by ${magnitude}% (from ${prevScore.toFixed(0)}% to ${currScore.toFixed(0)}%), z-score: ${zScore.toFixed(2)}.`;
|
|
66
|
+
if (triggeringEvent) {
|
|
67
|
+
description += ` Triggered by: ${triggeringEvent}.`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
description = `${fw} score change of ${latestDelta.toFixed(1)}% is within normal range (z-score: ${zScore.toFixed(2)}).`;
|
|
72
|
+
}
|
|
73
|
+
anomalies.push({
|
|
74
|
+
framework: fw,
|
|
75
|
+
delta: Math.round(latestDelta * 10) / 10,
|
|
76
|
+
previousScore: prevScore,
|
|
77
|
+
currentScore: currScore,
|
|
78
|
+
zScore: Math.round(zScore * 100) / 100,
|
|
79
|
+
isAnomalous,
|
|
80
|
+
evaluatedAt: latestTimestamp,
|
|
81
|
+
triggeringEvent,
|
|
82
|
+
description,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// Compute overall statistics
|
|
86
|
+
const overallMean = allDeltas.length > 0
|
|
87
|
+
? allDeltas.reduce((sum, d) => sum + d, 0) / allDeltas.length
|
|
88
|
+
: 0;
|
|
89
|
+
const overallVar = allDeltas.length > 0
|
|
90
|
+
? allDeltas.reduce((sum, d) => sum + (d - overallMean) ** 2, 0) / allDeltas.length
|
|
91
|
+
: 0;
|
|
92
|
+
const overallStdDev = Math.sqrt(overallVar);
|
|
93
|
+
return {
|
|
94
|
+
anomalies: anomalies.sort((a, b) => b.zScore - a.zScore),
|
|
95
|
+
hasAnomalies: anomalies.some((a) => a.isAnomalous),
|
|
96
|
+
averageScore: Math.round(overallMean * 10) / 10,
|
|
97
|
+
standardDeviation: Math.round(overallStdDev * 10) / 10,
|
|
98
|
+
dataPointCount: scoreHistory.length,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Find a triggering activity log entry near the given timestamp.
|
|
103
|
+
*/
|
|
104
|
+
function findTriggeringEvent(activityLog, targetTimestamp, framework) {
|
|
105
|
+
const target = new Date(targetTimestamp).getTime();
|
|
106
|
+
// Look for activity entries within 12 hours before the score drop
|
|
107
|
+
const window = 12 * 60 * 60 * 1000; // 12 hours
|
|
108
|
+
const candidates = activityLog
|
|
109
|
+
.filter((entry) => {
|
|
110
|
+
const entryTime = new Date(entry.timestamp).getTime();
|
|
111
|
+
return entryTime >= target - window && entryTime <= target;
|
|
112
|
+
})
|
|
113
|
+
.sort((a, b) => {
|
|
114
|
+
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
|
115
|
+
});
|
|
116
|
+
for (const entry of candidates) {
|
|
117
|
+
const actionMap = {
|
|
118
|
+
audit: "Audit completed",
|
|
119
|
+
fix: "Auto-fix applied",
|
|
120
|
+
policy_install: "Policy pack installed",
|
|
121
|
+
policy_remove: "Policy pack removed",
|
|
122
|
+
control_override: "Control override applied",
|
|
123
|
+
score: "Score evaluation",
|
|
124
|
+
};
|
|
125
|
+
const label = actionMap[entry.action] ?? entry.action;
|
|
126
|
+
if (entry.action === "audit" || entry.action === "score") {
|
|
127
|
+
return `${label}: ${entry.title}`;
|
|
128
|
+
}
|
|
129
|
+
// For policy installs, check if it affects this framework
|
|
130
|
+
const fwAdded = entry.details?.frameworks_added;
|
|
131
|
+
if (fwAdded?.some((fw) => fw.toUpperCase() === framework.toUpperCase())) {
|
|
132
|
+
return `${label}: ${entry.title}`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ScoreDataPoint, TrendPredictionResult } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Trend prediction via simple linear regression on score-over-time.
|
|
4
|
+
*
|
|
5
|
+
* For each framework (and overall), computes:
|
|
6
|
+
* - slope: rate of score change per data point
|
|
7
|
+
* - rSquared: fit quality
|
|
8
|
+
* - projected score: current + slope (one step ahead)
|
|
9
|
+
* - cyclesToThreshold: how many data points until crossing the 80% threshold
|
|
10
|
+
* - trendDirection: improving, declining, or stable
|
|
11
|
+
*/
|
|
12
|
+
export declare function predictTrends(scoreHistory: ScoreDataPoint[]): TrendPredictionResult;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const DEFAULT_THRESHOLD = 80;
|
|
2
|
+
/**
|
|
3
|
+
* Trend prediction via simple linear regression on score-over-time.
|
|
4
|
+
*
|
|
5
|
+
* For each framework (and overall), computes:
|
|
6
|
+
* - slope: rate of score change per data point
|
|
7
|
+
* - rSquared: fit quality
|
|
8
|
+
* - projected score: current + slope (one step ahead)
|
|
9
|
+
* - cyclesToThreshold: how many data points until crossing the 80% threshold
|
|
10
|
+
* - trendDirection: improving, declining, or stable
|
|
11
|
+
*/
|
|
12
|
+
export function predictTrends(scoreHistory) {
|
|
13
|
+
if (scoreHistory.length < 2) {
|
|
14
|
+
return {
|
|
15
|
+
predictions: [],
|
|
16
|
+
overall: null,
|
|
17
|
+
dataPointCount: scoreHistory.length,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// Extract overall score series
|
|
21
|
+
const overallScores = scoreHistory.map((dp) => dp.overall);
|
|
22
|
+
const overallPrediction = computePrediction("overall", overallScores);
|
|
23
|
+
// Collect all framework keys
|
|
24
|
+
const frameworkKeys = new Set();
|
|
25
|
+
for (const dp of scoreHistory) {
|
|
26
|
+
for (const fw of Object.keys(dp.frameworks)) {
|
|
27
|
+
frameworkKeys.add(fw);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Compute predictions per framework
|
|
31
|
+
const predictions = [];
|
|
32
|
+
for (const fw of frameworkKeys) {
|
|
33
|
+
const fwScores = scoreHistory
|
|
34
|
+
.map((dp) => dp.frameworks[fw])
|
|
35
|
+
.filter((s) => s !== undefined)
|
|
36
|
+
.map((s) => s.score);
|
|
37
|
+
if (fwScores.length < 2)
|
|
38
|
+
continue;
|
|
39
|
+
const prediction = computePrediction(fw, fwScores);
|
|
40
|
+
predictions.push(prediction);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
predictions: predictions.sort((a, b) => b.rSquared - a.rSquared),
|
|
44
|
+
overall: overallPrediction,
|
|
45
|
+
dataPointCount: scoreHistory.length,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function computePrediction(name, scores) {
|
|
49
|
+
const n = scores.length;
|
|
50
|
+
// Linear regression: y = slope * x + intercept
|
|
51
|
+
const xValues = scores.map((_, i) => i); // 0, 1, 2, ... (time steps)
|
|
52
|
+
const yValues = scores;
|
|
53
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
|
|
54
|
+
for (let i = 0; i < n; i++) {
|
|
55
|
+
sumX += xValues[i];
|
|
56
|
+
sumY += yValues[i];
|
|
57
|
+
sumXY += xValues[i] * yValues[i];
|
|
58
|
+
sumX2 += xValues[i] * xValues[i];
|
|
59
|
+
sumY2 += yValues[i] * yValues[i];
|
|
60
|
+
}
|
|
61
|
+
const denominator = n * sumX2 - sumX * sumX;
|
|
62
|
+
let slope;
|
|
63
|
+
let intercept;
|
|
64
|
+
if (denominator === 0) {
|
|
65
|
+
// All x values are the same (shouldn't happen with 0,1,2...)
|
|
66
|
+
slope = 0;
|
|
67
|
+
intercept = sumY / n;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
slope = (n * sumXY - sumX * sumY) / denominator;
|
|
71
|
+
intercept = (sumY * sumX2 - sumX * sumXY) / denominator;
|
|
72
|
+
}
|
|
73
|
+
// R-squared
|
|
74
|
+
const meanY = sumY / n;
|
|
75
|
+
let ssTot = 0, ssRes = 0;
|
|
76
|
+
for (let i = 0; i < n; i++) {
|
|
77
|
+
const predicted = slope * xValues[i] + intercept;
|
|
78
|
+
ssTot += (yValues[i] - meanY) ** 2;
|
|
79
|
+
ssRes += (yValues[i] - predicted) ** 2;
|
|
80
|
+
}
|
|
81
|
+
const rSquared = ssTot > 0 ? 1 - ssRes / ssTot : 0;
|
|
82
|
+
// Projected score (one step ahead)
|
|
83
|
+
const currentScore = yValues[n - 1];
|
|
84
|
+
const projectedScore = slope * n + intercept;
|
|
85
|
+
// Cycles to threshold
|
|
86
|
+
let cyclesToThreshold = null;
|
|
87
|
+
if (slope !== 0) {
|
|
88
|
+
// Solve: slope * t + intercept = threshold
|
|
89
|
+
const stepsToCross = (DEFAULT_THRESHOLD - intercept) / slope;
|
|
90
|
+
// t is relative to x=0, current position is at x=n-1
|
|
91
|
+
const stepsRemaining = stepsToCross - (n - 1);
|
|
92
|
+
if (stepsRemaining > 0 && stepsRemaining < 1000) {
|
|
93
|
+
cyclesToThreshold = Math.round(stepsRemaining);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (currentScore < DEFAULT_THRESHOLD) {
|
|
97
|
+
// flat below threshold — already there
|
|
98
|
+
cyclesToThreshold = 0;
|
|
99
|
+
}
|
|
100
|
+
// Trend direction
|
|
101
|
+
let trendDirection;
|
|
102
|
+
if (Math.abs(slope) < 1) {
|
|
103
|
+
trendDirection = "stable";
|
|
104
|
+
}
|
|
105
|
+
else if (slope > 0) {
|
|
106
|
+
trendDirection = "improving";
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
trendDirection = "declining";
|
|
110
|
+
}
|
|
111
|
+
// Description
|
|
112
|
+
let description;
|
|
113
|
+
const directionWord = slope > 0 ? "improving" : slope < 0 ? "declining" : "holding steady";
|
|
114
|
+
const absRate = Math.abs(slope).toFixed(1);
|
|
115
|
+
const quality = rSquared > 0.7 ? "strong" : rSquared > 0.4 ? "moderate" : "weak";
|
|
116
|
+
if (name === "overall") {
|
|
117
|
+
description = `Overall score is ${directionWord} at ${absRate}% per audit cycle (${quality} fit, R²=${rSquared.toFixed(2)}).`;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
description = `${name} is ${directionWord} at ${absRate}% per audit cycle (${quality} fit, R²=${rSquared.toFixed(2)}).`;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
framework: name,
|
|
124
|
+
slope: Math.round(slope * 100) / 100,
|
|
125
|
+
intercept: Math.round(intercept * 100) / 100,
|
|
126
|
+
rSquared: Math.round(rSquared * 1000) / 1000,
|
|
127
|
+
currentScore: Math.round(currentScore * 10) / 10,
|
|
128
|
+
projectedScore: Math.round(projectedScore * 10) / 10,
|
|
129
|
+
cyclesToThreshold,
|
|
130
|
+
threshold: DEFAULT_THRESHOLD,
|
|
131
|
+
trendDirection,
|
|
132
|
+
description,
|
|
133
|
+
};
|
|
134
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { InferenceInput, InferenceReport, InferenceSummary, ScoreDataPoint, ActivityEntry, InferenceFinding } from "./types.js";
|
|
2
|
+
export type { InferenceInput, InferenceReport, InferenceSummary, ScoreDataPoint, ActivityEntry, InferenceFinding };
|
|
3
|
+
export type { FindingCluster, ClusteringResult, RootCauseNode, RootCauseResult, ScoreAnomaly, ScoreAnomalyResult, TrendPrediction, TrendPredictionResult } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Load all inference input data from a project's .ges/ directory.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadInferenceInput(projectPath: string): InferenceInput;
|
|
8
|
+
/**
|
|
9
|
+
* Run the full inference pipeline on a project.
|
|
10
|
+
*/
|
|
11
|
+
export declare function runInference(projectPath: string): InferenceReport;
|
|
12
|
+
/**
|
|
13
|
+
* Run inference from pre-loaded input data (useful for testing).
|
|
14
|
+
*/
|
|
15
|
+
export declare function runInferenceFromInput(input: InferenceInput): InferenceReport;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { clusterFindings } from "./analyzers/finding-clusterer.js";
|
|
4
|
+
import { analyzeRootCause } from "./analyzers/root-cause.js";
|
|
5
|
+
import { detectScoreAnomalies } from "./analyzers/score-anomaly.js";
|
|
6
|
+
import { predictTrends } from "./analyzers/trend-predictor.js";
|
|
7
|
+
/**
|
|
8
|
+
* Load all inference input data from a project's .ges/ directory.
|
|
9
|
+
*/
|
|
10
|
+
export function loadInferenceInput(projectPath) {
|
|
11
|
+
const gesDir = path.join(projectPath, ".ges");
|
|
12
|
+
const findings = loadFindings(gesDir);
|
|
13
|
+
const scoreHistory = loadScoreHistory(gesDir);
|
|
14
|
+
const activityLog = loadActivity(gesDir);
|
|
15
|
+
return { findings, scoreHistory, activityLog };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Run the full inference pipeline on a project.
|
|
19
|
+
*/
|
|
20
|
+
export function runInference(projectPath) {
|
|
21
|
+
const input = loadInferenceInput(projectPath);
|
|
22
|
+
return runInferenceFromInput(input);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Run inference from pre-loaded input data (useful for testing).
|
|
26
|
+
*/
|
|
27
|
+
export function runInferenceFromInput(input) {
|
|
28
|
+
const clustering = input.findings.length > 0
|
|
29
|
+
? clusterFindings(input.findings)
|
|
30
|
+
: null;
|
|
31
|
+
const rootCause = input.findings.length > 0
|
|
32
|
+
? analyzeRootCause(input.findings)
|
|
33
|
+
: null;
|
|
34
|
+
const scoreAnomalies = input.scoreHistory.length >= 2
|
|
35
|
+
? detectScoreAnomalies(input.scoreHistory, input.activityLog)
|
|
36
|
+
: null;
|
|
37
|
+
const trends = input.scoreHistory.length >= 2
|
|
38
|
+
? predictTrends(input.scoreHistory)
|
|
39
|
+
: null;
|
|
40
|
+
const summary = buildSummary(input, clustering, rootCause, scoreAnomalies, trends);
|
|
41
|
+
return {
|
|
42
|
+
generatedAt: new Date().toISOString(),
|
|
43
|
+
clustering,
|
|
44
|
+
rootCause,
|
|
45
|
+
scoreAnomalies,
|
|
46
|
+
trends,
|
|
47
|
+
summary,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// ============================================================
|
|
51
|
+
// Data loaders
|
|
52
|
+
// ============================================================
|
|
53
|
+
function loadFindings(gesDir) {
|
|
54
|
+
const findingsPath = path.join(gesDir, "last-audit.json");
|
|
55
|
+
try {
|
|
56
|
+
const raw = fs.readFileSync(findingsPath, "utf-8");
|
|
57
|
+
const data = JSON.parse(raw);
|
|
58
|
+
const findings = Array.isArray(data.findings) ? data.findings : [];
|
|
59
|
+
// Filter out invalid entries
|
|
60
|
+
return findings.filter((f) => f !== null &&
|
|
61
|
+
typeof f === "object" &&
|
|
62
|
+
typeof f.ruleId === "string");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function loadScoreHistory(gesDir) {
|
|
69
|
+
const historyPath = path.join(gesDir, "score.json");
|
|
70
|
+
try {
|
|
71
|
+
const raw = fs.readFileSync(historyPath, "utf-8");
|
|
72
|
+
const data = JSON.parse(raw);
|
|
73
|
+
// score.json is a single ScoreFile. Build a single-element history.
|
|
74
|
+
if (data && typeof data.overall === "number") {
|
|
75
|
+
const point = {
|
|
76
|
+
overall: data.overall,
|
|
77
|
+
evaluatedAt: data.evaluated_at ?? new Date().toISOString(),
|
|
78
|
+
frameworks: {},
|
|
79
|
+
};
|
|
80
|
+
if (data.frameworks && typeof data.frameworks === "object") {
|
|
81
|
+
for (const [key, val] of Object.entries(data.frameworks)) {
|
|
82
|
+
if (val !== null && typeof val === "object" && typeof val.score === "number") {
|
|
83
|
+
point.frameworks[key] = { score: val.score };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [point];
|
|
88
|
+
}
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function loadActivity(gesDir) {
|
|
96
|
+
const activityPath = path.join(gesDir, "activity-log.json");
|
|
97
|
+
try {
|
|
98
|
+
const raw = fs.readFileSync(activityPath, "utf-8");
|
|
99
|
+
const data = JSON.parse(raw);
|
|
100
|
+
const entries = Array.isArray(data) ? data : [];
|
|
101
|
+
return entries.filter((e) => e !== null &&
|
|
102
|
+
typeof e === "object" &&
|
|
103
|
+
typeof e.timestamp === "string");
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// ============================================================
|
|
110
|
+
// Summary builder
|
|
111
|
+
// ============================================================
|
|
112
|
+
function buildSummary(input, clustering, rootCause, scoreAnomalies, trends) {
|
|
113
|
+
let insightCount = 0;
|
|
114
|
+
// Count insights from each analyzer
|
|
115
|
+
if (clustering && clustering.clusterCount > 0 && clustering.reductionRatio > 20)
|
|
116
|
+
insightCount++;
|
|
117
|
+
if (rootCause?.topCause)
|
|
118
|
+
insightCount++;
|
|
119
|
+
if (scoreAnomalies?.hasAnomalies) {
|
|
120
|
+
insightCount += scoreAnomalies.anomalies.filter((a) => a.isAnomalous).length;
|
|
121
|
+
}
|
|
122
|
+
if (trends?.overall && trends.overall.trendDirection === "declining")
|
|
123
|
+
insightCount++;
|
|
124
|
+
for (const pred of trends?.predictions ?? []) {
|
|
125
|
+
if (pred.trendDirection === "declining" && pred.cyclesToThreshold !== null)
|
|
126
|
+
insightCount++;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
totalFindings: input.findings.length,
|
|
130
|
+
distinctPatterns: clustering?.clusterCount ?? 0,
|
|
131
|
+
reductionRatio: clustering?.reductionRatio ?? 0,
|
|
132
|
+
topRootCause: rootCause?.topCause?.identifier ?? null,
|
|
133
|
+
topRootCauseImpact: rootCause?.topCause
|
|
134
|
+
? `${rootCause.topCause.connectedFindings.length} of ${rootCause.totalFindingsTraced} findings (${Math.round(rootCause.topCause.betweenness * 100)}%)`
|
|
135
|
+
: null,
|
|
136
|
+
hasScoreAnomalies: scoreAnomalies?.hasAnomalies ?? false,
|
|
137
|
+
overallTrend: trends?.overall?.trendDirection ?? "unknown",
|
|
138
|
+
insightCount,
|
|
139
|
+
};
|
|
140
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { SeverityLevel } from "@greenarmor/ges-core";
|
|
2
|
+
export interface InferenceFinding {
|
|
3
|
+
ruleId: string;
|
|
4
|
+
severity: SeverityLevel;
|
|
5
|
+
category: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
file: string;
|
|
9
|
+
line?: number;
|
|
10
|
+
evidence: string;
|
|
11
|
+
controlIds: string[];
|
|
12
|
+
fix: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ScoreDataPoint {
|
|
15
|
+
overall: number;
|
|
16
|
+
evaluatedAt: string;
|
|
17
|
+
frameworks: Record<string, {
|
|
18
|
+
score: number;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export interface ActivityEntry {
|
|
22
|
+
timestamp: string;
|
|
23
|
+
action: string;
|
|
24
|
+
title: string;
|
|
25
|
+
description: string;
|
|
26
|
+
status: string;
|
|
27
|
+
details: {
|
|
28
|
+
score?: number;
|
|
29
|
+
findings_count?: number;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export interface FindingCluster {
|
|
34
|
+
clusterId: string;
|
|
35
|
+
pattern: string;
|
|
36
|
+
ruleId: string;
|
|
37
|
+
category: string;
|
|
38
|
+
severity: SeverityLevel;
|
|
39
|
+
findingCount: number;
|
|
40
|
+
files: string[];
|
|
41
|
+
representativeTitle: string;
|
|
42
|
+
representativeFix: string;
|
|
43
|
+
findingIndices: number[];
|
|
44
|
+
}
|
|
45
|
+
export interface ClusteringResult {
|
|
46
|
+
totalFindings: number;
|
|
47
|
+
clusterCount: number;
|
|
48
|
+
clusters: FindingCluster[];
|
|
49
|
+
reductionRatio: number;
|
|
50
|
+
}
|
|
51
|
+
export interface RootCauseNode {
|
|
52
|
+
type: "file" | "control";
|
|
53
|
+
identifier: string;
|
|
54
|
+
connectedFindings: number[];
|
|
55
|
+
severityScore: number;
|
|
56
|
+
betweenness: number;
|
|
57
|
+
frameworks: string[];
|
|
58
|
+
}
|
|
59
|
+
export interface RootCauseResult {
|
|
60
|
+
nodes: RootCauseNode[];
|
|
61
|
+
topCause: RootCauseNode | null;
|
|
62
|
+
summary: string;
|
|
63
|
+
totalFindingsTraced: number;
|
|
64
|
+
maxSeverityScore: number;
|
|
65
|
+
}
|
|
66
|
+
export interface ScoreAnomaly {
|
|
67
|
+
framework: string;
|
|
68
|
+
delta: number;
|
|
69
|
+
previousScore: number;
|
|
70
|
+
currentScore: number;
|
|
71
|
+
zScore: number;
|
|
72
|
+
isAnomalous: boolean;
|
|
73
|
+
evaluatedAt: string;
|
|
74
|
+
triggeringEvent: string | null;
|
|
75
|
+
description: string;
|
|
76
|
+
}
|
|
77
|
+
export interface ScoreAnomalyResult {
|
|
78
|
+
anomalies: ScoreAnomaly[];
|
|
79
|
+
hasAnomalies: boolean;
|
|
80
|
+
averageScore: number;
|
|
81
|
+
standardDeviation: number;
|
|
82
|
+
dataPointCount: number;
|
|
83
|
+
}
|
|
84
|
+
export interface TrendPrediction {
|
|
85
|
+
framework: string;
|
|
86
|
+
slope: number;
|
|
87
|
+
intercept: number;
|
|
88
|
+
rSquared: number;
|
|
89
|
+
currentScore: number;
|
|
90
|
+
projectedScore: number;
|
|
91
|
+
cyclesToThreshold: number | null;
|
|
92
|
+
threshold: number;
|
|
93
|
+
trendDirection: "improving" | "declining" | "stable";
|
|
94
|
+
description: string;
|
|
95
|
+
}
|
|
96
|
+
export interface TrendPredictionResult {
|
|
97
|
+
predictions: TrendPrediction[];
|
|
98
|
+
overall: TrendPrediction | null;
|
|
99
|
+
dataPointCount: number;
|
|
100
|
+
}
|
|
101
|
+
export interface InferenceReport {
|
|
102
|
+
generatedAt: string;
|
|
103
|
+
clustering: ClusteringResult | null;
|
|
104
|
+
rootCause: RootCauseResult | null;
|
|
105
|
+
scoreAnomalies: ScoreAnomalyResult | null;
|
|
106
|
+
trends: TrendPredictionResult | null;
|
|
107
|
+
summary: InferenceSummary;
|
|
108
|
+
}
|
|
109
|
+
export interface InferenceSummary {
|
|
110
|
+
totalFindings: number;
|
|
111
|
+
distinctPatterns: number;
|
|
112
|
+
reductionRatio: number;
|
|
113
|
+
topRootCause: string | null;
|
|
114
|
+
topRootCauseImpact: string | null;
|
|
115
|
+
hasScoreAnomalies: boolean;
|
|
116
|
+
overallTrend: "improving" | "declining" | "stable" | "unknown";
|
|
117
|
+
insightCount: number;
|
|
118
|
+
}
|
|
119
|
+
export interface InferenceInput {
|
|
120
|
+
findings: InferenceFinding[];
|
|
121
|
+
scoreHistory: ScoreDataPoint[];
|
|
122
|
+
activityLog: ActivityEntry[];
|
|
123
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@greenarmor/ges-core": "1.6.0"
|
|
4
|
+
},
|
|
5
|
+
"description": "GESF Inference Engine - AI-powered analysis of compliance data (clustering, RCA, anomaly detection, trend prediction)",
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"@types/node": "^22.0.0",
|
|
8
|
+
"typescript": "^6.0.0",
|
|
9
|
+
"vitest": "^4.1.8"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"default": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"name": "@greenarmor/ges-inference-engine",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/greenarmor/gesf.git"
|
|
32
|
+
},
|
|
33
|
+
"type": "module",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"version": "1.6.0",
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc",
|
|
38
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
}
|
|
41
|
+
}
|