filegrc 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/v4.json +82 -72
- package/package.json +1 -1
- package/src/batch-review.js +142 -17
- package/src/cli.js +66 -2
- package/src/collection-review.js +15 -26
- package/src/collection-revision.js +83 -0
- package/src/collection-scope.js +160 -2
- package/src/content-readiness.js +11 -0
- package/src/files.js +5 -0
- package/src/git.js +54 -5
- package/src/index.js +1 -0
- package/src/policy-activation.js +84 -0
- package/src/program-lifecycle.js +4 -0
- package/src/program-path.js +11 -10
- package/src/program-readiness.js +237 -37
- package/src/reconciliation.js +3 -1
- package/src/server.js +12 -3
- package/src/validate.js +35 -20
- package/src/web.js +150 -23
- package/src/workflow.js +27 -1
package/src/program-readiness.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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";
|
|
9
10
|
import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
|
|
10
11
|
import { markdownEntries } from "./resource-markdown.js";
|
|
@@ -30,13 +31,25 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
30
31
|
return markdown.get(record.id);
|
|
31
32
|
};
|
|
32
33
|
|
|
34
|
+
const policyStage = await policiesStage(scope, records, byId, readMarkdown);
|
|
33
35
|
const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
|
|
34
36
|
controlStage.items.unshift(...collectionReviews
|
|
35
37
|
.filter(({ resourceType }) => resourceType === "complementary-control")
|
|
36
38
|
.map(collectionReviewReadinessItem));
|
|
37
39
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
38
40
|
controlStage.items.push(...sourceStage.items);
|
|
39
|
-
controlStage.
|
|
41
|
+
controlStage.items.push(...await governedContentItems(scope, records, byId, readMarkdown, asOf));
|
|
42
|
+
const policyActivations = await assessPolicyActivations(
|
|
43
|
+
requiredPolicies(scope, byId),
|
|
44
|
+
scope.controls,
|
|
45
|
+
records,
|
|
46
|
+
byId,
|
|
47
|
+
readMarkdown,
|
|
48
|
+
asOf,
|
|
49
|
+
loaded.model
|
|
50
|
+
);
|
|
51
|
+
controlStage.items.push(...policyActivations.map(policyActivationItem));
|
|
52
|
+
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
53
|
const evidenceGateStages = [
|
|
41
54
|
scopeStage(
|
|
42
55
|
program,
|
|
@@ -46,7 +59,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
46
59
|
loaded.model,
|
|
47
60
|
collectionReviews.filter(({ resourceType }) => resourceType !== "complementary-control")
|
|
48
61
|
),
|
|
49
|
-
|
|
62
|
+
policyStage,
|
|
50
63
|
controlStage
|
|
51
64
|
];
|
|
52
65
|
for (const current of evidenceGateStages) finalizeStage(current);
|
|
@@ -89,6 +102,8 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
89
102
|
operating,
|
|
90
103
|
canStartCandidatePeriod,
|
|
91
104
|
suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
|
|
105
|
+
policyActivations,
|
|
106
|
+
policyLibraryProposals: policyLibraryProposals(records),
|
|
92
107
|
progress: {
|
|
93
108
|
complete,
|
|
94
109
|
total: managedItems.length,
|
|
@@ -451,11 +466,8 @@ function ownershipResolutionReasons(ownerIds, byId) {
|
|
|
451
466
|
});
|
|
452
467
|
}
|
|
453
468
|
|
|
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
|
-
));
|
|
469
|
+
async function policiesStage(scope, records, byId, readMarkdown) {
|
|
470
|
+
const policies = requiredPolicies(scope, byId);
|
|
459
471
|
const appointedReviewer = policies
|
|
460
472
|
.filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
|
|
461
473
|
.flatMap((policy) => [...currentPartyPeople(policy.approverIds || [], byId)])
|
|
@@ -515,11 +527,9 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
515
527
|
const source = await readMarkdown(policy);
|
|
516
528
|
const placeholderCount = openPlaceholderCount(source);
|
|
517
529
|
const checks = {
|
|
518
|
-
reviewed: ["in-review", "approved", "active"].includes(policy.status),
|
|
519
530
|
independentlyApproved: ["approved", "active"].includes(policy.status)
|
|
520
531
|
&& policy.approvedOn
|
|
521
532
|
&& partiesIndependent(policy.ownerIds, policy.approverIds, byId),
|
|
522
|
-
effective: policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf,
|
|
523
533
|
linkedControls: scope.controls.some((control) => (control.policyIds || []).includes(policy.id)),
|
|
524
534
|
contentComplete: Boolean(source.trim()) && placeholderCount === 0
|
|
525
535
|
};
|
|
@@ -529,8 +539,8 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
529
539
|
missing.length ? "action" : "complete",
|
|
530
540
|
policy.title,
|
|
531
541
|
missing.length
|
|
532
|
-
? `Remaining
|
|
533
|
-
: `
|
|
542
|
+
? `Remaining approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Approval accepts the policy requirements; it does not assert Control implementation.`
|
|
543
|
+
: `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
544
|
policy,
|
|
535
545
|
{
|
|
536
546
|
checks,
|
|
@@ -543,11 +553,29 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
543
553
|
}
|
|
544
554
|
));
|
|
545
555
|
}
|
|
556
|
+
return stage(
|
|
557
|
+
"policies",
|
|
558
|
+
"Approve Policies",
|
|
559
|
+
"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.",
|
|
560
|
+
items
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function requiredPolicies(scope, byId) {
|
|
565
|
+
const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
|
|
566
|
+
return [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
|
|
567
|
+
record?.type === "policy"
|
|
568
|
+
&& record.programRole !== "conditional"
|
|
569
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
570
|
+
));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
|
|
546
574
|
const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
|
|
547
|
-
const
|
|
548
|
-
record.type === "obligation" &&
|
|
575
|
+
const enabledObligations = records.filter((record) => (
|
|
576
|
+
record.type === "obligation" && obligationIsEnabled(record)
|
|
549
577
|
));
|
|
550
|
-
const requiredGovernedIds = new Set(
|
|
578
|
+
const requiredGovernedIds = new Set(enabledObligations.flatMap((record) => [
|
|
551
579
|
...(record.scopeResourceIds || []),
|
|
552
580
|
...(record.templateResourceId ? [record.templateResourceId] : [])
|
|
553
581
|
]));
|
|
@@ -564,9 +592,16 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
564
592
|
)
|
|
565
593
|
|| (record.type === "training" && requiredGovernedIds.has(record.id))
|
|
566
594
|
));
|
|
595
|
+
const items = [];
|
|
567
596
|
for (const record of governedRecords) {
|
|
568
597
|
const source = await readMarkdown(record);
|
|
569
598
|
const placeholderCount = openPlaceholderCount(source);
|
|
599
|
+
const isSecurityIncidentRecoveryPlan = record.id === "document-security-incident-recovery-plan";
|
|
600
|
+
const systemsWithCompleteContinuityObjectives = scope.systems.filter(({ continuityObjectives }) => (
|
|
601
|
+
Number.isInteger(continuityObjectives?.recoveryTimeHours)
|
|
602
|
+
&& Number.isInteger(continuityObjectives?.recoveryPointHours)
|
|
603
|
+
&& Number.isInteger(continuityObjectives?.maximumTolerableDowntimeHours)
|
|
604
|
+
));
|
|
570
605
|
const checks = record.type === "document"
|
|
571
606
|
? {
|
|
572
607
|
active: record.status === "active",
|
|
@@ -576,7 +611,10 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
576
611
|
&& partiesIndependent(record.ownerIds, record.approverIds, byId)
|
|
577
612
|
),
|
|
578
613
|
effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
|
|
579
|
-
contentComplete: substantiveMarkdown(source) && placeholderCount === 0
|
|
614
|
+
contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
|
|
615
|
+
...(isSecurityIncidentRecoveryPlan
|
|
616
|
+
? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
|
|
617
|
+
: {})
|
|
580
618
|
}
|
|
581
619
|
: {
|
|
582
620
|
active: record.status === "active",
|
|
@@ -596,12 +634,20 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
596
634
|
missing.length
|
|
597
635
|
? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
|
|
598
636
|
: record.type === "document"
|
|
599
|
-
? `Active,
|
|
637
|
+
? `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
|
|
600
638
|
: `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
|
|
601
639
|
record,
|
|
602
640
|
{
|
|
603
641
|
checks,
|
|
604
642
|
placeholderCount,
|
|
643
|
+
...(isSecurityIncidentRecoveryPlan
|
|
644
|
+
? {
|
|
645
|
+
continuityObjectiveSystemIds: systemsWithCompleteContinuityObjectives.map(({ id }) => id),
|
|
646
|
+
missingContinuityObjectiveSystemIds: scope.systems
|
|
647
|
+
.filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
|
|
648
|
+
.map(({ id }) => id)
|
|
649
|
+
}
|
|
650
|
+
: {}),
|
|
605
651
|
commands: [
|
|
606
652
|
`npx filegrc get ${shellArgument(record.id)} --mutation`,
|
|
607
653
|
`npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
|
|
@@ -610,14 +656,178 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
|
|
|
610
656
|
}
|
|
611
657
|
));
|
|
612
658
|
}
|
|
613
|
-
return
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
659
|
+
return items;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model) {
|
|
663
|
+
const sourceType = String(model.modelVersion) === "4" ? "component" : "system";
|
|
664
|
+
const sourceField = String(model.modelVersion) === "4" ? "evidenceSourceComponentIds" : "evidenceSourceIds";
|
|
665
|
+
const assessments = [];
|
|
666
|
+
for (const policy of policies.filter((record) => ["approved", "active"].includes(record.status))) {
|
|
667
|
+
const linkedControls = controls.filter((control) => (control.policyIds || []).includes(policy.id));
|
|
668
|
+
const linkedControlIds = linkedControls.map(({ id }) => id);
|
|
669
|
+
const plannedOrPartialControlIds = linkedControls
|
|
670
|
+
.filter((control) => ["planned", "partially-implemented"].includes(control.status))
|
|
671
|
+
.map(({ id }) => id);
|
|
672
|
+
const missingComponentControlIds = String(model.modelVersion) === "4"
|
|
673
|
+
? linkedControls.filter((control) => ![
|
|
674
|
+
...(control.componentIds || []),
|
|
675
|
+
...(control.evidenceSourceComponentIds || [])
|
|
676
|
+
].some((id) => (
|
|
677
|
+
byId.get(id)?.type === "component" && byId.get(id).status === "active"
|
|
678
|
+
))).map(({ id }) => id)
|
|
679
|
+
: [];
|
|
680
|
+
const missingEvidenceSourceControlIds = [];
|
|
681
|
+
for (const control of linkedControls) {
|
|
682
|
+
const readySources = [];
|
|
683
|
+
for (const id of control[sourceField] || []) {
|
|
684
|
+
const source = byId.get(id);
|
|
685
|
+
if (source?.type !== sourceType || source.status !== "active") continue;
|
|
686
|
+
const instructions = await readMarkdown(source);
|
|
687
|
+
if (
|
|
688
|
+
(source.evidenceSourceKinds || []).length
|
|
689
|
+
&& (source.evidenceOwnerIds || []).length
|
|
690
|
+
&& substantiveMarkdown(instructions)
|
|
691
|
+
&& openPlaceholderCount(instructions) === 0
|
|
692
|
+
) readySources.push(source);
|
|
693
|
+
}
|
|
694
|
+
if (!readySources.length) missingEvidenceSourceControlIds.push(control.id);
|
|
695
|
+
}
|
|
696
|
+
const missingScheduleControlIds = linkedControls.filter((control) => (
|
|
697
|
+
["scheduled", "event-driven", "mixed"].includes(control.operationPattern)
|
|
698
|
+
&& !records.some((record) => (
|
|
699
|
+
record.type === "obligation"
|
|
700
|
+
&& obligationIsEnabled(record)
|
|
701
|
+
&& (record.controlIds || []).includes(control.id)
|
|
702
|
+
&& (record.policyIds || []).includes(policy.id)
|
|
703
|
+
))
|
|
704
|
+
)).map(({ id }) => id);
|
|
705
|
+
const relevantExceptions = records.filter((record) => (
|
|
706
|
+
record.type === "exception"
|
|
707
|
+
&& (record.scopeResourceIds || []).some((id) => id === policy.id || linkedControlIds.includes(id))
|
|
708
|
+
&& !["revoked", "closed"].includes(record.status)
|
|
709
|
+
));
|
|
710
|
+
const unresolvedExceptionIds = relevantExceptions.filter((record) => (
|
|
711
|
+
record.status !== "approved"
|
|
712
|
+
|| !record.approval?.expiresOn
|
|
713
|
+
|| record.approval.expiresOn < asOf
|
|
714
|
+
)).map(({ id }) => id);
|
|
715
|
+
const documentedExceptionIds = relevantExceptions.filter((record) => (
|
|
716
|
+
record.status === "approved"
|
|
717
|
+
&& record.approval?.expiresOn
|
|
718
|
+
&& record.approval.expiresOn >= asOf
|
|
719
|
+
)).map(({ id }) => id);
|
|
720
|
+
const timingWarnings = [
|
|
721
|
+
policy.proposedEffectiveOn && policy.proposedEffectiveOn < asOf
|
|
722
|
+
? `The proposed effective date ${policy.proposedEffectiveOn} has passed. Choose a current or future activation date; do not backdate adoption.`
|
|
723
|
+
: null,
|
|
724
|
+
policy.status === "active" && (!policy.effectiveOn || policy.effectiveOn > asOf)
|
|
725
|
+
? policy.effectiveOn
|
|
726
|
+
? `The Policy is marked active but does not become effective until ${policy.effectiveOn}. Governed Obligations remain dormant until then.`
|
|
727
|
+
: "The Policy is marked active without an effective date."
|
|
728
|
+
: null
|
|
729
|
+
].filter(Boolean);
|
|
730
|
+
const gapCount = plannedOrPartialControlIds.length
|
|
731
|
+
+ missingComponentControlIds.length
|
|
732
|
+
+ missingEvidenceSourceControlIds.length
|
|
733
|
+
+ missingScheduleControlIds.length
|
|
734
|
+
+ unresolvedExceptionIds.length
|
|
735
|
+
+ timingWarnings.length;
|
|
736
|
+
const activeNow = policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf;
|
|
737
|
+
const state = activeNow
|
|
738
|
+
? gapCount ? "active-with-implementation-gaps" : "active-and-operating"
|
|
739
|
+
: policy.status === "approved" && gapCount === 0
|
|
740
|
+
? "ready-to-activate"
|
|
741
|
+
: policy.status === "approved"
|
|
742
|
+
? "approved-implementation-pending"
|
|
743
|
+
: "active-with-implementation-gaps";
|
|
744
|
+
assessments.push({
|
|
745
|
+
policyId: policy.id,
|
|
746
|
+
title: policy.title,
|
|
747
|
+
state,
|
|
748
|
+
label: policyActivationLabel(state),
|
|
749
|
+
approvedOn: policy.approvedOn || null,
|
|
750
|
+
effectiveOn: policy.effectiveOn || null,
|
|
751
|
+
proposedEffectiveOn: policy.proposedEffectiveOn || null,
|
|
752
|
+
linkedControlIds,
|
|
753
|
+
plannedOrPartialControlIds,
|
|
754
|
+
missingComponentControlIds,
|
|
755
|
+
missingEvidenceSourceControlIds,
|
|
756
|
+
missingScheduleControlIds,
|
|
757
|
+
unresolvedExceptionIds,
|
|
758
|
+
documentedExceptionIds,
|
|
759
|
+
timingWarnings,
|
|
760
|
+
gapCount,
|
|
761
|
+
canActivateWithDocumentedGaps: policy.status === "approved",
|
|
762
|
+
activationWarning: gapCount
|
|
763
|
+
? policy.status === "approved"
|
|
764
|
+
? "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."
|
|
765
|
+
: "The Policy is active, but the remaining gaps keep Evidence Readiness incomplete. Resolve them or record the applicable time-bound Exception."
|
|
766
|
+
: null
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
return assessments;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function policyActivationLabel(state) {
|
|
773
|
+
return ({
|
|
774
|
+
"approved-implementation-pending": "Approved, implementation pending",
|
|
775
|
+
"ready-to-activate": "Ready to activate",
|
|
776
|
+
"active-with-implementation-gaps": "Active with implementation gaps",
|
|
777
|
+
"active-and-operating": "Active and operating"
|
|
778
|
+
})[state] || state;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function policyActivationItem(assessment) {
|
|
782
|
+
const counts = [
|
|
783
|
+
[assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
|
|
784
|
+
[assessment.missingComponentControlIds.length, "Controls missing active Components"],
|
|
785
|
+
[assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
|
|
786
|
+
[assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
|
|
787
|
+
[assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
|
|
788
|
+
].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
|
|
789
|
+
const message = assessment.state === "active-and-operating"
|
|
790
|
+
? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled schedules.`
|
|
791
|
+
: assessment.state === "ready-to-activate"
|
|
792
|
+
? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
|
|
793
|
+
: `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
|
|
794
|
+
return item(
|
|
795
|
+
`policy-activation-${assessment.policyId}`,
|
|
796
|
+
assessment.state === "active-and-operating" ? "complete" : "action",
|
|
797
|
+
`${assessment.title}: ${assessment.label}`,
|
|
798
|
+
message,
|
|
799
|
+
{ type: "policy", id: assessment.policyId },
|
|
800
|
+
{
|
|
801
|
+
activationAssessment: assessment,
|
|
802
|
+
commands: [
|
|
803
|
+
"npx filegrc activate-policies --scaffold > policy-activation.json",
|
|
804
|
+
"npx filegrc activate-policies policy-activation.json --preview --json",
|
|
805
|
+
"npx filegrc program-readiness --json"
|
|
806
|
+
]
|
|
807
|
+
}
|
|
618
808
|
);
|
|
619
809
|
}
|
|
620
810
|
|
|
811
|
+
function policyLibraryProposals(records) {
|
|
812
|
+
const legacyIds = [
|
|
813
|
+
"policy-anti-bribery-corruption",
|
|
814
|
+
"policy-clear-desk-screen",
|
|
815
|
+
"policy-data-protection-handling",
|
|
816
|
+
"policy-employee-handbook",
|
|
817
|
+
"policy-endpoint-remote-work",
|
|
818
|
+
"policy-mobile-computing-communications"
|
|
819
|
+
];
|
|
820
|
+
const presentIds = legacyIds.filter((id) => records.some((record) => record.type === "policy" && record.id === id));
|
|
821
|
+
if (!presentIds.length) return [];
|
|
822
|
+
return [{
|
|
823
|
+
id: "consolidate-soc2-security-policy",
|
|
824
|
+
title: "Review the minimal SOC 2 Security Policy consolidation",
|
|
825
|
+
policyIds: presentIds,
|
|
826
|
+
status: "review",
|
|
827
|
+
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."
|
|
828
|
+
}];
|
|
829
|
+
}
|
|
830
|
+
|
|
621
831
|
async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
622
832
|
const items = [];
|
|
623
833
|
if (!scope.controls.length) {
|
|
@@ -659,7 +869,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
659
869
|
criteriaMapping: (control.requirementIds || []).length > 0,
|
|
660
870
|
...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
|
|
661
871
|
workQueue: queueSchedules.length > 0
|
|
662
|
-
&& queueSchedules.
|
|
872
|
+
&& queueSchedules.some(obligationIsEnabled)
|
|
663
873
|
} : {})
|
|
664
874
|
};
|
|
665
875
|
const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
|
|
@@ -683,13 +893,14 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
683
893
|
`npx filegrc get ${shellArgument(control.id)} --mutation`
|
|
684
894
|
],
|
|
685
895
|
workQueue: queueSchedules.length ? {
|
|
896
|
+
enabled: queueSchedules.filter(obligationIsEnabled).length,
|
|
686
897
|
running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
|
|
687
898
|
total: queueSchedules.length
|
|
688
899
|
} : null
|
|
689
900
|
}
|
|
690
901
|
));
|
|
691
902
|
}
|
|
692
|
-
return stage("controls", "Implement Controls", "Each implemented
|
|
903
|
+
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
904
|
}
|
|
694
905
|
|
|
695
906
|
async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
@@ -993,18 +1204,6 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
|
|
|
993
1204
|
return ids;
|
|
994
1205
|
}
|
|
995
1206
|
|
|
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
1207
|
function policyCheckLabel(name) {
|
|
1009
1208
|
return ({
|
|
1010
1209
|
reviewed: "draft review",
|
|
@@ -1023,7 +1222,8 @@ function governedContentCheckLabel(name) {
|
|
|
1023
1222
|
approved: "approval and approval date",
|
|
1024
1223
|
effective: "effective date",
|
|
1025
1224
|
effectiveContent: "effective content revision",
|
|
1026
|
-
contentComplete: "content and organization placeholders"
|
|
1225
|
+
contentComplete: "content and organization placeholders",
|
|
1226
|
+
systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
|
|
1027
1227
|
})[name] || name;
|
|
1028
1228
|
}
|
|
1029
1229
|
|
package/src/reconciliation.js
CHANGED
|
@@ -91,7 +91,9 @@ const TRANSITIONS = {
|
|
|
91
91
|
};
|
|
92
92
|
|
|
93
93
|
export async function planReconciliation(input = process.cwd()) {
|
|
94
|
-
const loaded =
|
|
94
|
+
const loaded = input?.resources && input?.model && input?.entries
|
|
95
|
+
? input
|
|
96
|
+
: await loadWorkspace(input);
|
|
95
97
|
if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
|
|
96
98
|
return {
|
|
97
99
|
contractVersion: 1,
|
package/src/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import { performance } from "node:perf_hooks";
|
|
|
6
6
|
import { getResourceDefinition } from "../model/index.js";
|
|
7
7
|
import { prepareAuditWorkspace } from "./audit-preparation.js";
|
|
8
8
|
import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
9
|
-
import {
|
|
9
|
+
import { applyApplicabilityReviewWithContext, planApplicabilityReview } from "./batch-review.js";
|
|
10
10
|
import { applyCollectionReview, planCollectionReview } from "./collection-review.js";
|
|
11
11
|
import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
|
|
12
12
|
import { FAVICON_PNG, LOGO_MARK_PNG } from "./favicon.js";
|
|
@@ -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";
|
|
@@ -174,10 +175,11 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
174
175
|
message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions`,
|
|
175
176
|
fastResponse: prefersFastMutation(request),
|
|
176
177
|
prefetchToken
|
|
177
|
-
}, (_root, mutationContext) =>
|
|
178
|
+
}, (_root, mutationContext) => applyApplicabilityReviewWithContext(input, {
|
|
178
179
|
...reviewPayload,
|
|
179
|
-
scopeRevision: mutationContext?.repositorySnapshot?.currentCommit,
|
|
180
180
|
confirmed: payload.confirmed === true
|
|
181
|
+
}, {
|
|
182
|
+
repositorySnapshot: mutationContext?.repositorySnapshot
|
|
181
183
|
})));
|
|
182
184
|
}
|
|
183
185
|
if (request.method === "POST" && url.pathname === "/api/collection-review/preview") {
|
|
@@ -321,6 +323,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
321
323
|
};
|
|
322
324
|
return json(response, 200, await completeSetup());
|
|
323
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
|
+
}
|
|
324
333
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
325
334
|
const payload = normalizeResourceMutation(await readJson(request));
|
|
326
335
|
const { record } = payload;
|
package/src/validate.js
CHANGED
|
@@ -3,10 +3,11 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
4
|
import { getResourceDefinition } from "../model/index.js";
|
|
5
5
|
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
6
|
+
import { collectionRevision } from "./collection-revision.js";
|
|
6
7
|
import { isSafeGitName } from "./git-name.js";
|
|
7
8
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
8
9
|
import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
|
|
9
|
-
import {
|
|
10
|
+
import { obligationIsEnabled } from "./program-lifecycle.js";
|
|
10
11
|
import { partyPeople } from "./parties.js";
|
|
11
12
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
12
13
|
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
@@ -73,6 +74,7 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
73
74
|
validateLocation(record, definition, entry.relativePath, diagnostics);
|
|
74
75
|
validateRecord(record, definition, loaded.model, displayPath, diagnostics);
|
|
75
76
|
validateDateRanges(record, displayPath, diagnostics);
|
|
77
|
+
validateProposedEffectiveDate(record, asOf, displayPath, diagnostics);
|
|
76
78
|
if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
|
|
77
79
|
if (record.type === "program") validateProgram(record, displayPath, diagnostics);
|
|
78
80
|
if (record.type === "component") validateComponent(record, displayPath, diagnostics);
|
|
@@ -134,7 +136,7 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
134
136
|
}
|
|
135
137
|
validateIndependentApproval(record, byId, displayPath, diagnostics);
|
|
136
138
|
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
137
|
-
validateImplementedControlSchedules(record, obligationsByControl,
|
|
139
|
+
validateImplementedControlSchedules(record, obligationsByControl, displayPath, diagnostics);
|
|
138
140
|
await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
|
|
139
141
|
await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
|
|
140
142
|
}
|
|
@@ -177,14 +179,21 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
177
179
|
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
178
180
|
: null;
|
|
179
181
|
const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
|
|
180
|
-
|
|
182
|
+
const currentRevision = collectionRevision(loaded, record.resourceType, {
|
|
183
|
+
program,
|
|
184
|
+
authoritativeSourceId: record.decision === "externally-managed"
|
|
185
|
+
? record.authoritativeComponentId || record.authoritativeSystemId
|
|
186
|
+
: null
|
|
187
|
+
});
|
|
188
|
+
const current = record.collectionRevision === currentRevision;
|
|
189
|
+
if (current && !recordCount && record.decision === "complete") {
|
|
181
190
|
diagnostics.push(error(
|
|
182
191
|
"invalid-collection-review-decision",
|
|
183
192
|
path,
|
|
184
193
|
`${configuration.title} has no records and cannot use the complete conclusion.`
|
|
185
194
|
));
|
|
186
195
|
}
|
|
187
|
-
if (recordCount && record.decision === "zero-population") {
|
|
196
|
+
if (current && recordCount && record.decision === "zero-population") {
|
|
188
197
|
diagnostics.push(error(
|
|
189
198
|
"invalid-collection-review-decision",
|
|
190
199
|
path,
|
|
@@ -192,7 +201,8 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
192
201
|
));
|
|
193
202
|
}
|
|
194
203
|
if (
|
|
195
|
-
|
|
204
|
+
current
|
|
205
|
+
&& record.decision === "externally-managed"
|
|
196
206
|
&& byId.get(record.authoritativeComponentId || record.authoritativeSystemId)?.status !== "active"
|
|
197
207
|
) {
|
|
198
208
|
diagnostics.push(error(
|
|
@@ -237,7 +247,7 @@ export async function fingerprintWorkspace(input = process.cwd()) {
|
|
|
237
247
|
return { fingerprint: hash.digest("hex"), loaded };
|
|
238
248
|
}
|
|
239
249
|
|
|
240
|
-
function validateImplementedControlSchedules(record, obligationsByControl,
|
|
250
|
+
function validateImplementedControlSchedules(record, obligationsByControl, path, diagnostics) {
|
|
241
251
|
if (record.type !== "control" || record.status !== "implemented") return;
|
|
242
252
|
const schedules = obligationsByControl.get(record.id) || [];
|
|
243
253
|
if (!schedules.length) {
|
|
@@ -249,21 +259,13 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
|
|
|
249
259
|
));
|
|
250
260
|
return;
|
|
251
261
|
}
|
|
252
|
-
if (schedules.
|
|
253
|
-
const stopped = schedules.filter((obligation) => !
|
|
262
|
+
if (schedules.some(obligationIsEnabled)) return;
|
|
263
|
+
const stopped = schedules.filter((obligation) => !obligationIsEnabled(obligation));
|
|
254
264
|
const paused = stopped.filter((obligation) => obligation.status === "paused");
|
|
255
|
-
const
|
|
256
|
-
const policyBlockers = [...new Set(waiting.flatMap((obligation) => (obligation.policyIds || []).map((id) => {
|
|
257
|
-
const policy = byId.get(id);
|
|
258
|
-
if (!policy || policy.type !== "policy") return `${id} (missing)`;
|
|
259
|
-
if (policy.status !== "active") return `${policy.title} (${policy.status})`;
|
|
260
|
-
if (!policy.effectiveOn) return `${policy.title} (effective date missing)`;
|
|
261
|
-
if (policy.effectiveOn > asOf) return `${policy.title} (effective ${policy.effectiveOn})`;
|
|
262
|
-
return null;
|
|
263
|
-
})).filter(Boolean))];
|
|
265
|
+
const proposed = stopped.filter((obligation) => obligation.status === "proposed");
|
|
264
266
|
const reasons = [
|
|
265
|
-
|
|
266
|
-
? `${
|
|
267
|
+
proposed.length
|
|
268
|
+
? `${proposed.length} linked ${proposed.length === 1 ? "schedule is" : "schedules are"} still proposed. Enable ${proposed.length === 1 ? "it" : "them"} before implementing the control; the schedule will remain dormant until its governing Policy is active and effective.`
|
|
267
269
|
: "",
|
|
268
270
|
paused.length
|
|
269
271
|
? `${paused.length} linked ${paused.length === 1 ? "schedule is" : "schedules are"} paused. Enable ${paused.length === 1 ? "it" : "them"} before implementing the control.`
|
|
@@ -272,7 +274,7 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
|
|
|
272
274
|
diagnostics.push(error(
|
|
273
275
|
"control-work-queue-not-running",
|
|
274
276
|
path,
|
|
275
|
-
`This control cannot be marked implemented yet. ${reasons}`
|
|
277
|
+
`This control cannot be marked implemented yet. ${reasons || "Enable at least one linked schedule."}`
|
|
276
278
|
));
|
|
277
279
|
}
|
|
278
280
|
|
|
@@ -293,6 +295,19 @@ function validateDateRanges(record, path, diagnostics) {
|
|
|
293
295
|
}
|
|
294
296
|
}
|
|
295
297
|
|
|
298
|
+
function validateProposedEffectiveDate(record, asOf, path, diagnostics) {
|
|
299
|
+
if (
|
|
300
|
+
!["policy", "document"].includes(record.type)
|
|
301
|
+
|| !record.proposedEffectiveOn
|
|
302
|
+
|| record.proposedEffectiveOn >= asOf
|
|
303
|
+
) return;
|
|
304
|
+
diagnostics.push(warning(
|
|
305
|
+
"past-proposed-effective-date",
|
|
306
|
+
path,
|
|
307
|
+
`The proposed effective date ${record.proposedEffectiveOn} has passed. Choose a current or future date when management activates the approved content; do not backdate adoption.`
|
|
308
|
+
));
|
|
309
|
+
}
|
|
310
|
+
|
|
296
311
|
function validateAppointment(record, byId, path, diagnostics) {
|
|
297
312
|
if ((record.scopeResourceIds || []).includes(record.id)) {
|
|
298
313
|
diagnostics.push(error(
|