filegrc 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { createObligationEvent } from "./obligations.js";
6
+ import { markdownEntries } from "./resource-markdown.js";
7
+ import { loadWorkspace } from "./workspace.js";
8
+
9
+ const TRANSITIONS = {
10
+ person: [
11
+ {
12
+ eventType: "person-started",
13
+ applies: (before, after) => after?.status === "active" && before?.status !== "active",
14
+ message: "Confirm whether activating this Person represents a workforce start that needs policy-event work."
15
+ },
16
+ {
17
+ eventType: "person-ended",
18
+ applies: (before, after) => before?.status === "active" && after?.status !== "active",
19
+ message: "Confirm whether this Person status change represents a departure that needs access and asset work."
20
+ },
21
+ {
22
+ eventType: "person-role-changed",
23
+ applies: (before, after) => before && after && before.jobTitle !== after.jobTitle,
24
+ message: "Confirm whether this job-title change represents a role change that needs access and training work."
25
+ }
26
+ ],
27
+ vendor: [
28
+ {
29
+ eventType: "vendor-activated",
30
+ applies: (before, after) => after?.status === "active" && before?.status !== "active",
31
+ message: "Confirm whether activating this Vendor needs onboarding, assurance, and access work."
32
+ },
33
+ {
34
+ eventType: "vendor-terminated",
35
+ applies: (before, after) => before?.status === "active" && ["inactive", "terminated"].includes(after?.status),
36
+ message: "Confirm whether this Vendor status change needs termination, access, and data-return work."
37
+ }
38
+ ],
39
+ system: [{
40
+ eventType: "system-material-change",
41
+ applies: (before, after) => before && after && materialFieldsChanged(before, after, [
42
+ "boundary", "criticality", "classificationId", "internetExposed", "vendorIds", "ownerIds"
43
+ ]),
44
+ message: "Confirm whether this System change is material and needs the configured change workflow."
45
+ }],
46
+ incident: [{
47
+ eventType: "incident-closed",
48
+ applies: (before, after) => before && after && before.status !== "closed" && after.status === "closed",
49
+ message: "Confirm whether closing this Incident needs lessons-learned, disclosure, or remediation work."
50
+ }],
51
+ policy: [{
52
+ eventType: "policy-revised",
53
+ applies: (before, after, context) => Boolean(
54
+ before
55
+ && after
56
+ && ["approved", "active", "superseded", "retired"].includes(before.status)
57
+ && (
58
+ context.markdownChanged
59
+ || materialFieldsChanged(before, after, ["status", "effectiveOn", "ownerIds", "approverIds"])
60
+ )
61
+ ),
62
+ message: "Confirm whether this Policy revision is material and needs reapproval, training, or acknowledgement work."
63
+ }],
64
+ exception: [{
65
+ eventType: "exception-expired",
66
+ applies: (before, after) => before && after && before.status !== "expired" && after.status === "expired",
67
+ message: "Confirm whether this Exception expiry needs compensating-control review or remediation work."
68
+ }],
69
+ "service-account": [
70
+ {
71
+ eventType: "service-account-created",
72
+ applies: (before, after) => after?.status === "active" && before?.status !== "active",
73
+ message: "Confirm whether activating this Service Account needs authorization and review work."
74
+ },
75
+ {
76
+ eventType: "service-account-expired",
77
+ applies: (before, after) => before?.status === "active" && ["expired", "disabled"].includes(after?.status),
78
+ message: "Confirm whether this Service Account status change needs disablement and access-review work."
79
+ }
80
+ ],
81
+ vulnerability: [{
82
+ eventType: "vulnerability-confirmed",
83
+ applies: (before, after) => after?.confirmedOn && before?.confirmedOn !== after.confirmedOn,
84
+ message: "Confirm whether this newly confirmed Vulnerability needs remediation and tracking work."
85
+ }],
86
+ asset: [{
87
+ eventType: "asset-disposed",
88
+ applies: (before, after) => before && after && before.status !== "disposed" && after.status === "disposed",
89
+ message: "Confirm whether this Asset disposal needs sanitization and evidence work."
90
+ }]
91
+ };
92
+
93
+ export async function planReconciliation(input = process.cwd()) {
94
+ const loaded = await loadWorkspace(input);
95
+ if (String(loaded.model.modelVersion) !== "3") {
96
+ return {
97
+ contractVersion: 1,
98
+ gitRevision: gitRevision(loaded.root),
99
+ changedPaths: [],
100
+ candidates: [],
101
+ message: "Direct-file transition reconciliation is available after migrating this workspace to model v3."
102
+ };
103
+ }
104
+ const changedPaths = gitChangedPaths(loaded.root);
105
+ const currentByPath = new Map(loaded.entries.map((entry) => [
106
+ `data/${entry.relativePath}`,
107
+ entry
108
+ ]));
109
+ const markdownOwners = new Map();
110
+ for (const entry of loaded.entries) {
111
+ for (const markdown of markdownEntries(loaded.model, entry.record)) {
112
+ markdownOwners.set(`data/${markdown.path}`, entry);
113
+ }
114
+ }
115
+ const candidates = [];
116
+ const examined = new Set();
117
+
118
+ for (const path of changedPaths) {
119
+ const currentEntry = currentByPath.get(path) || markdownOwners.get(path);
120
+ const previous = readHeadRecord(loaded.root, path.endsWith(".json")
121
+ ? path
122
+ : currentEntry ? `data/${currentEntry.relativePath}` : null);
123
+ const current = currentEntry?.record || null;
124
+ const record = current || previous;
125
+ if (!record || examined.has(`${record.type}:${record.id}`)) continue;
126
+ examined.add(`${record.type}:${record.id}`);
127
+ const changedMarkdownPaths = changedPaths.filter((changed) => (
128
+ markdownOwners.get(changed)?.record.id === record.id
129
+ ));
130
+ const markdownChanged = changedMarkdownPaths.length > 0;
131
+ const currentMarkdown = await Promise.all(changedMarkdownPaths.map(async (changed) => {
132
+ try {
133
+ return await readFile(join(loaded.root, changed), "utf8");
134
+ } catch {
135
+ return "";
136
+ }
137
+ }));
138
+ const previousMarkdown = changedMarkdownPaths.map((changed) => readHeadSource(loaded.root, changed));
139
+ const currentSource = [currentEntry?.source || "", ...currentMarkdown].join("\n");
140
+ const beforeSource = [previous ? JSON.stringify(previous) : "", ...previousMarkdown].join("\n");
141
+ for (const transition of TRANSITIONS[record.type] || []) {
142
+ if (!transition.applies(previous, current, { markdownChanged })) continue;
143
+ const fingerprint = transitionFingerprint({
144
+ eventType: transition.eventType,
145
+ subjectId: record.id,
146
+ path: `data/${currentEntry?.relativePath || path.replace(/^data\//, "")}`,
147
+ beforeSource,
148
+ currentSource,
149
+ markdownChanged
150
+ });
151
+ if (loaded.resources.some((item) => (
152
+ item.type === "obligation-event"
153
+ && item.transitionFingerprint === fingerprint
154
+ ))) continue;
155
+ candidates.push({
156
+ id: `reconcile-${fingerprint.slice(0, 16)}`,
157
+ transitionFingerprint: fingerprint,
158
+ eventType: transition.eventType,
159
+ subject: { type: record.type, id: record.id, title: record.title },
160
+ sourcePath: path,
161
+ state: "needs-confirmation",
162
+ message: transition.message,
163
+ requiredFacts: [
164
+ transition.eventType === "person-ended" ? "riskLevel" : null,
165
+ eventNeedsTimestamp(loaded, transition.eventType) ? "occurredAt" : "occurredOn"
166
+ ].filter(Boolean),
167
+ action: {
168
+ kind: "command",
169
+ command: reconciliationCommand(transition.eventType, record.id, fingerprint)
170
+ }
171
+ });
172
+ }
173
+ }
174
+ return {
175
+ contractVersion: 1,
176
+ gitRevision: gitRevision(loaded.root),
177
+ changedPaths,
178
+ candidates: candidates.sort((a, b) => a.id.localeCompare(b.id))
179
+ };
180
+ }
181
+
182
+ export async function applyReconciliation(input = process.cwd(), options = {}) {
183
+ if (options.confirmed !== true) {
184
+ throw new Error("Reconciliation creates compliance records. Preview the candidate and confirm the write.");
185
+ }
186
+ const plan = await planReconciliation(input);
187
+ const candidate = plan.candidates.find(({ id, transitionFingerprint }) => (
188
+ id === options.candidateId || transitionFingerprint === options.transitionFingerprint
189
+ ));
190
+ if (!candidate) {
191
+ throw new Error("The reconciliation candidate is missing or changed. Run reconcile --preview again.");
192
+ }
193
+ const result = await createObligationEvent(input, {
194
+ eventType: candidate.eventType,
195
+ subjectResourceIds: [candidate.subject.id],
196
+ occurredOn: options.occurredOn,
197
+ occurredAt: options.occurredAt,
198
+ riskLevel: options.riskLevel,
199
+ title: options.title,
200
+ transitionFingerprint: candidate.transitionFingerprint
201
+ });
202
+ return { candidate, ...result };
203
+ }
204
+
205
+ function gitChangedPaths(root) {
206
+ const tracked = runGit(root, ["diff", "--name-only", "HEAD", "--", "data"]);
207
+ const untracked = runGit(root, ["ls-files", "--others", "--exclude-standard", "--", "data"]);
208
+ return [...new Set([...lines(tracked), ...lines(untracked)])]
209
+ .filter((path) => path.startsWith("data/"))
210
+ .sort();
211
+ }
212
+
213
+ function readHeadRecord(root, path) {
214
+ if (!path) return null;
215
+ try {
216
+ return JSON.parse(readHeadSource(root, path));
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function readHeadSource(root, path) {
223
+ try {
224
+ return execFileSync("git", ["show", `HEAD:${path}`], {
225
+ cwd: root,
226
+ encoding: "utf8",
227
+ stdio: ["ignore", "pipe", "ignore"],
228
+ timeout: 10_000
229
+ });
230
+ } catch {
231
+ return "";
232
+ }
233
+ }
234
+
235
+ function gitRevision(root) {
236
+ return runGit(root, ["rev-parse", "HEAD"]) || null;
237
+ }
238
+
239
+ function runGit(root, args) {
240
+ try {
241
+ return execFileSync("git", args, {
242
+ cwd: root,
243
+ encoding: "utf8",
244
+ stdio: ["ignore", "pipe", "ignore"],
245
+ timeout: 10_000
246
+ }).trim();
247
+ } catch {
248
+ return "";
249
+ }
250
+ }
251
+
252
+ function lines(value) {
253
+ return value ? value.split("\n").map((line) => line.trim()).filter(Boolean) : [];
254
+ }
255
+
256
+ function materialFieldsChanged(before, after, fields) {
257
+ return fields.some((field) => JSON.stringify(before?.[field]) !== JSON.stringify(after?.[field]));
258
+ }
259
+
260
+ function transitionFingerprint(value) {
261
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
262
+ }
263
+
264
+ function eventNeedsTimestamp(loaded, eventType) {
265
+ return loaded.resources.some((record) => (
266
+ record.type === "obligation"
267
+ && record.status === "active"
268
+ && record.recurrence?.eventType === eventType
269
+ && record.window?.precision === "timestamp"
270
+ ));
271
+ }
272
+
273
+ function reconciliationCommand(eventType, subjectId, fingerprint) {
274
+ const timeFlag = " --occurred-on YYYY-MM-DD";
275
+ const riskFlag = eventType === "person-ended" ? " --risk-level normal|high" : "";
276
+ return `npx filegrc reconcile --apply --candidate ${fingerprint}${timeFlag}${riskFlag} --yes`;
277
+ }
@@ -0,0 +1,17 @@
1
+ export function effectiveResourceStatus(record, asOf) {
2
+ if (
3
+ record?.type === "attestation"
4
+ && record.status === "pending"
5
+ && record.dueOn
6
+ && asOf
7
+ && record.dueOn < asOf
8
+ ) return "overdue";
9
+ if (
10
+ record?.type === "evidence"
11
+ && ["collected", "verified"].includes(record.status)
12
+ && record.expiresOn
13
+ && asOf
14
+ && record.expiresOn < asOf
15
+ ) return "expired";
16
+ return record?.status;
17
+ }