filegrc 0.16.6 → 0.16.8
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/package.json +1 -1
- package/src/applicability-scope.js +22 -22
- package/src/batch-review.js +12 -2
- package/src/policy-library/information-security-policy-v2.md +8 -0
- package/src/policy-library.js +90 -13
- package/src/program-readiness.js +16 -5
- package/src/state.js +20 -1
- package/src/web.js +69 -44
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@ const unorderedStringArrayFields = new Set([
|
|
|
21
21
|
"tags"
|
|
22
22
|
]);
|
|
23
23
|
|
|
24
|
-
export function applicabilityScopeRevision(record, program, resources, model) {
|
|
24
|
+
export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
|
|
25
25
|
const selectedSystemIds = new Set(program?.systemIds || []);
|
|
26
26
|
const selectedFrameworkIds = new Set(program?.frameworkIds || []);
|
|
27
27
|
const selectedControlIds = new Set(program?.controlIds || []);
|
|
@@ -117,19 +117,19 @@ export function applicabilityScopeRevision(record, program, resources, model) {
|
|
|
117
117
|
policies: resources
|
|
118
118
|
.filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id))
|
|
119
119
|
.sort(compareRecordIds)
|
|
120
|
-
.map((policy) =>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
120
|
+
.map((policy) => options.legacyPolicyStatus
|
|
121
|
+
? pick({
|
|
122
|
+
...policy,
|
|
123
|
+
status: options.legacyPolicyStatus === "approved" && ["in-review", "active"].includes(policy.status)
|
|
124
|
+
? "approved" : policy.status,
|
|
125
|
+
effectiveOn: options.legacyPolicyStatus === "approved" && policy.status === "active"
|
|
126
|
+
? undefined : policy.effectiveOn
|
|
127
|
+
}, [
|
|
128
|
+
"id", "status", "policyKind", "version", "programRole", "effectiveOn",
|
|
129
|
+
"requirementIds", "audience", "acknowledgementRequired",
|
|
130
|
+
"relatedDocumentIds", "approvedContentRevisions"
|
|
131
|
+
], model)
|
|
132
|
+
: pick(policy, ["id", "requirementIds"], model)),
|
|
133
133
|
requirements: resources
|
|
134
134
|
.filter(({ type, frameworkId }) => type === "requirement" && selectedFrameworkIds.has(frameworkId))
|
|
135
135
|
.sort(compareRecordIds)
|
|
@@ -165,14 +165,14 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
|
|
|
165
165
|
&& !review.scopeRevision.startsWith("filegrc:applicability-scope:")
|
|
166
166
|
&& Number(model?.modelVersion || 0) < 7
|
|
167
167
|
) return true;
|
|
168
|
-
return
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
168
|
+
if (!review?.scopeRevision) return false;
|
|
169
|
+
const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
|
|
170
|
+
if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
|
|
171
|
+
// Reviews written before the scope fix included Policy lifecycle fields. Accept
|
|
172
|
+
// that earlier fingerprint only when its material inputs still match.
|
|
173
|
+
if (matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "current" }))) return true;
|
|
174
|
+
return resources.some(({ type, status }) => type === "policy" && ["in-review", "active"].includes(status))
|
|
175
|
+
&& matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "approved" }));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
function pick(record, fields, model, objectType = null) {
|
package/src/batch-review.js
CHANGED
|
@@ -40,6 +40,8 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
40
40
|
&& applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model)
|
|
41
41
|
))
|
|
42
42
|
.map(({ requirementId }) => requirementId));
|
|
43
|
+
const requirementReviews = new Map((program.requirementApplicability || [])
|
|
44
|
+
.map((review) => [review.requirementId, review]));
|
|
43
45
|
const records = loaded.resources.filter((record) => (
|
|
44
46
|
REVIEWABLE_TYPES.has(record.type)
|
|
45
47
|
&& (!requestedType || record.type === requestedType)
|
|
@@ -56,10 +58,18 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
|
|
|
56
58
|
.sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`))
|
|
57
59
|
.map((record) => {
|
|
58
60
|
const constraint = soc2RequirementApplicabilityConstraint(record, program, loaded.model.modelVersion);
|
|
61
|
+
const saved = record.type === "requirement" && modelSupports(loaded.model, "program-scope")
|
|
62
|
+
? requirementReviews.get(record.id)
|
|
63
|
+
: record.applicabilityReview;
|
|
64
|
+
const prior = saved && ["applicable", "not-applicable", "externally-managed", "zero-population"].includes(saved.decision)
|
|
65
|
+
? saved : null;
|
|
59
66
|
return {
|
|
60
67
|
id: record.id,
|
|
61
|
-
decision: constraint?.requiredDecision || null,
|
|
62
|
-
rationale: constraint?.
|
|
68
|
+
decision: constraint?.requiredDecision || prior?.decision || null,
|
|
69
|
+
rationale: constraint?.requiredDecision && constraint.requiredDecision !== prior?.decision
|
|
70
|
+
? constraint.defaultRationale || null
|
|
71
|
+
: prior?.rationale || constraint?.defaultRationale || null,
|
|
72
|
+
reviewStatus: prior ? "stale" : "missing",
|
|
63
73
|
...(constraint ? { constraint } : {})
|
|
64
74
|
};
|
|
65
75
|
})
|
|
@@ -120,6 +120,8 @@ Removable media containing Confidential or Restricted data requires owner approv
|
|
|
120
120
|
|
|
121
121
|
The Data Retention Schedule defines the approved period and disposal method for important in-scope record classes. Supporting standards, procedures, and system records document implementation. Disposal must be suitable for the media and classification, with dated proof when the Control requires it.
|
|
122
122
|
|
|
123
|
+
Owners document and test how approved deletion requests and retention cutoffs apply to primary data, derived copies, local copies, Vendor-held data, and backups. Procedures identify copies that cannot be removed immediately, their access restrictions and expiry, and any legal hold or contractual limit. Owners verify completion against the approved scope before representing data as deleted.
|
|
124
|
+
|
|
123
125
|
## Cryptography, Encryption, Key, and Secrets Management Policy
|
|
124
126
|
|
|
125
127
|
### Encryption requirements
|
|
@@ -164,6 +166,8 @@ Passwords and other authenticators must meet settings approved for the System's
|
|
|
164
166
|
- **Customer and external-user access:** MFA is required when an approved Control, customer commitment, or risk decision requires it.
|
|
165
167
|
- **Exceptions:** Where required MFA is unavailable, management must approve a time-bound Exception with a risk assessment, compensating Controls, an accountable owner, and a review or expiration date.
|
|
166
168
|
|
|
169
|
+
Owners document customer authentication and workforce authentication separately for each relevant System. They record whether either path uses single sign-on, where MFA is enforced, and how access is revoked. Remote-access methods are selected and approved for the System and risk; a VPN is required only when an approved Control, commitment, or risk decision calls for one.
|
|
170
|
+
|
|
167
171
|
## Endpoint, Mobile Device, BYOD, and Malware Protection Policy
|
|
168
172
|
|
|
169
173
|
### Company devices and platform protection
|
|
@@ -194,6 +198,8 @@ Owners restrict inbound, outbound, and internal network paths and management int
|
|
|
194
198
|
|
|
195
199
|
Production, development, test, and general-user environments must be separated to the extent needed for their data, exposure, privileges, and change risk. Connections between environments require approved paths and safeguards. Wireless and other local networks used for company work require authentication and encryption appropriate to current risk and technical capability.
|
|
196
200
|
|
|
201
|
+
When a service processes data for multiple customers, owners define and enforce customer-data boundaries appropriate to the architecture. They verify that access paths and material changes preserve those boundaries, and record the test method, result, and follow-up.
|
|
202
|
+
|
|
197
203
|
## Configuration Management and System Maintenance Policy
|
|
198
204
|
|
|
199
205
|
Important Systems and Components use documented secure configuration expectations based on trusted guidance, technical capability, and risk. Owners change or disable unnecessary default accounts, credentials, services, ports, features, and configurations. Deviations require review and, when material, an approved Exception.
|
|
@@ -230,6 +236,8 @@ Important Systems record and protect the security and operational events needed
|
|
|
230
236
|
|
|
231
237
|
Logs use synchronized time, restrict alteration and access, and avoid unnecessary secrets or personal data. Each System's retention period belongs in the approved Data Retention Schedule. Owners document risk-based alerts, review paths, thresholds, and response ownership in approved standards, procedures, and schedules.
|
|
232
238
|
|
|
239
|
+
When an incident may require investigation, responders preserve relevant logs and their context under the incident process, with controlled access and a documented retention or hold decision.
|
|
240
|
+
|
|
233
241
|
### Monitoring and alert testing
|
|
234
242
|
|
|
235
243
|
Systems with availability commitments, recovery objectives, or material operational dependencies monitor the health, capacity, failure, and service indicators needed to detect degradation. Representative alert paths are tested from generation through acknowledgement, escalation, and fallback on the approved schedule and after a material path change. This requirement does not prescribe a particular monitoring or log-management product.
|
package/src/policy-library.js
CHANGED
|
@@ -146,6 +146,7 @@ const DOCUMENT_CONTENT_UPDATES = [
|
|
|
146
146
|
}
|
|
147
147
|
];
|
|
148
148
|
const PRIOR_STARTER_POLICY_REVISIONS = new Set([
|
|
149
|
+
"b440eb17797624c1b456f778b12a955c871de308349e565b5d69ab6a25703673",
|
|
149
150
|
"b0f9b988a8bd231fe70ce71b6a732970e709b7af7826c1b55f1532f511b6e511",
|
|
150
151
|
"e87961c6a665d73e9d3cce96ab978df87579668e72ea236248680db1f69a5fa1",
|
|
151
152
|
"2b50c75f4f138eebc7154a6b6e0e50b843f016c228632f7c71239ec27c18cbbf",
|
|
@@ -157,6 +158,16 @@ const PRIOR_STARTER_POLICY_REVISIONS = new Set([
|
|
|
157
158
|
]);
|
|
158
159
|
|
|
159
160
|
const CONTROL_UPDATES = [
|
|
161
|
+
{
|
|
162
|
+
id: "control-access-review-offboarding",
|
|
163
|
+
prior: [{
|
|
164
|
+
activity: "Review access populations, record decisions, and remove dormant, expired, or unneeded access."
|
|
165
|
+
}],
|
|
166
|
+
next: {
|
|
167
|
+
activity: "Review complete access populations, including workers and third parties with customer-data access, record decisions, and remove dormant, expired, or unneeded access."
|
|
168
|
+
},
|
|
169
|
+
summary: "Prompt a complete customer-data access population without changing the approved review cadence."
|
|
170
|
+
},
|
|
160
171
|
{
|
|
161
172
|
id: "control-policy-management",
|
|
162
173
|
prior: [{
|
|
@@ -283,15 +294,21 @@ const CONTROL_UPDATES = [
|
|
|
283
294
|
},
|
|
284
295
|
{
|
|
285
296
|
id: "control-network-security",
|
|
286
|
-
prior: [
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
297
|
+
prior: [
|
|
298
|
+
{
|
|
299
|
+
statement: "The organization restricts network paths, protects remote access with approved encryption and authentication, and reviews material network access rules at least annually.",
|
|
300
|
+
activity: "Manage boundaries, firewall rules, wireless safeguards, and remote production access."
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
statement: "The organization restricts network paths, separates production and nonproduction environments according to data and risk, protects remote access with approved encryption and authentication, and reviews material network access rules at least annually.",
|
|
304
|
+
activity: "Manage boundaries, environment connections, firewall rules, wireless safeguards, and remote production access."
|
|
305
|
+
}
|
|
306
|
+
],
|
|
290
307
|
next: {
|
|
291
308
|
statement: "The organization restricts network paths, separates production and nonproduction environments according to data and risk, protects remote access with approved encryption and authentication, and reviews material network access rules at least annually.",
|
|
292
|
-
activity: "
|
|
309
|
+
activity: "Document and verify customer and environment boundaries, approved network rules and deviations by source, wireless safeguards, and remote production access."
|
|
293
310
|
},
|
|
294
|
-
summary: "
|
|
311
|
+
summary: "Prompt evidence for customer and environment boundaries and network-rule coverage."
|
|
295
312
|
},
|
|
296
313
|
{
|
|
297
314
|
id: "control-change-management",
|
|
@@ -340,23 +357,32 @@ const CONTROL_UPDATES = [
|
|
|
340
357
|
{
|
|
341
358
|
activity: "Use continuous platform protection where supported and verify endpoint configuration, update, and compliance state on the risk-based schedule recorded in an Obligation when periodic work is needed.",
|
|
342
359
|
requirementIds: ["requirement-soc2-cc6-6", "requirement-soc2-cc6-8", "requirement-soc2-cc7-1"]
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
activity: "Use continuous platform protection where supported and verify endpoint configuration, update, and compliance state on the approved risk-based schedule when periodic work is needed.",
|
|
363
|
+
requirementIds: ["requirement-soc2-cc6-6", "requirement-soc2-cc6-8", "requirement-soc2-cc7-1"]
|
|
343
364
|
}
|
|
344
365
|
],
|
|
345
366
|
next: {
|
|
346
|
-
activity: "Use continuous platform protection where supported and verify
|
|
367
|
+
activity: "Document protection coverage and approved deviations by device or platform class. Use continuous platform protection where supported and verify configuration, updates, and compliance on the approved risk-based schedule when periodic work is needed.",
|
|
347
368
|
requirementIds: ["requirement-soc2-cc6-6", "requirement-soc2-cc6-8", "requirement-soc2-cc7-1"]
|
|
348
369
|
},
|
|
349
|
-
summary: "
|
|
370
|
+
summary: "Prompt platform protection coverage, deviations, and verification without mandating one product."
|
|
350
371
|
},
|
|
351
372
|
{
|
|
352
373
|
id: "control-vulnerability-management",
|
|
353
|
-
prior: [
|
|
354
|
-
|
|
355
|
-
|
|
374
|
+
prior: [
|
|
375
|
+
{
|
|
376
|
+
activity: "Choose scan coverage and cadence. Review the starter remediation targets of Critical 7 days, High 14 days, Medium 30 days, and Low 90 days, then record the approved targets or time-bound Exceptions."
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
activity: "Choose scan coverage and cadence, define approved risk-based remediation targets, and document time-bound Exceptions when a target cannot be met."
|
|
380
|
+
}
|
|
381
|
+
],
|
|
356
382
|
next: {
|
|
357
|
-
activity: "
|
|
383
|
+
activity: "Define scan scope and cadence, severity criteria, risk-based remediation and patch targets, and time-bound Exceptions when a target cannot be met."
|
|
358
384
|
},
|
|
359
|
-
summary: "
|
|
385
|
+
summary: "Prompt explicit scan scope, severity criteria, and approved remediation and patch targets."
|
|
360
386
|
},
|
|
361
387
|
{
|
|
362
388
|
id: "control-logging-monitoring",
|
|
@@ -403,6 +429,16 @@ const CONTROL_UPDATES = [
|
|
|
403
429
|
activity: "Assess service, data, access, assurance, recovery, incidents, dependencies, supplied Components, and applicable contract safeguards before access or material reliance."
|
|
404
430
|
},
|
|
405
431
|
summary: "Name the contract outcomes considered when a Vendor handles protected data or supports an important service."
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
id: "control-vendor-monitoring",
|
|
435
|
+
prior: [{
|
|
436
|
+
activity: "Review vendor performance, assurance, recovery, access, incidents, and contract obligations."
|
|
437
|
+
}],
|
|
438
|
+
next: {
|
|
439
|
+
activity: "Set review intervals by Vendor risk and customer-data access. Review performance, assurance, recovery, access, incidents, and contract obligations, and reassess after material change."
|
|
440
|
+
},
|
|
441
|
+
summary: "Prompt review intervals for every customer-data Vendor while keeping the interval risk-based."
|
|
406
442
|
}
|
|
407
443
|
];
|
|
408
444
|
|
|
@@ -439,6 +475,16 @@ const OBLIGATION_UPDATES = [
|
|
|
439
475
|
}
|
|
440
476
|
];
|
|
441
477
|
|
|
478
|
+
const PRIOR_STARTER_RULE_RATIONALE = "Starter proposal derived from the linked Policy. Management must review the cadence, population, completion criteria, and timing before activation.";
|
|
479
|
+
const OBLIGATION_RULE_RATIONALE_UPDATES = [
|
|
480
|
+
["obligation-rule-quarterly-privileged-access-review-v1", "Cover privileged and production access. Add other customer-data access only when an approved commitment or risk decision requires quarterly review."],
|
|
481
|
+
["obligation-rule-annual-access-review-v1", "Cover other important access, including customer-data paths not assigned to a shorter approved review schedule. Avoid counting the same access twice."],
|
|
482
|
+
["obligation-rule-monthly-endpoint-protection-verification-v1", "Confirm device and platform classes, protection coverage, exceptions, evidence, and whether the monthly cadence fits the approved risk decision."],
|
|
483
|
+
["obligation-rule-annual-network-access-review-v1", "Confirm the in-scope network and host rule sources, customer and environment boundaries, deviations, and review cadence."],
|
|
484
|
+
["obligation-rule-quarterly-vulnerability-scan-v1", "Confirm scan scope, severity method, remediation and patch targets, and whether exposure or a customer commitment requires a shorter cadence, such as monthly."],
|
|
485
|
+
["obligation-rule-annual-critical-vendor-review-v1", "Confirm the high and critical Vendor population, and set a separate review interval for other Vendors with customer-data access where needed."]
|
|
486
|
+
].map(([id, guidance]) => ({ id, rationale: `${PRIOR_STARTER_RULE_RATIONALE} ${guidance}` }));
|
|
487
|
+
|
|
442
488
|
const OBLIGATION_ADDITIONS = [
|
|
443
489
|
{
|
|
444
490
|
id: "obligation-annual-control-design-review",
|
|
@@ -760,6 +806,37 @@ async function buildPolicyLibraryPlan(loaded) {
|
|
|
760
806
|
});
|
|
761
807
|
}
|
|
762
808
|
|
|
809
|
+
for (const { id, rationale } of OBLIGATION_RULE_RATIONALE_UPDATES) {
|
|
810
|
+
const entry = byId.get(id);
|
|
811
|
+
const obligation = entry ? byId.get(entry.record.obligationId)?.record : null;
|
|
812
|
+
const displayPath = `data/obligation-rules/${id}.json`;
|
|
813
|
+
if (!entry || entry.record.type !== "obligation-rule" || !obligation || obligation.type !== "obligation") {
|
|
814
|
+
skipped.push(skippedItem(id, "missing", `The starter ${id} Obligation Rule is not present.`));
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
if (entry.record.rationale === rationale) {
|
|
818
|
+
skipped.push(skippedItem(id, "current", "The Obligation Rule already contains the current starter guidance."));
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (entry.record.status !== "proposed" || obligation.status !== "proposed") {
|
|
822
|
+
skipped.push(skippedItem(id, "operating", "Only proposed Obligations and Rules are eligible for starter-library guidance updates."));
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (entry.record.rationale !== PRIOR_STARTER_RULE_RATIONALE || !obligation.ruleIds?.includes(id)) {
|
|
826
|
+
skipped.push(skippedItem(id, "customized", "The Obligation Rule differs from a recognized prior starter, so FileGRC will not rewrite it."));
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
updates.push({ ...entry.record, rationale });
|
|
830
|
+
expectedRevisions[id] = entry.revision;
|
|
831
|
+
proposalChanges.push({
|
|
832
|
+
resourceType: "obligation-rule",
|
|
833
|
+
resourceId: id,
|
|
834
|
+
path: displayPath,
|
|
835
|
+
summary: "Add scope and cadence checks to the unchanged proposed starter rule.",
|
|
836
|
+
diff: replacementDiff(displayPath, [["rationale", entry.record.rationale, rationale]])
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
763
840
|
for (const addition of modelSupports(loaded.model, "program-scope") ? OBLIGATION_ADDITIONS : []) {
|
|
764
841
|
if (byId.has(addition.id)) {
|
|
765
842
|
skipped.push(skippedItem(addition.id, "present", "An Obligation with this ID already exists, so FileGRC will not replace it."));
|
package/src/program-readiness.js
CHANGED
|
@@ -493,12 +493,17 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
493
493
|
const requirementById = new Map(records
|
|
494
494
|
.filter(({ type }) => type === "requirement")
|
|
495
495
|
.map((record) => [record.id, record]));
|
|
496
|
-
const
|
|
496
|
+
const reviewedRequirements = (workspace?.requirementApplicability || [])
|
|
497
497
|
.filter((decision) => (
|
|
498
498
|
requirementById.has(decision.requirementId)
|
|
499
499
|
&& applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model)
|
|
500
|
-
))
|
|
501
|
-
|
|
500
|
+
));
|
|
501
|
+
const v4Decisions = new Map(reviewedRequirements.map((decision) => [decision.requirementId, decision.decision]));
|
|
502
|
+
const staleRequirementIds = new Set((workspace?.requirementApplicability || [])
|
|
503
|
+
.filter((decision) => requirementById.has(decision.requirementId)
|
|
504
|
+
&& ["applicable", "not-applicable"].includes(decision.decision)
|
|
505
|
+
&& !v4Decisions.has(decision.requirementId))
|
|
506
|
+
.map(({ requirementId }) => requirementId));
|
|
502
507
|
const applicableRequirements = records.filter((record) => (
|
|
503
508
|
record.type === "requirement"
|
|
504
509
|
&& scope.frameworks.some((framework) => framework.id === record.frameworkId)
|
|
@@ -514,6 +519,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
514
519
|
const selectedTrustServicesRequirements = scope.requirements.filter((requirement) => !isDescriptionRequirement(requirement));
|
|
515
520
|
const unresolvedDescriptionRequirements = unresolvedRequirements.filter(isDescriptionRequirement);
|
|
516
521
|
const unresolvedTrustServicesRequirements = unresolvedRequirements.filter((requirement) => !isDescriptionRequirement(requirement));
|
|
522
|
+
const staleRequirements = unresolvedRequirements.filter(({ id }) => staleRequirementIds.has(id));
|
|
523
|
+
const missingDecisionRequirements = unresolvedRequirements.filter(({ id }) => !staleRequirementIds.has(id));
|
|
517
524
|
const uncoveredRequirements = scope.requirements.filter((requirement) => (
|
|
518
525
|
!isDescriptionRequirement(requirement)
|
|
519
526
|
&& !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
|
|
@@ -541,6 +548,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
541
548
|
const invalidMandatoryDecisions = mandatoryRequirements.filter((requirement) => (
|
|
542
549
|
v4Decisions.get(requirement.id) !== "applicable"
|
|
543
550
|
));
|
|
551
|
+
const invalidMandatoryStaleCount = invalidMandatoryDecisions.filter(({ id }) => staleRequirementIds.has(id)).length;
|
|
552
|
+
const invalidMandatoryOtherCount = invalidMandatoryDecisions.length - invalidMandatoryStaleCount;
|
|
544
553
|
const criteriaComplete = Boolean(
|
|
545
554
|
scope.frameworks.length
|
|
546
555
|
&& scope.requirements.length
|
|
@@ -564,11 +573,13 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
564
573
|
...missingRequiredDescriptionReferences
|
|
565
574
|
].join(", ")}.`
|
|
566
575
|
: invalidMandatoryDecisions.length
|
|
567
|
-
? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision
|
|
568
|
-
: `Resolve the program criteria and Controls. ${unresolvedTrustServicesRequirements.length} Trust Services
|
|
576
|
+
? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision needs" : "decisions need"} attention: ${invalidMandatoryStaleCount} prior reviews are stale and ${invalidMandatoryOtherCount} are missing, undetermined, or not applicable.`
|
|
577
|
+
: `Resolve the program criteria and Controls. ${missingDecisionRequirements.length} applicability decisions are missing or undetermined and ${staleRequirements.length} prior reviews are stale. ${unresolvedTrustServicesRequirements.length} Trust Services criteria and ${unresolvedDescriptionRequirements.length} Description Criteria need current decisions, ${missingRequirements.length} applicable criteria are not selected, and ${uncoveredRequirements.length} selected applicable Trust Services criteria have no selected Control. Description Criteria govern the system description and do not map to Controls.`,
|
|
569
578
|
workspace || { type: "workspace" },
|
|
570
579
|
{
|
|
571
580
|
unresolvedRequirementIds: unresolvedRequirements.map(({ id }) => id),
|
|
581
|
+
staleRequirementIds: staleRequirements.map(({ id }) => id),
|
|
582
|
+
missingDecisionRequirementIds: missingDecisionRequirements.map(({ id }) => id),
|
|
572
583
|
missingRequirementIds: missingRequirements.map(({ id }) => id),
|
|
573
584
|
uncoveredRequirementIds: uncoveredRequirements.map(({ id }) => id),
|
|
574
585
|
invalidMandatoryRequirementIds: invalidMandatoryDecisions.map(({ id }) => id),
|
package/src/state.js
CHANGED
|
@@ -17,6 +17,7 @@ import { measureTiming } from "./timing.js";
|
|
|
17
17
|
import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
|
|
18
18
|
import { modelSupports } from "../model/index.js";
|
|
19
19
|
import { calculateRevision } from "./revisions.js";
|
|
20
|
+
import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
|
|
20
21
|
|
|
21
22
|
const renderedMarkdownCache = new Map();
|
|
22
23
|
const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
|
|
@@ -87,6 +88,7 @@ export async function createAppBootstrap(input = process.cwd(), options = {}) {
|
|
|
87
88
|
obligations: { loading: true, items: [], triggers: [], counts: {} },
|
|
88
89
|
collectionReviews: {},
|
|
89
90
|
applicabilityConstraints: {},
|
|
91
|
+
applicabilityReviewStatuses: {},
|
|
90
92
|
programReadiness: null,
|
|
91
93
|
auditPreparations: {},
|
|
92
94
|
workflow: { loading: true, findings: [], workItems: [], assessments: {} },
|
|
@@ -156,7 +158,8 @@ export async function createAppStateSection(input, section, options = {}) {
|
|
|
156
158
|
applicabilityConstraints: Object.fromEntries(loaded.resources.flatMap((record) => {
|
|
157
159
|
const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
|
|
158
160
|
return constraint ? [[record.id, constraint]] : [];
|
|
159
|
-
}))
|
|
161
|
+
})),
|
|
162
|
+
applicabilityReviewStatuses: assessApplicabilityReviewStatuses(loaded, activeProgram)
|
|
160
163
|
};
|
|
161
164
|
}
|
|
162
165
|
if (section === "obligations") {
|
|
@@ -330,6 +333,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
330
333
|
reconciliation,
|
|
331
334
|
collectionReviews,
|
|
332
335
|
applicabilityConstraints,
|
|
336
|
+
applicabilityReviewStatuses: assessApplicabilityReviewStatuses(loaded, activeProgram),
|
|
333
337
|
programReadiness,
|
|
334
338
|
auditPreparations,
|
|
335
339
|
workflow,
|
|
@@ -337,6 +341,21 @@ async function createAppStateUnlocked(input, options) {
|
|
|
337
341
|
};
|
|
338
342
|
}
|
|
339
343
|
|
|
344
|
+
function assessApplicabilityReviewStatuses(loaded, program) {
|
|
345
|
+
const decisions = new Map((program.requirementApplicability || []).map((review) => [review.requirementId, review]));
|
|
346
|
+
return Object.fromEntries(loaded.resources
|
|
347
|
+
.filter(({ type }) => ["requirement", "control", "commitment", "complementary-control"].includes(type))
|
|
348
|
+
.map((record) => {
|
|
349
|
+
const review = record.type === "requirement" && modelSupports(loaded.model, "program-scope")
|
|
350
|
+
? decisions.get(record.id)
|
|
351
|
+
: record.applicabilityReview;
|
|
352
|
+
return [record.id, !review || !["applicable", "not-applicable", "externally-managed", "zero-population"].includes(review.decision)
|
|
353
|
+
? "missing" : applicabilityReviewIsCurrent(
|
|
354
|
+
review, record, program, loaded.resources, loaded.model
|
|
355
|
+
) ? "current" : "stale"];
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
|
|
340
359
|
function serializeCollectionReviewAssessment(assessment) {
|
|
341
360
|
return {
|
|
342
361
|
resourceType: assessment.resourceType,
|
package/src/web.js
CHANGED
|
@@ -247,7 +247,8 @@ function normalizeAppState(next) {
|
|
|
247
247
|
function blockingStateSections(route) {
|
|
248
248
|
if (route.name === "home") return ["program", "obligations", "workflow"];
|
|
249
249
|
if (route.name === "repository") return ["repository"];
|
|
250
|
-
if (route.name === "list" &&
|
|
250
|
+
if (route.name === "list" && ["requirement", "control", "commitment", "complementary-control"].includes(route.type)) return ["program"];
|
|
251
|
+
if (route.name === "detail" && ["program", "requirement", "control", "commitment", "complementary-control"].includes(route.type)) return ["program"];
|
|
251
252
|
if (route.name === "list" && route.type === "obligation") return ["obligations"];
|
|
252
253
|
if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
|
|
253
254
|
if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
|
|
@@ -416,12 +417,6 @@ function activeProgram() {
|
|
|
416
417
|
|| state.workspace;
|
|
417
418
|
}
|
|
418
419
|
|
|
419
|
-
function reviewedRequirementIds() {
|
|
420
|
-
return new Set((activeProgram().requirementApplicability || [])
|
|
421
|
-
.filter(({ decision }) => ["applicable", "not-applicable"].includes(decision))
|
|
422
|
-
.map(({ requirementId }) => requirementId));
|
|
423
|
-
}
|
|
424
|
-
|
|
425
420
|
function renderSidebarUtility(utility, route, direct = false) {
|
|
426
421
|
const directClass = direct ? "nav-direct " : "";
|
|
427
422
|
if (utility === "obligation-board") return "";
|
|
@@ -2778,24 +2773,28 @@ function openNextAuditCycleDialog(prior) {
|
|
|
2778
2773
|
}
|
|
2779
2774
|
|
|
2780
2775
|
function controlApplicabilityPending(record) {
|
|
2776
|
+
if (state.applicabilityReviewStatuses?.[record.id]) {
|
|
2777
|
+
return state.applicabilityReviewStatuses[record.id] !== "current";
|
|
2778
|
+
}
|
|
2781
2779
|
if (!record.applicabilityReview) return true;
|
|
2782
2780
|
const item = state.programReadiness?.stages?.find(({ id }) => id === "controls")?.items
|
|
2783
2781
|
.find(({ resourceType, resourceId }) => resourceType === "control" && resourceId === record.id);
|
|
2784
2782
|
return item?.checks?.applicability === false;
|
|
2785
2783
|
}
|
|
2786
2784
|
|
|
2785
|
+
function applicabilityPending(record) {
|
|
2786
|
+
if (state.applicabilityReviewStatuses?.[record.id]) {
|
|
2787
|
+
return state.applicabilityReviewStatuses[record.id] !== "current";
|
|
2788
|
+
}
|
|
2789
|
+
return record.type === "control" ? controlApplicabilityPending(record)
|
|
2790
|
+
: !record.applicabilityReview || record.type === "requirement" && record.applicability === "undetermined";
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2787
2793
|
function openApplicabilityReviewDialog(type, entries) {
|
|
2788
2794
|
const definition = state.model.resources[type];
|
|
2789
2795
|
const reviewPoints = definition?.guidance?.reviewPoints || [];
|
|
2790
2796
|
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
2791
|
-
const
|
|
2792
|
-
const pending = entries.filter(({ record }) => (
|
|
2793
|
-
type === "requirement" && modelSupports("program-scope")
|
|
2794
|
-
? !reviewedRequirements.has(record.id)
|
|
2795
|
-
: type === "control"
|
|
2796
|
-
? controlApplicabilityPending(record)
|
|
2797
|
-
: !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
|
|
2798
|
-
));
|
|
2797
|
+
const pending = entries.filter(({ record }) => applicabilityPending(record));
|
|
2799
2798
|
const dialog = document.createElement("dialog");
|
|
2800
2799
|
dialog.className = "commit-dialog applicability-dialog";
|
|
2801
2800
|
dialog.setAttribute("aria-labelledby", "applicability-dialog-title");
|
|
@@ -2805,10 +2804,19 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
2805
2804
|
const decisions = constraint?.allowedDecisions || (type === "requirement"
|
|
2806
2805
|
? ["applicable", "not-applicable"]
|
|
2807
2806
|
: ["applicable", "not-applicable", "externally-managed", "zero-population"]);
|
|
2808
|
-
const
|
|
2807
|
+
const prior = type === "requirement" && modelSupports("program-scope")
|
|
2808
|
+
? (activeProgram().requirementApplicability || []).find(({ requirementId }) => requirementId === entry.record.id)
|
|
2809
|
+
: entry.record.applicabilityReview;
|
|
2810
|
+
const selected = constraint?.requiredDecision || (state.applicabilityReviewStatuses?.[entry.record.id] === "stale" ? prior?.decision : "") || "";
|
|
2811
|
+
const rationale = constraint?.requiredDecision && constraint.requiredDecision !== prior?.decision
|
|
2812
|
+
? constraint.defaultRationale || ""
|
|
2813
|
+
: state.applicabilityReviewStatuses?.[entry.record.id] === "stale"
|
|
2814
|
+
? prior?.rationale || constraint?.defaultRationale || ""
|
|
2815
|
+
: constraint?.defaultRationale || "";
|
|
2809
2816
|
const options = decisions.map((decision) => '<option value="' + esc(decision) + '" ' + (decision === selected ? "selected" : "") + '>' + esc(properCase(decision)) + '</option>').join("");
|
|
2810
2817
|
const note = constraint ? '<small class="applicability-constraint">' + esc(constraint.message) + '</small>' : "";
|
|
2811
|
-
|
|
2818
|
+
const stale = state.applicabilityReviewStatuses?.[entry.record.id] === "stale";
|
|
2819
|
+
return '<div class="applicability-row" data-review-id="' + esc(entry.record.id) + '"><div><strong>' + esc(entry.record.title) + '</strong><small>' + esc(entry.record.code || entry.record.id) + (stale ? '<small>Prior review is stale. Confirm this decision against the current scope.</small>' : '') + note + '</div><select name="decision"><option value="">Review later</option>' + options + '</select><input name="rationale" value="' + esc(rationale) + '" placeholder="Decision rationale"></div>';
|
|
2812
2820
|
}).join("");
|
|
2813
2821
|
const reviewChecks = reviewPoints.length
|
|
2814
2822
|
? '<section class="collection-review-checks"><strong>Before deciding</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>'
|
|
@@ -2816,7 +2824,7 @@ function openApplicabilityReviewDialog(type, entries) {
|
|
|
2816
2824
|
const baselineNote = constrainedCount
|
|
2817
2825
|
? '<p class="applicability-baseline-note"><strong>' + constrainedCount + ' required baseline ' + pluralize("criterion", constrainedCount) + '.</strong> FileGRC has prefilled the only decision compatible with the selected SOC 2 Security goal and a review rationale. Leave an item at Review later only when management is not ready to record it.</p>'
|
|
2818
2826
|
: '<p>Record only decisions reviewed against the current service scope. Leave an item at Review later when management has not decided it.</p>';
|
|
2819
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">Review ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' + baselineNote + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
|
|
2827
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">' + (pending.some(({ record }) => state.applicabilityReviewStatuses?.[record.id] === "stale") ? 'Reconfirm' : 'Review') + ' ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div>' + baselineNote + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
|
|
2820
2828
|
document.body.append(dialog);
|
|
2821
2829
|
dialog.showModal();
|
|
2822
2830
|
const form = dialog.querySelector("form");
|
|
@@ -3139,18 +3147,12 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
3139
3147
|
return { name, label: field.label || humanize(name), values };
|
|
3140
3148
|
}).filter(({ values }) => values.length > 1);
|
|
3141
3149
|
const createButton = !state.readOnly && !definition.singleton && !collectionNeedsFirstRecord(type) && resourceCreationAllowed(type) ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
|
|
3142
|
-
const
|
|
3143
|
-
const
|
|
3144
|
-
type === "requirement" && modelSupports("program-scope")
|
|
3145
|
-
? !reviewedRequirements.has(record.id)
|
|
3146
|
-
: type === "control"
|
|
3147
|
-
? controlApplicabilityPending(record)
|
|
3148
|
-
: !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
|
|
3149
|
-
));
|
|
3150
|
+
const hasPendingApplicability = entries.some(({ record }) => applicabilityPending(record));
|
|
3151
|
+
const hasStaleApplicability = entries.some(({ record }) => state.applicabilityReviewStatuses?.[record.id] === "stale");
|
|
3150
3152
|
const applicabilityButton = !state.readOnly
|
|
3151
3153
|
&& ["requirement", "control", "commitment", "complementary-control"].includes(type)
|
|
3152
3154
|
&& hasPendingApplicability
|
|
3153
|
-
? '<button class="button" id="review-applicability">Review applicability</button>'
|
|
3155
|
+
? '<button class="button" id="review-applicability">' + (hasStaleApplicability ? 'Reconfirm applicability' : 'Review applicability') + '</button>'
|
|
3154
3156
|
: "";
|
|
3155
3157
|
const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About ' + esc(definition.pluralTitle) + '" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
|
|
3156
3158
|
const listTools = '<div class="list-tools list-header-tools"><label><span class="sr-only">Filter list</span><input id="list-search" type="search" placeholder="Filter ' + esc(definition.pluralTitle.toLowerCase()) + '"></label>' +
|
|
@@ -3299,6 +3301,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3299
3301
|
&& !fields[name]?.content
|
|
3300
3302
|
&& !narrativeNames.has(name)
|
|
3301
3303
|
));
|
|
3304
|
+
if (type === "program" && !Object.hasOwn(entry.record, "requirementApplicability")) {
|
|
3305
|
+
visible.push(["requirementApplicability", []]);
|
|
3306
|
+
}
|
|
3302
3307
|
const content = Object.entries(entry.content);
|
|
3303
3308
|
const narrativeContent = narrative.length
|
|
3304
3309
|
? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
|
|
@@ -3353,7 +3358,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3353
3358
|
: "";
|
|
3354
3359
|
const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
|
|
3355
3360
|
const recordFieldsPanel = visible.length
|
|
3356
|
-
? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>'
|
|
3361
|
+
? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type, false, entry.record) + '</dd></div>').join("") + '</dl></section>'
|
|
3357
3362
|
: "";
|
|
3358
3363
|
const supportPanels = renderDetailSupport({
|
|
3359
3364
|
hasRecordBody,
|
|
@@ -3362,9 +3367,17 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3362
3367
|
attachmentPanel
|
|
3363
3368
|
});
|
|
3364
3369
|
const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton;
|
|
3370
|
+
const applicabilityStatus = state.applicabilityReviewStatuses?.[entry.record.id];
|
|
3371
|
+
const applicabilityAction = !state.readOnly && ["requirement", "control", "commitment", "complementary-control"].includes(type)
|
|
3372
|
+
&& ["stale", "missing"].includes(applicabilityStatus)
|
|
3373
|
+
? '<a class="button" href="#/resources/' + encodeURIComponent(type) + '?review=1">' + (applicabilityStatus === "stale" ? "Reconfirm" : "Review") + ' applicability</a>'
|
|
3374
|
+
: "";
|
|
3365
3375
|
const recordMenu = '<details class="record-overflow"><summary aria-label="More record actions" title="More record actions"><svg viewBox="0 0 18 4" aria-hidden="true"><circle cx="2" cy="2" r="2"></circle><circle cx="9" cy="2" r="2"></circle><circle cx="16" cy="2" r="2"></circle></svg></summary><div class="record-overflow-menu" aria-label="Record actions"><button type="button" data-record-modal="info">Record info</button><button type="button" data-record-modal="connections">Connections</button><button type="button" data-record-modal="history"' + (entry.historyLoaded === false ? ' disabled' : '') + '>File history' + (entry.historyLoaded === false ? ' (loading)' : '') + '</button>' + (mayDelete ? '<div class="record-overflow-separator"></div><button class="danger-text" type="button" data-delete-resource>Delete record</button>' : '') + '</div></details>';
|
|
3366
|
-
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + (type === "control" ? " detail-control" : "") + '">' + detailMain + supportPanels + '</div></div>';
|
|
3376
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + applicabilityAction + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + (type === "control" ? " detail-control" : "") + '">' + detailMain + supportPanels + '</div></div>';
|
|
3367
3377
|
main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
|
|
3378
|
+
main.querySelector("[data-select-program-for-review]")?.addEventListener("click", () => {
|
|
3379
|
+
selectProgram(entry.record.id, "#/resources/requirement?review=1");
|
|
3380
|
+
});
|
|
3368
3381
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
3369
3382
|
main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => {
|
|
3370
3383
|
button.closest("details")?.removeAttribute("open");
|
|
@@ -5770,17 +5783,21 @@ function openContentEditor(entry, name) {
|
|
|
5770
5783
|
});
|
|
5771
5784
|
}
|
|
5772
5785
|
|
|
5786
|
+
async function selectProgram(programId, destination = null) {
|
|
5787
|
+
const selectionGeneration = ++programSelectionGeneration;
|
|
5788
|
+
const response = await fetch("/api/state/bootstrap?programId=" + encodeURIComponent(programId));
|
|
5789
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5790
|
+
if (selectionGeneration !== programSelectionGeneration) return;
|
|
5791
|
+
state = normalizeAppState(await response.json());
|
|
5792
|
+
stateSectionRequests.clear();
|
|
5793
|
+
if (destination) history.replaceState(null, "", destination);
|
|
5794
|
+
render();
|
|
5795
|
+
loadStateForRoute();
|
|
5796
|
+
}
|
|
5797
|
+
|
|
5773
5798
|
function bindCommon() {
|
|
5774
|
-
root.querySelector("[data-program-select]")?.addEventListener("change",
|
|
5775
|
-
|
|
5776
|
-
const selectionGeneration = ++programSelectionGeneration;
|
|
5777
|
-
const response = await fetch("/api/state/bootstrap?programId=" + encodeURIComponent(programId));
|
|
5778
|
-
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5779
|
-
if (selectionGeneration !== programSelectionGeneration) return;
|
|
5780
|
-
state = normalizeAppState(await response.json());
|
|
5781
|
-
stateSectionRequests.clear();
|
|
5782
|
-
render();
|
|
5783
|
-
loadStateForRoute();
|
|
5799
|
+
root.querySelector("[data-program-select]")?.addEventListener("change", (event) => {
|
|
5800
|
+
selectProgram(event.currentTarget.value);
|
|
5784
5801
|
});
|
|
5785
5802
|
root.querySelectorAll(".nav-toggle, .nav-subgroup-toggle").forEach((button) => button.addEventListener("click", () => {
|
|
5786
5803
|
const group = button.closest(".nav-group");
|
|
@@ -6070,14 +6087,22 @@ function conditionMatchesValues(condition, valueFor) {
|
|
|
6070
6087
|
function conditionValueMatches(actual, expected) {
|
|
6071
6088
|
return Array.isArray(expected) ? expected.includes(actual) : actual === expected;
|
|
6072
6089
|
}
|
|
6073
|
-
function formatValue(value, field, type, compact = false) {
|
|
6090
|
+
function formatValue(value, field, type, compact = false, record = null) {
|
|
6074
6091
|
if (value === undefined || value === null || value === "") return '<span class="muted">Not set</span>';
|
|
6075
6092
|
const definition = fieldDefinition(type, field);
|
|
6076
6093
|
if (type === "program" && field === "requirementApplicability" && Array.isArray(value)) {
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6094
|
+
if (record && record.id !== state.selectedProgramId) {
|
|
6095
|
+
return '<button class="button" type="button" data-select-program-for-review="' + esc(record.id) + '">Select this Program to review applicability</button>';
|
|
6096
|
+
}
|
|
6097
|
+
const frameworkIds = new Set(record?.frameworkIds || activeProgram().frameworkIds || []);
|
|
6098
|
+
const requirementIds = state.resources.filter(({ record: candidate }) => (
|
|
6099
|
+
candidate.type === "requirement" && frameworkIds.has(candidate.frameworkId)
|
|
6100
|
+
)).map(({ record: candidate }) => candidate.id);
|
|
6101
|
+
const reviewed = requirementIds.filter((id) => state.applicabilityReviewStatuses?.[id] === "current").length;
|
|
6102
|
+
const stale = requirementIds.filter((id) => state.applicabilityReviewStatuses?.[id] === "stale").length;
|
|
6103
|
+
const pending = requirementIds.length - reviewed - stale;
|
|
6104
|
+
const label = reviewed + " reviewed" + (stale ? " · " + stale + " stale" : "") + (pending ? " · " + pending + " to review" : "");
|
|
6105
|
+
return '<a class="tag relation" href="#/resources/requirement' + (stale || pending ? "?review=1" : "") + '">' + esc(label) + '</a>';
|
|
6081
6106
|
}
|
|
6082
6107
|
if (definition?.type === "date") return esc(formatCalendarDate(value));
|
|
6083
6108
|
if (definition?.type === "timestamp") return esc(formatLocalDateTime(value));
|