filegrc 0.7.0 → 0.8.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 +21 -4
- package/model/v4.json +97 -6
- package/model/v5.json +10233 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +635 -81
- package/src/audit-transition.js +8 -2
- package/src/batch-review.js +21 -11
- package/src/cli.js +117 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +145 -0
- package/src/evidence-packet.js +393 -106
- package/src/external-reviewer.js +5 -4
- package/src/files.js +194 -35
- package/src/git.js +106 -10
- package/src/index.js +10 -1
- package/src/model-migration.js +218 -7
- package/src/obligations.js +14 -11
- package/src/policy-library/information-security-policy-v2.md +290 -0
- package/src/policy-library.js +827 -0
- package/src/program-lifecycle.js +93 -3
- package/src/program-path.js +14 -9
- package/src/program-readiness.js +306 -75
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +29 -7
- package/src/setup.js +25 -5
- package/src/soc2.js +228 -0
- package/src/state.js +8 -0
- package/src/validate.js +168 -14
- package/src/web.js +257 -35
- package/src/workflow.js +107 -44
- package/src/workspace.js +5 -0
package/src/audit-preparation.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { modelSupports } from "../model/index.js";
|
|
4
|
+
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
3
5
|
import {
|
|
4
6
|
coverageContains,
|
|
5
7
|
coverageEnd,
|
|
@@ -9,11 +11,24 @@ import {
|
|
|
9
11
|
coverageStart
|
|
10
12
|
} from "./coverage.js";
|
|
11
13
|
import { createResource, createResources, deleteResource, updateResource } from "./files.js";
|
|
14
|
+
import { getChangedDataPathsSinceRevision, getFileAtRevision, hasGitRevision } from "./git.js";
|
|
12
15
|
import { createResourceId } from "./id.js";
|
|
13
16
|
import { currentPartyPeople, partiesIndependent } from "./parties.js";
|
|
14
17
|
import { resolveDataPath } from "./paths.js";
|
|
15
18
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
16
19
|
import { markdownEntries } from "./resource-markdown.js";
|
|
20
|
+
import { contentRevisionBindingsMatch, governedDocumentIsOperating } from "./program-lifecycle.js";
|
|
21
|
+
import {
|
|
22
|
+
auditorWasEngaged,
|
|
23
|
+
missingSoc2References,
|
|
24
|
+
personWasActiveOn,
|
|
25
|
+
recordWasInUseDuringAudit,
|
|
26
|
+
REQUIRED_SOC2_DESCRIPTION_REFERENCES,
|
|
27
|
+
REQUIRED_SOC2_SECURITY_REFERENCES,
|
|
28
|
+
signatoryAppointmentIssue,
|
|
29
|
+
subsequentEventsReviewIssue
|
|
30
|
+
} from "./soc2.js";
|
|
31
|
+
import { currentCalendarDate } from "./time.js";
|
|
17
32
|
import { loadWorkspace } from "./workspace.js";
|
|
18
33
|
|
|
19
34
|
const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
@@ -39,7 +54,6 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
|
39
54
|
"vendor",
|
|
40
55
|
"workspace"
|
|
41
56
|
]);
|
|
42
|
-
|
|
43
57
|
export async function assessAuditPreparation(input, options = {}) {
|
|
44
58
|
const loaded = input?.resources && input?.model && input?.entries
|
|
45
59
|
? input
|
|
@@ -55,23 +69,27 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
55
69
|
if (options.auditId && !audit) throw new Error(`Audit "${options.auditId}" was not found.`);
|
|
56
70
|
|
|
57
71
|
const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
|
|
72
|
+
asOf: options.asOf,
|
|
58
73
|
generatedAt: options.generatedAt,
|
|
59
74
|
programId: audit?.programId
|
|
60
75
|
});
|
|
76
|
+
const documentActivations = audit && modelSupports(loaded.model, "governed-document-activation")
|
|
77
|
+
? await auditDocumentActivationAssessments(loaded, audit, byId, programReadiness.asOf)
|
|
78
|
+
: [];
|
|
61
79
|
const stages = [
|
|
62
80
|
programFoundationStage(programReadiness, loaded.workspace),
|
|
63
81
|
engagementStage(audit, byId, programReadiness),
|
|
64
|
-
scopeStage(audit, records, byId, programReadiness)
|
|
82
|
+
scopeStage(loaded, audit, records, byId, programReadiness)
|
|
65
83
|
];
|
|
66
84
|
const fieldworkSections = audit
|
|
67
85
|
? [
|
|
68
|
-
await documentsStage(loaded, audit, byId),
|
|
86
|
+
await documentsStage(loaded, audit, byId, programReadiness.asOf),
|
|
69
87
|
evidenceStage(audit, records, byId, loaded.model),
|
|
70
88
|
populationsStage(audit, records, byId, loaded.model)
|
|
71
89
|
]
|
|
72
90
|
: [];
|
|
73
91
|
stages.push(fieldworkStage(audit, fieldworkSections));
|
|
74
|
-
stages.push(auditorStage());
|
|
92
|
+
stages.push(auditorStage(audit, byId, loaded.model.modelVersion));
|
|
75
93
|
|
|
76
94
|
for (const stage of stages) {
|
|
77
95
|
stage.counts = countStatuses(stage.items);
|
|
@@ -97,10 +115,23 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
97
115
|
&& coverageStart(audit.coverage)
|
|
98
116
|
&& coverageEnd(audit.coverage)
|
|
99
117
|
&& initializationNeeded(audit, records, loaded.model)),
|
|
118
|
+
documentActivations,
|
|
100
119
|
stages
|
|
101
120
|
};
|
|
102
121
|
}
|
|
103
122
|
|
|
123
|
+
export async function assessAuditDocumentActivations(input = process.cwd(), options = {}) {
|
|
124
|
+
const loaded = input?.resources && input?.model && input?.entries
|
|
125
|
+
? input
|
|
126
|
+
: await loadWorkspace(input);
|
|
127
|
+
if (!modelSupports(loaded.model, "governed-document-activation")) return [];
|
|
128
|
+
const audit = loaded.resources.find((record) => record.type === "audit" && record.id === options.auditId);
|
|
129
|
+
if (!audit) throw new Error(`Audit "${options.auditId || ""}" was not found.`);
|
|
130
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
131
|
+
const asOf = options.asOf || currentCalendarDate(loaded.workspace.timezone);
|
|
132
|
+
return auditDocumentActivationAssessments(loaded, audit, byId, asOf);
|
|
133
|
+
}
|
|
134
|
+
|
|
104
135
|
export async function prepareAuditWorkspace(input, options = {}) {
|
|
105
136
|
const loaded = await loadWorkspace(input);
|
|
106
137
|
const audit = loaded.resources.find((record) => record.type === "audit" && record.id === options.auditId);
|
|
@@ -158,7 +189,7 @@ export async function prepareAuditWorkspace(input, options = {}) {
|
|
|
158
189
|
const selectedControls = (audit.controlIds || [])
|
|
159
190
|
.map((id) => loaded.resources.find((record) => record.id === id))
|
|
160
191
|
.filter(Boolean);
|
|
161
|
-
const v4 =
|
|
192
|
+
const v4 = modelSupports(loaded.model, "program-scope");
|
|
162
193
|
const sourceSystems = loaded.resources.filter((record) => record.type === (v4 ? "component" : "system"));
|
|
163
194
|
const populations = (audit.auditKind === "soc-2-type-2" ? model.populationTemplates || [] : [])
|
|
164
195
|
.filter((template) => !existingKinds.has(template.kind))
|
|
@@ -212,7 +243,7 @@ export async function prepareAuditWorkspace(input, options = {}) {
|
|
|
212
243
|
};
|
|
213
244
|
}
|
|
214
245
|
|
|
215
|
-
function scopeStage(audit, records, byId, programReadiness) {
|
|
246
|
+
function scopeStage(loaded, audit, records, byId, programReadiness) {
|
|
216
247
|
const items = [];
|
|
217
248
|
if (!audit) {
|
|
218
249
|
items.push(item(
|
|
@@ -259,11 +290,24 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
259
290
|
));
|
|
260
291
|
}
|
|
261
292
|
|
|
293
|
+
const v4 = modelSupports(programReadiness.dataModelVersion, "program-scope");
|
|
294
|
+
const engagementStart = coverageStart(audit.coverage);
|
|
295
|
+
const engagementEnd = coverageEnd(audit.coverage);
|
|
296
|
+
const scopeRevision = assessScopeRevision(loaded, audit, v4);
|
|
297
|
+
items.push(item(
|
|
298
|
+
"scope-revision",
|
|
299
|
+
scopeRevision.status,
|
|
300
|
+
"Bind the reviewed engagement scope",
|
|
301
|
+
scopeRevision.message,
|
|
302
|
+
audit
|
|
303
|
+
));
|
|
262
304
|
const systems = (audit.systemIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
263
305
|
const completeSystems = systems.filter((system) => (
|
|
264
|
-
system
|
|
265
|
-
&&
|
|
266
|
-
|
|
306
|
+
recordWasInUseDuringAudit(system, engagementStart, engagementEnd)
|
|
307
|
+
&& (v4
|
|
308
|
+
? system.purpose && system.boundary && (system.servicesProvided || []).length
|
|
309
|
+
: system.description)
|
|
310
|
+
&& (v4 || system.classificationId)
|
|
267
311
|
&& (system.ownerIds || []).length
|
|
268
312
|
));
|
|
269
313
|
items.push(item(
|
|
@@ -271,12 +315,11 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
271
315
|
systems.length && completeSystems.length === systems.length ? "complete" : "action",
|
|
272
316
|
"Define the service boundary",
|
|
273
317
|
systems.length
|
|
274
|
-
? `${completeSystems.length} of ${systems.length} selected systems
|
|
318
|
+
? `${completeSystems.length} of ${systems.length} selected systems were in use for the engagement, explicitly in scope, owned, and described${v4 ? "" : ", with a classification"}.`
|
|
275
319
|
: "Select every in-scope service and supporting system, then describe its owner, environment, data, vendors, and boundary.",
|
|
276
320
|
systems[0] || { type: "system" }
|
|
277
321
|
));
|
|
278
322
|
|
|
279
|
-
const engagementStart = coverageStart(audit.coverage);
|
|
280
323
|
const commitments = records.filter((record) => record.type === "commitment"
|
|
281
324
|
&& record.status === "active"
|
|
282
325
|
&& systems.some((system) => (record.systemIds || []).includes(system.id)));
|
|
@@ -304,48 +347,121 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
304
347
|
const frameworkRequirementIds = records
|
|
305
348
|
.filter((record) => record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId))
|
|
306
349
|
.map((record) => record.id);
|
|
350
|
+
const program = audit.programId ? byId.get(audit.programId) : null;
|
|
351
|
+
const v4Decisions = new Map((program?.requirementApplicability || []).map((decision) => [decision.requirementId, decision]));
|
|
307
352
|
const unresolvedRequirements = frameworkRequirementIds
|
|
308
353
|
.map((id) => byId.get(id))
|
|
309
|
-
.filter((requirement) =>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
354
|
+
.filter((requirement) => {
|
|
355
|
+
if (!v4) {
|
|
356
|
+
return requirement.applicability === "undetermined"
|
|
357
|
+
|| (requirement.applicability === "not-applicable" && !requirement.applicabilityRationale);
|
|
358
|
+
}
|
|
359
|
+
const decision = v4Decisions.get(requirement.id);
|
|
360
|
+
return !decision
|
|
361
|
+
|| decision.decision === "undetermined"
|
|
362
|
+
|| (decision.decision === "not-applicable" && !decision.rationale);
|
|
363
|
+
});
|
|
364
|
+
const applicableRequirementIds = frameworkRequirementIds.filter((id) => (
|
|
365
|
+
v4 ? v4Decisions.get(id)?.decision === "applicable" : byId.get(id)?.applicability === "applicable"
|
|
366
|
+
));
|
|
314
367
|
const missingApplicableRequirements = applicableRequirementIds.filter((id) => !(audit.requirementIds || []).includes(id));
|
|
315
368
|
const selectedRequirements = (audit.requirementIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
316
369
|
const unexpectedRequirements = selectedRequirements.filter((requirement) => (
|
|
317
370
|
!(audit.frameworkIds || []).includes(requirement.frameworkId)
|
|
318
|
-
||
|
|
371
|
+
|| (v4
|
|
372
|
+
? v4Decisions.get(requirement.id)?.decision !== "applicable"
|
|
373
|
+
: requirement.applicability !== "applicable")
|
|
319
374
|
));
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
375
|
+
const selectedControls = (audit.controlIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
376
|
+
const uncoveredRequirements = selectedRequirements.filter((requirement) => (
|
|
377
|
+
!isDescriptionRequirement(requirement)
|
|
378
|
+
&& !selectedControls.some((control) => (control.requirementIds || []).includes(requirement.id))
|
|
379
|
+
));
|
|
380
|
+
const descriptionRequirements = frameworkRequirementIds
|
|
381
|
+
.map((id) => byId.get(id))
|
|
382
|
+
.filter(isDescriptionRequirement);
|
|
383
|
+
const missingDescriptionRequirements = descriptionRequirements.filter((requirement) => (
|
|
384
|
+
!(audit.requirementIds || []).includes(requirement.id)
|
|
323
385
|
));
|
|
386
|
+
const missingRequiredDescriptionReferences = v4
|
|
387
|
+
? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
|
|
388
|
+
: [];
|
|
389
|
+
const invalidMandatoryDescriptionDecisions = v4
|
|
390
|
+
? descriptionRequirements.filter((requirement) => (
|
|
391
|
+
REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(String(requirement.reference || "").trim().toUpperCase())
|
|
392
|
+
&& v4Decisions.get(requirement.id)?.decision !== "applicable"
|
|
393
|
+
))
|
|
394
|
+
: [];
|
|
395
|
+
const securityRequirements = frameworkRequirementIds
|
|
396
|
+
.map((id) => byId.get(id))
|
|
397
|
+
.filter(isSecurityRequirement);
|
|
398
|
+
const missingRequiredSecurityReferences = v4
|
|
399
|
+
? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
400
|
+
: [];
|
|
401
|
+
const missingSelectedSecurityReferences = v4
|
|
402
|
+
? missingSoc2References(selectedRequirements.filter(isSecurityRequirement), REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
403
|
+
: [];
|
|
404
|
+
const invalidMandatorySecurityDecisions = v4
|
|
405
|
+
? securityRequirements.filter((requirement) => (
|
|
406
|
+
REQUIRED_SOC2_SECURITY_REFERENCES.includes(String(requirement.reference || "").trim().toUpperCase())
|
|
407
|
+
&& v4Decisions.get(requirement.id)?.decision !== "applicable"
|
|
408
|
+
))
|
|
409
|
+
: [];
|
|
324
410
|
const criteriaComplete = (audit.frameworkIds || []).length
|
|
325
411
|
&& (audit.requirementIds || []).length
|
|
326
412
|
&& (audit.controlIds || []).length
|
|
327
413
|
&& !unresolvedRequirements.length
|
|
328
414
|
&& !missingApplicableRequirements.length
|
|
329
415
|
&& !unexpectedRequirements.length
|
|
330
|
-
&&
|
|
416
|
+
&& !uncoveredRequirements.length
|
|
417
|
+
&& descriptionRequirements.length
|
|
418
|
+
&& !missingDescriptionRequirements.length
|
|
419
|
+
&& !missingRequiredDescriptionReferences.length
|
|
420
|
+
&& !missingRequiredSecurityReferences.length
|
|
421
|
+
&& !missingSelectedSecurityReferences.length
|
|
422
|
+
&& !invalidMandatorySecurityDecisions.length
|
|
423
|
+
&& !invalidMandatoryDescriptionDecisions.length;
|
|
331
424
|
items.push(item(
|
|
332
425
|
"criteria",
|
|
333
426
|
criteriaComplete ? "complete" : "action",
|
|
334
|
-
"Confirm criteria
|
|
427
|
+
"Confirm Trust Services criteria, Description Criteria, and Controls",
|
|
335
428
|
criteriaComplete
|
|
336
|
-
? `${
|
|
429
|
+
? `${selectedRequirements.filter((requirement) => !isDescriptionRequirement(requirement)).length} applicable Trust Services criteria, ${selectedRequirements.filter(isDescriptionRequirement).length} SOC 2 Description Criteria, and ${audit.controlIds.length} Controls are selected. Every applicable Trust Services criterion maps to a selected Control; Description Criteria govern the system description and do not map to Controls.`
|
|
337
430
|
: unresolvedRequirements.length
|
|
338
431
|
? `Resolve applicability and record a rationale for ${unresolvedRequirements.length} selected-framework criteria.`
|
|
339
|
-
:
|
|
340
|
-
?
|
|
341
|
-
:
|
|
342
|
-
? `
|
|
343
|
-
:
|
|
344
|
-
?
|
|
345
|
-
|
|
346
|
-
|
|
432
|
+
: !descriptionRequirements.length
|
|
433
|
+
? "Select the SOC 2 Description Criteria framework and all nine criteria. These govern the system description and do not map to Controls."
|
|
434
|
+
: missingRequiredDescriptionReferences.length
|
|
435
|
+
? `Use the complete SOC 2 Description Criteria set; ${missingRequiredDescriptionReferences.join(", ")} ${missingRequiredDescriptionReferences.length === 1 ? "is" : "are"} missing from the selected framework.`
|
|
436
|
+
: missingRequiredSecurityReferences.length
|
|
437
|
+
? `Use the complete SOC 2 Security Common Criteria set; ${missingRequiredSecurityReferences.join(", ")} ${missingRequiredSecurityReferences.length === 1 ? "is" : "are"} missing from the selected framework.`
|
|
438
|
+
: invalidMandatoryDescriptionDecisions.length
|
|
439
|
+
? `Mark all nine Description Criteria applicable in the selected Program; ${invalidMandatoryDescriptionDecisions.map(({ reference }) => reference).join(", ")} ${invalidMandatoryDescriptionDecisions.length === 1 ? "is" : "are"} missing, undetermined, or not applicable.`
|
|
440
|
+
: invalidMandatorySecurityDecisions.length
|
|
441
|
+
? `Mark every Security Common Criterion applicable in the selected Program; ${invalidMandatorySecurityDecisions.map(({ reference }) => reference).join(", ")} ${invalidMandatorySecurityDecisions.length === 1 ? "is" : "are"} missing, undetermined, or not applicable.`
|
|
442
|
+
: missingSelectedSecurityReferences.length
|
|
443
|
+
? `Add every Security Common Criterion to the engagement; ${missingSelectedSecurityReferences.join(", ")} ${missingSelectedSecurityReferences.length === 1 ? "is" : "are"} missing.`
|
|
444
|
+
: missingDescriptionRequirements.length
|
|
445
|
+
? `Add all nine SOC 2 Description Criteria to the engagement; ${missingDescriptionRequirements.length} ${missingDescriptionRequirements.length === 1 ? "is" : "are"} missing.`
|
|
446
|
+
: missingApplicableRequirements.length
|
|
447
|
+
? `Add ${missingApplicableRequirements.length} applicable selected-framework Trust Services criteria to the engagement.`
|
|
448
|
+
: unexpectedRequirements.length
|
|
449
|
+
? `Remove ${unexpectedRequirements.length} criteria that are not applicable members of the selected frameworks.`
|
|
450
|
+
: uncoveredRequirements.length
|
|
451
|
+
? `Map ${uncoveredRequirements.length} selected Trust Services criteria to Controls included in the engagement.`
|
|
452
|
+
: "Select the Security criteria, any optional Trust Services Categories, and the Controls included in this report.",
|
|
453
|
+
audit,
|
|
454
|
+
{
|
|
455
|
+
commands: unresolvedRequirements.length
|
|
456
|
+
? [
|
|
457
|
+
"npx filegrc review-applicability --scaffold --type requirement > decisions.json",
|
|
458
|
+
"npx filegrc review-applicability decisions.json --preview --json"
|
|
459
|
+
]
|
|
460
|
+
: [`npx filegrc get ${audit.id} --mutation`]
|
|
461
|
+
}
|
|
347
462
|
));
|
|
348
463
|
|
|
464
|
+
const treatments = audit.subserviceTreatments || [];
|
|
349
465
|
const expectedSubserviceVendorIds = new Set(systems.flatMap((system) => system.subserviceVendorIds || []));
|
|
350
466
|
const missingSubserviceVendorIds = [...expectedSubserviceVendorIds].filter((id) => !(audit.subserviceVendorIds || []).includes(id));
|
|
351
467
|
const inclusiveSystemIds = records
|
|
@@ -355,16 +471,54 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
355
471
|
.map((id) => byId.get(id))
|
|
356
472
|
.filter((control) => (control?.systemIds || []).some((id) => inclusiveSystemIds.includes(id)))
|
|
357
473
|
.length;
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
474
|
+
const v4InclusiveTreatments = treatments.filter(({ method }) => method === "inclusive");
|
|
475
|
+
const treatmentComponentCounts = new Map();
|
|
476
|
+
for (const treatment of treatments) {
|
|
477
|
+
for (const componentId of treatment.componentIds || []) {
|
|
478
|
+
treatmentComponentCounts.set(componentId, (treatmentComponentCounts.get(componentId) || 0) + 1);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const v4InvalidTreatments = treatments.filter((treatment) => (
|
|
482
|
+
byId.get(treatment.vendorId)?.type !== "vendor"
|
|
483
|
+
|| !recordWasInUseDuringAudit(byId.get(treatment.vendorId), engagementStart, engagementEnd)
|
|
484
|
+
|| !(treatment.componentIds || []).length
|
|
485
|
+
|| (treatment.componentIds || []).some((componentId) => {
|
|
486
|
+
const component = byId.get(componentId);
|
|
487
|
+
return component?.type !== "component"
|
|
488
|
+
|| !recordWasInUseDuringAudit(component, engagementStart, engagementEnd)
|
|
489
|
+
|| component.vendorId !== treatment.vendorId
|
|
490
|
+
|| (treatmentComponentCounts.get(componentId) || 0) > 1
|
|
491
|
+
|| !(component.systemUses || []).some(({ systemId }) => (audit.systemIds || []).includes(systemId));
|
|
492
|
+
})
|
|
493
|
+
));
|
|
494
|
+
const v4InclusiveWithoutControls = v4InclusiveTreatments.filter((treatment) => !selectedControls.some((control) => (
|
|
495
|
+
(control.componentIds || []).some((id) => (treatment.componentIds || []).includes(id))
|
|
496
|
+
)));
|
|
497
|
+
const subserviceComplete = v4
|
|
498
|
+
? Boolean(audit.subserviceConclusion && audit.subserviceConclusionRationale)
|
|
499
|
+
&& (audit.subserviceConclusion === "identified" ? treatments.length > 0 : treatments.length === 0)
|
|
500
|
+
&& v4InvalidTreatments.length === 0
|
|
501
|
+
&& v4InclusiveWithoutControls.length === 0
|
|
502
|
+
: Boolean(audit.subserviceMethod)
|
|
503
|
+
&& !((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable")
|
|
504
|
+
&& !missingSubserviceVendorIds.length
|
|
505
|
+
&& !(audit.subserviceMethod === "inclusive" && (!inclusiveSystemIds.length || !inclusiveControlCount));
|
|
362
506
|
items.push(item(
|
|
363
507
|
"subservices",
|
|
364
508
|
subserviceComplete ? "complete" : "action",
|
|
365
509
|
"Decide how subservice organizations are presented",
|
|
366
510
|
subserviceComplete
|
|
367
|
-
?
|
|
511
|
+
? v4
|
|
512
|
+
? audit.subserviceConclusion === "identified"
|
|
513
|
+
? `${treatments.length} Vendor and Component subservice treatments are recorded with a rationale.`
|
|
514
|
+
: "Management recorded and explained that no subservice organizations are included in the report scope."
|
|
515
|
+
: `${displayValue(audit.subserviceMethod)} method selected for ${(audit.subserviceVendorIds || []).length} subservice organizations${audit.subserviceMethod === "inclusive" ? `, with ${inclusiveControlCount} included controls` : ""}.`
|
|
516
|
+
: v4
|
|
517
|
+
? v4InvalidTreatments.length
|
|
518
|
+
? `${v4InvalidTreatments.length} subservice ${v4InvalidTreatments.length === 1 ? "treatment does" : "treatments do"} not identify one Vendor's supplied Components in use within the selected System boundary during the engagement, or ${v4InvalidTreatments.length === 1 ? "repeats" : "repeat"} a Component across treatments.`
|
|
519
|
+
: v4InclusiveWithoutControls.length
|
|
520
|
+
? `${v4InclusiveWithoutControls.length} inclusive subservice treatments have no selected Controls linked to their Components.`
|
|
521
|
+
: "Record whether subservice organizations are identified, explain the conclusion, and record each Vendor, Component, carve-out or inclusive method, and rationale when applicable."
|
|
368
522
|
: missingSubserviceVendorIds.length
|
|
369
523
|
? `Add ${missingSubserviceVendorIds.length} subservice organizations already identified by the in-scope systems.`
|
|
370
524
|
: audit.subserviceMethod === "inclusive"
|
|
@@ -404,6 +558,175 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
404
558
|
return stage("period", "Confirm the Formal Period", "Record the auditor-agreed report type, date or period, scope, criteria, systems, and dependency treatment.", items);
|
|
405
559
|
}
|
|
406
560
|
|
|
561
|
+
function isDescriptionRequirement(requirement) {
|
|
562
|
+
return (requirement.tags || []).includes("description-criteria") || /^DC\d+/i.test(requirement.reference || "");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isSecurityRequirement(requirement) {
|
|
566
|
+
const tags = requirement?.tags || [];
|
|
567
|
+
return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const AUDIT_SCOPE_REVISION_FIELDS = [
|
|
571
|
+
"auditKind",
|
|
572
|
+
"programId",
|
|
573
|
+
"frameworkIds",
|
|
574
|
+
"scope",
|
|
575
|
+
"coverage",
|
|
576
|
+
"systemIds",
|
|
577
|
+
"requirementIds",
|
|
578
|
+
"controlIds",
|
|
579
|
+
"commitmentIds",
|
|
580
|
+
"complementaryControlIds",
|
|
581
|
+
"complementaryControlsConclusion",
|
|
582
|
+
"subserviceConclusion",
|
|
583
|
+
"subserviceConclusionRationale",
|
|
584
|
+
"subserviceTreatments",
|
|
585
|
+
"subserviceVendorIds",
|
|
586
|
+
"subserviceMethod",
|
|
587
|
+
"signatoryAppointmentIds"
|
|
588
|
+
];
|
|
589
|
+
|
|
590
|
+
function assessScopeRevision(loaded, audit, v4) {
|
|
591
|
+
if (!v4) {
|
|
592
|
+
return {
|
|
593
|
+
status: "complete",
|
|
594
|
+
message: "The legacy workspace stores the engagement selections directly on the Audit."
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (!audit.scopeRevision) {
|
|
598
|
+
return {
|
|
599
|
+
status: "action",
|
|
600
|
+
message: "Review the engagement's Program, Systems, criteria, Controls, commitments, subservices, complementary controls, and signatories, then record the reviewed Git revision in scopeRevision."
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (!hasGitRevision(loaded.root, audit.scopeRevision)) {
|
|
604
|
+
return {
|
|
605
|
+
status: "action",
|
|
606
|
+
message: `${audit.scopeRevision} is not an available Git commit. Commit the reviewed scope and record that exact commit in scopeRevision.`
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const entry = loaded.entries.find(({ record }) => record.id === audit.id);
|
|
610
|
+
const source = entry
|
|
611
|
+
? getFileAtRevision(loaded.root, audit.scopeRevision, `data/${entry.relativePath}`)
|
|
612
|
+
: null;
|
|
613
|
+
let historicalAudit = null;
|
|
614
|
+
try {
|
|
615
|
+
historicalAudit = source ? JSON.parse(source) : null;
|
|
616
|
+
} catch {
|
|
617
|
+
historicalAudit = null;
|
|
618
|
+
}
|
|
619
|
+
if (historicalAudit?.id !== audit.id || historicalAudit.type !== "audit") {
|
|
620
|
+
return {
|
|
621
|
+
status: "action",
|
|
622
|
+
message: `${audit.scopeRevision} does not contain this Audit record. Commit the reviewed scope and update scopeRevision.`
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
if (auditScopeFingerprint(historicalAudit) !== auditScopeFingerprint(audit)) {
|
|
626
|
+
return {
|
|
627
|
+
status: "action",
|
|
628
|
+
message: "The current engagement scope differs from the scope stored at scopeRevision. Review the diff, commit the accepted scope, and update scopeRevision."
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const changedScopeRecords = scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit);
|
|
632
|
+
if (changedScopeRecords === null) {
|
|
633
|
+
return {
|
|
634
|
+
status: "action",
|
|
635
|
+
message: "FileGRC could not compare the current engagement scope with scopeRevision. Confirm Git history is available, then review and record the exact scope commit."
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (changedScopeRecords.length) {
|
|
639
|
+
return {
|
|
640
|
+
status: "action",
|
|
641
|
+
message: `${changedScopeRecords.length} record${changedScopeRecords.length === 1 ? "" : "s"} within the reviewed engagement scope changed after scopeRevision. Review the scope diff, commit the accepted records, and update scopeRevision.`
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
status: "complete",
|
|
646
|
+
message: `Management reviewed the current engagement scope at Git revision ${audit.scopeRevision}.`
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit) {
|
|
651
|
+
const currentByPath = new Map(loaded.entries.map((entry) => [`data/${entry.relativePath}`, entry.record]));
|
|
652
|
+
const changedPaths = getChangedDataPathsSinceRevision(loaded.root, audit.scopeRevision);
|
|
653
|
+
if (!changedPaths) return null;
|
|
654
|
+
const candidatePaths = changedPaths.filter((path) => path.endsWith(".json"));
|
|
655
|
+
const changed = [];
|
|
656
|
+
for (const path of candidatePaths) {
|
|
657
|
+
const current = currentByPath.get(path) || null;
|
|
658
|
+
let historical = null;
|
|
659
|
+
const historicalSource = getFileAtRevision(loaded.root, audit.scopeRevision, path);
|
|
660
|
+
try {
|
|
661
|
+
historical = historicalSource ? JSON.parse(historicalSource) : null;
|
|
662
|
+
} catch {
|
|
663
|
+
historical = null;
|
|
664
|
+
}
|
|
665
|
+
if (
|
|
666
|
+
!scopeRecordIsRelevant(current, audit)
|
|
667
|
+
&& !scopeRecordIsRelevant(historical, historicalAudit)
|
|
668
|
+
) continue;
|
|
669
|
+
if (JSON.stringify(canonicalScopeValue(current)) !== JSON.stringify(canonicalScopeValue(historical))) changed.push(path);
|
|
670
|
+
}
|
|
671
|
+
return changed;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function scopeRecordIsRelevant(record, audit) {
|
|
675
|
+
if (!record || !audit) return false;
|
|
676
|
+
const selectedSystemIds = new Set(audit.systemIds || []);
|
|
677
|
+
const selectedFrameworkIds = new Set(audit.frameworkIds || []);
|
|
678
|
+
const selectedIds = new Set([
|
|
679
|
+
audit.programId,
|
|
680
|
+
...(audit.frameworkIds || []),
|
|
681
|
+
...(audit.systemIds || []),
|
|
682
|
+
...(audit.requirementIds || []),
|
|
683
|
+
...(audit.controlIds || []),
|
|
684
|
+
...(audit.commitmentIds || []),
|
|
685
|
+
...(audit.complementaryControlIds || []),
|
|
686
|
+
...(audit.signatoryAppointmentIds || []),
|
|
687
|
+
...auditSubserviceVendorIds(audit),
|
|
688
|
+
...auditSubserviceComponentIds(audit)
|
|
689
|
+
].filter(Boolean));
|
|
690
|
+
if (selectedIds.has(record.id)) return true;
|
|
691
|
+
if (record.type === "requirement") return selectedFrameworkIds.has(record.frameworkId);
|
|
692
|
+
if (["commitment", "complementary-control"].includes(record.type)) {
|
|
693
|
+
return (record.systemIds || []).some((id) => selectedSystemIds.has(id));
|
|
694
|
+
}
|
|
695
|
+
if (record.type === "component") {
|
|
696
|
+
return (record.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId));
|
|
697
|
+
}
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function auditSubserviceVendorIds(audit) {
|
|
702
|
+
return [
|
|
703
|
+
...(audit.subserviceVendorIds || []),
|
|
704
|
+
...(audit.subserviceTreatments || []).map(({ vendorId }) => vendorId)
|
|
705
|
+
];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function auditSubserviceComponentIds(audit) {
|
|
709
|
+
return (audit.subserviceTreatments || []).flatMap(({ componentIds }) => componentIds || []);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function auditScopeFingerprint(audit) {
|
|
713
|
+
return JSON.stringify(Object.fromEntries(AUDIT_SCOPE_REVISION_FIELDS.map((field) => (
|
|
714
|
+
[field, canonicalScopeValue(audit[field] ?? null)]
|
|
715
|
+
))));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function canonicalScopeValue(value) {
|
|
719
|
+
if (Array.isArray(value)) {
|
|
720
|
+
return value
|
|
721
|
+
.map(canonicalScopeValue)
|
|
722
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
723
|
+
}
|
|
724
|
+
if (value && typeof value === "object") {
|
|
725
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalScopeValue(value[key])]));
|
|
726
|
+
}
|
|
727
|
+
return value;
|
|
728
|
+
}
|
|
729
|
+
|
|
407
730
|
function programFoundationStage(programReadiness, workspace) {
|
|
408
731
|
const ready = programReadiness.evidenceReady;
|
|
409
732
|
return stage("program", "Program Readiness", "The management program can be prepared and operated without an audit record or CPA firm.", [
|
|
@@ -434,11 +757,35 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
434
757
|
]);
|
|
435
758
|
}
|
|
436
759
|
const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
|
|
437
|
-
const named =
|
|
760
|
+
const named = auditorWasEngaged(auditor, audit);
|
|
438
761
|
const currentOwners = [...currentPartyPeople(audit.ownerIds, byId)]
|
|
439
762
|
.map((id) => byId.get(id))
|
|
440
763
|
.filter(Boolean);
|
|
441
|
-
|
|
764
|
+
const engagementTerms = audit.engagementTermsDocumentId ? byId.get(audit.engagementTermsDocumentId) : null;
|
|
765
|
+
const engagementTermsComplete = Boolean(
|
|
766
|
+
engagementTerms?.type === "document"
|
|
767
|
+
&& engagementTerms.documentKind === "soc2-engagement-terms"
|
|
768
|
+
&& governedDocumentIsOperating(
|
|
769
|
+
engagementTerms,
|
|
770
|
+
programReadiness.asOf,
|
|
771
|
+
{ modelVersion: programReadiness.dataModelVersion }
|
|
772
|
+
)
|
|
773
|
+
&& engagementTerms.approvedOn
|
|
774
|
+
);
|
|
775
|
+
const acknowledgementPeople = (audit.managementAcknowledgedByIds || [])
|
|
776
|
+
.map((id) => byId.get(id))
|
|
777
|
+
.filter((record) => personWasActiveOn(record, audit.managementAcknowledgedOn));
|
|
778
|
+
const acknowledged = Boolean(
|
|
779
|
+
engagementTermsComplete
|
|
780
|
+
&& acknowledgementPeople.length === (audit.managementAcknowledgedByIds || []).length
|
|
781
|
+
&& acknowledgementPeople.length
|
|
782
|
+
&& audit.managementAcknowledgedOn
|
|
783
|
+
&& audit.managementAcknowledgedOn >= engagementTerms.approvedOn
|
|
784
|
+
&& (!audit.fieldworkStart || audit.managementAcknowledgedOn <= audit.fieldworkStart)
|
|
785
|
+
);
|
|
786
|
+
const preliminary = audit.status === "planned";
|
|
787
|
+
const programGoalAligned = programReadiness.target.goal === audit.auditKind;
|
|
788
|
+
const items = [
|
|
442
789
|
item(
|
|
443
790
|
"engagement-record",
|
|
444
791
|
"complete",
|
|
@@ -446,13 +793,24 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
446
793
|
`${audit.title} tracks the formal scope, dates, requests, fieldwork, and report.`,
|
|
447
794
|
audit
|
|
448
795
|
),
|
|
796
|
+
item(
|
|
797
|
+
"program-goal-alignment",
|
|
798
|
+
programGoalAligned ? "complete" : "action",
|
|
799
|
+
"Align the Program goal with the engagement",
|
|
800
|
+
programGoalAligned
|
|
801
|
+
? `The Program goal and formal engagement are both ${audit.auditKind === "soc-2-type-2" ? "SOC 2 Type 2" : "SOC 2 Type 1"}.`
|
|
802
|
+
: `Change the Program goal from ${programReadiness.target.label} to ${audit.auditKind === "soc-2-type-2" ? "SOC 2 Type 2" : "SOC 2 Type 1"}, or correct the Audit type if the engagement record is wrong.`,
|
|
803
|
+
audit
|
|
804
|
+
),
|
|
449
805
|
item(
|
|
450
806
|
"auditor",
|
|
451
807
|
named ? "complete" : "action",
|
|
452
808
|
"Record the independent CPA firm",
|
|
453
809
|
named
|
|
454
810
|
? `${auditor.title} is recorded for the engagement.`
|
|
455
|
-
:
|
|
811
|
+
: auditor
|
|
812
|
+
? `${auditor.title} was not active for the engagement period. Confirm the CPA firm and its engagement dates.`
|
|
813
|
+
: "Select the CPA firm and record it here. The independent management policy reviewer is a different role.",
|
|
456
814
|
audit
|
|
457
815
|
),
|
|
458
816
|
item(
|
|
@@ -470,7 +828,34 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
470
828
|
]
|
|
471
829
|
}
|
|
472
830
|
)
|
|
473
|
-
]
|
|
831
|
+
];
|
|
832
|
+
if (modelSupports(programReadiness.dataModelVersion, "guided-workflow")) {
|
|
833
|
+
items.push(
|
|
834
|
+
item(
|
|
835
|
+
"engagement-terms",
|
|
836
|
+
engagementTermsComplete ? "complete" : preliminary ? "later" : "action",
|
|
837
|
+
"Link the engagement terms",
|
|
838
|
+
engagementTermsComplete
|
|
839
|
+
? `${engagementTerms.title} records the accepted CPA engagement terms.`
|
|
840
|
+
: preliminary
|
|
841
|
+
? "Link the accepted CPA engagement terms before moving this Audit to in progress."
|
|
842
|
+
: "Link an active, approved Document with documentKind soc2-engagement-terms that contains the accepted CPA engagement terms.",
|
|
843
|
+
engagementTerms || { type: "document" }
|
|
844
|
+
),
|
|
845
|
+
item(
|
|
846
|
+
"management-acknowledgement",
|
|
847
|
+
acknowledged ? "complete" : preliminary ? "later" : "action",
|
|
848
|
+
"Record management acknowledgement",
|
|
849
|
+
acknowledged
|
|
850
|
+
? `Management acknowledged the engagement terms on ${audit.managementAcknowledgedOn}.`
|
|
851
|
+
: preliminary
|
|
852
|
+
? "Name the responsible management people and the acknowledgement date before moving this Audit to in progress."
|
|
853
|
+
: "Name the management people who acknowledged the approved engagement terms and record the actual acknowledgement date on or after approval and no later than fieldwork start. Each person must have been active on that date.",
|
|
854
|
+
audit
|
|
855
|
+
)
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", items);
|
|
474
859
|
}
|
|
475
860
|
|
|
476
861
|
function fieldworkStage(audit, sections) {
|
|
@@ -492,48 +877,160 @@ function fieldworkStage(audit, sections) {
|
|
|
492
877
|
);
|
|
493
878
|
}
|
|
494
879
|
|
|
495
|
-
async function
|
|
880
|
+
async function auditDocumentActivationAssessments(loaded, audit, byId, asOf) {
|
|
881
|
+
const links = new Map();
|
|
882
|
+
const addLink = (documentId, role, definition = null) => {
|
|
883
|
+
if (!documentId) return;
|
|
884
|
+
const current = links.get(documentId) || { documentId, roles: [], definitions: [] };
|
|
885
|
+
current.roles.push(role);
|
|
886
|
+
if (definition) current.definitions.push(definition);
|
|
887
|
+
links.set(documentId, current);
|
|
888
|
+
};
|
|
889
|
+
addLink(audit.engagementTermsDocumentId, "engagement-terms");
|
|
890
|
+
for (const definition of applicableManagementDocuments(audit, loaded.model.auditReadiness || {})) {
|
|
891
|
+
addLink(audit[definition.field], definition.field, definition);
|
|
892
|
+
}
|
|
893
|
+
for (const documentId of audit.supplementalDocumentIds || []) addLink(documentId, "supplemental");
|
|
894
|
+
|
|
895
|
+
const assessments = [];
|
|
896
|
+
for (const link of links.values()) {
|
|
897
|
+
const document = byId.get(link.documentId);
|
|
898
|
+
if (document?.type !== "document") continue;
|
|
899
|
+
if (link.roles.every((role) => role === "supplemental") && document.workflowScope !== "engagement") continue;
|
|
900
|
+
const source = await primaryMarkdown(loaded, document);
|
|
901
|
+
const issues = [];
|
|
902
|
+
if (document.workflowScope !== "engagement") issues.push("Set workflowScope to engagement.");
|
|
903
|
+
if (document.template === true) issues.push("Replace the starter template with the completed engagement Document.");
|
|
904
|
+
if (!substantiveMarkdown(source)) issues.push("Complete the Document Markdown.");
|
|
905
|
+
const placeholders = openPlaceholderCount(source);
|
|
906
|
+
if (placeholders) issues.push(`Resolve ${placeholders} open ${placeholders === 1 ? "placeholder" : "placeholders"}.`);
|
|
907
|
+
if (link.roles.includes("engagement-terms") && document.documentKind !== "soc2-engagement-terms") {
|
|
908
|
+
issues.push("Use documentKind soc2-engagement-terms for the accepted engagement terms.");
|
|
909
|
+
}
|
|
910
|
+
for (const definition of link.definitions) {
|
|
911
|
+
issues.push(...await managementDocumentLifecycleIssues(loaded, audit, definition, document, source, asOf, byId));
|
|
912
|
+
}
|
|
913
|
+
const approvalComplete = Boolean(
|
|
914
|
+
["approved", "active"].includes(document.status)
|
|
915
|
+
&& document.approvedOn
|
|
916
|
+
&& document.approvedContentRevisions
|
|
917
|
+
&& (document.activationBasis === "legacy-v4"
|
|
918
|
+
? (document.ownerIds || []).length
|
|
919
|
+
: currentPartyPeople(document.ownerIds || [], byId).size)
|
|
920
|
+
&& (document.approverIds || []).length
|
|
921
|
+
&& partiesIndependent(document.ownerIds, document.approverIds, byId)
|
|
922
|
+
);
|
|
923
|
+
if (!approvalComplete) issues.push("Complete the independent approval and bind the approved Markdown revision first.");
|
|
924
|
+
if (document.status === "active" && document.activationBasis !== "legacy-v4") {
|
|
925
|
+
if (document.activationBasis !== "recorded") issues.push("Record the Step 5 activation basis.");
|
|
926
|
+
if (!document.activatedOn) issues.push("Record the separate Step 5 activation date.");
|
|
927
|
+
else if (document.activatedOn > asOf) issues.push(`The activation date ${document.activatedOn} is after ${asOf}.`);
|
|
928
|
+
if (!(document.activatedByIds || []).length) issues.push("Name the active Person who performed activation.");
|
|
929
|
+
else {
|
|
930
|
+
const invalidActorIds = document.activatedByIds.filter((id) => !personWasActiveOn(byId.get(id), document.activatedOn));
|
|
931
|
+
if (invalidActorIds.length) issues.push(`Activation actors were not active on ${document.activatedOn}: ${invalidActorIds.join(", ")}.`);
|
|
932
|
+
}
|
|
933
|
+
if (!document.activatedContentRevisions) issues.push("Bind activation to the exact Document Markdown revision.");
|
|
934
|
+
else if (!contentRevisionBindingsMatch(document.approvedContentRevisions, document.activatedContentRevisions)) {
|
|
935
|
+
issues.push("The activated revision must match the unchanged approved revision.");
|
|
936
|
+
}
|
|
937
|
+
if (!document.effectiveOn) issues.push("Record the effective date.");
|
|
938
|
+
else if (document.effectiveOn > asOf) issues.push(`The Document does not become effective until ${document.effectiveOn}.`);
|
|
939
|
+
}
|
|
940
|
+
const operating = governedDocumentIsOperating(document, asOf, loaded.model) && issues.length === 0;
|
|
941
|
+
const state = operating
|
|
942
|
+
? "active-and-operating"
|
|
943
|
+
: document.status === "active"
|
|
944
|
+
? "active-with-gaps"
|
|
945
|
+
: document.status === "approved" && issues.length === 0
|
|
946
|
+
? "ready-to-activate"
|
|
947
|
+
: document.status === "approved"
|
|
948
|
+
? "approved-not-ready"
|
|
949
|
+
: "approval-pending";
|
|
950
|
+
assessments.push({
|
|
951
|
+
auditId: audit.id,
|
|
952
|
+
documentId: document.id,
|
|
953
|
+
title: document.title,
|
|
954
|
+
roles: [...new Set(link.roles)],
|
|
955
|
+
state,
|
|
956
|
+
label: auditDocumentActivationLabel(state),
|
|
957
|
+
issues: [...new Set(issues)],
|
|
958
|
+
approvedOn: document.approvedOn || null,
|
|
959
|
+
activatedOn: document.activatedOn || null,
|
|
960
|
+
activatedByIds: document.activatedByIds || [],
|
|
961
|
+
effectiveOn: document.effectiveOn || null,
|
|
962
|
+
approvalRevisionBound: Boolean(document.approvedContentRevisions),
|
|
963
|
+
activationRevisionBound: Boolean(document.activatedContentRevisions),
|
|
964
|
+
gapCount: [...new Set(issues)].length
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
return assessments.sort((left, right) => left.title.localeCompare(right.title));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function auditDocumentActivationLabel(state) {
|
|
971
|
+
return ({
|
|
972
|
+
"approval-pending": "Approval pending in Step 5",
|
|
973
|
+
"approved-not-ready": "Approved, engagement facts incomplete",
|
|
974
|
+
"ready-to-activate": "Ready to activate in Step 5",
|
|
975
|
+
"active-with-gaps": "Active with lifecycle or engagement gaps",
|
|
976
|
+
"active-and-operating": "Active and ready for the engagement"
|
|
977
|
+
})[state] || state;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function managementDocumentLifecycleIssues(loaded, audit, definition, document, source, asOf, byId) {
|
|
981
|
+
const issues = managementDocumentContentIssues(source, definition, audit);
|
|
982
|
+
if (document.documentKind !== definition.kind) {
|
|
983
|
+
issues.push(`Use documentKind ${definition.kind} for this Audit field.`);
|
|
984
|
+
}
|
|
985
|
+
const engagementEnd = coverageEnd(audit?.coverage);
|
|
986
|
+
if (document.approvedOn && engagementEnd && document.approvedOn < engagementEnd) {
|
|
987
|
+
issues.push(`Approve the final document on or after the engagement ${audit.auditKind === "soc-2-type-1" ? "date" : "period end"}.`);
|
|
988
|
+
}
|
|
989
|
+
if (definition.kind === "soc2-period-completeness" && document.approvedOn) {
|
|
990
|
+
const latestReconciliation = loaded.resources
|
|
991
|
+
.filter((record) => record.type === "audit-population" && record.auditId === audit.id)
|
|
992
|
+
.map((record) => record.reconciledOn)
|
|
993
|
+
.filter(Boolean)
|
|
994
|
+
.sort()
|
|
995
|
+
.at(-1);
|
|
996
|
+
if (latestReconciliation && document.approvedOn < latestReconciliation) {
|
|
997
|
+
issues.push("Approve the period completeness statement after the last population reconciliation.");
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (definition.kind === "soc2-management-representation") {
|
|
1001
|
+
const signedEvidence = (document.evidenceIds || [])
|
|
1002
|
+
.map((id) => byId.get(id))
|
|
1003
|
+
.find((record) => (
|
|
1004
|
+
record?.type === "evidence"
|
|
1005
|
+
&& record.status === "verified"
|
|
1006
|
+
&& record.artifactKind === "signed-record"
|
|
1007
|
+
&& record.artifactSubtype === "signed-management-representation"
|
|
1008
|
+
&& (record.filePaths || []).length
|
|
1009
|
+
));
|
|
1010
|
+
if (!signedEvidence) issues.push("Link verified signed-record Evidence with subtype signed-management-representation and a fixed-format copy of the signed letter.");
|
|
1011
|
+
else {
|
|
1012
|
+
const dateIssue = signedRepresentationDateIssue(signedEvidence, audit, loaded.model.modelVersion);
|
|
1013
|
+
if (dateIssue) issues.push(dateIssue);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return issues;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function documentsStage(loaded, audit, byId, asOf) {
|
|
496
1020
|
const definitions = applicableManagementDocuments(audit, loaded.model.auditReadiness || {});
|
|
497
1021
|
const items = [];
|
|
498
1022
|
for (const definition of definitions) {
|
|
499
1023
|
const document = audit?.[definition.field] ? byId.get(audit[definition.field]) : null;
|
|
500
1024
|
const source = document ? await primaryMarkdown(loaded, document) : "";
|
|
501
|
-
const contentIssues =
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
contentIssues.push(`Approve the final document on or after the engagement ${audit.auditKind === "soc-2-type-1" ? "date" : "period end"}.`);
|
|
505
|
-
}
|
|
506
|
-
if (definition.kind === "soc2-period-completeness" && document?.approvedOn && audit) {
|
|
507
|
-
const latestReconciliation = loaded.resources
|
|
508
|
-
.filter((record) => record.type === "audit-population" && record.auditId === audit.id)
|
|
509
|
-
.map((record) => record.reconciledOn)
|
|
510
|
-
.filter(Boolean)
|
|
511
|
-
.sort()
|
|
512
|
-
.at(-1);
|
|
513
|
-
if (latestReconciliation && document.approvedOn < latestReconciliation) {
|
|
514
|
-
contentIssues.push("Approve the period completeness statement after the last population reconciliation.");
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
if (definition.kind === "soc2-management-representation" && document) {
|
|
518
|
-
const signedEvidence = (document.evidenceIds || [])
|
|
519
|
-
.map((id) => byId.get(id))
|
|
520
|
-
.find((record) => (
|
|
521
|
-
record?.type === "evidence"
|
|
522
|
-
&& record.status === "verified"
|
|
523
|
-
&& (record.filePaths || []).length
|
|
524
|
-
));
|
|
525
|
-
if (!signedEvidence) contentIssues.push("Link a verified fixed-format copy of the signed representation letter as evidence.");
|
|
526
|
-
else if (!signedEvidence.collectedOn || (engagementEnd && signedEvidence.collectedOn < engagementEnd)) {
|
|
527
|
-
contentIssues.push(`The signed representation must be dated on or after the engagement ${audit.auditKind === "soc-2-type-1" ? "date" : "period end"}.`);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
1025
|
+
const contentIssues = document
|
|
1026
|
+
? await managementDocumentLifecycleIssues(loaded, audit, definition, document, source, asOf, byId)
|
|
1027
|
+
: [];
|
|
530
1028
|
const complete = Boolean(
|
|
531
1029
|
document
|
|
532
1030
|
&& document.type === "document"
|
|
533
1031
|
&& document.template !== true
|
|
534
|
-
&& document
|
|
1032
|
+
&& governedDocumentIsOperating(document, asOf, loaded.model)
|
|
535
1033
|
&& document.approvedOn
|
|
536
|
-
&& document.effectiveOn
|
|
537
1034
|
&& (document.ownerIds || []).length
|
|
538
1035
|
&& (document.approverIds || []).length
|
|
539
1036
|
&& partiesIndependent(document.ownerIds, document.approverIds, byId)
|
|
@@ -542,13 +1039,17 @@ async function documentsStage(loaded, audit, byId) {
|
|
|
542
1039
|
);
|
|
543
1040
|
const representationLater = definition.kind === "soc2-management-representation"
|
|
544
1041
|
&& audit
|
|
545
|
-
&&
|
|
1042
|
+
&& ["planned", "in-progress", "fieldwork"].includes(audit.status);
|
|
546
1043
|
items.push(item(
|
|
547
1044
|
definition.kind,
|
|
548
1045
|
complete ? "complete" : representationLater ? "later" : "action",
|
|
549
1046
|
definition.title,
|
|
550
1047
|
complete
|
|
551
|
-
?
|
|
1048
|
+
? document.activationBasis === "legacy-v4"
|
|
1049
|
+
? "Linked Markdown is complete and effective. Its active state is preserved from model v4, which did not record approval and activation as separate events."
|
|
1050
|
+
: modelSupports(loaded.model, "governed-document-activation")
|
|
1051
|
+
? "Linked Markdown is complete, independently approved, separately activated by a named Person, revision-bound at both events, and effective."
|
|
1052
|
+
: "Linked Markdown is complete, active, approved, and effective."
|
|
552
1053
|
: document
|
|
553
1054
|
? `${definition.timing} ${contentIssues[0] || "Complete and approve the engagement-specific document."}`
|
|
554
1055
|
: `Link the starter ${definition.title.toLowerCase()} to this audit. ${definition.timing}`,
|
|
@@ -558,6 +1059,24 @@ async function documentsStage(loaded, audit, byId) {
|
|
|
558
1059
|
return stage("documents", "Management Documents", "Prepare management's description, assertions, completeness work, and closing representations.", items);
|
|
559
1060
|
}
|
|
560
1061
|
|
|
1062
|
+
export function signedRepresentationDateIssue(evidence, audit, modelVersion) {
|
|
1063
|
+
const engagementEnd = coverageEnd(audit?.coverage);
|
|
1064
|
+
const timingMessage = `The signed representation must be dated on or after the engagement ${audit?.auditKind === "soc-2-type-1" ? "date" : "period end"}.`;
|
|
1065
|
+
if (!modelSupports(modelVersion, "program-scope")) {
|
|
1066
|
+
return !evidence?.collectedOn || (engagementEnd && evidence.collectedOn < engagementEnd)
|
|
1067
|
+
? timingMessage
|
|
1068
|
+
: null;
|
|
1069
|
+
}
|
|
1070
|
+
const signedOn = evidence?.businessEventAt?.slice(0, 10);
|
|
1071
|
+
if (!signedOn) {
|
|
1072
|
+
return "Record the letter's actual signing timestamp in the signed Evidence businessEventAt field. Collection time does not prove when management made the representations.";
|
|
1073
|
+
}
|
|
1074
|
+
if (audit?.reportDate && signedOn !== audit.reportDate) {
|
|
1075
|
+
return `The written representations are dated ${signedOn}; AT-C 205 requires them to be dated as of the practitioner's report date, ${audit.reportDate}.`;
|
|
1076
|
+
}
|
|
1077
|
+
return engagementEnd && signedOn < engagementEnd ? timingMessage : null;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
561
1080
|
function evidenceStage(audit, records, byId, model) {
|
|
562
1081
|
const controls = (audit?.controlIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
563
1082
|
const evidence = records.filter((record) => record.type === "evidence");
|
|
@@ -617,7 +1136,7 @@ function evidenceStage(audit, records, byId, model) {
|
|
|
617
1136
|
externalEvidence[0] || { type: "evidence" }
|
|
618
1137
|
)
|
|
619
1138
|
];
|
|
620
|
-
const v4 =
|
|
1139
|
+
const v4 = modelSupports(model, "program-scope");
|
|
621
1140
|
const systems = records.filter((record) => record.type === (v4 ? "component" : "system") && record.status === "active");
|
|
622
1141
|
const sourceId = (record) => v4 ? record.sourceComponentId : record.sourceSystemId;
|
|
623
1142
|
for (const source of model.evidenceSourceFamilies || []) {
|
|
@@ -710,8 +1229,35 @@ function populationsStage(audit, records, byId, model) {
|
|
|
710
1229
|
);
|
|
711
1230
|
}
|
|
712
1231
|
|
|
713
|
-
function auditorStage() {
|
|
1232
|
+
function auditorStage(audit, byId, modelVersion) {
|
|
1233
|
+
const subsequentEventsIssue = audit ? subsequentEventsReviewIssue(audit) : null;
|
|
1234
|
+
const subsequentEventsStatus = !audit || ["planned", "in-progress", "fieldwork"].includes(audit.status)
|
|
1235
|
+
? "later"
|
|
1236
|
+
: subsequentEventsIssue ? "action" : "complete";
|
|
1237
|
+
const signatoryIssue = audit && ["report-draft", "issued", "delivered", "complete"].includes(audit.status)
|
|
1238
|
+
? signatoryAppointmentIssue(audit, byId)
|
|
1239
|
+
: null;
|
|
1240
|
+
const signatoryStatus = !audit || ["planned", "in-progress", "fieldwork"].includes(audit.status)
|
|
1241
|
+
? "later"
|
|
1242
|
+
: signatoryIssue ? "action" : "complete";
|
|
1243
|
+
const managementItems = modelSupports(modelVersion, "program-scope") ? [
|
|
1244
|
+
item(
|
|
1245
|
+
"subsequent-events",
|
|
1246
|
+
subsequentEventsStatus,
|
|
1247
|
+
"Review subsequent events through the report date",
|
|
1248
|
+
subsequentEventsIssue?.message || "Management recorded the subsequent-events review through the CPA report date.",
|
|
1249
|
+
audit || { type: "audit" }
|
|
1250
|
+
),
|
|
1251
|
+
item(
|
|
1252
|
+
"signatory-authority",
|
|
1253
|
+
signatoryStatus,
|
|
1254
|
+
"Confirm signatory authority",
|
|
1255
|
+
signatoryIssue?.message || "The linked Appointments establish the signers' authority on the CPA report date.",
|
|
1256
|
+
audit || { type: "audit" }
|
|
1257
|
+
)
|
|
1258
|
+
] : [];
|
|
714
1259
|
return stage("auditor", "Fieldwork and Report", "filegrc prepares the record set but does not make the CPA firm's independent judgments.", [
|
|
1260
|
+
...managementItems,
|
|
715
1261
|
item("firm-eligibility", "external", "Firm eligibility and independence", "Confirm directly with the engagement partner that the firm and signing practitioner meet applicable licensing, peer-review, ethics, and independence requirements. Keep the signed engagement terms with the audit record if management needs a copy."),
|
|
716
1262
|
item("sampling", "external", "Sample selection and independent testing", "The auditor chooses samples, performs tests, evaluates exceptions, and decides whether more work is needed."),
|
|
717
1263
|
item("report", "external", "Report and opinion", "Management reviews and signs its representations. The auditor issues the final report and opinion."),
|
|
@@ -804,8 +1350,9 @@ async function primaryMarkdown(loaded, record) {
|
|
|
804
1350
|
if (!item) return "";
|
|
805
1351
|
try {
|
|
806
1352
|
return await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
807
|
-
} catch {
|
|
808
|
-
return "";
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
if (error.code === "ENOENT") return "";
|
|
1355
|
+
throw error;
|
|
809
1356
|
}
|
|
810
1357
|
}
|
|
811
1358
|
|
|
@@ -988,7 +1535,13 @@ function materializeManagementMarkdown(source, audit, records) {
|
|
|
988
1535
|
.replaceAll("[selected categories]", categories || "[selected categories]")
|
|
989
1536
|
.replaceAll(
|
|
990
1537
|
"[Carve-out, inclusive, or not applicable]",
|
|
991
|
-
audit.
|
|
1538
|
+
audit.subserviceConclusion
|
|
1539
|
+
? audit.subserviceConclusion === "not-applicable"
|
|
1540
|
+
? `Not applicable: ${audit.subserviceConclusionRationale || "[explain the conclusion]"}`
|
|
1541
|
+
: `${[...new Set((audit.subserviceTreatments || []).map(({ method }) => displayValue(method)))].join(" and ") || "[record subservice treatments]"}: ${audit.subserviceConclusionRationale || "[explain the conclusion]"}`
|
|
1542
|
+
: audit.subserviceMethod
|
|
1543
|
+
? displayValue(audit.subserviceMethod)
|
|
1544
|
+
: "[Carve-out, inclusive, or not applicable]"
|
|
992
1545
|
);
|
|
993
1546
|
}
|
|
994
1547
|
|
|
@@ -1010,14 +1563,15 @@ function stage(id, title, description, items) {
|
|
|
1010
1563
|
return { id, title, description, items };
|
|
1011
1564
|
}
|
|
1012
1565
|
|
|
1013
|
-
function item(id, status, title, message, resource = {}) {
|
|
1566
|
+
function item(id, status, title, message, resource = {}, options = {}) {
|
|
1014
1567
|
return {
|
|
1015
1568
|
id,
|
|
1016
1569
|
status,
|
|
1017
1570
|
title,
|
|
1018
1571
|
message,
|
|
1019
1572
|
...(resource.type ? { resourceType: resource.type } : {}),
|
|
1020
|
-
...(resource.id ? { resourceId: resource.id } : {})
|
|
1573
|
+
...(resource.id ? { resourceId: resource.id } : {}),
|
|
1574
|
+
...(options.commands?.length ? { commands: options.commands } : {})
|
|
1021
1575
|
};
|
|
1022
1576
|
}
|
|
1023
1577
|
|