filegrc 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/model/v4.json +97 -6
- package/package.json +2 -2
- package/src/audit-preparation.js +465 -48
- package/src/audit-transition.js +5 -0
- package/src/batch-review.js +14 -5
- package/src/cli.js +43 -0
- package/src/evidence-packet.js +202 -36
- package/src/files.js +53 -3
- package/src/git.js +39 -7
- package/src/index.js +6 -0
- package/src/policy-library/information-security-policy-v2.md +290 -0
- package/src/policy-library.js +826 -0
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +74 -7
- package/src/setup.js +18 -1
- package/src/soc2.js +227 -0
- package/src/state.js +8 -0
- package/src/validate.js +20 -0
- package/src/web.js +97 -20
- package/src/workflow.js +90 -41
package/src/audit-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
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
} from "./external-reviewer.js";
|
|
43
43
|
import { relativeToWorkspace, resolveDataPath } from "./paths.js";
|
|
44
44
|
import { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from "./policy-activation.js";
|
|
45
|
+
import { applyPolicyLibraryUpgrade, assessPolicyLibraryUpgrades } from "./policy-library.js";
|
|
45
46
|
import { buildAgentProgramPath } from "./program-path.js";
|
|
46
47
|
import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
47
48
|
import { resolveProgram } from "./program.js";
|
|
@@ -616,6 +617,29 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
616
617
|
else console.log(`Activated ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
|
|
617
618
|
return result;
|
|
618
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
|
+
}
|
|
619
643
|
if (command === "trigger") {
|
|
620
644
|
const result = await withWorkflowDelta(root, () => createObligationEvent(root, {
|
|
621
645
|
eventType: positionals[0],
|
|
@@ -1054,6 +1078,7 @@ Usage:
|
|
|
1054
1078
|
filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
|
|
1055
1079
|
filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
|
|
1056
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]
|
|
1057
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]
|
|
1058
1083
|
filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
|
|
1059
1084
|
filegrc get [resource-type] <id> [--mutation]
|
|
@@ -1223,6 +1248,24 @@ Options:
|
|
|
1223
1248
|
--help Show this help`);
|
|
1224
1249
|
return;
|
|
1225
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
|
+
}
|
|
1226
1269
|
if (command === "evidence-map") {
|
|
1227
1270
|
console.log(`Usage:
|
|
1228
1271
|
filegrc evidence-map [options]
|
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);
|
package/src/files.js
CHANGED
|
@@ -208,13 +208,26 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
208
208
|
const creates = changes.create || [];
|
|
209
209
|
const updates = changes.update || [];
|
|
210
210
|
const moves = changes.movePaths || [];
|
|
211
|
+
const contentUpdates = changes.contentUpdates || {};
|
|
211
212
|
const expectedRevisions = changes.expectedRevisions || {};
|
|
212
|
-
if (
|
|
213
|
-
|
|
213
|
+
if (
|
|
214
|
+
!Array.isArray(creates)
|
|
215
|
+
|| !Array.isArray(updates)
|
|
216
|
+
|| !Array.isArray(moves)
|
|
217
|
+
|| (!creates.length && !updates.length && !moves.length && !Object.keys(contentUpdates).length)
|
|
218
|
+
) {
|
|
219
|
+
throw new Error("A resource batch needs at least one create, update, or content update.");
|
|
214
220
|
}
|
|
215
221
|
if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object") {
|
|
216
222
|
throw new Error("Batch expected revisions must be keyed by resource ID.");
|
|
217
223
|
}
|
|
224
|
+
if (Array.isArray(contentUpdates) || typeof contentUpdates !== "object") {
|
|
225
|
+
throw new Error("Batch content updates must be keyed by resource ID.");
|
|
226
|
+
}
|
|
227
|
+
const expectedContentRevisions = changes.expectedContentRevisions || {};
|
|
228
|
+
if (Array.isArray(expectedContentRevisions) || typeof expectedContentRevisions !== "object") {
|
|
229
|
+
throw new Error("Batch expected content revisions must be keyed by resource ID.");
|
|
230
|
+
}
|
|
218
231
|
const loaded = await loadWorkspace(input);
|
|
219
232
|
const workspaceUpdate = updates.find((record) => (
|
|
220
233
|
record.type === "workspace" && record.id === loaded.workspace?.id
|
|
@@ -248,6 +261,8 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
248
261
|
const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
249
262
|
const ids = new Set();
|
|
250
263
|
const writes = [];
|
|
264
|
+
const contentWrites = [];
|
|
265
|
+
const preparedContentIds = new Set();
|
|
251
266
|
const allowedPathMoves = new Set();
|
|
252
267
|
for (const record of creates) {
|
|
253
268
|
validateBatchRecord(record, ids);
|
|
@@ -290,7 +305,29 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
290
305
|
if (error.code !== "ENOENT") throw error;
|
|
291
306
|
}
|
|
292
307
|
}
|
|
293
|
-
|
|
308
|
+
const hasContentUpdate = Object.hasOwn(contentUpdates, record.id);
|
|
309
|
+
const recordContentWrites = await prepareContentWrites(loaded, record, contentUpdates[record.id], {
|
|
310
|
+
expectedRevisions: expectedContentRevisions[record.id],
|
|
311
|
+
requireExpectedRevisions: hasContentUpdate
|
|
312
|
+
});
|
|
313
|
+
if (hasContentUpdate) preparedContentIds.add(record.id);
|
|
314
|
+
contentWrites.push(...recordContentWrites);
|
|
315
|
+
const nextRecord = hasContentUpdate
|
|
316
|
+
? await prepareApprovalBinding(loaded, record, recordContentWrites, existing.record)
|
|
317
|
+
: record;
|
|
318
|
+
writes.push({ operation: path === previousPath ? "update" : "move-update", path, previousPath, record: nextRecord, previous, fileMode: mode });
|
|
319
|
+
}
|
|
320
|
+
for (const resourceId of Object.keys(contentUpdates)) {
|
|
321
|
+
if (preparedContentIds.has(resourceId)) continue;
|
|
322
|
+
const existing = existingById.get(resourceId);
|
|
323
|
+
if (!existing) throw new Error(`Resource "${resourceId}" was not found.`);
|
|
324
|
+
if (approvalBound(existing.record)) {
|
|
325
|
+
throw new Error(`Batch content for approved or active resource "${resourceId}" needs a matching resource update and validation of its approval binding.`);
|
|
326
|
+
}
|
|
327
|
+
contentWrites.push(...await prepareContentWrites(loaded, existing.record, contentUpdates[resourceId], {
|
|
328
|
+
expectedRevisions: expectedContentRevisions[resourceId],
|
|
329
|
+
requireExpectedRevisions: true
|
|
330
|
+
}));
|
|
294
331
|
}
|
|
295
332
|
const pathMoves = [];
|
|
296
333
|
const seenMovePaths = new Set();
|
|
@@ -317,8 +354,13 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
317
354
|
pathMoves.push({ from, to, mode });
|
|
318
355
|
}
|
|
319
356
|
const written = [];
|
|
357
|
+
const writtenContent = [];
|
|
320
358
|
const moved = [];
|
|
321
359
|
try {
|
|
360
|
+
for (const item of contentWrites) {
|
|
361
|
+
await writeTextAtomic(item.path, item.source);
|
|
362
|
+
writtenContent.push(item);
|
|
363
|
+
}
|
|
322
364
|
for (const item of writes) {
|
|
323
365
|
await writeAtomic(item.path, item.record, { exclusive: item.operation === "create" });
|
|
324
366
|
written.push(item);
|
|
@@ -363,6 +405,14 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
|
|
|
363
405
|
rollbackErrors.push(rollbackError.message);
|
|
364
406
|
}
|
|
365
407
|
}
|
|
408
|
+
for (const item of writtenContent.reverse()) {
|
|
409
|
+
try {
|
|
410
|
+
if (item.previous === null) await rm(item.path, { force: true });
|
|
411
|
+
else await writeTextAtomic(item.path, item.previous);
|
|
412
|
+
} catch (rollbackError) {
|
|
413
|
+
rollbackErrors.push(rollbackError.message);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
366
416
|
if (rollbackErrors.length) {
|
|
367
417
|
throw new Error(`${error.message} FileGRC could not restore every file in the resource batch: ${rollbackErrors.join(" ")}`);
|
|
368
418
|
}
|