filegrc 0.4.0 → 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.
- package/README.md +11 -3
- package/model/index.js +9 -5
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +53 -0
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +47 -6
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +418 -61
- package/src/collection-review.js +185 -0
- package/src/evidence-packet.js +34 -2
- package/src/external-reviewer.js +165 -0
- package/src/files.js +46 -10
- package/src/index.js +36 -2
- package/src/model-docs.js +15 -0
- package/src/model-migration.js +516 -21
- package/src/obligations.js +396 -13
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +49 -12
- package/src/program-readiness.js +331 -27
- package/src/reconciliation.js +277 -0
- package/src/server.js +242 -14
- package/src/setup.js +36 -4
- package/src/source-coverage.js +61 -0
- package/src/state.js +37 -1
- package/src/validate.js +100 -3
- package/src/web.js +961 -202
- package/src/workflow.js +1595 -0
|
@@ -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
|
+
}
|
package/src/server.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { createServer as createHttpServer } from "node:http";
|
|
2
|
-
import { readFile, realpath } from "node:fs/promises";
|
|
3
|
-
import {
|
|
2
|
+
import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { extname, join, resolve } from "node:path";
|
|
4
5
|
import { getResourceDefinition } from "../model/index.js";
|
|
5
6
|
import { prepareAuditWorkspace } from "./audit-preparation.js";
|
|
7
|
+
import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
8
|
+
import { applyApplicabilityReview, planApplicabilityReview } from "./batch-review.js";
|
|
9
|
+
import { applyCollectionReview, planCollectionReview } from "./collection-review.js";
|
|
6
10
|
import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
|
|
7
11
|
import { FAVICON_PNG, LOGO_MARK_PNG } from "./favicon.js";
|
|
8
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
addEvidenceAttachment,
|
|
14
|
+
createResource,
|
|
15
|
+
deleteResource,
|
|
16
|
+
removeEvidenceAttachment,
|
|
17
|
+
updateContent,
|
|
18
|
+
updateResource
|
|
19
|
+
} from "./files.js";
|
|
9
20
|
import {
|
|
10
21
|
BROWSER_VALIDATION,
|
|
11
22
|
commitAndPushWorkspace,
|
|
@@ -18,11 +29,28 @@ import {
|
|
|
18
29
|
runBrowserMutation
|
|
19
30
|
} from "./git.js";
|
|
20
31
|
import { normalizeResourceMutation, serializeWorkspaceMutation } from "./mutation.js";
|
|
21
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
completeObligationAction,
|
|
34
|
+
completeObligationEvent,
|
|
35
|
+
completeObligationOccurrence,
|
|
36
|
+
createObligationEvent,
|
|
37
|
+
planObligations
|
|
38
|
+
} from "./obligations.js";
|
|
39
|
+
import {
|
|
40
|
+
planExternalReviewerGovernance,
|
|
41
|
+
setupExternalReviewerGovernance
|
|
42
|
+
} from "./external-reviewer.js";
|
|
22
43
|
import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
|
|
44
|
+
import { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
23
45
|
import { createAppState, createResourceDetail } from "./state.js";
|
|
24
46
|
import { setupWorkspace } from "./setup.js";
|
|
25
47
|
import { collectTimings, measureTiming, timingEnabled } from "./timing.js";
|
|
48
|
+
import {
|
|
49
|
+
assessWorkflow,
|
|
50
|
+
buildWorkflowDelta,
|
|
51
|
+
previewWorkflowMutation,
|
|
52
|
+
workflowForResource
|
|
53
|
+
} from "./workflow.js";
|
|
26
54
|
import { loadWorkspace } from "./workspace.js";
|
|
27
55
|
import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
|
|
28
56
|
|
|
@@ -66,6 +94,80 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
66
94
|
model: loaded.model
|
|
67
95
|
}));
|
|
68
96
|
}
|
|
97
|
+
if (request.method === "GET" && url.pathname === "/api/workflow") {
|
|
98
|
+
const start = url.searchParams.get("start");
|
|
99
|
+
const end = url.searchParams.get("end");
|
|
100
|
+
return json(response, 200, await assessWorkflow(input, {
|
|
101
|
+
auditId: url.searchParams.get("auditId") || undefined,
|
|
102
|
+
asOf: url.searchParams.get("asOf") || undefined,
|
|
103
|
+
through: url.searchParams.get("through") || undefined,
|
|
104
|
+
coverage: start || end ? { kind: "range", startsOn: start, endsOn: end } : undefined,
|
|
105
|
+
includeComplete: url.searchParams.get("includeComplete") === "true"
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
if (request.method === "POST" && url.pathname === "/api/workflow/preview") {
|
|
109
|
+
return json(response, 200, await previewWorkflowMutation(input, await readJson(request)));
|
|
110
|
+
}
|
|
111
|
+
if (request.method === "GET" && url.pathname === "/api/reconciliation") {
|
|
112
|
+
return json(response, 200, await planReconciliation(input));
|
|
113
|
+
}
|
|
114
|
+
if (request.method === "POST" && url.pathname === "/api/reconciliation") {
|
|
115
|
+
const payload = await readJson(request);
|
|
116
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
117
|
+
message: (result) => `Reconcile policy event: ${result.event?.title || payload.candidateId}`
|
|
118
|
+
}, () => applyReconciliation(input, {
|
|
119
|
+
...payload,
|
|
120
|
+
confirmed: payload.confirmed === true
|
|
121
|
+
})));
|
|
122
|
+
}
|
|
123
|
+
if (request.method === "POST" && url.pathname === "/api/external-reviewer-governance/preview") {
|
|
124
|
+
return json(response, 200, await planExternalReviewerGovernance(input, await readJson(request)));
|
|
125
|
+
}
|
|
126
|
+
if (request.method === "POST" && url.pathname === "/api/external-reviewer-governance") {
|
|
127
|
+
const payload = await readJson(request);
|
|
128
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
129
|
+
message: (result) => `Assign external independent reviewer: ${result.reviewerId}`
|
|
130
|
+
}, () => setupExternalReviewerGovernance(input, {
|
|
131
|
+
...payload,
|
|
132
|
+
confirmed: payload.confirmed === true
|
|
133
|
+
})));
|
|
134
|
+
}
|
|
135
|
+
if (request.method === "POST" && url.pathname === "/api/audit-cycle/preview") {
|
|
136
|
+
return json(response, 200, await planNextAuditCycle(input, await readJson(request)));
|
|
137
|
+
}
|
|
138
|
+
if (request.method === "POST" && url.pathname === "/api/audit-cycle") {
|
|
139
|
+
const payload = await readJson(request);
|
|
140
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
141
|
+
message: (result) => `Create next audit cycle: ${result.audit?.title || payload.priorAuditId}`
|
|
142
|
+
}, () => createNextAuditCycle(input, {
|
|
143
|
+
...payload,
|
|
144
|
+
confirmed: payload.confirmed === true
|
|
145
|
+
})));
|
|
146
|
+
}
|
|
147
|
+
if (request.method === "POST" && url.pathname === "/api/applicability-review/preview") {
|
|
148
|
+
return json(response, 200, await planApplicabilityReview(input, await readJson(request)));
|
|
149
|
+
}
|
|
150
|
+
if (request.method === "POST" && url.pathname === "/api/applicability-review") {
|
|
151
|
+
const payload = await readJson(request);
|
|
152
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
153
|
+
message: (result) => `Record ${result.reviewedIds?.length || 0} applicability decisions`
|
|
154
|
+
}, () => applyApplicabilityReview(input, {
|
|
155
|
+
...payload,
|
|
156
|
+
confirmed: payload.confirmed === true
|
|
157
|
+
})));
|
|
158
|
+
}
|
|
159
|
+
if (request.method === "POST" && url.pathname === "/api/collection-review/preview") {
|
|
160
|
+
return json(response, 200, await planCollectionReview(input, await readJson(request)));
|
|
161
|
+
}
|
|
162
|
+
if (request.method === "POST" && url.pathname === "/api/collection-review") {
|
|
163
|
+
const payload = await readJson(request);
|
|
164
|
+
return json(response, 201, await browserMutation(input, options, {
|
|
165
|
+
message: (result) => `Confirm ${result.assessment?.configuration?.title || payload.resourceType}`
|
|
166
|
+
}, () => applyCollectionReview(input, {
|
|
167
|
+
...payload,
|
|
168
|
+
confirmed: payload.confirmed === true
|
|
169
|
+
})));
|
|
170
|
+
}
|
|
69
171
|
if (request.method === "POST" && url.pathname === "/api/obligation-events") {
|
|
70
172
|
const payload = await readJson(request);
|
|
71
173
|
return json(response, 201, await browserMutation(input, options, {
|
|
@@ -85,6 +187,73 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
85
187
|
}));
|
|
86
188
|
return json(response, 201, result);
|
|
87
189
|
}
|
|
190
|
+
if (request.method === "POST" && url.pathname === "/api/action-completions") {
|
|
191
|
+
const payload = await readJson(request);
|
|
192
|
+
if (!safeSegment(payload.actionItemId)) {
|
|
193
|
+
return json(response, 400, { error: "A safe Action Item ID is required." });
|
|
194
|
+
}
|
|
195
|
+
const result = await browserMutation(input, options, {
|
|
196
|
+
message: () => `Complete action item: ${payload.actionItemId}`
|
|
197
|
+
}, () => completeObligationAction(input, {
|
|
198
|
+
actionItemId: payload.actionItemId,
|
|
199
|
+
completedOn: payload.completedOn,
|
|
200
|
+
record: payload.record,
|
|
201
|
+
content: payload.content,
|
|
202
|
+
expectedRevision: requireRevision(payload.revision, `action-item/${payload.actionItemId}`)
|
|
203
|
+
}));
|
|
204
|
+
return json(response, 201, result);
|
|
205
|
+
}
|
|
206
|
+
if (request.method === "POST" && url.pathname === "/api/obligation-event-completions") {
|
|
207
|
+
const payload = await readJson(request);
|
|
208
|
+
if (!safeSegment(payload.eventId)) {
|
|
209
|
+
return json(response, 400, { error: "A safe Policy Event ID is required." });
|
|
210
|
+
}
|
|
211
|
+
const result = await browserMutation(input, options, {
|
|
212
|
+
message: () => `Complete policy event: ${payload.eventId}`
|
|
213
|
+
}, () => completeObligationEvent(input, {
|
|
214
|
+
eventId: payload.eventId,
|
|
215
|
+
completedOn: payload.completedOn,
|
|
216
|
+
expectedRevision: requireRevision(payload.revision, `obligation-event/${payload.eventId}`)
|
|
217
|
+
}));
|
|
218
|
+
return json(response, 200, result);
|
|
219
|
+
}
|
|
220
|
+
const attachmentMatch = /^\/api\/evidence-attachments\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
221
|
+
if (attachmentMatch && request.method === "POST") {
|
|
222
|
+
const evidenceId = decodeURIComponent(attachmentMatch[1]);
|
|
223
|
+
const attachment = decodeURIComponent(attachmentMatch[2]);
|
|
224
|
+
if (!safeSegment(evidenceId) || !safeAttachmentName(attachment)) {
|
|
225
|
+
return json(response, 400, { error: "Safe Evidence and attachment identifiers are required." });
|
|
226
|
+
}
|
|
227
|
+
const revision = requireRevision(url.searchParams.get("revision"), `evidence/${evidenceId}`);
|
|
228
|
+
const temporaryDirectory = await mkdtemp(join(tmpdir(), "filegrc-evidence-upload-"));
|
|
229
|
+
try {
|
|
230
|
+
const temporaryPath = join(temporaryDirectory, attachment);
|
|
231
|
+
await writeFile(temporaryPath, await readBytes(request));
|
|
232
|
+
const result = await browserMutation(input, options, {
|
|
233
|
+
message: () => `Attach evidence file: ${attachment}`
|
|
234
|
+
}, () => addEvidenceAttachment(input, evidenceId, temporaryPath, {
|
|
235
|
+
name: attachment,
|
|
236
|
+
expectedRevision: revision
|
|
237
|
+
}));
|
|
238
|
+
return json(response, 201, result);
|
|
239
|
+
} finally {
|
|
240
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (attachmentMatch && request.method === "DELETE") {
|
|
244
|
+
const evidenceId = decodeURIComponent(attachmentMatch[1]);
|
|
245
|
+
const attachment = decodeURIComponent(attachmentMatch[2]);
|
|
246
|
+
if (!safeSegment(evidenceId) || !safeAttachmentName(attachment)) {
|
|
247
|
+
return json(response, 400, { error: "Safe Evidence and attachment identifiers are required." });
|
|
248
|
+
}
|
|
249
|
+
const revision = requireRevision(url.searchParams.get("revision"), `evidence/${evidenceId}`);
|
|
250
|
+
const result = await browserMutation(input, options, {
|
|
251
|
+
message: () => `Remove evidence file: ${attachment}`
|
|
252
|
+
}, () => removeEvidenceAttachment(input, evidenceId, attachment, {
|
|
253
|
+
expectedRevision: revision
|
|
254
|
+
}));
|
|
255
|
+
return json(response, 200, result);
|
|
256
|
+
}
|
|
88
257
|
if (request.method === "GET" && url.pathname === "/api/evidence-packet") {
|
|
89
258
|
return json(response, 200, await prepareEvidencePacket(input, {
|
|
90
259
|
start: url.searchParams.get("start"),
|
|
@@ -130,7 +299,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
130
299
|
const result = await browserMutation(input, options, {
|
|
131
300
|
message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
|
|
132
301
|
}, () => createResource(input, record, { content: payload.content }));
|
|
133
|
-
return json(response, 201,
|
|
302
|
+
return json(response, 201, result);
|
|
134
303
|
}
|
|
135
304
|
if (request.method === "POST" && url.pathname === "/api/commit") {
|
|
136
305
|
await requireManualBrowserGit(input, options);
|
|
@@ -174,7 +343,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
174
343
|
}, () => updateContent(input, payload.path, payload.source, {
|
|
175
344
|
expectedRevision: requireRevision(payload.revision, `content/${payload.path}`)
|
|
176
345
|
}));
|
|
177
|
-
return json(response, 200,
|
|
346
|
+
return json(response, 200, result);
|
|
178
347
|
}
|
|
179
348
|
const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
180
349
|
if (match) {
|
|
@@ -183,7 +352,12 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
183
352
|
if (!safeSegment(type) || !safeSegment(id)) return json(response, 400, { error: "Unsafe resource identifier." });
|
|
184
353
|
if (request.method === "GET") {
|
|
185
354
|
const entry = await createResourceDetail(input, type, id);
|
|
186
|
-
|
|
355
|
+
if (!entry) return json(response, 404, { error: "Resource not found." });
|
|
356
|
+
if (url.searchParams.get("workflow") === "true") {
|
|
357
|
+
const workflow = await assessWorkflow(input);
|
|
358
|
+
entry.workflow = workflowForResource(workflow, type, id);
|
|
359
|
+
}
|
|
360
|
+
return json(response, 200, entry);
|
|
187
361
|
}
|
|
188
362
|
if (request.method === "PUT") {
|
|
189
363
|
const payload = normalizeResourceMutation(await readJson(request), { requireRevision: true });
|
|
@@ -196,7 +370,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
196
370
|
expectedContentRevisions: payload.contentRevisions,
|
|
197
371
|
requireExpectedContentRevisions: true
|
|
198
372
|
}));
|
|
199
|
-
return json(response, 200,
|
|
373
|
+
return json(response, 200, result);
|
|
200
374
|
}
|
|
201
375
|
if (request.method === "DELETE") {
|
|
202
376
|
const revision = requireRevision(url.searchParams.get("revision"), `${type}/${id}`);
|
|
@@ -209,6 +383,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
|
|
|
209
383
|
id,
|
|
210
384
|
deletedContent: result.deletedContent,
|
|
211
385
|
synchronization: result.synchronization,
|
|
386
|
+
workflowDelta: result.workflowDelta,
|
|
212
387
|
state: result.state
|
|
213
388
|
});
|
|
214
389
|
}
|
|
@@ -267,20 +442,49 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
267
442
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
|
|
268
443
|
backgroundPushDelayMs: options.backgroundPushDelayMs
|
|
269
444
|
});
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
})
|
|
445
|
+
let usedFallbackPort = false;
|
|
446
|
+
try {
|
|
447
|
+
await listen(server, port, host);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
if (
|
|
450
|
+
error.code !== "EADDRINUSE"
|
|
451
|
+
|| port === 0
|
|
452
|
+
|| options.fallbackToAvailablePort !== true
|
|
453
|
+
) {
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
await listen(server, 0, host);
|
|
457
|
+
usedFallbackPort = true;
|
|
458
|
+
}
|
|
274
459
|
return {
|
|
275
460
|
server,
|
|
276
461
|
root: loaded.root,
|
|
277
462
|
address: server.address(),
|
|
278
|
-
url: `http://${urlHost(host)}:${server.address().port}
|
|
463
|
+
url: `http://${urlHost(host)}:${server.address().port}`,
|
|
464
|
+
requestedPort: port,
|
|
465
|
+
usedFallbackPort
|
|
279
466
|
};
|
|
280
467
|
}
|
|
281
468
|
|
|
469
|
+
function listen(server, port, host) {
|
|
470
|
+
return new Promise((resolve, reject) => {
|
|
471
|
+
const onError = (error) => {
|
|
472
|
+
server.off("listening", onListening);
|
|
473
|
+
reject(error);
|
|
474
|
+
};
|
|
475
|
+
const onListening = () => {
|
|
476
|
+
server.off("error", onError);
|
|
477
|
+
resolve();
|
|
478
|
+
};
|
|
479
|
+
server.once("error", onError);
|
|
480
|
+
server.once("listening", onListening);
|
|
481
|
+
server.listen(port, host);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
282
485
|
function browserMutation(input, options, mutationOptions, task) {
|
|
283
486
|
const run = () => serializeWorkspaceMutation(input, async (root) => {
|
|
487
|
+
const workflowBefore = await assessWorkflow(root);
|
|
284
488
|
const result = await runBrowserMutation(root, {
|
|
285
489
|
...mutationOptions,
|
|
286
490
|
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true,
|
|
@@ -299,7 +503,11 @@ function browserMutation(input, options, mutationOptions, task) {
|
|
|
299
503
|
pushError: state.repository.backgroundSyncError ?? null
|
|
300
504
|
};
|
|
301
505
|
}
|
|
302
|
-
return {
|
|
506
|
+
return {
|
|
507
|
+
...result,
|
|
508
|
+
workflowDelta: buildWorkflowDelta(workflowBefore, state.workflow),
|
|
509
|
+
state
|
|
510
|
+
};
|
|
303
511
|
});
|
|
304
512
|
if (!timingEnabled()) return run();
|
|
305
513
|
return collectTimings(run).then(({ result, timings }) => {
|
|
@@ -347,6 +555,18 @@ async function readJson(request) {
|
|
|
347
555
|
return value;
|
|
348
556
|
}
|
|
349
557
|
|
|
558
|
+
async function readBytes(request) {
|
|
559
|
+
const chunks = [];
|
|
560
|
+
let size = 0;
|
|
561
|
+
for await (const chunk of request) {
|
|
562
|
+
size += chunk.length;
|
|
563
|
+
if (size > 25_000_000) throw new Error("Attachment exceeds 25 MB.");
|
|
564
|
+
chunks.push(chunk);
|
|
565
|
+
}
|
|
566
|
+
if (size === 0) throw new Error("An attachment body is required.");
|
|
567
|
+
return Buffer.concat(chunks);
|
|
568
|
+
}
|
|
569
|
+
|
|
350
570
|
function json(response, status, value) {
|
|
351
571
|
text(response, status, `${JSON.stringify(value, null, 2)}\n`, "application/json; charset=utf-8");
|
|
352
572
|
}
|
|
@@ -383,6 +603,13 @@ function safeSegment(value) {
|
|
|
383
603
|
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
384
604
|
}
|
|
385
605
|
|
|
606
|
+
function safeAttachmentName(value) {
|
|
607
|
+
return typeof value === "string"
|
|
608
|
+
&& value.length > 0
|
|
609
|
+
&& value === value.split(/[\\/]/).at(-1)
|
|
610
|
+
&& !value.startsWith(".");
|
|
611
|
+
}
|
|
612
|
+
|
|
386
613
|
function requireRevision(value, target) {
|
|
387
614
|
if (typeof value !== "string" || value.length === 0) {
|
|
388
615
|
throw new Error(`A revision is required when changing ${target}. Reload the resource and try again.`);
|
|
@@ -433,6 +660,7 @@ function urlHost(host) {
|
|
|
433
660
|
function statusFor(error) {
|
|
434
661
|
if (error instanceof SyntaxError || error instanceof URIError) return 400;
|
|
435
662
|
if (/exceeds 2 MB/i.test(error.message)) return 413;
|
|
663
|
+
if (/exceeds 25 MB/i.test(error.message)) return 413;
|
|
436
664
|
if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
|
|
437
665
|
if (/already exists|target file already exists/i.test(error.message)) return 409;
|
|
438
666
|
if (/Git could not|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|background push|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
|