filegrc 0.4.0 → 0.5.1
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/README.md +11 -3
- package/model/index.js +9 -5
- package/model/v3.json +9391 -0
- package/package.json +3 -3
- package/src/agent.js +53 -0
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +47 -6
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +418 -61
- package/src/collection-review.js +185 -0
- package/src/evidence-packet.js +34 -2
- package/src/external-reviewer.js +165 -0
- package/src/files.js +46 -10
- package/src/index.js +36 -2
- package/src/model-docs.js +15 -0
- package/src/model-migration.js +516 -21
- package/src/obligations.js +396 -13
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +49 -12
- package/src/program-readiness.js +331 -27
- package/src/reconciliation.js +277 -0
- package/src/server.js +242 -14
- package/src/setup.js +36 -4
- package/src/source-coverage.js +61 -0
- package/src/startup.js +1 -1
- package/src/state.js +37 -1
- package/src/validate.js +100 -3
- package/src/web.js +979 -213
- package/src/workflow.js +1595 -0
package/src/setup.js
CHANGED
|
@@ -18,7 +18,10 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
18
18
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, entry.revision]));
|
|
19
19
|
|
|
20
20
|
await applyResourceBatch(loaded.root, {
|
|
21
|
-
create:
|
|
21
|
+
create: [
|
|
22
|
+
...(plan.existingSystem ? [] : [plan.system]),
|
|
23
|
+
...(plan.commitment ? [plan.commitment] : [])
|
|
24
|
+
],
|
|
22
25
|
update: updates,
|
|
23
26
|
expectedRevisions: Object.fromEntries(updates.map((record) => [record.id, revisionById.get(record.id)])),
|
|
24
27
|
validateWholeWorkspace: true
|
|
@@ -29,6 +32,7 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
29
32
|
system: plan.system,
|
|
30
33
|
workspace: plan.workspace,
|
|
31
34
|
renderer: plan.renderer,
|
|
35
|
+
commitment: plan.commitment,
|
|
32
36
|
linkedControlIds: [],
|
|
33
37
|
onboardingComplete: !setup.draft
|
|
34
38
|
};
|
|
@@ -47,11 +51,13 @@ export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
|
|
|
47
51
|
system: plan.existingSystem ? "update" : "create",
|
|
48
52
|
workspace: "update",
|
|
49
53
|
renderer: plan.renderer ? "update" : "unchanged",
|
|
50
|
-
controls: 0
|
|
54
|
+
controls: 0,
|
|
55
|
+
commitment: plan.commitment ? "create" : "unchanged"
|
|
51
56
|
},
|
|
52
57
|
system: setupSystemSummary(plan.system),
|
|
53
58
|
target: setupTargetSummary(plan.workspace),
|
|
54
59
|
renderer: plan.renderer ? setupRendererSummary(plan.renderer) : null,
|
|
60
|
+
commitment: plan.commitment || null,
|
|
55
61
|
onboardingComplete: !setup.draft
|
|
56
62
|
};
|
|
57
63
|
}
|
|
@@ -64,11 +70,13 @@ export function summarizeSetupResult(result) {
|
|
|
64
70
|
changes: {
|
|
65
71
|
system: "saved",
|
|
66
72
|
workspace: "updated",
|
|
67
|
-
controls: result.linkedControlIds?.length || 0
|
|
73
|
+
controls: result.linkedControlIds?.length || 0,
|
|
74
|
+
commitment: result.commitment ? "saved" : "unchanged"
|
|
68
75
|
},
|
|
69
76
|
system: setupSystemSummary(result.system),
|
|
70
77
|
target: setupTargetSummary(result.workspace),
|
|
71
78
|
renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
|
|
79
|
+
commitment: result.commitment || null,
|
|
72
80
|
onboardingComplete: result.onboardingComplete
|
|
73
81
|
};
|
|
74
82
|
}
|
|
@@ -168,7 +176,31 @@ function buildSetupRecords(loaded, setup) {
|
|
|
168
176
|
};
|
|
169
177
|
const existingRenderer = loaded.resources.find(({ type }) => type === "renderer-settings");
|
|
170
178
|
const renderer = existingRenderer ? { ...existingRenderer, showOnboarding: setup.draft } : null;
|
|
171
|
-
|
|
179
|
+
const existingCommitment = loaded.resources.find((record) => (
|
|
180
|
+
record.type === "commitment"
|
|
181
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
182
|
+
&& (record.systemIds || []).includes(systemId)
|
|
183
|
+
));
|
|
184
|
+
const commitment = String(loaded.model.modelVersion) === "3" && !existingCommitment
|
|
185
|
+
? {
|
|
186
|
+
id: createResourceId(
|
|
187
|
+
"commitment",
|
|
188
|
+
`${setup.serviceName} service commitment`,
|
|
189
|
+
loaded.resources.map(({ id }) => id)
|
|
190
|
+
),
|
|
191
|
+
type: "commitment",
|
|
192
|
+
title: `${setup.serviceName} service commitment`,
|
|
193
|
+
status: "planned",
|
|
194
|
+
commitmentKind: "service",
|
|
195
|
+
statement: "Replace this starter with the actual customer promise or approved service requirement before activation.",
|
|
196
|
+
systemIds: [systemId],
|
|
197
|
+
ownerIds: [setup.ownerId],
|
|
198
|
+
customerFacing: true,
|
|
199
|
+
...(workspace.requirementIds?.length ? { requirementIds: [...workspace.requirementIds] } : {}),
|
|
200
|
+
...(workspace.controlIds?.length ? { controlIds: [...workspace.controlIds] } : {})
|
|
201
|
+
}
|
|
202
|
+
: null;
|
|
203
|
+
return { existingSystem, system, workspace, renderer, commitment };
|
|
172
204
|
}
|
|
173
205
|
|
|
174
206
|
function assuranceGoalFromSetup(goal) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function sourceCoverageComplete(record, loaded) {
|
|
2
|
+
if (!record?.validFrom || !record.collectionCadence || !record.retention || !record.reconciliationMethod) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
if (record.coverageKind === "external-system" && (!record.systemId || !(record.retrieverIds || []).length)) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
if (["not-applicable", "zero-population"].includes(record.coverageKind) && !record.applicabilityReview) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
if (loaded.workspace?.candidateCoverage && !(record.readinessTestEvidenceIds || []).length) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (loaded.workspace?.candidateCoverage) {
|
|
15
|
+
const tests = (record.readinessTestEvidenceIds || []).map((id) => (
|
|
16
|
+
loaded.resources.find((resource) => resource.id === id && resource.type === "evidence")
|
|
17
|
+
));
|
|
18
|
+
if (tests.some((test) => (
|
|
19
|
+
!test
|
|
20
|
+
|| test.readinessTest !== true
|
|
21
|
+
|| test.retrievalResult !== "passed"
|
|
22
|
+
|| test.accessConfirmed !== true
|
|
23
|
+
|| !(test.coveredSourceFamilyIds || []).includes(record.sourceFamilyId)
|
|
24
|
+
|| (record.coverageKind === "external-system" && !(
|
|
25
|
+
test.sourceSystemId === record.systemId
|
|
26
|
+
|| (test.systemIds || []).includes(record.systemId)
|
|
27
|
+
))
|
|
28
|
+
))) return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function assessSourceCoverageReadiness(loaded, selectedControlIds = []) {
|
|
34
|
+
if (!loaded.model.resources["source-coverage"]) return [];
|
|
35
|
+
const selected = new Set(selectedControlIds);
|
|
36
|
+
const selectedControlCodes = new Set(loaded.resources
|
|
37
|
+
.filter((record) => (
|
|
38
|
+
record.type === "control"
|
|
39
|
+
&& selected.has(record.id)
|
|
40
|
+
&& !["not-applicable", "retired"].includes(record.status)
|
|
41
|
+
))
|
|
42
|
+
.map(({ code }) => code)
|
|
43
|
+
.filter(Boolean));
|
|
44
|
+
return (loaded.model.evidenceSourceFamilies || [])
|
|
45
|
+
.filter((family) => family.controlCodes.some((code) => selectedControlCodes.has(code)))
|
|
46
|
+
.map((family) => {
|
|
47
|
+
const records = loaded.resources.filter((record) => (
|
|
48
|
+
record.type === "source-coverage"
|
|
49
|
+
&& record.sourceFamilyId === family.id
|
|
50
|
+
&& record.status !== "retired"
|
|
51
|
+
));
|
|
52
|
+
const record = records.find(({ status }) => status === "active")
|
|
53
|
+
|| records.find(({ status }) => status === "planned")
|
|
54
|
+
|| null;
|
|
55
|
+
return {
|
|
56
|
+
family,
|
|
57
|
+
record,
|
|
58
|
+
complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded))
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/startup.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const GITHUB_STAR_MESSAGE = "\n\x1b[38;2;255;184;0m⭐️ → ❤️ https://github.com/
|
|
1
|
+
const GITHUB_STAR_MESSAGE = "\n\x1b[38;2;255;184;0m⭐️ → ❤️ https://github.com/Alignbase/filegrc\x1b[0m\n";
|
|
2
2
|
|
|
3
3
|
export function printGithubStarMessage() {
|
|
4
4
|
console.log(GITHUB_STAR_MESSAGE);
|
package/src/state.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { assessAuditPreparation } from "./audit-preparation.js";
|
|
4
|
+
import { assessCollectionReviews } from "./collection-review.js";
|
|
4
5
|
import { getBrowserRepositoryState, getGitSummary, getWorkspaceHistories } from "./git.js";
|
|
5
6
|
import { renderMarkdown } from "./markdown.js";
|
|
6
7
|
import { planObligations } from "./obligations.js";
|
|
@@ -10,6 +11,7 @@ import { markdownEntries } from "./resource-markdown.js";
|
|
|
10
11
|
import { currentCalendarDate } from "./time.js";
|
|
11
12
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
12
13
|
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
14
|
+
import { assessWorkflow } from "./workflow.js";
|
|
13
15
|
|
|
14
16
|
const renderedMarkdownCache = new Map();
|
|
15
17
|
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
@@ -73,6 +75,38 @@ async function createAppStateUnlocked(input, options) {
|
|
|
73
75
|
return [audit?.id || "none", preparation];
|
|
74
76
|
})
|
|
75
77
|
));
|
|
78
|
+
const obligations = planObligations(entries, {
|
|
79
|
+
asOf,
|
|
80
|
+
now: options.now ?? generatedAt,
|
|
81
|
+
model: loaded.model
|
|
82
|
+
});
|
|
83
|
+
const workflow = await assessWorkflow(loaded, {
|
|
84
|
+
asOf,
|
|
85
|
+
evaluatedAt: generatedAt,
|
|
86
|
+
programReadiness,
|
|
87
|
+
auditPreparations: Object.fromEntries(
|
|
88
|
+
Object.entries(auditPreparations).filter(([id]) => id !== "none")
|
|
89
|
+
),
|
|
90
|
+
obligations,
|
|
91
|
+
git,
|
|
92
|
+
validation
|
|
93
|
+
});
|
|
94
|
+
const collectionReviews = Object.fromEntries(
|
|
95
|
+
assessCollectionReviews(loaded).map((assessment) => [
|
|
96
|
+
assessment.resourceType,
|
|
97
|
+
{
|
|
98
|
+
resourceType: assessment.resourceType,
|
|
99
|
+
configuration: assessment.configuration,
|
|
100
|
+
recordCount: assessment.recordCount,
|
|
101
|
+
review: assessment.review,
|
|
102
|
+
reviewRevision: assessment.reviewRevision,
|
|
103
|
+
collectionRevision: assessment.collectionRevision,
|
|
104
|
+
status: assessment.status,
|
|
105
|
+
complete: assessment.complete,
|
|
106
|
+
message: assessment.message
|
|
107
|
+
}
|
|
108
|
+
])
|
|
109
|
+
);
|
|
76
110
|
return {
|
|
77
111
|
generatedAt,
|
|
78
112
|
asOf,
|
|
@@ -86,9 +120,11 @@ async function createAppStateUnlocked(input, options) {
|
|
|
86
120
|
counts: validation.counts,
|
|
87
121
|
diagnostics: validation.diagnostics
|
|
88
122
|
},
|
|
89
|
-
obligations
|
|
123
|
+
obligations,
|
|
124
|
+
collectionReviews,
|
|
90
125
|
programReadiness,
|
|
91
126
|
auditPreparations,
|
|
127
|
+
workflow,
|
|
92
128
|
git
|
|
93
129
|
};
|
|
94
130
|
}
|
package/src/validate.js
CHANGED
|
@@ -73,7 +73,13 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
73
73
|
validateRecord(record, definition, loaded.model, displayPath, diagnostics);
|
|
74
74
|
validateDateRanges(record, displayPath, diagnostics);
|
|
75
75
|
if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
|
|
76
|
+
if (record.type === "collection-review") {
|
|
77
|
+
validateCollectionReview(record, loaded.model, loaded.resources, byId, displayPath, diagnostics);
|
|
78
|
+
}
|
|
76
79
|
if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
|
|
80
|
+
if (record.type === "action-item") {
|
|
81
|
+
validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics);
|
|
82
|
+
}
|
|
77
83
|
if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
|
|
78
84
|
if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
|
|
79
85
|
validateCoverage(record, displayPath, diagnostics);
|
|
@@ -148,6 +154,46 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
148
154
|
};
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
function validateCollectionReview(record, model, resources, byId, path, diagnostics) {
|
|
158
|
+
if (record.status !== "active") return;
|
|
159
|
+
const configuration = model.collectionReviews?.[record.resourceType];
|
|
160
|
+
if (!configuration) return;
|
|
161
|
+
const allowedDecisions = configuration.decisions || ["complete"];
|
|
162
|
+
if (!allowedDecisions.includes(record.decision)) {
|
|
163
|
+
diagnostics.push(error(
|
|
164
|
+
"invalid-collection-review-decision",
|
|
165
|
+
path,
|
|
166
|
+
`${configuration.title} review must use one of: ${allowedDecisions.join(", ")}.`
|
|
167
|
+
));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const recordCount = resources.filter(({ type }) => type === record.resourceType).length;
|
|
171
|
+
if (!recordCount && record.decision === "complete") {
|
|
172
|
+
diagnostics.push(error(
|
|
173
|
+
"invalid-collection-review-decision",
|
|
174
|
+
path,
|
|
175
|
+
`${configuration.title} has no records and cannot use the complete conclusion.`
|
|
176
|
+
));
|
|
177
|
+
}
|
|
178
|
+
if (recordCount && record.decision === "zero-population") {
|
|
179
|
+
diagnostics.push(error(
|
|
180
|
+
"invalid-collection-review-decision",
|
|
181
|
+
path,
|
|
182
|
+
`${configuration.title} has ${recordCount} records and cannot use the zero-population conclusion.`
|
|
183
|
+
));
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
record.decision === "externally-managed"
|
|
187
|
+
&& byId.get(record.authoritativeSystemId)?.status !== "active"
|
|
188
|
+
) {
|
|
189
|
+
diagnostics.push(error(
|
|
190
|
+
"inactive-authoritative-system",
|
|
191
|
+
path,
|
|
192
|
+
`${configuration.title} must name an active authoritative System for an externally managed conclusion.`
|
|
193
|
+
));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
151
197
|
export async function fingerprintWorkspace(input = process.cwd()) {
|
|
152
198
|
const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
|
|
153
199
|
const hash = createHash("sha256");
|
|
@@ -288,7 +334,10 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
288
334
|
? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
|
|
289
335
|
: [];
|
|
290
336
|
if (!expectedTypes.length) continue;
|
|
291
|
-
const
|
|
337
|
+
const completionIds = String(model.modelVersion) === "3"
|
|
338
|
+
? action.completionResourceIds || []
|
|
339
|
+
: [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
|
|
340
|
+
const linked = [...new Set(completionIds)]
|
|
292
341
|
.map((id) => byId.get(id))
|
|
293
342
|
.filter(Boolean);
|
|
294
343
|
if (!linked.some((item) => expectedTypes.includes(item.type))) {
|
|
@@ -301,6 +350,27 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
301
350
|
}
|
|
302
351
|
}
|
|
303
352
|
|
|
353
|
+
function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
|
|
354
|
+
if (
|
|
355
|
+
String(model.modelVersion) !== "3"
|
|
356
|
+
|| record.status !== "done"
|
|
357
|
+
|| !record.obligationId
|
|
358
|
+
) return;
|
|
359
|
+
const obligation = byId.get(record.obligationId);
|
|
360
|
+
if (obligation?.type !== "obligation") return;
|
|
361
|
+
const expectedTypes = model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || [];
|
|
362
|
+
if (!expectedTypes.length) return;
|
|
363
|
+
const linked = [...new Set(record.completionResourceIds || [])]
|
|
364
|
+
.map((id) => byId.get(id))
|
|
365
|
+
.filter(Boolean);
|
|
366
|
+
if (linked.some((item) => expectedTypes.includes(item.type))) return;
|
|
367
|
+
diagnostics.push(error(
|
|
368
|
+
"missing-obligation-completion",
|
|
369
|
+
path,
|
|
370
|
+
`A done Action Item linked to "${obligation.id}" needs completionResourceIds containing ${expectedTypes.join(" or ")}.`
|
|
371
|
+
));
|
|
372
|
+
}
|
|
373
|
+
|
|
304
374
|
function validateEvidencePaths(record, path, diagnostics) {
|
|
305
375
|
const expectedPrefix = `evidence/${record.id}/`;
|
|
306
376
|
for (const filePath of record.filePaths || []) {
|
|
@@ -381,6 +451,23 @@ function validateObligation(record, model, byId, path, diagnostics) {
|
|
|
381
451
|
"Event recurrence must use a policy event defined by the model."
|
|
382
452
|
));
|
|
383
453
|
}
|
|
454
|
+
if (record.eventRiskLevels?.length) {
|
|
455
|
+
if (recurrence.eventType !== "person-ended") {
|
|
456
|
+
diagnostics.push(error(
|
|
457
|
+
"invalid-obligation-event-filter",
|
|
458
|
+
path,
|
|
459
|
+
"eventRiskLevels may be used only with the person-ended Policy Event."
|
|
460
|
+
));
|
|
461
|
+
}
|
|
462
|
+
const invalid = record.eventRiskLevels.filter((value) => !["normal", "high"].includes(value));
|
|
463
|
+
if (invalid.length) {
|
|
464
|
+
diagnostics.push(error(
|
|
465
|
+
"invalid-obligation-event-filter",
|
|
466
|
+
path,
|
|
467
|
+
`Departure risk filters must be normal or high, not ${invalid.join(", ")}.`
|
|
468
|
+
));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
384
471
|
} else {
|
|
385
472
|
diagnostics.push(error(
|
|
386
473
|
"invalid-obligation-recurrence",
|
|
@@ -577,7 +664,8 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
|
|
|
577
664
|
}
|
|
578
665
|
|
|
579
666
|
async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
580
|
-
|
|
667
|
+
const bindingField = approvalBindingField(record, model);
|
|
668
|
+
if (!bindingField || !approvalBound(record) || !record[bindingField]) return;
|
|
581
669
|
const actual = {};
|
|
582
670
|
for (const item of markdownEntries(model, record)) {
|
|
583
671
|
try {
|
|
@@ -587,7 +675,7 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
|
587
675
|
if (error.code !== "ENOENT") throw error;
|
|
588
676
|
}
|
|
589
677
|
}
|
|
590
|
-
const expected = record
|
|
678
|
+
const expected = record[bindingField];
|
|
591
679
|
const paths = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort();
|
|
592
680
|
const invalid = paths.filter((item) => (
|
|
593
681
|
!/^[a-f0-9]{64}$/.test(String(expected[item] || ""))
|
|
@@ -605,9 +693,18 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
|
605
693
|
function approvalBound(record) {
|
|
606
694
|
if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
607
695
|
if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
|
|
696
|
+
if (record.type === "training") return ["active", "retired"].includes(record.status);
|
|
608
697
|
return false;
|
|
609
698
|
}
|
|
610
699
|
|
|
700
|
+
function approvalBindingField(record, model) {
|
|
701
|
+
if (["policy", "document"].includes(record.type)) return "approvedContentRevisions";
|
|
702
|
+
if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
|
|
703
|
+
return "effectiveContentRevisions";
|
|
704
|
+
}
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
611
708
|
function validateCoverage(record, path, diagnostics) {
|
|
612
709
|
const coverage = record.type === "workspace" ? record.candidateCoverage : record.coverage;
|
|
613
710
|
if (!coverage) return;
|