filegrc 0.12.4 → 0.13.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/package.json +1 -1
- package/src/files.js +13 -5
- package/src/git.js +122 -25
- package/src/program-amendment.js +3 -2
- package/src/program-path.js +4 -4
- package/src/program-readiness.js +26 -7
- package/src/reconciliation.js +10 -5
- package/src/reporting-route-integrity.js +181 -0
- package/src/reporting-route-sets.js +157 -25
- package/src/server.js +154 -68
- package/src/state.js +30 -5
- package/src/validate.js +136 -14
- package/src/web.js +218 -22
package/package.json
CHANGED
package/src/files.js
CHANGED
|
@@ -965,14 +965,22 @@ async function updateContentUnlocked(input, dataRelativePath, source, options) {
|
|
|
965
965
|
const path = resolveDataPath(loaded.root, dataRelativePath);
|
|
966
966
|
const previous = await readFile(path, "utf8");
|
|
967
967
|
assertRevision(previous, options.expectedRevision, "The Markdown file");
|
|
968
|
-
const
|
|
968
|
+
const deferValidation = workspaceValidationDeferred();
|
|
969
|
+
const before = deferValidation ? null : await validateWorkspace(loaded);
|
|
969
970
|
const nextSource = source.endsWith("\n") ? source : `${source}\n`;
|
|
970
971
|
await writeTextAtomic(path, nextSource);
|
|
971
972
|
try {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
973
|
+
if (!deferValidation) {
|
|
974
|
+
const result = await validateWorkspace(loaded.root);
|
|
975
|
+
const introduced = newErrors(result, before);
|
|
976
|
+
if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
path,
|
|
980
|
+
dataRelativePath,
|
|
981
|
+
source: nextSource,
|
|
982
|
+
revision: contentRevision(nextSource)
|
|
983
|
+
};
|
|
976
984
|
} catch (error) {
|
|
977
985
|
await writeTextAtomic(path, previous);
|
|
978
986
|
throw error;
|
package/src/git.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { rm } from "node:fs/promises";
|
|
6
6
|
import { devNull } from "node:os";
|
|
7
7
|
import { relative, resolve, sep } from "node:path";
|
|
@@ -23,6 +23,7 @@ const backgroundSynchronizations = new Map();
|
|
|
23
23
|
const browserRemotePrefetches = new Map();
|
|
24
24
|
const browserRemotePrefetchPromises = new Map();
|
|
25
25
|
const repositorySnapshotPromises = new Map();
|
|
26
|
+
const repositoryObjectFormats = new Map();
|
|
26
27
|
const gitCommandCaches = new AsyncLocalStorage();
|
|
27
28
|
const gitCommandDeadlines = new AsyncLocalStorage();
|
|
28
29
|
const gitCommandCacheBytes = new WeakMap();
|
|
@@ -30,6 +31,7 @@ let gitCommandInterceptor = null;
|
|
|
30
31
|
let gitSubprocessObserver = null;
|
|
31
32
|
let historicalBatchInterceptor = null;
|
|
32
33
|
let historicalRevisionReadObserver = null;
|
|
34
|
+
let workspaceBlobObjectIdOverride = null;
|
|
33
35
|
const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
|
|
34
36
|
const GIT_DEFAULT_TIMEOUT_MS = 10_000;
|
|
35
37
|
const GIT_REMOTE_TIMEOUT_MS = 30_000;
|
|
@@ -680,15 +682,50 @@ export function getFileObjectIdAtRevision(input, revision, relativePath) {
|
|
|
680
682
|
export function getWorkingFileObjectId(input, relativePath) {
|
|
681
683
|
if (!isSafeDataGitPath(relativePath)) return null;
|
|
682
684
|
const root = resolveWorkspaceRoot(input);
|
|
685
|
+
let descriptor;
|
|
683
686
|
try {
|
|
684
|
-
const
|
|
685
|
-
|
|
687
|
+
const objectFormat = repositoryObjectFormat(root);
|
|
688
|
+
if (!objectFormat) return null;
|
|
689
|
+
descriptor = openSync(resolve(root, relativePath), constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
690
|
+
const before = fstatSync(descriptor);
|
|
691
|
+
if (!before.isFile()) return null;
|
|
692
|
+
const hash = createHash(objectFormat).update(`blob ${before.size}\0`);
|
|
693
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
694
|
+
let position = 0;
|
|
695
|
+
while (position < before.size) {
|
|
696
|
+
const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, before.size - position), position);
|
|
697
|
+
if (!count) return null;
|
|
698
|
+
hash.update(buffer.subarray(0, count));
|
|
699
|
+
position += count;
|
|
700
|
+
}
|
|
701
|
+
const after = fstatSync(descriptor);
|
|
702
|
+
if (
|
|
703
|
+
after.size !== before.size
|
|
704
|
+
|| after.dev !== before.dev
|
|
705
|
+
|| after.ino !== before.ino
|
|
706
|
+
|| after.mtimeMs !== before.mtimeMs
|
|
707
|
+
|| after.ctimeMs !== before.ctimeMs
|
|
708
|
+
) return null;
|
|
709
|
+
return hash.digest("hex");
|
|
686
710
|
} catch (error) {
|
|
687
711
|
rethrowGitDeadline(error);
|
|
688
712
|
return null;
|
|
713
|
+
} finally {
|
|
714
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
689
715
|
}
|
|
690
716
|
}
|
|
691
717
|
|
|
718
|
+
function repositoryObjectFormat(root) {
|
|
719
|
+
if (repositoryObjectFormats.has(root)) return repositoryObjectFormats.get(root);
|
|
720
|
+
if (!tryGit(root, ["rev-parse", "--git-dir"])) return null;
|
|
721
|
+
const objectFormat = tryGit(root, ["config", "--get", "extensions.objectFormat"]) || "sha1";
|
|
722
|
+
if (!["sha1", "sha256"].includes(objectFormat)) {
|
|
723
|
+
throw new Error(`FileGRC does not support the repository object format "${objectFormat}".`);
|
|
724
|
+
}
|
|
725
|
+
repositoryObjectFormats.set(root, objectFormat);
|
|
726
|
+
return objectFormat;
|
|
727
|
+
}
|
|
728
|
+
|
|
692
729
|
export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12, options = {}) {
|
|
693
730
|
const root = resolveWorkspaceRoot(input);
|
|
694
731
|
const wanted = new Set(relativePaths);
|
|
@@ -1415,8 +1452,8 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
1415
1452
|
})));
|
|
1416
1453
|
subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
|
|
1417
1454
|
assertNoIgnoredAuthoritativeFiles(root);
|
|
1418
|
-
const beforeValidation = workspaceByteManifest(root);
|
|
1419
|
-
const validation = await validateWorkspace(root);
|
|
1455
|
+
const beforeValidation = measureTimingSync("workspace-manifest", () => workspaceByteManifest(root));
|
|
1456
|
+
const validation = await withGitCommandCache(new Map(), () => validateWorkspace(root));
|
|
1420
1457
|
if (!validation.ok) {
|
|
1421
1458
|
throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}.`);
|
|
1422
1459
|
}
|
|
@@ -1426,9 +1463,9 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
1426
1463
|
validation,
|
|
1427
1464
|
fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
|
|
1428
1465
|
};
|
|
1429
|
-
validatedManifest = workspaceByteManifest(root);
|
|
1466
|
+
validatedManifest = measureTimingSync("workspace-manifest", () => workspaceByteManifest(root));
|
|
1430
1467
|
assertWorkspaceManifestEqual(beforeValidation, validatedManifest);
|
|
1431
|
-
await assertNoOutsideWorktreeChangesAsync(root);
|
|
1468
|
+
await measureTiming("outside-worktree-check", () => assertNoOutsideWorktreeChangesAsync(root));
|
|
1432
1469
|
} catch (error) {
|
|
1433
1470
|
throw new Error(`${error.message} FileGRC preserved every current file instead of guessing which edits it owns. Review the Git diff; later browser mutations are blocked until the worktree is reconciled.`);
|
|
1434
1471
|
}
|
|
@@ -1454,13 +1491,13 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
|
|
|
1454
1491
|
}
|
|
1455
1492
|
assertValidGitIdentity(root);
|
|
1456
1493
|
assertNoWorkspaceContentFilters(root);
|
|
1457
|
-
await assertNoOutsideWorktreeChangesAsync(root);
|
|
1494
|
+
await measureTiming("outside-worktree-check", () => assertNoOutsideWorktreeChangesAsync(root));
|
|
1458
1495
|
let commit;
|
|
1459
1496
|
let indexReconciled;
|
|
1460
1497
|
try {
|
|
1461
1498
|
const expectedRef = `refs/heads/${config.authoritativeBranch}`;
|
|
1462
1499
|
assertExpectedCheckout(root, expectedRef, synchronized.currentCommit);
|
|
1463
|
-
validatedManifest = writeWorkspaceManifestObjects(root, validatedManifest);
|
|
1500
|
+
validatedManifest = measureTimingSync("workspace-manifest-objects", () => writeWorkspaceManifestObjects(root, validatedManifest));
|
|
1464
1501
|
({ commit, indexReconciled } = await measureTiming("commit", () => commitValidatedIndexAsync(root, subject, validatedManifest, {
|
|
1465
1502
|
expectedParent: synchronized.currentCommit,
|
|
1466
1503
|
expectedRef
|
|
@@ -2261,6 +2298,8 @@ function assertNoIgnoredAuthoritativeFiles(root) {
|
|
|
2261
2298
|
|
|
2262
2299
|
function workspaceByteManifest(root) {
|
|
2263
2300
|
assertNoHiddenIndexEntries(root);
|
|
2301
|
+
const objectFormat = repositoryObjectFormat(root);
|
|
2302
|
+
if (!objectFormat) throw new Error("FileGRC could not determine the Git repository object format.");
|
|
2264
2303
|
const paths = [...new Set(nulFields(gitRaw(root, [
|
|
2265
2304
|
"ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."
|
|
2266
2305
|
])))].sort();
|
|
@@ -2298,9 +2337,36 @@ function workspaceByteManifest(root) {
|
|
|
2298
2337
|
) {
|
|
2299
2338
|
throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
|
|
2300
2339
|
}
|
|
2301
|
-
const
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2340
|
+
const objectHash = createHash(objectFormat).update(`blob ${opened.size}\0`);
|
|
2341
|
+
const byteHash = createHash("sha256");
|
|
2342
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
2343
|
+
let position = 0;
|
|
2344
|
+
while (position < opened.size) {
|
|
2345
|
+
const count = readSync(descriptor, buffer, 0, Math.min(buffer.length, opened.size - position), position);
|
|
2346
|
+
if (!count) throw new Error(`FileGRC could not read complete workspace entry "${path}".`);
|
|
2347
|
+
const chunk = buffer.subarray(0, count);
|
|
2348
|
+
objectHash.update(chunk);
|
|
2349
|
+
byteHash.update(chunk);
|
|
2350
|
+
position += count;
|
|
2351
|
+
}
|
|
2352
|
+
const after = fstatSync(descriptor);
|
|
2353
|
+
if (
|
|
2354
|
+
after.size !== opened.size
|
|
2355
|
+
|| after.dev !== opened.dev
|
|
2356
|
+
|| after.ino !== opened.ino
|
|
2357
|
+
|| after.mtimeMs !== opened.mtimeMs
|
|
2358
|
+
|| after.ctimeMs !== opened.ctimeMs
|
|
2359
|
+
) {
|
|
2360
|
+
throw new Error(`FileGRC will not commit workspace entry "${path}" because it changed while its bytes were being inspected.`);
|
|
2361
|
+
}
|
|
2362
|
+
const calculatedObjectId = objectHash.digest("hex");
|
|
2363
|
+
const objectId = workspaceBlobObjectIdOverride?.(null, objectFormat, path) ?? calculatedObjectId;
|
|
2364
|
+
return [path, {
|
|
2365
|
+
objectId,
|
|
2366
|
+
byteDigest: byteHash.digest("hex"),
|
|
2367
|
+
size: opened.size,
|
|
2368
|
+
mode: opened.mode & 0o111 ? "100755" : "100644"
|
|
2369
|
+
}];
|
|
2304
2370
|
} finally {
|
|
2305
2371
|
closeSync(descriptor);
|
|
2306
2372
|
}
|
|
@@ -2316,24 +2382,42 @@ function assertNoHiddenIndexEntries(root) {
|
|
|
2316
2382
|
}
|
|
2317
2383
|
}
|
|
2318
2384
|
|
|
2319
|
-
function
|
|
2320
|
-
|
|
2385
|
+
function writeWorkspaceManifestObjects(root, manifest) {
|
|
2386
|
+
const valuesByObjectId = new Map();
|
|
2387
|
+
for (const value of manifest.values()) {
|
|
2388
|
+
if (!value.objectId) continue;
|
|
2389
|
+
const prior = valuesByObjectId.get(value.objectId);
|
|
2390
|
+
if (prior && (prior.size !== value.size || prior.byteDigest !== value.byteDigest)) {
|
|
2391
|
+
throw new Error(`Git object ${value.objectId} identifies different validated workspace bytes.`);
|
|
2392
|
+
}
|
|
2393
|
+
valuesByObjectId.set(value.objectId, value);
|
|
2394
|
+
}
|
|
2395
|
+
if (!valuesByObjectId.size) return manifest;
|
|
2396
|
+
const entries = [...manifest].filter(([, value]) => value.objectId);
|
|
2397
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
|
|
2398
|
+
const workspacePrefix = relative(topLevel, root).split(sep).join("/");
|
|
2399
|
+
if (workspacePrefix === ".." || workspacePrefix.startsWith("../")) {
|
|
2400
|
+
throw new Error("The FileGRC workspace is outside its Git repository.");
|
|
2401
|
+
}
|
|
2402
|
+
const written = observedExecFileSync("git", ["hash-object", "-w", "--stdin-paths"], {
|
|
2321
2403
|
cwd: root,
|
|
2322
|
-
input:
|
|
2404
|
+
input: `${entries.map(([path]) => workspacePrefix ? `${workspacePrefix}/${path}` : path).join("\n")}\n`,
|
|
2323
2405
|
encoding: "utf8",
|
|
2324
2406
|
stdio: ["pipe", "pipe", "ignore"],
|
|
2325
2407
|
timeout: 30_000,
|
|
2326
2408
|
maxBuffer: 20_000_000,
|
|
2327
2409
|
env: gitEnvironment()
|
|
2328
|
-
}).trim();
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2410
|
+
}).trim().split("\n");
|
|
2411
|
+
if (written.length !== entries.length) {
|
|
2412
|
+
throw new Error("Git returned an unexpected number of workspace objects.");
|
|
2413
|
+
}
|
|
2414
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
2415
|
+
if (written[index] !== entries[index][1].objectId) {
|
|
2416
|
+
throw new Error(`Git wrote an unexpected object while preparing validated workspace bytes for commit.`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
assertWorkspaceManifestEqual(manifest, workspaceByteManifest(root));
|
|
2420
|
+
return manifest;
|
|
2337
2421
|
}
|
|
2338
2422
|
|
|
2339
2423
|
function assertWorkspaceManifestEqual(expected, current) {
|
|
@@ -2346,7 +2430,11 @@ function workspaceManifestsEqual(expected, current) {
|
|
|
2346
2430
|
return expected.size === current.size
|
|
2347
2431
|
&& [...expected].every(([path, value]) => {
|
|
2348
2432
|
const other = current.get(path);
|
|
2349
|
-
return other
|
|
2433
|
+
return other
|
|
2434
|
+
&& value.objectId === other.objectId
|
|
2435
|
+
&& value.mode === other.mode
|
|
2436
|
+
&& value.size === other.size
|
|
2437
|
+
&& value.byteDigest === other.byteDigest;
|
|
2350
2438
|
});
|
|
2351
2439
|
}
|
|
2352
2440
|
|
|
@@ -2621,6 +2709,15 @@ export function setHistoricalRevisionReadObserverForTests(observer) {
|
|
|
2621
2709
|
return () => { historicalRevisionReadObserver = previous; };
|
|
2622
2710
|
}
|
|
2623
2711
|
|
|
2712
|
+
export function setWorkspaceBlobObjectIdOverrideForTests(override) {
|
|
2713
|
+
if (override !== null && typeof override !== "function") {
|
|
2714
|
+
throw new TypeError("The workspace blob object ID override must be a function or null.");
|
|
2715
|
+
}
|
|
2716
|
+
const previous = workspaceBlobObjectIdOverride;
|
|
2717
|
+
workspaceBlobObjectIdOverride = override;
|
|
2718
|
+
return () => { workspaceBlobObjectIdOverride = previous; };
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2624
2721
|
export function runGitCommand(cwd, args, options = {}) {
|
|
2625
2722
|
if (gitCommandInterceptor) {
|
|
2626
2723
|
return Promise.resolve().then(() => gitCommandInterceptor({
|
package/src/program-amendment.js
CHANGED
|
@@ -172,13 +172,14 @@ export async function assessProgramAmendmentReadiness(loaded) {
|
|
|
172
172
|
const commitments = loaded.resources.filter((record) => (
|
|
173
173
|
record.type === "commitment" && !["superseded", "retired"].includes(record.status)
|
|
174
174
|
));
|
|
175
|
-
const
|
|
175
|
+
const supplementalCommitments = commitments.filter((record) => (record.sourceResourceIds || []).length > 0);
|
|
176
|
+
const sourceIds = new Set(supplementalCommitments.flatMap((record) => record.sourceResourceIds || []));
|
|
176
177
|
for (const record of loaded.resources) {
|
|
177
178
|
if (["policy", "document"].includes(record.type) && record.programRole === "supporting" && !["superseded", "retired"].includes(record.status)) {
|
|
178
179
|
sourceIds.add(record.id);
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
|
-
const sourceRecords = [...new Set([...sourceIds, ...
|
|
182
|
+
const sourceRecords = [...new Set([...sourceIds, ...supplementalCommitments.map(({ id }) => id)])]
|
|
182
183
|
.map((id) => byId.get(id))
|
|
183
184
|
.filter((record) => record && SOURCE_TYPES.has(record.type));
|
|
184
185
|
const plans = await Promise.all(sourceRecords.map((record) => (
|
package/src/program-path.js
CHANGED
|
@@ -11,8 +11,8 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
11
11
|
framework: "Confirm the criteria framework and version used for the program.",
|
|
12
12
|
requirement: "Keep the published criterion as catalog content. Record management applicability and rationale on the selected Program.",
|
|
13
13
|
commitment: "Record supplemental customer promises and service requirements that shape the scope or control design. The Commitment’s systemIds and controlIds are authoritative for what fulfills it.",
|
|
14
|
-
"requirement-mapping": "
|
|
15
|
-
"reporting-route-set": "
|
|
14
|
+
"requirement-mapping": "Use a Requirement Mapping when supplemental policies, contracts, privacy promises, frameworks, or other sources need explicit coverage semantics. Choose the comparison method and relationship, explain the rationale, and bind the review to every mapped source revision.",
|
|
15
|
+
"reporting-route-set": "Prepare the normal and fallback ways people will send each report required by proposed program content, including where each channel goes and the role that keeps it usable. Commit the proposal in Step 1, approve it before cutover, use separate approval and ongoing authority Appointments, and create a successor when either channel changes.",
|
|
16
16
|
policy: "Tailor each Policy to match what the company is committing to. Clear placeholders, assign an owner and separate approver, then bind approval to the reviewed content. Approval does not prove implementation. Activate the Policy during the Step 3 cutover after reviewing its implementation gaps.",
|
|
17
17
|
document: "Complete required program Documents in Step 2, assign an owner and separate approver, and bind approval to the intended values and exact Markdown. Implement the linked requirements and activate that approved revision in Step 3. Prepare Audit Documents in Step 5.",
|
|
18
18
|
control: "Finish each applicable starter Control with the procedure people follow, its owner, bounded System scope, operating Components, authoritative evidence-source Components, governing Policy and Requirement mappings, and implementation date. Put calendar and event schedules in Obligations.",
|
|
@@ -82,8 +82,8 @@ export const PROGRAM_PATH = [
|
|
|
82
82
|
description: "Ownership, criteria, and service boundary",
|
|
83
83
|
summary: "Name the owners, criteria, service, Systems, and providers in scope.",
|
|
84
84
|
sections: [
|
|
85
|
-
{ id: "ownership", title: "Program Ownership", description: "Confirm who owns the program, plus the normal security reporting channel and its fallback.", steps: ["Confirm the initial program lead’s actual job title and the separate Policy Owner Appointment.", "Add the organization’s real appointments, reviewers, and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "
|
|
86
|
-
{ id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "
|
|
85
|
+
{ id: "ownership", title: "Program Ownership", description: "Confirm who owns the program, plus the normal security reporting channel and its fallback.", steps: ["Confirm the initial program lead’s actual job title and the separate Policy Owner Appointment.", "Add the organization’s real appointments, reviewers, and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Replace the starter reporting-channel placeholders, propose the set, and commit that proposal before Step 1 is complete. Approve it before the later implementation cutover.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
|
|
86
|
+
{ id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Replace each planned service-commitment prompt with the actual promise or requirement. Use Requirement Mappings only when a supplemental source needs an explicit coverage comparison. Keep optional criteria out until the company chooses to add them."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
|
|
87
87
|
{ id: "boundary", title: "System Boundary", description: "Start with the bounded System. Add Components that materially deliver the service, support Controls, produce authoritative Evidence, or support relevant operations. Keep Vendor relationships and specific Assets separate.", steps: ["Create the complete bounded System and select it on the Program.", "Add only relevant Components, with a role and rationale for each System use.", "Create Vendors for material external provider relationships and link supplied Components when factual.", "Normalize Information Types and Classifications used by the System, Components, Vendors, Risks, and Evidence Artifacts."], types: ["system", "component", "vendor", "classification", "information-type"], defaultOpen: false }
|
|
88
88
|
],
|
|
89
89
|
resourceTypes: ["person", "appointment", "team", "reporting-route-set", "program", "framework", "requirement", "commitment", "requirement-mapping", "system", "component", "vendor", "classification", "information-type"],
|
package/src/program-readiness.js
CHANGED
|
@@ -424,16 +424,32 @@ function reportingRouteSetItem(assessment) {
|
|
|
424
424
|
const draft = assessment.routeSets.find(({ record }) => ["draft", "proposed"].includes(record.status));
|
|
425
425
|
const required = assessment.requirements.length > 0;
|
|
426
426
|
const proposed = assessment.proposedRequirements.length > 0;
|
|
427
|
-
const
|
|
428
|
-
|
|
427
|
+
const proposedPurposeKeys = [...new Set(assessment.proposedRequirements
|
|
428
|
+
.map(({ purposeKey }) => purposeKey)
|
|
429
|
+
.filter(Boolean))];
|
|
430
|
+
const preparedPurposeKeys = new Set(assessment.routeSets
|
|
431
|
+
.filter(({ record, committed, canceled, proposedRequirementIssues }) => (
|
|
432
|
+
["proposed", "approved"].includes(record.status)
|
|
433
|
+
&& committed
|
|
434
|
+
&& !canceled
|
|
435
|
+
&& proposedRequirementIssues.length === 0
|
|
436
|
+
))
|
|
437
|
+
.map(({ record }) => record.purposeKey));
|
|
438
|
+
const unpreparedPurposeKeys = proposedPurposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey));
|
|
439
|
+
const ready = assessment.issues.length === 0
|
|
440
|
+
&& (!required || Boolean(current))
|
|
441
|
+
&& unpreparedPurposeKeys.length === 0;
|
|
442
|
+
const needsAction = assessment.issues.length > 0 || unpreparedPurposeKeys.length > 0;
|
|
429
443
|
const target = current?.record || draft?.record || { type: "reporting-route-set" };
|
|
430
444
|
let message;
|
|
431
|
-
if (
|
|
445
|
+
if (assessment.issues.length) {
|
|
432
446
|
message = assessment.issues[0].message;
|
|
447
|
+
} else if (unpreparedPurposeKeys.length) {
|
|
448
|
+
message = `Complete and commit a Reporting Channel Set proposal for ${unpreparedPurposeKeys.join(", ")} before Step 1 is complete. Approval and effectiveness remain part of the later implementation cutover.`;
|
|
433
449
|
} else if (required && ready && current) {
|
|
434
450
|
message = `${current.record.title} is committed, effective, and has a current responsible Appointment. Assignments bind its Git commit.`;
|
|
435
|
-
} else if (!required && proposed
|
|
436
|
-
message = "
|
|
451
|
+
} else if (!required && proposed) {
|
|
452
|
+
message = "Every reporting-channel requirement in proposed program content has a committed Reporting Channel Set proposal. Approve the exact proposal before its governing content becomes active.";
|
|
437
453
|
} else if (!required && draft) {
|
|
438
454
|
message = "No approved rule currently requires these reporting channels. Keep the draft for review or remove it; it is not a readiness gate.";
|
|
439
455
|
} else if (!required) {
|
|
@@ -443,12 +459,15 @@ function reportingRouteSetItem(assessment) {
|
|
|
443
459
|
}
|
|
444
460
|
return item(
|
|
445
461
|
"security-reporting-route-set",
|
|
446
|
-
needsAction ? "action" : required ? ready ? "complete" : "action" : proposed
|
|
447
|
-
needsAction ? "
|
|
462
|
+
needsAction ? "action" : required ? ready ? "complete" : "action" : proposed ? "complete" : "info",
|
|
463
|
+
needsAction ? "Prepare required reporting channels" : required ? "Approve required reporting channels" : proposed ? "Reporting channel proposals are ready" : "Reporting channels are not currently required",
|
|
448
464
|
message,
|
|
449
465
|
target,
|
|
450
466
|
{
|
|
451
467
|
requirements: assessment.requirements,
|
|
468
|
+
proposedRequirements: assessment.proposedRequirements,
|
|
469
|
+
proposedRequirementIssues: assessment.routeSets.flatMap(({ proposedRequirementIssues }) => proposedRequirementIssues),
|
|
470
|
+
unpreparedPurposeKeys,
|
|
452
471
|
issues: assessment.issues,
|
|
453
472
|
commands: [
|
|
454
473
|
"npx filegrc reporting-route-sets --json",
|
package/src/reconciliation.js
CHANGED
|
@@ -456,6 +456,10 @@ export async function planReconciliation(input = process.cwd(), options = {}) {
|
|
|
456
456
|
|
|
457
457
|
function reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, extra = {}) {
|
|
458
458
|
const needsTimestamp = eventNeedsTimestamp(loaded, transition.eventType);
|
|
459
|
+
const requiredFacts = [
|
|
460
|
+
transition.eventType === "person-ended" ? "riskLevel" : null,
|
|
461
|
+
needsTimestamp ? "occurredAt" : "occurredOn"
|
|
462
|
+
].filter(Boolean);
|
|
459
463
|
return {
|
|
460
464
|
id: `reconcile-${fingerprint.slice(0, 16)}`,
|
|
461
465
|
eventId,
|
|
@@ -465,12 +469,13 @@ function reconciliationCandidate(loaded, transition, record, path, fingerprint,
|
|
|
465
469
|
sourcePath: path,
|
|
466
470
|
state: "needs-confirmation",
|
|
467
471
|
message: transition.message,
|
|
468
|
-
requiredFacts
|
|
469
|
-
transition.eventType === "person-ended" ? "riskLevel" : null,
|
|
470
|
-
needsTimestamp ? "occurredAt" : "occurredOn"
|
|
471
|
-
].filter(Boolean),
|
|
472
|
+
requiredFacts,
|
|
472
473
|
action: {
|
|
473
|
-
kind: "
|
|
474
|
+
kind: "reconcile-transition",
|
|
475
|
+
candidateId: fingerprint,
|
|
476
|
+
eventType: transition.eventType,
|
|
477
|
+
subject: { type: record.type, id: record.id },
|
|
478
|
+
requiredFacts,
|
|
474
479
|
command: reconciliationCommand(transition.eventType, record.id, fingerprint, needsTimestamp)
|
|
475
480
|
},
|
|
476
481
|
...extra
|
|
@@ -14,6 +14,7 @@ import { appointmentWasAuthorizedOn } from "./soc2.js";
|
|
|
14
14
|
import { isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
|
|
15
15
|
|
|
16
16
|
const CONTEMPORANEOUS_COMMIT_WINDOW_MS = 86_400_000;
|
|
17
|
+
const REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES = new Set(["policy", "document", "commitment", "risk"]);
|
|
17
18
|
|
|
18
19
|
export function reportingRouteRevision(record) {
|
|
19
20
|
const effectiveFacts = {
|
|
@@ -103,10 +104,14 @@ export function reportingRouteAssertionTiming(loaded, routeSet, eventName) {
|
|
|
103
104
|
|
|
104
105
|
export function reportingRouteFixedEvidence(records, subjectId, evidenceIds, at, timezone = "UTC", options = {}) {
|
|
105
106
|
let date;
|
|
107
|
+
let availableDate;
|
|
106
108
|
try {
|
|
107
109
|
date = /^\d{4}-\d{2}-\d{2}$/.test(String(at || ""))
|
|
108
110
|
? String(at)
|
|
109
111
|
: localDateTimeValue(instant(at, "Supported event time"), timezone).slice(0, 10);
|
|
112
|
+
availableDate = options.availableAt
|
|
113
|
+
? localDateTimeValue(instant(options.availableAt, "Evidence availability time"), timezone).slice(0, 10)
|
|
114
|
+
: null;
|
|
110
115
|
} catch {
|
|
111
116
|
return [];
|
|
112
117
|
}
|
|
@@ -119,6 +124,7 @@ export function reportingRouteFixedEvidence(records, subjectId, evidenceIds, at,
|
|
|
119
124
|
|| evidence.status !== "verified"
|
|
120
125
|
|| !arrayValue(evidence.sourceResourceIds).includes(subjectId)
|
|
121
126
|
|| !verifiedEvidenceComplete(evidence, personIds)
|
|
127
|
+
|| (availableDate && (evidence.collectedOn > availableDate || evidence.verifiedOn > availableDate))
|
|
122
128
|
) return false;
|
|
123
129
|
const coversDate = coverageContains(evidence.coverage, date)
|
|
124
130
|
|| [evidence.businessEventAt, evidence.sourceGeneratedAt].filter(Boolean).some((value) => {
|
|
@@ -274,6 +280,150 @@ export function reportingRouteRequirementAppliesToProgram(requirement, programId
|
|
|
274
280
|
&& arrayValue(requirement.programIds).includes(programId);
|
|
275
281
|
}
|
|
276
282
|
|
|
283
|
+
export function reportingRouteProposalIssues(records, routeSet, options = {}) {
|
|
284
|
+
return reportingRouteProposalIssuesForRequirements(
|
|
285
|
+
reportingRouteRequirementsForProposal(records, routeSet),
|
|
286
|
+
routeSet,
|
|
287
|
+
records,
|
|
288
|
+
options
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function reportingRouteRequirementsForProposal(records, routeSet, options = {}) {
|
|
293
|
+
return records.flatMap((source) => (
|
|
294
|
+
REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES.has(source.type)
|
|
295
|
+
&& (
|
|
296
|
+
reportingRouteSourceMayBecomeEffective(source)
|
|
297
|
+
|| reportingRouteSourceEffectiveAt(source, options.at, options.timezone)
|
|
298
|
+
)
|
|
299
|
+
? (Array.isArray(source.reportingRouteRequirements) ? source.reportingRouteRequirements : [])
|
|
300
|
+
.filter((requirement) => (
|
|
301
|
+
requirement
|
|
302
|
+
&& typeof requirement === "object"
|
|
303
|
+
&& !Array.isArray(requirement)
|
|
304
|
+
&& requirement.purposeKey === routeSet.purposeKey
|
|
305
|
+
&& reportingRouteRequirementAppliesToProgram(requirement, routeSet.programId)
|
|
306
|
+
))
|
|
307
|
+
: []
|
|
308
|
+
));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function reportingRouteSourceEffectiveAt(source, at, timezone = "UTC") {
|
|
312
|
+
if (!at) return false;
|
|
313
|
+
try { return reportingRouteSourceEffective(source, at, timezone); } catch { return false; }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function reportingRouteSupportIssues(requirements, routeSet, proposalRecords, currentRecords, options = {}) {
|
|
317
|
+
const proposalIssues = reportingRouteProposalIssuesForRequirements(
|
|
318
|
+
requirements,
|
|
319
|
+
routeSet,
|
|
320
|
+
proposalRecords,
|
|
321
|
+
{ ...options, commit: options.proposalCommit }
|
|
322
|
+
);
|
|
323
|
+
const currentIssues = reportingRouteProposalIssuesForRequirements(
|
|
324
|
+
requirements,
|
|
325
|
+
routeSet,
|
|
326
|
+
currentRecords,
|
|
327
|
+
{ ...options, commit: options.currentCommit }
|
|
328
|
+
);
|
|
329
|
+
return [...new Map([...proposalIssues, ...currentIssues].map((item) => [
|
|
330
|
+
`${item.code}\0${item.resourceId}\0${item.message}`,
|
|
331
|
+
item
|
|
332
|
+
])).values()];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function reportingRouteProposalIssuesForRequirements(requirements, routeSet, records, options = {}) {
|
|
336
|
+
const applicableRequirements = requirements.filter((requirement) => (
|
|
337
|
+
requirement
|
|
338
|
+
&& typeof requirement === "object"
|
|
339
|
+
&& !Array.isArray(requirement)
|
|
340
|
+
&& requirement.purposeKey === routeSet.purposeKey
|
|
341
|
+
&& reportingRouteRequirementAppliesToProgram(requirement, routeSet.programId)
|
|
342
|
+
));
|
|
343
|
+
const issues = [];
|
|
344
|
+
const placeholder = (value) => /^(?:\[|tbd\b|todo\b|unknown\b|replace\b|complete before\b)/i.test(String(value || "").trim());
|
|
345
|
+
if (placeholder(routeSet.primaryLane?.destination)) {
|
|
346
|
+
issues.push({
|
|
347
|
+
code: "incomplete-reporting-route-proposal",
|
|
348
|
+
resourceId: routeSet.id,
|
|
349
|
+
message: `${routeSet.title} needs the real normal reporting destination before it can be proposed.`
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (applicableRequirements.some(({ requiredLanes }) => requiredLanes?.includes("alternate"))) {
|
|
353
|
+
if (!routeSet.alternateLane || placeholder(routeSet.alternateLane.destination)) {
|
|
354
|
+
issues.push({
|
|
355
|
+
code: "incomplete-reporting-route-proposal",
|
|
356
|
+
resourceId: routeSet.id,
|
|
357
|
+
message: `${routeSet.title} needs the real fallback reporting destination before it can be proposed.`
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (applicableRequirements.some(({ distinctChannels }) => distinctChannels)
|
|
362
|
+
&& routeSet.alternateLane?.channelKind === routeSet.primaryLane?.channelKind) {
|
|
363
|
+
issues.push({
|
|
364
|
+
code: "reporting-route-channel-not-distinct",
|
|
365
|
+
resourceId: routeSet.id,
|
|
366
|
+
message: `${routeSet.title} needs different normal and fallback channel types before it can be proposed.`
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
if (applicableRequirements.some(({ independentDependencies }) => independentDependencies)
|
|
370
|
+
&& !reportingRouteLanesIndependent(
|
|
371
|
+
routeSet,
|
|
372
|
+
records,
|
|
373
|
+
options.at || new Date(),
|
|
374
|
+
options
|
|
375
|
+
)) {
|
|
376
|
+
issues.push({
|
|
377
|
+
code: "reporting-route-dependencies-not-independent",
|
|
378
|
+
resourceId: routeSet.id,
|
|
379
|
+
message: `${routeSet.title} needs independent normal and fallback channel dependencies or an applicable Exception before it can be proposed.`
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return issues;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function reportingRouteLanesIndependent(record, records, at, options = {}) {
|
|
386
|
+
if (!record.alternateLane) return false;
|
|
387
|
+
for (const lane of [record.primaryLane, record.alternateLane]) {
|
|
388
|
+
if (!lane?.dependencyBasis) return false;
|
|
389
|
+
if (lane.dependencyBasis === "cataloged" && !lane.dependencySystemIds?.length) return false;
|
|
390
|
+
if (lane.dependencyBasis === "none" && !String(lane.dependencyRationale || "").trim()) return false;
|
|
391
|
+
}
|
|
392
|
+
const primary = new Set(record.primaryLane?.dependencySystemIds || []);
|
|
393
|
+
const overlap = (record.alternateLane.dependencySystemIds || []).filter((id) => primary.has(id));
|
|
394
|
+
if (!overlap.length) return true;
|
|
395
|
+
const timezone = options.timezone || record.approval?.timezone || "UTC";
|
|
396
|
+
let date;
|
|
397
|
+
let availableDate;
|
|
398
|
+
try {
|
|
399
|
+
date = localDateTimeValue(instant(at, "Reporting Route assessment time"), timezone).slice(0, 10);
|
|
400
|
+
availableDate = localDateTimeValue(
|
|
401
|
+
instant(options.availableAt || at, "Reporting Route support availability time"),
|
|
402
|
+
timezone
|
|
403
|
+
).slice(0, 10);
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
return records.some((candidate) => (
|
|
408
|
+
candidate.type === "exception"
|
|
409
|
+
&& candidate.status === "approved"
|
|
410
|
+
&& candidate.reportingRouteSetId === record.id
|
|
411
|
+
&& candidate.reportingRouteLanePair === "primary-alternate"
|
|
412
|
+
&& overlap.every((id) => candidate.dependencySystemIds?.includes(id))
|
|
413
|
+
&& candidate.approval?.approvedOn <= date
|
|
414
|
+
&& candidate.approval?.approvedOn <= availableDate
|
|
415
|
+
&& candidate.approval?.expiresOn >= date
|
|
416
|
+
&& reportingRouteFixedEvidence(
|
|
417
|
+
records,
|
|
418
|
+
candidate.id,
|
|
419
|
+
candidate.evidenceIds,
|
|
420
|
+
candidate.approval?.approvedOn,
|
|
421
|
+
timezone,
|
|
422
|
+
{ root: options.root, commit: options.commit, availableAt: options.availableAt || at }
|
|
423
|
+
).length > 0
|
|
424
|
+
));
|
|
425
|
+
}
|
|
426
|
+
|
|
277
427
|
export function reportingRouteSourceEffective(source, at, timezone = "UTC") {
|
|
278
428
|
const when = instant(at, "Source assessment time");
|
|
279
429
|
if (!reportingRouteSourceMayApply(source)) return false;
|
|
@@ -299,6 +449,11 @@ export function reportingRouteSourceMayApply(source) {
|
|
|
299
449
|
return false;
|
|
300
450
|
}
|
|
301
451
|
|
|
452
|
+
export function reportingRouteSourceMayBecomeEffective(source) {
|
|
453
|
+
return REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES.has(source?.type)
|
|
454
|
+
&& !["superseded", "retired", "closed", "archived"].includes(source.status);
|
|
455
|
+
}
|
|
456
|
+
|
|
302
457
|
export function reportingRouteSetInterval(routeSet) {
|
|
303
458
|
if (!["approved", "canceled"].includes(routeSet?.status) || !routeSet.approval?.effectiveAt) return null;
|
|
304
459
|
return {
|
|
@@ -524,6 +679,32 @@ export function reportingRouteHistory(loaded, routeSetId) {
|
|
|
524
679
|
return getDataRecordHistoryIndex(loaded.root).historiesById.get(routeSetId) || [];
|
|
525
680
|
}
|
|
526
681
|
|
|
682
|
+
export function reportingRouteExactHistoryEntry(loaded, routeSet, head) {
|
|
683
|
+
return reportingRouteHistory(loaded, routeSet.id).find((summary) => {
|
|
684
|
+
if (head && !isDataHistoryAncestor(loaded, summary.commit, head)) return false;
|
|
685
|
+
const source = getFileAtRevision(loaded.root, summary.commit, summary.path);
|
|
686
|
+
try { return source && JSON.stringify(JSON.parse(source)) === JSON.stringify(routeSet); } catch { return false; }
|
|
687
|
+
}) || null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function reportingRouteCommitTimestamp(loaded, routeSetId, commit) {
|
|
691
|
+
return reportingRouteHistory(loaded, routeSetId)
|
|
692
|
+
.find(({ commit: changedAt }) => changedAt === commit)?.timestamp || null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export function reportingRouteProposalAssessmentTime(timestamp, assessmentAt = new Date()) {
|
|
696
|
+
let commitAt;
|
|
697
|
+
let assessedAt;
|
|
698
|
+
try {
|
|
699
|
+
commitAt = instant(timestamp, "Proposal commit time");
|
|
700
|
+
assessedAt = instant(assessmentAt, "Proposal assessment time");
|
|
701
|
+
} catch {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
if (commitAt.getTime() > assessedAt.getTime() + CONTEMPORANEOUS_COMMIT_WINDOW_MS) return null;
|
|
705
|
+
return commitAt > assessedAt ? assessedAt : commitAt;
|
|
706
|
+
}
|
|
707
|
+
|
|
527
708
|
export function recordsAtRevision(loaded, commit) {
|
|
528
709
|
const index = getDataRecordHistoryIndex(loaded.root);
|
|
529
710
|
const records = [];
|