filegrc 0.16.9 → 0.16.11
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/package.json +1 -1
- package/src/applicability-scope.js +118 -19
- package/src/audit-preparation.js +1 -1
- package/src/batch-review.js +2 -2
- package/src/collection-revision.js +39 -3
- package/src/collection-scope.js +41 -10
- package/src/historical-workspace.js +33 -0
- package/src/program-readiness.js +8 -7
- package/src/retention.js +60 -7
- package/src/state.js +1 -1
- package/src/workflow.js +3 -3
package/package.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
|
|
2
|
+
import { getDataCommitHistory } from "./git.js";
|
|
3
|
+
import { historicalWorkspace } from "./historical-workspace.js";
|
|
2
4
|
|
|
3
5
|
const excludedResourceFields = new Set([
|
|
4
6
|
"applicabilityReview",
|
|
@@ -22,13 +24,30 @@ const unorderedStringArrayFields = new Set([
|
|
|
22
24
|
]);
|
|
23
25
|
|
|
24
26
|
export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
27
|
+
const scopedResource = ["commitment", "control", "complementary-control"].includes(record?.type)
|
|
28
|
+
&& !options.legacyBroadScope && !options.legacyBroadCommitment;
|
|
29
|
+
const linkedRequirementIds = new Set(record?.requirementIds || []);
|
|
30
|
+
const linkedControlIds = record?.type === "control"
|
|
31
|
+
? [record.id]
|
|
32
|
+
: record?.type === "complementary-control"
|
|
33
|
+
? record.relatedControlIds || []
|
|
34
|
+
: record?.controlIds || [];
|
|
35
|
+
const selectedSystemIds = new Set((program?.systemIds || []).filter((id) => (
|
|
36
|
+
!scopedResource || !(record.systemIds || []).length || record.systemIds.includes(id)
|
|
37
|
+
)));
|
|
38
|
+
const selectedFrameworkIds = new Set((program?.frameworkIds || []).filter((id) => (
|
|
39
|
+
!scopedResource || !linkedRequirementIds.size || resources.some((candidate) => (
|
|
40
|
+
candidate.type === "requirement" && candidate.frameworkId === id && linkedRequirementIds.has(candidate.id)
|
|
41
|
+
))
|
|
42
|
+
)));
|
|
43
|
+
const selectedControlIds = new Set((program?.controlIds || []).filter((id) => (
|
|
44
|
+
!scopedResource || !linkedControlIds.length || linkedControlIds.includes(id)
|
|
45
|
+
)));
|
|
46
|
+
const selectedComponents = resources.filter(({ type, status, systemUses, id }) => (
|
|
29
47
|
type === "component"
|
|
30
48
|
&& status !== "retired"
|
|
31
49
|
&& (systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
|
|
50
|
+
&& (!scopedResource || !(record.componentIds || []).length || record.componentIds.includes(id))
|
|
32
51
|
));
|
|
33
52
|
const selectedVendorIds = new Set(selectedComponents.map(({ vendorId }) => vendorId).filter(Boolean));
|
|
34
53
|
const selectedPolicyIds = new Set(resources
|
|
@@ -38,17 +57,24 @@ export function applicabilityScopeRevision(record, program, resources, model, op
|
|
|
38
57
|
const facts = {
|
|
39
58
|
modelVersion: model.modelVersion,
|
|
40
59
|
program: {
|
|
41
|
-
...pick(
|
|
60
|
+
...pick(scopedResource ? {
|
|
61
|
+
...program,
|
|
62
|
+
systemIds: [...selectedSystemIds],
|
|
63
|
+
frameworkIds: [...selectedFrameworkIds],
|
|
64
|
+
requirementIds: (program?.requirementIds || []).filter((id) => !linkedRequirementIds.size || linkedRequirementIds.has(id)),
|
|
65
|
+
controlIds: [...selectedControlIds]
|
|
66
|
+
} : program || {}, [
|
|
42
67
|
"id",
|
|
43
68
|
"assuranceGoal",
|
|
44
69
|
"systemIds",
|
|
45
70
|
"frameworkIds",
|
|
46
71
|
"requirementIds",
|
|
47
72
|
"controlIds",
|
|
48
|
-
"riskMethodology"
|
|
73
|
+
...(record?.type === "commitment" && scopedResource ? [] : ["riskMethodology"])
|
|
49
74
|
], model),
|
|
50
75
|
...(record.type === "requirement" ? {} : {
|
|
51
76
|
requirementApplicability: (program?.requirementApplicability || [])
|
|
77
|
+
.filter((review) => !scopedResource || !linkedRequirementIds.size || linkedRequirementIds.has(review.requirementId))
|
|
52
78
|
.map((review) => pick(review, ["requirementId", "decision"], model, "program-applicability"))
|
|
53
79
|
.sort((left, right) => left.requirementId.localeCompare(right.requirementId))
|
|
54
80
|
})
|
|
@@ -117,13 +143,16 @@ export function applicabilityScopeRevision(record, program, resources, model, op
|
|
|
117
143
|
policies: resources
|
|
118
144
|
.filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id))
|
|
119
145
|
.sort(compareRecordIds)
|
|
120
|
-
.map((policy) => options.legacyPolicyStatus
|
|
146
|
+
.map((policy) => options.legacyPolicyStatus || options.legacyPolicyOverrides
|
|
121
147
|
? pick({
|
|
122
148
|
...policy,
|
|
123
|
-
status: options.legacyPolicyStatus
|
|
124
|
-
?
|
|
125
|
-
effectiveOn: options.
|
|
126
|
-
? undefined : policy.effectiveOn
|
|
149
|
+
status: (options.legacyPolicyOverrides?.[policy.id]?.status || options.legacyPolicyStatus || "current") === "current"
|
|
150
|
+
? policy.status : options.legacyPolicyOverrides?.[policy.id]?.status || options.legacyPolicyStatus,
|
|
151
|
+
effectiveOn: (options.legacyPolicyOverrides?.[policy.id]?.withoutEffectiveOn
|
|
152
|
+
?? options.legacyPolicyWithoutEffectiveOn) ? undefined : policy.effectiveOn,
|
|
153
|
+
approvedContentRevisions: (options.legacyPolicyOverrides?.[policy.id]?.withoutApprovalBinding
|
|
154
|
+
?? options.legacyPolicyWithoutApprovalBinding)
|
|
155
|
+
? undefined : policy.approvedContentRevisions
|
|
127
156
|
}, [
|
|
128
157
|
"id", "status", "policyKind", "version", "programRole", "effectiveOn",
|
|
129
158
|
"requirementIds", "audience", "acknowledgementRequired",
|
|
@@ -131,14 +160,19 @@ export function applicabilityScopeRevision(record, program, resources, model, op
|
|
|
131
160
|
], model)
|
|
132
161
|
: pick(policy, ["id", "requirementIds"], model)),
|
|
133
162
|
requirements: resources
|
|
134
|
-
.filter(({ type, frameworkId }) => type === "requirement" && selectedFrameworkIds.has(frameworkId)
|
|
163
|
+
.filter(({ type, frameworkId, id }) => type === "requirement" && selectedFrameworkIds.has(frameworkId)
|
|
164
|
+
&& (!scopedResource || !linkedRequirementIds.size || linkedRequirementIds.has(id)))
|
|
135
165
|
.sort(compareRecordIds)
|
|
136
166
|
.map((requirement) => pick(requirement, ["id", "frameworkId", "reference", "description", "parentRequirementId"], model)),
|
|
137
167
|
commitments: resources
|
|
138
|
-
.filter(({ type, status, systemIds }) => (
|
|
168
|
+
.filter(({ type, status, systemIds, controlIds, id }) => (
|
|
139
169
|
type === "commitment"
|
|
140
170
|
&& !["retired", "superseded"].includes(status)
|
|
141
171
|
&& (systemIds || []).some((id) => selectedSystemIds.has(id))
|
|
172
|
+
&& (!scopedResource
|
|
173
|
+
|| record.type === "commitment" && id === record.id
|
|
174
|
+
|| record.type === "control" && (controlIds || []).includes(record.id)
|
|
175
|
+
|| record.type === "complementary-control" && (controlIds || []).some((controlId) => selectedControlIds.has(controlId)))
|
|
142
176
|
))
|
|
143
177
|
.sort(compareRecordIds)
|
|
144
178
|
.map((commitment) => pick(commitment, [
|
|
@@ -158,7 +192,7 @@ function compareRecordIds(left, right) {
|
|
|
158
192
|
return left.id.localeCompare(right.id);
|
|
159
193
|
}
|
|
160
194
|
|
|
161
|
-
export function applicabilityReviewIsCurrent(review, record, program, resources, model) {
|
|
195
|
+
export function applicabilityReviewIsCurrent(review, record, program, resources, model, root = null) {
|
|
162
196
|
if (
|
|
163
197
|
review?.scopeRevision
|
|
164
198
|
&& !review.scopeRevision.startsWith("scope:")
|
|
@@ -168,11 +202,76 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
168
202
|
if (!review?.scopeRevision) return false;
|
|
169
203
|
const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
|
|
170
204
|
if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
205
|
+
if (["commitment", "control", "complementary-control"].includes(record?.type) && matches(applicabilityScopeRevision(
|
|
206
|
+
record, program, resources, model, { legacyBroadScope: true }
|
|
207
|
+
))) return true;
|
|
208
|
+
if (["commitment", "control", "complementary-control"].includes(record?.type) && root && historicalScopedReviewMatches(
|
|
209
|
+
review.scopeRevision, record, program, resources, model, root
|
|
210
|
+
)) return true;
|
|
211
|
+
// Older reviews hashed Policy approval metadata alongside scope facts.
|
|
212
|
+
// Reconstruct each Policy independently because other Policies may already
|
|
213
|
+
// have been approved when one Policy changes. Material fields stay current.
|
|
214
|
+
for (const legacyPolicyStatus of ["current", "draft", "in-review", "approved", "active"]) {
|
|
215
|
+
for (const legacyPolicyWithoutApprovalBinding of [false, true]) {
|
|
216
|
+
for (const legacyPolicyWithoutEffectiveOn of [false, true]) {
|
|
217
|
+
if (matches(applicabilityScopeRevision(record, program, resources, model, {
|
|
218
|
+
legacyPolicyStatus,
|
|
219
|
+
legacyPolicyWithoutApprovalBinding,
|
|
220
|
+
legacyPolicyWithoutEffectiveOn
|
|
221
|
+
}))) return true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const selectedControlIds = new Set(program?.controlIds || []);
|
|
226
|
+
const selectedPolicyIds = new Set(resources
|
|
227
|
+
.filter(({ type, id }) => type === "control" && selectedControlIds.has(id))
|
|
228
|
+
.flatMap(({ policyIds }) => policyIds || []));
|
|
229
|
+
for (const id of record.policyIds || []) selectedPolicyIds.add(id);
|
|
230
|
+
const policies = resources.filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id));
|
|
231
|
+
// One Policy may advance while the others retain different approval states.
|
|
232
|
+
// Reconstruct that Policy's former lifecycle fields without changing any
|
|
233
|
+
// material scope fact or searching combinations of unrelated Policies.
|
|
234
|
+
for (const policy of policies) {
|
|
235
|
+
for (const value of legacyPolicyAlternatives(policy)) {
|
|
236
|
+
if (matches(applicabilityScopeRevision(record, program, resources, model, {
|
|
237
|
+
legacyPolicyOverrides: { [policy.id]: value }
|
|
238
|
+
}))) return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function historicalScopedReviewMatches(stored, record, program, resources, model, root) {
|
|
245
|
+
const current = applicabilityScopeRevision(record, program, resources, model);
|
|
246
|
+
for (const commit of getDataCommitHistory(root).reverse()) {
|
|
247
|
+
const snapshot = historicalWorkspace(root, commit);
|
|
248
|
+
if (!snapshot) continue;
|
|
249
|
+
const historicalResources = snapshot.resources;
|
|
250
|
+
const historicalRecord = historicalResources.find(({ id }) => id === record.id);
|
|
251
|
+
const historicalProgram = historicalResources.find(({ id }) => id === program?.id);
|
|
252
|
+
if (!historicalRecord || !historicalProgram) continue;
|
|
253
|
+
const historicalModel = snapshot.model;
|
|
254
|
+
if (!revisionsMatch("applicability-scope", current, applicabilityScopeRevision(
|
|
255
|
+
historicalRecord, historicalProgram, historicalResources, historicalModel
|
|
256
|
+
))) continue;
|
|
257
|
+
if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
|
|
258
|
+
historicalRecord, historicalProgram, historicalResources, historicalModel, { legacyBroadScope: true }
|
|
259
|
+
))) return true;
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function legacyPolicyAlternatives(policy) {
|
|
265
|
+
const prior = {
|
|
266
|
+
active: ["approved", "in-review", "draft"],
|
|
267
|
+
approved: ["in-review", "draft"],
|
|
268
|
+
"in-review": ["draft"]
|
|
269
|
+
}[policy.status] || [];
|
|
270
|
+
return prior.map((status) => ({
|
|
271
|
+
status,
|
|
272
|
+
withoutApprovalBinding: status === "draft" || status === "in-review",
|
|
273
|
+
withoutEffectiveOn: true
|
|
274
|
+
}));
|
|
176
275
|
}
|
|
177
276
|
|
|
178
277
|
function pick(record, fields, model, objectType = null) {
|
package/src/audit-preparation.js
CHANGED
|
@@ -373,7 +373,7 @@ function scopeStage(loaded, audit, records, byId, programReadiness) {
|
|
|
373
373
|
const v4Decisions = new Map((program?.requirementApplicability || [])
|
|
374
374
|
.filter((decision) => (
|
|
375
375
|
requirementById.has(decision.requirementId)
|
|
376
|
-
&& applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), program, records, loaded.model)
|
|
376
|
+
&& applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), program, records, loaded.model, loaded.root)
|
|
377
377
|
))
|
|
378
378
|
.map((decision) => [decision.requirementId, decision]));
|
|
379
379
|
const unresolvedRequirements = frameworkRequirementIds
|
package/src/batch-review.js
CHANGED
|
@@ -37,7 +37,7 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
37
37
|
.filter((review) => (
|
|
38
38
|
["applicable", "not-applicable"].includes(review.decision)
|
|
39
39
|
&& requirementById.has(review.requirementId)
|
|
40
|
-
&& applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model)
|
|
40
|
+
&& applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model, loaded.root)
|
|
41
41
|
))
|
|
42
42
|
.map(({ requirementId }) => requirementId));
|
|
43
43
|
const requirementReviews = new Map((program.requirementApplicability || [])
|
|
@@ -47,7 +47,7 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
47
47
|
&& (!requestedType || record.type === requestedType)
|
|
48
48
|
&& (record.type === "requirement" && modelSupports(loaded.model, "program-scope")
|
|
49
49
|
? !reviewedRequirementIds.has(record.id)
|
|
50
|
-
: !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, loaded.resources, loaded.model))
|
|
50
|
+
: !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, loaded.resources, loaded.model, loaded.root))
|
|
51
51
|
&& !["retired", "superseded"].includes(record.status)
|
|
52
52
|
));
|
|
53
53
|
return {
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
collectionScopeRevisionFacts
|
|
8
8
|
} from "./collection-scope.js";
|
|
9
9
|
import { resolveDataPath } from "./paths.js";
|
|
10
|
+
import { getDataCommitHistory, getFileAtRevision } from "./git.js";
|
|
11
|
+
import { historicalWorkspace } from "./historical-workspace.js";
|
|
10
12
|
import { resolveProgram } from "./program.js";
|
|
11
13
|
import { markdownEntries } from "./resource-markdown.js";
|
|
12
14
|
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
|
|
@@ -39,6 +41,35 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
}
|
|
44
|
+
return historicallyEquivalentCollection(loaded, resourceType, storedRevision, options, currentRevision);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function historicallyEquivalentCollection(loaded, resourceType, stored, options, current) {
|
|
48
|
+
if (!loaded.root) return false;
|
|
49
|
+
for (const commit of getDataCommitHistory(loaded.root).reverse()) {
|
|
50
|
+
const snapshot = historicalWorkspace(loaded.root, commit);
|
|
51
|
+
if (!snapshot) continue;
|
|
52
|
+
const historicalOptions = {
|
|
53
|
+
...options,
|
|
54
|
+
programId: options.programId || options.program?.id,
|
|
55
|
+
historicalCommit: commit
|
|
56
|
+
};
|
|
57
|
+
delete historicalOptions.program;
|
|
58
|
+
delete historicalOptions.currentRevision;
|
|
59
|
+
// Require the complete reviewed basis to be unchanged under the current
|
|
60
|
+
// semantic calculation before accepting an older byte-sensitive digest.
|
|
61
|
+
if (!revisionsMatch("collection", current, collectionRevision(snapshot, resourceType, historicalOptions))) continue;
|
|
62
|
+
for (const legacy of [false, true]) {
|
|
63
|
+
for (const scopeHashInput of ["legacy", "digest"]) {
|
|
64
|
+
for (const scopeFactsInput of ["legacy", "source"]) {
|
|
65
|
+
if (revisionsMatch("collection", stored, calculateCollectionRevision(
|
|
66
|
+
snapshot, resourceType, { ...historicalOptions, historicalReviewMetadata: true },
|
|
67
|
+
legacy, scopeHashInput, scopeFactsInput
|
|
68
|
+
))) return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
42
73
|
return false;
|
|
43
74
|
}
|
|
44
75
|
|
|
@@ -51,7 +82,7 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
|
|
|
51
82
|
: Object.hasOwn(options, "program")
|
|
52
83
|
? options.program
|
|
53
84
|
: resolveProgram(loaded, options.programId);
|
|
54
|
-
const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy })
|
|
85
|
+
const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy, historicalReviewMetadata: options.historicalReviewMetadata })
|
|
55
86
|
.map((input) => [input.record.id, input]));
|
|
56
87
|
const authoritativeSource = loaded.resources.find(({ id }) => id === options.authoritativeSourceId);
|
|
57
88
|
if (authoritativeSource) {
|
|
@@ -71,7 +102,10 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
|
|
|
71
102
|
.digest("hex"),
|
|
72
103
|
contentRevisions: (legacy || includeContent ? markdownEntries(loaded.model, record) : []).flatMap(({ path }) => {
|
|
73
104
|
try {
|
|
74
|
-
const content =
|
|
105
|
+
const content = options.historicalCommit
|
|
106
|
+
? getFileAtRevision(loaded.root, options.historicalCommit, `data/${path}`)
|
|
107
|
+
: readFileSync(resolveDataPath(loaded.root, path), "utf8");
|
|
108
|
+
if (content === null) return [];
|
|
75
109
|
return [{ path, revision: createHash("sha256").update(content).digest("hex") }];
|
|
76
110
|
} catch (error) {
|
|
77
111
|
if (error.code === "ENOENT") return [];
|
|
@@ -80,7 +114,9 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
|
|
|
80
114
|
})
|
|
81
115
|
}))
|
|
82
116
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
83
|
-
const scopeFacts = collectionScopeRevisionFacts(loaded, resourceType, program
|
|
117
|
+
const scopeFacts = collectionScopeRevisionFacts(loaded, resourceType, program, {
|
|
118
|
+
historicalReviewMetadata: options.historicalReviewMetadata
|
|
119
|
+
});
|
|
84
120
|
const workspaceScope = scopeFactsInput === "source" ? scopeFacts : canonicalScopeFacts(scopeFacts);
|
|
85
121
|
const source = JSON.stringify({ resourceType, records, workspaceScope });
|
|
86
122
|
return legacy
|
package/src/collection-scope.js
CHANGED
|
@@ -360,23 +360,26 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
360
360
|
record,
|
|
361
361
|
value: reviewedIds.has(record.id)
|
|
362
362
|
? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
363
|
-
? retentionScheduleRowProposalValue(record)
|
|
364
|
-
: record
|
|
363
|
+
? retentionScheduleRowProposalValue(record, options.historicalReviewMetadata)
|
|
364
|
+
: options.historicalReviewMetadata ? record : substantiveRevisionValue(record)
|
|
365
365
|
: resourceType === "retention-schedule-item"
|
|
366
366
|
&& modelSupports(loaded.model, "retention-schedule-approval")
|
|
367
367
|
&& record.type === "document"
|
|
368
368
|
? retentionScheduleDocumentProposalValue(record)
|
|
369
|
-
:
|
|
369
|
+
: options.historicalReviewMetadata
|
|
370
|
+
? dependencyRevisionValue(resourceType, record, legacy)
|
|
371
|
+
: substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy)),
|
|
370
372
|
includeContent: legacy
|
|
371
373
|
|| reviewedIds.has(record.id)
|
|
372
374
|
|| dependencyContentAffectsRevision(resourceType, record.type)
|
|
373
375
|
}));
|
|
374
376
|
}
|
|
375
377
|
|
|
376
|
-
function retentionScheduleRowProposalValue(record) {
|
|
378
|
+
function retentionScheduleRowProposalValue(record, historicalReviewMetadata = false) {
|
|
377
379
|
return Object.fromEntries(Object.entries(record).filter(([field]) => ![
|
|
378
380
|
"approvedByIds",
|
|
379
|
-
"approvedOn"
|
|
381
|
+
"approvedOn",
|
|
382
|
+
...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions"])
|
|
380
383
|
].includes(field)));
|
|
381
384
|
}
|
|
382
385
|
|
|
@@ -396,7 +399,7 @@ function retentionScheduleDocumentProposalValue(record) {
|
|
|
396
399
|
].includes(field)));
|
|
397
400
|
}
|
|
398
401
|
|
|
399
|
-
export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
402
|
+
export function collectionScopeRevisionFacts(loaded, resourceType, program, options = {}) {
|
|
400
403
|
if (!modelSupports(loaded.model, "program-scope")) {
|
|
401
404
|
const common = { programId: program?.id ?? null };
|
|
402
405
|
if (resourceType === "framework") {
|
|
@@ -426,7 +429,7 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
|
426
429
|
programs: programs.map((record) => ({
|
|
427
430
|
id: record.id,
|
|
428
431
|
systemIds: sorted(record.systemIds),
|
|
429
|
-
controlIds: sorted(record.controlIds)
|
|
432
|
+
...(options.historicalReviewMetadata ? { controlIds: sorted(record.controlIds) } : {})
|
|
430
433
|
}))
|
|
431
434
|
};
|
|
432
435
|
}
|
|
@@ -454,7 +457,11 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
|
454
457
|
assuranceGoal: program?.assuranceGoal ?? null,
|
|
455
458
|
frameworkIds: sorted(program?.frameworkIds),
|
|
456
459
|
requirementIds: sorted(selectedRequirementIds(program || {}, loaded.model)),
|
|
457
|
-
requirementApplicability:
|
|
460
|
+
requirementApplicability: options.historicalReviewMetadata
|
|
461
|
+
? program?.requirementApplicability || []
|
|
462
|
+
: (program?.requirementApplicability || [])
|
|
463
|
+
.map(({ requirementId, decision }) => ({ requirementId, decision }))
|
|
464
|
+
.sort((left, right) => left.requirementId.localeCompare(right.requirementId)),
|
|
458
465
|
riskMethodology: program?.riskMethodology || null
|
|
459
466
|
} : scope;
|
|
460
467
|
}
|
|
@@ -462,9 +469,33 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
|
462
469
|
}
|
|
463
470
|
|
|
464
471
|
export function authoritativeSourceRevisionValue(record) {
|
|
465
|
-
return Object.fromEntries(Object.entries(record).filter(([field]) => (
|
|
472
|
+
return substantiveRevisionValue(Object.fromEntries(Object.entries(record).filter(([field]) => (
|
|
466
473
|
!authoritativeSourceBookkeepingFields.has(field)
|
|
467
|
-
)));
|
|
474
|
+
))));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function substantiveRevisionValue(record) {
|
|
478
|
+
if (!record) return record;
|
|
479
|
+
const value = { ...record };
|
|
480
|
+
if (value.type === "retention-schedule-item") {
|
|
481
|
+
delete value.approvedByIds;
|
|
482
|
+
delete value.approvedOn;
|
|
483
|
+
delete value.reviewedSourceRevisions;
|
|
484
|
+
}
|
|
485
|
+
if (value.type === "program" && value.requirementApplicability) {
|
|
486
|
+
value.requirementApplicability = value.requirementApplicability
|
|
487
|
+
.map(({ requirementId, decision }) => ({ requirementId, decision }))
|
|
488
|
+
.sort((left, right) => left.requirementId.localeCompare(right.requirementId));
|
|
489
|
+
}
|
|
490
|
+
if (value.applicabilityReview) value.applicabilityReview = { decision: value.applicabilityReview.decision };
|
|
491
|
+
if (["policy", "document", "training"].includes(value.type)) {
|
|
492
|
+
for (const field of [
|
|
493
|
+
"approverIds", "approvedOn", "activationBasis", "activatedByIds",
|
|
494
|
+
"activatedOn", "activatedContentRevisions", "statusTransition"
|
|
495
|
+
]) delete value[field];
|
|
496
|
+
if (["approved", "active"].includes(value.status)) value.status = "approved";
|
|
497
|
+
}
|
|
498
|
+
return value;
|
|
468
499
|
}
|
|
469
500
|
|
|
470
501
|
const authoritativeSourceBookkeepingFields = new Set([
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadModel } from "../model/index.js";
|
|
2
|
+
import { getDataFilesAtRevision, getFileAtRevision } from "./git.js";
|
|
3
|
+
|
|
4
|
+
// Rebuild only the authoritative data needed for a historical revision check.
|
|
5
|
+
// The caller must compare current and historical facts before accepting a
|
|
6
|
+
// legacy binding; this snapshot never supplies a new approval.
|
|
7
|
+
export function historicalWorkspace(root, commit) {
|
|
8
|
+
const entries = [];
|
|
9
|
+
for (const path of getDataFilesAtRevision(root, commit).filter(authoritativeJsonPath)) {
|
|
10
|
+
const source = getFileAtRevision(root, commit, path);
|
|
11
|
+
if (!source) return null;
|
|
12
|
+
try {
|
|
13
|
+
entries.push({ record: JSON.parse(source), source, relativePath: path.slice("data/".length) });
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const resources = entries.map(({ record }) => record);
|
|
19
|
+
const workspace = resources.find(({ type }) => type === "workspace");
|
|
20
|
+
if (!workspace) return null;
|
|
21
|
+
try {
|
|
22
|
+
return { root, entries, resources, workspace, model: loadModel(workspace.dataModelVersion) };
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function authoritativeJsonPath(path) {
|
|
29
|
+
const parts = path.split("/");
|
|
30
|
+
if (parts[0] !== "data" || !path.endsWith(".json")) return false;
|
|
31
|
+
if (parts.slice(1).some((part) => part.startsWith(".") || part === "content")) return false;
|
|
32
|
+
return !(parts[1] === "evidence" && parts.length > 3 && parts.at(-1) !== "evidence.json");
|
|
33
|
+
}
|
package/src/program-readiness.js
CHANGED
|
@@ -155,7 +155,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
155
155
|
};
|
|
156
156
|
|
|
157
157
|
const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model);
|
|
158
|
-
const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
|
|
158
|
+
const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model, loaded.root);
|
|
159
159
|
controlStage.items.unshift(...collectionReviews
|
|
160
160
|
.filter(({ resourceType }) => resourceType === "complementary-control")
|
|
161
161
|
.map(collectionReviewReadinessItem));
|
|
@@ -218,7 +218,8 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
218
218
|
byId,
|
|
219
219
|
loaded.model,
|
|
220
220
|
collectionReviews.filter(({ resourceType }) => ["person", "framework", "system", "component", "vendor", "classification", "information-type"].includes(resourceType)),
|
|
221
|
-
workspace?.timezone || "UTC"
|
|
221
|
+
workspace?.timezone || "UTC",
|
|
222
|
+
loaded.root
|
|
222
223
|
);
|
|
223
224
|
if (modelSupports(loaded.model, "reporting-route-sets")) {
|
|
224
225
|
const routeSets = await assessReportingRouteSets(loaded, {
|
|
@@ -373,7 +374,7 @@ function programScope(program, records, byId, model, loaded) {
|
|
|
373
374
|
};
|
|
374
375
|
}
|
|
375
376
|
|
|
376
|
-
function scopeStage(workspace, scope, records, byId, model, collectionReviews = [], timezone = "UTC") {
|
|
377
|
+
function scopeStage(workspace, scope, records, byId, model, collectionReviews = [], timezone = "UTC", root = null) {
|
|
377
378
|
const items = [];
|
|
378
379
|
const goal = workspace?.assuranceGoal || "none";
|
|
379
380
|
items.push(item(
|
|
@@ -459,7 +460,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
459
460
|
&& record.effectiveOn
|
|
460
461
|
&& (!model.resources.commitment?.fields?.applicabilityReview || (
|
|
461
462
|
record.applicabilityReview?.decision === "applicable"
|
|
462
|
-
&& applicabilityReviewIsCurrent(record.applicabilityReview, record, workspace, records, model)
|
|
463
|
+
&& applicabilityReviewIsCurrent(record.applicabilityReview, record, workspace, records, model, root)
|
|
463
464
|
))
|
|
464
465
|
&& currentPartyPeople(record.ownerIds, byId).size > 0
|
|
465
466
|
&& (record.requirementIds || []).length > 0
|
|
@@ -496,7 +497,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
496
497
|
const reviewedRequirements = (workspace?.requirementApplicability || [])
|
|
497
498
|
.filter((decision) => (
|
|
498
499
|
requirementById.has(decision.requirementId)
|
|
499
|
-
&& applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model)
|
|
500
|
+
&& applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model, root)
|
|
500
501
|
));
|
|
501
502
|
const v4Decisions = new Map(reviewedRequirements.map((decision) => [decision.requirementId, decision.decision]));
|
|
502
503
|
const staleRequirementIds = new Set((workspace?.requirementApplicability || [])
|
|
@@ -1617,7 +1618,7 @@ function legacyPolicyLibraryProposals(records) {
|
|
|
1617
1618
|
}];
|
|
1618
1619
|
}
|
|
1619
1620
|
|
|
1620
|
-
async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
1621
|
+
async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null) {
|
|
1621
1622
|
const items = [];
|
|
1622
1623
|
const families = selectedControlFamilies(scope.controls, model);
|
|
1623
1624
|
if (!scope.controls.length) {
|
|
@@ -1651,7 +1652,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
1651
1652
|
const checks = {
|
|
1652
1653
|
...(model.resources.control?.fields?.applicabilityReview ? {
|
|
1653
1654
|
applicability: control.applicabilityReview?.decision === "applicable"
|
|
1654
|
-
&& applicabilityReviewIsCurrent(control.applicabilityReview, control, scope.program, [...byId.values()], model)
|
|
1655
|
+
&& applicabilityReviewIsCurrent(control.applicabilityReview, control, scope.program, [...byId.values()], model, root)
|
|
1655
1656
|
} : {}),
|
|
1656
1657
|
implemented: control.status === "implemented",
|
|
1657
1658
|
owner: (control.ownerIds || []).length > 0,
|
package/src/retention.js
CHANGED
|
@@ -6,6 +6,8 @@ import { programComponents } from "./program.js";
|
|
|
6
6
|
import { markdownEntries } from "./resource-markdown.js";
|
|
7
7
|
import { modelSupports } from "../model/index.js";
|
|
8
8
|
import { canonicalCalculatedRevisionJson, revisionDigest, revisionsMatch } from "./revisions.js";
|
|
9
|
+
import { getDataCommitHistory, getFileAtRevision } from "./git.js";
|
|
10
|
+
import { historicalWorkspace } from "./historical-workspace.js";
|
|
9
11
|
|
|
10
12
|
export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
11
13
|
if (!loaded.model.resources["retention-schedule-item"]) return [];
|
|
@@ -187,7 +189,7 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
|
|
|
187
189
|
return new Map([...revisions].filter(([id]) => wanted.has(id)));
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy") {
|
|
192
|
+
export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null) {
|
|
191
193
|
const wanted = new Set(ids);
|
|
192
194
|
const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
193
195
|
const revisions = new Map();
|
|
@@ -197,10 +199,13 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
|
|
|
197
199
|
const entry = entries.get(id);
|
|
198
200
|
if (!entry || reviewing.has(id)) return null;
|
|
199
201
|
reviewing.add(id);
|
|
200
|
-
const parts = [reviewSource(loaded, entry, scopeHashInput)];
|
|
202
|
+
const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource)];
|
|
201
203
|
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
202
204
|
try {
|
|
203
|
-
|
|
205
|
+
const source = historicalCommit
|
|
206
|
+
? getFileAtRevision(loaded.root, historicalCommit, `data/${markdown.path}`)
|
|
207
|
+
: readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8");
|
|
208
|
+
if (source !== null) parts.push(source);
|
|
204
209
|
} catch (error) {
|
|
205
210
|
if (error.code !== "ENOENT") throw error;
|
|
206
211
|
}
|
|
@@ -222,10 +227,31 @@ export function resourceReviewRevisionMatches(loaded, revisions, id, stored) {
|
|
|
222
227
|
const current = revisions.get(id);
|
|
223
228
|
if (!current || !revisionDigest("content", stored)) return false;
|
|
224
229
|
if (revisionsMatch("content", stored, current)) return true;
|
|
230
|
+
const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id);
|
|
231
|
+
if (revisionsMatch("content", stored, legacy)) return true;
|
|
225
232
|
// Version 0.16.0 used a bare digest as the hash input for scopeRevision.
|
|
226
233
|
// Read that binding without changing the legacy scope: basis restored here.
|
|
227
|
-
const compatible = resourceReviewRevisionsSync(loaded, [id], "digest").get(id);
|
|
228
|
-
|
|
234
|
+
const compatible = resourceReviewRevisionsSync(loaded, [id], "digest", true).get(id);
|
|
235
|
+
if (revisionsMatch("content", stored, compatible)) return true;
|
|
236
|
+
return historicallyEquivalentReviewSource(loaded, id, stored, current);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Recompute the old byte-sensitive binding at each committed state. The
|
|
240
|
+
// semantic source graph must still equal today's graph, including linked
|
|
241
|
+
// records and Markdown, before the old approval is treated as current.
|
|
242
|
+
function historicallyEquivalentReviewSource(loaded, id, stored, current) {
|
|
243
|
+
if (!loaded.root) return false;
|
|
244
|
+
for (const commit of getDataCommitHistory(loaded.root).reverse()) {
|
|
245
|
+
const snapshot = historicalWorkspace(loaded.root, commit);
|
|
246
|
+
if (!snapshot?.entries.some(({ record }) => record.id === id)) continue;
|
|
247
|
+
const semantic = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
|
|
248
|
+
if (!revisionsMatch("content", semantic, current)) continue;
|
|
249
|
+
for (const scopeHashInput of ["legacy", "digest"]) {
|
|
250
|
+
const legacy = resourceReviewRevisionsSync(snapshot, [id], scopeHashInput, true, commit).get(id);
|
|
251
|
+
if (revisionsMatch("content", stored, legacy)) return true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
229
255
|
}
|
|
230
256
|
|
|
231
257
|
export function retentionUses(loaded, program) {
|
|
@@ -249,7 +275,7 @@ export function retentionUses(loaded, program) {
|
|
|
249
275
|
return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
|
|
250
276
|
}
|
|
251
277
|
|
|
252
|
-
function reviewSource(loaded, entry, scopeHashInput = "legacy") {
|
|
278
|
+
function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false) {
|
|
253
279
|
if (
|
|
254
280
|
modelSupports(loaded.model, "retention-schedule-approval")
|
|
255
281
|
&& entry.record.type === "document"
|
|
@@ -271,7 +297,34 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy") {
|
|
|
271
297
|
]) delete approved[field];
|
|
272
298
|
return JSON.stringify(approved);
|
|
273
299
|
}
|
|
274
|
-
return
|
|
300
|
+
return legacySource
|
|
301
|
+
? canonicalCalculatedRevisionJson(entry.source, scopeHashInput)
|
|
302
|
+
: canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(JSON.parse(entry.source))), scopeHashInput);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function substantiveReviewSource(record) {
|
|
306
|
+
const source = record.type === "program" ? {
|
|
307
|
+
id: record.id,
|
|
308
|
+
type: record.type,
|
|
309
|
+
status: record.status,
|
|
310
|
+
assuranceGoal: record.assuranceGoal,
|
|
311
|
+
systemIds: [...(record.systemIds || [])].sort(),
|
|
312
|
+
extensions: record.extensions,
|
|
313
|
+
requirementApplicability: (record.requirementApplicability || [])
|
|
314
|
+
.map(({ requirementId, decision }) => ({ requirementId, decision }))
|
|
315
|
+
.sort((left, right) => left.requirementId.localeCompare(right.requirementId))
|
|
316
|
+
} : { ...record };
|
|
317
|
+
if (source.applicabilityReview) {
|
|
318
|
+
source.applicabilityReview = { decision: source.applicabilityReview.decision };
|
|
319
|
+
}
|
|
320
|
+
if (["policy", "document", "training"].includes(source.type)) {
|
|
321
|
+
for (const field of [
|
|
322
|
+
"approverIds", "approvedOn", "activationBasis", "activatedByIds",
|
|
323
|
+
"activatedOn", "activatedContentRevisions", "statusTransition"
|
|
324
|
+
]) delete source[field];
|
|
325
|
+
if (["approved", "active"].includes(source.status)) source.status = "approved";
|
|
326
|
+
}
|
|
327
|
+
return source;
|
|
275
328
|
}
|
|
276
329
|
|
|
277
330
|
export function nearDuplicateInformationTypes(records) {
|
package/src/state.js
CHANGED
|
@@ -351,7 +351,7 @@ function assessApplicabilityReviewStatuses(loaded, program) {
|
|
|
351
351
|
: record.applicabilityReview;
|
|
352
352
|
return [record.id, !review || !["applicable", "not-applicable", "externally-managed", "zero-population"].includes(review.decision)
|
|
353
353
|
? "missing" : applicabilityReviewIsCurrent(
|
|
354
|
-
review, record, program, loaded.resources, loaded.model
|
|
354
|
+
review, record, program, loaded.resources, loaded.model, loaded.root
|
|
355
355
|
) ? "current" : "stale"];
|
|
356
356
|
}));
|
|
357
357
|
}
|
package/src/workflow.js
CHANGED
|
@@ -397,7 +397,7 @@ function recordFinalizationFindings(loaded, program) {
|
|
|
397
397
|
if (incomplete) {
|
|
398
398
|
findings.push(finalizationFinding(record, incomplete, loaded.model));
|
|
399
399
|
}
|
|
400
|
-
for (const missing of finalizationFields(record, loaded.model, loaded.resources, program)) {
|
|
400
|
+
for (const missing of finalizationFields(record, loaded.model, loaded.resources, program, loaded.root)) {
|
|
401
401
|
findings.push(fieldFinding(record, missing, loaded.model));
|
|
402
402
|
}
|
|
403
403
|
}
|
|
@@ -532,11 +532,11 @@ function recordFinalizationRequiredness(record, loaded, program) {
|
|
|
532
532
|
return "conditional";
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
-
function finalizationFields(record, model, resources, program) {
|
|
535
|
+
function finalizationFields(record, model, resources, program, root) {
|
|
536
536
|
const fields = [];
|
|
537
537
|
const needsReview = ["requirement", "commitment", "complementary-control", "control"].includes(record.type)
|
|
538
538
|
&& model.resources[record.type]?.fields?.applicabilityReview
|
|
539
|
-
&& !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, resources, model);
|
|
539
|
+
&& !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, resources, model, root);
|
|
540
540
|
if (needsReview) {
|
|
541
541
|
const currentRevision = applicabilityScopeRevision(record, program, resources, model);
|
|
542
542
|
fields.push({
|