diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..e11effe --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,41 @@ +name: Validate + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: validate-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + clean-install: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + package-manager-cache: false + + - name: Update npm + run: npm install --global npm@11.19.1 + + - name: Install from lockfile + run: npm ci + + - name: Validate clean install + run: npm run validate + env: + CI: true + + - name: Require generated files to be current + run: git diff --exit-code diff --git a/package-lock.json b/package-lock.json index 1a2971f..a94ec8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.15.4", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.15.4", + "version": "0.16.0", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.15.4", + "version": "0.16.0", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.15.4", + "version": "0.16.0", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index ea5cb85..e1d979b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.15.4", + "version": "0.16.0", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index d2f3bbb..aa8d4b9 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.15.4", + "version": "0.16.0", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index c8f3b39..81f1f51 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -523,13 +523,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.15.4", + version: "0.16.0", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.15.4", + version: "0.16.0", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/AGENTS.md b/packages/create-filegrc/template/AGENTS.md index f73cd19..2213a72 100644 --- a/packages/create-filegrc/template/AGENTS.md +++ b/packages/create-filegrc/template/AGENTS.md @@ -119,6 +119,8 @@ npx filegrc migrate --to-model 8 --preview --json Older workspaces migrate one version at a time. Review every preview’s automatic, review-required, and unsupported classifications before applying it with the same options and `--yes`. The v8 migration preserves legacy retention prose as notes, renames Component processing operations, and creates no retention periods or disposition behavior. +After changing the installed `filegrc` version, run `npm ci` and validate the workspace. FileGRC rejects CLI, server, and write operations when the installed version differs from `package-lock.json`. Older calculated revision bindings remain valid when the reviewed facts have not changed; do not repeat a management review solely because the engine changed. + The [model v11 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v11.md) explains owner-recorded Control implementation and periodic Control collection oversight. The [model v10 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v10.md) explains Reporting Channel Sets and the legacy-route review. Run these commands when working with records: diff --git a/packages/create-filegrc/template/README.md b/packages/create-filegrc/template/README.md index 921d4df..3208a0e 100644 --- a/packages/create-filegrc/template/README.md +++ b/packages/create-filegrc/template/README.md @@ -19,6 +19,8 @@ npm run serve Requires Node.js 20 or newer and Git. +Run FileGRC through the version installed by `npm ci`. The CLI, local server, and write APIs stop when that installed version differs from `package-lock.json`. Older revision bindings remain readable after an engine upgrade, so an unchanged review does not need to be repeated or rewritten just to adopt a new engine. + Existing model v10 workspaces must run `npx filegrc migrate --to-model 11 --preview --json` after installing a model v11 package. The migration removes the old per-Control implementation review fields. Owners can record implementation directly, while periodic Control oversight uses a Collection Review. Independent Policy, Document, and Training approvals do not change. Older workspaces migrate one model version at a time. See the [model v11 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v11.md). ## How it works diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 96b1a30..cce0844 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.15.4", + "version": "0.16.0", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 6057bd7..dd1660b 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.15.4", + "version": "0.16.0", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/applicability-scope.js b/packages/filegrc/src/applicability-scope.js index 1527395..b6c9f1b 100644 --- a/packages/filegrc/src/applicability-scope.js +++ b/packages/filegrc/src/applicability-scope.js @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js"; const excludedResourceFields = new Set([ "applicabilityReview", @@ -151,7 +151,7 @@ export function applicabilityScopeRevision(record, program, resources, model) { "customerFacing" ], model)) }; - return `scope:${createHash("sha256").update(stableJson(facts)).digest("hex")}`; + return calculateRevision("applicability-scope", stableJson(facts)); } function compareRecordIds(left, right) { @@ -162,11 +162,16 @@ export function applicabilityReviewIsCurrent(review, record, program, resources, if ( review?.scopeRevision && !review.scopeRevision.startsWith("scope:") + && !review.scopeRevision.startsWith("filegrc:applicability-scope:") && Number(model?.modelVersion || 0) < 7 ) return true; return Boolean( review?.scopeRevision - && review.scopeRevision === applicabilityScopeRevision(record, program, resources, model) + && revisionsMatch( + "applicability-scope", + review.scopeRevision, + applicabilityScopeRevision(record, program, resources, model) + ) ); } @@ -176,17 +181,17 @@ function pick(record, fields, model, objectType = null) { .map((field) => [field, record[field]]))); } -function canonicalObject(model, type, value) { +function canonicalObject(model, type, value, revisionMap = false) { const fields = model.resources?.[type] ? { ...model.commonFields, ...model.resources[type].fields } : model.objectTypes?.[type]?.properties || {}; return Object.fromEntries(Object.keys(value).sort().map((name) => [ name, - canonicalFieldValue(model, value[name], fields[name], name) + canonicalFieldValue(model, value[name], fields[name], name, revisionMap) ])); } -function canonicalFieldValue(model, value, field, name) { +function canonicalFieldValue(model, value, field, name, revisionMap = false) { if (Array.isArray(value)) { const items = value.map((item) => field?.itemObjectType && item && typeof item === "object" ? canonicalObject(model, field.itemObjectType, item) @@ -197,9 +202,13 @@ function canonicalFieldValue(model, value, field, name) { return items; } if (value && typeof value === "object") { - return field?.objectType ? canonicalObject(model, field.objectType, value) : value; + return field?.objectType + ? canonicalObject(model, field.objectType, value, CALCULATED_REVISION_MAP_FIELDS.has(name)) + : value; } - return value; + return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(name)) + ? canonicalCalculatedRevision(value) + : value; } function stableJson(value) { diff --git a/packages/filegrc/src/audit-preparation.js b/packages/filegrc/src/audit-preparation.js index d1abd7c..ed1f3a7 100644 --- a/packages/filegrc/src/audit-preparation.js +++ b/packages/filegrc/src/audit-preparation.js @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { modelSupports } from "../model/index.js"; import { applicabilityReviewIsCurrent } from "./applicability-scope.js"; @@ -33,6 +32,7 @@ import { } from "./soc2.js"; import { currentCalendarDate } from "./time.js"; import { loadWorkspace } from "./workspace.js"; +import { calculateRevision } from "./revisions.js"; const NON_EVIDENCE_RECORD_TYPES = new Set([ "audit", @@ -160,7 +160,7 @@ export async function prepareAuditWorkspace(input, options = {}) { const model = loaded.model.auditReadiness || {}; const auditEntry = loaded.entries.find((entry) => entry.record.id === audit.id); - const auditRevision = createHash("sha256").update(auditEntry.source).digest("hex"); + const auditRevision = calculateRevision("content", auditEntry.source); const documents = loaded.resources.filter((record) => record.type === "document"); const nextAudit = { ...audit }; const linkedDocuments = []; diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index c1b15f9..18c2e6e 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -75,6 +75,7 @@ import { searchResources } from "./search.js"; import { serveWorkspace } from "./server.js"; import { planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js"; import { printGithubStarMessage } from "./startup.js"; +import { assertWorkspaceEngineVersion } from "./runtime-version.js"; import { createAppState } from "./state.js"; import { currentCalendarDate } from "./time.js"; import { validateWorkspace } from "./validate.js"; @@ -116,6 +117,10 @@ export async function runCli(argv = process.argv.slice(2)) { if (["help", "--help", "-h"].includes(command)) return printHelp(); if (["version", "--version", "-v"].includes(command)) return printVersion(); if (flags.help || args.includes("-h")) return printCommandHelp(command); + const targetRoot = ["serve", "build", "validate"].includes(command) + ? positionals[0] ?? root + : root; + assertWorkspaceEngineVersion(targetRoot); if (command === "serve") { const result = await serveWorkspace(positionals[0] ?? root, { diff --git a/packages/filegrc/src/collection-review-integrity.js b/packages/filegrc/src/collection-review-integrity.js index d529261..7751c8c 100644 --- a/packages/filegrc/src/collection-review-integrity.js +++ b/packages/filegrc/src/collection-review-integrity.js @@ -1,9 +1,9 @@ -import { createHash } from "node:crypto"; import { loadModel } from "../model/index.js"; import { collectionRevision } from "./collection-revision.js"; import { scopedCollectionRecords } from "./collection-scope.js"; import { getDataFilesAtRevision, getFileAtRevision, getRecordIdentityHistory, hasGitRevision } from "./git.js"; import { currentCalendarDate, isRfc3339Timestamp } from "./time.js"; +import { calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js"; export function collectionReviewRevision(record) { const reviewedFacts = { @@ -24,7 +24,17 @@ export function collectionReviewRevision(record) { authoritativeComponentId: record.authoritativeComponentId, supersedesId: record.supersedesId }; - return createHash("sha256").update(JSON.stringify(reviewedFacts)).digest("hex"); + return calculateRevision("collection-review", JSON.stringify(canonicalRevisionValue(reviewedFacts))); +} + +function canonicalRevisionValue(value, name = null) { + if (Array.isArray(value)) return value.map((item) => canonicalRevisionValue(item)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, canonicalRevisionValue(item, key)])); + } + return name === "collectionRevision" && typeof value === "string" + ? canonicalCalculatedRevision(value) + : value; } export function historicalCollectionReviewSnapshot(root, record, model, timezone, resourceType, cutoff, selector = null, relativePath = null, expectedCommit = null) { @@ -60,7 +70,7 @@ export function historicalCollectionReviewSnapshot(root, record, model, timezone authoritativeSourceId: record.authoritativeComponentId }); if ( - record.collectionRevision !== currentRevision + !revisionsMatch("collection", record.collectionRevision, currentRevision) || JSON.stringify([...(record.populationResourceIds || [])].sort()) !== JSON.stringify(collectionIds) ) return null; const selectedIds = selector @@ -89,7 +99,7 @@ function committedCollectionReviewMatch(root, record, relativePath, expectedComm return historical.type === "collection-review" && historical.id === record.id && ["active", "retired"].includes(historical.status) - && collectionReviewRevision(historical) === expected; + && revisionsMatch("collection-review", collectionReviewRevision(historical), expected); } catch { return false; } diff --git a/packages/filegrc/src/collection-review.js b/packages/filegrc/src/collection-review.js index 58911a1..e27302d 100644 --- a/packages/filegrc/src/collection-review.js +++ b/packages/filegrc/src/collection-review.js @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { modelSupports } from "../model/index.js"; import { @@ -16,6 +15,8 @@ import { currentPartyPeople } from "./parties.js"; import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js"; import { markdownEntries } from "./resource-markdown.js"; import { resolveDataPath } from "./paths.js"; +import { calculateRevision, calculatedRevisionDiagnostic, revisionsMatch } from "./revisions.js"; +import { personWasActiveOn } from "./soc2.js"; export { collectionRevision }; @@ -93,7 +94,10 @@ export function assessCollectionReview(loaded, resourceType, options = {}) { const incompleteRecordProposals = recordProposals.filter(({ complete: ready }) => !ready); const reviewersEligible = !reviewerEligibility || Boolean( review?.reviewedByIds?.length - && review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id)) + && review.reviewedByIds.every((id) => ( + personWasActiveOn(loaded.resources.find((record) => record.id === id), review.reviewedOn) + && !reviewerEligibility.reviewerConflictIds.includes(id) + )) ); const reviewScopeMatches = !workspaceWideRetentionReview || sameIds(review?.scopeResourceIds, retentionScope.programIds); @@ -119,10 +123,11 @@ export function assessCollectionReview(loaded, resourceType, options = {}) { recordCount: records.length, review, reviewRevision: reviewEntry - ? createHash("sha256").update(reviewEntry.source).digest("hex") + ? calculateRevision("content", reviewEntry.source) : null, reviewEntries, collectionRevision: currentRevision, + revisionDiagnostic: calculatedRevisionDiagnostic("collection", review?.collectionRevision, currentRevision), status: complete ? "current" : stale ? "stale" : "review-required", complete, ...(reviewerEligibility || {}), @@ -139,7 +144,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) { ? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row." : "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation." : stale - ? `${configuration.title} changed after the last confirmation. Review the current records again.` + ? `${configuration.title} changed after the last confirmation. Stored revision: ${review.collectionRevision}. Current revision: ${currentRevision}. Review the current records again.` : !records.length && !allowsEmptyCollection ? `Add at least one ${loaded.model.resources[resourceType].title.toLowerCase()} before confirming this collection.` : `Review ${configuration.title.toLowerCase()} before this page can be ready.` @@ -203,7 +208,7 @@ export async function planCollectionReview(input = process.cwd(), options = {}) && modelSupports(loaded.model, "retention-schedule-approval"); const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null; const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id }); - if (options.expectedCollectionRevision && options.expectedCollectionRevision !== assessment.collectionRevision) { + if (options.expectedCollectionRevision && !revisionsMatch("collection", options.expectedCollectionRevision, assessment.collectionRevision)) { throw new Error(`${assessment.configuration.title} changed after it was displayed. Reload and review the current revision before approving it.`); } if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") && !options.expectedCollectionRevision) { @@ -337,7 +342,7 @@ export async function planCollectionReview(input = process.cwd(), options = {}) entry.record.id, entry.record.id === existing?.id && options.expectedRevision ? options.expectedRevision - : createHash("sha256").update(entry.source).digest("hex") + : calculateRevision("content", entry.source) ])); if (existingEntries.length && preservesReviewHistory) { for (const entry of existingEntries) { @@ -456,7 +461,7 @@ function assessControlReviewers(loaded, controls) { const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active"); return { eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)), - reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id)) + reviewerConflictIds: [...conflictIds].sort() }; } @@ -475,7 +480,7 @@ function assessRetentionScheduleReviewers(loaded, rows) { const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active"); return { eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)), - reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id)) + reviewerConflictIds: [...conflictIds].sort() }; } diff --git a/packages/filegrc/src/collection-revision.js b/packages/filegrc/src/collection-revision.js index 66d7feb..0839b3e 100644 --- a/packages/filegrc/src/collection-revision.js +++ b/packages/filegrc/src/collection-revision.js @@ -9,6 +9,7 @@ import { import { resolveDataPath } from "./paths.js"; import { resolveProgram } from "./program.js"; import { markdownEntries } from "./resource-markdown.js"; +import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js"; export function collectionRevision(loaded, resourceType, options = {}) { return calculateCollectionRevision(loaded, resourceType, options, false); @@ -24,9 +25,9 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision, || collectionRevision(loaded, resourceType, options); // Version 0.9.2 narrowed this hash basis. Keep unchanged 0.9.1 reviews valid // until management records a new review on the current basis. - return storedRevision === currentRevision + return revisionsMatch("collection", storedRevision, currentRevision) || (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval")) - && storedRevision === legacyCollectionRevision(loaded, resourceType, options); + && revisionsMatch("collection", storedRevision, legacyCollectionRevision(loaded, resourceType, options)); } function calculateCollectionRevision(loaded, resourceType, options, legacy) { @@ -68,9 +69,10 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy) { })) .sort((left, right) => left.id.localeCompare(right.id)); const workspaceScope = collectionScopeRevisionFacts(loaded, resourceType, program); - return createHash("sha256") - .update(JSON.stringify({ resourceType, records, workspaceScope })) - .digest("hex"); + const source = JSON.stringify({ resourceType, records, workspaceScope }); + return legacy + ? createHash("sha256").update(source).digest("hex") + : calculateRevision("collection", source); } function canonicalRecordValue(model, resourceType, value) { @@ -81,14 +83,14 @@ function canonicalRecordValue(model, resourceType, value) { return canonicalObject(model, value, fields); } -function canonicalObject(model, value, fields = {}) { +function canonicalObject(model, value, fields = {}, revisionMap = false) { return Object.fromEntries(Object.keys(value).sort().map((name) => [ name, - canonicalFieldValue(model, value[name], fields[name]) + canonicalFieldValue(model, value[name], fields[name], name, revisionMap) ])); } -function canonicalFieldValue(model, value, field) { +function canonicalFieldValue(model, value, field, name, revisionMap = false) { if (Array.isArray(value)) { const objectType = field?.itemObjectType; const itemFields = objectType ? model.objectTypes?.[objectType]?.properties : undefined; @@ -103,9 +105,11 @@ function canonicalFieldValue(model, value, field) { } if (value && typeof value === "object") { const objectType = field?.objectType; - return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined); + return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined, CALCULATED_REVISION_MAP_FIELDS.has(name)); } - return value; + return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(name)) + ? canonicalCalculatedRevision(value) + : value; } function compareCanonicalValues(left, right) { diff --git a/packages/filegrc/src/document-activation.js b/packages/filegrc/src/document-activation.js index 82a9eb1..2d0c8bf 100644 --- a/packages/filegrc/src/document-activation.js +++ b/packages/filegrc/src/document-activation.js @@ -6,6 +6,7 @@ import { assessProgramReadiness } from "./program-readiness.js"; import { personWasActiveOn } from "./soc2.js"; import { currentCalendarDate } from "./time.js"; import { loadWorkspace } from "./workspace.js"; +import { revisionDigest } from "./revisions.js"; export async function scaffoldDocumentActivation(input = process.cwd(), options = {}) { const loaded = await loadWorkspace(input); @@ -97,7 +98,7 @@ export async function planDocumentActivation(input = process.cwd(), options = {} : `${resourceTitle} "${resourceId}" is not ready for ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.` ); } - if (!/^[a-f0-9]{64}$/.test(expectedRevisions[resourceId] || "")) { + if (!revisionDigest("content", expectedRevisions[resourceId])) { throw new Error(`Governed-content activation needs the current record revision for "${resourceId}". Regenerate the activation review and try again.`); } const record = { diff --git a/packages/filegrc/src/files.js b/packages/filegrc/src/files.js index b2fb18c..e20d2fa 100644 --- a/packages/filegrc/src/files.js +++ b/packages/filegrc/src/files.js @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { getResourceDefinition, loadModel, modelSupports } from "../model/index.js"; @@ -12,6 +12,7 @@ import { measureTiming } from "./timing.js"; import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js"; import { loadWorkspace } from "./workspace.js"; import { validateWorkspace } from "./validate.js"; +import { calculateRevision, revisionsMatch } from "./revisions.js"; export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({ auditManagementReconciliation: Symbol("audit-management-reconciliation"), @@ -692,11 +693,11 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu const legacyReplacement = options.workflowCapability === INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment && existing.status === "active" && next.status === "active"; - if (!retirement && !legacyReplacement && options.lifecycleOperation !== "model-migration") { + if (!retirement && !legacyReplacement && !isMigrationOperation(options.lifecycleOperation)) { throw new Error(`Finalized Collection Review "${existing.id}" is immutable. Record a superseding review instead.`); } } - if (options.lifecycleOperation === "model-migration") return; + if (isMigrationOperation(options.lifecycleOperation)) return; if ( modelSupports(loaded?.model || 0, "reporting-route-sets") && ["policy", "document", "commitment", "risk"].includes(existing.type) @@ -921,21 +922,21 @@ function assertSpecializedWorkflowCreate(record, options = {}, loaded = null) { if ( record?.type === "reconciliation-dismissal" && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal - && options.lifecycleOperation !== "model-migration" + && !isMigrationOperation(options.lifecycleOperation) ) { throw new Error(`Reconciliation dismissal "${record.id || ""}" is workflow-managed. Dismiss the current transition candidate instead of creating it directly.`); } if ( record?.type === "collection-review" && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment - && options.lifecycleOperation !== "model-migration" + && !isMigrationOperation(options.lifecycleOperation) ) { throw new Error(`Collection Review "${record.id || ""}" is workflow-managed. Preview and confirm the collection review instead of creating it directly.`); } if ( record?.type === "reporting-route-set" && record.status !== "draft" - && options.lifecycleOperation !== "model-migration" + && !isMigrationOperation(options.lifecycleOperation) ) { throw new Error(`Reporting Route Set "${record.id || ""}" must be created as a draft and advanced through managed actions.`); } @@ -1162,13 +1163,14 @@ async function prepareContentWrites(loaded, record, content, options = {}) { } function assertRevision(source, expected, label) { - if (expected && contentRevision(source) !== expected) { - throw new Error(`${label} changed after you opened it. Reload the workspace and apply your change again.`); + const current = contentRevision(source); + if (expected && !revisionsMatch("content", expected, current)) { + throw new Error(`${label} changed after you opened it. Stored revision: ${expected}. Current revision: ${current}. Reload the workspace and apply your change again.`); } } export function contentRevision(source) { - return createHash("sha256").update(source).digest("hex"); + return calculateRevision("content", source); } async function prepareApprovalBinding(loaded, record, contentWrites, previousRecord = null) { @@ -1223,7 +1225,7 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec } function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, model, lifecycleOperation) { - if (lifecycleOperation === "model-migration") return; + if (isMigrationOperation(lifecycleOperation)) return; const governedTraining = previousRecord?.type === "training" && nextRecord?.type === "training" && modelSupports(model, "governed-training-activation"); @@ -1269,6 +1271,10 @@ function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, mode } } +function isMigrationOperation(operation) { + return operation === "model-migration"; +} + function assertLifecycleFieldsUnchanged(previousRecord, nextRecord, fields, eventLabel, resourceTitle) { const changed = fields.filter((field) => ( JSON.stringify(previousRecord[field] ?? null) !== JSON.stringify(nextRecord[field] ?? null) diff --git a/packages/filegrc/src/index.js b/packages/filegrc/src/index.js index 16918d6..93d9295 100644 --- a/packages/filegrc/src/index.js +++ b/packages/filegrc/src/index.js @@ -123,6 +123,8 @@ export { createFilegrcServer, serveWorkspace } from "./server.js"; export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js"; export { createAppState, createResourceDetail } from "./state.js"; export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js"; +export { assertWorkspaceEngineVersion, workspaceEngineVersion } from "./runtime-version.js"; +export { calculatedRevisionDiagnostic, REVISION_SCHEME_VERSION } from "./revisions.js"; export { validateWorkspace } from "./validate.js"; export { assessWorkflow, diff --git a/packages/filegrc/src/mutation.js b/packages/filegrc/src/mutation.js index 61e1623..58469f6 100644 --- a/packages/filegrc/src/mutation.js +++ b/packages/filegrc/src/mutation.js @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { resolveWorkspaceRoot } from "./paths.js"; +import { assertWorkspaceEngineVersion } from "./runtime-version.js"; const mutationQueues = new Map(); const activeMutation = new AsyncLocalStorage(); @@ -7,6 +8,7 @@ const deferredValidation = new AsyncLocalStorage(); export function serializeWorkspaceMutation(input, task) { const root = resolveWorkspaceRoot(input); + assertWorkspaceEngineVersion(root); if (activeMutation.getStore() === root) return task(root); const previous = mutationQueues.get(root) ?? Promise.resolve(); const run = previous.catch(() => {}).then(() => activeMutation.run(root, () => task(root))); diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index 8a244cd..3c6a088 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { scaffoldResourceMutation } from "./agent.js"; import { createResourceId } from "./id.js"; import { @@ -30,6 +29,7 @@ import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./ import { bindAttestationReportingRouteSet, reportingRouteRevision } from "./reporting-route-integrity.js"; import { selectScopedCollectionRecords } from "./collection-scope.js"; import { occurrenceMemberIsResolved } from "./obligation-members.js"; +import { calculateRevision, revisionsMatch } from "./revisions.js"; const COMPLETION_DATE_FIELDS = [ "completedOn", @@ -993,7 +993,7 @@ export async function scaffoldObligationRuleActivation(input, options = {}) { export async function activateObligationRule(input, options = {}) { const scaffold = await scaffoldObligationRuleActivation(input, options); if (!(options.approvedByIds || []).length) throw new Error("Select at least one person who approved this rule."); - if (options.confirmedRevision !== scaffold.review.revision) { + if (!revisionsMatch("content", options.confirmedRevision, scaffold.review.revision)) { throw new Error("Confirm the current Obligation rule revision before activation."); } const loaded = await loadWorkspace(input); @@ -1508,7 +1508,7 @@ function bindEffectiveReportingRoute(loaded, record) { } function contentRevision(source) { - return createHash("sha256").update(source).digest("hex"); + return calculateRevision("content", source); } function calendarWindow(recurrence, configuredWindow, index) { diff --git a/packages/filegrc/src/policy-activation.js b/packages/filegrc/src/policy-activation.js index e2461d4..d7bd950 100644 --- a/packages/filegrc/src/policy-activation.js +++ b/packages/filegrc/src/policy-activation.js @@ -4,6 +4,7 @@ import { assessProgramReadiness } from "./program-readiness.js"; import { currentCalendarDate } from "./time.js"; import { loadWorkspace } from "./workspace.js"; import { modelSupports } from "../model/index.js"; +import { revisionDigest } from "./revisions.js"; export async function scaffoldPolicyActivation(input = process.cwd(), options = {}) { const loaded = await loadWorkspace(input); @@ -53,7 +54,7 @@ export async function planPolicyActivation(input = process.cwd(), options = {}) if (entry.record.status !== "approved") { throw new Error(`Policy "${policyId}" must be approved and inactive before the Step 3 cutover.`); } - if (!/^[a-f0-9]{64}$/.test(expectedRevisions[policyId] || "")) { + if (!revisionDigest("content", expectedRevisions[policyId])) { throw new Error(`Policy activation needs the current revision for "${policyId}". Regenerate the cutover review and try again.`); } const record = { ...entry.record, status: "active", effectiveOn }; diff --git a/packages/filegrc/src/program-lifecycle.js b/packages/filegrc/src/program-lifecycle.js index bdd29cf..affe1f9 100644 --- a/packages/filegrc/src/program-lifecycle.js +++ b/packages/filegrc/src/program-lifecycle.js @@ -1,5 +1,6 @@ import { currentPartyPeople } from "./parties.js"; import { modelSupports } from "../model/index.js"; +import { revisionsMatch } from "./revisions.js"; const requiredDocumentsByControlCache = new WeakMap(); @@ -65,8 +66,13 @@ export function governedContentIsOperating(record, asOf, model) { export function contentRevisionBindingsMatch(left, right) { if (!left || !right || Array.isArray(left) || Array.isArray(right)) return false; - const normalize = (value) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); - return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right)); + const leftEntries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b)); + const rightEntries = Object.entries(right).sort(([a], [b]) => a.localeCompare(b)); + return leftEntries.length === rightEntries.length + && leftEntries.every(([path, revision], index) => ( + rightEntries[index][0] === path + && (revision === rightEntries[index][1] || revisionsMatch("content", revision, rightEntries[index][1])) + )); } export function obligationGovernedDocuments(obligation, byId, model) { diff --git a/packages/filegrc/src/reporting-route-integrity.js b/packages/filegrc/src/reporting-route-integrity.js index c966abe..fcf9744 100644 --- a/packages/filegrc/src/reporting-route-integrity.js +++ b/packages/filegrc/src/reporting-route-integrity.js @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { getDataRecordHistoryIndex, getFileAtRevision, @@ -12,6 +11,7 @@ import { addCalendarDays } from "./recurrence.js"; import { coverageContains } from "./coverage.js"; import { appointmentWasAuthorizedOn } from "./soc2.js"; import { isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js"; +import { calculateRevision } from "./revisions.js"; const CONTEMPORANEOUS_COMMIT_WINDOW_MS = 86_400_000; const REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES = new Set(["policy", "document", "commitment", "risk"]); @@ -32,7 +32,7 @@ export function reportingRouteRevision(record) { sourceResourceIds: record.sourceResourceIds, ownerIds: record.ownerIds }; - return createHash("sha256").update(JSON.stringify(effectiveFacts)).digest("hex"); + return calculateRevision("reporting-route", JSON.stringify(effectiveFacts)); } export function reportingRouteEventAuthorityIssue(records, routeSet, options = {}) { diff --git a/packages/filegrc/src/requirement-mapping.js b/packages/filegrc/src/requirement-mapping.js index 41f3bf6..b5e60e5 100644 --- a/packages/filegrc/src/requirement-mapping.js +++ b/packages/filegrc/src/requirement-mapping.js @@ -1,4 +1,5 @@ import { resourceReviewRevisions } from "./retention.js"; +import { revisionsMatch } from "./revisions.js"; export async function assessRequirementMappingReadiness(loaded) { if (!loaded.model.resources["requirement-mapping"]) return []; @@ -17,7 +18,7 @@ export async function assessRequirementMappingReadiness(loaded) { ...(mapping.targetResourceIds || []) ])]; const staleIds = mappedIds.filter((id) => ( - !revisions.get(id) || mapping.reviewedSourceRevisions?.[id] !== revisions.get(id) + !revisions.get(id) || !revisionsMatch("content", mapping.reviewedSourceRevisions?.[id], revisions.get(id)) )).concat(Object.keys(mapping.reviewedSourceRevisions || {}).filter((id) => !mappedIds.includes(id))); const structurallyComplete = mappingStructureIsComplete(mapping); const complete = mapping.status === "active" && structurallyComplete && staleIds.length === 0; diff --git a/packages/filegrc/src/retention-schedule-approval.js b/packages/filegrc/src/retention-schedule-approval.js index e7d8405..f42ec4c 100644 --- a/packages/filegrc/src/retention-schedule-approval.js +++ b/packages/filegrc/src/retention-schedule-approval.js @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js"; import { markdownEntries } from "./resource-markdown.js"; @@ -11,6 +10,7 @@ import { retentionRuleIsCurrent, retentionUses } from "./retention.js"; +import { calculateRevision, revisionsMatch } from "./revisions.js"; export function retentionScheduleApprovalIssues(loaded, program, rows, options = {}) { const byId = new Map(loaded.resources.map((record) => [record.id, record])); @@ -125,9 +125,10 @@ function approvalBindingsMatch(loaded, record) { const actual = {}; for (const entry of markdownEntries(loaded.model, record)) { try { - actual[entry.path] = createHash("sha256") - .update(readFileSync(resolveDataPath(loaded.root, entry.path), "utf8")) - .digest("hex"); + actual[entry.path] = calculateRevision( + "content", + readFileSync(resolveDataPath(loaded.root, entry.path), "utf8") + ); } catch (error) { if (error.code !== "ENOENT") throw error; } @@ -136,5 +137,7 @@ function approvalBindingsMatch(loaded, record) { const actualKeys = Object.keys(actual).sort(); const expectedKeys = Object.keys(expected).sort(); return actualKeys.length === expectedKeys.length - && actualKeys.every((key, index) => key === expectedKeys[index] && actual[key] === expected[key]); + && actualKeys.every((key, index) => ( + key === expectedKeys[index] && revisionsMatch("content", actual[key], expected[key]) + )); } diff --git a/packages/filegrc/src/retention.js b/packages/filegrc/src/retention.js index f80d820..8c5efa0 100644 --- a/packages/filegrc/src/retention.js +++ b/packages/filegrc/src/retention.js @@ -5,6 +5,7 @@ import { resolveDataPath } from "./paths.js"; import { programComponents } from "./program.js"; import { markdownEntries } from "./resource-markdown.js"; import { modelSupports } from "../model/index.js"; +import { canonicalCalculatedRevisionJson, revisionDigest, revisionsMatch } from "./revisions.js"; export async function assessRetentionReadiness(loaded, program, options = {}) { if (!loaded.model.resources["retention-schedule-item"]) return []; @@ -173,7 +174,7 @@ export async function resourceReviewRevisions(loaded, ids) { } for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) { const revision = await review(sourceId); - if (revision) parts.push(`${sourceId}:${revision}`); + if (revision) parts.push(`${sourceId}:${revisionDigest("content", revision) || revision}`); } reviewing.delete(id); const revision = contentRevision(parts.join("\n")); @@ -206,7 +207,7 @@ export function resourceReviewRevisionsSync(loaded, ids) { } for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) { const revision = review(sourceId); - if (revision) parts.push(`${sourceId}:${revision}`); + if (revision) parts.push(`${sourceId}:${revisionDigest("content", revision) || revision}`); } reviewing.delete(id); const revision = contentRevision(parts.join("\n")); @@ -260,7 +261,7 @@ function reviewSource(loaded, entry) { ]) delete approved[field]; return JSON.stringify(approved); } - return entry.source; + return canonicalCalculatedRevisionJson(entry.source); } export function nearDuplicateInformationTypes(records) { @@ -326,7 +327,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded const dependencyIds = retentionReviewResourceIds(rule, loaded); if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false; return dependencyIds.every((id) => ( - revisions.get(id) && rule.reviewedSourceRevisions?.[id] === revisions.get(id) + revisions.get(id) && revisionsMatch("content", rule.reviewedSourceRevisions?.[id], revisions.get(id)) )); } diff --git a/packages/filegrc/src/revisions.js b/packages/filegrc/src/revisions.js new file mode 100644 index 0000000..d584562 --- /dev/null +++ b/packages/filegrc/src/revisions.js @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; + +export const REVISION_SCHEME_VERSION = 1; + +export const CALCULATED_REVISION_FIELDS = new Set([ + "collectionRevision", + "collectionReviewRevision", + "reportingRouteRevision", + "scopeRevision" +]); + +export const CALCULATED_REVISION_MAP_FIELDS = new Set([ + "approvedContentRevisions", + "activatedContentRevisions", + "effectiveContentRevisions", + "contentRevisions", + "reviewedSourceRevisions" +]); + +const PREFIXES = Object.freeze({ + content: `filegrc:content:v${REVISION_SCHEME_VERSION}:sha256:`, + collection: `filegrc:collection:v${REVISION_SCHEME_VERSION}:sha256:`, + "applicability-scope": `filegrc:applicability-scope:v${REVISION_SCHEME_VERSION}:sha256:`, + "collection-review": `filegrc:collection-review:v${REVISION_SCHEME_VERSION}:sha256:`, + "reporting-route": `filegrc:reporting-route:v${REVISION_SCHEME_VERSION}:sha256:` +}); + +export function calculateRevision(kind, source) { + const prefix = PREFIXES[kind]; + if (!prefix) throw new Error(`Unknown calculated revision kind "${kind}".`); + return `${prefix}${createHash("sha256").update(source).digest("hex")}`; +} + +export function revisionDigest(kind, value) { + const text = String(value || ""); + const prefix = PREFIXES[kind]; + if (prefix && text.startsWith(prefix) && /^[a-f0-9]{64}$/.test(text.slice(prefix.length))) { + return text.slice(prefix.length); + } + if (kind === "applicability-scope" && /^scope:[a-f0-9]{64}$/.test(text)) return text.slice(6); + if (/^[a-f0-9]{64}$/.test(text)) return text; + return null; +} + +export function revisionsMatch(kind, stored, current) { + const storedDigest = revisionDigest(kind, stored); + const currentDigest = revisionDigest(kind, current); + return Boolean(storedDigest && currentDigest && storedDigest === currentDigest); +} + +export function calculatedRevisionDiagnostic(kind, stored, current) { + return { + kind, + storedRevision: stored || null, + currentRevision: current || null, + storedScheme: revisionScheme(kind, stored), + currentScheme: revisionScheme(kind, current), + matches: revisionsMatch(kind, stored, current) + }; +} + +export function canonicalCalculatedRevision(value) { + const text = String(value || ""); + // Keep the original digest as the calculation input. Merely installing a + // scheme-aware engine must not stale a decision made over unchanged facts. + if (/^[a-f0-9]{64}$/.test(text)) return text; + if (/^scope:[a-f0-9]{64}$/.test(text)) return text.slice(6); + const match = /^filegrc:[a-z-]+:v\d+:sha256:([a-f0-9]{64})$/.exec(text); + return match ? match[1] : value; +} + +// Preserve JSON layout because older resource-review bindings hashed the exact +// source bytes. Only scheme labels in model-defined revision slots may change. +export function canonicalCalculatedRevisionJson(source) { + const fieldNames = [...CALCULATED_REVISION_FIELDS].join("|"); + const mapNames = [...CALCULATED_REVISION_MAP_FIELDS].join("|"); + const stringValue = /("(?:\\.|[^"\\])*")(\s*:\s*)("(?:\\.|[^"\\])*")/g; + const canonicalValue = (quoted) => { + const value = JSON.parse(quoted); + const canonical = canonicalCalculatedRevision(value); + return canonical === value ? quoted : JSON.stringify(canonical); + }; + return source + .replace(new RegExp(`("(?:${fieldNames})")(\\s*:\\s*)("(?:\\\\.|[^"\\\\])*")`, "g"), + (_match, key, separator, value) => `${key}${separator}${canonicalValue(value)}`) + .replace(new RegExp(`("(?:${mapNames})")(\\s*:\\s*\\{)([^{}]*)(\\})`, "g"), + (_match, key, opening, body, closing) => ( + `${key}${opening}${body.replace(stringValue, (_pair, name, separator, value) => ( + `${name}${separator}${canonicalValue(value)}` + ))}${closing}` + )); +} + +function revisionScheme(kind, value) { + const text = String(value || ""); + if (!text) return null; + if (text.startsWith(PREFIXES[kind] || "\0")) return `filegrc:${kind}:v${REVISION_SCHEME_VERSION}:sha256`; + if (kind === "applicability-scope" && /^scope:[a-f0-9]{64}$/.test(text)) return "legacy:applicability-scope:sha256"; + if (/^[a-f0-9]{64}$/.test(text)) return `legacy:${kind}:sha256`; + return "unknown"; +} diff --git a/packages/filegrc/src/runtime-version.js b/packages/filegrc/src/runtime-version.js new file mode 100644 index 0000000..6bb8d9a --- /dev/null +++ b/packages/filegrc/src/runtime-version.js @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveWorkspaceRoot } from "./paths.js"; + +const installedVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; + +export function workspaceEngineVersion(input = process.cwd()) { + let root; + try { + root = resolveWorkspaceRoot(input); + } catch (error) { + if (/No filegrc workspace was found/.test(error.message)) { + return { installedVersion, lockedVersion: null, lockfilePath: null }; + } + throw error; + } + const manifestPath = join(root, "package.json"); + let packageManaged = false; + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + packageManaged = Boolean(manifest.dependencies?.filegrc || manifest.devDependencies?.filegrc); + } catch (error) { + if (error.code !== "ENOENT") throw new Error(`Cannot read the workspace package.json: ${error.message}`); + } + let lock; + try { + lock = JSON.parse(readFileSync(join(root, "package-lock.json"), "utf8")); + } catch (error) { + if (error.code === "ENOENT" && !packageManaged) return { installedVersion, lockedVersion: null, lockfilePath: null }; + if (error.code === "ENOENT") throw versionDriftError("The workspace package-lock.json is missing. Run npm install to create it, then npm ci."); + throw new Error(`Cannot read the workspace package-lock.json: ${error.message}`); + } + const installedEntry = lock.packages?.["node_modules/filegrc"]; + const linkedEntry = installedEntry?.link && installedEntry.resolved + ? lock.packages?.[installedEntry.resolved] + : null; + const lockedVersion = linkedEntry?.version + || installedEntry?.version + || lock.dependencies?.filegrc?.version + || null; + if (packageManaged && !lockedVersion) { + throw versionDriftError("The workspace package-lock.json has no filegrc version. Run npm install to repair it, then npm ci."); + } + return { installedVersion, lockedVersion, lockfilePath: join(root, "package-lock.json") }; +} + +function versionDriftError(message) { + const error = new Error(message); + error.code = "FILEGRC_VERSION_DRIFT"; + return error; +} + +export function assertWorkspaceEngineVersion(input = process.cwd()) { + const result = workspaceEngineVersion(input); + if (result.lockedVersion && result.lockedVersion !== result.installedVersion) { + const error = new Error( + `Installed filegrc ${result.installedVersion} does not match package-lock.json (${result.lockedVersion}). ` + + "Run npm ci in the workspace, then retry." + ); + error.code = "FILEGRC_VERSION_DRIFT"; + error.installedVersion = result.installedVersion; + error.lockedVersion = result.lockedVersion; + throw error; + } + return result; +} diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index 45c2cad..30f040e 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -78,6 +78,7 @@ import { } from "./workflow.js"; import { loadWorkspace } from "./workspace.js"; import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js"; +import { assertWorkspaceEngineVersion } from "./runtime-version.js"; const STATE_SESSION_MAX_AGE_MS = 5 * 60_000; const MAX_STATE_SESSIONS = 8; @@ -86,6 +87,7 @@ const RESOURCE_DETAIL_GIT_DEADLINE_MS = 10_000; const STATE_SECTION_GIT_DEADLINE_MS = 10_000; export function createFilegrcServer(input = process.cwd(), options = {}) { + assertWorkspaceEngineVersion(input); const stateSessions = new Map(); const fileDigestCache = new Map(); let bootstrapSnapshotPromise = null; diff --git a/packages/filegrc/src/state.js b/packages/filegrc/src/state.js index 72f2da4..f70ae5e 100644 --- a/packages/filegrc/src/state.js +++ b/packages/filegrc/src/state.js @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { assessAuditPreparation } from "./audit-preparation.js"; import { assessCollectionReviews } from "./collection-review.js"; @@ -17,6 +16,7 @@ import { assessWorkflow } from "./workflow.js"; import { measureTiming } from "./timing.js"; import { soc2RequirementApplicabilityConstraint } from "./soc2.js"; import { modelSupports } from "../model/index.js"; +import { calculateRevision } from "./revisions.js"; const renderedMarkdownCache = new Map(); const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000; @@ -447,5 +447,5 @@ function renderMarkdownCached(source) { } function contentRevision(source) { - return createHash("sha256").update(source).digest("hex"); + return calculateRevision("content", source); } diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index 4028936..dc5d0ec 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -45,6 +45,7 @@ import { } from "./reporting-route-integrity.js"; import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js"; import { occurrenceMemberExceptionIsValid } from "./obligation-members.js"; +import { calculateRevision, revisionDigest, revisionsMatch } from "./revisions.js"; let fingerprintFileReadObserver = null; @@ -557,8 +558,8 @@ function validateObligationOccurrence(record, model, resources, entries, root, w || !(review.scopeResourceIds || []).includes(record.programId) || !historicalSnapshot || record.collectionReviewCommit !== historicalSnapshot.reviewCommit - || record.collectionReviewRevision !== collectionReviewRevision(review) - || record.collectionRevision !== review.collectionRevision + || !revisionsMatch("collection-review", record.collectionReviewRevision, collectionReviewRevision(review)) + || !revisionsMatch("collection", record.collectionRevision, review.collectionRevision) || record.scopeRevision !== review.scopeRevision || !reviewCoversCutoff || JSON.stringify(memberIds) !== JSON.stringify(reviewedIds) @@ -1231,6 +1232,16 @@ function validateTimestampTimezone(value, timezone, path, diagnostics, label) { function validateCollectionReview(record, loaded, byId, path, diagnostics) { if (record.status !== "active") return; + const inactiveReviewers = (record.reviewedByIds || []).filter((id) => ( + !personWasActiveOn(byId.get(id), record.reviewedOn) + )); + if (inactiveReviewers.length) { + diagnostics.push(error( + "inactive-collection-reviewer-on-review-date", + path, + `Collection Review reviewers must have been active on ${record.reviewedOn}: ${inactiveReviewers.join(", ")}.` + )); + } const { model } = loaded; const configuration = model.collectionReviews?.[record.resourceType]; if (!configuration) return; @@ -1987,13 +1998,13 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi const sources = retentionReviewResourceIds(record, loaded); const revisions = record.reviewedSourceRevisions || {}; const missing = sources.filter((id) => ( - !currentReviewRevisions.get(id) || revisions[id] !== currentReviewRevisions.get(id) + !currentReviewRevisions.get(id) || !revisionsMatch("content", revisions[id], currentReviewRevisions.get(id)) )).concat(Object.keys(revisions).filter((id) => !sources.includes(id))); if (missing.length) { diagnostics.push(error( "stale-retention-review", path, - `reviewedSourceRevisions must bind the schedule Document, Information Types, operational scope, and authority or source records before activation: ${missing.join(", ")}.` + `reviewedSourceRevisions must bind the schedule Document, Information Types, operational scope, and authority or source records before activation: ${revisionMismatchDetails(missing, revisions, currentReviewRevisions)}.` )); } } @@ -2011,17 +2022,23 @@ function validateRequirementMapping(record, currentReviewRevisions, path, diagno const mappedIds = [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])]; const revisions = record.reviewedSourceRevisions || {}; const missing = mappedIds.filter((id) => ( - !currentReviewRevisions.get(id) || revisions[id] !== currentReviewRevisions.get(id) + !currentReviewRevisions.get(id) || !revisionsMatch("content", revisions[id], currentReviewRevisions.get(id)) )).concat(Object.keys(revisions).filter((id) => !mappedIds.includes(id))); if (missing.length) { diagnostics.push(error( "stale-requirement-mapping", path, - `reviewedSourceRevisions must bind every mapped resource before activation: ${missing.join(", ")}.` + `reviewedSourceRevisions must bind every mapped resource before activation: ${revisionMismatchDetails(missing, revisions, currentReviewRevisions)}.` )); } } +function revisionMismatchDetails(ids, stored, current) { + return ids.map((id) => ( + `${id} (stored: ${stored[id] || "missing"}; current: ${current.get(id) || "missing"})` + )).join(", "); +} + function obligationCompletionTypes(model, obligation) { if (obligation.activityType === "custom") return obligation.customActivity?.completionResourceTypes || []; return model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []; @@ -2170,7 +2187,7 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn } const invalid = actualPaths.filter((item) => ( !expectedPaths.has(item) - || !/^[a-f0-9]{64}$/.test(String(record.contentRevisions[item] || "")) + || !revisionDigest("content", record.contentRevisions[item]) )); const unboundSubjects = [...subjectPaths].filter(([, paths]) => ( paths.length && !paths.some((item) => actualPaths.includes(item)) @@ -2251,7 +2268,11 @@ function validateReportingRouteBinding(record, loaded, path, diagnostics) { .sort((left, right) => right.record.effectiveAt.localeCompare(left.record.effectiveAt))[0] || null; const expectedId = route?.record.id; const expectedRevision = route ? reportingRouteRevision(route.record) : undefined; - if (record.reportingRouteId !== expectedId || record.reportingRouteRevision !== expectedRevision) { + if ( + record.reportingRouteId !== expectedId + || (expectedRevision && !revisionsMatch("reporting-route", record.reportingRouteRevision, expectedRevision)) + || (!expectedRevision && record.reportingRouteRevision !== expectedRevision) + ) { diagnostics.push(error( "invalid-reporting-route-binding", path, @@ -2276,7 +2297,7 @@ async function validateContentBinding(record, model, root, path, diagnostics, bi for (const item of markdownEntries(model, record)) { try { const source = await readFile(resolveDataPath(root, item.path), "utf8"); - actual[item.path] = createHash("sha256").update(source).digest("hex"); + actual[item.path] = calculateRevision("content", source); } catch (error) { if (error.code !== "ENOENT") throw error; } @@ -2284,8 +2305,8 @@ async function validateContentBinding(record, model, root, path, diagnostics, bi const expected = record[bindingField]; const paths = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort(); const invalid = paths.filter((item) => ( - !/^[a-f0-9]{64}$/.test(String(expected[item] || "")) - || expected[item] !== actual[item] + !revisionDigest("content", expected[item]) + || !revisionsMatch("content", expected[item], actual[item]) )); if (invalid.length) { diagnostics.push(error( diff --git a/packages/filegrc/src/workflow-history-integrity.js b/packages/filegrc/src/workflow-history-integrity.js index 273d8e5..fed92cc 100644 --- a/packages/filegrc/src/workflow-history-integrity.js +++ b/packages/filegrc/src/workflow-history-integrity.js @@ -13,6 +13,7 @@ import { resolveDataPath } from "./paths.js"; import { markdownEntries } from "./resource-markdown.js"; import { currentCalendarDate, isRfc3339Timestamp, timestampFromLocalDateTime } from "./time.js"; import { recordsAtRevision, reportingRouteFixedEvidence } from "./reporting-route-integrity.js"; +import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, canonicalCalculatedRevision } from "./revisions.js"; const FINAL_STATUSES = new Map([ ["reconciliation-dismissal", new Set([undefined])], @@ -823,18 +824,25 @@ function permittedFinalizedTransition(previous, current) { && (previous.evidenceIds || []).every((id) => (current.evidenceIds || []).includes(id)) ) allowed = ["evidenceIds"]; return [...new Set([...Object.keys(previous), ...Object.keys(current)])].every((key) => ( - allowed.includes(key) || sameJson(previous[key], current[key]) + allowed.includes(key) || sameJson(previous[key], current[key], key) )); } -function sameJson(left, right) { - return JSON.stringify(canonicalJson(left)) === JSON.stringify(canonicalJson(right)); +function sameJson(left, right, field = null) { + return JSON.stringify(canonicalJson(left, field)) === JSON.stringify(canonicalJson(right, field)); } -function canonicalJson(value) { - if (Array.isArray(value)) return value.map(canonicalJson); - if (!value || typeof value !== "object") return value; - return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalJson(value[key])])); +function canonicalJson(value, field = null, revisionMap = false) { + if (Array.isArray(value)) return value.map((item) => canonicalJson(item)); + if (!value || typeof value !== "object") { + return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(field)) + ? canonicalCalculatedRevision(value) + : value; + } + return Object.fromEntries(Object.keys(value).sort().map((key) => [ + key, + canonicalJson(value[key], key, revisionMap || CALCULATED_REVISION_MAP_FIELDS.has(field)) + ])); } function proofIds(occurrence, byId) { diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index 06ea687..8260409 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -2,7 +2,7 @@ import { cp, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { modelSupports } from "../model/index.js"; -import { applicabilityReviewIsCurrent } from "./applicability-scope.js"; +import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "./applicability-scope.js"; import { retentionScheduleIsAuthoritative } from "./collection-review.js"; import { assessRequiredAppointments } from "./appointments.js"; import { assessSourceCoverageReadiness } from "./source-coverage.js"; @@ -533,10 +533,13 @@ function finalizationFields(record, model, resources, program) { && model.resources[record.type]?.fields?.applicabilityReview && !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, resources, model); if (needsReview) { + const currentRevision = applicabilityScopeRevision(record, program, resources, model); fields.push({ field: "applicabilityReview", requiredness: "required", - message: "Review applicability against the current service scope, then record the decision, rationale, reviewer, and date." + message: record.applicabilityReview?.scopeRevision + ? `Review applicability against the current service scope. Stored revision: ${record.applicabilityReview.scopeRevision}. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.` + : `Review applicability against the current service scope. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.` }); } if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) { diff --git a/packages/filegrc/test/batch-review-performance.test.js b/packages/filegrc/test/batch-review-performance.test.js index af6e1ef..bfa400c 100644 --- a/packages/filegrc/test/batch-review-performance.test.js +++ b/packages/filegrc/test/batch-review-performance.test.js @@ -53,7 +53,7 @@ test("applicability batch resolves repository revision once and assesses workflo applicabilityReview.scopeRevision ))); assert.equal(revisions.size, DECISION_COUNT); - assert.ok([...revisions].every((revision) => /^scope:[a-f0-9]{64}$/.test(revision))); + assert.ok([...revisions].every((revision) => /^filegrc:applicability-scope:v1:sha256:[a-f0-9]{64}$/.test(revision))); }); test("previews 42 applicability decisions within the batch performance budget", async (context) => { @@ -109,7 +109,7 @@ test("browser preview and apply use the same 42-decision batch", async (context) "requirement-01.json" ), "utf8")); assert.equal(first.applicability, "applicable"); - assert.match(first.applicabilityReview.scopeRevision, /^scope:[a-f0-9]{64}$/); + assert.match(first.applicabilityReview.scopeRevision, /^filegrc:applicability-scope:v1:sha256:[a-f0-9]{64}$/); }); test("rejects apply when structured workspace scope changes after preview", async (context) => { @@ -153,7 +153,7 @@ test("binds dirty structured scope to an exact uncommitted fingerprint", async ( basis: preview.basis, confirmed: true }); - assert.match(applied.changes.update[0].applicabilityReview.scopeRevision, /^scope:[a-f0-9]{64}$/); + assert.match(applied.changes.update[0].applicabilityReview.scopeRevision, /^filegrc:applicability-scope:v1:sha256:[a-f0-9]{64}$/); }); test("rejects a dirty applicability change made after preview", async (context) => { diff --git a/packages/filegrc/test/collection-review.test.js b/packages/filegrc/test/collection-review.test.js index 893e756..cfa8394 100644 --- a/packages/filegrc/test/collection-review.test.js +++ b/packages/filegrc/test/collection-review.test.js @@ -126,6 +126,55 @@ test("offers Control collection oversight only to people outside Control and Obl assert.deepEqual(assessment.reviewerConflictIds, ["person-example"]); }); +test("a later reviewer departure does not undo an unchanged Control collection review", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-control-reviewer-departure-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const reviewer = { + id: "person-oversight-reviewer-example", + type: "person", + title: "Oversight reviewer", + status: "active", + affiliation: "internal" + }; + loaded.resources.push(reviewer); + const initial = assessCollectionReview(loaded, "control", { programId: "program-example" }); + assert.ok(initial.eligibleReviewerIds.includes(reviewer.id)); + const review = { + id: "collection-review-control-departure-example", + type: "collection-review", + title: "Control oversight review", + status: "active", + resourceType: "control", + scopeResourceIds: ["program-example"], + decision: "complete", + rationale: "Reviewed the implemented Controls.", + reviewedByIds: [reviewer.id], + reviewedOn: "2026-09-12", + coverage: { kind: "as-of", on: "2026-09-12" }, + knowledgeCutoffAt: "2026-09-12T12:00:00.000Z", + populationResourceIds: initial.records.map(({ id }) => id), + collectionRevision: initial.collectionRevision, + scopeRevision: "scope-example" + }; + loaded.resources.push(review); + loaded.entries.push({ record: review, source: JSON.stringify(review) }); + assert.equal(assessCollectionReview(loaded, "control", { programId: "program-example" }).complete, true); + reviewer.startDate = "2026-09-13"; + assert.equal(assessCollectionReview(loaded, "control", { programId: "program-example" }).complete, false); + reviewer.startDate = "2026-09-01"; + reviewer.status = "inactive"; + reviewer.statusTransition = { + changedByIds: ["person-example"], + changedOn: "2026-09-13", + reason: "Reviewer left after the review." + }; + const afterDeparture = assessCollectionReview(loaded, "control", { programId: "program-example" }); + assert.equal(afterDeparture.eligibleReviewerIds.includes(reviewer.id), false); + assert.equal(afterDeparture.complete, true); +}); + test("blocks an inventory collection review until every record proposal is complete", async (context) => { const root = await mkdtemp(join(tmpdir(), "filegrc-collection-proposal-gate-")); context.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/filegrc/test/guided-workflows.test.js b/packages/filegrc/test/guided-workflows.test.js index 6b2fc10..434bdba 100644 --- a/packages/filegrc/test/guided-workflows.test.js +++ b/packages/filegrc/test/guided-workflows.test.js @@ -1054,7 +1054,7 @@ test("records reviewed applicability decisions as one atomic batch", async (cont "utf8" )); assert.equal(requirement.applicability, "applicable"); - assert.match(requirement.applicabilityReview.scopeRevision, /^scope:[a-f0-9]{64}$/); + assert.match(requirement.applicabilityReview.scopeRevision, /^filegrc:applicability-scope:v1:sha256:[a-f0-9]{64}$/); await writeJson(join(root, "data", "requirements", "requirement-access.json"), { ...requirement, description: "Access now includes the production support boundary." diff --git a/packages/filegrc/test/model-migration.test.js b/packages/filegrc/test/model-migration.test.js index 2d307ce..91d7143 100644 --- a/packages/filegrc/test/model-migration.test.js +++ b/packages/filegrc/test/model-migration.test.js @@ -292,7 +292,7 @@ test("previews and atomically migrates every model v1 compatibility field", asyn assert.equal(records.get("control-example").frequency, undefined); assert.deepEqual(records.get("document-example").trainingIds, ["training-example"]); assert.equal(records.get("policy-example").reviewCadence, undefined); - assert.match(records.get("policy-example").approvedContentRevisions["policies/policy-example.md"], /^[a-f0-9]{64}$/); + assert.match(records.get("policy-example").approvedContentRevisions["policies/policy-example.md"], /^filegrc:content:v1:sha256:[a-f0-9]{64}$/); assert.ok([...records.values()].some((record) => ( record.type === "obligation" && record.templateResourceId === "policy-example" diff --git a/packages/filegrc/test/obligations.test.js b/packages/filegrc/test/obligations.test.js index 1609d49..2e71418 100644 --- a/packages/filegrc/test/obligations.test.js +++ b/packages/filegrc/test/obligations.test.js @@ -1179,7 +1179,7 @@ test("scaffolds a complete headless Work Queue mutation with its safe write revi assert.deepEqual(scaffold.record.reviewerIds, ["person-owner"]); assert.equal(scaffold.record.completedOn, "2026-03-20"); assert.equal(scaffold.scaffold.dueWindowEnd, "2026-03-31"); - assert.match(scaffold.revision, /^[a-f0-9]{64}$/); + assert.match(scaffold.revision, /^filegrc:content:v1:sha256:[a-f0-9]{64}$/); assert.deepEqual(scaffold.record.evidenceIds, []); await createResource(root, { diff --git a/packages/filegrc/test/policy-library.test.js b/packages/filegrc/test/policy-library.test.js index e09a4f1..9819664 100644 --- a/packages/filegrc/test/policy-library.test.js +++ b/packages/filegrc/test/policy-library.test.js @@ -398,8 +398,8 @@ test("offers scan-friendly requirement lists as a reviewable upgrade", async (co policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = policyPath(root); @@ -433,8 +433,8 @@ test("offers the readable policy structure as a reviewable upgrade", async (cont policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = policyPath(root); @@ -468,8 +468,8 @@ test("offers the glossary-formatted Definitions section as a reviewable upgrade" policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = policyPath(root); @@ -503,8 +503,8 @@ test("offers the standalone Policy as an explicit upgrade to the immediately pri policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = policyPath(root); @@ -537,8 +537,8 @@ test("offers standalone governed Documents as a reviewable upgrade and changes n policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const organizationSchedulePath = join(root, "data", "retention-schedule-items", "retention-schedule-item-filegrc-records.json"); @@ -604,8 +604,8 @@ test("leaves customized, approved, and active governed Documents untouched", asy policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const update = PRIOR_DOCUMENT_BOUNDARY_REPLACEMENTS["document-soc2-system-description"]; @@ -639,8 +639,8 @@ test("preserves customized retention Markdown while proposing additive Control l policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const markdownPath = join(root, "data", "documents", "document-data-retention-schedule.md"); @@ -730,8 +730,8 @@ test("proposes only the missing review schedules in an existing v4 starter works policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); @@ -801,8 +801,8 @@ test("reviews and explicitly accepts the vendor-neutral Training correction", as policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = trainingPath(root); @@ -852,8 +852,8 @@ test("recognizes prior vendor-neutral Training revisions before the ciphertext c policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = trainingPath(root); @@ -883,8 +883,8 @@ test("offers generic workforce Training as an explicit upgrade to the immediatel policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = trainingPath(root); @@ -918,8 +918,8 @@ test("leaves customized and active Training content untouched", async (context) policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = trainingPath(root); @@ -964,8 +964,8 @@ test("reviews the time-bound MFA exception correction without changing the Contr policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = join(root, "data", "controls", "control-strong-authentication.json"); @@ -1001,8 +1001,8 @@ test("reviews generic Control activities without changing planned Controls autom policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const priorActivities = { @@ -1061,8 +1061,8 @@ test("recognizes prior Training when the organization name is also training voca policyOwnerEmail: "owner@example.test", securityContactEmail: "security@example.test", timezone: "UTC", - filegrcVersion: "1.2.3", - install: false, + filegrcPackage: fileURLToPath(new URL("../", import.meta.url)), + install: true, effectiveDate: "2026-01-01" }); const path = trainingPath(root); diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js index c07489e..cf5a1dc 100644 --- a/packages/filegrc/test/render.test.js +++ b/packages/filegrc/test/render.test.js @@ -433,7 +433,7 @@ test("serves state and browser assets", async (context) => { const revisionsResponse = await fetch(`${result.url}/api/review-revisions?id=person-owner`); assert.equal(revisionsResponse.status, 200); const revisions = await revisionsResponse.json(); - assert.match(revisions.revisions["person-owner"], /^[a-f0-9]{64}$/); + assert.match(revisions.revisions["person-owner"], /^filegrc:content:v1:sha256:[a-f0-9]{64}$/); const appResponse = await fetch(`${result.url}/filegrc-app.js`); assert.equal(appResponse.status, 200); assert.equal(appResponse.headers.get("x-frame-options"), "DENY"); diff --git a/packages/filegrc/test/revision-upgrade.test.js b/packages/filegrc/test/revision-upgrade.test.js new file mode 100644 index 0000000..94e47ad --- /dev/null +++ b/packages/filegrc/test/revision-upgrade.test.js @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "../src/applicability-scope.js"; +import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "../src/collection-review-integrity.js"; +import { collectionRevision, collectionRevisionMatches, legacyCollectionRevision } from "../src/collection-revision.js"; +import { scopedCollectionRecords } from "../src/collection-scope.js"; +import { contentRevisionBindingsMatch } from "../src/program-lifecycle.js"; +import { reportingRouteRevision } from "../src/reporting-route-integrity.js"; +import { assessRequirementMappingReadiness } from "../src/requirement-mapping.js"; +import { resourceReviewRevisions, retentionReviewResourceIds } from "../src/retention.js"; +import { revisionsMatch } from "../src/revisions.js"; +import { currentCalendarDate } from "../src/time.js"; +import { validateWorkspace } from "../src/validate.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; + +test("reads legacy approval, activation, attestation, and applicability bindings without rewriting records", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-revision-upgrade-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + + let before = await loadWorkspace(root); + const programEntry = before.entries.find(({ record }) => record.type === "program"); + const reviewedRequirement = before.resources.find(({ type, id }) => ( + type === "requirement" + && programEntry.record.requirementApplicability.some(({ requirementId }) => requirementId === id) + )); + const currentScopeRevision = applicabilityScopeRevision( + reviewedRequirement, + programEntry.record, + before.resources, + before.model + ); + for (const review of programEntry.record.requirementApplicability || []) { + review.scopeRevision = `scope:${digest(currentScopeRevision)}`; + } + await writeFile(programEntry.path, `${JSON.stringify(programEntry.record, null, 2)}\n`); + execFileSync("git", ["init", "--initial-branch=main"], { cwd: root }); + execFileSync("git", ["config", "user.name", "FileGRC Test"], { cwd: root }); + execFileSync("git", ["config", "user.email", "filegrc@example.test"], { cwd: root }); + execFileSync("git", ["add", "data"], { cwd: root }); + execFileSync("git", ["commit", "-m", "Create legacy revision fixture"], { cwd: root }); + const validation = await validateWorkspace(root); + assert.equal(validation.ok, true, validation.diagnostics.map(({ message }) => message).join("\n")); + assert.equal(execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }), ""); + + const document = before.resources.find(({ type }) => type === "document"); + assert.ok(Object.values(document.approvedContentRevisions).every((value) => /^[a-f0-9]{64}$/.test(value))); + assert.equal(contentRevisionBindingsMatch(document.approvedContentRevisions, document.activatedContentRevisions), true); + + const program = before.resources.find(({ type }) => type === "program"); + const requirement = before.resources.find(({ type, id }) => ( + type === "requirement" && program.requirementApplicability.some(({ requirementId }) => requirementId === id) + )); + const review = program.requirementApplicability.find(({ requirementId }) => requirementId === requirement.id); + assert.equal(applicabilityReviewIsCurrent(review, requirement, program, before.resources, before.model), true); + assert.ok(review.scopeRevision.startsWith("scope:")); +}); + +test("accepts legacy collection, applicability, collection-review, and reporting-route revisions during upgrade", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-revision-compatibility-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const collection = collectionRevision(loaded, "person", { program }); + assert.equal(collectionRevisionMatches(loaded, "person", digest(collection), { program, currentRevision: collection }), true); + assert.equal(digest(collection), legacyCollectionRevision(loaded, "person", { program })); + assert.equal(digest(collectionRevision(loaded, "control", { program })), legacyCollectionRevision(loaded, "control", { program })); + + const control = loaded.resources.find(({ type }) => type === "control"); + const applicability = applicabilityScopeRevision(control, program, loaded.resources, loaded.model); + assert.equal(applicabilityReviewIsCurrent( + { scopeRevision: `scope:${digest(applicability)}` }, + control, + program, + loaded.resources, + loaded.model + ), true); + + const collectionReview = { + id: "collection-review-example", + type: "collection-review", + title: "Example review", + resourceType: "person", + decision: "complete", + rationale: "Reviewed.", + reviewedByIds: ["person-example"], + reviewedOn: "2026-09-23", + collectionRevision: collection, + scopeRevision: "0123456789abcdef" + }; + const reviewRevision = collectionReviewRevision(collectionReview); + assert.equal(digest(reviewRevision), createHash("sha256").update(JSON.stringify({ + ...collectionReview, + collectionRevision: digest(collection) + })).digest("hex")); + assert.equal(revisionsMatch("collection-review", digest(reviewRevision), reviewRevision), true); + assert.equal(revisionsMatch( + "collection-review", + collectionReviewRevision({ ...collectionReview, collectionRevision: digest(collection) }), + reviewRevision + ), true); + + const routeRevision = reportingRouteRevision({ + id: "route-example", + type: "reporting-route", + title: "Route", + purpose: "security-reporting", + priority: "primary", + channelKind: "email", + route: "security@example.test" + }); + assert.equal(revisionsMatch("reporting-route", digest(routeRevision), routeRevision), true); +}); + +test("scheme labels do not require a new collection review, but changed facts do", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-revision-stability-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const document = loaded.resources.find(({ type, approvedContentRevisions }) => ( + type === "document" && Object.keys(approvedContentRevisions || {}).length + )); + assert.ok(document, "fixture has a governed document"); + const reviewed = collectionRevision(loaded, "document", { program }); + document.approvedContentRevisions = Object.fromEntries(Object.entries(document.approvedContentRevisions).map(([path, value]) => [ + path, + `filegrc:content:v1:sha256:${digest(value)}` + ])); + assert.equal(collectionRevision(loaded, "document", { program }), reviewed); + document.title = `${document.title} revised`; + assert.notEqual(collectionRevision(loaded, "document", { program }), reviewed); + const exampleDigest = digest(Object.values(document.approvedContentRevisions)[0]); + document.title = exampleDigest; + const changedTitleRevision = collectionRevision(loaded, "document", { program }); + document.title = `filegrc:content:v1:sha256:${exampleDigest}`; + assert.notEqual(collectionRevision(loaded, "document", { program }), changedTitleRevision); +}); + +test("scheme-only source labels preserve retention and mapping review bindings", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-source-review-stability-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const documentEntry = loaded.entries.find(({ record }) => ( + record.type === "document" && Object.keys(record.approvedContentRevisions || {}).length + )); + const target = loaded.resources.find(({ type }) => type === "requirement"); + assert.ok(documentEntry && target); + const ids = [documentEntry.record.id, target.id]; + const original = await resourceReviewRevisions(loaded, ids); + const mapping = { + id: "requirement-mapping-revision-stability", + type: "requirement-mapping", + title: "Review binding stability", + status: "active", + sourceResourceIds: [documentEntry.record.id], + targetResourceIds: [target.id], + relationship: "intersects-with", + method: "semantic", + rationale: "Reviewed both source records.", + ownerIds: ["person-example"], + reviewedByIds: ["person-independent-approver-example"], + reviewedOn: "2026-09-23", + reviewedSourceRevisions: Object.fromEntries([...original].map(([id, revision]) => [id, digest(revision)])) + }; + loaded.resources.push(mapping); + documentEntry.record.approvedContentRevisions = Object.fromEntries(Object.entries(documentEntry.record.approvedContentRevisions).map(([path, value]) => [ + path, + `filegrc:content:v1:sha256:${digest(value)}` + ])); + documentEntry.source = `${JSON.stringify(documentEntry.record, null, 2)}\n`; + const relabeled = await resourceReviewRevisions(loaded, ids); + assert.deepEqual(relabeled, original); + assert.equal((await assessRequirementMappingReadiness(loaded)).find(({ id }) => id === `requirement-mapping-${mapping.id}`).status, "complete"); + documentEntry.record.title = `${documentEntry.record.title} updated`; + documentEntry.source = `${JSON.stringify(documentEntry.record, null, 2)}\n`; + assert.notEqual((await resourceReviewRevisions(loaded, [documentEntry.record.id])).get(documentEntry.record.id), original.get(documentEntry.record.id)); +}); + +test("committed legacy collection, occurrence, and retention bindings stay readable without another review", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-committed-legacy-revisions-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + let loaded = await loadWorkspace(root); + const rowEntry = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const sources = retentionReviewResourceIds(rowEntry.record, loaded); + rowEntry.record.reviewedSourceRevisions = Object.fromEntries( + [...await resourceReviewRevisions(loaded, sources)].map(([id, revision]) => [id, digest(revision)]) + ); + await writeFile(rowEntry.path, `${JSON.stringify(rowEntry.record, null, 2)}\n`); + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); + git("init", "--initial-branch=main"); + git("config", "user.name", "FileGRC Test"); + git("config", "user.email", "filegrc@example.test"); + git("add", "data"); + git("commit", "-m", "Record legacy source bindings"); + const scopeCommit = git("rev-parse", "HEAD"); + + loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const date = currentCalendarDate(loaded.workspace.timezone); + const reviewEntry = loaded.entries.find(({ record }) => record.type === "collection-review"); + const review = { + ...reviewEntry.record, + status: "active", + decision: "complete", + rationale: "Reviewed the scoped people.", + reviewedByIds: ["person-independent-approver-example"], + reviewedOn: date, + coverage: { kind: "as-of", on: date }, + knowledgeCutoffAt: new Date().toISOString(), + populationResourceIds: scopedCollectionRecords(loaded, "person", program).map(({ id }) => id), + collectionRevision: digest(collectionRevision(loaded, "person", { program })), + scopeRevision: scopeCommit + }; + await writeFile(reviewEntry.path, `${JSON.stringify(review, null, 2)}\n`); + git("add", "data"); + git("commit", "-m", "Record legacy collection review"); + const reviewCommit = git("rev-parse", "HEAD"); + + loaded = await loadWorkspace(root); + const committedReview = loaded.resources.find(({ id }) => id === review.id); + const snapshot = historicalCollectionReviewSnapshot( + root, + committedReview, + loaded.model, + loaded.workspace.timezone, + "person", + date, + null, + reviewEntry.relativePath, + reviewCommit + ); + assert.equal(snapshot?.reviewCommit, reviewCommit); + const occurrenceBinding = { + collectionReviewCommit: reviewCommit, + collectionReviewRevision: digest(collectionReviewRevision(committedReview)), + collectionRevision: committedReview.collectionRevision, + scopeRevision: scopeCommit + }; + assert.equal(revisionsMatch("collection-review", occurrenceBinding.collectionReviewRevision, collectionReviewRevision(committedReview)), true); + assert.equal(revisionsMatch("collection", occurrenceBinding.collectionRevision, collectionRevision(loaded, "person", { program })), true); + + const row = loaded.resources.find(({ id }) => id === rowEntry.record.id); + const currentSources = await resourceReviewRevisions(loaded, retentionReviewResourceIds(row, loaded)); + assert.ok(Object.entries(row.reviewedSourceRevisions).every(([id, revision]) => ( + revisionsMatch("content", revision, currentSources.get(id)) + ))); + const validation = await validateWorkspace(root); + assert.equal(validation.ok, true, validation.diagnostics.map(({ message }) => message).join("\n")); + assert.equal(git("status", "--porcelain"), ""); +}); + +function digest(value) { + return String(value).slice(-64); +} diff --git a/packages/filegrc/test/runtime-version.test.js b/packages/filegrc/test/runtime-version.test.js new file mode 100644 index 0000000..f9b9c7f --- /dev/null +++ b/packages/filegrc/test/runtime-version.test.js @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { runCli } from "../src/cli.js"; +import { createResource } from "../src/files.js"; +import { createFilegrcServer } from "../src/server.js"; +import { makeWorkspace } from "./helpers.js"; + +test("CLI, server, and direct writes reject an installed engine that differs from the lockfile", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-version-drift-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + await writeFile(join(root, "package-lock.json"), `${JSON.stringify({ + name: "version-drift-test", + lockfileVersion: 3, + packages: { + "": { dependencies: { filegrc: "^99.0.0" } }, + "node_modules/filegrc": { version: "99.0.0" } + } + }, null, 2)}\n`); + + const expected = /Installed filegrc .* does not match package-lock\.json \(99\.0\.0\).*npm ci/; + await assert.rejects(runCli(["validate", "--root", root]), expected); + for (const command of ["validate", "build", "serve"]) { + await assert.rejects(runCli([command, root]), expected); + await assert.rejects(runCli([command, root, "--root", process.cwd()]), expected); + } + assert.throws(() => createFilegrcServer(root), expected); + await assert.rejects(createResource(root, { id: "person-new", type: "person", title: "New person" }), expected); +}); + +test("package-managed workspaces reject missing and incomplete lockfiles", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-lockfile-required-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + await writeFile(join(root, "package.json"), `${JSON.stringify({ + name: "lockfile-required-test", + private: true, + dependencies: { filegrc: "^0.16.0" } + }, null, 2)}\n`); + await assert.rejects(runCli(["validate", "--root", root]), /package-lock\.json is missing/); + assert.throws(() => createFilegrcServer(root), /package-lock\.json is missing/); + await assert.rejects(createResource(root, { id: "person-new", type: "person", title: "New person" }), /package-lock\.json is missing/); + await writeFile(join(root, "package-lock.json"), `${JSON.stringify({ + name: "lockfile-required-test", + lockfileVersion: 3, + packages: { "": { dependencies: { filegrc: "^0.16.0" } } } + }, null, 2)}\n`); + await assert.rejects(runCli(["validate", "--root", root]), /no filegrc version/); + assert.throws(() => createFilegrcServer(root), /no filegrc version/); + await assert.rejects(createResource(root, { id: "person-new", type: "person", title: "New person" }), /no filegrc version/); +}); diff --git a/packages/filegrc/test/workspace.test.js b/packages/filegrc/test/workspace.test.js index 9eb9712..0208b1d 100644 --- a/packages/filegrc/test/workspace.test.js +++ b/packages/filegrc/test/workspace.test.js @@ -735,7 +735,7 @@ test("binds approvals to exact Markdown revisions and requires reapproval after approvedOn: "2026-08-02", effectiveOn: "2026-08-02" }); - assert.match(approved.record.approvedContentRevisions["policies/policy-bound.md"], /^[a-f0-9]{64}$/); + assert.match(approved.record.approvedContentRevisions["policies/policy-bound.md"], /^filegrc:content:v1:sha256:[a-f0-9]{64}$/); await assert.rejects( updateContent(root, "policies/policy-bound.md", "# Bound Policy\n\nUnapproved edit."), /Approved content no longer matches/ @@ -810,7 +810,7 @@ test("binds active training to its effective Markdown revision", async (context) approvedByIds: ["person-approver"], approvedOn: "2026-08-03" }); - assert.match(active.record.effectiveContentRevisions["training/training-security.md"], /^[a-f0-9]{64}$/); + assert.match(active.record.effectiveContentRevisions["training/training-security.md"], /^filegrc:content:v1:sha256:[a-f0-9]{64}$/); await assert.rejects( updateContent(root, "training/training-security.md", "# Security training\n\nUnapproved replacement."), /Approved content no longer matches/