filegrc 0.9.2 → 0.11.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/index.js +8 -5
- package/model/v7.json +10359 -0
- package/model/v8.json +10947 -0
- package/package.json +1 -1
- package/src/applicability-scope.js +211 -0
- package/src/audit-preparation.js +156 -20
- package/src/batch-review.js +40 -24
- package/src/cli.js +85 -7
- package/src/collection-review.js +16 -3
- package/src/collection-revision.js +23 -3
- package/src/collection-scope.js +94 -7
- package/src/document-activation.js +13 -1
- package/src/git.js +4 -4
- package/src/index.js +3 -0
- package/src/model-migration.js +381 -35
- package/src/obligations.js +142 -39
- package/src/policy-activation.js +5 -0
- package/src/policy-library/data-retention-schedule-v2.md +25 -0
- package/src/policy-library.js +52 -24
- package/src/program-amendment.js +222 -0
- package/src/program-lifecycle.js +1 -1
- package/src/program-path.js +13 -8
- package/src/program-readiness.js +43 -13
- package/src/reconciliation.js +15 -3
- package/src/requirement-mapping.js +61 -0
- package/src/retention.js +261 -0
- package/src/server.js +76 -2
- package/src/setup.js +1 -1
- package/src/source-coverage.js +21 -7
- package/src/state.js +171 -2
- package/src/validate.js +106 -11
- package/src/web.js +441 -70
- package/src/workflow.js +33 -13
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { findResourceReferences } from "./agent.js";
|
|
2
|
+
import { resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
|
|
3
|
+
import { loadWorkspace } from "./workspace.js";
|
|
4
|
+
|
|
5
|
+
const SOURCE_TYPES = new Set(["policy", "document", "framework", "requirement", "commitment"]);
|
|
6
|
+
const DIRECT_DEPENDENT_TYPES = new Set(["requirement", "commitment", "control", "requirement-mapping", "retention-schedule-item", "obligation"]);
|
|
7
|
+
|
|
8
|
+
export async function planProgramAmendment(input, options = {}) {
|
|
9
|
+
const loaded = input?.resources && input?.model ? input : await loadWorkspace(input);
|
|
10
|
+
const sourceId = options.sourceResourceId;
|
|
11
|
+
const source = loaded.resources.find((record) => record.id === sourceId);
|
|
12
|
+
if (!source) throw new Error(`Source resource "${sourceId}" was not found.`);
|
|
13
|
+
if (!SOURCE_TYPES.has(source.type)) {
|
|
14
|
+
throw new Error("A program amendment source must be a Policy, Document, Framework, Requirement, or Commitment.");
|
|
15
|
+
}
|
|
16
|
+
const direct = findResourceReferences(loaded, source.id).references.filter((reference) => DIRECT_DEPENDENT_TYPES.has(reference.type));
|
|
17
|
+
const directById = new Map(direct.map((reference) => [reference.id, reference]));
|
|
18
|
+
const reviewRevision = loaded.root ? await resourceReviewRevision(loaded, source.id) : null;
|
|
19
|
+
const relatedIds = new Set([source.id, ...direct.map(({ id }) => id)]);
|
|
20
|
+
const affectedRequirementIds = new Set([
|
|
21
|
+
...(source.type === "requirement" ? [source.id] : []),
|
|
22
|
+
...loaded.resources.filter((record) => relatedIds.has(record.id) && record.type === "requirement").map(({ id }) => id)
|
|
23
|
+
]);
|
|
24
|
+
for (const record of loaded.resources) {
|
|
25
|
+
if (record.type === "commitment" && (record.requirementIds || []).some((id) => affectedRequirementIds.has(id))) relatedIds.add(record.id);
|
|
26
|
+
if (record.type === "requirement-mapping" && [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])].some((id) => affectedRequirementIds.has(id))) relatedIds.add(record.id);
|
|
27
|
+
}
|
|
28
|
+
const affectedCommitmentIds = new Set([
|
|
29
|
+
...(source.type === "commitment" ? [source.id] : []),
|
|
30
|
+
...loaded.resources.filter((record) => relatedIds.has(record.id) && record.type === "commitment").map(({ id }) => id)
|
|
31
|
+
]);
|
|
32
|
+
const affectedControlIds = new Set([
|
|
33
|
+
...direct.filter(({ type }) => type === "control").map(({ id }) => id),
|
|
34
|
+
...(source.controlIds || []).filter((id) => loaded.resources.some((record) => record.id === id && record.type === "control"))
|
|
35
|
+
]);
|
|
36
|
+
for (const id of affectedControlIds) relatedIds.add(id);
|
|
37
|
+
for (const commitmentId of affectedCommitmentIds) {
|
|
38
|
+
const commitment = loaded.resources.find(({ id }) => id === commitmentId);
|
|
39
|
+
for (const id of [...(commitment?.systemIds || []), ...(commitment?.requirementIds || []), ...(commitment?.controlIds || [])]) relatedIds.add(id);
|
|
40
|
+
for (const id of commitment?.controlIds || []) affectedControlIds.add(id);
|
|
41
|
+
}
|
|
42
|
+
for (const record of loaded.resources) {
|
|
43
|
+
if (record.type === "requirement-mapping" && [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])].some((id) => affectedCommitmentIds.has(id))) relatedIds.add(record.id);
|
|
44
|
+
if (record.type === "retention-schedule-item" && (record.sourceResourceIds || []).some((id) => affectedCommitmentIds.has(id))) relatedIds.add(record.id);
|
|
45
|
+
}
|
|
46
|
+
const primaryMappings = loaded.resources.filter((record) => record.type === "requirement-mapping" && relatedIds.has(record.id));
|
|
47
|
+
for (const mapping of primaryMappings) {
|
|
48
|
+
for (const id of [...(mapping.sourceResourceIds || []), ...(mapping.targetResourceIds || [])]) {
|
|
49
|
+
relatedIds.add(id);
|
|
50
|
+
if (loaded.resources.find((record) => record.id === id)?.type === "control") affectedControlIds.add(id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const record of loaded.resources) {
|
|
54
|
+
if (record.type === "requirement" && relatedIds.has(record.id)) affectedRequirementIds.add(record.id);
|
|
55
|
+
}
|
|
56
|
+
for (const record of loaded.resources) {
|
|
57
|
+
if (record.type === "control" && (record.requirementIds || []).some((id) => affectedRequirementIds.has(id))) {
|
|
58
|
+
affectedControlIds.add(record.id);
|
|
59
|
+
relatedIds.add(record.id);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const record of loaded.resources) {
|
|
63
|
+
if (record.type === "requirement-mapping" && (record.sourceResourceIds || []).some((id) => affectedControlIds.has(id))) {
|
|
64
|
+
relatedIds.add(record.id);
|
|
65
|
+
for (const id of [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])]) relatedIds.add(id);
|
|
66
|
+
}
|
|
67
|
+
if (record.type === "retention-schedule-item" && (record.sourceResourceIds || []).some((id) => affectedControlIds.has(id))) relatedIds.add(record.id);
|
|
68
|
+
if (record.type === "obligation" && (record.controlIds || []).some((id) => affectedControlIds.has(id))) relatedIds.add(record.id);
|
|
69
|
+
}
|
|
70
|
+
const related = loaded.resources.filter((record) => relatedIds.has(record.id) && record.id !== source.id);
|
|
71
|
+
const affected = related.map((record) => {
|
|
72
|
+
const relationFields = ["sourceResourceIds", "targetResourceIds", "requirementIds", "systemIds", "controlIds", "policyIds", "scopeResourceIds"].filter((field) => (
|
|
73
|
+
(record[field] || []).some((id) => relatedIds.has(id))
|
|
74
|
+
));
|
|
75
|
+
const directReference = directById.get(record.id);
|
|
76
|
+
return {
|
|
77
|
+
type: record.type,
|
|
78
|
+
id: record.id,
|
|
79
|
+
title: record.title,
|
|
80
|
+
status: record.status,
|
|
81
|
+
field: relationFields.length === 1 ? relationFields[0] : directReference?.field || "transitive",
|
|
82
|
+
...(relationFields.length ? { relationFields } : {})
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
const groups = {};
|
|
86
|
+
for (const record of affected) (groups[record.type] ||= []).push(record);
|
|
87
|
+
const missing = [];
|
|
88
|
+
if (["policy", "document", "framework"].includes(source.type) && !(groups.commitment || []).length) {
|
|
89
|
+
missing.push({
|
|
90
|
+
resourceType: "commitment",
|
|
91
|
+
message: "Record each externally or internally stated promise that changes the program scope or operating duties."
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if ((source.type === "commitment" || (groups.commitment || []).length) && !(groups["requirement-mapping"] || []).length) {
|
|
95
|
+
missing.push({
|
|
96
|
+
resourceType: "requirement-mapping",
|
|
97
|
+
message: "Review how the source Commitments relate to existing Requirements and Controls. Do not assume full equivalence."
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const mappingSources = related
|
|
101
|
+
.filter((record) => record.type === "requirement-mapping")
|
|
102
|
+
.flatMap((record) => [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])]);
|
|
103
|
+
const retentionSources = related
|
|
104
|
+
.filter((record) => record.type === "retention-schedule-item")
|
|
105
|
+
.flatMap((record) => retentionReviewResourceIds(record, loaded));
|
|
106
|
+
const currentReviewRevisions = await resourceReviewRevisions(loaded, [
|
|
107
|
+
source.id,
|
|
108
|
+
...mappingSources,
|
|
109
|
+
...retentionSources
|
|
110
|
+
]);
|
|
111
|
+
const staleMappings = related.filter((record) => (
|
|
112
|
+
record.type === "requirement-mapping"
|
|
113
|
+
&& record.status === "active"
|
|
114
|
+
&& reviewBindingsDiffer(
|
|
115
|
+
[...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])],
|
|
116
|
+
record.reviewedSourceRevisions,
|
|
117
|
+
currentReviewRevisions
|
|
118
|
+
)
|
|
119
|
+
));
|
|
120
|
+
if (staleMappings.length) {
|
|
121
|
+
missing.push({
|
|
122
|
+
resourceType: "requirement-mapping",
|
|
123
|
+
resourceIds: staleMappings.map(({ id }) => id),
|
|
124
|
+
message: "Re-review each affected mapping against the current source revision before relying on it."
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const retention = related.filter((record) => (
|
|
128
|
+
record.type === "retention-schedule-item"
|
|
129
|
+
&& record.status === "active"
|
|
130
|
+
&& reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions)
|
|
131
|
+
));
|
|
132
|
+
if (retention.length) {
|
|
133
|
+
missing.push({
|
|
134
|
+
resourceType: "retention-schedule-item",
|
|
135
|
+
resourceIds: retention.map(({ id }) => id),
|
|
136
|
+
message: "Re-review each affected retention item and bind it to the current source revision before treating it as active."
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
schemaVersion: 1,
|
|
141
|
+
source: {
|
|
142
|
+
type: source.type,
|
|
143
|
+
id: source.id,
|
|
144
|
+
title: source.title,
|
|
145
|
+
status: source.status,
|
|
146
|
+
reviewRevision
|
|
147
|
+
},
|
|
148
|
+
currentReviewRevisions: Object.fromEntries(currentReviewRevisions),
|
|
149
|
+
affected,
|
|
150
|
+
byResourceType: Object.fromEntries(Object.entries(groups).map(([type, records]) => [type, records.map(({ id }) => id)])),
|
|
151
|
+
reviewWork: missing,
|
|
152
|
+
commands: [
|
|
153
|
+
`npx filegrc references ${source.id} --json`,
|
|
154
|
+
"npx filegrc guide commitment --json",
|
|
155
|
+
"npx filegrc guide requirement-mapping --json",
|
|
156
|
+
"npx filegrc guide retention-schedule-item --json",
|
|
157
|
+
"npx filegrc program-readiness --json"
|
|
158
|
+
],
|
|
159
|
+
principle: "The plan identifies affected records but never creates promises, mappings, retention periods, or deletion behavior without management review."
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function reviewBindingsDiffer(expectedIds, reviewed = {}, current) {
|
|
164
|
+
const expected = new Set(expectedIds);
|
|
165
|
+
if (Object.keys(reviewed).length !== expected.size) return true;
|
|
166
|
+
return [...expected].some((id) => !current.get(id) || reviewed[id] !== current.get(id));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function assessProgramAmendmentReadiness(loaded) {
|
|
170
|
+
if (!loaded.model.resources["requirement-mapping"]) return [];
|
|
171
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
172
|
+
const commitments = loaded.resources.filter((record) => (
|
|
173
|
+
record.type === "commitment" && !["superseded", "retired"].includes(record.status)
|
|
174
|
+
));
|
|
175
|
+
const sourceIds = new Set(commitments.flatMap((record) => record.sourceResourceIds || []));
|
|
176
|
+
for (const record of loaded.resources) {
|
|
177
|
+
if (["policy", "document"].includes(record.type) && record.programRole === "supporting" && !["superseded", "retired"].includes(record.status)) {
|
|
178
|
+
sourceIds.add(record.id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const sourceRecords = [...new Set([...sourceIds, ...commitments.map(({ id }) => id)])]
|
|
182
|
+
.map((id) => byId.get(id))
|
|
183
|
+
.filter((record) => record && SOURCE_TYPES.has(record.type));
|
|
184
|
+
const plans = await Promise.all(sourceRecords.map((record) => (
|
|
185
|
+
planProgramAmendment(loaded, { sourceResourceId: record.id })
|
|
186
|
+
)));
|
|
187
|
+
const items = [];
|
|
188
|
+
for (const plan of plans) {
|
|
189
|
+
for (const work of plan.reviewWork.filter((candidate) => (
|
|
190
|
+
["commitment", "requirement-mapping"].includes(candidate.resourceType)
|
|
191
|
+
&& !resourceIdsPresent(candidate, loaded)
|
|
192
|
+
))) {
|
|
193
|
+
const id = `program-amendment-${plan.source.id}-${work.resourceType}`;
|
|
194
|
+
if (items.some((item) => item.id === id)) continue;
|
|
195
|
+
items.push({
|
|
196
|
+
id,
|
|
197
|
+
status: "action",
|
|
198
|
+
title: work.resourceType === "commitment" ? `Record commitments from ${plan.source.title}` : `Map commitments affected by ${plan.source.title}`,
|
|
199
|
+
message: work.message,
|
|
200
|
+
resourceType: plan.source.type,
|
|
201
|
+
resourceId: plan.source.id,
|
|
202
|
+
createResourceType: work.resourceType,
|
|
203
|
+
sourceResourceIds: work.resourceType === "commitment"
|
|
204
|
+
? [plan.source.id]
|
|
205
|
+
: plan.byResourceType.commitment || (plan.source.type === "commitment" ? [plan.source.id] : []),
|
|
206
|
+
commands: [
|
|
207
|
+
`npx filegrc scaffold ${work.resourceType} --title ${shellArgument(work.resourceType === "commitment" ? `Commitment from ${plan.source.title}` : `Mapping for ${plan.source.title}`)}`,
|
|
208
|
+
`npx filegrc program-amendment ${plan.source.id} --json`
|
|
209
|
+
]
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return items;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function shellArgument(value) {
|
|
217
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resourceIdsPresent(work, loaded) {
|
|
221
|
+
return (work.resourceIds || []).some((id) => loaded.resources.some((record) => record.id === id));
|
|
222
|
+
}
|
package/src/program-lifecycle.js
CHANGED
|
@@ -22,7 +22,7 @@ export function governedDocumentIsOperating(document, asOf, model) {
|
|
|
22
22
|
if (document?.type !== "document" || document.status !== "active") return false;
|
|
23
23
|
if (!document.effectiveOn || document.effectiveOn > asOf) return false;
|
|
24
24
|
if (!modelSupports(model, "governed-document-activation")) return true;
|
|
25
|
-
if (
|
|
25
|
+
if (["legacy-v4", "historical"].includes(document.activationBasis)) {
|
|
26
26
|
return document.workflowScope === "engagement"
|
|
27
27
|
&& Boolean(document.approvedOn && document.approvedContentRevisions);
|
|
28
28
|
}
|
package/src/program-path.js
CHANGED
|
@@ -11,6 +11,7 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
11
11
|
framework: "Confirm the criteria framework and version used for the program.",
|
|
12
12
|
requirement: "Keep the published criterion as catalog content. Record management applicability and rationale on the selected Program.",
|
|
13
13
|
commitment: "Record supplemental customer promises and service requirements that shape the scope or control design. The Commitment’s systemIds and controlIds are authoritative for what fulfills it.",
|
|
14
|
+
"requirement-mapping": "Record a reviewed relationship among Requirements, Commitments, and Controls. Choose the comparison method and relationship explicitly, explain the rationale, and bind the review to every mapped source revision.",
|
|
14
15
|
policy: "Tailor each Policy to match what the company is committing to. Clear placeholders, assign an owner and separate approver, then bind approval to the reviewed content. Approval does not prove implementation. Activate the Policy during the Step 3 cutover after reviewing its implementation gaps.",
|
|
15
16
|
document: "Complete required program Documents in Step 2, assign an owner and separate approver, and bind approval to the intended values and exact Markdown. Implement the linked requirements and activate that approved revision in Step 3. Prepare Audit Documents in Step 5.",
|
|
16
17
|
control: "Finish each applicable starter Control with the procedure people follow, its owner, bounded System scope, operating Components, authoritative evidence-source Components, governing Policy and Requirement mappings, and implementation date. Put calendar and event schedules in Obligations.",
|
|
@@ -19,6 +20,7 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
19
20
|
"risk-assessment": "Complete and approve an assessment of the risks to the in-scope service, systems, vendors, and commitments.",
|
|
20
21
|
risk: "Record each risk identified by an assessment or operating activity. Assign an owner, rate it, document the chosen response, and link the Controls that treat it from the Risk record.",
|
|
21
22
|
obligation: "Review the recurring work proposed by effective policies. Confirm who owns it, when it is due, and what proof completion requires.",
|
|
23
|
+
"retention-schedule-item": "Use one structured row for each reviewed retention rule. Name its Information Types, scope, cutoff, period, disposition, sources, owner, and approval. Keep unknown organization values planned for management review.",
|
|
22
24
|
"obligation-event": "When a policy-triggering event occurs, record it here and complete the actions filegrc creates for it.",
|
|
23
25
|
"policy-review": "Record scheduled and change-driven reviews of policies and governed documents, including the decision and any follow-up.",
|
|
24
26
|
meeting: "Record required oversight meetings, including attendees, decisions, minutes, and follow-up work.",
|
|
@@ -53,11 +55,13 @@ export const RESOURCE_PAGE_SUMMARIES = {
|
|
|
53
55
|
framework: "Confirm the SOC 2 framework.",
|
|
54
56
|
requirement: "Decide which SOC 2 criteria apply.",
|
|
55
57
|
commitment: "Record customer promises that affect scope.",
|
|
58
|
+
"requirement-mapping": "Review how supplemental promises relate to Requirements and Controls.",
|
|
56
59
|
vendor: "List material external providers.",
|
|
57
60
|
system: "Define the service boundary.",
|
|
58
61
|
component: "Connect each material Component to a System.",
|
|
59
62
|
classification: "Define handling levels.",
|
|
60
63
|
"information-type": "Define information categories.",
|
|
64
|
+
"retention-schedule-item": "Review each structured retention rule.",
|
|
61
65
|
policy: "Tailor the starter Policy and have someone other than its owner approve it.",
|
|
62
66
|
document: "Adapt and approve plans.",
|
|
63
67
|
control: "Describe each Control and its evidence source.",
|
|
@@ -77,21 +81,19 @@ export const PROGRAM_PATH = [
|
|
|
77
81
|
summary: "Name the owners, criteria, service, Systems, and providers in scope.",
|
|
78
82
|
sections: [
|
|
79
83
|
{ id: "ownership", title: "Program Ownership", description: "Confirm the people, appointments, and teams that own, approve, review, and operate the program.", steps: ["Confirm the initial program lead’s actual job title and the separate Policy Owner Appointment.", "Add the organization’s real appointments, reviewers, and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "appointment", "team"], defaultOpen: true },
|
|
80
|
-
{ id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Record customer commitments and
|
|
84
|
+
{ id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Record customer commitments and reviewed mappings. Keep optional criteria out until the company chooses to add them."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
|
|
81
85
|
{ id: "boundary", title: "System Boundary", description: "Start with the bounded System. Add Components that materially deliver the service, support Controls, produce authoritative Evidence, or support relevant operations. Keep Vendor relationships and specific Assets separate.", steps: ["Create the complete bounded System and select it on the Program.", "Add only relevant Components, with a role and rationale for each System use.", "Create Vendors for material external provider relationships and link supplied Components when factual.", "Normalize Information Types and Classifications used by the System, Components, Vendors, Risks, and Evidence Artifacts."], types: ["system", "component", "vendor", "classification", "information-type"], defaultOpen: false }
|
|
82
86
|
],
|
|
83
|
-
resourceTypes: ["person", "appointment", "team", "program", "framework", "requirement", "commitment", "system", "component", "vendor", "classification", "information-type"],
|
|
87
|
+
resourceTypes: ["person", "appointment", "team", "program", "framework", "requirement", "commitment", "requirement-mapping", "system", "component", "vendor", "classification", "information-type"],
|
|
84
88
|
commands: [
|
|
85
89
|
"filegrc setup",
|
|
86
90
|
"filegrc guide person --json",
|
|
87
91
|
"filegrc guide appointment --json",
|
|
88
92
|
"filegrc guide system --json",
|
|
89
93
|
"filegrc guide component --json",
|
|
90
|
-
"filegrc
|
|
91
|
-
"filegrc review-collection framework --scaffold",
|
|
94
|
+
"filegrc guide requirement-mapping --json",
|
|
92
95
|
"filegrc review-collection vendor --scaffold",
|
|
93
|
-
"filegrc review-collection
|
|
94
|
-
"filegrc review-collection component --scaffold",
|
|
96
|
+
"filegrc review-collection information-type --scaffold",
|
|
95
97
|
"filegrc list system --json"
|
|
96
98
|
]
|
|
97
99
|
},
|
|
@@ -123,15 +125,18 @@ export const PROGRAM_PATH = [
|
|
|
123
125
|
description: "Finish controls and their evidence sources",
|
|
124
126
|
summary: "Describe each Control and connect its evidence source.",
|
|
125
127
|
sections: [
|
|
126
|
-
{ id: "catalog", title: "Control Catalog", description: "Implement approved requirements, configure Obligations, activate approved program content, finish authoritative evidence sources, then activate the Policies at cutover.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components.", "Review and enable every calendar and event schedule as an Obligation. Enabled work remains dormant until its governing Policy is active.", "Confirm each source Component is active, has an evidence-source role and rationale in the Control's System scope, has current access owners, and includes repeatable retrieval instructions in Record Markdown.", "Implement every requirement linked from an approved program Document or Training record, then activate the unchanged approved revisions with separate activation dates and bindings.", "Use the activation review to inspect planned or partial Controls, inactive governed content, missing Components or sources, missing schedules, and unresolved Exceptions.", "Choose the approved Policies that should take effect, set the real effective date, and confirm the Step 3 cutover."], types: ["control", "complementary-control", "obligation"], defaultOpen: true }
|
|
128
|
+
{ id: "catalog", title: "Control Catalog", description: "Implement approved requirements, configure retention and Obligations, activate approved program content, finish authoritative evidence sources, then activate the Policies at cutover.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components.", "Review every Retention Schedule Item against current information uses and approved sources. Do not infer periods or disposition behavior.", "Review and enable every calendar and event schedule as an Obligation. Enabled work remains dormant until its governing Policy is active.", "Confirm each source Component is active, has an evidence-source role and rationale in the Control's System scope, has current access owners, and includes repeatable retrieval instructions in Record Markdown.", "Implement every requirement linked from an approved program Document or Training record, then activate the unchanged approved revisions with separate activation dates and bindings.", "Use the activation review to inspect planned or partial Controls, inactive governed content, missing Components or sources, missing schedules, and unresolved Exceptions.", "Choose the approved Policies that should take effect, set the real effective date, and confirm the Step 3 cutover."], types: ["control", "complementary-control", "retention-schedule-item", "obligation"], defaultOpen: true }
|
|
127
129
|
],
|
|
128
|
-
resourceTypes: ["control", "complementary-control", "obligation"],
|
|
130
|
+
resourceTypes: ["control", "complementary-control", "retention-schedule-item", "obligation"],
|
|
129
131
|
commands: [
|
|
130
132
|
"filegrc guide control --json",
|
|
131
133
|
"filegrc list control --json",
|
|
132
134
|
"filegrc get CONTROL_ID --mutation",
|
|
133
135
|
"filegrc guide obligation --json",
|
|
136
|
+
"filegrc guide retention-schedule-item --json",
|
|
137
|
+
"filegrc review-collection retention-schedule-item --scaffold",
|
|
134
138
|
"filegrc list obligation --json",
|
|
139
|
+
"filegrc review-collection component --scaffold",
|
|
135
140
|
"filegrc review-collection complementary-control --scaffold",
|
|
136
141
|
"filegrc activate-content --scaffold",
|
|
137
142
|
"filegrc activate-policies --scaffold",
|
package/src/program-readiness.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { modelSupports } from "../model/index.js";
|
|
3
|
+
import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
|
|
3
4
|
import { assessRequiredAppointments } from "./appointments.js";
|
|
4
5
|
import { assessCollectionReviews } from "./collection-review.js";
|
|
5
6
|
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
@@ -15,7 +16,10 @@ import {
|
|
|
15
16
|
} from "./program-lifecycle.js";
|
|
16
17
|
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
17
18
|
import { assessPolicyLibraryUpgrades } from "./policy-library.js";
|
|
19
|
+
import { assessProgramAmendmentReadiness } from "./program-amendment.js";
|
|
18
20
|
import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
|
|
21
|
+
import { assessRequirementMappingReadiness } from "./requirement-mapping.js";
|
|
22
|
+
import { assessRetentionReadiness } from "./retention.js";
|
|
19
23
|
import { markdownEntries } from "./resource-markdown.js";
|
|
20
24
|
import {
|
|
21
25
|
missingSoc2References,
|
|
@@ -52,6 +56,12 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
52
56
|
.map(collectionReviewReadinessItem));
|
|
53
57
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
54
58
|
controlStage.items.push(...sourceStage.items);
|
|
59
|
+
controlStage.items.push(...await assessRetentionReadiness(loaded, program, {
|
|
60
|
+
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
|
|
61
|
+
}));
|
|
62
|
+
controlStage.items.push(...collectionReviews
|
|
63
|
+
.filter(({ resourceType }) => ["component", "retention-schedule-item"].includes(resourceType))
|
|
64
|
+
.map(collectionReviewReadinessItem));
|
|
55
65
|
const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
|
|
56
66
|
controlStage.items.push(...governedContent.items);
|
|
57
67
|
const policyActivations = await assessPolicyActivations(
|
|
@@ -65,15 +75,18 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
65
75
|
);
|
|
66
76
|
controlStage.items.push(...policyActivations.map(policyActivationItem));
|
|
67
77
|
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. Activate unchanged approved program Documents and Training after their requirements are implemented, then activate approved Policies at the implementation cutover.`;
|
|
68
|
-
const
|
|
69
|
-
scopeStage(
|
|
78
|
+
const scopeReadinessStage = scopeStage(
|
|
70
79
|
program,
|
|
71
80
|
scope,
|
|
72
81
|
records,
|
|
73
82
|
byId,
|
|
74
83
|
loaded.model,
|
|
75
|
-
collectionReviews.filter(({ resourceType }) =>
|
|
76
|
-
)
|
|
84
|
+
collectionReviews.filter(({ resourceType }) => ["person", "framework", "system", "vendor", "information-type"].includes(resourceType))
|
|
85
|
+
);
|
|
86
|
+
scopeReadinessStage.items.push(...await assessRequirementMappingReadiness(loaded));
|
|
87
|
+
scopeReadinessStage.items.push(...await assessProgramAmendmentReadiness(loaded));
|
|
88
|
+
const evidenceGateStages = [
|
|
89
|
+
scopeReadinessStage,
|
|
77
90
|
policyStage,
|
|
78
91
|
controlStage
|
|
79
92
|
];
|
|
@@ -81,7 +94,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
81
94
|
const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
|
|
82
95
|
const stages = [
|
|
83
96
|
...evidenceGateStages,
|
|
84
|
-
operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
97
|
+
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
85
98
|
];
|
|
86
99
|
finalizeStage(stages.at(-1));
|
|
87
100
|
const candidateStarted = Boolean(
|
|
@@ -103,6 +116,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
103
116
|
const firstAction = items.find((current) => current.status === "action") || null;
|
|
104
117
|
|
|
105
118
|
return {
|
|
119
|
+
program,
|
|
106
120
|
schemaVersion: 1,
|
|
107
121
|
dataModelVersion: String(loaded.model.modelVersion),
|
|
108
122
|
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
@@ -252,16 +266,22 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
252
266
|
));
|
|
253
267
|
|
|
254
268
|
if (modelSupports(model, "guided-workflow")) {
|
|
255
|
-
const
|
|
269
|
+
const allCommitments = records.filter((record) => (
|
|
256
270
|
record.type === "commitment"
|
|
257
271
|
&& !["superseded", "retired"].includes(record.status)
|
|
258
|
-
&& (record.systemIds || []).some((id) => scope.systems.some((system) => system.id === id))
|
|
259
272
|
));
|
|
273
|
+
const commitments = allCommitments.filter((record) => (
|
|
274
|
+
(record.systemIds || []).some((id) => scope.systems.some((system) => system.id === id))
|
|
275
|
+
));
|
|
276
|
+
const unscopedCommitments = allCommitments.filter((record) => !(record.systemIds || []).length);
|
|
260
277
|
const completeCommitments = commitments.filter((record) => (
|
|
261
278
|
record.status === "active"
|
|
262
279
|
&& record.statement
|
|
263
280
|
&& record.effectiveOn
|
|
264
|
-
&& (
|
|
281
|
+
&& (!model.resources.commitment?.fields?.applicabilityReview || (
|
|
282
|
+
record.applicabilityReview?.decision === "applicable"
|
|
283
|
+
&& applicabilityReviewIsCurrent(record.applicabilityReview, record, workspace, records, model)
|
|
284
|
+
))
|
|
265
285
|
&& currentPartyPeople(record.ownerIds, byId).size > 0
|
|
266
286
|
&& (record.requirementIds || []).length > 0
|
|
267
287
|
&& (record.controlIds || []).length > 0
|
|
@@ -271,14 +291,15 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
271
291
|
)));
|
|
272
292
|
items.push(item(
|
|
273
293
|
"commitments",
|
|
274
|
-
scope.systems.length && uncoveredSystems.length === 0 ? "complete" : "action",
|
|
294
|
+
scope.systems.length && uncoveredSystems.length === 0 && unscopedCommitments.length === 0 ? "complete" : "action",
|
|
275
295
|
"Record service commitments and system requirements",
|
|
276
296
|
scope.systems.length
|
|
277
|
-
? `${completeCommitments.length} complete active ${completeCommitments.length === 1 ? "commitment covers" : "commitments cover"} ${scope.systems.length - uncoveredSystems.length} of ${scope.systems.length} in-scope systems.`
|
|
297
|
+
? `${completeCommitments.length} complete active ${completeCommitments.length === 1 ? "commitment covers" : "commitments cover"} ${scope.systems.length - uncoveredSystems.length} of ${scope.systems.length} in-scope systems.${unscopedCommitments.length ? ` ${unscopedCommitments.length} Commitment${unscopedCommitments.length === 1 ? " has" : "s have"} no System scope and must be reviewed explicitly.` : ""}`
|
|
278
298
|
: "Define the service boundary before recording its customer promises and approved system requirements.",
|
|
279
299
|
commitments[0] || { type: "commitment" },
|
|
280
300
|
{
|
|
281
301
|
uncoveredSystemIds: uncoveredSystems.map(({ id }) => id),
|
|
302
|
+
unscopedCommitmentIds: unscopedCommitments.map(({ id }) => id),
|
|
282
303
|
commands: [
|
|
283
304
|
"npx filegrc guide commitment --json",
|
|
284
305
|
"npx filegrc list commitment --workflow --json",
|
|
@@ -290,7 +311,15 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
290
311
|
}
|
|
291
312
|
|
|
292
313
|
const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
|
|
293
|
-
const
|
|
314
|
+
const requirementById = new Map(records
|
|
315
|
+
.filter(({ type }) => type === "requirement")
|
|
316
|
+
.map((record) => [record.id, record]));
|
|
317
|
+
const v4Decisions = new Map((workspace?.requirementApplicability || [])
|
|
318
|
+
.filter((decision) => (
|
|
319
|
+
requirementById.has(decision.requirementId)
|
|
320
|
+
&& applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model)
|
|
321
|
+
))
|
|
322
|
+
.map((decision) => [decision.requirementId, decision.decision]));
|
|
294
323
|
const applicableRequirements = records.filter((record) => (
|
|
295
324
|
record.type === "requirement"
|
|
296
325
|
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
@@ -1162,6 +1191,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
1162
1191
|
const checks = {
|
|
1163
1192
|
...(model.resources.control?.fields?.applicabilityReview ? {
|
|
1164
1193
|
applicability: control.applicabilityReview?.decision === "applicable"
|
|
1194
|
+
&& applicabilityReviewIsCurrent(control.applicabilityReview, control, scope.program, [...byId.values()], model)
|
|
1165
1195
|
} : {}),
|
|
1166
1196
|
implemented: control.status === "implemented",
|
|
1167
1197
|
owner: (control.ownerIds || []).length > 0,
|
|
@@ -1316,7 +1346,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1316
1346
|
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);
|
|
1317
1347
|
}
|
|
1318
1348
|
|
|
1319
|
-
function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
1349
|
+
async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
1320
1350
|
const goal = workspace?.assuranceGoal || "none";
|
|
1321
1351
|
if (!evidenceReady) {
|
|
1322
1352
|
return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
|
|
@@ -1355,7 +1385,7 @@ function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceR
|
|
|
1355
1385
|
? coverageEnd(workspace.candidateCoverage)
|
|
1356
1386
|
: null;
|
|
1357
1387
|
const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
|
|
1358
|
-
const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
|
|
1388
|
+
const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
|
|
1359
1389
|
const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
|
|
1360
1390
|
return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
|
|
1361
1391
|
item(
|
package/src/reconciliation.js
CHANGED
|
@@ -11,7 +11,9 @@ const TRANSITIONS = {
|
|
|
11
11
|
person: [
|
|
12
12
|
{
|
|
13
13
|
eventType: "person-started",
|
|
14
|
-
applies: (before, after) => after?.status === "active"
|
|
14
|
+
applies: (before, after) => after?.status === "active"
|
|
15
|
+
&& after?.affiliation !== "external"
|
|
16
|
+
&& before?.status !== "active",
|
|
15
17
|
message: "Confirm whether activating this Person represents a workforce start that needs policy-event work."
|
|
16
18
|
},
|
|
17
19
|
{
|
|
@@ -95,16 +97,26 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
95
97
|
const loaded = input?.resources && input?.model && input?.entries
|
|
96
98
|
? input
|
|
97
99
|
: await loadWorkspace(input);
|
|
100
|
+
const headRevision = gitRevision(loaded.root);
|
|
98
101
|
if (!modelSupports(loaded.model, "guided-workflow")) {
|
|
99
102
|
return {
|
|
100
103
|
contractVersion: 1,
|
|
101
|
-
gitRevision:
|
|
104
|
+
gitRevision: headRevision,
|
|
102
105
|
changedPaths: [],
|
|
103
106
|
candidates: [],
|
|
104
107
|
message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
|
|
105
108
|
};
|
|
106
109
|
}
|
|
107
110
|
const changedPaths = gitChangedPaths(loaded.root);
|
|
111
|
+
if (!headRevision) {
|
|
112
|
+
return {
|
|
113
|
+
contractVersion: 1,
|
|
114
|
+
gitRevision: null,
|
|
115
|
+
changedPaths,
|
|
116
|
+
candidates: [],
|
|
117
|
+
message: "Commit the initial workspace before FileGRC checks later direct-file changes for Policy Events."
|
|
118
|
+
};
|
|
119
|
+
}
|
|
108
120
|
const currentByPath = new Map(loaded.entries.map((entry) => [
|
|
109
121
|
`data/${entry.relativePath}`,
|
|
110
122
|
entry
|
|
@@ -176,7 +188,7 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
176
188
|
}
|
|
177
189
|
return {
|
|
178
190
|
contractVersion: 1,
|
|
179
|
-
gitRevision:
|
|
191
|
+
gitRevision: headRevision,
|
|
180
192
|
changedPaths,
|
|
181
193
|
candidates: candidates.sort((a, b) => a.id.localeCompare(b.id))
|
|
182
194
|
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { resourceReviewRevisions } from "./retention.js";
|
|
2
|
+
|
|
3
|
+
export async function assessRequirementMappingReadiness(loaded) {
|
|
4
|
+
if (!loaded.model.resources["requirement-mapping"]) return [];
|
|
5
|
+
const mappings = loaded.resources.filter((record) => (
|
|
6
|
+
record.type === "requirement-mapping" && !["superseded", "retired"].includes(record.status)
|
|
7
|
+
));
|
|
8
|
+
const ids = mappings.flatMap((record) => [
|
|
9
|
+
...(record.sourceResourceIds || []),
|
|
10
|
+
...(record.targetResourceIds || [])
|
|
11
|
+
]);
|
|
12
|
+
const revisions = await resourceReviewRevisions(loaded, ids);
|
|
13
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
14
|
+
return mappings.map((mapping) => {
|
|
15
|
+
const mappedIds = [...new Set([
|
|
16
|
+
...(mapping.sourceResourceIds || []),
|
|
17
|
+
...(mapping.targetResourceIds || [])
|
|
18
|
+
])];
|
|
19
|
+
const staleIds = mappedIds.filter((id) => (
|
|
20
|
+
!revisions.get(id) || mapping.reviewedSourceRevisions?.[id] !== revisions.get(id)
|
|
21
|
+
)).concat(Object.keys(mapping.reviewedSourceRevisions || {}).filter((id) => !mappedIds.includes(id)));
|
|
22
|
+
const structurallyComplete = mappingStructureIsComplete(mapping);
|
|
23
|
+
const complete = mapping.status === "active" && structurallyComplete && staleIds.length === 0;
|
|
24
|
+
return {
|
|
25
|
+
id: `requirement-mapping-${mapping.id}`,
|
|
26
|
+
status: complete ? "complete" : "action",
|
|
27
|
+
title: complete ? `Mapping current: ${mapping.title}` : `Review mapping: ${mapping.title}`,
|
|
28
|
+
message: mapping.status !== "active"
|
|
29
|
+
? "This mapping is still planned. Review both sides, choose the relationship and comparison method, and bind the current revisions before activation."
|
|
30
|
+
: !structurallyComplete
|
|
31
|
+
? "This active mapping is incomplete. Add distinct source and target records, the relationship and method, a rationale, owners, reviewer, review date, and current revision bindings."
|
|
32
|
+
: `This mapping no longer matches ${staleIds.length} mapped ${staleIds.length === 1 ? "record" : "records"}. Review it before relying on the stated relationship.`,
|
|
33
|
+
resourceType: "requirement-mapping",
|
|
34
|
+
resourceId: mapping.id,
|
|
35
|
+
staleResourceIds: staleIds,
|
|
36
|
+
commands: [
|
|
37
|
+
`npx filegrc get ${mapping.id} --mutation`,
|
|
38
|
+
`npx filegrc review-bindings ${mapping.id} --json`,
|
|
39
|
+
...staleIds
|
|
40
|
+
.filter((id) => ["policy", "document", "framework", "requirement", "commitment"].includes(byId.get(id)?.type))
|
|
41
|
+
.map((id) => `npx filegrc program-amendment ${id} --json`)
|
|
42
|
+
]
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mappingStructureIsComplete(mapping) {
|
|
48
|
+
const sourceIds = [...new Set(mapping.sourceResourceIds || [])];
|
|
49
|
+
const targetIds = [...new Set(mapping.targetResourceIds || [])];
|
|
50
|
+
return sourceIds.length > 0
|
|
51
|
+
&& targetIds.length > 0
|
|
52
|
+
&& !sourceIds.some((id) => targetIds.includes(id))
|
|
53
|
+
&& ["equal-to", "equivalent-to", "subset-of", "superset-of", "intersects-with", "no-relationship"].includes(mapping.relationship)
|
|
54
|
+
&& ["syntactic", "semantic", "functional"].includes(mapping.method)
|
|
55
|
+
&& Boolean(String(mapping.rationale || "").trim())
|
|
56
|
+
&& (mapping.ownerIds || []).length > 0
|
|
57
|
+
&& (mapping.reviewedByIds || []).length > 0
|
|
58
|
+
&& Boolean(mapping.reviewedOn)
|
|
59
|
+
&& mapping.reviewedSourceRevisions
|
|
60
|
+
&& typeof mapping.reviewedSourceRevisions === "object";
|
|
61
|
+
}
|