@grc-claw/evidence-automation-engine 0.8.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/dist/EvidenceAutomationEngine.d.ts +38 -0
- package/dist/EvidenceAutomationEngine.js +256 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +166 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +80 -0
- package/package.json +33 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type EvidenceArtifact, type CollectionSchedule, type CollectionJob, type ScheduleConfig, type EvidenceGap, type EvidenceSummaryReport, type EvidenceStore } from "./types.js";
|
|
2
|
+
export interface ConnectorAdapter {
|
|
3
|
+
collectEvidence(): Promise<EvidenceArtifact[]>;
|
|
4
|
+
testConnection(): Promise<boolean>;
|
|
5
|
+
}
|
|
6
|
+
export interface EvidenceAutomationConfig {
|
|
7
|
+
defaultFreshnessHours?: number;
|
|
8
|
+
staleThresholdHours?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class EvidenceAutomationEngine {
|
|
11
|
+
private store;
|
|
12
|
+
private schedules;
|
|
13
|
+
private jobsArray;
|
|
14
|
+
private connectors;
|
|
15
|
+
private timers;
|
|
16
|
+
private config;
|
|
17
|
+
private runLoopActive;
|
|
18
|
+
constructor(config?: EvidenceAutomationConfig);
|
|
19
|
+
registerConnector(id: string, adapter: ConnectorAdapter): void;
|
|
20
|
+
unregisterConnector(id: string): void;
|
|
21
|
+
createSchedule(connectorId: string, config: ScheduleConfig): CollectionSchedule;
|
|
22
|
+
updateSchedule(scheduleId: string, updates: Partial<CollectionSchedule>): CollectionSchedule | null;
|
|
23
|
+
deleteSchedule(scheduleId: string): boolean;
|
|
24
|
+
getSchedules(): CollectionSchedule[];
|
|
25
|
+
getSchedule(scheduleId: string): CollectionSchedule | undefined;
|
|
26
|
+
collectFromConnector(connectorId: string): Promise<CollectionJob>;
|
|
27
|
+
collectAll(): Promise<CollectionJob[]>;
|
|
28
|
+
startScheduler(): void;
|
|
29
|
+
stopScheduler(): void;
|
|
30
|
+
private runLoop;
|
|
31
|
+
detectGaps(): EvidenceGap[];
|
|
32
|
+
private getConnectorIdsForControl;
|
|
33
|
+
generateSummaryReport(): EvidenceSummaryReport;
|
|
34
|
+
getStore(): EvidenceStore;
|
|
35
|
+
getJobs(): CollectionJob[];
|
|
36
|
+
getRecentJobs(limit?: number): CollectionJob[];
|
|
37
|
+
clearStore(): void;
|
|
38
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { generateId, computeNextRun, assessFreshness, getControlFrameworkMap, } from "./types.js";
|
|
2
|
+
class InMemoryEvidenceStore {
|
|
3
|
+
artifacts = new Map();
|
|
4
|
+
add(artifact) {
|
|
5
|
+
this.artifacts.set(artifact.id, artifact);
|
|
6
|
+
}
|
|
7
|
+
get(id) {
|
|
8
|
+
return this.artifacts.get(id);
|
|
9
|
+
}
|
|
10
|
+
getAll() {
|
|
11
|
+
return Array.from(this.artifacts.values());
|
|
12
|
+
}
|
|
13
|
+
getByConnector(connectorId) {
|
|
14
|
+
return this.getAll().filter((a) => a.connectorId === connectorId);
|
|
15
|
+
}
|
|
16
|
+
getByControl(controlId) {
|
|
17
|
+
return this.getAll().filter((a) => a.controlId === controlId);
|
|
18
|
+
}
|
|
19
|
+
getByFramework(framework) {
|
|
20
|
+
return this.getAll().filter((a) => a.framework === framework);
|
|
21
|
+
}
|
|
22
|
+
remove(id) {
|
|
23
|
+
return this.artifacts.delete(id);
|
|
24
|
+
}
|
|
25
|
+
get size() {
|
|
26
|
+
return this.artifacts.size;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class EvidenceAutomationEngine {
|
|
30
|
+
store;
|
|
31
|
+
schedules = new Map();
|
|
32
|
+
jobsArray = [];
|
|
33
|
+
connectors = new Map();
|
|
34
|
+
timers = new Map();
|
|
35
|
+
config;
|
|
36
|
+
runLoopActive = false;
|
|
37
|
+
constructor(config = {}) {
|
|
38
|
+
this.store = new InMemoryEvidenceStore();
|
|
39
|
+
this.config = {
|
|
40
|
+
defaultFreshnessHours: config.defaultFreshnessHours ?? 24 * 30,
|
|
41
|
+
staleThresholdHours: config.staleThresholdHours ?? 24 * 14,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
registerConnector(id, adapter) {
|
|
45
|
+
this.connectors.set(id, adapter);
|
|
46
|
+
}
|
|
47
|
+
unregisterConnector(id) {
|
|
48
|
+
this.connectors.delete(id);
|
|
49
|
+
for (const [scheduleId, s] of this.schedules) {
|
|
50
|
+
if (s.connectorId === id)
|
|
51
|
+
this.schedules.delete(scheduleId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
createSchedule(connectorId, config) {
|
|
55
|
+
if (!this.connectors.has(connectorId)) {
|
|
56
|
+
throw new Error(`Connector not registered: ${connectorId}`);
|
|
57
|
+
}
|
|
58
|
+
const schedule = {
|
|
59
|
+
id: generateId("sched"),
|
|
60
|
+
connectorId,
|
|
61
|
+
config,
|
|
62
|
+
enabled: true,
|
|
63
|
+
nextRunAt: computeNextRun(config),
|
|
64
|
+
};
|
|
65
|
+
this.schedules.set(schedule.id, schedule);
|
|
66
|
+
return schedule;
|
|
67
|
+
}
|
|
68
|
+
updateSchedule(scheduleId, updates) {
|
|
69
|
+
const schedule = this.schedules.get(scheduleId);
|
|
70
|
+
if (!schedule)
|
|
71
|
+
return null;
|
|
72
|
+
if (updates.config) {
|
|
73
|
+
schedule.config = updates.config;
|
|
74
|
+
schedule.nextRunAt = computeNextRun(updates.config, schedule.lastRunAt);
|
|
75
|
+
}
|
|
76
|
+
if (updates.enabled !== undefined)
|
|
77
|
+
schedule.enabled = updates.enabled;
|
|
78
|
+
return schedule;
|
|
79
|
+
}
|
|
80
|
+
deleteSchedule(scheduleId) {
|
|
81
|
+
const timer = this.timers.get(scheduleId);
|
|
82
|
+
if (timer) {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
this.timers.delete(scheduleId);
|
|
85
|
+
}
|
|
86
|
+
return this.schedules.delete(scheduleId);
|
|
87
|
+
}
|
|
88
|
+
getSchedules() {
|
|
89
|
+
return Array.from(this.schedules.values());
|
|
90
|
+
}
|
|
91
|
+
getSchedule(scheduleId) {
|
|
92
|
+
return this.schedules.get(scheduleId);
|
|
93
|
+
}
|
|
94
|
+
async collectFromConnector(connectorId) {
|
|
95
|
+
const adapter = this.connectors.get(connectorId);
|
|
96
|
+
if (!adapter)
|
|
97
|
+
throw new Error(`Connector not found: ${connectorId}`);
|
|
98
|
+
const job = {
|
|
99
|
+
id: generateId("job"),
|
|
100
|
+
connectorId,
|
|
101
|
+
startedAt: new Date().toISOString(),
|
|
102
|
+
status: "running",
|
|
103
|
+
artifacts: [],
|
|
104
|
+
};
|
|
105
|
+
this.jobsArray.push(job);
|
|
106
|
+
try {
|
|
107
|
+
const artifacts = await adapter.collectEvidence();
|
|
108
|
+
for (const artifact of artifacts) {
|
|
109
|
+
this.store.add(artifact);
|
|
110
|
+
}
|
|
111
|
+
job.artifacts = artifacts;
|
|
112
|
+
job.status = "completed";
|
|
113
|
+
job.completedAt = new Date().toISOString();
|
|
114
|
+
job.duration =
|
|
115
|
+
new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime();
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
job.status = "failed";
|
|
119
|
+
job.completedAt = new Date().toISOString();
|
|
120
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
121
|
+
job.duration =
|
|
122
|
+
new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime();
|
|
123
|
+
}
|
|
124
|
+
const schedule = Array.from(this.schedules.values()).find((s) => s.connectorId === connectorId);
|
|
125
|
+
if (schedule) {
|
|
126
|
+
schedule.lastRunAt = job.completedAt;
|
|
127
|
+
schedule.lastJobId = job.id;
|
|
128
|
+
schedule.nextRunAt = computeNextRun(schedule.config, schedule.lastRunAt);
|
|
129
|
+
}
|
|
130
|
+
return job;
|
|
131
|
+
}
|
|
132
|
+
async collectAll() {
|
|
133
|
+
const jobs = [];
|
|
134
|
+
for (const connectorId of this.connectors.keys()) {
|
|
135
|
+
jobs.push(await this.collectFromConnector(connectorId));
|
|
136
|
+
}
|
|
137
|
+
return jobs;
|
|
138
|
+
}
|
|
139
|
+
startScheduler() {
|
|
140
|
+
if (this.runLoopActive)
|
|
141
|
+
return;
|
|
142
|
+
this.runLoopActive = true;
|
|
143
|
+
this.runLoop();
|
|
144
|
+
}
|
|
145
|
+
stopScheduler() {
|
|
146
|
+
this.runLoopActive = false;
|
|
147
|
+
for (const timer of this.timers.values()) {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
}
|
|
150
|
+
this.timers.clear();
|
|
151
|
+
}
|
|
152
|
+
runLoop() {
|
|
153
|
+
if (!this.runLoopActive)
|
|
154
|
+
return;
|
|
155
|
+
const now = new Date();
|
|
156
|
+
for (const schedule of this.schedules.values()) {
|
|
157
|
+
if (!schedule.enabled || !schedule.nextRunAt)
|
|
158
|
+
continue;
|
|
159
|
+
const nextRun = new Date(schedule.nextRunAt);
|
|
160
|
+
if (nextRun <= now) {
|
|
161
|
+
this.collectFromConnector(schedule.connectorId).catch(() => { });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
setTimeout(() => this.runLoop(), 60_000);
|
|
165
|
+
}
|
|
166
|
+
detectGaps() {
|
|
167
|
+
const controlMap = getControlFrameworkMap();
|
|
168
|
+
const gaps = [];
|
|
169
|
+
for (const [controlId, frameworks] of Object.entries(controlMap)) {
|
|
170
|
+
for (const framework of frameworks) {
|
|
171
|
+
const evidence = this.store.getByFramework(framework).filter((a) => a.controlId === controlId);
|
|
172
|
+
if (evidence.length === 0) {
|
|
173
|
+
gaps.push({
|
|
174
|
+
controlId,
|
|
175
|
+
framework,
|
|
176
|
+
requiredBy: this.getConnectorIdsForControl(controlId),
|
|
177
|
+
freshness: "missing",
|
|
178
|
+
connectors: [],
|
|
179
|
+
recommendation: `No evidence collected for control ${controlId}. Configure a connector to collect this evidence.`,
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const latest = evidence.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
|
|
184
|
+
const freshness = assessFreshness(latest, this.config.defaultFreshnessHours);
|
|
185
|
+
if (freshness === "expired") {
|
|
186
|
+
gaps.push({
|
|
187
|
+
controlId,
|
|
188
|
+
framework,
|
|
189
|
+
requiredBy: this.getConnectorIdsForControl(controlId),
|
|
190
|
+
lastCollectedAt: latest.timestamp,
|
|
191
|
+
freshness,
|
|
192
|
+
connectors: [...new Set(evidence.map((e) => e.connectorId))],
|
|
193
|
+
recommendation: `Evidence for control ${controlId} is expired. Re-collect immediately.`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
else if (freshness === "stale") {
|
|
197
|
+
gaps.push({
|
|
198
|
+
controlId,
|
|
199
|
+
framework,
|
|
200
|
+
requiredBy: this.getConnectorIdsForControl(controlId),
|
|
201
|
+
lastCollectedAt: latest.timestamp,
|
|
202
|
+
freshness,
|
|
203
|
+
connectors: [...new Set(evidence.map((e) => e.connectorId))],
|
|
204
|
+
recommendation: `Evidence for control ${controlId} is stale. Schedule more frequent collection.`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return gaps;
|
|
210
|
+
}
|
|
211
|
+
getConnectorIdsForControl(controlId) {
|
|
212
|
+
return Array.from(this.connectors.keys()).filter((id) => {
|
|
213
|
+
const evidence = this.store.getByConnector(id);
|
|
214
|
+
return evidence.some((e) => e.controlId === controlId);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
generateSummaryReport() {
|
|
218
|
+
const artifacts = this.store.getAll();
|
|
219
|
+
const byFramework = {};
|
|
220
|
+
const byStatus = {};
|
|
221
|
+
const freshness = { fresh: 0, stale: 0, expired: 0, missing: 0 };
|
|
222
|
+
for (const artifact of artifacts) {
|
|
223
|
+
byFramework[artifact.framework] = (byFramework[artifact.framework] || 0) + 1;
|
|
224
|
+
byStatus[artifact.status] = (byStatus[artifact.status] || 0) + 1;
|
|
225
|
+
const f = assessFreshness(artifact, this.config.defaultFreshnessHours);
|
|
226
|
+
freshness[f] = (freshness[f] || 0) + 1;
|
|
227
|
+
}
|
|
228
|
+
const gaps = this.detectGaps();
|
|
229
|
+
const controlMap = getControlFrameworkMap();
|
|
230
|
+
const totalControls = Object.keys(controlMap).length;
|
|
231
|
+
const coveredControls = new Set(artifacts.map((a) => a.controlId)).size;
|
|
232
|
+
return {
|
|
233
|
+
generatedAt: new Date().toISOString(),
|
|
234
|
+
totalArtifacts: artifacts.length,
|
|
235
|
+
artifactsByFramework: byFramework,
|
|
236
|
+
artifactsByStatus: byStatus,
|
|
237
|
+
gaps,
|
|
238
|
+
totalGaps: gaps.length,
|
|
239
|
+
freshness: freshness,
|
|
240
|
+
coveragePercentage: totalControls > 0 ? Math.round((coveredControls / totalControls) * 100) : 0,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
getStore() {
|
|
244
|
+
return this.store;
|
|
245
|
+
}
|
|
246
|
+
getJobs() {
|
|
247
|
+
return [...this.jobsArray];
|
|
248
|
+
}
|
|
249
|
+
getRecentJobs(limit = 10) {
|
|
250
|
+
return this.jobsArray.slice(-limit);
|
|
251
|
+
}
|
|
252
|
+
clearStore() {
|
|
253
|
+
const inMem = this.store;
|
|
254
|
+
inMem.artifacts.clear();
|
|
255
|
+
}
|
|
256
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { EvidenceAutomationEngine } from "./EvidenceAutomationEngine.js";
|
|
2
|
+
export type { ConnectorAdapter, EvidenceAutomationConfig } from "./EvidenceAutomationEngine.js";
|
|
3
|
+
export type { EvidenceArtifact, CollectionSchedule, CollectionJob, ScheduleConfig, ScheduleFrequency, JobStatus, EvidenceGap, EvidenceSummaryReport, EvidenceStore, EvidenceFreshness, ComplianceFramework, } from "./types.js";
|
|
4
|
+
export { hashData, generateId, computeNextRun, assessFreshness, getControlFrameworkMap, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EvidenceAutomationEngine } from "./EvidenceAutomationEngine.js";
|
|
4
|
+
function createMockConnector(id, fail = false) {
|
|
5
|
+
return {
|
|
6
|
+
async collectEvidence() {
|
|
7
|
+
if (fail)
|
|
8
|
+
throw new Error(`Connector ${id} failed`);
|
|
9
|
+
return [
|
|
10
|
+
{
|
|
11
|
+
id: `ev-${id}-1`,
|
|
12
|
+
connectorId: id,
|
|
13
|
+
capabilityId: "test-cap",
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
hash: `sha256:test-${id}`,
|
|
16
|
+
framework: "SOC2",
|
|
17
|
+
controlId: "CC6.1",
|
|
18
|
+
source: `${id}/test`,
|
|
19
|
+
status: "compliant",
|
|
20
|
+
data: { test: true },
|
|
21
|
+
metadata: {},
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
},
|
|
25
|
+
async testConnection() {
|
|
26
|
+
return !fail;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
describe("EvidenceAutomationEngine", () => {
|
|
31
|
+
it("should create engine with default config", () => {
|
|
32
|
+
const engine = new EvidenceAutomationEngine();
|
|
33
|
+
assert.ok(engine);
|
|
34
|
+
assert.equal(engine.getStore().size, 0);
|
|
35
|
+
});
|
|
36
|
+
it("should register and unregister connectors", () => {
|
|
37
|
+
const engine = new EvidenceAutomationEngine();
|
|
38
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
39
|
+
engine.registerConnector("gitlab", createMockConnector("gitlab"));
|
|
40
|
+
const store = engine.getStore();
|
|
41
|
+
assert.equal(store.size, 0);
|
|
42
|
+
});
|
|
43
|
+
it("should create schedules", () => {
|
|
44
|
+
const engine = new EvidenceAutomationEngine();
|
|
45
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
46
|
+
const schedule = engine.createSchedule("github", {
|
|
47
|
+
frequency: "daily",
|
|
48
|
+
hourOfDay: 9,
|
|
49
|
+
});
|
|
50
|
+
assert.ok(schedule.id);
|
|
51
|
+
assert.equal(schedule.connectorId, "github");
|
|
52
|
+
assert.equal(schedule.enabled, true);
|
|
53
|
+
assert.ok(schedule.nextRunAt);
|
|
54
|
+
});
|
|
55
|
+
it("should throw when creating schedule for unregistered connector", () => {
|
|
56
|
+
const engine = new EvidenceAutomationEngine();
|
|
57
|
+
assert.throws(() => {
|
|
58
|
+
engine.createSchedule("nonexistent", { frequency: "daily" });
|
|
59
|
+
}, /not registered/);
|
|
60
|
+
});
|
|
61
|
+
it("should update schedules", () => {
|
|
62
|
+
const engine = new EvidenceAutomationEngine();
|
|
63
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
64
|
+
const schedule = engine.createSchedule("github", { frequency: "daily" });
|
|
65
|
+
const updated = engine.updateSchedule(schedule.id, {
|
|
66
|
+
config: { frequency: "weekly" },
|
|
67
|
+
});
|
|
68
|
+
assert.ok(updated);
|
|
69
|
+
assert.equal(updated.config.frequency, "weekly");
|
|
70
|
+
});
|
|
71
|
+
it("should delete schedules", () => {
|
|
72
|
+
const engine = new EvidenceAutomationEngine();
|
|
73
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
74
|
+
const schedule = engine.createSchedule("github", { frequency: "daily" });
|
|
75
|
+
assert.ok(engine.deleteSchedule(schedule.id));
|
|
76
|
+
assert.equal(engine.getSchedule(schedule.id), undefined);
|
|
77
|
+
});
|
|
78
|
+
it("should collect evidence from a connector", async () => {
|
|
79
|
+
const engine = new EvidenceAutomationEngine();
|
|
80
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
81
|
+
const job = await engine.collectFromConnector("github");
|
|
82
|
+
assert.equal(job.status, "completed");
|
|
83
|
+
assert.equal(job.artifacts.length, 1);
|
|
84
|
+
assert.equal(job.connectorId, "github");
|
|
85
|
+
assert.ok(job.duration !== undefined);
|
|
86
|
+
});
|
|
87
|
+
it("should store evidence after collection", async () => {
|
|
88
|
+
const engine = new EvidenceAutomationEngine();
|
|
89
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
90
|
+
await engine.collectFromConnector("github");
|
|
91
|
+
assert.equal(engine.getStore().size, 1);
|
|
92
|
+
});
|
|
93
|
+
it("should handle collection failures", async () => {
|
|
94
|
+
const engine = new EvidenceAutomationEngine();
|
|
95
|
+
engine.registerConnector("failing", createMockConnector("failing", true));
|
|
96
|
+
const job = await engine.collectFromConnector("failing");
|
|
97
|
+
assert.equal(job.status, "failed");
|
|
98
|
+
assert.ok(job.error);
|
|
99
|
+
assert.equal(job.artifacts.length, 0);
|
|
100
|
+
});
|
|
101
|
+
it("should collect from all connectors", async () => {
|
|
102
|
+
const engine = new EvidenceAutomationEngine();
|
|
103
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
104
|
+
engine.registerConnector("gitlab", createMockConnector("gitlab"));
|
|
105
|
+
const jobs = await engine.collectAll();
|
|
106
|
+
assert.equal(jobs.length, 2);
|
|
107
|
+
assert.ok(jobs.every((j) => j.status === "completed"));
|
|
108
|
+
assert.equal(engine.getStore().size, 2);
|
|
109
|
+
});
|
|
110
|
+
it("should update schedule lastRunAt after collection", async () => {
|
|
111
|
+
const engine = new EvidenceAutomationEngine();
|
|
112
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
113
|
+
const schedule = engine.createSchedule("github", { frequency: "daily" });
|
|
114
|
+
await engine.collectFromConnector("github");
|
|
115
|
+
const updated = engine.getSchedule(schedule.id);
|
|
116
|
+
assert.ok(updated?.lastRunAt);
|
|
117
|
+
});
|
|
118
|
+
it("should detect missing evidence gaps", () => {
|
|
119
|
+
const engine = new EvidenceAutomationEngine();
|
|
120
|
+
const gaps = engine.detectGaps();
|
|
121
|
+
assert.ok(gaps.length > 0);
|
|
122
|
+
assert.ok(gaps.every((g) => g.freshness === "missing"));
|
|
123
|
+
});
|
|
124
|
+
it("should detect no gaps when evidence is fresh", async () => {
|
|
125
|
+
const engine = new EvidenceAutomationEngine();
|
|
126
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
127
|
+
await engine.collectFromConnector("github");
|
|
128
|
+
const gaps = engine.detectGaps();
|
|
129
|
+
const githubGap = gaps.find((g) => g.controlId === "CC6.1" && g.framework === "SOC2");
|
|
130
|
+
assert.equal(githubGap, undefined);
|
|
131
|
+
});
|
|
132
|
+
it("should generate summary report", async () => {
|
|
133
|
+
const engine = new EvidenceAutomationEngine();
|
|
134
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
135
|
+
await engine.collectFromConnector("github");
|
|
136
|
+
const report = engine.generateSummaryReport();
|
|
137
|
+
assert.equal(report.totalArtifacts, 1);
|
|
138
|
+
assert.ok(report.generatedAt);
|
|
139
|
+
assert.equal(report.artifactsByStatus["compliant"], 1);
|
|
140
|
+
assert.equal(report.coveragePercentage > 0, true);
|
|
141
|
+
});
|
|
142
|
+
it("should track jobs", async () => {
|
|
143
|
+
const engine = new EvidenceAutomationEngine();
|
|
144
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
145
|
+
await engine.collectFromConnector("github");
|
|
146
|
+
const jobs = engine.getJobs();
|
|
147
|
+
assert.equal(jobs.length, 1);
|
|
148
|
+
assert.equal(jobs[0].status, "completed");
|
|
149
|
+
});
|
|
150
|
+
it("should clear store", async () => {
|
|
151
|
+
const engine = new EvidenceAutomationEngine();
|
|
152
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
153
|
+
await engine.collectFromConnector("github");
|
|
154
|
+
assert.equal(engine.getStore().size, 1);
|
|
155
|
+
engine.clearStore();
|
|
156
|
+
assert.equal(engine.getStore().size, 0);
|
|
157
|
+
});
|
|
158
|
+
it("should start and stop scheduler", () => {
|
|
159
|
+
const engine = new EvidenceAutomationEngine();
|
|
160
|
+
engine.registerConnector("github", createMockConnector("github"));
|
|
161
|
+
engine.createSchedule("github", { frequency: "hourly" });
|
|
162
|
+
engine.startScheduler();
|
|
163
|
+
engine.stopScheduler();
|
|
164
|
+
assert.equal(engine.getSchedules().length, 1);
|
|
165
|
+
});
|
|
166
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type ComplianceFramework = "SOC2" | "ISO27001" | "NIST_CSF" | "HIPAA" | "PCI_DSS" | "GDPR" | "CIS";
|
|
2
|
+
export type ScheduleFrequency = "hourly" | "daily" | "weekly" | "monthly" | "quarterly";
|
|
3
|
+
export type JobStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
|
4
|
+
export type EvidenceFreshness = "fresh" | "stale" | "expired" | "missing";
|
|
5
|
+
export interface ScheduleConfig {
|
|
6
|
+
frequency: ScheduleFrequency;
|
|
7
|
+
interval?: number;
|
|
8
|
+
dayOfWeek?: number;
|
|
9
|
+
dayOfMonth?: number;
|
|
10
|
+
hourOfDay?: number;
|
|
11
|
+
timezone?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface EvidenceArtifact {
|
|
14
|
+
id: string;
|
|
15
|
+
connectorId: string;
|
|
16
|
+
capabilityId: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
hash: string;
|
|
19
|
+
framework: ComplianceFramework;
|
|
20
|
+
controlId: string;
|
|
21
|
+
source: string;
|
|
22
|
+
status: "compliant" | "non_compliant" | "partial" | "unknown";
|
|
23
|
+
data: Record<string, unknown>;
|
|
24
|
+
metadata: Record<string, string>;
|
|
25
|
+
expiresAt?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface EvidenceStore {
|
|
28
|
+
artifacts: Map<string, EvidenceArtifact>;
|
|
29
|
+
add(artifact: EvidenceArtifact): void;
|
|
30
|
+
get(id: string): EvidenceArtifact | undefined;
|
|
31
|
+
getAll(): EvidenceArtifact[];
|
|
32
|
+
getByConnector(connectorId: string): EvidenceArtifact[];
|
|
33
|
+
getByControl(controlId: string): EvidenceArtifact[];
|
|
34
|
+
getByFramework(framework: ComplianceFramework): EvidenceArtifact[];
|
|
35
|
+
remove(id: string): boolean;
|
|
36
|
+
size: number;
|
|
37
|
+
}
|
|
38
|
+
export interface CollectionSchedule {
|
|
39
|
+
id: string;
|
|
40
|
+
connectorId: string;
|
|
41
|
+
config: ScheduleConfig;
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
lastRunAt?: string;
|
|
44
|
+
nextRunAt?: string;
|
|
45
|
+
lastJobId?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface CollectionJob {
|
|
48
|
+
id: string;
|
|
49
|
+
connectorId: string;
|
|
50
|
+
scheduleId?: string;
|
|
51
|
+
startedAt: string;
|
|
52
|
+
completedAt?: string;
|
|
53
|
+
status: JobStatus;
|
|
54
|
+
artifacts: EvidenceArtifact[];
|
|
55
|
+
error?: string;
|
|
56
|
+
duration?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface EvidenceGap {
|
|
59
|
+
controlId: string;
|
|
60
|
+
framework: ComplianceFramework;
|
|
61
|
+
requiredBy: string[];
|
|
62
|
+
lastCollectedAt?: string;
|
|
63
|
+
freshness: EvidenceFreshness;
|
|
64
|
+
connectors: string[];
|
|
65
|
+
recommendation: string;
|
|
66
|
+
}
|
|
67
|
+
export interface EvidenceSummaryReport {
|
|
68
|
+
generatedAt: string;
|
|
69
|
+
totalArtifacts: number;
|
|
70
|
+
artifactsByFramework: Record<ComplianceFramework, number>;
|
|
71
|
+
artifactsByStatus: Record<string, number>;
|
|
72
|
+
gaps: EvidenceGap[];
|
|
73
|
+
totalGaps: number;
|
|
74
|
+
freshness: {
|
|
75
|
+
fresh: number;
|
|
76
|
+
stale: number;
|
|
77
|
+
expired: number;
|
|
78
|
+
missing: number;
|
|
79
|
+
};
|
|
80
|
+
coveragePercentage: number;
|
|
81
|
+
}
|
|
82
|
+
export declare function hashData(data: Record<string, unknown>): string;
|
|
83
|
+
export declare function generateId(prefix: string): string;
|
|
84
|
+
export declare function computeNextRun(config: ScheduleConfig, lastRun?: string): string;
|
|
85
|
+
export declare function assessFreshness(artifact: EvidenceArtifact, maxAgeHours?: number): EvidenceFreshness;
|
|
86
|
+
export declare function getControlFrameworkMap(): Record<string, ComplianceFramework[]>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
export function hashData(data) {
|
|
3
|
+
const payload = JSON.stringify(data, Object.keys(data).sort());
|
|
4
|
+
return `sha256:${createHash("sha256").update(payload).digest("hex")}`;
|
|
5
|
+
}
|
|
6
|
+
export function generateId(prefix) {
|
|
7
|
+
return `${prefix}-${randomUUID()}`;
|
|
8
|
+
}
|
|
9
|
+
export function computeNextRun(config, lastRun) {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const base = lastRun ? new Date(lastRun) : now;
|
|
12
|
+
const next = new Date(base);
|
|
13
|
+
switch (config.frequency) {
|
|
14
|
+
case "hourly":
|
|
15
|
+
next.setHours(next.getHours() + (config.interval || 1));
|
|
16
|
+
break;
|
|
17
|
+
case "daily":
|
|
18
|
+
next.setDate(next.getDate() + (config.interval || 1));
|
|
19
|
+
next.setHours(config.hourOfDay ?? 9, 0, 0, 0);
|
|
20
|
+
break;
|
|
21
|
+
case "weekly":
|
|
22
|
+
next.setDate(next.getDate() + 7 * (config.interval || 1));
|
|
23
|
+
next.setHours(config.hourOfDay ?? 9, 0, 0, 0);
|
|
24
|
+
break;
|
|
25
|
+
case "monthly":
|
|
26
|
+
next.setMonth(next.getMonth() + (config.interval || 1));
|
|
27
|
+
next.setDate(config.dayOfMonth ?? 1);
|
|
28
|
+
next.setHours(config.hourOfDay ?? 9, 0, 0, 0);
|
|
29
|
+
break;
|
|
30
|
+
case "quarterly":
|
|
31
|
+
next.setMonth(next.getMonth() + 3 * (config.interval || 1));
|
|
32
|
+
next.setDate(config.dayOfMonth ?? 1);
|
|
33
|
+
next.setHours(config.hourOfDay ?? 9, 0, 0, 0);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
if (next <= now)
|
|
37
|
+
return now.toISOString();
|
|
38
|
+
return next.toISOString();
|
|
39
|
+
}
|
|
40
|
+
export function assessFreshness(artifact, maxAgeHours = 24 * 30) {
|
|
41
|
+
if (!artifact.timestamp)
|
|
42
|
+
return "missing";
|
|
43
|
+
const age = Date.now() - new Date(artifact.timestamp).getTime();
|
|
44
|
+
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
|
|
45
|
+
if (age <= maxAgeMs * 0.5)
|
|
46
|
+
return "fresh";
|
|
47
|
+
if (age <= maxAgeMs)
|
|
48
|
+
return "stale";
|
|
49
|
+
return "expired";
|
|
50
|
+
}
|
|
51
|
+
export function getControlFrameworkMap() {
|
|
52
|
+
return {
|
|
53
|
+
"CC6.1": ["SOC2"],
|
|
54
|
+
"CC6.2": ["SOC2"],
|
|
55
|
+
"CC6.3": ["SOC2"],
|
|
56
|
+
"CC6.4": ["SOC2"],
|
|
57
|
+
"CC6.6": ["SOC2"],
|
|
58
|
+
"CC6.8": ["SOC2"],
|
|
59
|
+
"CC7.1": ["SOC2"],
|
|
60
|
+
"CC7.2": ["SOC2"],
|
|
61
|
+
"CC7.3": ["SOC2"],
|
|
62
|
+
"CC8.1": ["SOC2"],
|
|
63
|
+
"A.9.2.5": ["ISO27001"],
|
|
64
|
+
"A.9.4.1": ["ISO27001"],
|
|
65
|
+
"A.10.1.1": ["ISO27001"],
|
|
66
|
+
"A.12.1.4": ["ISO27001"],
|
|
67
|
+
"A.12.3.1": ["ISO27001"],
|
|
68
|
+
"A.12.4.1": ["ISO27001"],
|
|
69
|
+
"A.12.6.1": ["ISO27001"],
|
|
70
|
+
"A.13.1.1": ["ISO27001"],
|
|
71
|
+
"A.14.2.1": ["ISO27001"],
|
|
72
|
+
"A.14.2.5": ["ISO27001"],
|
|
73
|
+
"A.16.1.4": ["ISO27001"],
|
|
74
|
+
"A.18.1.5": ["ISO27001"],
|
|
75
|
+
"PR.AC": ["NIST_CSF"],
|
|
76
|
+
"PR.DS": ["NIST_CSF"],
|
|
77
|
+
"DE.CM": ["NIST_CSF"],
|
|
78
|
+
"RS.AN": ["NIST_CSF"],
|
|
79
|
+
};
|
|
80
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grc-claw/evidence-automation-engine",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Automated evidence collection scheduling, execution, and gap detection for GRC compliance",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"test": "node --import tsx --test --test-force-exit src/**/*.test.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.7.0",
|
|
24
|
+
"tsx": "^4.19.0"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/AAH20/GRC_Claw"
|
|
32
|
+
}
|
|
33
|
+
}
|