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/program.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { modelSupports } from "../model/index.js";
|
|
2
|
+
|
|
1
3
|
export function resolveProgram(loaded, requestedId) {
|
|
2
|
-
if (
|
|
4
|
+
if (!modelSupports(loaded.model, "program-scope")) return loaded.workspace;
|
|
3
5
|
const programs = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
|
|
4
6
|
if (requestedId) {
|
|
5
7
|
const program = programs.find(({ id }) => id === requestedId);
|
|
@@ -33,7 +35,7 @@ export function resolveProgram(loaded, requestedId) {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function selectedRequirementIds(program, model) {
|
|
36
|
-
if (
|
|
38
|
+
if (modelSupports(model, "program-scope")) {
|
|
37
39
|
return (program.requirementApplicability || [])
|
|
38
40
|
.filter(({ decision }) => decision === "applicable")
|
|
39
41
|
.map(({ requirementId }) => requirementId);
|
|
@@ -42,7 +44,7 @@ export function selectedRequirementIds(program, model) {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export function programComponents(loaded, program) {
|
|
45
|
-
if (
|
|
47
|
+
if (!modelSupports(loaded.model, "program-scope")) return [];
|
|
46
48
|
const systemIds = new Set(program.systemIds || []);
|
|
47
49
|
return loaded.resources.filter((record) => (
|
|
48
50
|
record.type === "component"
|
package/src/reconciliation.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { modelSupports } from "../model/index.js";
|
|
5
6
|
import { createObligationEvent } from "./obligations.js";
|
|
6
7
|
import { markdownEntries } from "./resource-markdown.js";
|
|
7
8
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -94,13 +95,13 @@ export async function planReconciliation(input = process.cwd()) {
|
|
|
94
95
|
const loaded = input?.resources && input?.model && input?.entries
|
|
95
96
|
? input
|
|
96
97
|
: await loadWorkspace(input);
|
|
97
|
-
if (!
|
|
98
|
+
if (!modelSupports(loaded.model, "guided-workflow")) {
|
|
98
99
|
return {
|
|
99
100
|
contractVersion: 1,
|
|
100
101
|
gitRevision: gitRevision(loaded.root),
|
|
101
102
|
changedPaths: [],
|
|
102
103
|
candidates: [],
|
|
103
|
-
message: "Direct-file transition reconciliation is available in model v3 and
|
|
104
|
+
message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
|
|
104
105
|
};
|
|
105
106
|
}
|
|
106
107
|
const changedPaths = gitChangedPaths(loaded.root);
|
package/src/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { extname, join, resolve } from "node:path";
|
|
|
5
5
|
import { performance } from "node:perf_hooks";
|
|
6
6
|
import { getResourceDefinition } from "../model/index.js";
|
|
7
7
|
import { prepareAuditWorkspace } from "./audit-preparation.js";
|
|
8
|
+
import { activateDocuments } from "./document-activation.js";
|
|
8
9
|
import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
9
10
|
import { applyApplicabilityReviewWithContext, planApplicabilityReview } from "./batch-review.js";
|
|
10
11
|
import { applyCollectionReview, planCollectionReview } from "./collection-review.js";
|
|
@@ -330,6 +331,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
330
331
|
}, () => activatePolicies(input, { ...payload, confirmed: true }));
|
|
331
332
|
return json(response, 200, result);
|
|
332
333
|
}
|
|
334
|
+
if (request.method === "POST" && url.pathname === "/api/document-activations") {
|
|
335
|
+
const payload = await readJson(request);
|
|
336
|
+
const result = await browserMutation(input, options, {
|
|
337
|
+
message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}`
|
|
338
|
+
}, () => activateDocuments(input, { ...payload, confirmed: true }));
|
|
339
|
+
return json(response, 200, result);
|
|
340
|
+
}
|
|
333
341
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
334
342
|
const payload = normalizeResourceMutation(await readJson(request));
|
|
335
343
|
const { record } = payload;
|
|
@@ -543,13 +551,8 @@ function browserMutation(input, options, mutationOptions, task) {
|
|
|
543
551
|
includeDetails: false,
|
|
544
552
|
validationProof: result?.[BROWSER_VALIDATION]
|
|
545
553
|
}));
|
|
546
|
-
if (result?.synchronization
|
|
547
|
-
result.synchronization =
|
|
548
|
-
...result.synchronization,
|
|
549
|
-
status: state.repository.status === "synced" ? "synced" : "not-synced",
|
|
550
|
-
synchronizedAt: state.repository.lastSuccessfulSynchronization ?? null,
|
|
551
|
-
pushError: state.repository.backgroundSyncError ?? null
|
|
552
|
-
};
|
|
554
|
+
if (result?.synchronization) {
|
|
555
|
+
result.synchronization = reconcileMutationSynchronization(result.synchronization, state.repository);
|
|
553
556
|
}
|
|
554
557
|
return {
|
|
555
558
|
...result,
|
|
@@ -564,6 +567,25 @@ function browserMutation(input, options, mutationOptions, task) {
|
|
|
564
567
|
});
|
|
565
568
|
}
|
|
566
569
|
|
|
570
|
+
export function reconcileMutationSynchronization(synchronization, repository) {
|
|
571
|
+
if (synchronization?.status !== "syncing" || repository.status === "syncing") return synchronization;
|
|
572
|
+
const backgroundFailed = repository.backgroundSynchronization?.status === "failed";
|
|
573
|
+
if (repository.status !== "synced" && !backgroundFailed) {
|
|
574
|
+
// A repository snapshot can finish just before a fast background push while
|
|
575
|
+
// its state is inspected just after the push. Keep the queued result until
|
|
576
|
+
// a verified success or failure replaces it.
|
|
577
|
+
return synchronization;
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
...synchronization,
|
|
581
|
+
status: repository.status === "synced" ? "synced" : "not-synced",
|
|
582
|
+
synchronizedAt: repository.lastSuccessfulSynchronization ?? null,
|
|
583
|
+
pushError: repository.backgroundSyncError
|
|
584
|
+
?? repository.backgroundSynchronization?.error
|
|
585
|
+
?? null
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
567
589
|
function prefersFastMutation(request) {
|
|
568
590
|
return String(request.headers.prefer || "")
|
|
569
591
|
.split(",")
|
package/src/setup.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { modelSupports } from "../model/index.js";
|
|
1
2
|
import { applyResourceBatch } from "./files.js";
|
|
2
3
|
import { createResourceId } from "./id.js";
|
|
3
4
|
import { resolveProgram, selectedRequirementIds } from "./program.js";
|
|
@@ -9,6 +10,7 @@ const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
|
|
|
9
10
|
export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
10
11
|
const loaded = await loadWorkspace(input);
|
|
11
12
|
const setup = normalizeSetupPayload(payload);
|
|
13
|
+
setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
|
|
12
14
|
validateSetup(loaded, setup);
|
|
13
15
|
const plan = buildSetupRecords(loaded, setup);
|
|
14
16
|
const updates = [
|
|
@@ -44,6 +46,7 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
44
46
|
export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
|
|
45
47
|
const loaded = await loadWorkspace(input);
|
|
46
48
|
const setup = normalizeSetupPayload(payload);
|
|
49
|
+
setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
|
|
47
50
|
validateSetup(loaded, setup);
|
|
48
51
|
const plan = buildSetupRecords(loaded, setup);
|
|
49
52
|
return {
|
|
@@ -77,7 +80,9 @@ export function summarizeSetupResult(result) {
|
|
|
77
80
|
commitment: result.commitment ? "saved" : "unchanged"
|
|
78
81
|
},
|
|
79
82
|
system: setupSystemSummary(result.system),
|
|
80
|
-
target: setupTargetSummary(result.program || result.workspace, {
|
|
83
|
+
target: setupTargetSummary(result.program || result.workspace, {
|
|
84
|
+
modelVersion: result.workspace?.dataModelVersion || (result.program ? "5" : "3")
|
|
85
|
+
}),
|
|
81
86
|
renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
|
|
82
87
|
commitment: result.commitment || null,
|
|
83
88
|
onboardingComplete: result.onboardingComplete
|
|
@@ -129,7 +134,7 @@ function validateSetup(loaded, setup) {
|
|
|
129
134
|
throw new Error(`System "${setup.systemId}" cannot be used for initial scope because it is ${system.status}.`);
|
|
130
135
|
}
|
|
131
136
|
}
|
|
132
|
-
const classifications =
|
|
137
|
+
const classifications = modelSupports(loaded.model, "program-scope")
|
|
133
138
|
? loaded.resources.filter(({ type, status }) => type === "classification" && status === "active").map(({ id }) => id)
|
|
134
139
|
: Object.keys(loaded.workspace.classificationDefinitions || {});
|
|
135
140
|
if (classifications.length && !classifications.includes(setup.classificationId)) {
|
|
@@ -137,6 +142,21 @@ function validateSetup(loaded, setup) {
|
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
function resolveClassificationId(loaded, value) {
|
|
146
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
147
|
+
if (!normalized) return value;
|
|
148
|
+
const candidates = modelSupports(loaded.model, "program-scope")
|
|
149
|
+
? loaded.resources
|
|
150
|
+
.filter(({ type, status }) => type === "classification" && status === "active")
|
|
151
|
+
.map(({ id, title }) => ({ id, label: title }))
|
|
152
|
+
: Object.entries(loaded.workspace.classificationDefinitions || {})
|
|
153
|
+
.map(([id, label]) => ({ id, label }));
|
|
154
|
+
const matches = candidates.filter(({ id, label }) => (
|
|
155
|
+
id.toLowerCase() === normalized || String(label || "").trim().toLowerCase() === normalized
|
|
156
|
+
));
|
|
157
|
+
return matches.length === 1 ? matches[0].id : value;
|
|
158
|
+
}
|
|
159
|
+
|
|
140
160
|
function findSetupSystem(resources, target, setup) {
|
|
141
161
|
const scopedSystemIds = new Set(target.systemIds || []);
|
|
142
162
|
return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
|
|
@@ -150,7 +170,7 @@ function findSetupSystem(resources, target, setup) {
|
|
|
150
170
|
|
|
151
171
|
function buildSetupRecords(loaded, setup) {
|
|
152
172
|
const target = resolveProgram(loaded);
|
|
153
|
-
const v4 =
|
|
173
|
+
const v4 = modelSupports(loaded.model, "program-scope");
|
|
154
174
|
const existingSystem = findSetupSystem(loaded.resources, target, setup);
|
|
155
175
|
const systemId = existingSystem?.id || createResourceId(
|
|
156
176
|
"system",
|
|
@@ -216,7 +236,7 @@ function buildSetupRecords(loaded, setup) {
|
|
|
216
236
|
&& !["superseded", "retired"].includes(record.status)
|
|
217
237
|
&& (record.systemIds || []).includes(systemId)
|
|
218
238
|
));
|
|
219
|
-
const commitment =
|
|
239
|
+
const commitment = modelSupports(loaded.model, "guided-workflow") && !existingCommitment
|
|
220
240
|
? {
|
|
221
241
|
id: createResourceId(
|
|
222
242
|
"commitment",
|
|
@@ -227,7 +247,7 @@ function buildSetupRecords(loaded, setup) {
|
|
|
227
247
|
title: `${setup.serviceName} service commitment`,
|
|
228
248
|
status: "planned",
|
|
229
249
|
commitmentKind: "service",
|
|
230
|
-
statement: "
|
|
250
|
+
statement: "[Complete before activation: State the actual customer promise or approved service requirement.]",
|
|
231
251
|
systemIds: [systemId],
|
|
232
252
|
ownerIds: [setup.ownerId],
|
|
233
253
|
customerFacing: true,
|
package/src/soc2.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { modelSupports } from "../model/index.js";
|
|
2
|
+
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
3
|
+
|
|
4
|
+
export const REQUIRED_SOC2_DESCRIPTION_REFERENCES = Array.from(
|
|
5
|
+
{ length: 9 },
|
|
6
|
+
(_, index) => `DC${index + 1}`
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export const REQUIRED_SOC2_SECURITY_REFERENCES = [
|
|
10
|
+
"CC1.1",
|
|
11
|
+
"CC1.2",
|
|
12
|
+
"CC1.3",
|
|
13
|
+
"CC1.4",
|
|
14
|
+
"CC1.5",
|
|
15
|
+
"CC2.1",
|
|
16
|
+
"CC2.2",
|
|
17
|
+
"CC2.3",
|
|
18
|
+
"CC3.1",
|
|
19
|
+
"CC3.2",
|
|
20
|
+
"CC3.3",
|
|
21
|
+
"CC3.4",
|
|
22
|
+
"CC4.1",
|
|
23
|
+
"CC4.2",
|
|
24
|
+
"CC5.1",
|
|
25
|
+
"CC5.2",
|
|
26
|
+
"CC5.3",
|
|
27
|
+
"CC6.1",
|
|
28
|
+
"CC6.2",
|
|
29
|
+
"CC6.3",
|
|
30
|
+
"CC6.4",
|
|
31
|
+
"CC6.5",
|
|
32
|
+
"CC6.6",
|
|
33
|
+
"CC6.7",
|
|
34
|
+
"CC6.8",
|
|
35
|
+
"CC7.1",
|
|
36
|
+
"CC7.2",
|
|
37
|
+
"CC7.3",
|
|
38
|
+
"CC7.4",
|
|
39
|
+
"CC7.5",
|
|
40
|
+
"CC8.1",
|
|
41
|
+
"CC9.1",
|
|
42
|
+
"CC9.2"
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const SOC2_PROGRAM_GOALS = new Set([
|
|
46
|
+
"readiness",
|
|
47
|
+
"soc-2-type-1",
|
|
48
|
+
"soc-2-type-2"
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export function soc2RequirementApplicabilityConstraint(requirement, program, modelVersion = "4") {
|
|
52
|
+
if (
|
|
53
|
+
!modelSupports(modelVersion, "program-scope")
|
|
54
|
+
|| requirement?.type !== "requirement"
|
|
55
|
+
|| !SOC2_PROGRAM_GOALS.has(program?.assuranceGoal)
|
|
56
|
+
) return null;
|
|
57
|
+
const reference = String(requirement.reference || "").trim().toUpperCase();
|
|
58
|
+
const security = REQUIRED_SOC2_SECURITY_REFERENCES.includes(reference);
|
|
59
|
+
const description = REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(reference);
|
|
60
|
+
if (!security && !description) return null;
|
|
61
|
+
const family = security ? "Security Common Criteria" : "SOC 2 Description Criteria";
|
|
62
|
+
return {
|
|
63
|
+
requiredDecision: "applicable",
|
|
64
|
+
allowedDecisions: ["applicable"],
|
|
65
|
+
message: `${reference} is required for the selected SOC 2 Security program.`,
|
|
66
|
+
defaultRationale: `${reference} is part of the required ${family} baseline for the selected ${soc2GoalLabel(program.assuranceGoal)} Program.`
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function soc2GoalLabel(goal) {
|
|
71
|
+
if (goal === "soc-2-type-1") return "SOC 2 Type 1";
|
|
72
|
+
if (goal === "soc-2-type-2") return "SOC 2 Type 2";
|
|
73
|
+
return "SOC 2 readiness";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function missingSoc2References(requirements, requiredReferences) {
|
|
77
|
+
const references = new Set(requirements.map(({ reference }) => String(reference || "").trim().toUpperCase()));
|
|
78
|
+
return requiredReferences.filter((reference) => !references.has(reference));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function recordWasInUseDuringAudit(record, engagementStart, engagementEnd) {
|
|
82
|
+
if (!record) return false;
|
|
83
|
+
if (record.type === "vendor") {
|
|
84
|
+
if (!["active", "deprecated", "terminated"].includes(record.status)) return false;
|
|
85
|
+
const endedOn = record.endDate || (record.status === "terminated" ? record.statusTransition?.changedOn : null);
|
|
86
|
+
if (engagementEnd && record.startDate && record.startDate > engagementEnd) return false;
|
|
87
|
+
if (engagementStart && endedOn && endedOn < engagementStart) return false;
|
|
88
|
+
if (record.status === "terminated" && engagementStart && !endedOn) return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (["active", "deprecated"].includes(record.status)) return true;
|
|
92
|
+
if (!engagementStart) return false;
|
|
93
|
+
if (["component", "system"].includes(record.type) && record.status === "retired") {
|
|
94
|
+
return Boolean(record.statusTransition?.changedOn && record.statusTransition.changedOn >= engagementStart);
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function auditorWasEngaged(auditor, audit) {
|
|
100
|
+
const engagementStart = audit?.managementAcknowledgedOn
|
|
101
|
+
|| audit?.fieldworkStart
|
|
102
|
+
|| coverageStart(audit?.coverage);
|
|
103
|
+
const engagementEnd = audit?.reportDate
|
|
104
|
+
|| audit?.fieldworkEnd
|
|
105
|
+
|| coverageEnd(audit?.coverage);
|
|
106
|
+
if (!recordWasInUseDuringAudit(auditor, engagementStart, engagementEnd)) return false;
|
|
107
|
+
const endedOn = auditor.endDate || (auditor.status === "terminated" ? auditor.statusTransition?.changedOn : null);
|
|
108
|
+
if (engagementStart && auditor.startDate && auditor.startDate > engagementStart) return false;
|
|
109
|
+
if (engagementEnd && endedOn && endedOn < engagementEnd) return false;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function personWasActiveOn(person, on) {
|
|
114
|
+
if (person?.type !== "person" || !on) return false;
|
|
115
|
+
if (person.startDate && person.startDate > on) return false;
|
|
116
|
+
const endedOn = person.endDate || (person.status === "inactive" ? person.statusTransition?.changedOn : null);
|
|
117
|
+
if (endedOn && endedOn < on) return false;
|
|
118
|
+
return person.status === "active" || (person.status === "inactive" && Boolean(endedOn));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function appointmentWasAuthorizedOn(appointment, on, byId) {
|
|
122
|
+
if (appointment?.type !== "appointment" || !on || !["active", "ended"].includes(appointment.status)) return false;
|
|
123
|
+
if (!appointment.startsOn || appointment.startsOn > on) return false;
|
|
124
|
+
if (appointment.endsOn && appointment.endsOn < on) return false;
|
|
125
|
+
return personWasActiveOn(byId.get(appointment.holderId), on);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function signatoryAppointmentIssue(audit, byId) {
|
|
129
|
+
if (!audit?.reportDate) {
|
|
130
|
+
return {
|
|
131
|
+
code: "signatory-authority-date-missing",
|
|
132
|
+
message: "Record the CPA report date before confirming who had authority to sign management's assertion and written representations."
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const ids = audit.signatoryAppointmentIds || [];
|
|
136
|
+
if (!ids.length) {
|
|
137
|
+
return {
|
|
138
|
+
code: "signatory-authority-missing",
|
|
139
|
+
message: "Link the dated authority Appointment for each management assertion and representation signer."
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const permittedScopes = new Set([
|
|
143
|
+
"workspace",
|
|
144
|
+
audit.id,
|
|
145
|
+
audit.programId,
|
|
146
|
+
...(audit.systemIds || []),
|
|
147
|
+
...[...byId.values()].filter((record) => record.type === "organization").map(({ id }) => id)
|
|
148
|
+
].filter(Boolean));
|
|
149
|
+
const invalid = ids.filter((id) => {
|
|
150
|
+
const appointment = byId.get(id);
|
|
151
|
+
return !appointmentWasAuthorizedOn(appointment, audit.reportDate, byId)
|
|
152
|
+
|| !(appointment.scopeResourceIds || []).some((scopeId) => permittedScopes.has(scopeId));
|
|
153
|
+
});
|
|
154
|
+
if (invalid.length) {
|
|
155
|
+
return {
|
|
156
|
+
code: "signatory-authority-invalid",
|
|
157
|
+
message: `Confirm that ${invalid.join(", ")} names a dated Appointment whose holder was active and whose scope covered the workspace, Program, or Audit on ${audit.reportDate}.`
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function soc2ReportEvidenceIssue(evidence, audit, modelVersion = "4") {
|
|
164
|
+
if (
|
|
165
|
+
evidence?.type !== "evidence"
|
|
166
|
+
|| evidence.status !== "verified"
|
|
167
|
+
|| evidence.artifactKind !== "third-party-report"
|
|
168
|
+
|| evidence.artifactSubtype !== "soc2-report"
|
|
169
|
+
) {
|
|
170
|
+
return {
|
|
171
|
+
code: "invalid-audit-report-evidence",
|
|
172
|
+
message: `${evidence?.title || audit?.title || "The audit"} must be verified third-party-report Evidence with subtype soc2-report for the issued SOC 2 report.`
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const issuedOn = (modelSupports(modelVersion, "program-scope")
|
|
176
|
+
? evidence.sourceGeneratedAt
|
|
177
|
+
: evidence.sourceGeneratedAt || evidence.businessEventAt || evidence.collectedOn
|
|
178
|
+
)?.slice(0, 10);
|
|
179
|
+
if (!issuedOn) {
|
|
180
|
+
return {
|
|
181
|
+
code: "audit-report-date-missing",
|
|
182
|
+
message: `Record the issued SOC 2 report's actual issuance timestamp in ${evidence.title}'s sourceGeneratedAt field.`
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (audit?.reportDate && issuedOn !== audit.reportDate) {
|
|
186
|
+
return {
|
|
187
|
+
code: "audit-report-date-mismatch",
|
|
188
|
+
message: `${evidence.title} is dated ${issuedOn}, which does not match the Audit reportDate ${audit.reportDate}.`
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (audit?.reportDate && audit.opinionDate && audit.opinionDate !== audit.reportDate) {
|
|
192
|
+
return {
|
|
193
|
+
code: "audit-opinion-date-mismatch",
|
|
194
|
+
message: `The Audit opinionDate ${audit.opinionDate} does not match reportDate ${audit.reportDate}. Reconcile both dates to the issued CPA report.`
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function subsequentEventsReviewIssue(audit) {
|
|
201
|
+
const review = audit?.subsequentEventsReview;
|
|
202
|
+
if (!review) {
|
|
203
|
+
return {
|
|
204
|
+
code: "subsequent-events-review-missing",
|
|
205
|
+
message: "Review incidents, changes, findings, fraud, legal matters, subservice coverage, representations, and other relevant events through the CPA report date."
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!(review.reviewedByIds || []).length || !String(review.conclusion || "").trim()) {
|
|
209
|
+
return {
|
|
210
|
+
code: "subsequent-events-review-incomplete",
|
|
211
|
+
message: "The subsequent-events review must name its reviewers and record management's conclusion."
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const requiredThroughOn = audit.reportDate || audit.fieldworkEnd || audit.coverage?.endsOn || audit.coverage?.on;
|
|
215
|
+
if (!review.throughOn || (requiredThroughOn && review.throughOn < requiredThroughOn)) {
|
|
216
|
+
return {
|
|
217
|
+
code: "subsequent-events-period-incomplete",
|
|
218
|
+
message: `The subsequent-events review must cover through ${requiredThroughOn || "the latest engagement date"}${audit.reportDate ? ", the CPA report date" : ""}.`
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (!review.reviewedOn || review.reviewedOn < review.throughOn) {
|
|
222
|
+
return {
|
|
223
|
+
code: "subsequent-events-review-date-invalid",
|
|
224
|
+
message: "The subsequent-events review date must be on or after the date through which events were reviewed."
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
package/src/state.js
CHANGED
|
@@ -8,11 +8,13 @@ import { planObligations } from "./obligations.js";
|
|
|
8
8
|
import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
9
9
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
10
10
|
import { markdownEntries } from "./resource-markdown.js";
|
|
11
|
+
import { resolveProgram } from "./program.js";
|
|
11
12
|
import { currentCalendarDate } from "./time.js";
|
|
12
13
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
13
14
|
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
14
15
|
import { assessWorkflow } from "./workflow.js";
|
|
15
16
|
import { measureTiming } from "./timing.js";
|
|
17
|
+
import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
|
|
16
18
|
|
|
17
19
|
const renderedMarkdownCache = new Map();
|
|
18
20
|
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
@@ -82,6 +84,11 @@ async function createAppStateUnlocked(input, options) {
|
|
|
82
84
|
asOf,
|
|
83
85
|
generatedAt
|
|
84
86
|
}));
|
|
87
|
+
const activeProgram = resolveProgram(loaded);
|
|
88
|
+
const applicabilityConstraints = Object.fromEntries(loaded.resources.flatMap((record) => {
|
|
89
|
+
const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
|
|
90
|
+
return constraint ? [[record.id, constraint]] : [];
|
|
91
|
+
}));
|
|
85
92
|
const audits = loaded.resources.filter((record) => record.type === "audit");
|
|
86
93
|
const auditPreparations = await measureTiming("state-audit-preparation", async () => Object.fromEntries(await Promise.all(
|
|
87
94
|
(audits.length ? audits : [null]).map(async (audit) => {
|
|
@@ -140,6 +147,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
140
147
|
},
|
|
141
148
|
obligations,
|
|
142
149
|
collectionReviews,
|
|
150
|
+
applicabilityConstraints,
|
|
143
151
|
programReadiness,
|
|
144
152
|
auditPreparations,
|
|
145
153
|
workflow,
|