filegrc 0.7.1 → 0.9.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 +7 -7
- package/model/index.js +22 -4
- package/model/v5.json +10233 -0
- package/model/v6.json +10358 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +182 -45
- package/src/audit-transition.js +3 -2
- package/src/batch-review.js +7 -6
- package/src/cli.js +143 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +181 -0
- package/src/evidence-packet.js +199 -78
- package/src/external-reviewer.js +5 -4
- package/src/files.js +173 -35
- package/src/git.js +71 -7
- package/src/index.js +11 -1
- package/src/model-migration.js +363 -7
- package/src/obligations.js +14 -11
- package/src/policy-library.js +7 -3
- package/src/program-lifecycle.js +131 -3
- package/src/program-path.js +19 -13
- package/src/program-readiness.js +338 -71
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +36 -7
- package/src/setup.js +8 -5
- package/src/soc2.js +3 -2
- package/src/validate.js +178 -16
- package/src/web.js +264 -17
- package/src/workflow.js +38 -7
- package/src/workspace.js +5 -0
package/src/evidence-packet.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
3
|
import { copyFile, mkdir, readFile, readdir, readlink, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { modelSupports } from "../model/index.js";
|
|
5
6
|
import { assessAuditPreparation } from "./audit-preparation.js";
|
|
6
7
|
import {
|
|
7
8
|
coverageContains,
|
|
@@ -10,9 +11,9 @@ import {
|
|
|
10
11
|
coverageOverlaps,
|
|
11
12
|
coverageStart
|
|
12
13
|
} from "./coverage.js";
|
|
13
|
-
import {
|
|
14
|
+
import { getFilesAtRevisions, getGitSummary, getWorkspaceHistories, getWorkspaceRevisionSnapshot, hasGitRevision } from "./git.js";
|
|
14
15
|
import { planObligations } from "./obligations.js";
|
|
15
|
-
import { isWithin, resolveDataPath, resolveWorkspacePath } from "./paths.js";
|
|
16
|
+
import { isWithin, resolveDataPath, resolveWorkspacePath, resolveWorkspaceRoot } from "./paths.js";
|
|
16
17
|
import { parseCalendarDate } from "./recurrence.js";
|
|
17
18
|
import { markdownEntries } from "./resource-markdown.js";
|
|
18
19
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
@@ -27,6 +28,9 @@ import {
|
|
|
27
28
|
subsequentEventsReviewIssue
|
|
28
29
|
} from "./soc2.js";
|
|
29
30
|
import { validateWorkspace } from "./validate.js";
|
|
31
|
+
import { measureTiming } from "./timing.js";
|
|
32
|
+
|
|
33
|
+
const preparedPacketValidations = new WeakMap();
|
|
30
34
|
|
|
31
35
|
const NON_EVIDENCE_RECORD_TYPES = new Set([
|
|
32
36
|
"appointment",
|
|
@@ -110,6 +114,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
110
114
|
...(audit.complementaryControlIds || []),
|
|
111
115
|
...auditSubserviceVendorIds(audit),
|
|
112
116
|
...auditSubserviceComponentIds(audit),
|
|
117
|
+
audit.engagementTermsDocumentId,
|
|
113
118
|
audit.systemDescriptionDocumentId,
|
|
114
119
|
audit.managementAssertionDocumentId,
|
|
115
120
|
audit.managementRepresentationDocumentId,
|
|
@@ -228,7 +233,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
228
233
|
return sourceRevisionValidity.get(revision);
|
|
229
234
|
};
|
|
230
235
|
const evidence = [...evidenceIds].map((id) => evidenceSummary(byId.get(id), byId, revisionIsValid)).filter(Boolean).sort(byTitle);
|
|
231
|
-
const v4 =
|
|
236
|
+
const v4 = modelSupports(loaded.model, "component-sources");
|
|
232
237
|
const sourceSystemIds = new Set([
|
|
233
238
|
...(v4
|
|
234
239
|
? [...controlIds].flatMap((id) => byId.get(id)?.evidenceSourceComponentIds || [])
|
|
@@ -244,7 +249,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
244
249
|
`data/${entry.relativePath}`,
|
|
245
250
|
...markdownEntries(loaded.model, entry.record).map((markdown) => `data/${markdown.path}`)
|
|
246
251
|
]);
|
|
247
|
-
const historyRevision =
|
|
252
|
+
const historyRevision = await getWorkspaceRevisionSnapshot(loaded.root);
|
|
248
253
|
const histories = getWorkspaceHistories(
|
|
249
254
|
loaded.root,
|
|
250
255
|
selectedPaths,
|
|
@@ -305,6 +310,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
305
310
|
generatedAt,
|
|
306
311
|
selectDefault: false
|
|
307
312
|
});
|
|
313
|
+
const documentLifecycles = auditDocumentLifecycleSummaries(audit, loaded.model, byId);
|
|
308
314
|
const gaps = packetGaps({
|
|
309
315
|
audit,
|
|
310
316
|
byId,
|
|
@@ -323,7 +329,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
323
329
|
});
|
|
324
330
|
const errorCount = gaps.filter(({ severity }) => severity === "error").length;
|
|
325
331
|
const warningCount = gaps.filter(({ severity }) => severity === "warning").length;
|
|
326
|
-
|
|
332
|
+
const packet = {
|
|
327
333
|
schemaVersion: 1,
|
|
328
334
|
generatedAt,
|
|
329
335
|
period: { start, end, basis },
|
|
@@ -376,6 +382,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
376
382
|
eventRuns: eventRuns.length,
|
|
377
383
|
evidence: evidence.length,
|
|
378
384
|
populations: populations.length,
|
|
385
|
+
...(modelSupports(loaded.model, "governed-document-activation") ? { documents: documentLifecycles.length } : {}),
|
|
379
386
|
gaps: gaps.length,
|
|
380
387
|
errors: errorCount,
|
|
381
388
|
warnings: warningCount
|
|
@@ -390,11 +397,64 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
390
397
|
[v4 ? "sourceComponents" : "sourceSystems"]: sourceSystems,
|
|
391
398
|
dataModelVersion: String(loaded.model.modelVersion),
|
|
392
399
|
populations,
|
|
400
|
+
...(modelSupports(loaded.model, "governed-document-activation") ? { documentLifecycles } : {}),
|
|
393
401
|
managementPreparation,
|
|
394
402
|
controlCoverage,
|
|
395
403
|
gaps,
|
|
396
404
|
records: packetRecords
|
|
397
405
|
};
|
|
406
|
+
preparedPacketValidations.set(packet, {
|
|
407
|
+
validation,
|
|
408
|
+
revision: {
|
|
409
|
+
commit: packet.revision.commit,
|
|
410
|
+
branch: packet.revision.branch,
|
|
411
|
+
dataDigest: packet.revision.dataDigest
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
return packet;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function auditDocumentLifecycleSummaries(audit, model, byId) {
|
|
418
|
+
if (!audit || !modelSupports(model, "governed-document-activation")) return [];
|
|
419
|
+
const rolesById = new Map();
|
|
420
|
+
const add = (id, role) => {
|
|
421
|
+
if (!id) return;
|
|
422
|
+
const roles = rolesById.get(id) || [];
|
|
423
|
+
roles.push(role);
|
|
424
|
+
rolesById.set(id, roles);
|
|
425
|
+
};
|
|
426
|
+
add(audit.engagementTermsDocumentId, "Engagement Terms");
|
|
427
|
+
for (const definition of model.auditReadiness?.managementDocuments || []) {
|
|
428
|
+
if (!(definition.engagementKinds || []).includes(audit.auditKind)) continue;
|
|
429
|
+
add(audit[definition.field], definition.title || definition.field);
|
|
430
|
+
}
|
|
431
|
+
for (const id of audit.supplementalDocumentIds || []) add(id, "Supplemental");
|
|
432
|
+
return [...rolesById]
|
|
433
|
+
.map(([id, roles]) => {
|
|
434
|
+
const document = byId.get(id);
|
|
435
|
+
if (document?.type !== "document") return null;
|
|
436
|
+
const personLabels = (ids = []) => ids.map((personId) => byId.get(personId)?.title || personId);
|
|
437
|
+
return {
|
|
438
|
+
id: document.id,
|
|
439
|
+
title: document.title,
|
|
440
|
+
workflowScope: document.workflowScope || null,
|
|
441
|
+
roles: [...new Set(roles)],
|
|
442
|
+
status: document.status,
|
|
443
|
+
ownerIds: document.ownerIds || [],
|
|
444
|
+
approverIds: document.approverIds || [],
|
|
445
|
+
approvers: personLabels(document.approverIds),
|
|
446
|
+
approvedOn: document.approvedOn || null,
|
|
447
|
+
approvedContentRevisions: document.approvedContentRevisions || null,
|
|
448
|
+
activationBasis: document.activationBasis || null,
|
|
449
|
+
activatedByIds: document.activatedByIds || [],
|
|
450
|
+
activators: personLabels(document.activatedByIds),
|
|
451
|
+
activatedOn: document.activatedOn || null,
|
|
452
|
+
activatedContentRevisions: document.activatedContentRevisions || null,
|
|
453
|
+
effectiveOn: document.effectiveOn || null
|
|
454
|
+
};
|
|
455
|
+
})
|
|
456
|
+
.filter(Boolean)
|
|
457
|
+
.sort(byTitle);
|
|
398
458
|
}
|
|
399
459
|
|
|
400
460
|
function expandSupersededPolicyIds(policyIds, byId) {
|
|
@@ -422,6 +482,7 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
|
|
|
422
482
|
...(audit.complementaryControlIds || []),
|
|
423
483
|
...auditSubserviceVendorIds(audit),
|
|
424
484
|
...auditSubserviceComponentIds(audit),
|
|
485
|
+
audit.engagementTermsDocumentId,
|
|
425
486
|
audit.systemDescriptionDocumentId,
|
|
426
487
|
audit.managementAssertionDocumentId,
|
|
427
488
|
audit.managementRepresentationDocumentId,
|
|
@@ -501,7 +562,17 @@ export async function writeEvidencePacket(input, packet, options = {}) {
|
|
|
501
562
|
let outputOption = options.output || `.filegrc/evidence-packets/${baseName}`;
|
|
502
563
|
requireDerivedOutputPath(outputOption);
|
|
503
564
|
let output = resolveWorkspacePath(input, outputOption);
|
|
504
|
-
const
|
|
565
|
+
const prepared = preparedPacketValidations.get(packet);
|
|
566
|
+
const preparedValidation = prepared
|
|
567
|
+
&& prepared.validation.loaded.root === resolveWorkspaceRoot(input)
|
|
568
|
+
&& prepared.revision.commit === packet.revision?.commit
|
|
569
|
+
&& prepared.revision.branch === packet.revision?.branch
|
|
570
|
+
&& prepared.revision.dataDigest === packet.revision?.dataDigest
|
|
571
|
+
? prepared.validation
|
|
572
|
+
: null;
|
|
573
|
+
const validation = preparedValidation
|
|
574
|
+
? preparedValidation
|
|
575
|
+
: await validateWorkspace(input);
|
|
505
576
|
if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before writing evidence.`);
|
|
506
577
|
await assertPacketSourceState(packet, validation.loaded);
|
|
507
578
|
const entriesById = new Map(validation.loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
@@ -531,9 +602,12 @@ export async function writeEvidencePacket(input, packet, options = {}) {
|
|
|
531
602
|
await writePacketFile(output, "README.md", packetMarkdown(packet), files);
|
|
532
603
|
await writePacketFile(output, "index.html", packetHtml(packet), files);
|
|
533
604
|
await writePacketFile(output, "control-matrix.csv", controlMatrixCsv(packet), files);
|
|
605
|
+
if (modelSupports(packet.dataModelVersion, "governed-document-activation")) {
|
|
606
|
+
await writePacketFile(output, "document-lifecycle-index.csv", documentLifecycleIndexCsv(packet), files);
|
|
607
|
+
}
|
|
534
608
|
await writePacketFile(output, "evidence-index.csv", evidenceIndexCsv(packet), files);
|
|
535
|
-
await writePacketFile(output, packet.dataModelVersion
|
|
536
|
-
await writePacketFile(output, packet.dataModelVersion
|
|
609
|
+
await writePacketFile(output, modelSupports(packet.dataModelVersion, "component-sources") ? "source-component-index.csv" : "source-system-index.csv", sourceSystemIndexCsv(packet), files);
|
|
610
|
+
await writePacketFile(output, modelSupports(packet.dataModelVersion, "evidence-artifacts") ? "evidence-artifact-index.csv" : "external-evidence-index.csv", externalEvidenceIndexCsv(packet), files);
|
|
537
611
|
await writePacketFile(output, "population-index.csv", populationIndexCsv(packet), files);
|
|
538
612
|
await writePacketFile(output, "HANDLING.md", packetHandlingMarkdown(packet), files);
|
|
539
613
|
for (const item of packet.records) {
|
|
@@ -560,12 +634,28 @@ export async function writeEvidencePacket(input, packet, options = {}) {
|
|
|
560
634
|
}
|
|
561
635
|
}
|
|
562
636
|
const historyIndex = [];
|
|
637
|
+
const historicalFiles = [];
|
|
563
638
|
for (const item of packet.records) {
|
|
564
|
-
|
|
639
|
+
collectCommittedVersions(historicalFiles, item, item.path, item.history);
|
|
565
640
|
for (const content of item.contentPaths || []) {
|
|
566
|
-
|
|
641
|
+
collectCommittedVersions(historicalFiles, item, content.path, content.history);
|
|
567
642
|
}
|
|
568
643
|
}
|
|
644
|
+
const historicalSources = getFilesAtRevisions(validation.loaded.root, historicalFiles);
|
|
645
|
+
for (let index = 0; index < historicalFiles.length; index += 1) {
|
|
646
|
+
const source = historicalSources[index];
|
|
647
|
+
if (source === null) continue;
|
|
648
|
+
const { item, sourcePath, history } = historicalFiles[index];
|
|
649
|
+
const exportedPath = join("history", item.type, item.id, history.commit, basename(sourcePath));
|
|
650
|
+
await writePacketFile(output, exportedPath, source, files);
|
|
651
|
+
historyIndex.push({
|
|
652
|
+
resourceId: item.id,
|
|
653
|
+
resourceType: item.type,
|
|
654
|
+
sourcePath,
|
|
655
|
+
exportedPath: exportedPath.split("\\").join("/"),
|
|
656
|
+
...history
|
|
657
|
+
});
|
|
658
|
+
}
|
|
569
659
|
await writePacketFile(output, "history/index.json", `${JSON.stringify(historyIndex, null, 2)}\n`, files);
|
|
570
660
|
await assertPacketSourceState(packet, validation.loaded);
|
|
571
661
|
await writeChecksums(output, files);
|
|
@@ -586,35 +676,34 @@ export function generateEvidencePacket(input, options = {}) {
|
|
|
586
676
|
});
|
|
587
677
|
}
|
|
588
678
|
|
|
589
|
-
|
|
679
|
+
function collectCommittedVersions(target, item, sourcePath, history) {
|
|
590
680
|
for (const revision of history || []) {
|
|
591
|
-
|
|
592
|
-
if (source === null) continue;
|
|
593
|
-
const exportedPath = join("history", item.type, item.id, revision.commit, basename(sourcePath));
|
|
594
|
-
await writePacketFile(output, exportedPath, source, files);
|
|
595
|
-
historyIndex.push({
|
|
596
|
-
resourceId: item.id,
|
|
597
|
-
resourceType: item.type,
|
|
598
|
-
sourcePath,
|
|
599
|
-
exportedPath: exportedPath.split("\\").join("/"),
|
|
600
|
-
...revision
|
|
601
|
-
});
|
|
681
|
+
target.push({ item, sourcePath, relativePath: sourcePath, revision: revision.commit, history: revision });
|
|
602
682
|
}
|
|
603
683
|
}
|
|
604
684
|
|
|
605
685
|
async function writeChecksums(output, files) {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
686
|
+
await measureTiming("packet-output-hash", async () => {
|
|
687
|
+
const paths = [...files].sort();
|
|
688
|
+
const lines = new Array(paths.length);
|
|
689
|
+
let next = 0;
|
|
690
|
+
const workers = Array.from({ length: Math.min(4, paths.length) }, async () => {
|
|
691
|
+
while (next < paths.length) {
|
|
692
|
+
const index = next++;
|
|
693
|
+
const relativePath = paths[index];
|
|
694
|
+
const hash = createHash("sha256");
|
|
695
|
+
for await (const chunk of createReadStream(resolvePacketOutputPath(output, relativePath))) hash.update(chunk);
|
|
696
|
+
lines[index] = `${hash.digest("hex")} ${relativePath}`;
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
await Promise.all(workers);
|
|
700
|
+
await writeFile(resolvePacketOutputPath(output, "SHA256SUMS"), `${lines.join("\n")}\n`, { encoding: "utf8", flag: "wx" });
|
|
701
|
+
});
|
|
613
702
|
}
|
|
614
703
|
|
|
615
704
|
function controlMatrixCsv(packet) {
|
|
616
705
|
return csv([
|
|
617
|
-
["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Operation Pattern", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "filegrc Evidence IDs", packet.dataModelVersion
|
|
706
|
+
["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Operation Pattern", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "filegrc Evidence IDs", modelSupports(packet.dataModelVersion, "evidence-artifacts") ? "Evidence Artifact IDs" : "External Evidence IDs", "Control Test IDs", "Test Outcomes", "Population IDs", "Population Counts", "Sample Sizes", "Exception Counts", "Population Evidence IDs", "Sample Evidence IDs"],
|
|
618
707
|
...packet.controlCoverage.map((control) => [
|
|
619
708
|
control.id,
|
|
620
709
|
control.code,
|
|
@@ -643,6 +732,30 @@ function controlMatrixCsv(packet) {
|
|
|
643
732
|
]);
|
|
644
733
|
}
|
|
645
734
|
|
|
735
|
+
function documentLifecycleIndexCsv(packet) {
|
|
736
|
+
return csv([
|
|
737
|
+
["Document ID", "Document", "Workflow Scope", "Audit Roles", "Status", "Owner IDs", "Approver IDs", "Approvers", "Approved On", "Approved Content Revisions", "Activation Basis", "Activator IDs", "Activators", "Activated On", "Activation Content Revisions", "Effective On"],
|
|
738
|
+
...packet.documentLifecycles.map((document) => [
|
|
739
|
+
document.id,
|
|
740
|
+
document.title,
|
|
741
|
+
document.workflowScope,
|
|
742
|
+
document.roles.join("\n"),
|
|
743
|
+
document.status,
|
|
744
|
+
document.ownerIds.join("\n"),
|
|
745
|
+
document.approverIds.join("\n"),
|
|
746
|
+
document.approvers.join("\n"),
|
|
747
|
+
document.approvedOn,
|
|
748
|
+
document.approvedContentRevisions ? JSON.stringify(document.approvedContentRevisions) : "",
|
|
749
|
+
document.activationBasis,
|
|
750
|
+
document.activatedByIds.join("\n"),
|
|
751
|
+
document.activators.join("\n"),
|
|
752
|
+
document.activatedOn,
|
|
753
|
+
document.activatedContentRevisions ? JSON.stringify(document.activatedContentRevisions) : "",
|
|
754
|
+
document.effectiveOn
|
|
755
|
+
])
|
|
756
|
+
]);
|
|
757
|
+
}
|
|
758
|
+
|
|
646
759
|
function packetHandlingMarkdown(packet) {
|
|
647
760
|
return [
|
|
648
761
|
"# Packet Handling",
|
|
@@ -663,7 +776,7 @@ function packetHandlingMarkdown(packet) {
|
|
|
663
776
|
}
|
|
664
777
|
|
|
665
778
|
function evidenceIndexCsv(packet) {
|
|
666
|
-
const v4 = packet.dataModelVersion
|
|
779
|
+
const v4 = modelSupports(packet.dataModelVersion, "component-sources");
|
|
667
780
|
return csv([
|
|
668
781
|
["Evidence ID", v4 ? "Evidence Artifact" : "Evidence", "Status", "Kind", "Source", v4 ? "Source Component ID" : "Source System ID", v4 ? "Source Component" : "Source System", "Collected On", "Collector IDs", "Verified On", "Verifier IDs", "Period Start", "Period End", "Generated At", "Timezone", "Query or Report Parameters", "Population Count", "Completeness Validation", "Accuracy Validation", "Control IDs", "Source Resource IDs", "Source Commit", "File Paths", "External Reference"],
|
|
669
782
|
...packet.evidence.map((item) => [
|
|
@@ -696,7 +809,7 @@ function evidenceIndexCsv(packet) {
|
|
|
696
809
|
}
|
|
697
810
|
|
|
698
811
|
function sourceSystemIndexCsv(packet) {
|
|
699
|
-
const v4 = packet.dataModelVersion
|
|
812
|
+
const v4 = modelSupports(packet.dataModelVersion, "component-sources");
|
|
700
813
|
return csv([
|
|
701
814
|
[v4 ? "Component ID" : "System ID", v4 ? "Component" : "System", "Status", "Evidence Source Roles", "Evidence Access Owner IDs", "Vendor ID", v4 ? "Supports Audit System" : "In Audit Scope", "Evidence IDs"],
|
|
702
815
|
...(packet.sourceComponents || packet.sourceSystems || []).map((item) => [
|
|
@@ -713,7 +826,7 @@ function sourceSystemIndexCsv(packet) {
|
|
|
713
826
|
}
|
|
714
827
|
|
|
715
828
|
function externalEvidenceIndexCsv(packet) {
|
|
716
|
-
const v4 = packet.dataModelVersion
|
|
829
|
+
const v4 = modelSupports(packet.dataModelVersion, "component-sources");
|
|
717
830
|
return csv([
|
|
718
831
|
["Evidence ID", v4 ? "Evidence Artifact" : "Evidence", v4 ? "Source Component ID" : "Source System ID", v4 ? "Source Component" : "Source System", "Control IDs", "External Reference", "Fixed Attachment Included", "Delivery Note"],
|
|
719
832
|
...packet.evidence
|
|
@@ -734,7 +847,7 @@ function externalEvidenceIndexCsv(packet) {
|
|
|
734
847
|
}
|
|
735
848
|
|
|
736
849
|
function populationIndexCsv(packet) {
|
|
737
|
-
const v4 = packet.dataModelVersion
|
|
850
|
+
const v4 = modelSupports(packet.dataModelVersion, "component-sources");
|
|
738
851
|
return csv([
|
|
739
852
|
["Population ID", "Population", "Kind", "Status", "Period Start", "Period End", v4 ? "Source Component ID" : "Source System ID", v4 ? "Source Component" : "Source System", "Authoritative Source", "Query or Report Parameters", "Timezone", "Generated At", "Record Count", "Completeness Validation", "Accuracy Validation", "Reconciled By", "Reconciled On", "Conclusion", "Control IDs", "Evidence ID", "Not Applicable Reason"],
|
|
740
853
|
...packet.populations.map((item) => [
|
|
@@ -787,7 +900,7 @@ function requireDerivedOutputPath(value) {
|
|
|
787
900
|
async function assertPacketSourceState(packet, loaded) {
|
|
788
901
|
await assertLoadedEntriesCurrent(loaded);
|
|
789
902
|
const dataDigest = await dataTreeDigest(loaded.root);
|
|
790
|
-
const git =
|
|
903
|
+
const git = await getWorkspaceRevisionSnapshot(loaded.root);
|
|
791
904
|
if (
|
|
792
905
|
packet.revision?.dataDigest !== dataDigest
|
|
793
906
|
|| packet.revision?.commit !== git.commit
|
|
@@ -807,36 +920,38 @@ async function assertLoadedEntriesCurrent(loaded) {
|
|
|
807
920
|
}
|
|
808
921
|
|
|
809
922
|
async function dataTreeDigest(root) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
923
|
+
return measureTiming("packet-data-hash", async () => {
|
|
924
|
+
const hash = createHash("sha256");
|
|
925
|
+
updateDigestField(hash, "filegrc-data-tree-v1");
|
|
926
|
+
const visit = async (directory, prefix = "") => {
|
|
927
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
928
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
929
|
+
for (const entry of entries) {
|
|
930
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
931
|
+
const path = join(directory, entry.name);
|
|
932
|
+
if (entry.isDirectory()) {
|
|
933
|
+
updateDigestField(hash, "directory");
|
|
934
|
+
updateDigestField(hash, relativePath);
|
|
935
|
+
await visit(path, relativePath);
|
|
936
|
+
} else if (entry.isFile()) {
|
|
937
|
+
const fileHash = createHash("sha256");
|
|
938
|
+
for await (const chunk of createReadStream(path)) fileHash.update(chunk);
|
|
939
|
+
updateDigestField(hash, "file");
|
|
940
|
+
updateDigestField(hash, relativePath);
|
|
941
|
+
updateDigestField(hash, fileHash.digest("hex"));
|
|
942
|
+
} else if (entry.isSymbolicLink()) {
|
|
943
|
+
updateDigestField(hash, "symlink");
|
|
944
|
+
updateDigestField(hash, relativePath);
|
|
945
|
+
updateDigestField(hash, await readlink(path));
|
|
946
|
+
} else {
|
|
947
|
+
updateDigestField(hash, "other");
|
|
948
|
+
updateDigestField(hash, relativePath);
|
|
949
|
+
}
|
|
835
950
|
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
951
|
+
};
|
|
952
|
+
await visit(resolveDataPath(root, "."));
|
|
953
|
+
return `sha256:${hash.digest("hex")}`;
|
|
954
|
+
});
|
|
840
955
|
}
|
|
841
956
|
|
|
842
957
|
function updateDigestField(hash, value) {
|
|
@@ -1049,7 +1164,7 @@ function packetGaps({
|
|
|
1049
1164
|
}
|
|
1050
1165
|
}
|
|
1051
1166
|
if (controlNeedsExternalEvidence(control, model) && !coverage.evidenceIds.length) {
|
|
1052
|
-
gaps.push(gap("error", "control-missing-external-evidence", `${coverage.code || coverage.title} relies on an external source but has no linked ${
|
|
1167
|
+
gaps.push(gap("error", "control-missing-external-evidence", `${coverage.code || coverage.title} relies on an external source but has no linked ${modelSupports(model, "evidence-artifacts") ? "Evidence Artifact" : "External Evidence"} in the packet.`, coverage.id));
|
|
1053
1168
|
} else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
|
|
1054
1169
|
gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
|
|
1055
1170
|
}
|
|
@@ -1265,7 +1380,7 @@ function packetGaps({
|
|
|
1265
1380
|
}
|
|
1266
1381
|
}
|
|
1267
1382
|
} else if (["population-export", "system-export", "configuration-export"].includes(item.artifactKind)) {
|
|
1268
|
-
gaps.push(gap("error", "evidence-source-system-unrecorded", `${item.title} is a source export but does not link the cataloged ${
|
|
1383
|
+
gaps.push(gap("error", "evidence-source-system-unrecorded", `${item.title} is a source export but does not link the cataloged ${modelSupports(model, "component-sources") ? "source Component" : "system of record"}.`, item.id));
|
|
1269
1384
|
}
|
|
1270
1385
|
if (item.externalReference && !item.filePaths.length) {
|
|
1271
1386
|
gaps.push(gap("warning", "external-only-evidence", `${item.title} relies on an external reference and is not self-contained in the packet.`, item.id));
|
|
@@ -1322,7 +1437,7 @@ function controlNeedsExternalEvidence(control, model) {
|
|
|
1322
1437
|
|
|
1323
1438
|
function auditGaps(gaps, audit, byId, records, start, end, model) {
|
|
1324
1439
|
const program = byId.get(audit.programId);
|
|
1325
|
-
if (
|
|
1440
|
+
if (modelSupports(model, "program-scope") && program?.assuranceGoal !== audit.auditKind) {
|
|
1326
1441
|
gaps.push(gap("error", "audit-program-goal-mismatch", `${audit.title} is ${audit.auditKind}, but its Program goal is ${program?.assuranceGoal || "missing"}. Align the management objective with the formal engagement before delivery.`, audit.id));
|
|
1327
1442
|
}
|
|
1328
1443
|
if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
|
|
@@ -1350,14 +1465,14 @@ function auditGaps(gaps, audit, byId, records, start, end, model) {
|
|
|
1350
1465
|
const missingDescriptionRequirements = descriptionRequirements.filter((requirement) => (
|
|
1351
1466
|
!(audit.requirementIds || []).includes(requirement.id)
|
|
1352
1467
|
));
|
|
1353
|
-
const missingRequiredDescriptionReferences =
|
|
1468
|
+
const missingRequiredDescriptionReferences = modelSupports(model, "program-scope")
|
|
1354
1469
|
? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
|
|
1355
1470
|
: [];
|
|
1356
1471
|
const securityRequirements = frameworkRequirements.filter(isSecurityRequirement);
|
|
1357
|
-
const missingRequiredSecurityReferences =
|
|
1472
|
+
const missingRequiredSecurityReferences = modelSupports(model, "program-scope")
|
|
1358
1473
|
? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
1359
1474
|
: [];
|
|
1360
|
-
const missingSelectedSecurityReferences =
|
|
1475
|
+
const missingSelectedSecurityReferences = modelSupports(model, "program-scope")
|
|
1361
1476
|
? missingSoc2References(selectedRequirements.filter(isSecurityRequirement), REQUIRED_SOC2_SECURITY_REFERENCES)
|
|
1362
1477
|
: [];
|
|
1363
1478
|
if (!descriptionRequirements.length) {
|
|
@@ -1384,10 +1499,10 @@ function auditGaps(gaps, audit, byId, records, start, end, model) {
|
|
|
1384
1499
|
} else if (!auditorWasEngaged(auditor, audit)) {
|
|
1385
1500
|
gaps.push(gap("error", "auditor-outside-engagement-period", `${auditor.title} was not active during the recorded fieldwork or report period.`, auditor.id));
|
|
1386
1501
|
}
|
|
1387
|
-
if (
|
|
1502
|
+
if (modelSupports(model, "program-scope") && !audit.scopeRevision) {
|
|
1388
1503
|
gaps.push(gap("error", "audit-scope-revision-missing", `${audit.title} does not bind management's reviewed engagement scope to a Git revision.`, audit.id));
|
|
1389
1504
|
}
|
|
1390
|
-
if (
|
|
1505
|
+
if (modelSupports(model, "program-scope")) {
|
|
1391
1506
|
const treatments = audit.subserviceTreatments || [];
|
|
1392
1507
|
const treatmentComponentCounts = new Map();
|
|
1393
1508
|
for (const treatment of treatments) {
|
|
@@ -1503,7 +1618,7 @@ function auditGaps(gaps, audit, byId, records, start, end, model) {
|
|
|
1503
1618
|
gaps.push(gap("error", "missing-audit-opinion", `${audit.title} does not record the issued opinion and opinion date.`, audit.id));
|
|
1504
1619
|
}
|
|
1505
1620
|
}
|
|
1506
|
-
if (
|
|
1621
|
+
if (modelSupports(model, "program-scope") && ["report-draft", "issued", "delivered", "complete"].includes(audit.status)) {
|
|
1507
1622
|
const subsequentEventsIssue = subsequentEventsReviewIssue(audit);
|
|
1508
1623
|
if (subsequentEventsIssue) {
|
|
1509
1624
|
gaps.push(gap("error", subsequentEventsIssue.code, subsequentEventsIssue.message, audit.id));
|
|
@@ -1557,7 +1672,7 @@ function isSecurityRequirement(requirement) {
|
|
|
1557
1672
|
}
|
|
1558
1673
|
|
|
1559
1674
|
function requirementIsApplicable(requirement, audit, byId, model) {
|
|
1560
|
-
if (
|
|
1675
|
+
if (!modelSupports(model, "program-scope")) return requirement.applicability === "applicable";
|
|
1561
1676
|
const program = audit?.programId ? byId.get(audit.programId) : null;
|
|
1562
1677
|
return (program?.requirementApplicability || []).some((decision) => (
|
|
1563
1678
|
decision.requirementId === requirement.id && decision.decision === "applicable"
|
|
@@ -1785,7 +1900,7 @@ function recordSummary(record) {
|
|
|
1785
1900
|
}
|
|
1786
1901
|
|
|
1787
1902
|
function packetMarkdown(packet) {
|
|
1788
|
-
const v4 = packet.dataModelVersion
|
|
1903
|
+
const v4 = modelSupports(packet.dataModelVersion, "component-sources");
|
|
1789
1904
|
const readiness = packet.readiness.status === "delivery-ready"
|
|
1790
1905
|
? "filegrc management checks passed. The engagement team still determines whether the evidence is sufficient and appropriate."
|
|
1791
1906
|
: `${packet.readiness.errors} errors and ${packet.readiness.warnings} warnings require review. This is a draft packet.`;
|
|
@@ -1812,6 +1927,9 @@ function packetMarkdown(packet) {
|
|
|
1812
1927
|
`- ${packet.summary.eventRuns} event runs`,
|
|
1813
1928
|
`- ${packet.summary.evidence} ${v4 ? "Evidence Artifact" : "External Evidence"} records`,
|
|
1814
1929
|
`- ${packet.summary.populations} reconciled or planned populations`,
|
|
1930
|
+
...(modelSupports(packet.dataModelVersion, "governed-document-activation")
|
|
1931
|
+
? [`- ${packet.summary.documents} governed engagement Documents with approval and activation history`]
|
|
1932
|
+
: []),
|
|
1815
1933
|
`- ${packet.summary.policies} policies`,
|
|
1816
1934
|
`- ${packet.summary.controls} controls`,
|
|
1817
1935
|
`- ${packet.summary.requirements} criteria`,
|
|
@@ -1819,7 +1937,7 @@ function packetMarkdown(packet) {
|
|
|
1819
1937
|
`- ${v4 ? packet.summary.sourceComponents : packet.summary.sourceSystems} cataloged source ${v4 ? "Components" : "Systems"}`,
|
|
1820
1938
|
"",
|
|
1821
1939
|
v4
|
|
1822
|
-
?
|
|
1940
|
+
? `Open \`index.html\` for the auditor-oriented index. \`control-matrix.csv\` cross-references criteria, Controls, filegrc Evidence, Evidence Artifacts, and tests.${modelSupports(packet.dataModelVersion, "governed-document-activation") ? " `document-lifecycle-index.csv` lists each engagement Document's approval and activation facts and exact Markdown revisions." : ""} \`source-component-index.csv\` identifies the Components used to produce Evidence. \`evidence-artifact-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.`
|
|
1823
1941
|
: "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.",
|
|
1824
1942
|
"",
|
|
1825
1943
|
"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.",
|
|
@@ -1829,7 +1947,7 @@ function packetMarkdown(packet) {
|
|
|
1829
1947
|
}
|
|
1830
1948
|
|
|
1831
1949
|
function packetHtml(packet) {
|
|
1832
|
-
const v4 = packet.dataModelVersion
|
|
1950
|
+
const v4 = modelSupports(packet.dataModelVersion, "component-sources");
|
|
1833
1951
|
const artifactLabel = v4 ? "Evidence Artifact" : "External Evidence";
|
|
1834
1952
|
const section = (title, body) => `<section><h2>${escapeHtml(title)}</h2>${body}</section>`;
|
|
1835
1953
|
const links = (items) => items.length
|
|
@@ -1875,13 +1993,16 @@ function packetHtml(packet) {
|
|
|
1875
1993
|
const controlCoverage = packet.controlCoverage.length
|
|
1876
1994
|
? `<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>${artifactLabel}</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>`
|
|
1877
1995
|
: "<p>No controls were selected.</p>";
|
|
1996
|
+
const documentLifecycles = packet.documentLifecycles?.length
|
|
1997
|
+
? `<p><a href="document-lifecycle-index.csv">Download Document lifecycle index CSV</a></p><table><thead><tr><th>Document</th><th>Audit role</th><th>Approval</th><th>Activation</th></tr></thead><tbody>${packet.documentLifecycles.map((document) => `<tr><td><a href="records/document/${encodeURIComponent(document.id)}.json">${escapeHtml(document.title)}</a><small>${escapeHtml(document.status)} · ${escapeHtml(document.workflowScope || "scope not recorded")}</small></td><td>${document.roles.map(escapeHtml).join("<br>")}</td><td>${escapeHtml(document.approvedOn || "Not approved")}<small>${document.approvers.map(escapeHtml).join(", ") || "No approver recorded"}</small></td><td>${escapeHtml(document.activatedOn || "Not activated")}<small>${document.activators.map(escapeHtml).join(", ") || "No activator recorded"}${document.effectiveOn ? ` · effective ${escapeHtml(document.effectiveOn)}` : ""}</small></td></tr>`).join("")}</tbody></table>`
|
|
1998
|
+
: "<p>No governed engagement Documents were linked to this audit.</p>";
|
|
1878
1999
|
const readinessLabel = packet.readiness.status === "delivery-ready" ? "filegrc management checks passed" : "Draft, do not deliver";
|
|
1879
2000
|
const packetDate = packet.period.basis === "as-of"
|
|
1880
2001
|
? `As of ${escapeHtml(packet.period.start)}`
|
|
1881
2002
|
: `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
|
|
1882
2003
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Evidence packet</title><style>
|
|
1883
2004
|
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}
|
|
1884
|
-
</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(v4 ? "Source Components" : "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(v4 ? "Evidence Artifacts" : "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>`;
|
|
2005
|
+
</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)}${modelSupports(packet.dataModelVersion, "governed-document-activation") ? section("Document lifecycle", documentLifecycles) : ""}${section("Control coverage", controlCoverage)}${section(v4 ? "Source Components" : "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(v4 ? "Evidence Artifacts" : "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>`;
|
|
1885
2006
|
}
|
|
1886
2007
|
|
|
1887
2008
|
async function writePacketFile(output, relativePath, source, files) {
|
package/src/external-reviewer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { modelSupports } from "../model/index.js";
|
|
1
2
|
import { applyResourceBatch } from "./files.js";
|
|
2
3
|
import { createResourceId } from "./id.js";
|
|
3
4
|
import { currentCalendarDate } from "./time.js";
|
|
@@ -5,8 +6,8 @@ import { loadWorkspace } from "./workspace.js";
|
|
|
5
6
|
|
|
6
7
|
export async function scaffoldExternalReviewerGovernance(input = process.cwd()) {
|
|
7
8
|
const loaded = await loadWorkspace(input);
|
|
8
|
-
if (!
|
|
9
|
-
throw new Error("External reviewer setup requires a model v3 or
|
|
9
|
+
if (!modelSupports(loaded.model, "guided-workflow")) {
|
|
10
|
+
throw new Error("External reviewer setup requires a model v3 or newer workspace.");
|
|
10
11
|
}
|
|
11
12
|
return {
|
|
12
13
|
reviewerName: null,
|
|
@@ -22,8 +23,8 @@ export async function scaffoldExternalReviewerGovernance(input = process.cwd())
|
|
|
22
23
|
|
|
23
24
|
export async function planExternalReviewerGovernance(input = process.cwd(), options = {}) {
|
|
24
25
|
const loaded = await loadWorkspace(input);
|
|
25
|
-
if (!
|
|
26
|
-
throw new Error("External reviewer setup requires a model v3 or
|
|
26
|
+
if (!modelSupports(loaded.model, "guided-workflow")) {
|
|
27
|
+
throw new Error("External reviewer setup requires a model v3 or newer workspace.");
|
|
27
28
|
}
|
|
28
29
|
const name = required(options.reviewerName, "External reviewer name");
|
|
29
30
|
const startsOn = required(options.startsOn, "Appointment start date");
|