filegrc 0.4.0 → 0.5.1
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 +11 -3
- package/model/index.js +9 -5
- package/model/v3.json +9391 -0
- package/package.json +3 -3
- package/src/agent.js +53 -0
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +47 -6
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +418 -61
- package/src/collection-review.js +185 -0
- package/src/evidence-packet.js +34 -2
- package/src/external-reviewer.js +165 -0
- package/src/files.js +46 -10
- package/src/index.js +36 -2
- package/src/model-docs.js +15 -0
- package/src/model-migration.js +516 -21
- package/src/obligations.js +396 -13
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +49 -12
- package/src/program-readiness.js +331 -27
- package/src/reconciliation.js +277 -0
- package/src/server.js +242 -14
- package/src/setup.js +36 -4
- package/src/source-coverage.js +61 -0
- package/src/startup.js +1 -1
- package/src/state.js +37 -1
- package/src/validate.js +100 -3
- package/src/web.js +979 -213
- package/src/workflow.js +1595 -0
package/src/program-readiness.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { assessRequiredAppointments } from "./appointments.js";
|
|
3
|
+
import { assessCollectionReviews } from "./collection-review.js";
|
|
2
4
|
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
3
5
|
import { planObligations } from "./obligations.js";
|
|
4
6
|
import { resolveDataPath } from "./paths.js";
|
|
5
7
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
6
8
|
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
7
9
|
import { markdownEntries } from "./resource-markdown.js";
|
|
10
|
+
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
8
11
|
import { currentCalendarDate } from "./time.js";
|
|
9
12
|
import { loadWorkspace } from "./workspace.js";
|
|
10
13
|
|
|
@@ -17,6 +20,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
17
20
|
const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
|
|
18
21
|
const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
|
|
19
22
|
const scope = programScope(workspace, records, byId);
|
|
23
|
+
const collectionReviews = assessCollectionReviews(loaded);
|
|
20
24
|
const markdown = new Map();
|
|
21
25
|
const readMarkdown = async (record) => {
|
|
22
26
|
if (!record) return "";
|
|
@@ -24,20 +28,30 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
24
28
|
return markdown.get(record.id);
|
|
25
29
|
};
|
|
26
30
|
|
|
27
|
-
const controlStage = await controlsStage(scope, byId, readMarkdown, asOf);
|
|
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
36
|
controlStage.items.push(...sourceStage.items);
|
|
30
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.";
|
|
31
38
|
const evidenceGateStages = [
|
|
32
|
-
scopeStage(
|
|
33
|
-
|
|
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),
|
|
34
48
|
controlStage
|
|
35
49
|
];
|
|
36
50
|
for (const current of evidenceGateStages) finalizeStage(current);
|
|
37
51
|
const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
|
|
38
52
|
const stages = [
|
|
39
53
|
...evidenceGateStages,
|
|
40
|
-
operationStage(workspace, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
54
|
+
operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
41
55
|
];
|
|
42
56
|
finalizeStage(stages.at(-1));
|
|
43
57
|
const candidateStarted = Boolean(
|
|
@@ -46,7 +60,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
46
60
|
&& coverageStart(workspace.candidateCoverage) <= asOf
|
|
47
61
|
);
|
|
48
62
|
const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
|
|
49
|
-
const operating = evidenceReady && candidateStarted &&
|
|
63
|
+
const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
|
|
50
64
|
const canStartCandidatePeriod = Boolean(
|
|
51
65
|
evidenceReady
|
|
52
66
|
&& workspace?.assuranceGoal === "soc-2-type-2"
|
|
@@ -124,14 +138,14 @@ function programScope(workspace, records, byId) {
|
|
|
124
138
|
)),
|
|
125
139
|
requirements: select(workspace?.requirementIds, "requirement", (record) => (
|
|
126
140
|
record.type === "requirement" && record.applicability === "applicable"
|
|
127
|
-
)),
|
|
141
|
+
)).filter((record) => record.applicability === "applicable"),
|
|
128
142
|
controls: select(workspace?.controlIds, "control", (record) => (
|
|
129
143
|
record.type === "control" && !["not-applicable", "retired"].includes(record.status)
|
|
130
|
-
))
|
|
144
|
+
)).filter((record) => !["not-applicable", "retired"].includes(record.status))
|
|
131
145
|
};
|
|
132
146
|
}
|
|
133
147
|
|
|
134
|
-
function scopeStage(workspace, scope, records, byId) {
|
|
148
|
+
function scopeStage(workspace, scope, records, byId, model, collectionReviews = []) {
|
|
135
149
|
const items = [];
|
|
136
150
|
const goal = workspace?.assuranceGoal || "none";
|
|
137
151
|
items.push(item(
|
|
@@ -141,10 +155,20 @@ function scopeStage(workspace, scope, records, byId) {
|
|
|
141
155
|
goal !== "none"
|
|
142
156
|
? `Target: ${assuranceGoalLabel(goal)}. This is a management objective, not an active CPA engagement.`
|
|
143
157
|
: "Choose readiness, SOC 2 Type 1, or SOC 2 Type 2 as the management objective.",
|
|
144
|
-
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
|
+
}
|
|
145
165
|
));
|
|
146
166
|
|
|
147
167
|
items.push(programOwnershipItem(records, byId));
|
|
168
|
+
items.push(requiredAppointmentsItem(records, model));
|
|
169
|
+
for (const assessment of collectionReviews) {
|
|
170
|
+
items.push(collectionReviewReadinessItem(assessment));
|
|
171
|
+
}
|
|
148
172
|
|
|
149
173
|
const completeSystems = scope.systems.filter((system) => (
|
|
150
174
|
system.status === "active"
|
|
@@ -162,6 +186,44 @@ function scopeStage(workspace, scope, records, byId) {
|
|
|
162
186
|
scope.systems[0] || { type: "system" }
|
|
163
187
|
));
|
|
164
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
|
+
|
|
165
227
|
const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
|
|
166
228
|
const applicableRequirements = records.filter((record) => (
|
|
167
229
|
record.type === "requirement"
|
|
@@ -188,12 +250,67 @@ function scopeStage(workspace, scope, records, byId) {
|
|
|
188
250
|
criteriaComplete
|
|
189
251
|
? `${scope.requirements.length} applicable criteria and ${scope.controls.length} controls are in the management program scope.`
|
|
190
252
|
: `Resolve the program criteria and controls. ${unresolvedRequirements.length} criteria remain undetermined and ${missingRequirements.length} applicable criteria are not selected.`,
|
|
191
|
-
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
|
+
}
|
|
192
262
|
));
|
|
193
263
|
|
|
194
264
|
return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
|
|
195
265
|
}
|
|
196
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
|
+
|
|
197
314
|
function programOwnershipItem(records, byId) {
|
|
198
315
|
const ownedRecords = records.filter((record) => (
|
|
199
316
|
["policy", "control", "obligation"].includes(record.type)
|
|
@@ -229,15 +346,32 @@ function programOwnershipItem(records, byId) {
|
|
|
229
346
|
ownerIds: record.ownerIds || [],
|
|
230
347
|
reasons: ownershipResolutionReasons(record.ownerIds || [], byId)
|
|
231
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;
|
|
232
359
|
const detail = [];
|
|
233
360
|
if (!currentOwners.size) detail.push("No current person owns the program records.");
|
|
234
|
-
if (
|
|
235
|
-
detail.push(`${
|
|
361
|
+
if (separatelyUnresolved) {
|
|
362
|
+
detail.push(`${separatelyUnresolved} ${separatelyUnresolved === 1 ? "record has" : "records have"} no current person owner.`);
|
|
236
363
|
}
|
|
237
364
|
if (missingJobTitles.length) {
|
|
238
365
|
detail.push(`${missingJobTitles.length} active ${missingJobTitles.length === 1 ? "owner needs" : "owners need"} an organizational job title.`);
|
|
239
366
|
}
|
|
240
|
-
if (!oversightComplete)
|
|
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
|
+
);
|
|
374
|
+
}
|
|
241
375
|
const oversightId = oversight?.id ? shellArgument(oversight.id) : null;
|
|
242
376
|
return item(
|
|
243
377
|
"program-ownership",
|
|
@@ -287,7 +421,7 @@ function ownershipResolutionReasons(ownerIds, byId) {
|
|
|
287
421
|
});
|
|
288
422
|
}
|
|
289
423
|
|
|
290
|
-
async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
424
|
+
async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
291
425
|
const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
|
|
292
426
|
const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
|
|
293
427
|
record?.type === "policy" && !["superseded", "retired"].includes(record.status)
|
|
@@ -320,7 +454,15 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
320
454
|
: reviewerNeedsAssignment
|
|
321
455
|
? `${availableReviewer.title} chairs Security and Risk Oversight. Assign this person as approver on each policy after review.`
|
|
322
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.",
|
|
323
|
-
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
|
+
}
|
|
324
466
|
)
|
|
325
467
|
];
|
|
326
468
|
if (!policies.length) {
|
|
@@ -329,7 +471,14 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
329
471
|
"action",
|
|
330
472
|
"Link policies to the selected controls",
|
|
331
473
|
"No applicable policies are linked from the controls in program scope.",
|
|
332
|
-
{ 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
|
+
}
|
|
333
482
|
));
|
|
334
483
|
}
|
|
335
484
|
for (const policy of policies) {
|
|
@@ -353,13 +502,93 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
353
502
|
? `Remaining adoption work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
|
|
354
503
|
: `Reviewed, independently approved, effective ${policy.effectiveOn}, linked to controls, with no open organization placeholders.`,
|
|
355
504
|
policy,
|
|
356
|
-
{
|
|
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
|
+
}
|
|
357
581
|
));
|
|
358
582
|
}
|
|
359
|
-
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
|
+
);
|
|
360
589
|
}
|
|
361
590
|
|
|
362
|
-
async function controlsStage(scope, byId, readMarkdown, asOf) {
|
|
591
|
+
async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
363
592
|
const items = [];
|
|
364
593
|
if (!scope.controls.length) {
|
|
365
594
|
items.push(item("control-scope", "action", "Select the program controls", "No controls are selected for the management program.", { type: "control" }));
|
|
@@ -373,6 +602,9 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
|
|
|
373
602
|
&& (record.controlIds || []).includes(control.id)
|
|
374
603
|
));
|
|
375
604
|
const checks = {
|
|
605
|
+
...(model.resources.control?.fields?.applicabilityReview ? {
|
|
606
|
+
applicability: control.applicabilityReview?.decision === "applicable"
|
|
607
|
+
} : {}),
|
|
376
608
|
implemented: control.status === "implemented",
|
|
377
609
|
owner: (control.ownerIds || []).length > 0,
|
|
378
610
|
procedure: substantiveMarkdown(source) && openPlaceholderCount(source) === 0,
|
|
@@ -380,6 +612,15 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
|
|
|
380
612
|
operationPattern: Boolean(control.operationPattern),
|
|
381
613
|
evidenceSource: sourceSystems.length > 0,
|
|
382
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
|
+
} : {}),
|
|
383
624
|
policyMapping: (control.policyIds || []).length > 0,
|
|
384
625
|
criteriaMapping: (control.requirementIds || []).length > 0,
|
|
385
626
|
...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
|
|
@@ -393,11 +634,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
|
|
|
393
634
|
missing.length ? "action" : "complete",
|
|
394
635
|
`${control.code ? `${control.code}: ` : ""}${control.title}`,
|
|
395
636
|
missing.length
|
|
396
|
-
? `
|
|
637
|
+
? `Complete ${missing.length} ${missing.length === 1 ? "check" : "checks"} before implementation: ${missing.join(", ")}.`
|
|
397
638
|
: `Implemented ${control.effectiveOn}; owned, scoped, scheduled, documented, mapped, and tied to ${sourceSystems.length} authoritative ${sourceSystems.length === 1 ? "source" : "sources"}.`,
|
|
398
639
|
control,
|
|
399
640
|
{
|
|
400
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
|
+
],
|
|
401
651
|
workQueue: queueSchedules.length ? {
|
|
402
652
|
running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
|
|
403
653
|
total: queueSchedules.length
|
|
@@ -414,7 +664,13 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
414
664
|
for (const family of families) {
|
|
415
665
|
const selectedSources = [...new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []))]
|
|
416
666
|
.map((id) => byId.get(id))
|
|
417
|
-
.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
|
+
));
|
|
418
674
|
const completeSources = [];
|
|
419
675
|
const sourceSystemChecks = [];
|
|
420
676
|
for (const source of selectedSources) {
|
|
@@ -472,7 +728,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
472
728
|
complete ? "complete" : "action",
|
|
473
729
|
family.title,
|
|
474
730
|
complete
|
|
475
|
-
? `${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.`
|
|
476
732
|
: `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative system with the required source role, access owners, and extraction instructions.`,
|
|
477
733
|
completeSources[0] || selectedSources[0] || { type: "system" },
|
|
478
734
|
{
|
|
@@ -495,7 +751,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
495
751
|
return stage("sources", "Control Evidence Sources", "Complete the authoritative Systems for every selected control family before marking the Controls implemented.", items);
|
|
496
752
|
}
|
|
497
753
|
|
|
498
|
-
function operationStage(workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
754
|
+
function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
499
755
|
const goal = workspace?.assuranceGoal || "none";
|
|
500
756
|
if (!evidenceReady) {
|
|
501
757
|
return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
|
|
@@ -534,6 +790,8 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady, mo
|
|
|
534
790
|
? coverageEnd(workspace.candidateCoverage)
|
|
535
791
|
: null;
|
|
536
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);
|
|
537
795
|
return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
|
|
538
796
|
item(
|
|
539
797
|
"evidence-running",
|
|
@@ -555,14 +813,44 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady, mo
|
|
|
555
813
|
: "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
|
|
556
814
|
workspace
|
|
557
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
|
+
),
|
|
558
836
|
item(
|
|
559
837
|
"ongoing-obligations",
|
|
560
|
-
obligations.counts.overdue ? "action" : "complete",
|
|
838
|
+
obligations.counts.overdue || obligations.counts.blocked ? "action" : "complete",
|
|
561
839
|
"Keep policy work current",
|
|
562
840
|
obligations.counts.overdue
|
|
563
|
-
? `${obligations.counts.overdue}
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
}
|
|
566
854
|
),
|
|
567
855
|
riskAssessmentItem(scope, records, byId, asOf)
|
|
568
856
|
]);
|
|
@@ -687,8 +975,21 @@ function policyCheckLabel(name) {
|
|
|
687
975
|
})[name] || name;
|
|
688
976
|
}
|
|
689
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
|
+
|
|
690
990
|
function controlCheckLabel(name) {
|
|
691
991
|
return ({
|
|
992
|
+
applicability: "reviewed applicability decision",
|
|
692
993
|
implemented: "implemented status",
|
|
693
994
|
owner: "owner",
|
|
694
995
|
procedure: "actual procedure in Record Markdown",
|
|
@@ -696,6 +997,9 @@ function controlCheckLabel(name) {
|
|
|
696
997
|
operationPattern: "operation pattern",
|
|
697
998
|
evidenceSource: "authoritative evidence source",
|
|
698
999
|
implementationDate: "implementation date",
|
|
1000
|
+
procedureRevision: "effective procedure revision",
|
|
1001
|
+
procedureEffective: "procedure effective date",
|
|
1002
|
+
implementationReview: "independent implementation review",
|
|
699
1003
|
policyMapping: "policy mapping",
|
|
700
1004
|
criteriaMapping: "criteria mapping",
|
|
701
1005
|
workQueue: "running Work Queue schedules"
|