filegrc 0.7.0 → 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/README.md +14 -6
- package/model/index.js +21 -4
- package/model/v4.json +97 -6
- package/model/v5.json +10233 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +635 -81
- package/src/audit-transition.js +8 -2
- package/src/batch-review.js +21 -11
- package/src/cli.js +117 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +145 -0
- package/src/evidence-packet.js +393 -106
- package/src/external-reviewer.js +5 -4
- package/src/files.js +194 -35
- package/src/git.js +106 -10
- package/src/index.js +10 -1
- package/src/model-migration.js +218 -7
- package/src/obligations.js +14 -11
- package/src/policy-library/information-security-policy-v2.md +290 -0
- package/src/policy-library.js +827 -0
- package/src/program-lifecycle.js +93 -3
- package/src/program-path.js +14 -9
- package/src/program-readiness.js +306 -75
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +29 -7
- package/src/setup.js +25 -5
- package/src/soc2.js +228 -0
- package/src/state.js +8 -0
- package/src/validate.js +168 -14
- package/src/web.js +257 -35
- package/src/workflow.js +107 -44
- package/src/workspace.js +5 -0
package/src/validate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
|
-
import { getResourceDefinition } from "../model/index.js";
|
|
4
|
+
import { getResourceDefinition, modelSupports } from "../model/index.js";
|
|
5
5
|
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
6
6
|
import { collectionRevision } from "./collection-revision.js";
|
|
7
7
|
import { isSafeGitName } from "./git-name.js";
|
|
@@ -12,6 +12,7 @@ import { partyPeople } from "./parties.js";
|
|
|
12
12
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
13
13
|
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
14
14
|
import { recordTiming } from "./timing.js";
|
|
15
|
+
import { personWasActiveOn } from "./soc2.js";
|
|
15
16
|
import { indexResources, loadWorkspace } from "./workspace.js";
|
|
16
17
|
|
|
17
18
|
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -147,6 +148,9 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
147
148
|
pathById,
|
|
148
149
|
diagnostics
|
|
149
150
|
);
|
|
151
|
+
if (modelSupports(loaded.model, "document-workflow-scope")) {
|
|
152
|
+
validateDocumentWorkflowScopes(loaded.resources, loaded.model, byId, pathById, diagnostics);
|
|
153
|
+
}
|
|
150
154
|
|
|
151
155
|
diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
|
|
152
156
|
return {
|
|
@@ -161,6 +165,116 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
161
165
|
};
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
|
|
169
|
+
const managementFields = [
|
|
170
|
+
"engagementTermsDocumentId",
|
|
171
|
+
...(model.auditReadiness?.managementDocuments || []).map(({ field }) => field)
|
|
172
|
+
];
|
|
173
|
+
const auditReferences = new Map();
|
|
174
|
+
const governedAuditReferences = new Map();
|
|
175
|
+
const addAuditReference = (map, documentId, audit, field) => {
|
|
176
|
+
if (!documentId) return;
|
|
177
|
+
if (!map.has(documentId)) map.set(documentId, []);
|
|
178
|
+
map.get(documentId).push({ audit, field });
|
|
179
|
+
};
|
|
180
|
+
for (const audit of resources.filter(({ type }) => type === "audit")) {
|
|
181
|
+
for (const field of managementFields) {
|
|
182
|
+
const documentId = audit[field];
|
|
183
|
+
if (!documentId) continue;
|
|
184
|
+
addAuditReference(auditReferences, documentId, audit, field);
|
|
185
|
+
addAuditReference(governedAuditReferences, documentId, audit, field);
|
|
186
|
+
const document = byId.get(documentId);
|
|
187
|
+
if (document?.type === "document" && document.workflowScope !== "engagement") {
|
|
188
|
+
diagnostics.push(error(
|
|
189
|
+
"invalid-document-workflow-scope",
|
|
190
|
+
pathById.get(audit.id) || `data/${audit.id}`,
|
|
191
|
+
`${field} must reference an engagement-scoped Document; "${document.title}" is scoped to the program workflow.`
|
|
192
|
+
));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const documentId of audit.supplementalDocumentIds || []) {
|
|
196
|
+
addAuditReference(auditReferences, documentId, audit, "supplementalDocumentIds");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const programDocumentReferences = new Map();
|
|
201
|
+
const addProgramReference = (documentId, record, field) => {
|
|
202
|
+
if (!documentId) return;
|
|
203
|
+
if (!programDocumentReferences.has(documentId)) programDocumentReferences.set(documentId, []);
|
|
204
|
+
programDocumentReferences.get(documentId).push({ record, field });
|
|
205
|
+
};
|
|
206
|
+
for (const policy of resources.filter(({ type }) => type === "policy")) {
|
|
207
|
+
for (const documentId of policy.relatedDocumentIds || []) addProgramReference(documentId, policy, "relatedDocumentIds");
|
|
208
|
+
}
|
|
209
|
+
for (const obligation of resources.filter(({ type }) => type === "obligation")) {
|
|
210
|
+
for (const documentId of obligation.scopeResourceIds || []) {
|
|
211
|
+
if (byId.get(documentId)?.type === "document") addProgramReference(documentId, obligation, "scopeResourceIds");
|
|
212
|
+
}
|
|
213
|
+
if (byId.get(obligation.templateResourceId)?.type === "document") {
|
|
214
|
+
addProgramReference(obligation.templateResourceId, obligation, "templateResourceId");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const document of resources.filter(({ type }) => type === "document")) {
|
|
219
|
+
const path = pathById.get(document.id) || `data/${document.id}`;
|
|
220
|
+
const auditRefs = auditReferences.get(document.id) || [];
|
|
221
|
+
const auditIds = new Set(auditRefs.map(({ audit }) => audit.id));
|
|
222
|
+
const programRefs = programDocumentReferences.get(document.id) || [];
|
|
223
|
+
if (document.workflowScope === "engagement" && programRefs.length) {
|
|
224
|
+
const references = programRefs.map(({ record, field }) => `${record.id}.${field}`).join(", ");
|
|
225
|
+
diagnostics.push(error(
|
|
226
|
+
"engagement-document-in-program-workflow",
|
|
227
|
+
path,
|
|
228
|
+
`Engagement-scoped Document "${document.title}" cannot govern reusable program work through ${references}. Split the engagement deliverable from the program Document.`
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
document.workflowScope === "engagement"
|
|
233
|
+
&& ["approved", "active"].includes(document.status)
|
|
234
|
+
&& auditIds.size !== 1
|
|
235
|
+
) {
|
|
236
|
+
diagnostics.push(error(
|
|
237
|
+
"invalid-engagement-document-audit-count",
|
|
238
|
+
path,
|
|
239
|
+
`Approved or active engagement Document "${document.title}" must belong to exactly one Audit; found ${auditIds.size}.`
|
|
240
|
+
));
|
|
241
|
+
}
|
|
242
|
+
if (document.workflowScope === "program" && (governedAuditReferences.get(document.id) || []).length) {
|
|
243
|
+
diagnostics.push(error(
|
|
244
|
+
"program-document-in-engagement-workflow",
|
|
245
|
+
path,
|
|
246
|
+
`Program-scoped Document "${document.title}" cannot fill an Audit engagement or management-Document field.`
|
|
247
|
+
));
|
|
248
|
+
}
|
|
249
|
+
if (document.activationBasis !== "legacy-v4") continue;
|
|
250
|
+
const historicalAuditIds = new Set(auditRefs
|
|
251
|
+
.filter(({ audit }) => ["issued", "delivered", "complete"].includes(audit.status))
|
|
252
|
+
.map(({ audit }) => audit.id));
|
|
253
|
+
if (document.workflowScope !== "engagement" || historicalAuditIds.size !== 1 || auditIds.size !== 1) {
|
|
254
|
+
diagnostics.push(error(
|
|
255
|
+
"invalid-legacy-document-activation",
|
|
256
|
+
path,
|
|
257
|
+
`activationBasis legacy-v4 is reserved for an engagement Document tied to exactly one issued, delivered, or completed Audit.`
|
|
258
|
+
));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const document of resources.filter(({ type, activationBasis }) => (
|
|
263
|
+
type === "document" && activationBasis === "recorded"
|
|
264
|
+
))) {
|
|
265
|
+
const invalidActorIds = (document.activatedByIds || []).filter((id) => (
|
|
266
|
+
!personWasActiveOn(byId.get(id), document.activatedOn)
|
|
267
|
+
));
|
|
268
|
+
if (invalidActorIds.length) {
|
|
269
|
+
diagnostics.push(error(
|
|
270
|
+
"invalid-document-activation-actor",
|
|
271
|
+
pathById.get(document.id) || `data/${document.id}`,
|
|
272
|
+
`Document activation actors must have been active on ${document.activatedOn}: ${invalidActorIds.join(", ")}.`
|
|
273
|
+
));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
164
278
|
function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
165
279
|
if (record.status !== "active") return;
|
|
166
280
|
const { model } = loaded;
|
|
@@ -175,7 +289,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
175
289
|
));
|
|
176
290
|
return;
|
|
177
291
|
}
|
|
178
|
-
const program =
|
|
292
|
+
const program = modelSupports(model, "program-scope")
|
|
179
293
|
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
180
294
|
: null;
|
|
181
295
|
const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
|
|
@@ -208,7 +322,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
208
322
|
diagnostics.push(error(
|
|
209
323
|
"inactive-authoritative-system",
|
|
210
324
|
path,
|
|
211
|
-
`${configuration.title} must name an active authoritative ${
|
|
325
|
+
`${configuration.title} must name an active authoritative ${modelSupports(model, "component-sources") ? "Component" : "System"} for an externally managed conclusion.`
|
|
212
326
|
));
|
|
213
327
|
}
|
|
214
328
|
}
|
|
@@ -370,9 +484,19 @@ function validateControlComponents(record, byId, path, diagnostics) {
|
|
|
370
484
|
}
|
|
371
485
|
|
|
372
486
|
function validateAuditSubservices(record, byId, path, diagnostics) {
|
|
487
|
+
const selectedSystemIds = new Set(record.systemIds || []);
|
|
488
|
+
const seenComponentIds = new Set();
|
|
373
489
|
for (const [index, treatment] of (record.subserviceTreatments || []).entries()) {
|
|
374
490
|
for (const componentId of treatment.componentIds || []) {
|
|
375
491
|
const component = byId.get(componentId);
|
|
492
|
+
if (seenComponentIds.has(componentId)) {
|
|
493
|
+
diagnostics.push(error(
|
|
494
|
+
"duplicate-subservice-component",
|
|
495
|
+
path,
|
|
496
|
+
`subserviceTreatments[${index}] repeats Component "${componentId}" in more than one treatment.`
|
|
497
|
+
));
|
|
498
|
+
}
|
|
499
|
+
seenComponentIds.add(componentId);
|
|
376
500
|
if (component?.type === "component" && component.vendorId !== treatment.vendorId) {
|
|
377
501
|
diagnostics.push(error(
|
|
378
502
|
"subservice-vendor-mismatch",
|
|
@@ -380,6 +504,16 @@ function validateAuditSubservices(record, byId, path, diagnostics) {
|
|
|
380
504
|
`subserviceTreatments[${index}] Component "${componentId}" is not supplied by Vendor "${treatment.vendorId}".`
|
|
381
505
|
));
|
|
382
506
|
}
|
|
507
|
+
if (
|
|
508
|
+
component?.type === "component"
|
|
509
|
+
&& !(component.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
|
|
510
|
+
) {
|
|
511
|
+
diagnostics.push(error(
|
|
512
|
+
"subservice-component-outside-audit-scope",
|
|
513
|
+
path,
|
|
514
|
+
`subserviceTreatments[${index}] Component "${componentId}" has no use in a System selected by this Audit.`
|
|
515
|
+
));
|
|
516
|
+
}
|
|
383
517
|
}
|
|
384
518
|
}
|
|
385
519
|
}
|
|
@@ -416,7 +550,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
416
550
|
? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
|
|
417
551
|
: [];
|
|
418
552
|
if (!expectedTypes.length) continue;
|
|
419
|
-
const completionIds =
|
|
553
|
+
const completionIds = modelSupports(model, "guided-workflow")
|
|
420
554
|
? action.completionResourceIds || []
|
|
421
555
|
: [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
|
|
422
556
|
const linked = [...new Set(completionIds)]
|
|
@@ -434,7 +568,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
434
568
|
|
|
435
569
|
function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
|
|
436
570
|
if (
|
|
437
|
-
!
|
|
571
|
+
!modelSupports(model, "guided-workflow")
|
|
438
572
|
|| record.status !== "done"
|
|
439
573
|
|| !record.obligationId
|
|
440
574
|
) return;
|
|
@@ -652,6 +786,9 @@ function validateCompletionDates(record, path, diagnostics) {
|
|
|
652
786
|
]);
|
|
653
787
|
validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
|
|
654
788
|
validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
|
|
789
|
+
if (record.type === "document") {
|
|
790
|
+
validateOrderedDates(record, path, diagnostics, ["approvedOn", "activatedOn", "effectiveOn"]);
|
|
791
|
+
}
|
|
655
792
|
if (record.acceptance) {
|
|
656
793
|
validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
|
|
657
794
|
}
|
|
@@ -746,8 +883,15 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
|
|
|
746
883
|
}
|
|
747
884
|
|
|
748
885
|
async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
749
|
-
const
|
|
750
|
-
|
|
886
|
+
const bindingFields = contentBindingFields(record, model);
|
|
887
|
+
for (const binding of bindingFields) {
|
|
888
|
+
await validateContentBinding(record, model, root, path, diagnostics, binding);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function validateContentBinding(record, model, root, path, diagnostics, binding) {
|
|
893
|
+
const { field: bindingField, bound, label } = binding;
|
|
894
|
+
if (!bound(record) || !record[bindingField]) return;
|
|
751
895
|
const actual = {};
|
|
752
896
|
for (const item of markdownEntries(model, record)) {
|
|
753
897
|
try {
|
|
@@ -767,24 +911,34 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
|
767
911
|
diagnostics.push(error(
|
|
768
912
|
"approval-content-changed",
|
|
769
913
|
path,
|
|
770
|
-
|
|
914
|
+
`${label} content no longer matches ${invalid.map((item) => `data/${item}`).join(", ")}. Move the record to draft or in-review, review the change, then approve and activate it again.`
|
|
771
915
|
));
|
|
772
916
|
}
|
|
773
917
|
}
|
|
774
918
|
|
|
775
919
|
function approvalBound(record) {
|
|
776
920
|
if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
777
|
-
if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
|
|
921
|
+
if (record.type === "document") return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
778
922
|
if (record.type === "training") return ["active", "retired"].includes(record.status);
|
|
779
923
|
return false;
|
|
780
924
|
}
|
|
781
925
|
|
|
782
|
-
function
|
|
783
|
-
|
|
926
|
+
function contentBindingFields(record, model) {
|
|
927
|
+
const fields = [];
|
|
928
|
+
if (["policy", "document"].includes(record.type)) {
|
|
929
|
+
fields.push({ field: "approvedContentRevisions", bound: approvalBound, label: "Approved" });
|
|
930
|
+
}
|
|
931
|
+
if (record.type === "document" && model.resources.document?.fields?.activatedContentRevisions) {
|
|
932
|
+
fields.push({
|
|
933
|
+
field: "activatedContentRevisions",
|
|
934
|
+
bound: (candidate) => ["active", "superseded", "retired"].includes(candidate?.status),
|
|
935
|
+
label: "Activated"
|
|
936
|
+
});
|
|
937
|
+
}
|
|
784
938
|
if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
|
|
785
|
-
|
|
939
|
+
fields.push({ field: "effectiveContentRevisions", bound: approvalBound, label: "Approved" });
|
|
786
940
|
}
|
|
787
|
-
return
|
|
941
|
+
return fields;
|
|
788
942
|
}
|
|
789
943
|
|
|
790
944
|
function validateCoverage(record, path, diagnostics) {
|
|
@@ -838,7 +992,7 @@ function validateCoverage(record, path, diagnostics) {
|
|
|
838
992
|
|
|
839
993
|
function validateClassification(record, loaded, path, diagnostics) {
|
|
840
994
|
if (!record.classificationId) return;
|
|
841
|
-
if (
|
|
995
|
+
if (modelSupports(loaded.model, "program-scope")) {
|
|
842
996
|
if (!loaded.resources.some(({ id, type }) => id === record.classificationId && type === "classification")) {
|
|
843
997
|
diagnostics.push(error(
|
|
844
998
|
"unknown-classification",
|