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
41 changes: 41 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -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
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.15.4",
"version": "0.16.0",
"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.15.4",
"version": "0.16.0",
"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.15.4",
version: "0.16.0",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.15.4",
version: "0.16.0",
dependencies: { filegrc: versionRange }
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/create-filegrc/template/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions packages/create-filegrc/template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.15.4",
"version": "0.16.0",
"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.15.4",
"version": "0.16.0",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
25 changes: 17 additions & 8 deletions packages/filegrc/src/applicability-scope.js
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
)
);
}

Expand All @@ -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)
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/filegrc/src/audit-preparation.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = [];
Expand Down
5 changes: 5 additions & 0 deletions packages/filegrc/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, {
Expand Down
18 changes: 14 additions & 4 deletions packages/filegrc/src/collection-review-integrity.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
21 changes: 13 additions & 8 deletions packages/filegrc/src/collection-review.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { modelSupports } from "../model/index.js";
import {
Expand All @@ -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 };

Expand Down Expand Up @@ -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);
Expand All @@ -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 || {}),
Expand All @@ -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.`
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
};
}

Expand All @@ -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()
};
}

Expand Down
Loading
Loading