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.
@@ -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",
@@ -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
- id: record.id,
49
- decision: null,
50
- rationale: null
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]
@@ -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.subserviceVendorIds || []),
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(loaded.root, selectedPaths, Number.MAX_SAFE_INTEGER);
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.subserviceMethod || null
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.subserviceVendorIds || []),
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.applicability !== "applicable" || isDescriptionRequirement(requirement)) continue;
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.subserviceVendorIds || []) {
1141
+ for (const vendorId of auditSubserviceVendorIds(audit)) {
1124
1142
  const vendor = byId.get(vendorId);
1125
1143
  if (!vendor) continue;
1126
- if (vendor.status !== "active") {
1127
- gaps.push(gap("error", "inactive-subservice-organization", `${vendor.title} is in audit scope but is ${vendor.status}.`, vendor.id));
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
- if (!selectedRequirements.some(isDescriptionRequirement)) {
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.applicability !== "applicable") {
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
- if (!audit.auditorVendorId) gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
1333
- 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));
1334
- if ((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable") {
1335
- gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} names subservice organizations but marks their treatment not applicable.`, audit.id));
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
- const expectedSubserviceVendorIds = new Set((audit.systemIds || []).flatMap((id) => byId.get(id)?.subserviceVendorIds || []));
1338
- for (const vendorId of expectedSubserviceVendorIds) {
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 (audit.subserviceMethod === "inclusive") {
1344
- const subserviceSystemIds = records
1345
- .filter((record) => record.type === "system" && (audit.subserviceVendorIds || []).includes(record.vendorId))
1346
- .map((record) => record.id);
1347
- const includedControls = (audit.controlIds || [])
1348
- .map((id) => byId.get(id))
1349
- .filter((control) => (control?.systemIds || []).some((id) => subserviceSystemIds.includes(id)));
1350
- if (!subserviceSystemIds.length || !includedControls.length) {
1351
- 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));
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 (audit.status === "complete") {
1389
- const representation = byId.get(audit.managementRepresentationDocumentId);
1390
- if (!(representation?.evidenceIds || []).length) {
1391
- gaps.push(gap("error", "unsigned-management-representation", `${representation?.title || audit.title} does not link the signed representation letter evidence.`, representation?.id || audit.id));
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 (!Array.isArray(creates) || !Array.isArray(updates) || !Array.isArray(moves) || (!creates.length && !updates.length && !moves.length)) {
213
- throw new Error("A resource batch needs at least one create or update.");
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
- writes.push({ operation: path === previousPath ? "update" : "move-update", path, previousPath, record, previous, fileMode: mode });
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
  }