filegrc 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -7
- package/model/index.js +4 -4
- package/model/v4.json +10052 -0
- package/package.json +1 -1
- 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 +49 -26
- 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/state.js +17 -14
- package/src/timing.js +6 -0
- package/src/validate.js +100 -9
- package/src/web.js +690 -139
- package/src/workflow.js +47 -25
package/src/setup.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyResourceBatch } from "./files.js";
|
|
2
2
|
import { createResourceId } from "./id.js";
|
|
3
|
+
import { resolveProgram, selectedRequirementIds } from "./program.js";
|
|
3
4
|
import { loadWorkspace } from "./workspace.js";
|
|
4
5
|
|
|
5
6
|
const PROGRAM_GOALS = new Set(["none", "readiness", "type-1", "type-2"]);
|
|
@@ -12,7 +13,8 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
12
13
|
const plan = buildSetupRecords(loaded, setup);
|
|
13
14
|
const updates = [
|
|
14
15
|
...(plan.existingSystem ? [plan.system] : []),
|
|
15
|
-
plan.
|
|
16
|
+
plan.target,
|
|
17
|
+
...(plan.component ? [plan.component] : []),
|
|
16
18
|
...(plan.renderer ? [plan.renderer] : [])
|
|
17
19
|
];
|
|
18
20
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, entry.revision]));
|
|
@@ -30,7 +32,8 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
30
32
|
return {
|
|
31
33
|
draft: setup.draft,
|
|
32
34
|
system: plan.system,
|
|
33
|
-
workspace: plan.workspace,
|
|
35
|
+
workspace: plan.target.type === "workspace" ? plan.target : plan.workspace,
|
|
36
|
+
program: plan.target.type === "program" ? plan.target : null,
|
|
34
37
|
renderer: plan.renderer,
|
|
35
38
|
commitment: plan.commitment,
|
|
36
39
|
linkedControlIds: [],
|
|
@@ -49,13 +52,13 @@ export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
|
|
|
49
52
|
draft: setup.draft,
|
|
50
53
|
changes: {
|
|
51
54
|
system: plan.existingSystem ? "update" : "create",
|
|
52
|
-
|
|
55
|
+
[plan.target.type]: "update",
|
|
53
56
|
renderer: plan.renderer ? "update" : "unchanged",
|
|
54
57
|
controls: 0,
|
|
55
58
|
commitment: plan.commitment ? "create" : "unchanged"
|
|
56
59
|
},
|
|
57
60
|
system: setupSystemSummary(plan.system),
|
|
58
|
-
target: setupTargetSummary(plan.
|
|
61
|
+
target: setupTargetSummary(plan.target, loaded.model),
|
|
59
62
|
renderer: plan.renderer ? setupRendererSummary(plan.renderer) : null,
|
|
60
63
|
commitment: plan.commitment || null,
|
|
61
64
|
onboardingComplete: !setup.draft
|
|
@@ -69,12 +72,12 @@ export function summarizeSetupResult(result) {
|
|
|
69
72
|
draft: result.draft,
|
|
70
73
|
changes: {
|
|
71
74
|
system: "saved",
|
|
72
|
-
workspace: "updated",
|
|
75
|
+
[result.program ? "program" : "workspace"]: "updated",
|
|
73
76
|
controls: result.linkedControlIds?.length || 0,
|
|
74
77
|
commitment: result.commitment ? "saved" : "unchanged"
|
|
75
78
|
},
|
|
76
79
|
system: setupSystemSummary(result.system),
|
|
77
|
-
target: setupTargetSummary(result.workspace),
|
|
80
|
+
target: setupTargetSummary(result.program || result.workspace, { modelVersion: result.program ? "4" : "3" }),
|
|
78
81
|
renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
|
|
79
82
|
commitment: result.commitment || null,
|
|
80
83
|
onboardingComplete: result.onboardingComplete
|
|
@@ -126,14 +129,16 @@ function validateSetup(loaded, setup) {
|
|
|
126
129
|
throw new Error(`System "${setup.systemId}" cannot be used for initial scope because it is ${system.status}.`);
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
|
-
const classifications =
|
|
132
|
+
const classifications = String(loaded.model.modelVersion) === "4"
|
|
133
|
+
? loaded.resources.filter(({ type, status }) => type === "classification" && status === "active").map(({ id }) => id)
|
|
134
|
+
: Object.keys(loaded.workspace.classificationDefinitions || {});
|
|
130
135
|
if (classifications.length && !classifications.includes(setup.classificationId)) {
|
|
131
136
|
throw new Error(`classificationId must be one of ${classifications.join(", ")}.`);
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
|
|
135
|
-
function findSetupSystem(resources,
|
|
136
|
-
const scopedSystemIds = new Set(
|
|
140
|
+
function findSetupSystem(resources, target, setup) {
|
|
141
|
+
const scopedSystemIds = new Set(target.systemIds || []);
|
|
137
142
|
return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
|
|
138
143
|
|| resources.find(({ type, id, title, status }) => (
|
|
139
144
|
type === "system"
|
|
@@ -144,7 +149,9 @@ function findSetupSystem(resources, workspace, setup) {
|
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
function buildSetupRecords(loaded, setup) {
|
|
147
|
-
const
|
|
152
|
+
const target = resolveProgram(loaded);
|
|
153
|
+
const v4 = String(loaded.model.modelVersion) === "4";
|
|
154
|
+
const existingSystem = findSetupSystem(loaded.resources, target, setup);
|
|
148
155
|
const systemId = existingSystem?.id || createResourceId(
|
|
149
156
|
"system",
|
|
150
157
|
setup.serviceName,
|
|
@@ -162,18 +169,46 @@ function buildSetupRecords(loaded, setup) {
|
|
|
162
169
|
: existingSystem?.status || "active",
|
|
163
170
|
criticality: setup.criticality,
|
|
164
171
|
ownerIds: [setup.ownerId],
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
...(v4 ? {
|
|
173
|
+
purpose: existingSystem?.purpose || setup.boundary,
|
|
174
|
+
servicesProvided: existingSystem?.servicesProvided || [setup.serviceName],
|
|
175
|
+
boundary: setup.boundary,
|
|
176
|
+
exclusions: existingSystem?.exclusions || []
|
|
177
|
+
} : {
|
|
178
|
+
description: setup.boundary,
|
|
179
|
+
systemKind: existingSystem?.systemKind || "service"
|
|
180
|
+
}),
|
|
167
181
|
classificationId: setup.classificationId,
|
|
168
182
|
internetExposed: setup.internetExposed
|
|
169
183
|
};
|
|
170
184
|
const existingWorkspace = loaded.resources.find(({ type }) => type === "workspace");
|
|
171
185
|
if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
|
|
172
|
-
const
|
|
173
|
-
...
|
|
186
|
+
const nextTarget = {
|
|
187
|
+
...target,
|
|
188
|
+
...(!setup.draft && v4 ? { status: "active" } : {}),
|
|
174
189
|
assuranceGoal: assuranceGoalFromSetup(setup.programGoal),
|
|
175
|
-
systemIds: [...new Set([...(
|
|
190
|
+
systemIds: [...new Set([...(target.systemIds || []), systemId])]
|
|
176
191
|
};
|
|
192
|
+
const componentEntry = v4
|
|
193
|
+
? loaded.resources.find(({ id, type }) => id === "component-filegrc-program-repository" && type === "component")
|
|
194
|
+
: null;
|
|
195
|
+
const existingSystemUses = componentEntry?.systemUses || [];
|
|
196
|
+
const component = componentEntry ? {
|
|
197
|
+
...componentEntry,
|
|
198
|
+
status: setup.draft
|
|
199
|
+
? componentEntry.status || "planned"
|
|
200
|
+
: componentEntry.status === "planned"
|
|
201
|
+
? "active"
|
|
202
|
+
: componentEntry.status || "active",
|
|
203
|
+
systemUses: [
|
|
204
|
+
...existingSystemUses.filter((use) => use.systemId !== systemId),
|
|
205
|
+
{
|
|
206
|
+
systemId,
|
|
207
|
+
roles: ["control-support", "evidence-source", "supporting-operations"],
|
|
208
|
+
rationale: "FileGRC stores the Program records, retained evidence index, and Git revision history used to operate and support this System's Controls."
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
} : null;
|
|
177
212
|
const existingRenderer = loaded.resources.find(({ type }) => type === "renderer-settings");
|
|
178
213
|
const renderer = existingRenderer ? { ...existingRenderer, showOnboarding: setup.draft } : null;
|
|
179
214
|
const existingCommitment = loaded.resources.find((record) => (
|
|
@@ -181,7 +216,7 @@ function buildSetupRecords(loaded, setup) {
|
|
|
181
216
|
&& !["superseded", "retired"].includes(record.status)
|
|
182
217
|
&& (record.systemIds || []).includes(systemId)
|
|
183
218
|
));
|
|
184
|
-
const commitment = String(loaded.model.modelVersion)
|
|
219
|
+
const commitment = ["3", "4"].includes(String(loaded.model.modelVersion)) && !existingCommitment
|
|
185
220
|
? {
|
|
186
221
|
id: createResourceId(
|
|
187
222
|
"commitment",
|
|
@@ -196,11 +231,11 @@ function buildSetupRecords(loaded, setup) {
|
|
|
196
231
|
systemIds: [systemId],
|
|
197
232
|
ownerIds: [setup.ownerId],
|
|
198
233
|
customerFacing: true,
|
|
199
|
-
...(
|
|
200
|
-
...(
|
|
234
|
+
...(selectedRequirementIds(nextTarget, loaded.model).length ? { requirementIds: selectedRequirementIds(nextTarget, loaded.model) } : {}),
|
|
235
|
+
...(nextTarget.controlIds?.length ? { controlIds: [...nextTarget.controlIds] } : {})
|
|
201
236
|
}
|
|
202
237
|
: null;
|
|
203
|
-
return { existingSystem, system, workspace, renderer, commitment };
|
|
238
|
+
return { existingSystem, system, workspace: existingWorkspace, target: nextTarget, component, renderer, commitment };
|
|
204
239
|
}
|
|
205
240
|
|
|
206
241
|
function assuranceGoalFromSetup(goal) {
|
|
@@ -214,14 +249,14 @@ function setupSystemSummary(system) {
|
|
|
214
249
|
return { ...system };
|
|
215
250
|
}
|
|
216
251
|
|
|
217
|
-
function setupTargetSummary(workspace) {
|
|
252
|
+
function setupTargetSummary(workspace, model) {
|
|
218
253
|
return {
|
|
219
254
|
assuranceGoal: workspace.assuranceGoal,
|
|
220
255
|
systemIds: [...(workspace.systemIds || [])],
|
|
221
256
|
scopeCounts: {
|
|
222
257
|
systems: workspace.systemIds?.length || 0,
|
|
223
258
|
frameworks: workspace.frameworkIds?.length || 0,
|
|
224
|
-
requirements: workspace.
|
|
259
|
+
requirements: selectedRequirementIds(workspace, model).length,
|
|
225
260
|
controls: workspace.controlIds?.length || 0
|
|
226
261
|
}
|
|
227
262
|
};
|
package/src/source-coverage.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
export function sourceCoverageComplete(record, loaded) {
|
|
1
|
+
export function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
|
|
2
2
|
if (!record?.validFrom || !record.collectionCadence || !record.retention || !record.reconciliationMethod) {
|
|
3
3
|
return false;
|
|
4
4
|
}
|
|
5
|
-
|
|
5
|
+
const external = record.coverageKind === "external-system" || record.coverageKind === "external-component";
|
|
6
|
+
const sourceId = record.componentId || record.systemId;
|
|
7
|
+
if (external && (!sourceId || !(record.retrieverIds || []).length)) {
|
|
6
8
|
return false;
|
|
7
9
|
}
|
|
8
10
|
if (["not-applicable", "zero-population"].includes(record.coverageKind) && !record.applicabilityReview) {
|
|
9
11
|
return false;
|
|
10
12
|
}
|
|
11
|
-
if (
|
|
13
|
+
if (program?.candidateCoverage && !(record.readinessTestEvidenceIds || []).length) {
|
|
12
14
|
return false;
|
|
13
15
|
}
|
|
14
|
-
if (
|
|
16
|
+
if (program?.candidateCoverage) {
|
|
15
17
|
const tests = (record.readinessTestEvidenceIds || []).map((id) => (
|
|
16
18
|
loaded.resources.find((resource) => resource.id === id && resource.type === "evidence")
|
|
17
19
|
));
|
|
@@ -21,16 +23,18 @@ export function sourceCoverageComplete(record, loaded) {
|
|
|
21
23
|
|| test.retrievalResult !== "passed"
|
|
22
24
|
|| test.accessConfirmed !== true
|
|
23
25
|
|| !(test.coveredSourceFamilyIds || []).includes(record.sourceFamilyId)
|
|
24
|
-
|| (
|
|
25
|
-
test.
|
|
26
|
-
||
|
|
26
|
+
|| (external && !(
|
|
27
|
+
test.sourceComponentId === sourceId
|
|
28
|
+
|| test.sourceSystemId === sourceId
|
|
29
|
+
|| (test.componentIds || []).includes(sourceId)
|
|
30
|
+
|| (test.systemIds || []).includes(sourceId)
|
|
27
31
|
))
|
|
28
32
|
))) return false;
|
|
29
33
|
}
|
|
30
34
|
return true;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
export function assessSourceCoverageReadiness(loaded, selectedControlIds = []) {
|
|
37
|
+
export function assessSourceCoverageReadiness(loaded, selectedControlIds = [], program = loaded.workspace) {
|
|
34
38
|
if (!loaded.model.resources["source-coverage"]) return [];
|
|
35
39
|
const selected = new Set(selectedControlIds);
|
|
36
40
|
const selectedControlCodes = new Set(loaded.resources
|
|
@@ -55,7 +59,7 @@ export function assessSourceCoverageReadiness(loaded, selectedControlIds = []) {
|
|
|
55
59
|
return {
|
|
56
60
|
family,
|
|
57
61
|
record,
|
|
58
|
-
complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded))
|
|
62
|
+
complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded, program))
|
|
59
63
|
};
|
|
60
64
|
});
|
|
61
65
|
}
|
package/src/state.js
CHANGED
|
@@ -12,6 +12,7 @@ import { currentCalendarDate } from "./time.js";
|
|
|
12
12
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
13
13
|
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
14
14
|
import { assessWorkflow } from "./workflow.js";
|
|
15
|
+
import { measureTiming } from "./timing.js";
|
|
15
16
|
|
|
16
17
|
const renderedMarkdownCache = new Map();
|
|
17
18
|
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
@@ -37,19 +38,21 @@ async function createAppStateUnlocked(input, options) {
|
|
|
37
38
|
? getWorkspaceHistories(loaded.root, loaded.entries.map((entry) => `data/${entry.relativePath}`), 12)
|
|
38
39
|
: new Map();
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
await measureTiming("state-entries", async () => {
|
|
42
|
+
for (const entry of loaded.entries) {
|
|
43
|
+
entries.push(await createStateEntry(loaded, entry, {
|
|
44
|
+
includeDetails,
|
|
45
|
+
history: histories.get(`data/${entry.relativePath}`) ?? []
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
46
49
|
|
|
47
50
|
const git = getGitSummary(loaded.root);
|
|
48
51
|
delete git.root;
|
|
49
|
-
const repository = await getBrowserRepositoryState(loaded.root, {
|
|
52
|
+
const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded.root, {
|
|
50
53
|
readOnly: options.readOnly,
|
|
51
54
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
|
|
52
|
-
});
|
|
55
|
+
}));
|
|
53
56
|
const workspace = loaded.workspace ?? {
|
|
54
57
|
dataModelVersion: loaded.model.modelVersion,
|
|
55
58
|
id: "workspace",
|
|
@@ -60,12 +63,12 @@ async function createAppStateUnlocked(input, options) {
|
|
|
60
63
|
};
|
|
61
64
|
const asOf = options.asOf ?? currentCalendarDate(workspace.timezone);
|
|
62
65
|
const generatedAt = new Date().toISOString();
|
|
63
|
-
const programReadiness = await assessProgramReadiness(loaded, {
|
|
66
|
+
const programReadiness = await measureTiming("state-program-readiness", () => assessProgramReadiness(loaded, {
|
|
64
67
|
asOf,
|
|
65
68
|
generatedAt
|
|
66
|
-
});
|
|
69
|
+
}));
|
|
67
70
|
const audits = loaded.resources.filter((record) => record.type === "audit");
|
|
68
|
-
const auditPreparations = Object.fromEntries(await Promise.all(
|
|
71
|
+
const auditPreparations = await measureTiming("state-audit-preparation", async () => Object.fromEntries(await Promise.all(
|
|
69
72
|
(audits.length ? audits : [null]).map(async (audit) => {
|
|
70
73
|
const preparation = await assessAuditPreparation(loaded, {
|
|
71
74
|
auditId: audit?.id,
|
|
@@ -74,13 +77,13 @@ async function createAppStateUnlocked(input, options) {
|
|
|
74
77
|
});
|
|
75
78
|
return [audit?.id || "none", preparation];
|
|
76
79
|
})
|
|
77
|
-
));
|
|
80
|
+
)));
|
|
78
81
|
const obligations = planObligations(entries, {
|
|
79
82
|
asOf,
|
|
80
83
|
now: options.now ?? generatedAt,
|
|
81
84
|
model: loaded.model
|
|
82
85
|
});
|
|
83
|
-
const workflow = await assessWorkflow(loaded, {
|
|
86
|
+
const workflow = await measureTiming("state-workflow", () => assessWorkflow(loaded, {
|
|
84
87
|
asOf,
|
|
85
88
|
evaluatedAt: generatedAt,
|
|
86
89
|
programReadiness,
|
|
@@ -90,7 +93,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
90
93
|
obligations,
|
|
91
94
|
git,
|
|
92
95
|
validation
|
|
93
|
-
});
|
|
96
|
+
}));
|
|
94
97
|
const collectionReviews = Object.fromEntries(
|
|
95
98
|
assessCollectionReviews(loaded).map((assessment) => [
|
|
96
99
|
assessment.resourceType,
|
package/src/timing.js
CHANGED
|
@@ -5,7 +5,9 @@ const timingContext = new AsyncLocalStorage();
|
|
|
5
5
|
|
|
6
6
|
export async function collectTimings(task) {
|
|
7
7
|
const timings = new Map();
|
|
8
|
+
const started = performance.now();
|
|
8
9
|
const result = await timingContext.run(timings, task);
|
|
10
|
+
recordCollectedTiming(timings, "total", performance.now() - started);
|
|
9
11
|
return { result, timings: Object.fromEntries(timings) };
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -30,6 +32,10 @@ export function measureTimingSync(name, task) {
|
|
|
30
32
|
export function recordTiming(name, durationMs) {
|
|
31
33
|
const timings = timingContext.getStore();
|
|
32
34
|
if (!timings) return;
|
|
35
|
+
recordCollectedTiming(timings, name, durationMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function recordCollectedTiming(timings, name, durationMs) {
|
|
33
39
|
const current = timings.get(name) ?? { count: 0, durationMs: 0 };
|
|
34
40
|
current.count += 1;
|
|
35
41
|
current.durationMs += durationMs;
|
package/src/validate.js
CHANGED
|
@@ -73,6 +73,10 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
73
73
|
validateRecord(record, definition, loaded.model, displayPath, diagnostics);
|
|
74
74
|
validateDateRanges(record, displayPath, diagnostics);
|
|
75
75
|
if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
|
|
76
|
+
if (record.type === "program") validateProgram(record, displayPath, diagnostics);
|
|
77
|
+
if (record.type === "component") validateComponent(record, displayPath, diagnostics);
|
|
78
|
+
if (record.type === "control") validateControlComponents(record, byId, displayPath, diagnostics);
|
|
79
|
+
if (record.type === "audit") validateAuditSubservices(record, byId, displayPath, diagnostics);
|
|
76
80
|
if (record.type === "collection-review") {
|
|
77
81
|
validateCollectionReview(record, loaded.model, loaded.resources, byId, displayPath, diagnostics);
|
|
78
82
|
}
|
|
@@ -83,7 +87,7 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
83
87
|
if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
|
|
84
88
|
if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
|
|
85
89
|
validateCoverage(record, displayPath, diagnostics);
|
|
86
|
-
validateClassification(record, loaded
|
|
90
|
+
validateClassification(record, loaded, displayPath, diagnostics);
|
|
87
91
|
validateCompletionDates(record, displayPath, diagnostics);
|
|
88
92
|
await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
|
|
89
93
|
|
|
@@ -167,7 +171,24 @@ function validateCollectionReview(record, model, resources, byId, path, diagnost
|
|
|
167
171
|
));
|
|
168
172
|
return;
|
|
169
173
|
}
|
|
170
|
-
const
|
|
174
|
+
const program = String(model.modelVersion) === "4"
|
|
175
|
+
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
176
|
+
: null;
|
|
177
|
+
const programSystemIds = new Set(program?.systemIds || []);
|
|
178
|
+
const componentIds = new Set(resources.filter((candidate) => (
|
|
179
|
+
candidate.type === "component"
|
|
180
|
+
&& (candidate.systemUses || []).some(({ systemId }) => programSystemIds.has(systemId))
|
|
181
|
+
)).map(({ id }) => id));
|
|
182
|
+
const selectedIds = {
|
|
183
|
+
system: programSystemIds,
|
|
184
|
+
component: componentIds,
|
|
185
|
+
framework: new Set(program?.frameworkIds || []),
|
|
186
|
+
vendor: new Set(resources.filter(({ id }) => componentIds.has(id)).map(({ vendorId }) => vendorId).filter(Boolean)),
|
|
187
|
+
asset: new Set(resources.filter(({ type, componentIds: ids }) => type === "asset" && (ids || []).some((id) => componentIds.has(id))).map(({ id }) => id))
|
|
188
|
+
}[record.resourceType];
|
|
189
|
+
const recordCount = resources.filter(({ type, id }) => (
|
|
190
|
+
type === record.resourceType && (!program || !selectedIds || selectedIds.has(id))
|
|
191
|
+
)).length;
|
|
171
192
|
if (!recordCount && record.decision === "complete") {
|
|
172
193
|
diagnostics.push(error(
|
|
173
194
|
"invalid-collection-review-decision",
|
|
@@ -184,12 +205,12 @@ function validateCollectionReview(record, model, resources, byId, path, diagnost
|
|
|
184
205
|
}
|
|
185
206
|
if (
|
|
186
207
|
record.decision === "externally-managed"
|
|
187
|
-
&& byId.get(record.authoritativeSystemId)?.status !== "active"
|
|
208
|
+
&& byId.get(record.authoritativeComponentId || record.authoritativeSystemId)?.status !== "active"
|
|
188
209
|
) {
|
|
189
210
|
diagnostics.push(error(
|
|
190
211
|
"inactive-authoritative-system",
|
|
191
212
|
path,
|
|
192
|
-
`${configuration.title} must name an active authoritative System for an externally managed conclusion.`
|
|
213
|
+
`${configuration.title} must name an active authoritative ${String(model.modelVersion) === "4" ? "Component" : "System"} for an externally managed conclusion.`
|
|
193
214
|
));
|
|
194
215
|
}
|
|
195
216
|
}
|
|
@@ -302,6 +323,64 @@ function validateAppointment(record, byId, path, diagnostics) {
|
|
|
302
323
|
));
|
|
303
324
|
}
|
|
304
325
|
|
|
326
|
+
function validateProgram(record, path, diagnostics) {
|
|
327
|
+
const requirementIds = (record.requirementApplicability || [])
|
|
328
|
+
.map(({ requirementId }) => requirementId)
|
|
329
|
+
.filter(Boolean);
|
|
330
|
+
if (new Set(requirementIds).size !== requirementIds.length) {
|
|
331
|
+
diagnostics.push(error(
|
|
332
|
+
"duplicate-program-applicability",
|
|
333
|
+
path,
|
|
334
|
+
"A Program may record each Requirement only once in requirementApplicability."
|
|
335
|
+
));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function validateComponent(record, path, diagnostics) {
|
|
340
|
+
const systemIds = (record.systemUses || []).map(({ systemId }) => systemId).filter(Boolean);
|
|
341
|
+
if (new Set(systemIds).size !== systemIds.length) {
|
|
342
|
+
diagnostics.push(error("duplicate-system-use", path, "A Component may name each System only once in systemUses."));
|
|
343
|
+
}
|
|
344
|
+
for (const [index, use] of (record.systemUses || []).entries()) {
|
|
345
|
+
if (!(use.roles || []).length || !String(use.rationale || "").trim()) {
|
|
346
|
+
diagnostics.push(error("incomplete-system-use", path, `systemUses[${index}] needs at least one role and a rationale.`));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function validateControlComponents(record, byId, path, diagnostics) {
|
|
352
|
+
if (record.status !== "implemented") return;
|
|
353
|
+
const systemIds = new Set(record.systemIds || []);
|
|
354
|
+
for (const field of ["componentIds", "evidenceSourceComponentIds"]) {
|
|
355
|
+
for (const id of record[field] || []) {
|
|
356
|
+
const component = byId.get(id);
|
|
357
|
+
if (component?.type !== "component") continue;
|
|
358
|
+
const uses = (component.systemUses || []).filter(({ systemId }) => systemIds.has(systemId));
|
|
359
|
+
if (!uses.length) {
|
|
360
|
+
diagnostics.push(error("component-outside-control-scope", path, `${field} Component "${id}" has no use in a System where this Control applies.`));
|
|
361
|
+
}
|
|
362
|
+
if (field === "evidenceSourceComponentIds" && !uses.some(({ roles }) => (roles || []).includes("evidence-source"))) {
|
|
363
|
+
diagnostics.push(error("invalid-evidence-source-component", path, `Component "${id}" must have the evidence-source role in a System where this Control applies.`));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function validateAuditSubservices(record, byId, path, diagnostics) {
|
|
370
|
+
for (const [index, treatment] of (record.subserviceTreatments || []).entries()) {
|
|
371
|
+
for (const componentId of treatment.componentIds || []) {
|
|
372
|
+
const component = byId.get(componentId);
|
|
373
|
+
if (component?.type === "component" && component.vendorId !== treatment.vendorId) {
|
|
374
|
+
diagnostics.push(error(
|
|
375
|
+
"subservice-vendor-mismatch",
|
|
376
|
+
path,
|
|
377
|
+
`subserviceTreatments[${index}] Component "${componentId}" is not supplied by Vendor "${treatment.vendorId}".`
|
|
378
|
+
));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
305
384
|
function validateCompletedObligationEvent(record, byId, model, path, diagnostics) {
|
|
306
385
|
if (record.type !== "obligation-event" || record.status !== "complete") return;
|
|
307
386
|
if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
|
|
@@ -334,7 +413,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
334
413
|
? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
|
|
335
414
|
: [];
|
|
336
415
|
if (!expectedTypes.length) continue;
|
|
337
|
-
const completionIds = String(model.modelVersion)
|
|
416
|
+
const completionIds = ["3", "4"].includes(String(model.modelVersion))
|
|
338
417
|
? action.completionResourceIds || []
|
|
339
418
|
: [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
|
|
340
419
|
const linked = [...new Set(completionIds)]
|
|
@@ -352,7 +431,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
352
431
|
|
|
353
432
|
function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
|
|
354
433
|
if (
|
|
355
|
-
String(model.modelVersion)
|
|
434
|
+
!["3", "4"].includes(String(model.modelVersion))
|
|
356
435
|
|| record.status !== "done"
|
|
357
436
|
|| !record.obligationId
|
|
358
437
|
) return;
|
|
@@ -754,9 +833,19 @@ function validateCoverage(record, path, diagnostics) {
|
|
|
754
833
|
}
|
|
755
834
|
}
|
|
756
835
|
|
|
757
|
-
function validateClassification(record,
|
|
836
|
+
function validateClassification(record, loaded, path, diagnostics) {
|
|
758
837
|
if (!record.classificationId) return;
|
|
759
|
-
|
|
838
|
+
if (String(loaded.model.modelVersion) === "4") {
|
|
839
|
+
if (!loaded.resources.some(({ id, type }) => id === record.classificationId && type === "classification")) {
|
|
840
|
+
diagnostics.push(error(
|
|
841
|
+
"unknown-classification",
|
|
842
|
+
path,
|
|
843
|
+
`classificationId references undefined Classification "${record.classificationId}".`
|
|
844
|
+
));
|
|
845
|
+
}
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const definitions = loaded.workspace?.classificationDefinitions;
|
|
760
849
|
if (
|
|
761
850
|
!definitions
|
|
762
851
|
|| Array.isArray(definitions)
|
|
@@ -1076,8 +1165,10 @@ function validateArrayItem(name, value, field, model, path, diagnostics, index)
|
|
|
1076
1165
|
diagnostics.push(error("invalid-field", path, `${name} items must be objects.`));
|
|
1077
1166
|
} else if (type === "object" && field.itemObjectType) {
|
|
1078
1167
|
validateObjectValue(`${name}[${index}]`, value, field.itemObjectType, model, path, diagnostics);
|
|
1079
|
-
} else if ((type === "string" || type === "data-path") && typeof value !== "string") {
|
|
1168
|
+
} else if ((type === "string" || type === "data-path" || type === "enum") && typeof value !== "string") {
|
|
1080
1169
|
diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
|
|
1170
|
+
} else if (type === "enum" && field.values && !field.values.includes(value)) {
|
|
1171
|
+
diagnostics.push(error("invalid-field", path, `${name} items must be one of ${field.values.join(", ")}.`));
|
|
1081
1172
|
} else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
|
|
1082
1173
|
diagnostics.push(error("invalid-field", path, `${name} items must be lowercase kebab-case IDs.`));
|
|
1083
1174
|
}
|