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.20.0",
"version": "0.20.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.20.0",
"version": "0.20.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 @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) {
async function writeMinimalLockfile(target, name, versionRange) {
const lock = {
name,
version: "0.20.0",
version: "0.20.1",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.20.0",
version: "0.20.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.20.0",
"version": "0.20.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.20.0",
"version": "0.20.1",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
28 changes: 28 additions & 0 deletions packages/filegrc/src/attestation-integrity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { modelSupports } from "../model/index.js";
import { markdownEntries } from "./resource-markdown.js";
import { revisionDigest } from "./revisions.js";

// Check historical bindings without comparing them with today's content. The
// filesystem validator supplies existing paths; planners can require only
// mandatory authored content because optional Markdown may not exist.
export function attestationBindingIssue(record, model, byId = new Map(), subjectPaths = null) {
if (record.type !== "attestation" || record.status !== "completed"
|| (record.attestationMethod !== "git-approval" && !modelSupports(model, "complete-operating-baseline"))) return null;
const filesystemPaths = subjectPaths !== null;
subjectPaths ||= new Map((record.subjectResourceIds || []).map((id) => [id,
byId.get(id) ? markdownEntries(model, byId.get(id)).map(({ path }) => path) : []]));
const expectedPaths = new Set([...subjectPaths.values()].flat());
if (!expectedPaths.size) return "A git-approval Attestation must reference authored Policy, Document, or Training content.";
const actualPaths = Object.keys(record.contentRevisions || {});
if (!actualPaths.length) return "A git-approval Attestation must bind at least one subject Markdown file.";
const unboundSubjects = [...subjectPaths].filter(([id, paths]) => {
const requiresContent = filesystemPaths || byId.get(id)
&& markdownEntries(model, byId.get(id)).some(({ required }) => required);
return requiresContent && paths.length && !paths.some((path) => actualPaths.includes(path));
}).map(([id]) => id);
if (unboundSubjects.length) return `Attestation contentRevisions must bind authored Markdown for every subject; missing ${unboundSubjects.join(", ")}.`;
if (actualPaths.some((path) => !expectedPaths.has(path) || !revisionDigest("content", record.contentRevisions[path]))) {
return "Attestation contentRevisions must contain valid SHA-256 hashes for subject Markdown paths and no unrelated paths.";
}
return null;
}
38 changes: 27 additions & 11 deletions packages/filegrc/src/obligations.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { attestationBindingIssue } from "./attestation-integrity.js";
import { preparationEvidenceIssues } from "./preparation-evidence.js";
import { assertHistoricalRecordValid } from "./validate.js";
import { markdownEntries, resourceDataPath } from "./resource-markdown.js";
Expand Down Expand Up @@ -1260,6 +1261,11 @@ function occurrenceProofDate(occurrence, asOf, timezone = "UTC") {

function completionPassesActivity(record, activity, model, byId, asOf, timezone = "UTC") {
const assessmentDate = asOf || currentCalendarDate(timezone);
if (record.type === "attestation") {
if (record.status !== "completed" || !record.completedOn || record.completedOn > assessmentDate
|| attestationBindingIssue(record, model, byId)) return false;
try { assertHistoricalRecordValid(record, model, resourceDataPath(model, record)); } catch { return false; }
}
if (model && modelSupports(model, "operating-preparation") && model.operatingPreparation?.completionRecords?.[record.type]) {
const spec = model.operatingPreparation.completionRecords[record.type];
if (byId && (record.type === "evidence"
Expand All @@ -1284,6 +1290,16 @@ function completionPassesActivity(record, activity, model, byId, asOf, timezone
}

export function completionMatchesMember(record, field, resourceId, model, byId, obligation) {
if (record?.type === "attestation") {
const { expectedPeople, requiredSubjects } = attestationCompletionScope(obligation, byId);
if (expectedPeople.length && !expectedPeople.includes(record.personId)) return false;
const subjects = new Set(record.subjectResourceIds || []);
if (!requiredSubjects.length || requiredSubjects.some((id) => !subjects.has(id))) return false;
const memberType = byId?.get(resourceId)?.type;
if (memberType === "person") return record.personId === resourceId;
return Boolean(model?.resources.attestation?.fields.subjectResourceIds?.relation?.includes(memberType)
&& subjects.has(resourceId));
}
if (byId?.get(resourceId)?.type === "workspace" && !obligation?.scopeResourceIds?.length
&& record.obligationId === obligation?.id) return true;
const includes = (value) => Array.isArray(value) ? value.includes(resourceId) : value === resourceId;
Expand Down Expand Up @@ -1400,25 +1416,25 @@ function assertExpectedCompletionType(obligation, record, model) {
}
}

function assertAttestationCompletionScope(obligation, record, resources, event = null) {
if (record?.type !== "attestation") return;
const byId = resourceIndex(resources).byId;
function attestationCompletionScope(obligation = {}, byId = new Map(), event = null) {
const eventPeople = (event?.subjectResourceIds || []).filter((id) => byId.get(id)?.type === "person");
const expectedPeople = eventPeople.length
? eventPeople
: (obligation.scopeResourceIds || []).filter((id) => byId.get(id)?.type === "person");
const primarySubjects = [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])]
.filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type));
return { expectedPeople, requiredSubjects: primarySubjects.length ? primarySubjects : obligation.policyIds || [] };
}

function assertAttestationCompletionScope(obligation, record, resources, event = null) {
if (record?.type !== "attestation") return;
const { expectedPeople, requiredSubjects } = attestationCompletionScope(obligation, resourceIndex(resources).byId, event);
if (expectedPeople.length && !expectedPeople.includes(record.personId)) {
throw new Error(`Attestation personId must name the Person in scope for Obligation "${obligation.id}".`);
}
const primarySubjects = [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])]
.filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type));
const allowedSubjects = new Set(primarySubjects.length ? primarySubjects : obligation.policyIds || []);
const actualSubjects = new Set(record.subjectResourceIds || []);
if (
actualSubjects.size !== allowedSubjects.size
|| [...actualSubjects].some((id) => !allowedSubjects.has(id))
) {
throw new Error(`Attestation subjects must name the exact authored content in scope for Obligation "${obligation.id}".`);
if (!requiredSubjects.length || requiredSubjects.some((id) => !actualSubjects.has(id))) {
throw new Error(`Attestation subjects must include every authored subject in scope for Obligation "${obligation.id}".`);
}
}

Expand Down
38 changes: 3 additions & 35 deletions packages/filegrc/src/validate.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { attestationBindingIssue } from "./attestation-integrity.js";
import { withAssessmentCache } from "./calculation-cache.js";
import { retentionApplicationIssues } from "./retention-applications.js";
import { nonmaterialAmendmentIssues } from "./nonmaterial-amendments.js";
Expand Down Expand Up @@ -2249,7 +2250,6 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
|| (record.attestationMethod !== "git-approval" && !modelSupports(model, "complete-operating-baseline"))
|| !record.contentRevisions
) return;
const expectedPaths = new Set();
const subjectPaths = new Map();
for (const id of record.subjectResourceIds || []) {
const subject = byId.get(id);
Expand All @@ -2263,41 +2263,9 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
}
}
subjectPaths.set(id, paths);
for (const item of paths) expectedPaths.add(item);
}
const actualPaths = Object.keys(record.contentRevisions);
if (!expectedPaths.size) {
diagnostics.push(error(
"invalid-attestation-binding",
path,
"A git-approval Attestation must reference authored Policy, Document, or Training content."
));
return;
}
if (!actualPaths.length) {
diagnostics.push(error(
"invalid-attestation-binding",
path,
"A git-approval Attestation must bind at least one subject Markdown file."
));
return;
}
const invalid = actualPaths.filter((item) => (
!expectedPaths.has(item)
|| !revisionDigest("content", record.contentRevisions[item])
));
const unboundSubjects = [...subjectPaths].filter(([, paths]) => (
paths.length && !paths.some((item) => actualPaths.includes(item))
)).map(([id]) => id);
if (invalid.length || unboundSubjects.length) {
diagnostics.push(error(
"invalid-attestation-binding",
path,
unboundSubjects.length
? `Attestation contentRevisions must bind authored Markdown for every subject; missing ${unboundSubjects.join(", ")}.`
: "Attestation contentRevisions must contain valid SHA-256 hashes for subject Markdown paths and no unrelated paths."
));
}
const issue = attestationBindingIssue(record, model, byId, subjectPaths);
if (issue) diagnostics.push(error("invalid-attestation-binding", path, issue));
}

function validateReportingRouteBinding(record, loaded, path, diagnostics) {
Expand Down
9 changes: 6 additions & 3 deletions packages/filegrc/src/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,7 @@ function activeWork(item) {
}

function firstDate(record) {
if (record.type === "exception" && typeof record.approval?.expiresOn === "string") return record.approval.expiresOn;
if (record.completionWindow?.dueAt) return record.completionWindow.dueAt;
if (record.completionWindow?.dueOn) return record.completionWindow.dueOn;
for (const field of DATE_FIELDS) {
Expand Down Expand Up @@ -1619,8 +1620,9 @@ function sourceCompletionProfile(record) {
}

function sourceNextAction(record, due, asOf, records) {
const eventType = due <= asOf ? {
exception: "exception-expired",
const eventType = record.type === "exception"
? (record.status === "approved" && due < asOf ? "exception-expired" : null)
: due <= asOf ? {
"service-account": "service-account-expired",
vulnerability: "vulnerability-overdue"
}[record.type] : null;
Expand Down Expand Up @@ -1650,7 +1652,8 @@ function firstOwners(record) {
}

function sourceWorkState(record, due, asOf) {
if (TERMINAL_STATUSES.has(record.status)) return "complete";
if (record.type === "exception" && ["closed", "revoked"].includes(record.status)) return "complete";
if (!(record.type === "exception" && record.status === "approved") && TERMINAL_STATUSES.has(record.status)) return "complete";
if (["canceled", "superseded"].includes(record.status)) return record.status;
if (["draft", "in-review", "partially-implemented", "planned", "proposed"].includes(record.status)) {
return "proposed";
Expand Down
Loading
Loading