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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "filegrc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Zero-dependency Git-native GRC engine",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"src"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"test": "node --test"
|
|
24
|
+
"test": "node --test --test-concurrency=1"
|
|
25
25
|
},
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=20"
|
package/src/agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createResourceId } from "./id.js";
|
|
2
2
|
import { markdownEntries } from "./resource-markdown.js";
|
|
3
3
|
import { RESOURCE_INSTRUCTIONS, resourceProgramContext } from "./program-path.js";
|
|
4
|
+
import { assessCollectionReview } from "./collection-review.js";
|
|
4
5
|
|
|
5
6
|
const STARTING_STATUS_ORDER = [
|
|
6
7
|
"draft",
|
|
@@ -28,6 +29,7 @@ export function listResourceTypes(model) {
|
|
|
28
29
|
export function buildAgentGuide(loaded, type, options = {}) {
|
|
29
30
|
const definition = loaded.model.resources[type];
|
|
30
31
|
if (!definition) throw new Error(`Unknown resource type "${type}".`);
|
|
32
|
+
const collectionReview = assessCollectionReview(loaded, type);
|
|
31
33
|
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
32
34
|
const required = new Set([
|
|
33
35
|
...Object.entries(loaded.model.commonFields)
|
|
@@ -66,6 +68,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
66
68
|
const requiredAtCreation = fieldList.filter(({ required: isRequired }) => isRequired);
|
|
67
69
|
const conditionalRequirements = fieldList.filter(({ requiredWhen, required: isRequired }) => requiredWhen && !isRequired);
|
|
68
70
|
const optionalFields = fieldList.filter(({ required, requiredWhen }) => !required && !requiredWhen);
|
|
71
|
+
const recommendedMarkdown = markdown.filter(({ recommended }) => recommended);
|
|
69
72
|
const location = definition.singleton
|
|
70
73
|
? `data/${definition.singleton}`
|
|
71
74
|
: `data/${definition.collection}/${(definition.recordPath ?? "{id}.json").replaceAll("{id}", options.id || "{id}")}`;
|
|
@@ -80,8 +83,23 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
80
83
|
programStep: resourceProgramContext(type),
|
|
81
84
|
policyBasis: definition.guidance.policyBasis,
|
|
82
85
|
cadence: definition.guidance.cadence,
|
|
86
|
+
emptyState: definition.guidance.emptyState ?? null,
|
|
83
87
|
policySourceIds: definition.guidance.sourceResourceIds ?? [],
|
|
84
88
|
obligationActivityTypes: definition.guidance.obligationActivityTypes ?? [],
|
|
89
|
+
reviewRequirements: {
|
|
90
|
+
recordReviewPoints: definition.guidance.reviewPoints ?? [],
|
|
91
|
+
collectionReview: collectionReview
|
|
92
|
+
? {
|
|
93
|
+
title: collectionReview.configuration.title,
|
|
94
|
+
description: collectionReview.configuration.description,
|
|
95
|
+
reviewPoints: collectionReview.configuration.reviewPoints,
|
|
96
|
+
allowedDecisions: collectionReview.configuration.decisions,
|
|
97
|
+
status: collectionReview.status,
|
|
98
|
+
recordCount: collectionReview.recordCount,
|
|
99
|
+
command: `npx filegrc review-collection ${type} --scaffold`
|
|
100
|
+
}
|
|
101
|
+
: null
|
|
102
|
+
},
|
|
85
103
|
location,
|
|
86
104
|
singleton: Boolean(definition.singleton),
|
|
87
105
|
requiredAtCreation,
|
|
@@ -91,15 +109,21 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
91
109
|
markdown,
|
|
92
110
|
workflow: [
|
|
93
111
|
"Inspect existing records and relation candidates before writing.",
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
definition.singleton
|
|
113
|
+
? "Open the existing singleton record, then replace every null value and empty required array with facts from an authoritative source."
|
|
114
|
+
: "Create a scaffold, then replace every null value and empty required array with facts from an authoritative source.",
|
|
115
|
+
recommendedMarkdown.length
|
|
116
|
+
? "Keep model fields in JSON and use the recommended Markdown companion for the detailed work, decisions, results, exceptions, and follow-up that apply to this record."
|
|
117
|
+
: "Keep the current facts and lifecycle state in JSON. Add optional Record Markdown only when the model fields cannot explain the record clearly.",
|
|
96
118
|
"Run npx filegrc validate, review the full Git diff, and commit the JSON, Markdown, and attachments together with a message that explains why the record changed."
|
|
97
119
|
],
|
|
98
120
|
completionChecks: [
|
|
99
|
-
"
|
|
121
|
+
"Required and status-dependent fields are complete, and the lifecycle status matches the facts.",
|
|
100
122
|
"Every relationship resolves to the intended existing record.",
|
|
101
123
|
"Dates describe the business event in the workspace time zone, not the file edit time.",
|
|
102
|
-
|
|
124
|
+
...(recommendedMarkdown.length
|
|
125
|
+
? ["Required or recommended Markdown explains the work, decisions, results, exceptions, and follow-up that apply."]
|
|
126
|
+
: ["The structured fields state the current fact clearly; optional Record Markdown is added only when needed."]),
|
|
103
127
|
"No secrets or personal data that may need erasure were added to Git."
|
|
104
128
|
]
|
|
105
129
|
};
|
|
@@ -131,15 +155,24 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
|
|
|
131
155
|
...(definition.required ?? [])
|
|
132
156
|
]);
|
|
133
157
|
const record = {
|
|
134
|
-
schemaVersion: 1,
|
|
135
158
|
id,
|
|
136
159
|
type,
|
|
137
160
|
title: normalizedTitle
|
|
138
161
|
};
|
|
139
162
|
for (const name of required) {
|
|
140
163
|
if (record[name] !== undefined) continue;
|
|
141
|
-
record[name] = scaffoldValue(name, fields[name]);
|
|
164
|
+
record[name] = scaffoldValue(name, fields[name], loaded.model);
|
|
165
|
+
}
|
|
166
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
167
|
+
if (
|
|
168
|
+
record[name] === undefined
|
|
169
|
+
&& field.requiredWhen
|
|
170
|
+
&& conditionMatches(record, field.requiredWhen)
|
|
171
|
+
) {
|
|
172
|
+
record[name] = scaffoldValue(name, field, loaded.model);
|
|
173
|
+
}
|
|
142
174
|
}
|
|
175
|
+
applyModelScaffoldDefaults(record, loaded);
|
|
143
176
|
|
|
144
177
|
const slots = markdownEntries(loaded.model, record).filter((slot) => (
|
|
145
178
|
slot.required
|
|
@@ -156,6 +189,41 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
|
|
|
156
189
|
};
|
|
157
190
|
}
|
|
158
191
|
|
|
192
|
+
function applyModelScaffoldDefaults(record, loaded) {
|
|
193
|
+
if (record.type === "appointment") {
|
|
194
|
+
const normalizedTitle = record.title.toLowerCase();
|
|
195
|
+
const match = Object.entries(loaded.model.appointmentTemplates || {}).find(([kind, template]) => (
|
|
196
|
+
template.title.toLowerCase() === normalizedTitle
|
|
197
|
+
|| kind === normalizedTitle.replace(/[^a-z0-9]+/g, "-")
|
|
198
|
+
));
|
|
199
|
+
record.appointmentKind = match?.[0] || normalizedTitle.replace(/[^a-z0-9]+/g, "-");
|
|
200
|
+
if (!record.scopeResourceIds?.length && loaded.workspace?.id) {
|
|
201
|
+
record.scopeResourceIds = [loaded.workspace.id];
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (record.type === "audit") {
|
|
206
|
+
const kind = {
|
|
207
|
+
"soc-2-type-1": "soc-2-type-1",
|
|
208
|
+
"soc-2-type-2": "soc-2-type-2"
|
|
209
|
+
}[loaded.workspace?.assuranceGoal];
|
|
210
|
+
if (kind) record.auditKind = kind;
|
|
211
|
+
for (const field of ["frameworkIds", "systemIds", "requirementIds", "controlIds"]) {
|
|
212
|
+
if (loaded.workspace?.[field]?.length) record[field] = [...loaded.workspace[field]];
|
|
213
|
+
}
|
|
214
|
+
const programOwner = loaded.resources.find((candidate) => (
|
|
215
|
+
candidate.type === "appointment"
|
|
216
|
+
&& candidate.appointmentKind === "program-lead"
|
|
217
|
+
&& candidate.status === "active"
|
|
218
|
+
)) || loaded.resources.find((candidate) => (
|
|
219
|
+
candidate.type === "appointment"
|
|
220
|
+
&& candidate.appointmentKind === "policy-owner"
|
|
221
|
+
&& candidate.status === "active"
|
|
222
|
+
));
|
|
223
|
+
if (programOwner) record.ownerIds = [programOwner.id];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
159
227
|
export function findResourceReferences(loaded, id) {
|
|
160
228
|
const target = loaded.resources.find((record) => record.id === id);
|
|
161
229
|
if (!target) throw new Error(`Resource "${id}" was not found.`);
|
|
@@ -210,17 +278,30 @@ function allowedValues(model, field) {
|
|
|
210
278
|
return null;
|
|
211
279
|
}
|
|
212
280
|
|
|
213
|
-
function scaffoldValue(name, field = {}) {
|
|
281
|
+
function scaffoldValue(name, field = {}, model) {
|
|
214
282
|
if (field.const !== undefined) return field.const;
|
|
215
283
|
if (name === "status" && field.values) {
|
|
216
284
|
return STARTING_STATUS_ORDER.find((value) => field.values.includes(value)) ?? field.values[0] ?? null;
|
|
217
285
|
}
|
|
218
286
|
if (field.type === "array") return [];
|
|
219
|
-
if (field.type === "object")
|
|
287
|
+
if (field.type === "object") {
|
|
288
|
+
const schema = model?.objectTypes?.[field.objectType];
|
|
289
|
+
if (!schema) return {};
|
|
290
|
+
return Object.fromEntries((schema.required || []).map((propertyName) => [
|
|
291
|
+
propertyName,
|
|
292
|
+
scaffoldValue(propertyName, schema.properties?.[propertyName], model)
|
|
293
|
+
]));
|
|
294
|
+
}
|
|
220
295
|
if (field.type === "boolean") return false;
|
|
221
296
|
return null;
|
|
222
297
|
}
|
|
223
298
|
|
|
299
|
+
function conditionMatches(record, condition) {
|
|
300
|
+
return Object.entries(condition || {}).every(([name, expected]) => (
|
|
301
|
+
Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
|
|
302
|
+
));
|
|
303
|
+
}
|
|
304
|
+
|
|
224
305
|
function markdownScaffold(title, type, slot) {
|
|
225
306
|
const heading = slot.label === "Record" ? title : `${title}: ${slot.label}`;
|
|
226
307
|
const sections = slot.name === "agenda"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function assessRequiredAppointments(records, model) {
|
|
2
|
+
const templates = model.appointmentTemplates || {};
|
|
3
|
+
return Object.entries(templates).map(([kind, template]) => {
|
|
4
|
+
const appointments = records.filter((record) => (
|
|
5
|
+
record.type === "appointment"
|
|
6
|
+
&& record.appointmentKind === kind
|
|
7
|
+
&& record.status !== "ended"
|
|
8
|
+
));
|
|
9
|
+
const active = appointments.find(({ status }) => status === "active");
|
|
10
|
+
const planned = appointments.find(({ status }) => status === "planned");
|
|
11
|
+
return {
|
|
12
|
+
kind,
|
|
13
|
+
template,
|
|
14
|
+
requiredness: template.requiredness,
|
|
15
|
+
record: active || planned || null,
|
|
16
|
+
state: active ? "complete" : "ready"
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
}
|
package/src/audit-preparation.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
coverageContains,
|
|
5
|
+
coverageEnd,
|
|
6
|
+
coverageLabel,
|
|
7
|
+
coverageMatches,
|
|
8
|
+
coverageOverlaps,
|
|
9
|
+
coverageStart
|
|
10
|
+
} from "./coverage.js";
|
|
3
11
|
import { createResource, createResources, deleteResource, updateResource } from "./files.js";
|
|
4
12
|
import { createResourceId } from "./id.js";
|
|
5
|
-
import { partiesIndependent } from "./parties.js";
|
|
13
|
+
import { currentPartyPeople, partiesIndependent } from "./parties.js";
|
|
6
14
|
import { resolveDataPath } from "./paths.js";
|
|
7
15
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
8
16
|
import { markdownEntries } from "./resource-markdown.js";
|
|
@@ -85,9 +93,8 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
85
93
|
counts,
|
|
86
94
|
canInitialize: Boolean(audit
|
|
87
95
|
&& ["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)
|
|
88
|
-
&& (audit.
|
|
89
|
-
|
|
90
|
-
: audit.periodStart && audit.periodEnd)
|
|
96
|
+
&& coverageStart(audit.coverage)
|
|
97
|
+
&& coverageEnd(audit.coverage)
|
|
91
98
|
&& initializationNeeded(audit, records, loaded.model)),
|
|
92
99
|
stages
|
|
93
100
|
};
|
|
@@ -100,10 +107,10 @@ export async function prepareAuditWorkspace(input, options = {}) {
|
|
|
100
107
|
if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
|
|
101
108
|
throw new Error("Audit preparation requires a SOC 2 Type 1 or Type 2 engagement.");
|
|
102
109
|
}
|
|
103
|
-
if (audit.auditKind === "soc-2-type-2" &&
|
|
110
|
+
if (audit.auditKind === "soc-2-type-2" && audit.coverage?.kind !== "range") {
|
|
104
111
|
throw new Error("Set the Type 2 audit period before initializing audit preparation.");
|
|
105
112
|
}
|
|
106
|
-
if (audit.auditKind === "soc-2-type-1" &&
|
|
113
|
+
if (audit.auditKind === "soc-2-type-1" && audit.coverage?.kind !== "as-of") {
|
|
107
114
|
throw new Error("Set the Type 1 as-of date before initializing audit preparation.");
|
|
108
115
|
}
|
|
109
116
|
|
|
@@ -167,15 +174,13 @@ export async function prepareAuditWorkspace(input, options = {}) {
|
|
|
167
174
|
(system.evidenceSourceKinds || []).includes(template.sourceKind)
|
|
168
175
|
));
|
|
169
176
|
return {
|
|
170
|
-
schemaVersion: 1,
|
|
171
177
|
id,
|
|
172
178
|
type: "audit-population",
|
|
173
179
|
title: template.title,
|
|
174
180
|
status: "planned",
|
|
175
181
|
auditId: audit.id,
|
|
176
182
|
populationKind: template.kind,
|
|
177
|
-
|
|
178
|
-
periodEnd: audit.periodEnd,
|
|
183
|
+
coverage: structuredClone(audit.coverage),
|
|
179
184
|
ownerIds: [...audit.ownerIds],
|
|
180
185
|
...(controlIds.length ? { controlIds } : {}),
|
|
181
186
|
...(matchingSources.length === 1 ? { sourceSystemId: matchingSources[0].id } : {}),
|
|
@@ -219,9 +224,9 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
const periodComplete = audit.auditKind === "soc-2-type-2"
|
|
222
|
-
? audit.
|
|
227
|
+
? audit.coverage?.kind === "range"
|
|
223
228
|
: audit.auditKind === "soc-2-type-1"
|
|
224
|
-
? audit.
|
|
229
|
+
? audit.coverage?.kind === "as-of"
|
|
225
230
|
: false;
|
|
226
231
|
items.push(item(
|
|
227
232
|
"period",
|
|
@@ -229,8 +234,8 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
229
234
|
"Set the auditor-agreed report type and date",
|
|
230
235
|
periodComplete
|
|
231
236
|
? audit.auditKind === "soc-2-type-2"
|
|
232
|
-
? `Auditor-agreed Type 2 period: ${audit.
|
|
233
|
-
: `Auditor-agreed Type 1 as-of date: ${audit.
|
|
237
|
+
? `Auditor-agreed Type 2 period: ${coverageLabel(audit.coverage)}.`
|
|
238
|
+
: `Auditor-agreed Type 1 as-of date: ${coverageLabel(audit.coverage)}.`
|
|
234
239
|
: audit.auditKind === "soc-2-type-1"
|
|
235
240
|
? "Set the Type 1 as-of date."
|
|
236
241
|
: audit.auditKind === "soc-2-type-2"
|
|
@@ -238,11 +243,9 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
238
243
|
: "Change this readiness record to a Type 1 or Type 2 engagement before planning the report.",
|
|
239
244
|
audit
|
|
240
245
|
));
|
|
241
|
-
if (audit.auditKind === "soc-2-type-2" && programReadiness.target.
|
|
242
|
-
const candidate =
|
|
243
|
-
|
|
244
|
-
.join(" through ");
|
|
245
|
-
const agreed = [audit.periodStart, audit.periodEnd].filter(Boolean).join(" through ");
|
|
246
|
+
if (audit.auditKind === "soc-2-type-2" && programReadiness.target.candidateCoverage) {
|
|
247
|
+
const candidate = coverageLabel(programReadiness.target.candidateCoverage);
|
|
248
|
+
const agreed = coverageLabel(audit.coverage);
|
|
246
249
|
items.push(item(
|
|
247
250
|
"candidate-period-comparison",
|
|
248
251
|
"info",
|
|
@@ -257,9 +260,8 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
257
260
|
const systems = (audit.systemIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
258
261
|
const completeSystems = systems.filter((system) => (
|
|
259
262
|
system.status === "active"
|
|
260
|
-
&& system.inScope === true
|
|
261
263
|
&& system.description
|
|
262
|
-
&& system.
|
|
264
|
+
&& system.classificationId
|
|
263
265
|
&& (system.ownerIds || []).length
|
|
264
266
|
));
|
|
265
267
|
items.push(item(
|
|
@@ -272,12 +274,10 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
272
274
|
systems[0] || { type: "system" }
|
|
273
275
|
));
|
|
274
276
|
|
|
275
|
-
const engagementStart = audit.
|
|
277
|
+
const engagementStart = coverageStart(audit.coverage);
|
|
276
278
|
const commitments = records.filter((record) => record.type === "commitment"
|
|
277
279
|
&& record.status === "active"
|
|
278
|
-
&& systems.some((system) => (
|
|
279
|
-
(system.commitmentIds || []).includes(record.id) || (record.systemIds || []).includes(system.id)
|
|
280
|
-
)));
|
|
280
|
+
&& systems.some((system) => (record.systemIds || []).includes(system.id)));
|
|
281
281
|
const completeCommitments = commitments.filter((commitment) => (
|
|
282
282
|
commitment.statement
|
|
283
283
|
&& (commitment.ownerIds || []).length
|
|
@@ -287,7 +287,7 @@ function scopeStage(audit, records, byId, programReadiness) {
|
|
|
287
287
|
&& (commitment.controlIds || []).length
|
|
288
288
|
));
|
|
289
289
|
const systemsWithoutCommitments = systems.filter((system) => !completeCommitments.some((commitment) => (
|
|
290
|
-
(
|
|
290
|
+
(commitment.systemIds || []).includes(system.id)
|
|
291
291
|
)));
|
|
292
292
|
items.push(item(
|
|
293
293
|
"commitments",
|
|
@@ -432,8 +432,11 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
432
432
|
]);
|
|
433
433
|
}
|
|
434
434
|
const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
|
|
435
|
-
const named = Boolean(auditor
|
|
436
|
-
|
|
435
|
+
const named = Boolean(auditor);
|
|
436
|
+
const currentOwners = [...currentPartyPeople(audit.ownerIds, byId)]
|
|
437
|
+
.map((id) => byId.get(id))
|
|
438
|
+
.filter(Boolean);
|
|
439
|
+
return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", [
|
|
437
440
|
item(
|
|
438
441
|
"engagement-record",
|
|
439
442
|
"complete",
|
|
@@ -446,9 +449,24 @@ function engagementStage(audit, byId, programReadiness) {
|
|
|
446
449
|
named ? "complete" : "action",
|
|
447
450
|
"Record the independent CPA firm",
|
|
448
451
|
named
|
|
449
|
-
? `${auditor
|
|
452
|
+
? `${auditor.title} is recorded for the engagement.`
|
|
450
453
|
: "Select the CPA firm and record it here. The independent management policy reviewer is a different role.",
|
|
451
454
|
audit
|
|
455
|
+
),
|
|
456
|
+
item(
|
|
457
|
+
"engagement-owner",
|
|
458
|
+
currentOwners.length ? "complete" : "action",
|
|
459
|
+
"Confirm the management engagement owner",
|
|
460
|
+
currentOwners.length
|
|
461
|
+
? `${currentOwners.map(({ title }) => title).join(" and ")} currently owns management coordination for the engagement.`
|
|
462
|
+
: "Assign the audit to a current Person, Team, or Appointment. The audit owner may coordinate management and evidence work without a separate audit-specific title.",
|
|
463
|
+
audit,
|
|
464
|
+
{
|
|
465
|
+
commands: [
|
|
466
|
+
`npx filegrc get ${audit.id} --mutation`,
|
|
467
|
+
"npx filegrc audit-readiness AUDIT_ID --json"
|
|
468
|
+
]
|
|
469
|
+
}
|
|
452
470
|
)
|
|
453
471
|
]);
|
|
454
472
|
}
|
|
@@ -479,7 +497,7 @@ async function documentsStage(loaded, audit, byId) {
|
|
|
479
497
|
const document = audit?.[definition.field] ? byId.get(audit[definition.field]) : null;
|
|
480
498
|
const source = document ? await primaryMarkdown(loaded, document) : "";
|
|
481
499
|
const contentIssues = managementDocumentContentIssues(source, definition, audit);
|
|
482
|
-
const engagementEnd = audit?.
|
|
500
|
+
const engagementEnd = coverageEnd(audit?.coverage);
|
|
483
501
|
if (document?.approvedOn && engagementEnd && document.approvedOn < engagementEnd) {
|
|
484
502
|
contentIssues.push(`Approve the final document on or after the engagement ${audit.auditKind === "soc-2-type-1" ? "date" : "period end"}.`);
|
|
485
503
|
}
|
|
@@ -543,11 +561,11 @@ function evidenceStage(audit, records, byId, model) {
|
|
|
543
561
|
const evidence = records.filter((record) => record.type === "evidence");
|
|
544
562
|
const externalEvidence = evidence.filter((record) => (
|
|
545
563
|
record.status === "verified"
|
|
546
|
-
&& (record.
|
|
564
|
+
&& (record.artifactKind !== "rendered-page" || record.sourceCommit)
|
|
547
565
|
&& evidenceRelevantToAuditDate(record, audit)
|
|
548
566
|
));
|
|
549
|
-
const managedFamilies = (model.evidenceSourceFamilies || []).filter((family) => family.
|
|
550
|
-
const externalFamilies = (model.evidenceSourceFamilies || []).filter((family) => family.
|
|
567
|
+
const managedFamilies = (model.evidenceSourceFamilies || []).filter((family) => family.filegrcManaged === true);
|
|
568
|
+
const externalFamilies = (model.evidenceSourceFamilies || []).filter((family) => family.filegrcManaged !== true);
|
|
551
569
|
const evidenceFamiliesFor = (control) => (model.evidenceSourceFamilies || []).filter((family) => (
|
|
552
570
|
(family.controlCodes || []).includes(control.code)
|
|
553
571
|
));
|
|
@@ -556,12 +574,21 @@ function evidenceStage(audit, records, byId, model) {
|
|
|
556
574
|
&& controlIdsForRecord(record, byId).size
|
|
557
575
|
&& recordRelevantToAuditDate(record, audit, model)
|
|
558
576
|
));
|
|
577
|
+
const reconciledZeroPopulationControlIds = new Set(records
|
|
578
|
+
.filter((record) => (
|
|
579
|
+
record.type === "audit-population"
|
|
580
|
+
&& record.auditId === audit.id
|
|
581
|
+
&& record.status === "reconciled"
|
|
582
|
+
&& record.conclusion === "complete"
|
|
583
|
+
&& byId.get(record.sourceEvidenceId)?.populationCount === 0
|
|
584
|
+
))
|
|
585
|
+
.flatMap((record) => record.controlIds || []));
|
|
559
586
|
const managedControls = controls.filter((control) => managedFamilies.some((family) => (
|
|
560
587
|
(family.controlCodes || []).includes(control.code)
|
|
561
588
|
)));
|
|
562
589
|
const controlsWithFilegrcRecords = managedControls.filter((control) => filegrcRecords.some((record) => (
|
|
563
590
|
controlIdsForRecord(record, byId).has(control.id)
|
|
564
|
-
)));
|
|
591
|
+
)) || reconciledZeroPopulationControlIds.has(control.id));
|
|
565
592
|
const externalControls = controls.filter((control) => externalFamilies.some((family) => (
|
|
566
593
|
(family.controlCodes || []).includes(control.code)
|
|
567
594
|
)) || !evidenceFamiliesFor(control).length);
|
|
@@ -574,7 +601,7 @@ function evidenceStage(audit, records, byId, model) {
|
|
|
574
601
|
managedControls.length && controlsWithFilegrcRecords.length === managedControls.length ? "complete" : managedControls.length ? "action" : "info",
|
|
575
602
|
"Review filegrc Evidence",
|
|
576
603
|
managedControls.length
|
|
577
|
-
? `${controlsWithFilegrcRecords.length} of ${managedControls.length} selected controls that use filegrc workflows have a dated operating record for the formal period. Complete each Step
|
|
604
|
+
? `${controlsWithFilegrcRecords.length} of ${managedControls.length} selected controls that use filegrc workflows have a dated operating record or reconciled zero-event population for the formal period. Complete each Step 4 record, link it to the control, and add results in its structured fields or Markdown.`
|
|
578
605
|
: "No selected controls use a dedicated filegrc operating record.",
|
|
579
606
|
filegrcRecords[0] || { type: managedFamilies[0]?.operationRecordTypes?.[0] || "control" }
|
|
580
607
|
),
|
|
@@ -600,20 +627,23 @@ function evidenceStage(audit, records, byId, model) {
|
|
|
600
627
|
));
|
|
601
628
|
continue;
|
|
602
629
|
}
|
|
603
|
-
if (source.
|
|
630
|
+
if (source.filegrcManaged === true) {
|
|
604
631
|
const sourceRecords = filegrcRecords.filter((record) => (
|
|
605
632
|
relevantControls.some((control) => controlIdsForRecord(record, byId).has(control.id))
|
|
606
633
|
));
|
|
607
634
|
const coveredControls = relevantControls.filter((control) => sourceRecords.some((record) => (
|
|
608
635
|
controlIdsForRecord(record, byId).has(control.id)
|
|
609
|
-
)));
|
|
636
|
+
)) || reconciledZeroPopulationControlIds.has(control.id));
|
|
637
|
+
const zeroPopulationControls = relevantControls.filter((control) => (
|
|
638
|
+
reconciledZeroPopulationControlIds.has(control.id)
|
|
639
|
+
));
|
|
610
640
|
items.push(item(
|
|
611
641
|
`filegrc-${source.id}`,
|
|
612
642
|
coveredControls.length === relevantControls.length ? "complete" : "action",
|
|
613
643
|
source.title,
|
|
614
644
|
coveredControls.length === relevantControls.length
|
|
615
|
-
? `${sourceRecords.length} dated filegrc ${sourceRecords.length === 1 ? "record" : "records"} cover ${relevantControls.length} mapped controls.
|
|
616
|
-
: `${coveredControls.length} of ${relevantControls.length} mapped controls have a dated ${source.operationRecordTypes.map(displayValue).join(" or ")} record for the formal period. Complete the Step
|
|
645
|
+
? `${sourceRecords.length} dated filegrc ${sourceRecords.length === 1 ? "record" : "records"} and ${zeroPopulationControls.length} reconciled zero-population ${zeroPopulationControls.length === 1 ? "conclusion cover" : "conclusions cover"} ${relevantControls.length} mapped controls. Supporting artifacts are linked from the operating records or population export.`
|
|
646
|
+
: `${coveredControls.length} of ${relevantControls.length} mapped controls have a dated ${source.operationRecordTypes.map(displayValue).join(" or ")} record for the formal period. Complete the Step 4 work and attach or reference any supporting external artifact on that record.`,
|
|
617
647
|
sourceRecords[0] || { type: source.operationRecordTypes[0] }
|
|
618
648
|
));
|
|
619
649
|
continue;
|
|
@@ -687,7 +717,11 @@ function auditorStage() {
|
|
|
687
717
|
|
|
688
718
|
function populationResult(population, audit, byId) {
|
|
689
719
|
if (!population) return { status: "action", message: "Initialize this population for the engagement." };
|
|
690
|
-
if (
|
|
720
|
+
if (!coverageMatches(
|
|
721
|
+
population.coverage,
|
|
722
|
+
coverageStart(audit?.coverage),
|
|
723
|
+
coverageEnd(audit?.coverage)
|
|
724
|
+
)) {
|
|
691
725
|
return { status: "action", message: "The population period does not match the exact audit period." };
|
|
692
726
|
}
|
|
693
727
|
if (population.status === "not-applicable") {
|
|
@@ -712,12 +746,15 @@ function populationResult(population, audit, byId) {
|
|
|
712
746
|
];
|
|
713
747
|
const evidenceComplete = evidence
|
|
714
748
|
&& evidence.type === "evidence"
|
|
715
|
-
&& evidence.
|
|
749
|
+
&& evidence.artifactKind === "population-export"
|
|
716
750
|
&& evidence.status === "verified"
|
|
717
751
|
&& population.sourceSystemId
|
|
718
752
|
&& evidence.sourceSystemId === population.sourceSystemId
|
|
719
|
-
&&
|
|
720
|
-
|
|
753
|
+
&& coverageMatches(
|
|
754
|
+
evidence.coverage,
|
|
755
|
+
coverageStart(audit.coverage),
|
|
756
|
+
coverageEnd(audit.coverage)
|
|
757
|
+
)
|
|
721
758
|
&& requiredEvidence.every((field) => evidence[field] !== undefined && evidence[field] !== null && evidence[field] !== "");
|
|
722
759
|
const reconciliationComplete = (population.reconciledByIds || []).length
|
|
723
760
|
&& population.reconciledOn
|
|
@@ -726,14 +763,14 @@ function populationResult(population, audit, byId) {
|
|
|
726
763
|
const generatedOn = timestampDate(evidence?.generatedAt, evidence?.timezone);
|
|
727
764
|
const sequenceComplete = Number.isInteger(evidence?.populationCount)
|
|
728
765
|
&& evidence.populationCount >= 0
|
|
729
|
-
&& generatedOn > audit.
|
|
766
|
+
&& generatedOn > coverageEnd(audit.coverage)
|
|
730
767
|
&& population.reconciledOn >= generatedOn;
|
|
731
768
|
if (!evidenceComplete || !reconciliationComplete || !sequenceComplete) {
|
|
732
769
|
return { status: "action", message: "Finish the reconciliation and link a verified population export with its exact query, timezone, count, completeness check, and accuracy check." };
|
|
733
770
|
}
|
|
734
771
|
return {
|
|
735
772
|
status: "complete",
|
|
736
|
-
message: `${evidence.populationCount} items reconciled from ${evidence.
|
|
773
|
+
message: `${evidence.populationCount} items reconciled from ${evidence.sourceDescription || "the authoritative source"}${population.conclusion === "complete-with-exceptions" ? " with documented exceptions" : ""}.`
|
|
737
774
|
};
|
|
738
775
|
}
|
|
739
776
|
|
|
@@ -792,31 +829,30 @@ function applicableManagementDocuments(audit, readiness) {
|
|
|
792
829
|
}
|
|
793
830
|
|
|
794
831
|
function evidenceOverlaps(record, start, end) {
|
|
795
|
-
return (record.
|
|
832
|
+
return coverageOverlaps(record.coverage, start, end)
|
|
796
833
|
|| (record.collectedOn && record.collectedOn >= start && record.collectedOn <= end);
|
|
797
834
|
}
|
|
798
835
|
|
|
799
836
|
function evidenceRelevantToAuditDate(record, audit) {
|
|
800
837
|
if (!audit) return false;
|
|
801
838
|
if (audit.auditKind === "soc-2-type-1") {
|
|
802
|
-
const date = audit.
|
|
839
|
+
const date = coverageStart(audit.coverage);
|
|
803
840
|
return Boolean(date) && (
|
|
804
|
-
(record.
|
|
841
|
+
coverageContains(record.coverage, date)
|
|
805
842
|
|| record.collectedOn === date
|
|
806
843
|
);
|
|
807
844
|
}
|
|
808
|
-
|
|
809
|
-
|
|
845
|
+
const start = coverageStart(audit.coverage);
|
|
846
|
+
const end = coverageEnd(audit.coverage);
|
|
847
|
+
return Boolean(start && end) && evidenceOverlaps(record, start, end);
|
|
810
848
|
}
|
|
811
849
|
|
|
812
850
|
function recordRelevantToAuditDate(record, audit, model) {
|
|
813
851
|
if (!audit) return false;
|
|
814
|
-
const start = audit.
|
|
815
|
-
const end = audit.
|
|
852
|
+
const start = coverageStart(audit.coverage);
|
|
853
|
+
const end = coverageEnd(audit.coverage);
|
|
816
854
|
if (!start || !end) return false;
|
|
817
|
-
if (record.
|
|
818
|
-
return true;
|
|
819
|
-
}
|
|
855
|
+
if (coverageOverlaps(record.coverage, start, end)) return true;
|
|
820
856
|
const definition = model.resources[record.type];
|
|
821
857
|
const fields = { ...model.commonFields, ...(definition?.fields || {}) };
|
|
822
858
|
return Object.entries(fields).some(([name, field]) => {
|
|
@@ -840,6 +876,17 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
|
|
|
840
876
|
if (record.obligationId) {
|
|
841
877
|
for (const id of byId.get(record.obligationId)?.controlIds || []) ids.add(id);
|
|
842
878
|
}
|
|
879
|
+
for (const candidate of byId.values()) {
|
|
880
|
+
if (
|
|
881
|
+
candidate.type === "obligation"
|
|
882
|
+
&& (candidate.completionResourceIds || []).includes(record.id)
|
|
883
|
+
) {
|
|
884
|
+
for (const id of candidate.controlIds || []) ids.add(id);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
for (const subjectId of record.subjectResourceIds || []) {
|
|
888
|
+
for (const id of controlIdsForRecord(byId.get(subjectId), byId, seen)) ids.add(id);
|
|
889
|
+
}
|
|
843
890
|
for (const sourceId of record.sourceResourceIds || []) {
|
|
844
891
|
for (const id of controlIdsForRecord(byId.get(sourceId), byId, seen)) ids.add(id);
|
|
845
892
|
}
|
|
@@ -872,9 +919,7 @@ function managementDocumentContentIssues(source, definition, audit) {
|
|
|
872
919
|
));
|
|
873
920
|
if (missingHeadings.length) return [`Add the missing description sections: ${missingHeadings.join(", ")}.`];
|
|
874
921
|
if (definition.dateBinding === "engagement" && audit) {
|
|
875
|
-
const dates = audit.
|
|
876
|
-
? [audit.typeOneAsOf]
|
|
877
|
-
: [audit.periodStart, audit.periodEnd];
|
|
922
|
+
const dates = [coverageStart(audit.coverage), coverageEnd(audit.coverage)];
|
|
878
923
|
if (dates.some((date) => date && !source.includes(date))) {
|
|
879
924
|
return ["Name the exact engagement date or period in the document."];
|
|
880
925
|
}
|
|
@@ -923,16 +968,16 @@ function materializeManagementMarkdown(source, audit, records) {
|
|
|
923
968
|
.map(displayValue))]
|
|
924
969
|
.join(", ");
|
|
925
970
|
const period = audit.auditKind === "soc-2-type-1"
|
|
926
|
-
? audit.
|
|
927
|
-
: audit.
|
|
928
|
-
?
|
|
971
|
+
? coverageStart(audit.coverage) || "[as-of date]"
|
|
972
|
+
: coverageStart(audit.coverage) && coverageEnd(audit.coverage)
|
|
973
|
+
? coverageLabel(audit.coverage)
|
|
929
974
|
: "[start date] through [end date]";
|
|
930
975
|
return withoutDiscarded
|
|
931
976
|
.replaceAll(`<!-- ${keep}:start -->`, "")
|
|
932
977
|
.replaceAll(`<!-- ${keep}:end -->`, "")
|
|
933
|
-
.replaceAll("[as-of date]", audit.
|
|
934
|
-
.replaceAll("[start date]", audit.
|
|
935
|
-
.replaceAll("[end date]", audit.
|
|
978
|
+
.replaceAll("[as-of date]", coverageStart(audit.coverage) || "[as-of date]")
|
|
979
|
+
.replaceAll("[start date]", coverageStart(audit.coverage) || "[start date]")
|
|
980
|
+
.replaceAll("[end date]", coverageEnd(audit.coverage) || "[end date]")
|
|
936
981
|
.replaceAll("[engagement date or period]", period)
|
|
937
982
|
.replaceAll("[engagement scope]", audit.scope || "[engagement scope]")
|
|
938
983
|
.replaceAll("[in-scope systems]", systems || "[in-scope systems]")
|
|
@@ -953,8 +998,7 @@ function auditSummary(audit) {
|
|
|
953
998
|
title: audit.title,
|
|
954
999
|
status: audit.status,
|
|
955
1000
|
kind: audit.auditKind,
|
|
956
|
-
|
|
957
|
-
periodEnd: audit.periodEnd || null
|
|
1001
|
+
coverage: audit.coverage || null
|
|
958
1002
|
};
|
|
959
1003
|
}
|
|
960
1004
|
|