diff --git a/package-lock.json b/package-lock.json index a94ec8b..0ead1d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.16.0", + "version": "0.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.16.0", + "version": "0.16.1", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index e1d979b..bbf5511 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.16.0", + "version": "0.16.1", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index aa8d4b9..4927e2e 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.16.0", + "version": "0.16.1", "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 81f1f51..4ada2cd 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.16.0", + version: "0.16.1", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.16.0", + version: "0.16.1", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index cce0844..b116adc 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.16.0", + "version": "0.16.1", "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 dd1660b..0875c8f 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.16.0", + "version": "0.16.1", "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 b6c9f1b..4f2f51a 100644 --- a/packages/filegrc/src/applicability-scope.js +++ b/packages/filegrc/src/applicability-scope.js @@ -207,7 +207,7 @@ function canonicalFieldValue(model, value, field, name, revisionMap = false) { : value; } return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(name)) - ? canonicalCalculatedRevision(value) + ? canonicalCalculatedRevision(value, name) : value; } diff --git a/packages/filegrc/src/collection-review-integrity.js b/packages/filegrc/src/collection-review-integrity.js index 7751c8c..789cab1 100644 --- a/packages/filegrc/src/collection-review-integrity.js +++ b/packages/filegrc/src/collection-review-integrity.js @@ -1,5 +1,5 @@ import { loadModel } from "../model/index.js"; -import { collectionRevision } from "./collection-revision.js"; +import { collectionRevision, collectionRevisionMatches } from "./collection-revision.js"; import { scopedCollectionRecords } from "./collection-scope.js"; import { getDataFilesAtRevision, getFileAtRevision, getRecordIdentityHistory, hasGitRevision } from "./git.js"; import { currentCalendarDate, isRfc3339Timestamp } from "./time.js"; @@ -70,7 +70,11 @@ export function historicalCollectionReviewSnapshot(root, record, model, timezone authoritativeSourceId: record.authoritativeComponentId }); if ( - !revisionsMatch("collection", record.collectionRevision, currentRevision) + !collectionRevisionMatches(snapshot, resourceType, record.collectionRevision, { + program, + authoritativeSourceId: record.authoritativeComponentId, + currentRevision + }) || JSON.stringify([...(record.populationResourceIds || [])].sort()) !== JSON.stringify(collectionIds) ) return null; const selectedIds = selector diff --git a/packages/filegrc/src/collection-revision.js b/packages/filegrc/src/collection-revision.js index 0839b3e..a564c3f 100644 --- a/packages/filegrc/src/collection-revision.js +++ b/packages/filegrc/src/collection-revision.js @@ -12,25 +12,37 @@ 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); + return calculateCollectionRevision(loaded, resourceType, options, false, options.scopeHashInput || "legacy", options.scopeFactsInput || "legacy"); } export function legacyCollectionRevision(loaded, resourceType, options = {}) { - return calculateCollectionRevision(loaded, resourceType, options, true); + return calculateCollectionRevision(loaded, resourceType, options, true, options.scopeHashInput || "legacy", options.scopeFactsInput || "legacy"); } export function collectionRevisionMatches(loaded, resourceType, storedRevision, options = {}) { if (!storedRevision) return false; const currentRevision = options.currentRevision || 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 revisionsMatch("collection", storedRevision, currentRevision) - || (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval")) - && revisionsMatch("collection", storedRevision, legacyCollectionRevision(loaded, resourceType, options)); + // Accept both the pre-0.16 scope: input and the digest-only input used by + // 0.16.0, as well as the older collection basis from 0.9.1. + if (revisionsMatch("collection", storedRevision, currentRevision)) return true; + const allowOlderBasis = resourceType !== "retention-schedule-item" + || !modelSupports(loaded.model, "retention-schedule-approval"); + for (const olderBasis of [false, true]) { + if (olderBasis && !allowOlderBasis) continue; + for (const scopeHashInput of ["legacy", "digest"]) { + for (const scopeFactsInput of ["legacy", "source"]) { + if (!olderBasis && scopeHashInput === "legacy" && scopeFactsInput === "legacy") continue; + if (revisionsMatch("collection", storedRevision, calculateCollectionRevision( + loaded, resourceType, options, olderBasis, scopeHashInput, scopeFactsInput + ))) return true; + } + } + } + return false; } -function calculateCollectionRevision(loaded, resourceType, options, legacy) { +function calculateCollectionRevision(loaded, resourceType, options, legacy, scopeHashInput = "legacy", scopeFactsInput = "legacy") { const workspaceWideRetentionReview = resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") && !legacy; @@ -55,7 +67,7 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy) { .map(({ record, value, includeContent }) => ({ id: record.id, revision: createHash("sha256") - .update(JSON.stringify(canonicalRecordValue(loaded.model, record.type, value))) + .update(JSON.stringify(canonicalRecordValue(loaded.model, record.type, value, scopeHashInput))) .digest("hex"), contentRevisions: (legacy || includeContent ? markdownEntries(loaded.model, record) : []).flatMap(({ path }) => { try { @@ -68,35 +80,46 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy) { }) })) .sort((left, right) => left.id.localeCompare(right.id)); - const workspaceScope = collectionScopeRevisionFacts(loaded, resourceType, program); + const scopeFacts = collectionScopeRevisionFacts(loaded, resourceType, program); + const workspaceScope = scopeFactsInput === "source" ? scopeFacts : canonicalScopeFacts(scopeFacts); const source = JSON.stringify({ resourceType, records, workspaceScope }); return legacy ? createHash("sha256").update(source).digest("hex") : calculateRevision("collection", source); } -function canonicalRecordValue(model, resourceType, value) { +function canonicalScopeFacts(value, field = null) { + if (Array.isArray(value)) return value.map((item) => canonicalScopeFacts(item)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, canonicalScopeFacts(item, key)])); + } + return field === "scopeRevision" && typeof value === "string" + ? canonicalCalculatedRevision(value, field) + : value; +} + +function canonicalRecordValue(model, resourceType, value, scopeHashInput) { const fields = { ...model.commonFields, ...model.resources[resourceType]?.fields }; - return canonicalObject(model, value, fields); + return canonicalObject(model, value, fields, false, scopeHashInput); } -function canonicalObject(model, value, fields = {}, revisionMap = false) { +function canonicalObject(model, value, fields = {}, revisionMap = false, scopeHashInput = "legacy") { return Object.fromEntries(Object.keys(value).sort().map((name) => [ name, - canonicalFieldValue(model, value[name], fields[name], name, revisionMap) + canonicalFieldValue(model, value[name], fields[name], name, revisionMap, scopeHashInput) ])); } -function canonicalFieldValue(model, value, field, name, revisionMap = false) { +function canonicalFieldValue(model, value, field, name, revisionMap = false, scopeHashInput = "legacy") { if (Array.isArray(value)) { const objectType = field?.itemObjectType; const itemFields = objectType ? model.objectTypes?.[objectType]?.properties : undefined; const items = value.map((item) => ( item && typeof item === "object" && !Array.isArray(item) - ? canonicalObject(model, item, itemFields) + ? canonicalObject(model, item, itemFields, false, scopeHashInput) : item )); return field?.type === "array" @@ -105,10 +128,10 @@ function canonicalFieldValue(model, value, field, name, revisionMap = false) { } if (value && typeof value === "object") { const objectType = field?.objectType; - return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined, CALCULATED_REVISION_MAP_FIELDS.has(name)); + return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined, CALCULATED_REVISION_MAP_FIELDS.has(name), scopeHashInput); } return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(name)) - ? canonicalCalculatedRevision(value) + ? canonicalCalculatedRevision(value, name, scopeHashInput) : value; } diff --git a/packages/filegrc/src/program-amendment.js b/packages/filegrc/src/program-amendment.js index d844ed5..85e29d2 100644 --- a/packages/filegrc/src/program-amendment.js +++ b/packages/filegrc/src/program-amendment.js @@ -1,5 +1,5 @@ import { findResourceReferences } from "./agent.js"; -import { resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js"; +import { resourceReviewRevision, resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js"; import { loadWorkspace } from "./workspace.js"; const SOURCE_TYPES = new Set(["policy", "document", "framework", "requirement", "commitment"]); @@ -114,7 +114,8 @@ export async function planProgramAmendment(input, options = {}) { && reviewBindingsDiffer( [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])], record.reviewedSourceRevisions, - currentReviewRevisions + currentReviewRevisions, + loaded ) )); if (staleMappings.length) { @@ -127,7 +128,7 @@ export async function planProgramAmendment(input, options = {}) { const retention = related.filter((record) => ( record.type === "retention-schedule-item" && record.status === "active" - && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions) + && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions, loaded) )); if (retention.length) { missing.push({ @@ -160,10 +161,10 @@ export async function planProgramAmendment(input, options = {}) { }; } -function reviewBindingsDiffer(expectedIds, reviewed = {}, current) { +function reviewBindingsDiffer(expectedIds, reviewed = {}, current, loaded) { const expected = new Set(expectedIds); if (Object.keys(reviewed).length !== expected.size) return true; - return [...expected].some((id) => !current.get(id) || reviewed[id] !== current.get(id)); + return [...expected].some((id) => !resourceReviewRevisionMatches(loaded, current, id, reviewed[id])); } export async function assessProgramAmendmentReadiness(loaded) { diff --git a/packages/filegrc/src/requirement-mapping.js b/packages/filegrc/src/requirement-mapping.js index b5e60e5..488bab5 100644 --- a/packages/filegrc/src/requirement-mapping.js +++ b/packages/filegrc/src/requirement-mapping.js @@ -1,5 +1,4 @@ -import { resourceReviewRevisions } from "./retention.js"; -import { revisionsMatch } from "./revisions.js"; +import { resourceReviewRevisionMatches, resourceReviewRevisions } from "./retention.js"; export async function assessRequirementMappingReadiness(loaded) { if (!loaded.model.resources["requirement-mapping"]) return []; @@ -18,7 +17,7 @@ export async function assessRequirementMappingReadiness(loaded) { ...(mapping.targetResourceIds || []) ])]; const staleIds = mappedIds.filter((id) => ( - !revisions.get(id) || !revisionsMatch("content", mapping.reviewedSourceRevisions?.[id], revisions.get(id)) + !resourceReviewRevisionMatches(loaded, revisions, id, mapping.reviewedSourceRevisions?.[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.js b/packages/filegrc/src/retention.js index 8c5efa0..6afaa99 100644 --- a/packages/filegrc/src/retention.js +++ b/packages/filegrc/src/retention.js @@ -154,7 +154,7 @@ export async function resourceReviewRevision(loaded, resourceId) { return (await resourceReviewRevisions(loaded, [resourceId])).get(resourceId) || null; } -export async function resourceReviewRevisions(loaded, ids) { +export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "legacy") { const wanted = new Set(ids); const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry])); const revisions = new Map(); @@ -164,7 +164,7 @@ export async function resourceReviewRevisions(loaded, ids) { const entry = entries.get(id); if (!entry || reviewing.has(id)) return null; reviewing.add(id); - const parts = [reviewSource(loaded, entry)]; + const parts = [reviewSource(loaded, entry, scopeHashInput)]; for (const markdown of markdownEntries(loaded.model, entry.record)) { try { parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8")); @@ -187,7 +187,7 @@ export async function resourceReviewRevisions(loaded, ids) { return new Map([...revisions].filter(([id]) => wanted.has(id))); } -export function resourceReviewRevisionsSync(loaded, ids) { +export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy") { const wanted = new Set(ids); const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry])); const revisions = new Map(); @@ -197,7 +197,7 @@ export function resourceReviewRevisionsSync(loaded, ids) { const entry = entries.get(id); if (!entry || reviewing.has(id)) return null; reviewing.add(id); - const parts = [reviewSource(loaded, entry)]; + const parts = [reviewSource(loaded, entry, scopeHashInput)]; for (const markdown of markdownEntries(loaded.model, entry.record)) { try { parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8")); @@ -218,6 +218,16 @@ export function resourceReviewRevisionsSync(loaded, ids) { return new Map([...revisions].filter(([id]) => wanted.has(id))); } +export function resourceReviewRevisionMatches(loaded, revisions, id, stored) { + const current = revisions.get(id); + if (!current || !revisionDigest("content", stored)) return false; + if (revisionsMatch("content", stored, current)) return true; + // Version 0.16.0 used a bare digest as the hash input for scopeRevision. + // Read that binding without changing the legacy scope: basis restored here. + const compatible = resourceReviewRevisionsSync(loaded, [id], "digest").get(id); + return revisionsMatch("content", stored, compatible); +} + export function retentionUses(loaded, program) { const systemIds = new Set(program.systemIds || []); const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id)); @@ -239,7 +249,7 @@ export function retentionUses(loaded, program) { return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()]; } -function reviewSource(loaded, entry) { +function reviewSource(loaded, entry, scopeHashInput = "legacy") { if ( modelSupports(loaded.model, "retention-schedule-approval") && entry.record.type === "document" @@ -261,7 +271,7 @@ function reviewSource(loaded, entry) { ]) delete approved[field]; return JSON.stringify(approved); } - return canonicalCalculatedRevisionJson(entry.source); + return canonicalCalculatedRevisionJson(entry.source, scopeHashInput); } export function nearDuplicateInformationTypes(records) { @@ -327,7 +337,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) && revisionsMatch("content", rule.reviewedSourceRevisions?.[id], revisions.get(id)) + resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id]) )); } diff --git a/packages/filegrc/src/revisions.js b/packages/filegrc/src/revisions.js index d584562..01a903f 100644 --- a/packages/filegrc/src/revisions.js +++ b/packages/filegrc/src/revisions.js @@ -59,8 +59,17 @@ export function calculatedRevisionDiagnostic(kind, stored, current) { }; } -export function canonicalCalculatedRevision(value) { +export function canonicalCalculatedRevision(value, field = null, scopeHashInput = "legacy") { const text = String(value || ""); + // Legacy applicability reviews stored their scope digest with a `scope:` + // label. Keep that exact hash input when a newer scheme labels the same + // decision, including when the review is embedded in another source record. + if (field === "scopeRevision" && scopeHashInput === "legacy") { + const scopeDigest = revisionDigest("applicability-scope", text); + if (scopeDigest && (text.startsWith("scope:") || text.startsWith(PREFIXES["applicability-scope"]))) { + return `scope:${scopeDigest}`; + } + } // 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; @@ -71,22 +80,22 @@ export function canonicalCalculatedRevision(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) { +export function canonicalCalculatedRevisionJson(source, scopeHashInput = "legacy") { const fieldNames = [...CALCULATED_REVISION_FIELDS].join("|"); const mapNames = [...CALCULATED_REVISION_MAP_FIELDS].join("|"); const stringValue = /("(?:\\.|[^"\\])*")(\s*:\s*)("(?:\\.|[^"\\])*")/g; - const canonicalValue = (quoted) => { + const canonicalValue = (quoted, field) => { const value = JSON.parse(quoted); - const canonical = canonicalCalculatedRevision(value); + const canonical = canonicalCalculatedRevision(value, field, scopeHashInput); return canonical === value ? quoted : JSON.stringify(canonical); }; return source .replace(new RegExp(`("(?:${fieldNames})")(\\s*:\\s*)("(?:\\\\.|[^"\\\\])*")`, "g"), - (_match, key, separator, value) => `${key}${separator}${canonicalValue(value)}`) + (_match, key, separator, value) => `${key}${separator}${canonicalValue(value, JSON.parse(key))}`) .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)}` + `${name}${separator}${canonicalValue(value, JSON.parse(name))}` ))}${closing}` )); } diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index dc5d0ec..8451304 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -18,7 +18,7 @@ import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js"; -import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js"; +import { resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js"; import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js"; import { obligationIsEnabled } from "./program-lifecycle.js"; import { currentPartyPeople, partyPeople } from "./parties.js"; @@ -156,7 +156,7 @@ async function validateWorkspaceUnmeasured(input) { validateObligationOccurrence(record, loaded.model, loaded.resources, loaded.entries, loaded.root, loaded.workspace.timezone, byId, asOf, displayPath, diagnostics); } if (record.type === "retention-schedule-item") validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisions, displayPath, diagnostics); - if (record.type === "requirement-mapping") validateRequirementMapping(record, currentReviewRevisions, displayPath, diagnostics); + if (record.type === "requirement-mapping") validateRequirementMapping(record, loaded, currentReviewRevisions, displayPath, diagnostics); if (record.type === "action-item") { validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics); } @@ -1998,7 +1998,7 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi const sources = retentionReviewResourceIds(record, loaded); const revisions = record.reviewedSourceRevisions || {}; const missing = sources.filter((id) => ( - !currentReviewRevisions.get(id) || !revisionsMatch("content", revisions[id], currentReviewRevisions.get(id)) + !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id]) )).concat(Object.keys(revisions).filter((id) => !sources.includes(id))); if (missing.length) { diagnostics.push(error( @@ -2009,7 +2009,7 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi } } -function validateRequirementMapping(record, currentReviewRevisions, path, diagnostics) { +function validateRequirementMapping(record, loaded, currentReviewRevisions, path, diagnostics) { const overlap = (record.sourceResourceIds || []).filter((id) => (record.targetResourceIds || []).includes(id)); if (overlap.length) { diagnostics.push(error( @@ -2022,7 +2022,7 @@ 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) || !revisionsMatch("content", revisions[id], currentReviewRevisions.get(id)) + !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id]) )).concat(Object.keys(revisions).filter((id) => !mappedIds.includes(id))); if (missing.length) { diagnostics.push(error( diff --git a/packages/filegrc/src/workflow-history-integrity.js b/packages/filegrc/src/workflow-history-integrity.js index fed92cc..02d2e89 100644 --- a/packages/filegrc/src/workflow-history-integrity.js +++ b/packages/filegrc/src/workflow-history-integrity.js @@ -836,7 +836,7 @@ 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) + ? canonicalCalculatedRevision(value, field) : value; } return Object.fromEntries(Object.keys(value).sort().map((key) => [ diff --git a/packages/filegrc/test/revision-upgrade.test.js b/packages/filegrc/test/revision-upgrade.test.js index 94e47ad..8cb889d 100644 --- a/packages/filegrc/test/revision-upgrade.test.js +++ b/packages/filegrc/test/revision-upgrade.test.js @@ -12,8 +12,8 @@ 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 { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "../src/retention.js"; +import { canonicalCalculatedRevisionJson, revisionsMatch } from "../src/revisions.js"; import { currentCalendarDate } from "../src/time.js"; import { validateWorkspace } from "../src/validate.js"; import { loadWorkspace } from "../src/workspace.js"; @@ -185,6 +185,141 @@ test("scheme-only source labels preserve retention and mapping review bindings", assert.notEqual((await resourceReviewRevisions(loaded, [documentEntry.record.id])).get(documentEntry.record.id), original.get(documentEntry.record.id)); }); +test("embedded applicability labels preserve legacy source and collection revisions", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-applicability-review-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 entry = loaded.entries.find(({ record }) => record.type === "control"); + const digestValue = digest(applicabilityScopeRevision(entry.record, program, loaded.resources, loaded.model)); + entry.record.applicabilityReview = { scopeRevision: `scope:${digestValue}` }; + entry.source = `${JSON.stringify(entry.record, null, 2)}\n`; + assert.equal(applicabilityReviewIsCurrent(entry.record.applicabilityReview, entry.record, program, loaded.resources, loaded.model), true); + + const target = loaded.resources.find(({ type }) => type === "requirement"); + const reviewedIds = [entry.record.id, target.id]; + const reviewedRevisions = await resourceReviewRevisions(loaded, reviewedIds); + const mapping = { + id: "requirement-mapping-applicability-review", + type: "requirement-mapping", + title: "Applicability review mapping", + status: "active", + sourceResourceIds: [entry.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(reviewedRevisions) + }; + loaded.resources.push(mapping); + + const schedule = loaded.resources.find(({ type }) => type === "document"); + schedule.documentKind = "schedule"; + const rule = { + id: "retention-schedule-item-applicability-review", + type: "retention-schedule-item", + title: "Applicability review retention", + status: "active", + description: "Retain the reviewed control record.", + informationTypeIds: [loaded.resources.find(({ type }) => type === "information-type").id], + scopeResourceIds: [program.id], + scheduleDocumentId: schedule.id, + sourceResourceIds: [entry.record.id], + ownerIds: ["person-example"], + cutoff: { basis: "creation" }, + retentionPeriod: { basis: "fixed", amount: 1, unit: "year" }, + dispositionAction: "delete", + dispositionInstructions: "Delete after the approved period." + }; + const retentionIds = retentionReviewResourceIds(rule, loaded); + const retentionRevisions = await resourceReviewRevisions(loaded, retentionIds); + rule.reviewedSourceRevisions = Object.fromEntries(retentionRevisions); + const version016Revisions = await resourceReviewRevisions(loaded, retentionIds, "digest"); + const version016Rule = { ...rule, reviewedSourceRevisions: Object.fromEntries(version016Revisions) }; + const version016Mapping = { + ...mapping, + id: "requirement-mapping-version-016-review", + reviewedSourceRevisions: Object.fromEntries(await resourceReviewRevisions(loaded, reviewedIds, "digest")) + }; + loaded.resources.push(version016Mapping); + const ruleCurrent = async (candidate) => retentionRuleIsCurrent( + candidate, + await resourceReviewRevisions(loaded, retentionIds), + new Map(loaded.resources.map((record) => [record.id, record])), + loaded + ); + + const legacySourceRevision = (await resourceReviewRevisions(loaded, [entry.record.id])).get(entry.record.id); + const legacyCollectionRevision = collectionRevision(loaded, "control", { program }); + const version016SourceRevision = version016Revisions.get(entry.record.id); + const version016CollectionRevision = collectionRevision(loaded, "control", { program, scopeHashInput: "digest" }); + assert.notEqual(version016SourceRevision, legacySourceRevision); + assert.notEqual(version016CollectionRevision, legacyCollectionRevision); + assert.equal(canonicalCalculatedRevisionJson(entry.source), entry.source); + assert.equal(canonicalCalculatedRevisionJson(entry.source, "digest"), entry.source.replace(`scope:${digestValue}`, digestValue)); + assert.equal((await assessRequirementMappingReadiness(loaded)).find(({ id }) => id === `requirement-mapping-${mapping.id}`).status, "complete"); + assert.equal((await assessRequirementMappingReadiness(loaded)).find(({ id }) => id === `requirement-mapping-${version016Mapping.id}`).status, "complete"); + assert.equal(await ruleCurrent(rule), true); + assert.equal(await ruleCurrent(version016Rule), true); + assert.equal(collectionRevisionMatches(loaded, "control", version016CollectionRevision, { program }), true); + assert.equal(applicabilityReviewIsCurrent(entry.record.applicabilityReview, entry.record, program, loaded.resources, loaded.model), true); + + entry.record.applicabilityReview.scopeRevision = `filegrc:applicability-scope:v1:sha256:${digestValue}`; + entry.source = `${JSON.stringify(entry.record, null, 2)}\n`; + assert.equal((await resourceReviewRevisions(loaded, [entry.record.id])).get(entry.record.id), legacySourceRevision); + assert.equal(collectionRevision(loaded, "control", { program }), legacyCollectionRevision); + assert.equal((await assessRequirementMappingReadiness(loaded)).find(({ id }) => id === `requirement-mapping-${mapping.id}`).status, "complete"); + assert.equal((await assessRequirementMappingReadiness(loaded)).find(({ id }) => id === `requirement-mapping-${version016Mapping.id}`).status, "complete"); + assert.equal(await ruleCurrent(rule), true); + assert.equal(await ruleCurrent(version016Rule), true); + assert.equal(collectionRevisionMatches(loaded, "control", version016CollectionRevision, { program }), true); + + entry.record.applicabilityReview.scopeRevision = `filegrc:applicability-scope:v1:sha256:${"b".repeat(64)}`; + entry.source = `${JSON.stringify(entry.record, null, 2)}\n`; + assert.notEqual((await resourceReviewRevisions(loaded, [entry.record.id])).get(entry.record.id), legacySourceRevision); + assert.notEqual(collectionRevision(loaded, "control", { program }), legacyCollectionRevision); + assert.equal((await assessRequirementMappingReadiness(loaded)).find(({ id }) => id === `requirement-mapping-${mapping.id}`).status, "action"); + assert.equal((await assessRequirementMappingReadiness(loaded)).find(({ id }) => id === `requirement-mapping-${version016Mapping.id}`).status, "action"); + assert.equal(await ruleCurrent(rule), false); + assert.equal(await ruleCurrent(version016Rule), false); + assert.equal(collectionRevisionMatches(loaded, "control", version016CollectionRevision, { program }), false); + + entry.record.applicabilityReview.scopeRevision = `filegrc:applicability-scope:v1:sha256:${digestValue}`; + const selectedSystem = loaded.resources.find(({ type, id }) => type === "system" && (program.systemIds || []).includes(id)); + assert.ok(selectedSystem); + selectedSystem.boundary = `${selectedSystem.boundary || "Service boundary"} updated`; + assert.equal(applicabilityReviewIsCurrent(entry.record.applicabilityReview, entry.record, program, loaded.resources, loaded.model), false); +}); + +test("Program applicability labels preserve Control collection scope hashes", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-program-applicability-revision-")); + 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 review = program.requirementApplicability[0]; + const requirement = loaded.resources.find(({ id }) => id === review.requirementId); + const scopeDigest = digest(applicabilityScopeRevision(requirement, program, loaded.resources, loaded.model)); + review.scopeRevision = `scope:${scopeDigest}`; + const legacyCollection = collectionRevision(loaded, "control", { program }); + assert.equal(applicabilityReviewIsCurrent(review, requirement, program, loaded.resources, loaded.model), true); + + review.scopeRevision = `filegrc:applicability-scope:v1:sha256:${scopeDigest}`; + assert.equal(collectionRevision(loaded, "control", { program }), legacyCollection); + assert.equal(collectionRevisionMatches(loaded, "control", legacyCollection, { program }), true); + const version016Collection = collectionRevision(loaded, "control", { program, scopeHashInput: "digest", scopeFactsInput: "source" }); + assert.notEqual(version016Collection, legacyCollection); + assert.equal(collectionRevisionMatches(loaded, "control", version016Collection, { program }), true); + + review.scopeRevision = `filegrc:applicability-scope:v1:sha256:${"b".repeat(64)}`; + assert.equal(collectionRevisionMatches(loaded, "control", legacyCollection, { program }), false); + assert.equal(collectionRevisionMatches(loaded, "control", version016Collection, { program }), false); +}); + 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 }));