filegrc 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -6
- package/model/index.js +41 -3
- package/model/v1.json +81 -47
- package/model/v2.json +8022 -0
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +89 -8
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +109 -65
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +563 -148
- package/src/collection-review.js +185 -0
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +149 -77
- package/src/external-reviewer.js +165 -0
- package/src/files.js +267 -29
- package/src/git.js +239 -41
- package/src/index.js +41 -7
- package/src/model-docs.js +103 -7
- package/src/model-migration.js +1958 -0
- package/src/mutation.js +42 -0
- package/src/obligations.js +502 -95
- package/src/parties.js +17 -2
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +70 -60
- package/src/program-readiness.js +470 -130
- package/src/reconciliation.js +277 -0
- package/src/resource-status.js +17 -0
- package/src/server.js +347 -48
- package/src/setup.js +57 -26
- package/src/source-coverage.js +61 -0
- package/src/state.js +122 -25
- package/src/timing.js +41 -0
- package/src/validate.js +707 -44
- package/src/web.js +1440 -304
- package/src/workflow.js +1595 -0
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/program-readiness.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { assessRequiredAppointments } from "./appointments.js";
|
|
3
|
+
import { assessCollectionReviews } from "./collection-review.js";
|
|
4
|
+
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
2
5
|
import { planObligations } from "./obligations.js";
|
|
3
6
|
import { resolveDataPath } from "./paths.js";
|
|
4
7
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
5
8
|
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
6
9
|
import { markdownEntries } from "./resource-markdown.js";
|
|
10
|
+
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
7
11
|
import { currentCalendarDate } from "./time.js";
|
|
8
12
|
import { loadWorkspace } from "./workspace.js";
|
|
9
13
|
|
|
10
|
-
const TEST_EVIDENCE_KINDS = new Set(["test-capture", "test-export"]);
|
|
11
|
-
|
|
12
14
|
export async function assessProgramReadiness(input, options = {}) {
|
|
13
15
|
const loaded = input?.resources && input?.model && input?.entries
|
|
14
16
|
? input
|
|
@@ -18,6 +20,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
18
20
|
const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
|
|
19
21
|
const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
|
|
20
22
|
const scope = programScope(workspace, records, byId);
|
|
23
|
+
const collectionReviews = assessCollectionReviews(loaded);
|
|
21
24
|
const markdown = new Map();
|
|
22
25
|
const readMarkdown = async (record) => {
|
|
23
26
|
if (!record) return "";
|
|
@@ -25,38 +28,43 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
25
28
|
return markdown.get(record.id);
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
|
|
32
|
+
controlStage.items.unshift(...collectionReviews
|
|
33
|
+
.filter(({ resourceType }) => resourceType === "complementary-control")
|
|
34
|
+
.map(collectionReviewReadinessItem));
|
|
28
35
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"evidence",
|
|
32
|
-
"Test Evidence Collection",
|
|
33
|
-
"For external evidence without a dedicated Step 5 record, catalog the authoritative Systems, document repeatable extraction, and verify one test capture before operation begins.",
|
|
34
|
-
[...sourceStage.items, ...collectionStage.items]
|
|
35
|
-
);
|
|
36
|
+
controlStage.items.push(...sourceStage.items);
|
|
37
|
+
controlStage.description = "Each implemented control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, and complete authoritative source Systems with the required evidence roles, access owners, and retrieval instructions.";
|
|
36
38
|
const evidenceGateStages = [
|
|
37
|
-
scopeStage(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
scopeStage(
|
|
40
|
+
workspace,
|
|
41
|
+
scope,
|
|
42
|
+
records,
|
|
43
|
+
byId,
|
|
44
|
+
loaded.model,
|
|
45
|
+
collectionReviews.filter(({ resourceType }) => resourceType !== "complementary-control")
|
|
46
|
+
),
|
|
47
|
+
await policiesStage(scope, records, byId, readMarkdown, asOf),
|
|
48
|
+
controlStage
|
|
41
49
|
];
|
|
42
50
|
for (const current of evidenceGateStages) finalizeStage(current);
|
|
43
51
|
const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
|
|
44
52
|
const stages = [
|
|
45
53
|
...evidenceGateStages,
|
|
46
|
-
operationStage(workspace, scope, records, byId, asOf, evidenceReady)
|
|
54
|
+
operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
47
55
|
];
|
|
48
56
|
finalizeStage(stages.at(-1));
|
|
49
57
|
const candidateStarted = Boolean(
|
|
50
58
|
workspace?.assuranceGoal === "soc-2-type-2"
|
|
51
|
-
&& workspace.
|
|
52
|
-
&& workspace.
|
|
59
|
+
&& workspace.candidateCoverage?.kind === "range"
|
|
60
|
+
&& coverageStart(workspace.candidateCoverage) <= asOf
|
|
53
61
|
);
|
|
54
|
-
const obligations = planObligations(records, { asOf, through: asOf });
|
|
55
|
-
const operating = evidenceReady && candidateStarted &&
|
|
62
|
+
const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
|
|
63
|
+
const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
|
|
56
64
|
const canStartCandidatePeriod = Boolean(
|
|
57
65
|
evidenceReady
|
|
58
66
|
&& workspace?.assuranceGoal === "soc-2-type-2"
|
|
59
|
-
&& !workspace.
|
|
67
|
+
&& !workspace.candidateCoverage
|
|
60
68
|
);
|
|
61
69
|
const items = stages.flatMap((current) => current.items);
|
|
62
70
|
const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
|
|
@@ -70,9 +78,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
70
78
|
target: {
|
|
71
79
|
goal: workspace?.assuranceGoal || "none",
|
|
72
80
|
label: assuranceGoalLabel(workspace?.assuranceGoal),
|
|
73
|
-
|
|
74
|
-
candidatePeriodStart: workspace?.candidatePeriodStart || null,
|
|
75
|
-
candidatePeriodEnd: workspace?.candidatePeriodEnd || null
|
|
81
|
+
candidateCoverage: workspace?.candidateCoverage || null
|
|
76
82
|
},
|
|
77
83
|
status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
|
|
78
84
|
evidenceReady,
|
|
@@ -96,28 +102,50 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
96
102
|
};
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
export async function assessEvidenceMap(input, options = {}) {
|
|
106
|
+
const readiness = await assessProgramReadiness(input, options);
|
|
107
|
+
const evidenceItems = readiness.stages
|
|
108
|
+
.find((stage) => stage.id === "controls")
|
|
109
|
+
?.items.filter((current) => current.id.startsWith("source-family-")) || [];
|
|
110
|
+
const counts = countStatuses(evidenceItems);
|
|
111
|
+
return {
|
|
112
|
+
schemaVersion: 1,
|
|
113
|
+
generatedAt: readiness.generatedAt,
|
|
114
|
+
asOf: readiness.asOf,
|
|
115
|
+
status: counts.action ? "action" : "complete",
|
|
116
|
+
counts,
|
|
117
|
+
workflow: [
|
|
118
|
+
"Choose an existing System or create the System that is authoritative for each evidence family.",
|
|
119
|
+
"On every source System, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.",
|
|
120
|
+
"Set each selected Control's evidenceSourceIds to the authoritative Systems that produce its evidence.",
|
|
121
|
+
"Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
|
|
122
|
+
],
|
|
123
|
+
items: evidenceItems
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
99
127
|
function programScope(workspace, records, byId) {
|
|
100
128
|
const select = (ids, type, fallback) => {
|
|
101
129
|
if (ids?.length) return ids.map((id) => byId.get(id)).filter((record) => record?.type === type);
|
|
102
130
|
return records.filter(fallback);
|
|
103
131
|
};
|
|
104
132
|
return {
|
|
105
|
-
systems:
|
|
106
|
-
|
|
107
|
-
|
|
133
|
+
systems: (workspace?.systemIds || [])
|
|
134
|
+
.map((id) => byId.get(id))
|
|
135
|
+
.filter((record) => record?.type === "system" && record.status !== "retired"),
|
|
108
136
|
frameworks: select(workspace?.frameworkIds, "framework", (record) => (
|
|
109
137
|
record.type === "framework" && record.status === "active"
|
|
110
138
|
)),
|
|
111
139
|
requirements: select(workspace?.requirementIds, "requirement", (record) => (
|
|
112
140
|
record.type === "requirement" && record.applicability === "applicable"
|
|
113
|
-
)),
|
|
141
|
+
)).filter((record) => record.applicability === "applicable"),
|
|
114
142
|
controls: select(workspace?.controlIds, "control", (record) => (
|
|
115
143
|
record.type === "control" && !["not-applicable", "retired"].includes(record.status)
|
|
116
|
-
))
|
|
144
|
+
)).filter((record) => !["not-applicable", "retired"].includes(record.status))
|
|
117
145
|
};
|
|
118
146
|
}
|
|
119
147
|
|
|
120
|
-
function scopeStage(workspace, scope, records, byId) {
|
|
148
|
+
function scopeStage(workspace, scope, records, byId, model, collectionReviews = []) {
|
|
121
149
|
const items = [];
|
|
122
150
|
const goal = workspace?.assuranceGoal || "none";
|
|
123
151
|
items.push(item(
|
|
@@ -127,16 +155,25 @@ function scopeStage(workspace, scope, records, byId) {
|
|
|
127
155
|
goal !== "none"
|
|
128
156
|
? `Target: ${assuranceGoalLabel(goal)}. This is a management objective, not an active CPA engagement.`
|
|
129
157
|
: "Choose readiness, SOC 2 Type 1, or SOC 2 Type 2 as the management objective.",
|
|
130
|
-
workspace || { type: "workspace" }
|
|
158
|
+
workspace || { type: "workspace" },
|
|
159
|
+
{
|
|
160
|
+
commands: [
|
|
161
|
+
"npx filegrc setup",
|
|
162
|
+
`npx filegrc get ${shellArgument(workspace?.id || "workspace")} --mutation`
|
|
163
|
+
]
|
|
164
|
+
}
|
|
131
165
|
));
|
|
132
166
|
|
|
133
167
|
items.push(programOwnershipItem(records, byId));
|
|
168
|
+
items.push(requiredAppointmentsItem(records, model));
|
|
169
|
+
for (const assessment of collectionReviews) {
|
|
170
|
+
items.push(collectionReviewReadinessItem(assessment));
|
|
171
|
+
}
|
|
134
172
|
|
|
135
173
|
const completeSystems = scope.systems.filter((system) => (
|
|
136
174
|
system.status === "active"
|
|
137
|
-
&& system.inScope === true
|
|
138
175
|
&& system.description
|
|
139
|
-
&& system.
|
|
176
|
+
&& system.classificationId
|
|
140
177
|
&& (system.ownerIds || []).length
|
|
141
178
|
));
|
|
142
179
|
items.push(item(
|
|
@@ -149,6 +186,44 @@ function scopeStage(workspace, scope, records, byId) {
|
|
|
149
186
|
scope.systems[0] || { type: "system" }
|
|
150
187
|
));
|
|
151
188
|
|
|
189
|
+
if (String(model.modelVersion) === "3") {
|
|
190
|
+
const commitments = records.filter((record) => (
|
|
191
|
+
record.type === "commitment"
|
|
192
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
193
|
+
&& (record.systemIds || []).some((id) => scope.systems.some((system) => system.id === id))
|
|
194
|
+
));
|
|
195
|
+
const completeCommitments = commitments.filter((record) => (
|
|
196
|
+
record.status === "active"
|
|
197
|
+
&& record.statement
|
|
198
|
+
&& record.effectiveOn
|
|
199
|
+
&& record.applicabilityReview?.decision === "applicable"
|
|
200
|
+
&& currentPartyPeople(record.ownerIds, byId).size > 0
|
|
201
|
+
&& (record.requirementIds || []).length > 0
|
|
202
|
+
&& (record.controlIds || []).length > 0
|
|
203
|
+
));
|
|
204
|
+
const uncoveredSystems = scope.systems.filter((system) => !completeCommitments.some((record) => (
|
|
205
|
+
(record.systemIds || []).includes(system.id)
|
|
206
|
+
)));
|
|
207
|
+
items.push(item(
|
|
208
|
+
"commitments",
|
|
209
|
+
scope.systems.length && uncoveredSystems.length === 0 ? "complete" : "action",
|
|
210
|
+
"Record service commitments and system requirements",
|
|
211
|
+
scope.systems.length
|
|
212
|
+
? `${completeCommitments.length} complete active ${completeCommitments.length === 1 ? "commitment covers" : "commitments cover"} ${scope.systems.length - uncoveredSystems.length} of ${scope.systems.length} in-scope systems.`
|
|
213
|
+
: "Define the service boundary before recording its customer promises and approved system requirements.",
|
|
214
|
+
commitments[0] || { type: "commitment" },
|
|
215
|
+
{
|
|
216
|
+
uncoveredSystemIds: uncoveredSystems.map(({ id }) => id),
|
|
217
|
+
commands: [
|
|
218
|
+
"npx filegrc guide commitment --json",
|
|
219
|
+
"npx filegrc list commitment --workflow --json",
|
|
220
|
+
'npx filegrc scaffold commitment --title "SERVICE COMMITMENT"',
|
|
221
|
+
"npx filegrc program-readiness --json"
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
));
|
|
225
|
+
}
|
|
226
|
+
|
|
152
227
|
const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
|
|
153
228
|
const applicableRequirements = records.filter((record) => (
|
|
154
229
|
record.type === "requirement"
|
|
@@ -175,12 +250,67 @@ function scopeStage(workspace, scope, records, byId) {
|
|
|
175
250
|
criteriaComplete
|
|
176
251
|
? `${scope.requirements.length} applicable criteria and ${scope.controls.length} controls are in the management program scope.`
|
|
177
252
|
: `Resolve the program criteria and controls. ${unresolvedRequirements.length} criteria remain undetermined and ${missingRequirements.length} applicable criteria are not selected.`,
|
|
178
|
-
workspace || { type: "workspace" }
|
|
253
|
+
workspace || { type: "workspace" },
|
|
254
|
+
{
|
|
255
|
+
commands: [
|
|
256
|
+
"npx filegrc review-applicability --scaffold --type requirement > decisions.json",
|
|
257
|
+
"npx filegrc review-applicability decisions.json --preview --json",
|
|
258
|
+
"npx filegrc review-applicability decisions.json --yes --json",
|
|
259
|
+
"npx filegrc get workspace --mutation"
|
|
260
|
+
]
|
|
261
|
+
}
|
|
179
262
|
));
|
|
180
263
|
|
|
181
264
|
return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
|
|
182
265
|
}
|
|
183
266
|
|
|
267
|
+
function collectionReviewReadinessItem(assessment) {
|
|
268
|
+
return item(
|
|
269
|
+
`collection-review-${assessment.resourceType}`,
|
|
270
|
+
assessment.complete ? "complete" : "action",
|
|
271
|
+
assessment.status === "stale"
|
|
272
|
+
? `Review ${assessment.configuration.title.toLowerCase()} again`
|
|
273
|
+
: `Review ${assessment.configuration.title.toLowerCase()}`,
|
|
274
|
+
assessment.message,
|
|
275
|
+
assessment.review || { type: assessment.resourceType },
|
|
276
|
+
{
|
|
277
|
+
resourceType: assessment.resourceType,
|
|
278
|
+
reviewPoints: assessment.configuration.reviewPoints,
|
|
279
|
+
commands: [
|
|
280
|
+
`npx filegrc review-collection ${assessment.resourceType} --scaffold`,
|
|
281
|
+
`npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
|
|
282
|
+
]
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function requiredAppointmentsItem(records, model) {
|
|
288
|
+
const assessments = assessRequiredAppointments(records, model);
|
|
289
|
+
const incomplete = assessments.filter(({ requiredness, state }) => (
|
|
290
|
+
["core", "required"].includes(requiredness) && state !== "complete"
|
|
291
|
+
));
|
|
292
|
+
const complete = incomplete.length === 0;
|
|
293
|
+
const first = incomplete[0];
|
|
294
|
+
return item(
|
|
295
|
+
"required-appointments",
|
|
296
|
+
complete ? "complete" : "action",
|
|
297
|
+
"Assign required program authority",
|
|
298
|
+
complete
|
|
299
|
+
? "Every authority required by the current scope has an active dated Appointment."
|
|
300
|
+
: `${incomplete.length} required ${incomplete.length === 1 ? "Appointment needs" : "Appointments need"} a current holder: ${incomplete.map(({ template }) => template.title).join(", ")}.`,
|
|
301
|
+
first?.record || { type: "appointment" },
|
|
302
|
+
{
|
|
303
|
+
commands: [
|
|
304
|
+
"npx filegrc guide appointment --json",
|
|
305
|
+
"npx filegrc list appointment --workflow --json",
|
|
306
|
+
first?.record
|
|
307
|
+
? `npx filegrc get ${first.record.id} --mutation`
|
|
308
|
+
: `npx filegrc scaffold appointment --title "${first?.template.title || "APPOINTMENT TITLE"}"`
|
|
309
|
+
]
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
184
314
|
function programOwnershipItem(records, byId) {
|
|
185
315
|
const ownedRecords = records.filter((record) => (
|
|
186
316
|
["policy", "control", "obligation"].includes(record.type)
|
|
@@ -188,6 +318,9 @@ function programOwnershipItem(records, byId) {
|
|
|
188
318
|
));
|
|
189
319
|
const unresolved = ownedRecords.filter((record) => currentPartyPeople(record.ownerIds, byId).size === 0);
|
|
190
320
|
const currentOwners = new Set(ownedRecords.flatMap((record) => [...currentPartyPeople(record.ownerIds, byId)]));
|
|
321
|
+
const missingJobTitles = [...currentOwners]
|
|
322
|
+
.map((id) => byId.get(id))
|
|
323
|
+
.filter((record) => record?.type === "person" && record.status === "active" && !String(record.jobTitle || "").trim());
|
|
191
324
|
const oversight = byId.get("team-security-risk-oversight");
|
|
192
325
|
const policyOwnerIds = new Set(records
|
|
193
326
|
.filter((record) => record.type === "policy" && !["retired", "superseded"].includes(record.status))
|
|
@@ -202,7 +335,10 @@ function programOwnershipItem(records, byId) {
|
|
|
202
335
|
&& oversightChairs.size > 0
|
|
203
336
|
&& ![...oversightChairs].some((id) => policyOwnerIds.has(id))
|
|
204
337
|
);
|
|
205
|
-
const complete = currentOwners.size > 0
|
|
338
|
+
const complete = currentOwners.size > 0
|
|
339
|
+
&& unresolved.length === 0
|
|
340
|
+
&& missingJobTitles.length === 0
|
|
341
|
+
&& oversightComplete;
|
|
206
342
|
const unresolvedAssignments = unresolved.map((record) => ({
|
|
207
343
|
resourceType: record.type,
|
|
208
344
|
resourceId: record.id,
|
|
@@ -210,12 +346,32 @@ function programOwnershipItem(records, byId) {
|
|
|
210
346
|
ownerIds: record.ownerIds || [],
|
|
211
347
|
reasons: ownershipResolutionReasons(record.ownerIds || [], byId)
|
|
212
348
|
}));
|
|
349
|
+
const oversightDependent = oversight?.id
|
|
350
|
+
? unresolvedAssignments.filter(({ reasons }) => (
|
|
351
|
+
reasons.length > 0
|
|
352
|
+
&& reasons.every(({ ownerId, reason }) => (
|
|
353
|
+
ownerId === oversight.id
|
|
354
|
+
&& ["inactive-team", "team-has-no-current-members"].includes(reason)
|
|
355
|
+
))
|
|
356
|
+
))
|
|
357
|
+
: [];
|
|
358
|
+
const separatelyUnresolved = unresolved.length - oversightDependent.length;
|
|
213
359
|
const detail = [];
|
|
214
360
|
if (!currentOwners.size) detail.push("No current person owns the program records.");
|
|
215
|
-
if (
|
|
216
|
-
detail.push(`${
|
|
361
|
+
if (separatelyUnresolved) {
|
|
362
|
+
detail.push(`${separatelyUnresolved} ${separatelyUnresolved === 1 ? "record has" : "records have"} no current person owner.`);
|
|
363
|
+
}
|
|
364
|
+
if (missingJobTitles.length) {
|
|
365
|
+
detail.push(`${missingJobTitles.length} active ${missingJobTitles.length === 1 ? "owner needs" : "owners need"} an organizational job title.`);
|
|
366
|
+
}
|
|
367
|
+
if (!oversightComplete) {
|
|
368
|
+
detail.push(
|
|
369
|
+
"Activate Security and Risk Oversight with current members and a chair separate from policy ownership."
|
|
370
|
+
+ (oversightDependent.length
|
|
371
|
+
? ` This team owns ${oversightDependent.length} proposed ${oversightDependent.length === 1 ? "obligation" : "obligations"}.`
|
|
372
|
+
: "")
|
|
373
|
+
);
|
|
217
374
|
}
|
|
218
|
-
if (!oversightComplete) detail.push("Finish and activate Security and Risk Oversight with a current chair who is separate from policy ownership.");
|
|
219
375
|
const oversightId = oversight?.id ? shellArgument(oversight.id) : null;
|
|
220
376
|
return item(
|
|
221
377
|
"program-ownership",
|
|
@@ -227,9 +383,11 @@ function programOwnershipItem(records, byId) {
|
|
|
227
383
|
!oversightComplete ? oversight : unresolved[0] || { type: "person" },
|
|
228
384
|
{
|
|
229
385
|
unresolvedAssignments,
|
|
386
|
+
missingJobTitleIds: missingJobTitles.map(({ id }) => id),
|
|
230
387
|
...(!oversightComplete && oversight ? {
|
|
231
388
|
commands: [
|
|
232
389
|
"npx filegrc guide person --json",
|
|
390
|
+
"npx filegrc guide appointment --json",
|
|
233
391
|
"npx filegrc list person --json",
|
|
234
392
|
'npx filegrc scaffold person --title "REVIEWER NAME" | npx filegrc create - --json',
|
|
235
393
|
`npx filegrc get ${oversightId} --mutation`,
|
|
@@ -251,14 +409,19 @@ function ownershipResolutionReasons(ownerIds, byId) {
|
|
|
251
409
|
if (owner.type === "team" && owner.status !== "active") {
|
|
252
410
|
return [{ ownerId, reason: "inactive-team" }];
|
|
253
411
|
}
|
|
412
|
+
if (owner.type === "appointment" && owner.status !== "active") {
|
|
413
|
+
return [{ ownerId, reason: "inactive-appointment" }];
|
|
414
|
+
}
|
|
254
415
|
if (currentPartyPeople([ownerId], byId).size === 0) {
|
|
255
|
-
|
|
416
|
+
if (owner.type === "team") return [{ ownerId, reason: "team-has-no-current-members" }];
|
|
417
|
+
if (owner.type === "appointment") return [{ ownerId, reason: "appointment-has-no-current-holder" }];
|
|
418
|
+
return [{ ownerId, reason: "no-current-person" }];
|
|
256
419
|
}
|
|
257
420
|
return [];
|
|
258
421
|
});
|
|
259
422
|
}
|
|
260
423
|
|
|
261
|
-
async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
424
|
+
async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
262
425
|
const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
|
|
263
426
|
const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
|
|
264
427
|
record?.type === "policy" && !["superseded", "retired"].includes(record.status)
|
|
@@ -291,7 +454,15 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
291
454
|
: reviewerNeedsAssignment
|
|
292
455
|
? `${availableReviewer.title} chairs Security and Risk Oversight. Assign this person as approver on each policy after review.`
|
|
293
456
|
: "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
|
|
294
|
-
appointedReviewer || (reviewerNeedsAssignment ? policies[0] : { type: "person" })
|
|
457
|
+
appointedReviewer || (reviewerNeedsAssignment ? policies[0] : { type: "person" }),
|
|
458
|
+
{
|
|
459
|
+
commands: [
|
|
460
|
+
"npx filegrc list appointment --workflow --json",
|
|
461
|
+
"npx filegrc guide appointment --json",
|
|
462
|
+
"npx filegrc external-reviewer-setup --scaffold > reviewer.json",
|
|
463
|
+
"npx filegrc external-reviewer-setup reviewer.json --preview --json"
|
|
464
|
+
]
|
|
465
|
+
}
|
|
295
466
|
)
|
|
296
467
|
];
|
|
297
468
|
if (!policies.length) {
|
|
@@ -300,7 +471,14 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
300
471
|
"action",
|
|
301
472
|
"Link policies to the selected controls",
|
|
302
473
|
"No applicable policies are linked from the controls in program scope.",
|
|
303
|
-
{ type: "policy" }
|
|
474
|
+
{ type: "policy" },
|
|
475
|
+
{
|
|
476
|
+
commands: [
|
|
477
|
+
"npx filegrc list policy --workflow --json",
|
|
478
|
+
"npx filegrc list control --workflow --json",
|
|
479
|
+
"npx filegrc program-readiness --json"
|
|
480
|
+
]
|
|
481
|
+
}
|
|
304
482
|
));
|
|
305
483
|
}
|
|
306
484
|
for (const policy of policies) {
|
|
@@ -312,7 +490,7 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
312
490
|
&& policy.approvedOn
|
|
313
491
|
&& partiesIndependent(policy.ownerIds, policy.approverIds, byId),
|
|
314
492
|
effective: policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf,
|
|
315
|
-
linkedControls:
|
|
493
|
+
linkedControls: scope.controls.some((control) => (control.policyIds || []).includes(policy.id)),
|
|
316
494
|
contentComplete: Boolean(source.trim()) && placeholderCount === 0
|
|
317
495
|
};
|
|
318
496
|
const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => policyCheckLabel(name));
|
|
@@ -324,13 +502,93 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
324
502
|
? `Remaining adoption work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
|
|
325
503
|
: `Reviewed, independently approved, effective ${policy.effectiveOn}, linked to controls, with no open organization placeholders.`,
|
|
326
504
|
policy,
|
|
327
|
-
{
|
|
505
|
+
{
|
|
506
|
+
checks,
|
|
507
|
+
placeholderCount,
|
|
508
|
+
commands: [
|
|
509
|
+
`npx filegrc get ${shellArgument(policy.id)} --mutation`,
|
|
510
|
+
`npx filegrc update policy ${shellArgument(policy.id)} MUTATION.json --json`,
|
|
511
|
+
"npx filegrc program-readiness --json"
|
|
512
|
+
]
|
|
513
|
+
}
|
|
514
|
+
));
|
|
515
|
+
}
|
|
516
|
+
const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
|
|
517
|
+
const activeObligations = records.filter((record) => (
|
|
518
|
+
record.type === "obligation" && obligationIsRunning(record, byId, asOf)
|
|
519
|
+
));
|
|
520
|
+
const requiredGovernedIds = new Set(activeObligations.flatMap((record) => [
|
|
521
|
+
...(record.scopeResourceIds || []),
|
|
522
|
+
...(record.templateResourceId ? [record.templateResourceId] : [])
|
|
523
|
+
]));
|
|
524
|
+
const governedRecords = records.filter((record) => (
|
|
525
|
+
(
|
|
526
|
+
record.type === "document"
|
|
527
|
+
&& (
|
|
528
|
+
requiredGovernedIds.has(record.id)
|
|
529
|
+
|| (
|
|
530
|
+
record.programRole === "required"
|
|
531
|
+
&& (record.controlIds || []).some((id) => selectedControlIds.has(id))
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
|| (record.type === "training" && requiredGovernedIds.has(record.id))
|
|
536
|
+
));
|
|
537
|
+
for (const record of governedRecords) {
|
|
538
|
+
const source = await readMarkdown(record);
|
|
539
|
+
const placeholderCount = openPlaceholderCount(source);
|
|
540
|
+
const checks = record.type === "document"
|
|
541
|
+
? {
|
|
542
|
+
active: record.status === "active",
|
|
543
|
+
owner: currentPartyPeople(record.ownerIds, byId).size > 0,
|
|
544
|
+
independentlyApproved: Boolean(
|
|
545
|
+
record.approvedOn
|
|
546
|
+
&& partiesIndependent(record.ownerIds, record.approverIds, byId)
|
|
547
|
+
),
|
|
548
|
+
effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
|
|
549
|
+
contentComplete: substantiveMarkdown(source) && placeholderCount === 0
|
|
550
|
+
}
|
|
551
|
+
: {
|
|
552
|
+
active: record.status === "active",
|
|
553
|
+
owner: currentPartyPeople(record.ownerIds, byId).size > 0,
|
|
554
|
+
approved: Boolean(record.approvedOn && (record.approvedByIds || []).length),
|
|
555
|
+
effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
|
|
556
|
+
effectiveContent: Boolean(record.effectiveContentRevisions),
|
|
557
|
+
contentComplete: substantiveMarkdown(source) && placeholderCount === 0
|
|
558
|
+
};
|
|
559
|
+
const missing = Object.entries(checks)
|
|
560
|
+
.filter(([, value]) => !value)
|
|
561
|
+
.map(([name]) => governedContentCheckLabel(name));
|
|
562
|
+
items.push(item(
|
|
563
|
+
`${record.type}-${record.id}`,
|
|
564
|
+
missing.length ? "action" : "complete",
|
|
565
|
+
record.title,
|
|
566
|
+
missing.length
|
|
567
|
+
? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
|
|
568
|
+
: record.type === "document"
|
|
569
|
+
? `Active, independently approved, effective ${record.effectiveOn}, and ready for the selected controls or running schedule.`
|
|
570
|
+
: `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
|
|
571
|
+
record,
|
|
572
|
+
{
|
|
573
|
+
checks,
|
|
574
|
+
placeholderCount,
|
|
575
|
+
commands: [
|
|
576
|
+
`npx filegrc get ${shellArgument(record.id)} --mutation`,
|
|
577
|
+
`npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
|
|
578
|
+
"npx filegrc program-readiness --json"
|
|
579
|
+
]
|
|
580
|
+
}
|
|
328
581
|
));
|
|
329
582
|
}
|
|
330
|
-
return stage(
|
|
583
|
+
return stage(
|
|
584
|
+
"policies",
|
|
585
|
+
"Approve Policies",
|
|
586
|
+
"Review and approve the policies, governed plans, and training content required by selected controls and running schedules.",
|
|
587
|
+
items
|
|
588
|
+
);
|
|
331
589
|
}
|
|
332
590
|
|
|
333
|
-
async function controlsStage(scope, byId, readMarkdown, asOf) {
|
|
591
|
+
async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
334
592
|
const items = [];
|
|
335
593
|
if (!scope.controls.length) {
|
|
336
594
|
items.push(item("control-scope", "action", "Select the program controls", "No controls are selected for the management program.", { type: "control" }));
|
|
@@ -344,17 +602,30 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
|
|
|
344
602
|
&& (record.controlIds || []).includes(control.id)
|
|
345
603
|
));
|
|
346
604
|
const checks = {
|
|
605
|
+
...(model.resources.control?.fields?.applicabilityReview ? {
|
|
606
|
+
applicability: control.applicabilityReview?.decision === "applicable"
|
|
607
|
+
} : {}),
|
|
347
608
|
implemented: control.status === "implemented",
|
|
348
609
|
owner: (control.ownerIds || []).length > 0,
|
|
349
610
|
procedure: substantiveMarkdown(source) && openPlaceholderCount(source) === 0,
|
|
350
611
|
scope: (control.systemIds || []).some((id) => scope.systems.some((system) => system.id === id)),
|
|
351
|
-
|
|
612
|
+
operationPattern: Boolean(control.operationPattern),
|
|
352
613
|
evidenceSource: sourceSystems.length > 0,
|
|
353
614
|
implementationDate: Boolean(control.effectiveOn && control.effectiveOn <= asOf),
|
|
615
|
+
...(model.resources.control?.fields?.procedureRevision ? {
|
|
616
|
+
procedureRevision: Boolean(control.procedureRevision),
|
|
617
|
+
procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf),
|
|
618
|
+
implementationReview: Boolean(
|
|
619
|
+
control.implementationReviewedOn
|
|
620
|
+
&& control.implementationReviewedOn <= asOf
|
|
621
|
+
&& partiesIndependent(control.ownerIds, control.implementationReviewedByIds, byId)
|
|
622
|
+
)
|
|
623
|
+
} : {}),
|
|
354
624
|
policyMapping: (control.policyIds || []).length > 0,
|
|
355
625
|
criteriaMapping: (control.requirementIds || []).length > 0,
|
|
356
|
-
...(
|
|
357
|
-
workQueue: queueSchedules.
|
|
626
|
+
...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
|
|
627
|
+
workQueue: queueSchedules.length > 0
|
|
628
|
+
&& queueSchedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))
|
|
358
629
|
} : {})
|
|
359
630
|
};
|
|
360
631
|
const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
|
|
@@ -363,11 +634,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
|
|
|
363
634
|
missing.length ? "action" : "complete",
|
|
364
635
|
`${control.code ? `${control.code}: ` : ""}${control.title}`,
|
|
365
636
|
missing.length
|
|
366
|
-
? `
|
|
637
|
+
? `Complete ${missing.length} ${missing.length === 1 ? "check" : "checks"} before implementation: ${missing.join(", ")}.`
|
|
367
638
|
: `Implemented ${control.effectiveOn}; owned, scoped, scheduled, documented, mapped, and tied to ${sourceSystems.length} authoritative ${sourceSystems.length === 1 ? "source" : "sources"}.`,
|
|
368
639
|
control,
|
|
369
640
|
{
|
|
370
641
|
checks,
|
|
642
|
+
commands: [
|
|
643
|
+
...(Object.hasOwn(checks, "applicability") && !checks.applicability ? [
|
|
644
|
+
"npx filegrc review-applicability --scaffold --type control > control-decisions.json",
|
|
645
|
+
"npx filegrc review-applicability control-decisions.json --preview --json",
|
|
646
|
+
"npx filegrc review-applicability control-decisions.json --yes --json"
|
|
647
|
+
] : []),
|
|
648
|
+
"npx filegrc evidence-map --json",
|
|
649
|
+
`npx filegrc get ${shellArgument(control.id)} --mutation`
|
|
650
|
+
],
|
|
371
651
|
workQueue: queueSchedules.length ? {
|
|
372
652
|
running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
|
|
373
653
|
total: queueSchedules.length
|
|
@@ -375,95 +655,103 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
|
|
|
375
655
|
}
|
|
376
656
|
));
|
|
377
657
|
}
|
|
378
|
-
return stage("controls", "Implement Controls", "Each implemented control needs an owner, actual procedure, scope,
|
|
658
|
+
return stage("controls", "Implement Controls", "Each implemented control needs an owner, actual procedure, scope, operation pattern, evidence source, mappings, an implementation date, and any required Work Queue schedules running.", items);
|
|
379
659
|
}
|
|
380
660
|
|
|
381
661
|
async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
382
|
-
const families = selectedControlFamilies(scope.controls, model)
|
|
662
|
+
const families = selectedControlFamilies(scope.controls, model);
|
|
383
663
|
const items = [];
|
|
384
664
|
for (const family of families) {
|
|
385
665
|
const selectedSources = [...new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []))]
|
|
386
666
|
.map((id) => byId.get(id))
|
|
387
|
-
.filter((record) =>
|
|
667
|
+
.filter((record) => (
|
|
668
|
+
record?.type === "system"
|
|
669
|
+
&& (
|
|
670
|
+
!(record.evidenceSourceKinds || []).length
|
|
671
|
+
|| family.sourceKinds.some((kind) => (record.evidenceSourceKinds || []).includes(kind))
|
|
672
|
+
)
|
|
673
|
+
));
|
|
388
674
|
const completeSources = [];
|
|
675
|
+
const sourceSystemChecks = [];
|
|
389
676
|
for (const source of selectedSources) {
|
|
390
677
|
const instructions = await readMarkdown(source);
|
|
391
678
|
const matchesRole = !family.sourceKinds.length
|
|
392
679
|
|| family.sourceKinds.some((kind) => (source.evidenceSourceKinds || []).includes(kind));
|
|
393
|
-
|
|
394
|
-
source.status === "active"
|
|
395
|
-
&&
|
|
396
|
-
|
|
397
|
-
&& (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
680
|
+
const checks = {
|
|
681
|
+
active: source.status === "active",
|
|
682
|
+
sourceRole: matchesRole && (source.evidenceSourceKinds || []).length > 0,
|
|
683
|
+
accessOwners: (source.evidenceOwnerIds || []).length > 0,
|
|
684
|
+
retrievalInstructions: substantiveMarkdown(instructions) && openPlaceholderCount(instructions) === 0
|
|
685
|
+
};
|
|
686
|
+
const complete = Object.values(checks).every(Boolean);
|
|
687
|
+
sourceSystemChecks.push({
|
|
688
|
+
sourceSystemId: source.id,
|
|
689
|
+
complete,
|
|
690
|
+
checks
|
|
691
|
+
});
|
|
692
|
+
if (complete) {
|
|
401
693
|
completeSources.push(source);
|
|
402
694
|
}
|
|
403
695
|
}
|
|
404
|
-
const
|
|
405
|
-
(control.evidenceSourceIds || []).
|
|
406
|
-
|
|
696
|
+
const controlMappings = family.controls.map((control) => {
|
|
697
|
+
const sourceSystemIds = (control.evidenceSourceIds || []).filter((id) => selectedSources.some((source) => source.id === id));
|
|
698
|
+
const completeSourceSystemIds = sourceSystemIds.filter((id) => completeSources.some((source) => source.id === id));
|
|
699
|
+
return {
|
|
700
|
+
controlId: control.id,
|
|
701
|
+
sourceSystemIds,
|
|
702
|
+
completeSourceSystemIds,
|
|
703
|
+
mapped: sourceSystemIds.length > 0,
|
|
704
|
+
complete: completeSourceSystemIds.length > 0
|
|
705
|
+
};
|
|
706
|
+
});
|
|
707
|
+
const coveredControls = controlMappings.filter(({ complete }) => complete);
|
|
407
708
|
const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
|
|
709
|
+
const commands = [
|
|
710
|
+
...(selectedSources.length
|
|
711
|
+
? sourceSystemChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap(({ sourceSystemId }) => [
|
|
712
|
+
`npx filegrc get ${shellArgument(sourceSystemId)} --mutation > /tmp/${shellArgument(sourceSystemId)}.json`,
|
|
713
|
+
`npx filegrc update system ${shellArgument(sourceSystemId)} /tmp/${shellArgument(sourceSystemId)}.json --json`
|
|
714
|
+
])
|
|
715
|
+
: [
|
|
716
|
+
"npx filegrc list system --json",
|
|
717
|
+
'npx filegrc scaffold system --title "SYSTEM NAME" > /tmp/filegrc-system.json',
|
|
718
|
+
"npx filegrc create /tmp/filegrc-system.json --json"
|
|
719
|
+
]),
|
|
720
|
+
...controlMappings.filter(({ mapped }) => !mapped).flatMap(({ controlId }) => [
|
|
721
|
+
`npx filegrc get ${shellArgument(controlId)} --mutation > /tmp/${shellArgument(controlId)}.json`,
|
|
722
|
+
`npx filegrc update control ${shellArgument(controlId)} /tmp/${shellArgument(controlId)}.json --json`
|
|
723
|
+
]),
|
|
724
|
+
"npx filegrc program-readiness --json"
|
|
725
|
+
];
|
|
408
726
|
items.push(item(
|
|
409
727
|
`source-family-${family.id}`,
|
|
410
728
|
complete ? "complete" : "action",
|
|
411
729
|
family.title,
|
|
412
730
|
complete
|
|
413
|
-
? `${completeSources.map((source) => source.title).join(", ")} cover all ${family.controls.length} selected controls and record access owners and extraction instructions.`
|
|
731
|
+
? `${completeSources.map((source) => source.title).join(", ")} ${completeSources.length === 1 ? "covers" : "cover"} all ${family.controls.length} selected controls and record access owners and extraction instructions.`
|
|
414
732
|
: `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative system with the required source role, access owners, and extraction instructions.`,
|
|
415
733
|
completeSources[0] || selectedSources[0] || { type: "system" },
|
|
416
|
-
{ controlIds: family.controls.map((control) => control.id), sourceSystemIds: selectedSources.map((source) => source.id) }
|
|
417
|
-
));
|
|
418
|
-
}
|
|
419
|
-
return stage("sources", "Configure Evidence Sources", "Catalog the authoritative systems, name who can export from them, and write repeatable extraction instructions.", items);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function evidenceCollectionStage(scope, records, byId, model) {
|
|
423
|
-
const families = selectedControlFamilies(scope.controls, model).filter(requiresCollectionTest);
|
|
424
|
-
const captures = records.filter((record) => (
|
|
425
|
-
record.type === "evidence"
|
|
426
|
-
&& TEST_EVIDENCE_KINDS.has(record.evidenceKind)
|
|
427
|
-
));
|
|
428
|
-
const items = families.map((family) => {
|
|
429
|
-
const configuredSourceIds = new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []));
|
|
430
|
-
const controlIds = new Set(family.controls.map((control) => control.id));
|
|
431
|
-
const capture = captures.find((record) => record.collectionTestFamilyId === family.id)
|
|
432
|
-
|| captures.find((record) => (
|
|
433
|
-
[...controlIdsForRecord(record, byId)].some((id) => controlIds.has(id))
|
|
434
|
-
));
|
|
435
|
-
const sourceIds = new Set([
|
|
436
|
-
...configuredSourceIds,
|
|
437
|
-
...(capture?.sourceSystemId ? [capture.sourceSystemId] : [])
|
|
438
|
-
]);
|
|
439
|
-
const verified = capture?.status === "verified";
|
|
440
|
-
return item(
|
|
441
|
-
`test-family-${family.id}`,
|
|
442
|
-
verified ? "complete" : "action",
|
|
443
|
-
family.title,
|
|
444
|
-
verified
|
|
445
|
-
? `${capture.title} proves that management successfully captured and verified evidence from ${byId.get(capture.sourceSystemId)?.title || "the authoritative source"}.`
|
|
446
|
-
: capture?.status === "draft"
|
|
447
|
-
? `${capture.title} is a draft. Open it, select the authoritative source System, collect the named artifact, and have another person verify it.`
|
|
448
|
-
: capture
|
|
449
|
-
? `${capture.title} is ${capture.status} but must be verified before this family is ready.`
|
|
450
|
-
: `Run and verify one test export or test capture from an authoritative source outside filegrc, then link it to a family control.`,
|
|
451
|
-
capture || { type: "evidence" },
|
|
452
734
|
{
|
|
453
735
|
familyId: family.id,
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
736
|
+
sourceKinds: family.sourceKinds,
|
|
737
|
+
controlIds: family.controls.map((control) => control.id),
|
|
738
|
+
sourceSystemIds: selectedSources.map((source) => source.id),
|
|
739
|
+
completeSourceSystemIds: completeSources.map((source) => source.id),
|
|
740
|
+
sourceSystemChecks,
|
|
741
|
+
controlMappings,
|
|
742
|
+
evidenceForm: family.evidenceForm,
|
|
743
|
+
evidencePrompt: family.evidencePrompt,
|
|
744
|
+
description: family.description,
|
|
745
|
+
timing: family.timing,
|
|
746
|
+
operationRecordTypes: family.operationRecordTypes,
|
|
747
|
+
commands
|
|
460
748
|
}
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
return stage("
|
|
749
|
+
));
|
|
750
|
+
}
|
|
751
|
+
return stage("sources", "Control Evidence Sources", "Complete the authoritative Systems for every selected control family before marking the Controls implemented.", items);
|
|
464
752
|
}
|
|
465
753
|
|
|
466
|
-
function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
|
|
754
|
+
function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
467
755
|
const goal = workspace?.assuranceGoal || "none";
|
|
468
756
|
if (!evidenceReady) {
|
|
469
757
|
return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
|
|
@@ -471,13 +759,15 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
|
|
|
471
759
|
"operation-later",
|
|
472
760
|
"later",
|
|
473
761
|
"Begin reliable evidence collection",
|
|
474
|
-
"Finish scope, policy adoption, control implementation,
|
|
762
|
+
"Finish scope, policy adoption, and control implementation, including complete authoritative evidence sources, before recording the candidate period.",
|
|
475
763
|
workspace || { type: "workspace" }
|
|
476
764
|
)
|
|
477
765
|
]);
|
|
478
766
|
}
|
|
479
767
|
if (goal !== "soc-2-type-2") {
|
|
480
|
-
const date = workspace?.
|
|
768
|
+
const date = workspace?.candidateCoverage?.kind === "as-of"
|
|
769
|
+
? workspace.candidateCoverage.on
|
|
770
|
+
: null;
|
|
481
771
|
return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence before engaging the CPA firm.", [
|
|
482
772
|
item(
|
|
483
773
|
"candidate-type-one-date",
|
|
@@ -492,10 +782,16 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
|
|
|
492
782
|
]);
|
|
493
783
|
}
|
|
494
784
|
|
|
495
|
-
const obligations = planObligations(records, { asOf, through: asOf });
|
|
496
|
-
const start = workspace.
|
|
497
|
-
|
|
785
|
+
const obligations = planObligations(records, { asOf, through: asOf, model });
|
|
786
|
+
const start = workspace.candidateCoverage?.kind === "range"
|
|
787
|
+
? coverageStart(workspace.candidateCoverage)
|
|
788
|
+
: null;
|
|
789
|
+
const end = workspace.candidateCoverage?.kind === "range"
|
|
790
|
+
? coverageEnd(workspace.candidateCoverage)
|
|
791
|
+
: null;
|
|
498
792
|
const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
|
|
793
|
+
const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id));
|
|
794
|
+
const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
|
|
499
795
|
return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
|
|
500
796
|
item(
|
|
501
797
|
"evidence-running",
|
|
@@ -517,14 +813,44 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
|
|
|
517
813
|
: "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
|
|
518
814
|
workspace
|
|
519
815
|
),
|
|
816
|
+
item(
|
|
817
|
+
"source-readiness-tests",
|
|
818
|
+
!start ? "later" : incompleteSourceCoverage.length ? "action" : "complete",
|
|
819
|
+
"Pass the evidence-source retrieval dry runs",
|
|
820
|
+
!start
|
|
821
|
+
? "Set the candidate period before recording the pre-period source retrieval tests."
|
|
822
|
+
: incompleteSourceCoverage.length
|
|
823
|
+
? `${incompleteSourceCoverage.length} source ${incompleteSourceCoverage.length === 1 ? "family needs" : "families need"} a passed retrieval test with confirmed access before the program is operating.`
|
|
824
|
+
: `${sourceCoverage.length} source ${sourceCoverage.length === 1 ? "family has" : "families have"} passed retrieval tests with confirmed access.`,
|
|
825
|
+
{ type: "source-coverage" },
|
|
826
|
+
{
|
|
827
|
+
sourceFamilyIds: incompleteSourceCoverage.map(({ family }) => family.id),
|
|
828
|
+
resourceIds: incompleteSourceCoverage.map(({ record }) => record?.id).filter(Boolean),
|
|
829
|
+
commands: [
|
|
830
|
+
"npx filegrc list source-coverage --workflow --json",
|
|
831
|
+
"npx filegrc guide evidence --json",
|
|
832
|
+
"npx filegrc program-readiness --json"
|
|
833
|
+
]
|
|
834
|
+
}
|
|
835
|
+
),
|
|
520
836
|
item(
|
|
521
837
|
"ongoing-obligations",
|
|
522
|
-
obligations.counts.overdue ? "action" : "complete",
|
|
838
|
+
obligations.counts.overdue || obligations.counts.blocked ? "action" : "complete",
|
|
523
839
|
"Keep policy work current",
|
|
524
840
|
obligations.counts.overdue
|
|
525
|
-
? `${obligations.counts.overdue}
|
|
526
|
-
|
|
527
|
-
|
|
841
|
+
? `${obligations.counts.overdue} Work Queue ${obligations.counts.overdue === 1 ? "item is" : "items are"} overdue`
|
|
842
|
+
+ (obligations.counts.blocked ? ` and ${obligations.counts.blocked} ${obligations.counts.blocked === 1 ? "is" : "are"} blocked` : "")
|
|
843
|
+
+ ". Resolve the work and retain its dated proof."
|
|
844
|
+
: obligations.counts.blocked
|
|
845
|
+
? `${obligations.counts.blocked} Work Queue ${obligations.counts.blocked === 1 ? "item is" : "items are"} blocked. Open each task, review its named blockers, and resolve them before completion.`
|
|
846
|
+
: `${obligations.counts.due} due and ${obligations.counts.upcoming} upcoming Work Queue items; none are overdue or blocked.`,
|
|
847
|
+
{ type: "obligation" },
|
|
848
|
+
{
|
|
849
|
+
commands: [
|
|
850
|
+
"npx filegrc obligations --json",
|
|
851
|
+
"npx filegrc workflow --json"
|
|
852
|
+
]
|
|
853
|
+
}
|
|
528
854
|
),
|
|
529
855
|
riskAssessmentItem(scope, records, byId, asOf)
|
|
530
856
|
]);
|
|
@@ -536,7 +862,7 @@ function riskAssessmentItem(scope, records, byId, asOf) {
|
|
|
536
862
|
&& record.status === "complete"
|
|
537
863
|
&& record.methodology
|
|
538
864
|
&& record.approvedOn
|
|
539
|
-
&& record.
|
|
865
|
+
&& record.completedOn >= shiftYear(asOf, -1)
|
|
540
866
|
&& partiesIndependent(record.assessorIds, record.reviewerIds, byId)
|
|
541
867
|
&& (!scope.systems.length || !(record.systemIds || []).length || record.systemIds.some((id) => scope.systems.some((system) => system.id === id)))
|
|
542
868
|
));
|
|
@@ -562,9 +888,10 @@ export function selectedControlFamilies(controls, model) {
|
|
|
562
888
|
id: definition.id,
|
|
563
889
|
title: definition.title,
|
|
564
890
|
sourceKinds: definition.sourceKinds || [],
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
891
|
+
evidenceForm: definition.evidenceForm || "capture",
|
|
892
|
+
evidencePrompt: definition.evidencePrompt || `Retain usable evidence for ${definition.title.toLowerCase()}.`,
|
|
893
|
+
description: definition.description || "",
|
|
894
|
+
timing: definition.timing || "",
|
|
568
895
|
operationRecordTypes: definition.operationRecordTypes || [],
|
|
569
896
|
controls: selected
|
|
570
897
|
});
|
|
@@ -586,9 +913,10 @@ export function selectedControlFamilies(controls, model) {
|
|
|
586
913
|
id: `control-${prefix}`,
|
|
587
914
|
title,
|
|
588
915
|
sourceKinds: [],
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
916
|
+
evidenceForm: "capture",
|
|
917
|
+
evidencePrompt: `Retain usable evidence for the selected ${title.toLowerCase()} controls.`,
|
|
918
|
+
description: `Record the authoritative Systems that produce evidence for the selected ${title.toLowerCase()} controls.`,
|
|
919
|
+
timing: "Map the source and document repeatable retrieval before program operation begins.",
|
|
592
920
|
operationRecordTypes: [],
|
|
593
921
|
controls: selected
|
|
594
922
|
});
|
|
@@ -596,10 +924,6 @@ export function selectedControlFamilies(controls, model) {
|
|
|
596
924
|
return families;
|
|
597
925
|
}
|
|
598
926
|
|
|
599
|
-
function requiresCollectionTest(family) {
|
|
600
|
-
return family.collectionTestRequired !== false;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
927
|
async function primaryMarkdown(loaded, record) {
|
|
604
928
|
const definition = loaded.model.resources[record.type];
|
|
605
929
|
const selected = markdownEntries(loaded.model, record).find((entry) => (
|
|
@@ -651,15 +975,31 @@ function policyCheckLabel(name) {
|
|
|
651
975
|
})[name] || name;
|
|
652
976
|
}
|
|
653
977
|
|
|
978
|
+
function governedContentCheckLabel(name) {
|
|
979
|
+
return ({
|
|
980
|
+
active: "active status",
|
|
981
|
+
owner: "current owner",
|
|
982
|
+
independentlyApproved: "independent approval and approval date",
|
|
983
|
+
approved: "approval and approval date",
|
|
984
|
+
effective: "effective date",
|
|
985
|
+
effectiveContent: "effective content revision",
|
|
986
|
+
contentComplete: "content and organization placeholders"
|
|
987
|
+
})[name] || name;
|
|
988
|
+
}
|
|
989
|
+
|
|
654
990
|
function controlCheckLabel(name) {
|
|
655
991
|
return ({
|
|
992
|
+
applicability: "reviewed applicability decision",
|
|
656
993
|
implemented: "implemented status",
|
|
657
994
|
owner: "owner",
|
|
658
995
|
procedure: "actual procedure in Record Markdown",
|
|
659
996
|
scope: "in-scope systems",
|
|
660
|
-
|
|
997
|
+
operationPattern: "operation pattern",
|
|
661
998
|
evidenceSource: "authoritative evidence source",
|
|
662
999
|
implementationDate: "implementation date",
|
|
1000
|
+
procedureRevision: "effective procedure revision",
|
|
1001
|
+
procedureEffective: "procedure effective date",
|
|
1002
|
+
implementationReview: "independent implementation review",
|
|
663
1003
|
policyMapping: "policy mapping",
|
|
664
1004
|
criteriaMapping: "criteria mapping",
|
|
665
1005
|
workQueue: "running Work Queue schedules"
|