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/program-path.js
CHANGED
|
@@ -144,7 +144,7 @@ export const PROGRAM_PATH = [
|
|
|
144
144
|
{ id: "governance", title: "Governance", description: "Record formal reviews, oversight meetings, and approved policy or control exceptions.", steps: ["Complete scheduled policy reviews and oversight meetings.", "Record decisions, attendees, follow-up work, and evidence.", "Approve time-bound exceptions before the departure begins."], types: ["policy-review", "meeting", "exception"], defaultOpen: false },
|
|
145
145
|
{ id: "inventories", title: "Assets and Vendors", description: "Maintain the asset inventory and recurring reviews of supplier relationships during operation.", steps: ["Keep ownership, custody, status, and lifecycle current for important assets.", "Perform vendor reviews on schedule and after material supplier changes.", "Link fixed reports and review evidence to the operating records."], types: ["asset", "vendor-review"], defaultOpen: false },
|
|
146
146
|
{ id: "access-training", title: "Access and Training", description: "Inventory service accounts before recording access decisions, periodic reviews, assignments, and acknowledgements.", steps: ["Catalog service accounts that need separate tracking.", "Preserve access approvals and removals as they occur, then complete periodic access reviews and resolve exceptions.", "Assign training and retain acknowledgement evidence for the exact content revision."], types: ["service-account", "access-grant", "access-review", "training", "attestation"], defaultOpen: false },
|
|
147
|
-
{ id: "security", title: "Security Operations", description: "Record vulnerability work,
|
|
147
|
+
{ id: "security", title: "Security Operations", description: "Record vulnerability work, applicable penetration testing, and incident response activity for the period.", steps: ["Retain scan scope, results, vulnerabilities, remediation, and exceptions.", "When the approved applicability review requires penetration testing, record the test and follow-up findings.", "Start the incident workflow when a qualifying event occurs."], types: ["vulnerability-scan", "vulnerability", "penetration-test", "incident"], defaultOpen: false },
|
|
148
148
|
{ id: "resilience", title: "Resilience", description: "Preserve proof that backups, restoration, continuity, and incident exercises work as designed.", steps: ["Record backup restoration tests and their results.", "Run continuity and incident exercises on schedule.", "Assign and close follow-up work from failed objectives or lessons learned."], types: ["backup-test", "exercise"], defaultOpen: false },
|
|
149
149
|
{ id: "issues", title: "Issues and Remediation", description: "Keep observations in the source report and track only confirmed gaps that need a separate remediation lifecycle.", steps: ["Create a Finding only when a confirmed gap needs its own owner, due date, status, or verified closure.", "Use the Finding itself for straightforward remediation; create Action Items only for separate assigned tasks.", "Work Action Items from Work Queue and close the Finding only after remediation is independently verified."], types: ["finding"], defaultOpen: false }
|
|
150
150
|
],
|
package/src/program-readiness.js
CHANGED
|
@@ -7,8 +7,14 @@ import { planObligations } from "./obligations.js";
|
|
|
7
7
|
import { resolveDataPath } from "./paths.js";
|
|
8
8
|
import { obligationIsEnabled, obligationIsRunning } from "./program-lifecycle.js";
|
|
9
9
|
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
10
|
+
import { assessPolicyLibraryUpgrades } from "./policy-library.js";
|
|
10
11
|
import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
|
|
11
12
|
import { markdownEntries } from "./resource-markdown.js";
|
|
13
|
+
import {
|
|
14
|
+
missingSoc2References,
|
|
15
|
+
REQUIRED_SOC2_DESCRIPTION_REFERENCES,
|
|
16
|
+
REQUIRED_SOC2_SECURITY_REFERENCES
|
|
17
|
+
} from "./soc2.js";
|
|
12
18
|
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
13
19
|
import { currentCalendarDate } from "./time.js";
|
|
14
20
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -75,6 +81,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
75
81
|
&& coverageStart(program.candidateCoverage) <= asOf
|
|
76
82
|
);
|
|
77
83
|
const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
|
|
84
|
+
const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
|
|
78
85
|
const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
|
|
79
86
|
const canStartCandidatePeriod = Boolean(
|
|
80
87
|
evidenceReady
|
|
@@ -103,7 +110,10 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
103
110
|
canStartCandidatePeriod,
|
|
104
111
|
suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
|
|
105
112
|
policyActivations,
|
|
106
|
-
policyLibraryProposals:
|
|
113
|
+
policyLibraryProposals: [
|
|
114
|
+
...policyLibrary.proposals,
|
|
115
|
+
...legacyPolicyLibraryProposals(records)
|
|
116
|
+
],
|
|
107
117
|
progress: {
|
|
108
118
|
complete,
|
|
109
119
|
total: managedItems.length,
|
|
@@ -217,7 +227,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
217
227
|
const completeSystems = scope.systems.filter((system) => (
|
|
218
228
|
system.status === "active"
|
|
219
229
|
&& (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
|
|
220
|
-
&& system.classificationId
|
|
230
|
+
&& (String(model.modelVersion) === "4" || system.classificationId)
|
|
221
231
|
&& (system.ownerIds || []).length
|
|
222
232
|
));
|
|
223
233
|
items.push(item(
|
|
@@ -225,7 +235,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
225
235
|
scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
|
|
226
236
|
"Define the service boundary",
|
|
227
237
|
scope.systems.length
|
|
228
|
-
? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned,
|
|
238
|
+
? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${String(model.modelVersion) === "4" ? "" : ", with a classification"}.`
|
|
229
239
|
: "Select and describe every service and supporting system in the program boundary.",
|
|
230
240
|
scope.systems[0] || { type: "system" }
|
|
231
241
|
));
|
|
@@ -281,22 +291,69 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
281
291
|
&& (String(model.modelVersion) === "4" ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
|
|
282
292
|
));
|
|
283
293
|
const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
|
|
294
|
+
const selectedDescriptionRequirements = scope.requirements.filter(isDescriptionRequirement);
|
|
295
|
+
const selectedTrustServicesRequirements = scope.requirements.filter((requirement) => !isDescriptionRequirement(requirement));
|
|
296
|
+
const unresolvedDescriptionRequirements = unresolvedRequirements.filter(isDescriptionRequirement);
|
|
297
|
+
const unresolvedTrustServicesRequirements = unresolvedRequirements.filter((requirement) => !isDescriptionRequirement(requirement));
|
|
298
|
+
const uncoveredRequirements = scope.requirements.filter((requirement) => (
|
|
299
|
+
!isDescriptionRequirement(requirement)
|
|
300
|
+
&& !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
|
|
301
|
+
));
|
|
302
|
+
const enforceSoc2Baseline = String(model.modelVersion) === "4"
|
|
303
|
+
&& ["readiness", "soc-2-type-1", "soc-2-type-2"].includes(goal);
|
|
304
|
+
const selectedFrameworkRequirements = records.filter((record) => (
|
|
305
|
+
record.type === "requirement"
|
|
306
|
+
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
307
|
+
));
|
|
308
|
+
const securityRequirements = selectedFrameworkRequirements.filter(isSecurityRequirement);
|
|
309
|
+
const descriptionRequirements = selectedFrameworkRequirements.filter(isDescriptionRequirement);
|
|
310
|
+
const missingRequiredSecurityReferences = enforceSoc2Baseline
|
|
311
|
+
? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
312
|
+
: [];
|
|
313
|
+
const missingRequiredDescriptionReferences = enforceSoc2Baseline
|
|
314
|
+
? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
|
|
315
|
+
: [];
|
|
316
|
+
const mandatoryRequirements = enforceSoc2Baseline
|
|
317
|
+
? [...securityRequirements, ...descriptionRequirements].filter(({ reference }) => (
|
|
318
|
+
REQUIRED_SOC2_SECURITY_REFERENCES.includes(String(reference || "").toUpperCase())
|
|
319
|
+
|| REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(String(reference || "").toUpperCase())
|
|
320
|
+
))
|
|
321
|
+
: [];
|
|
322
|
+
const invalidMandatoryDecisions = mandatoryRequirements.filter((requirement) => (
|
|
323
|
+
v4Decisions.get(requirement.id) !== "applicable"
|
|
324
|
+
));
|
|
284
325
|
const criteriaComplete = Boolean(
|
|
285
326
|
scope.frameworks.length
|
|
286
327
|
&& scope.requirements.length
|
|
287
328
|
&& scope.controls.length
|
|
288
329
|
&& !unresolvedRequirements.length
|
|
289
330
|
&& !missingRequirements.length
|
|
331
|
+
&& !uncoveredRequirements.length
|
|
332
|
+
&& !missingRequiredSecurityReferences.length
|
|
333
|
+
&& !missingRequiredDescriptionReferences.length
|
|
334
|
+
&& !invalidMandatoryDecisions.length
|
|
290
335
|
);
|
|
291
336
|
items.push(item(
|
|
292
337
|
"criteria",
|
|
293
338
|
criteriaComplete ? "complete" : "action",
|
|
294
|
-
"Confirm criteria
|
|
339
|
+
"Confirm Trust Services criteria, Description Criteria, and Controls",
|
|
295
340
|
criteriaComplete
|
|
296
|
-
? `${
|
|
297
|
-
:
|
|
341
|
+
? `${selectedTrustServicesRequirements.length} applicable Trust Services criteria, ${selectedDescriptionRequirements.length} SOC 2 Description Criteria, and ${scope.controls.length} Controls are in scope. Every applicable Trust Services criterion has at least one selected Control; Description Criteria govern the system description and do not map to Controls.`
|
|
342
|
+
: missingRequiredSecurityReferences.length || missingRequiredDescriptionReferences.length
|
|
343
|
+
? `Use the complete SOC 2 baseline. The selected Frameworks omit ${[
|
|
344
|
+
...missingRequiredSecurityReferences,
|
|
345
|
+
...missingRequiredDescriptionReferences
|
|
346
|
+
].join(", ")}.`
|
|
347
|
+
: invalidMandatoryDecisions.length
|
|
348
|
+
? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision is" : "decisions are"} missing, undetermined, or not applicable.`
|
|
349
|
+
: `Resolve the program criteria and Controls. ${unresolvedTrustServicesRequirements.length} Trust Services applicability decisions and ${unresolvedDescriptionRequirements.length} Description Criteria decisions remain undetermined, ${missingRequirements.length} applicable criteria are not selected, and ${uncoveredRequirements.length} selected applicable Trust Services criteria have no selected Control. Description Criteria govern the system description and do not map to Controls.`,
|
|
298
350
|
workspace || { type: "workspace" },
|
|
299
351
|
{
|
|
352
|
+
unresolvedRequirementIds: unresolvedRequirements.map(({ id }) => id),
|
|
353
|
+
missingRequirementIds: missingRequirements.map(({ id }) => id),
|
|
354
|
+
uncoveredRequirementIds: uncoveredRequirements.map(({ id }) => id),
|
|
355
|
+
invalidMandatoryRequirementIds: invalidMandatoryDecisions.map(({ id }) => id),
|
|
356
|
+
missingRequiredReferences: [...missingRequiredSecurityReferences, ...missingRequiredDescriptionReferences],
|
|
300
357
|
commands: [
|
|
301
358
|
"npx filegrc review-applicability --scaffold --type requirement > decisions.json",
|
|
302
359
|
"npx filegrc review-applicability decisions.json --preview --json",
|
|
@@ -808,7 +865,7 @@ function policyActivationItem(assessment) {
|
|
|
808
865
|
);
|
|
809
866
|
}
|
|
810
867
|
|
|
811
|
-
function
|
|
868
|
+
function legacyPolicyLibraryProposals(records) {
|
|
812
869
|
const legacyIds = [
|
|
813
870
|
"policy-anti-bribery-corruption",
|
|
814
871
|
"policy-clear-desk-screen",
|
|
@@ -1253,6 +1310,16 @@ function assuranceGoalLabel(goal) {
|
|
|
1253
1310
|
return "No assurance goal selected";
|
|
1254
1311
|
}
|
|
1255
1312
|
|
|
1313
|
+
function isDescriptionRequirement(requirement) {
|
|
1314
|
+
return (requirement?.tags || []).includes("description-criteria")
|
|
1315
|
+
|| /^DC\d+/i.test(requirement?.reference || "");
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function isSecurityRequirement(requirement) {
|
|
1319
|
+
const tags = requirement?.tags || [];
|
|
1320
|
+
return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1256
1323
|
function stage(id, title, description, items) {
|
|
1257
1324
|
return { id, title, description, items };
|
|
1258
1325
|
}
|
package/src/setup.js
CHANGED
|
@@ -9,6 +9,7 @@ const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
|
|
|
9
9
|
export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
10
10
|
const loaded = await loadWorkspace(input);
|
|
11
11
|
const setup = normalizeSetupPayload(payload);
|
|
12
|
+
setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
|
|
12
13
|
validateSetup(loaded, setup);
|
|
13
14
|
const plan = buildSetupRecords(loaded, setup);
|
|
14
15
|
const updates = [
|
|
@@ -44,6 +45,7 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
|
|
|
44
45
|
export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
|
|
45
46
|
const loaded = await loadWorkspace(input);
|
|
46
47
|
const setup = normalizeSetupPayload(payload);
|
|
48
|
+
setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
|
|
47
49
|
validateSetup(loaded, setup);
|
|
48
50
|
const plan = buildSetupRecords(loaded, setup);
|
|
49
51
|
return {
|
|
@@ -137,6 +139,21 @@ function validateSetup(loaded, setup) {
|
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
function resolveClassificationId(loaded, value) {
|
|
143
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
144
|
+
if (!normalized) return value;
|
|
145
|
+
const candidates = String(loaded.model.modelVersion) === "4"
|
|
146
|
+
? loaded.resources
|
|
147
|
+
.filter(({ type, status }) => type === "classification" && status === "active")
|
|
148
|
+
.map(({ id, title }) => ({ id, label: title }))
|
|
149
|
+
: Object.entries(loaded.workspace.classificationDefinitions || {})
|
|
150
|
+
.map(([id, label]) => ({ id, label }));
|
|
151
|
+
const matches = candidates.filter(({ id, label }) => (
|
|
152
|
+
id.toLowerCase() === normalized || String(label || "").trim().toLowerCase() === normalized
|
|
153
|
+
));
|
|
154
|
+
return matches.length === 1 ? matches[0].id : value;
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
function findSetupSystem(resources, target, setup) {
|
|
141
158
|
const scopedSystemIds = new Set(target.systemIds || []);
|
|
142
159
|
return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
|
|
@@ -227,7 +244,7 @@ function buildSetupRecords(loaded, setup) {
|
|
|
227
244
|
title: `${setup.serviceName} service commitment`,
|
|
228
245
|
status: "planned",
|
|
229
246
|
commitmentKind: "service",
|
|
230
|
-
statement: "
|
|
247
|
+
statement: "[Complete before activation: State the actual customer promise or approved service requirement.]",
|
|
231
248
|
systemIds: [systemId],
|
|
232
249
|
ownerIds: [setup.ownerId],
|
|
233
250
|
customerFacing: true,
|
package/src/soc2.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
2
|
+
|
|
3
|
+
export const REQUIRED_SOC2_DESCRIPTION_REFERENCES = Array.from(
|
|
4
|
+
{ length: 9 },
|
|
5
|
+
(_, index) => `DC${index + 1}`
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
export const REQUIRED_SOC2_SECURITY_REFERENCES = [
|
|
9
|
+
"CC1.1",
|
|
10
|
+
"CC1.2",
|
|
11
|
+
"CC1.3",
|
|
12
|
+
"CC1.4",
|
|
13
|
+
"CC1.5",
|
|
14
|
+
"CC2.1",
|
|
15
|
+
"CC2.2",
|
|
16
|
+
"CC2.3",
|
|
17
|
+
"CC3.1",
|
|
18
|
+
"CC3.2",
|
|
19
|
+
"CC3.3",
|
|
20
|
+
"CC3.4",
|
|
21
|
+
"CC4.1",
|
|
22
|
+
"CC4.2",
|
|
23
|
+
"CC5.1",
|
|
24
|
+
"CC5.2",
|
|
25
|
+
"CC5.3",
|
|
26
|
+
"CC6.1",
|
|
27
|
+
"CC6.2",
|
|
28
|
+
"CC6.3",
|
|
29
|
+
"CC6.4",
|
|
30
|
+
"CC6.5",
|
|
31
|
+
"CC6.6",
|
|
32
|
+
"CC6.7",
|
|
33
|
+
"CC6.8",
|
|
34
|
+
"CC7.1",
|
|
35
|
+
"CC7.2",
|
|
36
|
+
"CC7.3",
|
|
37
|
+
"CC7.4",
|
|
38
|
+
"CC7.5",
|
|
39
|
+
"CC8.1",
|
|
40
|
+
"CC9.1",
|
|
41
|
+
"CC9.2"
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const SOC2_PROGRAM_GOALS = new Set([
|
|
45
|
+
"readiness",
|
|
46
|
+
"soc-2-type-1",
|
|
47
|
+
"soc-2-type-2"
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export function soc2RequirementApplicabilityConstraint(requirement, program, modelVersion = "4") {
|
|
51
|
+
if (
|
|
52
|
+
String(modelVersion) !== "4"
|
|
53
|
+
|| requirement?.type !== "requirement"
|
|
54
|
+
|| !SOC2_PROGRAM_GOALS.has(program?.assuranceGoal)
|
|
55
|
+
) return null;
|
|
56
|
+
const reference = String(requirement.reference || "").trim().toUpperCase();
|
|
57
|
+
const security = REQUIRED_SOC2_SECURITY_REFERENCES.includes(reference);
|
|
58
|
+
const description = REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(reference);
|
|
59
|
+
if (!security && !description) return null;
|
|
60
|
+
const family = security ? "Security Common Criteria" : "SOC 2 Description Criteria";
|
|
61
|
+
return {
|
|
62
|
+
requiredDecision: "applicable",
|
|
63
|
+
allowedDecisions: ["applicable"],
|
|
64
|
+
message: `${reference} is required for the selected SOC 2 Security program.`,
|
|
65
|
+
defaultRationale: `${reference} is part of the required ${family} baseline for the selected ${soc2GoalLabel(program.assuranceGoal)} Program.`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function soc2GoalLabel(goal) {
|
|
70
|
+
if (goal === "soc-2-type-1") return "SOC 2 Type 1";
|
|
71
|
+
if (goal === "soc-2-type-2") return "SOC 2 Type 2";
|
|
72
|
+
return "SOC 2 readiness";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function missingSoc2References(requirements, requiredReferences) {
|
|
76
|
+
const references = new Set(requirements.map(({ reference }) => String(reference || "").trim().toUpperCase()));
|
|
77
|
+
return requiredReferences.filter((reference) => !references.has(reference));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function recordWasInUseDuringAudit(record, engagementStart, engagementEnd) {
|
|
81
|
+
if (!record) return false;
|
|
82
|
+
if (record.type === "vendor") {
|
|
83
|
+
if (!["active", "deprecated", "terminated"].includes(record.status)) return false;
|
|
84
|
+
const endedOn = record.endDate || (record.status === "terminated" ? record.statusTransition?.changedOn : null);
|
|
85
|
+
if (engagementEnd && record.startDate && record.startDate > engagementEnd) return false;
|
|
86
|
+
if (engagementStart && endedOn && endedOn < engagementStart) return false;
|
|
87
|
+
if (record.status === "terminated" && engagementStart && !endedOn) return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (["active", "deprecated"].includes(record.status)) return true;
|
|
91
|
+
if (!engagementStart) return false;
|
|
92
|
+
if (["component", "system"].includes(record.type) && record.status === "retired") {
|
|
93
|
+
return Boolean(record.statusTransition?.changedOn && record.statusTransition.changedOn >= engagementStart);
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function auditorWasEngaged(auditor, audit) {
|
|
99
|
+
const engagementStart = audit?.managementAcknowledgedOn
|
|
100
|
+
|| audit?.fieldworkStart
|
|
101
|
+
|| coverageStart(audit?.coverage);
|
|
102
|
+
const engagementEnd = audit?.reportDate
|
|
103
|
+
|| audit?.fieldworkEnd
|
|
104
|
+
|| coverageEnd(audit?.coverage);
|
|
105
|
+
if (!recordWasInUseDuringAudit(auditor, engagementStart, engagementEnd)) return false;
|
|
106
|
+
const endedOn = auditor.endDate || (auditor.status === "terminated" ? auditor.statusTransition?.changedOn : null);
|
|
107
|
+
if (engagementStart && auditor.startDate && auditor.startDate > engagementStart) return false;
|
|
108
|
+
if (engagementEnd && endedOn && endedOn < engagementEnd) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function personWasActiveOn(person, on) {
|
|
113
|
+
if (person?.type !== "person" || !on) return false;
|
|
114
|
+
if (person.startDate && person.startDate > on) return false;
|
|
115
|
+
const endedOn = person.endDate || (person.status === "inactive" ? person.statusTransition?.changedOn : null);
|
|
116
|
+
if (endedOn && endedOn < on) return false;
|
|
117
|
+
return person.status === "active" || (person.status === "inactive" && Boolean(endedOn));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function appointmentWasAuthorizedOn(appointment, on, byId) {
|
|
121
|
+
if (appointment?.type !== "appointment" || !on || !["active", "ended"].includes(appointment.status)) return false;
|
|
122
|
+
if (!appointment.startsOn || appointment.startsOn > on) return false;
|
|
123
|
+
if (appointment.endsOn && appointment.endsOn < on) return false;
|
|
124
|
+
return personWasActiveOn(byId.get(appointment.holderId), on);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function signatoryAppointmentIssue(audit, byId) {
|
|
128
|
+
if (!audit?.reportDate) {
|
|
129
|
+
return {
|
|
130
|
+
code: "signatory-authority-date-missing",
|
|
131
|
+
message: "Record the CPA report date before confirming who had authority to sign management's assertion and written representations."
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const ids = audit.signatoryAppointmentIds || [];
|
|
135
|
+
if (!ids.length) {
|
|
136
|
+
return {
|
|
137
|
+
code: "signatory-authority-missing",
|
|
138
|
+
message: "Link the dated authority Appointment for each management assertion and representation signer."
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const permittedScopes = new Set([
|
|
142
|
+
"workspace",
|
|
143
|
+
audit.id,
|
|
144
|
+
audit.programId,
|
|
145
|
+
...(audit.systemIds || []),
|
|
146
|
+
...[...byId.values()].filter((record) => record.type === "organization").map(({ id }) => id)
|
|
147
|
+
].filter(Boolean));
|
|
148
|
+
const invalid = ids.filter((id) => {
|
|
149
|
+
const appointment = byId.get(id);
|
|
150
|
+
return !appointmentWasAuthorizedOn(appointment, audit.reportDate, byId)
|
|
151
|
+
|| !(appointment.scopeResourceIds || []).some((scopeId) => permittedScopes.has(scopeId));
|
|
152
|
+
});
|
|
153
|
+
if (invalid.length) {
|
|
154
|
+
return {
|
|
155
|
+
code: "signatory-authority-invalid",
|
|
156
|
+
message: `Confirm that ${invalid.join(", ")} names a dated Appointment whose holder was active and whose scope covered the workspace, Program, or Audit on ${audit.reportDate}.`
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function soc2ReportEvidenceIssue(evidence, audit, modelVersion = "4") {
|
|
163
|
+
if (
|
|
164
|
+
evidence?.type !== "evidence"
|
|
165
|
+
|| evidence.status !== "verified"
|
|
166
|
+
|| evidence.artifactKind !== "third-party-report"
|
|
167
|
+
|| evidence.artifactSubtype !== "soc2-report"
|
|
168
|
+
) {
|
|
169
|
+
return {
|
|
170
|
+
code: "invalid-audit-report-evidence",
|
|
171
|
+
message: `${evidence?.title || audit?.title || "The audit"} must be verified third-party-report Evidence with subtype soc2-report for the issued SOC 2 report.`
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const issuedOn = (String(modelVersion) === "4"
|
|
175
|
+
? evidence.sourceGeneratedAt
|
|
176
|
+
: evidence.sourceGeneratedAt || evidence.businessEventAt || evidence.collectedOn
|
|
177
|
+
)?.slice(0, 10);
|
|
178
|
+
if (!issuedOn) {
|
|
179
|
+
return {
|
|
180
|
+
code: "audit-report-date-missing",
|
|
181
|
+
message: `Record the issued SOC 2 report's actual issuance timestamp in ${evidence.title}'s sourceGeneratedAt field.`
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (audit?.reportDate && issuedOn !== audit.reportDate) {
|
|
185
|
+
return {
|
|
186
|
+
code: "audit-report-date-mismatch",
|
|
187
|
+
message: `${evidence.title} is dated ${issuedOn}, which does not match the Audit reportDate ${audit.reportDate}.`
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (audit?.reportDate && audit.opinionDate && audit.opinionDate !== audit.reportDate) {
|
|
191
|
+
return {
|
|
192
|
+
code: "audit-opinion-date-mismatch",
|
|
193
|
+
message: `The Audit opinionDate ${audit.opinionDate} does not match reportDate ${audit.reportDate}. Reconcile both dates to the issued CPA report.`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function subsequentEventsReviewIssue(audit) {
|
|
200
|
+
const review = audit?.subsequentEventsReview;
|
|
201
|
+
if (!review) {
|
|
202
|
+
return {
|
|
203
|
+
code: "subsequent-events-review-missing",
|
|
204
|
+
message: "Review incidents, changes, findings, fraud, legal matters, subservice coverage, representations, and other relevant events through the CPA report date."
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (!(review.reviewedByIds || []).length || !String(review.conclusion || "").trim()) {
|
|
208
|
+
return {
|
|
209
|
+
code: "subsequent-events-review-incomplete",
|
|
210
|
+
message: "The subsequent-events review must name its reviewers and record management's conclusion."
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const requiredThroughOn = audit.reportDate || audit.fieldworkEnd || audit.coverage?.endsOn || audit.coverage?.on;
|
|
214
|
+
if (!review.throughOn || (requiredThroughOn && review.throughOn < requiredThroughOn)) {
|
|
215
|
+
return {
|
|
216
|
+
code: "subsequent-events-period-incomplete",
|
|
217
|
+
message: `The subsequent-events review must cover through ${requiredThroughOn || "the latest engagement date"}${audit.reportDate ? ", the CPA report date" : ""}.`
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (!review.reviewedOn || review.reviewedOn < review.throughOn) {
|
|
221
|
+
return {
|
|
222
|
+
code: "subsequent-events-review-date-invalid",
|
|
223
|
+
message: "The subsequent-events review date must be on or after the date through which events were reviewed."
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
package/src/state.js
CHANGED
|
@@ -8,11 +8,13 @@ import { planObligations } from "./obligations.js";
|
|
|
8
8
|
import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
|
|
9
9
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
10
10
|
import { markdownEntries } from "./resource-markdown.js";
|
|
11
|
+
import { resolveProgram } from "./program.js";
|
|
11
12
|
import { currentCalendarDate } from "./time.js";
|
|
12
13
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
13
14
|
import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
|
|
14
15
|
import { assessWorkflow } from "./workflow.js";
|
|
15
16
|
import { measureTiming } from "./timing.js";
|
|
17
|
+
import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
|
|
16
18
|
|
|
17
19
|
const renderedMarkdownCache = new Map();
|
|
18
20
|
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
@@ -82,6 +84,11 @@ async function createAppStateUnlocked(input, options) {
|
|
|
82
84
|
asOf,
|
|
83
85
|
generatedAt
|
|
84
86
|
}));
|
|
87
|
+
const activeProgram = resolveProgram(loaded);
|
|
88
|
+
const applicabilityConstraints = Object.fromEntries(loaded.resources.flatMap((record) => {
|
|
89
|
+
const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
|
|
90
|
+
return constraint ? [[record.id, constraint]] : [];
|
|
91
|
+
}));
|
|
85
92
|
const audits = loaded.resources.filter((record) => record.type === "audit");
|
|
86
93
|
const auditPreparations = await measureTiming("state-audit-preparation", async () => Object.fromEntries(await Promise.all(
|
|
87
94
|
(audits.length ? audits : [null]).map(async (audit) => {
|
|
@@ -140,6 +147,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
140
147
|
},
|
|
141
148
|
obligations,
|
|
142
149
|
collectionReviews,
|
|
150
|
+
applicabilityConstraints,
|
|
143
151
|
programReadiness,
|
|
144
152
|
auditPreparations,
|
|
145
153
|
workflow,
|
package/src/validate.js
CHANGED
|
@@ -370,9 +370,19 @@ function validateControlComponents(record, byId, path, diagnostics) {
|
|
|
370
370
|
}
|
|
371
371
|
|
|
372
372
|
function validateAuditSubservices(record, byId, path, diagnostics) {
|
|
373
|
+
const selectedSystemIds = new Set(record.systemIds || []);
|
|
374
|
+
const seenComponentIds = new Set();
|
|
373
375
|
for (const [index, treatment] of (record.subserviceTreatments || []).entries()) {
|
|
374
376
|
for (const componentId of treatment.componentIds || []) {
|
|
375
377
|
const component = byId.get(componentId);
|
|
378
|
+
if (seenComponentIds.has(componentId)) {
|
|
379
|
+
diagnostics.push(error(
|
|
380
|
+
"duplicate-subservice-component",
|
|
381
|
+
path,
|
|
382
|
+
`subserviceTreatments[${index}] repeats Component "${componentId}" in more than one treatment.`
|
|
383
|
+
));
|
|
384
|
+
}
|
|
385
|
+
seenComponentIds.add(componentId);
|
|
376
386
|
if (component?.type === "component" && component.vendorId !== treatment.vendorId) {
|
|
377
387
|
diagnostics.push(error(
|
|
378
388
|
"subservice-vendor-mismatch",
|
|
@@ -380,6 +390,16 @@ function validateAuditSubservices(record, byId, path, diagnostics) {
|
|
|
380
390
|
`subserviceTreatments[${index}] Component "${componentId}" is not supplied by Vendor "${treatment.vendorId}".`
|
|
381
391
|
));
|
|
382
392
|
}
|
|
393
|
+
if (
|
|
394
|
+
component?.type === "component"
|
|
395
|
+
&& !(component.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
|
|
396
|
+
) {
|
|
397
|
+
diagnostics.push(error(
|
|
398
|
+
"subservice-component-outside-audit-scope",
|
|
399
|
+
path,
|
|
400
|
+
`subserviceTreatments[${index}] Component "${componentId}" has no use in a System selected by this Audit.`
|
|
401
|
+
));
|
|
402
|
+
}
|
|
383
403
|
}
|
|
384
404
|
}
|
|
385
405
|
}
|