filegrc 0.16.13 → 0.16.14
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/model/v11.json +4 -4
- package/package.json +1 -1
- package/src/applicability-scope.js +55 -10
- package/src/audit-preparation.js +40 -13
- package/src/collection-review.js +13 -6
- package/src/collection-revision.js +94 -2
- package/src/model-docs.js +1 -1
- package/src/program-readiness.js +30 -6
- package/src/validate.js +13 -20
- package/src/web.js +2 -2
package/model/v11.json
CHANGED
|
@@ -657,9 +657,9 @@
|
|
|
657
657
|
},
|
|
658
658
|
"control": {
|
|
659
659
|
"title": "Control implementation oversight",
|
|
660
|
-
"description": "After Step 3 implementation work is complete and before program content activation, confirm that the implemented Controls selected for the Program are ready to operate.
|
|
660
|
+
"description": "After Step 3 implementation work is complete and before program content activation, confirm that the implemented Controls selected for the Program are ready to operate. Handle later changes through affected Controls, event work, and scheduled program reviews.",
|
|
661
661
|
"reviewPoints": [
|
|
662
|
-
"Perform this one
|
|
662
|
+
"Perform this one setup review after Controls, evidence sources, and Obligations are ready, before management activates the approved program content.",
|
|
663
663
|
"Confirm owners, procedures, scope, operation patterns, mappings, Obligations, and authoritative evidence sources remain current.",
|
|
664
664
|
"Record follow-up work separately when the review finds a gap."
|
|
665
665
|
],
|
|
@@ -2875,7 +2875,7 @@
|
|
|
2875
2875
|
"description": "Management's actual Control implementation, mapped to Requirements, bounded Systems, operating Components, and authoritative evidence-source Components.",
|
|
2876
2876
|
"guidance": {
|
|
2877
2877
|
"policyBasis": "Controls translate approved Policies and applicable Requirements into owned procedures that management can operate and prove. FileGRC does not infer technical implementation from policy prose. Configuration facts belong in Controls, Components, Systems, Obligations, and Evidence.",
|
|
2878
|
-
"cadence": "A Control owner may mark the Control implemented after recording its actual procedure, bounded System scope, operation pattern, authoritative evidence-source Components, implementation date, and enabled calendar or event Obligations. Confirm each source Component is active, has the evidence role required by the Control family and current access owners, and includes repeatable retrieval instructions in Record Markdown. Enabled Obligations remain dormant until all governing content is active and effective. Review
|
|
2878
|
+
"cadence": "A Control owner may mark the Control implemented after recording its actual procedure, bounded System scope, operation pattern, authoritative evidence-source Components, implementation date, and enabled calendar or event Obligations. Confirm each source Component is active, has the evidence role required by the Control family and current access owners, and includes repeatable retrieval instructions in Record Markdown. Enabled Obligations remain dormant until all governing content is active and effective. Review affected Controls after material changes and use scheduled program reviews for periodic oversight.",
|
|
2879
2879
|
"reviewPoints": [
|
|
2880
2880
|
"Confirm the Control is needed for the applicable Requirements, Commitments, and service boundary.",
|
|
2881
2881
|
"Describe what people actually do to operate the Control.",
|
|
@@ -8669,7 +8669,7 @@
|
|
|
8669
8669
|
"description": "A logical operational or technical building block that materially delivers a System, supports a Control, produces authoritative Evidence, or supports relevant operations.",
|
|
8670
8670
|
"guidance": {
|
|
8671
8671
|
"policyBasis": "System boundaries and Control implementations need explicit operational building blocks and evidence sources.",
|
|
8672
|
-
"cadence": "Create only when the capability materially participates in an in-scope System.
|
|
8672
|
+
"cadence": "Create only when the capability materially participates in an in-scope System. After a change, inspect its System uses and affected Controls. If the change is material to risk, data use, evidence, access, or continuity, assess it through the relevant System change Policy Event and its linked work. Follow the scheduled policy and program reviews; do not repeat setup collection reviews.",
|
|
8673
8673
|
"reviewPoints": [
|
|
8674
8674
|
"Choose at least one role for each related System and explain why the Component matters there.",
|
|
8675
8675
|
"Link a Vendor only when that external organization primarily supplies this Component.",
|
package/package.json
CHANGED
|
@@ -29,12 +29,48 @@ const unorderedStringArrayFields = new Set([
|
|
|
29
29
|
|
|
30
30
|
const applicabilityDecisionFields = {
|
|
31
31
|
requirement: ["frameworkId", "reference", "description", "parentRequirementId"],
|
|
32
|
+
control: ["statement", "requirementIds", "code", "systemIds"],
|
|
33
|
+
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "reportingRouteRequirements"],
|
|
34
|
+
"complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds"]
|
|
35
|
+
};
|
|
36
|
+
const legacyApplicabilityDecisionFields = {
|
|
37
|
+
requirement: applicabilityDecisionFields.requirement,
|
|
32
38
|
control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
|
|
33
39
|
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "reportingRouteRequirements"],
|
|
34
40
|
"complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"]
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
|
|
44
|
+
if (Number(model.modelVersion) >= 11 && !options.legacyDecisionScope) {
|
|
45
|
+
const linkedRequirements = resources.filter((candidate) => candidate.type === "requirement"
|
|
46
|
+
&& (record.type === "requirement" ? candidate.id === record.id : (record.requirementIds || []).includes(candidate.id)));
|
|
47
|
+
const frameworkIds = new Set(linkedRequirements.map(({ frameworkId }) => frameworkId));
|
|
48
|
+
const scopedProgram = record.type === "requirement" ? {
|
|
49
|
+
...program,
|
|
50
|
+
systemIds: [],
|
|
51
|
+
frameworkIds: (program?.frameworkIds || []).filter((id) => id === record.frameworkId),
|
|
52
|
+
requirementIds: (program?.requirementIds || []).filter((id) => id === record.id)
|
|
53
|
+
} : {
|
|
54
|
+
...program,
|
|
55
|
+
systemIds: (program?.systemIds || []).filter((id) => (record.systemIds || []).includes(id)),
|
|
56
|
+
frameworkIds: (program?.frameworkIds || []).filter((id) => !frameworkIds.size || frameworkIds.has(id)),
|
|
57
|
+
requirementIds: (program?.requirementIds || []).filter((id) => linkedRequirements.some((candidate) => candidate.id === id))
|
|
58
|
+
};
|
|
59
|
+
const facts = {
|
|
60
|
+
modelVersion: model.modelVersion,
|
|
61
|
+
resource: applicabilityResourceFacts(record, model, options),
|
|
62
|
+
program: pick(scopedProgram || {}, ["id", "assuranceGoal", "systemIds", "frameworkIds", "requirementIds"], model, "program"),
|
|
63
|
+
requirements: linkedRequirements.sort(compareRecordIds).map((candidate) => pick(candidate,
|
|
64
|
+
["id", "frameworkId", "reference", "description", "parentRequirementId"], model)),
|
|
65
|
+
frameworks: resources.filter(({ type, id }) => type === "framework" && frameworkIds.has(id))
|
|
66
|
+
.sort(compareRecordIds).map((candidate) => pick(candidate, ["id", "version", "status"], model)),
|
|
67
|
+
requirementDecisions: (record.type === "requirement" ? [] : program?.requirementApplicability || [])
|
|
68
|
+
.filter(({ requirementId }) => linkedRequirements.some(({ id }) => id === requirementId))
|
|
69
|
+
.map(({ requirementId, decision }) => ({ requirementId, decision }))
|
|
70
|
+
.sort((left, right) => left.requirementId.localeCompare(right.requirementId))
|
|
71
|
+
};
|
|
72
|
+
return calculateRevision("applicability-scope", stableJson(facts));
|
|
73
|
+
}
|
|
38
74
|
const scopedResource = ["commitment", "control", "complementary-control"].includes(record?.type)
|
|
39
75
|
&& !options.legacyBroadScope && !options.legacyBroadCommitment;
|
|
40
76
|
const linkedRequirementIds = new Set(record?.requirementIds || []);
|
|
@@ -204,10 +240,10 @@ function compareRecordIds(left, right) {
|
|
|
204
240
|
|
|
205
241
|
function applicabilityResourceFacts(record, model, options) {
|
|
206
242
|
const fields = Number(model.modelVersion) >= 11 && !options.legacyResourceProjection
|
|
207
|
-
? applicabilityDecisionFields[record.type] : null;
|
|
243
|
+
? (options.legacyDecisionScope ? legacyApplicabilityDecisionFields : applicabilityDecisionFields)[record.type] : null;
|
|
208
244
|
if (!fields) return canonicalObject(model, record.type, Object.fromEntries(Object.entries(record)
|
|
209
245
|
.filter(([field]) => !excludedResourceFields.has(field))));
|
|
210
|
-
const value = Object.fromEntries(["id", "type", "title", "externalIds", "extensions", ...fields]
|
|
246
|
+
const value = Object.fromEntries(["id", "type", ...(options.legacyDecisionScope ? ["title"] : []), "externalIds", "extensions", ...fields]
|
|
211
247
|
.filter((field) => record[field] !== undefined).map((field) => [field, record[field]]));
|
|
212
248
|
value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
|
|
213
249
|
return canonicalObject(model, record.type, value);
|
|
@@ -226,10 +262,15 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
226
262
|
if (Number(model.modelVersion) >= 11 && ["retired", "superseded"].includes(record?.status)) return false;
|
|
227
263
|
const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
|
|
228
264
|
if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
|
|
265
|
+
if (Number(model.modelVersion) >= 11 && matches(applicabilityScopeRevision(record, program, resources, model, {
|
|
266
|
+
legacyDecisionScope: true
|
|
267
|
+
}))) return true;
|
|
268
|
+
if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type]
|
|
269
|
+
&& matches(applicabilityScopeRevision(record, program, resources, model, { legacyDecisionScope: true, legacyResourceProjection: true }))) return true;
|
|
229
270
|
if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type]
|
|
230
271
|
&& matches(applicabilityScopeRevision(record, program, resources, model, { legacyResourceProjection: true }))) return true;
|
|
231
272
|
if (["commitment", "control", "complementary-control"].includes(record?.type) && matches(applicabilityScopeRevision(
|
|
232
|
-
record, program, resources, model, { legacyBroadScope: true }
|
|
273
|
+
record, program, resources, model, { legacyDecisionScope: true, legacyBroadScope: true }
|
|
233
274
|
))) return true;
|
|
234
275
|
if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type] && root && historicalScopedReviewMatches(
|
|
235
276
|
review.scopeRevision, record, program, resources, model, root
|
|
@@ -242,6 +283,7 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
242
283
|
for (const legacyPolicyWithoutEffectiveOn of [false, true]) {
|
|
243
284
|
if (matches(applicabilityScopeRevision(record, program, resources, model, {
|
|
244
285
|
legacyPolicyStatus,
|
|
286
|
+
legacyDecisionScope: true,
|
|
245
287
|
legacyPolicyWithoutApprovalBinding,
|
|
246
288
|
legacyPolicyWithoutEffectiveOn
|
|
247
289
|
}))) return true;
|
|
@@ -260,7 +302,8 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
260
302
|
for (const policy of policies) {
|
|
261
303
|
for (const value of legacyPolicyAlternatives(policy)) {
|
|
262
304
|
if (matches(applicabilityScopeRevision(record, program, resources, model, {
|
|
263
|
-
legacyPolicyOverrides: { [policy.id]: value }
|
|
305
|
+
legacyPolicyOverrides: { [policy.id]: value },
|
|
306
|
+
legacyDecisionScope: true
|
|
264
307
|
}))) return true;
|
|
265
308
|
}
|
|
266
309
|
}
|
|
@@ -285,12 +328,14 @@ function historicalScopedReviewMatches(stored, record, program, resources, model
|
|
|
285
328
|
if (!revisionsMatch("applicability-scope", current, applicabilityScopeRevision(
|
|
286
329
|
historicalRecord, historicalProgram, historicalResources, historicalModel
|
|
287
330
|
))) continue;
|
|
288
|
-
for (const
|
|
289
|
-
for (const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
331
|
+
for (const legacyDecisionScope of [false, true]) {
|
|
332
|
+
for (const legacyBroadScope of [false, true]) {
|
|
333
|
+
for (const legacyResourceProjection of [false, true]) {
|
|
334
|
+
if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
|
|
335
|
+
historicalRecord, historicalProgram, historicalResources, historicalModel,
|
|
336
|
+
{ legacyDecisionScope, legacyBroadScope, legacyResourceProjection }
|
|
337
|
+
))) return true;
|
|
338
|
+
}
|
|
294
339
|
}
|
|
295
340
|
}
|
|
296
341
|
}
|
package/src/audit-preparation.js
CHANGED
|
@@ -690,18 +690,24 @@ function scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit) {
|
|
|
690
690
|
historical = null;
|
|
691
691
|
}
|
|
692
692
|
if (
|
|
693
|
-
!scopeRecordIsRelevant(current, audit)
|
|
694
|
-
&& !scopeRecordIsRelevant(historical, historicalAudit)
|
|
693
|
+
!scopeRecordIsRelevant(current, audit, loaded)
|
|
694
|
+
&& !scopeRecordIsRelevant(historical, historicalAudit, loaded)
|
|
695
695
|
) continue;
|
|
696
|
-
if (JSON.stringify(canonicalScopeValue(current))
|
|
696
|
+
if (JSON.stringify(canonicalScopeValue(auditScopeRecordValue(current)))
|
|
697
|
+
!== JSON.stringify(canonicalScopeValue(auditScopeRecordValue(historical)))) changed.push(path);
|
|
697
698
|
}
|
|
698
699
|
return changed;
|
|
699
700
|
}
|
|
700
701
|
|
|
701
|
-
function scopeRecordIsRelevant(record, audit) {
|
|
702
|
+
function scopeRecordIsRelevant(record, audit, loaded) {
|
|
702
703
|
if (!record || !audit) return false;
|
|
703
704
|
const selectedSystemIds = new Set(audit.systemIds || []);
|
|
704
|
-
|
|
705
|
+
if (["commitment", "complementary-control"].includes(record.type)
|
|
706
|
+
&& (record.systemIds || []).some((id) => selectedSystemIds.has(id))) return true;
|
|
707
|
+
if (record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId)) return true;
|
|
708
|
+
if (record.type === "component" && (record.systemUses || []).some(({ systemId, roles }) => (
|
|
709
|
+
selectedSystemIds.has(systemId) && (roles || []).includes("service-delivery")
|
|
710
|
+
))) return true;
|
|
705
711
|
const selectedIds = new Set([
|
|
706
712
|
audit.programId,
|
|
707
713
|
...(audit.frameworkIds || []),
|
|
@@ -714,15 +720,36 @@ function scopeRecordIsRelevant(record, audit) {
|
|
|
714
720
|
...auditSubserviceVendorIds(audit),
|
|
715
721
|
...auditSubserviceComponentIds(audit)
|
|
716
722
|
].filter(Boolean));
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
return (record.systemIds || []).some((id) => selectedSystemIds.has(id));
|
|
723
|
+
const selectedControls = new Set(audit.controlIds || []);
|
|
724
|
+
for (const control of loaded.resources.filter(({ type, id }) => type === "control" && selectedControls.has(id))) {
|
|
725
|
+
for (const id of control.evidenceSourceComponentIds || []) selectedIds.add(id);
|
|
721
726
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
727
|
+
return selectedIds.has(record.id);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const AUDIT_SCOPE_RECORD_FIELDS = {
|
|
731
|
+
program: ["id", "status", "assuranceGoal"],
|
|
732
|
+
framework: ["id", "version", "status", "publisher", "sourceReference", "effectiveOn", "retiredOn"],
|
|
733
|
+
requirement: ["id", "frameworkId", "reference", "description", "parentRequirementId"],
|
|
734
|
+
system: ["id", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "ownerIds", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
735
|
+
control: ["id", "status", "statement", "requirementIds", "code", "activity", "controlType", "systemIds", "ownerIds", "effectiveOn", "retiredOn", "operationMode", "operationPattern", "componentIds", "evidenceSourceComponentIds", "policyIds", "procedureRevision", "procedureEffectiveOn"],
|
|
736
|
+
commitment: ["id", "status", "commitmentKind", "statement", "systemIds", "requirementIds", "controlIds", "customerFacing", "sourceResourceIds", "effectiveOn", "ownerIds", "reportingRouteRequirements"],
|
|
737
|
+
"complementary-control": ["id", "status", "statement", "systemIds", "relatedControlIds", "responsibleParty", "vendorId", "componentIds", "commitmentIds", "sourceDocumentIds", "requirementIds", "effectiveOn"],
|
|
738
|
+
component: ["id", "status", "componentKind", "description", "environment", "vendorId", "systemUses", "informationUses", "evidenceSourceKinds", "ownerIds", "evidenceOwnerIds", "criticality", "classificationId", "internetExposed", "continuityObjectives"],
|
|
739
|
+
vendor: ["id", "category", "status", "criticality", "classificationId", "informationTypeIds", "standardAgreement", "agreementDocumentId", "startDate", "endDate"],
|
|
740
|
+
appointment: ["id", "status", "appointmentKind", "holderId", "scopeResourceIds", "startsOn", "endsOn"]
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
function auditScopeRecordValue(record) {
|
|
744
|
+
if (!record) return null;
|
|
745
|
+
const fields = AUDIT_SCOPE_RECORD_FIELDS[record.type] || ["id", "type", "status"];
|
|
746
|
+
return {
|
|
747
|
+
...Object.fromEntries(fields.filter((field) => record[field] !== undefined)
|
|
748
|
+
.map((field) => [field, record[field]])),
|
|
749
|
+
...(["control", "commitment", "complementary-control"].includes(record.type)
|
|
750
|
+
? { unavailable: ["not-applicable", "retired", "superseded"].includes(record.status) }
|
|
751
|
+
: {})
|
|
752
|
+
};
|
|
726
753
|
}
|
|
727
754
|
|
|
728
755
|
function auditSubserviceVendorIds(audit) {
|
package/src/collection-review.js
CHANGED
|
@@ -2,7 +2,8 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { modelSupports } from "../model/index.js";
|
|
3
3
|
import {
|
|
4
4
|
collectionRevision,
|
|
5
|
-
|
|
5
|
+
collectionReviewDecisionMatches,
|
|
6
|
+
reviewedControlConflictIds
|
|
6
7
|
} from "./collection-revision.js";
|
|
7
8
|
import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords } from "./collection-scope.js";
|
|
8
9
|
import { applyResourceBatch, applyRetentionScheduleReviewBatch, contentRevision, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
|
|
@@ -62,15 +63,17 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
62
63
|
&& review.coverage.on === review.reviewedOn
|
|
63
64
|
&& review.knowledgeCutoffAt
|
|
64
65
|
&& Array.isArray(review.populationResourceIds)
|
|
65
|
-
&&
|
|
66
|
+
&& (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
|
|
67
|
+
|| sameIds(review.populationResourceIds, records.map(({ id }) => id)))
|
|
66
68
|
);
|
|
67
69
|
const allowsEmptyCollection = allowedDecisions.some((decision) => (
|
|
68
70
|
decision === "zero-population" || decision === "externally-managed"
|
|
69
71
|
));
|
|
70
|
-
const revisionMatches =
|
|
72
|
+
const revisionMatches = collectionReviewDecisionMatches(
|
|
71
73
|
loaded,
|
|
72
74
|
resourceType,
|
|
73
|
-
review
|
|
75
|
+
review,
|
|
76
|
+
records,
|
|
74
77
|
{
|
|
75
78
|
programId: program.id,
|
|
76
79
|
authoritativeSourceId,
|
|
@@ -92,11 +95,14 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
92
95
|
: [];
|
|
93
96
|
const recordProposals = collectionRecordProposals(loaded, resourceType, records, program);
|
|
94
97
|
const incompleteRecordProposals = recordProposals.filter(({ complete: ready }) => !ready);
|
|
98
|
+
const reviewerConflicts = resourceType === "control" && Number(loaded.model.modelVersion) >= 11 && review
|
|
99
|
+
? reviewedControlConflictIds(loaded, review)
|
|
100
|
+
: reviewerEligibility?.reviewerConflictIds || [];
|
|
95
101
|
const reviewersEligible = !reviewerEligibility || Boolean(
|
|
96
102
|
review?.reviewedByIds?.length
|
|
97
103
|
&& review.reviewedByIds.every((id) => (
|
|
98
104
|
personWasActiveOn(loaded.resources.find((record) => record.id === id), review.reviewedOn)
|
|
99
|
-
&& !
|
|
105
|
+
&& !reviewerConflicts.includes(id)
|
|
100
106
|
))
|
|
101
107
|
);
|
|
102
108
|
const reviewScopeMatches = !workspaceWideRetentionReview
|
|
@@ -108,7 +114,8 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
108
114
|
&& temporalReview
|
|
109
115
|
&& reviewScopeMatches
|
|
110
116
|
&& reviewersEligible
|
|
111
|
-
&&
|
|
117
|
+
&& (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
|
|
118
|
+
|| !incompleteRecordProposals.length)
|
|
112
119
|
&& !approvalIssues.length
|
|
113
120
|
);
|
|
114
121
|
const stale = Boolean(
|
|
@@ -4,14 +4,16 @@ import { modelSupports } from "../model/index.js";
|
|
|
4
4
|
import {
|
|
5
5
|
authoritativeSourceRevisionValue,
|
|
6
6
|
collectionRevisionInputs,
|
|
7
|
-
collectionScopeRevisionFacts
|
|
7
|
+
collectionScopeRevisionFacts,
|
|
8
|
+
scopedCollectionRecords
|
|
8
9
|
} from "./collection-scope.js";
|
|
9
10
|
import { resolveDataPath } from "./paths.js";
|
|
10
11
|
import { getFileAtRevision } from "./git.js";
|
|
11
12
|
import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
|
|
12
13
|
import { resolveProgram } from "./program.js";
|
|
14
|
+
import { currentPartyPeople } from "./parties.js";
|
|
13
15
|
import { markdownEntries } from "./resource-markdown.js";
|
|
14
|
-
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
|
|
16
|
+
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionDigest, revisionsMatch } from "./revisions.js";
|
|
15
17
|
|
|
16
18
|
export function collectionRevision(loaded, resourceType, options = {}) {
|
|
17
19
|
return calculateCollectionRevision(loaded, resourceType, options, false, options.scopeHashInput || "legacy", options.scopeFactsInput || "legacy");
|
|
@@ -44,6 +46,96 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
|
|
|
44
46
|
return historicallyEquivalentCollection(loaded, resourceType, storedRevision, options, currentRevision);
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
// Ordinary Collection Reviews record that setup was completed. Their original
|
|
50
|
+
// population remains evidence of what management reviewed at that time;
|
|
51
|
+
// subsequent operating records are assessed by their own workflows. The Data
|
|
52
|
+
// Retention Schedule is an approval of exact content and keeps its binding.
|
|
53
|
+
export function collectionReviewDecisionMatches(loaded, resourceType, review, records, options = {}) {
|
|
54
|
+
if (!review) return false;
|
|
55
|
+
if (resourceType === "retention-schedule-item" || Number(loaded.model.modelVersion) < 11) {
|
|
56
|
+
return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options);
|
|
57
|
+
}
|
|
58
|
+
if (!revisionDigest("collection", review.collectionRevision) || review.status !== "active") return false;
|
|
59
|
+
if (!originalCollectionReviewMatches(loaded, resourceType, review, options)) return false;
|
|
60
|
+
if (review.decision === "zero-population") return records.length === 0;
|
|
61
|
+
if (review.decision === "complete") return records.length > 0;
|
|
62
|
+
if (review.decision === "externally-managed") {
|
|
63
|
+
const id = review.authoritativeComponentId || review.authoritativeSystemId;
|
|
64
|
+
return loaded.resources.some((record) => record.id === id && record.status === "active");
|
|
65
|
+
}
|
|
66
|
+
return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function originalCollectionReviewMatches(loaded, resourceType, review, options) {
|
|
70
|
+
const currentRevision = options.currentRevision || collectionRevision(loaded, resourceType, options);
|
|
71
|
+
if (revisionsMatch("collection", review.collectionRevision, currentRevision)) {
|
|
72
|
+
if (!review.populationResourceIds) return true;
|
|
73
|
+
const program = options.program || resolveProgram(loaded, options.programId || review.scopeResourceIds?.[0]);
|
|
74
|
+
const currentPopulation = scopedCollectionRecords(loaded, resourceType, program).map(({ id }) => id).sort();
|
|
75
|
+
const reviewedPopulation = [...review.populationResourceIds].sort();
|
|
76
|
+
return currentPopulation.length === reviewedPopulation.length
|
|
77
|
+
&& currentPopulation.every((id, index) => id === reviewedPopulation[index]);
|
|
78
|
+
}
|
|
79
|
+
if (!loaded.root || !review.scopeRevision) {
|
|
80
|
+
return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, { ...options, currentRevision });
|
|
81
|
+
}
|
|
82
|
+
const snapshot = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision);
|
|
83
|
+
if (!snapshot) return false;
|
|
84
|
+
const programId = options.programId || options.program?.id || review.scopeResourceIds?.[0];
|
|
85
|
+
let program;
|
|
86
|
+
try {
|
|
87
|
+
program = resolveProgram(snapshot, programId);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const currentProgram = resolveProgram(loaded, programId);
|
|
92
|
+
const selectionField = {
|
|
93
|
+
framework: "frameworkIds",
|
|
94
|
+
system: "systemIds",
|
|
95
|
+
component: "systemIds",
|
|
96
|
+
control: "controlIds"
|
|
97
|
+
}[resourceType];
|
|
98
|
+
if (selectionField && JSON.stringify([...(program[selectionField] || [])].sort())
|
|
99
|
+
!== JSON.stringify([...(currentProgram[selectionField] || [])].sort())) return false;
|
|
100
|
+
const population = scopedCollectionRecords(snapshot, resourceType, program).map(({ id }) => id).sort();
|
|
101
|
+
const reviewedPopulation = [...(review.populationResourceIds || [])].sort();
|
|
102
|
+
if (review.populationResourceIds && (
|
|
103
|
+
population.length !== reviewedPopulation.length
|
|
104
|
+
|| population.some((id, index) => id !== reviewedPopulation[index])
|
|
105
|
+
)) return false;
|
|
106
|
+
return collectionRevisionMatches(snapshot, resourceType, review.collectionRevision, {
|
|
107
|
+
programId,
|
|
108
|
+
authoritativeSourceId: options.authoritativeSourceId,
|
|
109
|
+
historicalCommit: review.scopeRevision
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function reviewedControlConflictIds(loaded, review) {
|
|
114
|
+
const snapshot = loaded.root && /^[a-f0-9]{40,64}$/i.test(review?.scopeRevision || "")
|
|
115
|
+
? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision)
|
|
116
|
+
: null;
|
|
117
|
+
const source = snapshot || loaded;
|
|
118
|
+
let program;
|
|
119
|
+
try {
|
|
120
|
+
program = resolveProgram(source, review.scopeResourceIds?.[0]);
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const controls = scopedCollectionRecords(source, "control", program);
|
|
125
|
+
const controlIds = new Set(controls.map(({ id }) => id));
|
|
126
|
+
const byId = new Map(source.resources.map((record) => [record.id, record]));
|
|
127
|
+
const conflicts = new Set();
|
|
128
|
+
for (const record of [
|
|
129
|
+
...controls,
|
|
130
|
+
...source.resources.filter((record) => record.type === "obligation"
|
|
131
|
+
&& record.status === "active"
|
|
132
|
+
&& (record.controlIds || []).some((id) => controlIds.has(id)))
|
|
133
|
+
]) {
|
|
134
|
+
for (const id of currentPartyPeople(record.ownerIds || [], byId)) conflicts.add(id);
|
|
135
|
+
}
|
|
136
|
+
return [...conflicts].sort();
|
|
137
|
+
}
|
|
138
|
+
|
|
47
139
|
function historicallyEquivalentCollection(loaded, resourceType, stored, options, current) {
|
|
48
140
|
if (!loaded.root) return false;
|
|
49
141
|
const context = reviewHistoryContext(loaded.root, loaded);
|
package/src/model-docs.js
CHANGED
|
@@ -47,7 +47,7 @@ export function generateModelDocumentation(model) {
|
|
|
47
47
|
"",
|
|
48
48
|
"## Collection review confirmations",
|
|
49
49
|
"",
|
|
50
|
-
"FileGRC derives record issues, but it cannot infer that management
|
|
50
|
+
"FileGRC derives record issues, but it cannot infer that management finished an apparently complete or empty setup collection. Each configured collection review records the conclusion, reviewer, date, scope revision, and reviewed collection revision. Routine operating changes do not reopen a completed setup confirmation. The Data Retention Schedule keeps exact approval bindings.",
|
|
51
51
|
"",
|
|
52
52
|
"| Resource type | Review | Allowed conclusions | What to review |",
|
|
53
53
|
"| --- | --- | --- | --- |",
|
package/src/program-readiness.js
CHANGED
|
@@ -242,6 +242,27 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
242
242
|
...evidenceGateStages,
|
|
243
243
|
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
|
|
244
244
|
];
|
|
245
|
+
for (const assessment of collectionReviews.filter(({ resourceType, complete }) => (
|
|
246
|
+
complete && resourceType !== "retention-schedule-item"
|
|
247
|
+
))) {
|
|
248
|
+
for (const proposal of assessment.incompleteRecordProposals || []) {
|
|
249
|
+
stages.at(-1).items.push(item(
|
|
250
|
+
`operating-${assessment.resourceType}-${proposal.resourceId}`,
|
|
251
|
+
"action",
|
|
252
|
+
`Finish ${proposal.title}`,
|
|
253
|
+
"Finish or retire this new record, check its affected Systems and Controls, and start a change event only if the change is material under an approved Policy.",
|
|
254
|
+
{ type: assessment.resourceType, id: proposal.resourceId, title: proposal.title },
|
|
255
|
+
{
|
|
256
|
+
resourceType: assessment.resourceType,
|
|
257
|
+
destination: { kind: "record", resourceType: assessment.resourceType, resourceId: proposal.resourceId },
|
|
258
|
+
commands: [
|
|
259
|
+
`npx filegrc guide ${assessment.resourceType} --json`,
|
|
260
|
+
`npx filegrc get ${shellArgument(proposal.resourceId)} --mutation`
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
245
266
|
finalizeStage(stages.at(-1));
|
|
246
267
|
const periodStarted = Boolean(
|
|
247
268
|
program?.assuranceGoal === "soc-2-type-2"
|
|
@@ -408,14 +429,17 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
408
429
|
&& String(rationale || "").trim()
|
|
409
430
|
))
|
|
410
431
|
));
|
|
432
|
+
const componentSetupComplete = collectionReviews.find(({ resourceType }) => resourceType === "component")?.complete === true;
|
|
411
433
|
items.push(item(
|
|
412
434
|
"system-components",
|
|
413
|
-
scope.components.length === completeComponents.length ? "complete" : "action",
|
|
414
|
-
"
|
|
415
|
-
|
|
435
|
+
componentSetupComplete || scope.components.length === completeComponents.length ? "complete" : "action",
|
|
436
|
+
"Define scoped Component roles",
|
|
437
|
+
componentSetupComplete
|
|
438
|
+
? "The initial Component scope was confirmed. Complete later Component changes through their own record and event work."
|
|
439
|
+
: scope.components.length
|
|
416
440
|
? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
|
|
417
441
|
: "No Components are selected. This is valid only when the bounded Systems do not rely on a separately managed service-delivery, Control-support, evidence-source, or supporting-operations building block.",
|
|
418
|
-
scope.components[0] || { type: "component" },
|
|
442
|
+
scope.components.find((component) => !completeComponents.includes(component)) || scope.components[0] || { type: "component" },
|
|
419
443
|
{
|
|
420
444
|
componentIds: scope.components.map(({ id }) => id),
|
|
421
445
|
progressUnit: false
|
|
@@ -723,7 +747,7 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
|
|
|
723
747
|
}
|
|
724
748
|
|
|
725
749
|
export function collectionReviewReadinessItem(assessment) {
|
|
726
|
-
const firstIncomplete = assessment.incompleteRecordProposals?.[0]
|
|
750
|
+
const firstIncomplete = !assessment.complete && assessment.incompleteRecordProposals?.[0]
|
|
727
751
|
|| assessment.recordProposals?.find(({ complete }) => !complete);
|
|
728
752
|
const needsFirstRecord = assessment.recordCount === 0
|
|
729
753
|
&& !(assessment.configuration.decisions || []).some((decision) => ["zero-population", "externally-managed"].includes(decision));
|
|
@@ -734,7 +758,7 @@ export function collectionReviewReadinessItem(assessment) {
|
|
|
734
758
|
const approvalAction = !proposalAction && !pendingRetentionRow
|
|
735
759
|
? collectionApprovalIssueAction(assessment)
|
|
736
760
|
: null;
|
|
737
|
-
const proposalUnits = (assessment.recordProposals || []).map((proposal) => ({
|
|
761
|
+
const proposalUnits = (assessment.complete ? [] : assessment.recordProposals || []).map((proposal) => ({
|
|
738
762
|
id: `${assessment.resourceType}-proposal-${proposal.resourceId}`,
|
|
739
763
|
status: proposal.complete ? "complete" : "action",
|
|
740
764
|
title: `${proposal.title} proposal`
|
package/src/validate.js
CHANGED
|
@@ -6,7 +6,8 @@ import { getResourceDefinition, modelSupports } from "../model/index.js";
|
|
|
6
6
|
import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
7
7
|
import {
|
|
8
8
|
collectionRevision,
|
|
9
|
-
|
|
9
|
+
collectionReviewDecisionMatches,
|
|
10
|
+
reviewedControlConflictIds
|
|
10
11
|
} from "./collection-revision.js";
|
|
11
12
|
import { isSafeGitName } from "./git-name.js";
|
|
12
13
|
import { getFileBufferAtRevision, getFileObjectIdAtRevision, getFilePathAtRevision, getGitSummary, getWorkingFileObjectId, hasGitRevision, isDataHistoryAncestor } from "./git.js";
|
|
@@ -1276,10 +1277,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1276
1277
|
? record.authoritativeComponentId || record.authoritativeSystemId
|
|
1277
1278
|
: null
|
|
1278
1279
|
});
|
|
1279
|
-
const current =
|
|
1280
|
+
const current = collectionReviewDecisionMatches(
|
|
1280
1281
|
loaded,
|
|
1281
1282
|
record.resourceType,
|
|
1282
|
-
record
|
|
1283
|
+
record,
|
|
1284
|
+
scopedCollectionRecords(loaded, record.resourceType, program),
|
|
1283
1285
|
{
|
|
1284
1286
|
program,
|
|
1285
1287
|
authoritativeSourceId: record.decision === "externally-managed"
|
|
@@ -1288,6 +1290,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1288
1290
|
currentRevision
|
|
1289
1291
|
}
|
|
1290
1292
|
);
|
|
1293
|
+
const originalPopulationCurrent = current && revisionsMatch("collection", record.collectionRevision, currentRevision);
|
|
1291
1294
|
if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
|
|
1292
1295
|
diagnostics.push(error(
|
|
1293
1296
|
"invalid-retention-schedule-review-scope",
|
|
@@ -1295,7 +1298,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1295
1298
|
"The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
|
|
1296
1299
|
));
|
|
1297
1300
|
}
|
|
1298
|
-
if (
|
|
1301
|
+
if (originalPopulationCurrent && incompleteProposals.length) {
|
|
1299
1302
|
diagnostics.push(error(
|
|
1300
1303
|
"incomplete-collection-review-proposals",
|
|
1301
1304
|
path,
|
|
@@ -1303,19 +1306,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1303
1306
|
));
|
|
1304
1307
|
}
|
|
1305
1308
|
if (current && record.resourceType === "control") {
|
|
1306
|
-
const
|
|
1307
|
-
const controlIds = new Set(controls.map(({ id }) => id));
|
|
1308
|
-
const conflictIds = new Set();
|
|
1309
|
-
for (const control of controls) {
|
|
1310
|
-
for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
|
|
1311
|
-
}
|
|
1312
|
-
for (const obligation of loaded.resources.filter((candidate) => (
|
|
1313
|
-
candidate.type === "obligation"
|
|
1314
|
-
&& candidate.status === "active"
|
|
1315
|
-
&& (candidate.controlIds || []).some((id) => controlIds.has(id))
|
|
1316
|
-
))) {
|
|
1317
|
-
for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
|
|
1318
|
-
}
|
|
1309
|
+
const conflictIds = new Set(reviewedControlConflictIds(loaded, record));
|
|
1319
1310
|
const conflictedReviewers = (record.reviewedByIds || []).filter((id) => conflictIds.has(id));
|
|
1320
1311
|
if (conflictedReviewers.length) {
|
|
1321
1312
|
diagnostics.push(error(
|
|
@@ -1369,7 +1360,8 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1369
1360
|
|| record.coverage.on !== record.reviewedOn
|
|
1370
1361
|
|| !isRfc3339Timestamp(record.knowledgeCutoffAt)
|
|
1371
1362
|
|| currentCalendarDate(loaded.workspace.timezone, new Date(record.knowledgeCutoffAt)) !== record.reviewedOn
|
|
1372
|
-
|| (current &&
|
|
1363
|
+
|| (current && (record.resourceType === "retention-schedule-item" || Number(model.modelVersion) < 11)
|
|
1364
|
+
&& JSON.stringify([...record.populationResourceIds].sort()) !== JSON.stringify(currentPopulationIds))
|
|
1373
1365
|
)) {
|
|
1374
1366
|
diagnostics.push(error(
|
|
1375
1367
|
"invalid-current-collection-review",
|
|
@@ -1431,14 +1423,15 @@ function collectionReviewIsCurrent(loaded, resourceType, program) {
|
|
|
1431
1423
|
));
|
|
1432
1424
|
if (!review || !(loaded.model.collectionReviews?.[resourceType]?.decisions || ["complete"]).includes(review.decision)) return false;
|
|
1433
1425
|
const records = scopedCollectionRecords(loaded, resourceType, program);
|
|
1434
|
-
const revisionMatches =
|
|
1426
|
+
const revisionMatches = collectionReviewDecisionMatches(loaded, resourceType, review, records, { program });
|
|
1435
1427
|
if (!modelSupports(loaded.model, "temporal-collection-reviews")) return revisionMatches;
|
|
1436
1428
|
return revisionMatches
|
|
1437
1429
|
&& review.coverage?.kind === "as-of"
|
|
1438
1430
|
&& review.coverage.on === review.reviewedOn
|
|
1439
1431
|
&& Boolean(review.knowledgeCutoffAt)
|
|
1440
1432
|
&& Array.isArray(review.populationResourceIds)
|
|
1441
|
-
&&
|
|
1433
|
+
&& (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
|
|
1434
|
+
|| JSON.stringify([...review.populationResourceIds].sort()) === JSON.stringify(records.map(({ id }) => id).sort()));
|
|
1442
1435
|
}
|
|
1443
1436
|
|
|
1444
1437
|
function validateCollectionReviewSet(resources, pathById, diagnostics) {
|
package/src/web.js
CHANGED
|
@@ -1239,7 +1239,7 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1239
1239
|
const current = assessment.status === "current";
|
|
1240
1240
|
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1241
1241
|
const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
|
|
1242
|
-
const proposalBlocker = assessment.incompleteRecordProposals?.length
|
|
1242
|
+
const proposalBlocker = !current && assessment.incompleteRecordProposals?.length
|
|
1243
1243
|
? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review."
|
|
1244
1244
|
: null;
|
|
1245
1245
|
const reviewBlocker = scheduleBlocker || proposalBlocker;
|
|
@@ -1263,7 +1263,7 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1263
1263
|
? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
|
|
1264
1264
|
: '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? scheduleReview ? "Approve a new revision" : "Review again" : scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>';
|
|
1265
1265
|
const status = reviewBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
1266
|
-
const proposalSummary = assessment.recordProposals?.length
|
|
1266
|
+
const proposalSummary = !current && assessment.recordProposals?.length
|
|
1267
1267
|
? '<p class="collection-review-proposal-summary"><strong>' + assessment.recordProposals.filter(({ complete }) => complete).length + ' of ' + assessment.recordProposals.length + ' record proposals complete.</strong> The collection review follows every proposal.</p>'
|
|
1268
1268
|
: "";
|
|
1269
1269
|
const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? scheduleReview ? "Show approved revision" : "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">' + (scheduleReview ? "Schedule approval" : "Scope confirmation") + '</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div>' + proposalSummary + '<ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
|