filegrc 0.1.0 → 0.2.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 +19 -1
- package/model/v1.json +159 -165
- package/package.json +1 -1
- package/src/agent.js +4 -0
- package/src/audit-preparation.js +217 -116
- package/src/cli.js +264 -7
- package/src/evidence-packet.js +72 -19
- package/src/evidence-tests.js +69 -0
- package/src/git.js +1 -0
- package/src/index.js +11 -0
- package/src/model-docs.js +51 -6
- package/src/obligations.js +87 -11
- package/src/program-lifecycle.js +22 -0
- package/src/program-path.js +275 -0
- package/src/program-readiness.js +635 -0
- package/src/resource-markdown.js +11 -4
- package/src/server.js +8 -0
- package/src/setup.js +187 -0
- package/src/state.js +8 -1
- package/src/validate.js +67 -5
- package/src/web.js +757 -428
package/src/evidence-packet.js
CHANGED
|
@@ -11,6 +11,30 @@ import { markdownEntries } from "./resource-markdown.js";
|
|
|
11
11
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
12
12
|
import { validateWorkspace } from "./validate.js";
|
|
13
13
|
|
|
14
|
+
const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
15
|
+
"audit",
|
|
16
|
+
"audit-population",
|
|
17
|
+
"audit-request",
|
|
18
|
+
"commitment",
|
|
19
|
+
"complementary-control",
|
|
20
|
+
"control",
|
|
21
|
+
"control-test",
|
|
22
|
+
"document",
|
|
23
|
+
"evidence",
|
|
24
|
+
"framework",
|
|
25
|
+
"obligation",
|
|
26
|
+
"organization",
|
|
27
|
+
"person",
|
|
28
|
+
"policy",
|
|
29
|
+
"renderer-settings",
|
|
30
|
+
"requirement",
|
|
31
|
+
"system",
|
|
32
|
+
"team",
|
|
33
|
+
"training",
|
|
34
|
+
"vendor",
|
|
35
|
+
"workspace"
|
|
36
|
+
]);
|
|
37
|
+
|
|
14
38
|
export async function prepareEvidencePacket(input, options = {}) {
|
|
15
39
|
const validation = await validateWorkspace(input);
|
|
16
40
|
if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before generating evidence.`);
|
|
@@ -66,7 +90,6 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
66
90
|
...(audit.controlIds || []),
|
|
67
91
|
...(audit.controlTestIds || []),
|
|
68
92
|
...(audit.evidenceIds || []),
|
|
69
|
-
...(audit.findingIds || []),
|
|
70
93
|
...(audit.contactIds || []),
|
|
71
94
|
...(audit.complementaryControlIds || []),
|
|
72
95
|
...(audit.subserviceVendorIds || []),
|
|
@@ -81,6 +104,9 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
81
104
|
for (const request of records.filter((record) => record.type === "audit-request" && record.auditId === audit.id)) {
|
|
82
105
|
selectedIds.add(request.id);
|
|
83
106
|
}
|
|
107
|
+
for (const record of records.filter((candidate) => ["finding", "action-item"].includes(candidate.type))) {
|
|
108
|
+
if (recordRelevantToAudit(record, audit, byId)) selectedIds.add(record.id);
|
|
109
|
+
}
|
|
84
110
|
}
|
|
85
111
|
for (const item of obligations) {
|
|
86
112
|
selectedIds.add(item.obligationId);
|
|
@@ -132,9 +158,13 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
132
158
|
const control = byId.get(controlId);
|
|
133
159
|
addIds(selectedIds, control?.systemIds);
|
|
134
160
|
addIds(selectedIds, control?.commitmentIds);
|
|
135
|
-
addIds(selectedIds, control?.complementaryControlIds);
|
|
136
161
|
addIds(selectedIds, control?.riskIds);
|
|
137
162
|
}
|
|
163
|
+
for (const complementaryControl of records.filter((record) => record.type === "complementary-control")) {
|
|
164
|
+
if ((complementaryControl.relatedControlIds || []).some((id) => controlIds.has(id))) {
|
|
165
|
+
selectedIds.add(complementaryControl.id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
138
168
|
for (const systemId of audit?.systemIds || []) {
|
|
139
169
|
const system = byId.get(systemId);
|
|
140
170
|
addIds(selectedIds, system?.commitmentIds);
|
|
@@ -215,6 +245,10 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
215
245
|
end,
|
|
216
246
|
timezone: loaded.workspace.timezone
|
|
217
247
|
});
|
|
248
|
+
const fileGRCRecords = datedRecords.filter((record) => (
|
|
249
|
+
!NON_EVIDENCE_RECORD_TYPES.has(record.type)
|
|
250
|
+
&& controlIdsForRecord(byId.get(record.id), byId).size
|
|
251
|
+
));
|
|
218
252
|
const populations = (typeOne ? [] : records)
|
|
219
253
|
.filter((record) => record.type === "audit-population" && (!audit || record.auditId === audit.id))
|
|
220
254
|
.map((record) => populationSummary(record, byId))
|
|
@@ -285,6 +319,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
285
319
|
},
|
|
286
320
|
summary: {
|
|
287
321
|
datedRecords: datedRecords.length,
|
|
322
|
+
fileGRCRecords: fileGRCRecords.length,
|
|
288
323
|
records: packetRecords.length,
|
|
289
324
|
policies: policyIds.size,
|
|
290
325
|
controls: controlIds.size,
|
|
@@ -300,6 +335,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
300
335
|
warnings: warningCount
|
|
301
336
|
},
|
|
302
337
|
datedRecords: datedRecords.sort((a, b) => a.primaryDate.localeCompare(b.primaryDate) || byTitle(a, b)),
|
|
338
|
+
fileGRCRecords: fileGRCRecords.sort((a, b) => a.primaryDate.localeCompare(b.primaryDate) || byTitle(a, b)),
|
|
303
339
|
policies: [...policyIds].map((id) => recordSummary(byId.get(id))).filter(Boolean).sort(byTitle),
|
|
304
340
|
controls: [...controlIds].map((id) => recordSummary(byId.get(id))).filter(Boolean).sort(byTitle),
|
|
305
341
|
obligations,
|
|
@@ -337,7 +373,6 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
|
|
|
337
373
|
...(audit.controlIds || []),
|
|
338
374
|
...(audit.controlTestIds || []),
|
|
339
375
|
...(audit.evidenceIds || []),
|
|
340
|
-
...(audit.findingIds || []),
|
|
341
376
|
...(audit.contactIds || []),
|
|
342
377
|
...(audit.complementaryControlIds || []),
|
|
343
378
|
...(audit.subserviceVendorIds || []),
|
|
@@ -373,6 +408,12 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
|
|
|
373
408
|
|
|
374
409
|
function expandEvidenceWorkflowContext(selectedIds, byId) {
|
|
375
410
|
const queue = [...selectedIds];
|
|
411
|
+
const childrenBySource = new Map();
|
|
412
|
+
for (const record of byId.values()) {
|
|
413
|
+
if (!["finding", "action-item"].includes(record.type) || !record.sourceResourceId) continue;
|
|
414
|
+
if (!childrenBySource.has(record.sourceResourceId)) childrenBySource.set(record.sourceResourceId, []);
|
|
415
|
+
childrenBySource.get(record.sourceResourceId).push(record.id);
|
|
416
|
+
}
|
|
376
417
|
const enqueue = (ids = []) => {
|
|
377
418
|
for (const id of ids) {
|
|
378
419
|
if (!id || selectedIds.has(id)) continue;
|
|
@@ -382,6 +423,7 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
|
|
|
382
423
|
};
|
|
383
424
|
for (let index = 0; index < queue.length; index += 1) {
|
|
384
425
|
const record = byId.get(queue[index]);
|
|
426
|
+
enqueue(childrenBySource.get(record?.id));
|
|
385
427
|
if (record?.type === "evidence") enqueue([...(record.sourceResourceIds || []), record.sourceSystemId]);
|
|
386
428
|
if (record?.type === "audit-population") enqueue([record.sourceEvidenceId, ...(record.controlIds || [])]);
|
|
387
429
|
if (record?.type === "control-test") enqueue([record.populationId, ...(record.sampleEvidenceIds || [])]);
|
|
@@ -393,7 +435,7 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
|
|
|
393
435
|
...(record.evidenceIds || [])
|
|
394
436
|
]);
|
|
395
437
|
}
|
|
396
|
-
if (record?.type === "obligation-event") enqueue(
|
|
438
|
+
if (record?.type === "obligation-event") enqueue(record.obligationIds || []);
|
|
397
439
|
}
|
|
398
440
|
}
|
|
399
441
|
|
|
@@ -515,7 +557,7 @@ async function writeChecksums(output, files) {
|
|
|
515
557
|
|
|
516
558
|
function controlMatrixCsv(packet) {
|
|
517
559
|
return csv([
|
|
518
|
-
["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Frequency", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "
|
|
560
|
+
["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Frequency", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "FileGRC Evidence IDs", "External Evidence IDs", "Control Test IDs", "Test Outcomes", "Population IDs", "Population Counts", "Sample Sizes", "Exception Counts", "Population Evidence IDs", "Sample Evidence IDs"],
|
|
519
561
|
...packet.controlCoverage.map((control) => [
|
|
520
562
|
control.id,
|
|
521
563
|
control.code,
|
|
@@ -810,7 +852,7 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
|
|
|
810
852
|
|| (!record.auditId && record.asOfDate >= start && record.asOfDate <= end)
|
|
811
853
|
));
|
|
812
854
|
const operatingRecords = records.filter((record) => (
|
|
813
|
-
record.type
|
|
855
|
+
!NON_EVIDENCE_RECORD_TYPES.has(record.type)
|
|
814
856
|
&& controlIdsForRecord(record, byId).has(controlId)
|
|
815
857
|
&& packetRecord(record, model, start, end, timezone)
|
|
816
858
|
));
|
|
@@ -859,6 +901,7 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
|
|
|
859
901
|
seen.add(record.id);
|
|
860
902
|
if (record.type === "control") ids.add(record.id);
|
|
861
903
|
addIds(ids, record.controlIds);
|
|
904
|
+
if (record.type === "complementary-control") addIds(ids, record.relatedControlIds);
|
|
862
905
|
if (record.controlId) ids.add(record.controlId);
|
|
863
906
|
for (const sourceId of record.sourceResourceIds || []) addIds(ids, controlIdsForRecord(byId.get(sourceId), byId, seen));
|
|
864
907
|
if (record.sourceResourceId) addIds(ids, controlIdsForRecord(byId.get(record.sourceResourceId), byId, seen));
|
|
@@ -926,8 +969,10 @@ function packetGaps({
|
|
|
926
969
|
}
|
|
927
970
|
}
|
|
928
971
|
}
|
|
929
|
-
if (!coverage.evidenceIds.length) {
|
|
930
|
-
gaps.push(gap("error", "control-missing-evidence", `${coverage.code || coverage.title} has no linked
|
|
972
|
+
if (controlNeedsExternalEvidence(control, model) && !coverage.evidenceIds.length) {
|
|
973
|
+
gaps.push(gap("error", "control-missing-external-evidence", `${coverage.code || coverage.title} relies on an external system but has no linked External Evidence in the packet.`, coverage.id));
|
|
974
|
+
} else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
|
|
975
|
+
gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated FileGRC operating record in the packet.`, coverage.id));
|
|
931
976
|
}
|
|
932
977
|
if (audit?.auditKind !== "soc-2-type-1" && coverage.status === "implemented" && !coverage.operatingRecordIds.length && coverage.operationMode !== "automated") {
|
|
933
978
|
gaps.push(gap("warning", "control-missing-operating-record", `${coverage.code || coverage.title} has no dated operating record in the packet period.`, coverage.id));
|
|
@@ -953,7 +998,8 @@ function packetGaps({
|
|
|
953
998
|
if (Number.isInteger(testRecord?.exceptionCount) && testRecord.exceptionCount < 0) {
|
|
954
999
|
gaps.push(gap("error", "control-test-exception-count-invalid", `${coverage.code || coverage.title} records a negative exception count.`, test.id));
|
|
955
1000
|
}
|
|
956
|
-
|
|
1001
|
+
const testFindings = records.filter((record) => record.type === "finding" && record.sourceResourceId === test.id);
|
|
1002
|
+
if ((testRecord?.exceptionCount > 0 || ["failed", "passed-with-exceptions"].includes(test.outcome)) && !testFindings.length) {
|
|
957
1003
|
gaps.push(gap("error", "control-test-finding-missing", `${coverage.code || coverage.title} records exceptions or failure without a linked finding.`, test.id));
|
|
958
1004
|
}
|
|
959
1005
|
const samplingTest = Number.isInteger(test.sampleSize) && test.sampleSize > 0;
|
|
@@ -1167,6 +1213,13 @@ function packetGaps({
|
|
|
1167
1213
|
return deduplicateGaps(gaps);
|
|
1168
1214
|
}
|
|
1169
1215
|
|
|
1216
|
+
function controlNeedsExternalEvidence(control, model) {
|
|
1217
|
+
const families = (model.evidenceSourceFamilies || []).filter((family) => (
|
|
1218
|
+
(family.controlCodes || []).includes(control?.code)
|
|
1219
|
+
));
|
|
1220
|
+
return !families.length || families.some((family) => family.collectionTestRequired !== false);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1170
1223
|
function auditGaps(gaps, audit, byId, records, start, end) {
|
|
1171
1224
|
if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
|
|
1172
1225
|
gaps.push(gap("error", "not-soc2-examination", `${audit.title} is ${audit.auditKind}; a delivery packet requires a SOC 2 Type 1 or Type 2 engagement.`, audit.id));
|
|
@@ -1490,10 +1543,10 @@ function packetMarkdown(packet) {
|
|
|
1490
1543
|
"",
|
|
1491
1544
|
"## Coverage",
|
|
1492
1545
|
"",
|
|
1493
|
-
`- ${packet.summary.
|
|
1546
|
+
`- ${packet.summary.fileGRCRecords} FileGRC Evidence records`,
|
|
1494
1547
|
`- ${packet.summary.obligationOccurrences} recurring obligation occurrences`,
|
|
1495
1548
|
`- ${packet.summary.eventRuns} event runs`,
|
|
1496
|
-
`- ${packet.summary.evidence}
|
|
1549
|
+
`- ${packet.summary.evidence} External Evidence records`,
|
|
1497
1550
|
`- ${packet.summary.populations} reconciled or planned populations`,
|
|
1498
1551
|
`- ${packet.summary.policies} policies`,
|
|
1499
1552
|
`- ${packet.summary.controls} controls`,
|
|
@@ -1501,7 +1554,7 @@ function packetMarkdown(packet) {
|
|
|
1501
1554
|
`- ${packet.summary.systems} in-scope systems`,
|
|
1502
1555
|
`- ${packet.summary.sourceSystems} cataloged source systems`,
|
|
1503
1556
|
"",
|
|
1504
|
-
"Open `index.html` for the auditor-oriented index. `control-matrix.csv` cross-references criteria, controls,
|
|
1557
|
+
"Open `index.html` for the auditor-oriented index. `control-matrix.csv` cross-references criteria, controls, FileGRC Evidence, External Evidence, and tests. `source-system-index.csv` identifies the systems of record used to produce External Evidence. `external-evidence-index.csv` lists material that must be delivered or accessed outside this packet. For Type 2, `population-index.csv` records management's population reconciliation and fixed source exports. FileGRC records, governed Markdown, fixed attachments, and committed historical versions are included in their respective directories.",
|
|
1505
1558
|
"",
|
|
1506
1559
|
"After transfer, enter the packet directory and run `shasum -a 256 -c SHA256SUMS` or `sha256sum -c SHA256SUMS`. The checksum file covers every other packet file.",
|
|
1507
1560
|
""
|
|
@@ -1527,8 +1580,8 @@ function packetHtml(packet) {
|
|
|
1527
1580
|
? `<table><thead><tr><th>Obligation</th><th>Allowed window</th><th>Status</th></tr></thead><tbody>${packet.obligations.map((item) => `<tr><td>${escapeHtml(item.title)}</td><td>${item.dueWindowStart} through ${item.dueWindowEnd}<br><small>Overdue ${item.overdueOn}</small></td><td>${escapeHtml(item.status)}</td></tr>`).join("")}</tbody></table>`
|
|
1528
1581
|
: "<p>No recurring occurrences intersect this period.</p>";
|
|
1529
1582
|
const evidence = packet.evidence.length
|
|
1530
|
-
? `<table><thead><tr><th>Evidence</th><th>Source and period</th><th>Controls</th><th>Files</th></tr></thead><tbody>${packet.evidence.map((item) => `<tr><td><a href="records/evidence/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.evidenceKind)}</small></td><td>${escapeHtml(item.source)}<small>${escapeHtml(item.periodStart || item.collectedOn)}${item.periodEnd ? ` through ${escapeHtml(item.periodEnd)}` : ""}</small></td><td>${item.controlIds.map(escapeHtml).join("<br>") || "None"}</td><td>${item.filePaths.map((path) => `<a class="attachment" href="attachments/${path.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(basename(path))}</a>`).join("") || "No fixed attachment"}</td></tr>`).join("")}</tbody></table>`
|
|
1531
|
-
: "<p>No
|
|
1583
|
+
? `<table><thead><tr><th>External Evidence</th><th>Source and period</th><th>Controls</th><th>Files</th></tr></thead><tbody>${packet.evidence.map((item) => `<tr><td><a href="records/evidence/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.evidenceKind)}</small></td><td>${escapeHtml(item.source)}<small>${escapeHtml(item.periodStart || item.collectedOn)}${item.periodEnd ? ` through ${escapeHtml(item.periodEnd)}` : ""}</small></td><td>${item.controlIds.map(escapeHtml).join("<br>") || "None"}</td><td>${item.filePaths.map((path) => `<a class="attachment" href="attachments/${path.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(basename(path))}</a>`).join("") || "No fixed attachment"}</td></tr>`).join("")}</tbody></table>`
|
|
1584
|
+
: "<p>No External Evidence records were selected.</p>";
|
|
1532
1585
|
const sourceSystems = packet.sourceSystems.length
|
|
1533
1586
|
? `<p><a href="source-system-index.csv">Download source system index CSV</a></p><table><thead><tr><th>System of record</th><th>Evidence roles</th><th>Audit relationship</th><th>Evidence</th></tr></thead><tbody>${packet.sourceSystems.map((item) => `<tr><td><a href="records/system/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)}</small></td><td>${item.evidenceSourceKinds.map(escapeHtml).join("<br>") || "No evidence role recorded"}</td><td>${item.inAuditScope ? "In-scope system" : "Evidence source"}</td><td>${item.evidenceIds.length}</td></tr>`).join("")}</tbody></table><p><a href="external-evidence-index.csv">Download external evidence delivery index CSV</a></p>`
|
|
1534
1587
|
: "<p>No source systems were cataloged.</p>";
|
|
@@ -1539,17 +1592,17 @@ function packetHtml(packet) {
|
|
|
1539
1592
|
? packet.eventRuns.map((run) => `<article><h3><a href="records/obligation-event/${encodeURIComponent(run.id)}.json">${escapeHtml(run.title)}</a></h3><p>${escapeHtml(run.occurredAt || run.occurredOn)} · ${escapeHtml(run.status)} · ${run.completeCount} of ${run.actions.length} complete</p><table><thead><tr><th>Required action</th><th>Policy cutoff</th><th>Status</th></tr></thead><tbody>${run.actions.map((action) => `<tr><td><a href="records/action-item/${encodeURIComponent(action.actionItemId)}.json">${escapeHtml(action.title)}</a></td><td>${escapeHtml(action.dueWindowEndAt || action.dueWindowEnd)}</td><td>${escapeHtml(action.status)}</td></tr>`).join("")}</tbody></table></article>`).join("")
|
|
1540
1593
|
: "<p>No event workflows intersect this period.</p>";
|
|
1541
1594
|
const recordsById = new Map(packet.records.map((record) => [record.id, record]));
|
|
1542
|
-
const
|
|
1543
|
-
? `<table><thead><tr><th>Date</th><th>
|
|
1595
|
+
const fileGRCRecords = packet.fileGRCRecords.length
|
|
1596
|
+
? `<table><thead><tr><th>Date</th><th>FileGRC record</th><th>Latest committed change</th></tr></thead><tbody>${packet.fileGRCRecords.map((item) => {
|
|
1544
1597
|
const history = recordsById.get(item.id)?.history?.[0];
|
|
1545
1598
|
const source = history
|
|
1546
1599
|
? `${history.timestamp} · ${history.author} · ${history.subject}`
|
|
1547
1600
|
: "No committed file history";
|
|
1548
1601
|
return `<tr><td>${escapeHtml(item.primaryDate)}</td><td><a href="records/${encodeURIComponent(item.type)}/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><br><small>${escapeHtml(item.type)}</small></td><td>${escapeHtml(source)}</td></tr>`;
|
|
1549
1602
|
}).join("")}</tbody></table>`
|
|
1550
|
-
: "<p>No
|
|
1603
|
+
: "<p>No FileGRC Evidence records matched this period.</p>";
|
|
1551
1604
|
const controlCoverage = packet.controlCoverage.length
|
|
1552
|
-
? `<p><a href="control-matrix.csv">Download control matrix CSV</a></p><table><thead><tr><th>Control</th><th>Status and scope</th><th>Criteria</th><th>
|
|
1605
|
+
? `<p><a href="control-matrix.csv">Download control matrix CSV</a></p><table><thead><tr><th>Control</th><th>Status and scope</th><th>Criteria</th><th>FileGRC Evidence</th><th>External Evidence</th><th>Tests</th></tr></thead><tbody>${packet.controlCoverage.map((control) => `<tr><td><a href="records/control/${encodeURIComponent(control.id)}.json">${escapeHtml(control.code || control.id)}</a><small>${escapeHtml(control.title)}</small></td><td>${escapeHtml(control.status)}<small>${control.systemIds.map(escapeHtml).join(", ") || "No system scope"}</small></td><td>${control.requirementIds.map(escapeHtml).join("<br>") || "None"}</td><td>${control.operatingRecordIds.length}</td><td>${control.evidenceIds.length}</td><td>${control.tests.length}</td></tr>`).join("")}</tbody></table>`
|
|
1553
1606
|
: "<p>No controls were selected.</p>";
|
|
1554
1607
|
const readinessLabel = packet.readiness.status === "delivery-ready" ? "FileGRC management checks passed" : "Draft, do not deliver";
|
|
1555
1608
|
const packetDate = packet.period.basis === "as-of"
|
|
@@ -1557,7 +1610,7 @@ function packetHtml(packet) {
|
|
|
1557
1610
|
: `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
|
|
1558
1611
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Evidence packet</title><style>
|
|
1559
1612
|
body{font:14px/1.5 system-ui,sans-serif;color:#161825;max-width:1120px;margin:auto;padding:40px;background:#f7f8fc}header,section{background:#fff;border:1px solid #dfe3ef;border-radius:10px;padding:24px;margin:14px 0}h1,h2{margin-top:0}h1{font-size:26px}h2{font-size:17px}ul{padding-left:20px}li{margin:8px 0}small{display:block;color:#656c7e}.attachment{margin-right:10px;font-size:12px}.error{color:#8a2f28}.warning{color:#76500d}.readiness{display:inline-block;padding:5px 9px;border-radius:999px;background:#f7e4e2;color:#7a2520;font-weight:700}.readiness.ready{background:#e2f1e8;color:#245d3b}table{width:100%;border-collapse:collapse}th,td{padding:9px;border:1px solid #dfe3ef;text-align:left;vertical-align:top}code{overflow-wrap:anywhere}dl{display:grid;grid-template-columns:max-content 1fr;gap:8px 16px}dt{font-weight:700}dd{margin:0}
|
|
1560
|
-
</style></head><body><header><p>SOC 2 evidence packet</p><span class="readiness ${packet.readiness.status === "delivery-ready" ? "ready" : ""}">${escapeHtml(readinessLabel)}</span><h1>${packetDate}</h1><p>${escapeHtml(packet.workspace.organizationName)} · revision <code>${escapeHtml(packet.revision.commit || "uncommitted")}</code></p></header>${section("Engagement scope", engagement)}${section("Review status", gaps)}${section("Control coverage", controlCoverage)}${section("Systems of record", sourceSystems)}${packet.period.basis === "period" ? section("Management population reconciliation", populations) : ""}${packet.period.basis === "period" ? section("Recurring obligation coverage", obligations) : ""}${packet.period.basis === "period" ? section("Event workflow coverage", eventRuns) : ""}${section("Policies", links(packet.policies))}${section("
|
|
1613
|
+
</style></head><body><header><p>SOC 2 evidence packet</p><span class="readiness ${packet.readiness.status === "delivery-ready" ? "ready" : ""}">${escapeHtml(readinessLabel)}</span><h1>${packetDate}</h1><p>${escapeHtml(packet.workspace.organizationName)} · revision <code>${escapeHtml(packet.revision.commit || "uncommitted")}</code></p></header>${section("Engagement scope", engagement)}${section("Review status", gaps)}${section("Control coverage", controlCoverage)}${section("Systems of record", sourceSystems)}${packet.period.basis === "period" ? section("Management population reconciliation", populations) : ""}${packet.period.basis === "period" ? section("Recurring obligation coverage", obligations) : ""}${packet.period.basis === "period" ? section("Event workflow coverage", eventRuns) : ""}${section("Policies", links(packet.policies))}${section("FileGRC Evidence", fileGRCRecords)}${section("External Evidence", evidence)}${section("Integrity and history", "<p>Verify all transferred files with <code>SHA256SUMS</code>. Committed prior versions are under <code>history/</code> with an index that records their source paths and Git metadata.</p>")}</body></html>`;
|
|
1561
1614
|
}
|
|
1562
1615
|
|
|
1563
1616
|
async function writePacketFile(output, relativePath, source, files) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createResources } from "./files.js";
|
|
2
|
+
import { createResourceId } from "./id.js";
|
|
3
|
+
import { selectedControlFamilies } from "./program-readiness.js";
|
|
4
|
+
import { loadWorkspace } from "./workspace.js";
|
|
5
|
+
|
|
6
|
+
const TEST_EVIDENCE_KINDS = new Set(["test-export", "test-capture"]);
|
|
7
|
+
|
|
8
|
+
export function planEvidenceTestDrafts(loaded) {
|
|
9
|
+
const workspace = loaded.workspace;
|
|
10
|
+
const selectedIds = new Set(workspace.controlIds || []);
|
|
11
|
+
const controls = loaded.resources.filter((record) => (
|
|
12
|
+
record.type === "control"
|
|
13
|
+
&& (!selectedIds.size || selectedIds.has(record.id))
|
|
14
|
+
&& !["not-applicable", "retired"].includes(record.status)
|
|
15
|
+
));
|
|
16
|
+
const families = selectedControlFamilies(controls, loaded.model)
|
|
17
|
+
.filter((family) => family.collectionTestRequired !== false);
|
|
18
|
+
const evidence = loaded.resources.filter((record) => (
|
|
19
|
+
record.type === "evidence" && TEST_EVIDENCE_KINDS.has(record.evidenceKind)
|
|
20
|
+
));
|
|
21
|
+
return families.map((family) => {
|
|
22
|
+
const familyControlIds = new Set(family.controls.map((control) => control.id));
|
|
23
|
+
const existing = evidence.find((record) => record.collectionTestFamilyId === family.id)
|
|
24
|
+
|| evidence.find((record) => (
|
|
25
|
+
(record.controlIds || []).some((id) => familyControlIds.has(id))
|
|
26
|
+
));
|
|
27
|
+
return {
|
|
28
|
+
familyId: family.id,
|
|
29
|
+
title: family.title,
|
|
30
|
+
testEvidenceKind: family.testEvidenceKind,
|
|
31
|
+
testPrompt: family.testPrompt,
|
|
32
|
+
controlIds: [...familyControlIds],
|
|
33
|
+
existing
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function ensureEvidenceTestDrafts(input = process.cwd()) {
|
|
39
|
+
const loaded = await loadWorkspace(input);
|
|
40
|
+
const plan = planEvidenceTestDrafts(loaded);
|
|
41
|
+
const created = [];
|
|
42
|
+
const usedIds = loaded.resources.map((record) => record.id);
|
|
43
|
+
for (const item of plan.filter(({ existing }) => !existing)) {
|
|
44
|
+
const id = createResourceId(
|
|
45
|
+
"evidence",
|
|
46
|
+
`${item.title} Collection Test`,
|
|
47
|
+
usedIds
|
|
48
|
+
);
|
|
49
|
+
usedIds.push(id);
|
|
50
|
+
const record = {
|
|
51
|
+
schemaVersion: 1,
|
|
52
|
+
id,
|
|
53
|
+
type: "evidence",
|
|
54
|
+
title: `${item.title} Evidence Collection Test`,
|
|
55
|
+
status: "draft",
|
|
56
|
+
evidenceKind: item.testEvidenceKind,
|
|
57
|
+
collectionTestFamilyId: item.familyId,
|
|
58
|
+
collectionTestPrompt: item.testPrompt,
|
|
59
|
+
controlIds: item.controlIds
|
|
60
|
+
};
|
|
61
|
+
created.push(record);
|
|
62
|
+
}
|
|
63
|
+
if (created.length) await createResources(loaded.root, created);
|
|
64
|
+
return {
|
|
65
|
+
created,
|
|
66
|
+
existing: plan.filter(({ existing }) => existing).map(({ existing }) => existing),
|
|
67
|
+
total: plan.length
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/git.js
CHANGED
|
@@ -131,6 +131,7 @@ async function commitWorkspaceUnlocked(root, message) {
|
|
|
131
131
|
}
|
|
132
132
|
const before = getGitSummary(root);
|
|
133
133
|
if (!before.available) throw new Error("Git history is unavailable for this workspace.");
|
|
134
|
+
if (!before.branch) throw new Error("Check out a branch before creating a browser commit.");
|
|
134
135
|
if (before.clean) throw new Error("The workspace has no changes to commit.");
|
|
135
136
|
if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
|
|
136
137
|
throw new Error("Configure git user.name and user.email before committing.");
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldRes
|
|
|
3
3
|
export { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
|
|
4
4
|
export { buildWorkspace } from "./build.js";
|
|
5
5
|
export { generateEvidencePacket, prepareEvidencePacket, writeEvidencePacket } from "./evidence-packet.js";
|
|
6
|
+
export { ensureEvidenceTestDrafts, planEvidenceTestDrafts } from "./evidence-tests.js";
|
|
6
7
|
export {
|
|
7
8
|
addEvidenceAttachment,
|
|
8
9
|
createResource,
|
|
@@ -32,6 +33,15 @@ export {
|
|
|
32
33
|
createObligationEvent,
|
|
33
34
|
planObligations
|
|
34
35
|
} from "./obligations.js";
|
|
36
|
+
export { assessProgramReadiness } from "./program-readiness.js";
|
|
37
|
+
export {
|
|
38
|
+
buildAgentProgramPath,
|
|
39
|
+
policyEventName,
|
|
40
|
+
POLICY_EVENT_NAMES,
|
|
41
|
+
PROGRAM_PATH,
|
|
42
|
+
RESOURCE_INSTRUCTIONS,
|
|
43
|
+
resourceProgramContext
|
|
44
|
+
} from "./program-path.js";
|
|
35
45
|
export {
|
|
36
46
|
addCalendarDays,
|
|
37
47
|
calendarDayDifference,
|
|
@@ -41,6 +51,7 @@ export {
|
|
|
41
51
|
} from "./recurrence.js";
|
|
42
52
|
export { searchResources, searchableValues } from "./search.js";
|
|
43
53
|
export { createFileGRCServer, serveWorkspace } from "./server.js";
|
|
54
|
+
export { normalizeSetupPayload, setupWorkspace } from "./setup.js";
|
|
44
55
|
export { createAppState } from "./state.js";
|
|
45
56
|
export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
46
57
|
export { validateWorkspace } from "./validate.js";
|
package/src/model-docs.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { buildAgentProgramPath, RESOURCE_INSTRUCTIONS } from "./program-path.js";
|
|
2
|
+
|
|
1
3
|
export function generateModelDocumentation(model) {
|
|
4
|
+
const programPath = buildAgentProgramPath(model);
|
|
2
5
|
const lines = [
|
|
3
6
|
"# GRC Data Model",
|
|
4
7
|
"",
|
|
@@ -10,6 +13,28 @@ export function generateModelDocumentation(model) {
|
|
|
10
13
|
"",
|
|
11
14
|
"Each structured resource is one UTF-8 JSON file. Long-form work is an implicit Markdown companion beside that JSON file. Git supplies file authors, timestamps, diffs, commit messages, and revisions, so records do not duplicate those fields or file paths.",
|
|
12
15
|
"",
|
|
16
|
+
"## Program path",
|
|
17
|
+
"",
|
|
18
|
+
"The renderer, CLI, generated agent instructions, and this reference use the same six-step lifecycle:",
|
|
19
|
+
"",
|
|
20
|
+
...programPath.flatMap((stage) => [
|
|
21
|
+
`### Step ${stage.number}. ${stage.title}`,
|
|
22
|
+
"",
|
|
23
|
+
stage.summary,
|
|
24
|
+
"",
|
|
25
|
+
...stage.pages.map((page) => `- **${page.title}** (\`${page.type || `utility:${page.utility}`}\`): ${page.instructions}`),
|
|
26
|
+
"",
|
|
27
|
+
...(stage.operatingRecords?.length ? [
|
|
28
|
+
"Operating record guides:",
|
|
29
|
+
"",
|
|
30
|
+
...stage.operatingRecords.map((page) => `- **${page.title}** (\`${page.type}\`): ${page.instructions}`),
|
|
31
|
+
""
|
|
32
|
+
] : []),
|
|
33
|
+
"Headless commands:",
|
|
34
|
+
"",
|
|
35
|
+
...stage.commands.map((command) => `- \`${command}\``),
|
|
36
|
+
""
|
|
37
|
+
]),
|
|
13
38
|
"## Common fields",
|
|
14
39
|
"",
|
|
15
40
|
"| Field | Type | Required | Meaning |",
|
|
@@ -26,9 +51,9 @@ export function generateModelDocumentation(model) {
|
|
|
26
51
|
"",
|
|
27
52
|
`Record Markdown is shown by default for: ${model.recordContent.defaultResourceTypes.map((type) => `\`${type}\``).join(", ")}. Other resources without dedicated Markdown can add it when structured fields are not enough.`,
|
|
28
53
|
"",
|
|
29
|
-
"##
|
|
54
|
+
"## Program and audit readiness defaults",
|
|
30
55
|
"",
|
|
31
|
-
"
|
|
56
|
+
"Program Readiness checks management scope, policy adoption, control implementation, authoritative source configuration, and verified test captures without requiring an audit record. Audit Readiness starts after a CPA firm is engaged and uses the defaults below to prepare Type 1 and Type 2 fieldwork.",
|
|
32
57
|
"",
|
|
33
58
|
"Management documents:",
|
|
34
59
|
"",
|
|
@@ -40,7 +65,11 @@ export function generateModelDocumentation(model) {
|
|
|
40
65
|
"",
|
|
41
66
|
"Authoritative systems of record:",
|
|
42
67
|
"",
|
|
43
|
-
...model.
|
|
68
|
+
...model.evidenceSourceFamilies.map((item) => (
|
|
69
|
+
item.collectionTestRequired === false
|
|
70
|
+
? `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} FileGRC operating records: ${item.operationRecordTypes.map((type) => `\`${type}\``).join(", ")}. No separate collection test is required. ${item.timing}`
|
|
71
|
+
: `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} Test external collection: ${item.testPrompt} ${item.timing}`
|
|
72
|
+
)),
|
|
44
73
|
"",
|
|
45
74
|
"## Resource groups",
|
|
46
75
|
""
|
|
@@ -52,6 +81,7 @@ export function generateModelDocumentation(model) {
|
|
|
52
81
|
lines.push(`### ${group.title}`, "");
|
|
53
82
|
for (const [type, resource] of resources) {
|
|
54
83
|
lines.push(`#### \`${type}\``, "", resource.description, "");
|
|
84
|
+
lines.push(`Instructions: ${RESOURCE_INSTRUCTIONS[type] || resource.description}`, "");
|
|
55
85
|
lines.push(`Policy basis: ${resource.guidance.policyBasis}`, "");
|
|
56
86
|
lines.push(`Timing: ${resource.guidance.cadence}`, "");
|
|
57
87
|
if (resource.guidance.sourceResourceIds?.length) {
|
|
@@ -68,7 +98,12 @@ export function generateModelDocumentation(model) {
|
|
|
68
98
|
lines.push("Markdown companions:", "");
|
|
69
99
|
for (const [name, markdown] of Object.entries(resource.markdown)) {
|
|
70
100
|
const suffix = markdown.primary ? ".md" : `-${name}.md`;
|
|
71
|
-
|
|
101
|
+
const requirement = markdown.required
|
|
102
|
+
? "required"
|
|
103
|
+
: markdown.requiredWhen
|
|
104
|
+
? `required when ${conditionText(markdown.requiredWhen)}`
|
|
105
|
+
: "optional";
|
|
106
|
+
lines.push(`- **${markdown.label}**: \`${suffix}\` beside the JSON record (${requirement}).`);
|
|
72
107
|
}
|
|
73
108
|
lines.push("");
|
|
74
109
|
}
|
|
@@ -78,8 +113,10 @@ export function generateModelDocumentation(model) {
|
|
|
78
113
|
const requiredLabel = required.has(name) || field.required ? "Yes" : field.requiredWhen ? "Conditional" : "No";
|
|
79
114
|
lines.push(`| \`${name}\` | ${fieldType(field)} | ${requiredLabel} | ${escapeCell(fieldNotes(field))} |`);
|
|
80
115
|
}
|
|
81
|
-
for (const
|
|
82
|
-
|
|
116
|
+
for (const group of resource.oneOf ?? []) {
|
|
117
|
+
const choices = Array.isArray(group) ? group : group.fields || [];
|
|
118
|
+
const condition = Array.isArray(group) ? "" : ` when ${conditionText(group.when)}`;
|
|
119
|
+
lines.push("", `At least one of ${choices.map(choiceLabel).join(", ")} is required${condition}.`);
|
|
83
120
|
}
|
|
84
121
|
lines.push("");
|
|
85
122
|
}
|
|
@@ -87,6 +124,14 @@ export function generateModelDocumentation(model) {
|
|
|
87
124
|
return lines.join("\n");
|
|
88
125
|
}
|
|
89
126
|
|
|
127
|
+
function conditionText(condition) {
|
|
128
|
+
return Object.entries(condition).map(([name, value]) => (
|
|
129
|
+
Array.isArray(value)
|
|
130
|
+
? `\`${name}\` is one of ${value.map((item) => `\`${item}\``).join(", ")}`
|
|
131
|
+
: `\`${name}\` is \`${value}\``
|
|
132
|
+
)).join(" and ");
|
|
133
|
+
}
|
|
134
|
+
|
|
90
135
|
function recordContentMode(model, type, resource) {
|
|
91
136
|
if (!model.recordContent?.slot || resource.markdown) return null;
|
|
92
137
|
return model.recordContent.defaultResourceTypes.includes(type) ? "default" : "optional";
|