filegrc 0.6.5 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/model/v4.json +179 -78
- package/package.json +2 -2
- package/src/audit-preparation.js +465 -48
- package/src/audit-transition.js +5 -0
- package/src/batch-review.js +14 -5
- package/src/cli.js +107 -0
- package/src/content-readiness.js +11 -0
- package/src/evidence-packet.js +202 -36
- package/src/files.js +58 -3
- package/src/git.js +39 -7
- package/src/index.js +7 -0
- package/src/policy-activation.js +84 -0
- package/src/policy-library/information-security-policy-v2.md +290 -0
- package/src/policy-library.js +826 -0
- package/src/program-lifecycle.js +4 -0
- package/src/program-path.js +12 -11
- package/src/program-readiness.js +309 -42
- package/src/server.js +8 -0
- package/src/setup.js +18 -1
- package/src/soc2.js +227 -0
- package/src/state.js +8 -0
- package/src/validate.js +43 -17
- package/src/web.js +218 -25
- package/src/workflow.js +111 -41
package/src/audit-transition.js
CHANGED
|
@@ -37,6 +37,8 @@ export async function planNextAuditCycle(input = process.cwd(), options = {}) {
|
|
|
37
37
|
subserviceVendorIds: [...(prior.subserviceVendorIds || [])],
|
|
38
38
|
...(prior.subserviceMethod ? { subserviceMethod: prior.subserviceMethod } : {})
|
|
39
39
|
}),
|
|
40
|
+
...(prior.subserviceConclusion ? { subserviceConclusion: prior.subserviceConclusion } : {}),
|
|
41
|
+
...(prior.subserviceConclusionRationale ? { subserviceConclusionRationale: prior.subserviceConclusionRationale } : {}),
|
|
40
42
|
...(prior.complementaryControlsConclusion
|
|
41
43
|
? { complementaryControlsConclusion: prior.complementaryControlsConclusion }
|
|
42
44
|
: {}),
|
|
@@ -59,6 +61,9 @@ export async function planNextAuditCycle(input = process.cwd(), options = {}) {
|
|
|
59
61
|
"requirementIds",
|
|
60
62
|
"controlIds",
|
|
61
63
|
"complementaryControlIds",
|
|
64
|
+
"subserviceConclusion",
|
|
65
|
+
"subserviceConclusionRationale",
|
|
66
|
+
"subserviceTreatments",
|
|
62
67
|
"subserviceVendorIds",
|
|
63
68
|
"subserviceMethod",
|
|
64
69
|
"auditorVendorId",
|
package/src/batch-review.js
CHANGED
|
@@ -6,6 +6,7 @@ import { serializeWorkspaceMutation } from "./mutation.js";
|
|
|
6
6
|
import { resolveDataPath } from "./paths.js";
|
|
7
7
|
import { resolveProgram } from "./program.js";
|
|
8
8
|
import { markdownEntries } from "./resource-markdown.js";
|
|
9
|
+
import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
|
|
9
10
|
import { assessWorkflow, buildWorkflowDelta } from "./workflow.js";
|
|
10
11
|
import { loadWorkspace } from "./workspace.js";
|
|
11
12
|
|
|
@@ -44,11 +45,15 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
44
45
|
reviewedOn: null,
|
|
45
46
|
decisions: records
|
|
46
47
|
.sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`))
|
|
47
|
-
.map((record) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
.map((record) => {
|
|
49
|
+
const constraint = soc2RequirementApplicabilityConstraint(record, program, loaded.model.modelVersion);
|
|
50
|
+
return {
|
|
51
|
+
id: record.id,
|
|
52
|
+
decision: constraint?.requiredDecision || null,
|
|
53
|
+
rationale: constraint?.defaultRationale || null,
|
|
54
|
+
...(constraint ? { constraint } : {})
|
|
55
|
+
};
|
|
56
|
+
})
|
|
52
57
|
};
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -89,6 +94,10 @@ function planApplicabilityReviewWithContext(context, options) {
|
|
|
89
94
|
if (!reviewedByIds.length || !reviewedOn || !rationale) {
|
|
90
95
|
throw new Error(`Decision for "${record.id}" needs a reviewer, review date, and rationale.`);
|
|
91
96
|
}
|
|
97
|
+
const constraint = soc2RequirementApplicabilityConstraint(record, program, loaded.model.modelVersion);
|
|
98
|
+
if (constraint && !constraint.allowedDecisions.includes(result)) {
|
|
99
|
+
throw new Error(`${record.reference || record.title} must be applicable because it is required for the selected SOC 2 Security program.`);
|
|
100
|
+
}
|
|
92
101
|
const next = {
|
|
93
102
|
...record,
|
|
94
103
|
applicabilityReview: {
|
package/src/cli.js
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
setupExternalReviewerGovernance
|
|
42
42
|
} from "./external-reviewer.js";
|
|
43
43
|
import { relativeToWorkspace, resolveDataPath } from "./paths.js";
|
|
44
|
+
import { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from "./policy-activation.js";
|
|
45
|
+
import { applyPolicyLibraryUpgrade, assessPolicyLibraryUpgrades } from "./policy-library.js";
|
|
44
46
|
import { buildAgentProgramPath } from "./program-path.js";
|
|
45
47
|
import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
46
48
|
import { resolveProgram } from "./program.js";
|
|
@@ -432,6 +434,12 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
432
434
|
console.log(`\n${stage.title}`);
|
|
433
435
|
for (const item of stage.items) console.log(`${item.status.toUpperCase()}\t${item.title}\t${item.message}`);
|
|
434
436
|
}
|
|
437
|
+
if (result.policyActivations.length) {
|
|
438
|
+
console.log("\nPolicy activation assessments");
|
|
439
|
+
for (const policy of result.policyActivations) {
|
|
440
|
+
console.log(`${policy.label.toUpperCase()}\t${policy.title}\t${policy.gapCount} implementation gaps`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
435
443
|
if (result.canStartCandidatePeriod && !result.operating) {
|
|
436
444
|
console.log(`\nEvidence Ready: management can start the candidate Type 2 period on or after ${result.suggestedCandidatePeriodStart || result.asOf}.`);
|
|
437
445
|
}
|
|
@@ -588,6 +596,50 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
588
596
|
else console.log(`Confirmed ${result.assessment.configuration.title}.`);
|
|
589
597
|
return result;
|
|
590
598
|
}
|
|
599
|
+
if (command === "activate-policies") {
|
|
600
|
+
if (flags.scaffold) {
|
|
601
|
+
const result = await scaffoldPolicyActivation(root, { programId: flags.program });
|
|
602
|
+
console.log(JSON.stringify(result, null, 2));
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
const payload = await readSetupPayload(positionals[0]);
|
|
606
|
+
const options = {
|
|
607
|
+
...payload,
|
|
608
|
+
policyIds: flags.policy ? String(flags.policy).split(",").filter(Boolean) : payload.policyIds,
|
|
609
|
+
effectiveOn: flags["effective-on"] || payload.effectiveOn,
|
|
610
|
+
confirmed: flags.yes === true
|
|
611
|
+
};
|
|
612
|
+
const result = flags.preview
|
|
613
|
+
? await planPolicyActivation(root, options)
|
|
614
|
+
: await withWorkflowDelta(root, () => activatePolicies(root, options));
|
|
615
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
616
|
+
else if (flags.preview) console.log(`Policy activation preview: ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
|
|
617
|
+
else console.log(`Activated ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
|
|
618
|
+
return result;
|
|
619
|
+
}
|
|
620
|
+
if (command === "policy-library") {
|
|
621
|
+
if (flags.yes && !flags.accept) {
|
|
622
|
+
throw new Error("Pass --accept <proposal-id> with --yes after reviewing the policy-library diff.");
|
|
623
|
+
}
|
|
624
|
+
const result = flags.accept
|
|
625
|
+
? await withWorkflowDelta(root, () => applyPolicyLibraryUpgrade(root, String(flags.accept), {
|
|
626
|
+
confirmed: flags.yes === true,
|
|
627
|
+
proposalRevision: flags["proposal-revision"] ? String(flags["proposal-revision"]) : null
|
|
628
|
+
}))
|
|
629
|
+
: await assessPolicyLibraryUpgrades(root);
|
|
630
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
631
|
+
else if (flags.accept) console.log(`Accepted policy-library proposal ${flags.accept}.`);
|
|
632
|
+
else if (!result.proposals.length) console.log("No starter policy-library update applies. Existing content is unchanged.");
|
|
633
|
+
else {
|
|
634
|
+
for (const proposal of result.proposals) {
|
|
635
|
+
console.log(`${proposal.title} (${proposal.id})`);
|
|
636
|
+
console.log(proposal.message);
|
|
637
|
+
for (const change of proposal.changes) console.log(`\n${change.diff}`);
|
|
638
|
+
console.log(`\nAccept with: filegrc policy-library --accept ${proposal.id} --proposal-revision ${proposal.revision} --yes`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
591
643
|
if (command === "trigger") {
|
|
592
644
|
const result = await withWorkflowDelta(root, () => createObligationEvent(root, {
|
|
593
645
|
eventType: positionals[0],
|
|
@@ -1025,6 +1077,8 @@ Usage:
|
|
|
1025
1077
|
filegrc next-audit-cycle <prior-audit-id> [cycle.json|-] --start YYYY-MM-DD --end YYYY-MM-DD [--preview|--yes] [--json]
|
|
1026
1078
|
filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
|
|
1027
1079
|
filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
|
|
1080
|
+
filegrc activate-policies [--scaffold | activation.json|-] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1081
|
+
filegrc policy-library [--json | --accept proposal-id --proposal-revision revision --yes]
|
|
1028
1082
|
filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
|
|
1029
1083
|
filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
|
|
1030
1084
|
filegrc get [resource-type] <id> [--mutation]
|
|
@@ -1174,6 +1228,44 @@ Options:
|
|
|
1174
1228
|
--help Show this help`);
|
|
1175
1229
|
return;
|
|
1176
1230
|
}
|
|
1231
|
+
if (command === "activate-policies") {
|
|
1232
|
+
console.log(`Usage:
|
|
1233
|
+
filegrc activate-policies --scaffold [--program id]
|
|
1234
|
+
filegrc activate-policies <activation.json|-> [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1235
|
+
|
|
1236
|
+
Review and atomically activate selected approved Policies at the end of Step 3.
|
|
1237
|
+
The scaffold includes every required, approved, inactive Policy and its current
|
|
1238
|
+
revision. A past effective date is rejected.
|
|
1239
|
+
|
|
1240
|
+
Options:
|
|
1241
|
+
--scaffold Print a cutover payload without writing
|
|
1242
|
+
--program <id> Program to assess when more than one active Program exists
|
|
1243
|
+
--effective-on <date> Shared effective date for the selected Policies
|
|
1244
|
+
--preview Validate and show the atomic updates without writing
|
|
1245
|
+
--yes Confirm and apply the reviewed cutover
|
|
1246
|
+
--json Print the result as JSON
|
|
1247
|
+
--root <path> Workspace path
|
|
1248
|
+
--help Show this help`);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (command === "policy-library") {
|
|
1252
|
+
console.log(`Usage:
|
|
1253
|
+
filegrc policy-library [--json]
|
|
1254
|
+
filegrc policy-library --accept <proposal-id> --proposal-revision <revision> --yes [--json]
|
|
1255
|
+
|
|
1256
|
+
Review optional starter-library updates for unchanged default Policy and Control
|
|
1257
|
+
content. The review prints exact diffs. FileGRC skips customized or adopted Policy
|
|
1258
|
+
content and writes nothing until you accept one named proposal revision with --yes.
|
|
1259
|
+
|
|
1260
|
+
Options:
|
|
1261
|
+
--accept <id> Accept one proposal after reviewing its diff
|
|
1262
|
+
--proposal-revision <hash> Confirm the exact reviewed proposal revision
|
|
1263
|
+
--yes Confirm the named proposal write
|
|
1264
|
+
--json Print the versioned proposal or acceptance result
|
|
1265
|
+
--root <path> Workspace path
|
|
1266
|
+
--help Show this help`);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1177
1269
|
if (command === "evidence-map") {
|
|
1178
1270
|
console.log(`Usage:
|
|
1179
1271
|
filegrc evidence-map [options]
|
|
@@ -1220,6 +1312,7 @@ function agentOverview(model) {
|
|
|
1220
1312
|
prepareAudit: "filegrc prepare-audit <audit-id>",
|
|
1221
1313
|
reconcile: "filegrc reconcile --preview --json",
|
|
1222
1314
|
externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
|
|
1315
|
+
policyActivation: "filegrc activate-policies [--scaffold | <activation.json|-> --preview] --json",
|
|
1223
1316
|
nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
|
|
1224
1317
|
reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
|
|
1225
1318
|
reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
|
|
@@ -1371,6 +1464,8 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
|
|
|
1371
1464
|
currentStep: { id: currentStep.id, number: currentStep.number, title: currentStep.title },
|
|
1372
1465
|
evidenceReady: readiness.evidenceReady,
|
|
1373
1466
|
operating: readiness.operating,
|
|
1467
|
+
policyActivations: readiness.policyActivations,
|
|
1468
|
+
policyLibraryProposals: readiness.policyLibraryProposals,
|
|
1374
1469
|
stages
|
|
1375
1470
|
};
|
|
1376
1471
|
}
|
|
@@ -1418,6 +1513,8 @@ function summarizeProgramPath(result) {
|
|
|
1418
1513
|
currentStep: result.currentStep,
|
|
1419
1514
|
evidenceReady: result.evidenceReady,
|
|
1420
1515
|
operating: result.operating,
|
|
1516
|
+
policyActivations: result.policyActivations,
|
|
1517
|
+
policyLibraryProposals: result.policyLibraryProposals,
|
|
1421
1518
|
stages: result.stages.map((stage) => ({
|
|
1422
1519
|
id: stage.id,
|
|
1423
1520
|
number: stage.number,
|
|
@@ -1439,6 +1536,8 @@ function nextProgramPath(result) {
|
|
|
1439
1536
|
currentStep: result.currentStep,
|
|
1440
1537
|
evidenceReady: result.evidenceReady,
|
|
1441
1538
|
operating: result.operating,
|
|
1539
|
+
policyActivations: result.policyActivations,
|
|
1540
|
+
policyLibraryProposals: result.policyLibraryProposals,
|
|
1442
1541
|
step: stage ? {
|
|
1443
1542
|
id: stage.id,
|
|
1444
1543
|
number: stage.number,
|
|
@@ -1562,6 +1661,14 @@ function summarizeProgramReadiness(result) {
|
|
|
1562
1661
|
scopeCounts: Object.fromEntries(
|
|
1563
1662
|
Object.entries(result.scope).map(([name, ids]) => [name.replace(/Ids$/, ""), ids.length])
|
|
1564
1663
|
),
|
|
1664
|
+
policyActivations: result.policyActivations.map(({ policyId, title, state, label, gapCount }) => ({
|
|
1665
|
+
policyId,
|
|
1666
|
+
title,
|
|
1667
|
+
state,
|
|
1668
|
+
label,
|
|
1669
|
+
gapCount
|
|
1670
|
+
})),
|
|
1671
|
+
policyLibraryProposals: result.policyLibraryProposals,
|
|
1565
1672
|
unresolvedOwnership: {
|
|
1566
1673
|
count: unresolvedOwnership.length,
|
|
1567
1674
|
byReason: ownershipReasons,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function openPlaceholderCount(source) {
|
|
2
|
+
if (!source) return 0;
|
|
3
|
+
const matches = source.match(
|
|
4
|
+
/\{\{[^}\n]+\}\}|\b(?:TODO|TBD)\b|\[(?:complete|confirm|describe|insert|name|replace|select|specify|todo|tbd)[^\]\n]*\]/giu
|
|
5
|
+
);
|
|
6
|
+
return matches?.length || 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function substantiveMarkdown(source) {
|
|
10
|
+
return (source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length >= 10;
|
|
11
|
+
}
|
package/src/evidence-packet.js
CHANGED
|
@@ -16,6 +16,16 @@ import { isWithin, resolveDataPath, resolveWorkspacePath } from "./paths.js";
|
|
|
16
16
|
import { parseCalendarDate } from "./recurrence.js";
|
|
17
17
|
import { markdownEntries } from "./resource-markdown.js";
|
|
18
18
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
19
|
+
import {
|
|
20
|
+
auditorWasEngaged,
|
|
21
|
+
missingSoc2References,
|
|
22
|
+
recordWasInUseDuringAudit,
|
|
23
|
+
REQUIRED_SOC2_DESCRIPTION_REFERENCES,
|
|
24
|
+
REQUIRED_SOC2_SECURITY_REFERENCES,
|
|
25
|
+
signatoryAppointmentIssue,
|
|
26
|
+
soc2ReportEvidenceIssue,
|
|
27
|
+
subsequentEventsReviewIssue
|
|
28
|
+
} from "./soc2.js";
|
|
19
29
|
import { validateWorkspace } from "./validate.js";
|
|
20
30
|
|
|
21
31
|
const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
@@ -42,7 +52,6 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
|
42
52
|
"vendor",
|
|
43
53
|
"workspace"
|
|
44
54
|
]);
|
|
45
|
-
|
|
46
55
|
export async function prepareEvidencePacket(input, options = {}) {
|
|
47
56
|
const validation = await validateWorkspace(input);
|
|
48
57
|
if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before generating evidence.`);
|
|
@@ -99,7 +108,8 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
99
108
|
...(audit.controlIds || []),
|
|
100
109
|
...(audit.contactIds || []),
|
|
101
110
|
...(audit.complementaryControlIds || []),
|
|
102
|
-
...(audit
|
|
111
|
+
...auditSubserviceVendorIds(audit),
|
|
112
|
+
...auditSubserviceComponentIds(audit),
|
|
103
113
|
audit.systemDescriptionDocumentId,
|
|
104
114
|
audit.managementAssertionDocumentId,
|
|
105
115
|
audit.managementRepresentationDocumentId,
|
|
@@ -235,7 +245,12 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
235
245
|
...markdownEntries(loaded.model, entry.record).map((markdown) => `data/${markdown.path}`)
|
|
236
246
|
]);
|
|
237
247
|
const historyRevision = getGitSummary(loaded.root);
|
|
238
|
-
const histories = getWorkspaceHistories(
|
|
248
|
+
const histories = getWorkspaceHistories(
|
|
249
|
+
loaded.root,
|
|
250
|
+
selectedPaths,
|
|
251
|
+
Number.MAX_SAFE_INTEGER,
|
|
252
|
+
{ strict: Boolean(historyRevision.commit) }
|
|
253
|
+
);
|
|
239
254
|
const packetRecords = [...selectedIds]
|
|
240
255
|
.map((id) => byId.get(id))
|
|
241
256
|
.filter(Boolean)
|
|
@@ -327,7 +342,9 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
327
342
|
systemIds: audit.systemIds || [],
|
|
328
343
|
requirementIds: audit.requirementIds || [],
|
|
329
344
|
controlIds: audit.controlIds || [],
|
|
330
|
-
subserviceMethod: audit
|
|
345
|
+
subserviceMethod: auditSubserviceLabel(audit),
|
|
346
|
+
subserviceConclusion: audit.subserviceConclusion || null,
|
|
347
|
+
subserviceTreatments: audit.subserviceTreatments || []
|
|
331
348
|
} : null,
|
|
332
349
|
workspace: {
|
|
333
350
|
title: loaded.workspace.title,
|
|
@@ -403,7 +420,8 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
|
|
|
403
420
|
...(audit.controlIds || []),
|
|
404
421
|
...(audit.contactIds || []),
|
|
405
422
|
...(audit.complementaryControlIds || []),
|
|
406
|
-
...(audit
|
|
423
|
+
...auditSubserviceVendorIds(audit),
|
|
424
|
+
...auditSubserviceComponentIds(audit),
|
|
407
425
|
audit.systemDescriptionDocumentId,
|
|
408
426
|
audit.managementAssertionDocumentId,
|
|
409
427
|
audit.managementRepresentationDocumentId,
|
|
@@ -993,7 +1011,7 @@ function packetGaps({
|
|
|
993
1011
|
if (!audit) {
|
|
994
1012
|
gaps.push(gap("error", "missing-audit-scope", "Select an audit record before treating this packet as an auditor delivery."));
|
|
995
1013
|
} else {
|
|
996
|
-
auditGaps(gaps, audit, byId, records, start, end);
|
|
1014
|
+
auditGaps(gaps, audit, byId, records, start, end, model);
|
|
997
1015
|
}
|
|
998
1016
|
for (const stage of managementPreparation?.stages || []) {
|
|
999
1017
|
for (const item of stage.items.filter((entry) => ["action", "later"].includes(entry.status))) {
|
|
@@ -1089,7 +1107,7 @@ function packetGaps({
|
|
|
1089
1107
|
|
|
1090
1108
|
for (const requirementId of requirementIds) {
|
|
1091
1109
|
const requirement = byId.get(requirementId);
|
|
1092
|
-
if (!requirement || requirement
|
|
1110
|
+
if (!requirement || !requirementIsApplicable(requirement, audit, byId, model) || isDescriptionRequirement(requirement)) continue;
|
|
1093
1111
|
const mapped = controlCoverage.some((coverage) => coverage.requirementIds.includes(requirementId));
|
|
1094
1112
|
if (!mapped) gaps.push(gap("error", "requirement-missing-control", `${requirement.reference || requirement.title} has no control in the packet.`, requirementId));
|
|
1095
1113
|
}
|
|
@@ -1120,11 +1138,11 @@ function packetGaps({
|
|
|
1120
1138
|
if (!recentAssessment) {
|
|
1121
1139
|
gaps.push(gap("error", "missing-risk-assessment", `No completed in-scope risk assessment was found in the year ending ${end}.`, audit.id));
|
|
1122
1140
|
}
|
|
1123
|
-
for (const vendorId of audit
|
|
1141
|
+
for (const vendorId of auditSubserviceVendorIds(audit)) {
|
|
1124
1142
|
const vendor = byId.get(vendorId);
|
|
1125
1143
|
if (!vendor) continue;
|
|
1126
|
-
if (vendor
|
|
1127
|
-
gaps.push(gap("error", "inactive-subservice-organization", `${vendor.title}
|
|
1144
|
+
if (!recordWasInUseDuringAudit(vendor, start, end)) {
|
|
1145
|
+
gaps.push(gap("error", "inactive-subservice-organization", `${vendor.title} was not in use during the engagement period.`, vendor.id));
|
|
1128
1146
|
}
|
|
1129
1147
|
const reviews = records.filter((record) => (
|
|
1130
1148
|
record.type === "vendor-review"
|
|
@@ -1302,7 +1320,11 @@ function controlNeedsExternalEvidence(control, model) {
|
|
|
1302
1320
|
return !families.length || families.some((family) => family.filegrcManaged !== true);
|
|
1303
1321
|
}
|
|
1304
1322
|
|
|
1305
|
-
function auditGaps(gaps, audit, byId, records, start, end) {
|
|
1323
|
+
function auditGaps(gaps, audit, byId, records, start, end, model) {
|
|
1324
|
+
const program = byId.get(audit.programId);
|
|
1325
|
+
if (String(model.modelVersion) === "4" && program?.assuranceGoal !== audit.auditKind) {
|
|
1326
|
+
gaps.push(gap("error", "audit-program-goal-mismatch", `${audit.title} is ${audit.auditKind}, but its Program goal is ${program?.assuranceGoal || "missing"}. Align the management objective with the formal engagement before delivery.`, audit.id));
|
|
1327
|
+
}
|
|
1306
1328
|
if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
|
|
1307
1329
|
gaps.push(gap("error", "not-soc2-examination", `${audit.title} is ${audit.auditKind}; a delivery packet requires a SOC 2 Type 1 or Type 2 engagement.`, audit.id));
|
|
1308
1330
|
} else if (audit.auditKind === "soc-2-type-1") {
|
|
@@ -1321,34 +1343,118 @@ function auditGaps(gaps, audit, byId, records, start, end) {
|
|
|
1321
1343
|
if (!(audit.requirementIds || []).length) gaps.push(gap("error", "audit-requirements-missing", `${audit.title} has no selected criteria.`, audit.id));
|
|
1322
1344
|
if (!(audit.controlIds || []).length) gaps.push(gap("error", "audit-controls-missing", `${audit.title} has no selected controls.`, audit.id));
|
|
1323
1345
|
const selectedRequirements = (audit.requirementIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
1324
|
-
|
|
1346
|
+
const frameworkRequirements = records.filter((record) => (
|
|
1347
|
+
record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId)
|
|
1348
|
+
));
|
|
1349
|
+
const descriptionRequirements = frameworkRequirements.filter(isDescriptionRequirement);
|
|
1350
|
+
const missingDescriptionRequirements = descriptionRequirements.filter((requirement) => (
|
|
1351
|
+
!(audit.requirementIds || []).includes(requirement.id)
|
|
1352
|
+
));
|
|
1353
|
+
const missingRequiredDescriptionReferences = String(model.modelVersion) === "4"
|
|
1354
|
+
? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
|
|
1355
|
+
: [];
|
|
1356
|
+
const securityRequirements = frameworkRequirements.filter(isSecurityRequirement);
|
|
1357
|
+
const missingRequiredSecurityReferences = String(model.modelVersion) === "4"
|
|
1358
|
+
? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
1359
|
+
: [];
|
|
1360
|
+
const missingSelectedSecurityReferences = String(model.modelVersion) === "4"
|
|
1361
|
+
? missingSoc2References(selectedRequirements.filter(isSecurityRequirement), REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
1362
|
+
: [];
|
|
1363
|
+
if (!descriptionRequirements.length) {
|
|
1325
1364
|
gaps.push(gap("error", "audit-description-criteria-missing", `${audit.title} does not include the SOC 2 description criteria.`, audit.id));
|
|
1365
|
+
} else if (missingDescriptionRequirements.length) {
|
|
1366
|
+
gaps.push(gap("error", "audit-description-criteria-incomplete", `${audit.title} omits ${missingDescriptionRequirements.length} ${missingDescriptionRequirements.length === 1 ? "criterion" : "criteria"} from the selected SOC 2 Description Criteria framework.`, audit.id));
|
|
1367
|
+
} else if (missingRequiredDescriptionReferences.length) {
|
|
1368
|
+
gaps.push(gap("error", "audit-description-criteria-incomplete", `${audit.title} omits ${missingRequiredDescriptionReferences.join(", ")} from the required DC1 through DC9 Description Criteria set.`, audit.id));
|
|
1369
|
+
}
|
|
1370
|
+
if (missingRequiredSecurityReferences.length) {
|
|
1371
|
+
gaps.push(gap("error", "audit-security-criteria-incomplete", `${audit.title}'s selected framework omits ${missingRequiredSecurityReferences.join(", ")} from the required CC1.1 through CC9.2 Security Common Criteria set.`, audit.id));
|
|
1372
|
+
}
|
|
1373
|
+
if (missingSelectedSecurityReferences.length) {
|
|
1374
|
+
gaps.push(gap("error", "audit-security-criteria-incomplete", `${audit.title} omits ${missingSelectedSecurityReferences.join(", ")} from the mandatory Security Common Criteria selected for the engagement.`, audit.id));
|
|
1326
1375
|
}
|
|
1327
1376
|
for (const requirement of selectedRequirements) {
|
|
1328
|
-
if (!(audit.frameworkIds || []).includes(requirement.frameworkId) || requirement
|
|
1377
|
+
if (!(audit.frameworkIds || []).includes(requirement.frameworkId) || !requirementIsApplicable(requirement, audit, byId, model)) {
|
|
1329
1378
|
gaps.push(gap("error", "audit-criteria-scope-conflict", `${requirement.reference || requirement.title} is selected but is not an applicable member of the selected frameworks.`, requirement.id));
|
|
1330
1379
|
}
|
|
1331
1380
|
}
|
|
1332
|
-
|
|
1333
|
-
if (!
|
|
1334
|
-
|
|
1335
|
-
|
|
1381
|
+
const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
|
|
1382
|
+
if (!auditor) {
|
|
1383
|
+
gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
|
|
1384
|
+
} else if (!auditorWasEngaged(auditor, audit)) {
|
|
1385
|
+
gaps.push(gap("error", "auditor-outside-engagement-period", `${auditor.title} was not active during the recorded fieldwork or report period.`, auditor.id));
|
|
1336
1386
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
if (!(audit.subserviceVendorIds || []).includes(vendorId)) {
|
|
1340
|
-
gaps.push(gap("error", "subservice-organization-omitted", `${byId.get(vendorId)?.title || vendorId} is identified by an in-scope system but omitted from the engagement's subservice organizations.`, vendorId));
|
|
1341
|
-
}
|
|
1387
|
+
if (String(model.modelVersion) === "4" && !audit.scopeRevision) {
|
|
1388
|
+
gaps.push(gap("error", "audit-scope-revision-missing", `${audit.title} does not bind management's reviewed engagement scope to a Git revision.`, audit.id));
|
|
1342
1389
|
}
|
|
1343
|
-
if (
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1390
|
+
if (String(model.modelVersion) === "4") {
|
|
1391
|
+
const treatments = audit.subserviceTreatments || [];
|
|
1392
|
+
const treatmentComponentCounts = new Map();
|
|
1393
|
+
for (const treatment of treatments) {
|
|
1394
|
+
for (const componentId of treatment.componentIds || []) {
|
|
1395
|
+
treatmentComponentCounts.set(componentId, (treatmentComponentCounts.get(componentId) || 0) + 1);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
if (!audit.subserviceConclusion) {
|
|
1399
|
+
gaps.push(gap("error", "subservice-conclusion-missing", `${audit.title} does not state whether subservice organizations are identified.`, audit.id));
|
|
1400
|
+
}
|
|
1401
|
+
if (!audit.subserviceConclusionRationale) {
|
|
1402
|
+
gaps.push(gap("error", "subservice-rationale-missing", `${audit.title} does not explain its subservice conclusion.`, audit.id));
|
|
1403
|
+
}
|
|
1404
|
+
if (audit.subserviceConclusion === "identified" && !treatments.length) {
|
|
1405
|
+
gaps.push(gap("error", "subservice-treatments-missing", `${audit.title} identifies subservice organizations but records no Vendor and Component treatments.`, audit.id));
|
|
1406
|
+
}
|
|
1407
|
+
if (audit.subserviceConclusion === "not-applicable" && treatments.length) {
|
|
1408
|
+
gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} records subservice treatments but marks subservice organizations not applicable.`, audit.id));
|
|
1409
|
+
}
|
|
1410
|
+
for (const treatment of treatments) {
|
|
1411
|
+
const vendor = byId.get(treatment.vendorId);
|
|
1412
|
+
const invalidComponents = (treatment.componentIds || []).filter((componentId) => {
|
|
1413
|
+
const component = byId.get(componentId);
|
|
1414
|
+
return component?.type !== "component"
|
|
1415
|
+
|| !recordWasInUseDuringAudit(component, start, end)
|
|
1416
|
+
|| component.vendorId !== treatment.vendorId
|
|
1417
|
+
|| (treatmentComponentCounts.get(componentId) || 0) > 1
|
|
1418
|
+
|| !(component.systemUses || []).some(({ systemId }) => (audit.systemIds || []).includes(systemId));
|
|
1419
|
+
});
|
|
1420
|
+
if (vendor?.type !== "vendor" || !recordWasInUseDuringAudit(vendor, start, end) || !(treatment.componentIds || []).length || invalidComponents.length) {
|
|
1421
|
+
gaps.push(gap(
|
|
1422
|
+
"error",
|
|
1423
|
+
"invalid-subservice-treatment",
|
|
1424
|
+
`${vendor?.title || treatment.vendorId} has a subservice treatment that does not identify that Vendor's supplied Components in use within the selected System boundary during the engagement, or repeats a Component across treatments.`,
|
|
1425
|
+
audit.id
|
|
1426
|
+
));
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
for (const treatment of treatments.filter(({ method }) => method === "inclusive")) {
|
|
1430
|
+
const includedControls = (audit.controlIds || [])
|
|
1431
|
+
.map((id) => byId.get(id))
|
|
1432
|
+
.filter((control) => (control?.componentIds || []).some((id) => (treatment.componentIds || []).includes(id)));
|
|
1433
|
+
if (!includedControls.length) {
|
|
1434
|
+
gaps.push(gap("error", "inclusive-subservice-controls-missing", `${byId.get(treatment.vendorId)?.title || treatment.vendorId} uses the inclusive method but no selected Controls are linked to its included Components.`, audit.id));
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
} else {
|
|
1438
|
+
if (!audit.subserviceMethod) gaps.push(gap("error", "subservice-method-missing", `${audit.title} does not state whether subservice organizations use the carve-out or inclusive method, or are not applicable.`, audit.id));
|
|
1439
|
+
if ((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable") {
|
|
1440
|
+
gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} names subservice organizations but marks their treatment not applicable.`, audit.id));
|
|
1441
|
+
}
|
|
1442
|
+
const expectedSubserviceVendorIds = new Set((audit.systemIds || []).flatMap((id) => byId.get(id)?.subserviceVendorIds || []));
|
|
1443
|
+
for (const vendorId of expectedSubserviceVendorIds) {
|
|
1444
|
+
if (!(audit.subserviceVendorIds || []).includes(vendorId)) {
|
|
1445
|
+
gaps.push(gap("error", "subservice-organization-omitted", `${byId.get(vendorId)?.title || vendorId} is identified by an in-scope system but omitted from the engagement's subservice organizations.`, vendorId));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
if (audit.subserviceMethod === "inclusive") {
|
|
1449
|
+
const subserviceSystemIds = records
|
|
1450
|
+
.filter((record) => record.type === "system" && (audit.subserviceVendorIds || []).includes(record.vendorId))
|
|
1451
|
+
.map((record) => record.id);
|
|
1452
|
+
const includedControls = (audit.controlIds || [])
|
|
1453
|
+
.map((id) => byId.get(id))
|
|
1454
|
+
.filter((control) => (control?.systemIds || []).some((id) => subserviceSystemIds.includes(id)));
|
|
1455
|
+
if (!subserviceSystemIds.length || !includedControls.length) {
|
|
1456
|
+
gaps.push(gap("error", "inclusive-subservice-controls-missing", `${audit.title} uses the inclusive method but does not include cataloged subservice systems and their controls.`, audit.id));
|
|
1457
|
+
}
|
|
1352
1458
|
}
|
|
1353
1459
|
}
|
|
1354
1460
|
if (!audit.complementaryControlsConclusion) {
|
|
@@ -1385,15 +1491,42 @@ function auditGaps(gaps, audit, byId, records, start, end) {
|
|
|
1385
1491
|
gaps.push(gap("error", `unapproved-${field}`, `${document.title} is not active with approval and effective dates.`, document.id));
|
|
1386
1492
|
}
|
|
1387
1493
|
}
|
|
1388
|
-
if (
|
|
1389
|
-
const
|
|
1390
|
-
if (!
|
|
1391
|
-
gaps.push(gap("error", "
|
|
1494
|
+
if (["issued", "delivered", "complete"].includes(audit.status)) {
|
|
1495
|
+
const reportEvidence = audit.reportEvidenceId ? byId.get(audit.reportEvidenceId) : null;
|
|
1496
|
+
if (!reportEvidence) {
|
|
1497
|
+
gaps.push(gap("error", "missing-audit-report", `${audit.title} does not link the final service auditor report.`, audit.id));
|
|
1498
|
+
} else {
|
|
1499
|
+
const reportIssue = soc2ReportEvidenceIssue(reportEvidence, audit, model.modelVersion);
|
|
1500
|
+
if (reportIssue) gaps.push(gap("error", reportIssue.code, reportIssue.message, reportEvidence.id));
|
|
1392
1501
|
}
|
|
1393
|
-
if (!audit.reportEvidenceId) gaps.push(gap("error", "missing-audit-report", `${audit.title} does not link the final service auditor report.`, audit.id));
|
|
1394
1502
|
if (!audit.opinion || audit.opinion === "not-issued" || !audit.opinionDate) {
|
|
1395
1503
|
gaps.push(gap("error", "missing-audit-opinion", `${audit.title} does not record the issued opinion and opinion date.`, audit.id));
|
|
1396
1504
|
}
|
|
1505
|
+
}
|
|
1506
|
+
if (String(model.modelVersion) === "4" && ["report-draft", "issued", "delivered", "complete"].includes(audit.status)) {
|
|
1507
|
+
const subsequentEventsIssue = subsequentEventsReviewIssue(audit);
|
|
1508
|
+
if (subsequentEventsIssue) {
|
|
1509
|
+
gaps.push(gap("error", subsequentEventsIssue.code, subsequentEventsIssue.message, audit.id));
|
|
1510
|
+
}
|
|
1511
|
+
const signatoryIssue = signatoryAppointmentIssue(audit, byId);
|
|
1512
|
+
if (signatoryIssue) {
|
|
1513
|
+
gaps.push(gap("error", signatoryIssue.code, signatoryIssue.message, audit.id));
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
if (audit.status === "complete") {
|
|
1517
|
+
const representation = byId.get(audit.managementRepresentationDocumentId);
|
|
1518
|
+
const signedRepresentation = (representation?.evidenceIds || [])
|
|
1519
|
+
.map((id) => byId.get(id))
|
|
1520
|
+
.find((record) => (
|
|
1521
|
+
record?.type === "evidence"
|
|
1522
|
+
&& record.status === "verified"
|
|
1523
|
+
&& record.artifactKind === "signed-record"
|
|
1524
|
+
&& record.artifactSubtype === "signed-management-representation"
|
|
1525
|
+
&& (record.filePaths || []).length
|
|
1526
|
+
));
|
|
1527
|
+
if (!signedRepresentation) {
|
|
1528
|
+
gaps.push(gap("error", "unsigned-management-representation", `${representation?.title || audit.title} does not link the signed representation letter evidence.`, representation?.id || audit.id));
|
|
1529
|
+
}
|
|
1397
1530
|
for (const request of records.filter((record) => record.type === "audit-request" && record.auditId === audit.id)) {
|
|
1398
1531
|
if (!["accepted", "closed"].includes(request.status)) {
|
|
1399
1532
|
gaps.push(gap("error", "open-final-audit-request", `${request.title} is ${request.status} after the audit was marked complete.`, request.id));
|
|
@@ -1418,6 +1551,39 @@ function isDescriptionRequirement(requirement) {
|
|
|
1418
1551
|
return (requirement.tags || []).includes("description-criteria") || /^DC\d+/i.test(requirement.reference || "");
|
|
1419
1552
|
}
|
|
1420
1553
|
|
|
1554
|
+
function isSecurityRequirement(requirement) {
|
|
1555
|
+
const tags = requirement?.tags || [];
|
|
1556
|
+
return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function requirementIsApplicable(requirement, audit, byId, model) {
|
|
1560
|
+
if (String(model.modelVersion) !== "4") return requirement.applicability === "applicable";
|
|
1561
|
+
const program = audit?.programId ? byId.get(audit.programId) : null;
|
|
1562
|
+
return (program?.requirementApplicability || []).some((decision) => (
|
|
1563
|
+
decision.requirementId === requirement.id && decision.decision === "applicable"
|
|
1564
|
+
));
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function auditSubserviceVendorIds(audit) {
|
|
1568
|
+
return [...new Set([
|
|
1569
|
+
...(audit?.subserviceVendorIds || []),
|
|
1570
|
+
...(audit?.subserviceTreatments || []).map(({ vendorId }) => vendorId)
|
|
1571
|
+
].filter(Boolean))];
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function auditSubserviceComponentIds(audit) {
|
|
1575
|
+
return [...new Set((audit?.subserviceTreatments || []).flatMap(({ componentIds }) => componentIds || []))];
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function auditSubserviceLabel(audit) {
|
|
1579
|
+
if (audit?.subserviceConclusion === "not-applicable") return "Not applicable";
|
|
1580
|
+
if (audit?.subserviceConclusion === "identified") {
|
|
1581
|
+
const methods = [...new Set((audit.subserviceTreatments || []).map(({ method }) => method))];
|
|
1582
|
+
return methods.length ? methods.join(" and ") : "Identified, treatments incomplete";
|
|
1583
|
+
}
|
|
1584
|
+
return audit?.subserviceMethod || null;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1421
1587
|
function shiftYear(value, offset) {
|
|
1422
1588
|
const date = new Date(`${value}T00:00:00Z`);
|
|
1423
1589
|
date.setUTCFullYear(date.getUTCFullYear() + offset);
|