filegrc 0.9.2 → 0.11.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/model/index.js +8 -5
- package/model/v7.json +10359 -0
- package/model/v8.json +10947 -0
- package/package.json +1 -1
- package/src/applicability-scope.js +211 -0
- package/src/audit-preparation.js +156 -20
- package/src/batch-review.js +40 -24
- package/src/cli.js +85 -7
- package/src/collection-review.js +16 -3
- package/src/collection-revision.js +23 -3
- package/src/collection-scope.js +94 -7
- package/src/document-activation.js +13 -1
- package/src/git.js +4 -4
- package/src/index.js +3 -0
- package/src/model-migration.js +381 -35
- package/src/obligations.js +142 -39
- package/src/policy-activation.js +5 -0
- package/src/policy-library/data-retention-schedule-v2.md +25 -0
- package/src/policy-library.js +52 -24
- package/src/program-amendment.js +222 -0
- package/src/program-lifecycle.js +1 -1
- package/src/program-path.js +13 -8
- package/src/program-readiness.js +43 -13
- package/src/reconciliation.js +15 -3
- package/src/requirement-mapping.js +61 -0
- package/src/retention.js +261 -0
- package/src/server.js +76 -2
- package/src/setup.js +1 -1
- package/src/source-coverage.js +21 -7
- package/src/state.js +171 -2
- package/src/validate.js +106 -11
- package/src/web.js +441 -70
- package/src/workflow.js +33 -13
package/package.json
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const excludedResourceFields = new Set([
|
|
4
|
+
"applicabilityReview",
|
|
5
|
+
"applicability",
|
|
6
|
+
"status",
|
|
7
|
+
"statusTransition",
|
|
8
|
+
"effectiveOn",
|
|
9
|
+
"procedureEffectiveOn",
|
|
10
|
+
"procedureRevision",
|
|
11
|
+
"implementationReviewedByIds",
|
|
12
|
+
"implementationReviewedOn"
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const unorderedStringArrayFields = new Set([
|
|
16
|
+
"audience",
|
|
17
|
+
"roles",
|
|
18
|
+
"servicesProvided",
|
|
19
|
+
"exclusions",
|
|
20
|
+
"evidenceSourceKinds",
|
|
21
|
+
"tags"
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export function applicabilityScopeRevision(record, program, resources, model) {
|
|
25
|
+
const selectedSystemIds = new Set(program?.systemIds || []);
|
|
26
|
+
const selectedFrameworkIds = new Set(program?.frameworkIds || []);
|
|
27
|
+
const selectedControlIds = new Set(program?.controlIds || []);
|
|
28
|
+
const selectedComponents = resources.filter(({ type, status, systemUses }) => (
|
|
29
|
+
type === "component"
|
|
30
|
+
&& status !== "retired"
|
|
31
|
+
&& (systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
|
|
32
|
+
));
|
|
33
|
+
const selectedVendorIds = new Set(selectedComponents.map(({ vendorId }) => vendorId).filter(Boolean));
|
|
34
|
+
const selectedPolicyIds = new Set(resources
|
|
35
|
+
.filter(({ type, id }) => type === "control" && selectedControlIds.has(id))
|
|
36
|
+
.flatMap(({ policyIds }) => policyIds || []));
|
|
37
|
+
for (const policyId of record.policyIds || []) selectedPolicyIds.add(policyId);
|
|
38
|
+
const facts = {
|
|
39
|
+
modelVersion: model.modelVersion,
|
|
40
|
+
program: {
|
|
41
|
+
...pick(program || {}, [
|
|
42
|
+
"id",
|
|
43
|
+
"assuranceGoal",
|
|
44
|
+
"systemIds",
|
|
45
|
+
"frameworkIds",
|
|
46
|
+
"requirementIds",
|
|
47
|
+
"controlIds",
|
|
48
|
+
"riskMethodology"
|
|
49
|
+
], model),
|
|
50
|
+
...(record.type === "requirement" ? {} : {
|
|
51
|
+
requirementApplicability: (program?.requirementApplicability || [])
|
|
52
|
+
.map((review) => pick(review, ["requirementId", "decision"], model, "program-applicability"))
|
|
53
|
+
.sort((left, right) => left.requirementId.localeCompare(right.requirementId))
|
|
54
|
+
})
|
|
55
|
+
},
|
|
56
|
+
resource: canonicalObject(model, record.type, Object.fromEntries(Object.entries(record)
|
|
57
|
+
.filter(([field]) => !excludedResourceFields.has(field)))),
|
|
58
|
+
systems: resources
|
|
59
|
+
.filter(({ type, id }) => type === "system" && selectedSystemIds.has(id))
|
|
60
|
+
.sort(compareRecordIds)
|
|
61
|
+
.map((system) => pick(system, [
|
|
62
|
+
"id",
|
|
63
|
+
"purpose",
|
|
64
|
+
"servicesProvided",
|
|
65
|
+
"boundary",
|
|
66
|
+
"exclusions",
|
|
67
|
+
"criticality",
|
|
68
|
+
"informationTypeIds",
|
|
69
|
+
"classificationId",
|
|
70
|
+
"internetExposed"
|
|
71
|
+
], model)),
|
|
72
|
+
frameworks: resources
|
|
73
|
+
.filter(({ type, id }) => type === "framework" && selectedFrameworkIds.has(id))
|
|
74
|
+
.sort(compareRecordIds)
|
|
75
|
+
.map((framework) => pick(framework, [
|
|
76
|
+
"id",
|
|
77
|
+
"status",
|
|
78
|
+
"title",
|
|
79
|
+
"version",
|
|
80
|
+
"publisher",
|
|
81
|
+
"description",
|
|
82
|
+
"sourceReference",
|
|
83
|
+
"effectiveOn"
|
|
84
|
+
], model)),
|
|
85
|
+
components: selectedComponents
|
|
86
|
+
.sort(compareRecordIds)
|
|
87
|
+
.map((component) => pick(component, [
|
|
88
|
+
"id",
|
|
89
|
+
"status",
|
|
90
|
+
"componentKind",
|
|
91
|
+
"description",
|
|
92
|
+
"criticality",
|
|
93
|
+
"environment",
|
|
94
|
+
"vendorId",
|
|
95
|
+
"systemUses",
|
|
96
|
+
"informationUses",
|
|
97
|
+
"internetExposed",
|
|
98
|
+
"classificationId",
|
|
99
|
+
"continuityObjectives"
|
|
100
|
+
], model)),
|
|
101
|
+
vendors: resources
|
|
102
|
+
.filter(({ type, id }) => type === "vendor" && selectedVendorIds.has(id))
|
|
103
|
+
.sort(compareRecordIds)
|
|
104
|
+
.map((vendor) => pick(vendor, [
|
|
105
|
+
"id",
|
|
106
|
+
"status",
|
|
107
|
+
"category",
|
|
108
|
+
"criticality",
|
|
109
|
+
"description",
|
|
110
|
+
"standardAgreement",
|
|
111
|
+
"agreementDocumentId",
|
|
112
|
+
"startDate",
|
|
113
|
+
"endDate",
|
|
114
|
+
"informationTypeIds",
|
|
115
|
+
"classificationId"
|
|
116
|
+
], model)),
|
|
117
|
+
policies: resources
|
|
118
|
+
.filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id))
|
|
119
|
+
.sort(compareRecordIds)
|
|
120
|
+
.map((policy) => pick(policy, [
|
|
121
|
+
"id",
|
|
122
|
+
"status",
|
|
123
|
+
"policyKind",
|
|
124
|
+
"version",
|
|
125
|
+
"programRole",
|
|
126
|
+
"effectiveOn",
|
|
127
|
+
"requirementIds",
|
|
128
|
+
"audience",
|
|
129
|
+
"acknowledgementRequired",
|
|
130
|
+
"relatedDocumentIds",
|
|
131
|
+
"approvedContentRevisions"
|
|
132
|
+
], model)),
|
|
133
|
+
requirements: resources
|
|
134
|
+
.filter(({ type, frameworkId }) => type === "requirement" && selectedFrameworkIds.has(frameworkId))
|
|
135
|
+
.sort(compareRecordIds)
|
|
136
|
+
.map((requirement) => pick(requirement, ["id", "frameworkId", "reference", "description", "parentRequirementId"], model)),
|
|
137
|
+
commitments: resources
|
|
138
|
+
.filter(({ type, status, systemIds }) => (
|
|
139
|
+
type === "commitment"
|
|
140
|
+
&& !["retired", "superseded"].includes(status)
|
|
141
|
+
&& (systemIds || []).some((id) => selectedSystemIds.has(id))
|
|
142
|
+
))
|
|
143
|
+
.sort(compareRecordIds)
|
|
144
|
+
.map((commitment) => pick(commitment, [
|
|
145
|
+
"id",
|
|
146
|
+
"commitmentKind",
|
|
147
|
+
"statement",
|
|
148
|
+
"systemIds",
|
|
149
|
+
"requirementIds",
|
|
150
|
+
"controlIds",
|
|
151
|
+
"customerFacing"
|
|
152
|
+
], model))
|
|
153
|
+
};
|
|
154
|
+
return `scope:${createHash("sha256").update(stableJson(facts)).digest("hex")}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compareRecordIds(left, right) {
|
|
158
|
+
return left.id.localeCompare(right.id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function applicabilityReviewIsCurrent(review, record, program, resources, model) {
|
|
162
|
+
if (
|
|
163
|
+
review?.scopeRevision
|
|
164
|
+
&& !review.scopeRevision.startsWith("scope:")
|
|
165
|
+
&& Number(model?.modelVersion || 0) < 7
|
|
166
|
+
) return true;
|
|
167
|
+
return Boolean(
|
|
168
|
+
review?.scopeRevision
|
|
169
|
+
&& review.scopeRevision === applicabilityScopeRevision(record, program, resources, model)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function pick(record, fields, model, objectType = null) {
|
|
174
|
+
return canonicalObject(model, objectType || record.type, Object.fromEntries(fields
|
|
175
|
+
.filter((field) => record?.[field] !== undefined)
|
|
176
|
+
.map((field) => [field, record[field]])));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function canonicalObject(model, type, value) {
|
|
180
|
+
const fields = model.resources?.[type]
|
|
181
|
+
? { ...model.commonFields, ...model.resources[type].fields }
|
|
182
|
+
: model.objectTypes?.[type]?.properties || {};
|
|
183
|
+
return Object.fromEntries(Object.keys(value).sort().map((name) => [
|
|
184
|
+
name,
|
|
185
|
+
canonicalFieldValue(model, value[name], fields[name], name)
|
|
186
|
+
]));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function canonicalFieldValue(model, value, field, name) {
|
|
190
|
+
if (Array.isArray(value)) {
|
|
191
|
+
const items = value.map((item) => field?.itemObjectType && item && typeof item === "object"
|
|
192
|
+
? canonicalObject(model, field.itemObjectType, item)
|
|
193
|
+
: item);
|
|
194
|
+
if (field?.relation || field?.items === "id" || field?.itemObjectType || unorderedStringArrayFields.has(name)) {
|
|
195
|
+
items.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
196
|
+
}
|
|
197
|
+
return items;
|
|
198
|
+
}
|
|
199
|
+
if (value && typeof value === "object") {
|
|
200
|
+
return field?.objectType ? canonicalObject(model, field.objectType, value) : value;
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stableJson(value) {
|
|
206
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
207
|
+
if (value && typeof value === "object") {
|
|
208
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
209
|
+
}
|
|
210
|
+
return JSON.stringify(value);
|
|
211
|
+
}
|
package/src/audit-preparation.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { modelSupports } from "../model/index.js";
|
|
4
|
+
import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
|
|
4
5
|
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
5
6
|
import {
|
|
6
7
|
coverageContains,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
import { createResource, createResources, deleteResource, updateResource } from "./files.js";
|
|
14
15
|
import { getChangedDataPathsSinceRevision, getFileAtRevision, hasGitRevision } from "./git.js";
|
|
15
16
|
import { createResourceId } from "./id.js";
|
|
17
|
+
import { planObligations } from "./obligations.js";
|
|
16
18
|
import { currentPartyPeople, partiesIndependent } from "./parties.js";
|
|
17
19
|
import { resolveDataPath } from "./paths.js";
|
|
18
20
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
@@ -68,27 +70,31 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
68
70
|
: audits.find((record) => !["complete", "closed", "canceled"].includes(record.status)) || audits[0];
|
|
69
71
|
if (options.auditId && !audit) throw new Error(`Audit "${options.auditId}" was not found.`);
|
|
70
72
|
|
|
73
|
+
const calendarAsOf = options.asOf || currentCalendarDate(loaded.workspace?.timezone || "UTC");
|
|
74
|
+
const formalPeriodEnd = audit?.auditKind === "soc-2-type-2" ? coverageEnd(audit.coverage) : null;
|
|
75
|
+
const readinessAsOf = formalPeriodEnd && formalPeriodEnd < calendarAsOf ? formalPeriodEnd : calendarAsOf;
|
|
71
76
|
const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
|
|
72
|
-
asOf:
|
|
77
|
+
asOf: readinessAsOf,
|
|
73
78
|
generatedAt: options.generatedAt,
|
|
74
79
|
programId: audit?.programId
|
|
75
80
|
});
|
|
76
81
|
const documentActivations = audit && modelSupports(loaded.model, "governed-document-activation")
|
|
77
|
-
? await auditDocumentActivationAssessments(loaded, audit, byId,
|
|
82
|
+
? await auditDocumentActivationAssessments(loaded, audit, byId, calendarAsOf)
|
|
78
83
|
: [];
|
|
79
84
|
const stages = [
|
|
80
|
-
programFoundationStage(programReadiness, loaded.workspace),
|
|
85
|
+
programFoundationStage(programReadiness, loaded.workspace, audit),
|
|
81
86
|
engagementStage(audit, byId, programReadiness),
|
|
82
87
|
scopeStage(loaded, audit, records, byId, programReadiness)
|
|
83
88
|
];
|
|
84
89
|
const fieldworkSections = audit
|
|
85
90
|
? [
|
|
86
|
-
await documentsStage(loaded, audit, byId,
|
|
91
|
+
await documentsStage(loaded, audit, byId, calendarAsOf),
|
|
87
92
|
evidenceStage(audit, records, byId, loaded.model),
|
|
88
|
-
populationsStage(audit, records, byId, loaded.model)
|
|
93
|
+
populationsStage(audit, records, byId, loaded.model),
|
|
94
|
+
occurrenceContinuityStage(audit, records, loaded.model, programReadiness.asOf)
|
|
89
95
|
]
|
|
90
96
|
: [];
|
|
91
|
-
stages.push(fieldworkStage(audit, fieldworkSections));
|
|
97
|
+
stages.push(fieldworkStage(audit, fieldworkSections, programReadiness));
|
|
92
98
|
stages.push(auditorStage(audit, byId, loaded.model.modelVersion));
|
|
93
99
|
|
|
94
100
|
for (const stage of stages) {
|
|
@@ -191,7 +197,9 @@ export async function prepareAuditWorkspace(input, options = {}) {
|
|
|
191
197
|
.filter(Boolean);
|
|
192
198
|
const v4 = modelSupports(loaded.model, "program-scope");
|
|
193
199
|
const sourceSystems = loaded.resources.filter((record) => record.type === (v4 ? "component" : "system"));
|
|
194
|
-
const populations = (audit.auditKind === "soc-2-type-2"
|
|
200
|
+
const populations = (audit.auditKind === "soc-2-type-2"
|
|
201
|
+
? applicablePopulationTemplates(audit, loaded.resources, model.populationTemplates || [])
|
|
202
|
+
: [])
|
|
195
203
|
.filter((template) => !existingKinds.has(template.kind))
|
|
196
204
|
.map((template) => {
|
|
197
205
|
const id = createResourceId(
|
|
@@ -348,7 +356,15 @@ function scopeStage(loaded, audit, records, byId, programReadiness) {
|
|
|
348
356
|
.filter((record) => record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId))
|
|
349
357
|
.map((record) => record.id);
|
|
350
358
|
const program = audit.programId ? byId.get(audit.programId) : null;
|
|
351
|
-
const
|
|
359
|
+
const requirementById = new Map(records
|
|
360
|
+
.filter(({ type }) => type === "requirement")
|
|
361
|
+
.map((record) => [record.id, record]));
|
|
362
|
+
const v4Decisions = new Map((program?.requirementApplicability || [])
|
|
363
|
+
.filter((decision) => (
|
|
364
|
+
requirementById.has(decision.requirementId)
|
|
365
|
+
&& applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), program, records, loaded.model)
|
|
366
|
+
))
|
|
367
|
+
.map((decision) => [decision.requirementId, decision]));
|
|
352
368
|
const unresolvedRequirements = frameworkRequirementIds
|
|
353
369
|
.map((id) => byId.get(id))
|
|
354
370
|
.filter((requirement) => {
|
|
@@ -727,16 +743,21 @@ function canonicalScopeValue(value) {
|
|
|
727
743
|
return value;
|
|
728
744
|
}
|
|
729
745
|
|
|
730
|
-
function programFoundationStage(programReadiness, workspace) {
|
|
731
|
-
const
|
|
746
|
+
function programFoundationStage(programReadiness, workspace, audit) {
|
|
747
|
+
const needsOperatingPeriod = audit?.auditKind === "soc-2-type-2";
|
|
748
|
+
const ready = needsOperatingPeriod ? programReadiness.operating : programReadiness.evidenceReady;
|
|
732
749
|
return stage("program", "Program Readiness", "The management program can be prepared and operated without an audit record or CPA firm.", [
|
|
733
750
|
item(
|
|
734
751
|
"evidence-ready",
|
|
735
752
|
ready ? "complete" : "action",
|
|
736
753
|
"Reach the Evidence Ready gate",
|
|
737
754
|
ready
|
|
738
|
-
?
|
|
739
|
-
|
|
755
|
+
? needsOperatingPeriod
|
|
756
|
+
? `${programReadiness.target.label} is operating for the selected engagement.`
|
|
757
|
+
: `${programReadiness.target.label} is evidence-ready.`
|
|
758
|
+
: needsOperatingPeriod && programReadiness.evidenceReady
|
|
759
|
+
? "Start the candidate period and finish the current operating-readiness work before treating a Type 2 engagement as management-ready."
|
|
760
|
+
: `${programReadiness.counts.action} program-readiness actions remain across scope, policies, controls, and evidence preparation.`,
|
|
740
761
|
workspace || { type: "workspace" }
|
|
741
762
|
)
|
|
742
763
|
]);
|
|
@@ -858,14 +879,23 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
858
879
|
return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", items);
|
|
859
880
|
}
|
|
860
881
|
|
|
861
|
-
function fieldworkStage(audit, sections) {
|
|
882
|
+
function fieldworkStage(audit, sections, programReadiness) {
|
|
862
883
|
if (!audit) {
|
|
863
884
|
return stage("fieldwork", "Prepare Fieldwork", "Build engagement-specific documents, exact-period evidence, and Type 2 populations after the firm and period are recorded.", [
|
|
864
885
|
item("fieldwork-later", "later", "Prepare engagement-specific fieldwork", "This work starts after the CPA engagement and formal period exist.", { type: "audit" })
|
|
865
886
|
]);
|
|
866
887
|
}
|
|
888
|
+
const formalPeriodReady = Boolean(coverageStart(audit.coverage) && coverageEnd(audit.coverage));
|
|
889
|
+
const programReady = audit.auditKind === "soc-2-type-2"
|
|
890
|
+
? programReadiness.operating
|
|
891
|
+
: programReadiness.evidenceReady;
|
|
892
|
+
const available = formalPeriodReady && programReady;
|
|
867
893
|
const items = sections.flatMap((section) => section.items.map((current) => ({
|
|
868
894
|
...current,
|
|
895
|
+
...(available || ["complete", "info", "external"].includes(current.status) ? {} : {
|
|
896
|
+
status: "later",
|
|
897
|
+
message: `${current.message} Finish ${formalPeriodReady ? "Program Readiness" : "the formal period and Program Readiness"} before starting this fieldwork item.`
|
|
898
|
+
}),
|
|
869
899
|
id: `${section.id}-${current.id}`,
|
|
870
900
|
section: section.title
|
|
871
901
|
})));
|
|
@@ -877,6 +907,89 @@ function fieldworkStage(audit, sections) {
|
|
|
877
907
|
);
|
|
878
908
|
}
|
|
879
909
|
|
|
910
|
+
function occurrenceContinuityStage(audit, records, model, asOf) {
|
|
911
|
+
if (audit.auditKind !== "soc-2-type-2") {
|
|
912
|
+
return stage(
|
|
913
|
+
"occurrences",
|
|
914
|
+
"Operating Occurrences",
|
|
915
|
+
"Occurrence continuity applies to Type 2 operating-effectiveness periods.",
|
|
916
|
+
[item("type-2-only", "info", "No Type 2 occurrence check required", "A Type 1 report evaluates design and implementation as of one date.")]
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const periodStart = coverageStart(audit.coverage);
|
|
920
|
+
const periodEnd = coverageEnd(audit.coverage);
|
|
921
|
+
if (!periodStart || !periodEnd) {
|
|
922
|
+
return stage(
|
|
923
|
+
"occurrences",
|
|
924
|
+
"Operating Occurrences",
|
|
925
|
+
"Check every expected scheduled occurrence across the exact Type 2 period.",
|
|
926
|
+
[item("period-required", "later", "Select the formal Type 2 period", "The formal period is required before FileGRC can calculate expected occurrences.", audit)]
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
const selectedControlIds = new Set(audit.controlIds || []);
|
|
930
|
+
const periodThrough = [periodEnd, asOf].filter(Boolean).sort()[0] || periodEnd;
|
|
931
|
+
if (periodThrough < periodStart) {
|
|
932
|
+
return stage(
|
|
933
|
+
"occurrences",
|
|
934
|
+
"Operating Occurrences",
|
|
935
|
+
"Check every expected scheduled occurrence across the exact Type 2 period.",
|
|
936
|
+
[item("period-not-started", "later", "Wait for the formal period to begin", `The formal Type 2 period starts on ${periodStart}. No operating occurrences are expected yet.`, audit)]
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
const periodResources = records.filter((record) => (
|
|
940
|
+
record.type !== "obligation"
|
|
941
|
+
|| !(record.controlIds || []).length
|
|
942
|
+
|| record.controlIds.some((id) => selectedControlIds.has(id))
|
|
943
|
+
));
|
|
944
|
+
const plan = planObligations(periodResources, {
|
|
945
|
+
from: periodStart,
|
|
946
|
+
asOf: periodThrough,
|
|
947
|
+
through: periodThrough,
|
|
948
|
+
now: `${periodThrough}T23:59:59.999Z`,
|
|
949
|
+
includeComplete: true,
|
|
950
|
+
model
|
|
951
|
+
});
|
|
952
|
+
const gapStatuses = new Set(["overdue", "blocked", "due", "proposed"]);
|
|
953
|
+
const calendarGaps = plan.calendarItems.filter(({ status }) => gapStatuses.has(status));
|
|
954
|
+
const eventGaps = plan.eventRuns
|
|
955
|
+
.filter((run) => run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
|
|
956
|
+
.flatMap((run) => run.actions)
|
|
957
|
+
.filter((action) => (
|
|
958
|
+
(gapStatuses.has(action.status) || action.lateCompletion)
|
|
959
|
+
&& action.controlIds.some((id) => selectedControlIds.has(id))
|
|
960
|
+
));
|
|
961
|
+
const gaps = [...calendarGaps, ...eventGaps];
|
|
962
|
+
const lateGaps = eventGaps.filter(({ lateCompletion }) => lateCompletion);
|
|
963
|
+
const firstGap = gaps[0];
|
|
964
|
+
return stage(
|
|
965
|
+
"occurrences",
|
|
966
|
+
"Operating Occurrences",
|
|
967
|
+
"Check every expected scheduled occurrence across the exact Type 2 period.",
|
|
968
|
+
[item(
|
|
969
|
+
"period-occurrences",
|
|
970
|
+
gaps.length ? "action" : "complete",
|
|
971
|
+
"Cover every expected operating occurrence",
|
|
972
|
+
gaps.length
|
|
973
|
+
? lateGaps.length
|
|
974
|
+
? `${gaps.length} expected ${gaps.length === 1 ? "occurrence needs" : "occurrences need"} review, including ${lateGaps.length} completed after the allowed window. Open the affected Action Item, record a Finding or Exception with management's conclusion, and retain the late completion as historical evidence.`
|
|
975
|
+
: `${gaps.length} expected ${gaps.length === 1 ? "occurrence is" : "occurrences are"} incomplete, blocked, or still proposed within the formal period. Resolve each Work Queue item before treating management fieldwork as ready.`
|
|
976
|
+
: `Every expected occurrence through ${periodThrough} has an accepted completion.`,
|
|
977
|
+
firstGap?.actionItemId
|
|
978
|
+
? { type: "action-item", id: firstGap.actionItemId }
|
|
979
|
+
: firstGap?.obligationId ? { type: "obligation", id: firstGap.obligationId } : audit,
|
|
980
|
+
{
|
|
981
|
+
affectedActionItemIds: eventGaps.map(({ actionItemId }) => actionItemId).filter(Boolean),
|
|
982
|
+
affectedEventIds: [...new Set(eventGaps.map(({ eventId }) => eventId).filter(Boolean))],
|
|
983
|
+
lateCount: lateGaps.length,
|
|
984
|
+
commands: firstGap?.actionItemId ? [
|
|
985
|
+
`npx filegrc get ${firstGap.actionItemId} --json`,
|
|
986
|
+
`npx filegrc scaffold finding --title "Late operating occurrence review"`
|
|
987
|
+
] : []
|
|
988
|
+
}
|
|
989
|
+
)]
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
880
993
|
async function auditDocumentActivationAssessments(loaded, audit, byId, asOf) {
|
|
881
994
|
const links = new Map();
|
|
882
995
|
const addLink = (documentId, role, definition = null) => {
|
|
@@ -914,14 +1027,14 @@ async function auditDocumentActivationAssessments(loaded, audit, byId, asOf) {
|
|
|
914
1027
|
["approved", "active"].includes(document.status)
|
|
915
1028
|
&& document.approvedOn
|
|
916
1029
|
&& document.approvedContentRevisions
|
|
917
|
-
&& (
|
|
1030
|
+
&& (["legacy-v4", "historical"].includes(document.activationBasis)
|
|
918
1031
|
? (document.ownerIds || []).length
|
|
919
1032
|
: currentPartyPeople(document.ownerIds || [], byId).size)
|
|
920
1033
|
&& (document.approverIds || []).length
|
|
921
1034
|
&& partiesIndependent(document.ownerIds, document.approverIds, byId)
|
|
922
1035
|
);
|
|
923
1036
|
if (!approvalComplete) issues.push("Complete the independent approval and bind the approved Markdown revision first.");
|
|
924
|
-
if (document.status === "active" &&
|
|
1037
|
+
if (document.status === "active" && !["legacy-v4", "historical"].includes(document.activationBasis)) {
|
|
925
1038
|
if (document.activationBasis !== "recorded") issues.push("Record the Step 5 activation basis.");
|
|
926
1039
|
if (!document.activatedOn) issues.push("Record the separate Step 5 activation date.");
|
|
927
1040
|
else if (document.activatedOn > asOf) issues.push(`The activation date ${document.activatedOn} is after ${asOf}.`);
|
|
@@ -1045,8 +1158,8 @@ async function documentsStage(loaded, audit, byId, asOf) {
|
|
|
1045
1158
|
complete ? "complete" : representationLater ? "later" : "action",
|
|
1046
1159
|
definition.title,
|
|
1047
1160
|
complete
|
|
1048
|
-
?
|
|
1049
|
-
? "Linked Markdown is complete and effective. Its
|
|
1161
|
+
? ["legacy-v4", "historical"].includes(document.activationBasis)
|
|
1162
|
+
? "Linked Markdown is complete and effective. Its historical lifecycle basis does not claim a separate activation event that was never recorded."
|
|
1050
1163
|
: modelSupports(loaded.model, "governed-document-activation")
|
|
1051
1164
|
? "Linked Markdown is complete, independently approved, separately activated by a named Person, revision-bound at both events, and effective."
|
|
1052
1165
|
: "Linked Markdown is complete, active, approved, and effective."
|
|
@@ -1209,7 +1322,12 @@ function populationsStage(audit, records, byId, model) {
|
|
|
1209
1322
|
const populations = audit
|
|
1210
1323
|
? records.filter((record) => record.type === "audit-population" && record.auditId === audit.id)
|
|
1211
1324
|
: [];
|
|
1212
|
-
const templates =
|
|
1325
|
+
const templates = applicablePopulationTemplates(
|
|
1326
|
+
audit,
|
|
1327
|
+
records,
|
|
1328
|
+
model.auditReadiness?.populationTemplates || [],
|
|
1329
|
+
populations
|
|
1330
|
+
);
|
|
1213
1331
|
const items = templates.map((template) => {
|
|
1214
1332
|
const population = populations.find((record) => record.populationKind === template.kind);
|
|
1215
1333
|
const result = populationResult(population, audit, byId);
|
|
@@ -1368,11 +1486,26 @@ function initializationNeeded(audit, records, model) {
|
|
|
1368
1486
|
const populationKinds = new Set(records
|
|
1369
1487
|
.filter((record) => record.type === "audit-population" && record.auditId === audit.id)
|
|
1370
1488
|
.map((record) => record.populationKind));
|
|
1371
|
-
const needsPopulation = audit.auditKind === "soc-2-type-2"
|
|
1489
|
+
const needsPopulation = audit.auditKind === "soc-2-type-2"
|
|
1490
|
+
&& applicablePopulationTemplates(audit, records, readiness.populationTemplates || [])
|
|
1372
1491
|
.some((template) => !populationKinds.has(template.kind));
|
|
1373
1492
|
return needsDocumentLink || needsPopulation;
|
|
1374
1493
|
}
|
|
1375
1494
|
|
|
1495
|
+
function applicablePopulationTemplates(audit, records, templates, existingPopulations = []) {
|
|
1496
|
+
const selectedControls = (audit?.controlIds || [])
|
|
1497
|
+
.map((id) => records.find((record) => record.type === "control" && record.id === id))
|
|
1498
|
+
.filter(Boolean);
|
|
1499
|
+
const selectedCodes = new Set(selectedControls.map(({ code }) => code).filter(Boolean));
|
|
1500
|
+
const existingKinds = new Set(existingPopulations.map(({ populationKind }) => populationKind));
|
|
1501
|
+
const recognizedCodes = new Set(templates.flatMap(({ controlCodes }) => controlCodes || []));
|
|
1502
|
+
if (selectedControls.some(({ code }) => !code || !recognizedCodes.has(code))) return templates;
|
|
1503
|
+
return templates.filter((template) => (
|
|
1504
|
+
existingKinds.has(template.kind)
|
|
1505
|
+
|| (template.controlCodes || []).some((code) => selectedCodes.has(code))
|
|
1506
|
+
));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1376
1509
|
function applicableManagementDocuments(audit, readiness) {
|
|
1377
1510
|
return (readiness.managementDocuments || []).filter((definition) => (
|
|
1378
1511
|
!audit || !definition.engagementKinds?.length || definition.engagementKinds.includes(audit.auditKind)
|
|
@@ -1571,7 +1704,10 @@ function item(id, status, title, message, resource = {}, options = {}) {
|
|
|
1571
1704
|
message,
|
|
1572
1705
|
...(resource.type ? { resourceType: resource.type } : {}),
|
|
1573
1706
|
...(resource.id ? { resourceId: resource.id } : {}),
|
|
1574
|
-
...(options.commands?.length ? { commands: options.commands } : {})
|
|
1707
|
+
...(options.commands?.length ? { commands: options.commands } : {}),
|
|
1708
|
+
...(options.affectedActionItemIds?.length ? { affectedActionItemIds: options.affectedActionItemIds } : {}),
|
|
1709
|
+
...(options.affectedEventIds?.length ? { affectedEventIds: options.affectedEventIds } : {}),
|
|
1710
|
+
...(options.lateCount ? { lateCount: options.lateCount } : {})
|
|
1575
1711
|
};
|
|
1576
1712
|
}
|
|
1577
1713
|
|
package/src/batch-review.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { modelSupports } from "../model/index.js";
|
|
4
|
+
import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "./applicability-scope.js";
|
|
4
5
|
import { applyResourceBatch } from "./files.js";
|
|
5
6
|
import { getWorkspaceRevisionSnapshot } from "./git.js";
|
|
6
7
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
@@ -29,15 +30,22 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
29
30
|
throw new Error(`Applicability review type must be one of ${[...REVIEWABLE_TYPES].join(", ")}.`);
|
|
30
31
|
}
|
|
31
32
|
const program = resolveProgram(loaded, options.programId);
|
|
33
|
+
const requirementById = new Map(loaded.resources
|
|
34
|
+
.filter(({ type }) => type === "requirement")
|
|
35
|
+
.map((record) => [record.id, record]));
|
|
32
36
|
const reviewedRequirementIds = new Set((program.requirementApplicability || [])
|
|
33
|
-
.filter((
|
|
37
|
+
.filter((review) => (
|
|
38
|
+
["applicable", "not-applicable"].includes(review.decision)
|
|
39
|
+
&& requirementById.has(review.requirementId)
|
|
40
|
+
&& applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model)
|
|
41
|
+
))
|
|
34
42
|
.map(({ requirementId }) => requirementId));
|
|
35
43
|
const records = loaded.resources.filter((record) => (
|
|
36
44
|
REVIEWABLE_TYPES.has(record.type)
|
|
37
45
|
&& (!requestedType || record.type === requestedType)
|
|
38
46
|
&& (record.type === "requirement" && modelSupports(loaded.model, "program-scope")
|
|
39
47
|
? !reviewedRequirementIds.has(record.id)
|
|
40
|
-
: !record.applicabilityReview)
|
|
48
|
+
: !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, loaded.resources, loaded.model))
|
|
41
49
|
&& !["retired", "superseded"].includes(record.status)
|
|
42
50
|
));
|
|
43
51
|
return {
|
|
@@ -79,8 +87,7 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
79
87
|
throw new Error("Batch expected revisions must be keyed by resource ID.");
|
|
80
88
|
}
|
|
81
89
|
const program = resolveProgram(loaded, options.programId);
|
|
82
|
-
const
|
|
83
|
-
const update = options.decisions.flatMap((decision) => {
|
|
90
|
+
const reviewedDecisions = options.decisions.map((decision) => {
|
|
84
91
|
const record = byId.get(decision.id);
|
|
85
92
|
if (!record || !REVIEWABLE_TYPES.has(record.type)) {
|
|
86
93
|
throw new Error(`Resource "${decision.id}" is not an applicability-review record.`);
|
|
@@ -99,6 +106,33 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
99
106
|
if (constraint && !constraint.allowedDecisions.includes(result)) {
|
|
100
107
|
throw new Error(`${record.reference || record.title} must be applicable because it is required for the selected SOC 2 Security program.`);
|
|
101
108
|
}
|
|
109
|
+
if (record.type === "requirement" && !["applicable", "not-applicable"].includes(result)) {
|
|
110
|
+
throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
|
|
111
|
+
}
|
|
112
|
+
return { record, result, rationale, reviewedByIds, reviewedOn };
|
|
113
|
+
});
|
|
114
|
+
const v4RequirementDecisions = reviewedDecisions
|
|
115
|
+
.filter(({ record }) => record.type === "requirement" && modelSupports(loaded.model, "program-scope"))
|
|
116
|
+
.map(({ record, result, rationale, reviewedByIds, reviewedOn }) => ({
|
|
117
|
+
requirementId: record.id,
|
|
118
|
+
decision: result,
|
|
119
|
+
rationale,
|
|
120
|
+
reviewedByIds,
|
|
121
|
+
reviewedOn,
|
|
122
|
+
scopeRevision: applicabilityScopeRevision(record, program, loaded.resources, loaded.model)
|
|
123
|
+
}));
|
|
124
|
+
const replacedRequirementIds = new Set(v4RequirementDecisions.map(({ requirementId }) => requirementId));
|
|
125
|
+
const reviewedProgram = v4RequirementDecisions.length
|
|
126
|
+
? {
|
|
127
|
+
...program,
|
|
128
|
+
requirementApplicability: [
|
|
129
|
+
...(program.requirementApplicability || []).filter(({ requirementId }) => !replacedRequirementIds.has(requirementId)),
|
|
130
|
+
...v4RequirementDecisions
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
: program;
|
|
134
|
+
const update = reviewedDecisions.flatMap(({ record, result, rationale, reviewedByIds, reviewedOn }) => {
|
|
135
|
+
const scopeRevision = applicabilityScopeRevision(record, reviewedProgram, loaded.resources, loaded.model);
|
|
102
136
|
const next = {
|
|
103
137
|
...record,
|
|
104
138
|
applicabilityReview: {
|
|
@@ -106,22 +140,11 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
106
140
|
rationale,
|
|
107
141
|
reviewedByIds,
|
|
108
142
|
reviewedOn,
|
|
109
|
-
scopeRevision
|
|
143
|
+
scopeRevision
|
|
110
144
|
}
|
|
111
145
|
};
|
|
112
146
|
if (record.type === "requirement") {
|
|
113
|
-
if (!["applicable", "not-applicable"].includes(result)) {
|
|
114
|
-
throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
|
|
115
|
-
}
|
|
116
147
|
if (modelSupports(loaded.model, "program-scope")) {
|
|
117
|
-
v4RequirementDecisions.push({
|
|
118
|
-
requirementId: record.id,
|
|
119
|
-
decision: result,
|
|
120
|
-
rationale,
|
|
121
|
-
reviewedByIds,
|
|
122
|
-
reviewedOn,
|
|
123
|
-
scopeRevision: basis.scopeRevision
|
|
124
|
-
});
|
|
125
148
|
return [];
|
|
126
149
|
}
|
|
127
150
|
next.applicability = result;
|
|
@@ -132,14 +155,7 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
132
155
|
return [next];
|
|
133
156
|
});
|
|
134
157
|
if (v4RequirementDecisions.length) {
|
|
135
|
-
|
|
136
|
-
update.push({
|
|
137
|
-
...program,
|
|
138
|
-
requirementApplicability: [
|
|
139
|
-
...(program.requirementApplicability || []).filter(({ requirementId }) => !replaced.has(requirementId)),
|
|
140
|
-
...v4RequirementDecisions
|
|
141
|
-
]
|
|
142
|
-
});
|
|
158
|
+
update.push(reviewedProgram);
|
|
143
159
|
}
|
|
144
160
|
return {
|
|
145
161
|
operation: "applicability-review",
|