filegrc 0.6.5 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -41,6 +41,8 @@ import {
41
41
  setupExternalReviewerGovernance
42
42
  } from "./external-reviewer.js";
43
43
  import { relativeToWorkspace, resolveDataPath } from "./paths.js";
44
+ import { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from "./policy-activation.js";
45
+ import { applyPolicyLibraryUpgrade, assessPolicyLibraryUpgrades } from "./policy-library.js";
44
46
  import { buildAgentProgramPath } from "./program-path.js";
45
47
  import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
46
48
  import { resolveProgram } from "./program.js";
@@ -432,6 +434,12 @@ export async function runCli(argv = process.argv.slice(2)) {
432
434
  console.log(`\n${stage.title}`);
433
435
  for (const item of stage.items) console.log(`${item.status.toUpperCase()}\t${item.title}\t${item.message}`);
434
436
  }
437
+ if (result.policyActivations.length) {
438
+ console.log("\nPolicy activation assessments");
439
+ for (const policy of result.policyActivations) {
440
+ console.log(`${policy.label.toUpperCase()}\t${policy.title}\t${policy.gapCount} implementation gaps`);
441
+ }
442
+ }
435
443
  if (result.canStartCandidatePeriod && !result.operating) {
436
444
  console.log(`\nEvidence Ready: management can start the candidate Type 2 period on or after ${result.suggestedCandidatePeriodStart || result.asOf}.`);
437
445
  }
@@ -588,6 +596,50 @@ export async function runCli(argv = process.argv.slice(2)) {
588
596
  else console.log(`Confirmed ${result.assessment.configuration.title}.`);
589
597
  return result;
590
598
  }
599
+ if (command === "activate-policies") {
600
+ if (flags.scaffold) {
601
+ const result = await scaffoldPolicyActivation(root, { programId: flags.program });
602
+ console.log(JSON.stringify(result, null, 2));
603
+ return result;
604
+ }
605
+ const payload = await readSetupPayload(positionals[0]);
606
+ const options = {
607
+ ...payload,
608
+ policyIds: flags.policy ? String(flags.policy).split(",").filter(Boolean) : payload.policyIds,
609
+ effectiveOn: flags["effective-on"] || payload.effectiveOn,
610
+ confirmed: flags.yes === true
611
+ };
612
+ const result = flags.preview
613
+ ? await planPolicyActivation(root, options)
614
+ : await withWorkflowDelta(root, () => activatePolicies(root, options));
615
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
616
+ else if (flags.preview) console.log(`Policy activation preview: ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
617
+ else console.log(`Activated ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
618
+ return result;
619
+ }
620
+ if (command === "policy-library") {
621
+ if (flags.yes && !flags.accept) {
622
+ throw new Error("Pass --accept <proposal-id> with --yes after reviewing the policy-library diff.");
623
+ }
624
+ const result = flags.accept
625
+ ? await withWorkflowDelta(root, () => applyPolicyLibraryUpgrade(root, String(flags.accept), {
626
+ confirmed: flags.yes === true,
627
+ proposalRevision: flags["proposal-revision"] ? String(flags["proposal-revision"]) : null
628
+ }))
629
+ : await assessPolicyLibraryUpgrades(root);
630
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
631
+ else if (flags.accept) console.log(`Accepted policy-library proposal ${flags.accept}.`);
632
+ else if (!result.proposals.length) console.log("No starter policy-library update applies. Existing content is unchanged.");
633
+ else {
634
+ for (const proposal of result.proposals) {
635
+ console.log(`${proposal.title} (${proposal.id})`);
636
+ console.log(proposal.message);
637
+ for (const change of proposal.changes) console.log(`\n${change.diff}`);
638
+ console.log(`\nAccept with: filegrc policy-library --accept ${proposal.id} --proposal-revision ${proposal.revision} --yes`);
639
+ }
640
+ }
641
+ return result;
642
+ }
591
643
  if (command === "trigger") {
592
644
  const result = await withWorkflowDelta(root, () => createObligationEvent(root, {
593
645
  eventType: positionals[0],
@@ -1025,6 +1077,8 @@ Usage:
1025
1077
  filegrc next-audit-cycle <prior-audit-id> [cycle.json|-] --start YYYY-MM-DD --end YYYY-MM-DD [--preview|--yes] [--json]
1026
1078
  filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
1027
1079
  filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
1080
+ filegrc activate-policies [--scaffold | activation.json|-] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
1081
+ filegrc policy-library [--json | --accept proposal-id --proposal-revision revision --yes]
1028
1082
  filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
1029
1083
  filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
1030
1084
  filegrc get [resource-type] <id> [--mutation]
@@ -1174,6 +1228,44 @@ Options:
1174
1228
  --help Show this help`);
1175
1229
  return;
1176
1230
  }
1231
+ if (command === "activate-policies") {
1232
+ console.log(`Usage:
1233
+ filegrc activate-policies --scaffold [--program id]
1234
+ filegrc activate-policies <activation.json|-> [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
1235
+
1236
+ Review and atomically activate selected approved Policies at the end of Step 3.
1237
+ The scaffold includes every required, approved, inactive Policy and its current
1238
+ revision. A past effective date is rejected.
1239
+
1240
+ Options:
1241
+ --scaffold Print a cutover payload without writing
1242
+ --program <id> Program to assess when more than one active Program exists
1243
+ --effective-on <date> Shared effective date for the selected Policies
1244
+ --preview Validate and show the atomic updates without writing
1245
+ --yes Confirm and apply the reviewed cutover
1246
+ --json Print the result as JSON
1247
+ --root <path> Workspace path
1248
+ --help Show this help`);
1249
+ return;
1250
+ }
1251
+ if (command === "policy-library") {
1252
+ console.log(`Usage:
1253
+ filegrc policy-library [--json]
1254
+ filegrc policy-library --accept <proposal-id> --proposal-revision <revision> --yes [--json]
1255
+
1256
+ Review optional starter-library updates for unchanged default Policy and Control
1257
+ content. The review prints exact diffs. FileGRC skips customized or adopted Policy
1258
+ content and writes nothing until you accept one named proposal revision with --yes.
1259
+
1260
+ Options:
1261
+ --accept <id> Accept one proposal after reviewing its diff
1262
+ --proposal-revision <hash> Confirm the exact reviewed proposal revision
1263
+ --yes Confirm the named proposal write
1264
+ --json Print the versioned proposal or acceptance result
1265
+ --root <path> Workspace path
1266
+ --help Show this help`);
1267
+ return;
1268
+ }
1177
1269
  if (command === "evidence-map") {
1178
1270
  console.log(`Usage:
1179
1271
  filegrc evidence-map [options]
@@ -1220,6 +1312,7 @@ function agentOverview(model) {
1220
1312
  prepareAudit: "filegrc prepare-audit <audit-id>",
1221
1313
  reconcile: "filegrc reconcile --preview --json",
1222
1314
  externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
1315
+ policyActivation: "filegrc activate-policies [--scaffold | <activation.json|-> --preview] --json",
1223
1316
  nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
1224
1317
  reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
1225
1318
  reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
@@ -1371,6 +1464,8 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
1371
1464
  currentStep: { id: currentStep.id, number: currentStep.number, title: currentStep.title },
1372
1465
  evidenceReady: readiness.evidenceReady,
1373
1466
  operating: readiness.operating,
1467
+ policyActivations: readiness.policyActivations,
1468
+ policyLibraryProposals: readiness.policyLibraryProposals,
1374
1469
  stages
1375
1470
  };
1376
1471
  }
@@ -1418,6 +1513,8 @@ function summarizeProgramPath(result) {
1418
1513
  currentStep: result.currentStep,
1419
1514
  evidenceReady: result.evidenceReady,
1420
1515
  operating: result.operating,
1516
+ policyActivations: result.policyActivations,
1517
+ policyLibraryProposals: result.policyLibraryProposals,
1421
1518
  stages: result.stages.map((stage) => ({
1422
1519
  id: stage.id,
1423
1520
  number: stage.number,
@@ -1439,6 +1536,8 @@ function nextProgramPath(result) {
1439
1536
  currentStep: result.currentStep,
1440
1537
  evidenceReady: result.evidenceReady,
1441
1538
  operating: result.operating,
1539
+ policyActivations: result.policyActivations,
1540
+ policyLibraryProposals: result.policyLibraryProposals,
1442
1541
  step: stage ? {
1443
1542
  id: stage.id,
1444
1543
  number: stage.number,
@@ -1562,6 +1661,14 @@ function summarizeProgramReadiness(result) {
1562
1661
  scopeCounts: Object.fromEntries(
1563
1662
  Object.entries(result.scope).map(([name, ids]) => [name.replace(/Ids$/, ""), ids.length])
1564
1663
  ),
1664
+ policyActivations: result.policyActivations.map(({ policyId, title, state, label, gapCount }) => ({
1665
+ policyId,
1666
+ title,
1667
+ state,
1668
+ label,
1669
+ gapCount
1670
+ })),
1671
+ policyLibraryProposals: result.policyLibraryProposals,
1565
1672
  unresolvedOwnership: {
1566
1673
  count: unresolvedOwnership.length,
1567
1674
  byReason: ownershipReasons,
@@ -0,0 +1,11 @@
1
+ export function openPlaceholderCount(source) {
2
+ if (!source) return 0;
3
+ const matches = source.match(
4
+ /\{\{[^}\n]+\}\}|\b(?:TODO|TBD)\b|\[(?:complete|confirm|describe|insert|name|replace|select|specify|todo|tbd)[^\]\n]*\]/giu
5
+ );
6
+ return matches?.length || 0;
7
+ }
8
+
9
+ export function substantiveMarkdown(source) {
10
+ return (source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length >= 10;
11
+ }
@@ -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);