filegrc 0.2.0 → 0.3.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/LICENSE +1 -1
- package/README.md +8 -7
- package/model/v1.json +21 -21
- package/package.json +2 -2
- package/src/audit-preparation.js +13 -36
- package/src/cli.js +105 -7
- package/src/evidence-packet.js +18 -18
- package/src/git.js +1 -1
- package/src/index.js +1 -1
- 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 +8 -8
- package/src/program-readiness.js +49 -32
- package/src/server.js +2 -2
- package/src/state.js +1 -1
- package/src/validate.js +5 -34
- package/src/web.js +98 -40
package/src/evidence-packet.js
CHANGED
|
@@ -245,7 +245,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
245
245
|
end,
|
|
246
246
|
timezone: loaded.workspace.timezone
|
|
247
247
|
});
|
|
248
|
-
const
|
|
248
|
+
const filegrcRecords = datedRecords.filter((record) => (
|
|
249
249
|
!NON_EVIDENCE_RECORD_TYPES.has(record.type)
|
|
250
250
|
&& controlIdsForRecord(byId.get(record.id), byId).size
|
|
251
251
|
));
|
|
@@ -319,7 +319,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
319
319
|
},
|
|
320
320
|
summary: {
|
|
321
321
|
datedRecords: datedRecords.length,
|
|
322
|
-
|
|
322
|
+
filegrcRecords: filegrcRecords.length,
|
|
323
323
|
records: packetRecords.length,
|
|
324
324
|
policies: policyIds.size,
|
|
325
325
|
controls: controlIds.size,
|
|
@@ -335,7 +335,7 @@ export async function prepareEvidencePacket(input, options = {}) {
|
|
|
335
335
|
warnings: warningCount
|
|
336
336
|
},
|
|
337
337
|
datedRecords: datedRecords.sort((a, b) => a.primaryDate.localeCompare(b.primaryDate) || byTitle(a, b)),
|
|
338
|
-
|
|
338
|
+
filegrcRecords: filegrcRecords.sort((a, b) => a.primaryDate.localeCompare(b.primaryDate) || byTitle(a, b)),
|
|
339
339
|
policies: [...policyIds].map((id) => recordSummary(byId.get(id))).filter(Boolean).sort(byTitle),
|
|
340
340
|
controls: [...controlIds].map((id) => recordSummary(byId.get(id))).filter(Boolean).sort(byTitle),
|
|
341
341
|
obligations,
|
|
@@ -557,7 +557,7 @@ async function writeChecksums(output, files) {
|
|
|
557
557
|
|
|
558
558
|
function controlMatrixCsv(packet) {
|
|
559
559
|
return csv([
|
|
560
|
-
["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"],
|
|
561
561
|
...packet.controlCoverage.map((control) => [
|
|
562
562
|
control.id,
|
|
563
563
|
control.code,
|
|
@@ -592,15 +592,15 @@ function packetHandlingMarkdown(packet) {
|
|
|
592
592
|
"",
|
|
593
593
|
`Evidence classifications: ${packet.handling.classifications.join(", ") || "none recorded"}`,
|
|
594
594
|
`External references present: ${packet.handling.containsExternalReferences ? "yes" : "no"}`,
|
|
595
|
-
"Encrypted by
|
|
595
|
+
"Encrypted by filegrc: no",
|
|
596
596
|
"",
|
|
597
597
|
"Review every included record and attachment for secrets, unnecessary personal data, customer data, and material outside the audit scope before transfer.",
|
|
598
598
|
"",
|
|
599
|
-
"Review `external-evidence-index.csv` before delivery. It identifies references that
|
|
599
|
+
"Review `external-evidence-index.csv` before delivery. It identifies references that filegrc did not copy. Reconcile those items to the auditor portal or other approved system so the engagement team can confirm it received the same evidence indexed here.",
|
|
600
600
|
"",
|
|
601
601
|
"Transfer this directory through the auditor's approved encrypted channel. Do not email an unencrypted packet. Give access only to the engagement team and retain or remove exported copies under the organization's evidence-retention rules.",
|
|
602
602
|
"",
|
|
603
|
-
"After transfer, enter the packet directory and run `shasum -a 256 -c SHA256SUMS` or `sha256sum -c SHA256SUMS`.
|
|
603
|
+
"After transfer, enter the packet directory and run `shasum -a 256 -c SHA256SUMS` or `sha256sum -c SHA256SUMS`. filegrc does not sign or encrypt the packet because those operations require organization-controlled keys and transfer-system choices.",
|
|
604
604
|
""
|
|
605
605
|
].join("\n");
|
|
606
606
|
}
|
|
@@ -972,7 +972,7 @@ function packetGaps({
|
|
|
972
972
|
if (controlNeedsExternalEvidence(control, model) && !coverage.evidenceIds.length) {
|
|
973
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
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
|
|
975
|
+
gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
|
|
976
976
|
}
|
|
977
977
|
if (audit?.auditKind !== "soc-2-type-1" && coverage.status === "implemented" && !coverage.operatingRecordIds.length && coverage.operationMode !== "automated") {
|
|
978
978
|
gaps.push(gap("warning", "control-missing-operating-record", `${coverage.code || coverage.title} has no dated operating record in the packet period.`, coverage.id));
|
|
@@ -1523,7 +1523,7 @@ function recordSummary(record) {
|
|
|
1523
1523
|
|
|
1524
1524
|
function packetMarkdown(packet) {
|
|
1525
1525
|
const readiness = packet.readiness.status === "delivery-ready"
|
|
1526
|
-
? "
|
|
1526
|
+
? "filegrc management checks passed. The engagement team still determines whether the evidence is sufficient and appropriate."
|
|
1527
1527
|
: `${packet.readiness.errors} errors and ${packet.readiness.warnings} warnings require review. This is a draft packet.`;
|
|
1528
1528
|
const periodLabel = packet.period.basis === "as-of"
|
|
1529
1529
|
? `as of ${packet.period.start}`
|
|
@@ -1543,7 +1543,7 @@ function packetMarkdown(packet) {
|
|
|
1543
1543
|
"",
|
|
1544
1544
|
"## Coverage",
|
|
1545
1545
|
"",
|
|
1546
|
-
`- ${packet.summary.
|
|
1546
|
+
`- ${packet.summary.filegrcRecords} filegrc Evidence records`,
|
|
1547
1547
|
`- ${packet.summary.obligationOccurrences} recurring obligation occurrences`,
|
|
1548
1548
|
`- ${packet.summary.eventRuns} event runs`,
|
|
1549
1549
|
`- ${packet.summary.evidence} External Evidence records`,
|
|
@@ -1554,7 +1554,7 @@ function packetMarkdown(packet) {
|
|
|
1554
1554
|
`- ${packet.summary.systems} in-scope systems`,
|
|
1555
1555
|
`- ${packet.summary.sourceSystems} cataloged source systems`,
|
|
1556
1556
|
"",
|
|
1557
|
-
"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.",
|
|
1558
1558
|
"",
|
|
1559
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.",
|
|
1560
1560
|
""
|
|
@@ -1569,7 +1569,7 @@ function packetHtml(packet) {
|
|
|
1569
1569
|
: "<p>None.</p>";
|
|
1570
1570
|
const gaps = packet.gaps.length
|
|
1571
1571
|
? `<ul>${packet.gaps.map((item) => `<li class="${item.severity}"><strong>${escapeHtml(item.severity)}</strong> ${escapeHtml(item.message)}</li>`).join("")}</ul>`
|
|
1572
|
-
: "<p>
|
|
1572
|
+
: "<p>filegrc management checks passed. The engagement team still evaluates sufficiency and appropriateness.</p>";
|
|
1573
1573
|
const engagementDate = packet.period.basis === "as-of"
|
|
1574
1574
|
? `As of ${escapeHtml(packet.period.start)}`
|
|
1575
1575
|
: `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
|
|
@@ -1592,25 +1592,25 @@ function packetHtml(packet) {
|
|
|
1592
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("")
|
|
1593
1593
|
: "<p>No event workflows intersect this period.</p>";
|
|
1594
1594
|
const recordsById = new Map(packet.records.map((record) => [record.id, record]));
|
|
1595
|
-
const
|
|
1596
|
-
? `<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) => {
|
|
1597
1597
|
const history = recordsById.get(item.id)?.history?.[0];
|
|
1598
1598
|
const source = history
|
|
1599
1599
|
? `${history.timestamp} · ${history.author} · ${history.subject}`
|
|
1600
1600
|
: "No committed file history";
|
|
1601
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>`;
|
|
1602
1602
|
}).join("")}</tbody></table>`
|
|
1603
|
-
: "<p>No
|
|
1603
|
+
: "<p>No filegrc Evidence records matched this period.</p>";
|
|
1604
1604
|
const controlCoverage = packet.controlCoverage.length
|
|
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>
|
|
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>`
|
|
1606
1606
|
: "<p>No controls were selected.</p>";
|
|
1607
|
-
const readinessLabel = packet.readiness.status === "delivery-ready" ? "
|
|
1607
|
+
const readinessLabel = packet.readiness.status === "delivery-ready" ? "filegrc management checks passed" : "Draft, do not deliver";
|
|
1608
1608
|
const packetDate = packet.period.basis === "as-of"
|
|
1609
1609
|
? `As of ${escapeHtml(packet.period.start)}`
|
|
1610
1610
|
: `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
|
|
1611
1611
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Evidence packet</title><style>
|
|
1612
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}
|
|
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("
|
|
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>`;
|
|
1614
1614
|
}
|
|
1615
1615
|
|
|
1616
1616
|
async function writePacketFile(output, relativePath, source, files) {
|
package/src/git.js
CHANGED
|
@@ -227,7 +227,7 @@ async function pushWorkspaceUnlocked(root) {
|
|
|
227
227
|
|
|
228
228
|
function syncReadySummary(root, action) {
|
|
229
229
|
const summary = getGitSummary(root);
|
|
230
|
-
if (!summary.available) throw new Error(`Git history is unavailable for this workspace, so
|
|
230
|
+
if (!summary.available) throw new Error(`Git history is unavailable for this workspace, so filegrc cannot ${action}.`);
|
|
231
231
|
if (!summary.branch) throw new Error(`Check out a branch before trying to ${action}.`);
|
|
232
232
|
if (!summary.clean) throw new Error(`Commit or discard workspace changes before trying to ${action}.`);
|
|
233
233
|
return summary;
|
package/src/index.js
CHANGED
|
@@ -50,7 +50,7 @@ export {
|
|
|
50
50
|
nextCalendarOccurrence
|
|
51
51
|
} from "./recurrence.js";
|
|
52
52
|
export { searchResources, searchableValues } from "./search.js";
|
|
53
|
-
export {
|
|
53
|
+
export { createFilegrcServer, serveWorkspace } from "./server.js";
|
|
54
54
|
export { normalizeSetupPayload, setupWorkspace } from "./setup.js";
|
|
55
55
|
export { createAppState } from "./state.js";
|
|
56
56
|
export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js";
|
package/src/model-docs.js
CHANGED
|
@@ -67,7 +67,7 @@ export function generateModelDocumentation(model) {
|
|
|
67
67
|
"",
|
|
68
68
|
...model.evidenceSourceFamilies.map((item) => (
|
|
69
69
|
item.collectionTestRequired === false
|
|
70
|
-
? `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description}
|
|
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
71
|
: `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} Test external collection: ${item.testPrompt} ${item.timing}`
|
|
72
72
|
)),
|
|
73
73
|
"",
|
package/src/parties.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const CURRENT_PERSON_STATUSES = new Set(["active", "external"]);
|
|
2
|
+
const CURRENT_TEAM_STATUSES = new Set(["active"]);
|
|
3
|
+
|
|
4
|
+
export function partyPeople(ids = [], byId, options = {}, seen = new Set()) {
|
|
5
|
+
const people = new Set();
|
|
6
|
+
for (const id of ids) {
|
|
7
|
+
if (seen.has(id)) continue;
|
|
8
|
+
seen.add(id);
|
|
9
|
+
const record = byId.get(id);
|
|
10
|
+
if (
|
|
11
|
+
record?.type === "person"
|
|
12
|
+
&& (!options.personStatuses || options.personStatuses.has(record.status))
|
|
13
|
+
) {
|
|
14
|
+
people.add(id);
|
|
15
|
+
}
|
|
16
|
+
if (
|
|
17
|
+
record?.type === "team"
|
|
18
|
+
&& (!options.teamStatuses || options.teamStatuses.has(record.status))
|
|
19
|
+
) {
|
|
20
|
+
for (const personId of partyPeople(
|
|
21
|
+
[...(record.memberIds || []), ...(record.chairIds || [])],
|
|
22
|
+
byId,
|
|
23
|
+
options,
|
|
24
|
+
seen
|
|
25
|
+
)) {
|
|
26
|
+
people.add(personId);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return people;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function currentPartyPeople(ids = [], byId) {
|
|
34
|
+
return partyPeople(ids, byId, {
|
|
35
|
+
personStatuses: CURRENT_PERSON_STATUSES,
|
|
36
|
+
teamStatuses: CURRENT_TEAM_STATUSES
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function partiesIndependent(ownerIds = [], approverIds = [], byId) {
|
|
41
|
+
const owners = partyPeople(ownerIds, byId);
|
|
42
|
+
const approvers = partyPeople(approverIds, byId);
|
|
43
|
+
return owners.size > 0
|
|
44
|
+
&& approvers.size > 0
|
|
45
|
+
&& ![...owners].some((id) => approvers.has(id));
|
|
46
|
+
}
|
package/src/paths.js
CHANGED
|
@@ -15,7 +15,7 @@ export function resolveWorkspaceRoot(input = process.cwd()) {
|
|
|
15
15
|
current = parent;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
throw new Error("No
|
|
18
|
+
throw new Error("No filegrc workspace was found from the requested path.");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function isWithin(parent, candidate) {
|
package/src/program-lifecycle.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { currentPartyPeople } from "./parties.js";
|
|
2
|
+
|
|
1
3
|
export function obligationProgramStatus(obligation, byId, asOf) {
|
|
4
|
+
if (currentPartyPeople(obligation.ownerIds || [], byId).size === 0) return "proposed";
|
|
2
5
|
const policyIds = obligation.policyIds || [];
|
|
3
6
|
const policiesReady = policyIds.every((id) => {
|
|
4
7
|
const policy = byId.get(id);
|
package/src/program-path.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const RESOURCE_INSTRUCTIONS = {
|
|
2
|
-
person: "Confirm the
|
|
2
|
+
person: "Confirm the policy owner created during setup, then add the actual people who will approve, review, or operate the program.",
|
|
3
3
|
team: "Review the starter Security and Risk Oversight team, including its members and chair. Add another team only when the organization assigns shared responsibility to it.",
|
|
4
4
|
system: "Catalog all in-scope systems for the program. Treat anything that operates a control or produces evidence as a System, including software provided by a vendor (like HR software).",
|
|
5
5
|
vendor: "Catalog the companies that provide in-scope software or services. Link each vendor-provided System to the company that provides it.",
|
|
@@ -14,7 +14,7 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
14
14
|
"risk-assessment": "Complete and approve an assessment of the risks to the in-scope service, systems, vendors, and commitments.",
|
|
15
15
|
risk: "Record each risk identified by an assessment or operating activity. Assign an owner, rate it, and document the chosen response.",
|
|
16
16
|
obligation: "Review the recurring work proposed by effective policies. Confirm who owns it, when it is due, and what proof completion requires.",
|
|
17
|
-
"obligation-event": "When a policy-triggering event occurs, record it here and complete the actions
|
|
17
|
+
"obligation-event": "When a policy-triggering event occurs, record it here and complete the actions filegrc creates for it.",
|
|
18
18
|
"policy-review": "Record scheduled and change-driven reviews of policies and governed documents, including the decision and any follow-up.",
|
|
19
19
|
meeting: "Record required oversight meetings, including attendees, decisions, minutes, and follow-up work.",
|
|
20
20
|
exception: "Record and approve any time-limited departure from a policy or control before the departure begins.",
|
|
@@ -64,13 +64,13 @@ export const PROGRAM_PATH = [
|
|
|
64
64
|
description: "Ownership, criteria, and service boundary",
|
|
65
65
|
summary: "Confirm the people and teams responsible for the program, set the management goal, review the criteria and customer commitments in scope, then define the customer-facing service, supporting systems, and supplier dependencies.",
|
|
66
66
|
sections: [
|
|
67
|
-
{ id: "ownership", title: "Program Ownership", description: "Confirm the people and teams that own, approve, review, and operate the program.", steps: ["
|
|
67
|
+
{ id: "ownership", title: "Program Ownership", description: "Confirm the people and teams that own, approve, review, and operate the program.", steps: ["Confirm the policy owner created during setup, then add the organization’s actual reviewers and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "team"], defaultOpen: true },
|
|
68
68
|
{ id: "criteria", title: "Criteria", description: "Confirm the criteria used for the program, resolve whether each requirement applies, and record customer commitments that shape the service or control design.", steps: ["Review the included Security criteria references.", "Mark each requirement applicable or not applicable with a rationale.", "Record customer commitments and keep optional criteria out until management deliberately adds them."], types: ["framework", "requirement", "commitment"], defaultOpen: true },
|
|
69
69
|
{ id: "boundary", title: "Service Boundary", description: "Record the service and its supporting technology and providers. An application or platform is a System because it operates controls or produces evidence; the company providing it is a Vendor because contracts, due diligence, and supplier risk belong to that relationship.", steps: ["Create a Vendor record for each material provider.", "Create System records for the customer-facing service and each supporting application, platform, or internal system that is in scope or produces evidence, then connect vendor-provided Systems to their providers.", "Assign owners, classification, dependencies, and a clear in-scope decision to each System, and keep supplier reviews with the Vendor."], types: ["vendor", "system"], defaultOpen: false }
|
|
70
70
|
],
|
|
71
71
|
resourceTypes: ["person", "team", "framework", "requirement", "commitment", "vendor", "system"],
|
|
72
72
|
commands: [
|
|
73
|
-
"filegrc setup
|
|
73
|
+
"filegrc setup",
|
|
74
74
|
"filegrc guide person --json",
|
|
75
75
|
"filegrc guide system --json",
|
|
76
76
|
"filegrc list system --json"
|
|
@@ -99,7 +99,7 @@ export const PROGRAM_PATH = [
|
|
|
99
99
|
description: "Tailor and finish the starter control set",
|
|
100
100
|
summary: "Review the starter catalog against the scoped service, then give every applicable internal control an actual procedure, owner, system scope, cadence, policy and criteria mappings, authoritative evidence source, and implementation date. Mark it implemented only after the procedure is operating, then record any controls that customers or carved-out providers must perform.",
|
|
101
101
|
sections: [
|
|
102
|
-
{ id: "catalog", title: "Control Catalog", description: "Finish the starter controls, record applicable complementary controls, and see whether
|
|
102
|
+
{ id: "catalog", title: "Control Catalog", description: "Finish the starter controls, record applicable complementary controls, and see whether filegrc tracks operation through Work Queue or evidence records.", steps: ["Open every planned control and confirm its mappings and suggested frequency.", "Write the real procedure in Record Markdown and add system scope and evidence sources.", "Record any required customer or carved-out provider controls as Complementary Controls."], types: ["control", "complementary-control"], defaultOpen: true }
|
|
103
103
|
],
|
|
104
104
|
resourceTypes: ["control", "complementary-control"],
|
|
105
105
|
commands: [
|
|
@@ -170,7 +170,7 @@ export const PROGRAM_PATH = [
|
|
|
170
170
|
{
|
|
171
171
|
id: "policy-events",
|
|
172
172
|
title: "Policy Events",
|
|
173
|
-
instructions: "Trigger the matching workflow when an event occurs.
|
|
173
|
+
instructions: "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.",
|
|
174
174
|
use: "Preview the full workflow before triggering it, then create the event and every linked task in one validated write.",
|
|
175
175
|
policyBasis: "Active event obligations translate policy-triggering changes into owned, deadline-bound Action Items. Proposed workflows remain unavailable until their governing policies and linked controls are ready.",
|
|
176
176
|
commands: ["filegrc obligations --json", "filegrc trigger EVENT_TYPE (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --subject RESOURCE_ID --json"]
|
|
@@ -201,14 +201,14 @@ export const PROGRAM_PATH = [
|
|
|
201
201
|
summary: "After the program is collecting reliable evidence, create an Audit record for the real CPA engagement, keep the firm-agreed report period separate from management’s candidate dates, complete management documents, populations, requests, evidence delivery, and fieldwork, then preserve the findings, responses, opinion, and final report.",
|
|
202
202
|
sections: [
|
|
203
203
|
{ id: "engagement", title: "Engagement", description: "Record the actual CPA engagement, formal scope and dates, requests, and management responses.", steps: ["Create the Audit after the CPA firm is engaged.", "Record the firm-agreed type, scope, systems, criteria, and dates.", "Track incoming requests and approved response material."], types: ["audit", "audit-request"], defaultOpen: true },
|
|
204
|
-
{ id: "fieldwork", title: "Fieldwork", description: "Prepare management documents, reconcile Type 2 populations, review both evidence paths, support testing, and build the indexed packet.", steps: ["Initialize engagement-specific management documents and populations.", "Review dated
|
|
204
|
+
{ id: "fieldwork", title: "Fieldwork", description: "Prepare management documents, reconcile Type 2 populations, review both evidence paths, support testing, and build the indexed packet.", steps: ["Initialize engagement-specific management documents and populations.", "Review dated filegrc Evidence and verified External Evidence for the formal period.", "Reconcile complete populations, link samples, and resolve fieldwork requests and findings.", "Build the packet from a clean Git revision; it includes filegrc records, Markdown, External Evidence, attachments, indexes, history, and checksums."], types: ["audit-population", "control-test"], utility: "audit-packet", defaultOpen: true }
|
|
205
205
|
],
|
|
206
206
|
resourceTypes: ["audit", "audit-request", "audit-population", "control-test"],
|
|
207
207
|
utilities: [
|
|
208
208
|
{
|
|
209
209
|
id: "audit-packet",
|
|
210
210
|
title: "Audit Evidence & Packet",
|
|
211
|
-
instructions: "Review
|
|
211
|
+
instructions: "Review filegrc Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet.",
|
|
212
212
|
use: "Prepare management documents and populations, answer fieldwork requests, review both evidence paths, and compile a delivery bound to a clean Git revision.",
|
|
213
213
|
policyBasis: "Management prepares the scoped records, evidence, populations, assertions, and responses. The CPA firm selects samples, evaluates evidence and exceptions, and issues the report.",
|
|
214
214
|
commands: ["filegrc audit-readiness AUDIT_ID --json", "filegrc evidence-packet --audit AUDIT_ID --preview --json"]
|
package/src/program-readiness.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { planObligations } from "./obligations.js";
|
|
3
3
|
import { resolveDataPath } from "./paths.js";
|
|
4
4
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
5
|
+
import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
|
|
5
6
|
import { markdownEntries } from "./resource-markdown.js";
|
|
6
7
|
import { currentCalendarDate } from "./time.js";
|
|
7
8
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -33,7 +34,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
33
34
|
[...sourceStage.items, ...collectionStage.items]
|
|
34
35
|
);
|
|
35
36
|
const evidenceGateStages = [
|
|
36
|
-
scopeStage(workspace, scope, records),
|
|
37
|
+
scopeStage(workspace, scope, records, byId),
|
|
37
38
|
await policiesStage(scope, byId, readMarkdown, asOf),
|
|
38
39
|
await controlsStage(scope, byId, readMarkdown, asOf),
|
|
39
40
|
evidenceStage
|
|
@@ -116,7 +117,7 @@ function programScope(workspace, records, byId) {
|
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
function scopeStage(workspace, scope, records) {
|
|
120
|
+
function scopeStage(workspace, scope, records, byId) {
|
|
120
121
|
const items = [];
|
|
121
122
|
const goal = workspace?.assuranceGoal || "none";
|
|
122
123
|
items.push(item(
|
|
@@ -129,6 +130,8 @@ function scopeStage(workspace, scope, records) {
|
|
|
129
130
|
workspace || { type: "workspace" }
|
|
130
131
|
));
|
|
131
132
|
|
|
133
|
+
items.push(programOwnershipItem(records, byId));
|
|
134
|
+
|
|
132
135
|
const completeSystems = scope.systems.filter((system) => (
|
|
133
136
|
system.status === "active"
|
|
134
137
|
&& system.inScope === true
|
|
@@ -175,7 +178,44 @@ function scopeStage(workspace, scope, records) {
|
|
|
175
178
|
workspace || { type: "workspace" }
|
|
176
179
|
));
|
|
177
180
|
|
|
178
|
-
return stage("scope", "Define Scope", "Set the management objective, service boundary, criteria, controls, and dependencies.", items);
|
|
181
|
+
return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function programOwnershipItem(records, byId) {
|
|
185
|
+
const ownedRecords = records.filter((record) => (
|
|
186
|
+
["policy", "control", "obligation"].includes(record.type)
|
|
187
|
+
&& !["retired", "superseded", "not-applicable"].includes(record.status)
|
|
188
|
+
));
|
|
189
|
+
const unresolved = ownedRecords.filter((record) => currentPartyPeople(record.ownerIds, byId).size === 0);
|
|
190
|
+
const currentOwners = new Set(ownedRecords.flatMap((record) => [...currentPartyPeople(record.ownerIds, byId)]));
|
|
191
|
+
const oversight = byId.get("team-security-risk-oversight");
|
|
192
|
+
const policyOwnerIds = new Set(records
|
|
193
|
+
.filter((record) => record.type === "policy" && !["retired", "superseded"].includes(record.status))
|
|
194
|
+
.flatMap((record) => [...partyPeople(record.ownerIds || [], byId)]));
|
|
195
|
+
const oversightChairs = oversight?.type === "team"
|
|
196
|
+
? currentPartyPeople(oversight.chairIds || [], byId)
|
|
197
|
+
: new Set();
|
|
198
|
+
const oversightComplete = !oversight || (
|
|
199
|
+
oversight.type === "team"
|
|
200
|
+
&& oversight.status === "active"
|
|
201
|
+
&& currentPartyPeople(oversight.memberIds || [], byId).size > 0
|
|
202
|
+
&& oversightChairs.size > 0
|
|
203
|
+
&& ![...oversightChairs].some((id) => policyOwnerIds.has(id))
|
|
204
|
+
);
|
|
205
|
+
const complete = currentOwners.size > 0 && unresolved.length === 0 && oversightComplete;
|
|
206
|
+
const detail = [];
|
|
207
|
+
if (!currentOwners.size) detail.push("No current person owns the program records.");
|
|
208
|
+
if (unresolved.length) detail.push(`${unresolved.length} ownership ${unresolved.length === 1 ? "assignment does" : "assignments do"} not resolve to a current person.`);
|
|
209
|
+
if (!oversightComplete) detail.push("Finish and activate Security and Risk Oversight with a current chair who is separate from policy ownership.");
|
|
210
|
+
return item(
|
|
211
|
+
"program-ownership",
|
|
212
|
+
complete ? "complete" : "action",
|
|
213
|
+
"Confirm program owners and oversight",
|
|
214
|
+
complete
|
|
215
|
+
? `${currentOwners.size} current ${currentOwners.size === 1 ? "person owns" : "people own"} the program records.${oversight ? " Security and Risk Oversight has a separate current chair." : ""}`
|
|
216
|
+
: detail.join(" "),
|
|
217
|
+
!oversightComplete ? oversight : unresolved[0] || { type: "person" }
|
|
218
|
+
);
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
@@ -183,14 +223,11 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
183
223
|
const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
|
|
184
224
|
record?.type === "policy" && !["superseded", "retired"].includes(record.status)
|
|
185
225
|
));
|
|
186
|
-
const
|
|
226
|
+
const appointedReviewer = policies
|
|
227
|
+
.filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
|
|
228
|
+
.flatMap((policy) => [...currentPartyPeople(policy.approverIds || [], byId)])
|
|
187
229
|
.map((id) => byId.get(id))
|
|
188
|
-
.
|
|
189
|
-
const appointedReviewer = approvers.find((person) => (
|
|
190
|
-
["active", "external"].includes(person.status)
|
|
191
|
-
&& person.email
|
|
192
|
-
&& !(person.id === "person-independent-approver" && ["Independent Approver", "Independent Reviewer"].includes(person.title))
|
|
193
|
-
));
|
|
230
|
+
.find(Boolean);
|
|
194
231
|
const items = [
|
|
195
232
|
item(
|
|
196
233
|
"independent-reviewer",
|
|
@@ -199,7 +236,7 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
|
|
|
199
236
|
appointedReviewer
|
|
200
237
|
? `${appointedReviewer.title} is recorded as a reviewer separate from policy ownership.`
|
|
201
238
|
: "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
|
|
202
|
-
appointedReviewer || { type: "person"
|
|
239
|
+
appointedReviewer || { type: "person" }
|
|
203
240
|
)
|
|
204
241
|
];
|
|
205
242
|
if (!policies.length) {
|
|
@@ -355,7 +392,7 @@ function evidenceCollectionStage(scope, records, byId, model) {
|
|
|
355
392
|
? `${capture.title} is a draft. Open it, select the authoritative source System, collect the named artifact, and have another person verify it.`
|
|
356
393
|
: capture
|
|
357
394
|
? `${capture.title} is ${capture.status} but must be verified before this family is ready.`
|
|
358
|
-
: `Run and verify one test export or test capture from an authoritative source outside
|
|
395
|
+
: `Run and verify one test export or test capture from an authoritative source outside filegrc, then link it to a family control.`,
|
|
359
396
|
capture || { type: "evidence" },
|
|
360
397
|
{
|
|
361
398
|
familyId: family.id,
|
|
@@ -549,26 +586,6 @@ function substantiveMarkdown(source) {
|
|
|
549
586
|
return (source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length >= 10;
|
|
550
587
|
}
|
|
551
588
|
|
|
552
|
-
function partiesIndependent(ownerIds = [], approverIds = [], byId) {
|
|
553
|
-
const owners = new Set(partyPeople(ownerIds, byId));
|
|
554
|
-
const approvers = partyPeople(approverIds, byId);
|
|
555
|
-
return owners.size > 0 && approvers.length > 0 && !approvers.some((id) => owners.has(id));
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function partyPeople(ids = [], byId, seen = new Set()) {
|
|
559
|
-
const people = [];
|
|
560
|
-
for (const id of ids) {
|
|
561
|
-
if (seen.has(id)) continue;
|
|
562
|
-
seen.add(id);
|
|
563
|
-
const record = byId.get(id);
|
|
564
|
-
if (record?.type === "person") people.push(id);
|
|
565
|
-
if (record?.type === "team") {
|
|
566
|
-
people.push(...partyPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen));
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
return [...new Set(people)];
|
|
570
|
-
}
|
|
571
|
-
|
|
572
589
|
function policyCheckLabel(name) {
|
|
573
590
|
return ({
|
|
574
591
|
reviewed: "draft review",
|
package/src/server.js
CHANGED
|
@@ -15,7 +15,7 @@ import { setupWorkspace } from "./setup.js";
|
|
|
15
15
|
import { loadWorkspace } from "./workspace.js";
|
|
16
16
|
import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
17
17
|
|
|
18
|
-
export function
|
|
18
|
+
export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
19
19
|
return createHttpServer(async (request, response) => {
|
|
20
20
|
try {
|
|
21
21
|
if (!expectedHost(request, options.allowedHosts)) {
|
|
@@ -184,7 +184,7 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
184
184
|
}
|
|
185
185
|
const loaded = await loadWorkspace(input);
|
|
186
186
|
getResourceDefinition(loaded.model, "workspace");
|
|
187
|
-
const server =
|
|
187
|
+
const server = createFilegrcServer(loaded.root, { allowedHosts: [host] });
|
|
188
188
|
await new Promise((resolve, reject) => {
|
|
189
189
|
server.once("error", reject);
|
|
190
190
|
server.listen(port, host, resolve);
|
package/src/state.js
CHANGED
|
@@ -47,7 +47,7 @@ export async function createAppState(input = process.cwd(), options = {}) {
|
|
|
47
47
|
dataModelVersion: loaded.model.modelVersion,
|
|
48
48
|
id: "workspace",
|
|
49
49
|
type: "workspace",
|
|
50
|
-
title: "
|
|
50
|
+
title: "filegrc workspace",
|
|
51
51
|
organizationName: "Workspace configuration unavailable",
|
|
52
52
|
timezone: "UTC"
|
|
53
53
|
};
|
package/src/validate.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getResourceDefinition } from "../model/index.js";
|
|
|
3
3
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
4
4
|
import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
|
|
5
5
|
import { obligationIsRunning } from "./program-lifecycle.js";
|
|
6
|
+
import { partyPeople } from "./parties.js";
|
|
6
7
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
7
8
|
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
8
9
|
import { indexResources, loadWorkspace } from "./workspace.js";
|
|
@@ -223,9 +224,10 @@ function validateEvidencePaths(record, path, diagnostics) {
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
function validateIndependentApproval(record, byId, path, diagnostics) {
|
|
226
|
-
if (!["policy", "document"].includes(record.type)
|
|
227
|
-
|
|
228
|
-
const
|
|
227
|
+
if (!["policy", "document"].includes(record.type)) return;
|
|
228
|
+
if (!(record.approverIds || []).length) return;
|
|
229
|
+
const owners = partyPeople(record.ownerIds || [], byId);
|
|
230
|
+
const approvers = partyPeople(record.approverIds || [], byId);
|
|
229
231
|
const overlap = [...owners].filter((id) => approvers.has(id));
|
|
230
232
|
if (overlap.length) {
|
|
231
233
|
diagnostics.push(error(
|
|
@@ -234,37 +236,6 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
|
|
|
234
236
|
`Approvers must be separate from owners, including through team membership: ${overlap.join(", ")}.`
|
|
235
237
|
));
|
|
236
238
|
}
|
|
237
|
-
if (["approved", "active"].includes(record.status)) {
|
|
238
|
-
const incompleteStarterApprover = [...approvers]
|
|
239
|
-
.map((id) => byId.get(id))
|
|
240
|
-
.find((person) => (
|
|
241
|
-
person?.id === "person-independent-approver"
|
|
242
|
-
&& ["Independent Approver", "Independent Reviewer"].includes(person.title)
|
|
243
|
-
));
|
|
244
|
-
if (incompleteStarterApprover) {
|
|
245
|
-
diagnostics.push(error(
|
|
246
|
-
"independent-approver-not-appointed",
|
|
247
|
-
path,
|
|
248
|
-
`The selected approver "${incompleteStarterApprover.title}" is still the starter placeholder. Open People and replace it with the reviewer's actual name before approving this record. The reviewer may be internal or external but must be separate from the owner.`
|
|
249
|
-
));
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function expandPeople(ids, byId, seen = new Set()) {
|
|
255
|
-
const people = new Set();
|
|
256
|
-
for (const id of ids) {
|
|
257
|
-
if (seen.has(id)) continue;
|
|
258
|
-
seen.add(id);
|
|
259
|
-
const record = byId.get(id);
|
|
260
|
-
if (record?.type === "person") people.add(id);
|
|
261
|
-
if (record?.type === "team") {
|
|
262
|
-
for (const personId of expandPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen)) {
|
|
263
|
-
people.add(personId);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return people;
|
|
268
239
|
}
|
|
269
240
|
|
|
270
241
|
function validateObligation(record, path, diagnostics) {
|