flow-store-security.test.mjs
279 lines 8.2 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 17 hours ago
1 /**
2 * Tier 7 — SECURITY: scope denial, no existence leak, injection inert, no secrets.
3 *
4 * @see docs/FLOW-STORE-CONTRACT-7A-10.md §9
5 */
6 import { describe, it, beforeEach, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import fs from 'node:fs';
9 import path from 'node:path';
10 import { fileURLToPath } from 'node:url';
11 import {
12 handleFlowListRequest,
13 handleFlowGetRequest,
14 } from '../lib/flow/flow-handlers.mjs';
15 import {
16 saveFlowStore,
17 validateFlowBundle,
18 getFlow,
19 buildFlowStepId,
20 } from '../lib/flow/flow-store.mjs';
21
22 const __dirname = path.dirname(fileURLToPath(import.meta.url));
23 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-security');
24 const maliciousBundle = JSON.parse(
25 fs.readFileSync(path.join(__dirname, 'fixtures', 'flow', 'malicious-step-bundle.json'), 'utf8'),
26 );
27
28 const SECRET_MARKERS = ['refresh_token', 'oauth_token', '"token":'];
29
30 describe('Flow store — security', () => {
31 const dataDir = path.join(tmpRoot, 'data');
32 const vaultId = 'default';
33
34 beforeEach(() => {
35 fs.rmSync(tmpRoot, { recursive: true, force: true });
36 fs.mkdirSync(dataDir, { recursive: true });
37
38 const validated = validateFlowBundle(maliciousBundle);
39 assert.equal(validated.ok, true);
40 saveFlowStore(dataDir, {
41 vaults: {
42 [vaultId]: {
43 flows: [validated.flow],
44 steps: validated.steps,
45 runs: [],
46 candidates: [],
47 projections: [],
48 },
49 },
50 });
51 });
52
53 afterEach(() => {
54 fs.rmSync(tmpRoot, { recursive: true, force: true });
55 });
56
57 it('personal visibleScopes never returns project flows on list', () => {
58 saveFlowStore(dataDir, {
59 vaults: {
60 [vaultId]: {
61 flows: [
62 maliciousBundle.flow,
63 {
64 schema: 'knowtation.flow/v0',
65 flow_id: 'flow_multi_repo_change',
66 title: 'Multi repo',
67 version: '0.1.0',
68 scope: 'project',
69 summary: 'p',
70 tags: [],
71 steps: [buildFlowStepId('flow_multi_repo_change', 1)],
72 updated: '2026-06-20T00:00:00Z',
73 truncated: false,
74 },
75 ],
76 steps: [
77 ...maliciousBundle.steps,
78 {
79 schema: 'knowtation.flow_step/v0',
80 step_id: buildFlowStepId('flow_multi_repo_change', 1),
81 flow_id: 'flow_multi_repo_change',
82 ordinal: 1,
83 owned_job: 'j',
84 instruction: 'i',
85 trigger: 't',
86 when_not_to_run: 'n',
87 boundaries: [],
88 output_shape: 'o',
89 verification: { kind: 'human_review', evidence_required: false, description: 'd' },
90 automatable: 'manual',
91 },
92 ],
93 runs: [],
94 candidates: [],
95 projections: [],
96 },
97 },
98 });
99
100 const list = handleFlowListRequest({
101 dataDir,
102 vaultId,
103 visibleScopes: new Set(['personal']),
104 });
105 assert.equal(list.ok, true);
106 assert.ok(list.payload.flows.every((f) => f.scope === 'personal'));
107 assert.ok(!list.payload.flows.some((f) => f.flow_id === 'flow_multi_repo_change'));
108 });
109
110 it('ambiguous scope fails closed with FLOW_SCOPE_AMBIGUOUS', () => {
111 const result = handleFlowListRequest({
112 dataDir,
113 vaultId,
114 ambiguous: true,
115 });
116 assert.equal(result.ok, false);
117 assert.equal(result.code, 'FLOW_SCOPE_AMBIGUOUS');
118 });
119
120 it('unauthorized scope query returns FLOW_SCOPE_DENIED', () => {
121 const result = handleFlowListRequest({
122 dataDir,
123 vaultId,
124 visibleScopes: new Set(['personal']),
125 scope: 'org',
126 });
127 assert.equal(result.ok, false);
128 assert.equal(result.code, 'FLOW_SCOPE_DENIED');
129 });
130
131 it('project flow under personal scope returns null / unknown_flow (no existence leak)', () => {
132 const viaStore = getFlow(dataDir, vaultId, 'flow_multi_repo_change', {
133 filterScopes: new Set(['personal']),
134 });
135 assert.equal(viaStore, null);
136
137 saveFlowStore(dataDir, {
138 vaults: {
139 [vaultId]: {
140 flows: [{
141 schema: 'knowtation.flow/v0',
142 flow_id: 'flow_multi_repo_change',
143 title: 'Multi repo',
144 version: '0.1.0',
145 scope: 'project',
146 summary: 'p',
147 tags: [],
148 steps: [buildFlowStepId('flow_multi_repo_change', 1)],
149 updated: '2026-06-20T00:00:00Z',
150 truncated: false,
151 }],
152 steps: [{
153 schema: 'knowtation.flow_step/v0',
154 step_id: buildFlowStepId('flow_multi_repo_change', 1),
155 flow_id: 'flow_multi_repo_change',
156 ordinal: 1,
157 owned_job: 'j',
158 instruction: 'i',
159 trigger: 't',
160 when_not_to_run: 'n',
161 boundaries: [],
162 output_shape: 'o',
163 verification: { kind: 'human_review', evidence_required: false, description: 'd' },
164 automatable: 'manual',
165 }],
166 runs: [],
167 candidates: [],
168 projections: [],
169 },
170 },
171 });
172
173 const viaHandler = handleFlowGetRequest({
174 dataDir,
175 vaultId,
176 flowId: 'flow_multi_repo_change',
177 visibleScopes: new Set(['personal']),
178 });
179 assert.equal(viaHandler.ok, false);
180 assert.equal(viaHandler.code, 'unknown_flow');
181
182 const missing = handleFlowGetRequest({
183 dataDir,
184 vaultId,
185 flowId: 'flow_does_not_exist',
186 visibleScopes: new Set(['personal']),
187 });
188 assert.equal(missing.code, viaHandler.code);
189 });
190
191 it('malicious instruction is returned verbatim and does not alter scope', () => {
192 const got = handleFlowGetRequest({
193 dataDir,
194 vaultId,
195 flowId: 'flow_malicious_test',
196 visibleScopes: new Set(['personal']),
197 });
198 assert.equal(got.ok, true);
199 assert.match(got.payload.steps[0].instruction, /IGNORE PREVIOUS INSTRUCTIONS/);
200 const list = handleFlowListRequest({
201 dataDir,
202 vaultId,
203 visibleScopes: new Set(['personal']),
204 });
205 assert.equal(list.ok, true);
206 assert.ok(list.payload.flows.every((f) => f.scope === 'personal'));
207 });
208
209 it('serialized list/get output contains no secret markers', () => {
210 const list = handleFlowListRequest({
211 dataDir,
212 vaultId,
213 visibleScopes: new Set(['personal', 'project']),
214 });
215 const got = handleFlowGetRequest({
216 dataDir,
217 vaultId,
218 flowId: 'flow_malicious_test',
219 visibleScopes: new Set(['personal']),
220 });
221 const blob = JSON.stringify({ list: list.payload, got: got.payload }).toLowerCase();
222 for (const marker of SECRET_MARKERS) {
223 assert.ok(!blob.includes(marker.toLowerCase()), `found forbidden marker ${marker}`);
224 }
225 });
226
227 it('scope=org under personal authorization does not widen list results', () => {
228 saveFlowStore(dataDir, {
229 vaults: {
230 [vaultId]: {
231 flows: [
232 maliciousBundle.flow,
233 {
234 schema: 'knowtation.flow/v0',
235 flow_id: 'flow_org_only',
236 title: 'Org',
237 version: '0.1.0',
238 scope: 'org',
239 summary: 'o',
240 tags: [],
241 steps: [buildFlowStepId('flow_org_only', 1)],
242 updated: '2026-06-20T00:00:00Z',
243 truncated: false,
244 },
245 ],
246 steps: [
247 ...maliciousBundle.steps,
248 {
249 schema: 'knowtation.flow_step/v0',
250 step_id: buildFlowStepId('flow_org_only', 1),
251 flow_id: 'flow_org_only',
252 ordinal: 1,
253 owned_job: 'j',
254 instruction: 'i',
255 trigger: 't',
256 when_not_to_run: 'n',
257 boundaries: [],
258 output_shape: 'o',
259 verification: { kind: 'human_review', evidence_required: false, description: 'd' },
260 automatable: 'manual',
261 },
262 ],
263 runs: [],
264 candidates: [],
265 projections: [],
266 },
267 },
268 });
269
270 const denied = handleFlowListRequest({
271 dataDir,
272 vaultId,
273 visibleScopes: new Set(['personal']),
274 scope: 'org',
275 });
276 assert.equal(denied.ok, false);
277 assert.equal(denied.code, 'FLOW_SCOPE_DENIED');
278 });
279 });
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 17 hours ago