@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 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
+ }
@@ -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
+ }
@@ -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
+ }