filegrc 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/model/index.js +4 -4
- package/model/v4.json +10052 -0
- package/package.json +2 -2
- package/src/agent.js +17 -5
- package/src/audit-preparation.js +17 -13
- package/src/audit-transition.js +7 -4
- package/src/batch-review.js +31 -8
- package/src/cli.js +43 -25
- package/src/collection-review.js +71 -25
- package/src/evidence-packet.js +76 -43
- package/src/external-reviewer.js +4 -4
- package/src/files.js +70 -6
- package/src/git.js +70 -13
- package/src/index.js +1 -0
- package/src/model-migration.js +631 -14
- package/src/obligations.js +14 -7
- package/src/program-path.js +49 -38
- package/src/program-readiness.js +94 -54
- package/src/program.js +52 -0
- package/src/reconciliation.js +2 -2
- package/src/server.js +50 -8
- package/src/setup.js +56 -21
- package/src/source-coverage.js +13 -9
- package/src/startup.js +1 -1
- package/src/state.js +17 -14
- package/src/timing.js +6 -0
- package/src/validate.js +100 -9
- package/src/web.js +709 -151
- package/src/workflow.js +47 -25
package/src/program-readiness.js
CHANGED
|
@@ -6,6 +6,7 @@ import { planObligations } from "./obligations.js";
|
|
|
6
6
|
import { resolveDataPath } from "./paths.js";
|
|
7
7
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
8
8
|
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
9
|
+
import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
|
|
9
10
|
import { markdownEntries } from "./resource-markdown.js";
|
|
10
11
|
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
11
12
|
import { currentCalendarDate } from "./time.js";
|
|
@@ -18,9 +19,10 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
18
19
|
const records = loaded.resources;
|
|
19
20
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
20
21
|
const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
|
|
22
|
+
const program = resolveProgram(loaded, options.programId);
|
|
21
23
|
const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
|
|
22
|
-
const scope = programScope(
|
|
23
|
-
const collectionReviews = assessCollectionReviews(loaded);
|
|
24
|
+
const scope = programScope(program, records, byId, loaded.model, loaded);
|
|
25
|
+
const collectionReviews = assessCollectionReviews(loaded, { programId: program.id });
|
|
24
26
|
const markdown = new Map();
|
|
25
27
|
const readMarkdown = async (record) => {
|
|
26
28
|
if (!record) return "";
|
|
@@ -34,10 +36,10 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
34
36
|
.map(collectionReviewReadinessItem));
|
|
35
37
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
36
38
|
controlStage.items.push(...sourceStage.items);
|
|
37
|
-
controlStage.description =
|
|
39
|
+
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, and complete authoritative source ${String(loaded.model.modelVersion) === "4" ? "Components" : "Systems"} with the required evidence roles, access owners, and retrieval instructions.`;
|
|
38
40
|
const evidenceGateStages = [
|
|
39
41
|
scopeStage(
|
|
40
|
-
|
|
42
|
+
program,
|
|
41
43
|
scope,
|
|
42
44
|
records,
|
|
43
45
|
byId,
|
|
@@ -51,20 +53,20 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
51
53
|
const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
|
|
52
54
|
const stages = [
|
|
53
55
|
...evidenceGateStages,
|
|
54
|
-
operationStage(loaded,
|
|
56
|
+
operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
55
57
|
];
|
|
56
58
|
finalizeStage(stages.at(-1));
|
|
57
59
|
const candidateStarted = Boolean(
|
|
58
|
-
|
|
59
|
-
&&
|
|
60
|
-
&& coverageStart(
|
|
60
|
+
program?.assuranceGoal === "soc-2-type-2"
|
|
61
|
+
&& program.candidateCoverage?.kind === "range"
|
|
62
|
+
&& coverageStart(program.candidateCoverage) <= asOf
|
|
61
63
|
);
|
|
62
64
|
const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
|
|
63
65
|
const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
|
|
64
66
|
const canStartCandidatePeriod = Boolean(
|
|
65
67
|
evidenceReady
|
|
66
|
-
&&
|
|
67
|
-
&& !
|
|
68
|
+
&& program?.assuranceGoal === "soc-2-type-2"
|
|
69
|
+
&& !program.candidateCoverage
|
|
68
70
|
);
|
|
69
71
|
const items = stages.flatMap((current) => current.items);
|
|
70
72
|
const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
|
|
@@ -73,12 +75,14 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
73
75
|
|
|
74
76
|
return {
|
|
75
77
|
schemaVersion: 1,
|
|
78
|
+
dataModelVersion: String(loaded.model.modelVersion),
|
|
76
79
|
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
77
80
|
asOf,
|
|
78
81
|
target: {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
programId: program?.id || null,
|
|
83
|
+
goal: program?.assuranceGoal || "none",
|
|
84
|
+
label: assuranceGoalLabel(program?.assuranceGoal),
|
|
85
|
+
candidateCoverage: program?.candidateCoverage || null
|
|
82
86
|
},
|
|
83
87
|
status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
|
|
84
88
|
evidenceReady,
|
|
@@ -94,6 +98,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
94
98
|
firstAction,
|
|
95
99
|
scope: {
|
|
96
100
|
systemIds: scope.systems.map((record) => record.id),
|
|
101
|
+
componentIds: scope.components.map((record) => record.id),
|
|
97
102
|
frameworkIds: scope.frameworks.map((record) => record.id),
|
|
98
103
|
requirementIds: scope.requirements.map((record) => record.id),
|
|
99
104
|
controlIds: scope.controls.map((record) => record.id)
|
|
@@ -115,31 +120,32 @@ export async function assessEvidenceMap(input, options = {}) {
|
|
|
115
120
|
status: counts.action ? "action" : "complete",
|
|
116
121
|
counts,
|
|
117
122
|
workflow: [
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
`Choose an existing ${String(readiness.dataModelVersion) === "4" ? "Component" : "System"} or create one that is authoritative for each evidence family.`,
|
|
124
|
+
`On every source ${String(readiness.dataModelVersion) === "4" ? "Component" : "System"}, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.`,
|
|
125
|
+
`Map each selected Control to the authoritative source ${String(readiness.dataModelVersion) === "4" ? "Components with evidenceSourceComponentIds" : "Systems with evidenceSourceIds"} that produce its evidence.`,
|
|
121
126
|
"Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
|
|
122
127
|
],
|
|
123
128
|
items: evidenceItems
|
|
124
129
|
};
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
function programScope(
|
|
132
|
+
function programScope(program, records, byId, model, loaded) {
|
|
128
133
|
const select = (ids, type, fallback) => {
|
|
129
134
|
if (ids?.length) return ids.map((id) => byId.get(id)).filter((record) => record?.type === type);
|
|
130
135
|
return records.filter(fallback);
|
|
131
136
|
};
|
|
132
137
|
return {
|
|
133
|
-
systems: (
|
|
138
|
+
systems: (program?.systemIds || [])
|
|
134
139
|
.map((id) => byId.get(id))
|
|
135
140
|
.filter((record) => record?.type === "system" && record.status !== "retired"),
|
|
136
|
-
|
|
141
|
+
components: programComponents(loaded, program),
|
|
142
|
+
frameworks: select(program?.frameworkIds, "framework", (record) => (
|
|
137
143
|
record.type === "framework" && record.status === "active"
|
|
138
144
|
)),
|
|
139
|
-
requirements: select(
|
|
145
|
+
requirements: select(selectedRequirementIds(program, model), "requirement", (record) => (
|
|
140
146
|
record.type === "requirement" && record.applicability === "applicable"
|
|
141
|
-
)).filter((record) => record.applicability === "applicable"),
|
|
142
|
-
controls: select(
|
|
147
|
+
)).filter((record) => String(model.modelVersion) === "4" || record.applicability === "applicable"),
|
|
148
|
+
controls: select(program?.controlIds, "control", (record) => (
|
|
143
149
|
record.type === "control" && !["not-applicable", "retired"].includes(record.status)
|
|
144
150
|
)).filter((record) => !["not-applicable", "retired"].includes(record.status))
|
|
145
151
|
};
|
|
@@ -164,6 +170,29 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
164
170
|
}
|
|
165
171
|
));
|
|
166
172
|
|
|
173
|
+
if (String(model.modelVersion) === "4") {
|
|
174
|
+
const completeComponents = scope.components.filter((component) => (
|
|
175
|
+
component.status === "active"
|
|
176
|
+
&& component.description
|
|
177
|
+
&& (component.ownerIds || []).length
|
|
178
|
+
&& (component.systemUses || []).some(({ systemId, roles, rationale }) => (
|
|
179
|
+
scope.systems.some(({ id }) => id === systemId)
|
|
180
|
+
&& (roles || []).length
|
|
181
|
+
&& String(rationale || "").trim()
|
|
182
|
+
))
|
|
183
|
+
));
|
|
184
|
+
items.push(item(
|
|
185
|
+
"system-components",
|
|
186
|
+
scope.components.length === completeComponents.length ? "complete" : "action",
|
|
187
|
+
"Confirm scoped Components",
|
|
188
|
+
scope.components.length
|
|
189
|
+
? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
|
|
190
|
+
: "No Components are selected. This is valid only when the bounded Systems do not rely on a separately managed service-delivery, Control-support, evidence-source, or supporting-operations building block.",
|
|
191
|
+
scope.components[0] || { type: "component" },
|
|
192
|
+
{ componentIds: scope.components.map(({ id }) => id) }
|
|
193
|
+
));
|
|
194
|
+
}
|
|
195
|
+
|
|
167
196
|
items.push(programOwnershipItem(records, byId));
|
|
168
197
|
items.push(requiredAppointmentsItem(records, model));
|
|
169
198
|
for (const assessment of collectionReviews) {
|
|
@@ -172,7 +201,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
172
201
|
|
|
173
202
|
const completeSystems = scope.systems.filter((system) => (
|
|
174
203
|
system.status === "active"
|
|
175
|
-
&& system.description
|
|
204
|
+
&& (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
|
|
176
205
|
&& system.classificationId
|
|
177
206
|
&& (system.ownerIds || []).length
|
|
178
207
|
));
|
|
@@ -186,7 +215,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
186
215
|
scope.systems[0] || { type: "system" }
|
|
187
216
|
));
|
|
188
217
|
|
|
189
|
-
if (String(model.modelVersion)
|
|
218
|
+
if (["3", "4"].includes(String(model.modelVersion))) {
|
|
190
219
|
const commitments = records.filter((record) => (
|
|
191
220
|
record.type === "commitment"
|
|
192
221
|
&& !["superseded", "retired"].includes(record.status)
|
|
@@ -196,7 +225,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
196
225
|
record.status === "active"
|
|
197
226
|
&& record.statement
|
|
198
227
|
&& record.effectiveOn
|
|
199
|
-
&& record.applicabilityReview?.decision === "applicable"
|
|
228
|
+
&& (String(model.modelVersion) === "4" || record.applicabilityReview?.decision === "applicable")
|
|
200
229
|
&& currentPartyPeople(record.ownerIds, byId).size > 0
|
|
201
230
|
&& (record.requirementIds || []).length > 0
|
|
202
231
|
&& (record.controlIds || []).length > 0
|
|
@@ -225,15 +254,16 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
225
254
|
}
|
|
226
255
|
|
|
227
256
|
const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
|
|
257
|
+
const v4Decisions = new Map((workspace?.requirementApplicability || []).map((decision) => [decision.requirementId, decision.decision]));
|
|
228
258
|
const applicableRequirements = records.filter((record) => (
|
|
229
259
|
record.type === "requirement"
|
|
230
260
|
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
231
|
-
&& record.applicability === "applicable"
|
|
261
|
+
&& (String(model.modelVersion) === "4" ? v4Decisions.get(record.id) === "applicable" : record.applicability === "applicable")
|
|
232
262
|
));
|
|
233
263
|
const unresolvedRequirements = records.filter((record) => (
|
|
234
264
|
record.type === "requirement"
|
|
235
265
|
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
236
|
-
&& record.applicability === "undetermined"
|
|
266
|
+
&& (String(model.modelVersion) === "4" ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
|
|
237
267
|
));
|
|
238
268
|
const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
|
|
239
269
|
const criteriaComplete = Boolean(
|
|
@@ -256,7 +286,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
256
286
|
"npx filegrc review-applicability --scaffold --type requirement > decisions.json",
|
|
257
287
|
"npx filegrc review-applicability decisions.json --preview --json",
|
|
258
288
|
"npx filegrc review-applicability decisions.json --yes --json",
|
|
259
|
-
|
|
289
|
+
`npx filegrc get ${shellArgument(workspace?.id || "workspace")} --mutation`
|
|
260
290
|
]
|
|
261
291
|
}
|
|
262
292
|
));
|
|
@@ -595,7 +625,11 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
595
625
|
}
|
|
596
626
|
for (const control of scope.controls) {
|
|
597
627
|
const source = await readMarkdown(control);
|
|
598
|
-
const sourceSystems = (
|
|
628
|
+
const sourceSystems = (
|
|
629
|
+
String(model.modelVersion) === "4"
|
|
630
|
+
? control.evidenceSourceComponentIds || []
|
|
631
|
+
: control.evidenceSourceIds || []
|
|
632
|
+
).map((id) => byId.get(id)).filter((record) => record?.type === (String(model.modelVersion) === "4" ? "component" : "system"));
|
|
599
633
|
const queueSchedules = [...byId.values()].filter((record) => (
|
|
600
634
|
record.type === "obligation"
|
|
601
635
|
&& record.status !== "retired"
|
|
@@ -662,17 +696,20 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
662
696
|
const families = selectedControlFamilies(scope.controls, model);
|
|
663
697
|
const items = [];
|
|
664
698
|
for (const family of families) {
|
|
665
|
-
const
|
|
699
|
+
const componentSources = String(model.modelVersion) === "4";
|
|
700
|
+
const sourceField = componentSources ? "evidenceSourceComponentIds" : "evidenceSourceIds";
|
|
701
|
+
const sourceType = componentSources ? "component" : "system";
|
|
702
|
+
const selectedSources = [...new Set(family.controls.flatMap((control) => control[sourceField] || []))]
|
|
666
703
|
.map((id) => byId.get(id))
|
|
667
704
|
.filter((record) => (
|
|
668
|
-
record?.type ===
|
|
705
|
+
record?.type === sourceType
|
|
669
706
|
&& (
|
|
670
707
|
!(record.evidenceSourceKinds || []).length
|
|
671
708
|
|| family.sourceKinds.some((kind) => (record.evidenceSourceKinds || []).includes(kind))
|
|
672
709
|
)
|
|
673
710
|
));
|
|
674
711
|
const completeSources = [];
|
|
675
|
-
const
|
|
712
|
+
const sourceChecks = [];
|
|
676
713
|
for (const source of selectedSources) {
|
|
677
714
|
const instructions = await readMarkdown(source);
|
|
678
715
|
const matchesRole = !family.sourceKinds.length
|
|
@@ -684,8 +721,8 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
684
721
|
retrievalInstructions: substantiveMarkdown(instructions) && openPlaceholderCount(instructions) === 0
|
|
685
722
|
};
|
|
686
723
|
const complete = Object.values(checks).every(Boolean);
|
|
687
|
-
|
|
688
|
-
sourceSystemId: source.id,
|
|
724
|
+
sourceChecks.push({
|
|
725
|
+
[componentSources ? "sourceComponentId" : "sourceSystemId"]: source.id,
|
|
689
726
|
complete,
|
|
690
727
|
checks
|
|
691
728
|
});
|
|
@@ -694,28 +731,31 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
694
731
|
}
|
|
695
732
|
}
|
|
696
733
|
const controlMappings = family.controls.map((control) => {
|
|
697
|
-
const
|
|
698
|
-
const
|
|
734
|
+
const sourceIds = (control[sourceField] || []).filter((id) => selectedSources.some((source) => source.id === id));
|
|
735
|
+
const completeSourceIds = sourceIds.filter((id) => completeSources.some((source) => source.id === id));
|
|
699
736
|
return {
|
|
700
737
|
controlId: control.id,
|
|
701
|
-
sourceSystemIds,
|
|
702
|
-
completeSourceSystemIds,
|
|
703
|
-
mapped:
|
|
704
|
-
complete:
|
|
738
|
+
[componentSources ? "sourceComponentIds" : "sourceSystemIds"]: sourceIds,
|
|
739
|
+
[componentSources ? "completeSourceComponentIds" : "completeSourceSystemIds"]: completeSourceIds,
|
|
740
|
+
mapped: sourceIds.length > 0,
|
|
741
|
+
complete: completeSourceIds.length > 0
|
|
705
742
|
};
|
|
706
743
|
});
|
|
707
744
|
const coveredControls = controlMappings.filter(({ complete }) => complete);
|
|
708
745
|
const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
|
|
709
746
|
const commands = [
|
|
710
747
|
...(selectedSources.length
|
|
711
|
-
?
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
748
|
+
? sourceChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap((sourceCheck) => {
|
|
749
|
+
const sourceId = sourceCheck.sourceComponentId || sourceCheck.sourceSystemId;
|
|
750
|
+
return [
|
|
751
|
+
`npx filegrc get ${shellArgument(sourceId)} --mutation > /tmp/${shellArgument(sourceId)}.json`,
|
|
752
|
+
`npx filegrc update ${sourceType} ${shellArgument(sourceId)} /tmp/${shellArgument(sourceId)}.json --json`
|
|
753
|
+
];
|
|
754
|
+
})
|
|
715
755
|
: [
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
756
|
+
`npx filegrc list ${sourceType} --json`,
|
|
757
|
+
`npx filegrc scaffold ${sourceType} --title "${componentSources ? "COMPONENT" : "SYSTEM"} NAME" > /tmp/filegrc-${sourceType}.json`,
|
|
758
|
+
`npx filegrc create /tmp/filegrc-${sourceType}.json --json`
|
|
719
759
|
]),
|
|
720
760
|
...controlMappings.filter(({ mapped }) => !mapped).flatMap(({ controlId }) => [
|
|
721
761
|
`npx filegrc get ${shellArgument(controlId)} --mutation > /tmp/${shellArgument(controlId)}.json`,
|
|
@@ -729,15 +769,15 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
729
769
|
family.title,
|
|
730
770
|
complete
|
|
731
771
|
? `${completeSources.map((source) => source.title).join(", ")} ${completeSources.length === 1 ? "covers" : "cover"} all ${family.controls.length} selected controls and record access owners and extraction instructions.`
|
|
732
|
-
: `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative
|
|
733
|
-
completeSources[0] || selectedSources[0] || { type:
|
|
772
|
+
: `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative ${componentSources ? "Component" : "System"} with the required source role, access owners, and extraction instructions.`,
|
|
773
|
+
completeSources[0] || selectedSources[0] || { type: sourceType },
|
|
734
774
|
{
|
|
735
775
|
familyId: family.id,
|
|
736
776
|
sourceKinds: family.sourceKinds,
|
|
737
777
|
controlIds: family.controls.map((control) => control.id),
|
|
738
|
-
sourceSystemIds: selectedSources.map((source) => source.id),
|
|
739
|
-
completeSourceSystemIds: completeSources.map((source) => source.id),
|
|
740
|
-
sourceSystemChecks,
|
|
778
|
+
[componentSources ? "sourceComponentIds" : "sourceSystemIds"]: selectedSources.map((source) => source.id),
|
|
779
|
+
[componentSources ? "completeSourceComponentIds" : "completeSourceSystemIds"]: completeSources.map((source) => source.id),
|
|
780
|
+
[componentSources ? "sourceComponentChecks" : "sourceSystemChecks"]: sourceChecks,
|
|
741
781
|
controlMappings,
|
|
742
782
|
evidenceForm: family.evidenceForm,
|
|
743
783
|
evidencePrompt: family.evidencePrompt,
|
|
@@ -748,7 +788,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
748
788
|
}
|
|
749
789
|
));
|
|
750
790
|
}
|
|
751
|
-
return stage("sources", "Control Evidence Sources",
|
|
791
|
+
return stage("sources", "Control Evidence Sources", `Complete the authoritative ${String(model.modelVersion) === "4" ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
|
|
752
792
|
}
|
|
753
793
|
|
|
754
794
|
function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
@@ -790,7 +830,7 @@ function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceR
|
|
|
790
830
|
? coverageEnd(workspace.candidateCoverage)
|
|
791
831
|
: null;
|
|
792
832
|
const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
|
|
793
|
-
const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id));
|
|
833
|
+
const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
|
|
794
834
|
const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
|
|
795
835
|
return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
|
|
796
836
|
item(
|
package/src/program.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function resolveProgram(loaded, requestedId) {
|
|
2
|
+
if (String(loaded.model.modelVersion) !== "4") return loaded.workspace;
|
|
3
|
+
const programs = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
|
|
4
|
+
if (requestedId) {
|
|
5
|
+
const program = programs.find(({ id }) => id === requestedId);
|
|
6
|
+
if (requestedId === "program-unconfigured" && !programs.length) {
|
|
7
|
+
return resolveProgram(loaded);
|
|
8
|
+
}
|
|
9
|
+
if (!program) throw new Error(`Program "${requestedId}" was not found or is retired.`);
|
|
10
|
+
return program;
|
|
11
|
+
}
|
|
12
|
+
const active = programs.filter(({ status }) => status === "active");
|
|
13
|
+
if (active.length === 1) return active[0];
|
|
14
|
+
if (active.length > 1) {
|
|
15
|
+
throw new Error(`More than one active Program is available. Select one of: ${active.map(({ id }) => id).join(", ")}.`);
|
|
16
|
+
}
|
|
17
|
+
if (programs.length === 1) return programs[0];
|
|
18
|
+
if (!programs.length) {
|
|
19
|
+
return {
|
|
20
|
+
id: "program-unconfigured",
|
|
21
|
+
type: "program",
|
|
22
|
+
title: "Program not configured",
|
|
23
|
+
status: "planned",
|
|
24
|
+
assuranceGoal: "none",
|
|
25
|
+
frameworkIds: [],
|
|
26
|
+
requirementApplicability: [],
|
|
27
|
+
systemIds: [],
|
|
28
|
+
controlIds: [],
|
|
29
|
+
policyIds: []
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`More than one planned Program is available. Select one of: ${programs.map(({ id }) => id).join(", ")}.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function selectedRequirementIds(program, model) {
|
|
36
|
+
if (String(model.modelVersion) === "4") {
|
|
37
|
+
return (program.requirementApplicability || [])
|
|
38
|
+
.filter(({ decision }) => decision === "applicable")
|
|
39
|
+
.map(({ requirementId }) => requirementId);
|
|
40
|
+
}
|
|
41
|
+
return program.requirementIds || [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function programComponents(loaded, program) {
|
|
45
|
+
if (String(loaded.model.modelVersion) !== "4") return [];
|
|
46
|
+
const systemIds = new Set(program.systemIds || []);
|
|
47
|
+
return loaded.resources.filter((record) => (
|
|
48
|
+
record.type === "component"
|
|
49
|
+
&& record.status !== "retired"
|
|
50
|
+
&& (record.systemUses || []).some(({ systemId }) => systemIds.has(systemId))
|
|
51
|
+
));
|
|
52
|
+
}
|
package/src/reconciliation.js
CHANGED
|
@@ -92,13 +92,13 @@ const TRANSITIONS = {
|
|
|
92
92
|
|
|
93
93
|
export async function planReconciliation(input = process.cwd()) {
|
|
94
94
|
const loaded = await loadWorkspace(input);
|
|
95
|
-
if (String(loaded.model.modelVersion)
|
|
95
|
+
if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
|
|
96
96
|
return {
|
|
97
97
|
contractVersion: 1,
|
|
98
98
|
gitRevision: gitRevision(loaded.root),
|
|
99
99
|
changedPaths: [],
|
|
100
100
|
candidates: [],
|
|
101
|
-
message: "Direct-file transition reconciliation is available
|
|
101
|
+
message: "Direct-file transition reconciliation is available in model v3 and v4 workspaces."
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
const changedPaths = gitChangedPaths(loaded.root);
|
package/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createServer as createHttpServer } from "node:http";
|
|
|
2
2
|
import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { extname, join, resolve } from "node:path";
|
|
5
|
+
import { performance } from "node:perf_hooks";
|
|
5
6
|
import { getResourceDefinition } from "../model/index.js";
|
|
6
7
|
import { prepareAuditWorkspace } from "./audit-preparation.js";
|
|
7
8
|
import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
getBrowserRepositoryState,
|
|
24
25
|
getFileHistory,
|
|
25
26
|
getGitSummary,
|
|
27
|
+
prefetchBrowserRemote,
|
|
26
28
|
pullWorkspace,
|
|
27
29
|
pushWorkspace,
|
|
28
30
|
retryBrowserSync,
|
|
@@ -56,6 +58,18 @@ import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
|
56
58
|
|
|
57
59
|
export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
58
60
|
return createHttpServer(async (request, response) => {
|
|
61
|
+
const requestStarted = performance.now();
|
|
62
|
+
if (timingEnabled()) {
|
|
63
|
+
response.once("finish", () => {
|
|
64
|
+
console.error(`[filegrc timing] ${JSON.stringify({
|
|
65
|
+
operation: "http-request",
|
|
66
|
+
method: request.method,
|
|
67
|
+
path: request.url?.split("?")[0],
|
|
68
|
+
status: response.statusCode,
|
|
69
|
+
durationMs: performance.now() - requestStarted
|
|
70
|
+
})}`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
59
73
|
try {
|
|
60
74
|
if (!expectedHost(request, options.allowedHosts)) {
|
|
61
75
|
return json(response, 403, { error: "The request host is not allowed." });
|
|
@@ -99,12 +113,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
99
113
|
const end = url.searchParams.get("end");
|
|
100
114
|
return json(response, 200, await assessWorkflow(input, {
|
|
101
115
|
auditId: url.searchParams.get("auditId") || undefined,
|
|
116
|
+
programId: url.searchParams.get("programId") || undefined,
|
|
102
117
|
asOf: url.searchParams.get("asOf") || undefined,
|
|
103
118
|
through: url.searchParams.get("through") || undefined,
|
|
104
119
|
coverage: start || end ? { kind: "range", startsOn: start, endsOn: end } : undefined,
|
|
105
120
|
includeComplete: url.searchParams.get("includeComplete") === "true"
|
|
106
121
|
}));
|
|
107
122
|
}
|
|
123
|
+
if (request.method === "POST" && url.pathname === "/api/git/prefetch") {
|
|
124
|
+
return json(response, 200, await prefetchBrowserRemote(input, {
|
|
125
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
108
128
|
if (request.method === "POST" && url.pathname === "/api/workflow/preview") {
|
|
109
129
|
return json(response, 200, await previewWorkflowMutation(input, await readJson(request)));
|
|
110
130
|
}
|
|
@@ -149,10 +169,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
149
169
|
}
|
|
150
170
|
if (request.method === "POST" && url.pathname === "/api/applicability-review") {
|
|
151
171
|
const payload = await readJson(request);
|
|
172
|
+
const { prefetchToken, ...reviewPayload } = payload;
|
|
152
173
|
return json(response, 201, await browserMutation(input, options, {
|
|
153
|
-
message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions
|
|
174
|
+
message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions`,
|
|
175
|
+
fastResponse: prefersFastMutation(request),
|
|
176
|
+
prefetchToken
|
|
154
177
|
}, () => applyApplicabilityReview(input, {
|
|
155
|
-
...
|
|
178
|
+
...reviewPayload,
|
|
156
179
|
confirmed: payload.confirmed === true
|
|
157
180
|
})));
|
|
158
181
|
}
|
|
@@ -161,10 +184,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
161
184
|
}
|
|
162
185
|
if (request.method === "POST" && url.pathname === "/api/collection-review") {
|
|
163
186
|
const payload = await readJson(request);
|
|
187
|
+
const { prefetchToken, ...reviewPayload } = payload;
|
|
164
188
|
return json(response, 201, await browserMutation(input, options, {
|
|
165
|
-
message: (result) => `Confirm ${result.assessment?.configuration?.title || payload.resourceType}
|
|
189
|
+
message: (result) => `Confirm ${result.assessment?.configuration?.title || payload.resourceType}`,
|
|
190
|
+
fastResponse: prefersFastMutation(request),
|
|
191
|
+
prefetchToken
|
|
166
192
|
}, () => applyCollectionReview(input, {
|
|
167
|
-
...
|
|
193
|
+
...reviewPayload,
|
|
168
194
|
confirmed: payload.confirmed === true
|
|
169
195
|
})));
|
|
170
196
|
}
|
|
@@ -484,12 +510,22 @@ function listen(server, port, host) {
|
|
|
484
510
|
|
|
485
511
|
function browserMutation(input, options, mutationOptions, task) {
|
|
486
512
|
const run = () => serializeWorkspaceMutation(input, async (root) => {
|
|
487
|
-
const
|
|
488
|
-
const
|
|
513
|
+
const fastResponse = mutationOptions.fastResponse === true;
|
|
514
|
+
const workflowBefore = fastResponse
|
|
515
|
+
? null
|
|
516
|
+
: await measureTiming("workflow-before", () => assessWorkflow(root));
|
|
517
|
+
const result = await measureTiming("mutation", () => runBrowserMutation(root, {
|
|
489
518
|
...mutationOptions,
|
|
490
519
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
|
|
491
|
-
backgroundPushDelayMs: options.backgroundPushDelayMs
|
|
492
|
-
|
|
520
|
+
backgroundPushDelayMs: options.backgroundPushDelayMs,
|
|
521
|
+
includeValidationProof: !fastResponse
|
|
522
|
+
}, task));
|
|
523
|
+
if (fastResponse) {
|
|
524
|
+
return {
|
|
525
|
+
...result,
|
|
526
|
+
stateRefresh: true
|
|
527
|
+
};
|
|
528
|
+
}
|
|
493
529
|
const state = await measureTiming("state", () => createAppState(root, {
|
|
494
530
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites,
|
|
495
531
|
includeDetails: false,
|
|
@@ -516,6 +552,12 @@ function browserMutation(input, options, mutationOptions, task) {
|
|
|
516
552
|
});
|
|
517
553
|
}
|
|
518
554
|
|
|
555
|
+
function prefersFastMutation(request) {
|
|
556
|
+
return String(request.headers.prefer || "")
|
|
557
|
+
.split(",")
|
|
558
|
+
.some((value) => value.trim().toLowerCase() === "respond-async");
|
|
559
|
+
}
|
|
560
|
+
|
|
519
561
|
async function requireManualBrowserGit(input, options) {
|
|
520
562
|
const repository = await getBrowserRepositoryState(input, {
|
|
521
563
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|