filegrc 0.6.5 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/model/v4.json +179 -78
- package/package.json +2 -2
- package/src/audit-preparation.js +465 -48
- package/src/audit-transition.js +5 -0
- package/src/batch-review.js +14 -5
- package/src/cli.js +107 -0
- package/src/content-readiness.js +11 -0
- package/src/evidence-packet.js +202 -36
- package/src/files.js +58 -3
- package/src/git.js +39 -7
- package/src/index.js +7 -0
- package/src/policy-activation.js +84 -0
- package/src/policy-library/information-security-policy-v2.md +290 -0
- package/src/policy-library.js +826 -0
- package/src/program-lifecycle.js +4 -0
- package/src/program-path.js +12 -11
- package/src/program-readiness.js +309 -42
- package/src/server.js +8 -0
- package/src/setup.js +18 -1
- package/src/soc2.js +227 -0
- package/src/state.js +8 -0
- package/src/validate.js +43 -17
- package/src/web.js +218 -25
- package/src/workflow.js +111 -41
package/src/soc2.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
2
|
+
|
|
3
|
+
export const REQUIRED_SOC2_DESCRIPTION_REFERENCES = Array.from(
|
|
4
|
+
{ length: 9 },
|
|
5
|
+
(_, index) => `DC${index + 1}`
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
export const REQUIRED_SOC2_SECURITY_REFERENCES = [
|
|
9
|
+
"CC1.1",
|
|
10
|
+
"CC1.2",
|
|
11
|
+
"CC1.3",
|
|
12
|
+
"CC1.4",
|
|
13
|
+
"CC1.5",
|
|
14
|
+
"CC2.1",
|
|
15
|
+
"CC2.2",
|
|
16
|
+
"CC2.3",
|
|
17
|
+
"CC3.1",
|
|
18
|
+
"CC3.2",
|
|
19
|
+
"CC3.3",
|
|
20
|
+
"CC3.4",
|
|
21
|
+
"CC4.1",
|
|
22
|
+
"CC4.2",
|
|
23
|
+
"CC5.1",
|
|
24
|
+
"CC5.2",
|
|
25
|
+
"CC5.3",
|
|
26
|
+
"CC6.1",
|
|
27
|
+
"CC6.2",
|
|
28
|
+
"CC6.3",
|
|
29
|
+
"CC6.4",
|
|
30
|
+
"CC6.5",
|
|
31
|
+
"CC6.6",
|
|
32
|
+
"CC6.7",
|
|
33
|
+
"CC6.8",
|
|
34
|
+
"CC7.1",
|
|
35
|
+
"CC7.2",
|
|
36
|
+
"CC7.3",
|
|
37
|
+
"CC7.4",
|
|
38
|
+
"CC7.5",
|
|
39
|
+
"CC8.1",
|
|
40
|
+
"CC9.1",
|
|
41
|
+
"CC9.2"
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const SOC2_PROGRAM_GOALS = new Set([
|
|
45
|
+
"readiness",
|
|
46
|
+
"soc-2-type-1",
|
|
47
|
+
"soc-2-type-2"
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export function soc2RequirementApplicabilityConstraint(requirement, program, modelVersion = "4") {
|
|
51
|
+
if (
|
|
52
|
+
String(modelVersion) !== "4"
|
|
53
|
+
|| requirement?.type !== "requirement"
|
|
54
|
+
|| !SOC2_PROGRAM_GOALS.has(program?.assuranceGoal)
|
|
55
|
+
) return null;
|
|
56
|
+
const reference = String(requirement.reference || "").trim().toUpperCase();
|
|
57
|
+
const security = REQUIRED_SOC2_SECURITY_REFERENCES.includes(reference);
|
|
58
|
+
const description = REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(reference);
|
|
59
|
+
if (!security && !description) return null;
|
|
60
|
+
const family = security ? "Security Common Criteria" : "SOC 2 Description Criteria";
|
|
61
|
+
return {
|
|
62
|
+
requiredDecision: "applicable",
|
|
63
|
+
allowedDecisions: ["applicable"],
|
|
64
|
+
message: `${reference} is required for the selected SOC 2 Security program.`,
|
|
65
|
+
defaultRationale: `${reference} is part of the required ${family} baseline for the selected ${soc2GoalLabel(program.assuranceGoal)} Program.`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function soc2GoalLabel(goal) {
|
|
70
|
+
if (goal === "soc-2-type-1") return "SOC 2 Type 1";
|
|
71
|
+
if (goal === "soc-2-type-2") return "SOC 2 Type 2";
|
|
72
|
+
return "SOC 2 readiness";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function missingSoc2References(requirements, requiredReferences) {
|
|
76
|
+
const references = new Set(requirements.map(({ reference }) => String(reference || "").trim().toUpperCase()));
|
|
77
|
+
return requiredReferences.filter((reference) => !references.has(reference));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function recordWasInUseDuringAudit(record, engagementStart, engagementEnd) {
|
|
81
|
+
if (!record) return false;
|
|
82
|
+
if (record.type === "vendor") {
|
|
83
|
+
if (!["active", "deprecated", "terminated"].includes(record.status)) return false;
|
|
84
|
+
const endedOn = record.endDate || (record.status === "terminated" ? record.statusTransition?.changedOn : null);
|
|
85
|
+
if (engagementEnd && record.startDate && record.startDate > engagementEnd) return false;
|
|
86
|
+
if (engagementStart && endedOn && endedOn < engagementStart) return false;
|
|
87
|
+
if (record.status === "terminated" && engagementStart && !endedOn) return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (["active", "deprecated"].includes(record.status)) return true;
|
|
91
|
+
if (!engagementStart) return false;
|
|
92
|
+
if (["component", "system"].includes(record.type) && record.status === "retired") {
|
|
93
|
+
return Boolean(record.statusTransition?.changedOn && record.statusTransition.changedOn >= engagementStart);
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function auditorWasEngaged(auditor, audit) {
|
|
99
|
+
const engagementStart = audit?.managementAcknowledgedOn
|
|
100
|
+
|| audit?.fieldworkStart
|
|
101
|
+
|| coverageStart(audit?.coverage);
|
|
102
|
+
const engagementEnd = audit?.reportDate
|
|
103
|
+
|| audit?.fieldworkEnd
|
|
104
|
+
|| coverageEnd(audit?.coverage);
|
|
105
|
+
if (!recordWasInUseDuringAudit(auditor, engagementStart, engagementEnd)) return false;
|
|
106
|
+
const endedOn = auditor.endDate || (auditor.status === "terminated" ? auditor.statusTransition?.changedOn : null);
|
|
107
|
+
if (engagementStart && auditor.startDate && auditor.startDate > engagementStart) return false;
|
|
108
|
+
if (engagementEnd && endedOn && endedOn < engagementEnd) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function personWasActiveOn(person, on) {
|
|
113
|
+
if (person?.type !== "person" || !on) return false;
|
|
114
|
+
if (person.startDate && person.startDate > on) return false;
|
|
115
|
+
const endedOn = person.endDate || (person.status === "inactive" ? person.statusTransition?.changedOn : null);
|
|
116
|
+
if (endedOn && endedOn < on) return false;
|
|
117
|
+
return person.status === "active" || (person.status === "inactive" && Boolean(endedOn));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function appointmentWasAuthorizedOn(appointment, on, byId) {
|
|
121
|
+
if (appointment?.type !== "appointment" || !on || !["active", "ended"].includes(appointment.status)) return false;
|
|
122
|
+
if (!appointment.startsOn || appointment.startsOn > on) return false;
|
|
123
|
+
if (appointment.endsOn && appointment.endsOn < on) return false;
|
|
124
|
+
return personWasActiveOn(byId.get(appointment.holderId), on);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function signatoryAppointmentIssue(audit, byId) {
|
|
128
|
+
if (!audit?.reportDate) {
|
|
129
|
+
return {
|
|
130
|
+
code: "signatory-authority-date-missing",
|
|
131
|
+
message: "Record the CPA report date before confirming who had authority to sign management's assertion and written representations."
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const ids = audit.signatoryAppointmentIds || [];
|
|
135
|
+
if (!ids.length) {
|
|
136
|
+
return {
|
|
137
|
+
code: "signatory-authority-missing",
|
|
138
|
+
message: "Link the dated authority Appointment for each management assertion and representation signer."
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const permittedScopes = new Set([
|
|
142
|
+
"workspace",
|
|
143
|
+
audit.id,
|
|
144
|
+
audit.programId,
|
|
145
|
+
...(audit.systemIds || []),
|
|
146
|
+
...[...byId.values()].filter((record) => record.type === "organization").map(({ id }) => id)
|
|
147
|
+
].filter(Boolean));
|
|
148
|
+
const invalid = ids.filter((id) => {
|
|
149
|
+
const appointment = byId.get(id);
|
|
150
|
+
return !appointmentWasAuthorizedOn(appointment, audit.reportDate, byId)
|
|
151
|
+
|| !(appointment.scopeResourceIds || []).some((scopeId) => permittedScopes.has(scopeId));
|
|
152
|
+
});
|
|
153
|
+
if (invalid.length) {
|
|
154
|
+
return {
|
|
155
|
+
code: "signatory-authority-invalid",
|
|
156
|
+
message: `Confirm that ${invalid.join(", ")} names a dated Appointment whose holder was active and whose scope covered the workspace, Program, or Audit on ${audit.reportDate}.`
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function soc2ReportEvidenceIssue(evidence, audit, modelVersion = "4") {
|
|
163
|
+
if (
|
|
164
|
+
evidence?.type !== "evidence"
|
|
165
|
+
|| evidence.status !== "verified"
|
|
166
|
+
|| evidence.artifactKind !== "third-party-report"
|
|
167
|
+
|| evidence.artifactSubtype !== "soc2-report"
|
|
168
|
+
) {
|
|
169
|
+
return {
|
|
170
|
+
code: "invalid-audit-report-evidence",
|
|
171
|
+
message: `${evidence?.title || audit?.title || "The audit"} must be verified third-party-report Evidence with subtype soc2-report for the issued SOC 2 report.`
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const issuedOn = (String(modelVersion) === "4"
|
|
175
|
+
? evidence.sourceGeneratedAt
|
|
176
|
+
: evidence.sourceGeneratedAt || evidence.businessEventAt || evidence.collectedOn
|
|
177
|
+
)?.slice(0, 10);
|
|
178
|
+
if (!issuedOn) {
|
|
179
|
+
return {
|
|
180
|
+
code: "audit-report-date-missing",
|
|
181
|
+
message: `Record the issued SOC 2 report's actual issuance timestamp in ${evidence.title}'s sourceGeneratedAt field.`
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (audit?.reportDate && issuedOn !== audit.reportDate) {
|
|
185
|
+
return {
|
|
186
|
+
code: "audit-report-date-mismatch",
|
|
187
|
+
message: `${evidence.title} is dated ${issuedOn}, which does not match the Audit reportDate ${audit.reportDate}.`
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (audit?.reportDate && audit.opinionDate && audit.opinionDate !== audit.reportDate) {
|
|
191
|
+
return {
|
|
192
|
+
code: "audit-opinion-date-mismatch",
|
|
193
|
+
message: `The Audit opinionDate ${audit.opinionDate} does not match reportDate ${audit.reportDate}. Reconcile both dates to the issued CPA report.`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function subsequentEventsReviewIssue(audit) {
|
|
200
|
+
const review = audit?.subsequentEventsReview;
|
|
201
|
+
if (!review) {
|
|
202
|
+
return {
|
|
203
|
+
code: "subsequent-events-review-missing",
|
|
204
|
+
message: "Review incidents, changes, findings, fraud, legal matters, subservice coverage, representations, and other relevant events through the CPA report date."
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (!(review.reviewedByIds || []).length || !String(review.conclusion || "").trim()) {
|
|
208
|
+
return {
|
|
209
|
+
code: "subsequent-events-review-incomplete",
|
|
210
|
+
message: "The subsequent-events review must name its reviewers and record management's conclusion."
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const requiredThroughOn = audit.reportDate || audit.fieldworkEnd || audit.coverage?.endsOn || audit.coverage?.on;
|
|
214
|
+
if (!review.throughOn || (requiredThroughOn && review.throughOn < requiredThroughOn)) {
|
|
215
|
+
return {
|
|
216
|
+
code: "subsequent-events-period-incomplete",
|
|
217
|
+
message: `The subsequent-events review must cover through ${requiredThroughOn || "the latest engagement date"}${audit.reportDate ? ", the CPA report date" : ""}.`
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (!review.reviewedOn || review.reviewedOn < review.throughOn) {
|
|
221
|
+
return {
|
|
222
|
+
code: "subsequent-events-review-date-invalid",
|
|
223
|
+
message: "The subsequent-events review date must be on or after the date through which events were reviewed."
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
package/src/state.js
CHANGED
|
@@ -8,11 +8,13 @@ import { planObligations } from "./obligations.js";
|
|
|
8
8
|
import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
9
9
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
10
10
|
import { markdownEntries } from "./resource-markdown.js";
|
|
11
|
+
import { resolveProgram } from "./program.js";
|
|
11
12
|
import { currentCalendarDate } from "./time.js";
|
|
12
13
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
13
14
|
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
14
15
|
import { assessWorkflow } from "./workflow.js";
|
|
15
16
|
import { measureTiming } from "./timing.js";
|
|
17
|
+
import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
|
|
16
18
|
|
|
17
19
|
const renderedMarkdownCache = new Map();
|
|
18
20
|
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
@@ -82,6 +84,11 @@ async function createAppStateUnlocked(input, options) {
|
|
|
82
84
|
asOf,
|
|
83
85
|
generatedAt
|
|
84
86
|
}));
|
|
87
|
+
const activeProgram = resolveProgram(loaded);
|
|
88
|
+
const applicabilityConstraints = Object.fromEntries(loaded.resources.flatMap((record) => {
|
|
89
|
+
const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
|
|
90
|
+
return constraint ? [[record.id, constraint]] : [];
|
|
91
|
+
}));
|
|
85
92
|
const audits = loaded.resources.filter((record) => record.type === "audit");
|
|
86
93
|
const auditPreparations = await measureTiming("state-audit-preparation", async () => Object.fromEntries(await Promise.all(
|
|
87
94
|
(audits.length ? audits : [null]).map(async (audit) => {
|
|
@@ -140,6 +147,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
140
147
|
},
|
|
141
148
|
obligations,
|
|
142
149
|
collectionReviews,
|
|
150
|
+
applicabilityConstraints,
|
|
143
151
|
programReadiness,
|
|
144
152
|
auditPreparations,
|
|
145
153
|
workflow,
|
package/src/validate.js
CHANGED
|
@@ -7,7 +7,7 @@ import { collectionRevision } from "./collection-revision.js";
|
|
|
7
7
|
import { isSafeGitName } from "./git-name.js";
|
|
8
8
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
9
9
|
import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
|
|
10
|
-
import {
|
|
10
|
+
import { obligationIsEnabled } from "./program-lifecycle.js";
|
|
11
11
|
import { partyPeople } from "./parties.js";
|
|
12
12
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
13
13
|
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
@@ -74,6 +74,7 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
74
74
|
validateLocation(record, definition, entry.relativePath, diagnostics);
|
|
75
75
|
validateRecord(record, definition, loaded.model, displayPath, diagnostics);
|
|
76
76
|
validateDateRanges(record, displayPath, diagnostics);
|
|
77
|
+
validateProposedEffectiveDate(record, asOf, displayPath, diagnostics);
|
|
77
78
|
if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
|
|
78
79
|
if (record.type === "program") validateProgram(record, displayPath, diagnostics);
|
|
79
80
|
if (record.type === "component") validateComponent(record, displayPath, diagnostics);
|
|
@@ -135,7 +136,7 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
135
136
|
}
|
|
136
137
|
validateIndependentApproval(record, byId, displayPath, diagnostics);
|
|
137
138
|
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
138
|
-
validateImplementedControlSchedules(record, obligationsByControl,
|
|
139
|
+
validateImplementedControlSchedules(record, obligationsByControl, displayPath, diagnostics);
|
|
139
140
|
await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
|
|
140
141
|
await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
|
|
141
142
|
}
|
|
@@ -246,7 +247,7 @@ export async function fingerprintWorkspace(input = process.cwd()) {
|
|
|
246
247
|
return { fingerprint: hash.digest("hex"), loaded };
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
function validateImplementedControlSchedules(record, obligationsByControl,
|
|
250
|
+
function validateImplementedControlSchedules(record, obligationsByControl, path, diagnostics) {
|
|
250
251
|
if (record.type !== "control" || record.status !== "implemented") return;
|
|
251
252
|
const schedules = obligationsByControl.get(record.id) || [];
|
|
252
253
|
if (!schedules.length) {
|
|
@@ -258,21 +259,13 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
|
|
|
258
259
|
));
|
|
259
260
|
return;
|
|
260
261
|
}
|
|
261
|
-
if (schedules.
|
|
262
|
-
const stopped = schedules.filter((obligation) => !
|
|
262
|
+
if (schedules.some(obligationIsEnabled)) return;
|
|
263
|
+
const stopped = schedules.filter((obligation) => !obligationIsEnabled(obligation));
|
|
263
264
|
const paused = stopped.filter((obligation) => obligation.status === "paused");
|
|
264
|
-
const
|
|
265
|
-
const policyBlockers = [...new Set(waiting.flatMap((obligation) => (obligation.policyIds || []).map((id) => {
|
|
266
|
-
const policy = byId.get(id);
|
|
267
|
-
if (!policy || policy.type !== "policy") return `${id} (missing)`;
|
|
268
|
-
if (policy.status !== "active") return `${policy.title} (${policy.status})`;
|
|
269
|
-
if (!policy.effectiveOn) return `${policy.title} (effective date missing)`;
|
|
270
|
-
if (policy.effectiveOn > asOf) return `${policy.title} (effective ${policy.effectiveOn})`;
|
|
271
|
-
return null;
|
|
272
|
-
})).filter(Boolean))];
|
|
265
|
+
const proposed = stopped.filter((obligation) => obligation.status === "proposed");
|
|
273
266
|
const reasons = [
|
|
274
|
-
|
|
275
|
-
? `${
|
|
267
|
+
proposed.length
|
|
268
|
+
? `${proposed.length} linked ${proposed.length === 1 ? "schedule is" : "schedules are"} still proposed. Enable ${proposed.length === 1 ? "it" : "them"} before implementing the control; the schedule will remain dormant until its governing Policy is active and effective.`
|
|
276
269
|
: "",
|
|
277
270
|
paused.length
|
|
278
271
|
? `${paused.length} linked ${paused.length === 1 ? "schedule is" : "schedules are"} paused. Enable ${paused.length === 1 ? "it" : "them"} before implementing the control.`
|
|
@@ -281,7 +274,7 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
|
|
|
281
274
|
diagnostics.push(error(
|
|
282
275
|
"control-work-queue-not-running",
|
|
283
276
|
path,
|
|
284
|
-
`This control cannot be marked implemented yet. ${reasons}`
|
|
277
|
+
`This control cannot be marked implemented yet. ${reasons || "Enable at least one linked schedule."}`
|
|
285
278
|
));
|
|
286
279
|
}
|
|
287
280
|
|
|
@@ -302,6 +295,19 @@ function validateDateRanges(record, path, diagnostics) {
|
|
|
302
295
|
}
|
|
303
296
|
}
|
|
304
297
|
|
|
298
|
+
function validateProposedEffectiveDate(record, asOf, path, diagnostics) {
|
|
299
|
+
if (
|
|
300
|
+
!["policy", "document"].includes(record.type)
|
|
301
|
+
|| !record.proposedEffectiveOn
|
|
302
|
+
|| record.proposedEffectiveOn >= asOf
|
|
303
|
+
) return;
|
|
304
|
+
diagnostics.push(warning(
|
|
305
|
+
"past-proposed-effective-date",
|
|
306
|
+
path,
|
|
307
|
+
`The proposed effective date ${record.proposedEffectiveOn} has passed. Choose a current or future date when management activates the approved content; do not backdate adoption.`
|
|
308
|
+
));
|
|
309
|
+
}
|
|
310
|
+
|
|
305
311
|
function validateAppointment(record, byId, path, diagnostics) {
|
|
306
312
|
if ((record.scopeResourceIds || []).includes(record.id)) {
|
|
307
313
|
diagnostics.push(error(
|
|
@@ -364,9 +370,19 @@ function validateControlComponents(record, byId, path, diagnostics) {
|
|
|
364
370
|
}
|
|
365
371
|
|
|
366
372
|
function validateAuditSubservices(record, byId, path, diagnostics) {
|
|
373
|
+
const selectedSystemIds = new Set(record.systemIds || []);
|
|
374
|
+
const seenComponentIds = new Set();
|
|
367
375
|
for (const [index, treatment] of (record.subserviceTreatments || []).entries()) {
|
|
368
376
|
for (const componentId of treatment.componentIds || []) {
|
|
369
377
|
const component = byId.get(componentId);
|
|
378
|
+
if (seenComponentIds.has(componentId)) {
|
|
379
|
+
diagnostics.push(error(
|
|
380
|
+
"duplicate-subservice-component",
|
|
381
|
+
path,
|
|
382
|
+
`subserviceTreatments[${index}] repeats Component "${componentId}" in more than one treatment.`
|
|
383
|
+
));
|
|
384
|
+
}
|
|
385
|
+
seenComponentIds.add(componentId);
|
|
370
386
|
if (component?.type === "component" && component.vendorId !== treatment.vendorId) {
|
|
371
387
|
diagnostics.push(error(
|
|
372
388
|
"subservice-vendor-mismatch",
|
|
@@ -374,6 +390,16 @@ function validateAuditSubservices(record, byId, path, diagnostics) {
|
|
|
374
390
|
`subserviceTreatments[${index}] Component "${componentId}" is not supplied by Vendor "${treatment.vendorId}".`
|
|
375
391
|
));
|
|
376
392
|
}
|
|
393
|
+
if (
|
|
394
|
+
component?.type === "component"
|
|
395
|
+
&& !(component.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
|
|
396
|
+
) {
|
|
397
|
+
diagnostics.push(error(
|
|
398
|
+
"subservice-component-outside-audit-scope",
|
|
399
|
+
path,
|
|
400
|
+
`subserviceTreatments[${index}] Component "${componentId}" has no use in a System selected by this Audit.`
|
|
401
|
+
));
|
|
402
|
+
}
|
|
377
403
|
}
|
|
378
404
|
}
|
|
379
405
|
}
|