filegrc 0.6.5 → 0.7.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 +8 -0
- package/model/v4.json +179 -78
- package/package.json +2 -2
- package/src/audit-preparation.js +465 -48
- package/src/audit-transition.js +5 -0
- package/src/batch-review.js +14 -5
- package/src/cli.js +107 -0
- package/src/content-readiness.js +11 -0
- package/src/evidence-packet.js +202 -36
- package/src/files.js +58 -3
- package/src/git.js +39 -7
- package/src/index.js +7 -0
- package/src/policy-activation.js +84 -0
- package/src/policy-library/information-security-policy-v2.md +290 -0
- package/src/policy-library.js +826 -0
- package/src/program-lifecycle.js +4 -0
- package/src/program-path.js +12 -11
- package/src/program-readiness.js +309 -42
- package/src/server.js +8 -0
- package/src/setup.js +18 -1
- package/src/soc2.js +227 -0
- package/src/state.js +8 -0
- package/src/validate.js +43 -17
- package/src/web.js +218 -25
- package/src/workflow.js +111 -41
package/src/program-readiness.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { assessRequiredAppointments } from "./appointments.js";
|
|
3
3
|
import { assessCollectionReviews } from "./collection-review.js";
|
|
4
|
+
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
4
5
|
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
5
6
|
import { planObligations } from "./obligations.js";
|
|
6
7
|
import { resolveDataPath } from "./paths.js";
|
|
7
|
-
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
8
|
+
import { obligationIsEnabled, obligationIsRunning } from "./program-lifecycle.js";
|
|
8
9
|
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
10
|
+
import { assessPolicyLibraryUpgrades } from "./policy-library.js";
|
|
9
11
|
import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
|
|
10
12
|
import { markdownEntries } from "./resource-markdown.js";
|
|
13
|
+
import {
|
|
14
|
+
missingSoc2References,
|
|
15
|
+
REQUIRED_SOC2_DESCRIPTION_REFERENCES,
|
|
16
|
+
REQUIRED_SOC2_SECURITY_REFERENCES
|
|
17
|
+
} from "./soc2.js";
|
|
11
18
|
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
12
19
|
import { currentCalendarDate } from "./time.js";
|
|
13
20
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -30,13 +37,25 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
30
37
|
return markdown.get(record.id);
|
|
31
38
|
};
|
|
32
39
|
|
|
40
|
+
const policyStage = await policiesStage(scope, records, byId, readMarkdown);
|
|
33
41
|
const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
|
|
34
42
|
controlStage.items.unshift(...collectionReviews
|
|
35
43
|
.filter(({ resourceType }) => resourceType === "complementary-control")
|
|
36
44
|
.map(collectionReviewReadinessItem));
|
|
37
45
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
38
46
|
controlStage.items.push(...sourceStage.items);
|
|
39
|
-
controlStage.
|
|
47
|
+
controlStage.items.push(...await governedContentItems(scope, records, byId, readMarkdown, asOf));
|
|
48
|
+
const policyActivations = await assessPolicyActivations(
|
|
49
|
+
requiredPolicies(scope, byId),
|
|
50
|
+
scope.controls,
|
|
51
|
+
records,
|
|
52
|
+
byId,
|
|
53
|
+
readMarkdown,
|
|
54
|
+
asOf,
|
|
55
|
+
loaded.model
|
|
56
|
+
);
|
|
57
|
+
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 ${String(loaded.model.modelVersion) === "4" ? "Components" : "Systems"}. Activate approved Policies at the implementation cutover after reviewing each activation assessment.`;
|
|
40
59
|
const evidenceGateStages = [
|
|
41
60
|
scopeStage(
|
|
42
61
|
program,
|
|
@@ -46,7 +65,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
46
65
|
loaded.model,
|
|
47
66
|
collectionReviews.filter(({ resourceType }) => resourceType !== "complementary-control")
|
|
48
67
|
),
|
|
49
|
-
|
|
68
|
+
policyStage,
|
|
50
69
|
controlStage
|
|
51
70
|
];
|
|
52
71
|
for (const current of evidenceGateStages) finalizeStage(current);
|
|
@@ -62,6 +81,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
62
81
|
&& coverageStart(program.candidateCoverage) <= asOf
|
|
63
82
|
);
|
|
64
83
|
const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
|
|
84
|
+
const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
|
|
65
85
|
const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
|
|
66
86
|
const canStartCandidatePeriod = Boolean(
|
|
67
87
|
evidenceReady
|
|
@@ -89,6 +109,11 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
89
109
|
operating,
|
|
90
110
|
canStartCandidatePeriod,
|
|
91
111
|
suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
|
|
112
|
+
policyActivations,
|
|
113
|
+
policyLibraryProposals: [
|
|
114
|
+
...policyLibrary.proposals,
|
|
115
|
+
...legacyPolicyLibraryProposals(records)
|
|
116
|
+
],
|
|
92
117
|
progress: {
|
|
93
118
|
complete,
|
|
94
119
|
total: managedItems.length,
|
|
@@ -202,7 +227,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
202
227
|
const completeSystems = scope.systems.filter((system) => (
|
|
203
228
|
system.status === "active"
|
|
204
229
|
&& (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
|
|
205
|
-
&& system.classificationId
|
|
230
|
+
&& (String(model.modelVersion) === "4" || system.classificationId)
|
|
206
231
|
&& (system.ownerIds || []).length
|
|
207
232
|
));
|
|
208
233
|
items.push(item(
|
|
@@ -210,7 +235,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
210
235
|
scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
|
|
211
236
|
"Define the service boundary",
|
|
212
237
|
scope.systems.length
|
|
213
|
-
? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned,
|
|
238
|
+
? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${String(model.modelVersion) === "4" ? "" : ", with a classification"}.`
|
|
214
239
|
: "Select and describe every service and supporting system in the program boundary.",
|
|
215
240
|
scope.systems[0] || { type: "system" }
|
|
216
241
|
));
|
|
@@ -266,22 +291,69 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
266
291
|
&& (String(model.modelVersion) === "4" ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
|
|
267
292
|
));
|
|
268
293
|
const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
|
|
294
|
+
const selectedDescriptionRequirements = scope.requirements.filter(isDescriptionRequirement);
|
|
295
|
+
const selectedTrustServicesRequirements = scope.requirements.filter((requirement) => !isDescriptionRequirement(requirement));
|
|
296
|
+
const unresolvedDescriptionRequirements = unresolvedRequirements.filter(isDescriptionRequirement);
|
|
297
|
+
const unresolvedTrustServicesRequirements = unresolvedRequirements.filter((requirement) => !isDescriptionRequirement(requirement));
|
|
298
|
+
const uncoveredRequirements = scope.requirements.filter((requirement) => (
|
|
299
|
+
!isDescriptionRequirement(requirement)
|
|
300
|
+
&& !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
|
|
301
|
+
));
|
|
302
|
+
const enforceSoc2Baseline = String(model.modelVersion) === "4"
|
|
303
|
+
&& ["readiness", "soc-2-type-1", "soc-2-type-2"].includes(goal);
|
|
304
|
+
const selectedFrameworkRequirements = records.filter((record) => (
|
|
305
|
+
record.type === "requirement"
|
|
306
|
+
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
307
|
+
));
|
|
308
|
+
const securityRequirements = selectedFrameworkRequirements.filter(isSecurityRequirement);
|
|
309
|
+
const descriptionRequirements = selectedFrameworkRequirements.filter(isDescriptionRequirement);
|
|
310
|
+
const missingRequiredSecurityReferences = enforceSoc2Baseline
|
|
311
|
+
? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
312
|
+
: [];
|
|
313
|
+
const missingRequiredDescriptionReferences = enforceSoc2Baseline
|
|
314
|
+
? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
|
|
315
|
+
: [];
|
|
316
|
+
const mandatoryRequirements = enforceSoc2Baseline
|
|
317
|
+
? [...securityRequirements, ...descriptionRequirements].filter(({ reference }) => (
|
|
318
|
+
REQUIRED_SOC2_SECURITY_REFERENCES.includes(String(reference || "").toUpperCase())
|
|
319
|
+
|| REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(String(reference || "").toUpperCase())
|
|
320
|
+
))
|
|
321
|
+
: [];
|
|
322
|
+
const invalidMandatoryDecisions = mandatoryRequirements.filter((requirement) => (
|
|
323
|
+
v4Decisions.get(requirement.id) !== "applicable"
|
|
324
|
+
));
|
|
269
325
|
const criteriaComplete = Boolean(
|
|
270
326
|
scope.frameworks.length
|
|
271
327
|
&& scope.requirements.length
|
|
272
328
|
&& scope.controls.length
|
|
273
329
|
&& !unresolvedRequirements.length
|
|
274
330
|
&& !missingRequirements.length
|
|
331
|
+
&& !uncoveredRequirements.length
|
|
332
|
+
&& !missingRequiredSecurityReferences.length
|
|
333
|
+
&& !missingRequiredDescriptionReferences.length
|
|
334
|
+
&& !invalidMandatoryDecisions.length
|
|
275
335
|
);
|
|
276
336
|
items.push(item(
|
|
277
337
|
"criteria",
|
|
278
338
|
criteriaComplete ? "complete" : "action",
|
|
279
|
-
"Confirm criteria
|
|
339
|
+
"Confirm Trust Services criteria, Description Criteria, and Controls",
|
|
280
340
|
criteriaComplete
|
|
281
|
-
? `${
|
|
282
|
-
:
|
|
341
|
+
? `${selectedTrustServicesRequirements.length} applicable Trust Services criteria, ${selectedDescriptionRequirements.length} SOC 2 Description Criteria, and ${scope.controls.length} Controls are in scope. Every applicable Trust Services criterion has at least one selected Control; Description Criteria govern the system description and do not map to Controls.`
|
|
342
|
+
: missingRequiredSecurityReferences.length || missingRequiredDescriptionReferences.length
|
|
343
|
+
? `Use the complete SOC 2 baseline. The selected Frameworks omit ${[
|
|
344
|
+
...missingRequiredSecurityReferences,
|
|
345
|
+
...missingRequiredDescriptionReferences
|
|
346
|
+
].join(", ")}.`
|
|
347
|
+
: invalidMandatoryDecisions.length
|
|
348
|
+
? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision is" : "decisions are"} missing, undetermined, or not applicable.`
|
|
349
|
+
: `Resolve the program criteria and Controls. ${unresolvedTrustServicesRequirements.length} Trust Services applicability decisions and ${unresolvedDescriptionRequirements.length} Description Criteria decisions remain undetermined, ${missingRequirements.length} applicable criteria are not selected, and ${uncoveredRequirements.length} selected applicable Trust Services criteria have no selected Control. Description Criteria govern the system description and do not map to Controls.`,
|
|
283
350
|
workspace || { type: "workspace" },
|
|
284
351
|
{
|
|
352
|
+
unresolvedRequirementIds: unresolvedRequirements.map(({ id }) => id),
|
|
353
|
+
missingRequirementIds: missingRequirements.map(({ id }) => id),
|
|
354
|
+
uncoveredRequirementIds: uncoveredRequirements.map(({ id }) => id),
|
|
355
|
+
invalidMandatoryRequirementIds: invalidMandatoryDecisions.map(({ id }) => id),
|
|
356
|
+
missingRequiredReferences: [...missingRequiredSecurityReferences, ...missingRequiredDescriptionReferences],
|
|
285
357
|
commands: [
|
|
286
358
|
"npx filegrc review-applicability --scaffold --type requirement > decisions.json",
|
|
287
359
|
"npx filegrc review-applicability decisions.json --preview --json",
|
|
@@ -451,11 +523,8 @@ function ownershipResolutionReasons(ownerIds, byId) {
|
|
|
451
523
|
});
|
|
452
524
|
}
|
|
453
525
|
|
|
454
|
-
async function policiesStage(scope, records, byId, readMarkdown
|
|
455
|
-
const
|
|
456
|
-
const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
|
|
457
|
-
record?.type === "policy" && !["superseded", "retired"].includes(record.status)
|
|
458
|
-
));
|
|
526
|
+
async function policiesStage(scope, records, byId, readMarkdown) {
|
|
527
|
+
const policies = requiredPolicies(scope, byId);
|
|
459
528
|
const appointedReviewer = policies
|
|
460
529
|
.filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
|
|
461
530
|
.flatMap((policy) => [...currentPartyPeople(policy.approverIds || [], byId)])
|
|
@@ -515,11 +584,9 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
515
584
|
const source = await readMarkdown(policy);
|
|
516
585
|
const placeholderCount = openPlaceholderCount(source);
|
|
517
586
|
const checks = {
|
|
518
|
-
reviewed: ["in-review", "approved", "active"].includes(policy.status),
|
|
519
587
|
independentlyApproved: ["approved", "active"].includes(policy.status)
|
|
520
588
|
&& policy.approvedOn
|
|
521
589
|
&& partiesIndependent(policy.ownerIds, policy.approverIds, byId),
|
|
522
|
-
effective: policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf,
|
|
523
590
|
linkedControls: scope.controls.some((control) => (control.policyIds || []).includes(policy.id)),
|
|
524
591
|
contentComplete: Boolean(source.trim()) && placeholderCount === 0
|
|
525
592
|
};
|
|
@@ -529,8 +596,8 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
529
596
|
missing.length ? "action" : "complete",
|
|
530
597
|
policy.title,
|
|
531
598
|
missing.length
|
|
532
|
-
? `Remaining
|
|
533
|
-
: `
|
|
599
|
+
? `Remaining approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Approval accepts the policy requirements; it does not assert Control implementation.`
|
|
600
|
+
: `Independently approved on ${policy.approvedOn}, bound to the approved content revision, linked to Controls, and free of open organization placeholders. Activation remains in Step 3.`,
|
|
534
601
|
policy,
|
|
535
602
|
{
|
|
536
603
|
checks,
|
|
@@ -543,11 +610,29 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
543
610
|
}
|
|
544
611
|
));
|
|
545
612
|
}
|
|
613
|
+
return stage(
|
|
614
|
+
"policies",
|
|
615
|
+
"Approve Policies",
|
|
616
|
+
"A Policy says what the company commits to do by the date it takes effect. Approval means the company accepts those commitments. It does not prove the work is done. Controls and operating records describe how the company meets them and provide the proof.",
|
|
617
|
+
items
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function requiredPolicies(scope, byId) {
|
|
622
|
+
const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
|
|
623
|
+
return [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
|
|
624
|
+
record?.type === "policy"
|
|
625
|
+
&& record.programRole !== "conditional"
|
|
626
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
627
|
+
));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
|
|
546
631
|
const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
|
|
547
|
-
const
|
|
548
|
-
record.type === "obligation" &&
|
|
632
|
+
const enabledObligations = records.filter((record) => (
|
|
633
|
+
record.type === "obligation" && obligationIsEnabled(record)
|
|
549
634
|
));
|
|
550
|
-
const requiredGovernedIds = new Set(
|
|
635
|
+
const requiredGovernedIds = new Set(enabledObligations.flatMap((record) => [
|
|
551
636
|
...(record.scopeResourceIds || []),
|
|
552
637
|
...(record.templateResourceId ? [record.templateResourceId] : [])
|
|
553
638
|
]));
|
|
@@ -564,9 +649,16 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
564
649
|
)
|
|
565
650
|
|| (record.type === "training" && requiredGovernedIds.has(record.id))
|
|
566
651
|
));
|
|
652
|
+
const items = [];
|
|
567
653
|
for (const record of governedRecords) {
|
|
568
654
|
const source = await readMarkdown(record);
|
|
569
655
|
const placeholderCount = openPlaceholderCount(source);
|
|
656
|
+
const isSecurityIncidentRecoveryPlan = record.id === "document-security-incident-recovery-plan";
|
|
657
|
+
const systemsWithCompleteContinuityObjectives = scope.systems.filter(({ continuityObjectives }) => (
|
|
658
|
+
Number.isInteger(continuityObjectives?.recoveryTimeHours)
|
|
659
|
+
&& Number.isInteger(continuityObjectives?.recoveryPointHours)
|
|
660
|
+
&& Number.isInteger(continuityObjectives?.maximumTolerableDowntimeHours)
|
|
661
|
+
));
|
|
570
662
|
const checks = record.type === "document"
|
|
571
663
|
? {
|
|
572
664
|
active: record.status === "active",
|
|
@@ -576,7 +668,10 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
576
668
|
&& partiesIndependent(record.ownerIds, record.approverIds, byId)
|
|
577
669
|
),
|
|
578
670
|
effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
|
|
579
|
-
contentComplete: substantiveMarkdown(source) && placeholderCount === 0
|
|
671
|
+
contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
|
|
672
|
+
...(isSecurityIncidentRecoveryPlan
|
|
673
|
+
? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
|
|
674
|
+
: {})
|
|
580
675
|
}
|
|
581
676
|
: {
|
|
582
677
|
active: record.status === "active",
|
|
@@ -596,12 +691,20 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
596
691
|
missing.length
|
|
597
692
|
? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
|
|
598
693
|
: record.type === "document"
|
|
599
|
-
? `Active,
|
|
694
|
+
? `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
|
|
600
695
|
: `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
|
|
601
696
|
record,
|
|
602
697
|
{
|
|
603
698
|
checks,
|
|
604
699
|
placeholderCount,
|
|
700
|
+
...(isSecurityIncidentRecoveryPlan
|
|
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
|
+
: {}),
|
|
605
708
|
commands: [
|
|
606
709
|
`npx filegrc get ${shellArgument(record.id)} --mutation`,
|
|
607
710
|
`npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
|
|
@@ -610,14 +713,178 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
610
713
|
}
|
|
611
714
|
));
|
|
612
715
|
}
|
|
613
|
-
return
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
716
|
+
return items;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model) {
|
|
720
|
+
const sourceType = String(model.modelVersion) === "4" ? "component" : "system";
|
|
721
|
+
const sourceField = String(model.modelVersion) === "4" ? "evidenceSourceComponentIds" : "evidenceSourceIds";
|
|
722
|
+
const assessments = [];
|
|
723
|
+
for (const policy of policies.filter((record) => ["approved", "active"].includes(record.status))) {
|
|
724
|
+
const linkedControls = controls.filter((control) => (control.policyIds || []).includes(policy.id));
|
|
725
|
+
const linkedControlIds = linkedControls.map(({ id }) => id);
|
|
726
|
+
const plannedOrPartialControlIds = linkedControls
|
|
727
|
+
.filter((control) => ["planned", "partially-implemented"].includes(control.status))
|
|
728
|
+
.map(({ id }) => id);
|
|
729
|
+
const missingComponentControlIds = String(model.modelVersion) === "4"
|
|
730
|
+
? linkedControls.filter((control) => ![
|
|
731
|
+
...(control.componentIds || []),
|
|
732
|
+
...(control.evidenceSourceComponentIds || [])
|
|
733
|
+
].some((id) => (
|
|
734
|
+
byId.get(id)?.type === "component" && byId.get(id).status === "active"
|
|
735
|
+
))).map(({ id }) => id)
|
|
736
|
+
: [];
|
|
737
|
+
const missingEvidenceSourceControlIds = [];
|
|
738
|
+
for (const control of linkedControls) {
|
|
739
|
+
const readySources = [];
|
|
740
|
+
for (const id of control[sourceField] || []) {
|
|
741
|
+
const source = byId.get(id);
|
|
742
|
+
if (source?.type !== sourceType || source.status !== "active") continue;
|
|
743
|
+
const instructions = await readMarkdown(source);
|
|
744
|
+
if (
|
|
745
|
+
(source.evidenceSourceKinds || []).length
|
|
746
|
+
&& (source.evidenceOwnerIds || []).length
|
|
747
|
+
&& substantiveMarkdown(instructions)
|
|
748
|
+
&& openPlaceholderCount(instructions) === 0
|
|
749
|
+
) readySources.push(source);
|
|
750
|
+
}
|
|
751
|
+
if (!readySources.length) missingEvidenceSourceControlIds.push(control.id);
|
|
752
|
+
}
|
|
753
|
+
const missingScheduleControlIds = linkedControls.filter((control) => (
|
|
754
|
+
["scheduled", "event-driven", "mixed"].includes(control.operationPattern)
|
|
755
|
+
&& !records.some((record) => (
|
|
756
|
+
record.type === "obligation"
|
|
757
|
+
&& obligationIsEnabled(record)
|
|
758
|
+
&& (record.controlIds || []).includes(control.id)
|
|
759
|
+
&& (record.policyIds || []).includes(policy.id)
|
|
760
|
+
))
|
|
761
|
+
)).map(({ id }) => id);
|
|
762
|
+
const relevantExceptions = records.filter((record) => (
|
|
763
|
+
record.type === "exception"
|
|
764
|
+
&& (record.scopeResourceIds || []).some((id) => id === policy.id || linkedControlIds.includes(id))
|
|
765
|
+
&& !["revoked", "closed"].includes(record.status)
|
|
766
|
+
));
|
|
767
|
+
const unresolvedExceptionIds = relevantExceptions.filter((record) => (
|
|
768
|
+
record.status !== "approved"
|
|
769
|
+
|| !record.approval?.expiresOn
|
|
770
|
+
|| record.approval.expiresOn < asOf
|
|
771
|
+
)).map(({ id }) => id);
|
|
772
|
+
const documentedExceptionIds = relevantExceptions.filter((record) => (
|
|
773
|
+
record.status === "approved"
|
|
774
|
+
&& record.approval?.expiresOn
|
|
775
|
+
&& record.approval.expiresOn >= asOf
|
|
776
|
+
)).map(({ id }) => id);
|
|
777
|
+
const timingWarnings = [
|
|
778
|
+
policy.proposedEffectiveOn && policy.proposedEffectiveOn < asOf
|
|
779
|
+
? `The proposed effective date ${policy.proposedEffectiveOn} has passed. Choose a current or future activation date; do not backdate adoption.`
|
|
780
|
+
: null,
|
|
781
|
+
policy.status === "active" && (!policy.effectiveOn || policy.effectiveOn > asOf)
|
|
782
|
+
? policy.effectiveOn
|
|
783
|
+
? `The Policy is marked active but does not become effective until ${policy.effectiveOn}. Governed Obligations remain dormant until then.`
|
|
784
|
+
: "The Policy is marked active without an effective date."
|
|
785
|
+
: null
|
|
786
|
+
].filter(Boolean);
|
|
787
|
+
const gapCount = plannedOrPartialControlIds.length
|
|
788
|
+
+ missingComponentControlIds.length
|
|
789
|
+
+ missingEvidenceSourceControlIds.length
|
|
790
|
+
+ missingScheduleControlIds.length
|
|
791
|
+
+ unresolvedExceptionIds.length
|
|
792
|
+
+ timingWarnings.length;
|
|
793
|
+
const activeNow = policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf;
|
|
794
|
+
const state = activeNow
|
|
795
|
+
? gapCount ? "active-with-implementation-gaps" : "active-and-operating"
|
|
796
|
+
: policy.status === "approved" && gapCount === 0
|
|
797
|
+
? "ready-to-activate"
|
|
798
|
+
: policy.status === "approved"
|
|
799
|
+
? "approved-implementation-pending"
|
|
800
|
+
: "active-with-implementation-gaps";
|
|
801
|
+
assessments.push({
|
|
802
|
+
policyId: policy.id,
|
|
803
|
+
title: policy.title,
|
|
804
|
+
state,
|
|
805
|
+
label: policyActivationLabel(state),
|
|
806
|
+
approvedOn: policy.approvedOn || null,
|
|
807
|
+
effectiveOn: policy.effectiveOn || null,
|
|
808
|
+
proposedEffectiveOn: policy.proposedEffectiveOn || null,
|
|
809
|
+
linkedControlIds,
|
|
810
|
+
plannedOrPartialControlIds,
|
|
811
|
+
missingComponentControlIds,
|
|
812
|
+
missingEvidenceSourceControlIds,
|
|
813
|
+
missingScheduleControlIds,
|
|
814
|
+
unresolvedExceptionIds,
|
|
815
|
+
documentedExceptionIds,
|
|
816
|
+
timingWarnings,
|
|
817
|
+
gapCount,
|
|
818
|
+
canActivateWithDocumentedGaps: policy.status === "approved",
|
|
819
|
+
activationWarning: gapCount
|
|
820
|
+
? policy.status === "approved"
|
|
821
|
+
? "You can activate the Policy with a documented gap or approved Exception. Activation does not mark a Control implemented, and Evidence Readiness stays incomplete until the remaining work is done."
|
|
822
|
+
: "The Policy is active, but the remaining gaps keep Evidence Readiness incomplete. Resolve them or record the applicable time-bound Exception."
|
|
823
|
+
: null
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
return assessments;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function policyActivationLabel(state) {
|
|
830
|
+
return ({
|
|
831
|
+
"approved-implementation-pending": "Approved, implementation pending",
|
|
832
|
+
"ready-to-activate": "Ready to activate",
|
|
833
|
+
"active-with-implementation-gaps": "Active with implementation gaps",
|
|
834
|
+
"active-and-operating": "Active and operating"
|
|
835
|
+
})[state] || state;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function policyActivationItem(assessment) {
|
|
839
|
+
const counts = [
|
|
840
|
+
[assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
|
|
841
|
+
[assessment.missingComponentControlIds.length, "Controls missing active Components"],
|
|
842
|
+
[assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
|
|
843
|
+
[assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
|
|
844
|
+
[assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
|
|
845
|
+
].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
|
|
846
|
+
const message = assessment.state === "active-and-operating"
|
|
847
|
+
? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled schedules.`
|
|
848
|
+
: assessment.state === "ready-to-activate"
|
|
849
|
+
? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
|
|
850
|
+
: `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
|
|
851
|
+
return item(
|
|
852
|
+
`policy-activation-${assessment.policyId}`,
|
|
853
|
+
assessment.state === "active-and-operating" ? "complete" : "action",
|
|
854
|
+
`${assessment.title}: ${assessment.label}`,
|
|
855
|
+
message,
|
|
856
|
+
{ type: "policy", id: assessment.policyId },
|
|
857
|
+
{
|
|
858
|
+
activationAssessment: assessment,
|
|
859
|
+
commands: [
|
|
860
|
+
"npx filegrc activate-policies --scaffold > policy-activation.json",
|
|
861
|
+
"npx filegrc activate-policies policy-activation.json --preview --json",
|
|
862
|
+
"npx filegrc program-readiness --json"
|
|
863
|
+
]
|
|
864
|
+
}
|
|
618
865
|
);
|
|
619
866
|
}
|
|
620
867
|
|
|
868
|
+
function legacyPolicyLibraryProposals(records) {
|
|
869
|
+
const legacyIds = [
|
|
870
|
+
"policy-anti-bribery-corruption",
|
|
871
|
+
"policy-clear-desk-screen",
|
|
872
|
+
"policy-data-protection-handling",
|
|
873
|
+
"policy-employee-handbook",
|
|
874
|
+
"policy-endpoint-remote-work",
|
|
875
|
+
"policy-mobile-computing-communications"
|
|
876
|
+
];
|
|
877
|
+
const presentIds = legacyIds.filter((id) => records.some((record) => record.type === "policy" && record.id === id));
|
|
878
|
+
if (!presentIds.length) return [];
|
|
879
|
+
return [{
|
|
880
|
+
id: "consolidate-soc2-security-policy",
|
|
881
|
+
title: "Review the minimal SOC 2 Security Policy consolidation",
|
|
882
|
+
policyIds: presentIds,
|
|
883
|
+
status: "review",
|
|
884
|
+
message: "New Security-core workspaces use one Information Security Policy. Review a proposed replacement and Control remapping before superseding absorbed Policies. Keep employment, anti-bribery, privacy, or other broader records outside the SOC 2 Security scope when the organization still uses them. FileGRC never rewrites established content during an upgrade."
|
|
885
|
+
}];
|
|
886
|
+
}
|
|
887
|
+
|
|
621
888
|
async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
622
889
|
const items = [];
|
|
623
890
|
if (!scope.controls.length) {
|
|
@@ -659,7 +926,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
659
926
|
criteriaMapping: (control.requirementIds || []).length > 0,
|
|
660
927
|
...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
|
|
661
928
|
workQueue: queueSchedules.length > 0
|
|
662
|
-
&& queueSchedules.
|
|
929
|
+
&& queueSchedules.some(obligationIsEnabled)
|
|
663
930
|
} : {})
|
|
664
931
|
};
|
|
665
932
|
const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
|
|
@@ -683,13 +950,14 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
683
950
|
`npx filegrc get ${shellArgument(control.id)} --mutation`
|
|
684
951
|
],
|
|
685
952
|
workQueue: queueSchedules.length ? {
|
|
953
|
+
enabled: queueSchedules.filter(obligationIsEnabled).length,
|
|
686
954
|
running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
|
|
687
955
|
total: queueSchedules.length
|
|
688
956
|
} : null
|
|
689
957
|
}
|
|
690
958
|
));
|
|
691
959
|
}
|
|
692
|
-
return stage("controls", "Implement Controls", "Each implemented
|
|
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 is active and effective.", items);
|
|
693
961
|
}
|
|
694
962
|
|
|
695
963
|
async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
@@ -993,18 +1261,6 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
|
|
|
993
1261
|
return ids;
|
|
994
1262
|
}
|
|
995
1263
|
|
|
996
|
-
function openPlaceholderCount(source) {
|
|
997
|
-
if (!source) return 0;
|
|
998
|
-
const matches = source.match(
|
|
999
|
-
/\{\{[^}\n]+\}\}|\b(?:TODO|TBD)\b|\[(?:complete|confirm|describe|insert|name|replace|select|specify|todo|tbd)[^\]\n]*\]/giu
|
|
1000
|
-
);
|
|
1001
|
-
return matches?.length || 0;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function substantiveMarkdown(source) {
|
|
1005
|
-
return (source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length >= 10;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
1264
|
function policyCheckLabel(name) {
|
|
1009
1265
|
return ({
|
|
1010
1266
|
reviewed: "draft review",
|
|
@@ -1023,7 +1279,8 @@ function governedContentCheckLabel(name) {
|
|
|
1023
1279
|
approved: "approval and approval date",
|
|
1024
1280
|
effective: "effective date",
|
|
1025
1281
|
effectiveContent: "effective content revision",
|
|
1026
|
-
contentComplete: "content and organization placeholders"
|
|
1282
|
+
contentComplete: "content and organization placeholders",
|
|
1283
|
+
systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
|
|
1027
1284
|
})[name] || name;
|
|
1028
1285
|
}
|
|
1029
1286
|
|
|
@@ -1053,6 +1310,16 @@ function assuranceGoalLabel(goal) {
|
|
|
1053
1310
|
return "No assurance goal selected";
|
|
1054
1311
|
}
|
|
1055
1312
|
|
|
1313
|
+
function isDescriptionRequirement(requirement) {
|
|
1314
|
+
return (requirement?.tags || []).includes("description-criteria")
|
|
1315
|
+
|| /^DC\d+/i.test(requirement?.reference || "");
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function isSecurityRequirement(requirement) {
|
|
1319
|
+
const tags = requirement?.tags || [];
|
|
1320
|
+
return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1056
1323
|
function stage(id, title, description, items) {
|
|
1057
1324
|
return { id, title, description, items };
|
|
1058
1325
|
}
|
package/src/server.js
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
setupExternalReviewerGovernance
|
|
44
44
|
} from "./external-reviewer.js";
|
|
45
45
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
46
|
+
import { activatePolicies } from "./policy-activation.js";
|
|
46
47
|
import { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
47
48
|
import { createAppState, createResourceDetail } from "./state.js";
|
|
48
49
|
import { setupWorkspace } from "./setup.js";
|
|
@@ -322,6 +323,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
322
323
|
};
|
|
323
324
|
return json(response, 200, await completeSetup());
|
|
324
325
|
}
|
|
326
|
+
if (request.method === "POST" && url.pathname === "/api/policy-activations") {
|
|
327
|
+
const payload = await readJson(request);
|
|
328
|
+
const result = await browserMutation(input, options, {
|
|
329
|
+
message: (activation) => `Activate ${activation.policyIds.length} ${activation.policyIds.length === 1 ? "Policy" : "Policies"}`
|
|
330
|
+
}, () => activatePolicies(input, { ...payload, confirmed: true }));
|
|
331
|
+
return json(response, 200, result);
|
|
332
|
+
}
|
|
325
333
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
326
334
|
const payload = normalizeResourceMutation(await readJson(request));
|
|
327
335
|
const { record } = payload;
|
package/src/setup.js
CHANGED
|
@@ -9,6 +9,7 @@ const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
|
|
|
9
9
|
export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
10
10
|
const loaded = await loadWorkspace(input);
|
|
11
11
|
const setup = normalizeSetupPayload(payload);
|
|
12
|
+
setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
|
|
12
13
|
validateSetup(loaded, setup);
|
|
13
14
|
const plan = buildSetupRecords(loaded, setup);
|
|
14
15
|
const updates = [
|
|
@@ -44,6 +45,7 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
44
45
|
export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
|
|
45
46
|
const loaded = await loadWorkspace(input);
|
|
46
47
|
const setup = normalizeSetupPayload(payload);
|
|
48
|
+
setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
|
|
47
49
|
validateSetup(loaded, setup);
|
|
48
50
|
const plan = buildSetupRecords(loaded, setup);
|
|
49
51
|
return {
|
|
@@ -137,6 +139,21 @@ function validateSetup(loaded, setup) {
|
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
function resolveClassificationId(loaded, value) {
|
|
143
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
144
|
+
if (!normalized) return value;
|
|
145
|
+
const candidates = String(loaded.model.modelVersion) === "4"
|
|
146
|
+
? loaded.resources
|
|
147
|
+
.filter(({ type, status }) => type === "classification" && status === "active")
|
|
148
|
+
.map(({ id, title }) => ({ id, label: title }))
|
|
149
|
+
: Object.entries(loaded.workspace.classificationDefinitions || {})
|
|
150
|
+
.map(([id, label]) => ({ id, label }));
|
|
151
|
+
const matches = candidates.filter(({ id, label }) => (
|
|
152
|
+
id.toLowerCase() === normalized || String(label || "").trim().toLowerCase() === normalized
|
|
153
|
+
));
|
|
154
|
+
return matches.length === 1 ? matches[0].id : value;
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
function findSetupSystem(resources, target, setup) {
|
|
141
158
|
const scopedSystemIds = new Set(target.systemIds || []);
|
|
142
159
|
return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
|
|
@@ -227,7 +244,7 @@ function buildSetupRecords(loaded, setup) {
|
|
|
227
244
|
title: `${setup.serviceName} service commitment`,
|
|
228
245
|
status: "planned",
|
|
229
246
|
commitmentKind: "service",
|
|
230
|
-
statement: "
|
|
247
|
+
statement: "[Complete before activation: State the actual customer promise or approved service requirement.]",
|
|
231
248
|
systemIds: [systemId],
|
|
232
249
|
ownerIds: [setup.ownerId],
|
|
233
250
|
customerFacing: true,
|