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
2 changes: 2 additions & 0 deletions docs/upgrading-to-model-v13.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ Existing 0.18 workspaces run `npm update filegrc`, commit the dependency and loc
For an unchanged committed proposal, retain the fixed configuration export and its real observation date. The actual independent reviewer verifies it using the normal Evidence workflow and records the real verification date and timestamp when known. The authorized approving Person then records the actual approval and effectiveness time using the existing browser, HTTP, or `reporting-route-set approve` workflow. Attachments must already be committed. Verification must follow the proposal and collection, and be available by approval. A configuration export supports the reviewed proposal; it does not prove historical operation or a cancellation event. Changed JSON or instructions require a new committed proposal, and a successor still needs separate predecessor cancellation proof.

If a Policy activated before approved channels became effective, the historical coverage gap remains. Record current work on its actual dates and assess the earlier gap through the normal program and audit workflows. Recollecting an unchanged capture or backdating approval does not resolve that gap.

Component-scoped operating results can cover a System through the Component’s `systemUses` relationship. Explicit result `systemIds` remain authoritative. For a Component shared by several Systems, link Evidence naming both the tested Component and the intended System; a broad source or Control link does not establish execution scope. A System result does not automatically cover every Component. Dates, completion, review, proof and occurrence reconciliation still apply.
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.18.7",
"version": "0.18.8",
"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.18.7",
"version": "0.18.8",
"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.18.7",
version: "0.18.8",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.18.7",
version: "0.18.8",
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.18.7",
"version": "0.18.8",
"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.18.7",
"version": "0.18.8",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
23 changes: 23 additions & 0 deletions packages/filegrc/src/obligations.js
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,29 @@ export function completionMatchesMember(record, field, resourceId, model, byId,
const spec = modelSupports(model, "operating-preparation") && model?.operatingPreparation?.completionRecords?.[record.type];
if (!spec) return false;
if (spec.scopeFields.some((name) => includes(record[name]))) return true;
// Components carry their System membership through systemUses. A Component
// result can cover that System, but a broad System result never proves that
// every individual Component was tested. Explicit System scope wins over
// inference, and a shared Component needs proof naming the intended System.
const scopeIds = spec.scopeFields.flatMap((name) => Array.isArray(record[name]) ? record[name] : [record[name]]);
if (byId?.get(resourceId)?.type === "system") {
if (Array.isArray(record.systemIds) && record.systemIds.length
|| scopeIds.some((id) => byId.get(id)?.type === "system")) return false;
const componentFields = spec.scopeFields.filter((name) =>
model.resources[record.type]?.fields[name]?.relation?.includes("component"));
const hasBoundedProof = (componentId) => (record.evidenceIds || []).some((id) => {
const proof = byId.get(id);
return proof?.type === "evidence" && proof.systemIds?.includes(resourceId)
&& proof.componentIds?.includes(componentId);
});
const scopedComponents = componentFields.flatMap((name) => Array.isArray(record[name]) ? record[name] : [record[name]]).filter(Boolean);
if (scopedComponents.length) return scopedComponents.some((id) => {
const component = byId.get(id);
if (component?.type !== "component" || !Array.isArray(component.systemUses)) return false;
const systemIds = [...new Set(component.systemUses.map((use) => use.systemId))];
return systemIds.includes(resourceId) && (systemIds.length === 1 || hasBoundedProof(component.id));
});
}
const proofFields = ["sourceResourceIds", ...model.operatingPreparation.completionRecords.evidence.scopeFields];
return spec.allowProofScope === true && (record.evidenceIds || []).some((id) =>
proofFields.some((name) => includes(byId?.get(id)?.[name])));
Expand Down
79 changes: 79 additions & 0 deletions packages/filegrc/test/completion-scope.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadModel } from "../model/index.js";
import { completionMatchesMember } from "../src/obligations.js";

const model = loadModel("13");
function fixture() {
const records = [
{ id: "system-a", type: "system" },
{ id: "system-b", type: "system" },
{ id: "component-a", type: "component", systemUses: [{ systemId: "system-a" }] },
{ id: "component-b", type: "component", systemUses: [{ systemId: "system-b" }] },
{ id: "component-shared", type: "component", systemUses: [{ systemId: "system-a" }, { systemId: "system-b" }] },
{ id: "proof-a", type: "evidence", componentIds: ["component-shared"], systemIds: ["system-a"] }
];
const byId = new Map(records.map((record) => [record.id, record]));
const matches = (record, id) => completionMatchesMember(record, "componentIds", id, model, byId);
return { byId, matches };
}

test("component-scoped operating results cover only their typed parent Systems", () => {
const { matches, byId } = fixture();
for (const type of ["vulnerability-scan", "backup-test", "exercise", "penetration-test", "evidence", "control-activity"]) {
const field = type === "control-activity" ? "scopeResourceIds" : "componentIds";
const result = { type, [field]: ["component-a"] };
assert.equal(matches(result, "system-a"), true, type);
assert.equal(matches(result, "system-b"), false, type);
assert.equal(matches(result, "component-b"), false, type);
result[field].push("component-b");
assert.equal(matches(result, "system-b"), true, `${type}: mixed explicit Components`);
}
assert.equal(matches({ type: "vulnerability-scan", componentIds: ["missing"] }, "system-a"), false);
byId.set("component-fake", { id: "component-fake", type: "control", systemUses: [{ systemId: "system-a" }] });
assert.equal(matches({ type: "vulnerability-scan", componentIds: ["component-fake"] }, "system-a"), false);
});

test("explicit System scope takes precedence and never expands to individual Components", () => {
const { matches } = fixture();
const result = { type: "backup-test", systemIds: ["system-b"], componentIds: ["component-a"] };
assert.equal(matches(result, "system-a"), false);
assert.equal(matches(result, "system-b"), true);
assert.equal(matches({ type: "backup-test", systemIds: ["system-a"] }, "component-a"), false);
const activity = { type: "control-activity", scopeResourceIds: ["system-b", "component-a"] };
assert.equal(matches(activity, "system-a"), false, "Typed Systems in polymorphic scope are authoritative");
assert.equal(matches(activity, "system-b"), true);
assert.equal(matches({ type: "backup-test", systemIds: ["missing-system"], componentIds: ["component-a"] }, "system-a"), false);
assert.equal(matches({ type: "exercise", systemIds: ["system-b"], evidenceIds: ["proof-a"] }, "system-a"), false,
"Proof scope cannot override explicit execution scope");
});

test("shared Components require linked proof bounded to both that Component and intended System", () => {
const { matches, byId } = fixture();
const scan = { type: "vulnerability-scan", componentIds: ["component-shared"] };
assert.equal(matches(scan, "system-a"), false);
scan.evidenceIds = ["proof-a"];
assert.equal(matches(scan, "system-a"), true);
assert.equal(matches(scan, "system-b"), false);
byId.get("proof-a").componentIds = ["component-a"];
assert.equal(matches(scan, "system-a"), false, "Proof for another Component cannot bound the shared test");
assert.equal(matches({ ...scan, type: "exercise" }, "system-a"), false, "Exercise proof fallback cannot bypass explicit Component scope");
byId.get("proof-a").componentIds = [];
assert.equal(matches({ ...scan, type: "exercise" }, "system-a"), false, "Broad proof is insufficient for a shared Component");
scan.componentIds = ["component-b"];
assert.equal(matches(scan, "system-a"), false, "Evidence alone cannot enlarge a scan's Component scope");
scan.componentIds = ["component-shared"];
byId.get("proof-a").componentIds = ["component-shared"];
byId.get("proof-a").type = "control";
assert.equal(matches(scan, "system-a"), false);
});

test("unrelated source and Control links do not turn results into System work", () => {
const { matches, byId } = fixture();
byId.set("control-a", { id: "control-a", type: "control", systemIds: ["system-a"] });
assert.equal(matches({ type: "control-test", controlId: "control-a" }, "system-a"), false);
assert.equal(matches({ type: "vulnerability-scan", evidenceIds: ["proof-a"] }, "system-a"), false);
assert.equal(matches({ type: "evidence", sourceComponentId: "component-a" }, "system-a"), false);
assert.equal(matches({ type: "exercise", componentIds: ["component-b"], evidenceIds: ["proof-a"] }, "system-a"), false);
assert.equal(matches({ type: "exercise", evidenceIds: ["proof-a"] }, "system-a"), true, "Proof-only scope remains supported where the model allows it");
});
33 changes: 33 additions & 0 deletions packages/filegrc/test/operating-preparation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,36 @@ test("an applicability label cannot substitute for executing an adopted test act
const baseline = (await assessOperatingPreparation(loaded, program, controls, "2026-10-13")).baseline.find(({ obligationId }) => obligationId === obligation.id);
assert.equal(baseline.complete, false);
});

test("System scan baselines and CLI reuse component-scoped execution without weakening proof or dates", async (context) => {
const { root, loaded, program, controls } = await fixture(context);
const { assessCompletionForObligation } = await import("../src/obligations.js");
const { resourceDataPath } = await import("../src/resource-markdown.js");
const obligation = loaded.resources.find(({ type }) => type === "obligation");
const rule = loaded.resources.find(({ type }) => type === "obligation-rule");
const scan = loaded.resources.find(({ type }) => type === "vulnerability-scan");
scan.completedAt = "2026-07-01T12:00:00Z";
Object.assign(obligation, { activityType: "vulnerability-scan", scopeResourceIds: ["system-example"] });
Object.assign(rule, { recurrence: { mode: "calendar", unit: "year", interval: 1, anchorMode: "activation" },
selector: { resourceType: "system", statuses: ["active"], membershipMode: "as-of", cutoff: "window-start" } });
const baseline = () => assessOperatingPreparation(loaded, program, controls, "2026-10-13")
.then(({ baseline }) => baseline.find(({ obligationId }) => obligationId === obligation.id));
assert.equal((await baseline()).complete, true, JSON.stringify(await baseline()));
assert.ok((await baseline()).resultIds.includes(scan.id));
for (const record of [obligation, rule, scan]) {
await writeFile(join(root, "data", resourceDataPath(loaded.model, record)), JSON.stringify(record));
}
const cli = JSON.parse((await captureCli(runCli, ["program-readiness", "--root", root, "--json"])).stdout);
const cliBaseline = cli.stages.flatMap(({ items }) => items).flatMap(({ preparationChecks }) => preparationChecks || []).find(({ id }) => id === `baseline-${obligation.id}`);
assert.equal(cliBaseline.complete, true);
const options = { asOf: "2026-10-13", memberIds: ["system-example"] };
scan.reviewedOn = "2099-01-01";
assert.equal(assessCompletionForObligation(scan, obligation, loaded, options).complete, false);
scan.reviewedOn = "2026-07-01";
scan.status = "running";
assert.equal(assessCompletionForObligation(scan, obligation, loaded, options).complete, false);
scan.status = "complete";
const proof = loaded.resources.find(({ type }) => type === "evidence");
proof.status = "withdrawn";
assert.equal(assessCompletionForObligation(scan, obligation, loaded, options).complete, false);
});
1 change: 1 addition & 0 deletions scripts/run-filegrc-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const packageRoot = join(root, "packages", "filegrc");
const testRoot = join(packageRoot, "test");
const unitFiles = new Set([
"applicability-scope.test.js",
"completion-scope.test.js",
"favicon.test.js",
"git-adapter.test.js",
"id.test.js",
Expand Down
Loading