filegrc 0.11.0 → 0.12.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/model/index.js +9 -5
- package/model/v10.json +12122 -0
- package/model/v9.json +11647 -0
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/audit-populations.js +88 -0
- package/src/audit-preparation.js +7 -2
- package/src/cli.js +186 -13
- package/src/collection-review-integrity.js +118 -0
- package/src/collection-review.js +71 -7
- package/src/collection-scope.js +8 -0
- package/src/evidence-packet.js +328 -46
- package/src/files.js +364 -6
- package/src/git.js +1064 -149
- package/src/index.js +17 -1
- package/src/model-migration.js +221 -5
- package/src/obligations.js +834 -66
- package/src/policy-library/information-security-policy-v2.md +1 -1
- package/src/policy-library.js +85 -8
- package/src/program-path.js +11 -5
- package/src/program-readiness.js +84 -6
- package/src/reconciliation.js +332 -81
- package/src/reporting-route-integrity.js +542 -0
- package/src/reporting-route-sets.js +745 -0
- package/src/server.js +160 -47
- package/src/state.js +30 -8
- package/src/time.js +55 -0
- package/src/validate.js +978 -4
- package/src/web.js +491 -49
- package/src/workflow-history-integrity.js +872 -0
- package/src/workflow.js +36 -10
package/src/evidence-packet.js
CHANGED
|
@@ -11,10 +11,11 @@ import {
|
|
|
11
11
|
coverageOverlaps,
|
|
12
12
|
coverageStart
|
|
13
13
|
} from "./coverage.js";
|
|
14
|
-
import { getFilesAtRevisions, getGitSummary, getWorkspaceHistories, getWorkspaceRevisionSnapshot, hasGitRevision } from "./git.js";
|
|
14
|
+
import { getFileHistoryWithPaths, getFilesAtRevisions, getGitSummary, getRecordIdentityHistories, getWorkspaceHistories, getWorkspaceRevisionSnapshot, hasGitRevision, isDataHistoryAncestor } from "./git.js";
|
|
15
15
|
import { planObligations } from "./obligations.js";
|
|
16
16
|
import { isWithin, resolveDataPath, resolveWorkspacePath, resolveWorkspaceRoot } from "./paths.js";
|
|
17
17
|
import { parseCalendarDate } from "./recurrence.js";
|
|
18
|
+
import { resolveProgram, selectedRequirementIds } from "./program.js";
|
|
18
19
|
import { markdownEntries } from "./resource-markdown.js";
|
|
19
20
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
20
21
|
import {
|
|
@@ -29,6 +30,8 @@ import {
|
|
|
29
30
|
} from "./soc2.js";
|
|
30
31
|
import { validateWorkspace } from "./validate.js";
|
|
31
32
|
import { measureTiming } from "./timing.js";
|
|
33
|
+
import { assessReportingRoutePeriod } from "./reporting-route-sets.js";
|
|
34
|
+
import { reportingRouteEventCommit, reportingRouteSourceAppliesToProgram, reportingRouteSourceMayApply } from "./reporting-route-integrity.js";
|
|
32
35
|
|
|
33
36
|
const preparedPacketValidations = new WeakMap();
|
|
34
37
|
|
|
@@ -45,11 +48,15 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
|
45
48
|
"evidence",
|
|
46
49
|
"framework",
|
|
47
50
|
"obligation",
|
|
51
|
+
"obligation-occurrence",
|
|
52
|
+
"obligation-rule",
|
|
48
53
|
"organization",
|
|
49
54
|
"person",
|
|
50
55
|
"policy",
|
|
51
56
|
"renderer-settings",
|
|
52
57
|
"requirement",
|
|
58
|
+
"reporting-route",
|
|
59
|
+
"reporting-route-set",
|
|
53
60
|
"system",
|
|
54
61
|
"team",
|
|
55
62
|
"training",
|
|
@@ -65,10 +72,25 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
65
72
|
const entriesById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
66
73
|
const audit = options.auditId ? byId.get(options.auditId) : null;
|
|
67
74
|
if (options.auditId && audit?.type !== "audit") throw new Error(`Audit "${options.auditId}" was not found.`);
|
|
75
|
+
if (audit?.programId && options.programId && audit.programId !== options.programId) {
|
|
76
|
+
throw new Error(`Audit "${audit.id}" belongs to Program "${audit.programId}", not "${options.programId}".`);
|
|
77
|
+
}
|
|
78
|
+
const program = modelSupports(loaded.model, "program-scope")
|
|
79
|
+
? resolveProgram(loaded, options.programId || audit?.programId)
|
|
80
|
+
: null;
|
|
81
|
+
const scope = audit || (program ? {
|
|
82
|
+
id: `program-scope:${program.id}`,
|
|
83
|
+
programScope: true,
|
|
84
|
+
programId: program.id,
|
|
85
|
+
frameworkIds: program.frameworkIds || [],
|
|
86
|
+
systemIds: program.systemIds || [],
|
|
87
|
+
requirementIds: selectedRequirementIds(program, loaded.model),
|
|
88
|
+
controlIds: program.controlIds || []
|
|
89
|
+
} : null);
|
|
68
90
|
const { start, end, basis } = resolvePacketPeriod(options, audit);
|
|
69
91
|
const typeOne = audit?.auditKind === "soc-2-type-1";
|
|
70
92
|
const datedRecords = loaded.entries
|
|
71
|
-
.filter((entry) => !
|
|
93
|
+
.filter((entry) => !scope || recordRelevantToAudit(entry.record, scope, byId))
|
|
72
94
|
.map((entry) => packetRecord(entry.record, loaded.model, start, end, loaded.workspace.timezone))
|
|
73
95
|
.filter(Boolean);
|
|
74
96
|
const datedRecordIds = new Set(datedRecords.map(({ id }) => id));
|
|
@@ -78,19 +100,21 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
78
100
|
))
|
|
79
101
|
);
|
|
80
102
|
const plan = planObligations(records, {
|
|
103
|
+
programId: program?.id || options.programId || audit?.programId,
|
|
81
104
|
asOf: end,
|
|
82
105
|
from: start,
|
|
83
106
|
through: end,
|
|
84
107
|
includeComplete: true,
|
|
108
|
+
additionalControlIds: audit?.controlIds || [],
|
|
85
109
|
model: loaded.model
|
|
86
110
|
});
|
|
87
111
|
const obligations = (typeOne ? [] : plan.calendarItems).filter((item) => (
|
|
88
112
|
item.dueWindowStart <= end
|
|
89
113
|
&& item.overdueOn > start
|
|
90
|
-
&& (!
|
|
114
|
+
&& (!scope || recordRelevantToAudit(byId.get(item.obligationId), scope, byId))
|
|
91
115
|
));
|
|
92
116
|
const eventRuns = (typeOne ? [] : plan.eventRuns).filter((run) => (
|
|
93
|
-
(!
|
|
117
|
+
(!scope || run.actions.some((action) => recordRelevantToAudit(byId.get(action.obligationId), scope, byId)))
|
|
94
118
|
&& (
|
|
95
119
|
(run.occurredOn >= start && run.occurredOn <= end)
|
|
96
120
|
|| datedEvidenceSourceIds.has(run.id)
|
|
@@ -103,6 +127,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
103
127
|
)
|
|
104
128
|
));
|
|
105
129
|
const selectedIds = new Set(datedRecords.map((record) => record.id));
|
|
130
|
+
if (program) selectedIds.add(program.id);
|
|
106
131
|
if (audit) {
|
|
107
132
|
selectedIds.add(audit.id);
|
|
108
133
|
addIds(selectedIds, [
|
|
@@ -137,6 +162,8 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
137
162
|
}
|
|
138
163
|
for (const item of obligations) {
|
|
139
164
|
selectedIds.add(item.obligationId);
|
|
165
|
+
if (item.ruleId) selectedIds.add(item.ruleId);
|
|
166
|
+
if (item.occurrenceId) selectedIds.add(item.occurrenceId);
|
|
140
167
|
addIds(selectedIds, item.completionResourceIds);
|
|
141
168
|
addIds(selectedIds, item.evidenceIds);
|
|
142
169
|
}
|
|
@@ -151,28 +178,12 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
151
178
|
}
|
|
152
179
|
|
|
153
180
|
const evidenceIds = new Set();
|
|
154
|
-
|
|
155
|
-
if (audit && !recordRelevantToAudit(evidence, audit, byId)) continue;
|
|
156
|
-
const direct = selectedIds.has(evidence.id)
|
|
157
|
-
|| (evidence.sourceResourceIds || []).some((id) => selectedIds.has(id))
|
|
158
|
-
|| overlapsEvidencePeriod(evidence, start, end);
|
|
159
|
-
if (direct) evidenceIds.add(evidence.id);
|
|
160
|
-
}
|
|
161
|
-
for (const id of [...selectedIds]) {
|
|
162
|
-
const record = byId.get(id);
|
|
163
|
-
addIds(evidenceIds, record?.evidenceIds);
|
|
164
|
-
addIds(evidenceIds, record?.sampleEvidenceIds);
|
|
165
|
-
if (record?.sourceEvidenceId) evidenceIds.add(record.sourceEvidenceId);
|
|
166
|
-
if (record?.populationId) selectedIds.add(record.populationId);
|
|
167
|
-
}
|
|
168
|
-
addIds(selectedIds, evidenceIds);
|
|
169
|
-
expandEvidenceWorkflowContext(selectedIds, byId);
|
|
170
|
-
for (const id of selectedIds) if (byId.get(id)?.type === "evidence") evidenceIds.add(id);
|
|
181
|
+
closeEvidenceSelection({ selectedIds, evidenceIds, records, scope, byId, start, end });
|
|
171
182
|
|
|
172
|
-
const controlIds = new Set(
|
|
183
|
+
const controlIds = new Set(scope?.controlIds || records
|
|
173
184
|
.filter((record) => record.type === "control" && !["not-applicable", "retired"].includes(record.status))
|
|
174
185
|
.map(({ id }) => id));
|
|
175
|
-
if (!
|
|
186
|
+
if (!scope) {
|
|
176
187
|
for (const id of selectedIds) {
|
|
177
188
|
const record = byId.get(id);
|
|
178
189
|
addIds(controlIds, record?.controlIds);
|
|
@@ -204,7 +215,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
204
215
|
selectedIds.add(complementaryControl.id);
|
|
205
216
|
}
|
|
206
217
|
}
|
|
207
|
-
for (const systemId of
|
|
218
|
+
for (const systemId of scope?.systemIds || []) {
|
|
208
219
|
const system = byId.get(systemId);
|
|
209
220
|
addIds(selectedIds, system?.subserviceVendorIds);
|
|
210
221
|
for (const commitment of records.filter((record) => (
|
|
@@ -214,9 +225,8 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
214
225
|
}
|
|
215
226
|
}
|
|
216
227
|
|
|
217
|
-
const policyIds = new Set(
|
|
218
|
-
|
|
219
|
-
: records.filter((record) => record.type === "policy" && ["approved", "active"].includes(record.status)).map((record) => record.id));
|
|
228
|
+
const policyIds = new Set(program?.policyIds || []);
|
|
229
|
+
if (!scope) addIds(policyIds, records.filter((record) => record.type === "policy" && ["approved", "active"].includes(record.status)).map((record) => record.id));
|
|
220
230
|
for (const id of selectedIds) addIds(policyIds, policyIdsFor(byId.get(id), byId));
|
|
221
231
|
for (const controlId of controlIds) addIds(policyIds, byId.get(controlId)?.policyIds);
|
|
222
232
|
expandSupersededPolicyIds(policyIds, byId);
|
|
@@ -224,12 +234,67 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
224
234
|
|
|
225
235
|
const requirementIds = new Set();
|
|
226
236
|
for (const controlId of controlIds) addIds(requirementIds, byId.get(controlId)?.requirementIds);
|
|
227
|
-
if (
|
|
237
|
+
if (scope) addIds(requirementIds, scope.requirementIds);
|
|
228
238
|
addIds(selectedIds, requirementIds);
|
|
229
239
|
|
|
240
|
+
const routeRequirementsApply = records.some((record) => (
|
|
241
|
+
reportingRouteSourceMayApply(record)
|
|
242
|
+
&& record.reportingRouteRequirements?.length
|
|
243
|
+
&& (!program || reportingRouteSourceAppliesToProgram(record, program, records))
|
|
244
|
+
));
|
|
245
|
+
const reportingRouteCoverage = modelSupports(loaded.model, "reporting-route-sets")
|
|
246
|
+
? audit && routeRequirementsApply && !audit.timezone
|
|
247
|
+
? {
|
|
248
|
+
supported: true,
|
|
249
|
+
start,
|
|
250
|
+
end,
|
|
251
|
+
timezone: null,
|
|
252
|
+
snapshots: [],
|
|
253
|
+
issues: [{
|
|
254
|
+
code: "missing-audit-timezone",
|
|
255
|
+
resourceId: audit.id,
|
|
256
|
+
message: `${audit.title} needs an IANA timezone before FileGRC can assess reporting-route coverage across its date boundaries.`
|
|
257
|
+
}]
|
|
258
|
+
}
|
|
259
|
+
: await assessReportingRoutePeriod(loaded, {
|
|
260
|
+
start,
|
|
261
|
+
end,
|
|
262
|
+
programId: program?.id || audit?.programId,
|
|
263
|
+
timezone: audit?.timezone || loaded.workspace.timezone
|
|
264
|
+
})
|
|
265
|
+
: null;
|
|
266
|
+
const reportingRouteSets = new Map();
|
|
267
|
+
for (const assessment of (reportingRouteCoverage?.snapshots || []).flatMap((snapshot) => governingReportingRouteSets(snapshot))) {
|
|
268
|
+
const current = reportingRouteSets.get(assessment.record.id);
|
|
269
|
+
reportingRouteSets.set(assessment.record.id, current ? {
|
|
270
|
+
...assessment,
|
|
271
|
+
authorities: [...new Map([...current.authorities, ...assessment.authorities].map((item) => [item.id, item])).values()]
|
|
272
|
+
} : assessment);
|
|
273
|
+
}
|
|
274
|
+
for (const { record: routeSet, authorities } of reportingRouteSets.values()) {
|
|
275
|
+
selectedIds.add(routeSet.id);
|
|
276
|
+
addIds(selectedIds, routeSet.sourceResourceIds);
|
|
277
|
+
addIds(selectedIds, authorities.map(({ id }) => id));
|
|
278
|
+
addIds(selectedIds, authorities.map(({ holderId }) => holderId));
|
|
279
|
+
for (const exception of records.filter((record) => (
|
|
280
|
+
record.type === "exception" && record.reportingRouteSetId === routeSet.id
|
|
281
|
+
))) selectedIds.add(exception.id);
|
|
282
|
+
}
|
|
283
|
+
for (const snapshot of reportingRouteCoverage?.snapshots || []) {
|
|
284
|
+
addIds(selectedIds, snapshot.requirements.map(({ sourceId }) => sourceId));
|
|
285
|
+
}
|
|
286
|
+
closeEvidenceSelection({ selectedIds, evidenceIds, records, scope, byId, start, end });
|
|
287
|
+
|
|
288
|
+
const historyRevision = await getWorkspaceRevisionSnapshot(loaded.root);
|
|
230
289
|
const sourceRevisionValidity = new Map();
|
|
231
290
|
const revisionIsValid = (revision) => {
|
|
232
|
-
if (!sourceRevisionValidity.has(revision))
|
|
291
|
+
if (!sourceRevisionValidity.has(revision)) {
|
|
292
|
+
sourceRevisionValidity.set(revision, Boolean(
|
|
293
|
+
historyRevision.commit
|
|
294
|
+
&& hasGitRevision(loaded.root, revision)
|
|
295
|
+
&& isDataHistoryAncestor(loaded, revision, historyRevision.commit)
|
|
296
|
+
));
|
|
297
|
+
}
|
|
233
298
|
return sourceRevisionValidity.get(revision);
|
|
234
299
|
};
|
|
235
300
|
const evidence = [...evidenceIds].map((id) => evidenceSummary(byId.get(id), byId, revisionIsValid)).filter(Boolean).sort(byTitle);
|
|
@@ -237,7 +302,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
237
302
|
const sourceSystemIds = new Set([
|
|
238
303
|
...(v4
|
|
239
304
|
? [...controlIds].flatMap((id) => byId.get(id)?.evidenceSourceComponentIds || [])
|
|
240
|
-
:
|
|
305
|
+
: scope?.systemIds || []),
|
|
241
306
|
...evidence.map((item) => item.sourceComponentId || item.sourceSystemId).filter(Boolean)
|
|
242
307
|
]);
|
|
243
308
|
const sourceSystems = [...sourceSystemIds]
|
|
@@ -249,13 +314,13 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
249
314
|
`data/${entry.relativePath}`,
|
|
250
315
|
...markdownEntries(loaded.model, entry.record).map((markdown) => `data/${markdown.path}`)
|
|
251
316
|
]);
|
|
252
|
-
const historyRevision = await getWorkspaceRevisionSnapshot(loaded.root);
|
|
253
317
|
const histories = getWorkspaceHistories(
|
|
254
318
|
loaded.root,
|
|
255
319
|
selectedPaths,
|
|
256
320
|
Number.MAX_SAFE_INTEGER,
|
|
257
321
|
{ strict: Boolean(historyRevision.commit) }
|
|
258
322
|
);
|
|
323
|
+
const identityHistories = getRecordIdentityHistories(loaded.root, selectedIds);
|
|
259
324
|
const packetRecords = [...selectedIds]
|
|
260
325
|
.map((id) => byId.get(id))
|
|
261
326
|
.filter(Boolean)
|
|
@@ -270,10 +335,10 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
270
335
|
dates: packetRecord(record, loaded.model, start, end, loaded.workspace.timezone)?.dates || [],
|
|
271
336
|
policyIds: policyIdsFor(record, byId),
|
|
272
337
|
evidenceIds: record.evidenceIds || [],
|
|
273
|
-
history:
|
|
338
|
+
history: identityHistories.get(record.id) || [],
|
|
274
339
|
contentPaths: contentPaths.map((contentPath) => ({
|
|
275
340
|
path: contentPath,
|
|
276
|
-
history: histories.get(contentPath) || []
|
|
341
|
+
history: getFileHistoryWithPaths(loaded.root, contentPath, Number.MAX_SAFE_INTEGER) || histories.get(contentPath) || []
|
|
277
342
|
}))
|
|
278
343
|
};
|
|
279
344
|
})
|
|
@@ -300,13 +365,21 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
300
365
|
!NON_EVIDENCE_RECORD_TYPES.has(record.type)
|
|
301
366
|
&& controlIdsForRecord(byId.get(record.id), byId).size
|
|
302
367
|
));
|
|
368
|
+
const referencedPopulationIds = new Set(controlCoverage.flatMap(({ tests }) => (
|
|
369
|
+
tests.map(({ populationId }) => populationId).filter(Boolean)
|
|
370
|
+
)));
|
|
303
371
|
const populations = (typeOne ? [] : records)
|
|
304
|
-
.filter((record) =>
|
|
372
|
+
.filter((record) => (
|
|
373
|
+
record.type === "audit-population"
|
|
374
|
+
&& (record.status !== "superseded" || referencedPopulationIds.has(record.id))
|
|
375
|
+
&& (!scope || recordRelevantToAudit(record, scope, byId))
|
|
376
|
+
))
|
|
305
377
|
.map((record) => populationSummary(record, byId))
|
|
306
378
|
.sort(byTitle);
|
|
307
379
|
const generatedAt = options.generatedAt || new Date().toISOString();
|
|
308
380
|
const managementPreparation = await assessAuditPreparation(loaded, {
|
|
309
381
|
auditId: audit?.id,
|
|
382
|
+
programId: program?.id,
|
|
310
383
|
generatedAt,
|
|
311
384
|
selectDefault: false
|
|
312
385
|
});
|
|
@@ -325,7 +398,8 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
325
398
|
records,
|
|
326
399
|
populations,
|
|
327
400
|
model: loaded.model,
|
|
328
|
-
managementPreparation
|
|
401
|
+
managementPreparation,
|
|
402
|
+
reportingRouteCoverage
|
|
329
403
|
});
|
|
330
404
|
const errorCount = gaps.filter(({ severity }) => severity === "error").length;
|
|
331
405
|
const warningCount = gaps.filter(({ severity }) => severity === "warning").length;
|
|
@@ -376,7 +450,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
376
450
|
policies: policyIds.size,
|
|
377
451
|
controls: controlIds.size,
|
|
378
452
|
requirements: requirementIds.size,
|
|
379
|
-
systems:
|
|
453
|
+
systems: scope?.systemIds?.length || 0,
|
|
380
454
|
[v4 ? "sourceComponents" : "sourceSystems"]: sourceSystems.length,
|
|
381
455
|
obligationOccurrences: obligations.length,
|
|
382
456
|
eventRuns: eventRuns.length,
|
|
@@ -398,6 +472,54 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
398
472
|
dataModelVersion: String(loaded.model.modelVersion),
|
|
399
473
|
populations,
|
|
400
474
|
...(modelSupports(loaded.model, "governed-document-activation") ? { documentLifecycles } : {}),
|
|
475
|
+
...(reportingRouteCoverage ? {
|
|
476
|
+
reportingRouteCoverage: {
|
|
477
|
+
start: reportingRouteCoverage.start,
|
|
478
|
+
end: reportingRouteCoverage.end,
|
|
479
|
+
timezone: reportingRouteCoverage.timezone,
|
|
480
|
+
snapshots: reportingRouteCoverage.snapshots.map((snapshot) => ({
|
|
481
|
+
at: snapshot.at,
|
|
482
|
+
requirements: snapshot.requirements,
|
|
483
|
+
routeSets: governingReportingRouteSets(snapshot).map(({
|
|
484
|
+
record,
|
|
485
|
+
committed,
|
|
486
|
+
effective,
|
|
487
|
+
canceled,
|
|
488
|
+
authorities,
|
|
489
|
+
approvalAssertionTiming,
|
|
490
|
+
cancellationAssertionTiming
|
|
491
|
+
}) => ({
|
|
492
|
+
id: record.id,
|
|
493
|
+
title: record.title,
|
|
494
|
+
status: record.status,
|
|
495
|
+
purposeKey: record.purposeKey,
|
|
496
|
+
predecessorId: record.predecessorId || null,
|
|
497
|
+
proposalCommit: record.proposalCommit || null,
|
|
498
|
+
approval: record.approval
|
|
499
|
+
? {
|
|
500
|
+
...record.approval,
|
|
501
|
+
assertionTiming: approvalAssertionTiming,
|
|
502
|
+
authorityRevision: reportingRouteEventCommit(loaded, record, "approval")
|
|
503
|
+
}
|
|
504
|
+
: null,
|
|
505
|
+
cancellation: record.cancellation
|
|
506
|
+
? {
|
|
507
|
+
...record.cancellation,
|
|
508
|
+
assertionTiming: cancellationAssertionTiming,
|
|
509
|
+
authorityRevision: reportingRouteEventCommit(loaded, record, "cancellation")
|
|
510
|
+
}
|
|
511
|
+
: null,
|
|
512
|
+
primaryLane: record.primaryLane,
|
|
513
|
+
alternateLane: record.alternateLane || null,
|
|
514
|
+
committed,
|
|
515
|
+
effective,
|
|
516
|
+
canceled,
|
|
517
|
+
authorityAppointmentIds: authorities.map(({ id }) => id)
|
|
518
|
+
})),
|
|
519
|
+
issues: snapshot.issues
|
|
520
|
+
}))
|
|
521
|
+
}
|
|
522
|
+
} : {}),
|
|
401
523
|
managementPreparation,
|
|
402
524
|
controlCoverage,
|
|
403
525
|
gaps,
|
|
@@ -473,6 +595,7 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
|
|
|
473
595
|
if (record.id === audit.id || record.auditId === audit.id || (record.auditIds || []).includes(audit.id)) return true;
|
|
474
596
|
if (record.auditId && record.auditId !== audit.id) return false;
|
|
475
597
|
if ((record.auditIds || []).length) return false;
|
|
598
|
+
if (audit.programScope && audit.programId && record.programId) return record.programId === audit.programId;
|
|
476
599
|
const selectedIds = new Set([
|
|
477
600
|
...(audit.frameworkIds || []),
|
|
478
601
|
...(audit.systemIds || []),
|
|
@@ -542,9 +665,48 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
|
|
|
542
665
|
for (let index = 0; index < queue.length; index += 1) {
|
|
543
666
|
const record = byId.get(queue[index]);
|
|
544
667
|
enqueue(childrenBySource.get(record?.id));
|
|
668
|
+
enqueue([
|
|
669
|
+
...(record?.completionResourceIds || []),
|
|
670
|
+
...(record?.evidenceIds || []),
|
|
671
|
+
...(record?.sampleEvidenceIds || []),
|
|
672
|
+
...(record?.exceptionIds || []),
|
|
673
|
+
...(record?.attestationIds || []),
|
|
674
|
+
record?.sourceEvidenceId,
|
|
675
|
+
record?.exceptionId
|
|
676
|
+
]);
|
|
545
677
|
if (record?.type === "evidence") enqueue([...(record.sourceResourceIds || []), record.sourceComponentId, record.sourceSystemId]);
|
|
546
678
|
if (record?.type === "audit-population") enqueue([record.sourceEvidenceId, ...(record.controlIds || [])]);
|
|
547
679
|
if (record?.type === "control-test") enqueue([record.populationId, ...(record.sampleEvidenceIds || [])]);
|
|
680
|
+
if (record?.type === "obligation-occurrence") {
|
|
681
|
+
enqueue((record.members || []).flatMap((member) => [
|
|
682
|
+
member.resourceId,
|
|
683
|
+
member.exceptionId,
|
|
684
|
+
...(member.completionResourceIds || [])
|
|
685
|
+
]));
|
|
686
|
+
enqueue([
|
|
687
|
+
record.obligationId,
|
|
688
|
+
record.ruleId,
|
|
689
|
+
record.collectionReviewId,
|
|
690
|
+
record.supersedesId
|
|
691
|
+
]);
|
|
692
|
+
}
|
|
693
|
+
if (record?.type === "attestation") enqueue([record.programId, record.reportingRouteId, record.reportingRouteSetId]);
|
|
694
|
+
if (record?.type === "reporting-route-set") {
|
|
695
|
+
enqueue([
|
|
696
|
+
...(record.sourceResourceIds || []),
|
|
697
|
+
record.predecessorId,
|
|
698
|
+
record.approval?.approvalAppointmentId,
|
|
699
|
+
record.approval?.approvedById,
|
|
700
|
+
...(record.approval?.evidenceIds || []),
|
|
701
|
+
record.cancellation?.authorityAppointmentId,
|
|
702
|
+
record.cancellation?.canceledById,
|
|
703
|
+
...(record.cancellation?.evidenceIds || []),
|
|
704
|
+
...(record.primaryLane?.dependencySystemIds || []),
|
|
705
|
+
...(record.alternateLane?.dependencySystemIds || [])
|
|
706
|
+
]);
|
|
707
|
+
}
|
|
708
|
+
if (record?.type === "appointment") enqueue([record.holderId]);
|
|
709
|
+
if (record?.type === "exception") enqueue([...(record.evidenceIds || []), ...(record.attestationIds || [])]);
|
|
548
710
|
if (record?.type === "action-item") {
|
|
549
711
|
enqueue([
|
|
550
712
|
record.sourceResourceId,
|
|
@@ -557,6 +719,30 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
|
|
|
557
719
|
}
|
|
558
720
|
}
|
|
559
721
|
|
|
722
|
+
function closeEvidenceSelection({ selectedIds, evidenceIds, records, scope, byId, start, end }) {
|
|
723
|
+
let previousSize = -1;
|
|
724
|
+
while (previousSize !== selectedIds.size + evidenceIds.size) {
|
|
725
|
+
previousSize = selectedIds.size + evidenceIds.size;
|
|
726
|
+
for (const evidence of records.filter((record) => record.type === "evidence")) {
|
|
727
|
+
if (scope && !recordRelevantToAudit(evidence, scope, byId)) continue;
|
|
728
|
+
const direct = selectedIds.has(evidence.id)
|
|
729
|
+
|| (evidence.sourceResourceIds || []).some((id) => selectedIds.has(id))
|
|
730
|
+
|| overlapsEvidencePeriod(evidence, start, end);
|
|
731
|
+
if (direct) evidenceIds.add(evidence.id);
|
|
732
|
+
}
|
|
733
|
+
for (const id of [...selectedIds]) {
|
|
734
|
+
const record = byId.get(id);
|
|
735
|
+
addIds(evidenceIds, record?.evidenceIds);
|
|
736
|
+
addIds(evidenceIds, record?.sampleEvidenceIds);
|
|
737
|
+
if (record?.sourceEvidenceId) evidenceIds.add(record.sourceEvidenceId);
|
|
738
|
+
if (record?.populationId) selectedIds.add(record.populationId);
|
|
739
|
+
}
|
|
740
|
+
addIds(selectedIds, evidenceIds);
|
|
741
|
+
expandEvidenceWorkflowContext(selectedIds, byId);
|
|
742
|
+
for (const id of selectedIds) if (byId.get(id)?.type === "evidence") evidenceIds.add(id);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
560
746
|
export async function writeEvidencePacket(input, packet, options = {}) {
|
|
561
747
|
const baseName = `${packet.period.start}-to-${packet.period.end}-${packet.revision.shortCommit || "uncommitted"}`;
|
|
562
748
|
let outputOption = options.output || `.filegrc/evidence-packets/${baseName}`;
|
|
@@ -641,11 +827,16 @@ export async function writeEvidencePacket(input, packet, options = {}) {
|
|
|
641
827
|
collectCommittedVersions(historicalFiles, item, content.path, content.history);
|
|
642
828
|
}
|
|
643
829
|
}
|
|
644
|
-
const
|
|
645
|
-
|
|
830
|
+
const uniqueHistoricalFiles = historicalFiles.filter((file, index, all) => all.findIndex((candidate) => (
|
|
831
|
+
candidate.item.id === file.item.id
|
|
832
|
+
&& candidate.history.commit === file.history.commit
|
|
833
|
+
&& candidate.sourcePath === file.sourcePath
|
|
834
|
+
)) === index);
|
|
835
|
+
const historicalSources = getFilesAtRevisions(validation.loaded.root, uniqueHistoricalFiles);
|
|
836
|
+
for (let index = 0; index < uniqueHistoricalFiles.length; index += 1) {
|
|
646
837
|
const source = historicalSources[index];
|
|
647
838
|
if (source === null) continue;
|
|
648
|
-
const { item, sourcePath, history } =
|
|
839
|
+
const { item, sourcePath, history } = uniqueHistoricalFiles[index];
|
|
649
840
|
const exportedPath = join("history", item.type, item.id, history.commit, basename(sourcePath));
|
|
650
841
|
await writePacketFile(output, exportedPath, source, files);
|
|
651
842
|
historyIndex.push({
|
|
@@ -678,7 +869,13 @@ export function generateEvidencePacket(input, options = {}) {
|
|
|
678
869
|
|
|
679
870
|
function collectCommittedVersions(target, item, sourcePath, history) {
|
|
680
871
|
for (const revision of history || []) {
|
|
681
|
-
target.push({
|
|
872
|
+
target.push({
|
|
873
|
+
item,
|
|
874
|
+
sourcePath: revision.path || sourcePath,
|
|
875
|
+
relativePath: revision.path || sourcePath,
|
|
876
|
+
revision: revision.commit,
|
|
877
|
+
history: revision
|
|
878
|
+
});
|
|
682
879
|
}
|
|
683
880
|
}
|
|
684
881
|
|
|
@@ -1117,12 +1314,53 @@ function packetGaps({
|
|
|
1117
1314
|
records,
|
|
1118
1315
|
populations,
|
|
1119
1316
|
model,
|
|
1120
|
-
managementPreparation
|
|
1317
|
+
managementPreparation,
|
|
1318
|
+
reportingRouteCoverage
|
|
1121
1319
|
}) {
|
|
1122
1320
|
const gaps = [];
|
|
1123
1321
|
if (!git.commit) gaps.push(gap("error", "uncommitted-workspace", "The workspace has no Git revision to bind this packet to."));
|
|
1124
1322
|
else if (!git.clean) gaps.push(gap("error", "dirty-workspace", "Commit or discard workspace changes before treating this packet as audit evidence."));
|
|
1125
1323
|
|
|
1324
|
+
for (const issue of reportingRouteCoverage?.issues || []) {
|
|
1325
|
+
gaps.push(gap("error", issue.code, issue.message, issue.resourceId));
|
|
1326
|
+
}
|
|
1327
|
+
const routeAssessments = (reportingRouteCoverage?.snapshots || []).flatMap(({ routeSets }) => routeSets);
|
|
1328
|
+
for (const assessment of [...new Map(routeAssessments.map((item) => [item.record.id, item])).values()]) {
|
|
1329
|
+
const route = assessment.record;
|
|
1330
|
+
if (assessment.approvalAssertionTiming === "git-recorded-later") {
|
|
1331
|
+
gaps.push(gap(
|
|
1332
|
+
"warning",
|
|
1333
|
+
"non-contemporaneous-reporting-route-approval",
|
|
1334
|
+
`${route.title} has a Git committer timestamp more than one day after the stated approval time. Committer timestamps are user-controlled metadata, so management and the engagement team must evaluate the linked fixed evidence itself.`,
|
|
1335
|
+
route.id
|
|
1336
|
+
));
|
|
1337
|
+
}
|
|
1338
|
+
if (assessment.approvalAssertionTiming === "git-recorded-before-event") {
|
|
1339
|
+
gaps.push(gap(
|
|
1340
|
+
"warning",
|
|
1341
|
+
"reporting-route-approval-recorded-before-event",
|
|
1342
|
+
`${route.title} has a Git committer timestamp before its stated approval time. Committer timestamps are user-controlled metadata; review the linked fixed evidence before relying on this approval.`,
|
|
1343
|
+
route.id
|
|
1344
|
+
));
|
|
1345
|
+
}
|
|
1346
|
+
if (assessment.cancellationAssertionTiming === "git-recorded-later") {
|
|
1347
|
+
gaps.push(gap(
|
|
1348
|
+
"warning",
|
|
1349
|
+
"non-contemporaneous-reporting-route-cancellation",
|
|
1350
|
+
`${route.title} has a Git committer timestamp more than one day after the stated cancellation time. Committer timestamps are user-controlled metadata; review the linked fixed evidence.`,
|
|
1351
|
+
route.id
|
|
1352
|
+
));
|
|
1353
|
+
}
|
|
1354
|
+
if (assessment.cancellationAssertionTiming === "git-recorded-before-event") {
|
|
1355
|
+
gaps.push(gap(
|
|
1356
|
+
"warning",
|
|
1357
|
+
"reporting-route-cancellation-recorded-before-event",
|
|
1358
|
+
`${route.title} has a Git committer timestamp before its stated cancellation time. Committer timestamps are user-controlled metadata; review the linked fixed evidence before relying on this cancellation.`,
|
|
1359
|
+
route.id
|
|
1360
|
+
));
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1126
1364
|
if (!audit) {
|
|
1127
1365
|
gaps.push(gap("error", "missing-audit-scope", "Select an audit record before treating this packet as an auditor delivery."));
|
|
1128
1366
|
} else {
|
|
@@ -1198,6 +1436,14 @@ function packetGaps({
|
|
|
1198
1436
|
if (Number.isInteger(testRecord?.exceptionCount) && testRecord.exceptionCount < 0) {
|
|
1199
1437
|
gaps.push(gap("error", "control-test-exception-count-invalid", `${coverage.code || coverage.title} records a negative exception count.`, test.id));
|
|
1200
1438
|
}
|
|
1439
|
+
if (test.populationStatus === "superseded") {
|
|
1440
|
+
gaps.push(gap(
|
|
1441
|
+
"error",
|
|
1442
|
+
"superseded-test-population",
|
|
1443
|
+
`${coverage.code || coverage.title} test ${test.id} used superseded population ${test.populationId}. Perform and review a replacement test against ${test.replacementPopulationId || "the corrected population"}.`,
|
|
1444
|
+
test.id
|
|
1445
|
+
));
|
|
1446
|
+
}
|
|
1201
1447
|
const testFindings = records.filter((record) => record.type === "finding" && record.sourceResourceId === test.id);
|
|
1202
1448
|
if ((testRecord?.exceptionCount > 0 || ["failed", "passed-with-exceptions"].includes(test.outcome)) && !testFindings.length) {
|
|
1203
1449
|
gaps.push(gap("error", "control-test-finding-missing", `${coverage.code || coverage.title} records exceptions or failure without a linked finding.`, test.id));
|
|
@@ -1309,7 +1555,12 @@ function packetGaps({
|
|
|
1309
1555
|
|
|
1310
1556
|
for (const item of obligations) {
|
|
1311
1557
|
if (item.dueWindowEnd <= end && item.status !== "complete") {
|
|
1312
|
-
gaps.push(gap(
|
|
1558
|
+
gaps.push(gap(
|
|
1559
|
+
"error",
|
|
1560
|
+
"missing-obligation-completion",
|
|
1561
|
+
`${item.title} is not reconciled for ${item.dueWindowStart} through ${item.dueWindowEnd} (${item.completedCount || 0} of ${item.expectedCount || 0} expected members passed).`,
|
|
1562
|
+
item.occurrenceId || item.obligationId
|
|
1563
|
+
));
|
|
1313
1564
|
}
|
|
1314
1565
|
}
|
|
1315
1566
|
for (const run of eventRuns) {
|
|
@@ -1336,6 +1587,20 @@ function packetGaps({
|
|
|
1336
1587
|
`${run.title}: ${action.title} is marked ${action.recordedStatus} but has no linked ${action.expectedCompletionTypes.join(" or ")} completion record.`,
|
|
1337
1588
|
action.actionItemId
|
|
1338
1589
|
));
|
|
1590
|
+
} else if (action.timelinessStatus === "unknown") {
|
|
1591
|
+
gaps.push(gap(
|
|
1592
|
+
"error",
|
|
1593
|
+
"event-completion-time-missing",
|
|
1594
|
+
`${run.title}: ${action.title} has an hour-based deadline but its completion proof has no exact timestamp. Link proof with an RFC 3339 completion time or record and resolve a timeliness exception.`,
|
|
1595
|
+
action.actionItemId
|
|
1596
|
+
));
|
|
1597
|
+
} else if (action.lateCompletion) {
|
|
1598
|
+
gaps.push(gap(
|
|
1599
|
+
"error",
|
|
1600
|
+
"late-event-completion",
|
|
1601
|
+
`${run.title}: ${action.title} was completed after ${action.dueWindowEndAt || action.dueWindowEnd}. Record and resolve the timeliness exception.`,
|
|
1602
|
+
action.actionItemId
|
|
1603
|
+
));
|
|
1339
1604
|
} else if (action.dueWindowEnd && action.dueWindowEnd <= end && action.status !== "complete") {
|
|
1340
1605
|
const cutoff = action.dueWindowEndAt || action.dueWindowEnd;
|
|
1341
1606
|
gaps.push(gap("error", "incomplete-event-action", `${run.title}: ${action.title} was not completed by ${cutoff}.`, action.actionItemId));
|
|
@@ -1343,7 +1608,7 @@ function packetGaps({
|
|
|
1343
1608
|
}
|
|
1344
1609
|
}
|
|
1345
1610
|
for (const item of evidence) {
|
|
1346
|
-
if (item.
|
|
1611
|
+
if (item.sourceKind === "rendered-page" && !item.sourceCommit) {
|
|
1347
1612
|
gaps.push(gap("error", "unbound-rendered-evidence", `${item.title} does not name the Git revision that was rendered.`, item.id));
|
|
1348
1613
|
} else if (item.sourceCommit && !item.sourceCommitValid) {
|
|
1349
1614
|
gaps.push(gap("error", "invalid-evidence-revision", `${item.title} names a source Git revision that is not available in this repository.`, item.id));
|
|
@@ -1385,7 +1650,7 @@ function packetGaps({
|
|
|
1385
1650
|
if (item.externalReference && !item.filePaths.length) {
|
|
1386
1651
|
gaps.push(gap("warning", "external-only-evidence", `${item.title} relies on an external reference and is not self-contained in the packet.`, item.id));
|
|
1387
1652
|
}
|
|
1388
|
-
if (item.
|
|
1653
|
+
if (item.sourceKind === "rendered-page") {
|
|
1389
1654
|
const captureComplete = item.capture
|
|
1390
1655
|
&& typeof item.capture.route === "string"
|
|
1391
1656
|
&& item.capture.route.trim()
|
|
@@ -1797,8 +2062,17 @@ function sourceSystemSummary(record, evidence, audit) {
|
|
|
1797
2062
|
function testPopulationSummary(test, byId) {
|
|
1798
2063
|
const population = byId.get(test.populationId);
|
|
1799
2064
|
const evidence = byId.get(population?.sourceEvidenceId);
|
|
2065
|
+
const replacement = population?.status === "superseded"
|
|
2066
|
+
? [...byId.values()].find((record) => (
|
|
2067
|
+
record.type === "audit-population"
|
|
2068
|
+
&& record.supersedesId === population.id
|
|
2069
|
+
&& record.status !== "superseded"
|
|
2070
|
+
))
|
|
2071
|
+
: null;
|
|
1800
2072
|
return {
|
|
1801
2073
|
populationId: test.populationId || null,
|
|
2074
|
+
populationStatus: population?.status || null,
|
|
2075
|
+
replacementPopulationId: replacement?.id || null,
|
|
1802
2076
|
populationCount: evidence?.populationCount ?? null,
|
|
1803
2077
|
populationEvidenceId: population?.sourceEvidenceId || null
|
|
1804
2078
|
};
|
|
@@ -1806,15 +2080,16 @@ function testPopulationSummary(test, byId) {
|
|
|
1806
2080
|
|
|
1807
2081
|
function populationGaps(gaps, audit, populations, byId, model) {
|
|
1808
2082
|
const expected = model.auditReadiness?.populationTemplates || [];
|
|
2083
|
+
const currentPopulations = populations.filter(({ status }) => status !== "superseded");
|
|
1809
2084
|
for (const template of expected) {
|
|
1810
|
-
const matching =
|
|
2085
|
+
const matching = currentPopulations.filter((population) => population.populationKind === template.kind);
|
|
1811
2086
|
if (!matching.length) {
|
|
1812
2087
|
gaps.push(gap("error", "missing-audit-population", `${audit.title} is missing the ${template.title} population.`, audit.id));
|
|
1813
2088
|
} else if (matching.length > 1) {
|
|
1814
2089
|
gaps.push(gap("error", "duplicate-audit-population", `${audit.title} has more than one ${template.title} population.`, audit.id));
|
|
1815
2090
|
}
|
|
1816
2091
|
}
|
|
1817
|
-
for (const population of
|
|
2092
|
+
for (const population of currentPopulations) {
|
|
1818
2093
|
if (!coverageMatches(population.coverage, coverageStart(audit.coverage), coverageEnd(audit.coverage))) {
|
|
1819
2094
|
gaps.push(gap("error", "population-period-mismatch", `${population.title} does not match the exact audit period.`, population.id));
|
|
1820
2095
|
}
|
|
@@ -2053,6 +2328,13 @@ function addIds(target, values = []) {
|
|
|
2053
2328
|
for (const value of values) if (value) target.add(value);
|
|
2054
2329
|
}
|
|
2055
2330
|
|
|
2331
|
+
function governingReportingRouteSets(snapshot) {
|
|
2332
|
+
const requiredPurposes = new Set(snapshot.requirements.map(({ purposeKey }) => purposeKey));
|
|
2333
|
+
return snapshot.routeSets.filter(({ record, effective, canceled }) => (
|
|
2334
|
+
effective && !canceled && requiredPurposes.has(record.purposeKey)
|
|
2335
|
+
));
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2056
2338
|
function requireDate(value, label) {
|
|
2057
2339
|
if (!parseCalendarDate(value)) throw new Error(`A valid ${label} is required.`);
|
|
2058
2340
|
return value;
|