filegrc 0.2.0 → 0.3.1
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/LICENSE +1 -1
- package/README.md +10 -9
- package/model/v1.json +21 -21
- package/package.json +2 -2
- package/src/audit-preparation.js +13 -36
- package/src/cli.js +154 -17
- package/src/evidence-packet.js +18 -18
- package/src/git.js +1 -1
- package/src/index.js +2 -2
- package/src/model-docs.js +1 -1
- package/src/parties.js +46 -0
- package/src/paths.js +1 -1
- package/src/program-lifecycle.js +3 -0
- package/src/program-path.js +9 -9
- package/src/program-readiness.js +80 -32
- package/src/server.js +2 -2
- package/src/setup.js +104 -66
- package/src/state.js +1 -1
- package/src/validate.js +5 -34
- package/src/web.js +98 -40
package/src/web.js
CHANGED
|
@@ -21,7 +21,7 @@ export function renderIndex(state = null) {
|
|
|
21
21
|
<meta charset="utf-8">
|
|
22
22
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
23
23
|
<meta name="color-scheme" content="light dark">
|
|
24
|
-
<title>
|
|
24
|
+
<title>filegrc</title>
|
|
25
25
|
<link rel="icon" type="image/png" href="./favicon.png">
|
|
26
26
|
<link rel="stylesheet" href="./filegrc.css">
|
|
27
27
|
</head>
|
|
@@ -52,7 +52,7 @@ const READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
|
|
|
52
52
|
}));
|
|
53
53
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
54
54
|
...RESOURCE_INSTRUCTIONS,
|
|
55
|
-
"utility:audit-packet": "Review
|
|
55
|
+
"utility:audit-packet": "Review filegrc Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet."
|
|
56
56
|
})};
|
|
57
57
|
const POLICY_EVENT_NAMES = ${JSON.stringify(POLICY_EVENT_NAMES)};
|
|
58
58
|
const STAGE_PAGE_ID_ALIASES = {
|
|
@@ -174,7 +174,7 @@ function buildNavigation(route) {
|
|
|
174
174
|
const organizationCurrent = route.name === "organization" || route.name === "repository" || ["workspace", "renderer-settings"].includes(route.type);
|
|
175
175
|
const organizationName = state.workspace.organizationName || "Organization";
|
|
176
176
|
const initial = organizationName.trim().charAt(0).toUpperCase() || "O";
|
|
177
|
-
return '<aside class="sidebar" id="sidebar-navigation"><button class="nav-close" type="button" aria-label="Close navigation">×</button><a href="#/" class="brand"' + (route.name === "home" ? ' aria-current="page"' : "") + '><img class="mark" src="./favicon.png" alt="" width="39" height="39"><span><strong>
|
|
177
|
+
return '<aside class="sidebar" id="sidebar-navigation"><button class="nav-close" type="button" aria-label="Close navigation">×</button><a href="#/" class="brand"' + (route.name === "home" ? ' aria-current="page"' : "") + '><img class="mark" src="./favicon.png" alt="" width="39" height="39"><span><strong>filegrc</strong><small>SOC 2 workspace</small></span></a><nav class="sidebar-nav">' + stages + '</nav><div class="sidebar-footer"><a class="organization-nav ' + (organizationCurrent ? "current" : "") + '" href="#/organization"><span class="organization-mark">' + esc(initial) + '</span><span><strong>' + esc(organizationName) + '</strong><small>Organization</small></span><span class="organization-arrow">›</span></a></div></aside><button class="nav-scrim" type="button" aria-label="Close navigation"></button>';
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
function readinessStageForRoute(route) {
|
|
@@ -216,7 +216,7 @@ function topbar(route) {
|
|
|
216
216
|
? "Work Queue"
|
|
217
217
|
: route.name === "audit-packet"
|
|
218
218
|
? "Audit Readiness"
|
|
219
|
-
: state.model.resources[route.type]?.pluralTitle || "
|
|
219
|
+
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
220
220
|
return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><label class="search"><span aria-hidden="true">⌕</span><input id="global-search" type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><div class="topbar-status"><a class="validation-chip" href="#/repository"><span class="status-dot ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + (state.git.clean ? "good" : "warn") + '"></span>' + esc(state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable") + '</a></div>';
|
|
221
221
|
}
|
|
222
222
|
|
|
@@ -227,21 +227,40 @@ function renderHome(main) {
|
|
|
227
227
|
const setupPending = rendererSettingsEntry()?.record.showOnboarding === true;
|
|
228
228
|
const acceptedEventTriggers = state.obligations.triggers.filter(({ programStatus }) => programStatus !== "proposed");
|
|
229
229
|
const openObligations = state.obligations.items.filter((item) => item.status !== "complete");
|
|
230
|
+
const previewObligations = distinctObligationPreviews(openObligations, 3);
|
|
230
231
|
const obligationHeading = openObligations.some((item) => item.status !== "proposed") ? "Due Windows" : "Starter Proposals";
|
|
231
|
-
const setupBanner = setupPending
|
|
232
|
-
? '<section class="setup-banner"><div><p class="kicker">Setup incomplete</p><h3>Define the initial service boundary</h3><p>Record the management program goal and the systems that should enter policy and control review.</p></div><ol><li>Describe the service boundary.</li><li>Choose the program goal.</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>'
|
|
233
|
-
: "";
|
|
232
|
+
const setupBanner = setupPending ? initialSetupBanner() : "";
|
|
234
233
|
const auditPanel = program.evidenceReady
|
|
235
234
|
? '<section class="panel audit-panel"><div class="panel-head"><div><p class="kicker">Optional next phase</p><h3>' + esc(activeFirm ? titleCase(activeAudit.record.title) : "Target: " + program.target.label) + '</h3></div>' + (activeAudit ? '<a href="#/resource/audit/' + encodeURIComponent(activeAudit.record.id) + '">Open audit</a>' : '<a href="#/resources/audit">Engagements</a>') + '</div>' +
|
|
236
235
|
(activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '</section>'
|
|
237
236
|
: "";
|
|
238
237
|
main.innerHTML = '<div class="page home-page"><section class="hero overview-hero"><div><p class="kicker">Current program state</p><h2>' + esc(titleCase(state.workspace.title)) + '</h2><p>' + esc(state.workspace.description || "Governance, risk, controls, evidence, and audit work maintained as plain files in Git.") + '</p></div></section>' + setupBanner + readinessOverview() +
|
|
239
|
-
'<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>' + obligationHeading + '</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(
|
|
238
|
+
'<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>' + obligationHeading + '</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(previewObligations) + '</section>' +
|
|
240
239
|
'<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">' + (acceptedEventTriggers.length ? "Trigger work" : "Review proposals") + '</a></div>' + eventReminderPreview(state.obligations.triggers.slice(0, 4)) + '</section>' +
|
|
241
240
|
auditPanel + '</div></div>';
|
|
242
241
|
main.querySelector("#resume-setup")?.addEventListener("click", requestOnboarding);
|
|
243
242
|
}
|
|
244
243
|
|
|
244
|
+
function initialSetupBanner() {
|
|
245
|
+
const system = resourcesOfType("system").find(({ record }) => record.inScope && record.status !== "retired")?.record;
|
|
246
|
+
if (!system) {
|
|
247
|
+
return '<section class="setup-banner"><div><p class="kicker">Setup incomplete</p><h3>Define the initial service boundary</h3><p>Record the management program goal and the systems that should enter policy and control review.</p></div><ol><li>Describe the service boundary.</li><li>Choose the program goal.</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
|
|
248
|
+
}
|
|
249
|
+
const goal = programGoalFromKind(state.workspace.assuranceGoal);
|
|
250
|
+
const goalLabels = {
|
|
251
|
+
readiness: "Program Readiness",
|
|
252
|
+
"type-1": "SOC 2 Type 1",
|
|
253
|
+
"type-2": "SOC 2 Type 2"
|
|
254
|
+
};
|
|
255
|
+
const goalStep = goal === "none"
|
|
256
|
+
? "Choose the program goal."
|
|
257
|
+
: "Confirm the saved program goal: " + goalLabels[goal] + ".";
|
|
258
|
+
const completion = system.status === "planned"
|
|
259
|
+
? "Complete setup to activate the planned service and continue to Step 1."
|
|
260
|
+
: "Complete setup to close onboarding and continue to Step 1.";
|
|
261
|
+
return '<section class="setup-banner"><div><p class="kicker">Setup draft saved</p><h3>Review and complete initial setup</h3><p>' + esc(system.title) + ' already has a saved service boundary.</p></div><ol><li>Review the saved service boundary.</li><li>' + esc(goalStep) + '</li><li>' + esc(completion) + '</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
|
|
262
|
+
}
|
|
263
|
+
|
|
245
264
|
function readinessOverview() {
|
|
246
265
|
const progress = programPathProgress();
|
|
247
266
|
const nextHref = nextProgramStageHref();
|
|
@@ -249,7 +268,7 @@ function readinessOverview() {
|
|
|
249
268
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === id);
|
|
250
269
|
const current = stageProgress(stage);
|
|
251
270
|
const remaining = current.total - current.complete;
|
|
252
|
-
const status = !remaining ? "
|
|
271
|
+
const status = !remaining ? "Review complete" : current.complete ? remaining + " pages to review" : "Review not marked";
|
|
253
272
|
return [stage.title, body, href, status, !remaining ? "good" : current.complete ? "warn" : "neutral"];
|
|
254
273
|
};
|
|
255
274
|
const stages = [
|
|
@@ -257,10 +276,10 @@ function readinessOverview() {
|
|
|
257
276
|
programStage("policies", "Tailor the policy set, obtain independent management approval, and establish effective dates.", "#/stage/policies"),
|
|
258
277
|
programStage("controls", "Finish the internal control set with actual procedures, owners, scope, and evidence sources, then record any complementary controls.", "#/stage/controls"),
|
|
259
278
|
programStage("evidence", "For each control family, finish the source-system instructions and verify a real test export or capture.", "#/stage/evidence"),
|
|
260
|
-
programStage("run", "Begin the candidate period, maintain risk assessments, work the
|
|
279
|
+
programStage("run", "Begin the candidate period, maintain risk assessments, work the filegrc queue, run the remaining controls, and retain dated evidence.", "#/stage/run"),
|
|
261
280
|
programStage("audit", "Engage the CPA firm, confirm the formal period, complete fieldwork, and generate the final evidence packet.", "#/stage/audit")
|
|
262
281
|
];
|
|
263
|
-
return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>
|
|
282
|
+
return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>Page Review Progress</span><strong>' + progress.percent + '%</strong><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " program pages marked reviewed") + '</small></div><a class="button primary" href="' + nextHref + '">Continue</a></div></div><div class="readiness-flow">' + stages.map(([title, body, href, status, tone], index) => '<a href="' + href + '"><span>' + (index + 1) + '</span><strong>' + esc(title) + '</strong><small>' + esc(body) + '</small><b class="readiness-state ' + esc(tone) + '">' + esc(status) + '</b></a>').join("") + '</div></section>';
|
|
264
283
|
}
|
|
265
284
|
|
|
266
285
|
function nextProgramStageHref() {
|
|
@@ -391,12 +410,12 @@ function programPathProgress() {
|
|
|
391
410
|
}
|
|
392
411
|
|
|
393
412
|
function progressFromCounts(complete, total, noun) {
|
|
394
|
-
if (!total) return { percent: 0, complete: 0, total: 0, status: "
|
|
413
|
+
if (!total) return { percent: 0, complete: 0, total: 0, status: "Nothing to review", tone: "neutral", detail: "No " + pluralize(noun, 2) + " are configured yet." };
|
|
395
414
|
const percent = Math.round((complete / total) * 100);
|
|
396
|
-
const detail = complete + " of " + total + " " + pluralize(noun, total) + " marked
|
|
397
|
-
if (complete === total) return { percent: 100, complete, total, status: "
|
|
398
|
-
if (!complete) return { percent: 0, complete, total, status: "
|
|
399
|
-
return { percent, complete, total, status: "
|
|
415
|
+
const detail = complete + " of " + total + " " + pluralize(noun, total) + " marked reviewed. This tracks page review, while readiness checks the records themselves.";
|
|
416
|
+
if (complete === total) return { percent: 100, complete, total, status: "Review complete", tone: "good", detail };
|
|
417
|
+
if (!complete) return { percent: 0, complete, total, status: "No pages marked", tone: "warn", detail };
|
|
418
|
+
return { percent, complete, total, status: "Review in progress", tone: "warn", detail };
|
|
400
419
|
}
|
|
401
420
|
|
|
402
421
|
function stageProgressCard(progress) {
|
|
@@ -411,7 +430,7 @@ function sectionDestinations(section) {
|
|
|
411
430
|
destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
|
|
412
431
|
}
|
|
413
432
|
if (section.utility === "obligation-board") destinations.push({ utility: section.utility, kind: "Working page", label: "Work Queue", href: "#/stage/run", description: "Complete recurring work, Policy Event tasks, and assigned follow-up with its due windows and linked proof." });
|
|
414
|
-
if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review
|
|
433
|
+
if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review filegrc and External Evidence, prepare fieldwork, and build the indexed packet." });
|
|
415
434
|
return destinations;
|
|
416
435
|
}
|
|
417
436
|
|
|
@@ -468,7 +487,7 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
468
487
|
main.innerHTML = '<div class="page obligation-board-page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
469
488
|
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 6</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(stageProgress(stage)) + '</section>' +
|
|
470
489
|
feedback +
|
|
471
|
-
'<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs.
|
|
490
|
+
'<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div></section>' +
|
|
472
491
|
'<section class="workflow-section work-queue-section"><div class="section-head"><div><p class="kicker">Recurring, event, and assigned work</p><h2>Work Queue</h2><p>This board schedules work linked to ' + scheduledControls + ' of ' + controls.length + ' controls and includes ' + assignedFollowUp + ' open ' + pluralize("Action Item", assignedFollowUp) + '. Triggered Policy Event actions appear here as individual tasks in Upcoming, Due, or Overdue. Other controls operate continuously or per transaction in their source systems and are documented through evidence records. Starter work remains a proposal until its governing policies are effective and at least one linked control is implemented.</p></div><div class="page-actions">' + (!state.readOnly ? '<button class="button" type="button" data-new-action-item>New task</button>' : "") + '<a class="button" href="#/resources/obligation">Edit schedules</a></div></div>' +
|
|
473
492
|
'<div class="obligation-board">' + sections + '</div>' +
|
|
474
493
|
'</section></div>';
|
|
@@ -510,7 +529,7 @@ function policyEventTrigger(trigger, index) {
|
|
|
510
529
|
const proposed = trigger.programStatus === "proposed";
|
|
511
530
|
const unavailable = state.readOnly || proposed;
|
|
512
531
|
const availability = proposed
|
|
513
|
-
? "Available after its governing policies are effective
|
|
532
|
+
? "Available after its governing policies are effective, at least one linked control is implemented, and every task has a current owner."
|
|
514
533
|
: state.readOnly
|
|
515
534
|
? "Open this workspace in writable mode to trigger the workflow."
|
|
516
535
|
: trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
|
|
@@ -525,7 +544,9 @@ function obligationCard(item, collapsed = false) {
|
|
|
525
544
|
const type = item.actionItemId ? "action-item" : "obligation";
|
|
526
545
|
const id = item.actionItemId || item.obligationId;
|
|
527
546
|
const completion = !item.actionItemId ? obligationCompletionPlan(item) : null;
|
|
528
|
-
const
|
|
547
|
+
const canAct = completion?.blocked === "Assign current owner"
|
|
548
|
+
|| !["upcoming", "proposed"].includes(item.status);
|
|
549
|
+
const action = !state.readOnly && canAct && completion
|
|
529
550
|
? completion.blocked
|
|
530
551
|
? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
|
|
531
552
|
: '<button class="obligation-action" type="button" data-record-obligation="' + esc(item.key) + '">Record work</button>'
|
|
@@ -536,14 +557,17 @@ function obligationCard(item, collapsed = false) {
|
|
|
536
557
|
|
|
537
558
|
function obligationCompletionPlan(item) {
|
|
538
559
|
const type = OBLIGATION_COMPLETION_TYPES[item.activityType] || "evidence";
|
|
560
|
+
if (!currentPeopleForParties(item.ownerIds || []).length) {
|
|
561
|
+
return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
|
|
562
|
+
}
|
|
539
563
|
if (["access-review", "backup-test"].includes(type) && !resourcesOfType("system").some(({ record }) => record.inScope && record.status !== "retired")) {
|
|
540
564
|
return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
|
|
541
565
|
}
|
|
542
566
|
if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
|
|
543
567
|
return { type, blocked: "Add vendor first", href: "#/resources/vendor?new=1" };
|
|
544
568
|
}
|
|
545
|
-
if (type === "meeting" && !
|
|
546
|
-
return { type, blocked: "
|
|
569
|
+
if (type === "meeting" && !completionTeam(item)) {
|
|
570
|
+
return { type, blocked: "Finish team and chair", href: "#/resources/team" };
|
|
547
571
|
}
|
|
548
572
|
return { type };
|
|
549
573
|
}
|
|
@@ -567,15 +591,14 @@ function openObligationCompletion(item) {
|
|
|
567
591
|
function obligationCompletionSeed(type, item, obligation) {
|
|
568
592
|
const date = currentDate();
|
|
569
593
|
const timestamp = new Date().toISOString();
|
|
570
|
-
const
|
|
571
|
-
const ownerIds = (item.ownerIds || []).filter((id) => people.includes(id));
|
|
572
|
-
const responsiblePeople = ownerIds.length ? ownerIds : people.slice(0, 1);
|
|
594
|
+
const responsiblePeople = currentPeopleForParties(item.ownerIds || []);
|
|
573
595
|
const inScopeSystems = resourcesOfType("system").filter(({ record }) => record.inScope && record.status !== "retired").map(({ record }) => record.id);
|
|
574
596
|
const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
|
|
575
597
|
const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
|
|
576
598
|
const common = { title };
|
|
577
599
|
if (type === "meeting") {
|
|
578
|
-
|
|
600
|
+
const team = completionTeam(item);
|
|
601
|
+
return { ...common, status: "complete", teamId: team.id, chairIds: currentPeopleForParties(team.chairIds || []), scheduledOn: date };
|
|
579
602
|
}
|
|
580
603
|
if (type === "policy-review") {
|
|
581
604
|
return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: responsiblePeople, reviewedOn: date, outcome: "passed", changesRequired: false, periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
|
|
@@ -678,10 +701,10 @@ function openObligationEventDialog(trigger) {
|
|
|
678
701
|
function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
679
702
|
const audits = resourcesOfType("audit");
|
|
680
703
|
const evidence = resourcesOfType("evidence");
|
|
681
|
-
const
|
|
704
|
+
const filegrcRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
|
|
682
705
|
.filter((family) => family.collectionTestRequired === false)
|
|
683
706
|
.flatMap((family) => family.operationRecordTypes || []));
|
|
684
|
-
const
|
|
707
|
+
const filegrcRecords = state.resources.filter(({ record }) => filegrcRecordTypes.has(record.type));
|
|
685
708
|
const requestedAudit = params.get("auditId");
|
|
686
709
|
const selected = audits.find(({ record }) => record.id === requestedAudit)?.record || audits.find(({ record }) => record.status !== "complete")?.record || null;
|
|
687
710
|
const today = currentDate();
|
|
@@ -693,15 +716,15 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
|
693
716
|
const preflight = [
|
|
694
717
|
["Repository", state.git.available ? state.git.clean ? "Clean revision" : state.git.changes.length + " uncommitted" : "Git unavailable", "#/repository", state.git.clean ? "good" : "warn"],
|
|
695
718
|
["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
|
|
696
|
-
["
|
|
719
|
+
["filegrc Evidence", filegrcRecords.length + " operating " + pluralize("record", filegrcRecords.length), "#/stage/run", "neutral"],
|
|
697
720
|
["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
|
|
698
721
|
["Policy work", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : state.obligations.counts.due ? state.obligations.counts.due + " due" : state.obligations.counts.proposed ? state.obligations.counts.proposed + " proposals" : "No work due", "#/stage/run", state.obligations.counts.overdue ? "bad" : state.obligations.counts.due ? "warn" : state.obligations.counts.proposed ? "neutral" : "good"]
|
|
699
722
|
];
|
|
700
|
-
const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">
|
|
723
|
+
const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">filegrc Evidence</span><h4>Review operating records</h4><p>Complete the applicable Step 5 records, link them to their Controls, and record results in their fields or Markdown. Link any external artifact needed to support the result. The packet includes the records, Markdown, Git history, and linked artifacts.</p></a><a href="#/resources/evidence"><span class="step-label">External Evidence</span><h4>Review imported or referenced proof</h4><p>Verify the source System, audit date or period, Control links, collector, verifier, and fixed attachment or approved external reference. The packet includes the records, retained files, delivery index, and checksums.</p></a></div></section>';
|
|
701
724
|
const dateFields = typeOne
|
|
702
725
|
? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
|
|
703
726
|
: '<label><span>Period start</span><input type="date" name="start" required value="' + esc(start) + '"></label><label><span>Period end</span><input type="date" name="end" required value="' + esc(end) + '"></label>';
|
|
704
|
-
main.innerHTML = '<div class="page audit-packet-page"><div class="page-intro"><div><p class="kicker">Audit evidence and packet</p><h2>Prepare Fieldwork</h2><p>Confirm the firm-agreed date or period, review
|
|
727
|
+
main.innerHTML = '<div class="page audit-packet-page"><div class="page-intro"><div><p class="kicker">Audit evidence and packet</p><h2>Prepare Fieldwork</h2><p>Confirm the firm-agreed date or period, review filegrc Evidence and External Evidence, complete management documents and Type 2 populations, then build the indexed packet. filegrc compiles both evidence paths with their links, control matrix, history, attachments, indexes, and checksums.</p></div></div><section class="packet-preflight" aria-label="Packet readiness">' + preflight.map(([label, value, href, tone]) => '<a href="' + href + '"><span class="status-dot ' + tone + '"></span><span><small>' + esc(label) + '</small><strong>' + esc(value) + '</strong></span></a>').join("") + '</section>' + evidencePaths + renderAuditPreparation(preparation) + '<section class="panel packet-builder"><div class="panel-head"><div><p class="kicker">Evidence delivery</p><h3>' + (typeOne ? "Build the As-of Packet" : "Build the Period Packet") + '</h3></div></div><form id="packet-form">' + dateFields + '<label><span>Audit <small>required for delivery</small></span><select name="auditId"><option value="">Draft Without Audit Scope</option>' + audits.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === selected?.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><button class="button primary" type="submit" ' + (state.readOnly ? "disabled" : "") + '>' + (draft ? "Generate draft" : "Generate packet") + '</button></form><p class="packet-note">' + (state.readOnly ? "Packet generation requires the local writable renderer or the CLI." : draft ? "Drafts expose coverage gaps now. Commit a clean revision and select an audit record before delivery." : "The packet is derived under .filegrc/ and bound to the selected audit and current Git revision. filegrc checks preparation and integrity; the engagement team determines evidence sufficiency.") + '</p><div class="dialog-error" role="alert"></div></section><div id="packet-results"></div></div>';
|
|
705
728
|
main.querySelector('select[name="auditId"]').addEventListener("change", (event) => {
|
|
706
729
|
const next = event.currentTarget.value;
|
|
707
730
|
location.hash = "#/audit-packet" + (next ? "?auditId=" + encodeURIComponent(next) : "");
|
|
@@ -796,18 +819,31 @@ function renderPacketResults(container, result) {
|
|
|
796
819
|
const packet = result.packet;
|
|
797
820
|
const ready = packet.readiness.status === "delivery-ready";
|
|
798
821
|
container.innerHTML = '<section class="metrics packet-metrics">' +
|
|
799
|
-
metric("
|
|
822
|
+
metric("filegrc Evidence", packet.summary.filegrcRecords, packet.summary.records + " total packet records", "neutral") +
|
|
800
823
|
metric("Obligations", packet.summary.obligationOccurrences, packet.summary.eventRuns + " event workflows", "neutral") +
|
|
801
824
|
metric("External Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
|
|
802
825
|
metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
|
|
803
|
-
'</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "
|
|
804
|
-
'<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included
|
|
826
|
+
'</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "filegrc management checks passed" : "Draft packet") + '</p><h3>' + esc(result.output) + '</h3></div>' + (result.packetUrl ? '<a class="button primary" href="' + esc(result.packetUrl) + '" target="_blank" rel="noreferrer">Open index</a>' : "") + '</div><p>The directory contains ' + result.files.length + ' files. ' + (ready ? "Verify the checksums, reconcile external deliveries, and let the engagement team confirm evidence sufficiency." : "Do not deliver it until every error is resolved and each warning has been reviewed.") + '</p></section>' +
|
|
827
|
+
'<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included filegrc Evidence</h3></div>' + (packet.filegrcRecords.length ? '<div class="packet-list">' + packet.filegrcRecords.slice(0, 12).map((item) => '<a href="#/resource/' + encodeURIComponent(item.type) + '/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.type)) + ' · ' + esc(item.primaryDate) + '</small></a>').join("") + '</div>' : empty("No filegrc Evidence matched.")) + '</section><section class="panel"><div class="panel-head"><h3>Included External Evidence</h3></div>' + (packet.evidence.length ? '<div class="packet-list">' + packet.evidence.slice(0, 12).map((item) => '<a href="#/resource/evidence/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.status)) + ' · ' + esc(properCase(item.evidenceKind)) + '</small></a>').join("") + '</div>' : empty("No External Evidence matched.")) + '</section></div>';
|
|
805
828
|
}
|
|
806
829
|
|
|
807
830
|
function obligationPreview(items) {
|
|
808
831
|
return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/stage/run"><span class="status-dot ' + (item.status === "overdue" ? "bad" : item.status === "due" ? "warn" : "neutral") + '"></span><span><strong>' + esc(item.title) + '</strong><small>' + esc(timingText(item)) + '</small></span></a>').join("") + '</div>' : empty("No open obligations.");
|
|
809
832
|
}
|
|
810
833
|
|
|
834
|
+
function distinctObligationPreviews(items, limit) {
|
|
835
|
+
const seen = new Set();
|
|
836
|
+
const result = [];
|
|
837
|
+
for (const item of items) {
|
|
838
|
+
const source = item.obligationId || item.actionItemId || item.key;
|
|
839
|
+
if (seen.has(source)) continue;
|
|
840
|
+
seen.add(source);
|
|
841
|
+
result.push(item);
|
|
842
|
+
if (result.length === limit) break;
|
|
843
|
+
}
|
|
844
|
+
return result;
|
|
845
|
+
}
|
|
846
|
+
|
|
811
847
|
function eventReminderPreview(triggers) {
|
|
812
848
|
return triggers.length ? '<div class="event-reminder-preview">' + triggers.map((trigger) => {
|
|
813
849
|
const proposed = trigger.programStatus === "proposed";
|
|
@@ -1182,7 +1218,7 @@ function openCommitDialog() {
|
|
|
1182
1218
|
const dialog = document.createElement("dialog");
|
|
1183
1219
|
dialog.className = "commit-dialog";
|
|
1184
1220
|
dialog.setAttribute("aria-labelledby", "commit-dialog-title");
|
|
1185
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git audit trail</p><h2 id="commit-dialog-title">' + (hasRemote ? "Commit and Push Workspace Changes" : "Commit Workspace Changes") + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + (hasRemote ? "Commit every change under this
|
|
1221
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git audit trail</p><h2 id="commit-dialog-title">' + (hasRemote ? "Commit and Push Workspace Changes" : "Commit Workspace Changes") + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + (hasRemote ? "Commit every change under this filegrc workspace, then push the commit to its Git remote." : "Commit every change under this filegrc workspace. Add a Git remote later when you are ready to sync it.") + ' Use a message that explains why the compliance records changed.</p><label><span>Commit message</span><input name="message" required maxlength="200" placeholder="Record quarterly access review"></label><div class="commit-files">' + state.git.changes.map((change) => '<code>' + esc(change) + '</code>').join("") + '</div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-commit="cancel">Cancel</button><button type="submit" class="button primary">' + actionLabel + '</button></div></form>';
|
|
1186
1222
|
document.body.append(dialog);
|
|
1187
1223
|
dialog.showModal();
|
|
1188
1224
|
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
@@ -1402,7 +1438,7 @@ function onboardingSteps() {
|
|
|
1402
1438
|
points: [
|
|
1403
1439
|
"Quarterly means any date in that cycle is valid unless the policy sets a narrower window.",
|
|
1404
1440
|
"Link a dated completion record and its evidence to satisfy one occurrence.",
|
|
1405
|
-
"The UI and
|
|
1441
|
+
"The UI and filegrc CLI use the same calculation."
|
|
1406
1442
|
]
|
|
1407
1443
|
};
|
|
1408
1444
|
const events = {
|
|
@@ -1414,7 +1450,7 @@ function onboardingSteps() {
|
|
|
1414
1450
|
"The checklist stays open until every action is done and has the requested completion record or evidence.",
|
|
1415
1451
|
"Hour-based rules keep the event time and exact cutoff; day-based rules keep the policy date range.",
|
|
1416
1452
|
"Every action has a policy-based cutoff or a reasonable default deadline for the event.",
|
|
1417
|
-
"Agents start the identical workflow with the
|
|
1453
|
+
"Agents start the identical workflow with the filegrc CLI."
|
|
1418
1454
|
]
|
|
1419
1455
|
};
|
|
1420
1456
|
const reportTypes = {
|
|
@@ -1435,7 +1471,7 @@ function onboardingSteps() {
|
|
|
1435
1471
|
body: "A CPA evaluates whether the controls operated consistently throughout an agreed review period, often six months. Dated evidence must cover that period."
|
|
1436
1472
|
}
|
|
1437
1473
|
],
|
|
1438
|
-
afterSections: "Most evidence comes from production, identity, monitoring, and business systems.
|
|
1474
|
+
afterSections: "Most evidence comes from production, identity, monitoring, and business systems. filegrc records where it comes from and how it was collected."
|
|
1439
1475
|
};
|
|
1440
1476
|
const audit = {
|
|
1441
1477
|
target: ".audit-panel",
|
|
@@ -1452,7 +1488,7 @@ function onboardingSteps() {
|
|
|
1452
1488
|
target: null,
|
|
1453
1489
|
kicker: "Initial scope",
|
|
1454
1490
|
title: "Describe the service you plan to audit",
|
|
1455
|
-
body: "This records the management goal and creates the first in-scope system. It does not create an audit engagement. Next, finish Step 1 by
|
|
1491
|
+
body: "This records the management goal and creates the first in-scope system. It does not create an audit engagement. Next, finish Step 1 by adding the real reviewers and operators, finishing the oversight team, and confirming the criteria, commitments, vendors, and systems."
|
|
1456
1492
|
};
|
|
1457
1493
|
return [
|
|
1458
1494
|
files,
|
|
@@ -2307,6 +2343,28 @@ function formatCadence(value) {
|
|
|
2307
2343
|
return value.mode ? humanize(value.mode) : "";
|
|
2308
2344
|
}
|
|
2309
2345
|
function resourcesOfType(type) { return state.resources.filter(({ record }) => record.type === type); }
|
|
2346
|
+
function currentPeopleForParties(ids = [], seen = new Set()) {
|
|
2347
|
+
const people = [];
|
|
2348
|
+
for (const id of ids) {
|
|
2349
|
+
if (seen.has(id)) continue;
|
|
2350
|
+
seen.add(id);
|
|
2351
|
+
const party = state.resources.find(({ record }) => record.id === id)?.record;
|
|
2352
|
+
if (party?.type === "person" && ["active", "external"].includes(party.status)) people.push(party.id);
|
|
2353
|
+
if (party?.type === "team" && party.status === "active") {
|
|
2354
|
+
people.push(...currentPeopleForParties([...(party.memberIds || []), ...(party.chairIds || [])], seen));
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
return [...new Set(people)];
|
|
2358
|
+
}
|
|
2359
|
+
function completionTeam(item) {
|
|
2360
|
+
const ownerIds = new Set(item.ownerIds || []);
|
|
2361
|
+
const teams = resourcesOfType("team").map(({ record }) => record);
|
|
2362
|
+
const ownedTeams = teams.filter((record) => ownerIds.has(record.id));
|
|
2363
|
+
return (ownedTeams.length ? ownedTeams : teams).find((record) => (
|
|
2364
|
+
record.status === "active"
|
|
2365
|
+
&& currentPeopleForParties(record.chairIds || []).length
|
|
2366
|
+
)) || null;
|
|
2367
|
+
}
|
|
2310
2368
|
function searchText(record) {
|
|
2311
2369
|
const definition = state.model.resources[record.type];
|
|
2312
2370
|
if (!definition) return "";
|
|
@@ -2507,7 +2565,7 @@ async function localFetch(url, options) {
|
|
|
2507
2565
|
try {
|
|
2508
2566
|
return await fetch(url, options);
|
|
2509
2567
|
} catch {
|
|
2510
|
-
throw new Error("The
|
|
2568
|
+
throw new Error("The filegrc server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
|
|
2511
2569
|
}
|
|
2512
2570
|
}
|
|
2513
2571
|
async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }
|