filegrc 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -6
- package/model/index.js +41 -3
- package/model/v1.json +81 -47
- package/model/v2.json +8022 -0
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +89 -8
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +109 -65
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +563 -148
- package/src/collection-review.js +185 -0
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +149 -77
- package/src/external-reviewer.js +165 -0
- package/src/files.js +267 -29
- package/src/git.js +239 -41
- package/src/index.js +41 -7
- package/src/model-docs.js +103 -7
- package/src/model-migration.js +1958 -0
- package/src/mutation.js +42 -0
- package/src/obligations.js +502 -95
- package/src/parties.js +17 -2
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +70 -60
- package/src/program-readiness.js +470 -130
- package/src/reconciliation.js +277 -0
- package/src/resource-status.js +17 -0
- package/src/server.js +347 -48
- package/src/setup.js +57 -26
- package/src/source-coverage.js +61 -0
- package/src/state.js +122 -25
- package/src/timing.js +41 -0
- package/src/validate.js +707 -44
- package/src/web.js +1440 -304
- package/src/workflow.js +1595 -0
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/workflow.js
ADDED
|
@@ -0,0 +1,1595 @@
|
|
|
1
|
+
import { cp, mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assessRequiredAppointments } from "./appointments.js";
|
|
5
|
+
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
6
|
+
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
7
|
+
import {
|
|
8
|
+
applyResourceBatch,
|
|
9
|
+
createResource,
|
|
10
|
+
deleteResource,
|
|
11
|
+
updateResource
|
|
12
|
+
} from "./files.js";
|
|
13
|
+
import { getGitSummary, getWorkspaceHistories } from "./git.js";
|
|
14
|
+
import {
|
|
15
|
+
completeObligationAction,
|
|
16
|
+
completeObligationEvent,
|
|
17
|
+
completeObligationOccurrence,
|
|
18
|
+
createObligationEvent,
|
|
19
|
+
planObligations
|
|
20
|
+
} from "./obligations.js";
|
|
21
|
+
import { assessAuditPreparation } from "./audit-preparation.js";
|
|
22
|
+
import { assessProgramReadiness } from "./program-readiness.js";
|
|
23
|
+
import { planReconciliation } from "./reconciliation.js";
|
|
24
|
+
import { currentCalendarDate } from "./time.js";
|
|
25
|
+
import { validateWorkspace } from "./validate.js";
|
|
26
|
+
import { loadWorkspace } from "./workspace.js";
|
|
27
|
+
|
|
28
|
+
export const WORKFLOW_CONTRACT_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
const TERMINAL_STATUSES = new Set([
|
|
31
|
+
"accepted",
|
|
32
|
+
"approved",
|
|
33
|
+
"closed",
|
|
34
|
+
"complete",
|
|
35
|
+
"completed",
|
|
36
|
+
"done",
|
|
37
|
+
"expired",
|
|
38
|
+
"inactive",
|
|
39
|
+
"not-applicable",
|
|
40
|
+
"reconciled",
|
|
41
|
+
"remediated",
|
|
42
|
+
"resolved",
|
|
43
|
+
"retired",
|
|
44
|
+
"superseded",
|
|
45
|
+
"terminated",
|
|
46
|
+
"verified"
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const DATE_FIELDS = [
|
|
50
|
+
"dueOn",
|
|
51
|
+
"expiresOn",
|
|
52
|
+
"targetRemediationOn",
|
|
53
|
+
"treatmentTargetOn",
|
|
54
|
+
"reviewDueOn",
|
|
55
|
+
"scheduledFor",
|
|
56
|
+
"validThrough",
|
|
57
|
+
"fieldworkStart",
|
|
58
|
+
"fieldworkEnd",
|
|
59
|
+
"reportDate",
|
|
60
|
+
"endsOn",
|
|
61
|
+
"endDate",
|
|
62
|
+
"proposedEffectiveOn"
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const OWNER_FIELDS = [
|
|
66
|
+
"assigneeIds",
|
|
67
|
+
"ownerIds",
|
|
68
|
+
"responsibleIds",
|
|
69
|
+
"reviewerIds"
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return the shared, interface-neutral workflow assessment for a workspace.
|
|
74
|
+
*
|
|
75
|
+
* Focused readiness functions use the same source facts, while this envelope is
|
|
76
|
+
* the complete contract for cross-interface workflow guidance.
|
|
77
|
+
*/
|
|
78
|
+
export async function assessWorkflow(input, options = {}) {
|
|
79
|
+
const loaded = input?.resources && input?.model && input?.entries
|
|
80
|
+
? input
|
|
81
|
+
: await loadWorkspace(input);
|
|
82
|
+
const workspace = loaded.workspace
|
|
83
|
+
|| loaded.resources.find((record) => record.type === "workspace");
|
|
84
|
+
const timezone = options.timezone || workspace?.timezone || "UTC";
|
|
85
|
+
const asOf = options.asOf || currentCalendarDate(timezone);
|
|
86
|
+
const evaluatedAt = options.evaluatedAt || options.now || new Date().toISOString();
|
|
87
|
+
const program = options.programReadiness || await assessProgramReadiness(loaded, {
|
|
88
|
+
asOf,
|
|
89
|
+
generatedAt: evaluatedAt
|
|
90
|
+
});
|
|
91
|
+
const audits = selectedAudits(loaded.resources, options.auditId);
|
|
92
|
+
const auditPreparations = options.auditPreparations || Object.fromEntries(await Promise.all(
|
|
93
|
+
audits.map(async (audit) => [
|
|
94
|
+
audit.id,
|
|
95
|
+
await assessAuditPreparation(loaded, {
|
|
96
|
+
auditId: audit.id,
|
|
97
|
+
generatedAt: evaluatedAt,
|
|
98
|
+
programReadiness: program
|
|
99
|
+
})
|
|
100
|
+
])
|
|
101
|
+
));
|
|
102
|
+
const obligationPlan = options.obligations || planObligations(loaded.resources, {
|
|
103
|
+
asOf,
|
|
104
|
+
through: options.through || asOf,
|
|
105
|
+
now: evaluatedAt,
|
|
106
|
+
includeComplete: Boolean(options.includeComplete),
|
|
107
|
+
model: loaded.model
|
|
108
|
+
});
|
|
109
|
+
const validation = options.validation || await validateWorkspace(loaded);
|
|
110
|
+
const reconciliation = options.reconciliation || await planReconciliation(loaded.root);
|
|
111
|
+
const coverage = normalizeCoverage(
|
|
112
|
+
options.coverage
|
|
113
|
+
|| (options.auditId ? audits[0]?.coverage : null)
|
|
114
|
+
|| workspace?.candidateCoverage
|
|
115
|
+
);
|
|
116
|
+
const periodFindings = await assessPeriodHealth(loaded, {
|
|
117
|
+
coverage,
|
|
118
|
+
asOf,
|
|
119
|
+
evaluatedAt,
|
|
120
|
+
controlIds: options.auditId ? audits[0]?.controlIds : workspace?.controlIds
|
|
121
|
+
});
|
|
122
|
+
const guidedFindings = [
|
|
123
|
+
...programFindings(program),
|
|
124
|
+
...Object.values(auditPreparations).flatMap(auditFindings),
|
|
125
|
+
...appointmentTemplateFindings(loaded),
|
|
126
|
+
...sourceCoverageFindings(loaded),
|
|
127
|
+
...reconciliationFindings(reconciliation),
|
|
128
|
+
...periodFindings,
|
|
129
|
+
...auditLifecycleFindings(loaded, audits)
|
|
130
|
+
];
|
|
131
|
+
const findings = [
|
|
132
|
+
...validationFindings(validation, loaded),
|
|
133
|
+
...removeRedundantRecordFindings(recordFinalizationFindings(loaded), guidedFindings),
|
|
134
|
+
...guidedFindings
|
|
135
|
+
];
|
|
136
|
+
const workItems = buildWorkItems(loaded.resources, obligationPlan, {
|
|
137
|
+
asOf,
|
|
138
|
+
includeComplete: Boolean(options.includeComplete)
|
|
139
|
+
});
|
|
140
|
+
const git = options.git || safeGitSummary(loaded.root);
|
|
141
|
+
const assessments = buildAssessments({
|
|
142
|
+
program,
|
|
143
|
+
audits,
|
|
144
|
+
auditPreparations,
|
|
145
|
+
obligationPlan,
|
|
146
|
+
findings,
|
|
147
|
+
validation,
|
|
148
|
+
coverage
|
|
149
|
+
});
|
|
150
|
+
const recommended = recommendedAction(findings, workItems, program);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
contractVersion: WORKFLOW_CONTRACT_VERSION,
|
|
154
|
+
dataModelVersion: loaded.model.modelVersion,
|
|
155
|
+
evaluatedAt,
|
|
156
|
+
input: {
|
|
157
|
+
asOf,
|
|
158
|
+
timezone,
|
|
159
|
+
coverage,
|
|
160
|
+
auditId: options.auditId || null,
|
|
161
|
+
gitRevision: git.commit || null
|
|
162
|
+
},
|
|
163
|
+
assessments,
|
|
164
|
+
findings: findings.sort(compareFindings),
|
|
165
|
+
workItems: workItems.sort(compareWorkItems),
|
|
166
|
+
recommended: recommended
|
|
167
|
+
? { ...recommended, rankingReason: rankingReason(recommended) }
|
|
168
|
+
: null,
|
|
169
|
+
counts: {
|
|
170
|
+
findings: countBy(findings, "state"),
|
|
171
|
+
workItems: countBy(workItems, "state")
|
|
172
|
+
},
|
|
173
|
+
reconciliation
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function buildWorkflowDelta(before, after) {
|
|
178
|
+
const beforeFindings = keyed(before?.findings);
|
|
179
|
+
const afterFindings = keyed(after?.findings);
|
|
180
|
+
const beforeWork = keyed(before?.workItems);
|
|
181
|
+
const afterWork = keyed(after?.workItems);
|
|
182
|
+
return {
|
|
183
|
+
contractVersion: WORKFLOW_CONTRACT_VERSION,
|
|
184
|
+
assessments: changedAssessments(before?.assessments || {}, after?.assessments || {}),
|
|
185
|
+
findings: changedItems(beforeFindings, afterFindings),
|
|
186
|
+
workItems: changedItems(beforeWork, afterWork),
|
|
187
|
+
recommendedBefore: before?.recommended?.key || null,
|
|
188
|
+
recommendedAfter: after?.recommended?.key || null
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function workflowForResource(workflow, type, id) {
|
|
193
|
+
if (!workflow) return { findings: [], workItems: [], recommended: null };
|
|
194
|
+
const matches = (item) => item.subject?.type === type && item.subject?.id === id;
|
|
195
|
+
const findings = workflow.findings.filter(matches);
|
|
196
|
+
const workItems = workflow.workItems.filter((item) => (
|
|
197
|
+
matches(item)
|
|
198
|
+
|| item.source?.type === type && item.source?.id === id
|
|
199
|
+
));
|
|
200
|
+
return {
|
|
201
|
+
contractVersion: workflow.contractVersion,
|
|
202
|
+
assessments: workflow.assessments,
|
|
203
|
+
findings,
|
|
204
|
+
workItems,
|
|
205
|
+
recommended: [...findings, ...workItems].sort(compareRecommended)[0] || null
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function previewWorkflowMutation(input, mutation) {
|
|
210
|
+
const loaded = await loadWorkspace(input);
|
|
211
|
+
const previewRoot = await mkdtemp(join(tmpdir(), "filegrc-workflow-preview-"));
|
|
212
|
+
try {
|
|
213
|
+
await cp(join(loaded.root, "data"), join(previewRoot, "data"), {
|
|
214
|
+
recursive: true,
|
|
215
|
+
errorOnExist: true,
|
|
216
|
+
force: false
|
|
217
|
+
});
|
|
218
|
+
const before = await assessWorkflow(loaded.root, mutation?.assessment || {});
|
|
219
|
+
const record = mutation?.record;
|
|
220
|
+
const existing = record?.id
|
|
221
|
+
? loaded.resources.find((item) => item.id === record.id && item.type === record.type)
|
|
222
|
+
: null;
|
|
223
|
+
const operation = mutation?.operation || (existing ? "update" : "create");
|
|
224
|
+
let result;
|
|
225
|
+
if (operation === "create") {
|
|
226
|
+
if (!record) throw new Error("A record is required for a create preview.");
|
|
227
|
+
result = await createResource(previewRoot, record, { content: mutation.content });
|
|
228
|
+
} else if (operation === "update") {
|
|
229
|
+
if (!record) throw new Error("A record is required for an update preview.");
|
|
230
|
+
result = await updateResource(previewRoot, mutation.type || record.type, mutation.id || record.id, record, {
|
|
231
|
+
content: mutation.content
|
|
232
|
+
});
|
|
233
|
+
} else if (operation === "delete") {
|
|
234
|
+
if (!mutation.type || !mutation.id) throw new Error("A type and ID are required for a delete preview.");
|
|
235
|
+
result = await deleteResource(previewRoot, mutation.type, mutation.id);
|
|
236
|
+
} else if (operation === "batch") {
|
|
237
|
+
result = await applyResourceBatch(previewRoot, mutation.changes);
|
|
238
|
+
} else if (operation === "complete-obligation") {
|
|
239
|
+
result = await completeObligationOccurrence(previewRoot, mutation);
|
|
240
|
+
} else if (operation === "complete-action") {
|
|
241
|
+
result = await completeObligationAction(previewRoot, mutation);
|
|
242
|
+
} else if (operation === "complete-event") {
|
|
243
|
+
result = await completeObligationEvent(previewRoot, mutation);
|
|
244
|
+
} else if (operation === "trigger-event") {
|
|
245
|
+
result = await createObligationEvent(previewRoot, mutation);
|
|
246
|
+
} else {
|
|
247
|
+
throw new Error(`Unsupported preview operation "${operation}".`);
|
|
248
|
+
}
|
|
249
|
+
const after = await assessWorkflow(previewRoot, mutation?.assessment || {});
|
|
250
|
+
return {
|
|
251
|
+
operation,
|
|
252
|
+
target: mutationTarget(operation, mutation, record),
|
|
253
|
+
result,
|
|
254
|
+
workflowDelta: buildWorkflowDelta(before, after),
|
|
255
|
+
workflow: after
|
|
256
|
+
};
|
|
257
|
+
} finally {
|
|
258
|
+
await rm(previewRoot, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function selectedAudits(records, auditId) {
|
|
263
|
+
const audits = records.filter((record) => record.type === "audit");
|
|
264
|
+
if (!auditId) return audits;
|
|
265
|
+
return audits.filter((audit) => audit.id === auditId);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function mutationTarget(operation, mutation, record) {
|
|
269
|
+
if (operation === "delete") return { type: mutation.type, id: mutation.id };
|
|
270
|
+
if (["create", "update"].includes(operation)) return { type: record.type, id: record.id };
|
|
271
|
+
if (operation === "complete-obligation") return { type: "obligation", id: mutation.obligationId };
|
|
272
|
+
if (operation === "complete-action") return { type: "action-item", id: mutation.actionItemId };
|
|
273
|
+
if (operation === "complete-event") return { type: "obligation-event", id: mutation.eventId };
|
|
274
|
+
if (operation === "trigger-event") return { type: "policy-event", id: mutation.eventType };
|
|
275
|
+
return { type: "workspace", id: "batch" };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function programFindings(program) {
|
|
279
|
+
return program.stages.flatMap((stage) => stage.items
|
|
280
|
+
.filter(({ id }) => !["required-appointments", "independent-reviewer"].includes(id))
|
|
281
|
+
.map((item) => normalizeFinding(
|
|
282
|
+
`program.${stage.id}.${item.id}`,
|
|
283
|
+
item,
|
|
284
|
+
{
|
|
285
|
+
assessment: stage.id === "operation" ? "period-health" : "program-configuration",
|
|
286
|
+
stage: stage.id
|
|
287
|
+
}
|
|
288
|
+
)));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function auditFindings(preparation) {
|
|
292
|
+
return preparation.stages.flatMap((stage) => stage.items.map((item) => normalizeFinding(
|
|
293
|
+
`audit.${preparation.audit?.id || "unscoped"}.${stage.id}.${item.id}`,
|
|
294
|
+
item,
|
|
295
|
+
{
|
|
296
|
+
assessment: stage.id === "auditor" ? "audit-closure" : "audit-readiness",
|
|
297
|
+
stage: stage.id,
|
|
298
|
+
auditId: preparation.audit?.id || null
|
|
299
|
+
}
|
|
300
|
+
)));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function validationFindings(validation, loaded) {
|
|
304
|
+
const entriesByPath = new Map(loaded.entries.map((entry) => [
|
|
305
|
+
`data/${entry.relativePath}`,
|
|
306
|
+
entry.record
|
|
307
|
+
]));
|
|
308
|
+
return (validation?.diagnostics || []).map((diagnostic) => {
|
|
309
|
+
const record = entriesByPath.get(diagnostic.path);
|
|
310
|
+
const state = "ready";
|
|
311
|
+
const code = `structural.${diagnostic.code}.${record?.id || pathKey(diagnostic.path)}.${stableSuffix(diagnostic.message)}`;
|
|
312
|
+
return {
|
|
313
|
+
key: code,
|
|
314
|
+
code,
|
|
315
|
+
assessment: "structural-validity",
|
|
316
|
+
stage: "structure",
|
|
317
|
+
state,
|
|
318
|
+
severity: diagnostic.severity,
|
|
319
|
+
requiredness: "required",
|
|
320
|
+
title: record?.title || "Workspace structure",
|
|
321
|
+
message: diagnostic.message,
|
|
322
|
+
fieldPath: diagnostic.path,
|
|
323
|
+
...(record ? { subject: { type: record.type, id: record.id } } : {}),
|
|
324
|
+
dependencies: [],
|
|
325
|
+
actions: record
|
|
326
|
+
? [mutationAction(record)]
|
|
327
|
+
: [{ kind: "command", command: "npx filegrc validate --json" }]
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function recordFinalizationFindings(loaded) {
|
|
333
|
+
const findings = [];
|
|
334
|
+
for (const record of loaded.resources) {
|
|
335
|
+
if (["workspace", "renderer-settings"].includes(record.type)) continue;
|
|
336
|
+
const incomplete = recordIncompleteReason(record, loaded);
|
|
337
|
+
if (incomplete) {
|
|
338
|
+
findings.push(finalizationFinding(record, incomplete));
|
|
339
|
+
}
|
|
340
|
+
for (const missing of finalizationFields(record, loaded.model)) {
|
|
341
|
+
findings.push(fieldFinding(record, missing));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return findings;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function removeRedundantRecordFindings(recordFindings, guidedFindings) {
|
|
348
|
+
const guidedSubjects = new Set(guidedFindings
|
|
349
|
+
.filter(({ subject }) => subject?.type && subject?.id)
|
|
350
|
+
.map(({ subject }) => subjectKey(subject)));
|
|
351
|
+
const guidedApplicabilitySubjects = new Set(guidedFindings
|
|
352
|
+
.filter(({ code, subject }) => (
|
|
353
|
+
subject?.type
|
|
354
|
+
&& subject?.id
|
|
355
|
+
&& (
|
|
356
|
+
(subject.type === "control" && code.startsWith("program.controls.control-"))
|
|
357
|
+
|| (subject.type === "commitment" && code === "program.scope.commitments")
|
|
358
|
+
)
|
|
359
|
+
))
|
|
360
|
+
.map(({ subject }) => subjectKey(subject)));
|
|
361
|
+
const guidedSubjectIds = new Set(guidedFindings
|
|
362
|
+
.filter(({ subject }) => subject?.id)
|
|
363
|
+
.map(({ subject }) => subject.id));
|
|
364
|
+
const applicabilityFieldSubjects = new Set(recordFindings
|
|
365
|
+
.filter(({ fieldPath, subject }) => (
|
|
366
|
+
fieldPath === "applicabilityReview"
|
|
367
|
+
&& subject?.type
|
|
368
|
+
&& subject?.id
|
|
369
|
+
))
|
|
370
|
+
.map(({ subject }) => subjectKey(subject)));
|
|
371
|
+
|
|
372
|
+
return recordFindings.filter((finding) => {
|
|
373
|
+
const key = finding.subject ? subjectKey(finding.subject) : null;
|
|
374
|
+
if (!key) return true;
|
|
375
|
+
if (finding.code.endsWith(".finalize")) {
|
|
376
|
+
if (finding.subject?.type === "collection-review" && guidedSubjectIds.has(finding.subject.id)) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
return !guidedSubjects.has(key) && !applicabilityFieldSubjects.has(key);
|
|
380
|
+
}
|
|
381
|
+
if (finding.fieldPath === "applicabilityReview") {
|
|
382
|
+
return !guidedApplicabilitySubjects.has(key);
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function subjectKey(subject) {
|
|
389
|
+
return `${subject.type}:${subject.id}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function recordIncompleteReason(record, loaded) {
|
|
393
|
+
if (record.type === "requirement" && record.applicability === "undetermined") {
|
|
394
|
+
return {
|
|
395
|
+
state: "ready",
|
|
396
|
+
requiredness: "required",
|
|
397
|
+
message: "Review this criterion against the current service scope and record the applicability decision."
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (record.type === "appointment" && record.status === "planned") {
|
|
401
|
+
const assessed = assessRequiredAppointments(loaded.resources, loaded.model)
|
|
402
|
+
.find(({ kind }) => kind === record.appointmentKind);
|
|
403
|
+
if (assessed) return null;
|
|
404
|
+
return {
|
|
405
|
+
state: "ready",
|
|
406
|
+
requiredness: appointmentRequiredness(record, loaded.model),
|
|
407
|
+
message: "Assign a holder, confirm the authority scope and independence needs, then activate this Appointment on its real start date."
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const messages = {
|
|
411
|
+
draft: "Complete the record, its relationships, and required Markdown before moving it to review.",
|
|
412
|
+
planned: "Review this planned record against the actual program and complete its finalization checks.",
|
|
413
|
+
proposed: "Review the proposed work, owner, schedule, and completion profile before activating it.",
|
|
414
|
+
"in-review": "Complete independent review and bind the approval to the exact content revision.",
|
|
415
|
+
open: "Complete or formally dispose of this open work with the required proof.",
|
|
416
|
+
"in-progress": "Finish the work, record its result, and link its completion proof.",
|
|
417
|
+
blocked: "Resolve the recorded blockers before completing this work.",
|
|
418
|
+
"partially-implemented": "Finish the remaining control design, operation, source, and scheduling work."
|
|
419
|
+
};
|
|
420
|
+
if (!messages[record.status]) return null;
|
|
421
|
+
const requiredness = recordFinalizationRequiredness(record, loaded);
|
|
422
|
+
if (record.status === "blocked") {
|
|
423
|
+
const byId = new Map(loaded.resources.map((resource) => [resource.id, resource]));
|
|
424
|
+
return {
|
|
425
|
+
state: "blocked",
|
|
426
|
+
requiredness,
|
|
427
|
+
message: actionBlockingReason(record, byId),
|
|
428
|
+
dependencies: blockingDependencies(record.blockingResourceIds, byId)
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
state: requiredness === "conditional" ? "scheduled" : "ready",
|
|
433
|
+
requiredness,
|
|
434
|
+
message: requiredness === "conditional"
|
|
435
|
+
? "Keep this starter in draft until its recorded audience, program role, or audit stage applies. Finalize or retire it when that decision is made."
|
|
436
|
+
: messages[record.status]
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function recordFinalizationRequiredness(record, loaded) {
|
|
441
|
+
if (!["policy", "document", "training"].includes(record.type)) return "required";
|
|
442
|
+
const selectedControlIds = new Set(
|
|
443
|
+
loaded.workspace?.controlIds?.length
|
|
444
|
+
? loaded.workspace.controlIds
|
|
445
|
+
: loaded.resources
|
|
446
|
+
.filter((resource) => resource.type === "control" && !["not-applicable", "retired"].includes(resource.status))
|
|
447
|
+
.map(({ id }) => id)
|
|
448
|
+
);
|
|
449
|
+
const requiredByRunningObligation = loaded.resources.some((resource) => (
|
|
450
|
+
resource.type === "obligation"
|
|
451
|
+
&& resource.status === "active"
|
|
452
|
+
&& (
|
|
453
|
+
resource.templateResourceId === record.id
|
|
454
|
+
|| (resource.scopeResourceIds || []).includes(record.id)
|
|
455
|
+
)
|
|
456
|
+
));
|
|
457
|
+
if (requiredByRunningObligation) return "required";
|
|
458
|
+
if (
|
|
459
|
+
["policy", "document"].includes(record.type)
|
|
460
|
+
&& record.programRole === "required"
|
|
461
|
+
&& (record.controlIds || []).some((id) => selectedControlIds.has(id))
|
|
462
|
+
) return "required";
|
|
463
|
+
if (
|
|
464
|
+
record.type === "policy"
|
|
465
|
+
&& loaded.resources.some((control) => (
|
|
466
|
+
control.type === "control"
|
|
467
|
+
&& selectedControlIds.has(control.id)
|
|
468
|
+
&& (control.policyIds || []).includes(record.id)
|
|
469
|
+
))
|
|
470
|
+
) return "required";
|
|
471
|
+
return "conditional";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function finalizationFields(record, model) {
|
|
475
|
+
const fields = [];
|
|
476
|
+
const needsReview = ["requirement", "commitment", "complementary-control", "control"].includes(record.type)
|
|
477
|
+
&& model.resources[record.type]?.fields?.applicabilityReview
|
|
478
|
+
&& !record.applicabilityReview;
|
|
479
|
+
if (needsReview) {
|
|
480
|
+
fields.push({
|
|
481
|
+
field: "applicabilityReview",
|
|
482
|
+
requiredness: "required",
|
|
483
|
+
message: "Record the reviewed applicability decision, rationale, reviewer, and date. FileGRC records the current scope automatically."
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {
|
|
487
|
+
fields.push({
|
|
488
|
+
field: "programRole",
|
|
489
|
+
requiredness: "required",
|
|
490
|
+
message: "Classify this starter as required, conditional, alternative, or supporting for this program."
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (record.type === "policy" && ["draft", "in-review"].includes(record.status) && record.effectiveOn) {
|
|
494
|
+
fields.push({
|
|
495
|
+
field: "effectiveOn",
|
|
496
|
+
requiredness: "required",
|
|
497
|
+
message: "A draft cannot assert a factual effective date. Move the date to proposedEffectiveOn or activate the approved revision."
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const requested = {
|
|
501
|
+
risk: [
|
|
502
|
+
["treatmentTargetOn", ["open", "monitoring"].includes(record.status)],
|
|
503
|
+
["reviewDueOn", ["open", "monitoring"].includes(record.status)]
|
|
504
|
+
],
|
|
505
|
+
vulnerability: [
|
|
506
|
+
["confirmedOn", record.status !== "false-positive"],
|
|
507
|
+
["severityAssignedOn", record.severity !== "unknown"],
|
|
508
|
+
["targetRemediationOn", ["open", "in-progress"].includes(record.status)]
|
|
509
|
+
],
|
|
510
|
+
"access-grant": [["businessNeed", !["revoked", "expired"].includes(record.status)]],
|
|
511
|
+
"service-account": [
|
|
512
|
+
["authenticationMethod", record.status === "active"],
|
|
513
|
+
["reviewDueOn", record.status === "active"],
|
|
514
|
+
["nonExpiringRationale", record.status === "active" && !record.expiresOn]
|
|
515
|
+
],
|
|
516
|
+
training: [
|
|
517
|
+
["effectiveContentRevisions", record.status === "active"],
|
|
518
|
+
["effectiveOn", record.status === "active"]
|
|
519
|
+
],
|
|
520
|
+
control: [
|
|
521
|
+
["procedureRevision", record.status === "implemented"],
|
|
522
|
+
["procedureEffectiveOn", record.status === "implemented"],
|
|
523
|
+
["implementationReviewedByIds", record.status === "implemented"],
|
|
524
|
+
["implementationReviewedOn", record.status === "implemented"]
|
|
525
|
+
]
|
|
526
|
+
}[record.type] || [];
|
|
527
|
+
for (const [field, applies] of requested) {
|
|
528
|
+
if (!model.resources[record.type]?.fields?.[field] || !applies || present(record[field])) continue;
|
|
529
|
+
fields.push({
|
|
530
|
+
field,
|
|
531
|
+
requiredness: "conditional",
|
|
532
|
+
message: `Complete ${fieldLabel(field)} before treating this ${fieldLabel(record.type).toLowerCase()} as finalized.`
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
return fields;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function finalizationFinding(record, details) {
|
|
539
|
+
const code = `record.${record.type}.${record.id}.finalize`;
|
|
540
|
+
return {
|
|
541
|
+
key: code,
|
|
542
|
+
code,
|
|
543
|
+
assessment: recordAssessment(record.type),
|
|
544
|
+
stage: recordStage(record.type),
|
|
545
|
+
state: details.state,
|
|
546
|
+
severity: details.state === "blocked" ? "error" : "warning",
|
|
547
|
+
requiredness: details.requiredness,
|
|
548
|
+
title: `Finalize ${record.title}`,
|
|
549
|
+
message: details.message,
|
|
550
|
+
subject: { type: record.type, id: record.id },
|
|
551
|
+
dependencies: details.dependencies || [],
|
|
552
|
+
actions: [mutationAction(record)]
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function fieldFinding(record, missing) {
|
|
557
|
+
const code = `record.${record.type}.${record.id}.field.${missing.field}`;
|
|
558
|
+
return {
|
|
559
|
+
key: code,
|
|
560
|
+
code,
|
|
561
|
+
assessment: recordAssessment(record.type),
|
|
562
|
+
stage: recordStage(record.type),
|
|
563
|
+
state: "ready",
|
|
564
|
+
severity: "warning",
|
|
565
|
+
requiredness: missing.requiredness,
|
|
566
|
+
title: `${record.title}: ${fieldLabel(missing.field)}`,
|
|
567
|
+
message: missing.message,
|
|
568
|
+
subject: { type: record.type, id: record.id },
|
|
569
|
+
fieldPath: missing.field,
|
|
570
|
+
dependencies: [],
|
|
571
|
+
actions: [mutationAction(record)]
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function appointmentTemplateFindings(loaded) {
|
|
576
|
+
return assessRequiredAppointments(loaded.resources, loaded.model)
|
|
577
|
+
.map(({ kind, template, record, state, requiredness }) => (
|
|
578
|
+
appointmentFinding(kind, template, record, state, requiredness)
|
|
579
|
+
));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function appointmentFinding(kind, template, record, state, requiredness) {
|
|
583
|
+
const code = `governance.appointment.${kind}`;
|
|
584
|
+
const messages = {
|
|
585
|
+
complete: `${template.title} is assigned through an active dated Appointment.`,
|
|
586
|
+
ready: record
|
|
587
|
+
? `Assign and activate the planned ${template.title} Appointment.`
|
|
588
|
+
: `Create, assign, and activate the required ${template.title} Appointment.`,
|
|
589
|
+
"not-applicable": `${template.title} is conditional and no current scope fact requires it.`
|
|
590
|
+
};
|
|
591
|
+
return {
|
|
592
|
+
key: code,
|
|
593
|
+
code,
|
|
594
|
+
assessment: "program-configuration",
|
|
595
|
+
stage: kind === "independent-policy-reviewer" ? "policies" : "scope",
|
|
596
|
+
state,
|
|
597
|
+
severity: state === "ready" ? "warning" : "info",
|
|
598
|
+
requiredness,
|
|
599
|
+
title: template.title,
|
|
600
|
+
message: messages[state],
|
|
601
|
+
subject: { type: "appointment", ...(record ? { id: record.id } : {}) },
|
|
602
|
+
dependencies: [],
|
|
603
|
+
actions: record
|
|
604
|
+
? [mutationAction(record)]
|
|
605
|
+
: [{ kind: "command", command: `npx filegrc scaffold appointment --title ${shellArgument(template.title)}` }]
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function sourceCoverageFindings(loaded) {
|
|
610
|
+
const selectedControlIds = loaded.resources
|
|
611
|
+
.filter((record) => record.type === "control" && record.status !== "not-applicable" && record.status !== "retired")
|
|
612
|
+
.map(({ id }) => id);
|
|
613
|
+
return assessSourceCoverageReadiness(loaded, selectedControlIds)
|
|
614
|
+
.map(({ family, record, complete }) => {
|
|
615
|
+
const state = complete ? "complete" : "ready";
|
|
616
|
+
const code = `evidence-source.${family.id}.coverage`;
|
|
617
|
+
return {
|
|
618
|
+
key: code,
|
|
619
|
+
code,
|
|
620
|
+
assessment: "program-configuration",
|
|
621
|
+
stage: "controls",
|
|
622
|
+
state,
|
|
623
|
+
severity: state === "complete" ? "info" : "warning",
|
|
624
|
+
requiredness: "required",
|
|
625
|
+
title: `${family.title} source coverage`,
|
|
626
|
+
message: complete
|
|
627
|
+
? "The source family has a reviewed authoritative path, valid coverage dates, retrieval ownership, and reconciliation method."
|
|
628
|
+
: record
|
|
629
|
+
? record.status === "active"
|
|
630
|
+
? "Complete any missing source details and link a passed retrieval test after a candidate period is set."
|
|
631
|
+
: "Finish the planned source-family decision, retrieval method, retention, validity dates, and pre-period dry run when a candidate period is set."
|
|
632
|
+
: "Create a source-family coverage record and choose FileGRC, an external authoritative System, reviewed zero population, or reviewed not applicable.",
|
|
633
|
+
subject: { type: "source-coverage", ...(record ? { id: record.id } : {}) },
|
|
634
|
+
dependencies: [],
|
|
635
|
+
actions: record
|
|
636
|
+
? [mutationAction(record)]
|
|
637
|
+
: [{ kind: "command", command: `npx filegrc scaffold source-coverage --title ${shellArgument(`${family.title} coverage`)}` }]
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function reconciliationFindings(reconciliation) {
|
|
643
|
+
return (reconciliation?.candidates || []).map((candidate) => ({
|
|
644
|
+
key: `reconciliation.${candidate.transitionFingerprint}`,
|
|
645
|
+
code: `reconciliation.${candidate.eventType}`,
|
|
646
|
+
assessment: "period-health",
|
|
647
|
+
stage: "operate",
|
|
648
|
+
state: "ready",
|
|
649
|
+
severity: "warning",
|
|
650
|
+
requiredness: "conditional",
|
|
651
|
+
title: `Confirm ${fieldLabel(candidate.eventType)}`,
|
|
652
|
+
message: candidate.message,
|
|
653
|
+
subject: candidate.subject,
|
|
654
|
+
fieldPath: candidate.sourcePath,
|
|
655
|
+
dependencies: [],
|
|
656
|
+
actions: [candidate.action]
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function assessPeriodHealth(loaded, options) {
|
|
661
|
+
const coverage = options.coverage;
|
|
662
|
+
if (!coverage?.start || !coverage?.end) {
|
|
663
|
+
if (loaded.workspace?.assuranceGoal !== "soc-2-type-2") return [];
|
|
664
|
+
return [periodFinding(
|
|
665
|
+
"period.coverage.select",
|
|
666
|
+
"Select the candidate operating period",
|
|
667
|
+
"Set the candidate Type 2 start and end dates before FileGRC can calculate continuous policy, control, source, role, and obligation coverage.",
|
|
668
|
+
"ready",
|
|
669
|
+
{ type: "workspace", id: loaded.workspace.id },
|
|
670
|
+
[{ kind: "command", command: "npx filegrc get workspace workspace --mutation" }]
|
|
671
|
+
)];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const findings = [];
|
|
675
|
+
const recordById = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
676
|
+
const relevantEntries = loaded.entries.filter(({ record }) => [
|
|
677
|
+
"appointment",
|
|
678
|
+
"control",
|
|
679
|
+
"evidence",
|
|
680
|
+
"obligation",
|
|
681
|
+
"policy",
|
|
682
|
+
"source-coverage",
|
|
683
|
+
"system"
|
|
684
|
+
].includes(record.type));
|
|
685
|
+
const paths = relevantEntries.map(({ relativePath }) => `data/${relativePath}`);
|
|
686
|
+
const histories = getWorkspaceHistories(loaded.root, paths, 50);
|
|
687
|
+
const allHistory = [...histories.values()].flat().filter(Boolean);
|
|
688
|
+
const earliestCommitDate = allHistory
|
|
689
|
+
.map(({ timestamp }) => timestamp?.slice(0, 10))
|
|
690
|
+
.filter(Boolean)
|
|
691
|
+
.sort()[0];
|
|
692
|
+
if (!earliestCommitDate || earliestCommitDate > coverage.start) {
|
|
693
|
+
findings.push(periodFinding(
|
|
694
|
+
"period.git-history.span",
|
|
695
|
+
"Confirm history before the period start",
|
|
696
|
+
earliestCommitDate
|
|
697
|
+
? `The available FileGRC history starts on ${earliestCommitDate}, after the proposed period start ${coverage.start}. Confirm that authoritative external history covers the earlier interval or change the period.`
|
|
698
|
+
: "No committed FileGRC history covers the proposed period. Commit current facts and identify authoritative external history before relying on this period.",
|
|
699
|
+
"ready",
|
|
700
|
+
{ type: "workspace", id: loaded.workspace.id },
|
|
701
|
+
[{ kind: "command", command: "git log --reverse -- data" }]
|
|
702
|
+
));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const coreKinds = assessRequiredAppointments(loaded.resources, loaded.model)
|
|
706
|
+
.filter(({ requiredness }) => ["required", "core"].includes(requiredness))
|
|
707
|
+
.map(({ kind }) => kind);
|
|
708
|
+
for (const kind of coreKinds) {
|
|
709
|
+
const covering = loaded.resources.some((record) => (
|
|
710
|
+
record.type === "appointment"
|
|
711
|
+
&& record.appointmentKind === kind
|
|
712
|
+
&& record.status === "active"
|
|
713
|
+
&& record.startsOn
|
|
714
|
+
&& record.startsOn <= coverage.start
|
|
715
|
+
&& (!record.endsOn || record.endsOn >= coverage.end)
|
|
716
|
+
));
|
|
717
|
+
if (!covering) {
|
|
718
|
+
findings.push(periodFinding(
|
|
719
|
+
`period.appointment.${kind}.gap`,
|
|
720
|
+
`${fieldLabel(kind)} period coverage`,
|
|
721
|
+
`No active dated ${fieldLabel(kind)} Appointment covers ${coverage.start} through ${coverage.end}.`,
|
|
722
|
+
"ready",
|
|
723
|
+
{ type: "appointment" },
|
|
724
|
+
[{ kind: "command", command: `npx filegrc list appointment --workflow --json` }]
|
|
725
|
+
));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const selectedControlIds = new Set(options.controlIds || []);
|
|
730
|
+
const selectedControlCodes = new Set(loaded.resources
|
|
731
|
+
.filter((record) => record.type === "control" && selectedControlIds.has(record.id))
|
|
732
|
+
.map(({ code }) => code)
|
|
733
|
+
.filter(Boolean));
|
|
734
|
+
const selectedSourceFamilies = new Set((loaded.model.evidenceSourceFamilies || [])
|
|
735
|
+
.filter((family) => family.controlCodes.some((code) => selectedControlCodes.has(code)))
|
|
736
|
+
.map(({ id }) => id));
|
|
737
|
+
for (const record of loaded.resources) {
|
|
738
|
+
if (record.type === "policy" && ["required", "alternative"].includes(record.programRole)) {
|
|
739
|
+
if (record.status !== "active" || !record.effectiveOn || record.effectiveOn > coverage.start) {
|
|
740
|
+
findings.push(periodGap(record, "policy", record.effectiveOn, coverage));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (
|
|
744
|
+
record.type === "control"
|
|
745
|
+
&& selectedControlIds.has(record.id)
|
|
746
|
+
&& !["not-applicable", "retired"].includes(record.status)
|
|
747
|
+
) {
|
|
748
|
+
const startsOn = record.procedureEffectiveOn || record.effectiveOn;
|
|
749
|
+
if (record.status !== "implemented" || !startsOn || startsOn > coverage.start || (
|
|
750
|
+
record.retiredOn && record.retiredOn < coverage.end
|
|
751
|
+
)) {
|
|
752
|
+
findings.push(periodGap(record, "control procedure", startsOn, coverage));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (
|
|
756
|
+
record.type === "source-coverage"
|
|
757
|
+
&& selectedSourceFamilies.has(record.sourceFamilyId)
|
|
758
|
+
&& record.status !== "retired"
|
|
759
|
+
) {
|
|
760
|
+
if (
|
|
761
|
+
record.status !== "active"
|
|
762
|
+
|| !record.validFrom
|
|
763
|
+
|| record.validFrom > coverage.start
|
|
764
|
+
|| (record.validThrough && record.validThrough < coverage.end)
|
|
765
|
+
) {
|
|
766
|
+
findings.push(periodGap(record, "evidence source", record.validFrom, coverage));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const periodThrough = [coverage.end, options.asOf].sort()[0];
|
|
772
|
+
const periodResources = loaded.resources.filter((record) => (
|
|
773
|
+
record.type !== "obligation"
|
|
774
|
+
|| !(record.controlIds || []).length
|
|
775
|
+
|| record.controlIds.some((id) => selectedControlIds.has(id))
|
|
776
|
+
));
|
|
777
|
+
const occurrences = planObligations(periodResources, {
|
|
778
|
+
from: coverage.start,
|
|
779
|
+
asOf: periodThrough,
|
|
780
|
+
through: periodThrough,
|
|
781
|
+
now: options.evaluatedAt,
|
|
782
|
+
includeComplete: true,
|
|
783
|
+
model: loaded.model
|
|
784
|
+
});
|
|
785
|
+
for (const item of occurrences.items.filter((item) => ["overdue", "blocked", "due", "proposed"].includes(item.status))) {
|
|
786
|
+
findings.push(periodFinding(
|
|
787
|
+
`period.obligation.${item.key || stableSuffix(JSON.stringify(item))}`,
|
|
788
|
+
item.title,
|
|
789
|
+
item.status === "proposed"
|
|
790
|
+
? "This occurrence is still a proposal because its governing policy, control, owner, or completion profile is incomplete."
|
|
791
|
+
: item.status === "blocked"
|
|
792
|
+
? item.blockingReason || "This work is blocked by an unresolved source record."
|
|
793
|
+
: item.status === "due"
|
|
794
|
+
? `This occurrence is open within its allowed window and must be completed by ${item.dueWindowEnd}.`
|
|
795
|
+
: `The expected occurrence for ${item.dueWindowStart} through ${item.dueWindowEnd} has no accepted completion.`,
|
|
796
|
+
item.status === "overdue"
|
|
797
|
+
? "overdue"
|
|
798
|
+
: item.status === "blocked"
|
|
799
|
+
? "blocked"
|
|
800
|
+
: item.status === "due" ? "scheduled" : "ready",
|
|
801
|
+
item.actionItemId
|
|
802
|
+
? { type: "action-item", id: item.actionItemId }
|
|
803
|
+
: { type: "obligation", id: item.obligationId },
|
|
804
|
+
[obligationNextAction(item)],
|
|
805
|
+
item.status === "blocked"
|
|
806
|
+
? blockingDependencies(item.blockingResourceIds, recordById)
|
|
807
|
+
: []
|
|
808
|
+
));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
for (const entry of relevantEntries) {
|
|
812
|
+
const record = entry.record;
|
|
813
|
+
const history = histories.get(`data/${entry.relativePath}`) || [];
|
|
814
|
+
const changesInsidePeriod = history.filter(({ timestamp }) => (
|
|
815
|
+
timestamp?.slice(0, 10) > coverage.start
|
|
816
|
+
&& timestamp?.slice(0, 10) <= coverage.end
|
|
817
|
+
));
|
|
818
|
+
if (changesInsidePeriod.length > 1 && ["appointment", "control", "policy", "source-coverage", "system"].includes(record.type)) {
|
|
819
|
+
findings.push(periodFinding(
|
|
820
|
+
`period.change.${record.type}.${record.id}`,
|
|
821
|
+
`Review period impact for ${record.title}`,
|
|
822
|
+
`${changesInsidePeriod.length} committed revisions fall inside the period. Confirm the effective date, review, source continuity, and any needed Policy Event or Exception.`,
|
|
823
|
+
"ready",
|
|
824
|
+
{ type: record.type, id: record.id },
|
|
825
|
+
[mutationAction(record)]
|
|
826
|
+
));
|
|
827
|
+
}
|
|
828
|
+
if (record.type === "evidence") {
|
|
829
|
+
const businessDate = (record.businessEventAt || record.sourceGeneratedAt || record.generatedAt || record.collectedOn)?.slice(0, 10);
|
|
830
|
+
const firstCommit = history.map(({ timestamp }) => timestamp?.slice(0, 10)).filter(Boolean).sort()[0];
|
|
831
|
+
if (businessDate && firstCommit && calendarDaysBetween(businessDate, firstCommit) > 7) {
|
|
832
|
+
findings.push(periodFinding(
|
|
833
|
+
`period.contemporaneity.evidence.${record.id}`,
|
|
834
|
+
`Explain late entry for ${record.title}`,
|
|
835
|
+
`The recorded source date is ${businessDate}, but the first available Git entry is ${firstCommit}. Preserve the reason for the delay and verify the original source.`,
|
|
836
|
+
"ready",
|
|
837
|
+
{ type: "evidence", id: record.id },
|
|
838
|
+
[mutationAction(record)]
|
|
839
|
+
));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return findings;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function auditLifecycleFindings(loaded, audits) {
|
|
847
|
+
if (String(loaded.model.modelVersion) !== "3") return [];
|
|
848
|
+
const findings = [];
|
|
849
|
+
for (const audit of audits) {
|
|
850
|
+
if (!(audit.controlIds || []).length) {
|
|
851
|
+
findings.push(auditLifecycleFinding(
|
|
852
|
+
audit,
|
|
853
|
+
"control-scope",
|
|
854
|
+
"audit-readiness",
|
|
855
|
+
"Select the audit control scope",
|
|
856
|
+
"An audit with no selected controls cannot produce a meaningful evidence or occurrence assessment."
|
|
857
|
+
));
|
|
858
|
+
}
|
|
859
|
+
if (!audit.scopeRevision) {
|
|
860
|
+
const workspaceControls = new Set(loaded.workspace?.controlIds || []);
|
|
861
|
+
const omittedControls = [...workspaceControls].filter((id) => !(audit.controlIds || []).includes(id));
|
|
862
|
+
findings.push(auditLifecycleFinding(
|
|
863
|
+
audit,
|
|
864
|
+
"scope-revision",
|
|
865
|
+
"audit-readiness",
|
|
866
|
+
"Review the engagement scope diff",
|
|
867
|
+
`Confirm the carried-forward services, systems, categories, requirements, controls, commitments, subservices, and signatories. ${omittedControls.length} workspace controls are outside the current audit selection.`
|
|
868
|
+
));
|
|
869
|
+
}
|
|
870
|
+
if (audit.auditKind === "soc-2-type-2") {
|
|
871
|
+
const start = coverageStart(audit.coverage);
|
|
872
|
+
const end = coverageEnd(audit.coverage);
|
|
873
|
+
if (!start || !end) {
|
|
874
|
+
findings.push(auditLifecycleFinding(
|
|
875
|
+
audit,
|
|
876
|
+
"formal-period",
|
|
877
|
+
"audit-readiness",
|
|
878
|
+
"Record the firm-agreed Type 2 period",
|
|
879
|
+
"Set the exact start and end dates before evaluating expected occurrences and source continuity."
|
|
880
|
+
));
|
|
881
|
+
}
|
|
882
|
+
const candidateStart = coverageStart(loaded.workspace?.candidateCoverage);
|
|
883
|
+
if (start && candidateStart && start < candidateStart) {
|
|
884
|
+
findings.push(auditLifecycleFinding(
|
|
885
|
+
audit,
|
|
886
|
+
"period-feasibility",
|
|
887
|
+
"audit-readiness",
|
|
888
|
+
"Resolve the retrospective period gap",
|
|
889
|
+
`The formal period starts on ${start}, before the candidate-ready date ${candidateStart}. Identify authoritative earlier history or change the period.`
|
|
890
|
+
));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (!["planning", "draft"].includes(audit.status)) {
|
|
894
|
+
if (!audit.engagementTermsDocumentId) {
|
|
895
|
+
findings.push(auditLifecycleFinding(
|
|
896
|
+
audit,
|
|
897
|
+
"engagement-terms",
|
|
898
|
+
"audit-readiness",
|
|
899
|
+
"Link the engagement terms",
|
|
900
|
+
"Record the CPA firm's engagement terms without representing its professional judgments as management facts."
|
|
901
|
+
));
|
|
902
|
+
}
|
|
903
|
+
if (!(audit.managementAcknowledgedByIds || []).length || !audit.managementAcknowledgedOn) {
|
|
904
|
+
findings.push(auditLifecycleFinding(
|
|
905
|
+
audit,
|
|
906
|
+
"management-acknowledgement",
|
|
907
|
+
"audit-readiness",
|
|
908
|
+
"Record management acknowledgement",
|
|
909
|
+
"Name who acknowledged the engagement terms and the actual acknowledgement date."
|
|
910
|
+
));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const lateStage = ["report-draft", "issued", "delivered", "complete"].includes(audit.status);
|
|
914
|
+
if (lateStage && !audit.subsequentEventsReview) {
|
|
915
|
+
findings.push(auditLifecycleFinding(
|
|
916
|
+
audit,
|
|
917
|
+
"subsequent-events",
|
|
918
|
+
"audit-readiness",
|
|
919
|
+
"Complete the subsequent-events review",
|
|
920
|
+
"Review incidents, changes, findings, subservice coverage, representations, and system-description disclosures through the report date."
|
|
921
|
+
));
|
|
922
|
+
}
|
|
923
|
+
if (["fieldwork", "report-draft", "issued", "delivered", "complete"].includes(audit.status) && !audit.packetDelivery) {
|
|
924
|
+
findings.push(auditLifecycleFinding(
|
|
925
|
+
audit,
|
|
926
|
+
"packet-delivery",
|
|
927
|
+
"delivery-readiness",
|
|
928
|
+
"Approve and record packet delivery",
|
|
929
|
+
"Record the least-disclosure review, redaction decision, recipient, delivery system, exact packet revision and manifest, management approval, delivery date, and receipt."
|
|
930
|
+
));
|
|
931
|
+
}
|
|
932
|
+
if (audit.status !== "complete") {
|
|
933
|
+
const nextStep = auditClosureNextStep(audit.status);
|
|
934
|
+
findings.push(auditLifecycleFinding(
|
|
935
|
+
audit,
|
|
936
|
+
"advance",
|
|
937
|
+
"audit-closure",
|
|
938
|
+
nextStep.title,
|
|
939
|
+
nextStep.message
|
|
940
|
+
));
|
|
941
|
+
}
|
|
942
|
+
if (audit.status !== "complete") continue;
|
|
943
|
+
const openRequests = loaded.resources.filter((record) => (
|
|
944
|
+
record.type === "audit-request"
|
|
945
|
+
&& (record.auditId === audit.id || record.auditIds?.includes(audit.id))
|
|
946
|
+
&& !["complete", "closed", "accepted", "canceled"].includes(record.status)
|
|
947
|
+
));
|
|
948
|
+
const openFindings = loaded.resources.filter((record) => (
|
|
949
|
+
record.type === "finding"
|
|
950
|
+
&& (record.auditId === audit.id || record.auditIds?.includes(audit.id))
|
|
951
|
+
&& !["closed", "accepted", "remediated"].includes(record.status)
|
|
952
|
+
));
|
|
953
|
+
for (const [suffix, records, noun] of [
|
|
954
|
+
["requests", openRequests, "Audit Requests"],
|
|
955
|
+
["findings", openFindings, "Findings"]
|
|
956
|
+
]) {
|
|
957
|
+
if (!records.length) continue;
|
|
958
|
+
findings.push(auditLifecycleFinding(
|
|
959
|
+
audit,
|
|
960
|
+
suffix,
|
|
961
|
+
"audit-closure",
|
|
962
|
+
`Resolve or accept open ${noun}`,
|
|
963
|
+
`${records.length} ${noun} remain open, so this engagement cannot be treated as closed.`
|
|
964
|
+
));
|
|
965
|
+
}
|
|
966
|
+
for (const [field, title, message] of [
|
|
967
|
+
["reportEvidenceId", "Link the issued report", "Link the exact issued report evidence and its coverage."],
|
|
968
|
+
["retentionDecision", "Record the retention decision", "Record the approved retention and authorized distribution decision."],
|
|
969
|
+
["carryForwardActionIds", "Review carry-forward work", "Record the next-period actions, including an explicit empty list when no carry-forward work remains."],
|
|
970
|
+
["signatoryAppointmentIds", "Confirm authorized signatories", "Link active authority Appointments for management assertion and representation signers."]
|
|
971
|
+
]) {
|
|
972
|
+
if (field === "carryForwardActionIds" ? Array.isArray(audit[field]) : present(audit[field])) continue;
|
|
973
|
+
findings.push(auditLifecycleFinding(audit, field, "audit-closure", title, message));
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return findings;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function normalizeFinding(code, item, context) {
|
|
980
|
+
const state = findingState(item.status);
|
|
981
|
+
const subject = item.resourceType
|
|
982
|
+
? { type: item.resourceType, ...(item.resourceId ? { id: item.resourceId } : {}) }
|
|
983
|
+
: null;
|
|
984
|
+
const dependencies = (item.unresolvedAssignments || []).map((assignment) => ({
|
|
985
|
+
type: assignment.resourceType,
|
|
986
|
+
id: assignment.resourceId,
|
|
987
|
+
reasons: assignment.reasons || []
|
|
988
|
+
}));
|
|
989
|
+
return {
|
|
990
|
+
key: code,
|
|
991
|
+
code,
|
|
992
|
+
assessment: context.assessment,
|
|
993
|
+
stage: context.stage,
|
|
994
|
+
...(context.auditId ? { auditId: context.auditId } : {}),
|
|
995
|
+
state,
|
|
996
|
+
severity: findingSeverity(state, context.assessment),
|
|
997
|
+
title: item.title,
|
|
998
|
+
message: item.message,
|
|
999
|
+
...(subject ? { subject } : {}),
|
|
1000
|
+
dependencies,
|
|
1001
|
+
actions: (item.commands || []).map((command) => ({
|
|
1002
|
+
kind: "command",
|
|
1003
|
+
command
|
|
1004
|
+
}))
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function mutationAction(record) {
|
|
1009
|
+
return {
|
|
1010
|
+
kind: "command",
|
|
1011
|
+
command: `npx filegrc get ${shellArgument(record.id)} --mutation`
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function findingState(status) {
|
|
1016
|
+
if (status === "action") return "ready";
|
|
1017
|
+
if (status === "later") return "scheduled";
|
|
1018
|
+
if (status === "external") return "waiting-external";
|
|
1019
|
+
if (status === "info") return "not-applicable";
|
|
1020
|
+
return status || "ready";
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function findingSeverity(state, assessment) {
|
|
1024
|
+
if (state === "overdue") return "error";
|
|
1025
|
+
if (["ready", "blocked"].includes(state)) {
|
|
1026
|
+
return ["period-health", "audit-readiness", "delivery-readiness"].includes(assessment)
|
|
1027
|
+
? "error"
|
|
1028
|
+
: "warning";
|
|
1029
|
+
}
|
|
1030
|
+
return "info";
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function buildWorkItems(records, obligationPlan, options) {
|
|
1034
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
1035
|
+
const items = obligationPlan.items.map((item) => obligationWorkItem(item, byId, options.asOf));
|
|
1036
|
+
const obligationSources = new Set(items
|
|
1037
|
+
.map((item) => item.source?.id)
|
|
1038
|
+
.filter(Boolean));
|
|
1039
|
+
for (const record of records) {
|
|
1040
|
+
if (record.type === "obligation" || obligationSources.has(record.id)) continue;
|
|
1041
|
+
const due = firstDate(record);
|
|
1042
|
+
if (!due) continue;
|
|
1043
|
+
const state = sourceWorkState(record, due, options.asOf);
|
|
1044
|
+
if (state === "complete" && !options.includeComplete) continue;
|
|
1045
|
+
items.push({
|
|
1046
|
+
key: `source:${record.type}:${record.id}:${due}`,
|
|
1047
|
+
kind: "source-deadline",
|
|
1048
|
+
source: { type: record.type, id: record.id },
|
|
1049
|
+
subject: { type: record.type, id: record.id },
|
|
1050
|
+
title: record.title,
|
|
1051
|
+
ownerIds: firstOwners(record),
|
|
1052
|
+
dueOn: due,
|
|
1053
|
+
state,
|
|
1054
|
+
priority: workPriority(state, due, options.asOf),
|
|
1055
|
+
scope: workScope(record),
|
|
1056
|
+
blockingReason: record.status === "blocked"
|
|
1057
|
+
? actionBlockingReason(record, byId)
|
|
1058
|
+
: null,
|
|
1059
|
+
dependencies: record.status === "blocked"
|
|
1060
|
+
? blockingDependencies(record.blockingResourceIds, byId)
|
|
1061
|
+
: [],
|
|
1062
|
+
requiredCompletionProfile: sourceCompletionProfile(record),
|
|
1063
|
+
nextAction: sourceNextAction(record, due, options.asOf, records)
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
return uniqueByKey(items);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function obligationWorkItem(item, byId, asOf) {
|
|
1070
|
+
const subjectId = item.subjectResourceId
|
|
1071
|
+
|| (item.scopeResourceIds?.length === 1 ? item.scopeResourceIds[0] : null);
|
|
1072
|
+
const subjectRecord = subjectId ? byId.get(subjectId) : null;
|
|
1073
|
+
const due = item.dueWindowEndAt || item.dueWindowEnd;
|
|
1074
|
+
return {
|
|
1075
|
+
key: `obligation:${item.key || item.actionItemId || item.obligationId}${subjectId ? `:${subjectId}` : ""}`,
|
|
1076
|
+
kind: item.kind === "calendar" ? "obligation-occurrence" : "assigned-work",
|
|
1077
|
+
source: {
|
|
1078
|
+
type: item.actionItemId ? "action-item" : "obligation",
|
|
1079
|
+
id: item.actionItemId || item.obligationId
|
|
1080
|
+
},
|
|
1081
|
+
...(subjectId ? { subject: { type: subjectRecord?.type || "unknown", id: subjectId } } : {}),
|
|
1082
|
+
title: item.title,
|
|
1083
|
+
ownerIds: item.ownerIds || item.assigneeIds || [],
|
|
1084
|
+
...(item.dueWindowStart ? { availableOn: item.dueWindowStart } : {}),
|
|
1085
|
+
...(due ? { dueOn: due } : {}),
|
|
1086
|
+
state: item.status,
|
|
1087
|
+
priority: workPriority(item.status, due?.slice(0, 10), asOf),
|
|
1088
|
+
scope: item.scopeResourceIds || (subjectId ? [subjectId] : []),
|
|
1089
|
+
blockingReason: item.blockingReason || item.reason || null,
|
|
1090
|
+
dependencies: blockingDependencies(item.blockingResourceIds, byId),
|
|
1091
|
+
requiredCompletionProfile: item.completionProfile || item.activityType || null,
|
|
1092
|
+
completionProfile: {
|
|
1093
|
+
activityType: item.activityType || null,
|
|
1094
|
+
profileId: item.completionProfile || null,
|
|
1095
|
+
resourceTypes: item.completionResourceTypes || []
|
|
1096
|
+
},
|
|
1097
|
+
nextAction: obligationNextAction(item)
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function obligationNextAction(item) {
|
|
1102
|
+
if (item.actionItemId) {
|
|
1103
|
+
if (item.status === "blocked") {
|
|
1104
|
+
return {
|
|
1105
|
+
kind: "command",
|
|
1106
|
+
command: `npx filegrc get ${shellArgument(item.actionItemId)} --mutation`
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
return {
|
|
1110
|
+
kind: "command",
|
|
1111
|
+
command: `npx filegrc complete-action ${shellArgument(item.actionItemId)} --scaffold --completed-on YYYY-MM-DD`
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
kind: "command",
|
|
1116
|
+
command: `npx filegrc complete ${shellArgument(item.obligationId)} --scaffold --window-start ${shellArgument(item.dueWindowStart)} --completed-on YYYY-MM-DD`
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function actionBlockingReason(record, byId) {
|
|
1121
|
+
if (record.status !== "blocked") return null;
|
|
1122
|
+
const blockers = (record.blockingResourceIds || [])
|
|
1123
|
+
.map((id) => byId.get(id)?.title || id)
|
|
1124
|
+
.filter(Boolean);
|
|
1125
|
+
return blockers.length
|
|
1126
|
+
? `Blocked by ${blockers.join(", ")}.`
|
|
1127
|
+
: "The source record is blocked.";
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function blockingDependencies(ids = [], byId) {
|
|
1131
|
+
return ids.map((id) => {
|
|
1132
|
+
const record = byId.get(id);
|
|
1133
|
+
return {
|
|
1134
|
+
type: record?.type || "unknown",
|
|
1135
|
+
id,
|
|
1136
|
+
reasons: ["Resolve this prerequisite before continuing the blocked work."]
|
|
1137
|
+
};
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function buildAssessments({ program, audits, auditPreparations, obligationPlan, findings, validation, coverage }) {
|
|
1142
|
+
const scopeStage = program.stages.find((stage) => stage.id === "scope");
|
|
1143
|
+
const configurationReady = stageComplete(scopeStage);
|
|
1144
|
+
const evidenceReady = program.evidenceReady;
|
|
1145
|
+
const periodFindings = findings.filter((finding) => finding.assessment === "period-health");
|
|
1146
|
+
const periodStarted = evidenceReady && Boolean(coverage?.start && coverage?.end);
|
|
1147
|
+
const periodHealthy = periodStarted
|
|
1148
|
+
&& obligationPlan.counts.overdue === 0
|
|
1149
|
+
&& obligationPlan.counts.blocked === 0
|
|
1150
|
+
&& !periodFindings.some(blockingFinding);
|
|
1151
|
+
const auditValues = Object.values(auditPreparations);
|
|
1152
|
+
const auditReady = audits.length > 0
|
|
1153
|
+
&& auditValues.every((preparation) => preparation.status === "management-ready");
|
|
1154
|
+
const deliveryReady = auditReady && audits.every((audit) => (
|
|
1155
|
+
["fieldwork", "report-draft", "issued", "delivered", "complete"].includes(audit.status)
|
|
1156
|
+
)) && !findings.some((finding) => finding.assessment === "delivery-readiness" && blockingFinding(finding));
|
|
1157
|
+
const auditClosed = audits.length > 0
|
|
1158
|
+
&& audits.every((audit) => audit.status === "complete")
|
|
1159
|
+
&& !findings.some((finding) => finding.assessment === "audit-closure" && blockingFinding(finding));
|
|
1160
|
+
const deliveryFindingKeys = findingKeys(findings, "delivery-readiness");
|
|
1161
|
+
return {
|
|
1162
|
+
structuralValidity: assessment(
|
|
1163
|
+
validation.ok ? "complete" : "needs-work",
|
|
1164
|
+
validation.ok ? "Workspace records pass structural validation." : "Workspace structure or relationships need work.",
|
|
1165
|
+
findingKeys(findings, "structural-validity")
|
|
1166
|
+
),
|
|
1167
|
+
programConfiguration: assessment(
|
|
1168
|
+
configurationReady ? "complete" : "needs-work",
|
|
1169
|
+
configurationReady ? "Program scope and ownership are configured." : "Program scope or ownership still needs work.",
|
|
1170
|
+
findingKeys(findings, "program-configuration", ({ key, stage }) => (
|
|
1171
|
+
key.startsWith("program.") && stage === "scope"
|
|
1172
|
+
))
|
|
1173
|
+
),
|
|
1174
|
+
evidenceReadiness: assessment(
|
|
1175
|
+
evidenceReady ? "complete" : "needs-work",
|
|
1176
|
+
evidenceReady ? "Evidence collection can begin." : "Evidence collection prerequisites remain.",
|
|
1177
|
+
findingKeys(findings, "program-configuration", ({ key }) => key.startsWith("program."))
|
|
1178
|
+
),
|
|
1179
|
+
periodHealth: assessment(
|
|
1180
|
+
!periodStarted ? "not-started" : periodHealthy ? "complete" : "at-risk",
|
|
1181
|
+
!evidenceReady
|
|
1182
|
+
? "Period health starts after the Evidence Ready gate."
|
|
1183
|
+
: !coverage?.start || !coverage?.end
|
|
1184
|
+
? "Select the candidate or formal period before checking period health."
|
|
1185
|
+
: periodHealthy
|
|
1186
|
+
? "No current period-health blockers were found."
|
|
1187
|
+
: "The candidate or operating period has blockers.",
|
|
1188
|
+
periodStarted ? findingKeys(findings, "period-health") : []
|
|
1189
|
+
),
|
|
1190
|
+
auditReadiness: assessment(
|
|
1191
|
+
auditReady ? "complete" : audits.length ? "needs-work" : "not-started",
|
|
1192
|
+
auditReady ? "Management audit preparation is complete." : audits.length ? "Audit preparation remains." : "No audit engagement is selected.",
|
|
1193
|
+
findingKeys(findings, "audit-readiness")
|
|
1194
|
+
),
|
|
1195
|
+
deliveryReadiness: assessment(
|
|
1196
|
+
deliveryReady
|
|
1197
|
+
? "complete"
|
|
1198
|
+
: auditReady || deliveryFindingKeys.length ? "needs-work" : "not-started",
|
|
1199
|
+
deliveryReady
|
|
1200
|
+
? "The engagement is ready for packet delivery review."
|
|
1201
|
+
: auditReady || deliveryFindingKeys.length
|
|
1202
|
+
? "Complete the delivery checks before sending the packet."
|
|
1203
|
+
: "Packet delivery starts after management audit preparation is complete.",
|
|
1204
|
+
auditReady || deliveryFindingKeys.length ? deliveryFindingKeys : []
|
|
1205
|
+
),
|
|
1206
|
+
auditClosure: assessment(
|
|
1207
|
+
auditClosed ? "complete" : audits.length ? "needs-work" : "not-started",
|
|
1208
|
+
auditClosed ? "All selected audits are closed." : "Audit closure remains.",
|
|
1209
|
+
findingKeys(findings, "audit-closure")
|
|
1210
|
+
)
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function assessment(status, message, findingKeysValue) {
|
|
1215
|
+
return { status, message, findingKeys: findingKeysValue };
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function stageComplete(stage) {
|
|
1219
|
+
return Boolean(stage) && !stage.items.some((item) => item.status === "action");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function findingKeys(findings, assessmentName, predicate = () => true) {
|
|
1223
|
+
return findings
|
|
1224
|
+
.filter((finding) => (
|
|
1225
|
+
finding.assessment === assessmentName
|
|
1226
|
+
&& blockingFinding(finding)
|
|
1227
|
+
&& predicate(finding)
|
|
1228
|
+
))
|
|
1229
|
+
.map((finding) => finding.key);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function blockingFinding(finding) {
|
|
1233
|
+
return ["blocked", "overdue", "ready"].includes(finding.state);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function recommendedAction(findings, workItems, program) {
|
|
1237
|
+
const firstProgramAction = program?.firstAction;
|
|
1238
|
+
if (firstProgramAction) {
|
|
1239
|
+
const preferred = findings.find((finding) => (
|
|
1240
|
+
finding.key.startsWith("program.")
|
|
1241
|
+
&& finding.key.endsWith(`.${firstProgramAction.id}`)
|
|
1242
|
+
&& blockingFinding(finding)
|
|
1243
|
+
));
|
|
1244
|
+
if (preferred) return preferred;
|
|
1245
|
+
}
|
|
1246
|
+
return [...findings.filter(blockingFinding), ...workItems.filter(activeWork)]
|
|
1247
|
+
.sort(compareRecommended)[0] || null;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function compareRecommended(left, right) {
|
|
1251
|
+
const leftPriority = left.priority ?? findingPriority(left);
|
|
1252
|
+
const rightPriority = right.priority ?? findingPriority(right);
|
|
1253
|
+
return leftPriority - rightPriority || left.key.localeCompare(right.key);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function compareFindings(left, right) {
|
|
1257
|
+
return compareRecommended(left, right);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function compareWorkItems(left, right) {
|
|
1261
|
+
return compareRecommended(left, right);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function findingPriority(finding) {
|
|
1265
|
+
if (finding.state === "overdue") return 0;
|
|
1266
|
+
if (finding.state === "ready") return finding.severity === "error" ? 10 : 20;
|
|
1267
|
+
if (finding.state === "blocked") return 30;
|
|
1268
|
+
if (finding.severity === "error") return 10;
|
|
1269
|
+
if (finding.state === "waiting-external") return 40;
|
|
1270
|
+
if (finding.state === "scheduled") return 50;
|
|
1271
|
+
return 90;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function workPriority(state, dueOn, asOf) {
|
|
1275
|
+
if (state === "overdue") return 0;
|
|
1276
|
+
if (dueOn && asOf && dueOn < asOf) return 5;
|
|
1277
|
+
if (state === "due") return 10;
|
|
1278
|
+
if (state === "ready" || state === "open") return 20;
|
|
1279
|
+
if (state === "blocked") return 30;
|
|
1280
|
+
if (state === "upcoming" || state === "scheduled") return 50;
|
|
1281
|
+
return 90;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function activeWork(item) {
|
|
1285
|
+
return !["canceled", "complete", "not-applicable", "superseded"].includes(item.state);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function firstDate(record) {
|
|
1289
|
+
if (record.completionWindow?.dueAt) return record.completionWindow.dueAt;
|
|
1290
|
+
if (record.completionWindow?.dueOn) return record.completionWindow.dueOn;
|
|
1291
|
+
for (const field of DATE_FIELDS) {
|
|
1292
|
+
if (typeof record[field] === "string" && record[field]) return record[field];
|
|
1293
|
+
}
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function workScope(record) {
|
|
1298
|
+
for (const field of [
|
|
1299
|
+
"scopeResourceIds",
|
|
1300
|
+
"systemIds",
|
|
1301
|
+
"controlIds",
|
|
1302
|
+
"vendorIds",
|
|
1303
|
+
"requirementIds"
|
|
1304
|
+
]) {
|
|
1305
|
+
if (Array.isArray(record[field]) && record[field].length) return record[field];
|
|
1306
|
+
}
|
|
1307
|
+
return [];
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function sourceCompletionProfile(record) {
|
|
1311
|
+
if (record.type === "action-item") return record.obligationId ? "assigned-obligation-work" : "assigned-action";
|
|
1312
|
+
if (record.type === "audit-request") return "audit-request-response";
|
|
1313
|
+
if (record.type === "risk") return "risk-treatment-and-review";
|
|
1314
|
+
if (record.type === "vulnerability") return "vulnerability-remediation";
|
|
1315
|
+
if (record.type === "finding") return "finding-remediation-and-verification";
|
|
1316
|
+
if (record.type === "exception") return "exception-expiry-and-compensating-control-review";
|
|
1317
|
+
if (record.type === "service-account") return "service-account-review-or-disablement";
|
|
1318
|
+
if (record.type === "vendor") return "vendor-assurance-renewal-or-termination";
|
|
1319
|
+
if (record.type === "access-grant") return "access-renewal-or-revocation";
|
|
1320
|
+
if (record.type === "audit") return "audit-lifecycle-transition";
|
|
1321
|
+
return `${record.type}-lifecycle`;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function sourceNextAction(record, due, asOf, records) {
|
|
1325
|
+
const eventType = due <= asOf ? {
|
|
1326
|
+
exception: "exception-expired",
|
|
1327
|
+
"service-account": "service-account-expired",
|
|
1328
|
+
vulnerability: "vulnerability-overdue"
|
|
1329
|
+
}[record.type] : null;
|
|
1330
|
+
const alreadyRecorded = eventType && records.some((item) => (
|
|
1331
|
+
item.type === "obligation-event"
|
|
1332
|
+
&& item.eventType === eventType
|
|
1333
|
+
&& (item.subjectResourceIds || []).includes(record.id)
|
|
1334
|
+
&& item.occurredOn >= due.slice(0, 10)
|
|
1335
|
+
));
|
|
1336
|
+
if (eventType && !alreadyRecorded) {
|
|
1337
|
+
return {
|
|
1338
|
+
kind: "command",
|
|
1339
|
+
command: `npx filegrc trigger ${eventType} --occurred-on YYYY-MM-DD --subject ${shellArgument(record.id)} --json`
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
return {
|
|
1343
|
+
kind: "command",
|
|
1344
|
+
command: `npx filegrc get ${shellArgument(record.id)} --mutation`
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function firstOwners(record) {
|
|
1349
|
+
for (const field of OWNER_FIELDS) {
|
|
1350
|
+
if (Array.isArray(record[field]) && record[field].length) return record[field];
|
|
1351
|
+
}
|
|
1352
|
+
return [];
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function sourceWorkState(record, due, asOf) {
|
|
1356
|
+
if (TERMINAL_STATUSES.has(record.status)) return "complete";
|
|
1357
|
+
if (["canceled", "superseded"].includes(record.status)) return record.status;
|
|
1358
|
+
if (["draft", "in-review", "partially-implemented", "planned", "proposed"].includes(record.status)) {
|
|
1359
|
+
return "proposed";
|
|
1360
|
+
}
|
|
1361
|
+
if (due < asOf) return "overdue";
|
|
1362
|
+
if (due === asOf) return "due";
|
|
1363
|
+
return "scheduled";
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function candidateCoverage(workspace) {
|
|
1367
|
+
const coverage = workspace?.candidateCoverage;
|
|
1368
|
+
if (!coverage) return null;
|
|
1369
|
+
return {
|
|
1370
|
+
kind: coverage.kind,
|
|
1371
|
+
start: coverageStart(coverage),
|
|
1372
|
+
end: coverageEnd(coverage)
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function normalizeCoverage(coverage) {
|
|
1377
|
+
if (!coverage) return null;
|
|
1378
|
+
const start = coverage.start || coverage.startsOn || coverage.on || null;
|
|
1379
|
+
const end = coverage.end || coverage.endsOn || coverage.on || null;
|
|
1380
|
+
return {
|
|
1381
|
+
kind: coverage.kind || (start === end ? "as-of" : "range"),
|
|
1382
|
+
start,
|
|
1383
|
+
end
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function periodFinding(key, title, message, state, subject, actions, dependencies = []) {
|
|
1388
|
+
return {
|
|
1389
|
+
key,
|
|
1390
|
+
code: key,
|
|
1391
|
+
assessment: "period-health",
|
|
1392
|
+
stage: "operation",
|
|
1393
|
+
state,
|
|
1394
|
+
severity: findingSeverity(state, "period-health"),
|
|
1395
|
+
requiredness: "required",
|
|
1396
|
+
title,
|
|
1397
|
+
message,
|
|
1398
|
+
subject,
|
|
1399
|
+
dependencies,
|
|
1400
|
+
actions
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function auditClosureNextStep(status) {
|
|
1405
|
+
const steps = {
|
|
1406
|
+
planning: {
|
|
1407
|
+
title: "Confirm the engagement and start audit preparation",
|
|
1408
|
+
message: "Link the agreed engagement terms, confirm scope and management acknowledgement, then move the audit to in progress."
|
|
1409
|
+
},
|
|
1410
|
+
draft: {
|
|
1411
|
+
title: "Finalize the draft engagement",
|
|
1412
|
+
message: "Resolve the draft scope and ownership, link the agreed engagement terms, then move the audit to in progress."
|
|
1413
|
+
},
|
|
1414
|
+
"in-progress": {
|
|
1415
|
+
title: "Begin fieldwork",
|
|
1416
|
+
message: "Finish management preparation, confirm the fieldwork dates, and move the audit to fieldwork when the CPA firm begins testing."
|
|
1417
|
+
},
|
|
1418
|
+
fieldwork: {
|
|
1419
|
+
title: "Finish fieldwork and prepare the report",
|
|
1420
|
+
message: "Resolve management audit requests, record the approved packet delivery, and move the audit to report draft when fieldwork is complete."
|
|
1421
|
+
},
|
|
1422
|
+
"report-draft": {
|
|
1423
|
+
title: "Complete the report-date review",
|
|
1424
|
+
message: "Complete the subsequent-events review and final management representations, then wait for the CPA firm to issue its report."
|
|
1425
|
+
},
|
|
1426
|
+
issued: {
|
|
1427
|
+
title: "Record final report delivery",
|
|
1428
|
+
message: "Confirm the issued report evidence, opinion, report date, authorized recipient, and delivery receipt, then move the audit to delivered."
|
|
1429
|
+
},
|
|
1430
|
+
delivered: {
|
|
1431
|
+
title: "Close the audit",
|
|
1432
|
+
message: "Resolve or accept open requests and findings, record retention and carry-forward decisions, then move the audit to complete."
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
return steps[status] || {
|
|
1436
|
+
title: "Advance the audit",
|
|
1437
|
+
message: "Review the audit record, complete the current lifecycle requirements, and record the next status."
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function periodGap(record, noun, startsOn, coverage) {
|
|
1442
|
+
const timing = startsOn
|
|
1443
|
+
? `Its recorded start is ${startsOn}, after the period begins on ${coverage.start}, or it does not remain effective through ${coverage.end}.`
|
|
1444
|
+
: `It has no effective start that covers ${coverage.start} through ${coverage.end}.`;
|
|
1445
|
+
return periodFinding(
|
|
1446
|
+
`period.coverage.${record.type}.${record.id}`,
|
|
1447
|
+
`${record.title}: continuous ${noun} coverage`,
|
|
1448
|
+
timing,
|
|
1449
|
+
"ready",
|
|
1450
|
+
{ type: record.type, id: record.id },
|
|
1451
|
+
[mutationAction(record)]
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function auditLifecycleFinding(audit, suffix, assessmentName, title, message) {
|
|
1456
|
+
const key = `audit.${audit.id}.lifecycle.${suffix}`;
|
|
1457
|
+
return {
|
|
1458
|
+
key,
|
|
1459
|
+
code: key,
|
|
1460
|
+
assessment: assessmentName,
|
|
1461
|
+
stage: assessmentName === "audit-closure" ? "auditor" : "deliver",
|
|
1462
|
+
auditId: audit.id,
|
|
1463
|
+
state: "ready",
|
|
1464
|
+
severity: "warning",
|
|
1465
|
+
requiredness: "required",
|
|
1466
|
+
title,
|
|
1467
|
+
message,
|
|
1468
|
+
subject: { type: "audit", id: audit.id },
|
|
1469
|
+
dependencies: [],
|
|
1470
|
+
actions: [mutationAction(audit)]
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function calendarDaysBetween(start, end) {
|
|
1475
|
+
return Math.floor((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function safeGitSummary(root) {
|
|
1479
|
+
try {
|
|
1480
|
+
return getGitSummary(root);
|
|
1481
|
+
} catch {
|
|
1482
|
+
return {};
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function countBy(items, field) {
|
|
1487
|
+
return items.reduce((counts, item) => {
|
|
1488
|
+
const key = item[field] || "unknown";
|
|
1489
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
1490
|
+
return counts;
|
|
1491
|
+
}, {});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function keyed(items = []) {
|
|
1495
|
+
return new Map(items.map((item) => [item.key, item]));
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function changedAssessments(before, after) {
|
|
1499
|
+
const changes = [];
|
|
1500
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
1501
|
+
if (before[key]?.status === after[key]?.status) continue;
|
|
1502
|
+
changes.push({
|
|
1503
|
+
assessment: key,
|
|
1504
|
+
before: before[key]?.status || null,
|
|
1505
|
+
after: after[key]?.status || null
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
return changes;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function changedItems(before, after) {
|
|
1512
|
+
const added = [];
|
|
1513
|
+
const removed = [];
|
|
1514
|
+
const changed = [];
|
|
1515
|
+
for (const [key, item] of after) {
|
|
1516
|
+
if (!before.has(key)) added.push(item);
|
|
1517
|
+
else if (before.get(key).state !== item.state) {
|
|
1518
|
+
changed.push({ key, before: before.get(key).state, after: item.state });
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
for (const [key, item] of before) {
|
|
1522
|
+
if (!after.has(key)) removed.push(item);
|
|
1523
|
+
}
|
|
1524
|
+
return { added, removed, changed };
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function uniqueByKey(items) {
|
|
1528
|
+
return [...new Map(items.map((item) => [item.key, item])).values()];
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function shellArgument(value) {
|
|
1532
|
+
const text = String(value);
|
|
1533
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(text)
|
|
1534
|
+
? text
|
|
1535
|
+
: `'${text.replaceAll("'", "'\\''")}'`;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function recordAssessment(type) {
|
|
1539
|
+
if (["audit", "audit-request", "audit-population"].includes(type)) return "audit-readiness";
|
|
1540
|
+
if (["action-item", "obligation", "obligation-event", "control-activity"].includes(type)) return "period-health";
|
|
1541
|
+
return "program-configuration";
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function recordStage(type) {
|
|
1545
|
+
if (["person", "appointment", "team", "framework", "requirement", "commitment", "system", "vendor"].includes(type)) {
|
|
1546
|
+
return "scope";
|
|
1547
|
+
}
|
|
1548
|
+
if (["policy", "document", "training"].includes(type)) return "policies";
|
|
1549
|
+
if (["control", "complementary-control", "source-coverage"].includes(type)) return "controls";
|
|
1550
|
+
if (["audit", "audit-request", "audit-population"].includes(type)) return "audit";
|
|
1551
|
+
return "operate";
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function appointmentRequiredness(record, model) {
|
|
1555
|
+
return model.appointmentTemplates?.[record.appointmentKind]?.requiredness || "conditional";
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function present(value) {
|
|
1559
|
+
if (value === undefined || value === null || value === "") return false;
|
|
1560
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
1561
|
+
if (typeof value === "object") return Object.keys(value).length > 0;
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function fieldLabel(value) {
|
|
1566
|
+
return String(value)
|
|
1567
|
+
.replace(/[-_]+/g, " ")
|
|
1568
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
1569
|
+
.replace(/^./, (letter) => letter.toUpperCase());
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function pathKey(value) {
|
|
1573
|
+
return String(value || "workspace")
|
|
1574
|
+
.toLowerCase()
|
|
1575
|
+
.replace(/[^a-z0-9]+/g, ".")
|
|
1576
|
+
.replace(/^\.|\.$/g, "");
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function stableSuffix(value) {
|
|
1580
|
+
let hash = 2166136261;
|
|
1581
|
+
for (const character of String(value || "")) {
|
|
1582
|
+
hash ^= character.codePointAt(0);
|
|
1583
|
+
hash = Math.imul(hash, 16777619);
|
|
1584
|
+
}
|
|
1585
|
+
return (hash >>> 0).toString(36);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function rankingReason(item) {
|
|
1589
|
+
if (item.state === "overdue") return "Ranked first because it is overdue.";
|
|
1590
|
+
if (item.state === "expired") return "Ranked first because it is expired.";
|
|
1591
|
+
if (item.state === "ready" || item.state === "due") return "Ranked before blocked work because it can be acted on now.";
|
|
1592
|
+
if (item.state === "blocked") return "Ranked after ready work because its named prerequisite must be resolved first.";
|
|
1593
|
+
if (item.severity === "error") return "Ranked before warning-level work because it blocks a named assessment.";
|
|
1594
|
+
return "Ranked by due date, workflow state, and stable item key.";
|
|
1595
|
+
}
|