filegrc 0.7.0 → 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 +97 -6
- 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 +43 -0
- package/src/evidence-packet.js +202 -36
- package/src/files.js +53 -3
- package/src/git.js +39 -7
- package/src/index.js +6 -0
- package/src/policy-library/information-security-policy-v2.md +290 -0
- package/src/policy-library.js +826 -0
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +74 -7
- package/src/setup.js +18 -1
- package/src/soc2.js +227 -0
- package/src/state.js +8 -0
- package/src/validate.js +20 -0
- package/src/web.js +97 -20
- package/src/workflow.js +90 -41
package/src/audit-preparation.js
CHANGED
|
@@ -9,11 +9,22 @@ import {
|
|
|
9
9
|
coverageStart
|
|
10
10
|
} from "./coverage.js";
|
|
11
11
|
import { createResource, createResources, deleteResource, updateResource } from "./files.js";
|
|
12
|
+
import { getChangedDataPathsSinceRevision, getFileAtRevision, hasGitRevision } from "./git.js";
|
|
12
13
|
import { createResourceId } from "./id.js";
|
|
13
14
|
import { currentPartyPeople, partiesIndependent } from "./parties.js";
|
|
14
15
|
import { resolveDataPath } from "./paths.js";
|
|
15
16
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
16
17
|
import { markdownEntries } from "./resource-markdown.js";
|
|
18
|
+
import {
|
|
19
|
+
auditorWasEngaged,
|
|
20
|
+
missingSoc2References,
|
|
21
|
+
personWasActiveOn,
|
|
22
|
+
recordWasInUseDuringAudit,
|
|
23
|
+
REQUIRED_SOC2_DESCRIPTION_REFERENCES,
|
|
24
|
+
REQUIRED_SOC2_SECURITY_REFERENCES,
|
|
25
|
+
signatoryAppointmentIssue,
|
|
26
|
+
subsequentEventsReviewIssue
|
|
27
|
+
} from "./soc2.js";
|
|
17
28
|
import { loadWorkspace } from "./workspace.js";
|
|
18
29
|
|
|
19
30
|
const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
@@ -39,7 +50,6 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
|
39
50
|
"vendor",
|
|
40
51
|
"workspace"
|
|
41
52
|
]);
|
|
42
|
-
|
|
43
53
|
export async function assessAuditPreparation(input, options = {}) {
|
|
44
54
|
const loaded = input?.resources && input?.model && input?.entries
|
|
45
55
|
? input
|
|
@@ -61,7 +71,7 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
61
71
|
const stages = [
|
|
62
72
|
programFoundationStage(programReadiness, loaded.workspace),
|
|
63
73
|
engagementStage(audit, byId, programReadiness),
|
|
64
|
-
scopeStage(audit, records, byId, programReadiness)
|
|
74
|
+
scopeStage(loaded, audit, records, byId, programReadiness)
|
|
65
75
|
];
|
|
66
76
|
const fieldworkSections = audit
|
|
67
77
|
? [
|
|
@@ -71,7 +81,7 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
71
81
|
]
|
|
72
82
|
: [];
|
|
73
83
|
stages.push(fieldworkStage(audit, fieldworkSections));
|
|
74
|
-
stages.push(auditorStage());
|
|
84
|
+
stages.push(auditorStage(audit, byId, loaded.model.modelVersion));
|
|
75
85
|
|
|
76
86
|
for (const stage of stages) {
|
|
77
87
|
stage.counts = countStatuses(stage.items);
|
|
@@ -212,7 +222,7 @@ export async function prepareAuditWorkspace(input, options = {}) {
|
|
|
212
222
|
};
|
|
213
223
|
}
|
|
214
224
|
|
|
215
|
-
function scopeStage(audit, records, byId, programReadiness) {
|
|
225
|
+
function scopeStage(loaded, audit, records, byId, programReadiness) {
|
|
216
226
|
const items = [];
|
|
217
227
|
if (!audit) {
|
|
218
228
|
items.push(item(
|
|
@@ -259,11 +269,24 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
259
269
|
));
|
|
260
270
|
}
|
|
261
271
|
|
|
272
|
+
const v4 = String(programReadiness.dataModelVersion) === "4";
|
|
273
|
+
const engagementStart = coverageStart(audit.coverage);
|
|
274
|
+
const engagementEnd = coverageEnd(audit.coverage);
|
|
275
|
+
const scopeRevision = assessScopeRevision(loaded, audit, v4);
|
|
276
|
+
items.push(item(
|
|
277
|
+
"scope-revision",
|
|
278
|
+
scopeRevision.status,
|
|
279
|
+
"Bind the reviewed engagement scope",
|
|
280
|
+
scopeRevision.message,
|
|
281
|
+
audit
|
|
282
|
+
));
|
|
262
283
|
const systems = (audit.systemIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
263
284
|
const completeSystems = systems.filter((system) => (
|
|
264
|
-
system
|
|
265
|
-
&&
|
|
266
|
-
|
|
285
|
+
recordWasInUseDuringAudit(system, engagementStart, engagementEnd)
|
|
286
|
+
&& (v4
|
|
287
|
+
? system.purpose && system.boundary && (system.servicesProvided || []).length
|
|
288
|
+
: system.description)
|
|
289
|
+
&& (v4 || system.classificationId)
|
|
267
290
|
&& (system.ownerIds || []).length
|
|
268
291
|
));
|
|
269
292
|
items.push(item(
|
|
@@ -271,12 +294,11 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
271
294
|
systems.length && completeSystems.length === systems.length ? "complete" : "action",
|
|
272
295
|
"Define the service boundary",
|
|
273
296
|
systems.length
|
|
274
|
-
? `${completeSystems.length} of ${systems.length} selected systems
|
|
297
|
+
? `${completeSystems.length} of ${systems.length} selected systems were in use for the engagement, explicitly in scope, owned, and described${v4 ? "" : ", with a classification"}.`
|
|
275
298
|
: "Select every in-scope service and supporting system, then describe its owner, environment, data, vendors, and boundary.",
|
|
276
299
|
systems[0] || { type: "system" }
|
|
277
300
|
));
|
|
278
301
|
|
|
279
|
-
const engagementStart = coverageStart(audit.coverage);
|
|
280
302
|
const commitments = records.filter((record) => record.type === "commitment"
|
|
281
303
|
&& record.status === "active"
|
|
282
304
|
&& systems.some((system) => (record.systemIds || []).includes(system.id)));
|
|
@@ -304,48 +326,121 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
304
326
|
const frameworkRequirementIds = records
|
|
305
327
|
.filter((record) => record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId))
|
|
306
328
|
.map((record) => record.id);
|
|
329
|
+
const program = audit.programId ? byId.get(audit.programId) : null;
|
|
330
|
+
const v4Decisions = new Map((program?.requirementApplicability || []).map((decision) => [decision.requirementId, decision]));
|
|
307
331
|
const unresolvedRequirements = frameworkRequirementIds
|
|
308
332
|
.map((id) => byId.get(id))
|
|
309
|
-
.filter((requirement) =>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
333
|
+
.filter((requirement) => {
|
|
334
|
+
if (!v4) {
|
|
335
|
+
return requirement.applicability === "undetermined"
|
|
336
|
+
|| (requirement.applicability === "not-applicable" && !requirement.applicabilityRationale);
|
|
337
|
+
}
|
|
338
|
+
const decision = v4Decisions.get(requirement.id);
|
|
339
|
+
return !decision
|
|
340
|
+
|| decision.decision === "undetermined"
|
|
341
|
+
|| (decision.decision === "not-applicable" && !decision.rationale);
|
|
342
|
+
});
|
|
343
|
+
const applicableRequirementIds = frameworkRequirementIds.filter((id) => (
|
|
344
|
+
v4 ? v4Decisions.get(id)?.decision === "applicable" : byId.get(id)?.applicability === "applicable"
|
|
345
|
+
));
|
|
314
346
|
const missingApplicableRequirements = applicableRequirementIds.filter((id) => !(audit.requirementIds || []).includes(id));
|
|
315
347
|
const selectedRequirements = (audit.requirementIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
316
348
|
const unexpectedRequirements = selectedRequirements.filter((requirement) => (
|
|
317
349
|
!(audit.frameworkIds || []).includes(requirement.frameworkId)
|
|
318
|
-
||
|
|
350
|
+
|| (v4
|
|
351
|
+
? v4Decisions.get(requirement.id)?.decision !== "applicable"
|
|
352
|
+
: requirement.applicability !== "applicable")
|
|
319
353
|
));
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
354
|
+
const selectedControls = (audit.controlIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
355
|
+
const uncoveredRequirements = selectedRequirements.filter((requirement) => (
|
|
356
|
+
!isDescriptionRequirement(requirement)
|
|
357
|
+
&& !selectedControls.some((control) => (control.requirementIds || []).includes(requirement.id))
|
|
323
358
|
));
|
|
359
|
+
const descriptionRequirements = frameworkRequirementIds
|
|
360
|
+
.map((id) => byId.get(id))
|
|
361
|
+
.filter(isDescriptionRequirement);
|
|
362
|
+
const missingDescriptionRequirements = descriptionRequirements.filter((requirement) => (
|
|
363
|
+
!(audit.requirementIds || []).includes(requirement.id)
|
|
364
|
+
));
|
|
365
|
+
const missingRequiredDescriptionReferences = v4
|
|
366
|
+
? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
|
|
367
|
+
: [];
|
|
368
|
+
const invalidMandatoryDescriptionDecisions = v4
|
|
369
|
+
? descriptionRequirements.filter((requirement) => (
|
|
370
|
+
REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(String(requirement.reference || "").trim().toUpperCase())
|
|
371
|
+
&& v4Decisions.get(requirement.id)?.decision !== "applicable"
|
|
372
|
+
))
|
|
373
|
+
: [];
|
|
374
|
+
const securityRequirements = frameworkRequirementIds
|
|
375
|
+
.map((id) => byId.get(id))
|
|
376
|
+
.filter(isSecurityRequirement);
|
|
377
|
+
const missingRequiredSecurityReferences = v4
|
|
378
|
+
? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
379
|
+
: [];
|
|
380
|
+
const missingSelectedSecurityReferences = v4
|
|
381
|
+
? missingSoc2References(selectedRequirements.filter(isSecurityRequirement), REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
382
|
+
: [];
|
|
383
|
+
const invalidMandatorySecurityDecisions = v4
|
|
384
|
+
? securityRequirements.filter((requirement) => (
|
|
385
|
+
REQUIRED_SOC2_SECURITY_REFERENCES.includes(String(requirement.reference || "").trim().toUpperCase())
|
|
386
|
+
&& v4Decisions.get(requirement.id)?.decision !== "applicable"
|
|
387
|
+
))
|
|
388
|
+
: [];
|
|
324
389
|
const criteriaComplete = (audit.frameworkIds || []).length
|
|
325
390
|
&& (audit.requirementIds || []).length
|
|
326
391
|
&& (audit.controlIds || []).length
|
|
327
392
|
&& !unresolvedRequirements.length
|
|
328
393
|
&& !missingApplicableRequirements.length
|
|
329
394
|
&& !unexpectedRequirements.length
|
|
330
|
-
&&
|
|
395
|
+
&& !uncoveredRequirements.length
|
|
396
|
+
&& descriptionRequirements.length
|
|
397
|
+
&& !missingDescriptionRequirements.length
|
|
398
|
+
&& !missingRequiredDescriptionReferences.length
|
|
399
|
+
&& !missingRequiredSecurityReferences.length
|
|
400
|
+
&& !missingSelectedSecurityReferences.length
|
|
401
|
+
&& !invalidMandatorySecurityDecisions.length
|
|
402
|
+
&& !invalidMandatoryDescriptionDecisions.length;
|
|
331
403
|
items.push(item(
|
|
332
404
|
"criteria",
|
|
333
405
|
criteriaComplete ? "complete" : "action",
|
|
334
|
-
"Confirm criteria
|
|
406
|
+
"Confirm Trust Services criteria, Description Criteria, and Controls",
|
|
335
407
|
criteriaComplete
|
|
336
|
-
? `${
|
|
408
|
+
? `${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
409
|
: unresolvedRequirements.length
|
|
338
410
|
? `Resolve applicability and record a rationale for ${unresolvedRequirements.length} selected-framework criteria.`
|
|
339
|
-
:
|
|
340
|
-
?
|
|
341
|
-
:
|
|
342
|
-
? `
|
|
343
|
-
:
|
|
344
|
-
?
|
|
345
|
-
|
|
346
|
-
|
|
411
|
+
: !descriptionRequirements.length
|
|
412
|
+
? "Select the SOC 2 Description Criteria framework and all nine criteria. These govern the system description and do not map to Controls."
|
|
413
|
+
: missingRequiredDescriptionReferences.length
|
|
414
|
+
? `Use the complete SOC 2 Description Criteria set; ${missingRequiredDescriptionReferences.join(", ")} ${missingRequiredDescriptionReferences.length === 1 ? "is" : "are"} missing from the selected framework.`
|
|
415
|
+
: missingRequiredSecurityReferences.length
|
|
416
|
+
? `Use the complete SOC 2 Security Common Criteria set; ${missingRequiredSecurityReferences.join(", ")} ${missingRequiredSecurityReferences.length === 1 ? "is" : "are"} missing from the selected framework.`
|
|
417
|
+
: invalidMandatoryDescriptionDecisions.length
|
|
418
|
+
? `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.`
|
|
419
|
+
: invalidMandatorySecurityDecisions.length
|
|
420
|
+
? `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.`
|
|
421
|
+
: missingSelectedSecurityReferences.length
|
|
422
|
+
? `Add every Security Common Criterion to the engagement; ${missingSelectedSecurityReferences.join(", ")} ${missingSelectedSecurityReferences.length === 1 ? "is" : "are"} missing.`
|
|
423
|
+
: missingDescriptionRequirements.length
|
|
424
|
+
? `Add all nine SOC 2 Description Criteria to the engagement; ${missingDescriptionRequirements.length} ${missingDescriptionRequirements.length === 1 ? "is" : "are"} missing.`
|
|
425
|
+
: missingApplicableRequirements.length
|
|
426
|
+
? `Add ${missingApplicableRequirements.length} applicable selected-framework Trust Services criteria to the engagement.`
|
|
427
|
+
: unexpectedRequirements.length
|
|
428
|
+
? `Remove ${unexpectedRequirements.length} criteria that are not applicable members of the selected frameworks.`
|
|
429
|
+
: uncoveredRequirements.length
|
|
430
|
+
? `Map ${uncoveredRequirements.length} selected Trust Services criteria to Controls included in the engagement.`
|
|
431
|
+
: "Select the Security criteria, any optional Trust Services Categories, and the Controls included in this report.",
|
|
432
|
+
audit,
|
|
433
|
+
{
|
|
434
|
+
commands: unresolvedRequirements.length
|
|
435
|
+
? [
|
|
436
|
+
"npx filegrc review-applicability --scaffold --type requirement > decisions.json",
|
|
437
|
+
"npx filegrc review-applicability decisions.json --preview --json"
|
|
438
|
+
]
|
|
439
|
+
: [`npx filegrc get ${audit.id} --mutation`]
|
|
440
|
+
}
|
|
347
441
|
));
|
|
348
442
|
|
|
443
|
+
const treatments = audit.subserviceTreatments || [];
|
|
349
444
|
const expectedSubserviceVendorIds = new Set(systems.flatMap((system) => system.subserviceVendorIds || []));
|
|
350
445
|
const missingSubserviceVendorIds = [...expectedSubserviceVendorIds].filter((id) => !(audit.subserviceVendorIds || []).includes(id));
|
|
351
446
|
const inclusiveSystemIds = records
|
|
@@ -355,16 +450,54 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
355
450
|
.map((id) => byId.get(id))
|
|
356
451
|
.filter((control) => (control?.systemIds || []).some((id) => inclusiveSystemIds.includes(id)))
|
|
357
452
|
.length;
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
453
|
+
const v4InclusiveTreatments = treatments.filter(({ method }) => method === "inclusive");
|
|
454
|
+
const treatmentComponentCounts = new Map();
|
|
455
|
+
for (const treatment of treatments) {
|
|
456
|
+
for (const componentId of treatment.componentIds || []) {
|
|
457
|
+
treatmentComponentCounts.set(componentId, (treatmentComponentCounts.get(componentId) || 0) + 1);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const v4InvalidTreatments = treatments.filter((treatment) => (
|
|
461
|
+
byId.get(treatment.vendorId)?.type !== "vendor"
|
|
462
|
+
|| !recordWasInUseDuringAudit(byId.get(treatment.vendorId), engagementStart, engagementEnd)
|
|
463
|
+
|| !(treatment.componentIds || []).length
|
|
464
|
+
|| (treatment.componentIds || []).some((componentId) => {
|
|
465
|
+
const component = byId.get(componentId);
|
|
466
|
+
return component?.type !== "component"
|
|
467
|
+
|| !recordWasInUseDuringAudit(component, engagementStart, engagementEnd)
|
|
468
|
+
|| component.vendorId !== treatment.vendorId
|
|
469
|
+
|| (treatmentComponentCounts.get(componentId) || 0) > 1
|
|
470
|
+
|| !(component.systemUses || []).some(({ systemId }) => (audit.systemIds || []).includes(systemId));
|
|
471
|
+
})
|
|
472
|
+
));
|
|
473
|
+
const v4InclusiveWithoutControls = v4InclusiveTreatments.filter((treatment) => !selectedControls.some((control) => (
|
|
474
|
+
(control.componentIds || []).some((id) => (treatment.componentIds || []).includes(id))
|
|
475
|
+
)));
|
|
476
|
+
const subserviceComplete = v4
|
|
477
|
+
? Boolean(audit.subserviceConclusion && audit.subserviceConclusionRationale)
|
|
478
|
+
&& (audit.subserviceConclusion === "identified" ? treatments.length > 0 : treatments.length === 0)
|
|
479
|
+
&& v4InvalidTreatments.length === 0
|
|
480
|
+
&& v4InclusiveWithoutControls.length === 0
|
|
481
|
+
: Boolean(audit.subserviceMethod)
|
|
482
|
+
&& !((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable")
|
|
483
|
+
&& !missingSubserviceVendorIds.length
|
|
484
|
+
&& !(audit.subserviceMethod === "inclusive" && (!inclusiveSystemIds.length || !inclusiveControlCount));
|
|
362
485
|
items.push(item(
|
|
363
486
|
"subservices",
|
|
364
487
|
subserviceComplete ? "complete" : "action",
|
|
365
488
|
"Decide how subservice organizations are presented",
|
|
366
489
|
subserviceComplete
|
|
367
|
-
?
|
|
490
|
+
? v4
|
|
491
|
+
? audit.subserviceConclusion === "identified"
|
|
492
|
+
? `${treatments.length} Vendor and Component subservice treatments are recorded with a rationale.`
|
|
493
|
+
: "Management recorded and explained that no subservice organizations are included in the report scope."
|
|
494
|
+
: `${displayValue(audit.subserviceMethod)} method selected for ${(audit.subserviceVendorIds || []).length} subservice organizations${audit.subserviceMethod === "inclusive" ? `, with ${inclusiveControlCount} included controls` : ""}.`
|
|
495
|
+
: v4
|
|
496
|
+
? v4InvalidTreatments.length
|
|
497
|
+
? `${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.`
|
|
498
|
+
: v4InclusiveWithoutControls.length
|
|
499
|
+
? `${v4InclusiveWithoutControls.length} inclusive subservice treatments have no selected Controls linked to their Components.`
|
|
500
|
+
: "Record whether subservice organizations are identified, explain the conclusion, and record each Vendor, Component, carve-out or inclusive method, and rationale when applicable."
|
|
368
501
|
: missingSubserviceVendorIds.length
|
|
369
502
|
? `Add ${missingSubserviceVendorIds.length} subservice organizations already identified by the in-scope systems.`
|
|
370
503
|
: audit.subserviceMethod === "inclusive"
|
|
@@ -404,6 +537,175 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
404
537
|
return stage("period", "Confirm the Formal Period", "Record the auditor-agreed report type, date or period, scope, criteria, systems, and dependency treatment.", items);
|
|
405
538
|
}
|
|
406
539
|
|
|
540
|
+
function isDescriptionRequirement(requirement) {
|
|
541
|
+
return (requirement.tags || []).includes("description-criteria") || /^DC\d+/i.test(requirement.reference || "");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function isSecurityRequirement(requirement) {
|
|
545
|
+
const tags = requirement?.tags || [];
|
|
546
|
+
return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const AUDIT_SCOPE_REVISION_FIELDS = [
|
|
550
|
+
"auditKind",
|
|
551
|
+
"programId",
|
|
552
|
+
"frameworkIds",
|
|
553
|
+
"scope",
|
|
554
|
+
"coverage",
|
|
555
|
+
"systemIds",
|
|
556
|
+
"requirementIds",
|
|
557
|
+
"controlIds",
|
|
558
|
+
"commitmentIds",
|
|
559
|
+
"complementaryControlIds",
|
|
560
|
+
"complementaryControlsConclusion",
|
|
561
|
+
"subserviceConclusion",
|
|
562
|
+
"subserviceConclusionRationale",
|
|
563
|
+
"subserviceTreatments",
|
|
564
|
+
"subserviceVendorIds",
|
|
565
|
+
"subserviceMethod",
|
|
566
|
+
"signatoryAppointmentIds"
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
function assessScopeRevision(loaded, audit, v4) {
|
|
570
|
+
if (!v4) {
|
|
571
|
+
return {
|
|
572
|
+
status: "complete",
|
|
573
|
+
message: "The legacy workspace stores the engagement selections directly on the Audit."
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (!audit.scopeRevision) {
|
|
577
|
+
return {
|
|
578
|
+
status: "action",
|
|
579
|
+
message: "Review the engagement's Program, Systems, criteria, Controls, commitments, subservices, complementary controls, and signatories, then record the reviewed Git revision in scopeRevision."
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
if (!hasGitRevision(loaded.root, audit.scopeRevision)) {
|
|
583
|
+
return {
|
|
584
|
+
status: "action",
|
|
585
|
+
message: `${audit.scopeRevision} is not an available Git commit. Commit the reviewed scope and record that exact commit in scopeRevision.`
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const entry = loaded.entries.find(({ record }) => record.id === audit.id);
|
|
589
|
+
const source = entry
|
|
590
|
+
? getFileAtRevision(loaded.root, audit.scopeRevision, `data/${entry.relativePath}`)
|
|
591
|
+
: null;
|
|
592
|
+
let historicalAudit = null;
|
|
593
|
+
try {
|
|
594
|
+
historicalAudit = source ? JSON.parse(source) : null;
|
|
595
|
+
} catch {
|
|
596
|
+
historicalAudit = null;
|
|
597
|
+
}
|
|
598
|
+
if (historicalAudit?.id !== audit.id || historicalAudit.type !== "audit") {
|
|
599
|
+
return {
|
|
600
|
+
status: "action",
|
|
601
|
+
message: `${audit.scopeRevision} does not contain this Audit record. Commit the reviewed scope and update scopeRevision.`
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
if (auditScopeFingerprint(historicalAudit) !== auditScopeFingerprint(audit)) {
|
|
605
|
+
return {
|
|
606
|
+
status: "action",
|
|
607
|
+
message: "The current engagement scope differs from the scope stored at scopeRevision. Review the diff, commit the accepted scope, and update scopeRevision."
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
const changedScopeRecords = scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit);
|
|
611
|
+
if (changedScopeRecords === null) {
|
|
612
|
+
return {
|
|
613
|
+
status: "action",
|
|
614
|
+
message: "FileGRC could not compare the current engagement scope with scopeRevision. Confirm Git history is available, then review and record the exact scope commit."
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (changedScopeRecords.length) {
|
|
618
|
+
return {
|
|
619
|
+
status: "action",
|
|
620
|
+
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.`
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
status: "complete",
|
|
625
|
+
message: `Management reviewed the current engagement scope at Git revision ${audit.scopeRevision}.`
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit) {
|
|
630
|
+
const currentByPath = new Map(loaded.entries.map((entry) => [`data/${entry.relativePath}`, entry.record]));
|
|
631
|
+
const changedPaths = getChangedDataPathsSinceRevision(loaded.root, audit.scopeRevision);
|
|
632
|
+
if (!changedPaths) return null;
|
|
633
|
+
const candidatePaths = changedPaths.filter((path) => path.endsWith(".json"));
|
|
634
|
+
const changed = [];
|
|
635
|
+
for (const path of candidatePaths) {
|
|
636
|
+
const current = currentByPath.get(path) || null;
|
|
637
|
+
let historical = null;
|
|
638
|
+
const historicalSource = getFileAtRevision(loaded.root, audit.scopeRevision, path);
|
|
639
|
+
try {
|
|
640
|
+
historical = historicalSource ? JSON.parse(historicalSource) : null;
|
|
641
|
+
} catch {
|
|
642
|
+
historical = null;
|
|
643
|
+
}
|
|
644
|
+
if (
|
|
645
|
+
!scopeRecordIsRelevant(current, audit)
|
|
646
|
+
&& !scopeRecordIsRelevant(historical, historicalAudit)
|
|
647
|
+
) continue;
|
|
648
|
+
if (JSON.stringify(canonicalScopeValue(current)) !== JSON.stringify(canonicalScopeValue(historical))) changed.push(path);
|
|
649
|
+
}
|
|
650
|
+
return changed;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function scopeRecordIsRelevant(record, audit) {
|
|
654
|
+
if (!record || !audit) return false;
|
|
655
|
+
const selectedSystemIds = new Set(audit.systemIds || []);
|
|
656
|
+
const selectedFrameworkIds = new Set(audit.frameworkIds || []);
|
|
657
|
+
const selectedIds = new Set([
|
|
658
|
+
audit.programId,
|
|
659
|
+
...(audit.frameworkIds || []),
|
|
660
|
+
...(audit.systemIds || []),
|
|
661
|
+
...(audit.requirementIds || []),
|
|
662
|
+
...(audit.controlIds || []),
|
|
663
|
+
...(audit.commitmentIds || []),
|
|
664
|
+
...(audit.complementaryControlIds || []),
|
|
665
|
+
...(audit.signatoryAppointmentIds || []),
|
|
666
|
+
...auditSubserviceVendorIds(audit),
|
|
667
|
+
...auditSubserviceComponentIds(audit)
|
|
668
|
+
].filter(Boolean));
|
|
669
|
+
if (selectedIds.has(record.id)) return true;
|
|
670
|
+
if (record.type === "requirement") return selectedFrameworkIds.has(record.frameworkId);
|
|
671
|
+
if (["commitment", "complementary-control"].includes(record.type)) {
|
|
672
|
+
return (record.systemIds || []).some((id) => selectedSystemIds.has(id));
|
|
673
|
+
}
|
|
674
|
+
if (record.type === "component") {
|
|
675
|
+
return (record.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId));
|
|
676
|
+
}
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function auditSubserviceVendorIds(audit) {
|
|
681
|
+
return [
|
|
682
|
+
...(audit.subserviceVendorIds || []),
|
|
683
|
+
...(audit.subserviceTreatments || []).map(({ vendorId }) => vendorId)
|
|
684
|
+
];
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function auditSubserviceComponentIds(audit) {
|
|
688
|
+
return (audit.subserviceTreatments || []).flatMap(({ componentIds }) => componentIds || []);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function auditScopeFingerprint(audit) {
|
|
692
|
+
return JSON.stringify(Object.fromEntries(AUDIT_SCOPE_REVISION_FIELDS.map((field) => (
|
|
693
|
+
[field, canonicalScopeValue(audit[field] ?? null)]
|
|
694
|
+
))));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function canonicalScopeValue(value) {
|
|
698
|
+
if (Array.isArray(value)) {
|
|
699
|
+
return value
|
|
700
|
+
.map(canonicalScopeValue)
|
|
701
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
702
|
+
}
|
|
703
|
+
if (value && typeof value === "object") {
|
|
704
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalScopeValue(value[key])]));
|
|
705
|
+
}
|
|
706
|
+
return value;
|
|
707
|
+
}
|
|
708
|
+
|
|
407
709
|
function programFoundationStage(programReadiness, workspace) {
|
|
408
710
|
const ready = programReadiness.evidenceReady;
|
|
409
711
|
return stage("program", "Program Readiness", "The management program can be prepared and operated without an audit record or CPA firm.", [
|
|
@@ -434,11 +736,32 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
434
736
|
]);
|
|
435
737
|
}
|
|
436
738
|
const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
|
|
437
|
-
const named =
|
|
739
|
+
const named = auditorWasEngaged(auditor, audit);
|
|
438
740
|
const currentOwners = [...currentPartyPeople(audit.ownerIds, byId)]
|
|
439
741
|
.map((id) => byId.get(id))
|
|
440
742
|
.filter(Boolean);
|
|
441
|
-
|
|
743
|
+
const engagementTerms = audit.engagementTermsDocumentId ? byId.get(audit.engagementTermsDocumentId) : null;
|
|
744
|
+
const engagementTermsComplete = Boolean(
|
|
745
|
+
engagementTerms?.type === "document"
|
|
746
|
+
&& engagementTerms.documentKind === "soc2-engagement-terms"
|
|
747
|
+
&& engagementTerms.status === "active"
|
|
748
|
+
&& engagementTerms.approvedOn
|
|
749
|
+
&& engagementTerms.effectiveOn
|
|
750
|
+
);
|
|
751
|
+
const acknowledgementPeople = (audit.managementAcknowledgedByIds || [])
|
|
752
|
+
.map((id) => byId.get(id))
|
|
753
|
+
.filter((record) => personWasActiveOn(record, audit.managementAcknowledgedOn));
|
|
754
|
+
const acknowledged = Boolean(
|
|
755
|
+
engagementTermsComplete
|
|
756
|
+
&& acknowledgementPeople.length === (audit.managementAcknowledgedByIds || []).length
|
|
757
|
+
&& acknowledgementPeople.length
|
|
758
|
+
&& audit.managementAcknowledgedOn
|
|
759
|
+
&& audit.managementAcknowledgedOn >= engagementTerms.approvedOn
|
|
760
|
+
&& (!audit.fieldworkStart || audit.managementAcknowledgedOn <= audit.fieldworkStart)
|
|
761
|
+
);
|
|
762
|
+
const preliminary = audit.status === "planned";
|
|
763
|
+
const programGoalAligned = programReadiness.target.goal === audit.auditKind;
|
|
764
|
+
const items = [
|
|
442
765
|
item(
|
|
443
766
|
"engagement-record",
|
|
444
767
|
"complete",
|
|
@@ -446,13 +769,24 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
446
769
|
`${audit.title} tracks the formal scope, dates, requests, fieldwork, and report.`,
|
|
447
770
|
audit
|
|
448
771
|
),
|
|
772
|
+
item(
|
|
773
|
+
"program-goal-alignment",
|
|
774
|
+
programGoalAligned ? "complete" : "action",
|
|
775
|
+
"Align the Program goal with the engagement",
|
|
776
|
+
programGoalAligned
|
|
777
|
+
? `The Program goal and formal engagement are both ${audit.auditKind === "soc-2-type-2" ? "SOC 2 Type 2" : "SOC 2 Type 1"}.`
|
|
778
|
+
: `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.`,
|
|
779
|
+
audit
|
|
780
|
+
),
|
|
449
781
|
item(
|
|
450
782
|
"auditor",
|
|
451
783
|
named ? "complete" : "action",
|
|
452
784
|
"Record the independent CPA firm",
|
|
453
785
|
named
|
|
454
786
|
? `${auditor.title} is recorded for the engagement.`
|
|
455
|
-
:
|
|
787
|
+
: auditor
|
|
788
|
+
? `${auditor.title} was not active for the engagement period. Confirm the CPA firm and its engagement dates.`
|
|
789
|
+
: "Select the CPA firm and record it here. The independent management policy reviewer is a different role.",
|
|
456
790
|
audit
|
|
457
791
|
),
|
|
458
792
|
item(
|
|
@@ -470,7 +804,34 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
470
804
|
]
|
|
471
805
|
}
|
|
472
806
|
)
|
|
473
|
-
]
|
|
807
|
+
];
|
|
808
|
+
if (["3", "4"].includes(String(programReadiness.dataModelVersion))) {
|
|
809
|
+
items.push(
|
|
810
|
+
item(
|
|
811
|
+
"engagement-terms",
|
|
812
|
+
engagementTermsComplete ? "complete" : preliminary ? "later" : "action",
|
|
813
|
+
"Link the engagement terms",
|
|
814
|
+
engagementTermsComplete
|
|
815
|
+
? `${engagementTerms.title} records the accepted CPA engagement terms.`
|
|
816
|
+
: preliminary
|
|
817
|
+
? "Link the accepted CPA engagement terms before moving this Audit to in progress."
|
|
818
|
+
: "Link an active, approved Document with documentKind soc2-engagement-terms that contains the accepted CPA engagement terms.",
|
|
819
|
+
engagementTerms || { type: "document" }
|
|
820
|
+
),
|
|
821
|
+
item(
|
|
822
|
+
"management-acknowledgement",
|
|
823
|
+
acknowledged ? "complete" : preliminary ? "later" : "action",
|
|
824
|
+
"Record management acknowledgement",
|
|
825
|
+
acknowledged
|
|
826
|
+
? `Management acknowledged the engagement terms on ${audit.managementAcknowledgedOn}.`
|
|
827
|
+
: preliminary
|
|
828
|
+
? "Name the responsible management people and the acknowledgement date before moving this Audit to in progress."
|
|
829
|
+
: "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.",
|
|
830
|
+
audit
|
|
831
|
+
)
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", items);
|
|
474
835
|
}
|
|
475
836
|
|
|
476
837
|
function fieldworkStage(audit, sections) {
|
|
@@ -520,11 +881,14 @@ async function documentsStage(loaded, audit, byId) {
|
|
|
520
881
|
.find((record) => (
|
|
521
882
|
record?.type === "evidence"
|
|
522
883
|
&& record.status === "verified"
|
|
884
|
+
&& record.artifactKind === "signed-record"
|
|
885
|
+
&& record.artifactSubtype === "signed-management-representation"
|
|
523
886
|
&& (record.filePaths || []).length
|
|
524
887
|
));
|
|
525
|
-
if (!signedEvidence) contentIssues.push("Link a
|
|
526
|
-
else
|
|
527
|
-
|
|
888
|
+
if (!signedEvidence) contentIssues.push("Link verified signed-record Evidence with subtype signed-management-representation and a fixed-format copy of the signed letter.");
|
|
889
|
+
else {
|
|
890
|
+
const dateIssue = signedRepresentationDateIssue(signedEvidence, audit, loaded.model.modelVersion);
|
|
891
|
+
if (dateIssue) contentIssues.push(dateIssue);
|
|
528
892
|
}
|
|
529
893
|
}
|
|
530
894
|
const complete = Boolean(
|
|
@@ -542,7 +906,7 @@ async function documentsStage(loaded, audit, byId) {
|
|
|
542
906
|
);
|
|
543
907
|
const representationLater = definition.kind === "soc2-management-representation"
|
|
544
908
|
&& audit
|
|
545
|
-
&&
|
|
909
|
+
&& ["planned", "in-progress", "fieldwork"].includes(audit.status);
|
|
546
910
|
items.push(item(
|
|
547
911
|
definition.kind,
|
|
548
912
|
complete ? "complete" : representationLater ? "later" : "action",
|
|
@@ -558,6 +922,24 @@ async function documentsStage(loaded, audit, byId) {
|
|
|
558
922
|
return stage("documents", "Management Documents", "Prepare management's description, assertions, completeness work, and closing representations.", items);
|
|
559
923
|
}
|
|
560
924
|
|
|
925
|
+
export function signedRepresentationDateIssue(evidence, audit, modelVersion) {
|
|
926
|
+
const engagementEnd = coverageEnd(audit?.coverage);
|
|
927
|
+
const timingMessage = `The signed representation must be dated on or after the engagement ${audit?.auditKind === "soc-2-type-1" ? "date" : "period end"}.`;
|
|
928
|
+
if (String(modelVersion) !== "4") {
|
|
929
|
+
return !evidence?.collectedOn || (engagementEnd && evidence.collectedOn < engagementEnd)
|
|
930
|
+
? timingMessage
|
|
931
|
+
: null;
|
|
932
|
+
}
|
|
933
|
+
const signedOn = evidence?.businessEventAt?.slice(0, 10);
|
|
934
|
+
if (!signedOn) {
|
|
935
|
+
return "Record the letter's actual signing timestamp in the signed Evidence businessEventAt field. Collection time does not prove when management made the representations.";
|
|
936
|
+
}
|
|
937
|
+
if (audit?.reportDate && signedOn !== audit.reportDate) {
|
|
938
|
+
return `The written representations are dated ${signedOn}; AT-C 205 requires them to be dated as of the practitioner's report date, ${audit.reportDate}.`;
|
|
939
|
+
}
|
|
940
|
+
return engagementEnd && signedOn < engagementEnd ? timingMessage : null;
|
|
941
|
+
}
|
|
942
|
+
|
|
561
943
|
function evidenceStage(audit, records, byId, model) {
|
|
562
944
|
const controls = (audit?.controlIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
563
945
|
const evidence = records.filter((record) => record.type === "evidence");
|
|
@@ -710,8 +1092,35 @@ function populationsStage(audit, records, byId, model) {
|
|
|
710
1092
|
);
|
|
711
1093
|
}
|
|
712
1094
|
|
|
713
|
-
function auditorStage() {
|
|
1095
|
+
function auditorStage(audit, byId, modelVersion) {
|
|
1096
|
+
const subsequentEventsIssue = audit ? subsequentEventsReviewIssue(audit) : null;
|
|
1097
|
+
const subsequentEventsStatus = !audit || ["planned", "in-progress", "fieldwork"].includes(audit.status)
|
|
1098
|
+
? "later"
|
|
1099
|
+
: subsequentEventsIssue ? "action" : "complete";
|
|
1100
|
+
const signatoryIssue = audit && ["report-draft", "issued", "delivered", "complete"].includes(audit.status)
|
|
1101
|
+
? signatoryAppointmentIssue(audit, byId)
|
|
1102
|
+
: null;
|
|
1103
|
+
const signatoryStatus = !audit || ["planned", "in-progress", "fieldwork"].includes(audit.status)
|
|
1104
|
+
? "later"
|
|
1105
|
+
: signatoryIssue ? "action" : "complete";
|
|
1106
|
+
const managementItems = String(modelVersion) === "4" ? [
|
|
1107
|
+
item(
|
|
1108
|
+
"subsequent-events",
|
|
1109
|
+
subsequentEventsStatus,
|
|
1110
|
+
"Review subsequent events through the report date",
|
|
1111
|
+
subsequentEventsIssue?.message || "Management recorded the subsequent-events review through the CPA report date.",
|
|
1112
|
+
audit || { type: "audit" }
|
|
1113
|
+
),
|
|
1114
|
+
item(
|
|
1115
|
+
"signatory-authority",
|
|
1116
|
+
signatoryStatus,
|
|
1117
|
+
"Confirm signatory authority",
|
|
1118
|
+
signatoryIssue?.message || "The linked Appointments establish the signers' authority on the CPA report date.",
|
|
1119
|
+
audit || { type: "audit" }
|
|
1120
|
+
)
|
|
1121
|
+
] : [];
|
|
714
1122
|
return stage("auditor", "Fieldwork and Report", "filegrc prepares the record set but does not make the CPA firm's independent judgments.", [
|
|
1123
|
+
...managementItems,
|
|
715
1124
|
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
1125
|
item("sampling", "external", "Sample selection and independent testing", "The auditor chooses samples, performs tests, evaluates exceptions, and decides whether more work is needed."),
|
|
717
1126
|
item("report", "external", "Report and opinion", "Management reviews and signs its representations. The auditor issues the final report and opinion."),
|
|
@@ -804,8 +1213,9 @@ async function primaryMarkdown(loaded, record) {
|
|
|
804
1213
|
if (!item) return "";
|
|
805
1214
|
try {
|
|
806
1215
|
return await readFile(resolveDataPath(loaded.root, item.path), "utf8");
|
|
807
|
-
} catch {
|
|
808
|
-
return "";
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
if (error.code === "ENOENT") return "";
|
|
1218
|
+
throw error;
|
|
809
1219
|
}
|
|
810
1220
|
}
|
|
811
1221
|
|
|
@@ -988,7 +1398,13 @@ function materializeManagementMarkdown(source, audit, records) {
|
|
|
988
1398
|
.replaceAll("[selected categories]", categories || "[selected categories]")
|
|
989
1399
|
.replaceAll(
|
|
990
1400
|
"[Carve-out, inclusive, or not applicable]",
|
|
991
|
-
audit.
|
|
1401
|
+
audit.subserviceConclusion
|
|
1402
|
+
? audit.subserviceConclusion === "not-applicable"
|
|
1403
|
+
? `Not applicable: ${audit.subserviceConclusionRationale || "[explain the conclusion]"}`
|
|
1404
|
+
: `${[...new Set((audit.subserviceTreatments || []).map(({ method }) => displayValue(method)))].join(" and ") || "[record subservice treatments]"}: ${audit.subserviceConclusionRationale || "[explain the conclusion]"}`
|
|
1405
|
+
: audit.subserviceMethod
|
|
1406
|
+
? displayValue(audit.subserviceMethod)
|
|
1407
|
+
: "[Carve-out, inclusive, or not applicable]"
|
|
992
1408
|
);
|
|
993
1409
|
}
|
|
994
1410
|
|
|
@@ -1010,14 +1426,15 @@ function stage(id, title, description, items) {
|
|
|
1010
1426
|
return { id, title, description, items };
|
|
1011
1427
|
}
|
|
1012
1428
|
|
|
1013
|
-
function item(id, status, title, message, resource = {}) {
|
|
1429
|
+
function item(id, status, title, message, resource = {}, options = {}) {
|
|
1014
1430
|
return {
|
|
1015
1431
|
id,
|
|
1016
1432
|
status,
|
|
1017
1433
|
title,
|
|
1018
1434
|
message,
|
|
1019
1435
|
...(resource.type ? { resourceType: resource.type } : {}),
|
|
1020
|
-
...(resource.id ? { resourceId: resource.id } : {})
|
|
1436
|
+
...(resource.id ? { resourceId: resource.id } : {}),
|
|
1437
|
+
...(options.commands?.length ? { commands: options.commands } : {})
|
|
1021
1438
|
};
|
|
1022
1439
|
}
|
|
1023
1440
|
|