filegrc 0.7.1 → 0.8.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 +6 -6
- package/model/index.js +21 -4
- package/model/v5.json +10233 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +182 -45
- package/src/audit-transition.js +3 -2
- package/src/batch-review.js +7 -6
- package/src/cli.js +74 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +145 -0
- package/src/evidence-packet.js +199 -78
- package/src/external-reviewer.js +5 -4
- package/src/files.js +141 -32
- package/src/git.js +71 -7
- package/src/index.js +4 -1
- package/src/model-migration.js +218 -7
- package/src/obligations.js +14 -11
- package/src/policy-library.js +2 -1
- package/src/program-lifecycle.js +93 -3
- package/src/program-path.js +13 -8
- package/src/program-readiness.js +235 -71
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +29 -7
- package/src/setup.js +8 -5
- package/src/soc2.js +3 -2
- package/src/validate.js +148 -14
- package/src/web.js +160 -15
- package/src/workflow.js +18 -4
- package/src/workspace.js +5 -0
package/src/program-readiness.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { modelSupports } from "../model/index.js";
|
|
2
3
|
import { assessRequiredAppointments } from "./appointments.js";
|
|
3
4
|
import { assessCollectionReviews } from "./collection-review.js";
|
|
4
5
|
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
5
6
|
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
6
7
|
import { planObligations } from "./obligations.js";
|
|
7
8
|
import { resolveDataPath } from "./paths.js";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
contentRevisionBindingsMatch,
|
|
11
|
+
documentIsAuditSpecific,
|
|
12
|
+
governedDocumentIsOperating,
|
|
13
|
+
obligationIsEnabled,
|
|
14
|
+
obligationIsRunning
|
|
15
|
+
} from "./program-lifecycle.js";
|
|
9
16
|
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
10
17
|
import { assessPolicyLibraryUpgrades } from "./policy-library.js";
|
|
11
18
|
import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
|
|
12
19
|
import { markdownEntries } from "./resource-markdown.js";
|
|
13
20
|
import {
|
|
14
21
|
missingSoc2References,
|
|
22
|
+
personWasActiveOn,
|
|
15
23
|
REQUIRED_SOC2_DESCRIPTION_REFERENCES,
|
|
16
24
|
REQUIRED_SOC2_SECURITY_REFERENCES
|
|
17
25
|
} from "./soc2.js";
|
|
@@ -37,14 +45,15 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
37
45
|
return markdown.get(record.id);
|
|
38
46
|
};
|
|
39
47
|
|
|
40
|
-
const policyStage = await policiesStage(scope, records, byId, readMarkdown);
|
|
48
|
+
const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model);
|
|
41
49
|
const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
|
|
42
50
|
controlStage.items.unshift(...collectionReviews
|
|
43
51
|
.filter(({ resourceType }) => resourceType === "complementary-control")
|
|
44
52
|
.map(collectionReviewReadinessItem));
|
|
45
53
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
46
54
|
controlStage.items.push(...sourceStage.items);
|
|
47
|
-
|
|
55
|
+
const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
|
|
56
|
+
controlStage.items.push(...governedContent.items);
|
|
48
57
|
const policyActivations = await assessPolicyActivations(
|
|
49
58
|
requiredPolicies(scope, byId),
|
|
50
59
|
scope.controls,
|
|
@@ -55,7 +64,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
55
64
|
loaded.model
|
|
56
65
|
);
|
|
57
66
|
controlStage.items.push(...policyActivations.map(policyActivationItem));
|
|
58
|
-
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled schedules, and complete authoritative source ${
|
|
67
|
+
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled schedules, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. Activate the unchanged approved governed Documents after their requirements are implemented, then activate approved Policies at the implementation cutover.`;
|
|
59
68
|
const evidenceGateStages = [
|
|
60
69
|
scopeStage(
|
|
61
70
|
program,
|
|
@@ -110,6 +119,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
110
119
|
canStartCandidatePeriod,
|
|
111
120
|
suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
|
|
112
121
|
policyActivations,
|
|
122
|
+
documentActivations: governedContent.documentActivations,
|
|
113
123
|
policyLibraryProposals: [
|
|
114
124
|
...policyLibrary.proposals,
|
|
115
125
|
...legacyPolicyLibraryProposals(records)
|
|
@@ -145,9 +155,9 @@ export async function assessEvidenceMap(input, options = {}) {
|
|
|
145
155
|
status: counts.action ? "action" : "complete",
|
|
146
156
|
counts,
|
|
147
157
|
workflow: [
|
|
148
|
-
`Choose an existing ${
|
|
149
|
-
`On every source ${
|
|
150
|
-
`Map each selected Control to the authoritative source ${
|
|
158
|
+
`Choose an existing ${modelSupports(readiness.dataModelVersion, "component-sources") ? "Component" : "System"} or create one that is authoritative for each evidence family.`,
|
|
159
|
+
`On every source ${modelSupports(readiness.dataModelVersion, "component-sources") ? "Component" : "System"}, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.`,
|
|
160
|
+
`Map each selected Control to the authoritative source ${modelSupports(readiness.dataModelVersion, "component-sources") ? "Components with evidenceSourceComponentIds" : "Systems with evidenceSourceIds"} that produce its evidence.`,
|
|
151
161
|
"Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
|
|
152
162
|
],
|
|
153
163
|
items: evidenceItems
|
|
@@ -169,7 +179,7 @@ function programScope(program, records, byId, model, loaded) {
|
|
|
169
179
|
)),
|
|
170
180
|
requirements: select(selectedRequirementIds(program, model), "requirement", (record) => (
|
|
171
181
|
record.type === "requirement" && record.applicability === "applicable"
|
|
172
|
-
)).filter((record) =>
|
|
182
|
+
)).filter((record) => modelSupports(model, "program-scope") || record.applicability === "applicable"),
|
|
173
183
|
controls: select(program?.controlIds, "control", (record) => (
|
|
174
184
|
record.type === "control" && !["not-applicable", "retired"].includes(record.status)
|
|
175
185
|
)).filter((record) => !["not-applicable", "retired"].includes(record.status))
|
|
@@ -195,7 +205,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
195
205
|
}
|
|
196
206
|
));
|
|
197
207
|
|
|
198
|
-
if (
|
|
208
|
+
if (modelSupports(model, "program-scope")) {
|
|
199
209
|
const completeComponents = scope.components.filter((component) => (
|
|
200
210
|
component.status === "active"
|
|
201
211
|
&& component.description
|
|
@@ -226,8 +236,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
226
236
|
|
|
227
237
|
const completeSystems = scope.systems.filter((system) => (
|
|
228
238
|
system.status === "active"
|
|
229
|
-
&& (
|
|
230
|
-
&& (
|
|
239
|
+
&& (modelSupports(model, "program-scope") ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
|
|
240
|
+
&& (modelSupports(model, "program-scope") || system.classificationId)
|
|
231
241
|
&& (system.ownerIds || []).length
|
|
232
242
|
));
|
|
233
243
|
items.push(item(
|
|
@@ -235,12 +245,12 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
235
245
|
scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
|
|
236
246
|
"Define the service boundary",
|
|
237
247
|
scope.systems.length
|
|
238
|
-
? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${
|
|
248
|
+
? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${modelSupports(model, "program-scope") ? "" : ", with a classification"}.`
|
|
239
249
|
: "Select and describe every service and supporting system in the program boundary.",
|
|
240
250
|
scope.systems[0] || { type: "system" }
|
|
241
251
|
));
|
|
242
252
|
|
|
243
|
-
if (
|
|
253
|
+
if (modelSupports(model, "guided-workflow")) {
|
|
244
254
|
const commitments = records.filter((record) => (
|
|
245
255
|
record.type === "commitment"
|
|
246
256
|
&& !["superseded", "retired"].includes(record.status)
|
|
@@ -250,7 +260,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
250
260
|
record.status === "active"
|
|
251
261
|
&& record.statement
|
|
252
262
|
&& record.effectiveOn
|
|
253
|
-
&& (
|
|
263
|
+
&& (modelSupports(model, "program-scope") || record.applicabilityReview?.decision === "applicable")
|
|
254
264
|
&& currentPartyPeople(record.ownerIds, byId).size > 0
|
|
255
265
|
&& (record.requirementIds || []).length > 0
|
|
256
266
|
&& (record.controlIds || []).length > 0
|
|
@@ -283,12 +293,12 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
283
293
|
const applicableRequirements = records.filter((record) => (
|
|
284
294
|
record.type === "requirement"
|
|
285
295
|
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
286
|
-
&& (
|
|
296
|
+
&& (modelSupports(model, "program-scope") ? v4Decisions.get(record.id) === "applicable" : record.applicability === "applicable")
|
|
287
297
|
));
|
|
288
298
|
const unresolvedRequirements = records.filter((record) => (
|
|
289
299
|
record.type === "requirement"
|
|
290
300
|
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
291
|
-
&& (
|
|
301
|
+
&& (modelSupports(model, "program-scope") ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
|
|
292
302
|
));
|
|
293
303
|
const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
|
|
294
304
|
const selectedDescriptionRequirements = scope.requirements.filter(isDescriptionRequirement);
|
|
@@ -299,7 +309,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
299
309
|
!isDescriptionRequirement(requirement)
|
|
300
310
|
&& !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
|
|
301
311
|
));
|
|
302
|
-
const enforceSoc2Baseline =
|
|
312
|
+
const enforceSoc2Baseline = modelSupports(model, "program-scope")
|
|
303
313
|
&& ["readiness", "soc-2-type-1", "soc-2-type-2"].includes(goal);
|
|
304
314
|
const selectedFrameworkRequirements = records.filter((record) => (
|
|
305
315
|
record.type === "requirement"
|
|
@@ -523,15 +533,19 @@ function ownershipResolutionReasons(ownerIds, byId) {
|
|
|
523
533
|
});
|
|
524
534
|
}
|
|
525
535
|
|
|
526
|
-
async function policiesStage(scope, records, byId, readMarkdown) {
|
|
536
|
+
async function policiesStage(scope, records, byId, readMarkdown, model) {
|
|
527
537
|
const policies = requiredPolicies(scope, byId);
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
538
|
+
const documents = modelSupports(model, "governed-document-activation")
|
|
539
|
+
? requiredGovernedDocuments(scope, records, byId, model)
|
|
540
|
+
: [];
|
|
541
|
+
const governedRecords = [...policies, ...documents];
|
|
542
|
+
const appointedReviewer = governedRecords
|
|
543
|
+
.filter((record) => partiesIndependent(record.ownerIds, record.approverIds, byId))
|
|
544
|
+
.flatMap((record) => [...currentPartyPeople(record.approverIds || [], byId)])
|
|
531
545
|
.map((id) => byId.get(id))
|
|
532
546
|
.find(Boolean);
|
|
533
|
-
const policyOwnerIds = new Set(
|
|
534
|
-
[...partyPeople(
|
|
547
|
+
const policyOwnerIds = new Set(governedRecords.flatMap((record) => (
|
|
548
|
+
[...partyPeople(record.ownerIds || [], byId)]
|
|
535
549
|
)));
|
|
536
550
|
const oversight = byId.get("team-security-risk-oversight");
|
|
537
551
|
const availableReviewer = oversight?.type === "team" && oversight.status === "active"
|
|
@@ -540,7 +554,7 @@ async function policiesStage(scope, records, byId, readMarkdown) {
|
|
|
540
554
|
.map((id) => byId.get(id))
|
|
541
555
|
.find(Boolean)
|
|
542
556
|
: null;
|
|
543
|
-
const reviewerNeedsAssignment = !appointedReviewer && availableReviewer &&
|
|
557
|
+
const reviewerNeedsAssignment = !appointedReviewer && availableReviewer && governedRecords.length;
|
|
544
558
|
const items = [
|
|
545
559
|
item(
|
|
546
560
|
"independent-reviewer",
|
|
@@ -553,7 +567,7 @@ async function policiesStage(scope, records, byId, readMarkdown) {
|
|
|
553
567
|
: reviewerNeedsAssignment
|
|
554
568
|
? `${availableReviewer.title} chairs Security and Risk Oversight. Assign this person as approver on each policy after review.`
|
|
555
569
|
: "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.",
|
|
556
|
-
appointedReviewer || (reviewerNeedsAssignment ?
|
|
570
|
+
appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
|
|
557
571
|
{
|
|
558
572
|
commands: [
|
|
559
573
|
"npx filegrc list appointment --workflow --json",
|
|
@@ -610,10 +624,61 @@ async function policiesStage(scope, records, byId, readMarkdown) {
|
|
|
610
624
|
}
|
|
611
625
|
));
|
|
612
626
|
}
|
|
627
|
+
for (const document of documents) {
|
|
628
|
+
const source = await readMarkdown(document);
|
|
629
|
+
const placeholderCount = openPlaceholderCount(source);
|
|
630
|
+
const isSecurityIncidentRecoveryPlan = document.id === "document-security-incident-recovery-plan";
|
|
631
|
+
const systemsWithCompleteContinuityObjectives = scope.systems.filter(({ continuityObjectives }) => (
|
|
632
|
+
Number.isInteger(continuityObjectives?.recoveryTimeHours)
|
|
633
|
+
&& Number.isInteger(continuityObjectives?.recoveryPointHours)
|
|
634
|
+
&& Number.isInteger(continuityObjectives?.maximumTolerableDowntimeHours)
|
|
635
|
+
));
|
|
636
|
+
const checks = {
|
|
637
|
+
independentlyApproved: ["approved", "active"].includes(document.status)
|
|
638
|
+
&& Boolean(document.approvedOn)
|
|
639
|
+
&& Boolean(document.approvedContentRevisions)
|
|
640
|
+
&& partiesIndependent(document.ownerIds, document.approverIds, byId),
|
|
641
|
+
owner: currentPartyPeople(document.ownerIds, byId).size > 0,
|
|
642
|
+
linkedControls: (document.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
|
|
643
|
+
contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
|
|
644
|
+
...(isSecurityIncidentRecoveryPlan
|
|
645
|
+
? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
|
|
646
|
+
: {})
|
|
647
|
+
};
|
|
648
|
+
const missing = Object.entries(checks)
|
|
649
|
+
.filter(([, value]) => !value)
|
|
650
|
+
.map(([name]) => governedApprovalCheckLabel(name));
|
|
651
|
+
items.push(item(
|
|
652
|
+
`document-approval-${document.id}`,
|
|
653
|
+
missing.length ? "action" : "complete",
|
|
654
|
+
document.title,
|
|
655
|
+
missing.length
|
|
656
|
+
? `Remaining Step 2 approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Complete and approve the intended values before implementation.`
|
|
657
|
+
: `Independently approved on ${document.approvedOn} and bound to the exact intended values and Markdown revision. Activation remains in Step 3.`,
|
|
658
|
+
document,
|
|
659
|
+
{
|
|
660
|
+
checks,
|
|
661
|
+
placeholderCount,
|
|
662
|
+
...(isSecurityIncidentRecoveryPlan
|
|
663
|
+
? {
|
|
664
|
+
continuityObjectiveSystemIds: systemsWithCompleteContinuityObjectives.map(({ id }) => id),
|
|
665
|
+
missingContinuityObjectiveSystemIds: scope.systems
|
|
666
|
+
.filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
|
|
667
|
+
.map(({ id }) => id)
|
|
668
|
+
}
|
|
669
|
+
: {}),
|
|
670
|
+
commands: [
|
|
671
|
+
`npx filegrc get ${shellArgument(document.id)} --mutation`,
|
|
672
|
+
`npx filegrc update document ${shellArgument(document.id)} MUTATION.json --json`,
|
|
673
|
+
"npx filegrc program-readiness --json"
|
|
674
|
+
]
|
|
675
|
+
}
|
|
676
|
+
));
|
|
677
|
+
}
|
|
613
678
|
return stage(
|
|
614
679
|
"policies",
|
|
615
|
-
"Approve Policies",
|
|
616
|
-
"
|
|
680
|
+
"Approve Policies and Plans",
|
|
681
|
+
"Approve Policy requirements and the intended values in required governed plans and schedules. Approval binds exact revisions but does not prove implementation or activate the content.",
|
|
617
682
|
items
|
|
618
683
|
);
|
|
619
684
|
}
|
|
@@ -627,8 +692,31 @@ function requiredPolicies(scope, byId) {
|
|
|
627
692
|
));
|
|
628
693
|
}
|
|
629
694
|
|
|
630
|
-
|
|
695
|
+
function requiredGovernedDocuments(scope, records, byId, model) {
|
|
631
696
|
const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
|
|
697
|
+
const linkedDocumentIds = new Set(requiredPolicies(scope, byId).flatMap((policy) => policy.relatedDocumentIds || []));
|
|
698
|
+
const obligationDocumentIds = new Set(records
|
|
699
|
+
.filter((record) => record.type === "obligation")
|
|
700
|
+
.flatMap((record) => [
|
|
701
|
+
...(record.scopeResourceIds || []),
|
|
702
|
+
...(record.templateResourceId ? [record.templateResourceId] : [])
|
|
703
|
+
]));
|
|
704
|
+
return records.filter((record) => (
|
|
705
|
+
record.type === "document"
|
|
706
|
+
&& !documentIsAuditSpecific(record, model)
|
|
707
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
708
|
+
&& (
|
|
709
|
+
linkedDocumentIds.has(record.id)
|
|
710
|
+
|| obligationDocumentIds.has(record.id)
|
|
711
|
+
|| (
|
|
712
|
+
record.programRole === "required"
|
|
713
|
+
&& (record.controlIds || []).some((id) => selectedControlIds.has(id))
|
|
714
|
+
)
|
|
715
|
+
)
|
|
716
|
+
));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function governedContentItems(scope, records, byId, readMarkdown, asOf, model) {
|
|
632
720
|
const enabledObligations = records.filter((record) => (
|
|
633
721
|
record.type === "obligation" && obligationIsEnabled(record)
|
|
634
722
|
));
|
|
@@ -636,31 +724,39 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
|
|
|
636
724
|
...(record.scopeResourceIds || []),
|
|
637
725
|
...(record.templateResourceId ? [record.templateResourceId] : [])
|
|
638
726
|
]));
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|| (
|
|
645
|
-
record.programRole === "required"
|
|
646
|
-
&& (record.controlIds || []).some((id) => selectedControlIds.has(id))
|
|
647
|
-
)
|
|
648
|
-
)
|
|
649
|
-
)
|
|
650
|
-
|| (record.type === "training" && requiredGovernedIds.has(record.id))
|
|
651
|
-
));
|
|
727
|
+
const documents = requiredGovernedDocuments(scope, records, byId, model);
|
|
728
|
+
const governedRecords = [
|
|
729
|
+
...documents,
|
|
730
|
+
...records.filter((record) => record.type === "training" && requiredGovernedIds.has(record.id))
|
|
731
|
+
];
|
|
652
732
|
const items = [];
|
|
733
|
+
const documentActivations = [];
|
|
653
734
|
for (const record of governedRecords) {
|
|
654
735
|
const source = await readMarkdown(record);
|
|
655
736
|
const placeholderCount = openPlaceholderCount(source);
|
|
656
|
-
const
|
|
657
|
-
const
|
|
658
|
-
Number.isInteger(continuityObjectives?.recoveryTimeHours)
|
|
659
|
-
&& Number.isInteger(continuityObjectives?.recoveryPointHours)
|
|
660
|
-
&& Number.isInteger(continuityObjectives?.maximumTolerableDowntimeHours)
|
|
661
|
-
));
|
|
737
|
+
const linkedControlIds = (record.controlIds || []).filter((id) => scope.controls.some((control) => control.id === id));
|
|
738
|
+
const missingImplementationControlIds = linkedControlIds.filter((id) => byId.get(id)?.status !== "implemented");
|
|
662
739
|
const checks = record.type === "document"
|
|
663
|
-
? {
|
|
740
|
+
? modelSupports(model, "governed-document-activation") ? {
|
|
741
|
+
approvalBound: ["approved", "active"].includes(record.status)
|
|
742
|
+
&& Boolean(record.approvedOn)
|
|
743
|
+
&& Boolean(record.approvedContentRevisions)
|
|
744
|
+
&& partiesIndependent(record.ownerIds, record.approverIds, byId),
|
|
745
|
+
owner: currentPartyPeople(record.ownerIds, byId).size > 0,
|
|
746
|
+
active: record.status === "active",
|
|
747
|
+
requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
|
|
748
|
+
activationRecorded: record.activationBasis === "recorded",
|
|
749
|
+
activated: Boolean(record.activatedOn),
|
|
750
|
+
activator: Boolean((record.activatedByIds || []).length)
|
|
751
|
+
&& record.activatedByIds.every((id) => personWasActiveOn(byId.get(id), record.activatedOn)),
|
|
752
|
+
activatedContent: Boolean(record.activatedContentRevisions),
|
|
753
|
+
activationMatchesApproval: contentRevisionBindingsMatch(
|
|
754
|
+
record.approvedContentRevisions,
|
|
755
|
+
record.activatedContentRevisions
|
|
756
|
+
),
|
|
757
|
+
effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
|
|
758
|
+
contentComplete: substantiveMarkdown(source) && placeholderCount === 0
|
|
759
|
+
} : {
|
|
664
760
|
active: record.status === "active",
|
|
665
761
|
owner: currentPartyPeople(record.ownerIds, byId).size > 0,
|
|
666
762
|
independentlyApproved: Boolean(
|
|
@@ -668,10 +764,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
|
|
|
668
764
|
&& partiesIndependent(record.ownerIds, record.approverIds, byId)
|
|
669
765
|
),
|
|
670
766
|
effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
|
|
671
|
-
contentComplete: substantiveMarkdown(source) && placeholderCount === 0
|
|
672
|
-
...(isSecurityIncidentRecoveryPlan
|
|
673
|
-
? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
|
|
674
|
-
: {})
|
|
767
|
+
contentComplete: substantiveMarkdown(source) && placeholderCount === 0
|
|
675
768
|
}
|
|
676
769
|
: {
|
|
677
770
|
active: record.status === "active",
|
|
@@ -684,6 +777,39 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
|
|
|
684
777
|
const missing = Object.entries(checks)
|
|
685
778
|
.filter(([, value]) => !value)
|
|
686
779
|
.map(([name]) => governedContentCheckLabel(name));
|
|
780
|
+
if (record.type === "document" && modelSupports(model, "governed-document-activation")) {
|
|
781
|
+
const activationComplete = Object.values(checks).every(Boolean);
|
|
782
|
+
const preActivationCheckNames = ["approvalBound", "owner", "requirementsImplemented", "contentComplete"];
|
|
783
|
+
const preActivationGapCount = preActivationCheckNames.filter((name) => !checks[name]).length;
|
|
784
|
+
const readyToActivate = record.status === "approved"
|
|
785
|
+
&& checks.approvalBound
|
|
786
|
+
&& checks.owner
|
|
787
|
+
&& checks.requirementsImplemented
|
|
788
|
+
&& checks.contentComplete;
|
|
789
|
+
const state = record.status === "active" && activationComplete
|
|
790
|
+
? "active-and-operating"
|
|
791
|
+
: record.status === "active"
|
|
792
|
+
? "active-with-gaps"
|
|
793
|
+
: readyToActivate
|
|
794
|
+
? "ready-to-activate"
|
|
795
|
+
: record.status === "approved"
|
|
796
|
+
? "approved-implementation-pending"
|
|
797
|
+
: "approval-pending";
|
|
798
|
+
documentActivations.push({
|
|
799
|
+
documentId: record.id,
|
|
800
|
+
title: record.title,
|
|
801
|
+
state,
|
|
802
|
+
label: documentActivationLabel(state),
|
|
803
|
+
approvedOn: record.approvedOn || null,
|
|
804
|
+
activatedOn: record.activatedOn || null,
|
|
805
|
+
effectiveOn: record.effectiveOn || null,
|
|
806
|
+
linkedControlIds,
|
|
807
|
+
missingImplementationControlIds,
|
|
808
|
+
activationRevisionBound: Boolean(record.activatedContentRevisions),
|
|
809
|
+
activatedByIds: record.activatedByIds || [],
|
|
810
|
+
gapCount: record.status === "active" ? missing.length : preActivationGapCount
|
|
811
|
+
});
|
|
812
|
+
}
|
|
687
813
|
items.push(item(
|
|
688
814
|
`${record.type}-${record.id}`,
|
|
689
815
|
missing.length ? "action" : "complete",
|
|
@@ -691,20 +817,15 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
|
|
|
691
817
|
missing.length
|
|
692
818
|
? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
|
|
693
819
|
: record.type === "document"
|
|
694
|
-
?
|
|
820
|
+
? modelSupports(model, "governed-document-activation")
|
|
821
|
+
? `Approved on ${record.approvedOn}, activated separately on ${record.activatedOn}, effective ${record.effectiveOn}, revision-bound at both events, and ready for operation.`
|
|
822
|
+
: `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
|
|
695
823
|
: `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
|
|
696
824
|
record,
|
|
697
825
|
{
|
|
698
826
|
checks,
|
|
699
827
|
placeholderCount,
|
|
700
|
-
...(
|
|
701
|
-
? {
|
|
702
|
-
continuityObjectiveSystemIds: systemsWithCompleteContinuityObjectives.map(({ id }) => id),
|
|
703
|
-
missingContinuityObjectiveSystemIds: scope.systems
|
|
704
|
-
.filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
|
|
705
|
-
.map(({ id }) => id)
|
|
706
|
-
}
|
|
707
|
-
: {}),
|
|
828
|
+
...(record.type === "document" ? { linkedControlIds, missingImplementationControlIds } : {}),
|
|
708
829
|
commands: [
|
|
709
830
|
`npx filegrc get ${shellArgument(record.id)} --mutation`,
|
|
710
831
|
`npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
|
|
@@ -713,12 +834,22 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
|
|
|
713
834
|
}
|
|
714
835
|
));
|
|
715
836
|
}
|
|
716
|
-
return items;
|
|
837
|
+
return { items, documentActivations };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function documentActivationLabel(state) {
|
|
841
|
+
return ({
|
|
842
|
+
"approval-pending": "Approval pending in Step 2",
|
|
843
|
+
"approved-implementation-pending": "Approved, implementation pending",
|
|
844
|
+
"ready-to-activate": "Ready to activate",
|
|
845
|
+
"active-with-gaps": "Active with activation or implementation gaps",
|
|
846
|
+
"active-and-operating": "Active and operating"
|
|
847
|
+
})[state] || state;
|
|
717
848
|
}
|
|
718
849
|
|
|
719
850
|
async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model) {
|
|
720
|
-
const sourceType =
|
|
721
|
-
const sourceField =
|
|
851
|
+
const sourceType = modelSupports(model, "component-sources") ? "component" : "system";
|
|
852
|
+
const sourceField = modelSupports(model, "component-sources") ? "evidenceSourceComponentIds" : "evidenceSourceIds";
|
|
722
853
|
const assessments = [];
|
|
723
854
|
for (const policy of policies.filter((record) => ["approved", "active"].includes(record.status))) {
|
|
724
855
|
const linkedControls = controls.filter((control) => (control.policyIds || []).includes(policy.id));
|
|
@@ -726,7 +857,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
|
|
|
726
857
|
const plannedOrPartialControlIds = linkedControls
|
|
727
858
|
.filter((control) => ["planned", "partially-implemented"].includes(control.status))
|
|
728
859
|
.map(({ id }) => id);
|
|
729
|
-
const missingComponentControlIds =
|
|
860
|
+
const missingComponentControlIds = modelSupports(model, "component-sources")
|
|
730
861
|
? linkedControls.filter((control) => ![
|
|
731
862
|
...(control.componentIds || []),
|
|
732
863
|
...(control.evidenceSourceComponentIds || [])
|
|
@@ -759,6 +890,18 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
|
|
|
759
890
|
&& (record.policyIds || []).includes(policy.id)
|
|
760
891
|
))
|
|
761
892
|
)).map(({ id }) => id);
|
|
893
|
+
const linkedGovernedDocumentIds = modelSupports(model, "governed-document-activation")
|
|
894
|
+
? (policy.relatedDocumentIds || []).filter((id) => {
|
|
895
|
+
const document = byId.get(id);
|
|
896
|
+
return document?.type === "document"
|
|
897
|
+
&& !["superseded", "retired"].includes(document.status)
|
|
898
|
+
&& !documentIsAuditSpecific(document, model);
|
|
899
|
+
})
|
|
900
|
+
: [];
|
|
901
|
+
const missingGovernedDocumentIds = linkedGovernedDocumentIds.filter((id) => {
|
|
902
|
+
const document = byId.get(id);
|
|
903
|
+
return !governedDocumentIsOperating(document, asOf, model);
|
|
904
|
+
});
|
|
762
905
|
const relevantExceptions = records.filter((record) => (
|
|
763
906
|
record.type === "exception"
|
|
764
907
|
&& (record.scopeResourceIds || []).some((id) => id === policy.id || linkedControlIds.includes(id))
|
|
@@ -788,6 +931,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
|
|
|
788
931
|
+ missingComponentControlIds.length
|
|
789
932
|
+ missingEvidenceSourceControlIds.length
|
|
790
933
|
+ missingScheduleControlIds.length
|
|
934
|
+
+ missingGovernedDocumentIds.length
|
|
791
935
|
+ unresolvedExceptionIds.length
|
|
792
936
|
+ timingWarnings.length;
|
|
793
937
|
const activeNow = policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf;
|
|
@@ -811,6 +955,8 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
|
|
|
811
955
|
missingComponentControlIds,
|
|
812
956
|
missingEvidenceSourceControlIds,
|
|
813
957
|
missingScheduleControlIds,
|
|
958
|
+
linkedGovernedDocumentIds,
|
|
959
|
+
missingGovernedDocumentIds,
|
|
814
960
|
unresolvedExceptionIds,
|
|
815
961
|
documentedExceptionIds,
|
|
816
962
|
timingWarnings,
|
|
@@ -841,6 +987,7 @@ function policyActivationItem(assessment) {
|
|
|
841
987
|
[assessment.missingComponentControlIds.length, "Controls missing active Components"],
|
|
842
988
|
[assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
|
|
843
989
|
[assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
|
|
990
|
+
[assessment.missingGovernedDocumentIds?.length || 0, "required governed Documents not active"],
|
|
844
991
|
[assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
|
|
845
992
|
].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
|
|
846
993
|
const message = assessment.state === "active-and-operating"
|
|
@@ -893,10 +1040,10 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
893
1040
|
for (const control of scope.controls) {
|
|
894
1041
|
const source = await readMarkdown(control);
|
|
895
1042
|
const sourceSystems = (
|
|
896
|
-
|
|
1043
|
+
modelSupports(model, "component-sources")
|
|
897
1044
|
? control.evidenceSourceComponentIds || []
|
|
898
1045
|
: control.evidenceSourceIds || []
|
|
899
|
-
).map((id) => byId.get(id)).filter((record) => record?.type === (
|
|
1046
|
+
).map((id) => byId.get(id)).filter((record) => record?.type === (modelSupports(model, "component-sources") ? "component" : "system"));
|
|
900
1047
|
const queueSchedules = [...byId.values()].filter((record) => (
|
|
901
1048
|
record.type === "obligation"
|
|
902
1049
|
&& record.status !== "retired"
|
|
@@ -951,20 +1098,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
951
1098
|
],
|
|
952
1099
|
workQueue: queueSchedules.length ? {
|
|
953
1100
|
enabled: queueSchedules.filter(obligationIsEnabled).length,
|
|
954
|
-
running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
|
|
1101
|
+
running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf, model)).length,
|
|
955
1102
|
total: queueSchedules.length
|
|
956
1103
|
} : null
|
|
957
1104
|
}
|
|
958
1105
|
));
|
|
959
1106
|
}
|
|
960
|
-
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 enabled. An enabled schedule stays dormant until its governing Policy
|
|
1107
|
+
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 enabled. An enabled schedule stays dormant until its governing Policy and required governed Documents are active and effective.", items);
|
|
961
1108
|
}
|
|
962
1109
|
|
|
963
1110
|
async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
964
1111
|
const families = selectedControlFamilies(scope.controls, model);
|
|
965
1112
|
const items = [];
|
|
966
1113
|
for (const family of families) {
|
|
967
|
-
const componentSources =
|
|
1114
|
+
const componentSources = modelSupports(model, "component-sources");
|
|
968
1115
|
const sourceField = componentSources ? "evidenceSourceComponentIds" : "evidenceSourceIds";
|
|
969
1116
|
const sourceType = componentSources ? "component" : "system";
|
|
970
1117
|
const selectedSources = [...new Set(family.controls.flatMap((control) => control[sourceField] || []))]
|
|
@@ -1056,7 +1203,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1056
1203
|
}
|
|
1057
1204
|
));
|
|
1058
1205
|
}
|
|
1059
|
-
return stage("sources", "Control Evidence Sources", `Complete the authoritative ${
|
|
1206
|
+
return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
|
|
1060
1207
|
}
|
|
1061
1208
|
|
|
1062
1209
|
function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
@@ -1271,14 +1418,31 @@ function policyCheckLabel(name) {
|
|
|
1271
1418
|
})[name] || name;
|
|
1272
1419
|
}
|
|
1273
1420
|
|
|
1421
|
+
function governedApprovalCheckLabel(name) {
|
|
1422
|
+
return ({
|
|
1423
|
+
independentlyApproved: "independent approval and approval date",
|
|
1424
|
+
owner: "current owner",
|
|
1425
|
+
linkedControls: "linked Controls",
|
|
1426
|
+
contentComplete: "intended values, content, and organization placeholders",
|
|
1427
|
+
systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
|
|
1428
|
+
})[name] || name;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1274
1431
|
function governedContentCheckLabel(name) {
|
|
1275
1432
|
return ({
|
|
1433
|
+
approvalBound: "Step 2 independent approval and approved revision",
|
|
1276
1434
|
active: "active status",
|
|
1277
1435
|
owner: "current owner",
|
|
1278
1436
|
independentlyApproved: "independent approval and approval date",
|
|
1279
1437
|
approved: "approval and approval date",
|
|
1280
1438
|
effective: "effective date",
|
|
1281
1439
|
effectiveContent: "effective content revision",
|
|
1440
|
+
requirementsImplemented: "implemented linked requirements",
|
|
1441
|
+
activationRecorded: "recorded activation basis",
|
|
1442
|
+
activated: "separate activation date",
|
|
1443
|
+
activator: "named activation Person",
|
|
1444
|
+
activatedContent: "separate activated content revision",
|
|
1445
|
+
activationMatchesApproval: "unchanged approved revision at activation",
|
|
1282
1446
|
contentComplete: "content and organization placeholders",
|
|
1283
1447
|
systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
|
|
1284
1448
|
})[name] || name;
|
package/src/program.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { modelSupports } from "../model/index.js";
|
|
2
|
+
|
|
1
3
|
export function resolveProgram(loaded, requestedId) {
|
|
2
|
-
if (
|
|
4
|
+
if (!modelSupports(loaded.model, "program-scope")) return loaded.workspace;
|
|
3
5
|
const programs = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
|
|
4
6
|
if (requestedId) {
|
|
5
7
|
const program = programs.find(({ id }) => id === requestedId);
|
|
@@ -33,7 +35,7 @@ export function resolveProgram(loaded, requestedId) {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function selectedRequirementIds(program, model) {
|
|
36
|
-
if (
|
|
38
|
+
if (modelSupports(model, "program-scope")) {
|
|
37
39
|
return (program.requirementApplicability || [])
|
|
38
40
|
.filter(({ decision }) => decision === "applicable")
|
|
39
41
|
.map(({ requirementId }) => requirementId);
|
|
@@ -42,7 +44,7 @@ export function selectedRequirementIds(program, model) {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export function programComponents(loaded, program) {
|
|
45
|
-
if (
|
|
47
|
+
if (!modelSupports(loaded.model, "program-scope")) return [];
|
|
46
48
|
const systemIds = new Set(program.systemIds || []);
|
|
47
49
|
return loaded.resources.filter((record) => (
|
|
48
50
|
record.type === "component"
|
package/src/reconciliation.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { modelSupports } from "../model/index.js";
|
|
5
6
|
import { createObligationEvent } from "./obligations.js";
|
|
6
7
|
import { markdownEntries } from "./resource-markdown.js";
|
|
7
8
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -94,13 +95,13 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
94
95
|
const loaded = input?.resources && input?.model && input?.entries
|
|
95
96
|
? input
|
|
96
97
|
: await loadWorkspace(input);
|
|
97
|
-
if (!
|
|
98
|
+
if (!modelSupports(loaded.model, "guided-workflow")) {
|
|
98
99
|
return {
|
|
99
100
|
contractVersion: 1,
|
|
100
101
|
gitRevision: gitRevision(loaded.root),
|
|
101
102
|
changedPaths: [],
|
|
102
103
|
candidates: [],
|
|
103
|
-
message: "Direct-file transition reconciliation is available in model v3 and
|
|
104
|
+
message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
|
|
104
105
|
};
|
|
105
106
|
}
|
|
106
107
|
const changedPaths = gitChangedPaths(loaded.root);
|