Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc-monorepo",
"version": "0.16.0",
"version": "0.16.1",
"private": true,
"workspaces": [
"packages/filegrc",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions packages/create-filegrc/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/template/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc",
"version": "0.16.0",
"version": "0.16.1",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/src/applicability-scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
8 changes: 6 additions & 2 deletions packages/filegrc/src/collection-review-integrity.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down
59 changes: 41 additions & 18 deletions packages/filegrc/src/collection-revision.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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"
Expand All @@ -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;
}

Expand Down
11 changes: 6 additions & 5 deletions packages/filegrc/src/program-amendment.js
Original file line number Diff line number Diff line change
@@ -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"]);
Expand Down Expand Up @@ -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) {
Expand All @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions packages/filegrc/src/requirement-mapping.js
Original file line number Diff line number Diff line change
@@ -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 [];
Expand All @@ -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;
Expand Down
24 changes: 17 additions & 7 deletions packages/filegrc/src/retention.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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"));
Expand All @@ -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();
Expand All @@ -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"));
Expand All @@ -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));
Expand All @@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -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])
));
}

Expand Down
Loading
Loading